This is the read-first doc. It traces a single CSV from the user clicking Submit in the form to the final 30-60-90 playbook arriving in the browser, naming every component along the way.
If you only read one doc, read this one.
User CSV + form
│
▼
POST /upload (FastAPI saves CSV to /tmp)
POST /analyze (FastAPI enqueues Inngest job)
│
▼
Inngest dispatches → LangGraph pipeline (21 nodes, mostly parallel pods)
│
│ appends SSE events to a broadcast history keyed by job_id
▼
GET /analyze/stream/{job_id} ← frontend EventSource subscribes, replays history then follows live
│
▼
React renders each card as the matching SSE event arrives.
Wall time depends on the questionnaire's analysis_depth toggle: quick (~2–4 min) pins every Gemini call to the fast tier and runs forensic diagnosis single-pass; deep (~10–20 min) promotes reasoning-heavy nodes to a stronger model and triples the forensic self-consistency vote.
| Layer | Where | What it does |
|---|---|---|
| Frontend | frontend/app/form/page.tsx, frontend/app/results/[job_id]/page.tsx |
Collect questionnaire + CSV, then stream-render results. |
| HTTP API | backend/app/main.py |
/upload, /analyze, /analyze/stream/{job_id}, /analyze/{job_id}/cancel, /analyze/{job_id}/respond. |
| Background runner | Inngest (@inngest_client.create_function) |
Receives app/analyze event and invokes the LangGraph stream. |
| Graph orchestration | backend/app/graph/builder.py |
Wires every node + conditional edge with parallel fan-out/fan-in. |
| Shared state | backend/app/graph/state.py |
RetentionGraphState — TypedDict every node reads and writes. |
| Nodes | backend/app/graph/nodes/*.py |
One file per node. Some are thin wrappers around an agent. |
| Agents | backend/app/graph/agents/{discovery,execution}/*.py |
LLM-driven reasoning units called by node wrappers. |
| LLM factory | backend/app/config.py |
Round-robin key pool + failover for Gemini and Groq. |
| RAG | backend/app/rag/{corpus_data,ingest,store,hyde}.py |
Chroma-backed retrieval with HyDE + signal-boosted scoring. |
| SSE infra | backend/app/shared.py + backend/app/graph/stream_utils.py |
active_streams dict (append-only event history + subscriber wake signals); push_event() / push_progress() broadcast events to every open connection. |
User fills the 5-phase questionnaire in frontend/app/form/page.tsx and selects a CSV. On submit the page first calls POST /upload (the file goes to /tmp/retain_ai_uploads/<uuid>.csv), gets back {file_path}, then calls POST /analyze with {raw_csv_path, questionnaire}.
# backend/app/main.py
@app.post("/analyze")
async def run_analysis(req_body):
job_id = str(uuid.uuid4())
active_streams[job_id] = {"events": [], "subscribers": [], "hitl_event": asyncio.Event(), ...}
await inngest_client.send(inngest.Event(name="app/analyze", data=req_body))
return {"status": "queued", "job_id": job_id}The browser receives {job_id}, navigates to /results/{job_id}, and opens an SSE connection to GET /analyze/stream/{job_id}. That connection replays active_streams[job_id]["events"] from its own cursor (0 on first connect), then follows new events live, until a complete / cancelled / error event. Because every event is appended to a shared history rather than consumed from a single queue, a page refresh — or two tabs on the same job — never drops an event.
analyze_retention_job (in main.py) runs inside an Inngest step. It builds the initial state and starts streaming the compiled graph:
async for state in graph.astream(initial_state, config={"configurable": {"job_id": job_id}}, stream_mode="values"):
node = state.get("current_node")
if node == "feature_engineering": push_event(job_id, {"type": "risk_ready", ...})
elif node == "behavioral_map": push_event(job_id, {"type": "churn_profile_ready", ...})
elif node == "diagnosis_merge": push_event(job_id, {"type": "diagnosis_ready", ...})
elif node == "simulation": push_event(job_id, {"type": "simulation_ready", ...})
elif node == "execution_architect": push_event(job_id, {"type": "solution_ready", ...})Five stages emit user-visible SSE events. Every node still runs — the SSE step just decides which transitions are worth showing.
flowchart TD
II[input_ingest]
DA{data_audit}
RH[retry_handler]
FE[feature_engineering]
BM[behavioral_map]
II --> DA
DA -->|score ≥ 0.5| FE
DA -.->|score < 0.5| RH
RH -.->|loop or pass| FE
FE --> BM
subgraph Disc [Discovery Pod — parallel]
FD[forensic_detective<br/>z-test gated + self-consistency]
PM[pattern_matcher]
CR[competitor_research]
end
BM --> FD & PM & CR
DM[diagnosis_merge<br/>+ professional_skeptic]
FD & PM & CR --> DM
HV{hypothesis_validation}
CA[constraint_add]
HITL[adaptive_hitl<br/>5-min wait for user]
DM --> HV
HV -->|verified| CA
HV -.->|weak| BM
CA --> HITL
subgraph Strat [Strategy Pod — parallel]
UE[unit_economist]
JTBD[jtbd_specialist]
GH[growth_hacker]
end
HITL --> UE & JTBD & GH
SM[strategy_merge]
UE & JTBD & GH --> SM
SK[strategy_skeptic]
SIM[simulation<br/>RAG-anchored MC]
SC[strategy_critic]
ED[evidence_dossier]
EA[execution_architect<br/>trace + structured]
SM --> SK --> SIM --> SC
SC -->|approved or budget exhausted| ED --> EA
SC -.->|retry| HITL
Wiring lives in backend/app/graph/builder.py. Every node is wrapped with _wrap_node() which:
- Checks for cancellation (
active_streams[job_id]["cancelled"]) and raisesJobCancelledif set. - Logs
[NODE→]entry and[NODE✓]exit with elapsed seconds and current RSS. - Re-raises any exception while logging
[NODE✗].
| Phase | Nodes | Purpose | Doc |
|---|---|---|---|
| Ingest | input_ingest → data_audit → (optional retry_handler) |
Load CSV with DuckDB, detect columns, score quality. | nodes/input-ingest.md, nodes/data-audit.md, nodes/retry-handler.md |
| Feature | feature_engineering |
RFM / LTV / velocity + CoxPH survival regression. Surfaces top-5 hazard-ratio drivers. | nodes/feature-engineering.md |
| Behavior | behavioral_map |
Kaplan-Meier survival curve, milestone retention, tenure cohorts. | nodes/behavioral-map.md |
| Discovery | forensic_detective, pattern_matcher, competitor_research (parallel) → diagnosis_merge |
Diagnose root causes (forensic), find segments/sequences (pattern), pull competitor counter-positioning (when applicable), then run the professional skeptic + build the top-segments table. | nodes/forensic-detective.md, nodes/pattern-matcher.md, nodes/competitor-research.md, nodes/diagnosis-merge.md |
| Validation | hypothesis_validation → constraint_add → adaptive_hitl |
Gate on confidence × robustness, filter by budget/legal, then ask the human 2–3 clarifying questions over SSE. | nodes/hypothesis-validation.md, nodes/constraint-add.md, nodes/adaptive-hitl.md |
| Strategy | unit_economist, jtbd_specialist, growth_hacker (parallel) → strategy_merge |
Three Groq agents (openai/gpt-oss-120b) propose interventions/tactics under different frameworks. Merge keeps the strict top + lighter alternatives. |
nodes/unit-economist.md, nodes/jtbd-specialist.md, nodes/growth-hacker.md, nodes/strategy-merge.md |
| Review | strategy_skeptic → simulation → strategy_critic |
Adversarial pre-sim review; RAG-anchored Monte Carlo (10k iters); senior-partner critic that gates approval. | nodes/strategy-skeptic.md, nodes/simulation.md, nodes/strategy-critic.md |
| Finalize | evidence_dossier → execution_architect |
Build per-problem rationale chains; pass-1 reasoning trace + pass-2 structured Pydantic playbook. | nodes/evidence-dossier.md, nodes/execution-architect.md |
The single RetentionGraphState dict accretes keys node-by-node:
raw_csv_path, questionnaire, job_id ← initial
+ input_context, input_constraints ← input_ingest
+ data_quality_score, data_quality_logs ← data_audit
+ feature_store (with predictive_churn_risk.driver_features) ← feature_engineering
+ behavior_curves, behavior_cohorts ← behavioral_map
+ forensic_detective_output ← forensic_detective
+ pattern_matcher_output ← pattern_matcher
+ competitor_research_output ← competitor_research
+ professional_skeptic_output, diagnosis_results, top_segments ← diagnosis_merge
+ hypothesis_status, verified_root_causes ← hypothesis_validation
+ constrained_brief ← constraint_add
+ hitl_questions, human_clarification ← adaptive_hitl
+ unit_economist_output, jtbd_specialist_output, growth_hacker_output ← strategy pod
+ strategy_outputs (with merged_strategies) ← strategy_merge
+ strategy_skeptic_output ← strategy_skeptic
+ simulations, lift_percent ← simulation
+ critic_verdict, criticism, feedback, iteration_count ← strategy_critic
+ evidence_dossier ← evidence_dossier
+ final_playbook, playbook_status ← execution_architect
Full schema and reducers: state.md.
Only five transitions write to the queue (backend/app/main.py):
SSE type |
Fired after node | Carries |
|---|---|---|
risk_ready |
feature_engineering |
High-risk count, CoxPH concordance, RFM, engagement cohorts, data quality summary, input context. |
churn_profile_ready |
behavioral_map |
KM survival curve (downsampled to ≤20 points), median survival, milestone retention, tenure cohorts. |
forensic_progress (mid-node) |
forensic_detective self-consistency runs |
Per-run started / completed / failed. 1 run at temp 0.3 in quick mode, 3 runs at temps 0.2/0.5/0.7 in deep mode. |
hitl_questions_ready (mid-node) |
adaptive_hitl |
2–3 questions; UI shows a modal and posts answers to /analyze/{job_id}/respond. |
diagnosis_ready |
diagnosis_merge |
merged_hypotheses, forensic_findings, pattern_findings, skeptic_findings, competitor_research, top_segments, driver_features. |
critic_retry_started (mid-node) |
strategy_critic (only when retrying) |
iteration, max, verdict reason, skeptic flag count. |
simulation_ready |
simulation |
expected_lift, confidence_interval_5_95, interventions[] (each with lift_prior_anchor/pct/citations), rag_anchored_count, strategy_skeptic. |
solution_ready |
execution_architect |
final_playbook (with reasoning_trace and per-problem rationale_chain) + evidence_dossier. |
cancelled / error |
terminal failure paths | error_type, error_message, last_node. |
complete |
end of stream | {}; SSE handler closes. active_streams[job_id] is popped 120s later (_schedule_cleanup), giving a same-moment refresh time to replay. |
Heartbeat: if the queue is silent for 15s the handler yields : heartbeat\n\n to keep proxies from killing the connection.
adaptive_hitl is unique — it is the only node that suspends the graph. It pushes hitl_questions_ready and then await asyncio.wait_for(stream["hitl_event"].wait(), timeout=300). The frontend submits answers via POST /analyze/{job_id}/respond, which sets stream["hitl_event"] and unblocks the graph. After 5 minutes the timeout fires and the graph continues with empty answers.
On critic-retry the HITL node short-circuits: it reuses the prior answers from state instead of re-prompting (if state.get("iteration_count", 0) >= 1: return prior).
conditions.py sets MAX_DISCOVERY_ATTEMPTS = 0 and MAX_CRITIC_ITERATIONS = 0 — both retry loops are gated off because each retry doubles RSS on Render's 512 MB free tier (the graph keeps both passes' agent outputs in state). On a bigger instance you can raise these to enable 1 retry pass; build_critic_feedback_block() in app/graph/utils.py already embeds the prior critic verdict + weaknesses + recommendations into every retry agent's prompt.
POST /analyze/{job_id}/cancel sets active_streams[job_id]["cancelled"] = True and hitl_event.set() (so any waiting HITL unblocks immediately). On the next node entry, _wrap_node calls is_cancelled(job_id) and raises JobCancelled. The Inngest task catches it, pushes a cancelled SSE event, then a complete, and the SSE handler closes.
- DuckDB — reads any CSV without writing schema. Used in
input_ingest,data_audit,feature_engineering,behavioral_map. - lifelines — Kaplan-Meier + CoxPH.
feature_engineeringfits the CoxPH model and extracts top-5 hazard drivers;behavioral_mapfits KM for the survival curve. - Chroma — vector DB for the retention-framework corpus. Default embedder
all-MiniLM-L6-v2. Persistent on disk atbackend/app/rag/chroma_db/. - Gemini — discovery + architect.
forensic_detective,pattern_matcher,professional_skeptic,strategy_skeptic,adaptive_hitl,strategy_critic,execution_architect, HyDE. Fast tier (gemini-3.1-flash-lite) by default; reasoning-heavy nodes promote to the deep tier (gemini-3.5-flash) whenquestionnaire.analysis_depth == "deep". Round-robins acrossGOOGLE_API_KEY[_1..32]. See llm-factory.md. - Groq — strategy agents.
unit_economist,jtbd_specialist,growth_hackeronopenai/gpt-oss-120b. Round-robins acrossGROQ_API_KEY[_1..32]. - Inngest — durable background job runner. Decouples the slow LangGraph stream from the API request that started it.
- Render — production host. Free tier is constrained on RSS, which is why the retry loops are disabled and why
app/main.pysymlinks~/.cache/chromato the project dir so the ONNX cache survives cold starts.
- Want to add a node? Read state.md first (so you know what to write into state), then nodes/ for the existing patterns. Register the node in
backend/app/graph/nodes/__init__.pyand wire it inbuilder.py. - Want to add a RAG signal tag? rag.md and rag/hyde.md. Add the tag to relevant chunks in
corpus_data.py, teach_derive_signals()inforensic_detective.pyto emit it, re-runpython -m app.rag.ingest. - Want to swap a model? Read llm-factory.md. All LLMs are obtained via
get_llm(provider, model, temperature)— never instantiated directly. - Want to add an SSE event? Update both the producer (
app/main.pyfor stage transitions orstream_utils.push_progress()for mid-node) and the consumer (frontend/app/results/[job_id]/page.tsx). ui-flow.md has the full event table.