feat(matching): implement two-stage matching with DeepSeek arbiter, fail-stop guard, and zero-probability filter
This commit is contained in:
@@ -116,7 +116,7 @@ async def test_jev_failure_triggers_fallback() -> None:
|
||||
matching.jev_ask = lambda state, qs: asyncio.sleep(0.001, result=None)
|
||||
|
||||
# Mock DeepSeek fallback matcher
|
||||
async def fake_fallback(raw_input, mentions, candidates_by_field, patch_context, trace_id):
|
||||
async def fake_fallback(*args, **kwargs):
|
||||
return {
|
||||
normalize_text("میلگرد ۱۴"): {
|
||||
"field": "material",
|
||||
@@ -181,12 +181,84 @@ async def test_jev_failure_triggers_fallback() -> None:
|
||||
matching.match_with_deepseek = orig_deepseek
|
||||
|
||||
|
||||
async def test_two_stage_adjudication_flow() -> None:
|
||||
"""2. When Jev succeeds, outputs are NOT directly committed; they are sent to DeepSeek Arbiter."""
|
||||
ctx = SimpleNamespace(resolver=FakeResolver())
|
||||
saved_key = settings.TYPESAFE_API_KEY
|
||||
settings.TYPESAFE_API_KEY = "test-typesafe-key"
|
||||
|
||||
orig_jev_ask = matching.jev_ask
|
||||
matching.jev_ask = lambda state, qs: asyncio.sleep(0.001, result={
|
||||
"answers": {
|
||||
"mention_0": {
|
||||
"choice": "میلگرد ۱۴ آجدار",
|
||||
"confidence": 0.95,
|
||||
"probabilities": {"میلگرد ۱۴ آجدار": 0.95, "تسمه فلزی": 0.0, "_none": 0.05},
|
||||
},
|
||||
"mention_1": {
|
||||
"choice": "لوله پیویسی ۴ اینچ",
|
||||
"confidence": 0.82,
|
||||
"probabilities": {"لوله پیویسی ۴ اینچ": 0.82, "لوله پلیاتیلن": 0.0, "_none": 0.18},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
arbiter_called_with = []
|
||||
|
||||
async def fake_arbiter(*args, **kwargs):
|
||||
arbiter_called_with.append((args, kwargs))
|
||||
items = kwargs.get("items") or (args[2] if len(args) > 2 else [])
|
||||
res = {}
|
||||
for it in items:
|
||||
v0 = it["variants"][0]
|
||||
if v0 == "میلگرد ۱۴":
|
||||
# Endorsed
|
||||
res[normalize_text(v0)] = {
|
||||
"field": it["field"], "status": "exact", "id": "m1", "name": "میلگرد ۱۴ آجدار",
|
||||
"variants": it["variants"], "source": "deepseek-arbiter", "adjudication_action": "endorsed_jev",
|
||||
}
|
||||
elif v0 == "لوله ۶ اینچ":
|
||||
# Vetoed Jev (Jev wrongly picked 4-inch pipe)
|
||||
res[normalize_text(v0)] = {
|
||||
"field": it["field"], "status": "new", "id": None, "name": None,
|
||||
"variants": it["variants"], "source": "deepseek-arbiter", "adjudication_action": "vetoed_to_new",
|
||||
}
|
||||
return res
|
||||
|
||||
orig_arbiter = matching.adjudicate_with_deepseek
|
||||
matching.adjudicate_with_deepseek = fake_arbiter
|
||||
|
||||
try:
|
||||
mentions = [
|
||||
{"field": "material", "variants": ["میلگرد ۱۴"]},
|
||||
{"field": "material", "variants": ["لوله ۶ اینچ"]},
|
||||
]
|
||||
res = await matching.resolve_mentions(
|
||||
ctx, {"items": []}, mentions, trace_id="t_arb",
|
||||
raw_input="استعلام میلگرد ۱۴ و لوله ۶ اینچ",
|
||||
)
|
||||
|
||||
check("Two-stage arbitration called when Jev succeeds", len(arbiter_called_with) == 1)
|
||||
passed_items = arbiter_called_with[0][1].get("items") or arbiter_called_with[0][0][2]
|
||||
m0_probs = passed_items[0]["jev_probabilities"]
|
||||
check("Zero probability items filtered out", "تسمه فلزی" not in m0_probs)
|
||||
check("Non-zero probability preserved exactly", m0_probs.get("میلگرد ۱۴ آجدار") == 0.95)
|
||||
check("_none with non-zero probability preserved", m0_probs.get("_none") == 0.05)
|
||||
check("Catalog candidates list complete and untouched", len(passed_items[0]["candidates"]) > 0)
|
||||
check("Arbiter endorsed valid Jev choice as exact", res[normalize_text("میلگرد ۱۴")]["status"] == "exact")
|
||||
check("Arbiter vetoed wrong Jev choice to new", res[normalize_text("لوله ۶ اینچ")]["status"] == "new" and res[normalize_text("لوله ۶ اینچ")]["id"] is None)
|
||||
finally:
|
||||
settings.TYPESAFE_API_KEY = saved_key
|
||||
matching.jev_ask = orig_jev_ask
|
||||
matching.adjudicate_with_deepseek = orig_arbiter
|
||||
|
||||
|
||||
async def test_defensive_failure_guard() -> None:
|
||||
"""2. If DeepSeek fallback fails or returns unparseable JSON, MatchingFailureError is raised."""
|
||||
"""3. Universal Fail-Stop Guard: If DeepSeek (fallback or arbiter or NLU) fails, MatchingFailureError is raised."""
|
||||
ctx = SimpleNamespace(resolver=FakeResolver())
|
||||
matching.jev_ask = lambda state, qs: asyncio.sleep(0.001, result=None)
|
||||
|
||||
async def boom_fallback(raw_input, mentions, candidates_by_field, patch_context, trace_id):
|
||||
async def boom_fallback(*args, **kwargs):
|
||||
raise MatchingFailureError("DeepSeek API 500 error")
|
||||
|
||||
orig_deepseek = matching.match_with_deepseek
|
||||
@@ -203,6 +275,27 @@ async def test_defensive_failure_guard() -> None:
|
||||
finally:
|
||||
matching.match_with_deepseek = orig_deepseek
|
||||
|
||||
# Also test Arbiter failure raises MatchingFailureError
|
||||
matching.jev_ask = lambda state, qs: asyncio.sleep(0.001, result={"answers": {"mention_0": {"choice": "m1", "confidence": 0.9}}})
|
||||
async def boom_arbiter(*args, **kwargs):
|
||||
raise MatchingFailureError("Arbiter DeepSeek timeout")
|
||||
|
||||
orig_arbiter = matching.adjudicate_with_deepseek
|
||||
matching.adjudicate_with_deepseek = boom_arbiter
|
||||
try:
|
||||
failed_arb = False
|
||||
try:
|
||||
await matching.resolve_mentions(ctx, {"items": []}, mentions, trace_id="t2_arb")
|
||||
except MatchingFailureError:
|
||||
failed_arb = True
|
||||
check("Arbiter failure raises MatchingFailureError (stops cleanly)", failed_arb)
|
||||
finally:
|
||||
matching.adjudicate_with_deepseek = orig_arbiter
|
||||
|
||||
# Test NLUFailureError is subclass of Exception
|
||||
from app.nlu import NLUFailureError
|
||||
check("NLUFailureError is subclass of Exception", issubclass(NLUFailureError, Exception))
|
||||
|
||||
|
||||
async def test_quote_resolve_payload_zero_difflib() -> None:
|
||||
"""3. resolve_payload in quote.py respects Jev/fallback map and never runs difflib."""
|
||||
@@ -292,6 +385,7 @@ async def main() -> None:
|
||||
fallback_matcher.obs.event = lambda *a, **k: asyncio.sleep(0.0001)
|
||||
try:
|
||||
await test_jev_failure_triggers_fallback()
|
||||
await test_two_stage_adjudication_flow()
|
||||
await test_defensive_failure_guard()
|
||||
await test_quote_resolve_payload_zero_difflib()
|
||||
await test_quote_commit_defensive_get_or_create()
|
||||
|
||||
@@ -81,6 +81,25 @@ async def mock_tests() -> None:
|
||||
for m in ms for v in m.get("variants", [])}
|
||||
matching.match_with_deepseek = mock_fallback_below_floor
|
||||
|
||||
orig_arbiter = matching.adjudicate_with_deepseek
|
||||
async def mock_arbiter(*args, **kwargs):
|
||||
items = kwargs.get("items") or (args[2] if len(args) > 2 else [])
|
||||
res = {}
|
||||
for it in items:
|
||||
choice = it.get("jev_choice")
|
||||
conf = it.get("jev_confidence", 0.0)
|
||||
if choice == "منشانه" and conf >= 0.8:
|
||||
entry = {"status": "exact", "id": "p2", "name": "منشانه", "field": it["field"], "variants": it["variants"]}
|
||||
elif choice == "دفتر" and conf >= 0.6:
|
||||
entry = {"status": "ambiguity", "id": "p1", "name": "دفتر", "field": it["field"], "variants": it["variants"],
|
||||
"candidates": [{"id": "p1", "name": "دفتر", "prob": conf}]}
|
||||
else:
|
||||
entry = {"status": "new", "id": None, "name": None, "field": it["field"], "variants": it["variants"], "candidates": []}
|
||||
for v in it["variants"]:
|
||||
res[normalize_text(v)] = entry
|
||||
return res
|
||||
matching.adjudicate_with_deepseek = mock_arbiter
|
||||
|
||||
mentions = [
|
||||
{"field": "project", "variants": ["منشیانه"]},
|
||||
{"field": "project", "variants": ["دفتر", "دفر"]},
|
||||
@@ -91,6 +110,7 @@ async def mock_tests() -> None:
|
||||
res = await matching.resolve_mentions(ctx, {"items": []}, mentions, None)
|
||||
finally:
|
||||
matching.match_with_deepseek = orig_fallback
|
||||
matching.adjudicate_with_deepseek = orig_arbiter
|
||||
|
||||
r0 = matching.lookup(res, "منشیانه")
|
||||
r1 = matching.lookup(res, "دفر") # variant key resolves too
|
||||
|
||||
Reference in New Issue
Block a user