704 lines
25 KiB
Python
704 lines
25 KiB
Python
from datetime import datetime, timezone
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from adapters.prometheus import PrometheusMetrics
|
|
from adapters.postgres import FeedbackTraceNotFound, RetrievalTrace
|
|
from config import Settings
|
|
from main import _route_label, create_app
|
|
from rag.agent import AgentReply
|
|
from rag.answer import DISCLAIMER, Citation, GroundedAnswerService
|
|
from rag.metrics import TRACE_WRITE_FAILED, InMemoryMetrics
|
|
from rag.models import (
|
|
EvidenceDecision,
|
|
QueryIntent,
|
|
RetrievalDocument,
|
|
RetrievalResult,
|
|
SearchHit,
|
|
SourceRef,
|
|
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 = []
|
|
self.feedback = []
|
|
|
|
def save(self, **fields):
|
|
self.rows.append(fields)
|
|
return "trace-1"
|
|
|
|
def save_feedback(self, **fields):
|
|
self.feedback.append(fields)
|
|
return "feedback-1"
|
|
|
|
def list_by_conversation(self, conversation_id, limit):
|
|
return []
|
|
|
|
|
|
def test_feedback_is_linked_to_the_answer_trace():
|
|
traces = MemoryTraceWriter()
|
|
app = create_app(settings=Settings(), trace_writer=traces)
|
|
response = TestClient(app).post("/v1/rag/feedback", json={
|
|
"trace_id": "8f33dd3e-9000-4384-ae69-33da2629e29b",
|
|
"rating": "not_helpful",
|
|
"comment": " Thiếu lưu ý suy thận. ",
|
|
"conversation_id": "case-1",
|
|
})
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"feedback_id": "feedback-1", "status": "saved"}
|
|
assert traces.feedback == [{
|
|
"trace_id": "8f33dd3e-9000-4384-ae69-33da2629e29b",
|
|
"rating": "not_helpful",
|
|
"comment": "Thiếu lưu ý suy thận.",
|
|
"conversation_id": "case-1",
|
|
}]
|
|
|
|
|
|
def test_feedback_rejects_an_unpersisted_trace():
|
|
class MissingTraceWriter(MemoryTraceWriter):
|
|
def save_feedback(self, **fields):
|
|
raise FeedbackTraceNotFound(fields["trace_id"])
|
|
|
|
app = create_app(settings=Settings(), trace_writer=MissingTraceWriter())
|
|
response = TestClient(app).post("/v1/rag/feedback", json={
|
|
"trace_id": "8f33dd3e-9000-4384-ae69-33da2629e29b",
|
|
"rating": "helpful",
|
|
})
|
|
|
|
assert response.status_code == 404
|
|
assert response.json() == {"detail": "trace_not_found"}
|
|
|
|
|
|
class FakeHistoryTraceWriter(MemoryTraceWriter):
|
|
def __init__(self, by_conversation):
|
|
super().__init__()
|
|
self._by_conversation = by_conversation
|
|
self.calls = []
|
|
|
|
def list_by_conversation(self, conversation_id, limit):
|
|
self.calls.append((conversation_id, limit))
|
|
return self._by_conversation.get(conversation_id, [])
|
|
|
|
|
|
def test_history_lists_past_queries_for_a_conversation_most_recent_first():
|
|
when = datetime(2026, 8, 14, 10, 0, tzinfo=timezone.utc)
|
|
traces = FakeHistoryTraceWriter({
|
|
"case-1": [
|
|
RetrievalTrace(
|
|
trace_id="t2", query="Chống chỉ định metformin?",
|
|
subject_scope="human", intent="fact_lookup",
|
|
decision="answerable", reason="grounded_evidence_available",
|
|
resolved_drug_id="metformin", citations=(),
|
|
conversation_id="case-1", created_at=when,
|
|
),
|
|
RetrievalTrace(
|
|
trace_id="t1", query="Chỉ định metformin?",
|
|
subject_scope="human", intent="fact_lookup",
|
|
decision="answerable", reason="grounded_evidence_available",
|
|
resolved_drug_id="metformin", citations=(),
|
|
conversation_id="case-1", created_at=when,
|
|
),
|
|
],
|
|
})
|
|
app = create_app(settings=Settings(), trace_writer=traces)
|
|
|
|
response = TestClient(app).get("/v1/rag/history", params={"conversation_id": "case-1"})
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert [item["query"] for item in body["items"]] == [
|
|
"Chống chỉ định metformin?", "Chỉ định metformin?",
|
|
]
|
|
assert body["items"][0]["trace_id"] == "t2"
|
|
assert body["items"][0]["decision"] == "answerable"
|
|
assert traces.calls == [("case-1", 50)]
|
|
|
|
|
|
def test_history_with_empty_conversation_id_returns_no_rows_and_does_not_query():
|
|
"""An empty/missing id must not silently fall through to an unscoped
|
|
listing — there is no auth anywhere in this system to make that safe."""
|
|
traces = FakeHistoryTraceWriter({})
|
|
app = create_app(settings=Settings(), trace_writer=traces)
|
|
|
|
response = TestClient(app).get("/v1/rag/history", params={"conversation_id": " "})
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"items": []}
|
|
assert traces.calls == []
|
|
|
|
|
|
def test_history_for_unknown_conversation_is_empty_not_an_error():
|
|
traces = FakeHistoryTraceWriter({})
|
|
app = create_app(settings=Settings(), trace_writer=traces)
|
|
|
|
response = TestClient(app).get(
|
|
"/v1/rag/history", params={"conversation_id": "never-seen"}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"items": []}
|
|
|
|
|
|
def test_history_rejects_an_oversized_conversation_id_before_querying_storage():
|
|
traces = FakeHistoryTraceWriter({})
|
|
app = create_app(settings=Settings(), trace_writer=traces)
|
|
|
|
response = TestClient(app).get(
|
|
"/v1/rag/history", params={"conversation_id": "x" * 129}
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert traces.calls == []
|
|
|
|
|
|
def test_transcript_returns_user_and_assistant_turns_oldest_first():
|
|
when = datetime(2026, 8, 14, 10, 0, tzinfo=timezone.utc)
|
|
traces = FakeHistoryTraceWriter({
|
|
# list_by_conversation contract is newest-first, same as /history.
|
|
"case-1": [
|
|
RetrievalTrace(
|
|
trace_id="t2", query="Chống chỉ định metformin?",
|
|
subject_scope="human", intent="fact_lookup",
|
|
decision="answerable", reason="grounded_evidence_available",
|
|
resolved_drug_id="metformin", citations=(),
|
|
conversation_id="case-1", created_at=when,
|
|
response_payload={
|
|
"answer": "Suy thận nặng.",
|
|
"resolved_drug_id": "metformin",
|
|
"citations": [{"chunk_id": "metformin::cci::0"}],
|
|
"generated": True,
|
|
"quick_replies": [],
|
|
"blocks": [],
|
|
"answer_mode": "concise",
|
|
"answer_plan": None,
|
|
"candidate_assessments": [],
|
|
"disclaimer": "disclaimer text",
|
|
},
|
|
),
|
|
RetrievalTrace(
|
|
trace_id="t1", query="Chỉ định metformin?",
|
|
subject_scope="human", intent="fact_lookup",
|
|
decision="answerable", reason="grounded_evidence_available",
|
|
resolved_drug_id="metformin", citations=(),
|
|
conversation_id="case-1", created_at=when,
|
|
response_payload=None,
|
|
),
|
|
],
|
|
})
|
|
app = create_app(settings=Settings(), trace_writer=traces)
|
|
|
|
response = TestClient(app).get("/v1/rag/transcript", params={"conversation_id": "case-1"})
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
# t1 (oldest) has no persisted answer -> user turn only. t2 has one -> both.
|
|
roles = [(m["trace_id"], m["role"]) for m in body["messages"]]
|
|
assert roles == [("t1", "user"), ("t2", "user"), ("t2", "assistant")]
|
|
assistant = body["messages"][2]
|
|
assert assistant["content"] == "Suy thận nặng."
|
|
assert assistant["citations"] == [{"chunk_id": "metformin::cci::0"}]
|
|
assert assistant["generated"] is True
|
|
|
|
|
|
def test_transcript_with_empty_conversation_id_returns_no_messages():
|
|
traces = FakeHistoryTraceWriter({})
|
|
app = create_app(settings=Settings(), trace_writer=traces)
|
|
|
|
response = TestClient(app).get("/v1/rag/transcript", params={"conversation_id": " "})
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"messages": []}
|
|
assert traces.calls == []
|
|
|
|
|
|
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_every_query_response_carries_the_disclaimer_including_an_abstain():
|
|
"""The API-visible half of the guardrail.
|
|
|
|
`docs/architecture.md` specifies the disclaimer at several layers and the
|
|
web banner was the only one present, so any consumer other than that one
|
|
UI received medical content with nothing attached. An abstain is the case
|
|
worth pinning: it is easy to treat as "not really an answer", and it is
|
|
still the system responding to a clinical question.
|
|
"""
|
|
app = create_app(
|
|
settings=Settings(),
|
|
answer_service=GroundedAnswerService(FixedRouting()),
|
|
trace_writer=MemoryTraceWriter(),
|
|
)
|
|
response = TestClient(app).post("/v1/rag/query", json={
|
|
"query": "Liều thuốc?",
|
|
"subject_scope": "human",
|
|
"intent": "fact_lookup",
|
|
})
|
|
|
|
body = response.json()
|
|
assert body["decision"] == "abstain"
|
|
assert body["disclaimer"] == DISCLAIMER
|
|
assert "không thay thế chỉ định" in body["disclaimer"]
|
|
|
|
|
|
def _metrics_app(**settings_kwargs):
|
|
return create_app(
|
|
settings=Settings(**settings_kwargs),
|
|
answer_service=GroundedAnswerService(FixedRouting()),
|
|
trace_writer=MemoryTraceWriter(),
|
|
)
|
|
|
|
|
|
def test_all_public_rag_endpoints_have_bounded_request_metric_labels():
|
|
paths = (
|
|
"/v1/rag/query",
|
|
"/v1/rag/suggest",
|
|
"/v1/rag/feedback",
|
|
"/v1/rag/history",
|
|
"/v1/rag/sections",
|
|
"/v1/rag/section-text",
|
|
)
|
|
|
|
assert {_route_label(path) for path in paths} == set(paths)
|
|
|
|
|
|
def test_metrics_stays_open_when_no_token_is_configured():
|
|
"""The default must not break the existing Compose scrape or local runs —
|
|
the endpoint is not internet-reachable in that topology."""
|
|
response = TestClient(_metrics_app()).get("/metrics")
|
|
assert response.status_code in (200, 404) # 404 only when no exporter
|
|
|
|
|
|
def test_metrics_requires_the_token_once_one_is_configured():
|
|
client = TestClient(_metrics_app(metrics_token="s3cret"))
|
|
|
|
assert client.get("/metrics").status_code == 401
|
|
assert client.get(
|
|
"/metrics", headers={"Authorization": "Bearer wrong"}
|
|
).status_code == 401
|
|
# A correct token gets past the guard; whether an exporter is attached is
|
|
# a separate concern, so 404 is an acceptable non-401 here.
|
|
assert client.get(
|
|
"/metrics", headers={"Authorization": "Bearer s3cret"}
|
|
).status_code in (200, 404)
|
|
|
|
|
|
def test_metrics_token_is_not_accepted_from_a_query_string():
|
|
"""Secrets in URLs end up in access logs and referrers, so only the
|
|
Authorization header is honoured."""
|
|
client = TestClient(_metrics_app(metrics_token="s3cret"))
|
|
assert client.get("/metrics?token=s3cret").status_code == 401
|
|
|
|
|
|
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
|
|
|
|
|
|
# --- 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, str]] = []
|
|
|
|
def handle(
|
|
self,
|
|
turn: str,
|
|
conversation_id: str | None = None,
|
|
response_mode: str = "ai",
|
|
) -> AgentReply:
|
|
self.calls.append((turn, conversation_id, response_mode))
|
|
return self._reply
|
|
|
|
def complete(self, prefix: str, k: int = 8) -> list[str]:
|
|
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", "ai")]
|
|
|
|
|
|
def test_query_persists_the_full_answer_for_later_transcript_replay():
|
|
"""The `/transcript` endpoint can only redraw a conversation if this
|
|
survives to storage — a plain trace row (decision/reason only) is not
|
|
enough to show what the AI actually said."""
|
|
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,
|
|
))
|
|
traces = MemoryTraceWriter()
|
|
app = create_app(
|
|
settings=Settings(),
|
|
answer_service=GroundedAnswerService(FixedRouting()),
|
|
conversational=agent,
|
|
trace_writer=traces,
|
|
)
|
|
TestClient(app).post("/v1/rag/query", json={
|
|
"query": "Liều metformin?", "subject_scope": "human",
|
|
"intent": "fact_lookup", "conversation_id": "c1",
|
|
})
|
|
|
|
payload = traces.rows[-1]["response_payload"]
|
|
assert payload["answer"] == "Liều 500 mg [1]."
|
|
assert payload["resolved_drug_id"] == "metformin"
|
|
assert payload["generated"] is True
|
|
assert len(payload["citations"]) == 1
|
|
assert payload["citations"][0]["chunk_id"] == "metformin::lieu::0"
|
|
|
|
|
|
def test_query_forwards_monograph_response_mode_to_agent():
|
|
agent = FakeAgent(AgentReply(
|
|
decision="clarify",
|
|
reason="select_drug_sections",
|
|
clarification="Chọn mục cần xem.",
|
|
drugs=("metformin",),
|
|
turn_type="drug_overview",
|
|
))
|
|
app = create_app(
|
|
settings=Settings(),
|
|
answer_service=GroundedAnswerService(FixedRouting()),
|
|
conversational=agent,
|
|
trace_writer=MemoryTraceWriter(),
|
|
)
|
|
|
|
response = TestClient(app).post("/v1/rag/query", json={
|
|
"query": "Metformin",
|
|
"subject_scope": "human",
|
|
"intent": "fact_lookup",
|
|
"response_mode": "monograph",
|
|
})
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["reason"] == "select_drug_sections"
|
|
assert agent.calls == [("Metformin", None, "monograph")]
|
|
|
|
|
|
def test_query_agent_clarification_is_surfaced_as_the_answer():
|
|
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": []}
|
|
|
|
|
|
class FakeSectionRetriever:
|
|
def __init__(self, sections=None, section_texts=None):
|
|
self._sections = sections or {}
|
|
self._section_texts = section_texts or {}
|
|
self.calls = []
|
|
|
|
def list_sections(self, drug_id):
|
|
self.calls.append(drug_id)
|
|
return self._sections.get(drug_id, [])
|
|
|
|
def find_by_section(self, drug_id, section_key):
|
|
self.calls.append((drug_id, section_key))
|
|
return self._section_texts.get((drug_id, section_key), [])
|
|
|
|
|
|
def test_list_sections_returns_the_real_per_drug_checklist():
|
|
retriever = FakeSectionRetriever({
|
|
"metformin": [
|
|
("chi_dinh", "Chỉ định"),
|
|
("chong_chi_dinh", "Chống chỉ định"),
|
|
],
|
|
})
|
|
app = create_app(
|
|
settings=Settings(), trace_writer=MemoryTraceWriter(), section_retriever=retriever
|
|
)
|
|
response = TestClient(app).get("/v1/rag/sections", params={"drug_id": "metformin"})
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"sections": [
|
|
{"section_key": "chi_dinh", "section_title": "Chỉ định"},
|
|
{"section_key": "chong_chi_dinh", "section_title": "Chống chỉ định"},
|
|
]
|
|
}
|
|
assert retriever.calls == ["metformin"]
|
|
|
|
|
|
def test_list_sections_with_no_retriever_configured_is_503_not_an_empty_list():
|
|
"""Distinct from `/suggest`'s empty-list fallback on purpose: an empty
|
|
list here would read as "this drug has zero sections", which is false —
|
|
it means the backend isn't configured at all."""
|
|
app = create_app(settings=Settings(), trace_writer=MemoryTraceWriter())
|
|
response = TestClient(app).get("/v1/rag/sections", params={"drug_id": "metformin"})
|
|
|
|
assert response.status_code == 503
|
|
|
|
|
|
def _hit(text, *, part_index=0, printed_page=200, physical_page=195, quarantined=False):
|
|
return SearchHit(
|
|
document=RetrievalDocument(
|
|
doc_id=f"metformin__chong_chi_dinh__{part_index}",
|
|
drug_id="metformin",
|
|
kind="block_descriptor" if quarantined else "prose",
|
|
text=text,
|
|
section_key="chong_chi_dinh",
|
|
section_title="Chống chỉ định",
|
|
source_refs=(SourceRef(
|
|
physical_page=physical_page, precision="exact", printed_page=printed_page,
|
|
),),
|
|
part_index=part_index,
|
|
requires_visual_check=quarantined,
|
|
),
|
|
score=1.0,
|
|
)
|
|
|
|
|
|
def test_section_text_joins_parts_in_order_with_page_provenance():
|
|
retriever = FakeSectionRetriever(section_texts={
|
|
("metformin", "chong_chi_dinh"): [
|
|
_hit("Phần một.", part_index=0, printed_page=200),
|
|
_hit("Phần hai.", part_index=1, printed_page=201),
|
|
],
|
|
})
|
|
app = create_app(
|
|
settings=Settings(), trace_writer=MemoryTraceWriter(), section_retriever=retriever
|
|
)
|
|
response = TestClient(app).get(
|
|
"/v1/rag/section-text", params={"drug_id": "metformin", "section_key": "chong_chi_dinh"}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["section_title"] == "Chống chỉ định"
|
|
assert [p["text"] for p in body["parts"]] == ["Phần một.", "Phần hai."]
|
|
assert body["parts"][0]["printed_page_start"] == 200
|
|
assert body["parts"][0]["is_quarantined"] is False
|
|
assert retriever.calls == [("metformin", "chong_chi_dinh")]
|
|
|
|
|
|
def test_section_text_flags_quarantined_parts_instead_of_treating_them_as_verbatim():
|
|
retriever = FakeSectionRetriever(section_texts={
|
|
("metformin", "chong_chi_dinh"): [
|
|
_hit(
|
|
"METFORMIN — Chống chỉ định — bảng, trang 200. Nội dung chỉ "
|
|
"tra cứu được trên ảnh trang gốc.",
|
|
quarantined=True,
|
|
),
|
|
],
|
|
})
|
|
app = create_app(
|
|
settings=Settings(), trace_writer=MemoryTraceWriter(), section_retriever=retriever
|
|
)
|
|
response = TestClient(app).get(
|
|
"/v1/rag/section-text", params={"drug_id": "metformin", "section_key": "chong_chi_dinh"}
|
|
)
|
|
|
|
assert response.json()["parts"][0]["is_quarantined"] is True
|
|
|
|
|
|
def test_section_text_with_unknown_drug_returns_empty_parts_not_an_error():
|
|
retriever = FakeSectionRetriever()
|
|
app = create_app(
|
|
settings=Settings(), trace_writer=MemoryTraceWriter(), section_retriever=retriever
|
|
)
|
|
response = TestClient(app).get(
|
|
"/v1/rag/section-text", params={"drug_id": "khong_ton_tai", "section_key": "chi_dinh"}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["parts"] == []
|
|
|
|
|
|
def test_section_text_with_no_retriever_configured_is_503():
|
|
app = create_app(settings=Settings(), trace_writer=MemoryTraceWriter())
|
|
response = TestClient(app).get(
|
|
"/v1/rag/section-text", params={"drug_id": "metformin", "section_key": "chi_dinh"}
|
|
)
|
|
|
|
assert response.status_code == 503
|
|
|
|
|
|
# --- 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
|
|
|
|
|
|
class _AnyRouting:
|
|
def retrieve(self, query, subject_scope, intent):
|
|
return RetrievalResult(EvidenceDecision.ABSTAIN, "drug_not_resolved")
|
|
|
|
|
|
def test_correlation_id_round_trips_through_headers_body_and_trace_row():
|
|
traces = MemoryTraceWriter()
|
|
app = create_app(
|
|
settings=Settings(embedding_provider="disabled"),
|
|
answer_service=GroundedAnswerService(_AnyRouting()),
|
|
trace_writer=traces,
|
|
)
|
|
|
|
response = TestClient(app).post(
|
|
"/v1/rag/query",
|
|
headers={"X-Correlation-ID": "req-test-1"},
|
|
json={
|
|
"query": "Paracetamol dose?",
|
|
"subject_scope": "human",
|
|
"intent": "fact_lookup",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["x-correlation-id"] == "req-test-1"
|
|
assert response.json()["correlation_id"] == "req-test-1"
|
|
assert traces.rows[0]["correlation_id"] == "req-test-1"
|
|
assert "otel_trace_id" in traces.rows[0]
|
|
|
|
|
|
def test_metrics_endpoint_exposes_request_decision_and_stage_histograms():
|
|
metrics = PrometheusMetrics()
|
|
app = create_app(
|
|
settings=Settings(embedding_provider="disabled"),
|
|
answer_service=GroundedAnswerService(_AnyRouting()),
|
|
trace_writer=MemoryTraceWriter(),
|
|
metrics=metrics,
|
|
)
|
|
client = TestClient(app)
|
|
|
|
response = client.post(
|
|
"/v1/rag/query",
|
|
json={
|
|
"query": "Paracetamol dose?",
|
|
"subject_scope": "human",
|
|
"intent": "fact_lookup",
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
scrape = client.get("/metrics")
|
|
assert scrape.status_code == 200
|
|
body = scrape.text
|
|
assert 'duocthu_requests_total{method="POST",route="/v1/rag/query",status="2xx"}' in body
|
|
assert 'duocthu_decision_total{decision="abstain",reason="drug_not_resolved"}' in body
|
|
assert 'duocthu_stage_duration_seconds_count{outcome="ok",stage="receive"}' in body
|
|
assert 'duocthu_stage_duration_seconds_count{outcome="ok",stage="persistence"}' in body
|
|
assert 'duocthu_stage_duration_seconds_count{outcome="ok",stage="response"}' in body
|