feat(matching): implement two-stage matching with DeepSeek arbiter, fail-stop guard, and zero-probability filter

This commit is contained in:
2026-10-16 03:13:21 +03:30
parent 9e848f535f
commit 787824a19e
6 changed files with 475 additions and 48 deletions
+281 -1
View File
@@ -48,6 +48,66 @@ class MatchingFailureError(Exception):
pass
ARBITRATION_SYSTEM_PROMPT = """تو قاضی و داور نهایی هوشمند تطبیق موجودیت‌های تدارکاتی و ساختمانی در یک سیستم ERP هستی.
یک مدل جستجوی برداری اولیه (به نام Jev) برای هر منشن کاربر، کاتالوگ دیتابیس را بررسی کرده و گزینه‌ای را پیشنهاد داده است.
وظیفه خطیر تو:
تحلیل هم‌زمان ۱) پیام اولیه کاربر، ۲) کاندیداهای کاتالوگ دیتابیس، و ۳) پیشنهاد مدل Jev، و صدور «حکم قطعی نهایی».
یک شیء JSON و فقط JSON برگردان با ساختار زیر:
{
"decisions": [
{
"field": "supplier|material|project|unit|brand|spec",
"variant": "نگارش منشن",
"status": "exact|ambiguity|new",
"id": "شناسه رکورد کاتالوگ یا null",
"name": "نام رسمی رکورد کاتالوگ یا null",
"candidates": [
{"id": "شناسه", "name": "نام", "prob": 0.0..1.0}
],
"inferred_category_slug": "یکی از ۸ اسلاگ مجاز فقط برای تامین‌کننده new یا null",
"adjudication_action": "endorsed_jev|vetoed_to_new|demoted_to_ambiguity",
"reasoning": "توضیح کوتاه فنی دلیل قضاوت"
}
]
}
قوانین حیاتی داوری:
۱. عدم اعتماد کورکورانه به Jev:
- مدل Jev صرفاً یک مدل آماری/برداری است و تفاوت سایزها، رده‌ها، ابعاد فنی و استانداردهای صنعتی (مانند لوله ۶ اینچ در برابر لوله ۴ اینچ، میلگرد ۱۶ در برابر ۲۰، یا تفاوت دو برند متفاوت) را تشخیص نمی‌دهد.
- تو باید کلام کاربر را با پیشنهاد Jev مقایسه کنی.
۲. حالات صدور حکم:
الف) تأیید انتخاب Jev (status: "exact"):
اگر پیشنهاد Jev دقیقاً و با احتساب نام تجاری و مشخصات فنی با خواسته کاربر در پیام مطابقت دارد.
- فیلد id و name را با مقادیر دقیق رکورد کاتالوگ پر کن. candidates خالی باشد ([]).
ب) وتوی Jev و اعلام قلم جدید (status: "new"):
اگر کالا، تأمین‌کننده یا پروژه مورد نظر کاربر در کاتالوگ ارسالی وجود ندارد (حتی اگر Jev به اشتباه به دلیل شباهت حروفی گزینه‌ای را با اطمینان بالا پیشنهاد داده باشد).
- پیشنهاد Jev را بلافاصله باطل و وتو کن!
- وضعیت را "new" بگذار و id حتماً null باشد. هرگز قلم ناموجود را به زور به کاتالوگ نچسبان.
- برای تأمین‌کننده جدید (supplier)، مناسب‌ترین اسلاگ را از بین ۸ دسته مجاز در inferred_category_slug قرار بده.
ج) تنزل به ابهام (status: "ambiguity"):
اگر کاربر عبارت کلی گفته (مثلاً «میلگرد») و در کاتالوگ چند گزینه محتمل وجود دارد، یا بین انتخاب Jev و سایر کاندیداها تردید فنی وجود دارد.
- وضعیت را "ambiguity" بگذار و گزینه‌های منتخب را در candidates بگذار.
۳. تحلیل ورودی‌های صوتی (Voice Mode):
اگر ورودی صوتی است (input_mode: "voice")، آرایه کامل ترانویسی موتورهای STT (مانند Whisper، Vosk و غیره) به همراه consensus_text در اختیارت است.
شواهد صوتی، تلفظ‌ها، خطاهای شنیداری و لهجه‌های کارگاهی را ملاک قضاوت قرار بده.
۴. اسلاگ‌های مجاز دسته‌بندی برای تأمین‌کننده جدید:
* metals-steel-sections
* mechanical-installations-pipes
* electrical-equipment-supplies
* construction-materials
* construction-chemicals
* doors-windows-glass
* finishing-interior-decoration
* construction-tools-equipment
"""
FALLBACK_SYSTEM_PROMPT = """تو موتور تطبیق و پالایش هوشمند موجودیت‌های تدارکاتی یک سیستم ساختمانی و صنعتی ERP در ایران هستی.
سرویس جانبی تطبیق کاتالوگ در دسترس نیست و اکنون تو وظیفه داری با درک مهندسی، تجاری و معنایی خود، عبارات ذکرشده توسط کاربر را مستقیماً با رکوردهای کاتالوگ دیتابیس تطبیق دهی.
@@ -115,6 +175,7 @@ async def match_with_deepseek(
candidates_by_field: dict[str, list],
patch_context: dict | None,
trace_id: str | None,
consensus_text: str | None = None,
) -> dict[str, dict]:
"""Execute DeepSeek crisis matching for unmapped mentions.
@@ -141,8 +202,20 @@ async def match_with_deepseek(
field_cands.append(item)
catalog_summary[fld] = field_cands[:80] # token-safe ceiling per field
if isinstance(raw_input, list):
reference_input = {
"input_mode": "voice",
"stt_multi_transcripts": raw_input,
"consensus_text": consensus_text or "",
}
else:
reference_input = {
"input_mode": "text",
"raw_text": str(raw_input or ""),
}
payload_data = {
"raw_user_input": raw_input,
"reference_user_input": reference_input,
"entity_mentions": mentions,
"catalog_candidates": catalog_summary,
"patch_context": patch_context or {},
@@ -270,3 +343,210 @@ async def match_with_deepseek(
})
return results_map
async def adjudicate_with_deepseek(
raw_input: object,
consensus_text: str | None,
items: list[dict],
patch_context: dict | None,
trace_id: str | None,
) -> dict[str, dict]:
"""Execute DeepSeek final arbitration over Jev recommendations.
Takes Jev-assessed items and makes final binding decisions.
Raises MatchingFailureError if DeepSeek fails or returns unparseable output.
"""
if not items:
return {}
if not settings.AI_BASE_URL or not settings.AI_API_KEY:
logger.error("fallback_matcher: AI provider not configured for arbitration")
raise MatchingFailureError("AI provider not configured")
if isinstance(raw_input, list):
reference_input = {
"input_mode": "voice",
"stt_multi_transcripts": raw_input,
"consensus_text": consensus_text or "",
}
else:
reference_input = {
"input_mode": "text",
"raw_text": str(raw_input or ""),
}
arbitration_items_payload = []
# Candidate lookup by (field, id) and (field, name) for metadata
cand_lookup: dict[tuple[str, str], object] = {}
for it in items:
fld = it["field"]
c_list = it.get("candidates") or []
serialized_cands = []
for c in c_list:
cid = str(getattr(c, "id", ""))
cname = str(getattr(c, "name", ""))
cand_lookup[(fld, cid)] = c
cand_lookup[(fld, normalize_text(cname))] = c
item_entry = {"id": cid, "name": cname}
extra = getattr(c, "extra", {}) or {}
for k in ("category_name", "location", "canonical_unit_id", "code"):
if extra.get(k):
item_entry[k] = extra[k]
serialized_cands.append(item_entry)
raw_probs = it.get("jev_probabilities") or {}
non_zero_probs = {k: v for k, v in raw_probs.items() if float(v or 0.0) > 0.0}
arbitration_items_payload.append({
"field": fld,
"variants": it.get("variants") or [],
"catalog_candidates": serialized_cands[:60],
"jev_assessment": {
"recommended_choice": it.get("jev_choice"),
"recommended_id": it.get("jev_choice_id"),
"confidence": it.get("jev_confidence"),
"probabilities": non_zero_probs,
"top_ranked": it.get("jev_ranked") or [],
},
})
payload_data = {
"reference_user_input": reference_input,
"items_to_adjudicate": arbitration_items_payload,
"patch_context": patch_context or {},
}
user_content = json.dumps(payload_data, ensure_ascii=False)
t0 = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT_SECONDS) as client:
resp = await client.post(
f"{settings.AI_BASE_URL.rstrip('/')}/chat/completions",
headers={"Authorization": f"Bearer {settings.AI_API_KEY}"},
json={
"model": settings.LLM_MODEL,
"messages": [
{"role": "system", "content": ARBITRATION_SYSTEM_PROMPT},
{"role": "user", "content": user_content},
],
"temperature": 0.1,
"response_format": {"type": "json_object"},
},
)
latency_ms = int((time.perf_counter() - t0) * 1000)
if resp.status_code != 200:
logger.error("adjudicate_with_deepseek: HTTP %s from DeepSeek: %s", resp.status_code, resp.text[:300])
await obs.event(trace_id, "matching.arbitration.failure", ok=False, payload={
"status_code": resp.status_code,
"error": "http_error",
"body": resp.text[:300],
})
raise MatchingFailureError(f"DeepSeek returned HTTP {resp.status_code}")
content = ((resp.json() or {}).get("choices") or [{}])[0].get("message", {}).get("content", "")
usage = (resp.json() or {}).get("usage") or {}
parsed = _extract_json(content)
if not parsed or not isinstance(parsed.get("decisions"), list):
logger.error("adjudicate_with_deepseek: unparseable JSON from DeepSeek: %r", content[:300])
await obs.event(trace_id, "matching.arbitration.failure", ok=False, payload={
"error": "unparseable_json",
"raw": content[:500],
})
raise MatchingFailureError("DeepSeek returned unparseable arbitration JSON")
except httpx.HTTPError as exc:
latency_ms = int((time.perf_counter() - t0) * 1000)
logger.exception("adjudicate_with_deepseek: HTTP network error calling DeepSeek")
await obs.event(trace_id, "matching.arbitration.failure", ok=False, payload={
"error": "network_exception",
"details": str(exc),
"latency_ms": latency_ms,
})
raise MatchingFailureError("Network exception calling DeepSeek") from exc
except Exception as exc:
if isinstance(exc, MatchingFailureError):
raise
latency_ms = int((time.perf_counter() - t0) * 1000)
logger.exception("adjudicate_with_deepseek: Unexpected error")
await obs.event(trace_id, "matching.arbitration.failure", ok=False, payload={
"error": "unexpected_exception",
"details": str(exc),
"latency_ms": latency_ms,
})
raise MatchingFailureError("Unexpected error calling DeepSeek") from exc
results_map: dict[str, dict] = {}
actions_count = {"endorsed_jev": 0, "vetoed_to_new": 0, "demoted_to_ambiguity": 0, "other": 0}
statuses_count = {"exact": 0, "ambiguity": 0, "new": 0}
# Map decisions back to results_map
for dec in parsed.get("decisions", []):
if not isinstance(dec, dict):
continue
field = str(dec.get("field") or "").strip()
variant = str(dec.get("variant") or "").strip()
status = str(dec.get("status") or "").strip().lower()
if status not in ("exact", "ambiguity", "new"):
status = "new"
action = str(dec.get("adjudication_action") or "other")
actions_count[action] = actions_count.get(action, 0) + 1
statuses_count[status] = statuses_count.get(status, 0) + 1
cand_id = str(dec.get("id")) if dec.get("id") else None
cand_name = str(dec.get("name")) if dec.get("name") else None
cand_obj = None
if cand_id:
cand_obj = cand_lookup.get((field, cand_id))
if not cand_obj and cand_name:
cand_obj = cand_lookup.get((field, normalize_text(cand_name)))
extra = getattr(cand_obj, "extra", {}) or {}
amb_candidates = []
for c in dec.get("candidates") or []:
if isinstance(c, dict) and c.get("id") and c.get("name"):
amb_candidates.append({
"id": str(c["id"]),
"name": str(c["name"]),
"prob": float(c.get("prob") or 0.5),
})
# Find matching item from items for variants
matched_item = next(
(it for it in items if it["field"] == field and any(normalize_text(v) == normalize_text(variant) for v in it.get("variants", []))),
None
)
variants = matched_item.get("variants") if matched_item else [variant]
entry = {
"field": field,
"status": status,
"variants": variants,
"id": cand_id,
"name": cand_name,
"extra": extra,
"candidates": amb_candidates,
"source": "deepseek-arbiter",
"adjudication_action": action,
"reasoning": dec.get("reasoning"),
}
if dec.get("inferred_category_slug"):
entry["inferred_category_slug"] = str(dec["inferred_category_slug"])
for v in variants:
results_map[normalize_text(v)] = entry
# Observability: matching.arbitration.result
await obs.event(trace_id, "matching.arbitration.result", ok=True, payload={
"latency_ms": latency_ms,
"usage": usage,
"actions": actions_count,
"statuses": statuses_count,
"n_items": len(items),
"n_resolved": len(results_map),
})
return results_map