# 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](07-indexing-and-storage.md)), but it also makes the test suite require either a reachable Qdrant or `EMBEDDING_PROVIDER=disabled` ([18](18-testing.md)). 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](03-data-flow.md#error--fallback-flow) | | `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 `HTTPException`s, 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 ```mermaid 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.