Add read-only production runtime audit
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
# 13 — Frontend architecture
|
||||
|
||||
`apps/web` — Next.js 14 App Router, React 18, TypeScript, Tailwind,
|
||||
framer-motion, lucide-react. Vietnamese-only UI.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
apps/web/
|
||||
├── middleware.ts rate limiting on /api/*
|
||||
├── app/
|
||||
│ ├── layout.tsx root layout + ThemeProvider
|
||||
│ ├── globals.css Tailwind + design tokens
|
||||
│ ├── page.tsx chat page
|
||||
│ ├── tra-cuu/page.tsx "lookup" page
|
||||
│ ├── api/{chat,suggest,feedback,pdf}/route.ts BFF (see doc 12)
|
||||
│ └── _components/
|
||||
│ ├── ChatPanel.tsx (445 lines) chat state + fetch + timeouts
|
||||
│ ├── Composer.tsx (231) input + autocomplete
|
||||
│ ├── Sidebar.tsx (237) sessions / navigation
|
||||
│ ├── EvidencePanel.tsx (102) citation cards
|
||||
│ ├── AnswerFeedback.tsx (111) thumbs → /api/feedback
|
||||
│ └── NavTabs.tsx (39)
|
||||
```
|
||||
|
||||
Shared packages: `@duoc-thu/ui` (`ChatBubble`, `CitationCard`,
|
||||
`CitationBeamOverlay`, `DisclaimerBanner`, `ThemeContext`, `ThemeSelector`, and
|
||||
shadcn-style `alert`/`badge`/`button`/`card`/`input` primitives) and
|
||||
`@duoc-thu/shared-types`.
|
||||
|
||||
`@duoc-thu/api-client` is declared as a dependency and exports
|
||||
`sendChatMessage` / `getDrugSuggestions` / `mockFixtures`, but the live chat
|
||||
path in `ChatPanel.tsx` calls `fetch("/api/chat")` directly. It is effectively
|
||||
unused by the running app.
|
||||
|
||||
## Rendering model
|
||||
|
||||
Server Components by default; `ChatPanel` and the other interactive components
|
||||
are `"use client"`. There is no SSR data fetching for chat — the page renders
|
||||
empty and the first turn is a client `fetch`. No state library: `useState` +
|
||||
props.
|
||||
|
||||
## The request lifecycle in `ChatPanel`
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S["handleSendMessage(text)"]
|
||||
G{empty or already loading?}
|
||||
U["append user message; isLoading = true"]
|
||||
AC["new AbortController()<br/>setTimeout(abort, 65_000)"]
|
||||
TICK["setInterval 1s → elapsedMs<br/>(slow notice at 15s)"]
|
||||
F["fetch /api/chat {content, conversationId: sessionId}"]
|
||||
OK["append assistant message<br/>onCitationsLoaded(citations)"]
|
||||
AB{AbortError?}
|
||||
STOP["user pressed Stop →<br/>'Đã dừng chờ trên giao diện…'"]
|
||||
TO["timeout → 'Hệ thống xử lý quá 65 giây…'"]
|
||||
ERR["other → 'Không thể kết nối đến máy chủ AI Service…'"]
|
||||
FIN["clear timers; isLoading = false"]
|
||||
|
||||
S --> G -->|yes| FIN
|
||||
G -->|no| U --> AC --> TICK --> F
|
||||
F -->|ok| OK --> FIN
|
||||
F -->|throw| AB
|
||||
AB -->|yes + stopRequested| STOP --> FIN
|
||||
AB -->|yes| TO --> FIN
|
||||
AB -->|no| ERR --> FIN
|
||||
```
|
||||
|
||||
### The two timing constants
|
||||
|
||||
```ts
|
||||
const REQUEST_TIMEOUT_MS = 65_000;
|
||||
const SLOW_REQUEST_NOTICE_MS = 15_000;
|
||||
```
|
||||
|
||||
The 65 s value is derived, and the derivation is in the source comment: the
|
||||
backend budget is 40 s and is only checked *between* model calls, so the real
|
||||
worst case is ~40 s plus one in-flight call bounded by `read_timeout=20` ≈ 60 s.
|
||||
Measured production latencies (n=8, 2026-08-11, one user, sequential):
|
||||
`6.2 / 6.4 / 8.4 / 10.9 / 12.4 / 21.7 / 25.1 / 40.3` s. The earlier 25 s limit
|
||||
cut off two of those eight — including a 25.1 s case that had returned a correct
|
||||
grounded answer with two citations.
|
||||
|
||||
`SLOW_REQUEST_NOTICE_MS` only changes the wording of the wait; the comment is
|
||||
explicit that it is a stopgap for the real fix (streaming verified claims as they
|
||||
land) and does not make anything faster.
|
||||
|
||||
### React 18 Strict Mode guard
|
||||
|
||||
`initialQuerySentRef` exists because Strict Mode replays effects in development,
|
||||
which sent every starter-question click as **two identical live requests** —
|
||||
found in the trace as duplicate turns.
|
||||
|
||||
## Rendering an answer
|
||||
|
||||
The UI does not parse prose. It renders what the backend verified:
|
||||
|
||||
| Backend field | UI use |
|
||||
|---|---|
|
||||
| `blocks[]` | Sections with a title and a `kind` (`fact_list` / `warning` / `dosage`) that drives styling |
|
||||
| `claims[].sourceIds` | Resolved against `message.citations` to link a claim to its card |
|
||||
| `citations[]` | `EvidencePanel` cards: drug, section, printed page range, exact `snippet` |
|
||||
| `isQuarantined` + `quarantineNotice` | A distinct card telling the reader to check the source page and not infer numbers |
|
||||
| `generated` | Distinguishes an LLM paraphrase from a verbatim quote |
|
||||
| `quickReplies` | Tappable chips on a `clarify` turn |
|
||||
| `disclaimer` | `DisclaimerBanner` |
|
||||
| `traceId` | Sent back with feedback |
|
||||
|
||||
`CitationBeamOverlay` draws the visual link between a claim and its citation
|
||||
card.
|
||||
|
||||
Starter questions in `ChatPanel` are hard-coded and each targets a different
|
||||
retrieval route: `Chỉ Định` (Levetiracetam), `Chống Chỉ Định` (Metformin),
|
||||
`ADR Theo Tần Suất` (Zolpidem), `Thời Kỳ Mang Thai` (Fluoxetin).
|
||||
|
||||
## Sessions
|
||||
|
||||
`sessionId` is a client-side value passed as `conversationId`. There is no
|
||||
session API, no login, and no server-side session record beyond the
|
||||
`rag_conversation_turn` rows keyed by whatever string the client sends. Anyone
|
||||
who guesses a `conversation_id` can read its history into their own turn's LLM
|
||||
context — see [16-security.md](16-security.md).
|
||||
|
||||
## Rate limiting lives here
|
||||
|
||||
`middleware.ts` implements the only rate limiting in the system. See
|
||||
[16-security.md](16-security.md) for the rules and their stated limitations.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Use |
|
||||
|---|---|---|
|
||||
| `API_GATEWAY_URL` | — | Preferred upstream base URL |
|
||||
| `AI_SERVICE_URL` | `http://localhost:8000` | Fallback; set to `http://ai-service:8000` in `docker-compose.prod.yml` |
|
||||
|
||||
Both accept either a base URL or a full `/v1/rag/...` URL — the handlers check
|
||||
`.includes("/v1/rag")` and rewrite accordingly.
|
||||
|
||||
## Build
|
||||
|
||||
Three-stage Dockerfile: `pnpm install --frozen-lockfile` over the workspace
|
||||
manifests, then `pnpm --filter @duoc-thu/web build`, then `next start -p 3000 -H
|
||||
0.0.0.0`. The runtime stage copies the **whole** `/repo` (not a standalone
|
||||
output), so the image carries source and `node_modules`.
|
||||
|
||||
`next.config.js`, `tailwind.config.ts`, `postcss.config.js`, `components.json`
|
||||
(shadcn) and `.eslintrc.json` are all present.
|
||||
|
||||
## Frontend testing
|
||||
|
||||
**Not found.** `apps/web/package.json` has no `test` script and no test
|
||||
dependency; there are no `*.test.tsx` / `*.spec.ts` files, no Jest/Vitest
|
||||
config, and no Playwright/Cypress setup. `turbo run test` therefore does nothing
|
||||
for `web`. Everything above — the timeout derivation, the abort handling, the
|
||||
Strict Mode guard, the reason-code mapping, the citation grouping — is
|
||||
uncovered by automated tests.
|
||||
|
||||
## Known frontend gaps
|
||||
|
||||
- No streaming, so the UI shows a spinner for the full 6–40 s.
|
||||
- No virtualised message list.
|
||||
- No error boundary around `ChatPanel`.
|
||||
- `/api/pdf` reads a 37 MB file into memory per request with no range support
|
||||
and no caching headers. It is **not rate limited**: `middleware.ts` matches
|
||||
`/api/:path*` but `matchRules` only has entries for `/api/chat` and
|
||||
`/api/suggest`, so `/api/pdf` and `/api/feedback` fall through to
|
||||
`NextResponse.next()`.
|
||||
- `mobile/` is a placeholder README.
|
||||
Reference in New Issue
Block a user