zov-tech/backend-py/app/ai.py
wasrusgen 02f8dba469 feat: expeditor cabinet, electronic signature (OTP+canvas), invoice room picker
New modules:
- expeditor_dashboard.js: route list (date-grouped) + act detail + signature screen
- invoice.js: 3-col chip room picker, 2500₽ base + 1000₽ extra logic
- act4.js, measurer_dashboard.js, finance_summary.js, client_timeline.js, feedback.js, staff_roster.js

Backend:
- /api/expeditor_inbox: filtered assembly list for expeditor role
- /api/act4_request_otp: 6-digit OTP via Telegram, 10-min expiry
- /api/act4_verify_otp: validates code, marks act as signed
- /api/act4_save_signature: saves base64 canvas signature
- Act4s sheet: added signature_b64, otp_code, otp_expires_at columns

Tests:
- tests/expeditor_scenarios.md: 11 manual test scenarios

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:11:20 +03:00

348 lines
24 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.

"""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"
"═══ РЫНОК РФ 2026 — что реально берут ═══\n"
"**💎 Премиум (от 100к ₽ за позицию)**:\n"
" - Miele, Gaggenau, V-Zug, Smeg, Asko, Liebherr — ВСЕ через параллельный импорт (⚠ гарантия только продавца, ожидание 4-8 недель)\n"
"**★ Средний — цена/качество (40-100к ₽)**:\n"
" - Bosch ⚠, Siemens ⚠, NEFF ⚠ — через параллельный, цены +15-30% к официальным\n"
" - **Haier (Китай) ⭐** — фактически №2 после Bosch, лучшее цена/качество 2025-2026, особенно холодильники и стиралки\n"
" - Electrolux, AEG — официально остались, проблем со снабжением нет\n"
" - LG, Samsung — крупные холодильники и СВЧ, доступны\n"
"**₽ Бюджет (15-40к ₽ за позицию)**:\n"
" - **Kuppersberg ⭐** — российско-немецкий, частично сборка в РФ, ОЧЕНЬ популярен в кухнях ЗОВ-уровня и у застройщиков. ДЕФОЛТНЫЙ выбор в бюджете.\n"
" - Maunfeld — росс./китайская сборка, агрессивно растёт\n"
" - Weissgauff — то же, активный маркетинг\n"
" - Korting (итало-китайский), Hansa (Польша/Турция), Beko (Турция)\n"
" - Gorenje (концерн Hisense), Hotpoint/Indesit (Whirlpool)\n"
" - Hisense, Midea, Candy/Hoover, Atlant (Беларусь, холодильники)\n\n"
"**Типичный ВЫБОР клиента ЗОВ-СПб**: Bosch + Haier + Maunfeld (компромисс).\n"
"**Премиум-комбо**: Bosch + Miele посудомойка (для кухонь 600к+).\n\n"
"**Тренды 2026**:\n"
" 1. Параллельный импорт стал нормой — Bosch/Siemens/Miele покупают спокойно, но без офиц. гарантии\n"
" 2. Китай вытеснил немцев в среднем сегменте — Haier #2 после Bosch\n"
" 3. Российско-немецкие бренды (Kuppersberg, Maunfeld, Weissgauff) — массовый выбор в новостройках\n"
" 4. СВЧ как отдельная категория теряет популярность — клиенты берут духовку с функцией СВЧ\n"
" 5. Индукция окончательно победила — газ только в частных домах, Hi-Light уходит в эконом\n\n"
"═══ ВХОДНЫЕ ДАННЫЕ ═══\n"
"В `checklist` получаешь:\n"
" • `categories[]` — какие категории подбираем (fridge, hob, oven, dw, hood, microwave, coffee, washer)\n"
" • `per_cat[cat].answers{}` — иерархические ответы wizard'а по каждой категории\n"
" (install, chamber, size, features[], heat_source, subtype[], burners и т.д.)\n"
" • `per_cat[cat].notes` — заметки менеджера по категории\n"
" • `brand_strategy` — 'ai' (AI решит) | 'single' (одна марка) | 'different' (разные марки)\n"
" • `single_brand` — если brand_strategy='single', выбранная марка (или 'ai_pick')\n"
" • `brands{}` — если brand_strategy='different', по категориям: { fridge: { Bosch: 'preferred'|'acceptable'|'avoid' } }\n"
" • `budget_preset` — 'luxe' (от 1.5М₽) | 'premium' (700к-1.5М) | 'middle' (350-700к) | 'budget' (до 350к) | 'exact'\n"
" • `price_ranges{}` — если budget_preset='exact', точные коридоры от-до по категориям\n"
" • `pick_strategies[]` — стратегии (multi): 'reviews', 'balance', 'premium_brand', 'cheap', 'tech', 'style'\n"
" • `infra` — { stove: 'induction'|'gas'|'el_220'|'any', vent: 'yes'|'no'|'unknown' }\n\n"
"═══ ПРИНЦИПЫ ПОДБОРА ═══\n"
"1. **Бренд-стратегия**:\n"
" - 'single' → ВСЯ техника от одной марки (или близких из её линейки), укажи модель из этой марки\n"
" - 'different' → preferred (★) приоритет, acceptable (✓) запасной вариант, avoid (✗) ИСКЛЮЧИ\n"
" - 'ai' → подбирай оптимальный микс под бюджет/стратегию\n"
"2. **Бюджет**:\n"
" - 'exact' → попадай в price_ranges[cat].from..to (±5%)\n"
" - 'luxe' / 'premium' / 'middle' / 'budget' → сам распредели бюджет по категориям:\n"
" холодильник ~25%, варочная ~12%, духовка ~15%, ПММ ~10%, вытяжка ~8%, СВЧ ~5%, кофемашина ~15%, стиралка ~10%\n"
"3. **Стратегии подбора** (pick_strategies, multi — учитывай ВСЕ):\n"
" - 'reviews' → топ по отзывам пользователей\n"
" - 'balance' → оптимальное цена/качество\n"
" - 'premium_brand' → только премиум-имена (Miele, Gaggenau, Sub-Zero, V-ZUG, Asko)\n"
" - 'cheap' → надёжный минимум по цене\n"
" - 'tech' → топ функционал (Wi-Fi, инвертор, пар, авто-программы)\n"
" - 'style' → согласованный дизайн всей техники\n"
"4. **Инфраструктура и режим вытяжки**:\n"
" - `checklist.infra.stove` (газ/электро/индукция) — газ исключает индукцию\n"
" - У ВЫТЯЖКИ режим живёт в `per_cat.hood.answers.mode`:\n"
" • `exhaust` (Отвод в вентшахту) → стандартная установка, без фильтра\n"
" • `recirc` (Рециркуляция) → **ОБЯЗАТЕЛЬНО в pros модели вытяжки укажи 'Угольный фильтр в комплекте/докупаем'**\n"
" и в первой строке pros указать что комплект подходит для квартир без вентшахты\n"
" • `combi` (Универсальная) → упомяни что подходит для обоих режимов\n"
" - Если у hood режим recirc и AI не предлагает угольный фильтр — это ОШИБКА\n"
"5. **Фичи современной техники** (NoFrost, Inverter, Wi-Fi, AquaStop, Booster и т.п.):\n"
" - Пользователь НЕ выбирает фичи — у современной техники они в основном одинаковые (95% моделей имеют base set)\n"
" - Ты УПОМИНАЕШЬ важные фичи каждой модели в `highlights` и в `pros` БЕЗ акцента на них\n"
" - Главное в pros — конкретные ИЗМЕРИМЫЕ преимущества (тише на N дБ, объём больше на Y л, дешевле на N%)\n"
"6. ВАЖНО: каждую тех. фичу в highlights ОБЯЗАТЕЛЬНО объясняй простым языком в скобках.\n\n"
"Примеры пояснений:\n"
" «NoFrost (не нужно размораживать вручную)»\n"
" «PowerBoost (форсаж — кипятит за минуту)»\n"
" «FlexZone (объединяет зоны под большую сковороду)»\n"
" «4D HotAir (конвекция с 4 сторон — равномерное запекание)»\n"
" «Термощуп (готовит до точной температуры)»\n"
" «AquaStop (защита от протечек)»\n"
" «Инвертор (тише и экономия ~30% электричества)»\n\n"
"═══ ФОРМАТ ОТВЕТА ═══\n"
"Количество моделей по категории определяется параметром `checklist.model_count` (3 / 5 / 7) — соблюдай!\n"
"Каждая модель ДОЛЖНА содержать аналитику: pros (минимум 3), cons (минимум 2), почему выбрана, с чем сравнивать.\n"
"По КАЖДОЙ категории напиши `analysis` — обзор: какие компромиссы, на что обратить внимание.\n"
"Валидный JSON без markdown, без ```.\n"
"Для текстовых полей (summary, analysis, reasoning, элементы pros[], cons[], highlights[], next_steps[]) используй HTML-разметку:\n"
" <b>число или ключевой термин</b> — выделение, <br> — перенос строки, <em> — курсив.\n"
" НЕ используй markdown (**текст**, *текст*, ## заголовки) — только HTML.\n"
" pros/cons/highlights — массивы строк с HTML внутри.\n\n"
"Структура ответа:\n"
"{\n"
' "summary": "2-3 предложения общего вывода: что подобрали, почему этот набор, на чём сэкономили / куда вложились",\n'
' "by_category": {\n'
' "fridge": {\n'
' "analysis": "2-3 предложения: какие компромиссы в этой категории, какие модели для каких сценариев, на что смотреть при финальном выборе",\n'
' "models": [\n'
' {\n'
' "brand": "Haier",\n'
' "model": "C4F744CMG",\n'
' "price_min_rub": 79990,\n'
' "price_max_rub": 92000,\n'
' "search_query": "Haier C4F744CMG холодильник",\n'
' "manual_search_query": "Haier C4F744CMG manual инструкция pdf",\n'
' "highlights": ["NoFrost (не нужно размораживать)", "Инвертор (тише и -30% энергии)"],\n'
' "pros": ["тихий 36 дБ — на 4 дБ тише среднего по сегменту", "класс A++, экономия ~30% против A+", "большой объём 463 л против 380 л у конкурентов в той же ценовой категории"],\n'
' "cons": ["глубина 660 мм — на 60 мм больше стандартной ниши, проверить нишу клиента", "нет зоны свежести BioFresh — в этом плане Liebherr ровно вдвое лучше"],\n'
' "reasoning": "Лучший выбор по цена/качество в этом бюджете. Тише и больше чем Bosch в той же цене, но без премиум-зоны свежести.",\n'
' "specs": {\n'
' "dimensions_mm": "595×660×2000",\n'
' "weight_kg": 75,\n'
' "volume_l": 463,\n'
' "noise_db": 36,\n'
' "energy_class": "A++",\n'
' "color": "Нержавеющая сталь"\n'
' },\n'
' "tier": "middle",\n'
' "match_score": 0.92\n'
" }\n"
" ]\n"
" }\n"
" },\n"
' "total_price_estimate_rub": { "min": 320000, "max": 480000 },\n'
' "budget_status": "в_рамках|превышение|значительно_ниже",\n'
' "client_temperature": "premium|middle|budget|mixed",\n'
' "warnings": [],\n'
' "next_steps": ["рекомендации для менеджера: что уточнить с клиентом, что проверить на замере"]\n'
"}\n\n"
"═══ КРИТИЧНО ═══\n"
"0. **КАТАЛОГ МОДЕЛЕЙ**: если в user-prompt передан раздел `ДОСТУПНЫЙ КАТАЛОГ МОДЕЛЕЙ` — \n"
" ты ОБЯЗАН выбирать модели ТОЛЬКО оттуда. Каждая модель из каталога — реальный артикул, \n"
" подтверждённый парсером маркетплейса. Не из каталога = не использовать.\n"
" Если каталог не передан (или пустой по нужным категориям) — действуй на основе своих знаний с правилами ниже.\n"
"1. **Реальные модели**: артикулы должны существовать в природе:\n"
" - Haier C4F744CMG, Haier HRF-541DM7RU (холодильники)\n"
" - Bosch Serie 4 KGN39NW00R ⚠, Liebherr CNd 5223 ⚠\n"
" - Kuppersberg NRS 1857 X (фридж), Kuppersberg HM 612 BG (плита)\n"
" - Maunfeld MBL 88LU, MOC.HF09L1MX (хорошие линейки)\n"
" - Weissgauff WBI 30 ATX, WS 519 (популярные)\n"
" - НЕ «Bosch X-200», НЕ «Haier выгодный», НЕ выдуманное\n"
"2. **РЕАЛИИ РФ 2026**: Bosch/Siemens/NEFF/Miele идут параллельным импортом — их цена в РФ выше официальных на 15-30%. Учитывай это.\n"
" - **Kuppersberg = дефолтный бюджет** для ЗОВ-клиентов, активно используется застройщиками\n"
" - **Haier = лучшая средняя цена/качество**, особенно в холодильниках и стиралках\n"
"3. **Pros с числами**: НЕ «тихий» — а «36 дБ». НЕ «энергоэффективный» — а «класс A++, ~30% экономии». НЕ «вместительный» — а «463 л».\n"
"4. **Cons обязательны**: даже у лучших моделей есть недостатки. Если cons пусто — модель не выбрана. Конкретные минусы: габарит больше ниши, шумнее на 2 дБ, без какой-то функции, цена выше на N%, длительная гарантия только N лет.\n"
"5. **Reasoning**: 1 предложение «почему именно эта модель в этом наборе» — позиционирование относительно других в выдаче.\n"
"6. **search_query**: точная строка для поиска (бренд + индекс + слово категория). AI агент будет парсить маркетплейсы по этой строке — не указывай лишнего.\n"
"7. **manual_search_query**: строка для Google-поиска инструкции, в формате «<brand> <model> manual инструкция pdf»\n"
"8. **specs ОБЯЗАТЕЛЬНЫ для проектирования кухни**:\n"
" - `dimensions_mm` — габариты ШхГxВ в мм (это критично для дизайна ниш в кухне ЗОВ)\n"
" - `weight_kg`, `volume_l` (для холодильников/духовок/ПММ), `noise_db`, `energy_class` ('A+++', 'A++', 'A+', 'A', 'B')\n"
" - `color` — основной цвет/материал\n"
"9. **Количество моделей в каждой категории = `checklist.model_count`** (3 или 5 или 7). Меньше не возвращай. Если AI не уверен в N-й модели — добавь её всё равно из доступных в РФ.\n"
"10. Бренд-стратегия 'single'ВСЕ models из одной марки.\n"
"11. price_min_rub/price_max_rub — диапазон по разным магазинам (если не уверен — один и тот же)."
)
def call_ai(user_prompt: str, system_prompt: str | None = None,
temperature: float = 0.3, max_tokens: int = 8000) -> 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}
_FILES_URL = "https://gigachat.devices.sberbank.ru/api/v1/files"
_VISION_MODEL = "GigaChat-Pro"
def parse_receipt_amount(image_b64: str) -> dict[str, Any]:
"""Парсит фото чека через GigaChat Vision.
Возвращает {"amount": float|None, "raw": str, "error": bool}."""
import base64, io, re as _re
try:
token = _get_token()
except Exception as e:
return {"amount": None, "raw": "", "error": True, "msg": str(e)}
# Декодируем data URL
m = re.match(r"^data:image/(jpeg|jpg|png|webp);base64,(.+)$", image_b64.strip(), re.DOTALL)
if not m:
return {"amount": None, "raw": "", "error": True, "msg": "bad_image_format"}
ext = "jpg" if m.group(1) in ("jpeg", "jpg") else m.group(1)
mime = f"image/{m.group(1)}"
raw_bytes = base64.b64decode(m.group(2), validate=False)
# 1. Загружаем файл в GigaChat Files API
file_id: str | None = None
try:
with httpx.Client(timeout=30.0) as client:
resp = client.post(
_FILES_URL,
headers={"Authorization": f"Bearer {token}"},
files={"file": (f"receipt.{ext}", io.BytesIO(raw_bytes), mime)},
data={"purpose": "general"},
)
if resp.status_code < 400:
file_id = resp.json().get("id")
except Exception as e:
return {"amount": None, "raw": "", "error": True, "msg": f"file_upload: {e}"}
if not file_id:
return {"amount": None, "raw": "", "error": True, "msg": "no_file_id"}
# 2. Спрашиваем итоговую сумму
payload = {
"model": _VISION_MODEL,
"temperature": 0.1,
"max_tokens": 256,
"messages": [{
"role": "user",
"content": [
{"type": "text",
"text": "На этом фото кассовый чек. Найди итоговую сумму (ИТОГ, ИТОГО, СУММА, TOTAL). "
"Ответь ТОЛЬКО числом в рублях без пробелов и без знака ₽ и без копеек, например: 1250. "
"Если сумму найти не удалось — напиши 0."},
{"type": "image_url", "image_url": {"url": f"gigachat://files/{file_id}"}},
],
}],
}
try:
with httpx.Client(timeout=45.0) as client:
resp = client.post(
_CHAT_URL,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
content=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
)
except Exception as e:
return {"amount": None, "raw": "", "error": True, "msg": f"vision_call: {e}"}
if resp.status_code >= 400:
return {"amount": None, "raw": resp.text[:200], "error": True, "msg": f"vision_http_{resp.status_code}"}
raw_text = ((resp.json().get("choices") or [{}])[0].get("message") or {}).get("content", "").strip()
# Извлекаем число из ответа
nums = re.findall(r"\d[\d\s]*(?:[.,]\d{1,2})?", raw_text)
amount: float | None = None
for n in nums:
try:
v = float(n.replace(" ", "").replace(",", "."))
if v > 0:
amount = v
break
except ValueError:
pass
return {"amount": amount, "raw": raw_text, "error": False}