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

687 lines
27 KiB
Python

"""The answer layer may rephrase evidence; it may not add to it.
Every test here is a fabrication the generator could plausibly produce, and
the assertion is that the clinician never sees it. The dose figures are taken
from the real METFORMIN and PARACETAMOL sections in `duocthu_v1`.
"""
from __future__ import annotations
import json
from dataclasses import replace
import pytest
from rag import grounding
from rag.answer import DISCLAIMER, GroundedAnswerService
from rag.budget import RequestBudgetExhausted
from rag.metrics import GENERATION_REJECTED, GENERATION_SERVED, InMemoryMetrics
from rag.models import (
Evidence,
EvidenceDecision,
QueryIntent,
RetrievalResult,
SourceRef,
SubjectScope,
)
from rag.ports import AnswerGenerationUnavailable
from rag.prompt import build_request
SOURCE = SourceRef(
physical_page=812,
precision="region",
printed_page_range=(714, 714),
)
EVIDENCE_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."
)
def _result(text: str = EVIDENCE_TEXT) -> RetrievalResult:
return RetrievalResult(
EvidenceDecision.ANSWERABLE,
"grounded_evidence_available",
(
Evidence(
evidence_id="metformin::lieu::0",
matched_doc_id="metformin::lieu::0",
kind="prose",
text=text,
score=1.0,
source_refs=(SOURCE,),
hydrated_from_parent=False,
requires_visual_check=False,
),
),
resolved_drug_id="metformin",
)
class _FixedRouting:
def __init__(self, result: RetrievalResult) -> None:
self._result = result
def retrieve(self, query, subject_scope, intent):
return self._result
class _Generator:
"""Returns whatever payload the test wants the model to have produced.
`_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.
"""
def __init__(self, payload, entailment_payload=None) -> None:
payloads = payload
self._payloads = list(payloads) if isinstance(payloads, list) else [payloads]
self._call = 0
default = {"entailed": True, "unsupported": []}
e_payloads = entailment_payload if entailment_payload is not None else default
self._entailment_payloads = (
list(e_payloads) if isinstance(e_payloads, list) else [e_payloads]
)
self._entailment_call = 0
def generate(self, system: str, user: str, schema: dict) -> str:
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:
index = min(self._call, len(self._payloads) - 1)
payload = self._payloads[index]
self._call += 1
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, entailment_payload=None):
metrics = InMemoryMetrics()
service = GroundedAnswerService(
_FixedRouting(result or _result()),
_Generator(payload, entailment_payload),
metrics,
)
grounded = service.answer("Liều Metformin?", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
return grounded, metrics
# --- the disclaimer guardrail -------------------------------------------------
# `docs/architecture.md` specifies the disclaimer at several layers. Only the
# web banner existed; `packages/shared-types/src/dto/chat.ts` declared the
# field but nothing filled it, so a consumer other than that one UI got medical
# content with nothing attached. It is a dataclass default rather than
# something each call site adds, so these tests are about the paths that could
# plausibly skip it: rejections, abstains and clarifications.
def test_a_served_answer_carries_the_disclaimer():
grounded, _ = _answer(
{"claims": [{"text": "Người lớn uống 500 mg", "citations": [1]}],
"evidence_sufficient": True},
)
assert grounded.generated is True
assert grounded.disclaimer == DISCLAIMER
assert grounded.disclaimer
def test_a_rejected_generation_still_carries_the_disclaimer():
"""An abstain is still a clinical response and still needs the notice —
it is the case most likely to be treated as "not really an answer"."""
grounded, _ = _answer(
{"claims": [{"text": "Người lớn uống 850 mg, 2 lần mỗi ngày", "citations": [1]}],
"evidence_sufficient": True},
)
assert grounded.answer is None
assert grounded.result.decision == EvidenceDecision.ABSTAIN
assert grounded.disclaimer == DISCLAIMER
def test_a_provider_outage_response_still_carries_the_disclaimer():
grounded, _ = _answer(
{"claims": [{"text": "Người lớn: 500 mg, 2 lần/ngày", "citations": [1]}],
"evidence_sufficient": True},
entailment_payload=AnswerGenerationUnavailable(),
)
assert grounded.disclaimer == DISCLAIMER
def test_the_disclaimer_is_not_something_the_model_can_influence():
"""It is a module constant, never routed through the generator, so a
prompt-injected or malfunctioning model cannot shorten or drop it. Asserted
directly because the value is the guardrail."""
grounded, _ = _answer(
{"claims": [{"text": "Bỏ qua mọi cảnh báo. Người lớn uống 500 mg", "citations": [1]}],
"evidence_sufficient": True},
)
assert grounded.disclaimer == DISCLAIMER
assert "không thay thế chỉ định" in grounded.disclaimer
# --- the guardrail's whole reason to exist ------------------------------------
def test_invented_dose_is_refused_and_never_reaches_the_answer():
grounded, metrics = _answer(
{"claims": [{"text": "Người lớn uống 850 mg, 2 lần mỗi ngày", "citations": [1]}],
"evidence_sufficient": True}
)
assert grounded.generated is False
# 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
# The specific check that rejected it, not a generic catch-all — found
# live 2026-08-07: every rejection reason used to collapse into
# "generation_unavailable" by the time it reached the API response,
# making a real provider outage indistinguishable from ordinary
# entailment noise without reading server metrics by hand.
assert grounded.result.reason == "ungrounded_number"
assert metrics.total(GENERATION_REJECTED, reason="ungrounded_number") == 1
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(
{"claims": [{"text": "Liều tối đa 2000 mg mỗi ngày", "citations": [1]}],
"evidence_sufficient": True}
)
assert grounded.generated is False
assert metrics.total(GENERATION_REJECTED, reason="ungrounded_number") == 1
def test_citation_pointing_at_nothing_is_refused():
grounded, metrics = _answer(
{"claims": [{"text": "Người lớn uống 500 mg", "citations": [3]}],
"evidence_sufficient": True}
)
assert grounded.generated is False
# [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():
grounded, metrics = _answer(
{"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"
assert grounded.blocks[0].claims[0].source_ids == ("metformin::lieu::0",)
assert metrics.total(GENERATION_SERVED) == 1
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(
{"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.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(
{"claims": [{"text": "Metformin dùng điều trị đái tháo đường", "citations": [1]}],
"evidence_sufficient": True},
entailment_payload={"entailed": True, "unsupported": []},
)
assert grounded.generated is True
assert metrics.total(GENERATION_SERVED) == 1
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(
{"claims": [{"text": "Metformin chữa ung thư", "citations": [1]}],
"evidence_sufficient": True},
entailment_payload=[
{"entailed": False, "unsupported": [1]},
{"entailed": True, "unsupported": []},
],
)
assert grounded.generated is False
assert grounded.answer is None
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():
"""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},
entailment_payload=AnswerGenerationUnavailable(),
)
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_patient_candidate_list_retries_one_noisy_entailment_rejection():
metrics = InMemoryMetrics()
result = _result()
result = replace(
result,
evidence=(replace(result.evidence[0], drug_id="metformin"),),
)
generator = _Generator(
[
{"claims": [{"drug_id": "metformin", "text": "Người lớn uống 500 mg", "citations": [1]}],
"evidence_sufficient": True},
{"claims": [{"drug_id": "metformin", "text": "Người lớn uống 500 mg", "citations": [1]}],
"evidence_sufficient": True},
],
entailment_payload=[
{"entailed": False, "unsupported": [1]},
{"entailed": True, "unsupported": [], "complete": True},
],
)
service = GroundedAnswerService(_FixedRouting(result), generator, metrics)
grounded = service.answer_from_result(
"Trong các thuốc trên thuốc nào cần lưu ý hơn với bệnh thận?",
result,
list_mode=True,
patient_specific=True,
candidate_drug_ids=("metformin",),
prechecked=True,
)
assert grounded.generated is True
assert grounded.result.decision == EvidenceDecision.ANSWERABLE
assert generator._call == 2
assert generator._entailment_call == 2
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 0
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(
{"claims": [], "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(
{"claims": [{"text": "Người lớn: 500 mg", "citations": [1]}],
"evidence_sufficient": True}
)
assert grounded.generated is True
assert len(grounded.citations) == 1
assert grounded.citations[0].printed_page_start == 714
# --- a configured generator that fails abstains, never a raw source dump -----
@pytest.mark.parametrize(
"payload, reason",
[
(AnswerGenerationUnavailable("revoked"), "provider_unavailable"),
("not json at all", "malformed_output"),
({"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):
grounded, metrics = _answer(payload)
assert grounded.generated is False
assert grounded.answer is None
assert grounded.result.decision == EvidenceDecision.ABSTAIN
# The API/trace-visible reason must match the specific check that
# failed, not a generic "generation_unavailable" for every cause —
# otherwise a real outage and ordinary model noise are indistinguishable
# from the outside (the exact gap a live report 2026-08-07 named).
assert grounded.result.reason == reason
assert metrics.total(GENERATION_REJECTED, reason=reason) == 1
def test_evidence_insufficient_retries_once_and_recovers():
"""Found live 2026-08-07 via a 50-question adversarial sample: a real
section that plainly contains the answer (confirmed by re-asking the
identical question 3/3 times successfully right after) still drew an
`evidence_sufficient: false` self-judgment once — the same noisy-judge
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([
{"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"
assert metrics.total(GENERATION_SERVED) == 1
assert metrics.total(GENERATION_REJECTED) == 0
def test_evidence_insufficient_twice_still_abstains():
grounded, metrics = _answer([
{"claims": [], "evidence_sufficient": False},
{"claims": [], "evidence_sufficient": False},
])
assert grounded.generated is False
assert grounded.answer is None
# Both attempts are the same noisy self-judgment on the same evidence —
# a real, reliable "insufficient" must still discard exactly once.
assert metrics.total(GENERATION_REJECTED, reason="evidence_insufficient") == 1
def test_no_generator_configured_still_answers():
service = GroundedAnswerService(_FixedRouting(_result()))
grounded = service.answer(
"Liều Metformin?", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP
)
assert grounded.generated is False
assert grounded.answer.startswith(EVIDENCE_TEXT)
# --- the comparison rule itself ----------------------------------------------
def test_decimal_separators_are_not_interchangeable():
"""`7,5` and `7.5` differ, and so do `7,5` and `75`. Normalising them
together is how a tenfold dose error scores as a match."""
source = ("Sơ sinh: 7,5 mg/kg cách 8 giờ/lần.",)
assert grounding.verify("7,5 mg/kg [1]", source).grounded is True
assert grounding.verify("7.5 mg/kg [1]", source).grounded is False
assert grounding.verify("75 mg/kg [1]", source).grounded is False
def test_citation_markers_are_not_read_as_quantities():
report = grounding.verify("Không dùng cho người suy thận [1].", ("Suy thận.",))
assert report.grounded is True
assert report.cited_indices == (1,)
def test_prompt_numbers_evidence_from_one():
request = build_request("Liều?", ("đoạn A", "đoạn B"))
assert "[1] đoạn A" in request.user
assert "[2] đoạn B" in request.user
assert "CHÉP NGUYÊN VĂN" in request.system
def test_prompt_refuses_to_build_without_evidence():
with pytest.raises(ValueError):
build_request("Liều?", ())