Checkpoint frontend UI/UX overhaul and ingestion embed benchmark work

This commit is contained in:
2026-08-06 17:21:21 +07:00
parent 1e8cbdb586
commit a4b8e1c4db
78 changed files with 6761 additions and 654 deletions
+72 -40
View File
@@ -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,