Soften the tone of the docs and comments written today
This commit is contained in:
@@ -142,6 +142,84 @@ def test_drug_attribute_without_an_attribute_does_not_fall_into_overview_retriev
|
||||
assert retrieval.calls == []
|
||||
|
||||
|
||||
# --- the pediatric dosing gate. This code path gained its first test
|
||||
# coverage on 2026-08-11, after driving production reproduced the same
|
||||
# behaviour 5/5: the clarify question asked for both age and weight every
|
||||
# time, including the field the user had just supplied ("Bé 18 ký ...",
|
||||
# "Bé nặng 18 kg ...", "Trẻ 5 tuổi ..." all received "Bé bao nhiêu tuổi và
|
||||
# cân nặng bao nhiêu kg?"). Requiring BOTH fields is deliberate and stays —
|
||||
# the formulary bands paracetamol by age *and* by mg/kg — so these tests pin
|
||||
# the question text without loosening the requirement. --
|
||||
|
||||
|
||||
def _pediatric_agent(**frame_kwargs) -> RagAgent:
|
||||
return _agent(QueryFrame(
|
||||
turn_type="dosing_calc",
|
||||
drugs=("paracetamol_acetaminophen",),
|
||||
population="tre_em",
|
||||
**frame_kwargs,
|
||||
))
|
||||
|
||||
|
||||
def test_pediatric_gate_with_a_known_weight_asks_only_for_the_age():
|
||||
reply = _pediatric_agent(weight_kg=18.0).handle("bé 18 ký sốt cao uống paracetamol liều bao nhiêu")
|
||||
|
||||
assert reply.decision == "clarify"
|
||||
assert reply.reason == "missing_pediatric_age_or_weight"
|
||||
assert "tuổi" in reply.clarification
|
||||
# The weight is echoed back so the user can see it was received (and
|
||||
# catch a mis-parse), but is never asked for again.
|
||||
assert "18 kg" in reply.clarification
|
||||
assert "bao nhiêu kg" not in reply.clarification
|
||||
|
||||
|
||||
def test_pediatric_gate_with_a_known_age_asks_only_for_the_weight():
|
||||
reply = _pediatric_agent(age_text="5 tuổi").handle("trẻ 5 tuổi sốt cao uống paracetamol liều bao nhiêu")
|
||||
|
||||
assert reply.decision == "clarify"
|
||||
assert "kg" in reply.clarification
|
||||
assert "bao nhiêu tuổi" not in reply.clarification
|
||||
|
||||
|
||||
def test_pediatric_gate_with_neither_field_still_asks_for_both():
|
||||
reply = _pediatric_agent().handle("liều paracetamol cho trẻ em")
|
||||
|
||||
assert reply.decision == "clarify"
|
||||
assert "tuổi" in reply.clarification
|
||||
assert "kg" in reply.clarification
|
||||
|
||||
|
||||
def test_pediatric_gate_still_requires_both_fields_before_answering():
|
||||
"""The safety property, asserted directly: knowing only one of the two
|
||||
must NOT be treated as enough to pick a regimen. Both single-field cases
|
||||
above stop at `clarify` — this states the invariant so a future change
|
||||
that "helpfully" answers with weight alone fails here loudly."""
|
||||
for kwargs in ({"weight_kg": 18.0}, {"age_text": "5 tuổi"}, {}):
|
||||
reply = _pediatric_agent(**kwargs).handle("liều paracetamol cho bé")
|
||||
assert reply.decision == "clarify", kwargs
|
||||
assert reply.reason == "missing_pediatric_age_or_weight", kwargs
|
||||
|
||||
|
||||
def test_a_whole_number_weight_is_not_echoed_with_a_trailing_zero():
|
||||
reply = _pediatric_agent(weight_kg=18.0).handle("liều paracetamol cho bé 18 ký")
|
||||
|
||||
assert "18 kg" in reply.clarification
|
||||
assert "18.0" not in reply.clarification
|
||||
|
||||
|
||||
def test_the_models_own_clarify_question_still_wins_over_the_generated_one():
|
||||
"""Unchanged precedence: when understanding.py produced a question of its
|
||||
own it is still preferred, because it can see phrasing/context this
|
||||
code-level fallback cannot."""
|
||||
reply = _pediatric_agent(
|
||||
weight_kg=18.0,
|
||||
needs_clarify=True,
|
||||
clarify_reason="Bé mấy tháng tuổi rồi ạ?",
|
||||
).handle("liều paracetamol cho bé 18 ký")
|
||||
|
||||
assert reply.clarification == "Bé mấy tháng tuổi rồi ạ?"
|
||||
|
||||
|
||||
def test_needs_clarify_frame_is_surfaced_directly():
|
||||
agent = _agent(QueryFrame(
|
||||
turn_type="dosing_calc", drugs=("paracetamol",),
|
||||
|
||||
@@ -12,6 +12,7 @@ import pytest
|
||||
|
||||
from rag import grounding
|
||||
from rag.answer import GroundedAnswerService
|
||||
from rag.budget import RequestBudgetExhausted
|
||||
from rag.metrics import GENERATION_REJECTED, GENERATION_SERVED, InMemoryMetrics
|
||||
from rag.models import (
|
||||
Evidence,
|
||||
@@ -319,6 +320,16 @@ def test_entailment_accepts_after_one_semantic_pass():
|
||||
|
||||
|
||||
def test_entailment_provider_outage_fails_closed_to_abstain():
|
||||
"""Fail-closed is unchanged; only the label it fails closed *under* is.
|
||||
|
||||
This previously asserted `unsupported_claim`, which reports a claim the
|
||||
evidence did not support, in a case where the judge was never reachable.
|
||||
`apps/web/app/api/chat/route.ts` renders that as "bước đối chiếu chưa
|
||||
xác nhận được câu trả lời khớp với nguồn", describing the answer rather
|
||||
than the outage, and places it in the content-failure bucket that the
|
||||
failure taxonomy in `docs/current-rag-pipeline-audit.md` §4 keeps
|
||||
separate from availability.
|
||||
"""
|
||||
grounded, metrics = _answer(
|
||||
{"claims": [{"text": "Người lớn: 500 mg, 2 lần/ngày", "citations": [1]}],
|
||||
"evidence_sufficient": True},
|
||||
@@ -328,7 +339,125 @@ def test_entailment_provider_outage_fails_closed_to_abstain():
|
||||
assert grounded.generated is False
|
||||
assert grounded.answer is None
|
||||
assert grounded.result.decision == EvidenceDecision.ABSTAIN
|
||||
assert grounded.result.reason == "provider_unavailable"
|
||||
assert metrics.total(GENERATION_REJECTED, reason="provider_unavailable") == 1
|
||||
# And specifically NOT counted as a content failure.
|
||||
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 0
|
||||
|
||||
|
||||
def test_entailment_budget_exhaustion_is_reported_as_a_timeout_not_a_bad_claim():
|
||||
"""Observed live 2026-08-11 against production.
|
||||
|
||||
`RequestBudgetExhausted` subclasses `AnswerGenerationUnavailable`, so it
|
||||
has to be caught first to be distinguishable from an ordinary outage;
|
||||
previously both arrived as `unsupported_claim`. The user-facing string
|
||||
for `request_budget_exhausted` already exists in the BFF mapping, so no
|
||||
new reason code is introduced here.
|
||||
"""
|
||||
grounded, metrics = _answer(
|
||||
{"claims": [{"text": "Người lớn: 500 mg, 2 lần/ngày", "citations": [1]}],
|
||||
"evidence_sufficient": True},
|
||||
entailment_payload=RequestBudgetExhausted(),
|
||||
)
|
||||
|
||||
assert grounded.generated is False
|
||||
assert grounded.answer is None
|
||||
assert grounded.result.decision == EvidenceDecision.ABSTAIN
|
||||
assert grounded.result.reason == "request_budget_exhausted"
|
||||
assert metrics.total(GENERATION_REJECTED, reason="request_budget_exhausted") == 1
|
||||
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 0
|
||||
assert metrics.total(GENERATION_REJECTED, reason="provider_unavailable") == 0
|
||||
|
||||
|
||||
def test_unparseable_judge_reply_is_reported_as_malformed_not_as_a_bad_claim():
|
||||
"""A judge reply this code cannot read is not a verdict against the answer."""
|
||||
grounded, metrics = _answer(
|
||||
{"claims": [{"text": "Người lớn: 500 mg, 2 lần/ngày", "citations": [1]}],
|
||||
"evidence_sufficient": True},
|
||||
entailment_payload="{not json at all",
|
||||
)
|
||||
|
||||
assert grounded.generated is False
|
||||
assert grounded.answer is None
|
||||
assert grounded.result.reason == "malformed_output"
|
||||
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 0
|
||||
|
||||
|
||||
def test_budget_running_out_during_completeness_repair_is_not_called_incomplete():
|
||||
"""Pins the production case observed live 2026-08-11.
|
||||
|
||||
"Liều dùng của Isosorbid dinitrat theo Dược thư là gì?" took 40.3s
|
||||
against a 40s budget and returned `incomplete_answer`, whose user-facing
|
||||
text says the answer was cancelled because the source had information it
|
||||
left out — while what actually happened is that the repair generation did
|
||||
not run to completion. The completeness repair roughly doubles a turn's
|
||||
model calls, so it is the likeliest place to exhaust the budget, and it
|
||||
reports that the same way the first attempt does.
|
||||
"""
|
||||
grounded, metrics = _answer(
|
||||
[
|
||||
{"claims": [{"text": "Người lớn uống 500 mg", "citations": [1]}],
|
||||
"evidence_sufficient": True},
|
||||
RequestBudgetExhausted(),
|
||||
],
|
||||
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 == "request_budget_exhausted"
|
||||
assert metrics.total(GENERATION_REJECTED, reason="request_budget_exhausted") == 1
|
||||
# The user must not be told their answer was missing source information
|
||||
# when the repair simply ran out of time.
|
||||
assert metrics.total(GENERATION_REJECTED, reason="incomplete_answer") == 0
|
||||
|
||||
|
||||
def test_a_genuinely_incomplete_repair_is_still_called_incomplete():
|
||||
"""Guards the other side of the split above: when the repair really does
|
||||
run and still comes back incomplete, `incomplete_answer` must survive."""
|
||||
incomplete_verdict = {
|
||||
"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,
|
||||
}],
|
||||
}
|
||||
grounded, metrics = _answer(
|
||||
{"claims": [{"text": "Người lớn uống 500 mg", "citations": [1]}],
|
||||
"evidence_sufficient": True},
|
||||
entailment_payload=[incomplete_verdict, incomplete_verdict],
|
||||
)
|
||||
|
||||
assert grounded.answer is None
|
||||
assert grounded.result.reason == "incomplete_answer"
|
||||
assert metrics.total(GENERATION_REJECTED, reason="incomplete_answer") == 1
|
||||
assert metrics.total(GENERATION_REJECTED, reason="request_budget_exhausted") == 0
|
||||
|
||||
|
||||
def test_a_real_negative_verdict_is_still_an_unsupported_claim():
|
||||
"""The counterpart to the three tests above: when the judge DID run and
|
||||
said no, the reason must stay a content failure. Splitting the
|
||||
availability cases out must not quietly reclassify genuine rejections."""
|
||||
grounded, metrics = _answer(
|
||||
{"claims": [{"text": "Metformin chữa ung thư", "citations": [1]}],
|
||||
"evidence_sufficient": True},
|
||||
entailment_payload={"entailed": False, "unsupported": [1]},
|
||||
)
|
||||
|
||||
assert grounded.generated is False
|
||||
assert grounded.result.reason == "unsupported_claim"
|
||||
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 1
|
||||
assert metrics.total(GENERATION_REJECTED, reason="provider_unavailable") == 0
|
||||
assert metrics.total(GENERATION_REJECTED, reason="request_budget_exhausted") == 0
|
||||
|
||||
|
||||
def test_entailment_check_is_skipped_when_there_are_no_claims():
|
||||
|
||||
Reference in New Issue
Block a user