Remove corpus counts from chat chrome
This commit is contained in:
@@ -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": [],
|
||||
},
|
||||
|
||||
@@ -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": ["Có", "Không"],
|
||||
}), CATALOG, RESOLVER)
|
||||
|
||||
frame = understander.understand("chống chỉ định paracetamol")
|
||||
|
||||
assert frame.needs_clarify is False
|
||||
assert frame.quick_replies == ()
|
||||
|
||||
|
||||
def test_missing_quick_replies_key_defaults_to_empty_not_a_crash():
|
||||
"""The model is asked for `quick_replies` but structured-output providers
|
||||
aren't guaranteed to include every optional key — a clarify without it
|
||||
@@ -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": [],
|
||||
|
||||
Reference in New Issue
Block a user