Fix migration workflow: upload as artifact instead of scp to practice EC2
This commit is contained in:
@@ -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, 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.
|
||||
Reference in New Issue
Block a user