Fix migration workflow: upload as artifact instead of scp to practice EC2

This commit is contained in:
2026-08-13 11:14:25 +07:00
parent 7ebbe1f309
commit a4819b8653
51 changed files with 6830 additions and 8 deletions
+193
View File
@@ -0,0 +1,193 @@
# 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.