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
+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