feat(llm): переключаемый бэкенд для L4 — Ollama или OpenRouter
LLM_BACKEND=ollama (дефолт, поведение не меняется) | openrouter (облачный API, дешёвая модель — для да/нет+уверенность "ум" не критичен, зато не нужен локальный GPU-хост вообще). Общая _complete() прячет разницу форматов запроса/ответа за check_paraphrase/summarize.
This commit is contained in:
@@ -38,6 +38,14 @@ class Settings(BaseSettings):
|
|||||||
# Ollama
|
# Ollama
|
||||||
OLLAMA_URL: str = "http://ollama:11434"
|
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 / ML
|
||||||
FAISS_INDEX_PATH: str = "/data/index/faiss.index"
|
FAISS_INDEX_PATH: str = "/data/index/faiss.index"
|
||||||
FAISS_ID_MAP_PATH: str = "/data/index/faiss_id_map.json"
|
FAISS_ID_MAP_PATH: str = "/data/index/faiss_id_map.json"
|
||||||
|
|||||||
@@ -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 json
|
||||||
import logging
|
import logging
|
||||||
@@ -11,13 +18,62 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class OllamaClient:
|
class OllamaClient:
|
||||||
"""HTTP клиент для Ollama LLM."""
|
"""HTTP клиент для LLM (Ollama или OpenRouter, см. LLM_BACKEND)."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.base_url = settings.OLLAMA_URL
|
self.base_url = settings.OLLAMA_URL
|
||||||
self.model = "qwen2.5:7b"
|
self.model = "qwen2.5:7b"
|
||||||
self.timeout = 60.0 # секунд
|
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:
|
def check_paraphrase(self, text_a: str, text_b: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Проверить является ли text_b парафразом text_a с помощью LLM.
|
Проверить является ли text_b парафразом text_a с помощью LLM.
|
||||||
@@ -44,42 +100,24 @@ class OllamaClient:
|
|||||||
{{"is_paraphrase": true/false, "confidence": 0.0-1.0, "reason": "краткое объяснение на русском"}}"""
|
{{"is_paraphrase": true/false, "confidence": 0.0-1.0, "reason": "краткое объяснение на русском"}}"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = httpx.post(
|
llm_response = self._complete(prompt, temperature=0.1, num_predict=200, json_mode=True)
|
||||||
f"{self.base_url}/api/generate",
|
except Exception as e:
|
||||||
json={
|
logger.error(f"Неожиданная ошибка при обращении к LLM: {e}")
|
||||||
"model": self.model,
|
return {"is_paraphrase": False, "confidence": 0.0, "reason": str(e)}
|
||||||
"prompt": prompt,
|
|
||||||
"stream": False,
|
|
||||||
"format": "json",
|
|
||||||
"options": {
|
|
||||||
"temperature": 0.1, # Детерминированный вывод
|
|
||||||
"num_predict": 200,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
timeout=self.timeout,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
result = response.json()
|
if llm_response is None:
|
||||||
llm_response = result.get("response", "{}")
|
return {"is_paraphrase": False, "confidence": 0.0, "reason": "LLM недоступна"}
|
||||||
|
|
||||||
# Парсим JSON из ответа
|
try:
|
||||||
parsed = json.loads(llm_response)
|
parsed = json.loads(llm_response or "{}")
|
||||||
return {
|
return {
|
||||||
"is_paraphrase": bool(parsed.get("is_paraphrase", False)),
|
"is_paraphrase": bool(parsed.get("is_paraphrase", False)),
|
||||||
"confidence": float(parsed.get("confidence", 0.0)),
|
"confidence": float(parsed.get("confidence", 0.0)),
|
||||||
"reason": str(parsed.get("reason", "")),
|
"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:
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
logger.warning(f"Ошибка парсинга ответа Ollama: {e}")
|
logger.warning(f"Ошибка парсинга ответа LLM: {e}")
|
||||||
return {"is_paraphrase": False, "confidence": 0.0, "reason": "Ошибка парсинга ответа"}
|
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:
|
def summarize(self, title: str, abstract: str, lang: str = "ru") -> str:
|
||||||
"""
|
"""
|
||||||
@@ -109,26 +147,15 @@ class OllamaClient:
|
|||||||
Ответ:"""
|
Ответ:"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = httpx.post(
|
result = self._complete(prompt, temperature=0.3, num_predict=300)
|
||||||
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()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка суммаризации через Ollama: {e}")
|
logger.error(f"Ошибка суммаризации через LLM: {e}")
|
||||||
return abstract[:500] if abstract else title
|
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:
|
def is_available(self) -> bool:
|
||||||
"""Проверить доступность Ollama сервера."""
|
"""Проверить доступность Ollama сервера."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user