Checkpoint frontend UI/UX overhaul and ingestion embed benchmark work

This commit is contained in:
2026-08-06 17:21:21 +07:00
parent 1e8cbdb586
commit a4b8e1c4db
78 changed files with 6761 additions and 654 deletions
+212
View File
@@ -0,0 +1,212 @@
"""`rag/agent.py` — the new orchestrator wired live in F-03.
Codex's 2026-08-06 review (F-03/F-10) flagged that this module had zero test
coverage despite being built to replace the live front end. This is the
first coverage: routing branches with fakes, not an exhaustive golden set
(that is F-10's job — a production-path regression suite against real
Qdrant/Bedrock fixtures).
"""
from __future__ import annotations
from rag.agent import RagAgent
from rag.answer import GroundedAnswerService
from rag.models import Evidence, EvidenceDecision, RetrievalResult, SourceRef
from rag.understanding import QueryFrame
SOURCE = SourceRef(physical_page=100, precision="region", printed_page=100)
def _evidence(text: str) -> Evidence:
return Evidence(
evidence_id="e0", matched_doc_id="e0", kind="prose", text=text, score=1.0,
source_refs=(SOURCE,), hydrated_from_parent=False, requires_visual_check=False,
)
class _FixedUnderstander:
def __init__(self, frame: QueryFrame) -> None:
self._frame = frame
def understand(self, turn, history=()):
return self._frame
class _FixedRetrieval:
"""Stands in for `RetrievalService.retrieve_framed` — a canned result per
drug_id regardless of section/query, so these tests assert routing, not
retrieval (that's `test_retrieval_service.py`'s job)."""
def __init__(self, results: dict[str, RetrievalResult]) -> None:
self._results = results
def retrieve_framed(self, drug_id, section_key, query, is_overview=False):
return self._results.get(
drug_id, RetrievalResult(EvidenceDecision.ABSTAIN, "not_configured")
)
def _agent(frame: QueryFrame, results: dict[str, RetrievalResult] | None = None) -> RagAgent:
# `routing=None`: `answer_from_result` (the only method this path calls)
# never touches it — see `rag/answer.py`.
answers = GroundedAnswerService(routing=None)
return RagAgent(_FixedUnderstander(frame), _FixedRetrieval(results or {}), answers)
def test_smalltalk_does_not_touch_retrieval():
agent = _agent(QueryFrame(turn_type="smalltalk"))
reply = agent.handle("chào bạn")
assert reply.decision == "answerable"
assert reply.turn_type == "smalltalk"
assert "Dược thư" in reply.answer
def test_out_of_scope_turn_type_abstains():
agent = _agent(QueryFrame(turn_type="out_of_scope"))
reply = agent.handle("cách tiêm truyền tĩnh mạch")
assert reply.decision == "abstain"
assert reply.reason == "out_of_scope"
def test_veterinary_phrase_abstains_even_if_the_model_missed_it():
# The model returned an ordinary-looking frame; the keyword backstop
# (`rag.policy.looks_non_human`, the same one F-02 wired server-side)
# still catches it before any retrieval happens.
agent = _agent(QueryFrame(turn_type="drug_attribute", drugs=("metformin",)))
reply = agent.handle("liều metformin cho chó bao nhiêu")
assert reply.decision == "abstain"
assert reply.reason == "out_of_scope"
def test_unknown_drug_name_is_reported_not_substituted():
agent = _agent(QueryFrame(turn_type="drug_attribute", unknown_drugs=("aspirinol",)))
reply = agent.handle("liều aspirinol")
assert reply.decision == "abstain"
assert reply.reason == "drug_not_in_formulary"
assert "aspirinol" in reply.answer
def test_no_drug_named_asks_which_one():
agent = _agent(QueryFrame(turn_type="drug_attribute"))
reply = agent.handle("liều dùng bao nhiêu")
assert reply.decision == "clarify"
assert reply.reason == "no_drug"
def test_needs_clarify_frame_is_surfaced_directly():
agent = _agent(QueryFrame(
turn_type="dosing_calc", drugs=("paracetamol",),
needs_clarify=True, clarify_reason="Bé mấy tuổi, cân nặng bao nhiêu kg?",
))
reply = agent.handle("liều paracetamol cho trẻ em")
assert reply.decision == "clarify"
assert reply.clarification == "Bé mấy tuổi, cân nặng bao nhiêu kg?"
def test_single_drug_attribute_retrieves_and_answers():
result = RetrievalResult(
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
(_evidence("Liều 500 mg mỗi ngày."),), resolved_drug_id="metformin",
)
agent = _agent(
QueryFrame(turn_type="drug_attribute", drugs=("metformin",),
attribute="lieu_luong_va_cach_dung"),
{"metformin": result},
)
reply = agent.handle("liều metformin")
assert reply.decision == "answerable"
assert reply.drugs == ("metformin",)
assert "500 mg" in reply.answer
def test_interaction_combines_both_drugs_evidence():
warfarin = RetrievalResult(
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
(_evidence("Tương tác với aspirin làm tăng nguy cơ chảy máu."),),
)
aspirin = RetrievalResult(
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
(_evidence("Tương tác với warfarin làm tăng nguy cơ chảy máu."),),
)
agent = _agent(
QueryFrame(turn_type="interaction", drugs=("warfarin", "aspirin")),
{"warfarin": warfarin, "aspirin": aspirin},
)
reply = agent.handle("warfarin với aspirin có dùng chung được không")
assert reply.decision == "answerable"
assert reply.turn_type == "interaction"
assert "chảy máu" in reply.answer
def test_interaction_with_no_evidence_abstains_and_does_not_imply_safety():
agent = _agent(
QueryFrame(turn_type="interaction", drugs=("drug_a", "drug_b")), {},
)
reply = agent.handle("drug_a và drug_b dùng chung được không")
assert reply.decision == "abstain"
assert reply.reason == "no_interaction_evidence"
assert "KHÔNG có nghĩa là an toàn" in reply.answer
def test_symptom_to_drug_without_a_drug_name_asks_honestly_not_wired_yet():
agent = _agent(QueryFrame(turn_type="symptom_to_drug", indication="sốt cao"))
reply = agent.handle("sốt cao uống thuốc gì")
assert reply.decision == "clarify"
assert reply.reason == "reverse_lookup_not_ready"
def test_history_is_passed_to_the_understander_on_the_next_turn():
received_history: list[tuple[str, ...]] = []
class _RecordingUnderstander:
def understand(self, turn, history=()):
received_history.append(tuple(history))
return QueryFrame(turn_type="smalltalk")
answers = GroundedAnswerService(routing=None)
agent = RagAgent(_RecordingUnderstander(), _FixedRetrieval({}), answers)
agent.handle("chào bạn", conversation_id="c1")
agent.handle("còn liều thì sao?", conversation_id="c1")
assert received_history[0] == ()
assert any("chào bạn" in line for line in received_history[1])
def test_history_is_isolated_per_conversation_id():
received_history: list[tuple[str, ...]] = []
class _RecordingUnderstander:
def understand(self, turn, history=()):
received_history.append(tuple(history))
return QueryFrame(turn_type="smalltalk")
answers = GroundedAnswerService(routing=None)
agent = RagAgent(_RecordingUnderstander(), _FixedRetrieval({}), answers)
agent.handle("xin chào", conversation_id="a")
agent.handle("liều dùng bao nhiêu", conversation_id="b")
# The second call, on a different conversation id, must not see "a"'s turn.
assert received_history[1] == ()
def test_autocomplete_delegates_to_the_configured_source():
class _Source:
def complete(self, prefix, k):
return ["metformin_id", "metoprolol_id"]
answers = GroundedAnswerService(routing=None)
agent = RagAgent(
_FixedUnderstander(QueryFrame(turn_type="smalltalk")),
_FixedRetrieval({}), answers, autocomplete=_Source(),
)
assert agent.complete("met") == ["Metformin Id", "Metoprolol Id"]
def test_autocomplete_with_no_source_configured_returns_empty():
answers = GroundedAnswerService(routing=None)
agent = RagAgent(
_FixedUnderstander(QueryFrame(turn_type="smalltalk")),
_FixedRetrieval({}), answers,
)
assert agent.complete("met") == []
+127 -1
View File
@@ -2,7 +2,9 @@ from fastapi.testclient import TestClient
from config import Settings
from main import create_app
from rag.answer import GroundedAnswerService
from rag.agent import AgentReply
from rag.answer import Citation, GroundedAnswerService
from rag.metrics import TRACE_WRITE_FAILED, InMemoryMetrics
from rag.models import EvidenceDecision, QueryIntent, RetrievalResult, SubjectScope
@@ -51,3 +53,127 @@ def test_query_requires_structured_scope_and_intent():
)
response = TestClient(app).post("/v1/rag/query", json={"query": "Liều?"})
assert response.status_code == 422
# --- F-03: the app.state.conversational slot is the new RagAgent, wired live ---
class FakeAgent:
"""Stands in for `RagAgent` — proves `routers/rag.py` calls `.handle()`
and maps `AgentReply` correctly, not that the agent's own routing logic
is correct (that's `test_agent.py`)."""
def __init__(self, reply: AgentReply) -> None:
self._reply = reply
self.calls: list[tuple[str, str | None]] = []
def handle(self, turn: str, conversation_id: str | None = None) -> AgentReply:
self.calls.append((turn, conversation_id))
return self._reply
def complete(self, prefix: str, k: int = 8) -> list[str]:
return ["Metformin"] if prefix else []
def _citation() -> Citation:
return Citation(
chunk_id="metformin::lieu::0", printed_page_start=714, printed_page_end=714,
physical_page=812,
)
def test_query_routes_through_the_agent_when_one_is_configured():
agent = FakeAgent(AgentReply(
decision="answerable", reason="grounded_evidence_available",
answer="Liều 500 mg [1].", citations=(_citation(),),
drugs=("metformin",), turn_type="drug_attribute", generated=True,
))
app = create_app(
settings=Settings(),
answer_service=GroundedAnswerService(FixedRouting()),
conversational=agent,
trace_writer=MemoryTraceWriter(),
)
response = TestClient(app).post("/v1/rag/query", json={
"query": "Liều metformin?", "subject_scope": "human",
"intent": "fact_lookup", "conversation_id": "c1",
})
body = response.json()
assert response.status_code == 200
assert body["decision"] == "answerable"
assert body["answer"] == "Liều 500 mg [1]."
assert body["resolved_drug_id"] == "metformin"
assert len(body["citations"]) == 1
assert agent.calls == [("Liều metformin?", "c1")]
def test_query_agent_clarification_is_surfaced_as_the_answer():
agent = FakeAgent(AgentReply(
decision="clarify", reason="no_drug",
clarification="Anh/chị muốn tra thuốc nào?",
))
app = create_app(
settings=Settings(),
answer_service=GroundedAnswerService(FixedRouting()),
conversational=agent,
trace_writer=MemoryTraceWriter(),
)
response = TestClient(app).post("/v1/rag/query", json={
"query": "liều dùng bao nhiêu", "subject_scope": "human",
"intent": "fact_lookup",
})
body = response.json()
assert body["decision"] == "clarify"
assert body["answer"] == "Anh/chị muốn tra thuốc nào?"
assert body["resolved_drug_id"] is None
def test_suggest_delegates_to_the_agent_when_configured():
app = create_app(
settings=Settings(),
conversational=FakeAgent(AgentReply(decision="answerable", reason="x")),
trace_writer=MemoryTraceWriter(),
)
response = TestClient(app).get("/v1/rag/suggest", params={"q": "met"})
assert response.json() == {"suggestions": ["Metformin"]}
def test_suggest_with_no_agent_configured_returns_empty():
app = create_app(settings=Settings(), trace_writer=MemoryTraceWriter())
response = TestClient(app).get("/v1/rag/suggest", params={"q": "met"})
assert response.json() == {"suggestions": []}
# --- F-09: trace persistence is fail-open ------------------------------------
class _RaisingTraceWriter:
"""Stands in for a Postgres outage: `save()` always raises."""
def save(self, **fields):
raise ConnectionError("could not connect to postgres")
def test_a_trace_write_failure_does_not_turn_a_good_answer_into_a_500():
agent = FakeAgent(AgentReply(
decision="answerable", reason="grounded_evidence_available",
answer="Liều 500 mg [1].", citations=(_citation(),),
drugs=("metformin",), turn_type="drug_attribute", generated=True,
))
metrics = InMemoryMetrics()
app = create_app(
settings=Settings(),
answer_service=GroundedAnswerService(FixedRouting()),
conversational=agent,
trace_writer=_RaisingTraceWriter(),
metrics=metrics,
)
response = TestClient(app).post("/v1/rag/query", json={
"query": "Liều metformin?", "subject_scope": "human", "intent": "fact_lookup",
})
body = response.json()
assert response.status_code == 200
assert body["answer"] == "Liều 500 mg [1]."
assert body["trace_id"] # a locally-generated fallback id, still present
assert metrics.total(TRACE_WRITE_FAILED) == 1
+38
View File
@@ -0,0 +1,38 @@
"""`_catalog_names` — the drug names shown to `LlmQueryUnderstander`.
Reproduces a live 2026-08-06 bug: picking the first N aliases alphabetically
could drop a drug's own recognizable name entirely, breaking multi-turn
follow-ups where the drug is no longer restated in the raw turn text (see
`docs/progress-log.md` for the exact failure: "Liều paracetamol cho trẻ em"
-> two clarify rounds -> "Không tìm thấy paracetamol").
"""
from bootstrap import _catalog_names
def test_alphabetically_early_junk_alias_does_not_bury_the_canonical_name():
aliases = {
"paracetamol_acetaminophen": {
"0Frezefev", "ABAB", "Ace kid 80", "PARACETAMOL", "Acetaminophen",
},
}
shown = _catalog_names(aliases)["paracetamol_acetaminophen"]
assert "paracetamol acetaminophen" in shown
def test_canonical_name_is_always_first():
aliases = {"metformin": {"METFORMIN", "Axiol", "Dybis", "Zzyzx"}}
shown = _catalog_names(aliases)["metformin"]
assert shown.split(", ")[0] == "metformin"
def test_drug_with_no_aliases_still_shows_its_canonical_name():
shown = _catalog_names({"some_drug": set()})["some_drug"]
assert shown == "some drug"
def test_output_is_capped_and_does_not_duplicate_the_canonical_name():
aliases = {"drug_x": {f"Brand{i}" for i in range(20)} | {"DRUG X", "drug x"}}
shown = _catalog_names(aliases)["drug_x"].split(", ")
assert shown[0] == "drug x"
assert shown.count("drug x") == 1
assert len(shown) <= 3
@@ -41,11 +41,23 @@ class _Routing:
class _Generator:
def __init__(self, payload: dict) -> None:
def __init__(self, payload: dict, entailment_payload: dict | None = None) -> None:
self._payload = payload
self._entailment_payload = entailment_payload or {
"entailed": True,
"unsupported": [],
}
def generate(self, system: str, user: str, schema: dict) -> str: # noqa: ARG002
return json.dumps(self._payload, ensure_ascii=False)
# `_generate` also runs a post-generation entailment check; tell the
# two request shapes apart by schema so callers here only need to
# fake the main answer, not both.
payload = (
self._entailment_payload
if "entailed" in schema.get("properties", {})
else self._payload
)
return json.dumps(payload, ensure_ascii=False)
def _answerable(*evidence: Evidence, is_overview: bool = False) -> RetrievalResult:
@@ -72,17 +84,56 @@ def test_only_cited_sources_are_returned():
assert grounded.citations[0].printed_page_start == 200
def test_answer_citing_nothing_falls_back_to_all_citations():
def test_answer_citing_nothing_is_rejected_not_dressed_up_with_borrowed_citations():
result = _answerable(_evidence(0, 100), _evidence(1, 200))
service = GroundedAnswerService(
_Routing(result),
# no [n] marker at all: rather than show zero provenance, show all.
# no [n] marker at all: grounding.verify rejects this outright (an
# uncited claim, per F-01). A generator is configured, so the
# rejection abstains — it must not attach every retrieved citation
# to dress an uncited generation up as sourced (the old behavior),
# and it must not silently degrade to a raw extractive quote either
# (owner correction, 2026-08-06: no fallback to the retired
# offline-extractive shape when a real generator is configured).
_Generator({"answer": "Không có trích dẫn.", "evidence_sufficient": True}),
)
grounded = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
assert len(grounded.citations) == 2
assert grounded.generated is False
assert grounded.answer is None
assert grounded.citations == ()
assert grounded.result.decision == EvidenceDecision.ABSTAIN
def test_underspecified_dose_asks_instead_of_dumping():
"""The reasoning step: a dose question spanning bands with no age/weight is
turned into a clarification, not the whole section."""
result = _answerable(_evidence(0, 100), _evidence(1, 200))
gen = _Generator(
{"sufficient": False,
"clarifying_question": "Bé mấy tuổi, cân nặng bao nhiêu kg?"}
)
service = GroundedAnswerService(_Routing(result), gen)
g = service.answer("paracetamol cho trẻ em", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
assert g.clarification is not None
assert "tuổi" in g.clarification
assert g.answer == g.clarification
assert g.generated is False
def test_sufficient_query_is_not_turned_into_a_clarification():
result = _answerable(_evidence(0, 100), _evidence(1, 200))
gen = _Generator({"sufficient": True, "clarifying_question": None})
service = GroundedAnswerService(_Routing(result), gen)
g = service.answer("liều người lớn", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
# sufficiency passes; generation then runs (its payload lacks answer keys, so
# it falls back to the source text) — the point is no clarification fired.
assert g.clarification is None
def test_bare_name_builds_an_intro_prompt():
@@ -136,6 +136,49 @@ def test_recent_window_evicts_oldest():
assert state.turn_count == 8
def test_evicted_turns_reach_overflow_not_silently_dropped():
"""Bug fixed 2026-08-06 (Codex review, F-06): `overflow()` used to check
`len(self.recent) > window`, but `append()` already truncates `recent`
to `window`, so that comparison could never be true — evicted turns
never reached the summariser no matter how long a conversation ran.
Exact repro from the review: 8 turns into a window of 6."""
state = ConversationState("c1")
for index in range(8):
state = state.append(Turn("user", f"q{index}", "2026-08-05"), window=6)
overflow = state.overflow()
assert [turn.text for turn in overflow] == ["q0", "q1"]
def test_overflow_accumulates_across_the_two_appends_one_turn_makes():
"""A live turn typically calls `append()` twice in a row (user, then
assistant). Each can evict at most one turn; the second call's overflow
must not overwrite, and so lose, the first's."""
state = ConversationState("c1")
for index in range(6):
state = state.append(Turn("user", f"q{index}", "2026-08-05"), window=6)
assert state.overflow() == () # window exactly full, nothing evicted yet
state = state.append(Turn("user", "q6", "2026-08-05"), window=6)
state = state.append(Turn("assistant", "a6", "2026-08-05"), window=6)
assert [turn.text for turn in state.overflow()] == ["q0", "q1"]
def test_overflow_is_empty_again_after_the_caller_clears_it():
from dataclasses import replace
state = ConversationState("c1")
for index in range(8):
state = state.append(Turn("user", f"q{index}", "2026-08-05"), window=6)
assert state.overflow() != ()
state = replace(state, pending_overflow=())
assert state.overflow() == ()
def test_focus_update_stamps_the_current_turn():
state = _state(turn_count=3)
@@ -75,7 +75,7 @@ def test_followup_inherits_drug_and_passes_it_resolved():
svc.answer("c3", "chống chỉ định metformin", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
out = svc.answer("c3", "còn trẻ em thì sao?", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
assert out.inherited_drug == "metformin"
assert out.answer.startswith("Về metformin:")
assert out.answer.startswith("Về Metformin:")
# The inherited drug is passed already-resolved (not re-resolved from the
# rewritten turn text), and the raw follow-up drives section routing.
last_query, last_drug_id = answers.calls[-1]
+140 -15
View File
@@ -65,23 +65,48 @@ class _FixedRouting:
class _Generator:
"""Returns whatever payload the test wants the model to have produced."""
"""Returns whatever payload the test wants the model to have produced.
def __init__(self, payload) -> None:
`_generate` now makes up to four calls through this port: the main
answer, a sufficiency check (skipped here — one evidence block), and up
to two entailment calls (a reject retries once — live probing found the
judge noisy on an identical claim/evidence pair). They're told apart by
schema, so a test that only cares about the main answer doesn't also
have to fake an entailment response by hand; `entailment_payload`
overrides it when a test wants the entailment pass to reject. Pass a
list of payloads to get a different answer on each successive
entailment call (e.g. `[reject, accept]` for the retry-recovers case).
"""
def __init__(self, payload, entailment_payload=None) -> None:
self._payload = payload
default = {"entailed": True, "unsupported": []}
payloads = entailment_payload if entailment_payload is not None else default
self._entailment_payloads = (
list(payloads) if isinstance(payloads, list) else [payloads]
)
self._entailment_call = 0
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)
if "entailed" in schema.get("properties", {}):
index = min(self._entailment_call, len(self._entailment_payloads) - 1)
payload = self._entailment_payloads[index]
self._entailment_call += 1
else:
payload = self._payload
if isinstance(payload, BaseException):
raise payload
if isinstance(payload, str):
return payload
return json.dumps(payload, ensure_ascii=False)
def _answer(payload, result: RetrievalResult | None = None):
def _answer(payload, result: RetrievalResult | None = None, entailment_payload=None):
metrics = InMemoryMetrics()
service = GroundedAnswerService(
_FixedRouting(result or _result()), _Generator(payload), metrics
_FixedRouting(result or _result()),
_Generator(payload, entailment_payload),
metrics,
)
grounded = service.answer("Liều Metformin?", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
return grounded, metrics
@@ -97,8 +122,12 @@ def test_invented_dose_is_refused_and_never_reaches_the_answer():
)
assert grounded.generated is False
assert "850" not in grounded.answer
assert grounded.answer.startswith(EVIDENCE_TEXT)
# A generator is configured, so a rejected generation abstains — it does
# NOT silently degrade to a raw source dump (owner correction, 2026-08-06:
# this is a real LLM chatbot, not the retired offline-extractive build).
assert grounded.answer is None
assert grounded.result.decision == EvidenceDecision.ABSTAIN
assert grounded.result.reason == "generation_unavailable"
assert metrics.total(GENERATION_REJECTED, reason="ungrounded_number") == 1
@@ -119,7 +148,12 @@ def test_citation_pointing_at_nothing_is_refused():
)
assert grounded.generated is False
assert metrics.total(GENERATION_REJECTED, reason="invalid_citation") == 1
# [3] is out of range with one evidence block, so "500" has no valid
# citation to bind to — grounding.verify now flags it as unsupported
# rather than letting it pass because 500 happens to exist somewhere in
# the (single) evidence block anyway. ungrounded_number takes priority
# over invalid_citation in GroundingReport.reason; both are present.
assert metrics.total(GENERATION_REJECTED, reason="ungrounded_number") == 1
def test_faithful_rewrite_is_served():
@@ -134,6 +168,95 @@ def test_faithful_rewrite_is_served():
assert metrics.total(GENERATION_REJECTED) == 0
# --- the entailment pass: catches what number/citation checks structurally can't -----
def test_fabricated_nonnumeric_claim_with_a_valid_citation_is_rejected():
"""Reproduces `claim_bia` from the Codex 2026-08-06 review end to end:
right drug, syntactically valid citation, fabricated indication.
grounding.verify alone cannot see this (no number, citation in range) —
the entailment pass, told the model judged evidence 1 does not support
it, is what rejects the generation."""
grounded, metrics = _answer(
{"answer": "Metformin chữa ung thư [1].", "evidence_sufficient": True},
entailment_payload={"entailed": False, "unsupported": [1]},
)
assert grounded.generated is False
assert grounded.answer is None
assert grounded.result.decision == EvidenceDecision.ABSTAIN
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 1
def test_entailment_check_running_and_passing_still_serves_the_answer():
grounded, metrics = _answer(
{"answer": "Metformin dùng điều trị đái tháo đường [1].",
"evidence_sufficient": True},
entailment_payload={"entailed": True, "unsupported": []},
)
assert grounded.generated is True
assert metrics.total(GENERATION_SERVED) == 1
def test_entailment_retries_once_after_a_reject_and_a_later_accept_serves():
"""Reproduces the 2026-08-06 live finding: the same claim/evidence pair,
called three times through the real judge, came back entailed twice and
rejected once — a single noisy reject must not discard a correct,
well-cited answer."""
grounded, metrics = _answer(
{"answer": "Metformin dùng điều trị đái tháo đường [1].",
"evidence_sufficient": True},
entailment_payload=[
{"entailed": False, "unsupported": [1]},
{"entailed": True, "unsupported": []},
],
)
assert grounded.generated is True
assert metrics.total(GENERATION_SERVED) == 1
def test_entailment_two_agreeing_rejects_still_discard():
grounded, metrics = _answer(
{"answer": "Metformin chữa ung thư [1].", "evidence_sufficient": True},
entailment_payload=[
{"entailed": False, "unsupported": [1]},
{"entailed": False, "unsupported": [1]},
],
)
assert grounded.generated is False
assert grounded.answer is None
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 1
def test_entailment_provider_outage_fails_closed_to_abstain():
grounded, metrics = _answer(
{"answer": "Người lớn: 500 mg, 2 lần/ngày [1].", "evidence_sufficient": True},
entailment_payload=AnswerGenerationUnavailable(),
)
assert grounded.generated is False
assert grounded.answer is None
assert grounded.result.decision == EvidenceDecision.ABSTAIN
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 1
def test_entailment_check_is_skipped_when_the_answer_has_no_claim_text():
"""An answer that is nothing but a citation marker has no claim text for
an entailment pass to check against — `_verify_entailment` must not call
the provider at all. Proven by making that call raise: if the skip
didn't fire, this would reject rather than serve the answer."""
grounded, metrics = _answer(
{"answer": "[1]", "evidence_sufficient": True},
entailment_payload=AnswerGenerationUnavailable(),
)
assert grounded.generated is True
assert metrics.total(GENERATION_REJECTED) == 0
def test_citations_survive_generation():
"""Provenance is the point; a prettier answer must not cost the folio."""
grounded, _ = _answer(
@@ -145,7 +268,7 @@ def test_citations_survive_generation():
assert grounded.citations[0].printed_page_start == 714
# --- degradation is always to the source, never to an error -------------------
# --- a configured generator that fails abstains, never a raw source dump -----
@pytest.mark.parametrize(
@@ -158,11 +281,13 @@ def test_citations_survive_generation():
({"answer": "...", "evidence_sufficient": False}, "evidence_insufficient"),
],
)
def test_every_generation_failure_falls_back_to_the_source_text(payload, reason):
def test_every_generation_failure_abstains_instead_of_a_raw_source_dump(payload, reason):
grounded, metrics = _answer(payload)
assert grounded.generated is False
assert grounded.answer.startswith(EVIDENCE_TEXT)
assert grounded.answer is None
assert grounded.result.decision == EvidenceDecision.ABSTAIN
assert grounded.result.reason == "generation_unavailable"
assert metrics.total(GENERATION_REJECTED, reason=reason) == 1
+122
View File
@@ -0,0 +1,122 @@
"""Adversarial regression cases for `rag.grounding.verify`.
Each case reproduces a defect found by Codex's 2026-08-06 code review
(`coordination/CODEX_RAG_CODE_REVIEW_2026-08-06.md`, F-01) against the old
implementation, which pooled every evidence number into one global set and
never required a citation at all. `verify` must now bind a claim's numbers
to only the evidence block(s) its own citation group names, and must reject
a claim with no citation regardless of whether it contains a number.
"""
from rag import grounding
def test_number_from_the_wrong_evidence_block_is_rejected():
# Reproduces `so_sai_nguon`: 500 mg is real, but only in evidence 2 —
# citing [1] for it must fail, not pass because 500 exists *somewhere*.
report = grounding.verify(
"Liều 500 mg [1].",
("Không dùng khi suy thận.", "Liều 500 mg mỗi ngày."),
)
assert not report.grounded
assert report.unsupported_numbers == ("500",)
assert report.reason == "ungrounded_number"
def test_citing_the_correct_block_for_the_number_is_grounded():
report = grounding.verify(
"Liều 500 mg [2].",
("Không dùng khi suy thận.", "Liều 500 mg mỗi ngày."),
)
assert report.grounded
assert report.cited_indices == (2,)
def test_answer_with_no_citation_at_all_is_rejected():
# Reproduces `khong_citation`: a number that is genuinely in the evidence
# still must not pass when the answer never cites anything.
report = grounding.verify("Liều 500 mg.", ("Liều 500 mg mỗi ngày.",))
assert not report.grounded
assert report.uncited_claim
assert report.unsupported_numbers == ("500",)
assert report.reason == "ungrounded_number"
def test_nonnumeric_claim_with_no_citation_is_rejected():
report = grounding.verify(
"Chống chỉ định với suy gan nặng.", ("Chống chỉ định: suy gan nặng.",)
)
assert not report.grounded
assert report.uncited_claim
assert report.reason == "uncited_claim"
def test_trailing_text_after_the_last_citation_needs_its_own_citation():
report = grounding.verify(
"Liều 500 mg [1]. Không dùng khi suy thận.",
("Liều 500 mg mỗi ngày.",),
)
assert not report.grounded
assert report.uncited_claim
def test_out_of_range_citation_is_invalid_and_leaves_its_claim_unsupported():
report = grounding.verify("Liều 500 mg [3].", ("Liều 500 mg mỗi ngày.",))
assert not report.grounded
assert report.invalid_citations == (3,)
assert report.unsupported_numbers == ("500",)
assert report.reason == "ungrounded_number"
def test_two_claims_each_binding_correctly_to_their_own_source_is_grounded():
report = grounding.verify(
"Người lớn 500 mg [1]. Trẻ em 250 mg [2].",
("Liều người lớn 500 mg.", "Liều trẻ em 250 mg."),
)
assert report.grounded
assert report.cited_indices == (1, 2)
def test_second_claim_citing_the_first_blocks_source_number_is_rejected():
# The child dose (250) is real, but only in evidence 2; citing [1] for it
# is the same defect as `so_sai_nguon`, just in a second sentence.
report = grounding.verify(
"Người lớn 500 mg [1]. Trẻ em 250 mg [1].",
("Liều người lớn 500 mg.", "Liều trẻ em 250 mg."),
)
assert not report.grounded
assert report.unsupported_numbers == ("250",)
def test_multiple_markers_on_one_claim_check_against_their_union():
report = grounding.verify(
"Liều 500 mg [1][2].",
("Liều người lớn 500 mg.", "Liều mỗi ngày."),
)
assert report.grounded
assert report.cited_indices == (1, 2)
def test_extractive_quoting_format_is_still_grounded():
# The service's extractive fallback formats each block as `text [n]`.
report = grounding.verify(
"Liều được ghi trong nguồn. [1]", ("Liều được ghi trong nguồn.",)
)
assert report.grounded
def test_decimal_separator_is_compared_verbatim_not_normalised():
report = grounding.verify("Liều 7.5 mg [1].", ("Liều 7,5 mg.",))
assert not report.grounded
assert report.unsupported_numbers == ("7.5",)
def test_known_gap_fabricated_nonnumeric_claim_with_a_valid_citation_still_passes():
# Documents the residual gap `grounding.verify` cannot close on its own
# (see module docstring): a citation-bearing claim whose content the
# cited block does not actually support. Closed by a separate LLM
# entailment pass in `rag/answer.py`, not by this regex-only check.
report = grounding.verify(
"Metformin chữa ung thư [1].",
("Metformin dùng điều trị đái tháo đường.",),
)
assert report.grounded
+164 -4
View File
@@ -20,6 +20,35 @@ PDF = ROOT / "ingestion/data/raw/duoc-thu-quoc-gia-viet-nam-2018.pdf"
MIGRATION = Path(__file__).resolve().parents[1] / "migrations/001_rag_retrieval_trace.sql"
class _PlumbingEmbedder:
"""Deterministic local vectors for the Qdrant round-trip plumbing tests.
Not a semantic model — it exists only so an integration test can upsert and
query real chunks without a cloud call. Production has exactly one query
embedder (`BedrockCohereQueryEmbedder`); the old local/section-only stubs
were removed, so this lives with the test that needs it.
"""
def __init__(self, dimensions: int) -> None:
self._dimensions = dimensions
@property
def dimensions(self) -> int:
return self._dimensions
def embed_query(self, text: str) -> list[float]:
import hashlib
import math
vector = [0.0] * self._dimensions
for token in text.casefold().split():
digest = hashlib.sha256(token.encode("utf-8")).digest()
index = int.from_bytes(digest[:4], "big") % self._dimensions
vector[index] += 1.0 if digest[4] & 1 else -1.0
norm = math.sqrt(sum(value * value for value in vector))
return [value / norm for value in vector] if norm else vector
@lru_cache
def _first_real_chunk() -> dict:
with CHUNKS.open(encoding="utf-8") as handle:
@@ -42,12 +71,11 @@ def test_real_qdrant_round_trip_uses_real_chunk_and_printed_folio():
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams
from adapters.embedding import LocalHashQueryEmbedder
from adapters.qdrant import QdrantRetriever
client = QdrantClient(url="http://localhost:6333")
collection = f"integration_{uuid.uuid4().hex}"
embedder = LocalHashQueryEmbedder(32)
embedder = _PlumbingEmbedder(32)
record = _first_real_chunk()
try:
client.create_collection(
@@ -103,12 +131,144 @@ def test_real_postgres_migration_insert_and_read_back():
assert stored.citations[0]["printed_page_start"] == 101
class _FakeJsonLlm:
"""Deterministic stand-in for the Bedrock Converse generator. Satisfies
both `JsonLlm` (query understanding) and `AnswerGenerator` (answer +
entailment) — both ports are just `generate(system, user, schema) ->
str` — and tells the three call shapes apart the same way
`tests/test_grounded_generation.py`'s fake does: by schema shape.
Deliberately not a real Bedrock call: this suite asserts exact
drug id / section / citation / grounding outcomes, and this session's
own live probing (`docs/progress-log.md`, F-01/F-03 entries) found real
generation and entailment calls genuinely non-deterministic — the wrong
foundation for a regression assertion. The wiring under test — real
`RagAgent`, real `RetrievalService`/`QdrantRetriever` against a real
(temporary) Qdrant collection, real `GroundedAnswerService` — is
identical to production; only the cloud model call is faked.
"""
def __init__(self, frame_payload: dict, answer_payload: dict) -> None:
self._frame_payload = frame_payload
self._answer_payload = answer_payload
def generate(self, system: str, user: str, schema: dict) -> str:
if "turn_type" in schema: # FRAME_SCHEMA: flat, not JSON-Schema-shaped
return json.dumps(self._frame_payload, ensure_ascii=False)
if "entailed" in schema.get("properties", {}):
return json.dumps({"entailed": True, "unsupported": []})
return json.dumps(self._answer_payload, ensure_ascii=False)
def test_real_rag_agent_end_to_end_through_the_http_api():
"""F-10: the *production* orchestrator (`RagAgent`), driven through the
real `/v1/rag/query` HTTP endpoint against a real (temporary) Qdrant
collection seeded with one real corpus chunk, with a real Postgres trace
persisted and read back. Reproduces Codex's exact 2026-08-06 finding —
"no current test imports RagAgent, LlmQueryUnderstander, QueryFrame, or
retrieve_framed" and "the evaluation runner constructs an in-memory
lexical retriever and the old resolver rather than executing the same
dependency graph as the live HTTP service" — both false as of this test.
"""
from fastapi.testclient import TestClient
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams
from adapters.postgres import PostgresTraceRepository
from adapters.qdrant import QdrantParentStore, QdrantRetriever
from config import Settings
from main import create_app
from rag.agent import RagAgent
from rag.answer import GroundedAnswerService
from rag.routing import CatalogDrugResolver, QueryRoutingService
from rag.service import EvidencePolicy, RetrievalService
from rag.understanding import LlmQueryUnderstander
qdrant = QdrantClient(url="http://localhost:6333")
collection = f"integration_{uuid.uuid4().hex}"
embedder = _PlumbingEmbedder(32)
record = dict(_first_real_chunk())
traces = PostgresTraceRepository(
"postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu"
)
traces.migrate(MIGRATION)
try:
qdrant.create_collection(
collection_name=collection,
vectors_config=VectorParams(size=32, distance=Distance.COSINE),
)
qdrant.upsert(
collection_name=collection,
points=[PointStruct(
id=str(uuid.uuid4()),
vector=embedder.embed_query(record["text"]),
payload=record,
)],
wait=True,
)
retrieval = RetrievalService(
QdrantRetriever(qdrant, collection, embedder),
QdrantParentStore(qdrant, collection),
EvidencePolicy(minimum_score=0.01),
)
# A minimal one-drug catalog, real drug_id — F-04's candidate
# bounding runs for real here (`RagAgent`/`LlmQueryUnderstander`
# are not mocked), so the query must literally name the drug for
# the deterministic resolver to find it as a candidate.
resolver = CatalogDrugResolver({record["drug_id"]: {record["drug_name"]}})
llm = _FakeJsonLlm(
frame_payload={
"turn_type": "drug_attribute", "drugs": [record["drug_id"]],
"unknown_drugs": [], "attribute": None, "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": False, "clarify_reason": None,
},
answer_payload={
"answer": f"{record['text']} [1].", "evidence_sufficient": True,
},
)
understander = LlmQueryUnderstander(
llm, {record["drug_id"]: record["drug_name"]}, resolver,
)
answers = GroundedAnswerService(
QueryRoutingService(retrieval, resolver), generator=llm,
)
agent = RagAgent(understander, retrieval, answers)
app = create_app(
settings=Settings(), answer_service=answers,
conversational=agent, trace_writer=traces,
)
response = TestClient(app).post("/v1/rag/query", json={
"query": record["drug_name"],
"subject_scope": "human", "intent": "fact_lookup",
})
assert response.status_code == 200
body = response.json()
assert body["decision"] == "answerable"
assert body["resolved_drug_id"] == record["drug_id"]
assert body["citations"][0]["chunk_id"] == record["chunk_id"]
assert body["citations"][0]["printed_page_start"] == (
record["printed_page_range"][0]
)
assert record["text"] in body["answer"]
stored = traces.get(body["trace_id"])
assert stored is not None
assert stored.decision == "answerable"
assert stored.resolved_drug_id == record["drug_id"]
assert stored.citations[0]["chunk_id"] == record["chunk_id"]
finally:
with suppress(Exception):
qdrant.delete_collection(collection)
def test_api_round_trip_uses_qdrant_and_persists_postgres_trace():
from fastapi.testclient import TestClient
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams
from adapters.embedding import LocalHashQueryEmbedder
from adapters.postgres import PostgresTraceRepository
from adapters.qdrant import QdrantParentStore, QdrantRetriever
from config import Settings
@@ -119,7 +279,7 @@ def test_api_round_trip_uses_qdrant_and_persists_postgres_trace():
qdrant = QdrantClient(url="http://localhost:6333")
collection = f"integration_{uuid.uuid4().hex}"
embedder = LocalHashQueryEmbedder(32)
embedder = _PlumbingEmbedder(32)
record = dict(_first_real_chunk())
traces = PostgresTraceRepository(
"postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu"
+50
View File
@@ -0,0 +1,50 @@
"""F-05: the service must refuse to start on a corpus/model manifest
mismatch, not silently search with vectors the collection wasn't built from.
"""
import pytest
from rag.manifest import ManifestMismatch, check_manifest, manifest_collection
def test_manifest_collection_naming():
assert manifest_collection("duocthu_v1") == "duocthu_v1__manifest"
def test_matching_manifest_passes():
check_manifest(
{"model_id": "cohere.embed-v4:0", "dimensions": 1024},
"duocthu_v1", "cohere.embed-v4:0", 1024,
) # no raise
def test_missing_manifest_refuses():
with pytest.raises(ManifestMismatch, match="no corpus manifest"):
check_manifest(None, "duocthu_v1", "cohere.embed-v4:0", 1024)
def test_wrong_model_id_refuses_even_with_matching_dimensions():
"""The exact scenario the finding names: two unrelated models can both
produce 1024-dim vectors."""
with pytest.raises(ManifestMismatch, match="model_id"):
check_manifest(
{"model_id": "amazon.titan-embed-text-v2:0", "dimensions": 1024},
"duocthu_v1", "cohere.embed-v4:0", 1024,
)
def test_wrong_dimensions_refuses():
with pytest.raises(ManifestMismatch, match="dimensions"):
check_manifest(
{"model_id": "cohere.embed-v4:0", "dimensions": 768},
"duocthu_v1", "cohere.embed-v4:0", 1024,
)
def test_both_mismatched_reports_both():
with pytest.raises(ManifestMismatch) as excinfo:
check_manifest(
{"model_id": "other-model", "dimensions": 768},
"duocthu_v1", "cohere.embed-v4:0", 1024,
)
assert "model_id" in str(excinfo.value)
assert "dimensions" in str(excinfo.value)
+50
View File
@@ -0,0 +1,50 @@
"""F-02: subject_scope must be server-derived, not client-asserted.
Reproduces the Codex 2026-08-06 finding directly: a caller claiming "human"
on a veterinary query must not get that claim honored. The claim can only
make the result MORE conservative, never less.
Scoped to `subject_scope` only. `resolve_query_intent`/`QueryIntent.RECOMMENDATION`
keyword detection was tried and removed the same day: this product is for
doctors and pharmacists, and a clinician asking "nên dùng thuốc gì" is a
normal professional use of a formulary reference, not something to abstain
on. See `docs/progress-log.md` 2026-08-06.
"""
from rag.models import SubjectScope
from rag.policy import resolve_subject_scope
def test_veterinary_query_is_non_human_even_when_client_claims_human():
scope = resolve_subject_scope("Liều cho chó bị viêm khớp?", SubjectScope.HUMAN)
assert scope == SubjectScope.NON_HUMAN
def test_ordinary_dose_question_stays_human():
scope = resolve_subject_scope("Liều metformin cho người lớn?", SubjectScope.HUMAN)
assert scope == SubjectScope.HUMAN
def test_client_cannot_widen_a_server_detected_non_human_scope():
# Even an explicit non_human claim from an honest client must stick.
scope = resolve_subject_scope("thuốc cho mèo", SubjectScope.NON_HUMAN)
assert scope == SubjectScope.NON_HUMAN
def test_client_narrowing_to_non_human_is_honored_even_without_a_keyword_hit():
# A caller with better information than the keyword list can narrow.
scope = resolve_subject_scope("liều dùng", SubjectScope.NON_HUMAN)
assert scope == SubjectScope.NON_HUMAN
def test_unknown_claim_with_no_server_signal_stays_unknown():
scope = resolve_subject_scope("liều dùng", SubjectScope.UNKNOWN)
assert scope == SubjectScope.UNKNOWN
def test_clinician_asking_which_drug_is_preferred_is_not_flagged_non_human():
# A doctor/pharmacist comparing options across monographs is core,
# intended use of this product — not a request to gate.
scope = resolve_subject_scope(
"Bệnh nhân suy thận, nên dùng thuốc hạ áp nào?", SubjectScope.HUMAN
)
assert scope == SubjectScope.HUMAN
@@ -56,6 +56,82 @@ def table_service(*, visual: bool = False) -> RetrievalService:
)
class _OverviewRetriever:
"""A fake with `find_by_drug`/`find_by_section` (the Qdrant adapter's
shape) — `InMemoryLexicalRetriever` doesn't implement either, so
`retrieve_framed`'s overview path is otherwise untestable."""
def __init__(self, documents: list[RetrievalDocument]) -> None:
self._documents = documents
def find_by_drug(self, drug_id: str) -> list[SearchHit]:
return [
SearchHit(document=d, score=1.0)
for d in self._documents if d.drug_id == drug_id
]
def find_by_section(self, drug_id: str, section_key: str) -> list[SearchHit]:
return [
SearchHit(document=d, score=1.0)
for d in self._documents
if d.drug_id == drug_id and d.section_key == section_key
]
def search(self, query: str, drug_id: str, limit: int) -> list[SearchHit]:
return []
_MONOGRAPH_SECTIONS = (
"ten_chung_quoc_te", "ma_atc", "loai_thuoc", "dang_thuoc_va_ham_luong",
"duoc_ly_va_co_che_tac_dung", "chi_dinh", "chong_chi_dinh", "than_trong",
"tac_dung_khong_mong_muon", "lieu_luong_va_cach_dung", "tuong_tac_thuoc",
"qua_lieu_va_xu_tri", "do_on_dinh_va_bao_quan", "thong_tin_quy_che",
)
def _monograph_service() -> RetrievalService:
documents = [
RetrievalDocument(
doc_id=f"paracetamol::{section}::0", drug_id="paracetamol",
kind="prose", section_key=section,
text=f"Nội dung mục {section}.", source_refs=(SOURCE,),
)
for section in _MONOGRAPH_SECTIONS
]
return RetrievalService(
_OverviewRetriever(documents), InMemoryParentStore([]),
EvidencePolicy(evidence_limit=3),
)
def test_retrieve_framed_overview_answers_from_intro_sections_only():
"""Found live 2026-08-06: a bare drug name sent all 14+ sections of the
monograph as evidence, producing an answer long enough to intermittently
fail generation/entailment. `is_overview=True` must narrow this the same
way `retrieve()`'s bare-name branch always has."""
result = _monograph_service().retrieve_framed(
"paracetamol", None, "paracetamol", is_overview=True
)
assert result.decision == EvidenceDecision.ANSWERABLE
assert result.is_drug_overview is True
returned_sections = {e.matched_doc_id.split("::")[1] for e in result.evidence}
assert returned_sections <= {
"ten_chung_quoc_te", "loai_thuoc", "chi_dinh", "duoc_ly_va_co_che_tac_dung",
}
assert len(result.evidence) < len(_MONOGRAPH_SECTIONS)
def test_retrieve_framed_question_without_section_is_capped_even_without_rerank():
# No reranker configured: `_rerank` fails open and returns everything
# unfiltered. Hydration must still bound it — an ordering aid failing
# open must not also remove the size cap.
result = _monograph_service().retrieve_framed(
"paracetamol", None, "thuốc này có tác dụng phụ gì", is_overview=False
)
assert result.decision == EvidenceDecision.ANSWERABLE
assert len(result.evidence) <= 3
def test_row_hit_hydrates_complete_parent_and_keeps_citation():
result = table_service().retrieve("acetylcystein 45 kg bao nhiêu ml", "acetylcystein")
assert result.decision == EvidenceDecision.ANSWERABLE
+202
View File
@@ -0,0 +1,202 @@
"""`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.understanding import SECTION_KEY_HINTS, SECTION_KEYS, LlmQueryUnderstander
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, 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 {}
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):
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_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",)
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
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