Files
duocthu/apps/ai-service/tests/test_understanding.py
T

623 lines
26 KiB
Python

"""`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": ["", "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 == ()