8.8 KiB
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, 1–4000 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:
- Validates or regenerates
X-Correlation-IDagainst^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$. - Starts a server span, extracting an inbound W3C
traceparent. - Sets
X-Correlation-IDandX-Trace-IDon the response. - Records
duocthu_requests_totalandduocthu_request_duration_secondswithmethod,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.
REFUSALSmaps ~25 reason codes to Vietnamese. The comment is emphatic that this must stay exhaustive: an unmapped reason falls through toGENERIC_REFUSAL, which reads as "no data in the formulary" and would misdescribe an outage. It is applied only whenanswer === 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 withisQuarantined, aquarantineNoticenaming the printed page, andquarantinePhysicalPagepreserved separately. - Header propagation. Forwards
X-Correlation-ID,traceparent,tracestateupstream; echoesX-Correlation-IDandX-Trace-IDback. - Abort propagation. Passes
request.signalto 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
abstainwithreason: "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.