"""Клиент для LLM (анализ парафраза, суммаризация) — Ollama или OpenRouter. Бэкенд переключается через LLM_BACKEND: - "ollama" — локальный сервер, полный контроль, но требует GPU-хост. - "openrouter" — облачный API (OpenAI-совместимый), дешёвая модель типа DeepSeek. Для L4 (да/нет + уверенность, не творческая задача) "ум" модели не критичен — но зато не нужен вообще никакой локальный GPU/Ollama. """ import json import logging import httpx from app.config import settings logger = logging.getLogger(__name__) class OllamaClient: """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, proxy=settings.OPENROUTER_PROXY_URL or None, ) 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. Args: text_a: Исходный текст из базы данных (до 500 символов) text_b: Проверяемый фрагмент (до 500 символов) Returns: dict с полями: - is_paraphrase: bool - confidence: float 0.0-1.0 - reason: str — краткое объяснение """ prompt = f"""Ты эксперт по академическому плагиату. Определи, является ли Текст B парафразом Текста A. Текст A (источник): {text_a[:500]} Текст B (проверяемый): {text_b[:500]} Критерии парафраза: передача тех же идей другими словами, перефразировка без ссылки на источник. Ответь ТОЛЬКО валидным JSON без пояснений и markdown: {{"is_paraphrase": true/false, "confidence": 0.0-1.0, "reason": "краткое объяснение на русском"}}""" try: 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)} if llm_response is None: return {"is_paraphrase": False, "confidence": 0.0, "reason": "LLM недоступна"} 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 (json.JSONDecodeError, KeyError) as e: logger.warning(f"Ошибка парсинга ответа LLM: {e}") return {"is_paraphrase": False, "confidence": 0.0, "reason": "Ошибка парсинга ответа"} def summarize(self, title: str, abstract: str, lang: str = "ru") -> str: """ Сгенерировать краткое академическое изложение статьи. Args: title: Название статьи abstract: Аннотация статьи lang: Язык ответа (ru/en) Returns: Краткое изложение на указанном языке """ lang_instruction = "на русском языке" if lang == "ru" else "in English" prompt = f"""Сделай краткое академическое изложение {lang_instruction}. Название: {title} Аннотация: {abstract[:1000]} Изложение должно: - Содержать 3-5 предложений - Передавать основную идею и результаты - Быть написано академическим стилем - НЕ копировать текст дословно Ответ:""" try: result = self._complete(prompt, temperature=0.3, num_predict=300) except Exception as 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: response = httpx.get(f"{self.base_url}/api/tags", timeout=5.0) return response.status_code == 200 except Exception: return False