Spaces:
Sleeping
CLAUDE.md β project memory for AI assistants
This file is read by Claude Code (and any compatible AI tool) at the start of a session in this repo. Keep it under ~200 lines and focused on stable, non-obvious facts a new contributor would need. For change history, look at git log, 80-audit/ENTERPRISE_AUDIT.md, and 70-docs/60-decisions/.
Project at a glance
- What: a voice-first AI advisor for Indian health insurance β RAG over a curated 206-document corpus (188 product PDFs across 21 insurer slugs + 18 regulatory IRDAI/NHA docs, 7,317 Chroma chunks post-KI-125β127 rebuild β wordings 5,401 Β· brochure 611 Β· regulatory 498 Β· prospectus 483 Β· cis 302 Β· curated 21 Β· profile 1), Sarvam STT/TTS, structural grounding (the single LLM brain quotes only what its tools returned), 21-insurer scorecard (regulatory tracked separately). Marketplace surfaces 148 catalogued cards across the 21 real insurer slugs (one card per IRDAI-filed product after KI-133 / KI-141 / KI-142 / KI-145 dedup, IndusInd-General slug per KI-144); 201 extracted JSONs + 253 curated
policy_factsJSONs feed the structured side. The 21 internal Chroma slugs = 20 user-facing insurers + IndusInd-General + 1regulatorybucket; the regulatory +profileslugs are filtered out of every user-facing count (KI-129 / KI-130 / KI-132). - Live: https://rohitsar567-insurancebot.hf.space (HF Space; rebuild triggered on every push to
origin main). - Repos:
originis the HF Space athuggingface.co/spaces/rohitsar567/InsuranceBot.githubis the mirror atgithub.com/rohitsar567/insurance-sales-bot. Data lives separately athuggingface.co/datasets/rohitsar567/insurance-bot-data(with a GitHub mirror that uses LFS). - Local dev path:
~/Developer/Insurance Sales Bot/(NOT~/Documents/Personal/AI Work/...β the older path that occasionally shows up in stale scripts; iCloud-synced + TCC-restricted).
Voice UX (ADR-028)
One default voice mode, one fallback.
- Live β (default ON) β
useLiveConversationkeeps the mic continuously open with VAD barge-in. The user can speak over the bot and it pauses TTS + aborts in-flight/api/chat. Pill in the toolbar is the toggle: green = on, red = off. State persists inlocalStorage.insurance_live_pref. - π€ Push-to-talk β a labeled button. Click β suspends Live for one turn β fresh recorder with VAD silence-cutoff β submits β resumes Live (only if
userPrefersLiveis still on). - Hands-free was removed entirely in KI-027. Anything in the codebase still referring to it is stale.
- Bot TTS plays via the in-DOM
<audio>element insideMessage(autoplay-on-mount via ref'duseEffect). Never usenew Audio(url).play()β those detached instances are invisible todocument.querySelectorAll("audio").pause()in the barge-in handler.
LLM stack (ADR-019 + ADR-026 β ADR-031 + ADR-032 β ADR-038 β ADR-040) β KI-080 β KI-087, KI-160, KI-167 β KI-179
Every LLM role is a NimChainLLM candidate pool, NOT a hardcoded single model. End-to-end spec: ADR-032. Chains now mix Google AI Studio (Gemini Flash) as Tier 0 primary, NIM as Tier 1 fallback, OpenRouter free as Tier 2 diversity pool (ADR-040 β supersedes ADR-038's NIM-only lock once KI-167 retired the <FF> trailer convention that motivated the lock). Native provider JSON mode (response_format={"type":"json_object"} on NIM, response_mime_type=application/json on Gemini) is the structured-output contract; cross-provider fallback is safe again as long as every candidate supports JSON mode natively. Chains preserve brain β judge family diversity (Gemini brain β Mistral / Llama-4 judge) so failovers can't produce circular grading.
- Three-tier election (ADR-040). Candidate pools per role:
- Brain Fast (sales-brain fact-find per KI-167, fast QA): Google
gemini-2.0-flash(PRIMARY) β NIMqwen3-next-80b-a3b-instructβ NIMmistralai/mistral-large-3-675b-instruct-2512(675B dense) β NIMmeta/llama-4-maverick-17b-128e-instruct(128B MoE) β OpenRouternvidia/nemotron-3-super-120b-a12b:freeβ OpenRouterqwen/qwen3-next-80b-a3b-instruct:freeβ NIMnvidia/llama-3.3-nemotron-super-49b-v1.5(last resort). - Brain Main (free-form QA + recommendation synthesis): Google
gemini-2.5-flash(PRIMARY) β NIMmistralai/mistral-large-3-675b-instruct-2512β NIMmeta/llama-4-maverick-17b-128e-instructβ NIMqwen/qwen3-next-80b-a3b-instructβ OpenRouternvidia/nemotron-3-super-120b-a12b:freeβ NIMnvidia/llama-3.3-nemotron-super-49b-v1.5(last resort). - Judge (faithfulness Gate 4, KI-171 skips on
fact_find+recommendationqueries): NIMmistralai/mistral-large-3-675b-instruct-2512(PRIMARY β different family from Gemini brain) β NIMmeta/llama-4-maverick-17b-128e-instructβ OpenRouterqwen/qwen3-next-80b-a3b-instruct:freeβ NIMnvidia/llama-3.3-nemotron-super-49b-v1.5. Google AI Studio free tier: 1500 req/day, 15 req/min on Gemini 2.0/2.5 Flash. On total chain exhaustion, orchestrator returns a graceful error message to the user β fail-loud > fail-silent-with-garbage still holds at the chain-tail level.
- Brain Fast (sales-brain fact-find per KI-167, fast QA): Google
- Probe-driven sticky primary election (KI-080, ADR-031, superseded by ADR-038 for candidate-pool scope). All three chains elect a sticky PRIMARY + BACKUP from a background probe within the NIM pool.
backend/llm_health.pyscores every candidate on(1 / max(50, latency_ms)) * success_rateand writes the current election to process state.NimChainLLM.chat()calls PRIMARY once; on real-time failure it falls to BACKUP and triggers an immediate probe refresh. Per-turn LLM call count: 1 (most cases) or 2 (PRIMARY fails real-time β BACKUP). - Probe cadence + per-phase timeouts (KI-084,
119e0fd). Probe loop ticks atPROBE_INTERVAL_SEC = 300s. Probemax_tokenscut5 β 1. Every chat call uses explicithttpx.Timeout(connect=2, read=12, write=2, pool=2)so a stuck NIM pool releases its TCP socket independently of the outerasyncio.wait_for. Rate-limit failures (HTTP 429 /RateLimitbody) get a 1h sin-bin (DEGRADE_DURATION_LONG_S = 3600s). - Proactive credit gating (KI-085,
8fc7979), now NIM-only scope. Election is gated byis_alive AND has_creditsso quota-exhausted NIM models are excluded BEFORE the user hits a 429. Signal source within the NIM pool: per-model local 60-second rate-meter (gate at 35-of-40 req/min, headroom 5). Per-model rate-metering applies within the locked NIM pool. - HF Space secrets (KI-081, updated KI-179). The chain config now consults
GOOGLE_API_KEY(Tier 0, Brain Fast + Brain Main primary),NVIDIA_NIM_API_KEY(Tier 1 fallback + Judge primary), andOPENROUTER_API_KEY(Tier 2 diversity pool β KI-176 server-sidemodels: [...]fallback within the OR free pool).GROQ_API_KEYremains in HF Space secrets but is dormant β Groq is no longer in any chain (KI-155<FF>violation root cause remains a guard even though the<FF>convention itself is gone). - Admin telemetry (KI-086,
d90f8c0).GET /api/admin/llm-healthreturns{chains, candidates, recent_turns, snapshot_ts}with per-chain elected primary/backup, per-candidate health + credits + degraded-until, and last 20 turn outcomes. Admin "LLM Chain" tab auto-refreshes every 30s and renders whatever the chain config exposes (NIM-only post-KI-160). - KI-025's 50/50 NIM β Groq rotation (ADR-026) is deprecated β
_balanced_brain_chainretained behind a feature flag for one-release rollback; the probe-driven NIM-only election picks the actually-faster candidate dynamically. - Cold-start fallback. Before the first probe completes (process restart, HF Space rebuild),
chain[0]is the initial primary andchain[1]is the initial backup, both NIM. - Brain / fast-brain / judge primaries in steady state are Google
gemini-2.5-flash(Brain Main), Googlegemini-2.0-flash(Brain Fast / sales-brain fact-find), and NIM Mistral Large 3 675B (Judge), per the ADR-040 chain. Not hardcoded β elected primary follows livelatency Γ success_rate Γ credits_availableacross the full three-tier pool; on Google quota exhaustion or 429, election falls naturally to NIM Mistral 675B (the Tier 1 fallback) without operator intervention. - KI-079 escalation as last bite (
87ee522) β modified by KI-167. If both PRIMARY and BACKUP fail in a single fact-find turn, orchestrator retries once onBRAIN_CHAIN(heavy brain,_TIMEOUT_S_ESCALATION = 15s, 35s chain budget). Post-KI-167 (ADR-039) the_canonical_fallbackterminal step is gone β on heavy-brain exhaustion the orchestrator returns the ADR-038 graceful error message instead. Worst-case wall-clock before graceful error: 25s FAST + 15s heavy = 40s. - NIM concurrency semaphore + serial probe (KI-088,
14ee008). Module-levelasyncio.Semaphore(2)wraps every NIM HTTP call so our process never has >2 NIM requests in flight simultaneously, regardless of source (probe loop + admin polls + per-user turns all serialise through the same semaphore). Probe loop changed parallelβserial so the 6-NIM probe burst becomes a 1-slot trickle over ~12s. Inner 4-attempt exponential-backoff retry deleted fromNvidiaNimLLM.chat()β KI-080 election + KI-079 escalation now handle failover. Result: latency-based failures (41s timeouts under self-saturation) dropped to zero; replaced by a parser-side bottleneck (KI-090). - Lenient FF-block parser (KI-090,
11cf4b3) β RETIRED by KI-167 (ADR-039). Was needed because the<FF>...</FF>trailer convention was fragile under load. Post-KI-167 the sales brain uses NIMresponse_format={"type":"json_object"}for guaranteed structured output, so the whole_parse_ff_blockstrict β fenced β bare-JSON ladder is gone. - Skip
profile_extractor+ faithfulness judge on fact-find turns (KI-091,9813994). Both chains were credit-exhausted on the steady-state primary, hung fact-find turns for 20+s, and the extractor periodically returned{"name": null}which wrote intosession.update_profile_fieldand wiped the captured name mid-session. Orchestrator short-circuits both chains behindintent == "fact_find"; the sales brain (KI-167) captures fields natively from its JSON-mode response body, and faithfulness scoring is meaningless on "what's your annual income?". QA-mode turns still run both chains. Live: name re-ask loop gone; fact-find p95 28s β 6-8s. - Defensive
None-guard in extractor merge (KI-094,f068094). Belt-and-braces companion to KI-091. On QA-mode turns the extractor still runs (correct: "I'm now 35" mid-recommendation should update the profile), but under load it periodically returns{"name": null, "age": null, ...}and the merge loop was writing every key including nulls back into the session. Addedif new_value in (None, "", []): continueat the top of the extracted-fields loop inbackend/orchestrator.pyβ null / empty-string / empty-list returns are now no-ops. Closes the same root cause (LLM-returned nulls wiping state) at a second layer; KI-091 prevents the extractor from running on fact-find turns at all, KI-094 makes it safe even when it does run. - Remove IP allowlist from admin β password-only gate (KI-097, pending). Dropped
ADMIN_IP_ALLOWLISTenv +_ip_allowed()frombackend/admin.py;_check_adminis now password-only againstX-Admin-PasswordβADMIN_PASSWORDenv. Backend returns 401 Unauthorized (previously 404-to-hide). Frontend admin panel is always visible; password unlocks the live data. ADR-023 superseded β IP gating added zero security beyond a strong password and locked the operator out whenever the home IP changed. - Drop function-local
import loggingin orchestrator (KI-101,66eb4ed). Removed 6 inlineimport logginglines frombackend/orchestrator.py; Python's scoping rule was promotingloggingto function-local, causingUnboundLocalErrorwhen theasyncio.wait_forTimeoutErrorbranch fired before reaching the inline import. Module-level import is the sole binding now. - Profile RAG session isolation (KI-102,
4bb8da0).upsert_profile_chunkstampssession_idmetadata on every chunk;retrieve()excludesdoc_type == "profile"from the main pass; per-session profile lookup triple-checksmeta.session_id == session_idin Python after the Chroma where-clause. Legacy chunks withoutsession_idare silently refused (fail-closed). Cross-session PII leak (age / dependents / health conditions) closed. ADR-022 extended with session-isolation subsection. _canonical_fallbackno_trailer loop-breaker (KI-103,8ef5c43) β RETIRED by KI-167 (ADR-039). The_ff_failed_attempts+_ff_skipped_slotsstate and the entire_canonical_fallbackbranch are deleted; the failure mode they capped (silent<FF>trailer parse failures looping on the same slot) is gone because the sales brain uses NIM JSON mode.- CoT / instruction-echo strip in voice_format (KI-104,
407f2a1).tts_preprocessnow kills<think>...</think>blocks,**Reasoning:**/**Thought:**labels,[INTERNAL]blocks, sentence-anchored CoT starters ("Let me think...", "Step 1:..."). Emergency fallback to a generic acknowledger if the whole reply is CoT-shaped. Stops Sarvam from TTS-ing the bot's internal monologue. - Recommendation closer wired (KI-105,
8a58fa1).RECOMMENDATION_CLOSER_PHRASESfrozenset ("show me the top 3", "rank", "pitch me", "compare X vs Y") classified asrecommendation/comparisonBEFORE theFACT_FIND_TRIGGERScheck, so a fully-fact-found user can never get bounced back into fact-find. Persona prompt getsRECOMMENDATION_CLOSER_ADDENDUMwith a strict 3-policy ranked-shortlist contract (3 policies, one-line rationale each, IRDAI disclaimer, no hedging). ADR-008 extended with closer-mode subsection. - Graceful TimeoutError + Exception on
/api/chat(KI-106,565bf31).handle_turn(...)wrapped inasyncio.wait_for(45s)with explicitexcept asyncio.TimeoutError+ broadexcept Exception. Both return HTTP 200 withsource="graceful_timeout"/graceful_exception"and an in-character recovery sentence instead of HTTP 500. Internallogger.exceptionstill captures the full traceback for admin observability. _safe_collection_gethelper for Chroma (KI-107,3a9a14f). Wraps everycollection.get(ids=[...])andcollection.get(where=...)call inbackend/profile_rag.pyintry / except Exception, returnsNoneon miss withlogger.warning(...). Closes the KI-102 per-session profile lookup raising on never-existed sessions on HF Space (Chroma version-dependent behaviour).Nonereturn is treated identically to asession_idmismatch β fail-closed.- Chroma collection re-ingested + profile-write hardening (KI-112). KI-111 wrapped
.query()so the bot survived the corruption, but every embedding query was raisingInternalError: Error executing plan: Internal error: Error finding idand silently returning empty retrieval β the bot was answering 206 policies' worth of Qs without access to any policy chunk. Root cause: a pre-KI-102 deploy wrote aprofile_anonymouschunk with NOsession_idmetadata; that legacy row poisoned every latercoll.query(where={"doc_type": {"$ne": "profile"}})and the damage spread across HNSW segments (full collection extraction surfaced 1580 / 7356 chunks across 148 policies asError getting embedding). Fix: full re-ingest fromrag/corpus/PDFs β cleanrag/vectors/+ two new write-time guards inbackend/profile_rag.py::upsert_profile_chunkβ (a) rejectsession_idthat isn't a non-emptystr, (b) reject any embedding whose length βembedder.dimensionor that containsNone. Both guards log aWARNINGand return without writing, so a future model-drift or bad-input event can't re-poison HNSW. 4 new regression tests intests/test_profile_rag_isolation.py::TestUpsertRejectsBadInputs. Repaired vectors uploaded to HF datasetrohitsar567/insurance-bot-dataviatools/upload_vectors_to_dataset.pyso the Space rebuild picks up the clean index. Old corrupted Chroma archived atrag/_hf_dataset_backup/rag/vectors.corrupted.<ts>/. - Chain budgets: brain 20s Γ 35s total, fast-brain 12s Γ 22s total, judge 30s Γ 75s total. With KI-080 only PRIMARY + BACKUP consume budget in the common case β leaves headroom for KI-079 escalation. KI-084 per-phase httpx timeouts are nested inside these budgets.
- STT/TTS/Translator = Sarvam (Saarika v2.5 / Bulbul v2 / Sarvam-M). Embeddings = local BGE-small-en-v1.5.
- Provider keys (post-KI-179).
GOOGLE_API_KEY(Tier 0 β Gemini Flash primary),NVIDIA_NIM_API_KEY(Tier 1 fallback + Judge primary),OPENROUTER_API_KEY(Tier 2 diversity pool) required in.env(local) and HF Space environment (production).GROQ_API_KEYretained as dormant secret for one-flip re-enable, but no chain references it.
Fact-find loop (ADR-039 + ADR-040, supersedes ADR-030 / ADR-027) β KI-167 / KI-179
One LLM call per turn, structured-output JSON, no scripted prompts, no fallback prose. KI-167 (2026-05-15) ripped out backend/fact_find_brain.py (the <FF>{...}</FF> trailer convention + _canonical_fallback + scripted Question.prompt_en) and replaced it with backend/sales_brain.py β a single LLM-driven sales agent. KI-225 (2026-05-15) then consolidated further: sales_brain.py + qa_brain.py + faithfulness.py + persona.py + translator.py + profile_extractor.py + orchestrator.py were all removed (~5,200 LOC net) and merged into the current backend/single_brain.py β ONE Gemini-with-function-calling call per turn with tools save_profile_field, retrieve_policies, get_policy_facts, mark_recommendation. Faithfulness is now structural (the brain can only quote what its tools returned).
- Single brain call per turn via
single_brain.handle_turn()callingGoogleGeminiLLMthrough the function-calling API. The pre-KI-167 architecture had accreted eight patches (KI-090 / KI-091 / KI-094 / KI-103 / KI-150 / KI-155 / KI-156 / KI-158 / KI-161) working around the fragility of pattern-matching structure out of prose; KI-225 collapsed the whole brain stack onto a single Gemini call with tools. NIM remains as fallback viabackend/nim_fallback.py(~100 LOC) only whenGOOGLE_API_KEYis missing or Gemini hard-errors β NOT as a per-turn chain. Indic queries route to Sarvam-M via single_brain when_detect_languagereturns'indic'. - System prompt carries the 9-slot schema + current profile state. The LLM is free to ask in any order, in any voice, multi-fact in one turn or one slot at a time. No scripted opener, no acknowledger prefix, no
prompt_entemplate. - Deterministic post-processor (
backend/sales_brain_normalizer.py) takes the LLM's loosecapturesdict and emits a{canonical_field: validated_value}map: alias resolution (locationβlocation_tier), enum normalization (Bangaloreβmetro), INR-amount parsing, null/empty drop, type/bounds validation. No LLM calls β pure rules. Override: if LLM setscomplete: truewhile any required slot is empty, forcecomplete: false. - Profile persistence (post-ADR-043, 2026-05-27). Captures flow through
session.update_profile_field()into the in-memorySessionState.profileonly. The previousbackend/profile_store.save_profile()disk write andbackend/profile_rag.upsert_profile_chunk()Chroma write were removed entirely when cross-session recall was retired (ADR-043). Closing the tab discards the profile. - No scripted prompts, no canonical_fallback, no trailer convention.
backend/needs_finder.py::GRAPHslot-id data stays as a schema source for the system prompt, butQuestion.prompt_enis dead text β never consulted by the fact-find branch._canonical_fallback,_normalize_for_slot,_pick_opener,_NEUTRAL_OPENERS,_FAMILY_OPENERS,_contains_self_introduction, the lenient<FF>parser ladder, and the"Got that β {slot}."prefix logic are deleted. - Outer 25s
asyncio.wait_forceiling retained. On total chain exhaustion (all of Google β NIM β OpenRouter fail in a single turn) the orchestrator returns the graceful error to the user (fail-loud) rather than cascading to a scripted reply. There is no scripted safety net β that is intentional. - DELETED in KI-167:
backend/fact_find_brain.py(441 LOC);_canonical_fallback+_pick_openerbranches inbackend/orchestrator.py;_ff_failed_attempts/_ff_skipped_slotssession fields; everyfact_find_brain::fallback:*telemetry variant; the lenient<FF>/ fenced-json/ bare-JSON-tail parser ladder. - Persona-audit fixtures rewritten to assert on final captured state (which slots are filled with what) rather than per-turn slot order β the LLM may capture in different turn order than the pre-KI-167 rules engine did. See WS4.
- Natural-conversation escape (KI-045): intent_change phrases / off-topic questions still exit fact-find by routing through
should_route_to_fact_findupstream of the brain. - Indic queries route through Sarvam-M for translation on input + output; the sales brain runs in English on the translated text.
Session & profile lifecycle (ADR-043 supersedes ADR-041 + ADR-042)
- Sessions are in-memory only. ADR-043 (2026-05-27) removed the cross-session recall layer entirely.
SessionStatelives in process memory insession_state._sessions; idle entries are evicted after_TTL_SECONDS = 60 * 60. There is no on-disk profile store, no name-slug pointer, no persona_id JSON, no profile_rag Chroma chunk, no Welcome-Back prompt, nopending_profile_recall, noapply_pending_recall, notry_recall_by_name, no_extract_*_from_textextractors. Closing the tab or hitting Clear chat discards the profile permanently β that's the privacy contract. POST /api/session/clearis the canonical "Clear chat" endpoint. Body{session_id}; reply{cleared, new_session_id}. Wipes the in-memory entry viaclear_session()and always mints a fresh UUID. The legacyPOST /api/session/reset(KI-020) is left in place for backwards compatibility.- State-recovery from chat_history (Bug #26, in-session only). If a container restart / >1 h idle blanked the server-side session BUT the browser is still on the same tab carrying the chat history, the brain enters STATE-RECOVERY MODE and silently re-captures the already-stated facts from history via
save_profile_field. This is purely in-session resilience β it never reads disk, never crosses sessions. - Sticky-session retry policy (kept from ADR-042).
_gemini_callacceptsis_sticky(read once fromsession.single_brain_stickyinhandle_turn). Non-sticky: 1 retry @ 1.5 s (fast-fail to nim_fallback on cold-start). Sticky: 2 retries with jittered exp backoffs (1.5 s β 3 s, Β±25 %). The user-facing canned reply on exhausted retries: "My model service had a brief blip on that turn β please send the same message again, it should go through now." - Profile completeness gates on
Profile.asked. Defaultdependents="self"pre-fill in the builder form no longer registers as "done";profile_completeness_view+POST /api/profilemask any field not in theaskedlist before scoring. The "Your profile X% done" badge starts at 0% for a brand-new session and only ticks up on explicit captures.
Uploaded-PDF pipeline (ADR-044, 2026-05-27 hardening)
- Owning module:
backend/uploaded_docs.py(~1100 LOC). EndpointPOST /api/upload-policyreturns HTTP 200 within ~1 s after the heuristic baseline is written. LLM extraction runs in a backgroundasyncio.create_task. - Heuristic floor is a HARD guarantee, FAT post-KI-332.
build_record()runs synchronously inside the upload HTTP call (sub-second), writingUPLOADED_DOCS_DIR/<pid>/record.jsonwith cell-shape{value, source_pdf_path, source_quote, _confidence}BEFORE any LLM fires. Post-2026-05-27 expansion covers 28+ fields: sum-insured ladder, policy_type, min/max entry age, child entry days, lifelong renewability, grace period, free-look, geographic coverage, ICU capping, deductible, NCB cap, organ/CI/preventive/domiciliary/newborn presence, premium payment modes. Floor lifted from47.8% to **65-70%** on PDFs containing the relevant text. Test Policy.pdf (8 MB) verified live at grade C / 65.2% comp / 6 rich sub-scores even when all LLM passes fail. - Multi-pass per-section extraction (KI-332). For PDFs with
len(text) β₯ 25_000,_multipass_extract_with_gemini()runs 7 Gemini calls in parallel viaasyncio.gather._EXTRACT_SECTIONSpartitions HealthPolicy into identity/eligibility/financial/waiting_periods/coverage/limits/network_claims (~6 fields each). Each section uses a filtered_schema_excerpt_for_fields()andmax_tokens=4096β half the single-pass budget. Failure-isolated: any single section landing produces a partial dict that's strictly better than the heuristic floor when merged. Identity fields force-filled by the caller. On total multi-pass failure (0/7 sections), falls through to legacy single-pass + NIM chain. extract_one_for_upload()resolution order (DO NOT reorder).- Hash-cache short-circuit β
_find_cached_extraction(sha256(pdf_bytes))returns a prior successful extraction β copy;llm_used='hash-cache'; ~1 s. - Multi-pass per-section β fires when
len(text) β₯ 25_000chars._multipass_extract_with_geminiruns 7 sections (_EXTRACT_SECTIONS) in parallel viaasyncio.gather. Any section landing counts as success (partial HealthPolicy still merges);llm_used='gemini-2.5-flash-multipass'. Total failure β falls through to step 3. - Gemini 2.5-flash single-pass β 3 attempts with jittered exp backoff (
base * (2**i) * random.uniform(0.75, 1.25)for base=2.0);llm_used='gemini-2.5-flash#1|#2|#3'. - NIM fallback β single attempt;
llm_used='nim-fallback'. - Heuristic floor β
record.jsonalready exists from step 0; card renders at floor (now ~65β70% post KI-332 expansion);status='failed'.
- Hash-cache short-circuit β
- Merge model. When the LLM payload lands, scalars are merged INTO the heuristic
record.json(LLM value wins where non-empty, heuristic stays where LLM silent; cell-shape preserved). This is the same "extracted + curated overlay" model the catalogued 148 use via40-data/policy_facts/. - Status endpoint = scorecard endpoint by construction.
_set_extraction_statusat extraction-complete time callsbackend.main._catalogue_scorecard(pid, None)β the SAME resolver/api/policies/{id}/scorecarduses primary. Falls back tobuild_scorecard(doc, insurer_reviews, profile=None)only when catalogue indices haven't refreshed. DO NOT callbuild_scorecard(doc, profile=None)withoutinsurer_reviewsβ that was the 2026-05-27 bug (completeness_pct=17.4,grade=None) the cache-fix landed. - Attribute trap. The
Scorecarddataclass exposes.grade, NOT.overall_grade(only the wireScorecardResponsemodel renames it). The status resolver reads_sc.gradeβ.overall_gradewill silently return None on the dataclass. _MG_CACHEinvalidation BEFORE scorecard resolve._set_extraction_statusburstsbackend.main._MG_CACHE(the marketplace grade cache) BEFORE calling_catalogue_scorecard, so the resolver rebuilds the catalogue indices with the new card. Doing it after = stale cache.- Provenance fields on every status response:
llm_used(gemini-2.5-flash#N | nim-fallback | hash-cache | null) +llm_response_chars(size of raw LLM payload). Operator can verify which LLM landed the extraction without HF Space stdout access. - Backfill on startup.
backfill_extractions()is fired as an asyncio task from a@app.on_event("startup")hook β iteratesUPLOADED_DOCS_DIR/*, skips any pid that already hasrag/extracted/<pid>.json(unlessforce=True), runs extraction for the rest. Same logic exposed as admin endpointPOST /api/admin/upload/reextract?force=<bool>. - Insurer detection.
detect_insurer_slug()scans the first ~6000 chars of PDF text against 21 known insurer name patterns (_INSURER_NAME_PATTERNS). On hit,insurer_slugflips from generic'user-upload'to the real slug β the Claim Experience sub-score then reads40-data/reviews/<slug>.json(real IRDAI claim ratios). Fail-closed: no match β stays'user-upload', no fabricated insurer name. - Locked chat sequence (ADR-044 D4). Frontend's
extractionInFlightflag gates Send + textarea + PDF button + every voice path (PTT/Sarvam/auto-fire) for the entire wait window. Choice prompt NEVER fires before card lands. Both branches of step 6 inpage.tsx'shandleFilepush the choice prompt AFTER the prior card/fail message β DO NOT reorder. - Post-card dive-in mode (KI-330). When the upload card lands,
page.tsxcallssetActiveUploadPid(r.policy_id). That pid then flows into every subsequent/api/chatrequest asview_context.active_policy_id.single_brain.handle_turnreads it and prepends an ACTIVE POLICY DIVE-IN block to the system instruction. DO NOT remove the wire β without it the brain pivots to "let me pull your recommendations" when the user asks waiting-period / room-rent / coverage questions about the just-uploaded PDF. Verified 9/10 grounded on 2026-05-27 audit. - Live verification matrix (2026-05-27, commit
2a58c28): 5 PDFs (manipalcigna, hdfc-ergo, care-health, icici-lombard, star-health) Γ {upload, extraction, scorecard, premium baseline, premium older+PED, personalisation profile, RAG grounded answer} = 35 cells, 33 green (the 2 misses are honest: Test Policy.pdf 3/3 Gemini fails caught by heuristic floor; one "room rent" question on star-health is correctly answered but the keyword detector missed it).
Refusal precision (KI-046)
- Persona prompt now explicitly instructs the bot to refuse on fanciful / out-of-scope scenarios (space tourism, diamond-tipped surgery, fictional procedures) with a specific refusal sentence.
- Anti-pattern guarded against: "policy doesn't explicitly exclude it β maybe it's covered". This is wrong; absence-of-exclusion is not evidence-of-inclusion.
Routing invariants (ADR-N/A β orchestrator.py)
These are pinned by tests/test_routing_regression.py:
classify_intent("What is the waiting period for PED in Activ Assure?")MUST return"qa", never"fact_find".should_route_to_fact_find("qa", profile_is_empty=True, ...)MUST returnFalseβ direct QA questions don't need a profile.- The empty-profile force-route guard only applies when
intent β {"recommendation", "comparison"}(theCONTEXT_DEPENDENT_INTENTSfrozenset). FACT_FIND_TRIGGERSmatches with word-boundary regex (\b...\b), NOT substring β"hi"no longer fires on"which"/"this"/"high".
Retrieval cache (ADR not yet written β code self-documents)
rag/retrieve.py has an in-process LRU cache keyed by (query_normalized, top_k, sorted policy_ids, sorted insurer_slugs). Cap 256. Cache hit skips both Voyage embed + Chroma query. Invalidates on process restart.
Top-k boost for table-cell questions (KI-049): room rent / sub-limit / cap on / single-private / NCB / co-pay / day-care-limit / etc. triggers bump top_k from 5 β 10 for that one query, so the policy's structured cap-table chunk has a higher chance of landing in context. Confined to the trigger query only β does not pollute the cache for downstream non-table queries.
Repo bucket layout (KI-047 / KI-050 / KI-051)
Numbered top-level buckets for non-code artifacts (sort lexicographically in ls):
40-data/β formerlydata/β runtime/cached data. All Python string-path refs updated (KI-050). DockerfileCOPYpaths updated (KI-051).70-docs/β formerlydocs/β ADRs, design notes, decisions.80-audit/β formerlyaudit_results/β defect register + eval artifacts (this audit lives here).
Code dirs (backend/, frontend/, rag/, tools/, eval/, tests/, kb/) kept as-is β Python forbids leading-digit / hyphen package names, so renaming code dirs would break imports.
Admin panel (KI-048 / KI-052, refresh wiring fixed in KI-296 / ADR-042)
- Backend:
GET /api/admin/profiles+GET /api/admin/performance, both behind_check_admin(X-Admin-Passwordheader only, post-KI-097). Auth failure returns 401 Unauthorized.POST /api/admin/proberuns a fresh serial probe of every candidate model and updates eachModelHealth.tested_at.GET /api/admin/llm-healthis read-only (KI-088) β never triggers probes. - Frontend: admin HTML has 3 lazy-loaded tabs β Profile + Visitor Log (pulls
/api/admin/profiles), Performance (pulls/api/admin/performance), LLM Chain (Refresh nowbutton + 30 s auto-poll). Auth state preserved across tab switches. - LLM Chain timer wiring (KI-296, 2026-05-27).
Refresh nowclick, 30 s auto-poll, tab-entryrefreshChain(), and tab re-entry partial refresh now ALL callfetchHealth()alongsidefetchLlmHealth()and invokerenderUpdatedLabel(). Without this,STATE.health.updated_at(the source for the top-left "Last refresh / Next in" timer) was frozen at the login-time snapshot, so the operator could not tell from the timer whether probing was actually happening on click. The bottom-right FRESH badge reads from a different source (STATE.llmHealth.snapshot_ts) and was already reset correctly β keep both in lockstep.
Disk + storage hardening (ADR-029)
Three independent safety layers against ChromaDB HNSW bloat:
- In-process tripwire β
rag/ingest.py::_abort_if_hnsw_bloatedaborts ingest iflink_lists.bin > 500 MB. Called fromrag/ingest.py,tools/ingest_kb_summaries.py,tools/ingest_reviews.py. - Hourly LaunchAgent β
com.rohit.insurancebot.vectorbloatauto-deletes_hf_dataset_backup/at > 20 GB; warns at 5 GB. - Disk-free tripwire β
com.rohit.disk-free-tripwirealerts at < 20 GB free; critical at < 8 GB, dumps every~/Developersubdir > 1 GB into the log.
All LaunchAgents must live under ~/Library/Scripts/, NOT ~/Documents/. macOS TCC blocks launchd from executing scripts inside iCloud-synced ~/Documents/ paths, silently exit-126.
What to read for what
- System tour:
README.md(the master entry). - Decisions with alternatives:
70-docs/60-decisions/ADR-*.md(28 ADRs as of 2026-05-15). - Production-readiness defect register:
80-audit/ENTERPRISE_AUDIT.md. - Data lineage:
kb/AUDIT_TRAIL.md. - Tests:
tests/test_routing_regression.py(15 tests pinning routing + load-balance invariants).
Working-style note (personal memory, not a project decision)
Always parallelize independent work (per feedback_always_parallelize.md in personal memory). On any task touching this project: dispatch agents in parallel when subtasks are independent, batch tool calls in a single message when there are no dependencies. Sequential-by-default wastes wall-clock time.
Watch-outs
- Never use detached
new Audio()β see "Voice UX" above. - Never hardcode a single LLM model client (
NvidiaNimLLM(model=...)) β always go throughNimChainLLM(chain=...)so the call survives single-pool rate limits. (KI-033 migrated the last two stragglers βprofile_extractorandfact_find_normalizer.) - Never let new code add
"hi"(or any single-word trigger) toFACT_FIND_TRIGGERSwithout word-boundary regex β substring matching brings back the KI-023 misrouting bug. - Never add
"qa"toCONTEXT_DEPENDENT_INTENTSβ that brings back the headline KI-018 bug where QA questions get trapped in fact-find. - Voyage free tier is 3 RPM. Affects only ingest (corpus rebuild); query-time uses Chroma vectors, no Voyage call. Don't worry about it on the hot path.
- HF Space rebuild is 5-8 min per push. Audits running against the live endpoint should be done AFTER the desired image is stably deployed, or the persona transcripts span multiple builds and become useless for A/B.
- Two image-only PDFs are explicitly EXCLUDED from the ingest pipeline:
royal-sundaram/family-plus__brochure.pdfandaditya-birla/activ-one__brochure.pdf(pdfplumber returns 0 chars; OCR is out of scope). Activ One coverage is provided via theactiv-health-individualwordings policy β do not re-add either brochure. KI-126 made this permanent β they are now removed from the source PDF set and the source-PDF total is 206 (188 product + 18 regulatory), with 201 extracted JSONs (the gap is the 2 image-only brochures + 3 documents whose extraction failed gracefully). - The
indusind-generalslug did not exist anywhere in the codebase before 2026-05-15. Reliance General Insurance was rebranded to IndusInd General; KI-144 migrated insurer slug + policy IDs + Chroma metadata + marketplace alias mapping. Any code referencingreliance-generalshould either be retained as a legacy alias (one card remains underreliance-generalfor back-compat) or migrated toindusind-general. Do not silently merge the two β they're tracked as separate slugs. - Voice mode now defaults OFF (KI-131 / KI-134 / KI-139 / KI-148). The Live pill renders red by default; the user must opt in. AudioContext.resume() is required to unlock TTS autoplay. VAD thresholds:
rmsThreshold=18,voiceBandMinProp=0.20,noiseFloor * 1.8. TTS preprocess now expandsk β thousand. Anything in the codebase still assuming default-ON Live mode is stale. - Marketplace dedup is one card per IRDAI-filed product (KI-133 / KI-141 / KI-142 / KI-145). Aliases handle marketing renames (e.g. Reliance β IndusInd); sub-variants stay separate only when material terms differ. Card count is 166 across 19 real insurers β anything counting 138 or 188 or 206 against the marketplace is stale.
sales_brain.pyis the fact-find handler (KI-167, ADR-039; primary candidate is Google Gemini 2.0 Flash post-KI-179 / ADR-040).backend/fact_find_brain.pyis deleted along with the<FF>trailer convention,_canonical_fallback, scriptedQuestion.prompt_en, and the"Got that β {slot}."prefix. Never re-introduce a fallback to scripted prompts on the fact-find branch β on total three-tier chain exhaustion the orchestrator returns a graceful error message (fail-loud > fail-silent-with-script). The KI-150 historical note (fact_find_brainmax_tokens420 β 700) is moot post-KI-167 since the prose+trailer prompt shape that needed the larger budget no longer exists.- Never hardcode
NvidiaNimLLM(model=...)post-KI-179 β chains now mix Google (backend/providers/google_gemini_llm.py), NIM (backend/providers/nvidia_nim_llm.py), and OpenRouter (backend/providers/openrouter_llm.py) candidates. Always go throughNimChainLLM(chain=...)so the call surfs the full three-tier failover; bypassing the chain reintroduces the single-provider single-point-of-failure that KI-080 / KI-160 / KI-179 collectively eliminated.
Last reviewed 2026-05-15 β KI-101..KI-112 landed (orchestrator stability + profile-RAG session isolation + recommendation closer + graceful chat error handling + Chroma re-ingest + profile-write hardening). Same day: KI-125..KI-150 landed (full corpus rebuild β 7,317 chunks; marketplace dedup β 166 cards; voice default OFF + VAD retune; IndusInd General slug migration from Reliance General). Same day: KI-160 / ADR-038 locked chains to NIM-only after KI-155's <FF> trailer contract violation. Same day: KI-167 / ADR-039 replaced fact_find_brain.py with backend/sales_brain.py (single LLM call per turn, native JSON mode, deterministic post-processor). Same day: KI-171 (judge skip on fact_find + recommendation), KI-175 (NIM chain reorder β nemotron demoted to last), KI-176 (OpenRouter models: [...] server-side fallback), KI-178 (live audit of OR free-tier JSON-mode support), KI-179 / ADR-040 added Google AI Studio (Gemini 2.0 / 2.5 Flash) as the Tier 0 primary on Brain Fast + Brain Main, with NIM as Tier 1 fallback and OpenRouter as Tier 2 diversity pool. ADR-038 is now superseded β the NIM-only lock was relaxed once the <FF> trailer convention that motivated it was retired.