Files
duocthu/docs-legacy/12-api-architecture.md
T

8.8 KiB
Raw Blame History

12 — API architecture

Two HTTP surfaces: the FastAPI service (apps/ai-service) and the Next.js BFF routes (apps/web/app/api/*). There is no API gateway.

ai-service — FastAPI

App factory: apps/ai-service/main.py::create_app. The module-level app is built at import time by calling build_runtime(get_settings()) — which means a Qdrant/manifest problem crashes the process on import, not on first request. That is deliberate (07), but it also makes the test suite require either a reachable Qdrant or EMBEDDING_PROVIDER=disabled (18).

OpenAPI is served by FastAPI's defaults at /openapi.json, /docs, /redoc. No customisation and no auth on those routes.

Endpoints

Method Path Purpose
GET /health Liveness. Always {"status":"ok"}
GET /ready Readiness. 503 when answer_service is None and EMBEDDING_PROVIDER != "disabled"
GET /metrics Prometheus exposition; optional bearer token
POST /v1/rag/query The one answering endpoint
GET /v1/rag/suggest?q= Drug-name autocomplete
POST /v1/rag/feedback Thumbs up/down on a persisted trace

/ready deliberately does not probe PostgreSQL: trace and history writes are fail-open, so a database outage must not make readiness flap. It also does not re-probe Qdrant — the startup manifest check already did, and a mismatch means the process never came up.

POST /v1/rag/query

Request (RagQueryRequest):

Field Type Validation
query str required, 14000 chars
subject_scope human|non_human|unknown required
intent fact_lookup|recommendation|unknown required
conversation_id str | null optional, ≤128 chars

subject_scope and intent are what the caller claims. They are logged for audit, but on the RagAgent path they are not inputs at all — scope is re-derived from the query text by resolve_subject_scope (a caller can narrow but not widen it), and intent is not gated on at all. The router's own comment explains: this product is for doctors and pharmacists, so a client label must not be — and here structurally cannot be — the safety decision.

Response (RagQueryResponse):

Field Type Notes
trace_id str Persisted UUID, or a local unpersisted UUID if the write failed
correlation_id str Echoed / generated
otel_trace_id str | null 32 hex chars when tracing is on
decision answerable|abstain|clarify|verify_pdf
reason str The granular reason code — see 03
answer str | null
resolved_drug_id str | null Comma-joined for multi-drug turns
citations Citation[] One entry per source_ref, so a quarantined chunk yields two sharing a chunk_id
generated bool true = LLM paraphrase that passed both checks; false = verbatim quote
quick_replies str[] Only for clarify, and only from the sufficiency/understanding paths
blocks AnswerBlock[] {title, kind, claims:[{text, source_ids}]}
answer_mode concise|normal|detailed
answer_plan AnswerPlan | null
candidate_assessments […] Condition→drug patient-specific results
disclaimer str Defaulted to DISCLAIMER; cannot be omitted

Citation fields: chunk_id, printed_page_start, printed_page_end, physical_page, block_id, bbox, source_crop, attachment, evidence_text (the exact retrieved chunk text), drug_id, drug_name, section_key, section_title, source_document.

Status codes: 200 for every decision including abstain; 422 on Pydantic validation failure; 503 when answer_service is not configured. Trace persistence failure does not change the status — it increments duocthu_trace_write_failed_total and substitutes a local UUID.

There is no streaming. The response is a single JSON body after all model calls complete.

GET /v1/rag/suggest

{"suggestions": ["Paracetamol Acetaminophen", …]}. Returns an empty list when no RagAgent is configured or q is blank. Pure prefix/substring matching over the alias index — no model call. Note it takes q as a bare query parameter with no length validation.

POST /v1/rag/feedback

Request: {trace_id: uuid, rating: "helpful"|"not_helpful", comment?: ≤2000, conversation_id?: ≤128}. Response: {feedback_id, status:"saved"}. 404 trace_not_found when the trace row does not exist (the insert is a SELECT … FROM rag_retrieval_trace), 503 feedback_store_unavailable on any other error. Upsert semantics — one verdict per trace.

Middleware

correlate_and_trace wraps every request:

  1. Validates or regenerates X-Correlation-ID against ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$.
  2. Starts a server span, extracting an inbound W3C traceparent.
  3. Sets X-Correlation-ID and X-Trace-ID on the response.
  4. Records duocthu_requests_total and duocthu_request_duration_seconds with method, route, status (a class: 2xx/4xx/5xx).

_route_label maps any unknown path to the literal "other", which keeps metric cardinality bounded — a raw path label would let a caller create unbounded time series.

Error model

There is no unified error envelope. FastAPI's default {"detail": …} is used for HTTPExceptions, and Pydantic's default 422 body for validation. Every domain failure is a 200 with a decision/reason pair instead — the web BFF turns those into user-facing Vietnamese.

web — Next.js route handlers

All nodejs runtime, all under middleware.ts's rate limiter.

Method Path Behaviour
POST /api/chat Validates content (non-empty, ≤4000) and conversationId (≤128); forwards to ${API_GATEWAY_URL}/v1/rag/query with subject_scope:"human", intent:"fact_lookup"; maps the response
GET /api/suggest?q= Proxies /v1/rag/suggest; returns {suggestions:[]} on any error
POST /api/feedback Proxies /v1/rag/feedback
GET /api/pdf Reads the 37MB source PDF from disk and returns it inline; 404 with a Vietnamese message if absent

What /api/chat adds

  • Reason → message mapping. REFUSALS maps ~25 reason codes to Vietnamese. The comment is emphatic that this must stay exhaustive: an unmapped reason falls through to GENERIC_REFUSAL, which reads as "no data in the formulary" and would misdescribe an outage. It is applied only when answer === null — the agent supplies its own Vietnamese text for most abstains, and the static table would otherwise discard a better message.
  • Citation grouping. Raw citations are grouped by chunk_id, so a quarantined chunk's prose ref and attachment ref become one card with isQuarantined, a quarantineNotice naming the printed page, and quarantinePhysicalPage preserved separately.
  • Header propagation. Forwards X-Correlation-ID, traceparent, tracestate upstream; echoes X-Correlation-ID and X-Trace-ID back.
  • Abort propagation. Passes request.signal to the upstream fetch so a browser Stop does not leave an orphaned request open.
  • Upstream failure handling. A non-OK or unreachable upstream becomes a synthetic abstain with reason: "upstream_error" / "upstream_unreachable" and a Vietnamese explanation — HTTP 200 either way.

Auth

Not found. No token is issued, validated or forwarded anywhere. /api/chat takes no credentials.

Sequence — one question end to end

sequenceDiagram
    participant B as Browser
    participant M as middleware.ts
    participant C as /api/chat
    participant A as ai-service
    participant P as PostgreSQL

    B->>M: POST /api/chat
    alt over rate limit
        M-->>B: 429 + Retry-After
    end
    M->>C: next()
    C->>C: validate content / conversationId
    C->>A: POST /v1/rag/query (+X-Correlation-ID, traceparent)
    A->>A: middleware: correlation + span + metrics
    A->>A: resolve_subject_scope(query, claimed)
    A->>A: RagAgent.handle(...)  [3+ Bedrock calls, Qdrant]
    A->>P: INSERT rag_retrieval_trace   (fail-open)
    A-->>C: 200 RagQueryResponse
    C->>C: reason→VN, group citations, attach disclaimer
    C-->>B: 200 SendMessageResponse (+X-Trace-ID)

Contract ownership

packages/shared-types/src/dto/chat.ts is the TypeScript contract (Citation, ChatMessage, AnswerBlock, AnswerPlan, MedicationCandidateAssessment, SendMessageResponse). It is hand-kept in sync with the Pydantic models in routers/rag.py — nothing generates one from the other, and the snake_case → camelCase mapping is written by hand in /api/chat/route.ts. A field added on the Python side is silently dropped until someone edits three files.