Companion to docs/pipeline.html (the 12-stage flow) and docs/vector-database.md (Supabase/Postgres + pgvector schema). This is the layer those don't cover: how memory works for a multi-turn chat, how voice fits in, what process runs the pipeline, how an anonymous browser becomes an identity, what stops abuse, and how you'd see what happened after the fact.
Python throughout — every other piece here has a first-class Python client, and this pipeline is almost entirely I/O-bound, which is what makes an async framework worth it.
| Layer | Choice | Why |
|---|---|---|
| Web framework | FastAPI | Async-native — one process handles many concurrent turns. Pydantic validation catches shape bugs before a stage sees them. |
| Database | Supabase (PostgreSQL + pgvector) | One database, content and user data both. Free tier, no separate vector-database service. Also holds the conversation buffer (§2). See vector-database.md §1. |
| Object storage | Supabase Storage | S3-compatible, same free project — holds image creations (§12) and curated Listen audio (§13). Poems are plain text and skip this. |
| ORM / migrations | SQLAlchemy + Alembic | Table defs as Python classes; schema changes become reviewable migrations. |
| Embeddings | NVIDIA NIM (nvidia/llama-nemotron-embed-1b-v2) | Originally Voyage AI, moved to the same NVIDIA NIM account already used for generation — one provider, one key, instead of two. |
| Generation | NVIDIA NIM (openai SDK, custom base_url) | Free-tier, OpenAI-compatible access to hosted open models (Qwen3.5/Qwen3-Next and DeepSeek V4, per what's actually available on this account) — switched from Anthropic Claude since no paid Anthropic key is available. Per the model-tier table in vector-database.md §1. |
| Speech-to-text | Not locked TBD | Needs strong Hindi/Hinglish accuracy. Candidates: OpenAI Whisper, Sarvam AI / Bhashini (India-first). See §5. |
| Text-to-speech | Not locked TBD | Same language bar, plus voice warmth matters for a companion. Candidates: ElevenLabs, Sarvam AI. See §5. |
| Rate limiting | slowapi | In-memory backend is enough for one instance — see §8. |
| Scheduled jobs | APScheduler, in-process | Runs cleanup + Summarizer with no extra service. See §9. |
| Telemetry | Langfuse Cloud (free Hobby tier) + OpenTelemetry | Originally Phoenix; switched after comparing free-tier options — see §10. |
Core to a chatbot flow specifically, not incidental plumbing. Two structurally separate things share the word "memory" — conflating them was the actual gap in an earlier version of this doc.
conversation_turnsmessages arrayuser_memory_summarysystem promptSELECT role, content, created_at FROM (
SELECT role, content, created_at FROM conversation_turns
WHERE session_id = $1
AND created_at > now() - interval '2 hours'
ORDER BY created_at DESC
LIMIT 12
) recent
ORDER BY created_at ASC;
A single ascending-order query with LIMIT 12 returns the oldest 12 turns inside the 2-hour window, not the most recent 12 — wrong once a conversation has more than 12 turns in that window. The inner query grabs the most recent 12 (descending, limited), the outer query re-sorts that small set back into chronological order for the messages array.
Only the long-term summary → conversation feels amnesiac mid-chat, since it updates periodically, not every turn. Only a raw buffer with no synthesis → it either grows into a literal transcript (what "never a data recap" rules out) or gets truncated and any earlier pattern is just gone.
No need to explicitly track when a "sitting" starts or ends — just "is the last thing said recent enough to still be live." Return tomorrow and this reads empty; the already-synthesized summary carries continuity across that gap instead.
pipeline.html flagged "Safety gate needs conversation memory" as a build-blocker — the hedge case ("just thinking about it" after an earlier direct statement) can't be caught from one message alone. The Safety gate now reads this same buffer, and the "already shown" flag that finding also called for is user_sessions.last_crisis_card_shown_at — suppresses re-rendering the crisis card, never suppresses the underlying match itself.
Steps before the pipeline are synchronous — the person is waiting. Full stage-by-stage detail is §4; voice-specific detail is §5.
Issue/validate bandhu_sid. Over quota → 429, stop here.
Transcribe → text + language, then discard the audio. Text messages skip straight past this. See §5.
Memory read pulls both horizons from §2 before Orchestrator/Generate run. Full breakdown in §4.
After Guardrail passes — synthesize response_text. Text turns skip this. See §5.
200 + body (text, +audio if voice). Set-Cookie only if a new session was issued.
Summarizer (periodic) + Sampled evaluator — run after the reply is already delivered.
Each stage: the plain-language version first, then what it actually receives, does, and hands forward.
session_idMessage{text, language, media_type, input_mode}last_crisis_card_shown_atsafety_patterns — a hedge only counts if a direct statement appears earlier in the buffer. Suppress re-rendering the crisis card if shown recently; match still always runs.{triggered, severity} — triggered short-circuits straight to the Crisis branch.tags{} or special_case — special case short-circuits to the fixed redirect branch.confidence: "low" with every tag null — never force-fit onto the nearest category. See app/pipeline/stages/classify.py.session_iduser_memory_summary (long-term) and conversation_turns (short-term, §2).{summary_text, recent_turns[]}session_idis_help_offer=true over last 3 user_checkins rows — structured events, not raw messages. close_the_loop never counts against this.eligible_for_offer: booltags + languagepgvector query — metadata filter then similarity, top 2-3 (vector-database.md §3). Keeps search anchored to what was just said, not drifting with dialogue.retrieved_chunks[]{summary_text, recent_turns[]}, eligible_for_offer, retrieved_chunks[]recent_turns specifically to avoid repeating an offer made two messages ago.directive{tool, target_or_none}directive + its target content + recent_turns + current messageresponse_textresponse_text + hard-constraint listuser_checkins row (facts + input_mode); insert 2 conversation_turns rows (message + reply, text only). If voice → TTS on response_text now (§5).user_checkins facts since last run, plus any user_creations.caption rows in the window (§12)conversation_turns, a full poem, or a stored file — facts and short captions only, never raw dialogue or the creative work itself.user_memory_summaryresponse_text + its contextevaluator_scores rowTwo edge adapters, not two new pipeline stages — everything between STT and TTS is the same 12-stage flow regardless of how the message arrived. pipeline.html's conversation logic shouldn't need to know or care whether someone typed or spoke.
POST /message, audio blob{text, detected_language}Message{text, language, input_mode:'voice'} into stage 1A voice recording is a strictly more sensitive artifact than its text transcript — it carries tone, identity, and emotional state in a way text doesn't, especially for someone using this app in distress. The "never a data recap / never a permanent transcript" principle that already governs conversation_turns (§2) applies at least as strongly here, arguably more. The commitment: audio is transcribed and immediately discarded, never persisted — not to a bucket, not to a temp table, not even transiently beyond what the STT call itself needs. Only the transcribed text ever reaches conversation_turns or user_checkins.
response_text finalizedinput_mode == 'voice'?{text, audio} — text always included, even on a voice turnresponse_text is what gets written to conversation_turns (§2, §4 stage 10) and what gets spoken — TTS runs on it, doesn't replace it. That keeps the buffer's content uniform regardless of input/output modality, so Orchestrator/Generate never need to branch on how a past turn was delivered.
A voice turn now pays STT time, the same two LLM calls a text turn pays, then TTS time — a longer, more latency-sensitive round-trip than typing. Two real options, neither committed to: accept the added latency for v1, or stream TTS as text is generated (meaningfully more complex). See open items.
Where the two memory horizons actually meet the LLM API — unaffected by voice (§5): by the time Generate runs, the turn is already plain text either way.
# recent_turns is stage 4's output, oldest first
messages = [{"role": t["role"], "content": t["content"]} for t in recent_turns]
messages.append({"role": "user", "content": message_text})
system_prompt = f"""{BANDHU_PERSONA_AND_CONSTRAINTS}
Rolling context on this person — for your own awareness only, never to be
recited back to them:
{summary_text or "No prior context yet."}
{_directive_instruction(directive, retrieved_chunks)}
# e.g. "Offer this, once, warmly, only if it fits naturally: <chunk text>"
"""
response_text = await generate(
model=GENERATE_MODEL, # small tier — vector-database.md §1
system=system_prompt,
messages=messages,
max_tokens=150,
)
This is clients/llm.py's generate() — its own system parameter, kept structurally separate at the function boundary, even though NVIDIA NIM's underlying API (OpenAI-shaped, unlike Anthropic's) has no genuinely separate top-level system slot; generate() assembles it as the first message with role: "system" before the call goes out.
The long-term summary and the Orchestrator's directive go into system — the model knows them, but they can never literally appear as a chat bubble, since the system role is structurally distinct from user/assistant, not just positioned differently. Only the real back-and-forth goes in as user/assistant turns. This makes "never a data recap" structurally true, not a prompt instruction the model could drift from — the summary physically cannot come out sounding like "Last Tuesday you said X," because it was never written into the transcript as if anyone said it.
bandhu_sid cookieNo login, no account — consistent with the product's companion-first stance. But the pipeline still needs something stable to hang turns and memory off of.
response.set_cookie(
key="bandhu_sid",
value=str(session_id),
max_age=60 * 60 * 24 * 14, # 14 days — matches the cleanup job's window exactly
httponly=True, # JS on the page can't read or tamper with it
secure=True, # only sent over HTTPS
samesite="lax", # normal navigation still sends it; blocks most
# cross-site abuse without breaking anything
)
session_id is the key to someone's conversation and memory. If JS could read it, any XSS bug becomes a way to read that history, not just deface a page.
A client-generated id is fully attacker-controlled with no way to detect forgery or replay. A server-issued, server-validated cookie is the safer default for anything scoping private data — even anonymous data.
The only identity to limit against is session_id (once issued) and IP (always available). Each layer catches a different abuse shape — voice adds a third.
Stops one runaway client from burning spend. A voice turn now costs STT + 2 LLM calls + TTS, not just 2 LLM calls. Also the backstop against NVIDIA NIM's own 40 req/min free-tier ceiling — this needs to stay comfortably under that, not just under whatever felt reasonable in isolation.
Backstop against discarding cookies for a fresh session every request.
Checked before STT runs at all — rejecting an oversized clip is free; rejecting one that already hit STT costs a paid call.
from slowapi import Limiter
from slowapi.util import get_remote_address
def rate_limit_key(request):
return request.cookies.get("bandhu_sid") or get_remote_address(request)
limiter = Limiter(key_func=rate_limit_key)
@app.post("/message")
@limiter.limit("20/10minutes")
async def message(...): ...
All numbers are starting points — tune from real traffic. slowapi's default backend counts in the process's own memory: correct for one process, but with multiple workers the effective limit becomes configured × process count — fix is pointing it at Redis, a config change not a redesign.
One scheduled job, daily. The entire job is one query, because ON DELETE CASCADE does the rest.
def cleanup_expired_sessions(db):
db.execute(
"DELETE FROM user_sessions WHERE last_active_at < now() - interval '14 days'"
)
db.commit()
ON DELETE CASCADE on user_checkins, conversation_turns, user_memory_summary, and transitively evaluator_scores means one DELETE cleans up every table that references user_sessions. There's no audio table to clean up — per §5, raw voice is never written anywhere, so there's nothing here for this job to even reach.
last_active_at updates every turn, so "2 weeks" means since the person's last visit. Confirm this is what you meant — the one-line alternative is keying off created_at instead.
Two independent settings encoding one policy. Not dangerous if they drift, but worth a code comment linking them so a future edit doesn't silently desync the two.
An LLM pipeline fails silently far more than it crashes — telemetry is how you see that happening instead of hearing about it from a screenshot later.
Both are free at this project's scale (Langfuse Hobby: 50,000 units/month, 30-day retention; Phoenix AX Free: 25,000 spans/month, 15-day retention). The deciding factors were fit, not cost — Langfuse's session view groups every span under one bandhu_sid across a multi-turn conversation, and its scores view maps directly onto stage 12's sampled Evaluator. Phoenix's dedicated retrieval-span rendering was a real edge, but that's just a few structured attributes either way — not worth losing the other two for.
import base64
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from openinference.instrumentation.openai import OpenAIInstrumentor
auth = base64.b64encode(
f"{settings.langfuse_public_key}:{settings.langfuse_secret_key}".encode()
).decode()
tracer_provider = TracerProvider()
tracer_provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(
endpoint="https://cloud.langfuse.com/api/public/otel/v1/traces",
headers={"Authorization": f"Basic {auth}"},
))
)
OpenAIInstrumentor().instrument(tracer_provider=tracer_provider)
Originally planned as Anthropic's AnthropicInstrumentor, written against the anthropic SDK client. Switched to OpenInference's OpenAI instrumentor since NVIDIA NIM is accessed through the openai SDK — it patches the openai package itself, so it auto-traces NIM calls the same way it would trace OpenAI's own API.
from opentelemetry import trace
tracer = trace.get_tracer("bandhu.pipeline")
def transcribe(audio_bytes):
with tracer.start_as_current_span("stt") as span:
result = stt_client.transcribe(audio_bytes)
span.set_attribute("stt.detected_language", result.language)
span.set_attribute("stt.duration_seconds", result.duration)
return result
def retrieve(query_embedding, filters):
with tracer.start_as_current_span("retrieval") as span:
results = vector_search(query_embedding, filters)
span.set_attribute("retrieval.result_count", len(results))
span.set_attribute("retrieval.entry_keys", [r.entry_key for r in results])
span.set_attribute("retrieval.top_similarity", results[0].similarity if results else 0)
return results
OpenAIInstrumentor captures full prompt/completion text by default — the wrong default for a mental-health check-in product. TelemetryConfig (in app/config.py) splits logging into two tiers: metadata (stage, latency, tokens, model, session_id, errors) always logs; raw message/prompt/retrieval-content fields are opt-in per field, off unless explicitly turned on — enforced at each span-creation call site, not just noted here.
Langfuse's 30-day retention is longer than user_sessions' 14-day cascade-delete window (§9) — a person's session_id can exist in Langfuse up to 16 days after its row is deleted. Not resolved: hash/truncate session_id before it's attached to a span (closes the gap, breaks Langfuse's session-grouping view) versus documenting this as an accepted exception. Tracked in Open Items.
Langfuse traces every turn — did this work correctly, technically, including exactly how long STT/TTS took on a voice turn versus the LLM calls. The Sampled evaluator scores 5–10% against the MITI rubric — was this a good response. Both matter, for different questions.
Every stage in pipeline/stages/ is a plain function — typed input in, typed output out. That's what keeps orchestrator.py readable and each stage unit-testable without a running server.
backend/
app/
main.py # FastAPI app, middleware, router mounting
creations.py # §12 — separate from pipeline/, its own write path
breathe.py # §13 — direct content query, bypasses pipeline/ entirely
listen.py # §13 — direct content query, bypasses pipeline/ entirely
middleware/
session.py # §7 — cookie issuance/validation
rate_limit.py # §8 — slowapi config
pipeline/
orchestrator.py # runs the 12 stages, handles branches
stages/
ingest.py # §4 stage 1 — calls clients/stt.py for audio
safety_gate.py
classify.py
memory_read.py # §2 — reads both memory horizons
eligibility_gate.py
retrieval.py
orchestrator_judgment.py # the LLM-discretion stage itself
generate.py # §6 — assembles system/messages
guardrail_check.py
memory_write.py # §2, §5 — writes turns, calls clients/tts.py
summarizer.py # stage 11, async
evaluator.py # stage 12, async, sampled
clients/
llm.py # openai SDK wrapper (NVIDIA NIM), model-tier config lives here
embeddings.py # also NVIDIA NIM — same account/key as llm.py
stt.py # §5 — transcribe + discard, provider TBD
tts.py # §5 — synthesize, provider TBD
storage.py # §12/§13 — Supabase Storage client, image creations + audio tracks
db.py # SQLAlchemy session/engine — talks to Supabase,
# content and user tables both
models/ # mirrors vector-database.md §2
user_sessions.py
conversation_turns.py # §2
user_checkins.py
user_creations.py # §12
audio_tracks.py # §13
user_memory_summary.py
evaluator_scores.py
redirect_templates.py
safety_patterns.py
helplines.py
jobs/
cleanup.py # §9
scheduler.py # APScheduler wiring
telemetry/
langfuse_setup.py # §10
alembic/ # migration history
tests/
pipeline/ # one test module per stage
Not part of the check-in pipeline's 12 stages — a separate feature (the "Co-Create" screen) with its own write path, that later feeds into the pipeline via the Summarizer (stage 11, §4). Music was originally in scope here — corrected: what ux-flow.html calls "Listen" turned out to be Bandhu-provided curated audio, not something the person creates. Different enough to be its own section — see §13.
"Write Together" and "Poem" both lead here — two entry points into the same user_creations write path, not two separate features. No schema or backend implication, just worth knowing so nothing gets built twice.
user_creations.text_content — no storage bucketstorage_path stores the path, not the filecaption is written alongside it — a description of the thing, not the thing itselfSame principle as conversation_turns vs. user_memory_summary in §2 — the long-term narrative carries an impression ("wrote something about feeling stuck this week"), never a replay of someone's actual creative work. It's also what keeps the Summarizer's LLM call cheap and bounded — a caption is a sentence; a poem or an image is not.
user_creations.session_id cascades from user_sessions like every other user table (vector-database.md §2) — no special lifecycle, per your call. Not treated as more permanent than a check-in.
The cleanup job (§9) deletes the user_creations row via cascade, but that doesn't delete the file in Supabase Storage — Postgres cascades don't reach object storage. Left open (§14), not solved here.
ux-flow.html itself flags Co-Create as "additive, scope risk — ship in v1, or wait for the core loop to be validated first." Schema and write path exist now per your explicit call to build them, but that doesn't resolve the underlying product question — worth a real decision before this reaches real users, not just an implementation.
Two more Home-screen buttons that, like Creations, sit outside the 12-stage check-in pipeline — but for a different reason: the person is asking for something directly, no Classify/Safety-gate/Orchestrator judgment needed at all. Building these as pipeline stages would model a decision nobody needs to make.
Same content_entries table Retrieval (§4 stage 6) uses, filtered to category = 'grounding-technique' — no vector similarity, no message to embed. Still logs a lightweight user_checkins row (theme='breathing') so the Summarizer's bigger picture includes it, the same reason Creations exists.
Filters audio_tracks (vector-database.md §2) by mood tag, with a sensible default when there's no prior context. No LLM call needed — a filtered lookup against a small curated table, same shape as redirect_templates.
Breathe can be fully built and still have nothing real to serve — knowledge-base/OPEN_QUESTIONS.md already flags that no breathing/relaxation script has been sourced (mhGAP doesn't cover it). Listen has the mirror problem: nobody has sourced or licensed any actual audio tracks yet (vector-database.md §5). Worth stating plainly so "the button works" doesn't get confused with "there's something good behind it." Both are also still the same open README-level scope question as Creations — additive, not confirmed for v1.
Was a build-blocker in pipeline.html — resolved by conversation_turns + last_crisis_card_shown_at above (§2). That doc's Open Items entry now points here.
Needs evaluation against real Hindi/Hinglish audio before locking in — same posture as the embedding provider decision. Don't guess a specific model/API shape yet.
Not validated — see §5.
Named as a real UX question in §5, not decided. Affects how complex the TTS integration needs to be for v1.
Not validated — tune once real conversations show how much history is useful before it's noise.
Written as "yes" (§9) since that read the requirement most naturally — confirm before it ships.
Same open decision as pipeline.html and vector-database.md — this doc assumes periodic as the default, not locked in.
The RRF query in vector-database.md §3 — add it only if pure vector search is observed missing an exact-phrase match. Don't build preemptively.
20/10min per session, 200/10min per IP — tune from real traffic, not assumption.
user_creations.caption — who writes it?The person typing their own short description vs. an LLM call generating one automatically (§12) are genuinely different builds — the second needs a new LLM call in the creation write path.
The 14-day cascade (§12) only reaches Postgres rows, not Supabase Storage objects. Needs a periodic cleanup job or a Storage lifecycle rule, once chosen.
Backend path (§13) can be fully built with nothing real behind it — same gap already logged in knowledge-base/OPEN_QUESTIONS.md, easy to lose track of once the endpoint "works."
Same shape of gap as breathing content (§13, vector-database.md §5) — a content/rights question, not a schema one.
Ship in v1, or wait for the core loop to validate first (§12, §13, docs/ux-flow.html). Building the backend doesn't resolve this — it just means the decision is now the only thing blocking either from shipping.
Langfuse's 30-day retention (§10) means a person's session_id can exist there up to 16 days after its row is deleted from user_sessions. Hash/truncate before it reaches a span, or document as an accepted exception — not decided.
rag-components.html (predates the single-Supabase pivot) proposed a Redis/Upstash cache sized to skip both the embedding call and the pgvector query on a near-duplicate check-in — that combination doesn't actually hold, since detecting a near-duplicate requires embedding the message first. Corrected design if ever built: an exact-text cache (hash the raw message, skips embedding on a literal repeat) and a separate similarity cache (skips only the pgvector query once the embedding exists). Not worth building now — no traffic volume yet, same tradeoff already declined for Celery/Redis. Revisit once real embedding-call volume is visible — embeddings moved to NVIDIA NIM (free tier, rate-limited) rather than a paid Voyage account, so the pressure here is request volume against the 40 req/min ceiling, not per-call cost.
The largest available NIM model was picked on the same "reserve the best model for judgment" principle used for Claude (§1, vector-database.md §1), but that principle doesn't guarantee equivalent quality across providers. Worth a real comparison once stage 12's sampled Evaluator has produced enough scores to judge by.
Flagged in §8 too, restated here since it's a genuine gap: a burst of real traffic could hit NVIDIA's ceiling before the app's own limiter kicks in, surfacing as a generic upstream failure instead of the app's own rate-limit response. Needs either a lower app-side limit or backoff handling on NIM's 429s — not decided yet.