Add production condition retrieval smoke test
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user