862e6b7b25
Co-authored-by: Cursor <cursoragent@cursor.com>
175 lines
6.1 KiB
Python
175 lines
6.1 KiB
Python
"""Клиент внешней LLM (OpenAI-совместимый API платформы ИИ)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
|
|
import httpx
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class LlmTestResult:
|
|
ok: bool
|
|
configured: bool
|
|
model: str
|
|
endpoint: str
|
|
message: str
|
|
sample: str | None = None
|
|
status_code: int | None = None
|
|
error: str | None = None
|
|
|
|
|
|
class LlmClient:
|
|
def __init__(self) -> None:
|
|
self._system_prompt = self._load_system_prompt()
|
|
|
|
def is_configured(self) -> bool:
|
|
return settings.llm_enabled and bool(settings.llm_api_key or settings.llm_jwt_token)
|
|
|
|
def _load_system_prompt(self) -> str:
|
|
path = settings.system_prompt_path
|
|
if path.exists():
|
|
try:
|
|
content = path.read_text(encoding="utf-8")
|
|
return content[:6000]
|
|
except OSError:
|
|
pass
|
|
return settings.system_prompt_fallback
|
|
|
|
def _headers(self) -> dict[str, str]:
|
|
headers = {"Content-Type": "application/json"}
|
|
if settings.llm_api_key:
|
|
headers["Authorization"] = f"Bearer {settings.llm_api_key}"
|
|
if settings.llm_jwt_token:
|
|
headers["X-JWT-Token"] = settings.llm_jwt_token
|
|
# OpenRouter рекомендует указать referer
|
|
if "openrouter.ai" in settings.llm_chat_url:
|
|
headers["HTTP-Referer"] = "http://localhost:5173"
|
|
headers["X-Title"] = "VKUS IT Assistant"
|
|
return headers
|
|
|
|
def test_connection(self) -> LlmTestResult:
|
|
endpoint = settings.llm_chat_url
|
|
model = settings.llm_model
|
|
|
|
if not settings.llm_enabled:
|
|
return LlmTestResult(
|
|
ok=False,
|
|
configured=False,
|
|
model=model,
|
|
endpoint=endpoint,
|
|
message="LLM отключена (VKUS_LLM_ENABLED=false)",
|
|
)
|
|
|
|
if not settings.llm_api_key and not settings.llm_jwt_token:
|
|
return LlmTestResult(
|
|
ok=False,
|
|
configured=False,
|
|
model=model,
|
|
endpoint=endpoint,
|
|
message="API-ключ не задан (VKUS_LLM_API_KEY)",
|
|
)
|
|
|
|
payload = {
|
|
"model": model,
|
|
"messages": [
|
|
{"role": "user", "content": "Ответь одним словом: работает"},
|
|
],
|
|
"max_tokens": 16,
|
|
"temperature": 0,
|
|
}
|
|
|
|
try:
|
|
with httpx.Client(timeout=settings.llm_timeout, verify=settings.llm_verify_ssl) as client:
|
|
response = client.post(endpoint, json=payload, headers=self._headers())
|
|
if response.status_code >= 400:
|
|
return LlmTestResult(
|
|
ok=False,
|
|
configured=True,
|
|
model=model,
|
|
endpoint=endpoint,
|
|
message="Ошибка HTTP при обращении к LLM",
|
|
status_code=response.status_code,
|
|
error=response.text[:500],
|
|
)
|
|
data = response.json()
|
|
sample = data["choices"][0]["message"]["content"].strip()
|
|
return LlmTestResult(
|
|
ok=True,
|
|
configured=True,
|
|
model=model,
|
|
endpoint=endpoint,
|
|
message="Соединение с LLM установлено",
|
|
sample=sample,
|
|
status_code=response.status_code,
|
|
)
|
|
except httpx.TimeoutException:
|
|
return LlmTestResult(
|
|
ok=False,
|
|
configured=True,
|
|
model=model,
|
|
endpoint=endpoint,
|
|
message=f"Таймаут ({settings.llm_timeout} сек)",
|
|
error="timeout",
|
|
)
|
|
except Exception as exc:
|
|
logger.exception("LLM connection test failed")
|
|
return LlmTestResult(
|
|
ok=False,
|
|
configured=True,
|
|
model=model,
|
|
endpoint=endpoint,
|
|
message="Не удалось подключиться к LLM",
|
|
error=str(exc)[:500],
|
|
)
|
|
|
|
def generate_with_context(self, query: str, context: str) -> str | None:
|
|
if not self.is_configured():
|
|
return None
|
|
|
|
user_content = (
|
|
"Используй только предоставленный контекст из корпоративных инструкций.\n"
|
|
"Если контекста недостаточно — честно скажи об этом.\n"
|
|
"Ответ должен быть кратким, структурированным, на русском языке.\n\n"
|
|
f"### Контекст\n{context}\n\n"
|
|
f"### Вопрос сотрудника\n{query}"
|
|
)
|
|
|
|
payload = {
|
|
"model": settings.llm_model,
|
|
"messages": [
|
|
{"role": "system", "content": self._system_prompt},
|
|
{"role": "user", "content": user_content},
|
|
],
|
|
"temperature": settings.llm_temperature,
|
|
"max_tokens": settings.llm_max_tokens,
|
|
}
|
|
|
|
try:
|
|
with httpx.Client(timeout=settings.llm_timeout, verify=settings.llm_verify_ssl) as client:
|
|
response = client.post(settings.llm_chat_url, json=payload, headers=self._headers())
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
return data["choices"][0]["message"]["content"].strip()
|
|
except Exception as exc:
|
|
if settings.use_template_on_llm_failure:
|
|
logger.warning("LLM request failed, fallback to template: %s", exc)
|
|
else:
|
|
logger.error("LLM request failed: %s", exc)
|
|
return None
|
|
|
|
|
|
_llm_client: LlmClient | None = None
|
|
|
|
|
|
def get_llm_client() -> LlmClient:
|
|
global _llm_client
|
|
if _llm_client is None:
|
|
_llm_client = LlmClient()
|
|
return _llm_client
|