Checkpoint frontend UI/UX overhaul and ingestion embed benchmark work
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
"""`rag/agent.py` — the new orchestrator wired live in F-03.
|
||||
|
||||
Codex's 2026-08-06 review (F-03/F-10) flagged that this module had zero test
|
||||
coverage despite being built to replace the live front end. This is the
|
||||
first coverage: routing branches with fakes, not an exhaustive golden set
|
||||
(that is F-10's job — a production-path regression suite against real
|
||||
Qdrant/Bedrock fixtures).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from rag.agent import RagAgent
|
||||
from rag.answer import GroundedAnswerService
|
||||
from rag.models import Evidence, EvidenceDecision, RetrievalResult, SourceRef
|
||||
from rag.understanding import QueryFrame
|
||||
|
||||
SOURCE = SourceRef(physical_page=100, precision="region", printed_page=100)
|
||||
|
||||
|
||||
def _evidence(text: str) -> Evidence:
|
||||
return Evidence(
|
||||
evidence_id="e0", matched_doc_id="e0", kind="prose", text=text, score=1.0,
|
||||
source_refs=(SOURCE,), hydrated_from_parent=False, requires_visual_check=False,
|
||||
)
|
||||
|
||||
|
||||
class _FixedUnderstander:
|
||||
def __init__(self, frame: QueryFrame) -> None:
|
||||
self._frame = frame
|
||||
|
||||
def understand(self, turn, history=()):
|
||||
return self._frame
|
||||
|
||||
|
||||
class _FixedRetrieval:
|
||||
"""Stands in for `RetrievalService.retrieve_framed` — a canned result per
|
||||
drug_id regardless of section/query, so these tests assert routing, not
|
||||
retrieval (that's `test_retrieval_service.py`'s job)."""
|
||||
|
||||
def __init__(self, results: dict[str, RetrievalResult]) -> None:
|
||||
self._results = results
|
||||
|
||||
def retrieve_framed(self, drug_id, section_key, query, is_overview=False):
|
||||
return self._results.get(
|
||||
drug_id, RetrievalResult(EvidenceDecision.ABSTAIN, "not_configured")
|
||||
)
|
||||
|
||||
|
||||
def _agent(frame: QueryFrame, results: dict[str, RetrievalResult] | None = None) -> RagAgent:
|
||||
# `routing=None`: `answer_from_result` (the only method this path calls)
|
||||
# never touches it — see `rag/answer.py`.
|
||||
answers = GroundedAnswerService(routing=None)
|
||||
return RagAgent(_FixedUnderstander(frame), _FixedRetrieval(results or {}), answers)
|
||||
|
||||
|
||||
def test_smalltalk_does_not_touch_retrieval():
|
||||
agent = _agent(QueryFrame(turn_type="smalltalk"))
|
||||
reply = agent.handle("chào bạn")
|
||||
assert reply.decision == "answerable"
|
||||
assert reply.turn_type == "smalltalk"
|
||||
assert "Dược thư" in reply.answer
|
||||
|
||||
|
||||
def test_out_of_scope_turn_type_abstains():
|
||||
agent = _agent(QueryFrame(turn_type="out_of_scope"))
|
||||
reply = agent.handle("cách tiêm truyền tĩnh mạch")
|
||||
assert reply.decision == "abstain"
|
||||
assert reply.reason == "out_of_scope"
|
||||
|
||||
|
||||
def test_veterinary_phrase_abstains_even_if_the_model_missed_it():
|
||||
# The model returned an ordinary-looking frame; the keyword backstop
|
||||
# (`rag.policy.looks_non_human`, the same one F-02 wired server-side)
|
||||
# still catches it before any retrieval happens.
|
||||
agent = _agent(QueryFrame(turn_type="drug_attribute", drugs=("metformin",)))
|
||||
reply = agent.handle("liều metformin cho chó bao nhiêu")
|
||||
assert reply.decision == "abstain"
|
||||
assert reply.reason == "out_of_scope"
|
||||
|
||||
|
||||
def test_unknown_drug_name_is_reported_not_substituted():
|
||||
agent = _agent(QueryFrame(turn_type="drug_attribute", unknown_drugs=("aspirinol",)))
|
||||
reply = agent.handle("liều aspirinol")
|
||||
assert reply.decision == "abstain"
|
||||
assert reply.reason == "drug_not_in_formulary"
|
||||
assert "aspirinol" in reply.answer
|
||||
|
||||
|
||||
def test_no_drug_named_asks_which_one():
|
||||
agent = _agent(QueryFrame(turn_type="drug_attribute"))
|
||||
reply = agent.handle("liều dùng bao nhiêu")
|
||||
assert reply.decision == "clarify"
|
||||
assert reply.reason == "no_drug"
|
||||
|
||||
|
||||
def test_needs_clarify_frame_is_surfaced_directly():
|
||||
agent = _agent(QueryFrame(
|
||||
turn_type="dosing_calc", drugs=("paracetamol",),
|
||||
needs_clarify=True, clarify_reason="Bé mấy tuổi, cân nặng bao nhiêu kg?",
|
||||
))
|
||||
reply = agent.handle("liều paracetamol cho trẻ em")
|
||||
assert reply.decision == "clarify"
|
||||
assert reply.clarification == "Bé mấy tuổi, cân nặng bao nhiêu kg?"
|
||||
|
||||
|
||||
def test_single_drug_attribute_retrieves_and_answers():
|
||||
result = RetrievalResult(
|
||||
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
|
||||
(_evidence("Liều 500 mg mỗi ngày."),), resolved_drug_id="metformin",
|
||||
)
|
||||
agent = _agent(
|
||||
QueryFrame(turn_type="drug_attribute", drugs=("metformin",),
|
||||
attribute="lieu_luong_va_cach_dung"),
|
||||
{"metformin": result},
|
||||
)
|
||||
reply = agent.handle("liều metformin")
|
||||
assert reply.decision == "answerable"
|
||||
assert reply.drugs == ("metformin",)
|
||||
assert "500 mg" in reply.answer
|
||||
|
||||
|
||||
def test_interaction_combines_both_drugs_evidence():
|
||||
warfarin = RetrievalResult(
|
||||
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
|
||||
(_evidence("Tương tác với aspirin làm tăng nguy cơ chảy máu."),),
|
||||
)
|
||||
aspirin = RetrievalResult(
|
||||
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
|
||||
(_evidence("Tương tác với warfarin làm tăng nguy cơ chảy máu."),),
|
||||
)
|
||||
agent = _agent(
|
||||
QueryFrame(turn_type="interaction", drugs=("warfarin", "aspirin")),
|
||||
{"warfarin": warfarin, "aspirin": aspirin},
|
||||
)
|
||||
reply = agent.handle("warfarin với aspirin có dùng chung được không")
|
||||
assert reply.decision == "answerable"
|
||||
assert reply.turn_type == "interaction"
|
||||
assert "chảy máu" in reply.answer
|
||||
|
||||
|
||||
def test_interaction_with_no_evidence_abstains_and_does_not_imply_safety():
|
||||
agent = _agent(
|
||||
QueryFrame(turn_type="interaction", drugs=("drug_a", "drug_b")), {},
|
||||
)
|
||||
reply = agent.handle("drug_a và drug_b dùng chung được không")
|
||||
assert reply.decision == "abstain"
|
||||
assert reply.reason == "no_interaction_evidence"
|
||||
assert "KHÔNG có nghĩa là an toàn" in reply.answer
|
||||
|
||||
|
||||
def test_symptom_to_drug_without_a_drug_name_asks_honestly_not_wired_yet():
|
||||
agent = _agent(QueryFrame(turn_type="symptom_to_drug", indication="sốt cao"))
|
||||
reply = agent.handle("sốt cao uống thuốc gì")
|
||||
assert reply.decision == "clarify"
|
||||
assert reply.reason == "reverse_lookup_not_ready"
|
||||
|
||||
|
||||
def test_history_is_passed_to_the_understander_on_the_next_turn():
|
||||
received_history: list[tuple[str, ...]] = []
|
||||
|
||||
class _RecordingUnderstander:
|
||||
def understand(self, turn, history=()):
|
||||
received_history.append(tuple(history))
|
||||
return QueryFrame(turn_type="smalltalk")
|
||||
|
||||
answers = GroundedAnswerService(routing=None)
|
||||
agent = RagAgent(_RecordingUnderstander(), _FixedRetrieval({}), answers)
|
||||
|
||||
agent.handle("chào bạn", conversation_id="c1")
|
||||
agent.handle("còn liều thì sao?", conversation_id="c1")
|
||||
|
||||
assert received_history[0] == ()
|
||||
assert any("chào bạn" in line for line in received_history[1])
|
||||
|
||||
|
||||
def test_history_is_isolated_per_conversation_id():
|
||||
received_history: list[tuple[str, ...]] = []
|
||||
|
||||
class _RecordingUnderstander:
|
||||
def understand(self, turn, history=()):
|
||||
received_history.append(tuple(history))
|
||||
return QueryFrame(turn_type="smalltalk")
|
||||
|
||||
answers = GroundedAnswerService(routing=None)
|
||||
agent = RagAgent(_RecordingUnderstander(), _FixedRetrieval({}), answers)
|
||||
|
||||
agent.handle("xin chào", conversation_id="a")
|
||||
agent.handle("liều dùng bao nhiêu", conversation_id="b")
|
||||
|
||||
# The second call, on a different conversation id, must not see "a"'s turn.
|
||||
assert received_history[1] == ()
|
||||
|
||||
|
||||
def test_autocomplete_delegates_to_the_configured_source():
|
||||
class _Source:
|
||||
def complete(self, prefix, k):
|
||||
return ["metformin_id", "metoprolol_id"]
|
||||
|
||||
answers = GroundedAnswerService(routing=None)
|
||||
agent = RagAgent(
|
||||
_FixedUnderstander(QueryFrame(turn_type="smalltalk")),
|
||||
_FixedRetrieval({}), answers, autocomplete=_Source(),
|
||||
)
|
||||
assert agent.complete("met") == ["Metformin Id", "Metoprolol Id"]
|
||||
|
||||
|
||||
def test_autocomplete_with_no_source_configured_returns_empty():
|
||||
answers = GroundedAnswerService(routing=None)
|
||||
agent = RagAgent(
|
||||
_FixedUnderstander(QueryFrame(turn_type="smalltalk")),
|
||||
_FixedRetrieval({}), answers,
|
||||
)
|
||||
assert agent.complete("met") == []
|
||||
@@ -2,7 +2,9 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from config import Settings
|
||||
from main import create_app
|
||||
from rag.answer import GroundedAnswerService
|
||||
from rag.agent import AgentReply
|
||||
from rag.answer import Citation, GroundedAnswerService
|
||||
from rag.metrics import TRACE_WRITE_FAILED, InMemoryMetrics
|
||||
from rag.models import EvidenceDecision, QueryIntent, RetrievalResult, SubjectScope
|
||||
|
||||
|
||||
@@ -51,3 +53,127 @@ def test_query_requires_structured_scope_and_intent():
|
||||
)
|
||||
response = TestClient(app).post("/v1/rag/query", json={"query": "Liều?"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# --- F-03: the app.state.conversational slot is the new RagAgent, wired live ---
|
||||
|
||||
|
||||
class FakeAgent:
|
||||
"""Stands in for `RagAgent` — proves `routers/rag.py` calls `.handle()`
|
||||
and maps `AgentReply` correctly, not that the agent's own routing logic
|
||||
is correct (that's `test_agent.py`)."""
|
||||
|
||||
def __init__(self, reply: AgentReply) -> None:
|
||||
self._reply = reply
|
||||
self.calls: list[tuple[str, str | None]] = []
|
||||
|
||||
def handle(self, turn: str, conversation_id: str | None = None) -> AgentReply:
|
||||
self.calls.append((turn, conversation_id))
|
||||
return self._reply
|
||||
|
||||
def complete(self, prefix: str, k: int = 8) -> list[str]:
|
||||
return ["Metformin"] if prefix else []
|
||||
|
||||
|
||||
def _citation() -> Citation:
|
||||
return Citation(
|
||||
chunk_id="metformin::lieu::0", printed_page_start=714, printed_page_end=714,
|
||||
physical_page=812,
|
||||
)
|
||||
|
||||
|
||||
def test_query_routes_through_the_agent_when_one_is_configured():
|
||||
agent = FakeAgent(AgentReply(
|
||||
decision="answerable", reason="grounded_evidence_available",
|
||||
answer="Liều 500 mg [1].", citations=(_citation(),),
|
||||
drugs=("metformin",), turn_type="drug_attribute", generated=True,
|
||||
))
|
||||
app = create_app(
|
||||
settings=Settings(),
|
||||
answer_service=GroundedAnswerService(FixedRouting()),
|
||||
conversational=agent,
|
||||
trace_writer=MemoryTraceWriter(),
|
||||
)
|
||||
response = TestClient(app).post("/v1/rag/query", json={
|
||||
"query": "Liều metformin?", "subject_scope": "human",
|
||||
"intent": "fact_lookup", "conversation_id": "c1",
|
||||
})
|
||||
body = response.json()
|
||||
assert response.status_code == 200
|
||||
assert body["decision"] == "answerable"
|
||||
assert body["answer"] == "Liều 500 mg [1]."
|
||||
assert body["resolved_drug_id"] == "metformin"
|
||||
assert len(body["citations"]) == 1
|
||||
assert agent.calls == [("Liều metformin?", "c1")]
|
||||
|
||||
|
||||
def test_query_agent_clarification_is_surfaced_as_the_answer():
|
||||
agent = FakeAgent(AgentReply(
|
||||
decision="clarify", reason="no_drug",
|
||||
clarification="Anh/chị muốn tra thuốc nào?",
|
||||
))
|
||||
app = create_app(
|
||||
settings=Settings(),
|
||||
answer_service=GroundedAnswerService(FixedRouting()),
|
||||
conversational=agent,
|
||||
trace_writer=MemoryTraceWriter(),
|
||||
)
|
||||
response = TestClient(app).post("/v1/rag/query", json={
|
||||
"query": "liều dùng bao nhiêu", "subject_scope": "human",
|
||||
"intent": "fact_lookup",
|
||||
})
|
||||
body = response.json()
|
||||
assert body["decision"] == "clarify"
|
||||
assert body["answer"] == "Anh/chị muốn tra thuốc nào?"
|
||||
assert body["resolved_drug_id"] is None
|
||||
|
||||
|
||||
def test_suggest_delegates_to_the_agent_when_configured():
|
||||
app = create_app(
|
||||
settings=Settings(),
|
||||
conversational=FakeAgent(AgentReply(decision="answerable", reason="x")),
|
||||
trace_writer=MemoryTraceWriter(),
|
||||
)
|
||||
response = TestClient(app).get("/v1/rag/suggest", params={"q": "met"})
|
||||
assert response.json() == {"suggestions": ["Metformin"]}
|
||||
|
||||
|
||||
def test_suggest_with_no_agent_configured_returns_empty():
|
||||
app = create_app(settings=Settings(), trace_writer=MemoryTraceWriter())
|
||||
response = TestClient(app).get("/v1/rag/suggest", params={"q": "met"})
|
||||
assert response.json() == {"suggestions": []}
|
||||
|
||||
|
||||
# --- F-09: trace persistence is fail-open ------------------------------------
|
||||
|
||||
|
||||
class _RaisingTraceWriter:
|
||||
"""Stands in for a Postgres outage: `save()` always raises."""
|
||||
|
||||
def save(self, **fields):
|
||||
raise ConnectionError("could not connect to postgres")
|
||||
|
||||
|
||||
def test_a_trace_write_failure_does_not_turn_a_good_answer_into_a_500():
|
||||
agent = FakeAgent(AgentReply(
|
||||
decision="answerable", reason="grounded_evidence_available",
|
||||
answer="Liều 500 mg [1].", citations=(_citation(),),
|
||||
drugs=("metformin",), turn_type="drug_attribute", generated=True,
|
||||
))
|
||||
metrics = InMemoryMetrics()
|
||||
app = create_app(
|
||||
settings=Settings(),
|
||||
answer_service=GroundedAnswerService(FixedRouting()),
|
||||
conversational=agent,
|
||||
trace_writer=_RaisingTraceWriter(),
|
||||
metrics=metrics,
|
||||
)
|
||||
response = TestClient(app).post("/v1/rag/query", json={
|
||||
"query": "Liều metformin?", "subject_scope": "human", "intent": "fact_lookup",
|
||||
})
|
||||
body = response.json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert body["answer"] == "Liều 500 mg [1]."
|
||||
assert body["trace_id"] # a locally-generated fallback id, still present
|
||||
assert metrics.total(TRACE_WRITE_FAILED) == 1
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""`_catalog_names` — the drug names shown to `LlmQueryUnderstander`.
|
||||
|
||||
Reproduces a live 2026-08-06 bug: picking the first N aliases alphabetically
|
||||
could drop a drug's own recognizable name entirely, breaking multi-turn
|
||||
follow-ups where the drug is no longer restated in the raw turn text (see
|
||||
`docs/progress-log.md` for the exact failure: "Liều paracetamol cho trẻ em"
|
||||
-> two clarify rounds -> "Không tìm thấy paracetamol").
|
||||
"""
|
||||
from bootstrap import _catalog_names
|
||||
|
||||
|
||||
def test_alphabetically_early_junk_alias_does_not_bury_the_canonical_name():
|
||||
aliases = {
|
||||
"paracetamol_acetaminophen": {
|
||||
"0Frezefev", "ABAB", "Ace kid 80", "PARACETAMOL", "Acetaminophen",
|
||||
},
|
||||
}
|
||||
shown = _catalog_names(aliases)["paracetamol_acetaminophen"]
|
||||
assert "paracetamol acetaminophen" in shown
|
||||
|
||||
|
||||
def test_canonical_name_is_always_first():
|
||||
aliases = {"metformin": {"METFORMIN", "Axiol", "Dybis", "Zzyzx"}}
|
||||
shown = _catalog_names(aliases)["metformin"]
|
||||
assert shown.split(", ")[0] == "metformin"
|
||||
|
||||
|
||||
def test_drug_with_no_aliases_still_shows_its_canonical_name():
|
||||
shown = _catalog_names({"some_drug": set()})["some_drug"]
|
||||
assert shown == "some drug"
|
||||
|
||||
|
||||
def test_output_is_capped_and_does_not_duplicate_the_canonical_name():
|
||||
aliases = {"drug_x": {f"Brand{i}" for i in range(20)} | {"DRUG X", "drug x"}}
|
||||
shown = _catalog_names(aliases)["drug_x"].split(", ")
|
||||
assert shown[0] == "drug x"
|
||||
assert shown.count("drug x") == 1
|
||||
assert len(shown) <= 3
|
||||
@@ -41,11 +41,23 @@ class _Routing:
|
||||
|
||||
|
||||
class _Generator:
|
||||
def __init__(self, payload: dict) -> None:
|
||||
def __init__(self, payload: dict, entailment_payload: dict | None = None) -> None:
|
||||
self._payload = payload
|
||||
self._entailment_payload = entailment_payload or {
|
||||
"entailed": True,
|
||||
"unsupported": [],
|
||||
}
|
||||
|
||||
def generate(self, system: str, user: str, schema: dict) -> str: # noqa: ARG002
|
||||
return json.dumps(self._payload, ensure_ascii=False)
|
||||
# `_generate` also runs a post-generation entailment check; tell the
|
||||
# two request shapes apart by schema so callers here only need to
|
||||
# fake the main answer, not both.
|
||||
payload = (
|
||||
self._entailment_payload
|
||||
if "entailed" in schema.get("properties", {})
|
||||
else self._payload
|
||||
)
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def _answerable(*evidence: Evidence, is_overview: bool = False) -> RetrievalResult:
|
||||
@@ -72,17 +84,56 @@ def test_only_cited_sources_are_returned():
|
||||
assert grounded.citations[0].printed_page_start == 200
|
||||
|
||||
|
||||
def test_answer_citing_nothing_falls_back_to_all_citations():
|
||||
def test_answer_citing_nothing_is_rejected_not_dressed_up_with_borrowed_citations():
|
||||
result = _answerable(_evidence(0, 100), _evidence(1, 200))
|
||||
service = GroundedAnswerService(
|
||||
_Routing(result),
|
||||
# no [n] marker at all: rather than show zero provenance, show all.
|
||||
# no [n] marker at all: grounding.verify rejects this outright (an
|
||||
# uncited claim, per F-01). A generator is configured, so the
|
||||
# rejection abstains — it must not attach every retrieved citation
|
||||
# to dress an uncited generation up as sourced (the old behavior),
|
||||
# and it must not silently degrade to a raw extractive quote either
|
||||
# (owner correction, 2026-08-06: no fallback to the retired
|
||||
# offline-extractive shape when a real generator is configured).
|
||||
_Generator({"answer": "Không có trích dẫn.", "evidence_sufficient": True}),
|
||||
)
|
||||
|
||||
grounded = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
|
||||
|
||||
assert len(grounded.citations) == 2
|
||||
assert grounded.generated is False
|
||||
assert grounded.answer is None
|
||||
assert grounded.citations == ()
|
||||
assert grounded.result.decision == EvidenceDecision.ABSTAIN
|
||||
|
||||
|
||||
def test_underspecified_dose_asks_instead_of_dumping():
|
||||
"""The reasoning step: a dose question spanning bands with no age/weight is
|
||||
turned into a clarification, not the whole section."""
|
||||
result = _answerable(_evidence(0, 100), _evidence(1, 200))
|
||||
gen = _Generator(
|
||||
{"sufficient": False,
|
||||
"clarifying_question": "Bé mấy tuổi, cân nặng bao nhiêu kg?"}
|
||||
)
|
||||
service = GroundedAnswerService(_Routing(result), gen)
|
||||
|
||||
g = service.answer("paracetamol cho trẻ em", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
|
||||
|
||||
assert g.clarification is not None
|
||||
assert "tuổi" in g.clarification
|
||||
assert g.answer == g.clarification
|
||||
assert g.generated is False
|
||||
|
||||
|
||||
def test_sufficient_query_is_not_turned_into_a_clarification():
|
||||
result = _answerable(_evidence(0, 100), _evidence(1, 200))
|
||||
gen = _Generator({"sufficient": True, "clarifying_question": None})
|
||||
service = GroundedAnswerService(_Routing(result), gen)
|
||||
|
||||
g = service.answer("liều người lớn", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
|
||||
|
||||
# sufficiency passes; generation then runs (its payload lacks answer keys, so
|
||||
# it falls back to the source text) — the point is no clarification fired.
|
||||
assert g.clarification is None
|
||||
|
||||
|
||||
def test_bare_name_builds_an_intro_prompt():
|
||||
|
||||
@@ -136,6 +136,49 @@ def test_recent_window_evicts_oldest():
|
||||
assert state.turn_count == 8
|
||||
|
||||
|
||||
def test_evicted_turns_reach_overflow_not_silently_dropped():
|
||||
"""Bug fixed 2026-08-06 (Codex review, F-06): `overflow()` used to check
|
||||
`len(self.recent) > window`, but `append()` already truncates `recent`
|
||||
to `window`, so that comparison could never be true — evicted turns
|
||||
never reached the summariser no matter how long a conversation ran.
|
||||
Exact repro from the review: 8 turns into a window of 6."""
|
||||
state = ConversationState("c1")
|
||||
for index in range(8):
|
||||
state = state.append(Turn("user", f"q{index}", "2026-08-05"), window=6)
|
||||
|
||||
overflow = state.overflow()
|
||||
|
||||
assert [turn.text for turn in overflow] == ["q0", "q1"]
|
||||
|
||||
|
||||
def test_overflow_accumulates_across_the_two_appends_one_turn_makes():
|
||||
"""A live turn typically calls `append()` twice in a row (user, then
|
||||
assistant). Each can evict at most one turn; the second call's overflow
|
||||
must not overwrite, and so lose, the first's."""
|
||||
state = ConversationState("c1")
|
||||
for index in range(6):
|
||||
state = state.append(Turn("user", f"q{index}", "2026-08-05"), window=6)
|
||||
assert state.overflow() == () # window exactly full, nothing evicted yet
|
||||
|
||||
state = state.append(Turn("user", "q6", "2026-08-05"), window=6)
|
||||
state = state.append(Turn("assistant", "a6", "2026-08-05"), window=6)
|
||||
|
||||
assert [turn.text for turn in state.overflow()] == ["q0", "q1"]
|
||||
|
||||
|
||||
def test_overflow_is_empty_again_after_the_caller_clears_it():
|
||||
from dataclasses import replace
|
||||
|
||||
state = ConversationState("c1")
|
||||
for index in range(8):
|
||||
state = state.append(Turn("user", f"q{index}", "2026-08-05"), window=6)
|
||||
assert state.overflow() != ()
|
||||
|
||||
state = replace(state, pending_overflow=())
|
||||
|
||||
assert state.overflow() == ()
|
||||
|
||||
|
||||
def test_focus_update_stamps_the_current_turn():
|
||||
state = _state(turn_count=3)
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ def test_followup_inherits_drug_and_passes_it_resolved():
|
||||
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:")
|
||||
assert out.answer.startswith("Về Metformin:")
|
||||
# The inherited drug is passed already-resolved (not re-resolved from the
|
||||
# rewritten turn text), and the raw follow-up drives section routing.
|
||||
last_query, last_drug_id = answers.calls[-1]
|
||||
|
||||
@@ -65,23 +65,48 @@ class _FixedRouting:
|
||||
|
||||
|
||||
class _Generator:
|
||||
"""Returns whatever payload the test wants the model to have produced."""
|
||||
"""Returns whatever payload the test wants the model to have produced.
|
||||
|
||||
def __init__(self, payload) -> None:
|
||||
`_generate` now makes up to four calls through this port: the main
|
||||
answer, a sufficiency check (skipped here — one evidence block), and up
|
||||
to two entailment calls (a reject retries once — live probing found the
|
||||
judge noisy on an identical claim/evidence pair). They're told apart by
|
||||
schema, so a test that only cares about the main answer doesn't also
|
||||
have to fake an entailment response by hand; `entailment_payload`
|
||||
overrides it when a test wants the entailment pass to reject. Pass a
|
||||
list of payloads to get a different answer on each successive
|
||||
entailment call (e.g. `[reject, accept]` for the retry-recovers case).
|
||||
"""
|
||||
|
||||
def __init__(self, payload, entailment_payload=None) -> None:
|
||||
self._payload = payload
|
||||
default = {"entailed": True, "unsupported": []}
|
||||
payloads = entailment_payload if entailment_payload is not None else default
|
||||
self._entailment_payloads = (
|
||||
list(payloads) if isinstance(payloads, list) else [payloads]
|
||||
)
|
||||
self._entailment_call = 0
|
||||
|
||||
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)
|
||||
if "entailed" in schema.get("properties", {}):
|
||||
index = min(self._entailment_call, len(self._entailment_payloads) - 1)
|
||||
payload = self._entailment_payloads[index]
|
||||
self._entailment_call += 1
|
||||
else:
|
||||
payload = self._payload
|
||||
if isinstance(payload, BaseException):
|
||||
raise payload
|
||||
if isinstance(payload, str):
|
||||
return payload
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def _answer(payload, result: RetrievalResult | None = None):
|
||||
def _answer(payload, result: RetrievalResult | None = None, entailment_payload=None):
|
||||
metrics = InMemoryMetrics()
|
||||
service = GroundedAnswerService(
|
||||
_FixedRouting(result or _result()), _Generator(payload), metrics
|
||||
_FixedRouting(result or _result()),
|
||||
_Generator(payload, entailment_payload),
|
||||
metrics,
|
||||
)
|
||||
grounded = service.answer("Liều Metformin?", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
|
||||
return grounded, metrics
|
||||
@@ -97,8 +122,12 @@ def test_invented_dose_is_refused_and_never_reaches_the_answer():
|
||||
)
|
||||
|
||||
assert grounded.generated is False
|
||||
assert "850" not in grounded.answer
|
||||
assert grounded.answer.startswith(EVIDENCE_TEXT)
|
||||
# A generator is configured, so a rejected generation abstains — it does
|
||||
# NOT silently degrade to a raw source dump (owner correction, 2026-08-06:
|
||||
# this is a real LLM chatbot, not the retired offline-extractive build).
|
||||
assert grounded.answer is None
|
||||
assert grounded.result.decision == EvidenceDecision.ABSTAIN
|
||||
assert grounded.result.reason == "generation_unavailable"
|
||||
assert metrics.total(GENERATION_REJECTED, reason="ungrounded_number") == 1
|
||||
|
||||
|
||||
@@ -119,7 +148,12 @@ def test_citation_pointing_at_nothing_is_refused():
|
||||
)
|
||||
|
||||
assert grounded.generated is False
|
||||
assert metrics.total(GENERATION_REJECTED, reason="invalid_citation") == 1
|
||||
# [3] is out of range with one evidence block, so "500" has no valid
|
||||
# citation to bind to — grounding.verify now flags it as unsupported
|
||||
# rather than letting it pass because 500 happens to exist somewhere in
|
||||
# the (single) evidence block anyway. ungrounded_number takes priority
|
||||
# over invalid_citation in GroundingReport.reason; both are present.
|
||||
assert metrics.total(GENERATION_REJECTED, reason="ungrounded_number") == 1
|
||||
|
||||
|
||||
def test_faithful_rewrite_is_served():
|
||||
@@ -134,6 +168,95 @@ def test_faithful_rewrite_is_served():
|
||||
assert metrics.total(GENERATION_REJECTED) == 0
|
||||
|
||||
|
||||
# --- the entailment pass: catches what number/citation checks structurally can't -----
|
||||
|
||||
|
||||
def test_fabricated_nonnumeric_claim_with_a_valid_citation_is_rejected():
|
||||
"""Reproduces `claim_bia` from the Codex 2026-08-06 review end to end:
|
||||
right drug, syntactically valid citation, fabricated indication.
|
||||
grounding.verify alone cannot see this (no number, citation in range) —
|
||||
the entailment pass, told the model judged evidence 1 does not support
|
||||
it, is what rejects the generation."""
|
||||
grounded, metrics = _answer(
|
||||
{"answer": "Metformin chữa ung thư [1].", "evidence_sufficient": True},
|
||||
entailment_payload={"entailed": False, "unsupported": [1]},
|
||||
)
|
||||
|
||||
assert grounded.generated is False
|
||||
assert grounded.answer is None
|
||||
assert grounded.result.decision == EvidenceDecision.ABSTAIN
|
||||
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 1
|
||||
|
||||
|
||||
def test_entailment_check_running_and_passing_still_serves_the_answer():
|
||||
grounded, metrics = _answer(
|
||||
{"answer": "Metformin dùng điều trị đái tháo đường [1].",
|
||||
"evidence_sufficient": True},
|
||||
entailment_payload={"entailed": True, "unsupported": []},
|
||||
)
|
||||
|
||||
assert grounded.generated is True
|
||||
assert metrics.total(GENERATION_SERVED) == 1
|
||||
|
||||
|
||||
def test_entailment_retries_once_after_a_reject_and_a_later_accept_serves():
|
||||
"""Reproduces the 2026-08-06 live finding: the same claim/evidence pair,
|
||||
called three times through the real judge, came back entailed twice and
|
||||
rejected once — a single noisy reject must not discard a correct,
|
||||
well-cited answer."""
|
||||
grounded, metrics = _answer(
|
||||
{"answer": "Metformin dùng điều trị đái tháo đường [1].",
|
||||
"evidence_sufficient": True},
|
||||
entailment_payload=[
|
||||
{"entailed": False, "unsupported": [1]},
|
||||
{"entailed": True, "unsupported": []},
|
||||
],
|
||||
)
|
||||
|
||||
assert grounded.generated is True
|
||||
assert metrics.total(GENERATION_SERVED) == 1
|
||||
|
||||
|
||||
def test_entailment_two_agreeing_rejects_still_discard():
|
||||
grounded, metrics = _answer(
|
||||
{"answer": "Metformin chữa ung thư [1].", "evidence_sufficient": True},
|
||||
entailment_payload=[
|
||||
{"entailed": False, "unsupported": [1]},
|
||||
{"entailed": False, "unsupported": [1]},
|
||||
],
|
||||
)
|
||||
|
||||
assert grounded.generated is False
|
||||
assert grounded.answer is None
|
||||
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 1
|
||||
|
||||
|
||||
def test_entailment_provider_outage_fails_closed_to_abstain():
|
||||
grounded, metrics = _answer(
|
||||
{"answer": "Người lớn: 500 mg, 2 lần/ngày [1].", "evidence_sufficient": True},
|
||||
entailment_payload=AnswerGenerationUnavailable(),
|
||||
)
|
||||
|
||||
assert grounded.generated is False
|
||||
assert grounded.answer is None
|
||||
assert grounded.result.decision == EvidenceDecision.ABSTAIN
|
||||
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 1
|
||||
|
||||
|
||||
def test_entailment_check_is_skipped_when_the_answer_has_no_claim_text():
|
||||
"""An answer that is nothing but a citation marker has no claim text for
|
||||
an entailment pass to check against — `_verify_entailment` must not call
|
||||
the provider at all. Proven by making that call raise: if the skip
|
||||
didn't fire, this would reject rather than serve the answer."""
|
||||
grounded, metrics = _answer(
|
||||
{"answer": "[1]", "evidence_sufficient": True},
|
||||
entailment_payload=AnswerGenerationUnavailable(),
|
||||
)
|
||||
|
||||
assert grounded.generated is True
|
||||
assert metrics.total(GENERATION_REJECTED) == 0
|
||||
|
||||
|
||||
def test_citations_survive_generation():
|
||||
"""Provenance is the point; a prettier answer must not cost the folio."""
|
||||
grounded, _ = _answer(
|
||||
@@ -145,7 +268,7 @@ def test_citations_survive_generation():
|
||||
assert grounded.citations[0].printed_page_start == 714
|
||||
|
||||
|
||||
# --- degradation is always to the source, never to an error -------------------
|
||||
# --- a configured generator that fails abstains, never a raw source dump -----
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -158,11 +281,13 @@ def test_citations_survive_generation():
|
||||
({"answer": "...", "evidence_sufficient": False}, "evidence_insufficient"),
|
||||
],
|
||||
)
|
||||
def test_every_generation_failure_falls_back_to_the_source_text(payload, reason):
|
||||
def test_every_generation_failure_abstains_instead_of_a_raw_source_dump(payload, reason):
|
||||
grounded, metrics = _answer(payload)
|
||||
|
||||
assert grounded.generated is False
|
||||
assert grounded.answer.startswith(EVIDENCE_TEXT)
|
||||
assert grounded.answer is None
|
||||
assert grounded.result.decision == EvidenceDecision.ABSTAIN
|
||||
assert grounded.result.reason == "generation_unavailable"
|
||||
assert metrics.total(GENERATION_REJECTED, reason=reason) == 1
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Adversarial regression cases for `rag.grounding.verify`.
|
||||
|
||||
Each case reproduces a defect found by Codex's 2026-08-06 code review
|
||||
(`coordination/CODEX_RAG_CODE_REVIEW_2026-08-06.md`, F-01) against the old
|
||||
implementation, which pooled every evidence number into one global set and
|
||||
never required a citation at all. `verify` must now bind a claim's numbers
|
||||
to only the evidence block(s) its own citation group names, and must reject
|
||||
a claim with no citation regardless of whether it contains a number.
|
||||
"""
|
||||
from rag import grounding
|
||||
|
||||
|
||||
def test_number_from_the_wrong_evidence_block_is_rejected():
|
||||
# Reproduces `so_sai_nguon`: 500 mg is real, but only in evidence 2 —
|
||||
# citing [1] for it must fail, not pass because 500 exists *somewhere*.
|
||||
report = grounding.verify(
|
||||
"Liều 500 mg [1].",
|
||||
("Không dùng khi suy thận.", "Liều 500 mg mỗi ngày."),
|
||||
)
|
||||
assert not report.grounded
|
||||
assert report.unsupported_numbers == ("500",)
|
||||
assert report.reason == "ungrounded_number"
|
||||
|
||||
|
||||
def test_citing_the_correct_block_for_the_number_is_grounded():
|
||||
report = grounding.verify(
|
||||
"Liều 500 mg [2].",
|
||||
("Không dùng khi suy thận.", "Liều 500 mg mỗi ngày."),
|
||||
)
|
||||
assert report.grounded
|
||||
assert report.cited_indices == (2,)
|
||||
|
||||
|
||||
def test_answer_with_no_citation_at_all_is_rejected():
|
||||
# Reproduces `khong_citation`: a number that is genuinely in the evidence
|
||||
# still must not pass when the answer never cites anything.
|
||||
report = grounding.verify("Liều 500 mg.", ("Liều 500 mg mỗi ngày.",))
|
||||
assert not report.grounded
|
||||
assert report.uncited_claim
|
||||
assert report.unsupported_numbers == ("500",)
|
||||
assert report.reason == "ungrounded_number"
|
||||
|
||||
|
||||
def test_nonnumeric_claim_with_no_citation_is_rejected():
|
||||
report = grounding.verify(
|
||||
"Chống chỉ định với suy gan nặng.", ("Chống chỉ định: suy gan nặng.",)
|
||||
)
|
||||
assert not report.grounded
|
||||
assert report.uncited_claim
|
||||
assert report.reason == "uncited_claim"
|
||||
|
||||
|
||||
def test_trailing_text_after_the_last_citation_needs_its_own_citation():
|
||||
report = grounding.verify(
|
||||
"Liều 500 mg [1]. Không dùng khi suy thận.",
|
||||
("Liều 500 mg mỗi ngày.",),
|
||||
)
|
||||
assert not report.grounded
|
||||
assert report.uncited_claim
|
||||
|
||||
|
||||
def test_out_of_range_citation_is_invalid_and_leaves_its_claim_unsupported():
|
||||
report = grounding.verify("Liều 500 mg [3].", ("Liều 500 mg mỗi ngày.",))
|
||||
assert not report.grounded
|
||||
assert report.invalid_citations == (3,)
|
||||
assert report.unsupported_numbers == ("500",)
|
||||
assert report.reason == "ungrounded_number"
|
||||
|
||||
|
||||
def test_two_claims_each_binding_correctly_to_their_own_source_is_grounded():
|
||||
report = grounding.verify(
|
||||
"Người lớn 500 mg [1]. Trẻ em 250 mg [2].",
|
||||
("Liều người lớn 500 mg.", "Liều trẻ em 250 mg."),
|
||||
)
|
||||
assert report.grounded
|
||||
assert report.cited_indices == (1, 2)
|
||||
|
||||
|
||||
def test_second_claim_citing_the_first_blocks_source_number_is_rejected():
|
||||
# The child dose (250) is real, but only in evidence 2; citing [1] for it
|
||||
# is the same defect as `so_sai_nguon`, just in a second sentence.
|
||||
report = grounding.verify(
|
||||
"Người lớn 500 mg [1]. Trẻ em 250 mg [1].",
|
||||
("Liều người lớn 500 mg.", "Liều trẻ em 250 mg."),
|
||||
)
|
||||
assert not report.grounded
|
||||
assert report.unsupported_numbers == ("250",)
|
||||
|
||||
|
||||
def test_multiple_markers_on_one_claim_check_against_their_union():
|
||||
report = grounding.verify(
|
||||
"Liều 500 mg [1][2].",
|
||||
("Liều người lớn 500 mg.", "Liều mỗi ngày."),
|
||||
)
|
||||
assert report.grounded
|
||||
assert report.cited_indices == (1, 2)
|
||||
|
||||
|
||||
def test_extractive_quoting_format_is_still_grounded():
|
||||
# The service's extractive fallback formats each block as `text [n]`.
|
||||
report = grounding.verify(
|
||||
"Liều được ghi trong nguồn. [1]", ("Liều được ghi trong nguồn.",)
|
||||
)
|
||||
assert report.grounded
|
||||
|
||||
|
||||
def test_decimal_separator_is_compared_verbatim_not_normalised():
|
||||
report = grounding.verify("Liều 7.5 mg [1].", ("Liều 7,5 mg.",))
|
||||
assert not report.grounded
|
||||
assert report.unsupported_numbers == ("7.5",)
|
||||
|
||||
|
||||
def test_known_gap_fabricated_nonnumeric_claim_with_a_valid_citation_still_passes():
|
||||
# Documents the residual gap `grounding.verify` cannot close on its own
|
||||
# (see module docstring): a citation-bearing claim whose content the
|
||||
# cited block does not actually support. Closed by a separate LLM
|
||||
# entailment pass in `rag/answer.py`, not by this regex-only check.
|
||||
report = grounding.verify(
|
||||
"Metformin chữa ung thư [1].",
|
||||
("Metformin dùng điều trị đái tháo đường.",),
|
||||
)
|
||||
assert report.grounded
|
||||
@@ -20,6 +20,35 @@ 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"
|
||||
|
||||
|
||||
class _PlumbingEmbedder:
|
||||
"""Deterministic local vectors for the Qdrant round-trip plumbing tests.
|
||||
|
||||
Not a semantic model — it exists only so an integration test can upsert and
|
||||
query real chunks without a cloud call. Production has exactly one query
|
||||
embedder (`BedrockCohereQueryEmbedder`); the old local/section-only stubs
|
||||
were removed, so this lives with the test that needs it.
|
||||
"""
|
||||
|
||||
def __init__(self, dimensions: int) -> None:
|
||||
self._dimensions = dimensions
|
||||
|
||||
@property
|
||||
def dimensions(self) -> int:
|
||||
return self._dimensions
|
||||
|
||||
def embed_query(self, text: str) -> list[float]:
|
||||
import hashlib
|
||||
import math
|
||||
|
||||
vector = [0.0] * self._dimensions
|
||||
for token in text.casefold().split():
|
||||
digest = hashlib.sha256(token.encode("utf-8")).digest()
|
||||
index = int.from_bytes(digest[:4], "big") % self._dimensions
|
||||
vector[index] += 1.0 if digest[4] & 1 else -1.0
|
||||
norm = math.sqrt(sum(value * value for value in vector))
|
||||
return [value / norm for value in vector] if norm else vector
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _first_real_chunk() -> dict:
|
||||
with CHUNKS.open(encoding="utf-8") as handle:
|
||||
@@ -42,12 +71,11 @@ 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)
|
||||
embedder = _PlumbingEmbedder(32)
|
||||
record = _first_real_chunk()
|
||||
try:
|
||||
client.create_collection(
|
||||
@@ -103,12 +131,144 @@ def test_real_postgres_migration_insert_and_read_back():
|
||||
assert stored.citations[0]["printed_page_start"] == 101
|
||||
|
||||
|
||||
class _FakeJsonLlm:
|
||||
"""Deterministic stand-in for the Bedrock Converse generator. Satisfies
|
||||
both `JsonLlm` (query understanding) and `AnswerGenerator` (answer +
|
||||
entailment) — both ports are just `generate(system, user, schema) ->
|
||||
str` — and tells the three call shapes apart the same way
|
||||
`tests/test_grounded_generation.py`'s fake does: by schema shape.
|
||||
|
||||
Deliberately not a real Bedrock call: this suite asserts exact
|
||||
drug id / section / citation / grounding outcomes, and this session's
|
||||
own live probing (`docs/progress-log.md`, F-01/F-03 entries) found real
|
||||
generation and entailment calls genuinely non-deterministic — the wrong
|
||||
foundation for a regression assertion. The wiring under test — real
|
||||
`RagAgent`, real `RetrievalService`/`QdrantRetriever` against a real
|
||||
(temporary) Qdrant collection, real `GroundedAnswerService` — is
|
||||
identical to production; only the cloud model call is faked.
|
||||
"""
|
||||
|
||||
def __init__(self, frame_payload: dict, answer_payload: dict) -> None:
|
||||
self._frame_payload = frame_payload
|
||||
self._answer_payload = answer_payload
|
||||
|
||||
def generate(self, system: str, user: str, schema: dict) -> str:
|
||||
if "turn_type" in schema: # FRAME_SCHEMA: flat, not JSON-Schema-shaped
|
||||
return json.dumps(self._frame_payload, ensure_ascii=False)
|
||||
if "entailed" in schema.get("properties", {}):
|
||||
return json.dumps({"entailed": True, "unsupported": []})
|
||||
return json.dumps(self._answer_payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def test_real_rag_agent_end_to_end_through_the_http_api():
|
||||
"""F-10: the *production* orchestrator (`RagAgent`), driven through the
|
||||
real `/v1/rag/query` HTTP endpoint against a real (temporary) Qdrant
|
||||
collection seeded with one real corpus chunk, with a real Postgres trace
|
||||
persisted and read back. Reproduces Codex's exact 2026-08-06 finding —
|
||||
"no current test imports RagAgent, LlmQueryUnderstander, QueryFrame, or
|
||||
retrieve_framed" and "the evaluation runner constructs an in-memory
|
||||
lexical retriever and the old resolver rather than executing the same
|
||||
dependency graph as the live HTTP service" — both false as of this test.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.models import Distance, PointStruct, VectorParams
|
||||
|
||||
from adapters.postgres import PostgresTraceRepository
|
||||
from adapters.qdrant import QdrantParentStore, QdrantRetriever
|
||||
from config import Settings
|
||||
from main import create_app
|
||||
from rag.agent import RagAgent
|
||||
from rag.answer import GroundedAnswerService
|
||||
from rag.routing import CatalogDrugResolver, QueryRoutingService
|
||||
from rag.service import EvidencePolicy, RetrievalService
|
||||
from rag.understanding import LlmQueryUnderstander
|
||||
|
||||
qdrant = QdrantClient(url="http://localhost:6333")
|
||||
collection = f"integration_{uuid.uuid4().hex}"
|
||||
embedder = _PlumbingEmbedder(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),
|
||||
)
|
||||
# A minimal one-drug catalog, real drug_id — F-04's candidate
|
||||
# bounding runs for real here (`RagAgent`/`LlmQueryUnderstander`
|
||||
# are not mocked), so the query must literally name the drug for
|
||||
# the deterministic resolver to find it as a candidate.
|
||||
resolver = CatalogDrugResolver({record["drug_id"]: {record["drug_name"]}})
|
||||
llm = _FakeJsonLlm(
|
||||
frame_payload={
|
||||
"turn_type": "drug_attribute", "drugs": [record["drug_id"]],
|
||||
"unknown_drugs": [], "attribute": None, "population": None,
|
||||
"weight_kg": None, "age_text": None, "indication": None,
|
||||
"needs_clarify": False, "clarify_reason": None,
|
||||
},
|
||||
answer_payload={
|
||||
"answer": f"{record['text']} [1].", "evidence_sufficient": True,
|
||||
},
|
||||
)
|
||||
understander = LlmQueryUnderstander(
|
||||
llm, {record["drug_id"]: record["drug_name"]}, resolver,
|
||||
)
|
||||
answers = GroundedAnswerService(
|
||||
QueryRoutingService(retrieval, resolver), generator=llm,
|
||||
)
|
||||
agent = RagAgent(understander, retrieval, answers)
|
||||
|
||||
app = create_app(
|
||||
settings=Settings(), answer_service=answers,
|
||||
conversational=agent, trace_writer=traces,
|
||||
)
|
||||
response = TestClient(app).post("/v1/rag/query", json={
|
||||
"query": record["drug_name"],
|
||||
"subject_scope": "human", "intent": "fact_lookup",
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["decision"] == "answerable"
|
||||
assert body["resolved_drug_id"] == record["drug_id"]
|
||||
assert body["citations"][0]["chunk_id"] == record["chunk_id"]
|
||||
assert body["citations"][0]["printed_page_start"] == (
|
||||
record["printed_page_range"][0]
|
||||
)
|
||||
assert record["text"] in body["answer"]
|
||||
|
||||
stored = traces.get(body["trace_id"])
|
||||
assert stored is not None
|
||||
assert stored.decision == "answerable"
|
||||
assert stored.resolved_drug_id == record["drug_id"]
|
||||
assert stored.citations[0]["chunk_id"] == record["chunk_id"]
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
qdrant.delete_collection(collection)
|
||||
|
||||
|
||||
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
|
||||
@@ -119,7 +279,7 @@ def test_api_round_trip_uses_qdrant_and_persists_postgres_trace():
|
||||
|
||||
qdrant = QdrantClient(url="http://localhost:6333")
|
||||
collection = f"integration_{uuid.uuid4().hex}"
|
||||
embedder = LocalHashQueryEmbedder(32)
|
||||
embedder = _PlumbingEmbedder(32)
|
||||
record = dict(_first_real_chunk())
|
||||
traces = PostgresTraceRepository(
|
||||
"postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""F-05: the service must refuse to start on a corpus/model manifest
|
||||
mismatch, not silently search with vectors the collection wasn't built from.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from rag.manifest import ManifestMismatch, check_manifest, manifest_collection
|
||||
|
||||
|
||||
def test_manifest_collection_naming():
|
||||
assert manifest_collection("duocthu_v1") == "duocthu_v1__manifest"
|
||||
|
||||
|
||||
def test_matching_manifest_passes():
|
||||
check_manifest(
|
||||
{"model_id": "cohere.embed-v4:0", "dimensions": 1024},
|
||||
"duocthu_v1", "cohere.embed-v4:0", 1024,
|
||||
) # no raise
|
||||
|
||||
|
||||
def test_missing_manifest_refuses():
|
||||
with pytest.raises(ManifestMismatch, match="no corpus manifest"):
|
||||
check_manifest(None, "duocthu_v1", "cohere.embed-v4:0", 1024)
|
||||
|
||||
|
||||
def test_wrong_model_id_refuses_even_with_matching_dimensions():
|
||||
"""The exact scenario the finding names: two unrelated models can both
|
||||
produce 1024-dim vectors."""
|
||||
with pytest.raises(ManifestMismatch, match="model_id"):
|
||||
check_manifest(
|
||||
{"model_id": "amazon.titan-embed-text-v2:0", "dimensions": 1024},
|
||||
"duocthu_v1", "cohere.embed-v4:0", 1024,
|
||||
)
|
||||
|
||||
|
||||
def test_wrong_dimensions_refuses():
|
||||
with pytest.raises(ManifestMismatch, match="dimensions"):
|
||||
check_manifest(
|
||||
{"model_id": "cohere.embed-v4:0", "dimensions": 768},
|
||||
"duocthu_v1", "cohere.embed-v4:0", 1024,
|
||||
)
|
||||
|
||||
|
||||
def test_both_mismatched_reports_both():
|
||||
with pytest.raises(ManifestMismatch) as excinfo:
|
||||
check_manifest(
|
||||
{"model_id": "other-model", "dimensions": 768},
|
||||
"duocthu_v1", "cohere.embed-v4:0", 1024,
|
||||
)
|
||||
assert "model_id" in str(excinfo.value)
|
||||
assert "dimensions" in str(excinfo.value)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""F-02: subject_scope must be server-derived, not client-asserted.
|
||||
|
||||
Reproduces the Codex 2026-08-06 finding directly: a caller claiming "human"
|
||||
on a veterinary query must not get that claim honored. The claim can only
|
||||
make the result MORE conservative, never less.
|
||||
|
||||
Scoped to `subject_scope` only. `resolve_query_intent`/`QueryIntent.RECOMMENDATION`
|
||||
keyword detection was tried and removed the same day: this product is for
|
||||
doctors and pharmacists, and a clinician asking "nên dùng thuốc gì" is a
|
||||
normal professional use of a formulary reference, not something to abstain
|
||||
on. See `docs/progress-log.md` 2026-08-06.
|
||||
"""
|
||||
from rag.models import SubjectScope
|
||||
from rag.policy import resolve_subject_scope
|
||||
|
||||
|
||||
def test_veterinary_query_is_non_human_even_when_client_claims_human():
|
||||
scope = resolve_subject_scope("Liều cho chó bị viêm khớp?", SubjectScope.HUMAN)
|
||||
assert scope == SubjectScope.NON_HUMAN
|
||||
|
||||
|
||||
def test_ordinary_dose_question_stays_human():
|
||||
scope = resolve_subject_scope("Liều metformin cho người lớn?", SubjectScope.HUMAN)
|
||||
assert scope == SubjectScope.HUMAN
|
||||
|
||||
|
||||
def test_client_cannot_widen_a_server_detected_non_human_scope():
|
||||
# Even an explicit non_human claim from an honest client must stick.
|
||||
scope = resolve_subject_scope("thuốc cho mèo", SubjectScope.NON_HUMAN)
|
||||
assert scope == SubjectScope.NON_HUMAN
|
||||
|
||||
|
||||
def test_client_narrowing_to_non_human_is_honored_even_without_a_keyword_hit():
|
||||
# A caller with better information than the keyword list can narrow.
|
||||
scope = resolve_subject_scope("liều dùng", SubjectScope.NON_HUMAN)
|
||||
assert scope == SubjectScope.NON_HUMAN
|
||||
|
||||
|
||||
def test_unknown_claim_with_no_server_signal_stays_unknown():
|
||||
scope = resolve_subject_scope("liều dùng", SubjectScope.UNKNOWN)
|
||||
assert scope == SubjectScope.UNKNOWN
|
||||
|
||||
|
||||
def test_clinician_asking_which_drug_is_preferred_is_not_flagged_non_human():
|
||||
# A doctor/pharmacist comparing options across monographs is core,
|
||||
# intended use of this product — not a request to gate.
|
||||
scope = resolve_subject_scope(
|
||||
"Bệnh nhân suy thận, nên dùng thuốc hạ áp nào?", SubjectScope.HUMAN
|
||||
)
|
||||
assert scope == SubjectScope.HUMAN
|
||||
@@ -56,6 +56,82 @@ def table_service(*, visual: bool = False) -> RetrievalService:
|
||||
)
|
||||
|
||||
|
||||
class _OverviewRetriever:
|
||||
"""A fake with `find_by_drug`/`find_by_section` (the Qdrant adapter's
|
||||
shape) — `InMemoryLexicalRetriever` doesn't implement either, so
|
||||
`retrieve_framed`'s overview path is otherwise untestable."""
|
||||
|
||||
def __init__(self, documents: list[RetrievalDocument]) -> None:
|
||||
self._documents = documents
|
||||
|
||||
def find_by_drug(self, drug_id: str) -> list[SearchHit]:
|
||||
return [
|
||||
SearchHit(document=d, score=1.0)
|
||||
for d in self._documents if d.drug_id == drug_id
|
||||
]
|
||||
|
||||
def find_by_section(self, drug_id: str, section_key: str) -> list[SearchHit]:
|
||||
return [
|
||||
SearchHit(document=d, score=1.0)
|
||||
for d in self._documents
|
||||
if d.drug_id == drug_id and d.section_key == section_key
|
||||
]
|
||||
|
||||
def search(self, query: str, drug_id: str, limit: int) -> list[SearchHit]:
|
||||
return []
|
||||
|
||||
|
||||
_MONOGRAPH_SECTIONS = (
|
||||
"ten_chung_quoc_te", "ma_atc", "loai_thuoc", "dang_thuoc_va_ham_luong",
|
||||
"duoc_ly_va_co_che_tac_dung", "chi_dinh", "chong_chi_dinh", "than_trong",
|
||||
"tac_dung_khong_mong_muon", "lieu_luong_va_cach_dung", "tuong_tac_thuoc",
|
||||
"qua_lieu_va_xu_tri", "do_on_dinh_va_bao_quan", "thong_tin_quy_che",
|
||||
)
|
||||
|
||||
|
||||
def _monograph_service() -> RetrievalService:
|
||||
documents = [
|
||||
RetrievalDocument(
|
||||
doc_id=f"paracetamol::{section}::0", drug_id="paracetamol",
|
||||
kind="prose", section_key=section,
|
||||
text=f"Nội dung mục {section}.", source_refs=(SOURCE,),
|
||||
)
|
||||
for section in _MONOGRAPH_SECTIONS
|
||||
]
|
||||
return RetrievalService(
|
||||
_OverviewRetriever(documents), InMemoryParentStore([]),
|
||||
EvidencePolicy(evidence_limit=3),
|
||||
)
|
||||
|
||||
|
||||
def test_retrieve_framed_overview_answers_from_intro_sections_only():
|
||||
"""Found live 2026-08-06: a bare drug name sent all 14+ sections of the
|
||||
monograph as evidence, producing an answer long enough to intermittently
|
||||
fail generation/entailment. `is_overview=True` must narrow this the same
|
||||
way `retrieve()`'s bare-name branch always has."""
|
||||
result = _monograph_service().retrieve_framed(
|
||||
"paracetamol", None, "paracetamol", is_overview=True
|
||||
)
|
||||
assert result.decision == EvidenceDecision.ANSWERABLE
|
||||
assert result.is_drug_overview is True
|
||||
returned_sections = {e.matched_doc_id.split("::")[1] for e in result.evidence}
|
||||
assert returned_sections <= {
|
||||
"ten_chung_quoc_te", "loai_thuoc", "chi_dinh", "duoc_ly_va_co_che_tac_dung",
|
||||
}
|
||||
assert len(result.evidence) < len(_MONOGRAPH_SECTIONS)
|
||||
|
||||
|
||||
def test_retrieve_framed_question_without_section_is_capped_even_without_rerank():
|
||||
# No reranker configured: `_rerank` fails open and returns everything
|
||||
# unfiltered. Hydration must still bound it — an ordering aid failing
|
||||
# open must not also remove the size cap.
|
||||
result = _monograph_service().retrieve_framed(
|
||||
"paracetamol", None, "thuốc này có tác dụng phụ gì", is_overview=False
|
||||
)
|
||||
assert result.decision == EvidenceDecision.ANSWERABLE
|
||||
assert len(result.evidence) <= 3
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""`rag/understanding.py::LlmQueryUnderstander` — zero coverage before this
|
||||
(Codex's 2026-08-06 review, F-03/F-10), despite being the entry point for
|
||||
every live turn once F-03 wired it in.
|
||||
|
||||
Also covers F-04 (bounded candidates): the resolver decides which drug_ids
|
||||
are even plausible for a turn *before* the model runs, and the model's pick
|
||||
is validated against that bound, not the full catalog.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from rag.understanding import SECTION_KEY_HINTS, SECTION_KEYS, LlmQueryUnderstander
|
||||
|
||||
CATALOG = {
|
||||
"paracetamol_acetaminophen": "paracetamol acetaminophen, PARACETAMOL",
|
||||
"metformin": "metformin, METFORMIN",
|
||||
}
|
||||
|
||||
|
||||
class _FixedLlm:
|
||||
def __init__(self, payload) -> None:
|
||||
self._payload = payload
|
||||
|
||||
def generate(self, system: str, user: str, schema: dict) -> str:
|
||||
if isinstance(self._payload, str):
|
||||
return self._payload
|
||||
return json.dumps(self._payload, ensure_ascii=False)
|
||||
|
||||
|
||||
class _Resolution:
|
||||
def __init__(self, status="not_found", drug_id=None, candidate_drug_ids=()) -> None:
|
||||
self.status = status
|
||||
self.drug_id = drug_id
|
||||
self.candidate_drug_ids = candidate_drug_ids
|
||||
|
||||
|
||||
class _FakeResolver:
|
||||
"""Deterministic stand-in for `CatalogDrugResolver`: resolves a line to
|
||||
a drug_id if one of `known`'s substrings appears in it (case-insensitive),
|
||||
with no fuzzy suggestions unless `suggestions` is given."""
|
||||
|
||||
def __init__(self, known: dict[str, str], suggestions: dict[str, str] | None = None) -> None:
|
||||
self._known = known
|
||||
self._suggestions = suggestions or {}
|
||||
|
||||
def resolve(self, query: str) -> _Resolution:
|
||||
low = query.lower()
|
||||
for needle, drug_id in self._known.items():
|
||||
if needle in low:
|
||||
return _Resolution(status="resolved", drug_id=drug_id)
|
||||
return _Resolution()
|
||||
|
||||
def suggest(self, query: str, k: int = 3, min_score: float = 0.5):
|
||||
low = query.lower()
|
||||
return [
|
||||
(drug_id, 0.9) for needle, drug_id in self._suggestions.items() if needle in low
|
||||
][:k]
|
||||
|
||||
|
||||
RESOLVER = _FakeResolver({
|
||||
"metformin": "metformin",
|
||||
"paracetamol": "paracetamol_acetaminophen",
|
||||
})
|
||||
|
||||
|
||||
def test_drug_id_in_exact_underscore_form_resolves():
|
||||
understander = LlmQueryUnderstander(_FixedLlm({
|
||||
"turn_type": "drug_attribute", "drugs": ["metformin"],
|
||||
"unknown_drugs": [], "attribute": None, "population": None,
|
||||
"weight_kg": None, "age_text": None, "indication": None,
|
||||
"needs_clarify": False, "clarify_reason": None,
|
||||
}), CATALOG, RESOLVER)
|
||||
frame = understander.understand("liều metformin")
|
||||
assert frame.drugs == ("metformin",)
|
||||
assert frame.unknown_drugs == ()
|
||||
|
||||
|
||||
def test_drug_id_echoed_with_spaces_instead_of_underscores_still_resolves():
|
||||
"""Reproduces the live 2026-08-06 bug on a genuine multi-turn shape: the
|
||||
drug is named in an earlier turn (in history), the current turn is just
|
||||
"30 cân", and the model echoed the spaced display name instead of the
|
||||
underscored id. The old strict-equality check demoted a correctly
|
||||
identified drug to unknown_drugs — producing "Không tìm thấy
|
||||
paracetamol trong Dược thư" for a drug that plainly is in it."""
|
||||
understander = LlmQueryUnderstander(_FixedLlm({
|
||||
"turn_type": "dosing_calc", "drugs": ["paracetamol acetaminophen"],
|
||||
"unknown_drugs": [], "attribute": None, "population": "tre_em",
|
||||
"weight_kg": 30, "age_text": "7 tuổi", "indication": None,
|
||||
"needs_clarify": False, "clarify_reason": None,
|
||||
}), CATALOG, RESOLVER)
|
||||
frame = understander.understand(
|
||||
"30 cân",
|
||||
history=("Người dùng: Liều paracetamol cho trẻ em", "Trợ lý: Bé mấy tuổi?"),
|
||||
)
|
||||
assert frame.drugs == ("paracetamol_acetaminophen",)
|
||||
assert frame.unknown_drugs == ()
|
||||
|
||||
|
||||
def test_a_genuinely_invented_name_is_unknown_not_substituted():
|
||||
understander = LlmQueryUnderstander(_FixedLlm({
|
||||
"turn_type": "drug_attribute", "drugs": [],
|
||||
"unknown_drugs": ["aspirinol"], "attribute": None, "population": None,
|
||||
"weight_kg": None, "age_text": None, "indication": None,
|
||||
"needs_clarify": False, "clarify_reason": None,
|
||||
}), CATALOG, RESOLVER)
|
||||
frame = understander.understand("liều aspirinol")
|
||||
assert frame.drugs == ()
|
||||
assert frame.unknown_drugs == ("aspirinol",)
|
||||
|
||||
|
||||
def test_a_name_with_no_deterministic_candidate_is_unknown_even_if_the_model_names_a_real_id():
|
||||
"""F-04's actual guarantee: the model naming a *real* catalog id is not
|
||||
enough — that id must also be among the turn's deterministic candidates.
|
||||
Nothing in "liều aspirinol" fuzzy/exact-matches any real drug (per
|
||||
RESOLVER), so even if the model output a real id here, it must be
|
||||
rejected: the candidate bound, not just catalog membership, is what's
|
||||
trusted."""
|
||||
understander = LlmQueryUnderstander(_FixedLlm({
|
||||
"turn_type": "drug_attribute", "drugs": ["metformin"],
|
||||
"unknown_drugs": [], "attribute": None, "population": None,
|
||||
"weight_kg": None, "age_text": None, "indication": None,
|
||||
"needs_clarify": False, "clarify_reason": None,
|
||||
}), CATALOG, RESOLVER)
|
||||
frame = understander.understand("liều aspirinol")
|
||||
assert frame.drugs == ()
|
||||
assert "metformin" in frame.unknown_drugs
|
||||
|
||||
|
||||
def test_fuzzy_suggestion_bounds_a_typo_into_the_candidate_set():
|
||||
resolver = _FakeResolver({}, suggestions={"metfomin": "metformin"})
|
||||
understander = LlmQueryUnderstander(_FixedLlm({
|
||||
"turn_type": "drug_attribute", "drugs": ["metformin"],
|
||||
"unknown_drugs": [], "attribute": None, "population": None,
|
||||
"weight_kg": None, "age_text": None, "indication": None,
|
||||
"needs_clarify": False, "clarify_reason": None,
|
||||
}), CATALOG, resolver)
|
||||
frame = understander.understand("liều metfomin")
|
||||
assert frame.drugs == ("metformin",)
|
||||
|
||||
|
||||
def test_malformed_json_fails_closed_to_a_clarify():
|
||||
understander = LlmQueryUnderstander(_FixedLlm("not json"), CATALOG, RESOLVER)
|
||||
frame = understander.understand("gì đó")
|
||||
assert frame.turn_type == "out_of_scope"
|
||||
assert frame.needs_clarify is True
|
||||
|
||||
|
||||
def test_unrecognised_turn_type_falls_back_based_on_whether_a_drug_resolved():
|
||||
understander = LlmQueryUnderstander(_FixedLlm({
|
||||
"turn_type": "not_a_real_type", "drugs": ["metformin"],
|
||||
"unknown_drugs": [], "attribute": None, "population": None,
|
||||
"weight_kg": None, "age_text": None, "indication": None,
|
||||
"needs_clarify": False, "clarify_reason": None,
|
||||
}), CATALOG, RESOLVER)
|
||||
frame = understander.understand("metformin")
|
||||
assert frame.turn_type == "drug_attribute"
|
||||
|
||||
|
||||
def test_every_section_key_has_a_hint():
|
||||
# A key with no gloss shown to the model is exactly the bug this fixed —
|
||||
# never let a new SECTION_KEYS entry silently ship without one.
|
||||
assert set(SECTION_KEYS) == set(SECTION_KEY_HINTS)
|
||||
|
||||
|
||||
def test_prompt_disambiguates_than_trong_from_chong_chi_dinh():
|
||||
"""Reproduces the live 2026-08-06 golden-eval finding: a bare section
|
||||
key list gave the model nothing to tell "thận trọng" (precautions) apart
|
||||
from "chống chỉ định" (contraindications) — 9/9 live calls for "X cần
|
||||
thận trọng gì?" picked chong_chi_dinh, silently answering from the wrong
|
||||
section and dropping safety content the precautions section actually
|
||||
has (metformin's lactic-acidosis warning, gentamicin's oto/nephrotoxicity).
|
||||
Fixed with an inline gloss; this pins the gloss's presence in the actual
|
||||
request sent, not just its existence in the hints dict."""
|
||||
captured = {}
|
||||
|
||||
class _CapturingLlm:
|
||||
def generate(self, system, user, schema):
|
||||
captured["user"] = user
|
||||
return json.dumps({
|
||||
"turn_type": "drug_attribute", "drugs": ["metformin"],
|
||||
"unknown_drugs": [], "attribute": "than_trong", "population": None,
|
||||
"weight_kg": None, "age_text": None, "indication": None,
|
||||
"needs_clarify": False, "clarify_reason": None,
|
||||
})
|
||||
|
||||
understander = LlmQueryUnderstander(_CapturingLlm(), CATALOG, RESOLVER)
|
||||
understander.understand("metformin cần thận trọng gì?")
|
||||
|
||||
assert "KHÁC chống chỉ định" in captured["user"]
|
||||
assert "nhiễm toan lactic" in captured["user"]
|
||||
|
||||
|
||||
def test_invalid_attribute_is_dropped_not_passed_through():
|
||||
understander = LlmQueryUnderstander(_FixedLlm({
|
||||
"turn_type": "drug_attribute", "drugs": ["metformin"],
|
||||
"unknown_drugs": [], "attribute": "not_a_real_section", "population": None,
|
||||
"weight_kg": None, "age_text": None, "indication": None,
|
||||
"needs_clarify": False, "clarify_reason": None,
|
||||
}), CATALOG, RESOLVER)
|
||||
frame = understander.understand("metformin")
|
||||
assert frame.attribute is None
|
||||
Reference in New Issue
Block a user