Initial commit: VKUS vitrina MVP (frontend + backend).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
"""Клиент внешней 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
|
||||
Reference in New Issue
Block a user