"""Unit and integration tests for DeepSeek crisis fallback matching. Covers: 1. Jev failure -> jev.failure observability event + DeepSeek fallback invocation. 2. DeepSeek fallback results mapping: exact, ambiguity, new (with inferred category). 3. Defensive failure guard: DeepSeek network/HTTP/JSON failure raises MatchingFailureError. 4. quote.py resolve_payload: respects new, binds exact, populates ambiguity, zero difflib. 5. quote.py commit: defensive get_or_create pattern prevents 409 duplicate errors. """ import asyncio import json import sys from pathlib import Path from types import SimpleNamespace sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app import fallback_matcher, matching from app.config import settings from app.fallback_matcher import MatchingFailureError from app.handlers import quote from app.nlu import normalize_text from app.resolve import Candidate PASSED: list[str] = [] FAILED: list[str] = [] def check(name: str, ok: bool, extra: str = "") -> None: (PASSED if ok else FAILED).append(name) print(f"{'PASS' if ok else 'FAIL'} {name}{' — ' + str(extra) if extra else ''}") class FakeResolver: async def projects(self): return [Candidate(id="p1", name="پروژه پردیس"), Candidate(id="p2", name="برج نگین")] async def all_suppliers(self): return [Candidate( id="s1", name="فولاد خوزستان", extra={"location": "اهواز", "category_name": "فلزات و مقاطع فولادی", "slug": "metals-steel-sections"} )] async def all_materials(self): return [ Candidate(id="m1", name="میلگرد ۱۴ آجدار", extra={"canonical_unit_id": 1}), Candidate(id="m2", name="لوله پی‌وی‌سی ۴ اینچ", extra={"canonical_unit_id": 3}), ] async def categories(self): return [ Candidate(id="cat1", name="فلزات و مقاطع فولادی", extra={"slug": "metals-steel-sections"}), Candidate(id="cat2", name="تأسیسات مکانیکی و آبی", extra={"slug": "mechanical-installations-pipes"}), ] async def units(self): return [Candidate(id="u1", name="شاخه", extra={"code": "branch"}), Candidate(id="u2", name="کیلوگرم", extra={"code": "kg"})] def invalidate(self, key=None): pass class FakeApi: def __init__(self): self.created_materials = [] self.created_suppliers = [] self.created_projects = [] async def list_materials(self, q=None, page_size=20, page=1): if q and "میلگرد ۱۴" in q: return [{"id": "m1", "name": "میلگرد ۱۴ آجدار", "canonical_unit_id": 1}] return [] async def create_material(self, body, actor_id=None): if body["name"] == "میلگرد ۱۴ آجدار": raise Exception("Conflict 409") item = {"id": f"m_new_{len(self.created_materials)+1}", **body} self.created_materials.append(item) return item async def list_suppliers(self, q=None, page_size=20, page=1): if q and "فولاد خوزستان" in q: return [{"id": "s1", "name": "فولاد خوزستان"}] return [] async def create_supplier(self, body, actor_id=None): item = {"id": f"s_new_{len(self.created_suppliers)+1}", **body} self.created_suppliers.append(item) return item async def list_projects(self, page_size=100): return [{"id": "p1", "name": "پروژه پردیس"}] async def create_project(self, body, actor_id=None): item = {"id": f"p_new_{len(self.created_projects)+1}", **body} self.created_projects.append(item) return item async def test_jev_failure_triggers_fallback() -> None: """1. When Jev returns None (HTTP 500/timeout), DeepSeek fallback is triggered.""" ctx = SimpleNamespace(resolver=FakeResolver()) events_logged = [] async def fake_event(trace_id, event_type, ok=True, **kwargs): events_logged.append((event_type, kwargs)) orig_event = matching.obs.event matching.obs.event = fake_event 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=None) # Mock DeepSeek fallback matcher async def fake_fallback(raw_input, mentions, candidates_by_field, patch_context, trace_id): return { normalize_text("میلگرد ۱۴"): { "field": "material", "status": "exact", "id": "m1", "name": "میلگرد ۱۴ آجدار", "variants": ["میلگرد ۱۴"], "candidates": [], }, normalize_text("لوله ۶ اینچ"): { "field": "material", "status": "new", "id": None, "name": None, "variants": ["لوله ۶ اینچ"], "candidates": [], }, normalize_text("آهن"): { "field": "material", "status": "ambiguity", "id": None, "name": None, "variants": ["آهن"], "candidates": [{"id": "m1", "name": "میلگرد ۱۴ آجدار", "prob": 0.8}], }, normalize_text("تأمین‌کننده البرز"): { "field": "supplier", "status": "new", "id": None, "name": None, "variants": ["تأمین‌کننده البرز"], "candidates": [], "inferred_category_slug": "metals-steel-sections", }, } orig_deepseek = matching.match_with_deepseek matching.match_with_deepseek = fake_fallback try: mentions = [ {"field": "material", "variants": ["میلگرد ۱۴"]}, {"field": "material", "variants": ["لوله ۶ اینچ"]}, {"field": "material", "variants": ["آهن"]}, {"field": "supplier", "variants": ["تأمین‌کننده البرز"]}, ] res = await matching.resolve_mentions(ctx, {"items": []}, mentions, trace_id="t1", raw_input="تست") check("Jev failure recorded in observability", any(e[0] == "jev.failure" for e in events_logged)) check("Exact match resolved via fallback", res[normalize_text("میلگرد ۱۴")]["status"] == "exact") check("New item strictly preserved as new (no difflib forced match)", res[normalize_text("لوله ۶ اینچ")]["status"] == "new" and res[normalize_text("لوله ۶ اینچ")]["id"] is None) check("Ambiguity item has candidates", res[normalize_text("آهن")]["status"] == "ambiguity" and len(res[normalize_text("آهن")]["candidates"]) == 1) check("New supplier has inferred_category_slug", res[normalize_text("تأمین‌کننده البرز")]["inferred_category_slug"] == "metals-steel-sections") finally: settings.TYPESAFE_API_KEY = saved_key matching.obs.event = orig_event matching.jev_ask = orig_jev_ask matching.match_with_deepseek = orig_deepseek async def test_defensive_failure_guard() -> None: """2. If DeepSeek fallback fails or returns unparseable JSON, 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): raise MatchingFailureError("DeepSeek API 500 error") orig_deepseek = matching.match_with_deepseek matching.match_with_deepseek = boom_fallback try: mentions = [{"field": "material", "variants": ["سیمان"]}] failed = False try: await matching.resolve_mentions(ctx, {"items": []}, mentions, trace_id="t2") except MatchingFailureError: failed = True check("Crisis fallback failure raises MatchingFailureError (stops cleanly)", failed) finally: matching.match_with_deepseek = orig_deepseek async def test_quote_resolve_payload_zero_difflib() -> None: """3. resolve_payload in quote.py respects Jev/fallback map and never runs difflib.""" ctx = SimpleNamespace(resolver=FakeResolver()) payload = { "supplier_name": "تأمین‌کننده البرز", "items": [ {"material_name": "لوله ۶ اینچ", "unit_name": "شاخه"}, {"material_name": "میلگرد ۱۴", "unit_name": "شاخه"}, ], } jev_map = { normalize_text("تأمین‌کننده البرز"): { "field": "supplier", "status": "new", "variants": ["تأمین‌کننده البرز"], "inferred_category_slug": "metals-steel-sections", }, normalize_text("لوله ۶ اینچ"): { "field": "material", "status": "new", "variants": ["لوله ۶ اینچ"], }, normalize_text("میلگرد ۱۴"): { "field": "material", "status": "exact", "id": "m1", "name": "میلگرد ۱۴ آجدار", "variants": ["میلگرد ۱۴"], }, normalize_text("شاخه"): { "field": "unit", "status": "exact", "id": "u1", "name": "شاخه", "variants": ["شاخه"], }, } await quote.resolve_payload(ctx, payload, trace_id="t3", jev_map=jev_map) check("New supplier kept id=None (no difflib forced match)", payload.get("supplier_id") is None) check("New supplier inferred category saved", payload.get("supplier_inferred_category_slug") == "metals-steel-sections") check("New material kept id=None (no difflib forced match to 4-inch pipe)", payload["items"][0].get("material_id") is None) check("Exact material bound to catalog ID", payload["items"][1].get("material_id") == "m1") check("Exact unit bound to catalog ID", payload["items"][0].get("unit_id") == "u1") async def test_quote_commit_defensive_get_or_create() -> None: """4. commit in quote.py uses get_or_create to prevent 409 conflict errors.""" api = FakeApi() resolver = FakeResolver() ctx = SimpleNamespace(api=api, resolver=resolver, pending={}, send=lambda *a, **k: asyncio.sleep(0.0001)) user = SimpleNamespace(actor_id="usr_1") # Mock obs calls so offline test doesn't require live DB connection orig_latest = quote.obs.latest_trace_for_draft orig_link = quote.obs.link_draft orig_event = quote.obs.event quote.obs.latest_trace_for_draft = lambda did: asyncio.sleep(0.0001, result="mock_trace") quote.obs.link_draft = lambda *a, **k: asyncio.sleep(0.0001) quote.obs.event = lambda *a, **k: asyncio.sleep(0.0001) # Item with name "میلگرد ۱۴ آجدار" that already exists in DB payload = { "supplier_name": "فولاد خوزستان", # Already in DB "supplier_inferred_category_slug": "metals-steel-sections", "items": [ {"material_name": "میلگرد ۱۴ آجدار", "unit_id": 1, "quantity": 10, "unit_price": 25000}, {"material_name": "تیرآهن ۱۸", "unit_id": 1, "quantity": 5, "unit_price": 50000}, # Brand new ], "project_name": "پروژه پردیس", # Already in DB } draft = SimpleNamespace(id="d1", payload=payload, review_message_id=None) # Mock create_quote api.create_quote = lambda body, actor_id: asyncio.sleep(0.001, result={"id": "q1"}) # Mock draft_store.set_status quote.draft_store.set_status = lambda did, st: asyncio.sleep(0.001) try: await quote.commit(ctx, chat_id=123, user=user, draft=draft, only_resolved=False) check("Existing supplier reused ID from DB (not recreated)", payload["supplier_id"] == "s1") check("Existing material reused ID from DB on conflict (not crashing)", payload["items"][0]["material_id"] == "m1") check("New material created cleanly", payload["items"][1]["material_id"] == "m_new_1") check("Existing project reused ID from DB", payload["project_id"] == "p1") finally: quote.obs.latest_trace_for_draft = orig_latest quote.obs.link_draft = orig_link quote.obs.event = orig_event async def main() -> None: print("Running DeepSeek Crisis Fallback & Zero-Difflib Tests...\n") orig_fb_event = fallback_matcher.obs.event fallback_matcher.obs.event = lambda *a, **k: asyncio.sleep(0.0001) try: await test_jev_failure_triggers_fallback() await test_defensive_failure_guard() await test_quote_resolve_payload_zero_difflib() await test_quote_commit_defensive_get_or_create() finally: fallback_matcher.obs.event = orig_fb_event total = len(PASSED) + len(FAILED) print(f"\n{len(PASSED)}/{total} passed") if FAILED: print("FAILED:", FAILED) sys.exit(1) if __name__ == "__main__": asyncio.run(main())