# 02 — System architecture ## Architectural style **As built:** a two-service application (`web` + `ai-service`) plus an offline batch pipeline, deployed as Docker Compose services on one host. Communication is synchronous HTTP/JSON. There is no message broker, no queue, no async worker, and no service mesh. **As designed on paper:** a seven-service microservices platform (`api-gateway`, `auth-service`, `user-service`, `chat-service`, `ai-service`, `web`, `ingestion`), described in the pre-existing `docs/architecture.md`. Four of those seven do not exist — their directories hold a `README.md` and a four-line `package.json` with no `dependencies` and no source files. The monorepo scaffolding (pnpm workspace entries, `infra/k8s/base//` directories) still reserves their names. Both facts matter: the second explains why `apps/`, `pnpm-workspace.yaml` and the Helm chart look bigger than the running system. ## Component diagram — what actually runs ```mermaid flowchart TB subgraph browser["Browser"] UI["Chat UI
ChatPanel.tsx · 65s abort"] end subgraph ec2["EC2 host — docker compose"] CADDY["caddy:2-alpine
:80 :443 · ACME TLS"] subgraph webc["web (Next.js 14, :3000)"] MW["middleware.ts
in-memory IP rate limit"] BFF["/api/chat · /api/suggest
/api/feedback · /api/pdf"] end subgraph aic["ai-service (FastAPI, :8000)"] HTTP["routers/rag.py"] AGENT["RagAgent"] RET["RetrievalService"] ANS["GroundedAnswerService"] end PG[("postgres:16-alpine")] QD[("qdrant/qdrant")] subgraph obs["observability overlay"] OTELC["otel-collector"] TEMPO["tempo"] PROM["prometheus"] GRAF["grafana :3002 (127.0.0.1)"] end end BEDROCK["AWS Bedrock
embed-v4 · rerank-v3.5 · Converse"] UI -->|HTTPS| CADDY CADDY -->|"/*"| MW --> BFF CADDY -->|"/grafana/*"| GRAF BFF -->|"POST /v1/rag/query"| HTTP HTTP --> AGENT AGENT --> RET AGENT --> ANS RET --> QD ANS -->|generation + entailment| BEDROCK AGENT -->|understanding| BEDROCK RET -->|embed + rerank| BEDROCK HTTP --> PG AGENT --> PG aic -->|OTLP/HTTP| OTELC --> TEMPO PROM -->|scrape /metrics| aic GRAF --> PROM GRAF --> TEMPO ``` `ai-service` publishes **no host port** in `docker-compose.prod.yml`; it is reachable only on the Compose network. Caddy proxies `web` and `grafana` only. ## Service boundaries | Service | Owns | Depends on | Stateless? | |---|---|---|---| | `web` | Rendering, reason-code → Vietnamese message mapping, citation grouping, rate limiting | `ai-service` over HTTP; the source PDF on a bind-mounted path | **No** — rate-limit counters are per-process in memory | | `ai-service` | Understanding, retrieval, generation, grounding, citations, traces | Qdrant, PostgreSQL, Bedrock | **Mostly** — `RagAgent` keeps two in-process dicts (`_last_frame`, `_clarify_streak`); conversation *text* is in PostgreSQL | | `ingestion` | Turning the PDF into `chunks.jsonl` and Qdrant points | Qdrant, Bedrock, local disk | N/A — batch | ### The stateful detail that constrains scaling `RagAgent` (`rag/agent.py`) holds three dicts: ```python self._history: dict[str, list[str]] # unused when a ConversationStore is configured self._last_frame: dict[str, QueryFrame] # ALWAYS in-process self._clarify_streak: dict[str, int] # ALWAYS in-process ``` `PostgresConversationStore` replaces `_history` only. `_last_frame` carries the structured merge that stops the model re-asking an answered clarify question, and `_clarify_streak` drives the clarify circuit breaker. Both are lost on restart and **not shared between replicas**. Running more than one `ai-service` replica therefore degrades multi-turn quality in a way nothing detects. The Helm chart's `aiService.replicaCount` defaults to `1`; nothing enforces it. ## Module boundaries inside `ai-service` Ports-and-adapters, enforced by import discipline rather than by tooling: - `rag/ports.py` declares `Retriever`, `SectionRetriever`, `ParentStore`, `AnswerGenerator`, `Reranker`, plus three exception types (`QueryEmbeddingUnavailable`, `AnswerGenerationUnavailable`, `RerankUnavailable`) that adapters raise and the domain catches. - `adapters/` is the only place `qdrant_client`, `boto3`, `psycopg` and `prometheus_client` are imported — and always **lazily**, inside a method, so the domain imports cleanly on a machine with none of them installed. - `bootstrap.py` is the composition root. Nothing else constructs an adapter. One boundary is looser than the protocol suggests: `RetrievalService` reaches optional retriever capabilities with `getattr(self._retriever, "find_by_section", None)` rather than through a declared protocol. `find_by_indication`, `search_indication`, `search_lexical` and `find_by_drug` are all discovered this way and none of them appear in `ports.py`. A retriever missing one silently disables a whole route instead of failing a type check. ## Synchronous communication Every hop is a blocking HTTP or SDK call. One answerable turn issues, in sequence: 1. `POST /api/chat` (browser → web) 2. `POST /v1/rag/query` (web → ai-service) 3. Bedrock Converse — understanding 4. Qdrant `scroll`/`query_points` — retrieval (1–N calls) 5. Bedrock Converse — generation (plus one retry on `evidence_sufficient=false`) 6. Bedrock Converse — entailment 7. optional Bedrock Converse ×2 — completeness repair + its re-verification 8. PostgreSQL insert — trace Measured production latencies recorded in `ChatPanel.tsx` (n=8, 2026-08-11): 6.2 / 6.4 / 8.4 / 10.9 / 12.4 / 21.7 / 25.1 / 40.3 seconds. ## Asynchronous communication **Not found.** No broker, no queue, no background worker, no SSE, no WebSocket, no streaming response. `web`'s `/api/chat` awaits the full upstream response before replying. ## Failure boundaries | Boundary | Policy | Implemented in | |---|---|---| | Corpus/model manifest mismatch at startup | **Fail closed, crash the process** | `bootstrap.py::_verify_corpus_manifest` → `rag/manifest.py` | | Query embedder unreachable | **Fail closed** — abstain, never a 500 | `rag/service.py`, `rag/ports.py` | | Generator unreachable / malformed / budget exhausted | **Fail closed** — abstain with a specific reason code | `rag/answer.py::_generate` | | Understanding call fails | **Fail closed** — abstain, tagged `system_error` | `rag/understanding.py::understand` | | Grounding or entailment rejects | **Fail closed** — abstain | `rag/answer.py` | | Reranker unreachable | **Fail open** — keep original order | `rag/service.py::_rerank` | | Sufficiency check unreachable | **Fail open** — proceed to generate | `rag/answer.py::_check_sufficiency` | | PostgreSQL trace write fails | **Fail open** — answer returned, `TRACE_WRITE_FAILED` counter | `routers/rag.py` | | Conversation store read/write fails | **Fail open** — this turn has no memory | `rag/agent.py::_get_history` / `_remember` | | Metrics package missing | **Degrade** — `NullMetrics` | `bootstrap.py::_build_metrics` | | OpenTelemetry packages missing / `OTEL_ENABLED=false` | **Degrade** — no-op tracer | `rag/telemetry.py` | The asymmetry is deliberate and documented in-code: anything that could change *what is stated* fails closed; anything that only affects quality or observability fails open. ## Deployment units | Unit | Image | Built by | |---|---|---| | `ai-service` | `apps/ai-service/Dockerfile` — `python:3.12-slim`, deps pinned inline (not from `pyproject.toml`) | `docker compose up --build` on the EC2 host | | `web` | `apps/web/Dockerfile` — 3-stage node:20-slim, `pnpm --filter @duoc-thu/web build` | same | | `postgres`, `qdrant`, `caddy`, `prometheus`, `tempo`, `grafana`, `otel-collector` | Upstream images | pulled | There is **no container registry**. Images are built on the production host at deploy time. The Helm chart assumes registry images (`duocthu-ai-service:`) that nothing currently produces. ## Scaling implications - `ai-service` is CPU-light and latency-bound on Bedrock. Horizontal scaling is blocked by the in-process `_last_frame`/`_clarify_streak` state above. - `web`'s rate limiter is per-process; a second replica doubles the effective allowance. `middleware.ts` says so explicitly. - Qdrant and PostgreSQL are single containers with named Docker volumes on one EBS-backed host. No replication, no backup job in the repository. - The `evidence_limit`/`max_context_tokens` policy (`rag/service.py`, `EvidencePolicy`) bounds prompt size; nothing bounds concurrent Bedrock calls beyond the per-request budget.