feat(matching): implement two-stage matching with DeepSeek arbiter, fail-stop guard, and zero-probability filter
This commit is contained in:
+47
-33
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user