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