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

194 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, 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](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.