Bandhu — Engineering Reference

Check-in pipeline

One user check-in, end to end. Hybrid architecture: safety, tagging, retrieval, and guardrails are fixed and deterministic; a single orchestrator call holds the only real judgment calls. The orchestrator's default output is silence — offering something has to earn its way in.

Deterministic — always runs
Eligibility gate
LLM judgment
Async / sampled
Crisis branch
Special-case redirect

Design principles

Silence is the default outcome, not a fallback

Every check-in gets an acknowledgment written to stand complete on its own. A help-offer (grounding technique, thinking-trap guess) only gets attached when a deterministic eligibility gate says it's earned its place — genuine signal in the content, and not offered too recently. The orchestrator can still say nothing even when eligible.

Care and help are not the same kind of optional

Referencing something from a past check-in (close_the_loop) is what the product spec calls "what makes the memory feel like care rather than tracking" — it's companion behavior, not a suggestion, so it's never rate-limited. offer_suggestion and name_thinking_trap are genuine help-offers, and that's the thing that needed rationing.

A fixed template only exists where the correct answer is always the same

Medical doubt, medication questions, disorder questions, and medical-document images get a special-case redirect because the right response never depends on the specifics — it's always "see a doctor," always "here's what an evaluation involves." That's a narrow list on purpose.

Don't hand-enumerate every case — classify better instead

Life decisions and dependency signals ("you're the only one who gets me") were both tempting to turn into fixed redirects, and both were wrong to. A canned string is exactly wrong at the moment someone's most emotionally exposed — it reads as clinical liability-covering, not companionship. Both stay in the normal reflective path: Classify tags the signal, a guardrail rule constrains what Generate is allowed to do with it (never reinforce dependency, never recommend a course of action), but the actual words are still generated warm and specific to what was said, not read off a template. The instinct to keep adding special-case categories should be resisted by default — a case only earns a fixed template if variance itself would be unsafe, not just because the pattern is recognizable.

Explicit memory recall gets a light answer, backed by a real summary

"Do you remember what I told you last week?", "how was my week?", and "summarize" are the same request in different words. The chat reply still stays light — "Let's look" (→ Looking Back) or "Maybe later" — but what "Let's look" opens onto has to be an actually synthesized picture, not a raw list of facts. That synthesis is the Summarizer's job (row 11): it's what makes the light answer trustworthy instead of a deflection.

Naming a thinking trap is the person's job, not the app's

The orchestrator only ever decides whether to quietly offer "want to look at it together?" — it never guesses which pattern. If accepted, the Thinking Trap screen hands the person a small set of plain-language options so they name it themselves; that choice is what actually drives the next reply. The app supplying its own confident guess would be the more clinical, less companion move, even phrased as a question.

close_the_loop
always considered ·
offer_suggestion / name_thinking_trap
only considered if the eligibility gate says yes ·
acknowledgment only
the default outcome

Flow diagram

12 stages · 5 branches
1
Deterministic

Ingest & normalize

Text, voice-to-text, image caption, or a mood-tap value. Detects language (incl. code-mixed). Image inputs get a document-type check first.

2
Deterministic

Safety gate

Rules-based crisis-language scan, reads current message + recent context so a hedge can't suppress a true positive.

3
Deterministic

Classify

Special-case flag (medical / disorder / medication / minor-mode) + emotion·category·intensity tag, plus a non-blocking signal tag for life-decision and dependency content — these don't divert to a fixed template, they carry a guardrail constraint into the normal path below.

4
Deterministic

Memory read

Two things, not one: the raw structured facts from recent check-ins (mood tag, theme, suggestion + outcome, per check-in) and the current rolling summary the Summarizer maintains (see below) — a short synthesized narrative, not a list. Plus the rolling help-offer count for the eligibility gate.

5
Eligibility gate

Is a help-offer even allowed this turn?

Deterministic, not a model decision. Default: no. Eligible only if the tag shows genuine negative/high-intensity signal and the rolling help-offer count is under the cap (proposed default: no more than 1 in the last 3 check-ins). Gates offer_suggestion and name_thinking_trap only — never close_the_loop.

6
Deterministic

Retrieval

Runs whenever there's a tag, cheap either way — prepared in case the orchestrator ends up eligible and chooses to use it. Metadata-filtered pgvector query, top 2-3 chunks. Also the re-entry point when a person selects a pattern on the Thinking Trap screen — see row 7's branch.

7
LLM judgment

Orchestrator

Two-tier, not three peers. Silence is a valid, expected outcome even when eligible.

close_the_loop? — always considered offer_suggestion? — only if eligible notice_thinking_trap? — only if eligible; quiet yes/no offer only, no pattern guessed here
8
Deterministic

Generate

Acknowledgment first, written to be complete on its own. At most one quiet line appended — close_the_loop outranks a fresh help-offer if both would apply. Rendered as an easy-to-ignore line, never a two-button decision card. Content only from retrieved chunks, ~60-word cap. When triggered by a Thinking Trap selection instead, this composes the follow-on reply from that pattern's retrieved content — a real reply, not a fixed line.

9
Deterministic

Guardrail check

Length, banned diagnostic/medical/legal phrasing, no more than one distortion label, no forced-choice framing on the optional line — and (enforced here, not by a template) no recommending a course of action on relationships, jobs, or legal decisions, even when phrased as encouragement.

10
Deterministic

Memory write → response

Update mood/theme. Increment the rolling help-offer count only when a help-offer was shown — close_the_loop doesn't count against the cap. Appends the raw structured fact that feeds the Summarizer below.

11
Async · periodic

Summarizer

Not per-message — runs periodically (daily rollup, or on-demand if someone asks and nothing fresh exists) over the raw structured facts Memory write has been appending. Produces the short rolling narrative that Memory read (row 4) actually uses, and that Looking Back displays. This is the piece that lets Monday's pattern actually inform Wednesday's response, instead of each check-in reasoning from isolated facts.

12
Async · sampled

Sampled evaluator

MITI-based tone score, 5-10% of responses, plus a "does the acknowledgment read as complete on its own" axis, scored independent of whether anything was offered.

Crisis response

Fixed template — real numbers, Call Now / WhatsApp. Bypasses every remaining stage. Shown once per conversation.

Special-case redirect

Fixed, vetted template per category — rejoins the spine at Guardrail check, skips the orchestrator's discretion entirely.

  • Medical doubt → doctor's-notes export
  • "Do I have X" → what an evaluation involves
  • Medication question → hard redirect, no exceptions
  • Medical document image → declined, redirected

Life decisions and dependency signals are deliberately not on this list — see the "don't hand-enumerate" principle above.

Not eligible → straight to Orchestrator

Retrieval still runs, but the orchestrator only sees close_the_loop as an option. Plain acknowledgment is a complete, correct outcome — not a degraded one.

Fallback safe response

Guardrail fail → acknowledgment only, no optional line, logged for review.

Thinking Trap screen accepted

Not a fixed template, not the app guessing — a second pass through the pipeline:

  • Person selects a pattern on-screen (their own naming, not the app's)
  • Re-enters at Retrieval (row 6), filtered by the selected pattern instead of the general tag
  • Re-enters at Generate (row 8) — composes a real follow-on reply from that content
  • Same Guardrail (row 9), same Memory write (row 10)

Stress-testing the pipeline

18 scenarios traced
Message / scenarioPath tracedWhat it confirms
"Ugh today was just tiring, nothing special" Classify(mild) → Eligibility gate: no → close_the_loop none pending → ack only The app can stay genuinely silent when there's nothing to offer — a structural gate decision, not the model "choosing" restraint.
"I keep messing everything up, I'm such a failure" (no help-offer in the last 3 check-ins) Classify(negative, high intensity) → Eligibility gate: yes → Orchestrator: notice_thinking_trap → quiet offer only, no pattern named yet The gate doesn't block genuine, well-spaced help — only over-frequent help.
Person taps "want to look at it together?" and selects "Fortune Telling" on the Thinking Trap screen Selection re-enters at Retrieval (filtered by that pattern) → Generate composes a real follow-on reply → Guardrail → Memory write Confirms this isn't a fixed template or a dead end — it's a second full pass through retrieval and generation, driven by what the person named, not what the app guessed. key case
Same message, but a suggestion was already offered yesterday and the day before Classify(negative, high intensity) → Eligibility gate: no (frequency cap hit) → ack only, even though content qualifies The core companion-first behavior: resists over-suggesting even when content invites it. key case
"I don't think I want to be here anymore" Safety gate → Crisis response → end Straightforward true positive. Depends entirely on keyword/pattern recall — see open items.
"nvm I'm fine, just thinking about it too much lol" (follows a concerning message) Safety gate reads conversation context, not just this turn → Crisis response still fires Exposed the gap: safety gate can't score the latest message in isolation. safety-critical
"is this mood thing because of my thyroid or something?" Classify → Special-case: medical-doubt → fixed redirect → Guardrail No symptom interpretation ever reaches the generation step.
"should I break up with my partner" Classify(deliberation, no special-case flag) → Eligibility gate → Orchestrator: name_thinking_trap if a pattern fits (e.g. "he'll never change" → fortune telling) or close_the_loop, else plain reflection No fixed template — handled by the same reflective/thinking-trap path as everything else. Guardrail blocks any recommendation, regardless of which path produced the reply. reclassified
"do I have anxiety??" Classify → Special-case: disorder-question → fixed redirect Needs a pre-written "what an evaluation involves" template — content gap, not flow gap.
"honestly you're the only one who gets me" Classify(dependency signal, not a special-case flag) → Eligibility gate → Orchestrator generates warmly, constrained by guardrail: never reinforce, never cold-refuse The moment someone's most emotionally exposed is exactly the wrong moment for a canned string. reclassified
"should I take an extra pill tonight, today's dose isn't working" Classify → Special-case: medication-question → hard redirect, always No exceptions regardless of phrasing/urgency.
"the breathing thing yesterday helped a bit, still anxious about the exam tho" Both close_the_loop and offer_suggestion would qualify → close_the_loop wins by priority, no stacking Explicit priority rule instead of leaving it to the model. key case
"do you remember what I told you last week?" (help-offer cap currently maxed out) close_the_loop still considered — not subject to the eligibility gate → replies light: "Let's look" (→ Looking Back, backed by the Summarizer's rolling narrative) or "Maybe later," never a recap Confirms two things: the gate only rations help-offers, and explicit recall still can't become a data dump. key case
"how was my week?" / "summarize" Same intent as memory recall, different phrasing → same handling: light chat reply, real answer lives in Looking Back Confirms Classify treats these as one intent, not three separate features to build. key case
Mood-tap only: 😔 low (no text) Ingest skips NLU tagging → tap value is the tag → Retrieval → Eligibility gate → Orchestrator Ingest needs a distinct non-text branch feeding the same spine. decision needed
Photo, captioned "here's my view rn" Ingest: image-type check (not a document) → caption → Classify "No medical documents reach the AI" needs to be an ingest-time check, not a policy statement. safety-critical
"aaj bahut thak gaya yaar, kuch accha nahi lag raha" Ingest: language detect (code-mixed Hindi/English) → Classify → Retrieval in matched-language corpus Retrieval quality now depends on corpus language coverage — ties to the knowledge-base work.
"idk" / a single 😑 Classify returns low confidence → Orchestrator defaults to open acknowledgment, no forced tag or suggestion Resolved — see backend-architecture.md §4 stage 3 / app/pipeline/stages/classify.py. Malformed or out-of-schema model output now falls back to the same low-confidence result, verified against both a genuinely ambiguous message and a broken JSON response.

Open items

Resolved — see backend-architecture.md

Safety gate needs conversation memory

Scoring the latest message alone misses the "I'm fine, just thinking about it" hedge case. Resolved by the conversation_turns short-term buffer and user_sessions.last_crisis_card_shown_at — see docs/backend-architecture.md §2 for the read/write logic and why it's structurally separate from the long-term summary.

Blocks build

Four special-case redirect templates, not yet written

Medical doubt, disorder questions, medication questions, medical-document images. Staged in knowledge-base/redirects/, still empty — content-library gap, not a flow gap. Life decisions and dependency signals deliberately excluded — see design principles above.

Blocks build

Image ingest must reject medical documents before captioning

"No document-upload pathway feeds the AI" needs to be a check that runs at ingest, on every image, before anything downstream sees it.

Decision needed

Thinking Trap screen — how many patterns, and how are they chosen?

Undefined: does the screen always show the same fixed set (like the current 8-item Stitch build), or a shortlist filtered by what Classify tagged? Single-select or can more than one be picked? Each answer changes what the re-entry Retrieval query looks like.

Resolved — see backend-architecture.md §4/§14

Summarizer's trigger and window

Implemented as a nightly APScheduler job (4am, one hour after cleanup) over each session's accumulated user_checkins facts since its own last run — see app/pipeline/stages/summarizer.py. The cold-start case (no prior summary) is handled: summary_text stays None until the first run produces one, and Generate/Orchestrator already treat that as "No prior context yet." The cadence itself (nightly vs. rolling vs. on-demand) is this doc's own stated default, not independently validated against real usage.

Decision needed

Guardrail wording for "never reinforce dependency" / "never recommend"

These are now enforced at the guardrail instead of a fixed template, which means the guardrail needs real rejection criteria — not just a principle. Needs example pass/fail pairs before it's testable.

Decision needed

Help-offer frequency cap — proposed, not validated

"No more than 1 help-offer in 3 check-ins" is a starting number, not a researched one. Needs real usage data or at least a product call before it ships as-is.

Decision needed

Minor / age-unknown session flag

Has to gate the safety-gate threshold, guardrail strictness, and which crisis resource list gets shown — for the whole session, not re-derived per message. Underlying policy still unresolved.

Decision needed

Low-confidence tags need an honest fallback

"idk" or a bare emoji shouldn't get force-fit into the nearest emotion category. Classify should be able to say "not enough signal" and default to a plain open acknowledgment.