Add production condition retrieval smoke test

This commit is contained in:
2026-08-11 14:58:28 +07:00
parent 59e6ad2d0d
commit 7ebbe1f309
38 changed files with 3752 additions and 121 deletions
+91 -2
View File
@@ -1,12 +1,12 @@
from __future__ import annotations
import uuid
from typing import Annotated, Any, Protocol
from typing import Annotated, Any, Literal, Protocol
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from rag.answer import GroundedAnswerService
from rag.answer import DISCLAIMER, GroundedAnswerService
from rag.metrics import DECISION, TRACE_WRITE_FAILED, Metrics, NullMetrics
from rag.models import QueryIntent, SubjectScope
from rag.policy import resolve_subject_scope
@@ -16,11 +16,14 @@ from rag.telemetry import (
current_trace_id,
stage,
)
from adapters.postgres import FeedbackTraceNotFound
class TraceWriter(Protocol):
def save(self, **fields: Any) -> str: ...
def save_feedback(self, **fields: Any) -> str: ...
class RagQueryRequest(BaseModel):
query: str = Field(min_length=1, max_length=4000)
@@ -44,6 +47,11 @@ class CitationResponse(BaseModel):
# The exact retrieved chunk text this citation stands for — lets the UI
# show precisely what was retrieved, not a client-side guess at it.
evidence_text: str = ""
drug_id: str | None = None
drug_name: str | None = None
section_key: str | None = None
section_title: str | None = None
source_document: str = "Dược thư Quốc gia Việt Nam 2018"
class AnswerClaimResponse(BaseModel):
@@ -65,6 +73,27 @@ class AnswerPlanResponse(BaseModel):
needs_warning: bool
class MedicationCandidateAssessmentResponse(BaseModel):
drug_id: str
drug_name: str
indication_supported: bool
status: str
indication_source_ids: list[str]
safety_source_ids: list[str]
class RagFeedbackRequest(BaseModel):
trace_id: uuid.UUID
rating: Literal["helpful", "not_helpful"]
comment: str | None = Field(default=None, max_length=2000)
conversation_id: str | None = Field(default=None, max_length=128)
class RagFeedbackResponse(BaseModel):
feedback_id: str
status: Literal["saved"] = "saved"
class RagQueryResponse(BaseModel):
trace_id: str
correlation_id: str
@@ -87,6 +116,15 @@ class RagQueryResponse(BaseModel):
blocks: list[AnswerBlockResponse] = []
answer_mode: str = "concise"
answer_plan: AnswerPlanResponse | None = None
candidate_assessments: list[MedicationCandidateAssessmentResponse] = []
# `docs/architecture.md` specifies the disclaimer at several layers. The
# web banner was the only one in place, so any other consumer received
# medical content with nothing attached. It is a fixed string owned by
# `rag/answer.py`, never produced by the model, and it is present on
# every decision — an abstain or a clarification is still a clinical
# response. Defaulted here as well so a response constructed in a test or
# a future code path cannot accidentally omit it.
disclaimer: str = DISCLAIMER
def _answer_service(request: Request) -> GroundedAnswerService:
@@ -110,6 +148,26 @@ def _metrics(request: Request) -> Metrics:
router = APIRouter(prefix="/v1/rag", tags=["rag"])
@router.post("/feedback", response_model=RagFeedbackResponse)
def save_feedback(
payload: RagFeedbackRequest,
traces: Annotated[TraceWriter, Depends(_trace_writer)],
) -> RagFeedbackResponse:
comment = payload.comment.strip() if payload.comment else None
try:
feedback_id = traces.save_feedback(
trace_id=str(payload.trace_id),
rating=payload.rating,
comment=comment or None,
conversation_id=payload.conversation_id,
)
except FeedbackTraceNotFound as exc:
raise HTTPException(status_code=404, detail="trace_not_found") from exc
except Exception as exc:
raise HTTPException(status_code=503, detail="feedback_store_unavailable") from exc
return RagFeedbackResponse(feedback_id=feedback_id)
class SuggestResponse(BaseModel):
suggestions: list[str]
@@ -135,6 +193,11 @@ def _map_citations(items) -> list[CitationResponse]:
source_crop=item.source_crop,
attachment=item.attachment,
evidence_text=item.evidence_text,
drug_id=item.drug_id,
drug_name=item.drug_name,
section_key=item.section_key,
section_title=item.section_title,
source_document=item.source_document,
)
for item in items
]
@@ -166,6 +229,26 @@ def _map_plan(item) -> AnswerPlanResponse | None:
)
def _map_candidate_assessments(items) -> list[MedicationCandidateAssessmentResponse]:
return [
MedicationCandidateAssessmentResponse(
drug_id=item.drug_id,
drug_name=item.drug_name,
indication_supported=item.indication_supported,
status=item.status.value,
indication_source_ids=[
evidence.matched_doc_id for evidence in item.indication_evidence
],
safety_source_ids=[
evidence.matched_doc_id
for evidence in item.evidence
if evidence not in item.indication_evidence
],
)
for item in items
]
@router.post("/query", response_model=RagQueryResponse)
def query_rag(
payload: RagQueryRequest,
@@ -203,6 +286,9 @@ def query_rag(
blocks = _map_blocks(reply.blocks)
answer_mode = reply.answer_mode
answer_plan = _map_plan(reply.plan)
candidate_assessments = _map_candidate_assessments(
reply.candidate_assessments
)
else:
# No generator configured (ANSWER_PROVIDER=disabled): there is no LLM
# to understand a turn with, so this is retrieval-only, single-turn,
@@ -219,6 +305,7 @@ def query_rag(
blocks = []
answer_mode = "concise"
answer_plan = None
candidate_assessments = []
else:
decision = grounded.result.decision.value
reason = grounded.result.reason
@@ -230,6 +317,7 @@ def query_rag(
blocks = _map_blocks(grounded.blocks)
answer_mode = grounded.answer_mode
answer_plan = _map_plan(grounded.plan)
candidate_assessments = []
# Trace persistence is fail-open (F-09): an already-computed, safe answer
# must reach the caller even if Postgres is unreachable. `save()` opens a
@@ -284,5 +372,6 @@ def query_rag(
blocks=blocks,
answer_mode=answer_mode,
answer_plan=answer_plan,
candidate_assessments=candidate_assessments,
)
return response