feat(matching): implement two-stage matching with DeepSeek arbiter, fail-stop guard, and zero-probability filter

This commit is contained in:
2026-10-16 03:13:21 +03:30
parent 9e848f535f
commit 787824a19e
6 changed files with 475 additions and 48 deletions
+47 -33
View File
@@ -87,7 +87,7 @@ def _instructions(field: str, variants: list[str]) -> str:
# ---------------------------------------------------------------------------
from app.fallback_matcher import MatchingFailureError, match_with_deepseek
from app.fallback_matcher import MatchingFailureError, match_with_deepseek, adjudicate_with_deepseek
# ---------------------------------------------------------------------------
# the per-field fan-out
@@ -99,8 +99,9 @@ async def resolve_mentions(
mentions: list[dict],
trace_id: str | None = None,
raw_input: object = None,
consensus_text: str | None = None,
) -> dict:
"""Run Jev per field; on failure, trigger DeepSeek Crisis Fallback.
"""Run Two-Stage Matching: Jev scorer + DeepSeek final arbiter; DeepSeek crisis fallback if Jev down.
Return a map keyed by normalized variant.
Each value: {"field", "status": exact|ambiguity|new", "variants",
@@ -119,6 +120,8 @@ async def resolve_mentions(
unhandled_mentions: list[dict] = []
unhandled_fields: set[str] = set()
jev_arbitration_items: list[dict] = []
cands_by_field: dict[str, list] = {}
async def one_field(field: str, ms: list[dict]) -> None:
try:
@@ -133,6 +136,8 @@ async def resolve_mentions(
unhandled_fields.add(field)
return
cands_by_field[field] = cands
by_name: dict[str, object] = {}
criteria: dict[str, str] = {}
for c in cands:
@@ -187,39 +192,36 @@ async def resolve_mentions(
choice = ans.get("choice")
conf = float(ans.get("confidence") or 0.0)
probs = ans.get("probabilities") or {}
non_zero_probs = {k: v for k, v in probs.items() if float(v or 0.0) > 0.0}
cand = by_name.get(choice) if choice else None
ranked = sorted(
({"id": by_name[n].id, "name": n, "prob": float(p or 0.0)}
for n, p in probs.items() if n != "_none" and n in by_name),
for n, p in non_zero_probs.items() if n != "_none" and n in by_name),
key=lambda x: -x["prob"],
)[:6]
base = {"field": field, "variants": m["variants"], "confidence": conf,
"choice": (cand.name if cand is not None else None),
"candidates": ranked}
# Collect for DeepSeek final arbitration (never write directly to results)
jev_arbitration_items.append({
"field": field,
"variants": m["variants"],
"candidates": cands,
"jev_choice": cand.name if cand else ("_none" if choice == "_none" else None),
"jev_choice_id": cand.id if cand else None,
"jev_confidence": conf,
"jev_probabilities": non_zero_probs,
"jev_ranked": ranked,
})
decisions.append({
"variants": m["variants"],
"jev_choice": cand.name if cand else choice,
"confidence": conf,
"probs_top": [{k: round(v, 3)} for k, v in
sorted(probs.items(), key=lambda kv: -kv[1])[:3]],
})
if cand is not None and conf >= settings.JEV_CONF_EXACT:
res = {**base, "status": "exact", "id": cand.id, "name": cand.name,
"extra": cand.extra or {}}
elif cand is not None and conf >= settings.JEV_CONF_FLOOR:
res = {**base, "status": "ambiguity", "id": cand.id, "name": cand.name,
"extra": cand.extra or {},
"candidates": ranked or [{"id": cand.id, "name": cand.name, "prob": conf}]}
elif cand is None:
res = {**base, "status": "new"}
else:
# 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
decisions.append({"variants": m["variants"], "status": res["status"],
"choice": res.get("name"), "confidence": conf,
"probs_top": [{k: round(v, 3)} for k, v in
sorted(probs.items(), key=lambda kv: -kv[1])[:3]]})
await obs.event(trace_id, "routing.decision", field=field,
payload={"source": "jev", "mentions": decisions})
payload={"source": "jev_scored", "mentions": decisions})
if settings.JEV_ENABLED:
await asyncio.gather(*(one_field(f, ms) for f, ms in by_field.items()))
@@ -229,20 +231,32 @@ async def resolve_mentions(
unhandled_mentions.extend(ms)
unhandled_fields.add(f)
# If any mentions were unhandled / failed in Jev, run DeepSeek Crisis Fallback
# 1. Final Arbitration: Pass Jev recommendations + raw user input + DB catalog to DeepSeek
if jev_arbitration_items:
adjudicated_res = await adjudicate_with_deepseek(
raw_input=raw_input,
consensus_text=consensus_text,
items=jev_arbitration_items,
patch_context=patch,
trace_id=trace_id,
)
results.update(adjudicated_res)
# 2. Crisis Fallback: 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] = []
if f not in cands_by_field:
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,
consensus_text=consensus_text,
)
results.update(fallback_res)