from fastapi.testclient import TestClient from config import Settings from main import create_app 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 class FixedRouting: def retrieve(self, query, subject_scope, intent): assert query == "Liều thuốc?" assert subject_scope == SubjectScope.HUMAN assert intent == QueryIntent.FACT_LOOKUP return RetrievalResult(EvidenceDecision.ABSTAIN, "drug_not_resolved") class MemoryTraceWriter: def __init__(self): self.rows = [] def save(self, **fields): self.rows.append(fields) return "trace-1" def test_health_and_fail_closed_rag_response_are_traced(): traces = MemoryTraceWriter() app = create_app( settings=Settings(), answer_service=GroundedAnswerService(FixedRouting()), trace_writer=traces, ) client = TestClient(app) assert client.get("/health").json() == {"status": "ok"} response = client.post("/v1/rag/query", json={ "query": "Liều thuốc?", "subject_scope": "human", "intent": "fact_lookup", }) assert response.status_code == 200 assert response.json()["decision"] == "abstain" assert response.json()["trace_id"] == "trace-1" assert traces.rows[0]["reason"] == "drug_not_resolved" def test_query_requires_structured_scope_and_intent(): app = create_app( settings=Settings(), answer_service=GroundedAnswerService(FixedRouting()), trace_writer=MemoryTraceWriter(), ) response = TestClient(app).post("/v1/rag/query", json={"query": "Liều?"}) assert response.status_code == 422 # --- 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