Phase 2 — jev.py client: POST /v1/systemone, PINNED model jev-1.13.0 (drift warning when the response model differs), one retry on 429/5xx, fail-soft (None on any error), usage/latency returned for observability. Config adds TYPESAFE_API_KEY / JEV_* (thresholds 0.85/0.60, 15 questions per field); no daily cap by owner decision. bot/.env is now mounted read-only into the container so the key never enters compose/git.
Phase 3 — matching.py: برونگروهی (one request per field, parallel), درونگروهی (one choice question per mention sharing the same full candidate list + «_none»), درونواریانسی (all STT spellings in one question). Routing: conf>=exact → bind; floor..exact → ambiguity; else/_none → new; missing/failed field → difflib fallback (bug fixed during testing: a failed request used to record 'new' instead of falling back). Resolver gains full cached lists for suppliers/materials (paged) plus brands (API+DB) and specs (EAV values + draft specs). NLU prompt now emits entity_mentions (6 fields) with the voice-variant grouping rule; sanitize_mentions guards the shape. handle_message runs matching only for register_quote/update_draft/add_material behind JEV_ENABLED; jev_map threads into quote/material resolve_payload (Jev hit skips difflib; exact writes ids; ambiguity skips difflib). Spec effect per owner: spelling normalization only (normalize_brand_spec).
Phase 4 — entity-match ambiguity sub-messages: create_ambiguities() builds deduped entries {kind: entity_match, field, variants, candidates, details with Persian percentages}; new keyboards.ambiguity_candidates_keyboard renders one button per candidate + «هیچکدام — ایجاد جدید»; callbacks handle amb⛏️{prefix}:{amb_id}:{idx} (applies the candidate via matching.apply_choice then re-resolves/renders) and amb:new (rejected → auto-create path at commit). Every user choice lands in observability (ambiguity.user_choice with arg+candidates).
Phase 5 — voice variants: NLU groups different STT spellings of one entity into one variants[] (verified live: مصطفوی/مستفدی → one supplier mention); tools/test_matching.py live section (RUN_LIVE=1) exercises real DeepSeek + real Jev without touching Bale.
Tests: test_jev.py 5/5 (mock HTTP: no-key, happy path, 429-retry, 400, exception), test_matching.py 11/11 mock + 3/3 live (real NLU produced 7 mentions with correct fields; real Jev matched «50 کیلویی»→«۵۰ کیلویی» 0.98 and «کیسه» 1.0 and correctly returned new against the emptied catalog); observability regression 8/8.
68 lines
2.8 KiB
Python
68 lines
2.8 KiB
Python
"""Jev (TypeSafe) client — structured `choice` matching over HTTP (phases 2-5).
|
|
|
|
Contract (docs.typesafe.ai): POST /v1/systemone with
|
|
{"state": ..., "model": <pinned>, "questions": {key: {type, instructions, criteria}}}
|
|
→ {"answers": {key: {choice, confidence, probabilities}}, "usage": {...}, "model": "..."}
|
|
|
|
Design rules (final plan v5):
|
|
- The model version is PINNED (JEV_MODEL=jev-1.13.0 from phase 0); a response
|
|
whose `model` differs logs a drift warning and is still returned (recorded).
|
|
- Fail-soft: any error/timeout → None. Callers fall back per field to the
|
|
existing difflib path; with JEV_ENABLED=false nothing here is called at all.
|
|
- One retry only for 429/5xx (after a short backoff), then give up.
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
|
|
import httpx
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger("bot.jev")
|
|
|
|
_URL = "https://api.typesafe.ai/v1/systemone"
|
|
|
|
|
|
async def ask(state: dict, questions: dict, *, timeout: float | None = None) -> dict | None:
|
|
"""One systemone request. Returns {"answers", "usage", "model", "latency_ms"} or None."""
|
|
if not settings.TYPESAFE_API_KEY:
|
|
logger.warning("jev: TYPESAFE_API_KEY is not configured")
|
|
return None
|
|
|
|
payload = {"state": state, "model": settings.JEV_MODEL, "questions": questions}
|
|
headers = {
|
|
"Authorization": f"Bearer {settings.TYPESAFE_API_KEY}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
t0 = time.perf_counter()
|
|
for attempt in (1, 2):
|
|
try:
|
|
async with httpx.AsyncClient(timeout=timeout or settings.JEV_TIMEOUT) as client:
|
|
resp = await client.post(_URL, json=payload, headers=headers)
|
|
if resp.status_code == 200:
|
|
body = resp.json() or {}
|
|
actual = str(body.get("model") or "")
|
|
if actual and actual != settings.JEV_MODEL:
|
|
logger.warning("jev: model drift — got %s, pinned %s", actual, settings.JEV_MODEL)
|
|
return {
|
|
"answers": body.get("answers") or {},
|
|
"usage": body.get("usage") or {},
|
|
"model": actual or settings.JEV_MODEL,
|
|
"latency_ms": int((time.perf_counter() - t0) * 1000),
|
|
}
|
|
if resp.status_code in (429, 500, 502, 503, 504) and attempt == 1:
|
|
logger.warning("jev: HTTP %s — retrying once", resp.status_code)
|
|
await asyncio.sleep(1.0)
|
|
continue
|
|
logger.error("jev: HTTP %s: %s", resp.status_code, resp.text[:300])
|
|
return None
|
|
except Exception as exc:
|
|
if attempt == 1:
|
|
logger.warning("jev: request failed (%s) — retrying once", exc)
|
|
await asyncio.sleep(1.0)
|
|
continue
|
|
logger.error("jev: request failed: %s", exc)
|
|
return None
|
|
return None
|