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)
This commit is contained in:
2026-10-16 02:05:01 +03:30
parent 76b297f823
commit 9e848f535f
9 changed files with 870 additions and 124 deletions
+272
View File
@@ -0,0 +1,272 @@
"""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
+20 -6
View File
@@ -229,16 +229,30 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
await obs.event(trace_id, "entities.extracted",
payload={"patch": patch, "intent": intent, "entity_mentions": mentions})
# Phase 3: Jev per-field matching — only when it can be applied (flag-gated;
# a failure leaves jev_map empty and the difflib path runs unchanged).
# Entity matching (Jev primary, DeepSeek crisis fallback)
jev_map: dict = {}
if settings.JEV_ENABLED and mentions and intent in ("register_quote", "update_draft", "add_material"):
if mentions and intent in ("register_quote", "update_draft", "add_material"):
try:
from app import matching
jev_map = await matching.resolve_mentions(ctx, patch, mentions, trace_id)
jev_map = await matching.resolve_mentions(
ctx, patch, mentions, trace_id=trace_id, raw_input=input_payload
)
except matching.MatchingFailureError:
logger.error("entity matching failed completely (crisis fallback failed)")
await ctx.send(
chat_id,
"⚠️ سامانه در پردازش هوشمند موجودیت‌ها با اختلال مواجه شد. لطفاً دوباره پیام را ارسال کنید.",
reply_to=message_id,
)
return
except Exception:
logger.exception("jev matching failed")
jev_map = {}
logger.exception("unexpected matching failure")
await ctx.send(
chat_id,
"⚠️ سامانه در پردازش هوشمند موجودیت‌ها با اختلال مواجه شد. لطفاً دوباره پیام را ارسال کنید.",
reply_to=message_id,
)
return
if source == "voice":
await ctx._log(chat_id, "in", "voice", consensus_text)
+12 -9
View File
@@ -9,7 +9,7 @@ from app.drafts import (
KIND_MATERIAL, merge_material_patch, material_missing, new_material_payload,
)
from app.keyboards import pick_buttons, simple_review_keyboard
from app.resolve import safe_resolve
from app.nlu import normalize_text
from app.texts import ERR_GENERIC, esc, material_committed_report, material_review_text
logger = logging.getLogger("bot.material")
@@ -45,16 +45,19 @@ async def resolve_payload(ctx, payload: dict, trace_id: str | None = None,
elif hit["status"] == "ambiguity" and hit.get("candidates"):
options["unit"] = [{"id": c["id"], "name": c["name"]}
for c in hit["candidates"][:6]]
elif hit["status"] == "new":
options.pop("unit", None)
else:
res = await safe_resolve(ctx.resolver, "unit", payload["unit_name"])
await obs.log_resolution(trace_id, "unit", payload["unit_name"], res.status,
[{"id": c.id, "name": c.name} for c in (res.candidates or [])[:4]])
if res.resolved:
payload["canonical_unit_id"] = res.candidates[0].id
payload["unit_name"] = res.candidates[0].name
try:
units = await ctx.resolver.units()
except Exception:
units = []
norm_u = normalize_text(payload["unit_name"])
exact = [u for u in units if normalize_text(u.name) == norm_u or (u.extra or {}).get("code") == norm_u]
if len(exact) == 1:
payload["canonical_unit_id"] = exact[0].id
payload["unit_name"] = exact[0].name
options.pop("unit", None)
elif res.candidates:
options["unit"] = [{"id": c.id, "name": c.name} for c in res.candidates]
else:
options.pop("unit", None)
+154 -70
View File
@@ -10,6 +10,7 @@ from app.drafts import (
new_quote_payload, quote_ready,
)
from app.keyboards import quote_review_keyboard
from app.nlu import normalize_text
from app.resolve import safe_resolve
from app.texts import ERR_GENERIC, quote_committed_report, quote_review_text, esc
@@ -67,15 +68,9 @@ async def handle_entities(
# ---------------------------------------------------------------------------
# resolution
# resolution (pure intelligence; zero local difflib heuristics)
# ---------------------------------------------------------------------------
async def _log_res(trace_id, field: str, mention, res) -> None:
"""Observability: record one difflib resolution attempt (baseline for phase 6)."""
await obs.log_resolution(trace_id, field, mention, res.status,
[{"id": c.id, "name": c.name} for c in (res.candidates or [])[:4]])
async def resolve_payload(ctx, payload: dict, trace_id: str | None = None,
jev_map: dict | None = None) -> None:
options = payload.setdefault("options", {})
@@ -86,9 +81,14 @@ async def resolve_payload(ctx, payload: dict, trace_id: str | None = None,
if hit["status"] == "exact":
matching.apply_exact(payload, hit)
options.pop("sup", None)
elif hit["status"] == "ambiguity" and hit.get("candidates"):
options["sup"] = [{"id": c["id"], "name": c["name"]} for c in hit["candidates"][:6]]
elif hit["status"] == "new":
options.pop("sup", None)
if hit.get("inferred_category_slug"):
payload["supplier_inferred_category_slug"] = hit["inferred_category_slug"]
else:
res = await safe_resolve(ctx.resolver, "supplier", payload["supplier_name"])
await _log_res(trace_id, "supplier", payload["supplier_name"], res)
if res.resolved:
c = res.candidates[0]
payload["supplier_id"] = c.id
@@ -111,18 +111,25 @@ async def resolve_payload(ctx, payload: dict, trace_id: str | None = None,
if hit["status"] == "exact":
matching.apply_exact(payload, hit)
payload["project_name"] = hit["name"]
resolved_projects[hit["name"]] = hit["id"]
resolved_projects[payload["project_name"]] = hit["id"]
if hit["id"] not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(hit["id"])
options.pop("prj", None)
elif hit["status"] == "ambiguity" and hit.get("candidates"):
options["prj"] = [{"id": c["id"], "name": c["name"]} for c in hit["candidates"][:6]]
elif hit["status"] == "new":
options.pop("prj", None)
else:
res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
await _log_res(trace_id, "project", payload["project_name"], res)
if res.resolved:
c = res.candidates[0]
payload["project_id"] = c.id
payload["project_name"] = c.name
resolved_projects[c.name] = c.id
resolved_projects[payload["project_name"]] = c.id
if payload["project_id"] not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(payload["project_id"])
if c.id not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(c.id)
options.pop("prj", None)
elif res.candidates:
options["prj"] = [{"id": c.id, "name": c.name} for c in res.candidates]
@@ -131,21 +138,23 @@ async def resolve_payload(ctx, payload: dict, trace_id: str | None = None,
for p_name in payload.get("project_names") or []:
hit = matching.lookup(jev_map, p_name)
if hit is not None:
if hit["status"] == "exact":
matching.apply_exact(payload, hit)
continue
res = await safe_resolve(ctx.resolver, "project", p_name)
await _log_res(trace_id, "project", p_name, res)
if res.resolved:
c = res.candidates[0]
p_id = c.id
resolved_projects[c.name] = p_id
resolved_projects[p_name] = p_id
if p_id not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(p_id)
if hit is not None and hit.get("status") == "exact":
matching.apply_exact(payload, hit)
resolved_projects[hit["name"]] = hit["id"]
resolved_projects[p_name] = hit["id"]
if hit["id"] not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(hit["id"])
if not payload.get("project_id"):
payload["project_id"] = p_id
payload["project_id"] = hit["id"]
elif hit is None:
res = await safe_resolve(ctx.resolver, "project", p_name)
if res.resolved:
resolved_projects[p_name] = res.candidates[0].id
resolved_projects[res.candidates[0].name] = res.candidates[0].id
if res.candidates[0].id not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(res.candidates[0].id)
if not payload.get("project_id"):
payload["project_id"] = res.candidates[0].id
for i, item in enumerate(payload.get("items") or [], start=1):
if not item.get("material_id") and item.get("material_name"):
@@ -154,11 +163,15 @@ async def resolve_payload(ctx, payload: dict, trace_id: str | None = None,
if hit["status"] == "exact":
matching.apply_exact(payload, hit)
options.pop(f"mat{i}", None)
elif hit["status"] == "ambiguity" and hit.get("candidates"):
options[f"mat{i}"] = [{"id": c["id"], "name": c["name"]} for c in hit["candidates"][:6]]
elif hit["status"] == "new":
options.pop(f"mat{i}", None)
else:
res = await safe_resolve(ctx.resolver, "material", item["material_name"])
await _log_res(trace_id, "material", item["material_name"], res)
if res.resolved:
_set_material(item, res.candidates[0].id, res.candidates[0].name, res.candidates[0].extra)
c = res.candidates[0]
_set_material(item, c.id, c.name, c.extra)
options.pop(f"mat{i}", None)
elif res.candidates:
options[f"mat{i}"] = [{"id": c.id, "name": c.name} for c in res.candidates]
@@ -179,15 +192,13 @@ async def _resolve_unit(ctx, item: dict, trace_id: str | None = None,
return
if item.get("unit_name"):
hit = matching.lookup(jev_map, item["unit_name"])
if hit is not None:
if hit["status"] == "exact":
item["unit_id"] = hit["id"]
item["unit_explicit"] = True
item["unit_name"] = hit["name"]
return
else:
if hit is not None and hit.get("status") == "exact":
item["unit_id"] = hit["id"]
item["unit_explicit"] = True
item["unit_name"] = hit["name"]
return
if hit is None:
res = await safe_resolve(ctx.resolver, "unit", item["unit_name"])
await _log_res(trace_id, "unit", item["unit_name"], res)
if res.resolved:
item["unit_id"] = res.candidates[0].id
item["unit_explicit"] = True
@@ -369,46 +380,106 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False,
newly_created: dict = {}
# 1. Sequential pre-creation for new supplier if user wants to commit
# 1. Defensive pre-creation for new supplier (get_or_create)
if not payload.get("supplier_id") and payload.get("supplier_name"):
cat_id = payload.get("supplier_category_id")
if not cat_id:
cats = await ctx.resolver.categories()
if cats:
cat_id = cats[0].id
if cat_id:
try:
sup_body = {
"name": payload["supplier_name"],
"category_id": cat_id,
}
if payload.get("supplier_phone"):
sup_body["phone"] = payload["supplier_phone"]
created_sup = await ctx.api.create_supplier(sup_body, actor_id=user.actor_id)
payload["supplier_id"] = created_sup["id"]
newly_created["supplier"] = payload["supplier_name"]
ctx.resolver.invalidate("suppliers")
except Exception as e:
logger.warning("Could not auto-create supplier: %s", e)
sup_name = payload["supplier_name"].strip()
# 1.a) Check if already in DB
try:
existing_sups = await ctx.api.list_suppliers(q=sup_name, page_size=10)
for s in existing_sups:
if normalize_text(s.get("name") or "") == normalize_text(sup_name):
payload["supplier_id"] = str(s["id"])
payload["supplier_name"] = s.get("name")
break
except Exception as e:
logger.warning("Could not check existing suppliers: %s", e)
# 2. Sequential pre-creation for new materials
# 1.b) If still not in DB, create it
if not payload.get("supplier_id"):
cat_id = payload.get("supplier_category_id")
if not cat_id and payload.get("supplier_inferred_category_slug"):
inferred_slug = payload["supplier_inferred_category_slug"]
try:
cats = await ctx.resolver.categories()
for c in cats:
if (c.extra or {}).get("slug") == inferred_slug or c.name == inferred_slug:
cat_id = c.id
break
except Exception:
pass
if not cat_id:
try:
cats = await ctx.resolver.categories()
if cats:
cat_id = cats[0].id
except Exception:
pass
if cat_id:
try:
sup_body = {
"name": sup_name,
"category_id": cat_id,
}
if payload.get("supplier_phone"):
sup_body["phone"] = payload["supplier_phone"]
created_sup = await ctx.api.create_supplier(sup_body, actor_id=user.actor_id)
payload["supplier_id"] = created_sup["id"]
newly_created["supplier"] = sup_name
ctx.resolver.invalidate("suppliers")
except Exception as e:
logger.warning("Could not auto-create supplier: %s — trying fallback fetch", e)
try:
existing_sups = await ctx.api.list_suppliers(q=sup_name, page_size=10)
for s in existing_sups:
if normalize_text(s.get("name") or "") == normalize_text(sup_name):
payload["supplier_id"] = str(s["id"])
payload["supplier_name"] = s.get("name")
break
except Exception:
pass
# 2. Defensive pre-creation for new materials (get_or_create)
created_mats = []
for i, item in enumerate(payload.get("items") or [], start=1):
if not item.get("material_id") and item.get("material_name") and item.get("unit_id"):
mat_name = item["material_name"].strip()
# 2.a) Check if already in DB
try:
created_mat = await ctx.api.create_material({
"name": item["material_name"],
"canonical_unit_id": int(item["unit_id"]),
}, actor_id=user.actor_id)
_set_material(item, created_mat["id"], created_mat.get("name") or item["material_name"])
created_mats.append(item["material_name"])
ctx.resolver.invalidate("materials")
existing_mats = await ctx.api.list_materials(q=mat_name, page_size=10)
for m in existing_mats:
if normalize_text(m.get("name") or "") == normalize_text(mat_name):
_set_material(item, str(m["id"]), m.get("name") or mat_name,
{"canonical_unit_id": m.get("canonical_unit_id")})
break
except Exception as e:
logger.warning("Could not auto-create material: %s", e)
logger.warning("Could not check existing materials: %s", e)
# 2.b) If still not in DB, create it
if not item.get("material_id"):
try:
created_mat = await ctx.api.create_material({
"name": mat_name,
"canonical_unit_id": int(item["unit_id"]),
}, actor_id=user.actor_id)
_set_material(item, created_mat["id"], created_mat.get("name") or mat_name)
created_mats.append(mat_name)
ctx.resolver.invalidate("materials")
except Exception as e:
logger.warning("Could not auto-create material %s: %s — trying fallback fetch", mat_name, e)
try:
existing_mats = await ctx.api.list_materials(q=mat_name, page_size=10)
for m in existing_mats:
if normalize_text(m.get("name") or "") == normalize_text(mat_name):
_set_material(item, str(m["id"]), m.get("name") or mat_name,
{"canonical_unit_id": m.get("canonical_unit_id")})
break
except Exception:
pass
if created_mats:
newly_created["materials"] = created_mats
# 3. Sequential pre-creation for new projects
# 3. Defensive pre-creation for new projects (get_or_create)
created_prjs = []
all_p_names = []
if payload.get("project_names"):
@@ -429,14 +500,26 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False,
all_p_names.append(it_p_str)
resolved_map = payload.setdefault("resolved_projects", {})
all_known_prjs = None
for p_name in all_p_names:
p_id = resolved_map.get(p_name)
if not p_id:
res = await safe_resolve(ctx.resolver, "project", p_name)
if res.resolved:
p_id = res.candidates[0].id
resolved_map[p_name] = p_id
else:
# 3.a) Check if project already exists in catalog
if all_known_prjs is None:
try:
all_known_prjs = await ctx.resolver.projects()
except Exception:
all_known_prjs = []
norm_pn = normalize_text(p_name)
for prj in all_known_prjs:
if normalize_text(prj.name) == norm_pn:
p_id = prj.id
resolved_map[p_name] = p_id
resolved_map[prj.name] = p_id
break
# 3.b) If not in catalog, create it
if not p_id:
try:
created_prj = await ctx.api.create_project({"name": p_name}, actor_id=user.actor_id)
p_id = created_prj.get("id")
@@ -444,6 +527,7 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False,
resolved_map[p_name] = p_id
created_prjs.append(p_name)
ctx.resolver.invalidate("projects")
all_known_prjs = None
except Exception as e:
logger.warning("Could not auto-create project %s: %s", p_name, e)
+19 -10
View File
@@ -8,7 +8,7 @@ from app.drafts import (
KIND_SUPPLIER, merge_supplier_patch, new_supplier_payload, supplier_missing,
)
from app.keyboards import pick_buttons, simple_review_keyboard
from app.resolve import safe_resolve
from app.nlu import normalize_text
from app.texts import ERR_GENERIC, esc, supplier_committed_report, supplier_review_text
logger = logging.getLogger("bot.supplier")
@@ -33,17 +33,26 @@ async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None,
async def resolve_payload(ctx, payload: dict, trace_id: str | None = None) -> None:
options = payload.setdefault("options", {})
if not payload.get("category_id") and payload.get("category_name"):
res = await safe_resolve(ctx.resolver, "category", payload["category_name"])
await obs.log_resolution(trace_id, "category", payload["category_name"], res.status,
[{"id": c.id, "name": c.name} for c in (res.candidates or [])[:4]])
if res.resolved:
payload["category_id"] = res.candidates[0].id
payload["category_name"] = res.candidates[0].name
norm_cat = normalize_text(payload["category_name"])
try:
cats = await ctx.resolver.categories()
except Exception:
cats = []
exact = [c for c in cats if normalize_text(c.name) == norm_cat or (c.extra or {}).get("slug") == norm_cat]
if len(exact) == 1:
payload["category_id"] = exact[0].id
payload["category_name"] = exact[0].name
options.pop("cat", None)
elif res.candidates:
options["cat"] = [{"id": c.id, "name": c.name} for c in res.candidates]
else:
options.pop("cat", None)
partial = [c for c in cats if norm_cat in normalize_text(c.name) and len(norm_cat) >= 3]
if len(partial) == 1:
payload["category_id"] = partial[0].id
payload["category_name"] = partial[0].name
options.pop("cat", None)
elif partial:
options["cat"] = [{"id": c.id, "name": c.name} for c in partial]
else:
options.pop("cat", None)
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
+58 -14
View File
@@ -86,13 +86,23 @@ def _instructions(field: str, variants: list[str]) -> str:
)
# ---------------------------------------------------------------------------
from app.fallback_matcher import MatchingFailureError, match_with_deepseek
# ---------------------------------------------------------------------------
# the per-field fan-out
# ---------------------------------------------------------------------------
async def resolve_mentions(ctx, patch: dict, mentions: list[dict], trace_id) -> dict:
"""Run Jev per field (parallel); return a map keyed by normalized variant.
async def resolve_mentions(
ctx,
patch: dict,
mentions: list[dict],
trace_id: str | None = None,
raw_input: object = None,
) -> dict:
"""Run Jev per field; on failure, trigger DeepSeek Crisis Fallback.
Return a map keyed by normalized variant.
Each value: {"field", "status": exact|ambiguity|new", "variants",
"id"?, "name"?, "extra"?, "confidence"?, "candidates": [...]}
"""
@@ -107,13 +117,20 @@ async def resolve_mentions(ctx, patch: dict, mentions: list[dict], trace_id) ->
if not by_field:
return results
unhandled_mentions: list[dict] = []
unhandled_fields: set[str] = set()
async def one_field(field: str, ms: list[dict]) -> None:
try:
cands = await _candidates(ctx.resolver, field)
except Exception:
logger.exception("matching: candidate fetch failed for %s", field)
unhandled_mentions.extend(ms)
unhandled_fields.add(field)
return
if not cands:
unhandled_mentions.extend(ms)
unhandled_fields.add(field)
return
by_name: dict[str, object] = {}
@@ -148,7 +165,15 @@ async def resolve_mentions(ctx, patch: dict, mentions: list[dict], trace_id) ->
payload={"answers": (resp or {}).get("answers"),
"usage": (resp or {}).get("usage")})
if resp is None:
# Field-wide failure → leave no results so callers fall back to difflib.
# Observability: log jev.failure event
await obs.event(trace_id, "jev.failure", field=field, payload={
"error": "jev_unavailable_or_error",
"reason": "HTTP error or timeout or no response",
"model": settings.JEV_MODEL,
"n_mentions": len(capped),
})
unhandled_mentions.extend(capped)
unhandled_fields.add(field)
return
answers = resp.get("answers") or {}
@@ -156,7 +181,8 @@ async def resolve_mentions(ctx, patch: dict, mentions: list[dict], trace_id) ->
for i, m in enumerate(capped):
ans = answers.get(f"mention_{i}") or {}
if not ans:
# No answer for this mention → difflib fallback for it.
unhandled_mentions.append(m)
unhandled_fields.add(field)
continue
choice = ans.get("choice")
conf = float(ans.get("confidence") or 0.0)
@@ -180,17 +206,11 @@ async def resolve_mentions(ctx, patch: dict, mentions: list[dict], trace_id) ->
"extra": cand.extra or {},
"candidates": ranked or [{"id": cand.id, "name": cand.name, "prob": conf}]}
elif cand is None:
# Explicit «_none» (or an unusable choice): the model judged the whole
# list → treat as new (the auto-create path handles it).
res = {**base, "status": "new"}
else:
# Picked something but below the floor = low trust: leave NO result so
# the difflib fallback still gets its chance (suppressing it here would
# let a correct-but-under-confident pick create duplicates instead).
await obs.event(trace_id, "routing.decision", field=field, payload={
"source": "jev-lowtrust", "variants": m["variants"],
"choice": cand.name, "confidence": conf,
})
# Below-floor pick: route to fallback matcher
unhandled_mentions.append(m)
unhandled_fields.add(field)
continue
for v in m["variants"]:
results[normalize_text(v)] = res
@@ -201,7 +221,31 @@ async def resolve_mentions(ctx, patch: dict, mentions: list[dict], trace_id) ->
await obs.event(trace_id, "routing.decision", field=field,
payload={"source": "jev", "mentions": decisions})
await asyncio.gather(*(one_field(f, ms) for f, ms in by_field.items()))
if settings.JEV_ENABLED:
await asyncio.gather(*(one_field(f, ms) for f, ms in by_field.items()))
else:
# Jev disabled -> all mentions route to DeepSeek fallback
for f, ms in by_field.items():
unhandled_mentions.extend(ms)
unhandled_fields.add(f)
# If any mentions were unhandled / failed in Jev, run DeepSeek Crisis Fallback
if unhandled_mentions:
cands_by_field = {}
for f in unhandled_fields:
try:
cands_by_field[f] = await _candidates(ctx.resolver, f)
except Exception:
cands_by_field[f] = []
fallback_res = await match_with_deepseek(
raw_input=raw_input,
mentions=unhandled_mentions,
candidates_by_field=cands_by_field,
patch_context=patch,
trace_id=trace_id,
)
results.update(fallback_res)
return results
+1 -9
View File
@@ -3,7 +3,6 @@
The ERP search endpoints are ilike-based; we fetch candidates with `q=` and
rank them with difflib so "میلگرد A3 14" still matches "میلگرد A3 سایز ۱۴".
"""
import difflib
import time
from dataclasses import dataclass, field
@@ -44,7 +43,7 @@ def _rank(query: str, items: list[Candidate], limit: int = 6) -> list[Candidate]
elif name in q and len(name) >= 3:
ratio = 0.85 + 0.15 * (len(name) / len(q))
else:
ratio = difflib.SequenceMatcher(None, q, name).ratio()
ratio = 0.0
if ratio >= 0.72:
scored.append((ratio, item))
scored.sort(key=lambda pair: (-pair[0], pair[1].name))
@@ -365,13 +364,6 @@ class Resolver:
exact = [c for c in candidates if normalize_text(c.name) == norm_name]
if len(exact) == 1:
return Resolution("one", exact)
top = candidates[0]
top_norm = normalize_text(top.name)
ratio = difflib.SequenceMatcher(None, norm_name, top_norm).ratio()
if (norm_name in top_norm or top_norm in norm_name) and len(norm_name) >= 3:
ratio = max(ratio, 0.90)
if ratio >= 0.88 and (len(candidates) == 1 or ratio > 0.95):
return Resolution("one", [top])
return Resolution("many", candidates)
async def unit_for_material(self, unit_name: str | None, material: dict | None) -> tuple[str | None, bool]:
+309
View File
@@ -0,0 +1,309 @@
"""Unit and integration tests for DeepSeek crisis fallback matching.
Covers:
1. Jev failure -> jev.failure observability event + DeepSeek fallback invocation.
2. DeepSeek fallback results mapping: exact, ambiguity, new (with inferred category).
3. Defensive failure guard: DeepSeek network/HTTP/JSON failure raises MatchingFailureError.
4. quote.py resolve_payload: respects new, binds exact, populates ambiguity, zero difflib.
5. quote.py commit: defensive get_or_create pattern prevents 409 duplicate errors.
"""
import asyncio
import json
import sys
from pathlib import Path
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app import fallback_matcher, matching
from app.config import settings
from app.fallback_matcher import MatchingFailureError
from app.handlers import quote
from app.nlu import normalize_text
from app.resolve import Candidate
PASSED: list[str] = []
FAILED: list[str] = []
def check(name: str, ok: bool, extra: str = "") -> None:
(PASSED if ok else FAILED).append(name)
print(f"{'PASS' if ok else 'FAIL'} {name}{' — ' + str(extra) if extra else ''}")
class FakeResolver:
async def projects(self):
return [Candidate(id="p1", name="پروژه پردیس"), Candidate(id="p2", name="برج نگین")]
async def all_suppliers(self):
return [Candidate(
id="s1",
name="فولاد خوزستان",
extra={"location": "اهواز", "category_name": "فلزات و مقاطع فولادی", "slug": "metals-steel-sections"}
)]
async def all_materials(self):
return [
Candidate(id="m1", name="میلگرد ۱۴ آجدار", extra={"canonical_unit_id": 1}),
Candidate(id="m2", name="لوله پی‌وی‌سی ۴ اینچ", extra={"canonical_unit_id": 3}),
]
async def categories(self):
return [
Candidate(id="cat1", name="فلزات و مقاطع فولادی", extra={"slug": "metals-steel-sections"}),
Candidate(id="cat2", name="تأسیسات مکانیکی و آبی", extra={"slug": "mechanical-installations-pipes"}),
]
async def units(self):
return [Candidate(id="u1", name="شاخه", extra={"code": "branch"}), Candidate(id="u2", name="کیلوگرم", extra={"code": "kg"})]
def invalidate(self, key=None):
pass
class FakeApi:
def __init__(self):
self.created_materials = []
self.created_suppliers = []
self.created_projects = []
async def list_materials(self, q=None, page_size=20, page=1):
if q and "میلگرد ۱۴" in q:
return [{"id": "m1", "name": "میلگرد ۱۴ آجدار", "canonical_unit_id": 1}]
return []
async def create_material(self, body, actor_id=None):
if body["name"] == "میلگرد ۱۴ آجدار":
raise Exception("Conflict 409")
item = {"id": f"m_new_{len(self.created_materials)+1}", **body}
self.created_materials.append(item)
return item
async def list_suppliers(self, q=None, page_size=20, page=1):
if q and "فولاد خوزستان" in q:
return [{"id": "s1", "name": "فولاد خوزستان"}]
return []
async def create_supplier(self, body, actor_id=None):
item = {"id": f"s_new_{len(self.created_suppliers)+1}", **body}
self.created_suppliers.append(item)
return item
async def list_projects(self, page_size=100):
return [{"id": "p1", "name": "پروژه پردیس"}]
async def create_project(self, body, actor_id=None):
item = {"id": f"p_new_{len(self.created_projects)+1}", **body}
self.created_projects.append(item)
return item
async def test_jev_failure_triggers_fallback() -> None:
"""1. When Jev returns None (HTTP 500/timeout), DeepSeek fallback is triggered."""
ctx = SimpleNamespace(resolver=FakeResolver())
events_logged = []
async def fake_event(trace_id, event_type, ok=True, **kwargs):
events_logged.append((event_type, kwargs))
orig_event = matching.obs.event
matching.obs.event = fake_event
saved_key = settings.TYPESAFE_API_KEY
settings.TYPESAFE_API_KEY = "test-typesafe-key"
orig_jev_ask = matching.jev_ask
matching.jev_ask = lambda state, qs: asyncio.sleep(0.001, result=None)
# Mock DeepSeek fallback matcher
async def fake_fallback(raw_input, mentions, candidates_by_field, patch_context, trace_id):
return {
normalize_text("میلگرد ۱۴"): {
"field": "material",
"status": "exact",
"id": "m1",
"name": "میلگرد ۱۴ آجدار",
"variants": ["میلگرد ۱۴"],
"candidates": [],
},
normalize_text("لوله ۶ اینچ"): {
"field": "material",
"status": "new",
"id": None,
"name": None,
"variants": ["لوله ۶ اینچ"],
"candidates": [],
},
normalize_text("آهن"): {
"field": "material",
"status": "ambiguity",
"id": None,
"name": None,
"variants": ["آهن"],
"candidates": [{"id": "m1", "name": "میلگرد ۱۴ آجدار", "prob": 0.8}],
},
normalize_text("تأمین‌کننده البرز"): {
"field": "supplier",
"status": "new",
"id": None,
"name": None,
"variants": ["تأمین‌کننده البرز"],
"candidates": [],
"inferred_category_slug": "metals-steel-sections",
},
}
orig_deepseek = matching.match_with_deepseek
matching.match_with_deepseek = fake_fallback
try:
mentions = [
{"field": "material", "variants": ["میلگرد ۱۴"]},
{"field": "material", "variants": ["لوله ۶ اینچ"]},
{"field": "material", "variants": ["آهن"]},
{"field": "supplier", "variants": ["تأمین‌کننده البرز"]},
]
res = await matching.resolve_mentions(ctx, {"items": []}, mentions, trace_id="t1", raw_input="تست")
check("Jev failure recorded in observability", any(e[0] == "jev.failure" for e in events_logged))
check("Exact match resolved via fallback", res[normalize_text("میلگرد ۱۴")]["status"] == "exact")
check("New item strictly preserved as new (no difflib forced match)",
res[normalize_text("لوله ۶ اینچ")]["status"] == "new" and res[normalize_text("لوله ۶ اینچ")]["id"] is None)
check("Ambiguity item has candidates",
res[normalize_text("آهن")]["status"] == "ambiguity" and len(res[normalize_text("آهن")]["candidates"]) == 1)
check("New supplier has inferred_category_slug",
res[normalize_text("تأمین‌کننده البرز")]["inferred_category_slug"] == "metals-steel-sections")
finally:
settings.TYPESAFE_API_KEY = saved_key
matching.obs.event = orig_event
matching.jev_ask = orig_jev_ask
matching.match_with_deepseek = orig_deepseek
async def test_defensive_failure_guard() -> None:
"""2. If DeepSeek fallback fails or returns unparseable JSON, MatchingFailureError is raised."""
ctx = SimpleNamespace(resolver=FakeResolver())
matching.jev_ask = lambda state, qs: asyncio.sleep(0.001, result=None)
async def boom_fallback(raw_input, mentions, candidates_by_field, patch_context, trace_id):
raise MatchingFailureError("DeepSeek API 500 error")
orig_deepseek = matching.match_with_deepseek
matching.match_with_deepseek = boom_fallback
try:
mentions = [{"field": "material", "variants": ["سیمان"]}]
failed = False
try:
await matching.resolve_mentions(ctx, {"items": []}, mentions, trace_id="t2")
except MatchingFailureError:
failed = True
check("Crisis fallback failure raises MatchingFailureError (stops cleanly)", failed)
finally:
matching.match_with_deepseek = orig_deepseek
async def test_quote_resolve_payload_zero_difflib() -> None:
"""3. resolve_payload in quote.py respects Jev/fallback map and never runs difflib."""
ctx = SimpleNamespace(resolver=FakeResolver())
payload = {
"supplier_name": "تأمین‌کننده البرز",
"items": [
{"material_name": "لوله ۶ اینچ", "unit_name": "شاخه"},
{"material_name": "میلگرد ۱۴", "unit_name": "شاخه"},
],
}
jev_map = {
normalize_text("تأمین‌کننده البرز"): {
"field": "supplier", "status": "new", "variants": ["تأمین‌کننده البرز"],
"inferred_category_slug": "metals-steel-sections",
},
normalize_text("لوله ۶ اینچ"): {
"field": "material", "status": "new", "variants": ["لوله ۶ اینچ"],
},
normalize_text("میلگرد ۱۴"): {
"field": "material", "status": "exact", "id": "m1", "name": "میلگرد ۱۴ آجدار",
"variants": ["میلگرد ۱۴"],
},
normalize_text("شاخه"): {
"field": "unit", "status": "exact", "id": "u1", "name": "شاخه", "variants": ["شاخه"],
},
}
await quote.resolve_payload(ctx, payload, trace_id="t3", jev_map=jev_map)
check("New supplier kept id=None (no difflib forced match)", payload.get("supplier_id") is None)
check("New supplier inferred category saved", payload.get("supplier_inferred_category_slug") == "metals-steel-sections")
check("New material kept id=None (no difflib forced match to 4-inch pipe)", payload["items"][0].get("material_id") is None)
check("Exact material bound to catalog ID", payload["items"][1].get("material_id") == "m1")
check("Exact unit bound to catalog ID", payload["items"][0].get("unit_id") == "u1")
async def test_quote_commit_defensive_get_or_create() -> None:
"""4. commit in quote.py uses get_or_create to prevent 409 conflict errors."""
api = FakeApi()
resolver = FakeResolver()
ctx = SimpleNamespace(api=api, resolver=resolver, pending={},
send=lambda *a, **k: asyncio.sleep(0.0001))
user = SimpleNamespace(actor_id="usr_1")
# Mock obs calls so offline test doesn't require live DB connection
orig_latest = quote.obs.latest_trace_for_draft
orig_link = quote.obs.link_draft
orig_event = quote.obs.event
quote.obs.latest_trace_for_draft = lambda did: asyncio.sleep(0.0001, result="mock_trace")
quote.obs.link_draft = lambda *a, **k: asyncio.sleep(0.0001)
quote.obs.event = lambda *a, **k: asyncio.sleep(0.0001)
# Item with name "میلگرد ۱۴ آجدار" that already exists in DB
payload = {
"supplier_name": "فولاد خوزستان", # Already in DB
"supplier_inferred_category_slug": "metals-steel-sections",
"items": [
{"material_name": "میلگرد ۱۴ آجدار", "unit_id": 1, "quantity": 10, "unit_price": 25000},
{"material_name": "تیرآهن ۱۸", "unit_id": 1, "quantity": 5, "unit_price": 50000}, # Brand new
],
"project_name": "پروژه پردیس", # Already in DB
}
draft = SimpleNamespace(id="d1", payload=payload, review_message_id=None)
# Mock create_quote
api.create_quote = lambda body, actor_id: asyncio.sleep(0.001, result={"id": "q1"})
# Mock draft_store.set_status
quote.draft_store.set_status = lambda did, st: asyncio.sleep(0.001)
try:
await quote.commit(ctx, chat_id=123, user=user, draft=draft, only_resolved=False)
check("Existing supplier reused ID from DB (not recreated)", payload["supplier_id"] == "s1")
check("Existing material reused ID from DB on conflict (not crashing)", payload["items"][0]["material_id"] == "m1")
check("New material created cleanly", payload["items"][1]["material_id"] == "m_new_1")
check("Existing project reused ID from DB", payload["project_id"] == "p1")
finally:
quote.obs.latest_trace_for_draft = orig_latest
quote.obs.link_draft = orig_link
quote.obs.event = orig_event
async def main() -> None:
print("Running DeepSeek Crisis Fallback & Zero-Difflib Tests...\n")
orig_fb_event = fallback_matcher.obs.event
fallback_matcher.obs.event = lambda *a, **k: asyncio.sleep(0.0001)
try:
await test_jev_failure_triggers_fallback()
await test_defensive_failure_guard()
await test_quote_resolve_payload_zero_difflib()
await test_quote_commit_defensive_get_or_create()
finally:
fallback_matcher.obs.event = orig_fb_event
total = len(PASSED) + len(FAILED)
print(f"\n{len(PASSED)}/{total} passed")
if FAILED:
print("FAILED:", FAILED)
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())
+25 -6
View File
@@ -74,13 +74,24 @@ async def mock_tests() -> None:
"mention_3": {"choice": "دفتر", "confidence": 0.5,
"probabilities": {"دفتر": 0.52, "_none": 0.48}},
})
orig_fallback = matching.match_with_deepseek
async def mock_fallback_below_floor(*args, **kwargs):
ms = kwargs.get("mentions") or (args[1] if len(args) > 1 else [])
return {normalize_text(v): {"status": "new", "field": m.get("field"), "variants": m.get("variants"), "id": None}
for m in ms for v in m.get("variants", [])}
matching.match_with_deepseek = mock_fallback_below_floor
mentions = [
{"field": "project", "variants": ["منشیانه"]},
{"field": "project", "variants": ["دفتر", "دفر"]},
{"field": "project", "variants": ["چیز عجیب"]},
{"field": "project", "variants": ["دفر دو"]},
]
res = await matching.resolve_mentions(ctx, {"items": []}, mentions, None)
try:
res = await matching.resolve_mentions(ctx, {"items": []}, mentions, None)
finally:
matching.match_with_deepseek = orig_fallback
r0 = matching.lookup(res, "منشیانه")
r1 = matching.lookup(res, "دفر") # variant key resolves too
r2 = matching.lookup(res, "چیز عجیب")
@@ -90,15 +101,23 @@ async def mock_tests() -> None:
check("variant of same mention shares one result", r1 is r0 if False else
(r1 and matching.lookup(res, "دفتر") is r1))
check("_none / low conf -> new", r2 and r2["status"] == "new")
check("below-floor pick -> NO result (difflib fallback keeps its chance)",
matching.lookup(res, "دفر دو") is None)
check("below-floor pick -> routed to crisis fallback",
matching.lookup(res, "دفر دو") and matching.lookup(res, "دفر دو")["status"] == "new")
check("below-floor pick did not pollute other mentions", r1 and r1["status"] == "ambiguity")
# 2) Jev failure -> empty map (callers fall back to difflib)
# 2) Jev failure -> DeepSeek crisis fallback
matching.jev_ask = make_fake_ask({})
matching.jev_ask.fail = True
res_fail = await matching.resolve_mentions(ctx, {"items": []}, mentions, None)
check("jev failure -> empty results (difflib fallback path)", res_fail == {})
async def mock_deepseek(*args, **kwargs):
return {normalize_text("منشیانه"): {"status": "exact", "id": "p2", "name": "منشانه",
"field": "project", "variants": ["منشیانه"]}}
matching.match_with_deepseek = mock_deepseek
try:
res_fail = await matching.resolve_mentions(ctx, {"items": []}, mentions, None)
check("jev failure -> triggers crisis fallback",
normalize_text("منشیانه") in res_fail and res_fail[normalize_text("منشیانه")]["status"] == "exact")
finally:
matching.match_with_deepseek = orig_fallback
# 3) apply_exact writes supplier/project ids into a payload
payload = {"items": []}