Spaces:
Sleeping
fix(voice+chat+llm+docs): KI-155 + KI-156 + KI-157 + KI-158 + KI-159 + KI-160 bundle
Browse filesSix 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 +3 -3
- 70-docs/60-decisions/ADR-031-sticky-primary-election.md +3 -1
- 70-docs/60-decisions/ADR-032-llm-chain-architecture.md +9 -1
- 70-docs/60-decisions/ADR-033-marketplace-dedup-uin-rule.md +36 -0
- 70-docs/60-decisions/ADR-034-curated-facts-as-chroma-chunks.md +35 -0
- 70-docs/60-decisions/ADR-035-insurer-slug-filtering-marketplace.md +40 -0
- 70-docs/60-decisions/ADR-036-voice-vad-threshold-tuning.md +40 -0
- 70-docs/60-decisions/ADR-037-indusind-general-slug.md +43 -0
- 70-docs/60-decisions/ADR-038-nim-only-chains.md +44 -0
- CLAUDE.md +18 -14
- README.md +36 -14
- backend/fact_find_brain.py +106 -9
- backend/fact_find_normalizer.py +31 -2
- backend/providers/nvidia_nim_llm.py +65 -76
- frontend/src/lib/useLiveConversation.ts +153 -2
- kb/INDEX.md +7 -7
- rag/README.md +1 -1
- rag/SCHEMA.md +15 -0
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Insurance Sales Bot β Information Source Map
|
| 2 |
|
| 3 |
-
Generated: 2026-05-
|
| 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-
|
| 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-
|
| 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 |
|
|
@@ -1,6 +1,8 @@
|
|
| 1 |
# ADR-031 β Sticky primary election for LLM chains
|
| 2 |
|
| 3 |
-
**Status:**
|
|
|
|
|
|
|
| 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).
|
|
@@ -1,6 +1,14 @@
|
|
| 1 |
# ADR-032 β LLM Chain Architecture Reference
|
| 2 |
|
| 3 |
-
**Status:**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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.
|
|
@@ -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,
|
| 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
|
| 24 |
|
| 25 |
-
- **
|
| 26 |
-
- **
|
| 27 |
-
- **Probe cadence + per-phase timeouts (KI-084, `119e0fd`).** Probe loop ticks at `PROBE_INTERVAL_SEC = 300s`
|
| 28 |
-
- **Proactive credit gating (KI-085, `8fc7979`).** Election is gated by `is_alive AND has_credits` so quota-exhausted
|
| 29 |
-
- **HF Space secrets (KI-081
|
| 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.
|
| 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]`
|
| 33 |
-
- **Brain / fast-brain / judge primaries in steady state** are
|
| 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).*
|
|
@@ -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
|
| 75 |
|
| 76 |
-
- **`BRAIN_CHAIN`** (comparison, recommendation, synthesis) β primary **Qwen 3-Next 80B** (`qwen/qwen3-next-80b-a3b-instruct`, 80B / 3B-active MoE
|
| 77 |
-
- **`FAST_BRAIN_CHAIN`** (fact-find turns, QA, paraphrase, normalize, extract β every latency-sensitive role) β primary **
|
| 78 |
-
- **`JUDGE_CHAIN`** (faithfulness Gate 4, Hinglish drift LLM-judge, eval grader) β primary **Mistral Large 3 675B** (`mistralai/mistral-large-3-675b-instruct-2512`
|
| 79 |
|
| 80 |
-
**
|
| 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
|
| 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) |
|
| 602 |
-
|
|
|
|
|
|
|
|
| 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
|
| 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
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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", "
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 β
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 877 |
reply = (
|
| 878 |
-
"
|
| 879 |
-
"
|
| 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=
|
| 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 |
)
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = (
|
|
@@ -241,27 +241,28 @@ class NvidiaNimLLM(LLMProvider):
|
|
| 241 |
# cross-family-grading invariant survives any failover.
|
| 242 |
|
| 243 |
BRAIN_CHAIN = [
|
| 244 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
"qwen/qwen3-next-80b-a3b-instruct",
|
| 246 |
-
# 1st fallback:
|
| 247 |
-
|
| 248 |
-
#
|
| 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 |
-
#
|
| 255 |
-
|
| 256 |
-
#
|
| 257 |
-
"
|
| 258 |
-
#
|
| 259 |
-
#
|
| 260 |
-
|
| 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-
|
| 274 |
-
#
|
| 275 |
-
#
|
| 276 |
-
#
|
| 277 |
-
#
|
| 278 |
-
#
|
| 279 |
-
#
|
| 280 |
-
#
|
| 281 |
-
#
|
| 282 |
-
#
|
| 283 |
-
#
|
| 284 |
-
#
|
| 285 |
-
|
| 286 |
-
#
|
| 287 |
-
#
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
#
|
| 291 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
JUDGE_CHAIN = [
|
| 302 |
-
# Primary: Mistral Large 3 675B β
|
|
|
|
| 303 |
"mistralai/mistral-large-3-675b-instruct-2512",
|
| 304 |
-
# 1st fallback:
|
| 305 |
-
|
| 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 |
-
#
|
| 313 |
-
#
|
| 314 |
-
#
|
| 315 |
-
|
| 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
|
| 418 |
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 430 |
-
|
| 431 |
-
|
| 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 |
|
|
@@ -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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -1,10 +1,10 @@
|
|
| 1 |
# Knowledge Base β Insurance Sales Bot
|
| 2 |
|
| 3 |
-
_Last synced: 2026-05-
|
| 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 (
|
| 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
|
| 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 |
-
|
|
| 235 |
-
|
|
| 236 |
-
|
|
| 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 (
|
| 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)
|
|
@@ -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` |
|
| 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. |
|
|
@@ -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. |
|