Remove corpus counts from chat chrome

This commit is contained in:
2026-08-10 17:26:58 +07:00
parent 46469468bb
commit 97cb6d16f4
31 changed files with 2192 additions and 424 deletions
+201 -2
View File
@@ -124,6 +124,24 @@ def test_no_drug_named_asks_which_one():
assert reply.reason == "no_drug"
def test_drug_attribute_without_an_attribute_does_not_fall_into_overview_retrieval():
retrieval = _FixedRetrieval({})
answers = GroundedAnswerService(routing=None)
agent = RagAgent(
_FixedUnderstander(QueryFrame(
turn_type="drug_attribute", drugs=("paracetamol_acetaminophen",)
)),
retrieval,
answers,
)
reply = agent.handle("paracetamol thì sao?")
assert reply.decision == "clarify"
assert reply.reason == "missing_attribute"
assert retrieval.calls == []
def test_needs_clarify_frame_is_surfaced_directly():
agent = _agent(QueryFrame(
turn_type="dosing_calc", drugs=("paracetamol",),
@@ -137,6 +155,22 @@ def test_needs_clarify_frame_is_surfaced_directly():
assert reply.quick_replies == ()
def test_understanding_provider_failure_is_an_abstain_not_a_fake_clarification():
agent = _agent(QueryFrame(
turn_type="out_of_scope",
needs_clarify=True,
clarify_reason="Dịch vụ đang gặp sự cố tạm thời.",
system_error="understanding_provider_unavailable",
))
reply = agent.handle("liều paracetamol")
assert reply.decision == "abstain"
assert reply.reason == "understanding_provider_unavailable"
assert reply.answer == "Dịch vụ đang gặp sự cố tạm thời."
assert reply.clarification is None
def test_needs_clarify_frame_carries_quick_replies_through():
"""This is the path real traffic actually hits (checked live): the
understanding LLM call itself sets needs_clarify/clarify_reason before
@@ -153,6 +187,167 @@ def test_needs_clarify_frame_carries_quick_replies_through():
assert reply.quick_replies == ("Người lớn", "Trẻ em")
def test_dosing_without_route_asks_only_when_evidence_is_ambiguous():
result = RetrievalResult(
EvidenceDecision.ANSWERABLE,
"grounded_evidence_available",
(
_evidence("Đường uống, người lớn: 500 mg mỗi lần."),
_evidence("Đặt trực tràng, người lớn: 500 mg mỗi lần."),
),
resolved_drug_id="paracetamol_acetaminophen",
)
class _Generator:
def generate(self, system, user, schema):
return (
'{"claims": [], "evidence_sufficient": false, '
'"clarifying_question": "Anh/chị muốn dùng đường nào?", '
'"quick_replies": ["Uống", "Đặt trực tràng"]}'
)
retrieval = _FixedRetrieval({"paracetamol_acetaminophen": result})
answers = GroundedAnswerService(routing=None, generator=_Generator())
agent = RagAgent(
_FixedUnderstander(QueryFrame(
turn_type="dosing_calc",
drugs=("paracetamol_acetaminophen",),
population="nguoi_lon",
)),
retrieval,
answers,
)
reply = agent.handle("Người lớn", conversation_id="dose-route")
assert reply.decision == "clarify"
assert reply.reason == "needs_more_info"
assert reply.quick_replies == ("Uống", "Đặt trực tràng")
assert len(retrieval.calls) == 1
remembered = agent._last_frame["dose-route"]
assert remembered.needs_clarify is True
assert remembered.population == "nguoi_lon"
assert remembered.clarify_reason == reply.clarification
def test_dosing_without_route_answers_directly_when_evidence_has_one_route():
result = RetrievalResult(
EvidenceDecision.ANSWERABLE,
"grounded_evidence_available",
(_evidence("Đường uống, người lớn: 500 mg mỗi lần."),),
resolved_drug_id="paracetamol_acetaminophen",
)
class _Generator:
def generate(self, system, user, schema):
if "entailed" in schema.get("properties", {}):
return '{"entailed": true, "unsupported": []}'
return (
'{"claims": [{"text": "Đường uống, người lớn: 500 mg mỗi lần.", '
'"citations": [1]}], "evidence_sufficient": true, '
'"clarifying_question": null, "quick_replies": []}'
)
retrieval = _FixedRetrieval({"paracetamol_acetaminophen": result})
answers = GroundedAnswerService(routing=None, generator=_Generator())
agent = RagAgent(
_FixedUnderstander(QueryFrame(
turn_type="dosing_calc",
drugs=("paracetamol_acetaminophen",),
population="nguoi_lon",
)),
retrieval,
answers,
)
reply = agent.handle("Liều Paracetamol cho người lớn")
assert reply.decision == "answerable"
assert reply.quick_replies == ()
assert "500 mg" in reply.answer
assert len(retrieval.calls) == 1
def test_general_dosage_section_survey_does_not_force_population_chip():
result = RetrievalResult(
EvidenceDecision.ANSWERABLE,
"grounded_evidence_available",
(_evidence("Người lớn: uống 10 mg. Trẻ em: liều theo cân nặng."),),
resolved_drug_id="example",
)
retrieval = _FixedRetrieval({"example": result})
agent = RagAgent(
_FixedUnderstander(QueryFrame(
turn_type="dosing_calc",
drugs=("example",),
needs_clarify=True,
clarify_reason="Người lớn hay trẻ em?",
)),
retrieval,
GroundedAnswerService(routing=None),
)
reply = agent.handle(
"Dược thư hướng dẫn dùng Example thế nào: đường dùng và các liều nếu có?"
)
assert reply.decision == "answerable"
assert reply.quick_replies == ()
assert "tra cứu tổng quan toàn mục" in retrieval.calls[0][2]
def test_clear_precaution_section_survey_overrides_model_overclarification():
result = RetrievalResult(
EvidenceDecision.ANSWERABLE,
"grounded_evidence_available",
(_evidence("Theo dõi chức năng thận và điện giải."),),
resolved_drug_id="example",
)
agent = _agent(
QueryFrame(
turn_type="drug_attribute",
drugs=("example",),
attribute="than_trong",
needs_clarify=True,
clarify_reason="Muốn hỏi thận trọng hay chống chỉ định?",
),
{"example": result},
)
reply = agent.handle("Những tình huống nào cần thận trọng khi dùng Example?")
assert reply.decision == "answerable"
assert reply.quick_replies == ()
def test_complete_adult_dosing_core_ignores_an_irrelevant_weight_reask():
result = RetrievalResult(
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
(_evidence("Đường uống, người lớn: 500 mg mỗi lần."),),
resolved_drug_id="paracetamol_acetaminophen",
)
retrieval = _FixedRetrieval({"paracetamol_acetaminophen": result})
answers = GroundedAnswerService(routing=None)
agent = RagAgent(
_FixedUnderstander(QueryFrame(
turn_type="dosing_calc",
drugs=("paracetamol_acetaminophen",),
population="nguoi_lon",
route="uong",
needs_clarify=True,
clarify_reason="Cân nặng của người lớn là bao nhiêu kg?",
)),
retrieval,
answers,
)
reply = agent.handle("Uống")
assert reply.decision == "answerable"
assert len(retrieval.calls) == 1
assert retrieval.calls[0][1] == "lieu_luong_va_cach_dung"
def test_single_drug_attribute_retrieves_and_answers():
result = RetrievalResult(
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
@@ -490,7 +685,10 @@ def test_a_generous_budget_does_not_change_normal_behaviour():
return '{"sufficient": true, "clarifying_question": null, "quick_replies": []}'
if "entailed" in schema.get("properties", {}):
return '{"entailed": true, "unsupported": []}'
return '{"answer": "Liều 500 mg [1].", "evidence_sufficient": true, "clarifying_question": null}'
return (
'{"claims": [{"text": "Liều 500 mg", "citations": [1]}], '
'"evidence_sufficient": true, "clarifying_question": null}'
)
answers = GroundedAnswerService(routing=None, generator=_Generator())
agent = RagAgent(
@@ -500,7 +698,8 @@ def test_a_generous_budget_does_not_change_normal_behaviour():
reply = agent.handle("liều metformin")
assert reply.decision == "answerable"
assert reply.answer == "Liều 500 mg [1]."
assert reply.answer == "Liều 500 mg"
assert reply.blocks[0].claims[0].source_ids
# --- durable conversation history (ADR 0008's named gap): an optional
@@ -36,7 +36,8 @@ def test_answer_uses_only_printed_page_citations():
(evidence(source),), "abacavir", "resolved",
)))
answer = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
assert answer.answer == "Liều được ghi trong nguồn. [1]"
assert answer.answer == "Liều được ghi trong nguồn."
assert answer.blocks[0].claims[0].source_ids
assert answer.citations[0].printed_page_start == 101
assert answer.citations[0].printed_page_end == 103
@@ -84,7 +84,8 @@ def test_only_cited_sources_are_returned():
result = _answerable(_evidence(0, 100), _evidence(1, 200), _evidence(2, 300))
service = GroundedAnswerService(
_Routing(result),
_Generator({"answer": "Chỉ dùng đoạn hai [2].", "evidence_sufficient": True}),
_Generator({"claims": [{"text": "Chỉ dùng đoạn hai", "citations": [2]}],
"evidence_sufficient": True}),
)
grounded = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
@@ -105,7 +106,8 @@ def test_answer_citing_nothing_is_rejected_not_dressed_up_with_borrowed_citation
# 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}),
_Generator({"claims": [{"text": "Không có trích dẫn.", "citations": []}],
"evidence_sufficient": True}),
)
grounded = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
@@ -157,8 +159,8 @@ def test_sufficiency_check_outage_fails_open_to_generation_not_abstain():
the real safety net on whatever gets generated next."""
result = _answerable(_evidence(0, 100), _evidence(1, 200))
gen = _Generator(
{"answer": "Đoạn bằng chứng 0 [1].", "evidence_sufficient": True,
"clarifying_question": None},
{"claims": [{"text": "Đoạn bằng chứng 0", "citations": [1]}],
"evidence_sufficient": True, "clarifying_question": None},
sufficiency_payload=AnswerGenerationUnavailable("Bedrock unreachable"),
)
service = GroundedAnswerService(_Routing(result), gen)
@@ -177,8 +179,8 @@ def test_sufficient_query_is_not_turned_into_a_clarification():
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.
# Sufficiency passes; generation then runs (its payload lacks answer keys,
# so it fails closed) — the point is no clarification fired.
assert g.clarification is None
@@ -208,8 +210,10 @@ def test_list_mode_skips_the_sufficiency_clarify():
must never call it at all, so only the real answer payload is ever read."""
result = _answerable(_evidence(0, 100), _evidence(1, 200))
gen = _Generator(
{"answer": "Đoạn bằng chứng 0 [1]. Đoạn bằng chứng 1 [2].",
"evidence_sufficient": True, "clarifying_question": None},
{"claims": [
{"text": "Đoạn bằng chứng 0", "citations": [1]},
{"text": "Đoạn bằng chứng 1", "citations": [2]},
], "evidence_sufficient": True, "clarifying_question": None},
sufficiency_payload={
"sufficient": False, "clarifying_question": "Loại nào?", "quick_replies": [],
},
+130 -75
View File
@@ -67,18 +67,14 @@ class _FixedRouting:
class _Generator:
"""Returns whatever payload the test wants the model to have produced.
`_generate` now makes up to five calls through this port: the main
answer (a lone `evidence_sufficient: false` retries once — the same
noisy-judge finding as entailment, live-confirmed 2026-08-07), a
sufficiency check (skipped here — one evidence block), and up to three
entailment calls (widened from two 2026-08-07: live probing found the
judge noisy on an identical claim/evidence pair, and a real adversarial
sample showed a single retry still discarding correct answers on the
unlucky reject-reject draw). They're told apart by schema, so a test
`_generate` can make a main answer call (a lone
`evidence_sufficient: false` retries once) and one fail-closed entailment
call. The legacy direct-answer path may also make a sufficiency call when
several evidence blocks need disambiguation; the structured agent path
skips that duplicate judgment. They're told apart by schema, so a test
that only cares about one call doesn't have to fake the others; `payload`
and `entailment_payload` each take either a fixed value or a list for a
different answer on each successive call to that schema (e.g.
`[reject, reject, accept]` for the third-attempt-recovers case).
different answer on each successive call to that schema.
"""
def __init__(self, payload, entailment_payload=None) -> None:
@@ -124,7 +120,7 @@ def _answer(payload, result: RetrievalResult | None = None, entailment_payload=N
def test_invented_dose_is_refused_and_never_reaches_the_answer():
grounded, metrics = _answer(
{"answer": "Người lớn uống 850 mg, 2 lần mỗi ngày [1].",
{"claims": [{"text": "Người lớn uống 850 mg, 2 lần mỗi ngày", "citations": [1]}],
"evidence_sufficient": True}
)
@@ -147,7 +143,8 @@ def test_a_rounded_figure_counts_as_invented():
"""`2 g` is in the source; `2000 mg` is a conversion, and conversions are
where unit errors live. The prompt forbids it and the check enforces it."""
grounded, metrics = _answer(
{"answer": "Liều tối đa 2000 mg mỗi ngày [1].", "evidence_sufficient": True}
{"claims": [{"text": "Liều tối đa 2000 mg mỗi ngày", "citations": [1]}],
"evidence_sufficient": True}
)
assert grounded.generated is False
@@ -156,7 +153,8 @@ def test_a_rounded_figure_counts_as_invented():
def test_citation_pointing_at_nothing_is_refused():
grounded, metrics = _answer(
{"answer": "Người lớn uống 500 mg [3].", "evidence_sufficient": True}
{"claims": [{"text": "Người lớn uống 500 mg", "citations": [3]}],
"evidence_sufficient": True}
)
assert grounded.generated is False
@@ -170,12 +168,13 @@ def test_citation_pointing_at_nothing_is_refused():
def test_faithful_rewrite_is_served():
grounded, metrics = _answer(
{"answer": "Người lớn: 500 mg, 2 lần/ngày; tối đa 2 g/ngày [1].",
{"claims": [{"text": "Người lớn: 500 mg, 2 lần/ngày; tối đa 2 g/ngày", "citations": [1]}],
"evidence_sufficient": True}
)
assert grounded.generated is True
assert grounded.answer == "Người lớn: 500 mg, 2 lần/ngày; tối đa 2 g/ngày [1]."
assert grounded.answer == "Người lớn: 500 mg, 2 lần/ngày; tối đa 2 g/ngày"
assert grounded.blocks[0].claims[0].source_ids == ("metformin::lieu::0",)
assert metrics.total(GENERATION_SERVED) == 1
assert metrics.total(GENERATION_REJECTED) == 0
@@ -190,7 +189,7 @@ def test_fabricated_nonnumeric_claim_with_a_valid_citation_is_rejected():
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},
{"claims": [{"text": "Metformin chữa ung thư", "citations": [1]}], "evidence_sufficient": True},
entailment_payload={"entailed": False, "unsupported": [1]},
)
@@ -202,7 +201,7 @@ def test_fabricated_nonnumeric_claim_with_a_valid_citation_is_rejected():
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].",
{"claims": [{"text": "Metformin dùng điều trị đái tháo đường", "citations": [1]}],
"evidence_sufficient": True},
entailment_payload={"entailed": True, "unsupported": []},
)
@@ -211,13 +210,15 @@ def test_entailment_check_running_and_passing_still_serves_the_answer():
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."""
def test_entailment_rejects_after_one_fail_closed_semantic_pass():
"""The verifier is one semantic pass after deterministic grounding.
Repeating an identical temperature-0 prompt against the same model is a
correlated retry, not an independent vote, and doubled the hot-path model
latency for every valid answer.
"""
grounded, metrics = _answer(
{"answer": "Metformin dùng điều trị đái tháo đường [1].",
{"claims": [{"text": "Metformin chữa ung thư", "citations": [1]}],
"evidence_sufficient": True},
entailment_payload=[
{"entailed": False, "unsupported": [1]},
@@ -225,52 +226,102 @@ def test_entailment_retries_once_after_a_reject_and_a_later_accept_serves():
],
)
assert grounded.generated is True
assert metrics.total(GENERATION_SERVED) == 1
def test_entailment_recovers_on_third_attempt_after_two_rejects():
"""The improvement 2026-08-07 widened the retry from 2 to 3 attempts
after a live 50-question adversarial sample found the 2-attempt policy's
own math (~11% false-discard rate on a genuinely valid claim, from the
noise probed in the docstring above) matched the observed real
abstention rate almost exactly. Two rejects followed by a real accept
must now be served, not discarded."""
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": False, "unsupported": [1]},
{"entailed": True, "unsupported": []},
],
)
assert grounded.generated is True
assert metrics.total(GENERATION_SERVED) == 1
assert metrics.total(GENERATION_REJECTED) == 0
def test_entailment_three_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]},
{"entailed": False, "unsupported": [1]},
],
)
assert grounded.generated is False
assert grounded.answer is None
# All 3 attempts are noisy-judge calls against the SAME claim/evidence —
# a real, reliable rejection must still discard exactly once, not 3x.
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 1
def test_supported_but_incomplete_answer_is_rejected_against_full_raw_evidence():
grounded, metrics = _answer(
{
"claims": [{"text": "Người lớn uống 500 mg", "citations": [1]}],
"evidence_sufficient": True,
},
entailment_payload={
"entailed": True,
"unsupported": [],
"complete": False,
"missing_evidence": [{
"description": "2 lần mỗi ngày và liều tối đa 2 g mỗi ngày",
"evidence_quote": EVIDENCE_TEXT,
}],
},
)
assert grounded.answer is None
assert grounded.result.reason == "incomplete_answer"
assert metrics.total(GENERATION_REJECTED, reason="incomplete_answer") == 1
def test_completeness_judge_cannot_claim_its_own_quoted_fact_is_missing():
grounded, _ = _answer(
{
"claims": [{
"text": "Chảy máu giữa vòng kinh (rất hay gặp trong 3 tháng đầu dùng thuốc theo đường tiêm).",
"citations": [1],
}],
"evidence_sufficient": True,
},
result=_result(
"Chảy máu giữa vòng kinh (rất hay gặp trong 3 tháng đầu dùng thuốc theo đường tiêm)."
),
entailment_payload={
"entailed": True,
"unsupported": [],
"complete": False,
"missing_evidence": [{
"description": "Không ghi nhận 'rất hay gặp trong 3 tháng đầu dùng thuốc theo đường tiêm'",
"evidence_quote": "rất hay gặp trong 3 tháng đầu dùng thuốc theo đường tiêm",
}],
},
)
assert grounded.generated is True
assert grounded.answer is not None
def test_completeness_objection_without_a_real_source_quote_is_ignored():
grounded, _ = _answer(
{
"claims": [{
"text": "Người lớn: uống 500 mg metformin hydroclorid, 2 lần mỗi ngày. Liều tối đa 2 g mỗi ngày, chia làm nhiều lần.",
"citations": [1],
}],
"evidence_sufficient": True,
},
entailment_payload={
"entailed": True,
"unsupported": [],
"complete": False,
"missing_evidence": [{
"description": "Không nêu điều kiện độ ẩm",
"evidence_quote": "độ ẩm",
}],
},
)
assert grounded.generated is True
assert grounded.answer is not None
def test_entailment_accepts_after_one_semantic_pass():
grounded, metrics = _answer(
{"claims": [{"text": "Metformin dùng điều trị đái tháo đường", "citations": [1]}],
"evidence_sufficient": True},
entailment_payload=[
{"entailed": True, "unsupported": []},
{"entailed": False, "unsupported": [1]}, # never consulted
],
)
assert grounded.generated is True
assert metrics.total(GENERATION_SERVED) == 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},
{"claims": [{"text": "Người lớn: 500 mg, 2 lần/ngày", "citations": [1]}],
"evidence_sufficient": True},
entailment_payload=AnswerGenerationUnavailable(),
)
@@ -280,13 +331,16 @@ def test_entailment_provider_outage_fails_closed_to_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
def test_entailment_check_is_skipped_when_there_are_no_claims():
"""No claims at all (2026-08-10: the structured-claims schema makes a
claim's `text` a required, non-empty field, so the old "answer is
nothing but a bare citation marker" scenario can no longer occur — the
analogous edge case is an empty `claims` list) has nothing 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},
{"claims": [], "evidence_sufficient": True},
entailment_payload=AnswerGenerationUnavailable(),
)
@@ -297,7 +351,8 @@ def test_entailment_check_is_skipped_when_the_answer_has_no_claim_text():
def test_citations_survive_generation():
"""Provenance is the point; a prettier answer must not cost the folio."""
grounded, _ = _answer(
{"answer": "Người lớn: 500 mg [1].", "evidence_sufficient": True}
{"claims": [{"text": "Người lớn: 500 mg", "citations": [1]}],
"evidence_sufficient": True}
)
assert grounded.generated is True
@@ -313,9 +368,9 @@ def test_citations_survive_generation():
[
(AnswerGenerationUnavailable("revoked"), "provider_unavailable"),
("not json at all", "malformed_output"),
({"answer": "500 mg [1]"}, "malformed_output"),
({"answer": 500, "evidence_sufficient": True}, "malformed_output"),
({"answer": "...", "evidence_sufficient": False}, "evidence_insufficient"),
({"claims": [{"text": "500 mg", "citations": [1]}]}, "malformed_output"),
({"claims": 500, "evidence_sufficient": True}, "malformed_output"),
({"claims": [], "evidence_sufficient": False}, "evidence_insufficient"),
],
)
def test_every_generation_failure_abstains_instead_of_a_raw_source_dump(payload, reason):
@@ -340,21 +395,21 @@ def test_evidence_insufficient_retries_once_and_recovers():
pattern already known for entailment, just on a different field of the
same call. A lone insufficient verdict must not be final."""
grounded, metrics = _answer([
{"answer": "...", "evidence_sufficient": False},
{"answer": "Metformin dùng điều trị đái tháo đường [1].",
{"claims": [], "evidence_sufficient": False},
{"claims": [{"text": "Metformin dùng điều trị đái tháo đường", "citations": [1]}],
"evidence_sufficient": True},
])
assert grounded.generated is True
assert grounded.answer == "Metformin dùng điều trị đái tháo đường [1]."
assert grounded.answer == "Metformin dùng điều trị đái tháo đường"
assert metrics.total(GENERATION_SERVED) == 1
assert metrics.total(GENERATION_REJECTED) == 0
def test_evidence_insufficient_twice_still_abstains():
grounded, metrics = _answer([
{"answer": "...", "evidence_sufficient": False},
{"answer": "...", "evidence_sufficient": False},
{"claims": [], "evidence_sufficient": False},
{"claims": [], "evidence_sufficient": False},
])
assert grounded.generated is False
@@ -259,7 +259,8 @@ def test_real_rag_agent_end_to_end_through_the_http_api():
"needs_clarify": False, "clarify_reason": None,
},
answer_payload={
"answer": f"{record['text']} [1].", "evidence_sufficient": True,
"claims": [{"text": record["text"], "citations": [1]}],
"evidence_sufficient": True,
},
)
understander = LlmQueryUnderstander(
@@ -197,6 +197,34 @@ def test_retrieve_framed_pools_lexically_strong_neighbour_section():
]
def test_explicit_dosage_section_does_not_pool_a_lexical_interaction_match():
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 ("lieu_luong_va_cach_dung", "tuong_tac_thuoc")
]
interaction = next(d for d in documents if d.section_key == "tuong_tac_thuoc")
retriever = _OverviewRetriever(
documents, lexical_hits=[SearchHit(interaction, score=10.0)]
)
service = RetrievalService(retriever, InMemoryParentStore([]), EvidencePolicy())
result = service.retrieve_framed(
"paracetamol", "lieu_luong_va_cach_dung",
"Liều uống paracetamol cho người lớn",
)
assert result.decision == EvidenceDecision.ANSWERABLE
returned_sections = {
evidence.matched_doc_id.split("::")[1] for evidence in result.evidence
}
assert returned_sections == {"lieu_luong_va_cach_dung"}
assert retriever.lexical_calls == []
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
@@ -318,6 +346,18 @@ def test_verified_aliases_reach_common_parenthesized_drug_names():
)
def test_autocomplete_prioritizes_canonical_name_over_an_unrelated_trade_alias():
resolver = CatalogDrugResolver({
"paracetamol_acetaminophen": {"paracetamol", "acetaminophen"},
"galantamin": {"paragal"},
"metformin": {"metformin"},
"alpha_tocopherol_vitamin_e": {"met-alpha"},
})
assert resolver.complete("para", k=2)[0] == "paracetamol_acetaminophen"
assert resolver.complete("met", k=2)[0] == "metformin"
def test_verified_catalog_protects_canonical_substring_traps():
resolver = CatalogDrugResolver(load_aliases(VERIFIED_ENTITIES))
traps = {
@@ -51,6 +51,7 @@ class _FakeResolver:
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()
@@ -60,6 +61,7 @@ class _FakeResolver:
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
@@ -84,6 +86,21 @@ def test_drug_id_in_exact_underscore_form_resolves():
assert frame.unknown_drugs == ()
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
@@ -202,6 +219,40 @@ def test_quick_replies_are_parsed_when_the_model_offers_them():
assert frame.quick_replies == ("Người lớn", "Trẻ em")
def test_quick_replies_are_dynamic_but_bounded_before_becoming_ui_chips():
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",
],
}), CATALOG, RESOLVER)
frame = understander.understand("liều paracetamol")
assert frame.quick_replies == (
"Người lớn", "Trẻ em", "Phụ nữ có thai", "Người cao tuổi"
)
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
@@ -240,6 +291,26 @@ def test_route_is_parsed_when_the_model_resolves_it():
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": [],