mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 22:04:48 +00:00
153 lines
7.1 KiB
Python
153 lines
7.1 KiB
Python
"""GigaChat client — OAuth + chat completions."""
|
||
from __future__ import annotations
|
||
import json
|
||
import re
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from typing import Any
|
||
import httpx
|
||
|
||
from .config import get_config
|
||
|
||
_AUTH_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"
|
||
_CHAT_URL = "https://gigachat.devices.sberbank.ru/api/v1/chat/completions"
|
||
|
||
_lock = threading.Lock()
|
||
_token: str | None = None
|
||
_token_expires_at: float = 0.0
|
||
|
||
|
||
def _get_token() -> str:
|
||
global _token, _token_expires_at
|
||
with _lock:
|
||
# 5-минутный запас перед истечением
|
||
if _token and time.time() < _token_expires_at - 300:
|
||
return _token
|
||
|
||
cfg = get_config()
|
||
rq_uid = str(uuid.uuid4())
|
||
with httpx.Client(timeout=15.0) as client:
|
||
resp = client.post(
|
||
_AUTH_URL,
|
||
headers={
|
||
"Authorization": f"Basic {cfg.gigachat_auth_key}",
|
||
"RqUID": rq_uid,
|
||
"Accept": "application/json",
|
||
},
|
||
data={"scope": cfg.gigachat_scope},
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
_token = data.get("access_token") or data.get("tok")
|
||
if not _token:
|
||
raise RuntimeError(f"No access_token in GigaChat response: {data}")
|
||
# expires_at в миллисекундах unix
|
||
expires_at_ms = data.get("expires_at") or data.get("exp") or 0
|
||
_token_expires_at = (expires_at_ms / 1000) if expires_at_ms else (time.time() + 1500)
|
||
return _token
|
||
|
||
|
||
SYSTEM_PROMPT_PICKER = (
|
||
"Ты — эксперт-консультант по подбору кухонной техники для фабрики мебели «ЗОВ».\n"
|
||
"Помогаешь менеджерам салонов согласовать с клиентом комплект техники.\n\n"
|
||
"Принципы подбора:\n"
|
||
"1. Уважай ценовой коридор. У каждой категории `price_ranges.{cat}.from..to` — попадай в него (±5%).\n"
|
||
"2. Уважай предпочтения по брендам: сначала preferred (★), потом acceptable (✓).\n"
|
||
"3. Учитывай инфраструктуру: газ исключает индукцию; нет вентиляции = только рециркуляция (угольный фильтр).\n"
|
||
"4. Учитывай приоритеты выбора (`priorities`): «цена/качество» → балансные модели; «отзывы» → проверенные хиты; «дизайн» → подбирай эстетику; «технологичность» → топовые фичи.\n"
|
||
"5. Если клиент явно отметил features в `per_cat.{cat}.features` — обязательно ставь модели с этими фичами.\n"
|
||
"6. ВАЖНО: каждую тех. фичу в highlights ОБЯЗАТЕЛЬНО объясняй простым языком в скобках.\n\n"
|
||
"Примеры пояснений:\n"
|
||
" «NoFrost (не нужно размораживать вручную)»\n"
|
||
" «PowerBoost (форсаж — кипятит за минуту)»\n"
|
||
" «FlexZone (объединяет зоны под большую сковороду)»\n"
|
||
" «4D HotAir (конвекция с 4 сторон — равномерное запекание)»\n"
|
||
" «Термощуп (готовит до точной температуры)»\n"
|
||
" «AquaStop (защита от протечек)»\n"
|
||
" «Инвертор (тише и экономия ~30% электричества)»\n\n"
|
||
"Формат ответа — валидный JSON без markdown:\n"
|
||
"{\n"
|
||
' "summary": "1-2 предложения общего вывода",\n'
|
||
' "items": [{\n'
|
||
' "category": "fridge",\n'
|
||
' "brand": "Bosch",\n'
|
||
' "model": "Serie 4 60см",\n'
|
||
' "price_rub": 79990,\n'
|
||
' "highlights": ["NoFrost (не нужно размораживать)", "Инвертор (тише и экономия ~30%)"],\n'
|
||
' "caveats": "Глубина 660мм — на 60мм больше стандартной ниши",\n'
|
||
' "match_score": 0.92,\n'
|
||
' "tier_signal": "middle"\n'
|
||
" }],\n"
|
||
' "total_price_rub": 350000,\n'
|
||
' "budget_status": "в_рамках|превышение|значительно_ниже",\n'
|
||
' "client_temperature": "premium|middle|budget|mixed",\n'
|
||
' "warnings": [],\n'
|
||
' "next_steps": []\n'
|
||
"}\n\n"
|
||
"Не выдумывай несуществующие артикулы — указывай линейку (Bosch Serie 4 60см)."
|
||
)
|
||
|
||
|
||
def call_ai(user_prompt: str, system_prompt: str | None = None,
|
||
temperature: float = 0.3, max_tokens: int = 4000) -> dict[str, Any]:
|
||
"""Вызов GigaChat. Возвращает {json, text, tokens, model, error?}."""
|
||
cfg = get_config()
|
||
try:
|
||
token = _get_token()
|
||
except Exception as e:
|
||
return {"json": None, "text": f"AI auth: {e}", "tokens": 0, "model": cfg.gigachat_model, "error": True}
|
||
|
||
payload = {
|
||
"model": cfg.gigachat_model,
|
||
"temperature": temperature,
|
||
"max_tokens": max_tokens,
|
||
"messages": [
|
||
{"role": "system", "content": system_prompt or SYSTEM_PROMPT_PICKER},
|
||
{"role": "user", "content": user_prompt},
|
||
],
|
||
}
|
||
|
||
try:
|
||
with httpx.Client(timeout=60.0) as client:
|
||
resp = client.post(
|
||
_CHAT_URL,
|
||
headers={
|
||
"Authorization": f"Bearer {token}",
|
||
"Accept": "application/json",
|
||
"Content-Type": "application/json",
|
||
},
|
||
content=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||
)
|
||
except Exception as e:
|
||
return {"json": None, "text": f"AI network: {e}", "tokens": 0, "model": cfg.gigachat_model, "error": True}
|
||
|
||
if resp.status_code >= 400:
|
||
try:
|
||
j = resp.json()
|
||
err_msg = j.get("message") or (j.get("error") or {}).get("message") or resp.text[:300]
|
||
except Exception:
|
||
err_msg = resp.text[:300]
|
||
return {"json": None, "text": f"AI HTTP {resp.status_code}: {err_msg}",
|
||
"tokens": 0, "model": cfg.gigachat_model, "error": True}
|
||
|
||
data = resp.json()
|
||
choice = (data.get("choices") or [{}])[0]
|
||
response_text = (choice.get("message") or {}).get("content", "")
|
||
tokens = (data.get("usage") or {}).get("total_tokens", 0)
|
||
actual_model = data.get("model", cfg.gigachat_model)
|
||
|
||
json_obj = None
|
||
if response_text:
|
||
try:
|
||
json_obj = json.loads(response_text)
|
||
except json.JSONDecodeError:
|
||
stripped = re.sub(r"^```(?:json)?\s*", "", response_text.strip())
|
||
stripped = re.sub(r"\s*```\s*$", "", stripped)
|
||
try:
|
||
json_obj = json.loads(stripped)
|
||
except json.JSONDecodeError:
|
||
pass
|
||
|
||
return {"json": json_obj, "text": response_text, "tokens": tokens, "model": actual_model}
|