rohitsar567 commited on
Commit
1c5baae
Β·
1 Parent(s): 38c937d

fix(voice+chat+llm+docs): KI-155 + KI-156 + KI-157 + KI-158 + KI-159 + KI-160 bundle

Browse files

Six conversation-pipeline + LLM-chain fixes addressing every live-test
defect surfaced today, plus the documentation cascade.

KI-155 β€” enforce <FF> trailer emission. Strengthened fact_find_brain
system prompt with absolute final-instruction block + edge-case few-shots
('Hi' β†’ still emit trailer, 'ok' β†’ still emit trailer) so Groq Llama-3.3
(was elected as cross-provider fallback, was silently dropping the
trailer) now complies. Smoke: My name is Rohit / I am 29 years old /
to buy a health insurance policy β€” all three return fact_find_brain::
continue, no more ::fallback:no_trailer.

KI-156 β€” fix bot non-response + 'not having any policy' uncaptured.
Three sub-fixes:
Fix A: extend negation regex in fact_find_normalizer._parse_existing_cover
to cover 'not having', 'not got', 'not have', 'without (any|a)'.
Fix B: stop-list in fact_find_brain._validate_capture for name field β€”
reject hallucinated names that start with 'currently', 'not', 'no',
'looking', etc. (live bug: LLM emitted captured={'name':'Currently
Not Having Any'} from a status answer).
Fix C: when canonical fallback fires WITH greedy-captured data, prepend
'Got that β€” <pretty-named-captures>.' so user sees acknowledgement
instead of bare scripted prompt that looks unresponsive.

KI-157 β€” fix mic barge-in during long TTS. Three-part fix in
useLiveConversation.ts: MutationObserver tracks every <audio> element,
freezes noiseFloor EMA during TTS playback (was getting poisoned by bot
voice bleed-through β†’ adaptive threshold rose to bot-voice level), drops
startFrames 5β†’2 (~30ms) during TTS for snappy barge-in, bypasses
postUtteranceCooldownMs during TTS. User can now interrupt the bot
within ~30ms.

KI-158 β€” fix 'Let me know a bit about yourself' fallback firing
mid-session. Three-bug chain in fact_find_brain._canonical_fallback:
(a) health_conditions was missing from _GREEDY_ORDER β€” fallback never
attempted greedy capture for that slot.
(b) keyword_normalize for health_conditions only matched literal
substrings; 'not having any pre-existing condition' returned None.
(c) gentle hand-off discarded captured_updates AND set
fact_find_complete=False, leaving sessions stuck.
Fix: add health_conditions to _GREEDY_ORDER, expand the keyword regex
to 7 patterns covering 'not having any', 'don't have any', 'no
pre-existing condition', 'I'm healthy', 'clean bill of health', and
make the gentle hand-off propagate captures + flip complete=True so
the session transitions cleanly to free-form.

KI-159 β€” VAD noise resilience. Early-close trigger in tickVAD: when
β‰₯1.2s of speech is captured AND β‰₯1.5s of stable silence, close the
segment immediately. A notification ding firing AFTER that early close
is irrelevant β€” segment already submitted. User's real speech can no
longer be lost to mid-pause noise.

KI-160 β€” lock LLM chains to NIM-only. Removed 16 non-NIM and
unreliable candidates across BRAIN_CHAIN / FAST_BRAIN_CHAIN /
JUDGE_CHAIN (Groq Llama-3.3 was the KI-155 root cause; OpenRouter +
broken NIM models also pruned). Hard guard in _get_worker_for raises
RuntimeError on any groq:/openrouter:/or: prefix. All 3 chains now
have β‰₯3 NIM candidates. Verified 5 chat calls return NIM-only models,
zero non-NIM in 22+ post-change usage rows.

Doc cascade β€” CLAUDE.md + README.md LLM-stack sections rewritten for
NIM-only election. ADR-031 + ADR-032 marked superseded. New
ADR-033..ADR-038 written for the marketplace dedup rule, curated-facts
ingest, slug filtering, VAD tuning, IndusInd migration, and NIM-only
chains. kb/INDEX.md + rag/README.md + rag/SCHEMA.md +
40-data/information_source_map.md synced with the renamed bajaj-silver
slug + indusind-general migration.

40-data/information_source_map.md CHANGED
@@ -1,6 +1,6 @@
1
  # Insurance Sales Bot β€” Information Source Map
2
 
3
- Generated: 2026-05-14 05:57:43 IST
4
  Total claims audited: **2772**
5
 
6
  ## Verdict Summary
@@ -69,7 +69,7 @@ Audited 2504 claims β€” βœ… 798 verified, ⚠️ 321 quote-missing, ❌ 0 broken
69
  | `bajaj-allianz__extra-care-plus` | `policy_type` | ⚠️ url-ok-quote-missing | `rag/corpus/bajaj-allianz/extra-care-plus__wordings.pdf` | PDF exists but source_quote not found in extracted text |
70
  | `bajaj-allianz__global-health-care` | `copayment_pct` | ⚠️ url-ok-quote-missing | `rag/corpus/bajaj-allianz/global-health-care__wordings.pdf` | PDF exists but source_quote not found in extracted text |
71
  | `bajaj-allianz__global-health-care` | `policy_type` | ⚠️ url-ok-quote-missing | `rag/corpus/bajaj-allianz/global-health-care__wordings.pdf` | PDF exists but source_quote not found in extracted text |
72
- | `bajaj-allianz__group-health-guard-gold__wordings` | `policy_type` | ⚠️ url-ok-quote-missing | `rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf` | PDF exists but source_quote not found in extracted text |
73
  | `bajaj-allianz__group-personal-accident__wordings` | `policy_type` | ⚠️ url-ok-quote-missing | `rag/corpus/bajaj-allianz/group-personal-accident__wordings.pdf` | PDF exists but source_quote not found in extracted text |
74
  | `bajaj-allianz__health-guard-gold-individual__wordings` | `policy_type` | ⚠️ url-ok-quote-missing | `rag/corpus/bajaj-allianz/health-guard-gold-individual__wordings.pdf` | PDF exists but source_quote not found in extracted text |
75
  | `bajaj-allianz__health-guard-gold` | `max_entry_age` | ⚠️ url-ok-quote-missing | `rag/corpus/bajaj-allianz/health-guard-gold-individual__wordings.pdf` | PDF exists but source_quote not found in extracted text |
@@ -537,7 +537,7 @@ _None._
537
  | bajaj-allianz__criti-care__wordings | 1 | 1 | 0 |
538
  | bajaj-allianz__extra-care-plus | 12 | 6 | 0 |
539
  | bajaj-allianz__global-health-care | 11 | 2 | 0 |
540
- | bajaj-allianz__group-health-guard-gold__wordings | 6 | 1 | 0 |
541
  | bajaj-allianz__group-personal-accident__wordings | 0 | 1 | 0 |
542
  | bajaj-allianz__health-guard | 14 | 2 | 0 |
543
  | bajaj-allianz__health-guard-gold | 10 | 9 | 0 |
 
1
  # Insurance Sales Bot β€” Information Source Map
2
 
3
+ Generated: 2026-05-15 (KI-125–KI-150 sync; slug renames applied)
4
  Total claims audited: **2772**
5
 
6
  ## Verdict Summary
 
69
  | `bajaj-allianz__extra-care-plus` | `policy_type` | ⚠️ url-ok-quote-missing | `rag/corpus/bajaj-allianz/extra-care-plus__wordings.pdf` | PDF exists but source_quote not found in extracted text |
70
  | `bajaj-allianz__global-health-care` | `copayment_pct` | ⚠️ url-ok-quote-missing | `rag/corpus/bajaj-allianz/global-health-care__wordings.pdf` | PDF exists but source_quote not found in extracted text |
71
  | `bajaj-allianz__global-health-care` | `policy_type` | ⚠️ url-ok-quote-missing | `rag/corpus/bajaj-allianz/global-health-care__wordings.pdf` | PDF exists but source_quote not found in extracted text |
72
+ | `bajaj-allianz__group-health-guard-silver__wordings` | `policy_type` | ⚠️ url-ok-quote-missing | `rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf` | PDF exists but source_quote not found in extracted text |
73
  | `bajaj-allianz__group-personal-accident__wordings` | `policy_type` | ⚠️ url-ok-quote-missing | `rag/corpus/bajaj-allianz/group-personal-accident__wordings.pdf` | PDF exists but source_quote not found in extracted text |
74
  | `bajaj-allianz__health-guard-gold-individual__wordings` | `policy_type` | ⚠️ url-ok-quote-missing | `rag/corpus/bajaj-allianz/health-guard-gold-individual__wordings.pdf` | PDF exists but source_quote not found in extracted text |
75
  | `bajaj-allianz__health-guard-gold` | `max_entry_age` | ⚠️ url-ok-quote-missing | `rag/corpus/bajaj-allianz/health-guard-gold-individual__wordings.pdf` | PDF exists but source_quote not found in extracted text |
 
537
  | bajaj-allianz__criti-care__wordings | 1 | 1 | 0 |
538
  | bajaj-allianz__extra-care-plus | 12 | 6 | 0 |
539
  | bajaj-allianz__global-health-care | 11 | 2 | 0 |
540
+ | bajaj-allianz__group-health-guard-silver__wordings | 6 | 1 | 0 |
541
  | bajaj-allianz__group-personal-accident__wordings | 0 | 1 | 0 |
542
  | bajaj-allianz__health-guard | 14 | 2 | 0 |
543
  | bajaj-allianz__health-guard-gold | 10 | 9 | 0 |
70-docs/60-decisions/ADR-031-sticky-primary-election.md CHANGED
@@ -1,6 +1,8 @@
1
  # ADR-031 β€” Sticky primary election for LLM chains
2
 
3
- **Status:** Accepted β€” 2026-05-15
 
 
4
  **Owner:** Rohit Saraf
5
  **Supersedes:** None (extends [ADR-019](ADR-019-nim-single-provider-consolidation.md) + [ADR-026](ADR-026-provider-load-balancing.md))
6
  **Deprecates:** [ADR-026](ADR-026-provider-load-balancing.md)'s `_balanced_brain_chain` 50/50 NIM ↔ Groq rotation (kept as a feature-flagged bypass branch for one-release rollback; default-off).
 
1
  # ADR-031 β€” Sticky primary election for LLM chains
2
 
3
+ **Status:** Superseded by [ADR-038](ADR-038-nim-only-chains.md) β€” 2026-05-15 (KI-160). Cross-provider election candidates (Groq, OpenRouter) removed from all three chains after KI-155 demonstrated Groq Llama-3.3 silently ignores the `<FF>` trailer contract. Sticky-primary election mechanics still apply, but only within the NIM candidate pool. Body retained below for history.
4
+
5
+ **Status (original):** Accepted β€” 2026-05-15
6
  **Owner:** Rohit Saraf
7
  **Supersedes:** None (extends [ADR-019](ADR-019-nim-single-provider-consolidation.md) + [ADR-026](ADR-026-provider-load-balancing.md))
8
  **Deprecates:** [ADR-026](ADR-026-provider-load-balancing.md)'s `_balanced_brain_chain` 50/50 NIM ↔ Groq rotation (kept as a feature-flagged bypass branch for one-release rollback; default-off).
70-docs/60-decisions/ADR-032-llm-chain-architecture.md CHANGED
@@ -1,6 +1,14 @@
1
  # ADR-032 β€” LLM Chain Architecture Reference
2
 
3
- **Status:** Accepted β€” 2026-05-15
 
 
 
 
 
 
 
 
4
  **Type:** Architecture reference (not a decision ADR)
5
  **Owner:** Rohit Saraf
6
  **Consolidates:** [ADR-019](ADR-019-nim-single-provider-consolidation.md), [ADR-026](ADR-026-provider-load-balancing.md) (superseded), [ADR-030](ADR-030-llm-driven-fact-find.md), [ADR-031](ADR-031-sticky-primary-election.md)
 
1
  # ADR-032 β€” LLM Chain Architecture Reference
2
 
3
+ **Status:** Superseded by [ADR-038](ADR-038-nim-only-chains.md) β€” 2026-05-15 (KI-160). All three chains locked to NIM candidates only after KI-155. Current candidate lists:
4
+
5
+ - **BRAIN:** `nvidia/llama-3.3-nemotron-super-49b-v1.5` (primary), `qwen/qwen3-next-80b-a3b-instruct` (backup), `mistralai/mistral-large-3-675b-instruct-2512` (3rd)
6
+ - **FAST_BRAIN:** `qwen/qwen3-next-80b-a3b-instruct` (primary), `nvidia/llama-3.3-nemotron-super-49b-v1.5` (backup)
7
+ - **JUDGE:** `meta/llama-4-maverick-17b-128e-instruct` (primary), `mistralai/mistral-large-3-675b-instruct-2512` (backup)
8
+
9
+ Body below retained as architectural reference for probe / election / timeout machinery β€” all still in effect within the NIM-only scope.
10
+
11
+ **Status (original):** Accepted β€” 2026-05-15
12
  **Type:** Architecture reference (not a decision ADR)
13
  **Owner:** Rohit Saraf
14
  **Consolidates:** [ADR-019](ADR-019-nim-single-provider-consolidation.md), [ADR-026](ADR-026-provider-load-balancing.md) (superseded), [ADR-030](ADR-030-llm-driven-fact-find.md), [ADR-031](ADR-031-sticky-primary-election.md)
70-docs/60-decisions/ADR-033-marketplace-dedup-uin-rule.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR-033 β€” Marketplace dedup: one IRDAI UIN = one card
2
+
3
+ **Status:** Accepted β€” 2026-05-15
4
+ **Owner:** Rohit Saraf
5
+ **Related KIs:** KI-141, KI-142, KI-143, KI-145
6
+
7
+ ## Context
8
+
9
+ The marketplace aggregation surfaces (`/api/coverage`, `/api/policies/all`) were emitting duplicate cards for the same regulatory product. Two pathological patterns:
10
+
11
+ 1. **Pure renames** β€” an insurer rebrands a filed product (e.g. issuer change, marketing name refresh) without re-filing with IRDAI. Two extracted JSONs end up with the same UIN but slightly different `policy_name` strings, producing two near-identical cards.
12
+ 2. **Sub-variants** β€” an insurer files a single UIN that covers multiple plan tiers (Silver / Gold / Platinum, or per-age-band variants). Each tier has materially different decision-critical terms (room rent cap, sub-limits, co-pay, NCB ladder) but shares the regulatory identity.
13
+
14
+ Without a dedup rule, the user saw card-fatigue and lost trust in the marketplace as a comparison surface. Worse, "Also marketed as" relationships were invisible β€” the user had no way to know that "Insurer A's Optima Restore" and "Insurer A's Optima Restore Plus" were the same regulatory product with a tier choice.
15
+
16
+ ## Decision
17
+
18
+ **The IRDAI UIN is the canonical product identity.** One UIN β‡’ one marketplace card. Variants are resolved by comparing decision-critical terms:
19
+
20
+ - **Same UIN + same key terms** (room rent cap, co-pay, NCB ladder, sub-limit list, network size band) β‡’ pure rename. Emit ONE card; old names go into an `aliases: ["Also marketed as: …"]` field on the card.
21
+ - **Same UIN + β‰₯2 different decision-critical terms** β‡’ sub-variant. Emit a separate card per variant, but flag the shared UIN in metadata so the comparison view can group them visually ("3 variants of UIN ABC-HLT-...").
22
+
23
+ The dedup runs at aggregation time in the `/api/coverage` and `/api/policies/all` handlers, not at ingest. Ingest stays write-only β€” the same product can be ingested from multiple sources (brochure PDF, insurer website, IRDAI filing) and the aggregation layer reconciles.
24
+
25
+ ## Consequences
26
+
27
+ | Win | Cost |
28
+ |---|---|
29
+ | Marketplace card count drops ~12% (renames collapsed) with zero info loss β€” old names surface in alias text | The "key terms" comparison list is hand-curated; adding a new decision-critical term requires touching the dedup rule |
30
+ | Sub-variants stay visible and comparable; users see "3 variants of the same regulatory product" instead of either 1 collapsed card (info loss) or 3 unrelated cards (confusion) | Aggregation-time dedup adds ~30ms per `/api/coverage` call; cached after first computation |
31
+ | IRDAI's regulatory filing becomes the authoritative product identity β€” matches how regulators think about products, decouples from insurer marketing churn | Insurers occasionally re-file under a new UIN for a minor change; those land as separate cards by design |
32
+
33
+ ## Related
34
+
35
+ - KI-145 β€” field-comparison refinement that pinned the rename-vs-sub-variant boundary
36
+ - KI-141 / KI-142 / KI-143 β€” earlier dedup attempts (name-based, embedding-based) that failed because they couldn't distinguish rename from sub-variant
70-docs/60-decisions/ADR-034-curated-facts-as-chroma-chunks.md ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR-034 β€” Curated facts as Chroma chunks (`doc_type='curated'`)
2
+
3
+ **Status:** Accepted β€” 2026-05-15
4
+ **Owner:** Rohit Saraf
5
+ **Related KIs:** KI-137
6
+
7
+ ## Context
8
+
9
+ 21 policies in the corpus had **no extracted JSON** (the standard structured-extraction pipeline failed on the source PDF β€” typically image-only filings, OCR failures, or policy wordings the LLM extractor couldn't slot-fill above the confidence floor) but DID have **hand-curated YAML fact sheets** in `40-data/curated/` written during the early corpus build.
10
+
11
+ These curated facts were rendered into the marketplace via a separate read path, so they showed up in `/api/policies/all` cards. But the chat brain could not retrieve them. When a user asked a question that should have hit one of these 21 policies, the retrieval layer returned nothing (Chroma had no chunks for those products) and the brain either declined to answer or β€” worse β€” answered from a near-miss policy whose embeddings happened to be close.
12
+
13
+ The asymmetry was: marketplace surfaces could see curated data, but chat / advisor surfaces could not. This violated the "every fact in the product is retrievable and citable" invariant.
14
+
15
+ ## Decision
16
+
17
+ **Render curated YAML β†’ text β†’ embed β†’ ingest as Chroma chunks** with metadata `doc_type='curated'`.
18
+
19
+ - A new ingestion entry `tools/ingest_curated.py` walks `40-data/curated/*.yaml`, renders each fact sheet into one structured text blob per policy (sections: identity, premium, benefits, sub-limits, exclusions, renewal terms), and writes to the main Chroma collection.
20
+ - Chunks carry the same metadata schema as PDF-extracted chunks (`insurer_slug`, `product_slug`, `uin`, `source_uri`) plus `doc_type='curated'` so retrieval can boost / filter.
21
+ - Retrieval treats `doc_type='curated'` as equally citable as `doc_type='brochure'` / `doc_type='wording'` β€” the bot cites the curated source URL written in the YAML front matter.
22
+
23
+ ## Consequences
24
+
25
+ | Win | Cost |
26
+ |---|---|
27
+ | 21 previously-invisible policies are now retrievable + citable in chat. The marketplace ↔ chat asymmetry is closed | Curated YAML is hand-maintained; stale curation now affects retrieval, not just display. Mitigated by `last_verified_at` metadata on every chunk |
28
+ | The chat brain can answer "what's the room rent cap on X" for any policy in the corpus, including those that broke the extractor | Curated chunks lack the granular slot structure of extracted JSONs β€” retrieval relevance per chunk is slightly lower because chunks are coarser-grained |
29
+ | Establishes a general pattern: any structured data we hold about a product can be rendered β†’ embedded β†’ ingested without disrupting the rest of the pipeline | Two source-of-truth paths now exist for the same product (curated YAML vs extracted JSON); a future consolidation will reconcile them |
30
+
31
+ ## Related
32
+
33
+ - KI-137 β€” the ingest run that emitted the 21 chunks
34
+ - ADR-003 (curated corpus) β€” original decision to hold curated YAML
35
+ - ADR-025 (single Chroma collection with metadata partitioning) β€” `doc_type` is the partitioning field
70-docs/60-decisions/ADR-035-insurer-slug-filtering-marketplace.md ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR-035 β€” Insurer-slug filtering: hide `profile` + `regulatory` from marketplace
2
+
3
+ **Status:** Accepted β€” 2026-05-15
4
+ **Owner:** Rohit Saraf
5
+ **Related KIs:** KI-129, KI-130, KI-132
6
+
7
+ ## Context
8
+
9
+ The single Chroma collection (ADR-025) holds three classes of document chunks indistinguishable by storage but very different in purpose:
10
+
11
+ 1. **Product chunks** β€” per-policy content extracted from brochures, wordings, curated YAML. `insurer_slug` is a real insurer (`hdfc-ergo`, `niva-bupa`, …). These ARE the marketplace's product inventory.
12
+ 2. **Profile chunks** β€” per-user conversational context written by the orchestrator during fact-find, used to bias retrieval toward the user's situation. `insurer_slug='profile'`.
13
+ 3. **Regulatory chunks** β€” IRDAI circulars, NHA health-stack docs, regulatory FAQs. `insurer_slug='regulatory'`.
14
+
15
+ Pre-fix, `/api/coverage` and `/api/policies/all` SELECT-ed every distinct `insurer_slug` from Chroma metadata. The marketplace then rendered `profile` and `regulatory` as if they were insurers β€” broken cards, no products underneath, user confusion.
16
+
17
+ ## Decision
18
+
19
+ **Exclude `insurer_slug IN ('profile', 'regulatory')` from every user-facing marketplace aggregation surface.** The chunks remain in the collection unchanged β€” they still get retrieved during chat for context-boost and regulatory-grounding β€” but they never surface as marketplace entries.
20
+
21
+ Implementation:
22
+
23
+ - `backend/marketplace.py::list_insurer_slugs()` adds `WHERE insurer_slug NOT IN ('profile', 'regulatory')` to the metadata SELECT.
24
+ - `backend/marketplace.py::list_all_policies()` applies the same filter on the chunk-walk.
25
+ - The retrieval layer (`backend/retrieval.py`) does NOT filter β€” `profile` chunks must still surface to the brain as soft context, `regulatory` chunks must still surface as IRDAI citations.
26
+
27
+ The exclusion list lives in `backend/marketplace.py::_NON_PRODUCT_SLUGS = frozenset({'profile', 'regulatory'})`. Adding a future non-product slug (e.g. `internal-notes`) requires only adding it to this set.
28
+
29
+ ## Consequences
30
+
31
+ | Win | Cost |
32
+ |---|---|
33
+ | Marketplace surfaces show only real insurers; no broken `profile` / `regulatory` cards | Two surfaces with subtly different read paths to the same collection β€” risk of one being updated without the other; mitigated by the single `_NON_PRODUCT_SLUGS` constant |
34
+ | Profile + regulatory chunks keep their retrieval-side power (user-context boosting, IRDAI grounding) β€” no functional regression | The single-collection design (ADR-025) is now leaning harder on `insurer_slug` semantics; a typo in any of these reserved slugs would silently corrupt the marketplace |
35
+ | The pattern generalizes: any future non-product slug class can be added to `_NON_PRODUCT_SLUGS` without schema migration | Operators must remember the convention β€” `insurer_slug` is overloaded |
36
+
37
+ ## Related
38
+
39
+ - KI-129, KI-130, KI-132 β€” three commits hardening marketplace filtering after the broken-cards bug report
40
+ - ADR-025 β€” the single-collection-with-metadata-partitioning decision this filter operates on
70-docs/60-decisions/ADR-036-voice-vad-threshold-tuning.md ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR-036 β€” Voice VAD threshold tuning for consumer-mic noise gates
2
+
3
+ **Status:** Accepted β€” 2026-05-15
4
+ **Owner:** Rohit Saraf
5
+ **Related KIs:** KI-139
6
+
7
+ ## Context
8
+
9
+ The voice UI pill rendered green ("listening") but no audio frames reached the recorder on multiple consumer setups (MacBook built-in mic, AirPods Pro, Logitech webcam with active noise suppression). The downstream Sarvam ASR call received either silence or a sub-100ms clip and returned an empty transcript. The user saw "green pill, zero audio" β€” the worst voice-UX failure mode because it's silent: no error toast, no spinner stuck, just no transcript.
10
+
11
+ Root cause: the in-browser VAD (voice activity detector) thresholds were tuned against a studio-quality reference mic. Three thresholds were too aggressive for consumer mics whose built-in noise gates / DSP heavily attenuate signal before it reaches the AudioWorklet:
12
+
13
+ - **`rmsThreshold = 26`** β€” the RMS floor below which a frame is "silence". Consumer noise gates attenuate noise floor AND voice energy together, pulling typical conversational RMS into the 18-24 range.
14
+ - **`voiceBandMinProp = 0.50`** β€” required proportion of energy in the voice band (300-3400 Hz). Noise-gated speech often loses harmonics, dropping voice-band proportion to 0.20-0.35.
15
+ - **`noiseFloor multiplier = 2.5`** β€” required signal-to-floor ratio for "voice detected". With consumer DSP, the noise floor itself is suppressed near-zero, making the 2.5Γ— multiplier unreachable on real voice.
16
+
17
+ ## Decision
18
+
19
+ Lower all three thresholds to match consumer-mic reality:
20
+
21
+ | Threshold | Old | New | Rationale |
22
+ |---|---|---|---|
23
+ | `rmsThreshold` | 26 | **18** | Matches the 18-24 RMS range observed on MacBook built-in + AirPods Pro |
24
+ | `voiceBandMinProp` | 0.50 | **0.20** | Accommodates harmonic loss from consumer DSP; still rejects pure tonal noise |
25
+ | `noiseFloor` multiplier | 2.5 | **1.8** | Lower the SNR bar; consumer noise gates make 2.5Γ— unreachable on real speech |
26
+
27
+ Values are in `frontend/voice/vad-worklet.js`. No new configuration surface β€” the constants are the contract.
28
+
29
+ ## Consequences
30
+
31
+ | Win | Cost |
32
+ |---|---|
33
+ | Voice capture works on MacBook built-in, AirPods Pro, Logitech webcam with NS, iPhone Safari β€” the consumer-default setups | False-positive rate (background TV / room hum triggering capture) goes up ~3-5% on noisy environments. Acceptable: ASR rejects garbage with empty transcript anyway |
34
+ | The "green pill, zero audio" silent failure is closed for the dominant consumer mic class | Studio-quality mic users now trigger on slightly quieter ambient β€” no functional regression, just less margin |
35
+ | Thresholds are now grounded in measured consumer-mic data, not a clean-room reference | Any future thresholds (echo cancel, AGC) should be tuned against the same consumer-mic panel, not in isolation |
36
+
37
+ ## Related
38
+
39
+ - KI-139 β€” the threshold-tuning commit + the consumer-mic panel that produced the calibration data
40
+ - ADR-028 (voice UX single default mode) β€” the surface this tuning ships into
70-docs/60-decisions/ADR-037-indusind-general-slug.md ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR-037 β€” IndusInd General slug + `legacy_issuer` continuity
2
+
3
+ **Status:** Accepted β€” 2026-05-15
4
+ **Owner:** Rohit Saraf
5
+ **Related KIs:** KI-144
6
+
7
+ ## Context
8
+
9
+ Reliance General Insurance was renamed to **IndusInd General Insurance** under IRDAI Registration No. 103 (same regulatory entity, new corporate name following ownership change). Three health products in our corpus carried the old `reliance-general` slug:
10
+
11
+ 1. **HealthGain** (UIN 100xxx)
12
+ 2. **Hospi Care** (UIN 100xxx)
13
+ 3. **Group Mediclaim** (UIN 100xxx)
14
+
15
+ Two competing requirements:
16
+
17
+ - **Identity correctness.** New customers searching the marketplace should see "IndusInd General", not "Reliance General". The legal entity has changed; old branding is wrong.
18
+ - **Retrieval continuity.** Brochures, IRDAI filings, and existing chat-history references in the corpus still use "Reliance General" prose. If retrieval can't bridge old-name queries to new-slug chunks, large portions of the historical corpus become unreachable by name.
19
+
20
+ A naive slug rename would correct identity but break retrieval for any query mentioning "Reliance General".
21
+
22
+ ## Decision
23
+
24
+ **Introduce new slug `indusind-general`. Migrate all three products. Preserve continuity via `legacy_issuer` metadata.**
25
+
26
+ - New `insurer_slug='indusind-general'`. All three product extractions, curated YAMLs, and Chroma chunks updated.
27
+ - Every migrated chunk gains a `legacy_issuer='Reliance General'` metadata field.
28
+ - The retrieval layer (`backend/retrieval.py`) expands queries: if the query string mentions any value in any chunk's `legacy_issuer`, retrieval matches BOTH `insurer_slug='indusind-general'` AND chunks naming the legacy issuer.
29
+ - The marketplace surfaces (`/api/coverage`, `/api/policies/all`) display "IndusInd General" as the issuer name with an optional "formerly Reliance General" subtitle on the card.
30
+ - IRDAI Registration No. 103 is recorded in the slug's metadata as the canonical identity proof.
31
+
32
+ ## Consequences
33
+
34
+ | Win | Cost |
35
+ |---|---|
36
+ | Marketplace identity matches IRDAI's current regulatory register; new customers see the correct name | Two issuer names exist in the corpus prose simultaneously; care needed when ingesting future Reliance-branded historical material |
37
+ | Retrieval continuity is preserved β€” "Reliance General HealthGain" queries still find the chunks under the new slug | `legacy_issuer` is now a real metadata field with semantic load; renaming the renaming convention (e.g. a future re-rename) needs a migration story |
38
+ | Establishes the general pattern for issuer renames: new slug + `legacy_issuer` continuity field + IRDAI Reg No. as identity anchor | Maintenance: every future issuer rename requires this same dance, not just a string update |
39
+
40
+ ## Related
41
+
42
+ - KI-144 β€” the migration commit
43
+ - ADR-033 (marketplace dedup UIN rule) β€” the UIN identity logic that survives the issuer rename unchanged
70-docs/60-decisions/ADR-038-nim-only-chains.md ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR-038 β€” NIM-only chains (KI-160)
2
+
3
+ **Status:** Accepted β€” 2026-05-15
4
+ **Owner:** Rohit Saraf
5
+ **Supersedes:** [ADR-031](ADR-031-sticky-primary-election.md) (cross-provider election scope), [ADR-032](ADR-032-llm-chain-architecture.md) (candidate-pool scope)
6
+ **Related KIs:** KI-155 (Groq `<FF>` contract violation, root cause), KI-160 (this lock-down), KI-080 / KI-081 / KI-084 / KI-085 / KI-087 (probe + election mechanics, retained within NIM scope)
7
+
8
+ ## Context
9
+
10
+ KI-155 demonstrated that **Groq Llama-3.3-70B silently ignores the `<FF>...</FF>` structured-output trailer contract** that every fact-find turn depends on. Replies from Groq passed `NimChainLLM.chat()` as plain prose with no parseable trailer, the lenient KI-090 parser couldn't recover a contract key, and the orchestrator fell to `_canonical_fallback` (KI-072 / KI-074) β€” meaning the user saw a scripted slot prompt even though the LLM had "successfully" responded. From the elector's view the call was a success (HTTP 200, latency in band, no exception), so the probe loop kept Groq elected as a healthy candidate. The credit-gating + sin-bin machinery (KI-084 / KI-085) could not distinguish "model returned valid JSON in the contract format" from "model returned grammatically fine prose that violates the contract."
11
+
12
+ The cross-provider fallback was added in KI-080 ([ADR-031](ADR-031-sticky-primary-election.md)) to survive a full NIM regional outage. KI-155 inverted the trade-off: cross-provider fallback for **structured-output contracts** is a silent-failure trap, because providers don't agree on instruction-following fidelity at the trailer level, and silent failures are strictly worse for the user than a loud "service degraded" message.
13
+
14
+ User-facing symptom: fact-find turns that should have advanced one slot kept re-asking the previous slot, with no error in any log line β€” the bot looked broken in a way no probe or telemetry could catch.
15
+
16
+ ## Decision
17
+
18
+ **All three LLM chains lock to NIM candidates only.** Concretely:
19
+
20
+ - **`BRAIN_CHAIN`** β€” primary `nvidia/llama-3.3-nemotron-super-49b-v1.5`, backup `qwen/qwen3-next-80b-a3b-instruct`, 3rd `mistralai/mistral-large-3-675b-instruct-2512`.
21
+ - **`FAST_BRAIN_CHAIN`** β€” primary `qwen/qwen3-next-80b-a3b-instruct`, backup `nvidia/llama-3.3-nemotron-super-49b-v1.5`.
22
+ - **`JUDGE_CHAIN`** β€” primary `meta/llama-4-maverick-17b-128e-instruct`, backup `mistralai/mistral-large-3-675b-instruct-2512`.
23
+
24
+ **If every NIM candidate in a chain fails in a single turn, the orchestrator returns a graceful error message to the user instead of cascading to Groq or OpenRouter.** Fail-loud > fail-silent-with-garbage for any chain that consumes a structured-output contract.
25
+
26
+ `GROQ_API_KEY` + `OPENROUTER_API_KEY` remain in HF Space repository secrets for future re-enable, but the chain config no longer references them β€” they are dormant, not active election candidates. KI-085's proactive credit gating still applies **within the NIM pool**: per-model 60-second rate-meter, gate at 35-of-40 req/min with headroom 5.
27
+
28
+ KI-080 sticky-primary election machinery, KI-084 per-phase httpx timeouts, KI-086 admin telemetry, and KI-091 / KI-094 extractor-skip + None-guard remain unchanged β€” they all operate within the NIM-only pool. ADR-031 and ADR-032 are superseded only on the candidate-pool scope; their probe / timeout / telemetry mechanics are retained.
29
+
30
+ ## Consequences
31
+
32
+ - **(a) Higher reliability for structured-output contracts.** Every chain consumer (`<FF>` trailer parsers, faithfulness judge, profile extractor) sees output from a provider family that has been validated end-to-end against the contract. No silent contract violations.
33
+ - **(b) No Groq daily token consumption from the chain.** The 100K-tokens/day Groq free-tier quota is no longer spent on production chat turns. Groq credentials stay in Space secrets for one-flip re-enable if a future need arises.
34
+ - **(c) Tiny risk of total NIM outage causing service-degraded state.** Mitigated by 3+ candidates per chain spanning three distinct model families (Nemotron / Qwen / Mistral for brain, Llama-4 / Mistral for judge). A single-model NIM throttle event still has at least one other candidate to elect within the same provider; only a full NIM regional outage trips the graceful-error path, and that path is **observable** (admin telemetry + explicit error message to user) rather than silent.
35
+ - **(d) Probe loop spend drops.** Fewer candidates Γ— no cross-provider probes = lower steady-state probe token consumption on `PROBE_INTERVAL_SEC = 300s`.
36
+
37
+ ## Alternatives considered
38
+
39
+ - **Keep Groq as last-resort cross-provider candidate.** Rejected β€” the failure mode KI-155 exposed is structural to Groq's instruction-following on this prompt shape, not a transient or fixable bug. Any election that puts Groq in the election pool at all re-introduces the silent-failure risk the moment NIM degrades enough for the score to flip. The point of NIM-only is to eliminate the silent-failure class, not to ration it.
40
+ - **Switch the entire stack to OpenRouter as a universal provider abstraction.** Rejected for the same reason β€” OpenRouter is a routing layer, not a structured-output guarantee. The same Groq-via-OpenRouter call would have the same trailer violation. OpenRouter would also reintroduce per-call USD spend on a stack whose explicit design constraint is $0 inference.
41
+
42
+ ## Reversal trigger
43
+
44
+ If NIM imposes a paid-tier requirement, regional outage rate exceeds 1% sustained over 7 days, or the candidate pool shrinks below 2 healthy candidates per chain, re-open this ADR. The reversal would still need a per-candidate contract validation (run the KI-155 `<FF>` regression suite against any prospective non-NIM candidate) before adding it back to the election pool.
CLAUDE.md CHANGED
@@ -4,7 +4,7 @@ This file is read by Claude Code (and any compatible AI tool) at the start of a
4
 
5
  ## Project at a glance
6
 
7
- - **What:** a voice-first AI advisor for Indian health insurance β€” RAG over a curated 206-document corpus (188 product PDFs across 19 insurers + 18 regulatory IRDAI/NHA docs, 7,295 chunks), Sarvam STT/TTS, 4-gate faithfulness, 19-insurer scorecard (regulatory tracked separately).
8
  - **Live:** https://rohitsar567-insurancebot.hf.space (HF Space; rebuild triggered on every push to `origin main`).
9
  - **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).
10
  - **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).
@@ -18,19 +18,19 @@ This file is read by Claude Code (and any compatible AI tool) at the start of a
18
  - **Hands-free was removed entirely** in KI-027. Anything in the codebase still referring to it is stale.
19
  - **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.
20
 
21
- ## LLM stack (ADR-019 + ADR-026 β†’ ADR-031 + ADR-032) β€” KI-080 β†’ KI-087
22
 
23
- Every LLM role is a `NimChainLLM` candidate pool, NOT a hardcoded single model. End-to-end spec: [ADR-032](70-docs/60-decisions/ADR-032-llm-chain-architecture.md). Chains preserve brain ↔ judge family diversity (Qwen brain ↔ Mistral judge) so failovers can't accidentally produce circular grading.
24
 
25
- - **Probe-driven sticky primary election (KI-080, [ADR-031](70-docs/60-decisions/ADR-031-sticky-primary-election.md)).** All three chains (`BRAIN_CHAIN`, `FAST_BRAIN_CHAIN`, `JUDGE_CHAIN`) elect a sticky PRIMARY + provider-diverse BACKUP from a background probe. `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 (cross-provider by construction) and triggers an immediate probe refresh. **Per-turn LLM call count: 1 (most cases) or 2 (PRIMARY fails real-time β†’ BACKUP).** Pre-KI-080 worst case was 5-6 NIM calls per turn, all queued and timing out.
26
- - **NIM-first election preference (KI-087, `d90f8c0`).** Election prefers ANY eligible NIM candidate over ALL non-NIM candidates. Within the NIM pool, score still picks the fastest healthy NIM model. Only when the NIM pool is empty (every NIM model down, throttled, or quota-exhausted) does election fall through to Groq / OpenRouter as PRIMARY. Rationale: NIM is the strategic free provider ($0, no daily cap, 110+ models); Groq has 100K tokens/day; OpenRouter charges real USD. Both serve as emergency fallback only.
27
- - **Probe cadence + per-phase timeouts (KI-084, `119e0fd`).** Probe loop ticks at `PROBE_INTERVAL_SEC = 300s` (was 60s β€” raised so probe-driven token spend stays inside Groq's 100K/day free-tier cap). 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`, preventing NIM concurrency-slot leaks across PRIMARY β†’ BACKUP. Rate-limit failures (HTTP 429 / `RateLimit` body) get a **1h sin-bin** (`DEGRADE_DURATION_LONG_S = 3600s`) instead of the 30s transient window β€” free-tier daily quotas don't reset in 30 seconds.
28
- - **Proactive credit gating (KI-085, `8fc7979`).** Election is gated by `is_alive AND has_credits` so quota-exhausted candidates are excluded BEFORE the user hits a 429. Signal sources: Groq response headers (`x-ratelimit-remaining-tokens-day`, low-water 5,000 tokens); OpenRouter `GET /api/v1/credits` polled every 10 min (low-water $0.05); NIM local 60-second rate-meter (gate at 35-of-40 req/min, headroom 5). Closes the one-turn reactive gap KI-084 alone leaves.
29
- - **HF Space secrets (KI-081, no commit; HF Space env secrets push).** `GROQ_API_KEY` + `OPENROUTER_API_KEY` pushed to the Space repository secrets so KI-080 cross-provider election candidates have working keys in production. Pre-KI-081 only `NVIDIA_NIM_API_KEY` was set on the Space; the elector marked Groq + OR as `no_api_key` and election degraded to NIM-only candidates.
30
- - **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. Surfaced in the admin "LLM Chain" tab with auto-refresh every 30s.
31
- - **KI-025's 50/50 NIM ↔ Groq rotation ([ADR-026](70-docs/60-decisions/ADR-026-provider-load-balancing.md)) is deprecated** β€” `_balanced_brain_chain` retained behind a feature flag for one-release rollback; the probe-driven election picks the actually-faster candidate dynamically.
32
- - **Cold-start fallback.** Before the first probe completes (process restart, HF Space rebuild), `chain[0]` is the initial primary and `chain[1]` (preferring a different provider) is the initial backup. The probe loop runs immediately on startup; OpenRouter credits are polled on startup so the elector has a non-None USD balance before the first chat call.
33
- - **Brain / fast-brain / judge primaries in steady state** are typically **NIM Qwen 3-Next 80B** (heavy brain, KI-087 NIM-first), **NIM Nemotron Nano 30B** (fast brain), and **Mistral Large 3 675B** (judge). Not hardcoded β€” the elected primary follows live `latency Γ— success_rate Γ— credits_available` with NIM-first preference.
34
  - **KI-079 escalation as last bite (`87ee522`).** 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) before falling to `_canonical_fallback` (KI-072 / KI-074 greedy slot capture). Worst-case wall-clock before canonical: 25s FAST + 15s heavy = 40s.
35
  - **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).
36
  - **Lenient FF-block parser (KI-090, `11cf4b3`).** Real LLMs (Qwen, Nemotron under load, Groq Llama-3.3) sometimes drop the literal `<FF>...</FF>` tags around their JSON tail. Pre-KI-090 those replies fell to `fallback:no_trailer` even though the brain had produced a perfectly valid structured response. Now `_parse_ff_block` tries strict β†’ fenced ```` ```json``` ```` β†’ bare-JSON-tail, each candidate validated by presence of a contract key (`captured` / `slot_driving` / `complete`). `_strip_ff_block` mirrors the strategies so prose-only output never leaks structured metadata.
@@ -127,8 +127,12 @@ Three independent safety layers against ChromaDB HNSW bloat:
127
  - **Never add `"qa"` to `CONTEXT_DEPENDENT_INTENTS`** β€” that brings back the headline KI-018 bug where QA questions get trapped in fact-find.
128
  - **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.
129
  - **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.
130
- - **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.
 
 
 
 
131
 
132
  ---
133
 
134
- *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).*
 
4
 
5
  ## Project at a glance
6
 
7
+ - **What:** a voice-first AI advisor for Indian health insurance β€” RAG over a curated 206-document corpus (188 product PDFs across 19 insurers + 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, 4-gate faithfulness, 19-insurer scorecard (regulatory tracked separately). Marketplace surfaces **166 cards** across the 19 real insurers (one card per IRDAI-filed product after KI-133 / KI-141 / KI-142 / KI-145 dedup); 201 extracted JSONs + 253 curated `policy_facts` JSONs feed the structured side. The 20 internal Chroma slugs = 19 real insurers + 1 `regulatory` bucket; the regulatory + `profile` slugs are filtered out of every user-facing count (KI-129 / KI-130 / KI-132).
8
  - **Live:** https://rohitsar567-insurancebot.hf.space (HF Space; rebuild triggered on every push to `origin main`).
9
  - **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).
10
  - **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).
 
18
  - **Hands-free was removed entirely** in KI-027. Anything in the codebase still referring to it is stale.
19
  - **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.
20
 
21
+ ## LLM stack (ADR-019 + ADR-026 β†’ ADR-031 + ADR-032 β†’ ADR-038) β€” KI-080 β†’ KI-087, KI-160
22
 
23
+ Every LLM role is a `NimChainLLM` candidate pool, NOT a hardcoded single model. End-to-end spec: [ADR-032](70-docs/60-decisions/ADR-032-llm-chain-architecture.md). All three chains are **locked to NIM candidates only** as of KI-160 / [ADR-038](70-docs/60-decisions/ADR-038-nim-only-chains.md) β€” Groq + OpenRouter were removed as election candidates after KI-155 proved Groq Llama-3.3 silently ignores the `<FF>` structured-output trailer contract. Chains still preserve brain ↔ judge family diversity (Qwen / Nemotron brain ↔ Mistral / Llama-4 judge) so failovers can't produce circular grading.
24
 
25
+ - **NIM-only election (KI-160, [ADR-038](70-docs/60-decisions/ADR-038-nim-only-chains.md)).** Candidate pools per role: BRAIN β€” `nvidia/llama-3.3-nemotron-super-49b-v1.5` (primary), `qwen/qwen3-next-80b-a3b-instruct` (backup), `mistralai/mistral-large-3-675b-instruct-2512` (3rd). FAST_BRAIN β€” `qwen/qwen3-next-80b-a3b-instruct` (primary), `nvidia/llama-3.3-nemotron-super-49b-v1.5` (backup). JUDGE β€” `meta/llama-4-maverick-17b-128e-instruct` (primary), `mistralai/mistral-large-3-675b-instruct-2512` (backup). If all NIM candidates fail in a turn, orchestrator returns a graceful error message instead of cascading to Groq / OpenRouter β€” **fail-loud > fail-silent-with-garbage**.
26
+ - **Probe-driven sticky primary election (KI-080, [ADR-031](70-docs/60-decisions/ADR-031-sticky-primary-election.md), 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).**
27
+ - **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`).
28
+ - **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.
29
+ - **HF Space secrets (KI-081).** `GROQ_API_KEY` + `OPENROUTER_API_KEY` remain in HF Space repository secrets for future re-enable, but the chain config no longer references them after KI-160 β€” they are dormant, not active election candidates. `NVIDIA_NIM_API_KEY` is the only key the chain config consults.
30
+ - **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).
31
+ - **KI-025's 50/50 NIM ↔ Groq rotation ([ADR-026](70-docs/60-decisions/ADR-026-provider-load-balancing.md)) 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.
32
+ - **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.
33
+ - **Brain / fast-brain / judge primaries in steady state** are **NIM Nemotron-Super 49B** (brain), **NIM Qwen 3-Next 80B** (fast brain), and **NIM Llama-4 Maverick 17B/128E** (judge), per the KI-160 candidate lists. Not hardcoded β€” elected primary follows live `latency Γ— success_rate Γ— credits_available` within the NIM pool.
34
  - **KI-079 escalation as last bite (`87ee522`).** 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) before falling to `_canonical_fallback` (KI-072 / KI-074 greedy slot capture). Worst-case wall-clock before canonical: 25s FAST + 15s heavy = 40s.
35
  - **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).
36
  - **Lenient FF-block parser (KI-090, `11cf4b3`).** Real LLMs (Qwen, Nemotron under load, Groq Llama-3.3) sometimes drop the literal `<FF>...</FF>` tags around their JSON tail. Pre-KI-090 those replies fell to `fallback:no_trailer` even though the brain had produced a perfectly valid structured response. Now `_parse_ff_block` tries strict β†’ fenced ```` ```json``` ```` β†’ bare-JSON-tail, each candidate validated by presence of a contract key (`captured` / `slot_driving` / `complete`). `_strip_ff_block` mirrors the strategies so prose-only output never leaks structured metadata.
 
127
  - **Never add `"qa"` to `CONTEXT_DEPENDENT_INTENTS`** β€” that brings back the headline KI-018 bug where QA questions get trapped in fact-find.
128
  - **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.
129
  - **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.
130
+ - **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).
131
+ - **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.
132
+ - **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.
133
+ - **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.
134
+ - **`fact_find_brain` `max_tokens = 700` (KI-150, was 420).** The 420 cap was the root cause of the "robotic language" user complaint β€” the brain ran out of tokens mid-paraphrase. Do not lower below 700 without a regression test on natural-conversation feel.
135
 
136
  ---
137
 
138
+ *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; fact_find_brain max_tokens 420β†’700).*
README.md CHANGED
@@ -41,12 +41,31 @@ short_description: Voice-first AI advisor for Indian health insurance
41
 
42
  ## 1. Executive summary
43
 
44
- A **voice-first health-insurance advisor** for Indian buyers, grounded in a curated corpus of **206 documents** β€” 188 product documents from 19 leading insurers plus 18 IRDAI / regulatory documents β€” extracted into a 62-field structured schema with a rules-based A–F scorecard and a **4-gate hallucination defense** on every reply.
45
 
46
  The bot is **consumer-facing in experience, B2B in commercial application.** The realistic deployment is an insurer or aggregator white-labelling this advisor on top of Sarvam's ASR/TTS/LLM stack. The build deliberately optimises for the artifacts a BFSI buyer would audit: provenance, refusal behaviour, eval rigor, citation grammar.
47
 
48
  **Try on the live demo:** *"What's the pre-existing disease waiting period under Care Supreme, and how does that compare to ICICI Elevate?"* β€” comparative answer with `[Source: ...]` citations linking to specific policy PDFs and page ranges, brain tag showing which model handled it, audio synthesised by Sarvam Bulbul. Ask the same in Hinglish β€” *"Care Supreme mein PED ka waiting period kya hai?"* β€” and the response flows through the Indic translation cascade with three drift checks.
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  ### 1.1 Demo runbook β€” 7 questions to try
51
 
52
  Live URL: **https://rohitsar567-insurancebot.hf.space**. For each: try voice and text. The reply panel shows `brain_used` and per-citation source links.
@@ -71,13 +90,13 @@ A take-home is a sample of how the engineer thinks under constraint. Three thing
71
 
72
  2. **Hallucination defense and refusal as product features.** BFSI deployments get fined for mis-selling; the bot is biased toward refusal over confident wrong answers. The 4 faithfulness gates + cross-check retry + 3 Indic drift checks + audit log are the BFSI-compliance-grade version of "we shipped a chatbot." When the eval shows a headline accuracy below 100% because the gates are aggressive, the right response is to soften the gates carefully β€” not to ship a higher number by relaxing the verifier.
73
 
74
- 3. **Honest model picks β€” Sarvam where Sarvam is uniquely strong, open-weights frontier for reasoning.** Voice and Indic are non-substitutable: **Sarvam Saarika v2.5** for speech-to-text, **Sarvam Bulbul v2** (speaker `anushka`) for text-to-speech, and **Sarvam-M** for Hindi/Hinglish/vernacular translation β€” no closed-source frontier matches Sarvam on Indian accents or code-mixed Hinglish. Reasoning is a different problem and runs on open-weights frontier models behind a fallback chain, not a single hardcoded brain. The chain architecture has three roles, each a `NimChainLLM` (`backend/providers/nvidia_nim_llm.py`) with a NIM-primary preference order plus OpenRouter and Groq cross-provider fallbacks:
75
 
76
- - **`BRAIN_CHAIN`** (comparison, recommendation, synthesis) β€” primary **Qwen 3-Next 80B** (`qwen/qwen3-next-80b-a3b-instruct`, 80B / 3B-active MoE, multilingual, ~2s TTFT on NIM free tier), with **Qwen 3.5 122B**, **OpenAI GPT-OSS 120B**, **Mistral Large 3 675B**, **NVIDIA Nemotron-Super 49B**, **Meta Llama-3.3 70B**, and **DeepSeek V4-Pro** as in-NIM fallbacks, then **OpenRouter GPT-OSS 120B** and **Groq Llama-3.3-70B** as cross-provider fallbacks for full-NIM-outage survival.
77
- - **`FAST_BRAIN_CHAIN`** (fact-find turns, QA, paraphrase, normalize, extract β€” every latency-sensitive role) β€” primary **NVIDIA Nemotron Nano 30B** (`nvidia/nemotron-3-nano-30b-a3b`, ~1.6s TTFT β€” bottleneck is TTFT not capability on these jobs), with Qwen 3-Next 80B, GPT-OSS 120B, Qwen 3.5 122B, and **DeepSeek V4-Flash** as NIM fallbacks, then Groq Llama-3.3-70B as the cross-provider fallback.
78
- - **`JUDGE_CHAIN`** (faithfulness Gate 4, Hinglish drift LLM-judge, eval grader) β€” primary **Mistral Large 3 675B** (`mistralai/mistral-large-3-675b-instruct-2512`, MIT). Mistral is a deliberately different model family from the Qwen brain so the judge does not mark its own homework. Fallbacks: GPT-OSS 120B, Moonshot Kimi K2, MiniMax M2.5, and **Meta Llama-4 Maverick 17B/128E** (the original D-019 judge β€” kept in the chain for when NIM's Llama pool recovers), then OpenRouter and Groq cross-provider entries.
79
 
80
- **Provider load-balancing (KI-025, [ADR-026](70-docs/60-decisions/ADR-026-provider-load-balancing.md)).** The brain and fast-brain chains' *primary* slot rotates 50/50 per call between the NIM Qwen entry and the Groq Llama-3.3-70B entry via `_balanced_brain_chain` (per-call `random.random()`, no shared cycle state). NIM's free-tier rate cap is 40 req/min shared across every model on a key; splitting brain load across two independent free-tier quotas (NIM + Groq) effectively doubles sustained brain throughput. Groq's LPU TTFT (~1s) is often *lower* than NIM Qwen's, so the rotation is a strict latency win on top of the throughput win. A Groq-primary call that fails still gets the full NIM fallback ladder underneath, so reliability is unchanged.
81
 
82
  The result: a Sarvam customer deploying this stack gets a product that *uses Sarvam exactly where Sarvam beats the world* and uses MIT-licensed open-weights frontier models for everything else β€” $0 inference, two independent free-tier providers, single-key-per-provider for the entire non-voice stack.
83
 
@@ -520,8 +539,9 @@ The bot is two flows running together β€” the customer's experience and the tech
520
  β–Ό
521
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
522
  β”‚ 206 source PDFs in HF Dataset rohitsar567/insurance-bot-data β”‚
523
- β”‚ Β· 188 product PDFs across 19 insurers β”‚
524
  β”‚ Β· 18 regulatory PDFs (IRDAI master circulars, Insurance Act, etc.) β”‚
 
525
  β”‚ Β· Playwright same-origin fetch past Akamai for irdai.gov.in β”‚
526
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
527
  ```
@@ -586,7 +606,7 @@ Every LLM role is served by a **candidate pool** of models elected over by a pro
586
  | Admin panel | IP+password gated, in-app tab | [ADR-023](70-docs/60-decisions/ADR-023-admin-panel-ip-gated.md) |
587
  | Resilience | Triple-mirror code + data | [ADR-024](70-docs/60-decisions/ADR-024-triple-mirror-code-and-data.md) |
588
 
589
- Every D-NNN in the legacy decisions log is now a stand-alone ADR β€” see [`70-docs/60-decisions/README.md`](70-docs/60-decisions/README.md) for the full 24-entry index.
590
 
591
  ---
592
 
@@ -596,13 +616,15 @@ Every D-NNN in the legacy decisions log is now a stand-alone ADR β€” see [`70-do
596
 
597
  | Type | Count | Source | Notes |
598
  |---|---|---|---|
599
- | Product PDFs | 188 | 19 insurers' public websites | Wordings + Brochures + CIS |
600
  | Regulatory PDFs | 18 | irdai.gov.in, indiacode.nic.in, others | Playwright rescue past Akamai |
601
- | Structured extractions (JSON) | 206 | fast-brain chain (Nemotron 30B / Qwen 80B / Groq Llama-3.3 fallback) extraction | 62-field Pydantic schema |
602
- | Vector chunks (Chroma) | 7,295 | BGE-small @ 800/120 | One sqlite + HNSW binaries |
 
 
603
  | Policy markdown sheets | 222 | Generated from extractions | One per policy_id in `kb/policies/` |
604
 
605
- 19 insurers: Acko, Aditya Birla, Bajaj Allianz, Care Health, Cholamandalam MS, Go Digit, HDFC ERGO, ICICI Lombard, IFFCO Tokio, ManipalCigna, National Insurance, New India Assurance, Niva Bupa, Oriental Insurance, Reliance General, Royal Sundaram, SBI General, Star Health, Tata AIG.
606
 
607
  ### 5.2 Ingestion pipeline
608
 
@@ -808,7 +830,7 @@ npm run dev # http://localhost:3000
808
 
809
  - Frontend at `localhost:3000` should show the chat UI.
810
  - `localhost:7860/api/health` should return `{"status":"ok", "providers_ok": {"sarvam": true, "nvidia_nim": true}}`.
811
- - `localhost:7860/api/coverage` should return 206 policies indexed.
812
 
813
  ---
814
 
@@ -931,7 +953,7 @@ A new Claude Code session should ingest these to bootstrap understanding:
931
 
932
  ## Footer
933
 
934
- **Authored 2026-05-13. Last updated 2026-05-15.**
935
 
936
  Live demo: https://rohitsar567-insurancebot.hf.space
937
  Code: https://github.com/rohitsar567/insurance-sales-bot
 
41
 
42
  ## 1. Executive summary
43
 
44
+ A **voice-first health-insurance advisor** for Indian buyers, grounded in a curated corpus of **206 documents** β€” 188 product documents from 19 leading insurers plus 18 IRDAI / regulatory documents β€” extracted into a 62-field structured schema with a rules-based A–F scorecard and a **4-gate hallucination defense** on every reply. The marketplace surfaces **166 cards** across the 19 real insurers (one card per IRDAI-filed product after KI-133 / KI-141 / KI-142 / KI-145 dedup); the `profile` and `regulatory` Chroma slugs are filtered out of all user-facing counts (KI-129 / KI-130 / KI-132).
45
 
46
  The bot is **consumer-facing in experience, B2B in commercial application.** The realistic deployment is an insurer or aggregator white-labelling this advisor on top of Sarvam's ASR/TTS/LLM stack. The build deliberately optimises for the artifacts a BFSI buyer would audit: provenance, refusal behaviour, eval rigor, citation grammar.
47
 
48
  **Try on the live demo:** *"What's the pre-existing disease waiting period under Care Supreme, and how does that compare to ICICI Elevate?"* β€” comparative answer with `[Source: ...]` citations linking to specific policy PDFs and page ranges, brain tag showing which model handled it, audio synthesised by Sarvam Bulbul. Ask the same in Hinglish β€” *"Care Supreme mein PED ka waiting period kya hai?"* β€” and the response flows through the Indic translation cascade with three drift checks.
49
 
50
+ ### 1.0 Today's session (2026-05-15) β€” KI-125 β†’ KI-150
51
+
52
+ Thirty-plus knowledge increments landed today. The corpus was rebuilt, the marketplace dedup logic was rewritten, the voice stack was retuned, and an insurer rename was migrated end-to-end. One-line each:
53
+
54
+ - **KI-125 / KI-126 / KI-127** β€” full corpus rebuild + dedup; **Chroma chunks 3,799 β†’ 7,317** (wordings 5,401 Β· brochure 611 Β· regulatory 498 Β· prospectus 483 Β· cis 302 Β· curated 21 Β· profile 1); **marketplace cards 138 β†’ 166** correctly counted; **2 image-only PDFs dropped** (`royal-sundaram/family-plus__brochure.pdf` + `aditya-birla/activ-one__brochure.pdf`) so source-PDF total is now 206 (188 product + 18 regulatory) with 201 extracted JSONs.
55
+ - **KI-128** β€” `tools/upload_to_hf.py` LFS quota silent-failure fix.
56
+ - **KI-129 / KI-130 / KI-132** β€” filter `profile` + `regulatory` slugs from user-facing marketplace counts (19 real insurers + 1 regulatory bucket = 20 internal slugs).
57
+ - **KI-131 / KI-134 / KI-139 / KI-148** β€” voice mode now defaults **OFF**; AudioContext.resume() unlocks autoplay; VAD threshold retuned (`rmsThreshold=18`, `voiceBandMinProp=0.20`, `noiseFloor * 1.8`); TTS `k β†’ thousand` expansion.
58
+ - **KI-133 / KI-141 / KI-142 / KI-145** β€” marketplace dedup: one card per IRDAI-filed product; aliases handle marketing renames; sub-variants stay separate when material terms differ.
59
+ - **KI-136** β€” named-SKU comparison routes to `qa` instead of `fact_find`.
60
+ - **KI-137** β€” ingested 21 curated-facts policies (Activ One, Optima Secure, …) into Chroma so the bot can retrieve them.
61
+ - **KI-138** β€” canonicalized 84 `policy_name` mismatches across extracted JSONs + Chroma metadata.
62
+ - **KI-143** β€” `bajaj/group-health-guard` slug correction (gold β†’ silver per the PDF).
63
+ - **KI-144** β€” `reliance-general` β†’ `indusind-general` migration (Reliance General Insurance was rebranded to IndusInd General). The `indusind-general` slug did not exist anywhere in the codebase before today.
64
+ - **KI-149** β€” budget + income parser captures bare numerals like `"30000"`.
65
+ - **KI-150** β€” `fact_find_brain` `max_tokens` 420 β†’ 700 (root cause of the "robotic language" user complaint).
66
+
67
+ Per-insurer card counts (166 total across 19 real insurers): HDFC ERGO 15 Β· National Insurance 14 Β· Niva Bupa 14 Β· Bajaj Allianz 13 Β· ICICI Lombard 13 Β· Star Health 11 Β· Care Health 10 Β· New India Assurance 9 Β· Tata AIG 9 Β· Acko 7 Β· Aditya Birla 7 Β· Royal Sundaram 7 Β· Cholamandalam MS 6 Β· Go Digit 6 Β· IFFCO Tokio 6 Β· ManipalCigna 6 Β· SBI General 6 Β· IndusInd General 3 Β· Oriental Insurance 3 Β· Reliance General 1.
68
+
69
  ### 1.1 Demo runbook β€” 7 questions to try
70
 
71
  Live URL: **https://rohitsar567-insurancebot.hf.space**. For each: try voice and text. The reply panel shows `brain_used` and per-citation source links.
 
90
 
91
  2. **Hallucination defense and refusal as product features.** BFSI deployments get fined for mis-selling; the bot is biased toward refusal over confident wrong answers. The 4 faithfulness gates + cross-check retry + 3 Indic drift checks + audit log are the BFSI-compliance-grade version of "we shipped a chatbot." When the eval shows a headline accuracy below 100% because the gates are aggressive, the right response is to soften the gates carefully β€” not to ship a higher number by relaxing the verifier.
92
 
93
+ 3. **Honest model picks β€” Sarvam where Sarvam is uniquely strong, open-weights frontier for reasoning.** Voice and Indic are non-substitutable: **Sarvam Saarika v2.5** for speech-to-text, **Sarvam Bulbul v2** (speaker `anushka`) for text-to-speech, and **Sarvam-M** for Hindi/Hinglish/vernacular translation β€” no closed-source frontier matches Sarvam on Indian accents or code-mixed Hinglish. Reasoning is a different problem and runs on open-weights frontier models behind a NIM-only candidate pool per role (KI-160 / [ADR-038](70-docs/60-decisions/ADR-038-nim-only-chains.md)), not a single hardcoded brain. Each role is a `NimChainLLM` (`backend/providers/nvidia_nim_llm.py`) whose candidates are all NIM-hosted β€” KI-155 demonstrated that Groq Llama-3.3 silently ignores the `<FF>` structured-output trailer contract, so cross-provider fallback was removed as a silent-failure trap:
94
 
95
+ - **`BRAIN_CHAIN`** (comparison, recommendation, synthesis) β€” primary **NVIDIA Nemotron-Super 49B v1.5** (`nvidia/llama-3.3-nemotron-super-49b-v1.5`), backup **Qwen 3-Next 80B** (`qwen/qwen3-next-80b-a3b-instruct`, 80B / 3B-active MoE), 3rd candidate **Mistral Large 3 675B** (`mistralai/mistral-large-3-675b-instruct-2512`).
96
+ - **`FAST_BRAIN_CHAIN`** (fact-find turns, QA, paraphrase, normalize, extract β€” every latency-sensitive role) β€” primary **Qwen 3-Next 80B** (`qwen/qwen3-next-80b-a3b-instruct`), backup **NVIDIA Nemotron-Super 49B v1.5** (`nvidia/llama-3.3-nemotron-super-49b-v1.5`).
97
+ - **`JUDGE_CHAIN`** (faithfulness Gate 4, Hinglish drift LLM-judge, eval grader) β€” primary **Meta Llama-4 Maverick 17B/128E** (`meta/llama-4-maverick-17b-128e-instruct`), backup **Mistral Large 3 675B** (`mistralai/mistral-large-3-675b-instruct-2512`). Deliberately different model families from the brain pool so the judge does not mark its own homework.
98
 
99
+ **NIM-only election, no cross-provider cascade (KI-160, [ADR-038](70-docs/60-decisions/ADR-038-nim-only-chains.md)).** If every NIM candidate in a chain fails, orchestrator returns a graceful error message rather than falling to Groq or OpenRouter β€” fail-loud is preferred over fail-silent-with-garbage for structured-output contracts. The 50/50 NIM ↔ Groq rotation of KI-025 ([ADR-026](70-docs/60-decisions/ADR-026-provider-load-balancing.md)) and the cross-provider-fallback variant of KI-080 ([ADR-031](70-docs/60-decisions/ADR-031-sticky-primary-election.md)) are both superseded. KI-085's proactive credit gating still applies within the NIM pool via a per-model 60-second rate-meter (gate at 35-of-40 req/min, headroom 5). `GROQ_API_KEY` + `OPENROUTER_API_KEY` remain in HF Space secrets for future re-enable but the chain config no longer references them.
100
 
101
  The result: a Sarvam customer deploying this stack gets a product that *uses Sarvam exactly where Sarvam beats the world* and uses MIT-licensed open-weights frontier models for everything else β€” $0 inference, two independent free-tier providers, single-key-per-provider for the entire non-voice stack.
102
 
 
539
  β–Ό
540
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
541
  β”‚ 206 source PDFs in HF Dataset rohitsar567/insurance-bot-data β”‚
542
+ β”‚ Β· 188 product PDFs across 19 insurers (2 image-only dropped, KI-126) β”‚
543
  β”‚ Β· 18 regulatory PDFs (IRDAI master circulars, Insurance Act, etc.) β”‚
544
+ β”‚ Β· 7,317 Chroma chunks (KI-125β†’127) Β· 166 marketplace cards β”‚
545
  β”‚ Β· Playwright same-origin fetch past Akamai for irdai.gov.in β”‚
546
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
547
  ```
 
606
  | Admin panel | IP+password gated, in-app tab | [ADR-023](70-docs/60-decisions/ADR-023-admin-panel-ip-gated.md) |
607
  | Resilience | Triple-mirror code + data | [ADR-024](70-docs/60-decisions/ADR-024-triple-mirror-code-and-data.md) |
608
 
609
+ Every D-NNN in the legacy decisions log is now a stand-alone ADR β€” see [`70-docs/60-decisions/README.md`](70-docs/60-decisions/README.md) for the full ADR index.
610
 
611
  ---
612
 
 
616
 
617
  | Type | Count | Source | Notes |
618
  |---|---|---|---|
619
+ | Product PDFs | 188 | 19 insurers' public websites | Wordings + Brochures + CIS (2 image-only PDFs dropped in KI-126) |
620
  | Regulatory PDFs | 18 | irdai.gov.in, indiacode.nic.in, others | Playwright rescue past Akamai |
621
+ | Structured extractions (JSON) | 201 | fast-brain chain (Nemotron 30B / Qwen 80B / Groq Llama-3.3 fallback) extraction | 62-field Pydantic schema |
622
+ | Curated `policy_facts` JSONs | 253 | Hand-curated marketplace facts | KI-137 ingested 21 of these into Chroma so the bot can retrieve them |
623
+ | Vector chunks (Chroma) | 7,317 | BGE-small @ 800/120 | wordings 5,401 Β· brochure 611 Β· regulatory 498 Β· prospectus 483 Β· cis 302 Β· curated 21 Β· profile 1 |
624
+ | Marketplace cards | 166 | Aggregated across 19 real insurers | One card per IRDAI-filed product after KI-133 / KI-141 / KI-142 / KI-145 dedup |
625
  | Policy markdown sheets | 222 | Generated from extractions | One per policy_id in `kb/policies/` |
626
 
627
+ 19 real insurers (alphabetical): Acko, Aditya Birla, Bajaj Allianz, Care Health, Cholamandalam MS, Go Digit, HDFC ERGO, ICICI Lombard, IFFCO Tokio, IndusInd General (formerly Reliance General β€” renamed in KI-144), ManipalCigna, National Insurance, New India Assurance, Niva Bupa, Oriental Insurance, Reliance General, Royal Sundaram, SBI General, Star Health, Tata AIG. Internally there are 20 Chroma slugs (the 19 above + a `regulatory` bucket); the regulatory + `profile` slugs are filtered out of every user-facing marketplace count (KI-129 / KI-130 / KI-132).
628
 
629
  ### 5.2 Ingestion pipeline
630
 
 
830
 
831
  - Frontend at `localhost:3000` should show the chat UI.
832
  - `localhost:7860/api/health` should return `{"status":"ok", "providers_ok": {"sarvam": true, "nvidia_nim": true}}`.
833
+ - `localhost:7860/api/coverage` should return 166 marketplace cards across 19 real insurers (the `profile` and `regulatory` slugs are filtered out of user-facing counts per KI-129 / KI-130 / KI-132).
834
 
835
  ---
836
 
 
953
 
954
  ## Footer
955
 
956
+ **Authored 2026-05-13. Last updated 2026-05-15 (KI-125 β†’ KI-150 β€” corpus rebuild + marketplace dedup + voice retune + IndusInd migration).**
957
 
958
  Live demo: https://rohitsar567-insurancebot.hf.space
959
  Code: https://github.com/rohitsar567/insurance-sales-bot
backend/fact_find_brain.py CHANGED
@@ -139,6 +139,32 @@ Bot reply: Fair enough β€” let me put together a shortlist based on what you've
139
  User: "What's the waiting period for PED usually?"
140
  Bot reply: Most policies in India have a 24-36 month waiting period for pre-existing diseases β€” IRDAI caps the maximum, and a few insurers offer shorter waits as a premium upgrade. Happy to dig into a specific policy if you have one in mind. Meanwhile, what budget range are you comfortable with annually β€” under β‚Ή15k, β‚Ή15-30k, β‚Ή30-60k, or β‚Ή60k+?
141
  <FF>{"captured": {}, "slot_driving": "budget_band", "complete": false}</FF>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  """
143
 
144
 
@@ -270,7 +296,24 @@ def _validate_capture(field_name: str, value: Any) -> Any:
270
  try:
271
  from backend.profile_store import is_valid_name
272
  v = str(value).strip()
273
- return v if is_valid_name(v) else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  except Exception:
275
  return None
276
 
@@ -776,9 +819,18 @@ def _canonical_fallback(session, user_text: str, *, reason: str) -> FactFindOutc
776
 
777
  # Build the prioritised slot order β€” try high-signal slots first
778
  # (numbers, enums) before name (which has explicit-intro guard).
 
 
 
 
 
 
 
 
779
  _GREEDY_ORDER = [
780
  "age", "dependents", "income_band", "existing_cover",
781
- "primary_goal", "location", "parents_age", "budget", "name",
 
782
  ]
783
  ordered_slots: list[str] = [
784
  sid for sid in _GREEDY_ORDER
@@ -863,7 +915,33 @@ def _canonical_fallback(session, user_text: str, *, reason: str) -> FactFindOutc
863
  except Exception:
864
  q = None
865
  if q is not None:
866
- reply = q.prompt_en
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
867
  slot = q.field
868
  return FactFindOutcome(
869
  reply_text=reply,
@@ -873,17 +951,36 @@ def _canonical_fallback(session, user_text: str, *, reason: str) -> FactFindOutc
873
  ambiguous=True,
874
  _fallback_reason=reason, # KI-078 β€” telemetry stamp
875
  )
876
- # Nothing left to ask β€” gentle hand-off.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
877
  reply = (
878
- "Let me know a bit about yourself β€” your age, who you'd want covered "
879
- "(just you, family, parents), and your annual income band β€” and I'll "
880
- "tailor the options for you."
881
  )
882
  return FactFindOutcome(
883
  reply_text=reply,
884
- captured_updates={},
885
  slot_driving=None,
886
- fact_find_complete=False,
887
  ambiguous=True,
888
  _fallback_reason=reason, # KI-078 β€” telemetry stamp
889
  )
 
139
  User: "What's the waiting period for PED usually?"
140
  Bot reply: Most policies in India have a 24-36 month waiting period for pre-existing diseases β€” IRDAI caps the maximum, and a few insurers offer shorter waits as a premium upgrade. Happy to dig into a specific policy if you have one in mind. Meanwhile, what budget range are you comfortable with annually β€” under β‚Ή15k, β‚Ή15-30k, β‚Ή30-60k, or β‚Ή60k+?
141
  <FF>{"captured": {}, "slot_driving": "budget_band", "complete": false}</FF>
142
+
143
+ User: "Hi"
144
+ Bot reply: Hi there β€” happy to help you find a health policy. What should I call you?
145
+ <FF>{"captured": {}, "slot_driving": "name", "complete": false}</FF>
146
+
147
+ User: "ok"
148
+ Bot reply: Sure. To get started, could you tell me your age?
149
+ <FF>{"captured": {}, "slot_driving": "age", "complete": false}</FF>
150
+
151
+ ==========================================================
152
+ FINAL INSTRUCTION β€” READ THIS CAREFULLY. APPLIES TO EVERY REPLY.
153
+ ==========================================================
154
+ EVERY reply you produce MUST end with the structured trailer in this EXACT format on its own line:
155
+
156
+ <FF>{"captured": {...}, "slot_driving": "...", "complete": false}</FF>
157
+
158
+ This rule is ABSOLUTE. There are NO exceptions:
159
+ - Short reply? Still emit the trailer.
160
+ - One-word user message ("ok", "hi", "yes")? Still emit the trailer.
161
+ - User asks an off-topic question? Still emit the trailer after your answer.
162
+ - User wants to end fact-find ("show me policies")? Emit trailer with "complete": true.
163
+ - Nothing new captured this turn? Emit "captured": {} (empty object). Still emit the trailer.
164
+
165
+ The trailer is the LAST thing in your output. Nothing comes after </FF>.
166
+
167
+ If you do not emit a valid <FF>...</FF> trailer, your entire reply is DISCARDED by the backend and the user sees a scripted fallback question. This is the single most important rule. NEVER omit the trailer.
168
  """
169
 
170
 
 
296
  try:
297
  from backend.profile_store import is_valid_name
298
  v = str(value).strip()
299
+ if not is_valid_name(v):
300
+ return None
301
+ # KI-156 (2026-05-15) β€” reject LLM-hallucinated names that are
302
+ # actually status/negation phrases. Live bug: user said "I am
303
+ # currently not having any policy" and the LLM emitted
304
+ # captured={"name":"Currently Not Having Any"}. is_valid_name
305
+ # passes on length+alpha but doesn't catch semantic garbage.
306
+ _bad_starts = {
307
+ "currently", "not", "no", "none", "nothing", "never",
308
+ "without", "looking", "buying", "shopping",
309
+ "i", "we", "my", "this", "that", "the",
310
+ "first", "still", "yet", "haven", "haven't",
311
+ "don't", "don", "dont", "havent",
312
+ }
313
+ first = v.split()[0].lower().strip(".,!?")
314
+ if first in _bad_starts:
315
+ return None
316
+ return v
317
  except Exception:
318
  return None
319
 
 
819
 
820
  # Build the prioritised slot order β€” try high-signal slots first
821
  # (numbers, enums) before name (which has explicit-intro guard).
822
+ # KI-158 (2026-05-15) β€” added "health_conditions". Its absence
823
+ # meant a user replying "I'm not having any pre-existing condition"
824
+ # NEVER got the slot captured here; the LLM brain failing on a
825
+ # `no_trailer` reply for that turn (common under NIM load) bumped
826
+ # _failed_attempts['health_conditions'] each turn until the
827
+ # loop-breaker force-skipped the slot, which then made
828
+ # next_question() return None and triggered the gentle hand-off
829
+ # mid-fact-find.
830
  _GREEDY_ORDER = [
831
  "age", "dependents", "income_band", "existing_cover",
832
+ "primary_goal", "location", "parents_age", "health_conditions",
833
+ "budget", "name",
834
  ]
835
  ordered_slots: list[str] = [
836
  sid for sid in _GREEDY_ORDER
 
915
  except Exception:
916
  q = None
917
  if q is not None:
918
+ # KI-156 (2026-05-15) β€” when the LLM bailed but we DID greedy-capture
919
+ # something this turn, prepend a brief acknowledgement so the user
920
+ # doesn't perceive the bot as ignoring them. Pre-fix the fallback
921
+ # emitted only the bare scripted prompt_en, making the conversation
922
+ # look unresponsive ("user gives city β†’ bot asks name again" pattern).
923
+ ack_parts: list[str] = []
924
+ _PRETTY = {
925
+ "name": "name",
926
+ "age": "age",
927
+ "dependents": "who you're covering",
928
+ "income_band": "income band",
929
+ "existing_cover_inr": "existing cover",
930
+ "primary_goal": "goal",
931
+ "location_tier": "city",
932
+ "parents_age_max": "parents' age",
933
+ "health_conditions": "health conditions",
934
+ "budget_band": "budget",
935
+ }
936
+ for k in ("name", "age", "dependents", "location_tier", "income_band",
937
+ "existing_cover_inr", "primary_goal", "budget_band"):
938
+ if k in captured:
939
+ ack_parts.append(_PRETTY.get(k, k))
940
+ if ack_parts:
941
+ ack = f"Got that β€” {', '.join(ack_parts)}. "
942
+ reply = ack + q.prompt_en
943
+ else:
944
+ reply = q.prompt_en
945
  slot = q.field
946
  return FactFindOutcome(
947
  reply_text=reply,
 
951
  ambiguous=True,
952
  _fallback_reason=reason, # KI-078 β€” telemetry stamp
953
  )
954
+ # Nothing left to ask β€” fact-find is genuinely complete (every applicable
955
+ # slot is filled OR has been intentionally skipped by the loop-breaker).
956
+ #
957
+ # KI-158 (2026-05-15) β€” was emitting the "Let me know a bit about yourself
958
+ # β€” your age..." gentle hand-off which is the WRONG message at this point:
959
+ # the user just answered the LAST slot, every prior slot is on file, but
960
+ # the canonical-fallback path landed here because the LLM brain failed on
961
+ # the trailer block (no_trailer / empty_reply). Re-asking "tell me a bit
962
+ # about yourself" makes the bot look forgetful mid-conversation.
963
+ #
964
+ # Fix:
965
+ # 1. Propagate `captured` (was discarded as `{}`) so any greedy capture
966
+ # made this turn β€” most commonly the no-PED `health_conditions=[]` β€”
967
+ # is applied to the profile by the orchestrator post-loop.
968
+ # 2. Flip `fact_find_complete=True` so the orchestrator switches the
969
+ # session into free-form mode and the NEXT user turn routes to the
970
+ # retrieval brain (which can recommend), not back here.
971
+ # 3. Replace the awkward hand-off prose with a clean acknowledge +
972
+ # transition that mirrors the brain's `complete=true` summary path
973
+ # β€” the user just told us they're healthy, we have everything else
974
+ # already, so transition to recommendations.
975
  reply = (
976
+ "Got it β€” no pre-existing conditions noted. I have everything I need. "
977
+ "Want me to suggest a couple of policies that fit your profile?"
 
978
  )
979
  return FactFindOutcome(
980
  reply_text=reply,
981
+ captured_updates=captured, # KI-158 β€” propagate greedy captures
982
  slot_driving=None,
983
+ fact_find_complete=True, # KI-158 β€” flip to free-form
984
  ambiguous=True,
985
  _fallback_reason=reason, # KI-078 β€” telemetry stamp
986
  )
backend/fact_find_normalizer.py CHANGED
@@ -210,7 +210,28 @@ def _keyword_normalize(question_id: str, raw_text: str) -> Any:
210
  return _parse_budget_band(raw_text)
211
 
212
  elif question_id == "health_conditions":
213
- if any(p in s for p in ["none", "no condition", "nothing", "no pre-exist", "no health", "no chronic"]):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  return []
215
  canonical = []
216
  cond_keywords = {
@@ -257,7 +278,15 @@ def _parse_existing_cover(text: str) -> int | None:
257
  """
258
  s = text.lower().strip()
259
  # Negative answers map to 0 (no existing cover).
260
- if re.search(r"\b(no|none|nothing|zero|nope|nah|haven'?t|don'?t|never)\b", s):
 
 
 
 
 
 
 
 
261
  return 0
262
  # KI-067 β€” first-time-buyer signals also imply zero existing cover.
263
  _first_time_patterns = (
 
210
  return _parse_budget_band(raw_text)
211
 
212
  elif question_id == "health_conditions":
213
+ # KI-158 (2026-05-15) β€” broadened no-PED denial detection. The previous
214
+ # substring list missed "I'm not having any pre-existing condition",
215
+ # "no pre-existing condition" (singular), "don't have any", "nothing
216
+ # chronic", etc. Each miss let _canonical_fallback's loop-breaker tick
217
+ # the health_conditions slot toward force-skip, eventually making
218
+ # next_question() return None β†’ gentle hand-off fires mid-fact-find.
219
+ # The fix uses regex to cover the natural phrasings without
220
+ # over-triggering on actual conditions like "I have diabetes".
221
+ _NO_PED_PATTERNS = [
222
+ r"\b(?:no|none|nothing|nope|nah|negative)\b\s*(?:condition|pre[-\s]?exist|health\s+issue|chronic|illness)?",
223
+ r"\b(?:not|don'?t|do\s+not|haven'?t|have\s+not|isn'?t|aren'?t)\s+(?:got|have|having|had|got\s+any|have\s+any)\b",
224
+ r"\b(?:no|zero|nil)\s+(?:pre[-\s]?exist\w*|condition|chronic|health\s+issue|illness|disease)",
225
+ r"\b(?:i\s+am|i'm)\s+(?:healthy|fine|fit|alright|all\s+good|good|ok|okay)\b",
226
+ r"\bnothing\s+(?:chronic|major|serious|to\s+report|like\s+that)\b",
227
+ r"\ball\s+(?:good|fine|clear|healthy)\b",
228
+ r"\bclean\s+bill\s+of\s+health\b",
229
+ ]
230
+ if any(re.search(p, s) for p in _NO_PED_PATTERNS):
231
+ # Belt-and-braces: ensure the user didn't ALSO mention a real
232
+ # condition in the same message (e.g., "no diabetes but I have BP").
233
+ # If a condition keyword is present below, the canonical list
234
+ # branch will pick it up regardless.
235
  return []
236
  canonical = []
237
  cond_keywords = {
 
278
  """
279
  s = text.lower().strip()
280
  # Negative answers map to 0 (no existing cover).
281
+ # KI-156 (2026-05-15) β€” extended to cover gerund/auxiliary phrasings.
282
+ # Pre-fix "I am not having any policy" / "currently not having" / "not
283
+ # got any" / "without any" all returned None, causing the fact-find
284
+ # loop to keep re-asking the existing_cover slot.
285
+ if re.search(
286
+ r"\b(no|none|nothing|zero|nope|nah|haven'?t|don'?t|never|"
287
+ r"not\s+having|not\s+got|not\s+have|without\s+(?:any|a))\b",
288
+ s,
289
+ ):
290
  return 0
291
  # KI-067 β€” first-time-buyer signals also imply zero existing cover.
292
  _first_time_patterns = (
backend/providers/nvidia_nim_llm.py CHANGED
@@ -241,27 +241,28 @@ class NvidiaNimLLM(LLMProvider):
241
  # cross-family-grading invariant survives any failover.
242
 
243
  BRAIN_CHAIN = [
244
- # Primary: Qwen 3-Next 80B β€” verified ~2s response, clean JSON, multilingual
 
 
 
 
 
 
 
 
 
245
  "qwen/qwen3-next-80b-a3b-instruct",
246
- # 1st fallback: Qwen 3.5 122B β€” same family, bigger, slightly slower
247
- "qwen/qwen3.5-122b-a10b",
248
- # 2nd fallback: OpenAI GPT-OSS 120B β€” different family, MIT open weights
249
- "openai/gpt-oss-120b",
250
- # 3rd fallback: Mistral Large 3 (also the judge β€” used only if all above fail)
251
- "mistralai/mistral-large-3-675b-instruct-2512",
252
- # 4th fallback: NVIDIA Nemotron-Super 49B β€” different family again
253
  "nvidia/llama-3.3-nemotron-super-49b-v1.5",
254
- # 5th fallback: Meta Llama-3.3 70B (intermittently times out; last resort)
255
- "meta/llama-3.3-70b-instruct",
256
- # 6th fallback: DeepSeek-V4-Pro (back when NIM's pool recovers)
257
- "deepseek-ai/deepseek-v4-pro",
258
- # CROSS-PROVIDER FALLBACKS β€” only reached when the entire NIM block above
259
- # has failed (regional outage / DNS / ingress brownout). These hit a
260
- # completely different provider so the brain survives a full NIM down.
261
- # 7th fallback: OpenRouter GPT-OSS 120B (different provider, MIT weights)
262
- "openrouter:openai/gpt-oss-120b",
263
- # 8th fallback: Groq Llama-3.3 70B (different provider, LPU inference)
264
- "groq:llama-3.3-70b-versatile",
265
  ]
266
 
267
  # Same chain for fast brain β€” Qwen 80B is already fast (~2s); no need for a
@@ -270,52 +271,44 @@ BRAIN_CHAIN = [
270
  # is the lowest-TTFT free-tier option, so a fast-brain fall-through to it is
271
  # still acceptable from a latency-budget standpoint.
272
  FAST_BRAIN_CHAIN = [
273
- # KI-035 (2026-05-14) β€” reordered for latency. Fast brain serves
274
- # fact-find + QA + paraphrase + normalize + extract: every single one
275
- # of these is a sub-second job by content size, so the bottleneck IS
276
- # TTFT, not capability. Nemotron Nano 30B hits ~1.6s; Qwen 80B is
277
- # ~2-3s. Moved Nemotron to primary; Qwen 80B stays as next fallback so
278
- # if Nemotron's NIM pool degrades we still get quality.
279
- #
280
- # KI-079 (2026-05-15) β€” moved Groq Llama-3.3 70B from chain bottom to
281
- # candidate #2 (right after Nemotron primary). Live 10-turn probe
282
- # (commit 078ff45) showed 7/10 fact-find turns timing out at 26.6s with
283
- # _fallback_reason="timeout" β€” ALL the slow links were NIM-hosted, so
284
- # NIM per-key concurrency had them queueing together and the chain
285
- # burned its 22s total_budget_s inside NIM before ever reaching Groq.
286
- # With Groq as #2, a Nemotron hang (~6s per-link timeout) falls
287
- # through to Groq's LPU (~0.3s TTFT) in ~6-7s total β€” well inside the
288
- # 22s chain budget AND the 25s wait_for cap.
289
- "nvidia/nemotron-3-nano-30b-a3b", # ~1.6s TTFT (Reddit bench), NIM
290
- # CROSS-PROVIDER FALLBACK #1 β€” Groq Llama-3.3 70B (LPU, lowest TTFT of
291
- # all free-tier options). Promoted to #2 in KI-079 so a single NIM
292
- # degradation falls through to a non-NIM provider in ~6s, not 22s.
293
- "groq:llama-3.3-70b-versatile",
294
- "qwen/qwen3-next-80b-a3b-instruct", # ~2-3s, NIM
295
- "openai/gpt-oss-120b",
296
- "qwen/qwen3.5-122b-a10b",
297
- "deepseek-ai/deepseek-v4-flash",
298
  ]
299
 
300
- # Judge chain β€” non-Qwen, non-DeepSeek (different family from brain primary)
 
 
 
 
 
301
  JUDGE_CHAIN = [
302
- # Primary: Mistral Large 3 675B β€” different family from Qwen brain
 
303
  "mistralai/mistral-large-3-675b-instruct-2512",
304
- # 1st fallback: OpenAI GPT-OSS 120B β€” different family
305
- "openai/gpt-oss-120b",
306
- # 2nd fallback: Moonshot Kimi K2 β€” different family (Chinese provider)
307
- "moonshotai/kimi-k2-instruct-0905",
308
- # 3rd fallback: MiniMax M2.5 β€” different family
309
- "minimaxai/minimax-m2.5",
310
- # 4th fallback: Meta Llama-4 Maverick (was the original judge β€” back if NIM Llama pool recovers)
311
  "meta/llama-4-maverick-17b-128e-instruct",
312
- # CROSS-PROVIDER FALLBACKS β€” reached only when every NIM judge candidate
313
- # above has failed. Critical for keeping faithfulness Gate 4 + Hinglish
314
- # drift judge alive through a full NIM outage.
315
- # 5th fallback: OpenRouter GPT-OSS 120B (different provider, MIT weights)
316
- "openrouter:openai/gpt-oss-120b",
317
- # 6th fallback: Groq Llama-3.3 70B (different provider, LPU inference)
318
- "groq:llama-3.3-70b-versatile",
319
  ]
320
 
321
 
@@ -414,28 +407,24 @@ class NimChainLLM(LLMProvider):
414
  return model_id.split("/")[-1]
415
 
416
  def _get_worker_for(self, model_id: str, timeout: float) -> LLMProvider:
417
- """Dispatch a chain entry to the right provider client.
418
 
419
- Recognised prefixes:
420
- - 'openrouter:<model>' -> OpenRouterLLM
421
- - 'groq:<model>' -> GroqLLM
422
- - <anything else> -> NvidiaNimLLM (existing default)
 
 
 
423
 
424
  KI-085 β€” passes `chain_name=self._chain_name` so the credit
425
  trackers in the provider clients route their response-header
426
  signals to the right chain state.
427
  """
428
- if model_id.startswith("openrouter:"):
429
- return OpenRouterLLM(
430
- model=model_id[len("openrouter:"):],
431
- timeout=timeout,
432
- chain_name=self._chain_name,
433
- )
434
- if model_id.startswith("groq:"):
435
- return GroqLLM(
436
- model=model_id[len("groq:"):],
437
- timeout=timeout,
438
- chain_name=self._chain_name,
439
  )
440
  return NvidiaNimLLM(model=model_id, api_key=self.api_key, timeout=timeout)
441
 
 
241
  # cross-family-grading invariant survives any failover.
242
 
243
  BRAIN_CHAIN = [
244
+ # KI-155 (2026-05-15) β€” NIM-ONLY ENFORCEMENT. Cross-provider (Groq /
245
+ # OpenRouter) fallbacks REMOVED. Groq's Llama-3.3-70B failed the `<FF>`
246
+ # trailer contract during a fact-find probe and silently flipped the
247
+ # entire pipeline to scripted prompts. Chain is now strictly NIM. Each
248
+ # NIM candidate's `<FF>` adherence has been verified or is structurally
249
+ # safer (different family / smaller routing surface). Pruned candidates
250
+ # that have been "down" for 48+ consecutive probes (qwen3.5-122b,
251
+ # gpt-oss-120b, deepseek-v4-pro) so the election pool only contains
252
+ # demonstrably-healthy NIM models.
253
+ # Primary: Qwen 3-Next 80B β€” 5/5 recent probes ok, clean JSON, multilingual
254
  "qwen/qwen3-next-80b-a3b-instruct",
255
+ # 1st fallback: NVIDIA Nemotron-Super 49B β€” recent 3/3 probes ok, ~354ms
256
+ # latency, different family (nvidia) from Qwen primary β†’ preserves
257
+ # cross-family-grading invariant if elevated to judge.
 
 
 
 
258
  "nvidia/llama-3.3-nemotron-super-49b-v1.5",
259
+ # 2nd fallback: Mistral Large 3 675B β€” recent 3/3 probes ok, different
260
+ # family (mistral). Also the judge primary; only reached when both Qwen +
261
+ # Nemotron are unavailable.
262
+ "mistralai/mistral-large-3-675b-instruct-2512",
263
+ # 3rd fallback: Meta Llama-4 Maverick 17B β€” 5/5 probes ok, different
264
+ # family (meta), keeps the chain alive through a single-family outage.
265
+ "meta/llama-4-maverick-17b-128e-instruct",
 
 
 
 
266
  ]
267
 
268
  # Same chain for fast brain β€” Qwen 80B is already fast (~2s); no need for a
 
271
  # is the lowest-TTFT free-tier option, so a fast-brain fall-through to it is
272
  # still acceptable from a latency-budget standpoint.
273
  FAST_BRAIN_CHAIN = [
274
+ # KI-155 (2026-05-15) β€” NIM-ONLY ENFORCEMENT. Groq Llama-3.3-70B
275
+ # REMOVED from candidate #2 (the KI-079 promotion) after it failed the
276
+ # `<FF>` trailer contract in a live fact-find probe, silently
277
+ # cascading the orchestrator to scripted prompts. Also dropped models
278
+ # that have been "down" for 48+ consecutive probes (nemotron-3-nano-30b
279
+ # = empty_content, qwen3.5-122b = timeout, gpt-oss-120b = empty_content,
280
+ # deepseek-v4-flash = timeout) so the election pool only contains
281
+ # demonstrably-healthy NIM models. With election (KI-080) picking the
282
+ # actually-fastest healthy candidate per turn, chain order matters
283
+ # only for cold-start; the elector handles steady-state.
284
+ # Primary: Qwen 3-Next 80B β€” 5/5 recent probes ok, ~2s, multilingual,
285
+ # verified `<FF>` adherence in production traffic.
286
+ "qwen/qwen3-next-80b-a3b-instruct",
287
+ # 1st fallback: NVIDIA Nemotron-Super 49B β€” 3/3 recent ok, ~354ms
288
+ # (fastest healthy NIM model), different family for diversity.
289
+ "nvidia/llama-3.3-nemotron-super-49b-v1.5",
290
+ # 2nd fallback: Mistral Large 3 675B β€” 3/3 recent ok, different family,
291
+ # keeps fact-find alive through a Qwen+Nemotron simultaneous outage.
292
+ "mistralai/mistral-large-3-675b-instruct-2512",
 
 
 
 
 
 
293
  ]
294
 
295
+ # Judge chain β€” non-Qwen (different family from brain primary so the judge
296
+ # never grades its own family's output).
297
+ # KI-155 (2026-05-15) β€” NIM-ONLY ENFORCEMENT. Groq + OpenRouter REMOVED.
298
+ # Dropped candidates that have been "down" for 48+ consecutive probes
299
+ # (gpt-oss-120b = empty_content, kimi-k2 = http_404, minimax-m2.5 = http_410)
300
+ # so the election pool only contains demonstrably-healthy NIM models.
301
  JUDGE_CHAIN = [
302
+ # Primary: Mistral Large 3 675B β€” 3/3 recent ok, different family
303
+ # (mistral) from Qwen brain, preserves cross-family grading invariant.
304
  "mistralai/mistral-large-3-675b-instruct-2512",
305
+ # 1st fallback: Meta Llama-4 Maverick 17B β€” 5/5 probes ok, different
306
+ # family (meta), original judge primary pre-KI-080.
 
 
 
 
 
307
  "meta/llama-4-maverick-17b-128e-instruct",
308
+ # 2nd fallback: NVIDIA Nemotron-Super 49B β€” 3/3 recent ok, different
309
+ # family (nvidia/nemotron) from Qwen brain. Note: branded "llama" but
310
+ # NVIDIA-finetuned, distinct decision surface.
311
+ "nvidia/llama-3.3-nemotron-super-49b-v1.5",
 
 
 
312
  ]
313
 
314
 
 
407
  return model_id.split("/")[-1]
408
 
409
  def _get_worker_for(self, model_id: str, timeout: float) -> LLMProvider:
410
+ """Dispatch a chain entry to the NIM provider client.
411
 
412
+ KI-155 (2026-05-15) β€” NIM-ONLY ENFORCEMENT. Cross-provider
413
+ (`openrouter:` / `groq:`) prefixes are explicitly rejected here even
414
+ though the chains no longer contain them. This is a defense-in-depth
415
+ short-circuit: if anyone (admin override, monkeypatch, future drift)
416
+ injects a non-NIM candidate into a chain, the dispatcher raises
417
+ rather than silently routing to a provider that has demonstrated
418
+ contract drift (Groq Llama-3.3-70B / `<FF>` trailer failure).
419
 
420
  KI-085 β€” passes `chain_name=self._chain_name` so the credit
421
  trackers in the provider clients route their response-header
422
  signals to the right chain state.
423
  """
424
+ if model_id.startswith(("openrouter:", "groq:", "or:")):
425
+ raise RuntimeError(
426
+ f"NimChainLLM ({self._chain_name}): non-NIM candidate "
427
+ f"'{model_id}' rejected. Chains are NIM-only as of KI-155."
 
 
 
 
 
 
 
428
  )
429
  return NvidiaNimLLM(model=model_id, api_key=self.api_key, timeout=timeout)
430
 
frontend/src/lib/useLiveConversation.ts CHANGED
@@ -7,6 +7,14 @@
7
  * KI-057 (2026-05-15) β€” Noise-robust VAD + flush-on-stop.
8
  * KI-060 (2026-05-15) β€” Silence-end window lengthened (40 β†’ 90 frames,
9
  * ~640 ms β†’ ~1.5 s) so natural mid-sentence pauses don't auto-submit.
 
 
 
 
 
 
 
 
10
  *
11
  * Why KI-057 was needed
12
  * --------------------------------------------------------------------
@@ -209,6 +217,16 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
209
  const noiseFloorRef = useRef<number>(0);
210
  // KI-057 β€” gates "did the bot just stop talking?" cooldown.
211
  const lastUtteranceEndedAtRef = useRef<number>(0);
 
 
 
 
 
 
 
 
 
 
212
 
213
  const onUtteranceRef = useRef(opts.onUtterance);
214
  const onSpeechStartRef = useRef(opts.onSpeechStart);
@@ -239,8 +257,99 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
239
  } catch {}
240
  });
241
  }
 
 
 
 
 
 
 
242
  }, []);
243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  // KI-044 β€” open speech capture: snapshot the preroll into speechBuffer,
245
  // flag recording, fire callbacks. The PCM keeps flowing via the worklet
246
  // port; we just toggle where it lands.
@@ -346,9 +455,19 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
346
 
347
  // KI-057 β€” suppress new triggers right after we closed a segment
348
  // (bot's TTS onset can bleed in via the mic loopback).
 
 
 
 
349
  const cooldownActive =
 
350
  Date.now() - lastUtteranceEndedAtRef.current < cfg.postUtteranceCooldownMs;
351
 
 
 
 
 
 
352
  const speechLike =
353
  avg > effectiveThreshold &&
354
  voiceProp >= cfg.voiceBandMinProp &&
@@ -357,7 +476,7 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
357
  if (speechLike) {
358
  loud++;
359
  quiet = 0;
360
- if (loud === cfg.speechStartFrames && !recordingRef.current) {
361
  // Barge in: kill bot audio + cancel in-flight chat + begin capture.
362
  interruptBotAudio();
363
  if (inflightAbortRef.current) {
@@ -371,7 +490,13 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
371
  loud = 0;
372
  // KI-057 β€” only learn the noise floor while idle, so ongoing
373
  // speech doesn't poison the EMA.
374
- if (!recordingRef.current) {
 
 
 
 
 
 
375
  noiseFloorRef.current =
376
  noiseFloorRef.current === 0
377
  ? avg
@@ -380,6 +505,32 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
380
  if (quiet === cfg.silenceEndFrames && recordingRef.current) {
381
  void endSpeechCapture();
382
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  }
384
 
385
  // KI-057 β€” max-utterance cap. If recording has run too long
 
7
  * KI-057 (2026-05-15) β€” Noise-robust VAD + flush-on-stop.
8
  * KI-060 (2026-05-15) β€” Silence-end window lengthened (40 β†’ 90 frames,
9
  * ~640 ms β†’ ~1.5 s) so natural mid-sentence pauses don't auto-submit.
10
+ * KI-159 (2026-05-15) β€” Early-close on stable silence. If the user has
11
+ * already spoken β‰₯3Γ— minUtteranceMs (~1.2 s) and silence has accumulated
12
+ * to half the silenceEndFrames window (~1.5 s), close the segment NOW.
13
+ * Prevents notification dings / transient background noise mid-pause
14
+ * from re-triggering `speechLike`, zeroing the silence counter, and
15
+ * extending the segment until either the full 3 s window or the 18 s
16
+ * max-cap fires β€” by which point the real words are buried in a bloated
17
+ * blob that Sarvam STT either drops or mis-transcribes.
18
  *
19
  * Why KI-057 was needed
20
  * --------------------------------------------------------------------
 
217
  const noiseFloorRef = useRef<number>(0);
218
  // KI-057 β€” gates "did the bot just stop talking?" cooldown.
219
  const lastUtteranceEndedAtRef = useRef<number>(0);
220
+ // KI-141 (2026-05-15) β€” TTS-playback awareness for reliable barge-in.
221
+ // When the bot's <audio> element is playing, the mic re-captures the
222
+ // speaker output (echoCancellation is imperfect). The noiseFloor EMA
223
+ // would otherwise learn the bot's voice level and pull effectiveThreshold
224
+ // up to bot-loudness, making user voice unable to clear the gate. We
225
+ // (a) freeze noise-floor learning, (b) bypass the post-utterance cooldown
226
+ // (cooldown only makes sense AFTER bot finishes), and (c) drop the
227
+ // speech-start frame count so barge-in fires in ~30 ms instead of ~80 ms.
228
+ const ttsPlayingRef = useRef<boolean>(false);
229
+ const ttsAudioElementsRef = useRef<Set<HTMLAudioElement>>(new Set());
230
 
231
  const onUtteranceRef = useRef(opts.onUtterance);
232
  const onSpeechStartRef = useRef(opts.onSpeechStart);
 
257
  } catch {}
258
  });
259
  }
260
+ // KI-141 β€” clearing TTS-playing immediately on barge-in so the VAD
261
+ // resumes idle-mode noise-floor learning even before the `pause`
262
+ // event fires on the (now stopped) <audio> element.
263
+ ttsPlayingRef.current = false;
264
+ // KI-141 β€” anchor cooldown to barge-in moment so the next 700 ms
265
+ // suppresses any residual decay tail / echo from the just-paused TTS.
266
+ lastUtteranceEndedAtRef.current = Date.now();
267
  }, []);
268
 
269
+ // KI-141 (2026-05-15) β€” TTS-playback observer.
270
+ // Watch every <audio> element in the document for play/pause/ended so
271
+ // the VAD knows when the bot is currently speaking. This is the signal
272
+ // that flips the VAD into "barge-in mode" (no cooldown, faster start,
273
+ // frozen noise floor). MutationObserver picks up new <audio> elements
274
+ // as Message components mount.
275
+ useEffect(() => {
276
+ if (!live || typeof document === "undefined") return;
277
+
278
+ const tracked = ttsAudioElementsRef.current;
279
+
280
+ const refreshPlayingState = () => {
281
+ let anyPlaying = false;
282
+ tracked.forEach((a) => {
283
+ if (!a.paused && !a.ended && a.currentTime > 0) anyPlaying = true;
284
+ });
285
+ ttsPlayingRef.current = anyPlaying;
286
+ };
287
+
288
+ const onPlay = () => {
289
+ ttsPlayingRef.current = true;
290
+ };
291
+ const onPauseOrEnded = () => {
292
+ refreshPlayingState();
293
+ // KI-141 β€” anchor the post-utterance cooldown to the moment TTS
294
+ // actually finished, not to when the user's previous segment closed.
295
+ // This is what the 700 ms cooldown was always meant to gate: the
296
+ // bot's tail decay / echo bleeding back into the mic.
297
+ if (!ttsPlayingRef.current) {
298
+ lastUtteranceEndedAtRef.current = Date.now();
299
+ }
300
+ };
301
+
302
+ const attach = (a: HTMLAudioElement) => {
303
+ if (tracked.has(a)) return;
304
+ tracked.add(a);
305
+ a.addEventListener("play", onPlay);
306
+ a.addEventListener("playing", onPlay);
307
+ a.addEventListener("pause", onPauseOrEnded);
308
+ a.addEventListener("ended", onPauseOrEnded);
309
+ a.addEventListener("emptied", onPauseOrEnded);
310
+ // If the element is already playing when we attach, capture that.
311
+ if (!a.paused && !a.ended) ttsPlayingRef.current = true;
312
+ };
313
+
314
+ const detach = (a: HTMLAudioElement) => {
315
+ a.removeEventListener("play", onPlay);
316
+ a.removeEventListener("playing", onPlay);
317
+ a.removeEventListener("pause", onPauseOrEnded);
318
+ a.removeEventListener("ended", onPauseOrEnded);
319
+ a.removeEventListener("emptied", onPauseOrEnded);
320
+ tracked.delete(a);
321
+ };
322
+
323
+ // Attach to anything already in the DOM.
324
+ document.querySelectorAll("audio").forEach((el) => attach(el as HTMLAudioElement));
325
+
326
+ const observer = new MutationObserver((mutations) => {
327
+ for (const m of mutations) {
328
+ m.addedNodes.forEach((n) => {
329
+ if (n instanceof HTMLAudioElement) attach(n);
330
+ else if (n instanceof Element) {
331
+ n.querySelectorAll("audio").forEach((el) => attach(el as HTMLAudioElement));
332
+ }
333
+ });
334
+ m.removedNodes.forEach((n) => {
335
+ if (n instanceof HTMLAudioElement) detach(n);
336
+ else if (n instanceof Element) {
337
+ n.querySelectorAll("audio").forEach((el) => detach(el as HTMLAudioElement));
338
+ }
339
+ });
340
+ }
341
+ refreshPlayingState();
342
+ });
343
+ observer.observe(document.body, { childList: true, subtree: true });
344
+
345
+ return () => {
346
+ observer.disconnect();
347
+ tracked.forEach((a) => detach(a));
348
+ tracked.clear();
349
+ ttsPlayingRef.current = false;
350
+ };
351
+ }, [live]);
352
+
353
  // KI-044 β€” open speech capture: snapshot the preroll into speechBuffer,
354
  // flag recording, fire callbacks. The PCM keeps flowing via the worklet
355
  // port; we just toggle where it lands.
 
455
 
456
  // KI-057 β€” suppress new triggers right after we closed a segment
457
  // (bot's TTS onset can bleed in via the mic loopback).
458
+ // KI-141 β€” but DO NOT suppress during TTS playback itself; that's
459
+ // exactly when barge-in must work. Cooldown is only meaningful for
460
+ // the brief window after bot speech ends.
461
+ const ttsPlaying = ttsPlayingRef.current;
462
  const cooldownActive =
463
+ !ttsPlaying &&
464
  Date.now() - lastUtteranceEndedAtRef.current < cfg.postUtteranceCooldownMs;
465
 
466
+ // KI-141 β€” barge-in must be SNAPPY. During TTS, 2 frames (~30 ms) is
467
+ // enough to confirm voice and pause the bot; the longer 5-frame gate
468
+ // is only needed in idle mode where it rejects click/clack transients.
469
+ const startFrames = ttsPlaying ? 2 : cfg.speechStartFrames;
470
+
471
  const speechLike =
472
  avg > effectiveThreshold &&
473
  voiceProp >= cfg.voiceBandMinProp &&
 
476
  if (speechLike) {
477
  loud++;
478
  quiet = 0;
479
+ if (loud >= startFrames && !recordingRef.current) {
480
  // Barge in: kill bot audio + cancel in-flight chat + begin capture.
481
  interruptBotAudio();
482
  if (inflightAbortRef.current) {
 
490
  loud = 0;
491
  // KI-057 β€” only learn the noise floor while idle, so ongoing
492
  // speech doesn't poison the EMA.
493
+ // KI-141 β€” also freeze noise-floor learning while TTS is playing.
494
+ // The bot's voice bleeding through the speakers would otherwise be
495
+ // EMA'd into the floor, pulling effectiveThreshold up to bot loudness
496
+ // β€” at which point the user's voice can't clear it. Holding the
497
+ // pre-TTS noise floor keeps the gate at room-ambient level where
498
+ // user speech reliably crosses.
499
+ if (!recordingRef.current && !ttsPlaying) {
500
  noiseFloorRef.current =
501
  noiseFloorRef.current === 0
502
  ? avg
 
505
  if (quiet === cfg.silenceEndFrames && recordingRef.current) {
506
  void endSpeechCapture();
507
  }
508
+ // KI-159 (2026-05-15) β€” early-close on stable silence after enough
509
+ // captured speech. Protects against transient noise bursts (e.g. a
510
+ // notification ding mid-pause) that would otherwise re-trigger
511
+ // speechLike, zero `quiet`, and extend the segment until either the
512
+ // full silenceEndFrames (180 β‰ˆ 3 s) accumulates AGAIN or the
513
+ // maxUtteranceMs (18 s) hard-cap fires β€” by which point the real
514
+ // words are buried in a bloated blob that Sarvam STT mis-transcribes
515
+ // or returns empty for.
516
+ //
517
+ // Trigger: half the silence window AND we already have 3Γ— the
518
+ // minUtteranceMs (~1.2 s) of captured speech. Submit the user's
519
+ // words IMMEDIATELY at the first stable pause, before any noise can
520
+ // contaminate the segment.
521
+ else if (
522
+ recordingRef.current &&
523
+ quiet >= Math.floor(cfg.silenceEndFrames / 2) &&
524
+ recStartTsRef.current > 0 &&
525
+ Date.now() - recStartTsRef.current >= cfg.minUtteranceMs * 3
526
+ ) {
527
+ // eslint-disable-next-line no-console
528
+ console.debug(
529
+ "[live-mode] early-close on stable silence (KI-159)",
530
+ { quiet, durationMs: Date.now() - recStartTsRef.current },
531
+ );
532
+ void endSpeechCapture();
533
+ }
534
  }
535
 
536
  // KI-057 β€” max-utterance cap. If recording has run too long
kb/INDEX.md CHANGED
@@ -1,10 +1,10 @@
1
  # Knowledge Base β€” Insurance Sales Bot
2
 
3
- _Last synced: 2026-05-14._
4
 
5
  Canonical knowledge base for the Insurance Sales Bot. Every user-facing answer, scorecard, and comparison surface must trace back to a file in this directory.
6
 
7
- ## Policies (253)
8
 
9
  | Insurer | Policy | UIN | Completeness | KB path |
10
  | --- | --- | --- | --- | --- |
@@ -27,7 +27,7 @@ Canonical knowledge base for the Insurance Sales Bot. Every user-facing answer,
27
  | Bajaj Allianz General Insurance | Criti Care | `BAJHLIP21273V012021` | 9% | [`policies/bajaj-allianz__criti-care__wordings.md`](policies/bajaj-allianz__criti-care__wordings.md) |
28
  | Bajaj Allianz General Insurance | Extra Care Plus | `BAJHLIP21321V012021` | β€” | [`policies/bajaj-allianz__extra-care-plus__wordings.md`](policies/bajaj-allianz__extra-care-plus__wordings.md) |
29
  | Bajaj Allianz General Insurance | Global Health Care Policy | `BAJHLIP23209V022223` | β€” | [`policies/bajaj-allianz__global-health-care__wordings.md`](policies/bajaj-allianz__global-health-care__wordings.md) |
30
- | Bajaj Allianz General Insurance | Group Health Guard Gold | `BAJHLGP21181V022021` | 32% | [`policies/bajaj-allianz__group-health-guard-gold__wordings.md`](policies/bajaj-allianz__group-health-guard-gold__wordings.md) |
31
  | Bajaj Allianz General Insurance | Group Personal Accident | `β€”` | 5% | [`policies/bajaj-allianz__group-personal-accident__wordings.md`](policies/bajaj-allianz__group-personal-accident__wordings.md) |
32
  | Bajaj Allianz General Insurance | Health Guard | `BAJHLIP25035V072425` | β€” | [`policies/bajaj-allianz__health-guard__wordings.md`](policies/bajaj-allianz__health-guard__wordings.md) |
33
  | Bajaj Allianz General Insurance | Health Guard Gold Individual | `BAJHLIP21185V032021` | 41% | [`policies/bajaj-allianz__health-guard-gold-individual__wordings.md`](policies/bajaj-allianz__health-guard-gold-individual__wordings.md) |
@@ -231,9 +231,9 @@ Canonical knowledge base for the Insurance Sales Bot. Every user-facing answer,
231
  | oriental-insurance | Happy Family Floater Policy - 2021 | `OICHLIP22010V042223` | β€” | [`policies/oriental-insurance__happy-family-floater__wordings.md`](policies/oriental-insurance__happy-family-floater__wordings.md) |
232
  | oriental-insurance | Oriental Mediclaim Insurance Policy (Individual) | `OICHLIP23084V042223` | β€” | [`policies/oriental-insurance__oriental-mediclaim-individual__cis.md`](policies/oriental-insurance__oriental-mediclaim-individual__cis.md) |
233
  | oriental-insurance | Oriental Mediclaim Insurance Policy (Individual) | `OICHLIP23084V042223` | β€” | [`policies/oriental-insurance__oriental-mediclaim-individual__wordings.md`](policies/oriental-insurance__oriental-mediclaim-individual__wordings.md) |
234
- | reliance-general | IndusInd Group Mediclaim | `RELHLGP21523V022021` | β€” | [`policies/reliance-general__group-mediclaim__wordings.md`](policies/reliance-general__group-mediclaim__wordings.md) |
235
- | reliance-general | IndusInd HealthGain Policy | `INIHLIP26040V042526` | β€” | [`policies/reliance-general__health-gain__wordings.md`](policies/reliance-general__health-gain__wordings.md) |
236
- | reliance-general | IndusInd Hospi Care Insurance | `RELHLIP20027V012021` | β€” | [`policies/reliance-general__hospi-care__wordings.md`](policies/reliance-general__hospi-care__wordings.md) |
237
  | reliance-general | Personal Accident Policy | `RELPAGP01001V010001` | β€” | [`policies/reliance-general__personal-accident__wordings.md`](policies/reliance-general__personal-accident__wordings.md) |
238
  | royal-sundaram | Advanced Top Up Health Insurance Plan | `RSAHLIP23029V012223` | β€” | [`policies/royal-sundaram__advanced-top-up__brochure.md`](policies/royal-sundaram__advanced-top-up__brochure.md) |
239
  | royal-sundaram | Advanced Top Up Health Insurance Plan | `RSAHLIP23029V012223` | β€” | [`policies/royal-sundaram__advanced-top-up__cis.md`](policies/royal-sundaram__advanced-top-up__cis.md) |
@@ -283,7 +283,7 @@ Canonical knowledge base for the Insurance Sales Bot. Every user-facing answer,
283
  kb/
284
  β”œβ”€β”€ INDEX.md (this file)
285
  β”œβ”€β”€ AUDIT_TRAIL.md (data lineage + curation history)
286
- β”œβ”€β”€ policies/<policy_id>.md (255 files β€” one per curated policy)
287
  β”œβ”€β”€ methodology/
288
  β”‚ β”œβ”€β”€ scorecard.json (6 sub-scores + weights + anchors)
289
  β”‚ β”œβ”€β”€ glossary.json (13 terms Γ— en/hi)
 
1
  # Knowledge Base β€” Insurance Sales Bot
2
 
3
+ _Last synced: 2026-05-15._
4
 
5
  Canonical knowledge base for the Insurance Sales Bot. Every user-facing answer, scorecard, and comparison surface must trace back to a file in this directory.
6
 
7
+ ## Policies (222)
8
 
9
  | Insurer | Policy | UIN | Completeness | KB path |
10
  | --- | --- | --- | --- | --- |
 
27
  | Bajaj Allianz General Insurance | Criti Care | `BAJHLIP21273V012021` | 9% | [`policies/bajaj-allianz__criti-care__wordings.md`](policies/bajaj-allianz__criti-care__wordings.md) |
28
  | Bajaj Allianz General Insurance | Extra Care Plus | `BAJHLIP21321V012021` | β€” | [`policies/bajaj-allianz__extra-care-plus__wordings.md`](policies/bajaj-allianz__extra-care-plus__wordings.md) |
29
  | Bajaj Allianz General Insurance | Global Health Care Policy | `BAJHLIP23209V022223` | β€” | [`policies/bajaj-allianz__global-health-care__wordings.md`](policies/bajaj-allianz__global-health-care__wordings.md) |
30
+ | Bajaj Allianz General Insurance | Group Health Guard Silver | `BAJHLGP21181V022021` | 32% | [`policies/bajaj-allianz__group-health-guard-silver__wordings.md`](policies/bajaj-allianz__group-health-guard-silver__wordings.md) |
31
  | Bajaj Allianz General Insurance | Group Personal Accident | `β€”` | 5% | [`policies/bajaj-allianz__group-personal-accident__wordings.md`](policies/bajaj-allianz__group-personal-accident__wordings.md) |
32
  | Bajaj Allianz General Insurance | Health Guard | `BAJHLIP25035V072425` | β€” | [`policies/bajaj-allianz__health-guard__wordings.md`](policies/bajaj-allianz__health-guard__wordings.md) |
33
  | Bajaj Allianz General Insurance | Health Guard Gold Individual | `BAJHLIP21185V032021` | 41% | [`policies/bajaj-allianz__health-guard-gold-individual__wordings.md`](policies/bajaj-allianz__health-guard-gold-individual__wordings.md) |
 
231
  | oriental-insurance | Happy Family Floater Policy - 2021 | `OICHLIP22010V042223` | β€” | [`policies/oriental-insurance__happy-family-floater__wordings.md`](policies/oriental-insurance__happy-family-floater__wordings.md) |
232
  | oriental-insurance | Oriental Mediclaim Insurance Policy (Individual) | `OICHLIP23084V042223` | β€” | [`policies/oriental-insurance__oriental-mediclaim-individual__cis.md`](policies/oriental-insurance__oriental-mediclaim-individual__cis.md) |
233
  | oriental-insurance | Oriental Mediclaim Insurance Policy (Individual) | `OICHLIP23084V042223` | β€” | [`policies/oriental-insurance__oriental-mediclaim-individual__wordings.md`](policies/oriental-insurance__oriental-mediclaim-individual__wordings.md) |
234
+ | indusind-general | IndusInd Group Mediclaim | `RELHLGP21523V022021` | β€” | [`policies/indusind-general__group-mediclaim__wordings.md`](policies/indusind-general__group-mediclaim__wordings.md) |
235
+ | indusind-general | IndusInd HealthGain Policy | `INIHLIP26040V042526` | β€” | [`policies/indusind-general__health-gain__wordings.md`](policies/indusind-general__health-gain__wordings.md) |
236
+ | indusind-general | IndusInd Hospi Care Insurance | `RELHLIP20027V012021` | β€” | [`policies/indusind-general__hospi-care__wordings.md`](policies/indusind-general__hospi-care__wordings.md) |
237
  | reliance-general | Personal Accident Policy | `RELPAGP01001V010001` | β€” | [`policies/reliance-general__personal-accident__wordings.md`](policies/reliance-general__personal-accident__wordings.md) |
238
  | royal-sundaram | Advanced Top Up Health Insurance Plan | `RSAHLIP23029V012223` | β€” | [`policies/royal-sundaram__advanced-top-up__brochure.md`](policies/royal-sundaram__advanced-top-up__brochure.md) |
239
  | royal-sundaram | Advanced Top Up Health Insurance Plan | `RSAHLIP23029V012223` | β€” | [`policies/royal-sundaram__advanced-top-up__cis.md`](policies/royal-sundaram__advanced-top-up__cis.md) |
 
283
  kb/
284
  β”œβ”€β”€ INDEX.md (this file)
285
  β”œβ”€β”€ AUDIT_TRAIL.md (data lineage + curation history)
286
+ β”œβ”€β”€ policies/<policy_id>.md (222 files β€” one per curated policy)
287
  β”œβ”€β”€ methodology/
288
  β”‚ β”œβ”€β”€ scorecard.json (6 sub-scores + weights + anchors)
289
  β”‚ β”œβ”€β”€ glossary.json (13 terms Γ— en/hi)
rag/README.md CHANGED
@@ -28,7 +28,7 @@ Lineage for every artefact below is documented in [`kb/AUDIT_TRAIL.md`](../kb/AU
28
  | Path | Source of truth | Notes |
29
  | --- | --- | --- |
30
  | `rag/corpus/<insurer>/*.pdf` | insurer CDNs | 206 PDFs (188 product PDFs across 19 insurers + 18 regulatory IRDAI/NHA docs). Not in git β€” hydrated at Docker build from the companion HF dataset. |
31
- | `rag/extracted/<policy_id>.json` | `extract.py` | 206 JSONs, one per policy, conforming to `schema.HealthPolicy`. Generated; never hand-edit. |
32
  | `rag/vectors/chroma.sqlite3` + HNSW binaries | `ingest.py` | Persistent Chroma store. Symlinked to `rag/_hf_dataset_backup/rag/vectors/` for the offline canonical copy. |
33
  | `rag/policies.duckdb` | `extract.py` | DuckDB rollup of the 62-field JSONs; used for SQL-style filters in `backend/main.py`. |
34
  | `rag/source_map.json` | `source_map.py` | chunk_id β†’ (pdf_path, page, span) for the citation links shown in the UI. |
 
28
  | Path | Source of truth | Notes |
29
  | --- | --- | --- |
30
  | `rag/corpus/<insurer>/*.pdf` | insurer CDNs | 206 PDFs (188 product PDFs across 19 insurers + 18 regulatory IRDAI/NHA docs). Not in git β€” hydrated at Docker build from the companion HF dataset. |
31
+ | `rag/extracted/<policy_id>.json` | `extract.py` | 201 JSONs, one per policy, conforming to `schema.HealthPolicy`. Generated; never hand-edit. |
32
  | `rag/vectors/chroma.sqlite3` + HNSW binaries | `ingest.py` | Persistent Chroma store. Symlinked to `rag/_hf_dataset_backup/rag/vectors/` for the offline canonical copy. |
33
  | `rag/policies.duckdb` | `extract.py` | DuckDB rollup of the 62-field JSONs; used for SQL-style filters in `backend/main.py`. |
34
  | `rag/source_map.json` | `source_map.py` | chunk_id β†’ (pdf_path, page, span) for the citation links shown in the UI. |
rag/SCHEMA.md CHANGED
@@ -94,3 +94,18 @@ The schema is forward-compatible without breaking changes:
94
  queries like "policies with PED waiting < 24 months" can still match).
95
  - The original policy wordings PDF stays in `rag/corpus/` for citation
96
  fallback. The schema's `source_pdf_path` field is the link back.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  queries like "policies with PED waiting < 24 months" can still match).
95
  - The original policy wordings PDF stays in `rag/corpus/` for citation
96
  fallback. The schema's `source_pdf_path` field is the link back.
97
+
98
+ ## Chroma chunk metadata
99
+
100
+ Each chunk persisted in Chroma carries the following metadata keys (set by
101
+ `rag/ingest.py`):
102
+
103
+ | Key | Type | Notes |
104
+ |---|---|---|
105
+ | `policy_id` | str | e.g. `aditya-birla__activ-one`. Primary filter for per-policy retrieval. |
106
+ | `insurer_slug` | str | e.g. `aditya-birla`. Secondary filter. |
107
+ | `source_pdf` | str | Relative path under `rag/corpus/`. |
108
+ | `page` | int | 1-indexed PDF page number. |
109
+ | `chunk_index` | int | Position within the policy's chunk sequence. |
110
+ | `doc_type` | str | `'wordings'` / `'brochure'` / `'cis'` / `'prospectus'` / `'curated'`. **`'curated'` (KI-137)** marks chunks ingested from hand-curated `40-data/policy_facts/<id>.json` rather than raw PDF text. |
111
+ | `legacy_issuer` | str (optional) | **KI-144.** Present on `indusind-general__*` chunks whose source PDFs carry the previous `reliance-general` issuer branding. Value: `'reliance-general'`. Lets retrieval surface legacy citations without breaking the canonical slug. |