Wire the guarded conversational RAG answer layer end-to-end

This commit is contained in:
2026-08-05 14:33:13 +07:00
parent 834d9e51b0
commit ef08b4929e
127 changed files with 37921 additions and 169 deletions
@@ -0,0 +1,90 @@
from rag.answer import GroundedAnswerService
from rag.models import (
Evidence,
EvidenceDecision,
QueryIntent,
RetrievalResult,
SourceRef,
SubjectScope,
)
class FixedRouting:
def __init__(self, result: RetrievalResult) -> None:
self._result = result
def retrieve(self, query, subject_scope, intent):
del query, subject_scope, intent
return self._result
def evidence(source: SourceRef, *, visual: bool = False) -> Evidence:
return Evidence(
evidence_id="chunk-1", matched_doc_id="chunk-1", kind="prose",
text="Liều được ghi trong nguồn.", score=0.9, source_refs=(source,),
hydrated_from_parent=False, requires_visual_check=visual,
)
def test_answer_uses_only_printed_page_citations():
source = SourceRef(
physical_page=100, precision="chunk_page_range",
page_range=(100, 102), printed_page_range=(101, 103),
)
service = GroundedAnswerService(FixedRouting(RetrievalResult(
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
(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.citations[0].printed_page_start == 101
assert answer.citations[0].printed_page_end == 103
def test_answer_abstains_when_only_physical_page_is_available():
source = SourceRef(physical_page=100, precision="chunk_page_range")
service = GroundedAnswerService(FixedRouting(RetrievalResult(
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
(evidence(source),), "abacavir", "resolved",
)))
answer = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
assert answer.result.decision == EvidenceDecision.ABSTAIN
assert answer.result.reason == "missing_printed_page_provenance"
assert answer.answer is None
assert answer.citations == ()
def test_visual_evidence_never_auto_extracts_numbers():
source = SourceRef(
physical_page=100, precision="region", printed_page=101,
)
service = GroundedAnswerService(FixedRouting(RetrievalResult(
EvidenceDecision.VERIFY_PDF, "visual_verification_required",
(evidence(source, visual=True),), "abacavir", "resolved",
)))
answer = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
assert "không tự động trích số liệu" in answer.answer
assert "Liều được ghi" not in answer.answer
def test_visual_citation_preserves_block_page_and_bbox_without_a_crop_file():
source = SourceRef(
physical_page=209,
precision="region",
block_id="p209_t0",
bbox=(49.5, 68.1, 289.4, 789.4),
printed_page=210,
)
service = GroundedAnswerService(FixedRouting(RetrievalResult(
EvidenceDecision.VERIFY_PDF, "visual_verification_required",
(evidence(source, visual=True),), "arsenic_trioxyd", "resolved",
)))
answer = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
citation = answer.citations[0]
assert citation.physical_page == 209
assert citation.block_id == "p209_t0"
assert citation.bbox == (49.5, 68.1, 289.4, 789.4)
assert citation.source_crop is None
assert citation.attachment == "p209_t0"
+53
View File
@@ -0,0 +1,53 @@
from fastapi.testclient import TestClient
from config import Settings
from main import create_app
from rag.answer import GroundedAnswerService
from rag.models import EvidenceDecision, QueryIntent, RetrievalResult, SubjectScope
class FixedRouting:
def retrieve(self, query, subject_scope, intent):
assert query == "Liều thuốc?"
assert subject_scope == SubjectScope.HUMAN
assert intent == QueryIntent.FACT_LOOKUP
return RetrievalResult(EvidenceDecision.ABSTAIN, "drug_not_resolved")
class MemoryTraceWriter:
def __init__(self):
self.rows = []
def save(self, **fields):
self.rows.append(fields)
return "trace-1"
def test_health_and_fail_closed_rag_response_are_traced():
traces = MemoryTraceWriter()
app = create_app(
settings=Settings(),
answer_service=GroundedAnswerService(FixedRouting()),
trace_writer=traces,
)
client = TestClient(app)
assert client.get("/health").json() == {"status": "ok"}
response = client.post("/v1/rag/query", json={
"query": "Liều thuốc?",
"subject_scope": "human",
"intent": "fact_lookup",
})
assert response.status_code == 200
assert response.json()["decision"] == "abstain"
assert response.json()["trace_id"] == "trace-1"
assert traces.rows[0]["reason"] == "drug_not_resolved"
def test_query_requires_structured_scope_and_intent():
app = create_app(
settings=Settings(),
answer_service=GroundedAnswerService(FixedRouting()),
trace_writer=MemoryTraceWriter(),
)
response = TestClient(app).post("/v1/rag/query", json={"query": "Liều?"})
assert response.status_code == 422
+21
View File
@@ -0,0 +1,21 @@
import pytest
from rag.calculators import body_surface_area_m2
def test_bsa_matches_book_worked_example():
# Dược thư Phụ lục 1: "165 cm và 60 kg sẽ có diện tích 1,66 m²".
assert round(body_surface_area_m2(60, 165), 2) == 1.66
def test_bsa_matches_book_table_cells():
# Independent cells read from the BSA table (printed 1499): ground truth.
assert round(body_surface_area_m2(10, 90), 2) == 0.50
assert round(body_surface_area_m2(70, 170), 2) == 1.81
def test_bsa_rejects_nonpositive_inputs():
with pytest.raises(ValueError):
body_surface_area_m2(0, 165)
with pytest.raises(ValueError):
body_surface_area_m2(60, -1)
+146
View File
@@ -0,0 +1,146 @@
"""Follow-ups must inherit context, and must never inherit it silently.
The cases here are the ones the owner named on 2026-08-05: "còn trẻ em thì
sao?", "giải thích kỹ hơn", and not making the user repeat themselves. The
adversarial cases are the ones that make inheritance dangerous in a formulary
— a stale drug, and an explicit mention being overridden by context.
"""
from __future__ import annotations
from rag.conversation import (
FOCUS_TTL_TURNS,
ConversationState,
Focus,
Turn,
detect_population,
detect_verbosity,
looks_like_followup,
resolve_against,
update_focus,
)
def _state(turn_count: int = 1, **focus_fields) -> ConversationState:
focus = Focus()
for name, value in focus_fields.items():
focus = focus.with_field(name, value, turn_count - 1)
return ConversationState("c1", focus=focus, turn_count=turn_count)
# --- the follow-ups the owner asked for --------------------------------------
def test_con_tre_em_thi_sao_inherits_drug_and_section():
state = _state(drug_id="metformin", section_key="lieu_luong_va_cach_dung")
resolved = resolve_against(state, "còn trẻ em thì sao?", None, None)
assert resolved.drug_id == "metformin"
assert resolved.section_key == "lieu_luong_va_cach_dung"
assert resolved.population == "tre_em"
assert resolved.inherited_drug is True
def test_giai_thich_ky_hon_sets_verbosity_and_keeps_the_topic():
state = _state(drug_id="warfarin", section_key="tuong_tac_thuoc")
resolved = resolve_against(state, "giải thích kỹ hơn", None, None)
assert resolved.drug_id == "warfarin"
assert resolved.verbosity == "detailed"
def test_the_user_is_not_made_to_repeat_the_drug():
state = _state(drug_id="metformin")
resolved = resolve_against(state, "chống chỉ định", None, "chong_chi_dinh")
assert resolved.drug_id == "metformin"
assert resolved.section_key == "chong_chi_dinh"
# --- what makes inheritance safe ---------------------------------------------
def test_an_explicit_drug_always_beats_context():
"""Naming a drug must override whatever the conversation was about, or a
deliberate topic change silently answers about the previous medicine."""
state = _state(drug_id="metformin", section_key="lieu_luong_va_cach_dung")
resolved = resolve_against(state, "liều dùng warfarin", "warfarin", None)
assert resolved.drug_id == "warfarin"
assert resolved.inherited_drug is False
def test_a_stale_drug_is_dropped_rather_than_inherited():
"""Beyond the TTL the drug is not context, it is a hazard."""
state = _state(turn_count=FOCUS_TTL_TURNS + 3, drug_id="metformin")
# `_state` stamps at turn_count - 1, so age is 1; age it past the TTL.
aged = ConversationState(
"c1",
focus=Focus(drug_id="metformin", set_at_turn={"drug_id": 0}),
turn_count=FOCUS_TTL_TURNS + 2,
)
assert state.inherited("drug_id") == "metformin"
assert aged.inherited("drug_id") is None
resolved = resolve_against(aged, "còn trẻ em thì sao?", None, None)
assert resolved.drug_id is None
def test_an_inherited_drug_must_be_named_in_the_answer():
state = _state(drug_id="metformin")
inherited = resolve_against(state, "còn trẻ em thì sao?", None, None)
explicit = resolve_against(state, "liều warfarin", "warfarin", None)
assert inherited.needs_carry_over_notice is True
assert explicit.needs_carry_over_notice is False
# --- phrase detection ---------------------------------------------------------
def test_longest_population_phrase_wins():
"""`phụ nữ cho con bú` must not be read as `phụ nữ`, and `trẻ sơ sinh`
must not be read as `trẻ em` — the same rule `sections.py` relies on."""
assert detect_population("phụ nữ cho con bú") == "phu_nu_cho_con_bu"
assert detect_population("trẻ sơ sinh dùng sao") == "tre_so_sinh"
assert detect_population("bà bầu uống được không") == "phu_nu_co_thai"
assert detect_population("liều cho người lớn") == "nguoi_lon"
def test_no_population_named_is_none_not_a_guess():
assert detect_population("liều dùng paracetamol") is None
assert detect_verbosity("liều dùng paracetamol") is None
def test_followup_markers():
assert looks_like_followup("còn trẻ em thì sao?") is True
assert looks_like_followup("so với metformin thì sao") is True
assert looks_like_followup("liều dùng paracetamol") is False
# --- window and focus update --------------------------------------------------
def test_recent_window_evicts_oldest():
state = ConversationState("c1")
for index in range(8):
state = state.append(Turn("user", f"q{index}", "2026-08-05"), window=6)
assert len(state.recent) == 6
assert state.recent[0].text == "q2"
assert state.turn_count == 8
def test_focus_update_stamps_the_current_turn():
state = _state(turn_count=3)
resolved = resolve_against(state, "liều dùng metformin", "metformin", "lieu_luong_va_cach_dung")
focus = update_focus(state, resolved)
assert focus.drug_id == "metformin"
assert focus.set_at_turn["drug_id"] == 3
@@ -0,0 +1,50 @@
from rag.conversation import (
ConversationState,
DeterministicSummariser,
InMemoryConversationStore,
Turn,
)
def test_store_returns_fresh_state_for_unknown_id():
store = InMemoryConversationStore()
state = store.load("conv-new")
assert state.conversation_id == "conv-new"
assert state.turn_count == 0
assert state.recent == ()
def test_store_round_trips_saved_state():
store = InMemoryConversationStore()
state = ConversationState("conv-1", summary="s", turn_count=3)
store.save(state)
assert store.load("conv-1") is state
def test_summariser_records_topic_labels_only():
s = DeterministicSummariser()
dropped = (
Turn("user", "Chống chỉ định của metformin?", "t0",
drug_id="metformin", section_key="chong_chi_dinh"),
Turn("assistant", "Quá mẫn với metformin, suy thận Clcr < 60...", "t1",
drug_id="metformin", section_key="chong_chi_dinh"),
)
out = s.fold("", dropped)
# The label line is present...
assert "chong_chi_dinh của metformin" in out
# ...and no clinical value leaked from the assistant turn.
assert "Clcr" not in out
assert "60" not in out
def test_summariser_stays_within_budget_dropping_oldest():
s = DeterministicSummariser()
dropped = tuple(
Turn("user", f"q{i}", f"t{i}", drug_id=f"drug{i}", section_key="lieu_luong")
for i in range(400)
)
out = s.fold("", dropped)
assert len(out) <= DeterministicSummariser.MAX_CHARS
# Most-recent topic survives, oldest is dropped.
assert "drug399" in out
assert "drug0 " not in out
@@ -0,0 +1,105 @@
from rag.answer import GroundedAnswer
from rag.conversation import DeterministicSummariser, InMemoryConversationStore
from rag.conversational import (
SMALLTALK_REPLY,
ConversationalLoopService,
)
from rag.models import (
Evidence,
EvidenceDecision,
QueryIntent,
RetrievalResult,
SubjectScope,
)
from rag.reasoning import ClarifyReason
from rag.routing import CatalogDrugResolver
from rag.sections import SectionResolver
def _grounded(answer, evidence_text):
ev = Evidence("e1", "e1", "prose", evidence_text, 1.0, (), False, False)
result = RetrievalResult(
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
(ev,), "metformin", "resolved",
)
return GroundedAnswer(result, answer, (), False)
class FakeAnswers:
def __init__(self, answer_text, evidence_text):
self._a = answer_text
self._e = evidence_text
self.calls = []
def answer(self, query, subject_scope, intent):
self.calls.append(query)
return _grounded(self._a, self._e)
def _service(answers):
return ConversationalLoopService(
answers=answers,
resolver=CatalogDrugResolver({"metformin": {"metformin"}}),
section_resolver=SectionResolver(),
store=InMemoryConversationStore(),
summariser=DeterministicSummariser(),
)
def test_smalltalk_answers_socially_without_calling_engine():
answers = FakeAnswers("x", "x")
svc = _service(answers)
out = svc.answer("c1", "chào bạn", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
assert out.smalltalk is True
assert out.answer == SMALLTALK_REPLY
assert answers.calls == [] # a greeting is not a drug lookup
def test_medical_turn_returns_grounded_answer():
answers = FakeAnswers("Quá mẫn với metformin.", "Quá mẫn với metformin.")
svc = _service(answers)
out = svc.answer(
"c2", "chống chỉ định metformin", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP
)
assert out.smalltalk is False
assert out.answer == "Quá mẫn với metformin."
assert out.grounded is not None
def test_followup_inherits_drug_and_names_it_and_rewrites_query():
answers = FakeAnswers(
"Ở trẻ em điều chỉnh theo cân nặng.",
"Ở trẻ em, liều metformin điều chỉnh theo cân nặng.",
)
svc = _service(answers)
svc.answer("c3", "chống chỉ định metformin", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
out = svc.answer("c3", "còn trẻ em thì sao?", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
assert out.inherited_drug == "metformin"
assert out.answer.startswith("Về metformin:")
# The follow-up was rewritten self-contained before hitting the engine.
assert "metformin" in answers.calls[-1]
# State carried the drug forward.
assert svc._store.load("c3").focus.drug_id == "metformin"
def test_no_close_drug_reports_not_supported():
answers = FakeAnswers("x", "x")
svc = _service(answers) # catalog holds only metformin
out = svc.answer("c4", "cái này thế nào?", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
assert out.answer is None
assert out.clarification is not None
# Nothing close to a real drug: honest "not in the formulary", not a guess.
assert out.clarification.reason == "drug_not_supported"
assert answers.calls == []
def test_typo_offers_did_you_mean_not_silent_resolution():
answers = FakeAnswers("x", "x")
svc = _service(answers) # catalog holds only metformin
out = svc.answer("c5", "metformim", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
# A near-miss is asked about, never auto-resolved on a similarity threshold.
assert out.answer is None
assert out.clarification is not None
assert out.clarification.reason == "did_you_mean"
assert "Metformin" in out.clarification.options
assert answers.calls == []
@@ -0,0 +1,81 @@
from rag.conversation import DeterministicSummariser, InMemoryConversationStore
from rag.conversational import ConversationalRagService, TurnResolution
from rag.reasoning import ClarifyReason, MAX_LLM_CALLS, MAX_RETRIEVAL_ROUNDS, TurnBudget
class FakeResolver:
"""Maps a turn's text to what it resolves on its own (no context)."""
def __init__(self, table):
self._table = table
def resolve_turn(self, text):
for needle, resolution in self._table:
if needle in text:
return resolution
return TurnResolution(drug_id=None, section_key=None, drug_status="not_found")
def _service(resolver, retrieve, generate):
return ConversationalRagService(
store=InMemoryConversationStore(),
summariser=DeterministicSummariser(),
resolver=resolver,
retrieve=retrieve,
generate=generate,
)
def test_followup_inherits_drug_and_answer_names_it():
resolver = FakeResolver([
("metformin", TurnResolution("metformin", "chong_chi_dinh", "resolved")),
# "còn trẻ em" names no drug on its own — must inherit.
("trẻ em", TurnResolution(None, None, "not_found")),
])
# Evidence mentions "trẻ em" so the population assessor is satisfied.
retrieve = lambda q: ("Ở trẻ em, liều metformin điều chỉnh theo cân nặng.",)
generate = lambda q, ev, st: "liều theo cân nặng"
svc = _service(resolver, retrieve, generate)
first = svc.answer("c1", "Chống chỉ định của metformin?")
assert first.inherited_drug is None
second = svc.answer("c1", "còn trẻ em thì sao?")
assert second.inherited_drug == "metformin"
assert second.answer.startswith("Về metformin:")
def test_no_drug_and_no_context_asks_without_spending_budget():
resolver = FakeResolver([]) # nothing resolves
calls = {"retrieve": 0, "generate": 0}
def retrieve(q):
calls["retrieve"] += 1
return ("x",)
def generate(q, ev, st):
calls["generate"] += 1
return "x"
svc = _service(resolver, retrieve, generate)
budget = TurnBudget()
out = svc.answer("c2", "cái này thế nào?", budget=budget)
assert out.answer is None
assert out.clarification is not None
assert out.clarification.reason == ClarifyReason.AMBIGUOUS_DRUG
# Asking short-circuits before any spend.
assert calls == {"retrieve": 0, "generate": 0}
assert budget.retrieval_rounds == MAX_RETRIEVAL_ROUNDS
assert budget.llm_calls == MAX_LLM_CALLS
def test_state_persists_across_turns():
resolver = FakeResolver([
("metformin", TurnResolution("metformin", "chi_dinh", "resolved")),
])
svc = _service(resolver, lambda q: ("Chỉ định của metformin.",), lambda q, ev, st: "ok")
svc.answer("c3", "chỉ định metformin?")
state = svc._store.load("c3")
assert state.turn_count == 2 # user + assistant
assert state.focus.drug_id == "metformin"
@@ -0,0 +1,103 @@
"""A dead embedding provider must abstain, never crash the request.
Measured 2026-08-05 against the live `duocthu_v1` collection: with Bedrock
access revoked, `Tôi sốt cao, uống Paracetamol được không?` returned
**HTTP 500** from `botocore AccessDeniedException`. The section route needs no
embedder, so only the similarity fallback is affected — but that fallback is
reached by any question whose attribute is not in the phrase table, and an
error page is not an acceptable answer for a clinician.
"""
from __future__ import annotations
import pytest
from adapters.embedding import BedrockCohereQueryEmbedder
from rag.in_memory import InMemoryParentStore
from rag.models import EvidenceDecision
from rag.ports import QueryEmbeddingUnavailable
from rag.service import EvidencePolicy, RetrievalService
class _AccessDenied(Exception):
"""Stands in for botocore's ClientError without importing botocore."""
class _DeadEmbedder:
dimensions = 1024
def embed_query(self, text: str) -> list[float]:
raise QueryEmbeddingUnavailable("provider is revoked")
class _DeadRetriever:
def __init__(self) -> None:
self.calls = 0
def search(self, query: str, drug_id: str, limit: int) -> list:
self.calls += 1
return list(_DeadEmbedder().embed_query(query))
def _service(retriever: _DeadRetriever) -> RetrievalService:
return RetrievalService(
retriever, InMemoryParentStore([]), EvidencePolicy(minimum_score=0.01)
)
def test_similarity_fallback_abstains_when_the_provider_is_unreachable():
retriever = _DeadRetriever()
result = _service(retriever).retrieve("uống được không", "paracetamol")
assert retriever.calls == 1
assert result.decision == EvidenceDecision.ABSTAIN
assert result.reason == "query_embedding_unavailable"
assert result.evidence == ()
def test_abstention_reason_is_distinct_from_a_genuine_no_match():
"""An outage and an empty corpus must not report the same reason.
Reading `insufficient_retrieval_score` when the search never ran would send
anyone debugging this at the corpus instead of at the provider.
"""
result = _service(_DeadRetriever()).retrieve("uống được không", "paracetamol")
assert result.reason != "insufficient_retrieval_score"
def test_bedrock_adapter_translates_provider_errors_into_the_domain_error():
"""The domain must never see a botocore type; the adapter translates."""
class _RefusingClient:
def invoke_model(self, **kwargs):
raise _AccessDenied("not authorized to perform: bedrock:InvokeModel")
embedder = BedrockCohereQueryEmbedder(1024, client=_RefusingClient())
# botocore is installed here, so `_AccessDenied` is deliberately NOT one of
# the translated types: an unrecognised error must still surface loudly
# rather than be silently downgraded to an abstention.
with pytest.raises(_AccessDenied):
embedder.embed_query("liều paracetamol")
def test_real_botocore_client_error_becomes_an_abstainable_domain_error():
botocore_exceptions = pytest.importorskip("botocore.exceptions")
class _RefusingClient:
def invoke_model(self, **kwargs):
raise botocore_exceptions.ClientError(
{
"Error": {
"Code": "AccessDeniedException",
"Message": "not authorized to perform: bedrock:InvokeModel",
}
},
"InvokeModel",
)
embedder = BedrockCohereQueryEmbedder(1024, client=_RefusingClient())
with pytest.raises(QueryEmbeddingUnavailable):
embedder.embed_query("liều paracetamol")
@@ -0,0 +1,210 @@
"""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
import pytest
from rag import grounding
from rag.answer import GroundedAnswerService
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."""
def __init__(self, payload) -> None:
self._payload = payload
def generate(self, system: str, user: str, schema: dict) -> str:
if isinstance(self._payload, BaseException):
raise self._payload
if isinstance(self._payload, str):
return self._payload
return json.dumps(self._payload, ensure_ascii=False)
def _answer(payload, result: RetrievalResult | None = None):
metrics = InMemoryMetrics()
service = GroundedAnswerService(
_FixedRouting(result or _result()), _Generator(payload), metrics
)
grounded = service.answer("Liều Metformin?", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
return grounded, metrics
# --- the guardrail's whole reason to exist ------------------------------------
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].",
"evidence_sufficient": True}
)
assert grounded.generated is False
assert "850" not in grounded.answer
assert grounded.answer.startswith(EVIDENCE_TEXT)
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(
{"answer": "Liều tối đa 2000 mg mỗi ngày [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(
{"answer": "Người lớn uống 500 mg [3].", "evidence_sufficient": True}
)
assert grounded.generated is False
assert metrics.total(GENERATION_REJECTED, reason="invalid_citation") == 1
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].",
"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 metrics.total(GENERATION_SERVED) == 1
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(
{"answer": "Người lớn: 500 mg [1].", "evidence_sufficient": True}
)
assert grounded.generated is True
assert len(grounded.citations) == 1
assert grounded.citations[0].printed_page_start == 714
# --- degradation is always to the source, never to an error -------------------
@pytest.mark.parametrize(
"payload, reason",
[
(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"),
],
)
def test_every_generation_failure_falls_back_to_the_source_text(payload, reason):
grounded, metrics = _answer(payload)
assert grounded.generated is False
assert grounded.answer.startswith(EVIDENCE_TEXT)
assert metrics.total(GENERATION_REJECTED, reason=reason) == 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?", ())
@@ -0,0 +1,172 @@
from __future__ import annotations
import json
import os
import uuid
from contextlib import suppress
from functools import lru_cache
from pathlib import Path
import pytest
pytestmark = pytest.mark.skipif(
os.getenv("RUN_INTEGRATION") != "1",
reason="set RUN_INTEGRATION=1 with local PostgreSQL and Qdrant running",
)
ROOT = Path(__file__).resolve().parents[3]
CHUNKS = ROOT / "ingestion/data/processed/chunks.jsonl"
PDF = ROOT / "ingestion/data/raw/duoc-thu-quoc-gia-viet-nam-2018.pdf"
MIGRATION = Path(__file__).resolve().parents[1] / "migrations/001_rag_retrieval_trace.sql"
@lru_cache
def _first_real_chunk() -> dict:
with CHUNKS.open(encoding="utf-8") as handle:
record = json.loads(next(handle))
import fitz
from ingestion.extract.page_map import build_page_map
with fitz.open(PDF) as document:
page_map = build_page_map(document)
physical_start, physical_end = record["source_page_range"]
printed_start = page_map[physical_start]
printed_end = page_map[physical_end]
assert printed_start is not None and printed_end is not None
record["printed_page_range"] = [printed_start, printed_end]
return record
def test_real_qdrant_round_trip_uses_real_chunk_and_printed_folio():
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams
from adapters.embedding import LocalHashQueryEmbedder
from adapters.qdrant import QdrantRetriever
client = QdrantClient(url="http://localhost:6333")
collection = f"integration_{uuid.uuid4().hex}"
embedder = LocalHashQueryEmbedder(32)
record = _first_real_chunk()
try:
client.create_collection(
collection_name=collection,
vectors_config=VectorParams(size=32, distance=Distance.COSINE),
)
client.upsert(
collection_name=collection,
points=[PointStruct(
id=str(uuid.uuid4()),
vector=embedder.embed_query(record["text"]),
payload=record,
)],
wait=True,
)
hits = QdrantRetriever(client, collection, embedder).search(
record["text"], record["drug_id"], 3,
)
assert [hit.document.doc_id for hit in hits] == [record["chunk_id"]]
assert hits[0].document.text == record["text"]
assert hits[0].document.source_refs[0].printed_page_range == tuple(
record["printed_page_range"]
)
finally:
with suppress(Exception):
client.delete_collection(collection)
def test_real_postgres_migration_insert_and_read_back():
from adapters.postgres import PostgresTraceRepository
repository = PostgresTraceRepository(
"postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu"
)
repository.migrate(MIGRATION)
trace_id = repository.save(
query="Liều abacavir?",
subject_scope="human",
intent="fact_lookup",
decision="answerable",
reason="grounded_evidence_available",
resolved_drug_id="abacavir",
citations=({
"chunk_id": "abacavir__ten_chung_quoc_te__0",
"printed_page_start": 101,
"printed_page_end": 103,
},),
)
stored = repository.get(trace_id)
assert stored is not None
assert stored.query == "Liều abacavir?"
assert stored.resolved_drug_id == "abacavir"
assert stored.citations[0]["printed_page_start"] == 101
def test_api_round_trip_uses_qdrant_and_persists_postgres_trace():
from fastapi.testclient import TestClient
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams
from adapters.embedding import LocalHashQueryEmbedder
from adapters.postgres import PostgresTraceRepository
from adapters.qdrant import QdrantParentStore, QdrantRetriever
from config import Settings
from main import create_app
from rag.answer import GroundedAnswerService
from rag.routing import CatalogDrugResolver, QueryRoutingService
from rag.service import EvidencePolicy, RetrievalService
qdrant = QdrantClient(url="http://localhost:6333")
collection = f"integration_{uuid.uuid4().hex}"
embedder = LocalHashQueryEmbedder(32)
record = dict(_first_real_chunk())
traces = PostgresTraceRepository(
"postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu"
)
traces.migrate(MIGRATION)
try:
qdrant.create_collection(
collection_name=collection,
vectors_config=VectorParams(size=32, distance=Distance.COSINE),
)
qdrant.upsert(
collection_name=collection,
points=[PointStruct(
id=str(uuid.uuid4()),
vector=embedder.embed_query(record["text"]),
payload=record,
)],
wait=True,
)
retrieval = RetrievalService(
QdrantRetriever(qdrant, collection, embedder),
QdrantParentStore(qdrant, collection),
EvidencePolicy(minimum_score=0.01),
)
answers = GroundedAnswerService(QueryRoutingService(
retrieval,
CatalogDrugResolver({record["drug_id"]: {record["drug_name"]}}),
))
app = create_app(
settings=Settings(), answer_service=answers, trace_writer=traces,
)
response = TestClient(app).post("/v1/rag/query", json={
"query": record["text"],
"subject_scope": "human",
"intent": "fact_lookup",
})
assert response.status_code == 200
body = response.json()
assert body["decision"] == "answerable"
assert body["citations"][0]["chunk_id"] == record["chunk_id"]
assert body["citations"][0]["printed_page_start"] == (
record["printed_page_range"][0]
)
stored = traces.get(body["trace_id"])
assert stored is not None
assert stored.decision == "answerable"
assert stored.citations[0]["chunk_id"] == record["chunk_id"]
finally:
with suppress(Exception):
qdrant.delete_collection(collection)
@@ -0,0 +1,47 @@
from adapters.qdrant import _source_refs
def test_descriptor_source_ref_comes_from_attachment_not_heading_page():
refs = _source_refs({
"chunk_kind": "block_descriptor",
"heading_physical_page": 208,
"source_page_range": [209, 209],
"printed_page_range": [210, 210],
"attachments": [{
"block_id": "p209_t0",
"physical_page": 209,
"printed_page": 210,
"bbox": [49.5, 68.1, 289.4, 789.4],
"source_crop": "crops/p209_t0.png",
}],
})
assert len(refs) == 1
assert refs[0].physical_page == 209
assert refs[0].printed_page == 210
assert refs[0].block_id == "p209_t0"
assert refs[0].bbox == (49.5, 68.1, 289.4, 789.4)
assert refs[0].source_crop == "crops/p209_t0.png"
assert refs[0].precision == "region"
def test_prose_ref_uses_exact_chunk_range_and_keeps_attachment_region():
refs = _source_refs({
"chunk_kind": "prose",
"heading_physical_page": 100,
"source_page_range": [104, 105],
"printed_page_range": [105, 106],
"attachments": [{
"block_id": "p105_t0",
"physical_page": 105,
"printed_page": 106,
"bbox": [1.0, 2.0, 3.0, 4.0],
}],
})
assert refs[0].physical_page == 104
assert refs[0].page_range == (104, 105)
assert refs[0].printed_page_range == (105, 106)
assert refs[1].block_id == "p105_t0"
assert refs[1].physical_page == 105
assert refs[1].printed_page == 106
@@ -0,0 +1,244 @@
"""The loop must improve answers, and must be unable to run away.
Bounded is the load-bearing property: an unbounded self-improvement loop on a
paid provider is a bill and a latency incident, and on a clinical tool it is
also an answer nobody is waiting for any more.
"""
from __future__ import annotations
import pytest
from rag.conversation import ConversationState, ResolvedQuestion
from rag.metrics import CLARIFY_ASKED, LOOP_REFINED, InMemoryMetrics
from rag.reasoning import (
ClarifyReason,
DeterministicAssessor,
LoopTrace,
Sufficiency,
TurnBudget,
run_turn,
)
ADULT = "Người lớn: uống 0,5 - 1 g/lần, cách 4 - 6 giờ; tối đa 4 g/ngày."
CHILD = "Trẻ em 6 - 12 tuổi: 240 - 250 mg mỗi lần."
def _q(text: str = "liều dùng paracetamol", population: str | None = None) -> ResolvedQuestion:
return ResolvedQuestion(
text=text,
drug_id="paracetamol",
section_key="lieu_luong_va_cach_dung",
population=population,
verbosity=None,
inherited_drug=False,
inherited_section=False,
)
def _state() -> ConversationState:
return ConversationState("c1", turn_count=1)
class _Retriever:
"""Returns a different evidence set on each round, recording calls."""
def __init__(self, *rounds: tuple[str, ...]) -> None:
self._rounds = list(rounds)
self.queries: list[str] = []
def __call__(self, resolved: ResolvedQuestion) -> tuple[str, ...]:
self.queries.append(resolved.text)
if self._rounds:
return self._rounds.pop(0)
return ()
def _generator(answer: str | None):
calls = {"n": 0}
def generate(resolved, evidence, state):
calls["n"] += 1
return answer
generate.calls = calls # type: ignore[attr-defined]
return generate
# --- the loop earns its rounds ------------------------------------------------
def test_a_named_gap_buys_exactly_one_more_round():
"""Asked for adults, first round returned only paediatric text."""
retriever = _Retriever((CHILD,), (ADULT, CHILD))
metrics = InMemoryMetrics()
outcome = run_turn(
_state(),
_q(population="nguoi_lon"),
retriever,
_generator("Người lớn: 0,5 - 1 g/lần [1]"),
metrics=metrics,
)
assert outcome.retrieval_rounds_used == 2
assert outcome.generated is True
assert metrics.total(LOOP_REFINED, missing="population:nguoi_lon") == 1
assert retriever.queries[1] != retriever.queries[0]
def test_a_satisfied_question_spends_one_round_only():
retriever = _Retriever((ADULT,))
outcome = run_turn(
_state(), _q(population="nguoi_lon"), retriever, _generator("ok [1]")
)
assert outcome.retrieval_rounds_used == 1
assert outcome.stopped_because == "sufficient"
def test_a_simple_question_does_not_loop():
"""No population asked for means nothing to be missing."""
retriever = _Retriever((ADULT, CHILD))
outcome = run_turn(_state(), _q(), retriever, _generator("ok [1]"))
assert outcome.retrieval_rounds_used == 1
# --- the loop cannot run away -------------------------------------------------
def test_retrieval_rounds_are_hard_capped():
"""Evidence never satisfies the assessor; the loop must still stop."""
retriever = _Retriever((CHILD,), (CHILD,), (CHILD,), (CHILD,), (CHILD,))
outcome = run_turn(
_state(),
_q(population="nguoi_lon"),
retriever,
_generator("ok [1]"),
budget=TurnBudget(retrieval_rounds=2),
)
assert outcome.retrieval_rounds_used == 2
assert outcome.stopped_because == "retrieval_budget"
assert len(retriever.queries) == 2
def test_repairs_are_hard_capped_and_degrade_to_no_answer():
"""`generate` returning None means verification refused it every time."""
generate = _generator(None)
outcome = run_turn(
_state(),
_q(),
_Retriever((ADULT,)),
generate,
budget=TurnBudget(repairs=1, llm_calls=4),
)
assert outcome.answer is None
assert outcome.repairs_used == 1
assert generate.calls["n"] == 2 # first attempt + one repair
assert outcome.stopped_because == "repair_budget"
def test_llm_call_budget_stops_generation_entirely():
generate = _generator(None)
outcome = run_turn(
_state(), _q(), _Retriever((ADULT,)), generate, budget=TurnBudget(llm_calls=0)
)
assert generate.calls["n"] == 0
assert outcome.stopped_because == "llm_budget"
def test_a_refinement_that_changes_nothing_stops_the_loop():
"""Guards against a loop that keeps re-issuing the same query."""
class _SameQuery:
def assess(self, resolved, evidence):
return Sufficiency(False, missing="x", refined_query=resolved.text)
retriever = _Retriever((CHILD,), (CHILD,))
outcome = run_turn(
_state(), _q(), retriever, _generator("ok [1]"), assessor=_SameQuery()
)
assert outcome.stopped_because == "query_unchanged"
assert len(retriever.queries) == 1
def test_an_unnamed_gap_does_not_buy_a_round():
""""Feels incomplete" is not a reason to spend the budget."""
class _Vague:
def assess(self, resolved, evidence):
return Sufficiency(False)
retriever = _Retriever((CHILD,), (CHILD,))
outcome = run_turn(_state(), _q(), retriever, _generator("ok [1]"), assessor=_Vague())
assert outcome.stopped_because == "no_actionable_gap"
assert len(retriever.queries) == 1
# --- clarify beats guessing ---------------------------------------------------
@pytest.mark.parametrize(
"signal",
[ClarifyReason.NO_ATTRIBUTE, ClarifyReason.AMBIGUOUS_DRUG, ClarifyReason.MULTI_ATTRIBUTE],
)
def test_a_clarify_signal_short_circuits_before_any_spend(signal):
retriever = _Retriever((ADULT,))
generate = _generator("ok [1]")
metrics = InMemoryMetrics()
budget = TurnBudget()
outcome = run_turn(
_state(), _q(), retriever, generate, clarify_signals=(signal,), budget=budget, metrics=metrics
)
assert outcome.clarification is not None
assert outcome.clarification.reason == signal
assert outcome.answer is None
assert retriever.queries == []
assert generate.calls["n"] == 0
assert budget.llm_calls == 4 and budget.retrieval_rounds == 2
assert metrics.total(CLARIFY_ASKED, reason=signal) == 1
def test_no_evidence_at_all_asks_rather_than_abstaining_silently():
outcome = run_turn(_state(), _q(), _Retriever(()), _generator("ok [1]"))
assert outcome.clarification is not None
assert outcome.clarification.reason == ClarifyReason.STILL_INSUFFICIENT
assert outcome.stopped_because == "no_evidence"
# --- the deterministic assessor ----------------------------------------------
def test_assessor_only_reports_gaps_it_can_demonstrate():
assessor = DeterministicAssessor()
assert assessor.assess(_q(population="nguoi_lon"), (ADULT,)).sufficient is True
assert assessor.assess(_q(population="nguoi_lon"), (CHILD,)).sufficient is False
# No population asked for: nothing can be shown missing.
assert assessor.assess(_q(), (CHILD,)).sufficient is True
def test_trace_records_the_stages_walked():
trace = LoopTrace()
run_turn(_state(), _q(), _Retriever((ADULT,)), _generator("ok [1]"), trace=trace)
assert trace.stages[0] == "understand"
assert "retrieve" in trace.stages
assert "assess" in trace.stages
assert trace.stages[-1] == "generate"
@@ -0,0 +1,265 @@
from pathlib import Path
from rag.artifacts import load_aliases
from rag.evaluation import CaseOrigin, EvaluationCase, EvaluationOutcome, summarize
from rag.in_memory import InMemoryLexicalRetriever, InMemoryParentStore, _char_ngrams
from rag.models import (
EvidenceDecision,
ParentDocument,
QueryIntent,
RetrievalDocument,
SearchHit,
SourceRef,
SubjectScope,
)
from rag.routing import (
CatalogDrugResolver,
DrugResolutionStatus,
QueryRoutingService,
)
from rag.service import EvidencePolicy, RetrievalService
SOURCE = SourceRef(
physical_page=112,
precision="region",
block_id="p112_t0",
bbox=(1, 2, 3, 4),
source_crop="crops/p112_t0.png",
)
VERIFIED_ENTITIES = (
Path(__file__).parents[3] / "ingestion/data/verified/drug_entities.json"
)
def table_service(*, visual: bool = False) -> RetrievalService:
row = RetrievalDocument(
doc_id="p112_t0::row::0",
parent_id="p112_t0",
drug_id="acetylcystein",
kind="table_row",
section_key="lieu_luong_va_cach_dung",
text="ACETYLCYSTEIN thể trọng 40 đến 49 kg thể tích 34 ml",
source_refs=(SOURCE,),
requires_visual_check=visual,
)
parent = ParentDocument(
parent_id="p112_t0",
kind="table",
text="| Thể trọng | Thể tích |\n| 40 - 49 kg | 34 ml |",
source_refs=(SOURCE,),
)
return RetrievalService(
InMemoryLexicalRetriever([row]),
InMemoryParentStore([parent]),
EvidencePolicy(minimum_score=0.01),
)
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
assert result.evidence[0].hydrated_from_parent is True
assert result.evidence[0].text.startswith("| Thể trọng")
assert result.evidence[0].source_refs == (SOURCE,)
def test_visual_risk_routes_to_pdf_verifier():
result = table_service(visual=True).retrieve(
"acetylcystein 45 kg bao nhiêu ml", "acetylcystein",
)
assert result.decision == EvidenceDecision.VERIFY_PDF
assert result.reason == "visual_verification_required"
def test_missing_parent_abstains_instead_of_answering_from_row_fragment():
row = RetrievalDocument(
doc_id="row", parent_id="missing", drug_id="drug", kind="table_row",
section_key="dose", text="drug dose 10 mg", source_refs=(SOURCE,),
)
service = RetrievalService(
InMemoryLexicalRetriever([row]), InMemoryParentStore([]),
EvidencePolicy(minimum_score=0.01),
)
result = service.retrieve("drug dose", "drug")
assert result.decision == EvidenceDecision.ABSTAIN
assert result.reason == "parent_hydration_failed"
def test_missing_provenance_abstains():
document = RetrievalDocument(
doc_id="prose", drug_id="drug", kind="prose", section_key="dose",
text="drug dose 10 mg", source_refs=(),
)
service = RetrievalService(
InMemoryLexicalRetriever([document]), InMemoryParentStore([]),
EvidencePolicy(minimum_score=0.01),
)
result = service.retrieve("drug dose", "drug")
assert result.decision == EvidenceDecision.ABSTAIN
assert result.reason == "missing_provenance"
class FixedRetriever:
def __init__(self, hits: list[SearchHit]) -> None:
self._hits = hits
def search(self, query: str, drug_id: str, limit: int) -> list[SearchHit]:
del query, drug_id
return self._hits[:limit]
def test_near_tied_different_sources_are_returned_for_evidence_grading():
first = RetrievalDocument("a", "drug", "prose", "A", "dose", (SOURCE,))
second = RetrievalDocument("b", "drug", "prose", "B", "dose", (SOURCE,))
service = RetrievalService(
FixedRetriever([SearchHit(first, 0.50), SearchHit(second, 0.495)]),
InMemoryParentStore([]),
)
result = service.retrieve("dose", "drug")
assert result.decision == EvidenceDecision.ANSWERABLE
assert [item.evidence_id for item in result.evidence] == ["a", "b"]
def test_source_derived_cases_do_not_inflate_release_gate_metric():
outcomes = [
EvaluationOutcome(
EvaluationCase(
"expert-1", "q", "drug", "right", CaseOrigin.EXPERT,
SubjectScope.HUMAN,
),
("wrong",),
),
EvaluationOutcome(
EvaluationCase(
"generated-1", "q", "drug", "right", CaseOrigin.SOURCE_DERIVED,
SubjectScope.HUMAN,
),
("right",),
),
]
report = summarize(outcomes)
assert report["expert_release_gate"]["recall_at_1"] == 0.0
assert report["source_derived_diagnostic"]["recall_at_1"] == 1.0
assert report["manual_routing_diagnostic"]["cases"] == 0
def test_character_ngrams_preserve_word_order():
assert _char_ngrams("beta alpha") != _char_ngrams("alpha beta")
def test_drug_resolver_handles_a_typo_without_fixture_drug_id():
resolver = CatalogDrugResolver({"famciclovir": {"famciclovir"}})
result = resolver.resolve("famciclovia chỉnh liều khi ClCr 20")
assert result.status == DrugResolutionStatus.RESOLVED
assert result.drug_id == "famciclovir"
def test_drug_resolver_does_not_guess_when_query_mentions_two_drugs():
resolver = CatalogDrugResolver({
"oresol": {"oresol"},
"natri_clorid": {"natri clorid"},
})
result = resolver.resolve("oresol có bao nhiêu natri clorid")
assert result.status == DrugResolutionStatus.AMBIGUOUS
def test_verified_aliases_reach_common_parenthesized_drug_names():
resolver = CatalogDrugResolver(load_aliases(VERIFIED_ENTITIES))
assert resolver.resolve("Liều paracetamol cho người lớn").drug_id == (
"paracetamol_acetaminophen"
)
assert resolver.resolve("Chống chỉ định aspirin").drug_id == (
"acid_acetylsalicylic_aspirin"
)
assert resolver.resolve("Công thức oresol").drug_id == (
"thuoc_uong_bu_nuoc_va_ien_giai"
)
def test_verified_catalog_protects_canonical_substring_traps():
resolver = CatalogDrugResolver(load_aliases(VERIFIED_ENTITIES))
traps = {
"homatropin hydrobromid": "homatropin_hydrobromid",
"hydroclorothiazid": "hydroclorothiazid",
"flucloxacilin": "flucloxacilin",
"pseudoephedrin": "pseudoephedrin",
"ethinylestradiol": "ethinylestradiol",
"desloratadin": "desloratadin",
"ciprofloxacin": "ciprofloxacin",
"levofloxacin": "levofloxacin",
"esomeprazol": "esomeprazol",
"methylprednisolon": "methylprednisolon",
"medroxyprogesteron acetat": "medroxyprogesteron_acetat",
"methyltestosteron": "methyltestosteron",
"oxytetracyclin": "oxytetracyclin",
}
for query, expected_id in traps.items():
result = resolver.resolve(query)
assert result.status == DrugResolutionStatus.RESOLVED
assert result.drug_id == expected_id
def test_asymmetric_evidence_resolves_subject_and_component():
ors = RetrievalDocument(
doc_id="ors", drug_id="ors", kind="prose", section_key="formula",
text="Oresol chứa natri clorid", source_refs=(SOURCE,),
)
sodium = RetrievalDocument(
doc_id="sodium", drug_id="sodium", kind="prose", section_key="dose",
text="Natri clorid dùng đường truyền", source_refs=(SOURCE,),
)
routed = QueryRoutingService(
RetrievalService(
InMemoryLexicalRetriever([ors, sodium]), InMemoryParentStore([]),
EvidencePolicy(minimum_score=0.01),
),
CatalogDrugResolver({"ors": {"oresol"}, "sodium": {"natri clorid"}}),
)
result = routed.retrieve(
"Oresol có bao nhiêu natri clorid?",
SubjectScope.HUMAN,
QueryIntent.FACT_LOOKUP,
)
assert result.decision == EvidenceDecision.ANSWERABLE
assert result.resolved_drug_id == "ors"
def test_structured_scope_fails_closed_and_rejects_non_human_subject():
document = RetrievalDocument(
doc_id="dose", drug_id="famciclovir", drug_name="FAMCICLOVIR",
kind="prose", text="Famciclovir liều cho người lớn", section_key="dose",
source_refs=(SOURCE,),
)
routed = QueryRoutingService(
RetrievalService(
InMemoryLexicalRetriever([document]), InMemoryParentStore([]),
EvidencePolicy(minimum_score=0.01),
),
CatalogDrugResolver({"famciclovir": {"famciclovir"}}),
)
veterinary = routed.retrieve(
"Liều famciclovir cho mèo", SubjectScope.NON_HUMAN,
)
unknown = routed.retrieve("Liều famciclovir")
adult = routed.retrieve(
"Liều famciclovir cho người lớn", SubjectScope.HUMAN,
QueryIntent.FACT_LOOKUP,
)
assert veterinary.decision == EvidenceDecision.ABSTAIN
assert veterinary.reason == "out_of_scope_non_human"
assert unknown.decision == EvidenceDecision.ABSTAIN
assert unknown.reason == "subject_scope_unknown"
assert adult.decision == EvidenceDecision.ANSWERABLE
assert adult.resolved_drug_id == "famciclovir"
def test_recommendation_intent_is_refused_at_policy_boundary():
routed = QueryRoutingService(
table_service(), CatalogDrugResolver({"drug": {"drug"}}),
)
result = routed.retrieve(
"Nên dùng drug nào?", SubjectScope.HUMAN, QueryIntent.RECOMMENDATION,
)
assert result.decision == EvidenceDecision.ABSTAIN
assert result.reason == "recommendation_out_of_scope"
@@ -0,0 +1,76 @@
"""A section must be served in the order it was written.
Found 2026-08-05 by reading a real answer in the UI rather than a test:
`liều dùng paracetamol` opened mid-sentence on `5 - 12 tuổi:` and buried
`Liều lượng: Người lớn:` seven hundred words down. Qdrant scrolls in point-id
order and point ids are `uuid5(chunk_id)`, so PARACETAMOL's five dosing parts
came back **3, 4, 1, 2, 0**.
This is a clinical defect, not a cosmetic one: a reader who stops partway
through stops in the middle of a different population's dose.
"""
from __future__ import annotations
from adapters.qdrant import QdrantRetriever
class _ScrambledClient:
"""Returns parts out of order, the way a real scroll did."""
def __init__(self, part_indices: list[int], include_index: bool = True) -> None:
self._payloads = [
{
"chunk_id": f"paracetamol__lieu__{index}",
"drug_id": "paracetamol",
"section_key": "lieu_luong_va_cach_dung",
"chunk_kind": "prose",
"text": f"part {index}",
"source_refs": [{"physical_page": 1120, "precision": "page"}],
**({"part_index": index} if include_index else {}),
}
for index in part_indices
]
def scroll(self, **kwargs):
points = [type("P", (), {"payload": payload})() for payload in self._payloads]
return points, None
class _Embedder:
dimensions = 4
def embed_query(self, text: str) -> list[float]: # never used by this route
raise AssertionError("find_by_section must not embed anything")
def _hits(part_indices: list[int], include_index: bool = True) -> list[str]:
retriever = QdrantRetriever(
_ScrambledClient(part_indices, include_index), "duocthu_v1", _Embedder()
)
return [
hit.document.text
for hit in retriever.find_by_section("paracetamol", "lieu_luong_va_cach_dung")
]
def test_the_exact_scramble_observed_against_the_real_collection():
assert _hits([3, 4, 1, 2, 0]) == [
"part 0",
"part 1",
"part 2",
"part 3",
"part 4",
]
def test_an_already_ordered_section_is_left_alone():
assert _hits([0, 1, 2, 3]) == ["part 0", "part 1", "part 2", "part 3"]
def test_a_part_missing_its_index_is_kept_and_sorted_last():
"""Dropping it would silently shorten a dose list, which is the one
outcome worse than showing it out of order."""
texts = _hits([1, 0], include_index=False)
assert len(texts) == 2
assert set(texts) == {"part 0", "part 1"}
@@ -0,0 +1,218 @@
"""Section routing: the fix for hit@1 0.05 on `chong_chi_dinh`.
Measured 2026-08-04 on the real Cohere collection, letting vector similarity
choose the section answered contraindication questions correctly 1 time in 20.
These tests pin the two properties that make filtering safe: the longer phrase
always wins, and an unrecognised question routes nowhere rather than guessing.
"""
from __future__ import annotations
from rag.models import (
EvidenceDecision,
RetrievalDocument,
SearchHit,
SourceRef,
)
from rag.in_memory import InMemoryParentStore
from rag.sections import SectionResolver
from rag.service import EvidencePolicy, RetrievalService
SOURCE = SourceRef(physical_page=200, precision="page", printed_page=142)
def _doc(doc_id: str, section_key: str, text: str) -> RetrievalDocument:
return RetrievalDocument(
doc_id=doc_id,
parent_id=None,
drug_id="aspirin",
kind="prose",
section_key=section_key,
text=text,
source_refs=(SOURCE,),
requires_visual_check=False,
)
class SectionAwareRetriever:
"""Fake that records which route the service actually took."""
def __init__(self, docs: list[RetrievalDocument]) -> None:
self._docs = docs
self.search_calls: list[str] = []
self.section_calls: list[tuple[str, str]] = []
def search(
self, query: str, drug_id: str, limit: int # noqa: ARG002 — Retriever protocol
) -> list[SearchHit]:
self.search_calls.append(query)
# Deliberately wrong on purpose: the whole point is that the section
# route must not consult similarity at all.
return [SearchHit(self._docs[-1], 0.99)]
def find_by_section(self, drug_id: str, section_key: str) -> list[SearchHit]:
self.section_calls.append((drug_id, section_key))
return [
SearchHit(doc, 1.0) for doc in self._docs if doc.section_key == section_key
]
class SimilarityOnlyRetriever:
def __init__(self, docs: list[RetrievalDocument]) -> None:
self._docs = docs
self.search_calls: list[str] = []
def search(
self, query: str, drug_id: str, limit: int # noqa: ARG002 — Retriever protocol
) -> list[SearchHit]:
self.search_calls.append(query)
return [SearchHit(self._docs[0], 0.99)]
CONTRA = [
_doc("c1", "chong_chi_dinh", "Mẫn cảm với aspirin."),
_doc("c2", "chong_chi_dinh", "Loét dạ dày tá tràng đang tiến triển."),
_doc("c3", "chong_chi_dinh", "Hen do aspirin."),
_doc("c4", "chong_chi_dinh", "Suy gan nặng."),
_doc("c5", "chong_chi_dinh", "Trẻ em dưới 16 tuổi có sốt virus."),
]
INDICATION = [_doc("i1", "chi_dinh", "Giảm đau, hạ sốt, chống viêm.")]
PHARMACOLOGY = [_doc("p1", "duoc_ly_va_co_che_tac_dung", "Ức chế cyclooxygenase.")]
ALL_DOCS = CONTRA + INDICATION + PHARMACOLOGY
def _service(retriever, resolver: SectionResolver | None) -> RetrievalService:
return RetrievalService(
retriever,
InMemoryParentStore([]),
EvidencePolicy(minimum_score=0.01),
section_resolver=resolver,
)
class TestSectionResolver:
def test_contraindication_is_never_read_as_indication(self) -> None:
"""The one that measured 0.05. "chống chỉ định" contains "chỉ định"."""
resolver = SectionResolver()
assert resolver.resolve("Chống chỉ định của aspirin là gì?").section_key == (
"chong_chi_dinh"
)
assert resolver.resolve("Chỉ định của aspirin?").section_key == "chi_dinh"
def test_works_without_diacritics(self) -> None:
assert SectionResolver().resolve("aspirin chong chi dinh").section_key == (
"chong_chi_dinh"
)
def test_overdose_is_not_read_as_dose(self) -> None:
resolver = SectionResolver()
assert resolver.resolve("xử trí quá liều metformin").section_key == (
"qua_lieu_va_xu_tri"
)
assert resolver.resolve("liều dùng metformin").section_key == (
"lieu_luong_va_cach_dung"
)
def test_adr_management_is_not_read_as_adr_itself(self) -> None:
resolver = SectionResolver()
assert resolver.resolve("xử trí tác dụng phụ của prednisolon").section_key == (
"huong_dan_xu_tri_adr"
)
assert resolver.resolve("tác dụng phụ của prednisolon").section_key == (
"tac_dung_khong_mong_muon"
)
def test_incompatibility_is_not_read_as_interaction(self) -> None:
resolver = SectionResolver()
assert resolver.resolve("tương kỵ của ceftriaxon").section_key == "tuong_ky"
assert resolver.resolve("tương tác của ceftriaxon").section_key == (
"tuong_tac_thuoc"
)
def test_bare_lieu_resolves_without_capturing_overdose(self) -> None:
"""Found by testing on human-written golden questions, not templates.
4 of 16 said just "Liều Metformin cho người lớn?". Adding bare "liều"
is only safe because "quá liều" is longer and is tested first.
"""
resolver = SectionResolver()
assert resolver.resolve("Liều Metformin cho người lớn?").section_key == (
"lieu_luong_va_cach_dung"
)
assert resolver.resolve("quá liều paracetamol").section_key == (
"qua_lieu_va_xu_tri"
)
def test_colloquial_pregnancy_phrasing(self) -> None:
assert SectionResolver().resolve(
"Bà bầu dùng Ibuprofen được không?"
).section_key == "thoi_ky_mang_thai"
def test_unrecognised_question_routes_nowhere(self) -> None:
"""No match must not become a guess — the caller falls back."""
assert SectionResolver().resolve("thuốc này giá bao nhiêu") is None
assert SectionResolver().resolve("") is None
def test_new_section_needs_no_code_change(self) -> None:
resolver = SectionResolver({"invented_section": ("một mục hoàn toàn mới",)})
assert resolver.resolve("hỏi về một mục hoàn toàn mới").section_key == (
"invented_section"
)
class TestSectionRouting:
def test_named_section_bypasses_similarity_entirely(self) -> None:
retriever = SectionAwareRetriever(ALL_DOCS)
result = _service(retriever, SectionResolver()).retrieve(
"Chống chỉ định của aspirin là gì?", "aspirin"
)
assert retriever.section_calls == [("aspirin", "chong_chi_dinh")]
assert retriever.search_calls == []
assert result.decision == EvidenceDecision.ANSWERABLE
assert {item.evidence_id for item in result.evidence} == {
"c1", "c2", "c3", "c4", "c5",
}
def test_whole_section_is_returned_past_the_evidence_limit(self) -> None:
"""Five contraindications must not arrive as three."""
retriever = SectionAwareRetriever(ALL_DOCS)
service = RetrievalService(
retriever,
InMemoryParentStore([]),
EvidencePolicy(minimum_score=0.01, evidence_limit=3),
section_resolver=SectionResolver(),
)
result = service.retrieve("chống chỉ định aspirin", "aspirin")
assert len(result.evidence) == 5
def test_unnamed_section_falls_back_to_similarity(self) -> None:
retriever = SectionAwareRetriever(ALL_DOCS)
result = _service(retriever, SectionResolver()).retrieve(
"aspirin dùng cho bệnh nhân này thế nào", "aspirin"
)
assert retriever.section_calls == []
assert retriever.search_calls
assert result.decision == EvidenceDecision.ANSWERABLE
def test_retriever_without_the_capability_still_works(self) -> None:
retriever = SimilarityOnlyRetriever(ALL_DOCS)
result = _service(retriever, SectionResolver()).retrieve(
"Chống chỉ định của aspirin là gì?", "aspirin"
)
assert retriever.search_calls
assert result.decision == EvidenceDecision.ANSWERABLE
def test_no_resolver_keeps_the_old_behaviour(self) -> None:
retriever = SectionAwareRetriever(ALL_DOCS)
_service(retriever, None).retrieve("chống chỉ định aspirin", "aspirin")
assert retriever.section_calls == []
assert retriever.search_calls
def test_named_but_empty_section_falls_back(self) -> None:
"""A drug with no such section must not abstain — similarity still tries."""
retriever = SectionAwareRetriever(INDICATION + PHARMACOLOGY)
result = _service(retriever, SectionResolver()).retrieve(
"chống chỉ định aspirin", "aspirin"
)
assert retriever.section_calls == [("aspirin", "chong_chi_dinh")]
assert retriever.search_calls
assert result.decision == EvidenceDecision.ANSWERABLE