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.
This commit is contained in:
@@ -82,6 +82,8 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
|
||||
await obs.event(tid, "ambiguity.user_choice", payload={
|
||||
"amb_id": amb_id, "action": action, "kind": target_amb.get("kind"),
|
||||
"title": target_amb.get("title"),
|
||||
"arg": parts[4] if len(parts) > 4 else None,
|
||||
"candidates": target_amb.get("candidates"),
|
||||
})
|
||||
|
||||
if action == "ok":
|
||||
@@ -107,6 +109,38 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
|
||||
await ctx.delete(chat_id, target_amb["message_id"])
|
||||
except Exception:
|
||||
pass
|
||||
elif action == "pick" and len(parts) >= 5:
|
||||
# Phase 4: user picked one of the Jev candidates.
|
||||
try:
|
||||
idx = int(parts[4])
|
||||
except ValueError:
|
||||
return
|
||||
cands = target_amb.get("candidates") or []
|
||||
if not (0 <= idx < len(cands)):
|
||||
return
|
||||
from app import matching
|
||||
matching.apply_choice(payload, target_amb.get("field"), cands[idx],
|
||||
target_amb.get("variants") or [])
|
||||
target_amb["status"] = "applied"
|
||||
await quote.resolve_payload(ctx, payload)
|
||||
await draft_store.save_payload(draft, mark_bale_synced=True)
|
||||
# In-place edit main review card
|
||||
await quote.render(ctx, chat_id, draft, fresh=False)
|
||||
# Delete ONLY this ambiguity message
|
||||
if target_amb.get("message_id"):
|
||||
try:
|
||||
await ctx.delete(chat_id, target_amb["message_id"])
|
||||
except Exception:
|
||||
pass
|
||||
elif action == "new":
|
||||
# «هیچکدام» — keep the raw name; the auto-create path handles it at commit.
|
||||
target_amb["status"] = "rejected"
|
||||
await draft_store.save_payload(draft, mark_bale_synced=True)
|
||||
if target_amb.get("message_id"):
|
||||
try:
|
||||
await ctx.delete(chat_id, target_amb["message_id"])
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# ---- pick a candidate for a draft field -------------------------------
|
||||
|
||||
Reference in New Issue
Block a user