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