Files
duocthu/apps/ai-service/tests/test_api.py
T

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