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.
116 lines
3.7 KiB
Python
116 lines
3.7 KiB
Python
"""Phase-2 tests for the Jev client — MOCKED HTTP (no network, no key needed).
|
|
|
|
docker compose exec bot python tools/test_jev.py
|
|
"""
|
|
import asyncio
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from app import jev
|
|
from app.config import settings
|
|
|
|
PASSED: list[str] = []
|
|
FAILED: list[str] = []
|
|
|
|
|
|
def check(name: str, ok: bool, extra: str = "") -> None:
|
|
(PASSED if ok else FAILED).append(name)
|
|
print(f"{'PASS' if ok else 'FAIL'} {name}{' — ' + str(extra) if extra else ''}")
|
|
|
|
|
|
class FakeResp:
|
|
def __init__(self, status: int, body: dict):
|
|
self.status_code = status
|
|
self._body = body
|
|
self.text = str(body)
|
|
|
|
def json(self):
|
|
return self._body
|
|
|
|
|
|
class FakeClient:
|
|
"""Scripted httpx.AsyncClient stand-in: pops one (status, body) per POST."""
|
|
|
|
script: list = []
|
|
posts: int = 0
|
|
|
|
def __init__(self, *a, **k):
|
|
pass
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
return False
|
|
|
|
async def post(self, url, json=None, headers=None):
|
|
FakeClient.posts += 1
|
|
status, body = FakeClient.script.pop(0)
|
|
return FakeResp(status, body)
|
|
|
|
|
|
Q = {"m0": {"type": "choice", "instructions": "i", "criteria": {"x": ""}}}
|
|
|
|
|
|
async def main() -> None:
|
|
orig_client = jev.httpx.AsyncClient
|
|
saved_key = settings.TYPESAFE_API_KEY
|
|
jev.httpx.AsyncClient = FakeClient
|
|
try:
|
|
# 1) no key -> None (no HTTP at all)
|
|
settings.TYPESAFE_API_KEY = ""
|
|
FakeClient.posts = 0
|
|
r = await jev.ask({"s": 1}, Q)
|
|
check("no api key -> None (no request)", r is None and FakeClient.posts == 0)
|
|
|
|
settings.TYPESAFE_API_KEY = saved_key or "test-key"
|
|
|
|
# 2) happy path: answers + model + usage + latency
|
|
FakeClient.script = [(200, {"model": "jev-1.13.0",
|
|
"answers": {"m0": {"choice": "x", "confidence": 0.9,
|
|
"probabilities": {"x": 0.9}}},
|
|
"usage": {"input_tokens": 10, "output_tokens": 2}})]
|
|
r = await jev.ask({"s": 1}, Q)
|
|
ok = bool(r) and r["answers"]["m0"]["choice"] == "x" and r["model"] == "jev-1.13.0" \
|
|
and r["usage"]["input_tokens"] == 10 and isinstance(r["latency_ms"], int)
|
|
check("happy path -> answers/model/usage/latency", ok)
|
|
|
|
# 3) 429 -> one retry, then success
|
|
FakeClient.script = [(429, {"error": "rate_limited"}),
|
|
(200, {"model": "jev-1.13.0", "answers": {}, "usage": {}})]
|
|
FakeClient.posts = 0
|
|
r = await jev.ask({"s": 1}, Q)
|
|
check("429 retried once then succeeds", r is not None and FakeClient.posts == 2)
|
|
|
|
# 4) hard 400 -> None, no retry
|
|
FakeClient.script = [(400, {"error": "bad_request"})]
|
|
FakeClient.posts = 0
|
|
r = await jev.ask({"s": 1}, Q)
|
|
check("400 -> None without retry", r is None and FakeClient.posts == 1)
|
|
|
|
# 5) exception -> retried once -> None
|
|
class BoomClient(FakeClient):
|
|
async def post(self, url, json=None, headers=None):
|
|
FakeClient.posts += 1
|
|
raise RuntimeError("net down")
|
|
|
|
jev.httpx.AsyncClient = BoomClient
|
|
FakeClient.posts = 0
|
|
r = await jev.ask({"s": 1}, Q)
|
|
check("exception retried once -> None", r is None and FakeClient.posts == 2)
|
|
finally:
|
|
jev.httpx.AsyncClient = orig_client
|
|
settings.TYPESAFE_API_KEY = saved_key
|
|
|
|
total = len(PASSED) + len(FAILED)
|
|
print(f"\n{len(PASSED)}/{total} passed")
|
|
if FAILED:
|
|
print("FAILED:", FAILED)
|
|
raise SystemExit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|