feat(matching): implement two-stage matching with DeepSeek arbiter, fail-stop guard, and zero-probability filter
This commit is contained in:
+281
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user