diff --git a/services/worker-gpu/app/config.py b/services/worker-gpu/app/config.py index f2175c0..d7823c8 100644 --- a/services/worker-gpu/app/config.py +++ b/services/worker-gpu/app/config.py @@ -38,6 +38,14 @@ class Settings(BaseSettings): # Ollama OLLAMA_URL: str = "http://ollama:11434" + # LLM_BACKEND: "ollama" (локальная модель, нужен GPU-хост) или "openrouter" + # (облачный API, дешёвая модель — для L4-проверки парафраза "умность" не + # критична, это не творческая задача). Переключается без изменения кода. + LLM_BACKEND: str = "ollama" + OPENROUTER_API_KEY: str = "" + OPENROUTER_MODEL: str = "deepseek/deepseek-chat" + OPENROUTER_URL: str = "https://openrouter.ai/api/v1/chat/completions" + # FAISS / ML FAISS_INDEX_PATH: str = "/data/index/faiss.index" FAISS_ID_MAP_PATH: str = "/data/index/faiss_id_map.json" diff --git a/services/worker-gpu/app/ollama_client.py b/services/worker-gpu/app/ollama_client.py index ec4a369..569d6c6 100644 --- a/services/worker-gpu/app/ollama_client.py +++ b/services/worker-gpu/app/ollama_client.py @@ -1,4 +1,11 @@ -"""Клиент для Ollama HTTP API — LLM анализ парафраза и суммаризация.""" +"""Клиент для LLM (анализ парафраза, суммаризация) — Ollama или OpenRouter. + +Бэкенд переключается через LLM_BACKEND: +- "ollama" — локальный сервер, полный контроль, но требует GPU-хост. +- "openrouter" — облачный API (OpenAI-совместимый), дешёвая модель типа + DeepSeek. Для L4 (да/нет + уверенность, не творческая задача) "ум" модели + не критичен — но зато не нужен вообще никакой локальный GPU/Ollama. +""" import json import logging @@ -11,13 +18,62 @@ logger = logging.getLogger(__name__) class OllamaClient: - """HTTP клиент для Ollama LLM.""" + """HTTP клиент для LLM (Ollama или OpenRouter, см. LLM_BACKEND).""" def __init__(self) -> None: self.base_url = settings.OLLAMA_URL self.model = "qwen2.5:7b" self.timeout = 60.0 # секунд + def _complete(self, prompt: str, temperature: float, num_predict: int, json_mode: bool = False) -> str | None: + """Единая точка входа для генерации текста — прячет разницу Ollama/OpenRouter. + + Возвращает сырой текст ответа модели или None при ошибке (недоступность, + таймаут и т.п. — вызывающий код сам решает, как деградировать). + """ + if settings.LLM_BACKEND == "openrouter": + return self._complete_openrouter(prompt, temperature, num_predict, json_mode) + return self._complete_ollama(prompt, temperature, num_predict, json_mode) + + def _complete_ollama(self, prompt: str, temperature: float, num_predict: int, json_mode: bool) -> str | None: + try: + payload = { + "model": self.model, + "prompt": prompt, + "stream": False, + "options": {"temperature": temperature, "num_predict": num_predict}, + } + if json_mode: + payload["format"] = "json" + response = httpx.post(f"{self.base_url}/api/generate", json=payload, timeout=self.timeout) + response.raise_for_status() + return response.json().get("response", "") + except (httpx.TimeoutException, httpx.ConnectError) as e: + logger.warning(f"Ollama недоступна: {e}") + return None + + def _complete_openrouter(self, prompt: str, temperature: float, num_predict: int, json_mode: bool) -> str | None: + try: + payload: dict = { + "model": settings.OPENROUTER_MODEL, + "messages": [{"role": "user", "content": prompt}], + "temperature": temperature, + "max_tokens": num_predict, + } + if json_mode: + payload["response_format"] = {"type": "json_object"} + response = httpx.post( + settings.OPENROUTER_URL, + json=payload, + headers={"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}"}, + timeout=self.timeout, + ) + response.raise_for_status() + return response.json()["choices"][0]["message"]["content"] + except (httpx.TimeoutException, httpx.ConnectError) as e: + logger.warning(f"OpenRouter недоступен: {e}") + return None + def check_paraphrase(self, text_a: str, text_b: str) -> dict: """ Проверить является ли text_b парафразом text_a с помощью LLM. @@ -44,42 +100,24 @@ class OllamaClient: {{"is_paraphrase": true/false, "confidence": 0.0-1.0, "reason": "краткое объяснение на русском"}}""" try: - response = httpx.post( - f"{self.base_url}/api/generate", - json={ - "model": self.model, - "prompt": prompt, - "stream": False, - "format": "json", - "options": { - "temperature": 0.1, # Детерминированный вывод - "num_predict": 200, - }, - }, - timeout=self.timeout, - ) - response.raise_for_status() + llm_response = self._complete(prompt, temperature=0.1, num_predict=200, json_mode=True) + except Exception as e: + logger.error(f"Неожиданная ошибка при обращении к LLM: {e}") + return {"is_paraphrase": False, "confidence": 0.0, "reason": str(e)} - result = response.json() - llm_response = result.get("response", "{}") + if llm_response is None: + return {"is_paraphrase": False, "confidence": 0.0, "reason": "LLM недоступна"} - # Парсим JSON из ответа - parsed = json.loads(llm_response) + try: + parsed = json.loads(llm_response or "{}") return { "is_paraphrase": bool(parsed.get("is_paraphrase", False)), "confidence": float(parsed.get("confidence", 0.0)), "reason": str(parsed.get("reason", "")), } - - except (httpx.TimeoutException, httpx.ConnectError) as e: - logger.warning(f"Ollama недоступна: {e}") - return {"is_paraphrase": False, "confidence": 0.0, "reason": "LLM недоступна"} except (json.JSONDecodeError, KeyError) as e: - logger.warning(f"Ошибка парсинга ответа Ollama: {e}") + logger.warning(f"Ошибка парсинга ответа LLM: {e}") return {"is_paraphrase": False, "confidence": 0.0, "reason": "Ошибка парсинга ответа"} - except Exception as e: - logger.error(f"Неожиданная ошибка при обращении к Ollama: {e}") - return {"is_paraphrase": False, "confidence": 0.0, "reason": str(e)} def summarize(self, title: str, abstract: str, lang: str = "ru") -> str: """ @@ -109,26 +147,15 @@ class OllamaClient: Ответ:""" try: - response = httpx.post( - f"{self.base_url}/api/generate", - json={ - "model": self.model, - "prompt": prompt, - "stream": False, - "options": { - "temperature": 0.3, - "num_predict": 300, - }, - }, - timeout=self.timeout, - ) - response.raise_for_status() - return response.json().get("response", "").strip() - + result = self._complete(prompt, temperature=0.3, num_predict=300) except Exception as e: - logger.error(f"Ошибка суммаризации через Ollama: {e}") + logger.error(f"Ошибка суммаризации через LLM: {e}") return abstract[:500] if abstract else title + if result is None: + return abstract[:500] if abstract else title + return result.strip() + def is_available(self) -> bool: """Проверить доступность Ollama сервера.""" try: