Add read-only production runtime audit

This commit is contained in:
2026-08-17 11:17:40 +07:00
parent 057d4ed9dc
commit a1de4715a4
106 changed files with 6869 additions and 1782 deletions
+4
View File
@@ -89,6 +89,10 @@ _ALLOWED: dict[str, frozenset[str]] = {
"/metrics",
"/v1/rag/query",
"/v1/rag/suggest",
"/v1/rag/feedback",
"/v1/rag/history",
"/v1/rag/sections",
"/v1/rag/section-text",
"section",
"overview",
"similarity",
+2 -1
View File
@@ -129,7 +129,8 @@ def create_app(
def _route_label(path: str) -> str:
known = {
"/health", "/ready", "/metrics", "/v1/rag/query", "/v1/rag/suggest",
"/v1/rag/feedback",
"/v1/rag/feedback", "/v1/rag/history", "/v1/rag/sections",
"/v1/rag/section-text",
}
return path if path in known else "other"
+60 -7
View File
@@ -28,10 +28,12 @@ from .clinical import ConditionRelation, MedicationCandidateAssessment
from .models import EvidenceDecision, RetrievalResult
from .policy import looks_non_human
from .service import RetrievalService
from .sections import SectionResolver
from .text import normalize_name
from .understanding import QueryFrame, QueryUnderstander
logger = logging.getLogger(__name__)
_SECTION_RESOLVER = SectionResolver()
TUONG_TAC = "tuong_tac_thuoc"
HISTORY_TURNS = 6
@@ -142,7 +144,12 @@ class RagAgent:
return []
return [_display_name(drug_id) for drug_id in self._autocomplete.complete(prefix, k)]
def handle(self, turn: str, conversation_id: str | None = None) -> AgentReply:
def handle(
self,
turn: str,
conversation_id: str | None = None,
response_mode: str = "ai",
) -> AgentReply:
# F-08: one budget per turn, threaded through every LLM call this
# turn makes (understand, then whatever `_route` reaches).
t0 = time.monotonic()
@@ -154,7 +161,7 @@ class RagAgent:
turn, tuple(history), budget=budget, prior_frame=prior_frame
)
t2 = time.monotonic()
reply = self._route(turn, frame, budget)
reply = self._route(turn, frame, budget, response_mode=response_mode)
reply = self._enforce_clarify_circuit_breaker(conversation_id, reply)
t3 = time.monotonic()
if conversation_id is not None:
@@ -242,7 +249,13 @@ class RagAgent:
return []
return self._history.get(conversation_id, [])
def _route(self, turn: str, frame: QueryFrame, budget: RequestBudget) -> AgentReply:
def _route(
self,
turn: str,
frame: QueryFrame,
budget: RequestBudget,
response_mode: str = "ai",
) -> AgentReply:
tt = frame.turn_type
section_overview = _is_section_overview(turn, frame)
if section_overview and not frame.section_overview:
@@ -253,10 +266,10 @@ class RagAgent:
# an out-of-scope request look recoverable.
if looks_non_human(turn):
return AgentReply(
"abstain", "out_of_scope",
answer="Nội dung này nằm ngoài phần chuyên luận thuốc của Dược thư "
"(có thể thuc phần hướng dẫn chung/phụ lục chưa được đưa vào). "
"Tôi chưa có dữ liệu để trả lời chính xác.",
"abstain", "out_of_scope_non_human",
answer="Dược thư Quốc gia Việt Nam trong hệ thống này chỉ bao "
"phủ thuc dùng cho người. Hệ thống không tra cứu liều "
"dùng hoặc hướng dẫn điều trị cho động vật.",
turn_type=tt)
# Dosing is a small state machine, not an unconstrained model opinion.
@@ -407,6 +420,24 @@ class RagAgent:
turn_type=tt,
)
if response_mode == "monograph" and (
tt == "drug_overview"
or (
tt == "drug_attribute"
and frame.attribute is None
and not frame.needs_clarify
)
or _is_bare_monograph_request(turn)
) and frame.drugs:
return AgentReply(
"clarify", "select_drug_sections",
clarification=(
"Đã nhận diện chuyên luận thuốc. Anh/chị chọn các mục cần "
"xem; nếu không chọn mục nào, hệ thống sẽ hiển thị toàn bộ."
),
drugs=frame.drugs, turn_type=tt,
)
if tt == "drug_attribute" and frame.drugs and frame.attribute is None:
return AgentReply(
"clarify", "missing_attribute",
@@ -713,6 +744,28 @@ def _is_section_overview(turn: str, frame: QueryFrame) -> bool:
return frame.section_overview or any(cue in text for cue in overview_cues)
def _is_bare_monograph_request(turn: str) -> bool:
"""True for a plain drug name in explicit monograph-browse mode.
A persisted conversation can contribute a stale attribute to a new bare
drug turn (for example the prior question was about contraindications).
The UI mode is an explicit current-turn instruction, so a plain name must
open the picker rather than inherit that old section. Any actual section
phrase or clinical-question cue keeps the normal AI route.
"""
text = normalize_name(turn)
if not text or len(text) > 100 or _SECTION_RESOLVER.resolve_all(turn):
return False
clinical_cues = (
" dung ", " dieu tri ", " tuong tac ", " tac dung ", " lieu ",
" benh ", " thai ", " cho con bu ", " tre em ", " nguoi lon ",
" suy than ", " suy gan ", " di ung ", " bao nhieu ", " la gi ",
" co the ", " duoc khong ",
)
padded = f" {text} "
return not any(cue in padded for cue in clinical_cues)
_POPULATION_LABELS = {
"tre_em": "trẻ em",
"tre_so_sinh": "trẻ sơ sinh",
+21
View File
@@ -839,6 +839,7 @@ class GroundedAnswerService:
evidence_drug_ids: tuple[str | None, ...] = (),
budget: RequestBudget | None = None,
plan: AnswerPlan | None = None,
retry_unsupported_patient_list: bool = True,
) -> "_GenOutcome":
"""A verified generation, a clarifying question, or empty to fall back."""
if self._generator is None or not evidence_texts:
@@ -935,6 +936,26 @@ class GroundedAnswerService:
)
return _GenOutcome(reject_reason=verification.reason)
if not verification.supported:
# Patient-specific candidate comparisons occasionally receive a
# noisy negative entailment verdict even though the same evidence
# and a fresh answer clear both fail-closed checks immediately
# afterwards (observed in the C03 contextual renal-safety turn).
# Retry only this known conversational lane, once. Ordinary AI
# answers and monograph browsing are intentionally unchanged.
if patient_specific and list_mode and retry_unsupported_patient_list:
return self._generate(
query,
evidence_texts,
prompt_evidence_texts,
intro=intro,
list_mode=list_mode,
patient_specific=patient_specific,
candidate_drug_ids=candidate_drug_ids,
evidence_drug_ids=evidence_drug_ids,
budget=budget,
plan=plan,
retry_unsupported_patient_list=False,
)
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="unsupported_claim"
)
+2
View File
@@ -314,6 +314,8 @@ class ConditionNormalizer:
"benh gout": "gút",
"benh gut": "gút",
"gut": "gút",
"viem phoi": "viêm phổi",
"benh viem phoi": "viêm phổi",
}
_BROAD = frozenset({"viem gan", "ung thu", "nhiem trung", "nhiem khuan"})
_BROAD_QUESTIONS = {
+91 -3
View File
@@ -593,6 +593,7 @@ class LlmQueryUnderstander:
frame = _apply_broad_condition_cue(
frame, turn, self._condition_normalizer
)
frame = _apply_general_condition_scope(frame, turn)
frame = _apply_reverse_relation_cues(frame, turn)
section_match = _SECTION_RESOLVER.resolve(turn)
frame = _apply_named_drug_cues(
@@ -603,7 +604,8 @@ class LlmQueryUnderstander:
resolved_section_phrase=(section_match.phrase if section_match else None),
)
frame = _apply_multi_section_clarify(frame, turn)
return _merge_with_prior_frame(frame, prior_frame)
frame = _merge_with_prior_frame(frame, prior_frame)
return _apply_contextual_candidate_safety(frame, turn, prior_frame)
@staticmethod
def _resolve_id(value: str, shown: dict[str, str]) -> str | None:
@@ -754,7 +756,7 @@ def _apply_condition_candidate_cue(
"""Keep current medicines subordinate in an explicit condition lookup."""
if frame.turn_type == "condition_to_drug" and frame.condition is not None:
return frame
condition = normalizer.detect_known_alias(turn)
condition = frame.condition or normalizer.detect_known_alias(turn)
if condition is None:
return frame
text = f" {normalize_name(turn)} "
@@ -767,6 +769,8 @@ def _apply_condition_candidate_cue(
" option dieu tri ",
" ung vien nao ",
" cac ung vien nao ",
" co chi dinh lien quan ",
" co chi dinh cho ",
)
if not any(cue in text for cue in candidate_cues):
return frame
@@ -782,6 +786,80 @@ def _apply_condition_candidate_cue(
)
def _apply_general_condition_scope(frame: QueryFrame, turn: str) -> QueryFrame:
"""Do not turn a disease name into an unstated patient impairment.
A general reverse lookup such as ``Viêm gan B mạn dùng thuốc gì?`` names
the condition being treated; it does not say that a particular patient has
hepatic impairment. The understanding model can otherwise duplicate the
same phrase into ``patient_context.hepatic`` and trigger a stage-2 safety
review, mixing contraindication/precaution citations into a general
indication list. Explicit patient cues keep the full context untouched.
"""
if frame.turn_type not in {"condition_to_drug", "symptom_to_drug"}:
return frame
text = f" {normalize_name(turn)} "
patient_cues = (
" bn ", " benh nhan ", " nguoi benh ", " kem ", " di ung ",
" dang dung ", " mang thai ", " cho con bu ", " tuoi ", " kg ",
" ckd ", " suy than ", " suy gan ", " child pugh ", " egfr ",
" creatinin ", " ast ", " alt ",
)
if any(cue in text for cue in patient_cues):
return frame
primary = (
frame.condition.normalized_condition
if frame.condition is not None
else frame.indication
)
return replace(frame, patient_context=PatientContext(primary_condition=primary))
def _apply_contextual_candidate_safety(
frame: QueryFrame,
turn: str,
prior_frame: QueryFrame | None,
) -> QueryFrame:
"""Keep ``các thuốc trên`` on the prior condition-to-drug candidate lane.
This follow-up asks to compare the already retrieved candidates against a
new patient constraint. It is not a reverse disease->contraindication
lookup, even if the current turn contains words such as ``bệnh thận``.
"""
if prior_frame is None or prior_frame.turn_type not in {
"condition_to_drug", "symptom_to_drug"
}:
return frame
text = f" {normalize_name(turn)} "
refers_to_candidates = any(
cue in text for cue in (" cac thuoc tren ", " trong cac thuoc tren ")
)
safety_cue = any(
cue in text
for cue in (
" luu y ", " than trong ", " benh than ", " suy than ",
" benh gan ", " suy gan ", " di ung ", " tuong tac ",
)
)
if not (refers_to_candidates and safety_cue):
return frame
condition = frame.condition or prior_frame.condition
return replace(
frame,
turn_type="condition_to_drug",
indication=(
condition.normalized_condition
if condition is not None
else frame.indication or prior_frame.indication
),
condition=condition,
condition_relation=ConditionRelation.INDICATION,
needs_clarify=False,
clarify_reason=None,
quick_replies=(),
)
def _apply_broad_condition_cue(
frame: QueryFrame, turn: str, normalizer: ConditionNormalizer
) -> QueryFrame:
@@ -1037,7 +1115,17 @@ def _merge_with_prior_frame(frame: QueryFrame, prior_frame: QueryFrame | None) -
indication=indication or prior_frame.indication,
condition=condition,
patient_context=patient_context,
attribute=frame.attribute or prior_frame.attribute,
# A current drug-attribute clarify with no attribute is an explicit
# ambiguity signal (for example, the user named both "chỉ định" and
# "chống chỉ định"). Re-inheriting the previous turn's attribute here
# silently picks one of those sections and poisons the frame remembered
# for the next quick reply. Other continuation shapes still inherit the
# prior slot as before (notably pediatric dosing clarifications).
attribute=(
frame.attribute
if frame.turn_type == "drug_attribute" and frame.needs_clarify
else frame.attribute or prior_frame.attribute
),
)
+8 -3
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid
from typing import Annotated, Any, Literal, Protocol
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from rag.answer import DISCLAIMER, GroundedAnswerService
@@ -36,6 +36,7 @@ class RagQueryRequest(BaseModel):
# (follow-up inheritance, clarify, smalltalk). Absent → single-turn, exactly
# as before, so existing callers are unchanged.
conversation_id: str | None = Field(default=None, max_length=128)
response_mode: Literal["ai", "monograph"] = "ai"
class CitationResponse(BaseModel):
@@ -198,7 +199,7 @@ _HISTORY_LIMIT = 50
@router.get("/history", response_model=HistoryResponse)
def list_history(
conversation_id: str,
conversation_id: Annotated[str, Query(max_length=128)],
traces: Annotated[TraceWriter, Depends(_trace_writer)],
) -> HistoryResponse:
"""Feature-List #25: past queries for one session, most recent first, so
@@ -430,7 +431,11 @@ def query_rag(
# then routes to the safety-verified retrieval + grounded-answer
# engine. Replaces the old fuzzy resolver + keyword section router +
# manual follow-up inheritance for both single- and multi-turn.
reply = agent.handle(payload.query, payload.conversation_id)
reply = agent.handle(
payload.query,
payload.conversation_id,
response_mode=payload.response_mode,
)
decision = reply.decision
reason = reply.reason
answer = reply.clarification if reply.clarification is not None else reply.answer
+62 -2
View File
@@ -127,7 +127,8 @@ def test_veterinary_phrase_abstains_even_if_the_model_missed_it():
agent = _agent(QueryFrame(turn_type="drug_attribute", drugs=("metformin",)))
reply = agent.handle("liều metformin cho chó bao nhiêu")
assert reply.decision == "abstain"
assert reply.reason == "out_of_scope"
assert reply.reason == "out_of_scope_non_human"
assert "chỉ bao phủ thuốc dùng cho người" in reply.answer
def test_unknown_drug_name_is_reported_not_substituted():
@@ -145,7 +146,7 @@ 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():
def test_drug_attribute_without_an_attribute_keeps_ai_clarification_without_retrieval():
retrieval = _FixedRetrieval({})
answers = GroundedAnswerService(routing=None)
agent = RagAgent(
@@ -163,6 +164,65 @@ def test_drug_attribute_without_an_attribute_does_not_fall_into_overview_retriev
assert retrieval.calls == []
def test_bare_drug_overview_opens_section_picker_without_retrieval():
retrieval = _FixedRetrieval({})
agent = RagAgent(
_FixedUnderstander(QueryFrame(
turn_type="drug_overview", drugs=("metformin",)
)),
retrieval,
GroundedAnswerService(routing=None),
)
reply = agent.handle("Metformin", response_mode="monograph")
assert reply.decision == "clarify"
assert reply.reason == "select_drug_sections"
assert reply.drugs == ("metformin",)
assert retrieval.calls == []
def test_monograph_bare_drug_ignores_stale_inherited_attribute():
retrieval = _FixedRetrieval({})
agent = RagAgent(
_FixedUnderstander(QueryFrame(
turn_type="drug_attribute",
drugs=("metformin",),
attribute="chong_chi_dinh",
)),
retrieval,
GroundedAnswerService(routing=None),
)
reply = agent.handle("Metformin", response_mode="monograph")
assert reply.reason == "select_drug_sections"
assert retrieval.calls == []
def test_monograph_mode_keeps_explicit_attribute_on_ai_route():
result = RetrievalResult(
EvidenceDecision.ABSTAIN, "not_configured", resolved_drug_id="metformin"
)
retrieval = _FixedRetrieval({"metformin": result})
agent = RagAgent(
_FixedUnderstander(QueryFrame(
turn_type="drug_attribute",
drugs=("metformin",),
attribute="chong_chi_dinh",
)),
retrieval,
GroundedAnswerService(routing=None),
)
reply = agent.handle(
"Chống chỉ định của Metformin là gì?", response_mode="monograph"
)
assert reply.reason != "select_drug_sections"
assert retrieval.calls[0][1] == "chong_chi_dinh"
# --- 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
+62 -5
View File
@@ -5,7 +5,7 @@ from fastapi.testclient import TestClient
from adapters.prometheus import PrometheusMetrics
from adapters.postgres import FeedbackTraceNotFound, RetrievalTrace
from config import Settings
from main import create_app
from main import _route_label, create_app
from rag.agent import AgentReply
from rag.answer import DISCLAIMER, Citation, GroundedAnswerService
from rag.metrics import TRACE_WRITE_FAILED, InMemoryMetrics
@@ -150,6 +150,18 @@ def test_history_for_unknown_conversation_is_empty_not_an_error():
assert response.json() == {"items": []}
def test_history_rejects_an_oversized_conversation_id_before_querying_storage():
traces = FakeHistoryTraceWriter({})
app = create_app(settings=Settings(), trace_writer=traces)
response = TestClient(app).get(
"/v1/rag/history", params={"conversation_id": "x" * 129}
)
assert response.status_code == 422
assert traces.calls == []
def test_health_and_fail_closed_rag_response_are_traced():
traces = MemoryTraceWriter()
app = create_app(
@@ -204,6 +216,19 @@ def _metrics_app(**settings_kwargs):
)
def test_all_public_rag_endpoints_have_bounded_request_metric_labels():
paths = (
"/v1/rag/query",
"/v1/rag/suggest",
"/v1/rag/feedback",
"/v1/rag/history",
"/v1/rag/sections",
"/v1/rag/section-text",
)
assert {_route_label(path) for path in paths} == set(paths)
def test_metrics_stays_open_when_no_token_is_configured():
"""The default must not break the existing Compose scrape or local runs —
the endpoint is not internet-reachable in that topology."""
@@ -252,10 +277,15 @@ class FakeAgent:
def __init__(self, reply: AgentReply) -> None:
self._reply = reply
self.calls: list[tuple[str, str | None]] = []
self.calls: list[tuple[str, str | None, str]] = []
def handle(self, turn: str, conversation_id: str | None = None) -> AgentReply:
self.calls.append((turn, conversation_id))
def handle(
self,
turn: str,
conversation_id: str | None = None,
response_mode: str = "ai",
) -> AgentReply:
self.calls.append((turn, conversation_id, response_mode))
return self._reply
def complete(self, prefix: str, k: int = 8) -> list[str]:
@@ -291,7 +321,34 @@ def test_query_routes_through_the_agent_when_one_is_configured():
assert body["answer"] == "Liều 500 mg [1]."
assert body["resolved_drug_id"] == "metformin"
assert len(body["citations"]) == 1
assert agent.calls == [("Liều metformin?", "c1")]
assert agent.calls == [("Liều metformin?", "c1", "ai")]
def test_query_forwards_monograph_response_mode_to_agent():
agent = FakeAgent(AgentReply(
decision="clarify",
reason="select_drug_sections",
clarification="Chọn mục cần xem.",
drugs=("metformin",),
turn_type="drug_overview",
))
app = create_app(
settings=Settings(),
answer_service=GroundedAnswerService(FixedRouting()),
conversational=agent,
trace_writer=MemoryTraceWriter(),
)
response = TestClient(app).post("/v1/rag/query", json={
"query": "Metformin",
"subject_scope": "human",
"intent": "fact_lookup",
"response_mode": "monograph",
})
assert response.status_code == 200
assert response.json()["reason"] == "select_drug_sections"
assert agent.calls == [("Metformin", None, "monograph")]
def test_query_agent_clarification_is_surfaced_as_the_answer():
@@ -11,6 +11,7 @@ from rag.clinical import (
ConditionNormalizer,
ConditionQuery,
ConditionRelation,
HepaticContext,
PatientContext,
RenalContext,
)
@@ -20,6 +21,8 @@ from rag.understanding import (
LlmQueryUnderstander,
QueryFrame,
_apply_condition_candidate_cue,
_apply_contextual_candidate_safety,
_apply_general_condition_scope,
_apply_named_drug_cues,
_apply_reverse_relation_cues,
_merge_with_prior_frame,
@@ -72,9 +75,104 @@ def test_condition_normalizer_handles_professional_aliases_without_drug_mapping(
assert normalizer.normalize("THA dùng gì", "THA").normalized_condition == "tăng huyết áp"
assert normalizer.normalize("cao huyết áp", "cao huyết áp").normalized_condition == "tăng huyết áp"
assert normalizer.normalize("Gout", "gout").normalized_condition == "gút"
assert normalizer.detect_known_alias("BN viêm phổi dùng thuốc gì?").normalized_condition == "viêm phổi"
assert normalizer.normalize("bệnh lạ", "bệnh lạ").normalized_condition == "bệnh lạ"
def test_explicit_indication_relation_is_a_condition_candidate_lookup():
noisy = QueryFrame(
turn_type="condition_relation",
condition=ConditionNormalizer().normalize("bệnh gút", "gút"),
needs_clarify=True,
clarify_reason="Hỏi lại sai hướng",
)
frame = _apply_condition_candidate_cue(
noisy,
"Thuốc nào có chỉ định liên quan bệnh gút?",
ConditionNormalizer(),
)
assert frame.turn_type == "condition_to_drug"
assert frame.condition_relation == ConditionRelation.INDICATION
assert frame.needs_clarify is False
def test_general_condition_does_not_invent_patient_hepatic_context():
frame = QueryFrame(
turn_type="condition_to_drug",
indication="viêm gan B mạn",
condition=ConditionQuery(
original_query="Viêm gan B mạn dùng thuốc gì?",
normalized_condition="viêm gan B mạn",
),
patient_context=PatientContext(
primary_condition="viêm gan B mạn",
hepatic=HepaticContext(description="viêm gan B mạn"),
),
)
cleaned = _apply_general_condition_scope(
frame, "Viêm gan B mạn dùng thuốc gì?"
)
assert cleaned.patient_context.primary_condition == "viêm gan B mạn"
assert cleaned.patient_context.requires_safety_review is False
def test_patient_allergy_condition_lookup_keeps_safety_context():
patient = PatientContext(
primary_condition="viêm phổi", allergies=("penicillin",)
)
frame = QueryFrame(
turn_type="condition_to_drug",
condition=ConditionQuery(
original_query="BN dị ứng penicillin, viêm phổi dùng thuốc gì?",
normalized_condition="viêm phổi",
),
patient_context=patient,
)
kept = _apply_general_condition_scope(
frame, "BN dị ứng penicillin, viêm phổi dùng thuốc gì?"
)
assert kept.patient_context == patient
assert kept.patient_context.requires_safety_review is True
def test_candidate_safety_followup_stays_on_prior_condition_lookup():
prior = QueryFrame(
turn_type="condition_to_drug",
indication="tăng huyết áp",
condition=ConditionQuery(
original_query="BN bị tăng huyết áp",
normalized_condition="tăng huyết áp",
),
patient_context=PatientContext(
age_text="68 tuổi",
renal=RenalContext(description="CKD", ckd_stage="G4"),
),
)
noisy = QueryFrame(
turn_type="condition_relation",
condition_relation=ConditionRelation.CONTRAINDICATION,
depends_on_previous_turn=True,
patient_context=prior.patient_context,
needs_clarify=False,
)
corrected = _apply_contextual_candidate_safety(
noisy,
"Trong các thuốc trên cái nào cần lưu ý hơn với bệnh thận?",
prior,
)
assert corrected.turn_type == "condition_to_drug"
assert corrected.condition == prior.condition
assert corrected.condition_relation == ConditionRelation.INDICATION
def test_broad_condition_is_clarified_but_specific_subtype_is_not():
normalizer = ConditionNormalizer()
broad = normalizer.normalize("Viêm gan dùng thuốc gì?", "viêm gan")
@@ -7,6 +7,7 @@ from the real METFORMIN and PARACETAMOL sections in `duocthu_v1`.
from __future__ import annotations
import json
from dataclasses import replace
import pytest
@@ -516,6 +517,43 @@ def test_a_real_negative_verdict_is_still_an_unsupported_claim():
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
@@ -16,6 +16,7 @@ from rag.understanding import (
SECTION_KEYS,
LlmQueryUnderstander,
QueryFrame,
_merge_with_prior_frame,
)
CATALOG = {
@@ -162,6 +163,29 @@ def test_single_section_named_is_unaffected_by_the_multi_section_clarify():
assert frame.needs_clarify is False
def test_multi_section_clarify_does_not_inherit_a_stale_prior_attribute():
prior = QueryFrame(
turn_type="drug_attribute",
drugs=("paracetamol_acetaminophen",),
attribute="lieu_luong_va_cach_dung",
needs_clarify=True,
clarify_reason="Anh/chị muốn tra gì?",
)
current = QueryFrame(
turn_type="drug_attribute",
drugs=("paracetamol_acetaminophen",),
attribute=None,
needs_clarify=True,
clarify_reason="Anh/chị muốn xem mục nào trước?",
quick_replies=("Chỉ định", "Chống chỉ định"),
)
merged = _merge_with_prior_frame(current, prior)
assert merged.attribute is None
assert merged.quick_replies == ("Chỉ định", "Chống chỉ định")
def test_exact_candidate_does_not_repeat_the_catalog_wide_fuzzy_scan():
resolver = _FakeResolver({"metformin": "metformin"})
understander = LlmQueryUnderstander(_FixedLlm({