Wire the guarded conversational RAG answer layer end-to-end
This commit is contained in:
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user