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):
① 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.