Files
ERP/bot/app/fallback_matcher.py
T
whqreza 9e848f535f feat(bot): add DeepSeek crisis fallback engine for Jev failure and remove difflib heuristics
- Add app/fallback_matcher.py for intelligent crisis entity matching with token and latency observability
- Route Jev failure/timeouts in app/matching.py to DeepSeek fallback engine
- Handle MatchingFailureError defensively in handlers/__init__.py with honest error message
- Enforce strict 'new' entity status in handlers/quote.py and implement defensive get_or_create on commit
- Clean up app/resolve.py to eliminate difflib and character ratio heuristics
- Pass all smoke tests (59/59), fallback matching tests (15/15), and matching unit tests (13/13)
2026-10-16 02:05:01 +03:30

273 lines
13 KiB
Python

"""Dedicated DeepSeek fallback matching engine for crisis mode.
Invoked when Jev (TypeSafe) fails, times out, returns 5xx/429, or is unavailable.
Takes:
1. raw_input: Raw user text or list of STT transcriptions.
2. mentions: Entity mentions extracted by DeepSeek NLU.
3. candidates_by_field: Catalog candidate records fetched from the database.
4. patch_context: Current draft patch / context.
5. trace_id: Observability trace ID.
Outputs:
Standard resolution map compatible with jev_map:
{
"<normalized_variant>": {
"field": str,
"status": "exact" | "ambiguity" | "new",
"variants": list[str],
"id": str | None,
"name": str | None,
"extra": dict,
"candidates": list[dict],
"inferred_category_slug"?: str,
}
}
Defensive Guarantee:
If DeepSeek call fails (HTTP error, timeout, or unparseable JSON),
raises MatchingFailureError so the bot halts gracefully with:
"⚠️ سامانه در پردازش هوشمند موجودیت‌ها با اختلال مواجه شد. لطفاً دوباره پیام را ارسال کنید."
"""
import hashlib
import json
import logging
import re
import time
import httpx
from app import observability as obs
from app.config import settings
from app.nlu import normalize_text
logger = logging.getLogger("bot.fallback_matcher")
class MatchingFailureError(Exception):
"""Raised when matching cannot proceed due to LLM / network failure."""
pass
FALLBACK_SYSTEM_PROMPT = """تو موتور تطبیق و پالایش هوشمند موجودیت‌های تدارکاتی یک سیستم ساختمانی و صنعتی ERP در ایران هستی.
سرویس جانبی تطبیق کاتالوگ در دسترس نیست و اکنون تو وظیفه داری با درک مهندسی، تجاری و معنایی خود، عبارات ذکرشده توسط کاربر را مستقیماً با رکوردهای کاتالوگ دیتابیس تطبیق دهی.
یک شیء JSON و فقط JSON برگردان (بدون هرگونه متن یا مارک‌داون اضافه) با ساختار زیر:
{
"results": [
{
"field": "supplier|material|project|unit|brand|spec",
"variants": ["تمام نگارش‌های این موجودیت"],
"status": "exact|ambiguity|new",
"id": "شناسه رکورد کاتالوگ منطبق یا null",
"name": "نام رسمی رکورد کاتالوگ منطبق یا null",
"candidates": [
{"id": "شناسه رکورد کاندیدا", "name": "نام رکورد کاندیدا", "prob": 0.0..1.0}
],
"inferred_category_slug": "فقط برای supplier جدید یکی از ۸ اسلاگ مجاز یا null"
}
]
}
قوانین حیاتی تطبیق:
۱. تطبیق قطعی (status: "exact"):
- اگر واریانت کاربر با یکی از رکوردهای کاتالوگ انطباق معنایی و تجاری روشن دارد (با احتساب خطاهای گفتاری STT، لهجه اصفهانی، املای ارقام فارسی/انگلیسی، پیشوند و پسوندهای کارگاهی مانند «آهن»، «بازرگانی»، «لوله»، «میلگرد»)، آن را انتخاب کن.
- فیلد id و name را با مقادیر دقیق موجود در کاتالوگ پر کن.
- فیلد candidates را خالی بگذار ([]).
۲. ابهام تجاری/ابعادی (status: "ambiguity"):
- اگر کاربر عنوان عامی را به کار برده که به چند رکورد مشخص در کاتالوگ قابل تفکیک است (مثلاً کاربر گفته «میلگرد» ولی در کاتالوگ انواع سایزهای ۱۴، ۱۶، ۱۸ داریم؛ یا کاربر نام خانوادگی تأمین‌کننده را گفته و چند تأمین‌کننده مشابه با این نام وجود دارند)، حتماً وضعیت را "ambiguity" بگذار.
- رکوردهای کاندیدای محتمل را در لیست candidates با نام، شناسه و تخمین احتمال (prob) قرار بده (حداکثر ۶ مورد).
۳. موجودیت جدید (status: "new"):
- قانون طلایی: اگر کالا، تأمین‌کننده یا پروژه در کاتالوگ ارسالی وجود ندارد، وضعیت را حتماً "new" بگذار!
- به هیچ عنوان و تحت هیچ شرایطی یک قلم جدید را به زور شباهت کلمات یا کاراکتر به یک قلم نامربوط در کاتالوگ نچسبان (مثلاً لوله ۶ اینچ را به لوله ۴ اینچ یا میلگرد ۲۰ را به میلگرد ۱۶ نچسبان).
- در حالت new، فیلد id باید null باشد.
۴. استنتاج خودکار دسته‌بندی برای تأمین‌کننده جدید (inferred_category_slug):
- اگر تأمین‌کننده‌ای وضعیتش new است، با توجه به اقلام استعلام‌شده در پیام کاربر، مناسب‌ترین اسلاگ دسته‌بندی را از بین این ۸ مورد انتخاب کن:
* metals-steel-sections (فلزات و مقاطع فولادی)
* mechanical-installations-pipes (تأسیسات مکانیکی و آبی)
* electrical-equipment-supplies (تجهیزات و لوازم برقی)
* construction-materials (مصالح ساختمانی)
* construction-chemicals (مواد شیمیایی ساختمانی)
* doors-windows-glass (درب، پنجره و شیشه)
* finishing-interior-decoration (نازک‌کاری و دکوراسیون داخلی)
* construction-tools-equipment (ابزار و تجهیزات کارگاهی)
"""
def _extract_json(raw: str) -> dict | None:
if not raw or not raw.strip():
return None
text = raw.strip()
match = re.search(r"\{.*\}", text, re.DOTALL)
if not match:
return None
try:
return json.loads(match.group(0))
except Exception:
return None
async def match_with_deepseek(
raw_input: object,
mentions: list[dict],
candidates_by_field: dict[str, list],
patch_context: dict | None,
trace_id: str | None,
) -> dict[str, dict]:
"""Execute DeepSeek crisis matching for unmapped mentions.
Returns a dict keyed by normalized variant, ready to merge into jev_map.
Raises MatchingFailureError if DeepSeek fails or returns invalid output.
"""
if not mentions:
return {}
if not settings.AI_BASE_URL or not settings.AI_API_KEY:
logger.error("fallback_matcher: AI provider not configured (AI_BASE_URL/AI_API_KEY missing)")
raise MatchingFailureError("AI provider not configured")
# Serialize candidates to clean dicts for prompt efficiency
catalog_summary: dict[str, list[dict]] = {}
for fld, cands in (candidates_by_field or {}).items():
field_cands = []
for c in cands:
item = {"id": str(getattr(c, "id", "")), "name": str(getattr(c, "name", ""))}
extra = getattr(c, "extra", {}) or {}
for k in ("category_name", "location", "canonical_unit_id", "code"):
if extra.get(k):
item[k] = extra[k]
field_cands.append(item)
catalog_summary[fld] = field_cands[:80] # token-safe ceiling per field
payload_data = {
"raw_user_input": raw_input,
"entity_mentions": mentions,
"catalog_candidates": catalog_summary,
"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": FALLBACK_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("fallback_matcher: HTTP %s from DeepSeek: %s", resp.status_code, resp.text[:300])
await obs.event(trace_id, "matching.fallback.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("results"), list):
logger.error("fallback_matcher: unparseable JSON from DeepSeek: %r", content[:300])
await obs.event(trace_id, "matching.fallback.failure", ok=False, payload={
"error": "unparseable_json",
"raw": content[:500],
})
raise MatchingFailureError("DeepSeek returned unparseable JSON")
except httpx.HTTPError as exc:
latency_ms = int((time.perf_counter() - t0) * 1000)
logger.exception("fallback_matcher: HTTP network error calling DeepSeek")
await obs.event(trace_id, "matching.fallback.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("fallback_matcher: Unexpected error")
await obs.event(trace_id, "matching.fallback.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] = {}
statuses_count = {"exact": 0, "ambiguity": 0, "new": 0}
# Build candidate lookup dict for candidate extras
cand_by_field_id: dict[tuple[str, str], object] = {}
for fld, c_list in (candidates_by_field or {}).items():
for c in c_list:
cid = str(getattr(c, "id", ""))
cand_by_field_id[(fld, cid)] = c
for item in parsed.get("results", []):
if not isinstance(item, dict):
continue
field = str(item.get("field") or "").strip()
variants = [str(v).strip() for v in (item.get("variants") or []) if str(v or "").strip()]
status = str(item.get("status") or "").strip().lower()
if status not in ("exact", "ambiguity", "new"):
status = "new"
statuses_count[status] = statuses_count.get(status, 0) + 1
cand_id = str(item.get("id")) if item.get("id") else None
cand_name = str(item.get("name")) if item.get("name") else None
cand_obj = cand_by_field_id.get((field, cand_id or "")) if cand_id else None
extra = getattr(cand_obj, "extra", {}) or {}
# Format candidates for ambiguity
amb_candidates = []
for c in item.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),
})
entry = {
"field": field,
"status": status,
"variants": variants,
"id": cand_id,
"name": cand_name,
"extra": extra,
"candidates": amb_candidates,
"source": "deepseek-fallback",
}
if item.get("inferred_category_slug"):
entry["inferred_category_slug"] = str(item["inferred_category_slug"])
for v in variants:
results_map[normalize_text(v)] = entry
# Observability: matching.fallback.result
await obs.event(trace_id, "matching.fallback.result", ok=True, payload={
"latency_ms": latency_ms,
"usage": usage,
"statuses": statuses_count,
"n_mentions": len(mentions),
"n_resolved": len(results_map),
})
return results_map