"""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: { "": { "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