Overview

All components at a glance

Client
Next.js App Router
Browser — React Frontend
Case submission form · SSE stream renderer · History tab · Dashboard
Next.js 16 Clerk JS EventSource API Tailwind CSS
Auth — Clerk
Identity & Sessions
JWT issued on login · RS256 signed · sent as Bearer token on every request
RS256 JWKS endpoint Sub claim = user_id
POST /api/v1/analyze — multipart/form-data · Bearer JWT
text/event-stream SSE frames back per step
API · Infra
FastAPI — routes_analyze.py
HTTP API Layer
Validates JWT · extracts PDF/TXT/MD · merges text · returns StreamingResponse
multipart/form-data Cache-Control: no-cache X-Accel-Buffering: no HTTP 422 on empty body
Infrastructure
AWS App Runner
Containerised FastAPI · auto-scales · HTTPS termination · nginx proxy in front
Docker Terraform Secrets Manager
Structured Logging
structlog
JSON in prod · case_id + user_id in every line
pipeline_start rag_retrieve_complete pipeline_complete
async generator passed to StreamingResponse — every yield = one SSE frame
Orchestrator
orchestrator.py — run_pipeline()
Async Generator
For each step: persist PROCESSING → execute (wait_for + retry) → persist COMPLETED → yield SSE frame
asyncio.wait_for 120s Tenacity 3 retries persist-before-yield QA non-fatal
⚡ Steps 0 & 1 run in parallel via asyncio.create_task
Step 0 — Extraction Agent
Fact Extraction
Reads only client narrative — no statute context yet. Outputs: entities, timeline, material facts, case type.
gpt-4oJSON schema
Step 1 — RAG Retrieval
7-Stage Retrieval Pipeline
Query expansion → parallel Pinecone searches → threshold → dedup → LLM judge → contextual compression
gpt-4o-miniPineconetext-embedding-3-small
parallel ↑
0
Extraction
entities · facts · timeline
parallel ↑
1
RAG
statute grounding
2
Strategy
issues · arguments
3
Draft Brief
High Court Markdown
non-fatal
4
QA Audit
hallucination check
Each step: PROCESSING persisted → execute → COMPLETED + JSON result persisted → SSE yield. Browser receives sections incrementally.
↓↑SQL reads/writes (Case + AgentStep)
↓↑Vector queries (cosine search)
LLM completions + embeddings
Data · Services
Aurora / PostgreSQL
Transactional State
Case records · AgentStep JSON per step · user isolation via user_id
SQLAlchemy asyncasyncpgSQLite in dev
Pinecone Serverless
Semantic Corpus
~1,800 statute chunks · 1,536-dim · cosine · source metadata per vector
text-embedding-3-smallmetadata filtersserverless tier
OpenAI API
LLM + Embeddings
gpt-4o for agents · gpt-4o-mini for RAG stages · text-embedding-3-small for vectors
gpt-4ogpt-4o-miniOpenRouter fallback
Request Lifecycle

One case submission — step by step with design rationale

routes_analyze.py → orchestrator.py
User Action
Submit case
Title + case narrative text + optional PDF/TXT/MD upload · Clerk session active
POST /api/v1/analyze
Multipart intake & validation
Extract PDF/TXT/MD text · merge with typed narrative · validate JWT · reject empty body (HTTP 422)
Auth — Clerk JWT
RS256 token validation
JWKS fetched once, cached 5 min · sub claim extracted as user_id · X-User-Id header accepted in dev only
StreamingResponse created
SSE response headers sent
media_type="text/event-stream" · Cache-Control: no-cache · X-Accel-Buffering: no
Orchestrator starts — persist Case row
Case created in Postgres
status=PROCESSING · user_id · title · raw_input stored
⚡ asyncio.create_task — Steps 0 & 1 run concurrently
Step 0
Extraction
gpt-4o · structured facts, entities, timeline from narrative only
Step 1 — RAG
Retrieval pipeline
7-stage pipeline · runs in background while extraction streams
Per-step pattern (repeated for each of the 5 steps):
N
Any pipeline step
asyncio.wait_for(120s) + tenacity retry
① Persist
AgentStep → PROCESSING written to DB
② Execute
LLM call within timeout + retry
③ Persist + Stream
COMPLETED + JSON → DB, then SSE yield
Step 2 — waits for both tasks
Strategy
extraction result + RAG chunks both available · gpt-4o · legal issues, arguments, counterarguments
Step 3
Draft Brief
gpt-4o · High Court–style Markdown from extraction + strategy
Step 4 — non-fatal
QA Audit
gpt-4o · hallucination and logic check · failure logged as FAILED, brief still delivered
Complete
Case status → COMPLETED
All 5 AgentStep JSON rows in Postgres · History tab can replay full output
Context User can type a case narrative, paste from notes, or upload a PDF/TXT/MD file. Both inputs are accepted simultaneously — a typed summary and a supporting document.
Why multipart? Not JSON — multipart/form-data handles binary file uploads natively, the same way an HTML form works. No base64 encoding, no artificial size limit. A JSON body would require base64-encoding the PDF, inflating its size by ~33% and adding parsing overhead.
HTTP 422 If both case_text and case_file are empty after merging → reject immediately with 422 before any LLM work starts. Fail fast, save cost.
JWKS caching Clerk's public keys are fetched once and cached for 5 minutes. Without caching, every request makes an outbound HTTP call to Clerk's servers — that's a network round-trip on the hot path for every single API request.
User isolation The JWT sub claim becomes user_id on the Case row. Every query is WHERE user_id = ? — one user can never see another's cases.
The nginx problem Without X-Accel-Buffering: no, nginx (which sits in front of App Runner) buffers the SSE response. All five sections arrive in the browser at once at the end — the streaming code is correct but the proxy defeats it.
Fix X-Accel-Buffering: no tells nginx to pass each chunk through immediately. Cache-Control: no-cache tells the browser not to buffer either. Both headers are required.
Why SSE not WebSocket? This is one-way push — pipeline stages stream to browser, nothing comes back. WebSockets add bidirectional complexity for no benefit. SSE reconnects automatically, works through HTTP proxies without special config, and is handled natively by the browser's EventSource API.
Case ID UUID generated here becomes the key for every subsequent AgentStep row and every structlog line in this request. All 242 tests use it to find steps after pipeline completion.
Pinecone cold-start Pinecone serverless tier has cold-start latency of several seconds. If RAG blocks extraction, the browser waits in silence before seeing anything.
asyncio.create_task RAG starts immediately as a background task. Extraction runs and yields its SSE frame — the user sees entities and timeline within ~10 seconds. RAG is awaited only when Step 2 (Strategy) needs it.
Why not gather()? gather() would be simpler to read but blocks both tasks until the slowest one finishes. With create_task, extraction streams immediately regardless of Pinecone latency.
Persist-before-yield The AgentStep row is committed to Postgres as COMPLETED with its JSON result before the SSE yield. If the browser disconnects mid-stream, the completed step is already safe in the database. History reconstructs the full output from stored JSON — same content, same structure, always.
asyncio.wait_for Each step has a 120-second wall-clock cap. A hung or slow OpenAI call cannot stall the stream indefinitely. Configured via AGENT_STEP_TIMEOUT_SECONDS env var.
Tenacity retries Up to 3 attempts with exponential backoff — but only on RateLimitError, APIConnectionError, APITimeoutError. A bad prompt or model refusal fails immediately — retrying wastes quota and time on errors that won't resolve by themselves.
Step 2 convergence Strategy is the first step that needs both extraction facts and statute chunks. It awaits the RAG task here. If RAG failed or timed out, strategy uses general legal knowledge — pipeline never crashes.
Draft input Receives extraction result + strategy result. Does not re-read the raw case text — reduces prompt size and forces structured content rather than raw narrative into the drafting model.
Non-fatal by design The brief is complete and useful even if QA times out or the model refuses. QA failure is logged as step status FAILED, the pipeline marks the overall Case COMPLETED anyway. A partial QA is better than no brief.
What QA checks Hallucinated citations, logical inconsistencies, arguments that contradict the retrieved statute text, missing claims given the facts. Outputs a risk level (low/medium/high) and specific concerns.
Audit trail History view calls GET /cases/{id} and reads all AgentStep rows. The RAG step's result.chunks field shows exactly which statute text the strategy model was shown — full provenance, zero reconstruction needed.
Data Layer

SQL vs vectors — why two separate stores

🗄️
Aurora PostgreSQL
Transactional app state · user data · PII
Case
idUUID PKstable reference across all steps
user_idStringClerk sub claim — all queries filter by this
titleStringuser-supplied, searchable
raw_inputTextoriginal case narrative (PII — stays in SQL)
statusEnumPROCESSING / COMPLETED / FAILED
AgentStep
case_idFK → Casecascade delete
step_nameStringextraction / rag_retrieval / strategy / …
step_indexInteger0–4 · guarantees display order regardless of insert time
statusEnumPROCESSING → COMPLETED / FAILED
resultJSONdifferent schema per step · typed, not markdown
Why JSON not Text for result?
Each step has a different structure — extraction returns {facts, entities, timeline}, RAG returns {chunks: [...]}, strategy returns {issues, arguments}. The frontend consumes structured data directly. No markdown parsing needed.
VS
different scaling · different PII risk
🔍
Pinecone Serverless
Semantic corpus · public statute text · no PII
Index size ~1,800 vectors — 15 Kenyan statutes, sentence-chunked and filtered
Dimensions 1,536 — text-embedding-3-small · cosine similarity metric
Metadata per vector text (chunk content) · source (filename) · chunk_index (position in file)
Filtering Queries can scope to a single statute file via {source: {$in: [filename]}}
Re-indexing Delete-before-upsert per file. Add a new statute → re-run ingestion → done. No SQL migration, no model retraining.
Why separate from SQL?
The statute corpus is public text — no PII. User case narratives are private — PII stays in Aurora. Separate stores means a Pinecone index breach exposes nothing sensitive. They also scale differently: the vector index grows with statute corpus size; the SQL DB grows with user activity. Neither forces the other to scale.
External Services
Primary LLM
OpenAI API
gpt-4o — extraction, strategy, drafting, QA agents
gpt-4o-mini — query expansion, LLM judge, contextual compression (RAG stages — fast + cheap)
text-embedding-3-small — 1,536-dim embeddings for both ingestion and query-time search
Single shared async client. OPENAI_API_KEY takes precedence over OpenRouter when both set.
Auth Provider
Clerk
Issues RS256 JWT on login · JWKS endpoint provides public key for server-side validation
5-minute in-memory JWKS cache on FastAPI side
JWT sub claim = stable user identifier stored on every Case row
Dev bypass: X-User-Id header accepted without JWT (non-production only)
Fallback LLM
OpenRouter
Used when OPENAI_API_KEY is absent — same API interface, drop-in replacement
Useful for cost comparison or when OpenAI has availability issues
Config — pydantic-settings
Environment-Driven Config
DATABASE_URL: SQLite locally → postgresql+asyncpg:// in prod — zero code change
PINECONE_API_KEY + INDEX_HOST + NAMESPACE — empty key disables RAG gracefully
AGENT_STEP_TIMEOUT_SECONDS — default 120, overridable per environment
Decisions

Every architectural decision — problem and rationale

Decision What the naive approach gets wrong Why this solution is better
multipart/form-data not JSON JSON requires base64 encoding binary files — 33% size inflation, added parsing overhead, artificial size limits Multipart handles binary uploads natively — same as an HTML form. No encoding, no size limit imposed by the transport layer.
SSE not WebSocket WebSocket adds bidirectional complexity and requires special proxy/firewall config for HTTP upgrade Pipeline is one-way push only. SSE is purpose-built for this, auto-reconnects, works through standard HTTP proxies, native EventSource browser API.
X-Accel-Buffering: no header nginx buffers SSE by default — all five sections arrive at once at the end, defeating the streaming code entirely This single header disables nginx proxy buffering on App Runner. Without it, the streaming code is correct but invisible to the user.
asyncio.create_task for Steps 0+1 gather() would wait for both tasks before yielding anything — Pinecone cold-start latency delays the first browser output Extraction streams to browser immediately. RAG runs in background, awaited only when Strategy needs it. User sees progress within ~10 seconds.
Persist-before-yield Yielding SSE before persisting means a browser disconnect loses completed step data — no recovery, no History AgentStep is COMPLETED in Postgres before the SSE yield. Browser disconnect is safe. History tab always has full data.
Tenacity — transient errors only Retrying all errors wastes quota on bad prompts, model refusals, and malformed inputs that will fail every time Only RateLimitError, APIConnectionError, APITimeoutError are retried. Deterministic errors fail fast.
QA non-fatal A hard gate on QA means a timeout or refusal at the last step destroys an otherwise complete, useful brief QA failure is logged and marked FAILED. The draft brief is still delivered. A risk-flagged brief is better than no brief.
result column as JSON not text Storing Markdown in the result column means the frontend parses text — brittle, no structure, different logic per step type Each step stores its own typed schema. Frontend reads structured data directly. RAG chunks are accessible as result.chunks for the audit trail.
SQL vs Pinecone separation One database for everything couples user PII to public statute text, and ties scaling of app state to vector index growth PII stays in Aurora. Public statutes in Pinecone. Independent scaling, independent breach surface, independent re-indexing without touching user data.
SQLite in dev → asyncpg in prod Using a different ORM or connection path in dev means production bugs aren't caught locally pydantic-settings swaps only the DATABASE_URL. All SQLAlchemy async ORM code is identical. Zero code change between environments.
Litigation Prep Assistant — Andela AI Engineering Bootcamp Capstone FastAPI · Pinecone · Aurora · Clerk · AWS App Runner · OpenAI