InsuranceBot / CLAUDE.md
rohitsar567's picture
docs: Cluster A (count drift) + Cluster B (deleted-module refs) sweep
4c728a9
|
Raw
History Blame Contribute Delete
41.3 kB

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_facts JSONs feed the structured side. The 21 internal Chroma slugs = 20 user-facing insurers + IndusInd-General + 1 regulatory bucket; the regulatory + profile slugs 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: origin is the HF Space at huggingface.co/spaces/rohitsar567/InsuranceBot. github is the mirror at github.com/rohitsar567/insurance-sales-bot. Data lives separately at huggingface.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) β€” useLiveConversation keeps 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 in localStorage.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 userPrefersLive is 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 inside Message (autoplay-on-mount via ref'd useEffect). Never use new Audio(url).play() β€” those detached instances are invisible to document.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) β†’ NIM qwen3-next-80b-a3b-instruct β†’ NIM mistralai/mistral-large-3-675b-instruct-2512 (675B dense) β†’ NIM meta/llama-4-maverick-17b-128e-instruct (128B MoE) β†’ OpenRouter nvidia/nemotron-3-super-120b-a12b:free β†’ OpenRouter qwen/qwen3-next-80b-a3b-instruct:free β†’ NIM nvidia/llama-3.3-nemotron-super-49b-v1.5 (last resort).
    • Brain Main (free-form QA + recommendation synthesis): Google gemini-2.5-flash (PRIMARY) β†’ NIM mistralai/mistral-large-3-675b-instruct-2512 β†’ NIM meta/llama-4-maverick-17b-128e-instruct β†’ NIM qwen/qwen3-next-80b-a3b-instruct β†’ OpenRouter nvidia/nemotron-3-super-120b-a12b:free β†’ NIM nvidia/llama-3.3-nemotron-super-49b-v1.5 (last resort).
    • Judge (faithfulness Gate 4, KI-171 skips on fact_find + recommendation queries): NIM mistralai/mistral-large-3-675b-instruct-2512 (PRIMARY β€” different family from Gemini brain) β†’ NIM meta/llama-4-maverick-17b-128e-instruct β†’ OpenRouter qwen/qwen3-next-80b-a3b-instruct:free β†’ NIM nvidia/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.
  • 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.py scores every candidate on (1 / max(50, latency_ms)) * success_rate and 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 at PROBE_INTERVAL_SEC = 300s. Probe max_tokens cut 5 β†’ 1. Every chat call uses explicit httpx.Timeout(connect=2, read=12, write=2, pool=2) so a stuck NIM pool releases its TCP socket independently of the outer asyncio.wait_for. Rate-limit failures (HTTP 429 / RateLimit body) get a 1h sin-bin (DEGRADE_DURATION_LONG_S = 3600s).
  • Proactive credit gating (KI-085, 8fc7979), now NIM-only scope. Election is gated by is_alive AND has_credits so 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), and OPENROUTER_API_KEY (Tier 2 diversity pool β€” KI-176 server-side models: [...] fallback within the OR free pool). GROQ_API_KEY remains 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-health returns {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_chain retained 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 and chain[1] is the initial backup, both NIM.
  • Brain / fast-brain / judge primaries in steady state are Google gemini-2.5-flash (Brain Main), Google gemini-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 live latency Γ— success_rate Γ— credits_available across 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 on BRAIN_CHAIN (heavy brain, _TIMEOUT_S_ESCALATION = 15s, 35s chain budget). Post-KI-167 (ADR-039) the _canonical_fallback terminal 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-level asyncio.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 from NvidiaNimLLM.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 NIM response_format={"type":"json_object"} for guaranteed structured output, so the whole _parse_ff_block strict β†’ 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 into session.update_profile_field and wiped the captured name mid-session. Orchestrator short-circuits both chains behind intent == "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. Added if new_value in (None, "", []): continue at the top of the extracted-fields loop in backend/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_ALLOWLIST env + _ip_allowed() from backend/admin.py; _check_admin is now password-only against X-Admin-Password β†’ ADMIN_PASSWORD env. 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 logging in orchestrator (KI-101, 66eb4ed). Removed 6 inline import logging lines from backend/orchestrator.py; Python's scoping rule was promoting logging to function-local, causing UnboundLocalError when the asyncio.wait_for TimeoutError branch fired before reaching the inline import. Module-level import is the sole binding now.
  • Profile RAG session isolation (KI-102, 4bb8da0). upsert_profile_chunk stamps session_id metadata on every chunk; retrieve() excludes doc_type == "profile" from the main pass; per-session profile lookup triple-checks meta.session_id == session_id in Python after the Chroma where-clause. Legacy chunks without session_id are silently refused (fail-closed). Cross-session PII leak (age / dependents / health conditions) closed. ADR-022 extended with session-isolation subsection.
  • _canonical_fallback no_trailer loop-breaker (KI-103, 8ef5c43) β€” RETIRED by KI-167 (ADR-039). The _ff_failed_attempts + _ff_skipped_slots state and the entire _canonical_fallback branch 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_preprocess now 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_PHRASES frozenset ("show me the top 3", "rank", "pitch me", "compare X vs Y") classified as recommendation / comparison BEFORE the FACT_FIND_TRIGGERS check, so a fully-fact-found user can never get bounced back into fact-find. Persona prompt gets RECOMMENDATION_CLOSER_ADDENDUM with 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 in asyncio.wait_for(45s) with explicit except asyncio.TimeoutError + broad except Exception. Both return HTTP 200 with source="graceful_timeout" / graceful_exception" and an in-character recovery sentence instead of HTTP 500. Internal logger.exception still captures the full traceback for admin observability.
  • _safe_collection_get helper for Chroma (KI-107, 3a9a14f). Wraps every collection.get(ids=[...]) and collection.get(where=...) call in backend/profile_rag.py in try / except Exception, returns None on miss with logger.warning(...). Closes the KI-102 per-session profile lookup raising on never-existed sessions on HF Space (Chroma version-dependent behaviour). None return is treated identically to a session_id mismatch β€” 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 raising InternalError: Error executing plan: Internal error: Error finding id and 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 a profile_anonymous chunk with NO session_id metadata; that legacy row poisoned every later coll.query(where={"doc_type": {"$ne": "profile"}}) and the damage spread across HNSW segments (full collection extraction surfaced 1580 / 7356 chunks across 148 policies as Error getting embedding). Fix: full re-ingest from rag/corpus/ PDFs β†’ clean rag/vectors/ + two new write-time guards in backend/profile_rag.py::upsert_profile_chunk β€” (a) reject session_id that isn't a non-empty str, (b) reject any embedding whose length β‰  embedder.dimension or that contains None. Both guards log a WARNING and return without writing, so a future model-drift or bad-input event can't re-poison HNSW. 4 new regression tests in tests/test_profile_rag_isolation.py::TestUpsertRejectsBadInputs. Repaired vectors uploaded to HF dataset rohitsar567/insurance-bot-data via tools/upload_vectors_to_dataset.py so the Space rebuild picks up the clean index. Old corrupted Chroma archived at rag/_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_KEY retained 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() calling GoogleGeminiLLM through 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 via backend/nim_fallback.py (~100 LOC) only when GOOGLE_API_KEY is missing or Gemini hard-errors β€” NOT as a per-turn chain. Indic queries route to Sarvam-M via single_brain when _detect_language returns '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_en template.
  • Deterministic post-processor (backend/sales_brain_normalizer.py) takes the LLM's loose captures dict 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 sets complete: true while any required slot is empty, force complete: false.
  • Profile persistence (post-ADR-043, 2026-05-27). Captures flow through session.update_profile_field() into the in-memory SessionState.profile only. The previous backend/profile_store.save_profile() disk write and backend/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::GRAPH slot-id data stays as a schema source for the system prompt, but Question.prompt_en is 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_for ceiling 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_opener branches in backend/orchestrator.py; _ff_failed_attempts / _ff_skipped_slots session fields; every fact_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_find upstream 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. SessionState lives in process memory in session_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, no pending_profile_recall, no apply_pending_recall, no try_recall_by_name, no _extract_*_from_text extractors. Closing the tab or hitting Clear chat discards the profile permanently β€” that's the privacy contract.
  • POST /api/session/clear is the canonical "Clear chat" endpoint. Body {session_id}; reply {cleared, new_session_id}. Wipes the in-memory entry via clear_session() and always mints a fresh UUID. The legacy POST /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_call accepts is_sticky (read once from session.single_brain_sticky in handle_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. Default dependents="self" pre-fill in the builder form no longer registers as "done"; profile_completeness_view + POST /api/profile mask any field not in the asked list 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). Endpoint POST /api/upload-policy returns HTTP 200 within ~1 s after the heuristic baseline is written. LLM extraction runs in a background asyncio.create_task.
  • Heuristic floor is a HARD guarantee, FAT post-KI-332. build_record() runs synchronously inside the upload HTTP call (sub-second), writing UPLOADED_DOCS_DIR/<pid>/record.json with 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 from 47.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 via asyncio.gather. _EXTRACT_SECTIONS partitions HealthPolicy into identity/eligibility/financial/waiting_periods/coverage/limits/network_claims (~6 fields each). Each section uses a filtered _schema_excerpt_for_fields() and max_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).
    1. Hash-cache short-circuit β€” _find_cached_extraction(sha256(pdf_bytes)) returns a prior successful extraction β†’ copy; llm_used='hash-cache'; ~1 s.
    2. Multi-pass per-section β€” fires when len(text) β‰₯ 25_000 chars. _multipass_extract_with_gemini runs 7 sections (_EXTRACT_SECTIONS) in parallel via asyncio.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.
    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'.
    4. NIM fallback β€” single attempt; llm_used='nim-fallback'.
    5. Heuristic floor β€” record.json already exists from step 0; card renders at floor (now ~65–70% post KI-332 expansion); status='failed'.
  • 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 via 40-data/policy_facts/.
  • Status endpoint = scorecard endpoint by construction. _set_extraction_status at extraction-complete time calls backend.main._catalogue_scorecard(pid, None) β€” the SAME resolver /api/policies/{id}/scorecard uses primary. Falls back to build_scorecard(doc, insurer_reviews, profile=None) only when catalogue indices haven't refreshed. DO NOT call build_scorecard(doc, profile=None) without insurer_reviews β€” that was the 2026-05-27 bug (completeness_pct=17.4, grade=None) the cache-fix landed.
  • Attribute trap. The Scorecard dataclass exposes .grade, NOT .overall_grade (only the wire ScorecardResponse model renames it). The status resolver reads _sc.grade β€” .overall_grade will silently return None on the dataclass.
  • _MG_CACHE invalidation BEFORE scorecard resolve. _set_extraction_status bursts backend.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 β€” iterates UPLOADED_DOCS_DIR/*, skips any pid that already has rag/extracted/<pid>.json (unless force=True), runs extraction for the rest. Same logic exposed as admin endpoint POST /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_slug flips from generic 'user-upload' to the real slug β€” the Claim Experience sub-score then reads 40-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 extractionInFlight flag 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 in page.tsx's handleFile push 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.tsx calls setActiveUploadPid(r.policy_id). That pid then flows into every subsequent /api/chat request as view_context.active_policy_id. single_brain.handle_turn reads 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 return False β€” direct QA questions don't need a profile.
  • The empty-profile force-route guard only applies when intent ∈ {"recommendation", "comparison"} (the CONTEXT_DEPENDENT_INTENTS frozenset).
  • FACT_FIND_TRIGGERS matches 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/ ← formerly data/ β€” runtime/cached data. All Python string-path refs updated (KI-050). Dockerfile COPY paths updated (KI-051).
  • 70-docs/ ← formerly docs/ β€” ADRs, design notes, decisions.
  • 80-audit/ ← formerly audit_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-Password header only, post-KI-097). Auth failure returns 401 Unauthorized. POST /api/admin/probe runs a fresh serial probe of every candidate model and updates each ModelHealth.tested_at. GET /api/admin/llm-health is 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 now button + 30 s auto-poll). Auth state preserved across tab switches.
  • LLM Chain timer wiring (KI-296, 2026-05-27). Refresh now click, 30 s auto-poll, tab-entry refreshChain(), and tab re-entry partial refresh now ALL call fetchHealth() alongside fetchLlmHealth() and invoke renderUpdatedLabel(). 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:

  1. In-process tripwire β€” rag/ingest.py::_abort_if_hnsw_bloated aborts ingest if link_lists.bin > 500 MB. Called from rag/ingest.py, tools/ingest_kb_summaries.py, tools/ingest_reviews.py.
  2. Hourly LaunchAgent β€” com.rohit.insurancebot.vectorbloat auto-deletes _hf_dataset_backup/ at > 20 GB; warns at 5 GB.
  3. Disk-free tripwire β€” com.rohit.disk-free-tripwire alerts at < 20 GB free; critical at < 8 GB, dumps every ~/Developer subdir > 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 through NimChainLLM(chain=...) so the call survives single-pool rate limits. (KI-033 migrated the last two stragglers β€” profile_extractor and fact_find_normalizer.)
  • Never let new code add "hi" (or any single-word trigger) to FACT_FIND_TRIGGERS without word-boundary regex β€” substring matching brings back the KI-023 misrouting bug.
  • Never add "qa" to CONTEXT_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.pdf and aditya-birla/activ-one__brochure.pdf (pdfplumber returns 0 chars; OCR is out of scope). Activ One coverage is provided via the activ-health-individual wordings 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-general slug 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 referencing reliance-general should either be retained as a legacy alias (one card remains under reliance-general for back-compat) or migrated to indusind-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 expands k β†’ 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.py is 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.py is deleted along with the <FF> trailer convention, _canonical_fallback, scripted Question.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_brain max_tokens 420 β†’ 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 through NimChainLLM(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.