Files
anti-plagiarism/services/worker-gpu/app/ollama_client.py
jze9 96a4530a93
All checks were successful
Deploy / test (push) Successful in 3m1s
Deploy / deploy (push) Successful in 19s
feat(llm): переключаемый бэкенд для L4 — Ollama или OpenRouter
LLM_BACKEND=ollama (дефолт, поведение не меняется) | openrouter (облачный
API, дешёвая модель — для да/нет+уверенность "ум" не критичен, зато
не нужен локальный GPU-хост вообще). Общая _complete() прячет разницу
форматов запроса/ответа за check_paraphrase/summarize.
2026-08-26 17:11:46 +05:00

166 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Клиент для 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,
)
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