"""`rag/understanding.py::LlmQueryUnderstander` — zero coverage before this (Codex's 2026-08-06 review, F-03/F-10), despite being the entry point for every live turn once F-03 wired it in. Also covers F-04 (bounded candidates): the resolver decides which drug_ids are even plausible for a turn *before* the model runs, and the model's pick is validated against that bound, not the full catalog. """ from __future__ import annotations import json from rag.ports import AnswerGenerationUnavailable from rag.understanding import ( SECTION_KEY_HINTS, SECTION_KEYS, LlmQueryUnderstander, QueryFrame, _merge_with_prior_frame, ) CATALOG = { "paracetamol_acetaminophen": "paracetamol acetaminophen, PARACETAMOL", "metformin": "metformin, METFORMIN", } class _FixedLlm: def __init__(self, payload) -> None: self._payload = payload def generate(self, system: str, user: str, schema: dict) -> str: if isinstance(self._payload, BaseException): raise self._payload if isinstance(self._payload, str): return self._payload return json.dumps(self._payload, ensure_ascii=False) class _Resolution: def __init__(self, status="not_found", drug_id=None, candidate_drug_ids=()) -> None: self.status = status self.drug_id = drug_id self.candidate_drug_ids = candidate_drug_ids class _FakeResolver: """Deterministic stand-in for `CatalogDrugResolver`: resolves a line to a drug_id if one of `known`'s substrings appears in it (case-insensitive), with no fuzzy suggestions unless `suggestions` is given.""" def __init__(self, known: dict[str, str], suggestions: dict[str, str] | None = None) -> None: self._known = known self._suggestions = suggestions or {} self.suggest_calls = 0 def resolve(self, query: str) -> _Resolution: low = query.lower() for needle, drug_id in self._known.items(): if needle in low: return _Resolution(status="resolved", drug_id=drug_id) return _Resolution() def suggest(self, query: str, k: int = 3, min_score: float = 0.5): self.suggest_calls += 1 low = query.lower() return [ (drug_id, 0.9) for needle, drug_id in self._suggestions.items() if needle in low ][:k] RESOLVER = _FakeResolver({ "metformin": "metformin", "paracetamol": "paracetamol_acetaminophen", }) def test_drug_id_in_exact_underscore_form_resolves(): understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "drug_attribute", "drugs": ["metformin"], "unknown_drugs": [], "attribute": None, "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }), CATALOG, RESOLVER) frame = understander.understand("liều metformin") assert frame.drugs == ("metformin",) assert frame.unknown_drugs == () def test_golden_named_drug_section_overrides_a_misclassified_relation_frame(): """Regression for the production failure observed through the real UI.""" understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "condition_relation", "drugs": [], "unknown_drugs": [], "attribute": None, "population": None, "weight_kg": None, "age_text": None, "indication": None, "condition_relation": "contraindication", "needs_clarify": False, "clarify_reason": None, }), CATALOG, RESOLVER) frame = understander.understand("Chống chỉ định của Paracetamol là gì?") assert frame.turn_type == "drug_attribute" assert frame.drugs == ("paracetamol_acetaminophen",) assert frame.attribute == "chong_chi_dinh" assert frame.needs_clarify is False def test_two_sections_named_at_once_clarifies_instead_of_silently_narrowing(): """Regression for `abstain/incomplete_answer` reproduced live 2026-08-14 on "Chỉ định và chống chỉ định của Aspirin là gì?": the turn used to silently collapse to whichever one section `_apply_named_drug_cues` picked (chống chỉ định, being the longer phrase), so retrieval only fetched that section's evidence while the question handed to generation still promised both — a real, quote-backed completeness gap the generator could never close. Must now clarify instead.""" understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "drug_attribute", "drugs": ["paracetamol_acetaminophen"], "unknown_drugs": [], "attribute": "chong_chi_dinh", "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }), CATALOG, RESOLVER) frame = understander.understand( "Chỉ định và chống chỉ định của Paracetamol là gì?" ) assert frame.turn_type == "drug_attribute" assert frame.drugs == ("paracetamol_acetaminophen",) assert frame.attribute is None assert frame.needs_clarify is True assert set(frame.quick_replies) == {"Chỉ định", "Chống chỉ định"} def test_single_section_named_is_unaffected_by_the_multi_section_clarify(): understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "drug_attribute", "drugs": ["paracetamol_acetaminophen"], "unknown_drugs": [], "attribute": "chong_chi_dinh", "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }), CATALOG, RESOLVER) frame = understander.understand("Chống chỉ định của Paracetamol là gì?") assert frame.attribute == "chong_chi_dinh" assert frame.needs_clarify is False def test_multi_section_clarify_does_not_inherit_a_stale_prior_attribute(): prior = QueryFrame( turn_type="drug_attribute", drugs=("paracetamol_acetaminophen",), attribute="lieu_luong_va_cach_dung", needs_clarify=True, clarify_reason="Anh/chị muốn tra gì?", ) current = QueryFrame( turn_type="drug_attribute", drugs=("paracetamol_acetaminophen",), attribute=None, needs_clarify=True, clarify_reason="Anh/chị muốn xem mục nào trước?", quick_replies=("Chỉ định", "Chống chỉ định"), ) merged = _merge_with_prior_frame(current, prior) assert merged.attribute is None assert merged.quick_replies == ("Chỉ định", "Chống chỉ định") def test_exact_candidate_does_not_repeat_the_catalog_wide_fuzzy_scan(): resolver = _FakeResolver({"metformin": "metformin"}) understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "drug_attribute", "drugs": ["metformin"], "unknown_drugs": [], "attribute": "chong_chi_dinh", "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }), CATALOG, resolver) frame = understander.understand("chống chỉ định metformin") assert frame.drugs == ("metformin",) assert resolver.suggest_calls == 0 def test_drug_id_echoed_with_spaces_instead_of_underscores_still_resolves(): """Reproduces the live 2026-08-06 bug on a genuine multi-turn shape: the drug is named in an earlier turn (in history), the current turn is just "30 cân", and the model echoed the spaced display name instead of the underscored id. The old strict-equality check demoted a correctly identified drug to unknown_drugs — producing "Không tìm thấy paracetamol trong Dược thư" for a drug that plainly is in it.""" understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "dosing_calc", "drugs": ["paracetamol acetaminophen"], "unknown_drugs": [], "attribute": None, "population": "tre_em", "weight_kg": 30, "age_text": "7 tuổi", "indication": None, "needs_clarify": False, "clarify_reason": None, }), CATALOG, RESOLVER) frame = understander.understand( "30 cân", history=("Người dùng: Liều paracetamol cho trẻ em", "Trợ lý: Bé mấy tuổi?"), ) assert frame.drugs == ("paracetamol_acetaminophen",) assert frame.unknown_drugs == () def test_a_genuinely_invented_name_is_unknown_not_substituted(): understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "drug_attribute", "drugs": [], "unknown_drugs": ["aspirinol"], "attribute": None, "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }), CATALOG, RESOLVER) frame = understander.understand("liều aspirinol") assert frame.drugs == () assert frame.unknown_drugs == ("aspirinol",) def test_a_name_with_no_deterministic_candidate_is_unknown_even_if_the_model_names_a_real_id(): """F-04's actual guarantee: the model naming a *real* catalog id is not enough — that id must also be among the turn's deterministic candidates. Nothing in "liều aspirinol" fuzzy/exact-matches any real drug (per RESOLVER), so even if the model output a real id here, it must be rejected: the candidate bound, not just catalog membership, is what's trusted.""" understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "drug_attribute", "drugs": ["metformin"], "unknown_drugs": [], "attribute": None, "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }), CATALOG, RESOLVER) frame = understander.understand("liều aspirinol") assert frame.drugs == () assert "metformin" in frame.unknown_drugs def test_fuzzy_suggestion_bounds_a_typo_into_the_candidate_set(): resolver = _FakeResolver({}, suggestions={"metfomin": "metformin"}) understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "drug_attribute", "drugs": ["metformin"], "unknown_drugs": [], "attribute": None, "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }), CATALOG, resolver) frame = understander.understand("liều metfomin") assert frame.drugs == ("metformin",) # --- F-10: a small battery of invented near-alias shapes, beyond the single # "aspirinol" case above — each simulates a different way a name could be # crafted to *look* like it should fuzzy-match a real drug --------------- def test_a_real_drug_name_with_a_brand_like_suffix_is_not_substituted(): resolver = _FakeResolver({}) # nothing in this turn resolves or suggests understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "drug_attribute", "drugs": [], "unknown_drugs": ["metforminex"], "attribute": None, "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }), CATALOG, resolver) frame = understander.understand("liều metforminex") assert frame.drugs == () assert frame.unknown_drugs == ("metforminex",) def test_a_name_blending_two_real_drugs_is_not_substituted_for_either(): resolver = _FakeResolver({}) understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "drug_attribute", "drugs": ["metformin", "paracetamol_acetaminophen"], "unknown_drugs": [], "attribute": None, "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }), CATALOG, resolver) frame = understander.understand("liều metformacetamol") # Neither real id has deterministic candidate support for this turn — # F-04's bound must reject both, not accept the ones that happen to be # real catalog members. assert frame.drugs == () assert "metformin" in frame.unknown_drugs assert "paracetamol_acetaminophen" in frame.unknown_drugs def test_malformed_json_fails_closed_to_a_clarify(): understander = LlmQueryUnderstander(_FixedLlm("not json"), CATALOG, RESOLVER) frame = understander.understand("gì đó") assert frame.turn_type == "out_of_scope" assert frame.needs_clarify is True assert frame.quick_replies == () def test_quick_replies_are_parsed_when_the_model_offers_them(): understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "dosing_calc", "drugs": ["paracetamol_acetaminophen"], "unknown_drugs": [], "attribute": None, "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": True, "clarify_reason": "Người lớn hay trẻ em?", "quick_replies": ["Người lớn", "Trẻ em"], }), CATALOG, RESOLVER) frame = understander.understand("liều paracetamol") assert frame.quick_replies == ("Người lớn", "Trẻ em") def test_quick_replies_are_dynamic_but_bounded_before_becoming_ui_chips(): # 20 distinct valid entries (after " Người lớn " / "người lớn" dedup # and the non-string 12 are dropped) so the 19-item cap — one per # monograph section, see rag/sections.py SECTION_ORDER — still trims # the last one, not just the old 4-item cap. extra = [f"Lựa chọn {i}" for i in range(6, 21)] understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "dosing_calc", "drugs": ["paracetamol_acetaminophen"], "unknown_drugs": [], "attribute": None, "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": True, "clarify_reason": "Chọn nhóm phù hợp?", "quick_replies": [ " Người lớn ", "người lớn", "Trẻ em", 12, "Phụ nữ có thai", "Người cao tuổi", "Lựa chọn thứ năm", *extra, ], }), CATALOG, RESOLVER) frame = understander.understand("liều paracetamol") assert len(frame.quick_replies) == 19 assert frame.quick_replies[:5] == ( "Người lớn", "Trẻ em", "Phụ nữ có thai", "Người cao tuổi", "Lựa chọn thứ năm" ) assert "Lựa chọn 20" not in frame.quick_replies def test_string_false_does_not_turn_into_a_clarification(): understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "drug_attribute", "drugs": ["paracetamol_acetaminophen"], "unknown_drugs": [], "attribute": "chong_chi_dinh", "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": "false", "clarify_reason": "Không được hiển thị", "quick_replies": ["Có", "Không"], }), CATALOG, RESOLVER) frame = understander.understand("chống chỉ định paracetamol") assert frame.needs_clarify is False assert frame.quick_replies == () def test_missing_quick_replies_key_defaults_to_empty_not_a_crash(): """The model is asked for `quick_replies` but structured-output providers aren't guaranteed to include every optional key — a clarify without it must still parse, just with no chips.""" understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "dosing_calc", "drugs": [], "unknown_drugs": [], "attribute": None, "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": True, "clarify_reason": "Cân nặng bao nhiêu kg?", }), CATALOG, RESOLVER) frame = understander.understand("liều cho bé") assert frame.quick_replies == () def test_route_is_parsed_when_the_model_resolves_it(): """The 2026-08-07 bug: a bare reply like 'Uống' answering the model's own prior route question had nowhere to be recorded (QueryFrame had no route field), so the model could only repeat its clarify_reason verbatim.""" understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "dosing_calc", "drugs": ["paracetamol_acetaminophen"], "unknown_drugs": [], "attribute": "lieu_luong_va_cach_dung", "population": "nguoi_lon", "weight_kg": None, "age_text": None, "indication": None, "route": "uong", "needs_clarify": False, "clarify_reason": None, }), CATALOG, RESOLVER) frame = understander.understand( "Uống", history=( "Người dùng: Liều paracetamol hạ sốt là bao nhiêu?", "Trợ lý: Người lớn hay trẻ em? Uống hay đặt trực tràng?", "Người dùng: Người lớn", "Trợ lý: Uống hay đặt trực tràng?", ), ) assert frame.route == "uong" assert frame.needs_clarify is False def test_follow_up_exposes_a_first_class_standalone_query(): understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "drug_attribute", "drugs": ["metformin"], "unknown_drugs": [], "attribute": "chong_chi_dinh", "population": None, "weight_kg": None, "age_text": None, "indication": None, "route": None, "standalone_query": "Chống chỉ định của metformin", "depends_on_previous_turn": True, "needs_clarify": False, "clarify_reason": None, }), CATALOG, RESOLVER) frame = understander.understand( "thế còn chống chỉ định?", history=("Người dùng: Metformin dùng để làm gì?",), ) assert frame.standalone_query == "Chống chỉ định của metformin" assert frame.depends_on_previous_turn is True def test_missing_route_key_defaults_to_none_not_a_crash(): understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "dosing_calc", "drugs": [], "unknown_drugs": [], "attribute": None, "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }), CATALOG, RESOLVER) frame = understander.understand("liều metformin") assert frame.route is None def test_unrecognised_turn_type_falls_back_based_on_whether_a_drug_resolved(): understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "not_a_real_type", "drugs": ["metformin"], "unknown_drugs": [], "attribute": None, "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }), CATALOG, RESOLVER) frame = understander.understand("metformin") assert frame.turn_type == "drug_attribute" def test_every_section_key_has_a_hint(): # A key with no gloss shown to the model is exactly the bug this fixed — # never let a new SECTION_KEYS entry silently ship without one. assert set(SECTION_KEYS) == set(SECTION_KEY_HINTS) def test_prompt_disambiguates_than_trong_from_chong_chi_dinh(): """Reproduces the live 2026-08-06 golden-eval finding: a bare section key list gave the model nothing to tell "thận trọng" (precautions) apart from "chống chỉ định" (contraindications) — 9/9 live calls for "X cần thận trọng gì?" picked chong_chi_dinh, silently answering from the wrong section and dropping safety content the precautions section actually has (metformin's lactic-acidosis warning, gentamicin's oto/nephrotoxicity). Fixed with an inline gloss; this pins the gloss's presence in the actual request sent, not just its existence in the hints dict.""" captured = {} class _CapturingLlm: def generate(self, system, user, schema): captured["user"] = user return json.dumps({ "turn_type": "drug_attribute", "drugs": ["metformin"], "unknown_drugs": [], "attribute": "than_trong", "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }) understander = LlmQueryUnderstander(_CapturingLlm(), CATALOG, RESOLVER) understander.understand("metformin cần thận trọng gì?") assert "KHÁC chống chỉ định" in captured["user"] assert "nhiễm toan lactic" in captured["user"] def test_invalid_attribute_is_dropped_not_passed_through(): understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "drug_attribute", "drugs": ["metformin"], "unknown_drugs": [], "attribute": "not_a_real_section", "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }), CATALOG, RESOLVER) frame = understander.understand("metformin") assert frame.attribute is None # --- F-10: provider outage during the ONE call site that had no error # handling at all ------------------------------------------------------------ # --- F-11: prior-frame merge — the code-level backstop for the model # dropping an already-established slot mid clarify-chain. Found live # 2026-08-07 (50-question hand-typed browser audit): reproduced 3 times # independently as either a non-terminating re-ask of the same clarify # question, or a stale drug bleeding into an unrelated new topic. -------- def test_prior_frame_known_fields_survive_a_short_reply_the_model_drops(): """The Insulin/Azithromycin shape: the new call's own JSON comes back with the just-answered field null (a real, observed failure — the model is asked to restate it and sometimes doesn't), but since this turn named no drug of its own (a short reply like "20kg" never does), the previously established fields must survive via the merge, not be silently lost.""" understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "dosing_calc", "drugs": [], "unknown_drugs": [], "attribute": None, "population": None, "weight_kg": 20, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }), CATALOG, RESOLVER) prior = QueryFrame( turn_type="dosing_calc", drugs=("paracetamol_acetaminophen",), attribute="lieu_luong_va_cach_dung", needs_clarify=True, clarify_reason="Bé nặng bao nhiêu kg?", ) frame = understander.understand("bé nặng 20 cân", prior_frame=prior) assert frame.drugs == ("paracetamol_acetaminophen",) assert frame.attribute == "lieu_luong_va_cach_dung" assert frame.weight_kg == 20 def test_prior_frame_is_not_merged_when_the_turn_resolves_a_different_drug(): """The headache/OMEPRAZOL bleed this guards against: a turn that itself names a real, different drug is a genuine topic change and must not inherit the old drug's population/weight/route — merging here would reproduce the exact bug being fixed.""" understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "drug_attribute", "drugs": ["metformin"], "unknown_drugs": [], "attribute": "chi_dinh", "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }), CATALOG, RESOLVER) prior = QueryFrame( turn_type="drug_attribute", drugs=("paracetamol_acetaminophen",), population="tre_em", weight_kg=20, needs_clarify=True, clarify_reason="Bé nặng bao nhiêu kg?", ) frame = understander.understand("chỉ định của metformin là gì", prior_frame=prior) assert frame.drugs == ("metformin",) assert frame.population is None assert frame.weight_kg is None def test_prior_frame_that_was_already_resolved_is_not_merged(): """A prior turn that already answered (needs_clarify=False) has nothing to continue — merging it into a brand-new turn would leak stale state into an unrelated question that happens to follow it.""" understander = LlmQueryUnderstander(_FixedLlm({ "turn_type": "smalltalk", "drugs": [], "unknown_drugs": [], "attribute": None, "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }), CATALOG, RESOLVER) prior = QueryFrame( turn_type="drug_attribute", drugs=("metformin",), population="nguoi_lon", needs_clarify=False, ) frame = understander.understand("cảm ơn bạn", prior_frame=prior) assert frame.drugs == () assert frame.population is None def test_known_facts_block_is_sent_to_the_model_on_a_clarify_continuation(): captured = {} class _CapturingLlm: def generate(self, system, user, schema): captured["user"] = user return json.dumps({ "turn_type": "dosing_calc", "drugs": ["paracetamol_acetaminophen"], "unknown_drugs": [], "attribute": None, "population": None, "weight_kg": 20, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }) understander = LlmQueryUnderstander(_CapturingLlm(), CATALOG, RESOLVER) prior = QueryFrame( turn_type="dosing_calc", drugs=("paracetamol_acetaminophen",), population="tre_em", needs_clarify=True, clarify_reason="Bé nặng bao nhiêu kg?", ) understander.understand("20 cân", prior_frame=prior) assert "THÔNG TIN ĐÃ XÁC ĐỊNH" in captured["user"] assert "paracetamol_acetaminophen" in captured["user"] def test_no_known_facts_block_when_there_is_no_prior_clarify(): captured = {} class _CapturingLlm: def generate(self, system, user, schema): captured["user"] = user return json.dumps({ "turn_type": "smalltalk", "drugs": [], "unknown_drugs": [], "attribute": None, "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }) understander = LlmQueryUnderstander(_CapturingLlm(), CATALOG, RESOLVER) understander.understand("chào bạn") assert "THÔNG TIN ĐÃ XÁC ĐỊNH" not in captured["user"] def test_provider_outage_fails_closed_to_a_clarify_not_an_unhandled_crash(): """Found live 2026-08-07: unlike every other LLM call site in this product, `understand()` had no try/except around its call at all — a Bedrock outage here propagated straight through `RagAgent.handle()` into an unhandled 500 (`routers/rag.py` only wraps the trace-save call, not `agent.handle()`), instead of the graceful abstain every other failure mode already gets.""" understander = LlmQueryUnderstander( _FixedLlm(AnswerGenerationUnavailable("Bedrock unreachable")), CATALOG, RESOLVER, ) frame = understander.understand("liều paracetamol cho người lớn") assert frame.needs_clarify is True assert frame.clarify_reason is not None assert frame.drugs == () assert frame.quick_replies == ()