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:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user