End-to-end trace from the user clicking Submit on the form to the playbook rendering. Read pipeline-overview.md first if you haven't.
| Role | File |
|---|---|
| Landing | frontend/app/page.tsx |
| Form (5 phases + depth toggle) | frontend/app/form/page.tsx |
| Results orchestrator | frontend/app/results/[job_id]/page.tsx |
| SSE hook + snapshot persistence | frontend/app/results/[job_id]/useAnalysisStream.ts |
| Results sections | frontend/app/results/[job_id]/components/ — one file per section, Tailwind utilities only (no hand-written CSS) |
| API entry | backend/app/main.py |
| SSE infra | backend/app/shared.py, backend/app/graph/stream_utils.py |
| Method | Path | Purpose | Body / Returns |
|---|---|---|---|
POST |
/upload |
Store uploaded CSV in /tmp/retain_ai_uploads/<uuid>.csv. |
FormData file. Returns {status, file_path, original_name}. |
POST |
/analyze |
Queue a new analysis. Creates active_streams[job_id] and sends an Inngest event. |
{raw_csv_path, questionnaire}. Returns {status: "queued", job_id, message}. |
GET |
/analyze/stream/{job_id} |
Server-Sent Events stream. Replays active_streams[job_id]["events"] from its own cursor, then follows live, until a complete event. Multiple connections on the same job each get the full history — nothing is consumed. |
text/event-stream. |
POST |
/analyze/{job_id}/cancel |
Mark the job cancelled. Next node entry raises JobCancelled. Also set()s the HITL event so a paused graph unblocks. |
{}. Returns {status, job_id, cancelled}. |
POST |
/analyze/{job_id}/respond |
Submit HITL answers. Sets stream["hitl_event"] so adaptive_hitl resumes. |
{answers: {...}}. Returns {status: "ok"}. |
GET |
/ |
Health string. | |
GET |
/healthz |
Liveness probe. | {ok: true}. |
The frontend uploads the CSV first, then sends the analysis request:
POST /analyze
{
"raw_csv_path": "a1b2c3d4...csv", // returned by /upload — basename, not full path
"questionnaire": {
"business_context": "Retention analytics for SaaS",
"industry": "SaaS",
"size": "100-500",
"business_model": "B2B SaaS",
"company_stage": "Series A",
"goal": "Reduce churn rate",
"timeline": "Quick wins (30 days)",
"priority_segment": "Newest customers (first 90 days)",
"typical_customer": "...",
"can_ship_changes": "No",
"support_model": "Self-serve only",
"pricing_flexibility": ["None — pricing is locked"],
"retention_tactics": ["onboarding email"],
"competitors": ["Slack"],
"churn_destination": "Microsoft Teams",
"time_range": "last_12_months",
"legal_constraints": ["GDPR"],
"budget": "medium",
"analysis_depth": "quick",
"edge_cases": ["Monthly contracts are labeled churn=1 at contract end"],
"churn_definition": "Inactivity (30 days)",
"top_channels": ["Paid ads", "Organic search / SEO"],
"has_completion_point": "No",
"revenue_model": "Monthly subscription"
}
}Many of these keys are read directly by the strategy / critic / architect prompts — the field names matter. Adding a new questionnaire field requires updating both the form (form/page.tsx) and any prompt that should consume it.
industry, size, time_range, and budget are sent but the form never actually collects them — they arrive as empty strings/defaults. Don't assume a key exists in the payload just because it's read somewhere in the backend; grep the actual form fields in form/page.tsx first (constraint_add's old budget/legal filter was deleted after this exact mistake — the code paths could never fire). analysis_depth, edge_cases, churn_definition, top_channels, has_completion_point, and revenue_model are real, form-collected fields that reach prompts — see the relevant node docs for where each lands.
raw_csv_path is a bare basename — input_ingest resolves it against /tmp/retain_ai_uploads/ first and backend/data/ as fallback for local static files.
| Key | Contents |
|---|---|
latest_form_state |
Current form inputs — pre-fills /form on return. |
latest_form_payload |
JSON actually sent to /analyze. Used to re-display the request that started a job, and reused by the results page's Rerun button. |
latest_csv_text / latest_csv_name |
Raw CSV text (<4MB) stashed on form submit. Rerun re-uploads it to get a fresh server-side file — the original upload's temp file doesn't survive a server restart / Render spin-down, and the results page has no File object of its own. |
job_{jobId} |
Accumulated SSE stagesData, hitlSubmitted, connectionStatus, errorInfo, and per-stage event timestamps (stageEventTs, jobStartTs). Lets the results page survive a refresh without losing state or resetting stage-duration timers. |
The results page checks job_{jobId} on mount and only opens a fresh EventSource if no terminal complete event has been recorded yet.
sequenceDiagram
participant U as User
participant F as Next.js UI
participant A as FastAPI
participant Q as broadcast event log<br/>(active_streams)
participant I as Inngest
participant G as LangGraph
U->>F: Fill form + select CSV
F->>A: POST /upload (FormData)
A-->>F: { file_path }
F->>A: POST /analyze { raw_csv_path, questionnaire }
A->>A: job_id = uuid4()<br/>active_streams[job_id] = {events: [], subscribers: [], hitl_event, hitl_answers, cancelled}
A->>I: inngest.send(event="app/analyze")
A-->>F: { job_id, status: "queued" }
F->>F: router.push(/results/{job_id})
F->>A: GET /analyze/stream/{job_id}
A->>F: open SSE, replay history + subscribe to live events
I->>G: analyze_retention_job(ctx, step)
G->>G: astream(initial_state, stream_mode="values")
loop every node yields a state
G-->>A: state.current_node
Note over A,Q: emit only on selected transitions
alt feature_engineering done
A->>Q: risk_ready
else behavioral_map done
A->>Q: churn_profile_ready
else diagnosis_merge done
A->>Q: diagnosis_ready
else simulation done
A->>Q: simulation_ready
else execution_architect done
A->>Q: solution_ready
end
Q-->>F: SSE event
F->>U: render card live
end
opt mid-node progress (forensic self-consistency)
G->>Q: forensic_progress (via push_progress)
Q-->>F: SSE event
end
opt HITL pause
G->>Q: hitl_questions_ready
Q-->>F: render modal
U->>F: submit answers
F->>A: POST /analyze/{job_id}/respond
A->>A: stream["hitl_event"].set()
Note over G: graph resumes
end
opt critic retry
G->>Q: critic_retry_started
end
A->>Q: complete
Q-->>F: SSE complete
F->>F: close EventSource, persist job_{jobId}
Every event is JSON:
{ "type": "<event_type>", "message": "...", "data": { ... }, "ts_ms": 1234567890 }
(ts_ms is added by push_event() for every event — including the stage-transition events written in main.py, which now also go through push_event() rather than a raw queue put. The frontend derives stage durations from this timestamp rather than client arrival time, so durations stay correct across page refreshes — a burst-replay after a refresh would otherwise collapse every duration to ~0s.)
type |
Fired in | data keys |
|---|---|---|
risk_ready |
feature_engineering |
high_risk_count, total_active, risk_pct, confidence, insight, has_model, feature_store: {ltv_estimates, velocity_metrics, engagement_cohorts, rfm_scores}, data_quality_score, data_quality_logs, input_context. |
churn_profile_ready |
behavioral_map |
churn_probability, survival_curve, max_tenure, median_survival_time, milestone_retention, milestone_metadata, behavior_cohorts. |
diagnosis_ready |
diagnosis_merge |
merged_hypotheses, forensic_findings, pattern_findings, skeptic_findings, user_segments, top_segments, driver_features, total_patterns_identified, competitors, churn_destination, competitor_research. |
simulation_ready |
simulation |
expected_lift, confidence_low, confidence_high, expected_roi, iterations, interventions: [{name, p10, mean, p90, lift_prior_anchor, lift_prior_pct, lift_prior_citations}], rag_anchored_count, strategy_skeptic. |
solution_ready |
execution_architect |
final_playbook (full Pydantic dump including reasoning_trace + per-problem rationale_chain), evidence_dossier. |
complete |
end of stream | {}. SSE handler closes and pops active_streams[job_id]. |
type |
Pushed by | When | data |
|---|---|---|---|
forensic_progress |
forensic_detective self-consistency loop |
Each of the 3 parallel runs at temps 0.2/0.5/0.7 emits started → completed/failed. |
{run, total, temp, status, causes_found?, error?}. |
hitl_questions_ready |
adaptive_hitl |
Before suspending for human answers. | {questions: [...]}. |
critic_retry_started |
strategy_critic |
Only when a retry will actually fire (verdict ≠ approved AND iterations < MAX). With MAX_CRITIC_ITERATIONS=0 this never fires in production. |
{iteration, max, verdict, reason, weak_points_count, skeptic_flags}. |
type |
When | data |
|---|---|---|
cancelled |
JobCancelled raised mid-pipeline (user hit cancel). Followed by complete. |
{job_id}. |
error |
Uncaught exception in execute_and_stream. Followed by complete. |
{error_type, error_message, last_node}. |
If the queue is idle for 15s the SSE handler yields : heartbeat\n\n (an SSE comment). Keeps Render's idle-connection killer and intermediate proxies from closing the stream during long-running nodes (forensic_detective can take ~50s).
The results page (results/[job_id]/page.tsx):
- Holds one
useRef<EventSource | null>to dedupe under React Strict Mode. - On mount: reads
job_{jobId}fromsessionStorage— if a terminalcomplete/error/cancelledstatus was recorded, renders from cache instead of opening a freshEventSource(the backend would 404 on an already-cleaned-up job). - Otherwise opens
GET /analyze/stream/{job_id}, which replays the full event history before following live — a refresh mid-run picks up every event again, not just new ones. - Per-stage timers are derived from each event's
ts_ms(useAnalysisStream.ts), not from when the browser received it.
| Event type | Renders |
|---|---|
risk_ready |
"Churn Risk" card + RFM/velocity/cohort summary. |
churn_profile_ready |
Survival slider, milestone retention strip, median-survival card, tenure cohort chips. |
forensic_progress |
Optional inline progress chips on the forensic step. |
hitl_questions_ready |
Modal with 2–3 inputs; submit posts to /analyze/{job_id}/respond. |
diagnosis_ready |
Root-cause cards (clickable → F15 evidence drawer with stat / citations / skeptic caveat / alternative / hazard drivers). |
simulation_ready |
Lift band + intervention rows (each shows lift_prior_anchor badge, RAG citation pills). |
solution_ready |
Playbook tab — problems & solutions (with F16 rationale chain strip), 30-60-90 roadmap, success metrics, risks, "Why this playbook" reasoning-trace toggle. |
cancelled / error |
Banner + recovery CTA. |
complete |
Close EventSource. |
behavior_curves.survival_curve arrives as {month_1: 0.95, month_2: 0.91, ..., month_24: 0.42}. Parsed once:
const points = parseSurvivalCurve(curves.survival_curve);…initialized to max_tenure. On slide:
const churnPct = getChurnAtPeriod(points, tenureSlider); // nearest-lower lookupBoth helpers live at the top of frontend/app/results/[job_id]/page.tsx.
lifelines can produce NaN / Infinity (e.g. median_survival_time when fewer than 50% of users have churned). Those are invalid JSON. _sanitize() in app/main.py walks the final state recursively and replaces them with None before the Inngest step returns.
- Node exception: the node catches and returns
{"errors": [...]}(using theoperator.addreducer). The graph continues; the SSE for that stage simply isn't emitted becausecurrent_nodeadvances. - Inngest step crash: the
execute_and_streamexceptblock pushes anerrorSSE thencompleteso the frontend exits cleanly. - SSE disconnect mid-pipeline: the generator loop exits but
active_streams[job_id]is untouched — the event history keeps growing. The frontend reconnects by re-issuingGET /analyze/stream/{job_id}, which replays the entire history from cursor 0 (not just what's new), so nothing is lost regardless of how long the gap was. Cleanup only happens 120s aftercomplete. - HITL timeout: 5 minutes. The graph proceeds with empty
responsesandclarification_status: "timeout". Downstream strategy agents readhuman_clarification.responsesas{}. - Cancellation race during HITL: the cancel endpoint sets
hitl_event.set()so the awaitingasyncio.wait_forunblocks immediately; the next_wrap_nodeentry then raisesJobCancelled. - Inngest SDK arg-shape drift: the job handler uses
*args, **kwargsand trieskwargs.get("ctx")thenargs[0]to survive 0.5.x→ shape changes.