Checkpoint frontend UI/UX overhaul and ingestion embed benchmark work
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated, Any, Protocol
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from rag.answer import GroundedAnswerService
|
||||
from rag.metrics import TRACE_WRITE_FAILED, Metrics, NullMetrics
|
||||
from rag.models import QueryIntent, SubjectScope
|
||||
from rag.policy import resolve_subject_scope
|
||||
|
||||
|
||||
class TraceWriter(Protocol):
|
||||
@@ -57,6 +60,10 @@ def _trace_writer(request: Request) -> TraceWriter:
|
||||
return writer
|
||||
|
||||
|
||||
def _metrics(request: Request) -> Metrics:
|
||||
return getattr(request.app.state, "metrics", None) or NullMetrics()
|
||||
|
||||
|
||||
router = APIRouter(prefix="/v1/rag", tags=["rag"])
|
||||
|
||||
|
||||
@@ -67,10 +74,10 @@ class SuggestResponse(BaseModel):
|
||||
@router.get("/suggest", response_model=SuggestResponse)
|
||||
def suggest_drugs(q: str, request: Request) -> SuggestResponse:
|
||||
"""As-you-type drug-name autocomplete, so a name is picked, not mistyped."""
|
||||
conversational = getattr(request.app.state, "conversational", None)
|
||||
if conversational is None or not q.strip():
|
||||
agent = getattr(request.app.state, "conversational", None)
|
||||
if agent is None or not q.strip():
|
||||
return SuggestResponse(suggestions=[])
|
||||
return SuggestResponse(suggestions=conversational.complete(q.strip()))
|
||||
return SuggestResponse(suggestions=agent.complete(q.strip()))
|
||||
|
||||
|
||||
def _map_citations(items) -> list[CitationResponse]:
|
||||
@@ -95,47 +102,72 @@ def query_rag(
|
||||
request: Request,
|
||||
answers: Annotated[GroundedAnswerService, Depends(_answer_service)],
|
||||
traces: Annotated[TraceWriter, Depends(_trace_writer)],
|
||||
metrics: Annotated[Metrics, Depends(_metrics)],
|
||||
) -> RagQueryResponse:
|
||||
conversational = getattr(request.app.state, "conversational", None)
|
||||
agent = getattr(request.app.state, "conversational", None)
|
||||
|
||||
# Single-turn path (no conversation id, or conversational layer disabled):
|
||||
# unchanged behaviour so existing callers keep working.
|
||||
if payload.conversation_id is None or conversational is None:
|
||||
grounded = answers.answer(payload.query, payload.subject_scope, payload.intent)
|
||||
decision = grounded.result.decision.value
|
||||
reason = grounded.result.reason
|
||||
answer = grounded.answer
|
||||
resolved_drug_id = grounded.result.resolved_drug_id
|
||||
citations = _map_citations(grounded.citations)
|
||||
# `payload.subject_scope`/`payload.intent` are what the CALLER claims —
|
||||
# logged below for audit, but the RagAgent path does not take them as an
|
||||
# input at all. It derives scope from the query text itself (the same
|
||||
# `rag.policy` check used here for the no-agent fallback) as part of its
|
||||
# own LLM understanding call, and does not gate on intent at all (this
|
||||
# product is for doctors/pharmacists; a client label must not be, and
|
||||
# here structurally cannot be, the safety decision — F-02).
|
||||
subject_scope = resolve_subject_scope(payload.query, payload.subject_scope)
|
||||
intent = payload.intent
|
||||
|
||||
if agent is not None:
|
||||
# The live path (F-03): one LLM call understands the turn (drug
|
||||
# identity against the real catalog, turn type, population/weight),
|
||||
# then routes to the safety-verified retrieval + grounded-answer
|
||||
# engine. Replaces the old fuzzy resolver + keyword section router +
|
||||
# manual follow-up inheritance for both single- and multi-turn.
|
||||
reply = agent.handle(payload.query, payload.conversation_id)
|
||||
decision = reply.decision
|
||||
reason = reply.reason
|
||||
answer = reply.clarification if reply.clarification is not None else reply.answer
|
||||
resolved_drug_id = ", ".join(reply.drugs) if reply.drugs else None
|
||||
citations = _map_citations(reply.citations)
|
||||
else:
|
||||
turn = conversational.answer(
|
||||
payload.conversation_id,
|
||||
payload.query,
|
||||
payload.subject_scope,
|
||||
payload.intent,
|
||||
)
|
||||
if turn.clarification is not None:
|
||||
decision, reason = "clarify", turn.clarification.reason
|
||||
answer, resolved_drug_id, citations = turn.clarification.question, None, []
|
||||
elif turn.grounded is not None:
|
||||
decision = turn.grounded.result.decision.value
|
||||
reason = turn.grounded.result.reason
|
||||
answer = turn.answer
|
||||
resolved_drug_id = turn.grounded.result.resolved_drug_id
|
||||
citations = _map_citations(turn.grounded.citations)
|
||||
else: # smalltalk
|
||||
decision, reason = "answerable", turn.reason
|
||||
answer, resolved_drug_id, citations = turn.answer, None, []
|
||||
# No generator configured (ANSWER_PROVIDER=disabled): there is no LLM
|
||||
# to understand a turn with, so this is retrieval-only, single-turn,
|
||||
# unchanged from before F-03.
|
||||
grounded = answers.answer(payload.query, subject_scope, intent)
|
||||
if grounded.clarification is not None:
|
||||
decision, reason = "clarify", "needs_more_info"
|
||||
answer = grounded.clarification
|
||||
resolved_drug_id = grounded.result.resolved_drug_id
|
||||
citations = []
|
||||
else:
|
||||
decision = grounded.result.decision.value
|
||||
reason = grounded.result.reason
|
||||
answer = grounded.answer
|
||||
resolved_drug_id = grounded.result.resolved_drug_id
|
||||
citations = _map_citations(grounded.citations)
|
||||
|
||||
trace_id = traces.save(
|
||||
query=payload.query,
|
||||
subject_scope=payload.subject_scope.value,
|
||||
intent=payload.intent.value,
|
||||
decision=decision,
|
||||
reason=reason,
|
||||
resolved_drug_id=resolved_drug_id,
|
||||
citations=tuple(item.model_dump() for item in citations),
|
||||
)
|
||||
# Trace persistence is fail-open (F-09): an already-computed, safe answer
|
||||
# must reach the caller even if Postgres is unreachable. `save()` opens a
|
||||
# fresh connection per call with no retry, so a DB outage previously
|
||||
# turned a good answer into a 500 for a reason that has nothing to do
|
||||
# with whether the answer was safe. `trace_id` degrades to a local,
|
||||
# unpersisted uuid — still a valid response field, just not one `GET
|
||||
# /v1/rag/trace/{id}` (if it existed) could later look up.
|
||||
try:
|
||||
trace_id = traces.save(
|
||||
query=payload.query,
|
||||
# The resolved (server-derived) values, not the caller's claim —
|
||||
# this is what actually gated the answer, so it's what the trace
|
||||
# must show.
|
||||
subject_scope=subject_scope.value,
|
||||
intent=intent.value,
|
||||
decision=decision,
|
||||
reason=reason,
|
||||
resolved_drug_id=resolved_drug_id,
|
||||
citations=tuple(item.model_dump() for item in citations),
|
||||
)
|
||||
except Exception:
|
||||
metrics.increment(TRACE_WRITE_FAILED)
|
||||
trace_id = str(uuid.uuid4())
|
||||
return RagQueryResponse(
|
||||
trace_id=trace_id,
|
||||
decision=decision,
|
||||
|
||||
Reference in New Issue
Block a user