Bandhu — Engineering Reference

Backend architecture

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.

Supabase (Postgres + pgvector)
NVIDIA NIM (LLM)
STT / TTS
In-process logic
Async / background
Terms: a message is one thing the person sends — text or voice. A turn is one message + Bandhu's one reply — the atomic unit every stage processes. A sitting isn't stored anywhere — it's just turns close together in time (§2 explains how that's detected without tracking session boundaries).

1. Stack

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.

LayerChoiceWhy
Web frameworkFastAPIAsync-native — one process handles many concurrent turns. Pydantic validation catches shape bugs before a stage sees them.
DatabaseSupabase (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 storageSupabase StorageS3-compatible, same free project — holds image creations (§12) and curated Listen audio (§13). Poems are plain text and skip this.
ORM / migrationsSQLAlchemy + AlembicTable defs as Python classes; schema changes become reviewable migrations.
EmbeddingsNVIDIA 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.
GenerationNVIDIA 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-textNot locked TBDNeeds strong Hindi/Hinglish accuracy. Candidates: OpenAI Whisper, Sarvam AI / Bhashini (India-first). See §5.
Text-to-speechNot locked TBDSame language bar, plus voice warmth matters for a companion. Candidates: ElevenLabs, Sarvam AI. See §5.
Rate limitingslowapiIn-memory backend is enough for one instance — see §8.
Scheduled jobsAPScheduler, in-processRuns cleanup + Summarizer with no extra service. See §9.
TelemetryLangfuse Cloud (free Hobby tier) + OpenTelemetryOriginally Phoenix; switched after comparing free-tier options — see §10.

2. Memory — two horizons

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.

Same sitting

Conversation buffer

Table
conversation_turns
Horizon
Last ~12 turns, only if within the last 2 hours
Content
Raw text, verbatim (transcribed, if voice — §5)
Written
Every turn (stage 10)
Read by
Safety gate (2), Orchestrator (7), Generate (8)
Feeds the LLM as
The messages array
Never does
Get summarized, or shown to the person as a recap
Across visits

Rolling summary

Table
user_memory_summary
Horizon
Cross-day / cross-week, synthesized narrative
Content
Synthesized facts, never a direct quote
Written
Periodically, async (stage 11)
Read by
Orchestrator (7), via Memory read (4)
Feeds the LLM as
A block inside the system prompt
Never does
Contain a verbatim quote presented as if just said
Read query — Memory read, stage 4
SELECT 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;
Why the subquery, not a single ORDER BY ... ASC LIMIT 12

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.

Why two, not one

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.

The 2-hour filter replaces session-boundary tracking

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.

Resolves an existing open item

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.

3. Request lifecycle

Steps before the pipeline are synchronous — the person is waiting. Full stage-by-stage detail is §4; voice-specific detail is §5.

1

Session + rate-limit middleware

Issue/validate bandhu_sid. Over quota → 429, stop here.

2

STT, if the message is audio

Transcribe → text + language, then discard the audio. Text messages skip straight past this. See §5.

3

Pipeline orchestrator — 12 stages

Memory read pulls both horizons from §2 before Orchestrator/Generate run. Full breakdown in §4.

4

TTS, if the turn was voice

After Guardrail passes — synthesize response_text. Text turns skip this. See §5.

5

Response

200 + body (text, +audio if voice). Set-Cookie only if a new session was issued.

6

Async tail

Summarizer (periodic) + Sampled evaluator — run after the reply is already delivered.

4. Component logic — one by one

Each stage: the plain-language version first, then what it actually receives, does, and hands forward.

1

Ingest & normalize

now 3 media types
In plain termsThe message comes in — typed, spoken, or a photo. Spoken gets turned into text immediately, and the recording itself is thrown away. A photo gets a quick check for whether it's a medical document before anything else touches it. Typed text just gets a language check.
Input
Raw message — text, image, or audio — + session_id
Logic
Audio → STT first (§5), then treated identically to typed text from here on. Image → classify photo-vs-medical-document before anything else touches it. Text → language detection (incl. code-mixed Hindi/English).
Output
Message{text, language, media_type, input_mode}
2

Safety gate

In plain termsBefore anything else, check the message and the last few things said for any sign the person might be in real danger. If something's there, everything below stops and the crisis response takes over instead — this is the very first thing that runs, not a maybe-later step.
Input
Normalized message + conversation buffer (§2) + last_crisis_card_shown_at
Logic
Pattern-match message and buffer against safety_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.
Output
{triggered, severity} — triggered short-circuits straight to the Crisis branch.
3

Classify

not the buffer — this message only
In plain termsA quick read on the emotional tone of just this message — sad, anxious, stressed. Also catches a few danger zones ("do I have depression," "what medication should I take") and routes those to a pre-written, careful redirect instead of letting anything improvise an answer.
Input
Normalized message only
Logic
a small-tier LLM call → emotion/category/intensity tags, or a special-case flag (medical doubt, disorder, medication question).
Output
tags{} or special_case — special case short-circuits to the fixed redirect branch.
Low-confidence path
Resolves pipeline.html's "needs an explicit low-confidence path" open item. A genuinely ambiguous message ("idk", a bare emoji) or a malformed/out-of-schema model response both resolve to confidence: "low" with every tag null — never force-fit onto the nearest category. See app/pipeline/stages/classify.py.
4

Memory read

In plain termsPull up who we're talking to — the last few things said in this sitting (so the reply doesn't sound like it forgot), and a softened, longer-term impression of how this person's been doing lately.
Input
session_id
Logic
Two reads: user_memory_summary (long-term) and conversation_turns (short-term, §2).
Output
{summary_text, recent_turns[]}
5

Eligibility gate

In plain termsCheck whether a suggestion has already been offered too many times recently, so Bandhu doesn't feel like it's constantly pitching things. Just following up on something already offered doesn't count as a new offer.
Input
session_id
Logic
Count is_help_offer=true over last 3 user_checkins rows — structured events, not raw messages. close_the_loop never counts against this.
Output
eligible_for_offer: bool
6

Retrieval

deliberately not the buffer
In plain termsBased on the emotional tag from step 3, pull a couple of short, pre-approved pieces of content — a grounding technique, a way of reframing a thought — from a small, human-reviewed library. Nothing invented, only retrieved.
Input
Classify's tags + language
Logic
pgvector query — metadata filter then similarity, top 2-3 (vector-database.md §3). Keeps search anchored to what was just said, not drifting with dialogue.
Output
retrieved_chunks[]
Deferred
A Redis/Upstash cache in front of this stage was proposed in an earlier doc (rag-components.html). Not built — see Open Items for the corrected design.
7

Orchestrator (judgment)

In plain termsThe one real decision in the whole flow. Everything gathered so far goes here, and it decides: acknowledge and stop, gently offer something, point out a thinking pattern, or just stay quiet. Quiet is the default — something has to earn its way into the reply.
Input
Message, tags, {summary_text, recent_turns[]}, eligible_for_offer, retrieved_chunks[]
Logic
The largest-tier LLM call — the one real-discretion call. Decides close_the_loop / offer_suggestion / notice_thinking_trap / silence (default). Needs recent_turns specifically to avoid repeating an offer made two messages ago.
Output
directive{tool, target_or_none}
8

Generate

see §6 — always outputs text
In plain termsWrite the actual reply. Doesn't decide anything new — just phrases whatever stage 7 decided into a short, warm sentence or two, using only what it was handed.
Input
directive + its target content + recent_turns + current message
Logic
A small-tier LLM call, phrasing only, ~60-word cap, constrained to what's handed to it. Doesn't think about output modality — voice synthesis happens later, in stage 10.
Output
response_text
9

Guardrail check

In plain termsDouble-check the drafted reply before it goes out — did it accidentally sound like a diagnosis, a recommendation, anything it shouldn't. If it slips, swap it for a safe fallback instead of sending it as-is.
Input
response_text + hard-constraint list
Logic
Rule engine / secondary check for violations.
Output
Pass → send. Fail → fallback safe response.
10

Memory write → response

TTS happens here
In plain termsSave what happened this turn, then send the reply. If the person spoke to Bandhu, this is also where the reply gets turned into audio before it goes back.
Input
Everything produced this turn
Logic
Insert user_checkins row (facts + input_mode); insert 2 conversation_turns rows (message + reply, text only). If voice → TTS on response_text now (§5).
Output
Response sent — text always, +audio if the turn was voice
11

Summarizer

async · periodic
In plain termsEvery so often, not every turn, take stock — look back at recent facts (and anything created, §12) and rewrite the longer-term summary in a few sentences, so a later conversation still feels like it remembers an earlier one, without ever storing or repeating exact quotes.
Input
Accumulated user_checkins facts since last run, plus any user_creations.caption rows in the window (§12)
Logic
A mid-tier LLM call, synthesizes a few-sentence narrative. Does not read conversation_turns, a full poem, or a stored file — facts and short captions only, never raw dialogue or the creative work itself.
Output
Updates user_memory_summary
12

Sampled evaluator

async · 5–10% of turns
In plain termsSpot-check quality — on a small slice of replies, separate from the live conversation, grade the reply against a coaching-conversation rubric, purely so tone can be checked over time. Never affects what the person actually sees.
Input
A sampled turn's response_text + its context
Logic
The largest-tier LLM call, scores against the MITI rubric.
Output
evaluator_scores row

5. Voice input & output

Two 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.

Voice in — before stage 1

Browser records audio (MediaRecorder) → POST /message, audio blob
Duration / size check
Over cap → reject now, before paying for STT at all
STT call (provider TBD, §1) → {text, detected_language}
Discard the audio blob — never written to disk or object storage
Continues as a normal Message{text, language, input_mode:'voice'} into stage 1
The privacy point, stated plainly

A 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.

Voice out — after stage 9, inside stage 10

Guardrail check passes → response_text finalized
input_mode == 'voice'?
No → respond with text only
↓ yes
TTS call (provider TBD, §1) → audio stream/URL
Respond with {text, audio} — text always included, even on a voice turn
Why text is always stored, regardless of modality

response_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.

Latency — named, not solved here

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.

6. The Generate call, concretely

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.

Why the split matters, not just how it's coded

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.

7. Anonymous identity — the bandhu_sid cookie

No 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.

Cookie flags
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
)
Why httponly matters specifically here

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.

Why the server issues it, not the client

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.

8. Rate limiting — layered

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.

Layer 1

Per-session — 20 / 10 min

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.

Layer 2

Per-IP, looser — 200 / 10 min

Backstop against discarding cookies for a fresh session every request.

Layer 3 — voice only

Duration cap, §5

Checked before STT runs at all — rejecting an oversized clip is free; rejecting one that already hit STT costs a paid call.

slowapi
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.

9. Cleanup job — the 2-week expiry

One scheduled job, daily. The entire job is one query, because ON DELETE CASCADE does the rest.

APScheduler, daily 3am
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.

The window resets on activity

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.

Cookie max-age and the job's window must move together

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.

10. Telemetry — Langfuse

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.

Why Langfuse over the originally-planned Phoenix

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.

Setup — OpenAIInstrumentor, since clients/llm.py calls NVIDIA NIM through the openai SDK
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.

Postgres / STT / TTS all need a manual span
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
Retrieval — structured attributes stand in for Phoenix's dedicated panel
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
Content control — what actually leaves the server

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.

Open gap — session_id outlives the 14-day cleanup guarantee

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.

Tracing ≠ evaluation

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.

11. Suggested project layout

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

12. Creations — image and poem

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.

Two Home-screen buttons, one flow

"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.

Person creates something in the app
Poem? → plain text, straight into user_creations.text_content — no storage bucket
Image? → uploaded to Supabase Storage; storage_path stores the path, not the file
Either way: a short caption is written alongside it — a description of the thing, not the thing itself
Why a caption, not the raw content, is what the Summarizer reads

Same 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.

Retention: same 14-day window as everything else

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.

Storage cleanup gap, worth naming

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.

Still an open README-level question

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.

13. Direct-entry features — Breathe and Listen

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.

Breathe

Direct grounding-content query

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.

Listen

Direct audio_tracks query

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.

Both have the same real blocker, just from different angles

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.

Open items

Resolved this pass

Safety gate needed conversation memory

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.

Verify against docs

STT and TTS providers are unresolved

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.

Decision needed

Voice duration cap (60–90s) is a guess

Not validated — see §5.

Decision needed

Voice latency — accept it, or stream TTS?

Named as a real UX question in §5, not decided. Affects how complex the TTS integration needs to be for v1.

Decision needed

Conversation buffer window (2h / 12 rows) is a guess

Not validated — tune once real conversations show how much history is useful before it's noise.

Decision needed

Does the 2-week cleanup window reset on activity?

Written as "yes" (§9) since that read the requirement most naturally — confirm before it ships.

Decision needed

Summarizer: APScheduler or inline on write?

Same open decision as pipeline.html and vector-database.md — this doc assumes periodic as the default, not locked in.

Decision needed

Hybrid search is written but not wired 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.

Decision needed

Rate-limit numbers are starting guesses

20/10min per session, 200/10min per IP — tune from real traffic, not assumption.

Decision needed

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.

Decision needed

Deleting a creation doesn't delete its file

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.

Verify against docs

No breathing content exists yet

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."

Verify against docs

No audio tracks sourced or licensed

Same shape of gap as breathing content (§13, vector-database.md §5) — a content/rights question, not a schema one.

Decision needed

Co-Create and Listen are still an open README-level product decision

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.

Decision needed

session_id in Langfuse outlives the 14-day cleanup guarantee

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.

Deferred, not dropped

Retrieval cache (§4 stage 6)

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.

Verify against docs

NVIDIA NIM's judgment quality on the Orchestrator (stage 7) is unevaluated

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.

Decision needed

NIM's 40 req/min free-tier limit isn't reconciled against slowapi's own limits

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.