Add production condition retrieval smoke test
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from adapters.prometheus import PrometheusMetrics
|
||||
from adapters.postgres import FeedbackTraceNotFound
|
||||
from config import Settings
|
||||
from main import create_app
|
||||
from rag.agent import AgentReply
|
||||
from rag.answer import Citation, GroundedAnswerService
|
||||
from rag.answer import DISCLAIMER, Citation, GroundedAnswerService
|
||||
from rag.metrics import TRACE_WRITE_FAILED, InMemoryMetrics
|
||||
from rag.models import EvidenceDecision, QueryIntent, RetrievalResult, SubjectScope
|
||||
|
||||
@@ -20,11 +21,51 @@ class FixedRouting:
|
||||
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 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"}
|
||||
|
||||
|
||||
def test_health_and_fail_closed_rag_response_are_traced():
|
||||
traces = MemoryTraceWriter()
|
||||
@@ -46,6 +87,68 @@ def test_health_and_fail_closed_rag_response_are_traced():
|
||||
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_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(),
|
||||
|
||||
Reference in New Issue
Block a user