OpenRouter отдаёт 403 с российских IP (подтверждено вживую). Добавлен sing-box как отдельный сервис (VLESS+WS+TLS до ноды вне РФ) — только для исходящих запросов к OpenRouter из ollama_client.py, остальной трафик (Postgres/RabbitMQ/MinIO/Ollama) идёт напрямую. Реальный конфиг с UUID — только на проде (см. config.json.example), в git не попадает.
167 lines
7.5 KiB
Python
167 lines
7.5 KiB
Python
"""Клиент для 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
|