Files
ERP/bot/tools/test_jev.py
T
whqreza 1c853e50a9 feat(matching): Jev entity matching phases 2-5 (per-field fan-out, variants, candidate ambiguity buttons)
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.
2026-10-12 21:24:03 +03:30

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())