rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
bcb7079
·
1 Parent(s): 244fa54

fix: KI-018..022 — routing, profile telemetry, NIM timeouts, judge JSON, chat clear

Browse files

Five interlocking enterprise-readiness fixes plus a master audit report.

KI-018 — orchestrator.py: stop force-routing QA intent to fact-find when the
session profile is empty. The KI-013 guard was over-correcting: every QA
question ("What's the waiting period for PED in Activ Assure?") was being
answered with "Happy to help. First, your age?". Restrict the empty-profile
guard to {recommendation, comparison} intents only. 5-Q post-fix smoke:
factual accuracy 0% → 60%; nim-chain serving 100% of QA (was 0%). Full
re-eval owed.

KI-019 — orchestrator.py: populate TurnResult.profile_updates from the
fact-find branch (previously only the free-form branch populated it). The
100-persona audit appeared to show age captured for only 12/100 personas
when the slot-filler was actually working — confirmed by the
fact_find_complete readback "42 years old; covering self+spouse+kids+
parents; income 25L+". This was a telemetry bug, not a slot-filler bug.

KI-020 — main.py / session_state.py / frontend: user-facing chat clear &
fresh-start toggles. Two buttons above the message list — "Clear chat"
(wipes visible chat, keeps profile) and "Start fresh" (confirms, then nukes
session server-side + returns a fresh session_id). Backend gets a new
POST /api/session/reset endpoint and a reset_session() helper.

KI-021 — nvidia_nim_llm.py: cumulative chain budget on the NIM brain
fallback chain. Worst-case 8-fallback × 30s timeout = 240s/turn produced
the 100-persona p95 49s tail. Per-link timeouts cut to 20s (brain) / 12s
(fast brain); cumulative budgets capped at 35s / 22s. Per-link timeout is
now clipped to remaining budget so the final link can't blow past the
ceiling. Judge chain kept at 30s/75s (offline grading, latency-insensitive).

KI-022 — eval/run.py: regex-grader fallback when the Groq judge returns
unparseable JSON. 11/96 questions on the 2026-05-14 baseline failed with
JSONDecodeError and were scored 0 falsely. New _parse_judge_json() tries
strict-parse, then trailing-comma-and-missing-brace repair; on irrecoverable
parse failure, fall through to the existing regex grader instead of
dropping a 0.

D-001 hardening — rag/ingest.py + tools/ingest_kb_summaries.py +
tools/ingest_reviews.py: in-process HNSW bloat tripwire. After every
collection.add(...) we check link_lists.bin size; if it exceeds 500 MB
(500x normal for a 5K-chunk corpus) the run aborts with a loud
RuntimeError. Backs the LaunchAgent watchers installed in ~/Library
yesterday (vectorbloat + disk-free-tripwire).

D-009 — removed three tmp_*.py scripts from the repo root (extract /
count_fields / batch_extract). These were ad-hoc debug helpers; should
never have been tracked.

audit_results/ENTERPRISE_AUDIT.md — master defect log for the
top-Indian-insurer readiness review. Tabulates every P0/P1/P2 finding
from today's session with severity, evidence, fix status. Pre-fix
baseline: factual 41.7%, p95 49s, 263 refusals / 3000 turns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

audit_results/ENTERPRISE_AUDIT.md ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Enterprise-Grade Readiness Audit
2
+
3
+ **Target deployment:** top-tier Indian insurance companies (HDFC ERGO, ICICI Lombard, Bajaj Allianz, Star Health, Tata AIG, etc.)
4
+ **Audit date:** 2026-05-14
5
+ **Audit window:** active — this is a living document. Background audits (100-persona simulation + full gold-QA eval) running concurrently; results merge here as they land.
6
+
7
+ ---
8
+
9
+ ## Executive scorecard
10
+
11
+ | Domain | Status | Severity if not fixed |
12
+ |---|---|---|
13
+ | Disk / storage stability | ✅ Fixed (3-layer prevention installed) | — |
14
+ | Data pipeline integrity | ✅ Fixed (HF Hub canonical restored, in-process HNSW tripwire added) | — |
15
+ | Operational observability | ⚠️ Partial (silent-LaunchAgent regression discovered + fixed; broader `except Exception:` audit pending) | P0 if regulated workload |
16
+ | Product quality (factual accuracy) | 🟡 Root-caused + partial fix shipped (was 41.7% full, 60% on 5-Q post-fix smoke; D-003 routing bug fixed in `orchestrator.py`) | **P0 — blocks deployment until ≥90%** |
17
+ | UX latency | 🔴 Open + DEGRADED on full sample (p50 9.9s, p95 49.1s, p99 58.9s — 100 personas, 3000 turns) | **P0** — unacceptable for real-time chat |
18
+ | Profile-capture / slot-filling | 🔴 CONFIRMED REAL BUG (age captured for only 12/100 personas; not a D-003 cascade) | **P0** — recommendation flow broken for 88% of users |
19
+ | Language-handling fairness | ⚠️ Open (hinglish + stream-style refuse 2× more than other styles) | P1 — India-market regulatory risk |
20
+ | Code hygiene | 🟡 Improving (loose tmp files removed; bare-except blocks documented) | P2 |
21
+ | Test coverage | 🔴 Open (no unit tests; only `live_verify.py`) | P1 — required by enterprise security review |
22
+ | Secrets handling | ✅ Verified clean (.env never committed) | — |
23
+
24
+ Legend: ✅ fixed / ⚠️ partial / 🟡 improving / 🔴 open
25
+
26
+ ---
27
+
28
+ ## Defect Register
29
+
30
+ Each row: ID · severity · title · evidence · fix status. P0 = blocks production deployment. P1 = blocks enterprise procurement. P2 = quality / hygiene. P3 = nice-to-have.
31
+
32
+ ### D-001 · P0 · ChromaDB HNSW link_lists.bin runaway growth — **FIXED**
33
+ **Symptom:** 2026-05-14 15:18 — `rag/_hf_dataset_backup/rag/vectors/148fbdda-…/link_lists.bin` reached 277 GB logical / 136 GB on-disk for only 12 MB of actual vector data (5K chunks). Disk filled from ~137 GB free → 50 MiB free in ~45 min.
34
+ **Root cause:** ChromaDB 1.5.9 HNSW persistence pathology — known issue where the link-graph adjacency file accumulates sparse holes during certain add/delete cycles. Bloat factor: ~277,000× expected.
35
+ **Impact:** total system unavailability (workstation unusable); during inference would have meant slow query, eventual OOM.
36
+ **Fix (deployed):**
37
+ 1. In-process tripwire — `rag/ingest.py` declares `HNSW_BLOAT_THRESHOLD_BYTES = 500 MB` and calls `_abort_if_hnsw_bloated()` after every `collection.add(...)`. Two other writers (`tools/ingest_kb_summaries.py`, `tools/ingest_reviews.py`) import the same guard.
38
+ 2. Out-of-process auto-purge — `~/Library/Scripts/insurance-bot/check-vector-bloat.sh` + LaunchAgent `com.rohit.insurancebot.vectorbloat` (60-min cadence). Auto-deletes `_hf_dataset_backup/` at 20 GB. Warns at 5 GB.
39
+ 3. Disk-free tripwire — `~/Library/Scripts/cache-prevention/disk-free-tripwire.sh` + LaunchAgent `com.rohit.disk-free-tripwire` (15-min cadence). Critical alert <8 GB free; dumps every `~/Developer` subdir >1 GB into the log.
40
+ 4. Re-downloaded canonical dataset from HF Hub (`rohitsar567/insurance-bot-data` · 539 files · 498 MB) — `link_lists.bin` now 58-66 KB.
41
+
42
+ **Production hardening still owed (D-001a, P1):**
43
+ - Open ChromaDB issue tracker — file or upstream a reproduction so the actual root cause is fixed, not just contained.
44
+ - Move ingest to a temp directory + atomic rename — currently the live store is also the ingest target.
45
+
46
+ ---
47
+
48
+ ### D-002 · P0 · Three LaunchAgents silently failing under wrong path — **FIXED**
49
+ **Symptom:** `com.rohit.insurancebot.linkrot`, `.pdfetags`, `.premiums` all `cd "/Users/rohitsar/Documents/Personal/AI Work/Insurance Sales Bot"` — that directory does NOT exist. The actual project is `/Users/rohitsar/Developer/Insurance Sales Bot`. They've been failing every scheduled run.
50
+ **Impact:** Link-rot detection, PDF eTag refresh, and premium-page refresh have all been **broken indefinitely** — corpus URL changes go undetected, insurer pricing data goes stale, regulatory PDFs may have been updated without the bot's awareness.
51
+ **Fix (deployed):** Sed-replaced `~/Documents/Personal/AI Work/` → `~/Developer/` in all three `run_*.sh` scripts. Created log directory. Smoke-test running in background (id `bh2cv32e2`).
52
+ **Production hardening owed (D-002a, P1):**
53
+ - Add a heartbeat-or-page check (like the bloat watcher) for every LaunchAgent: if `last_exit != 0` for N consecutive runs, page.
54
+ - This is the SECOND silent-LaunchAgent regression in 18 days (memory: `feedback_tcc_blocks_launchd_in_documents`). Pattern needs a class-fix.
55
+
56
+ ---
57
+
58
+ ### D-003 · P0 · Factual accuracy on gold-QA eval — **ROOT-CAUSED + PARTIAL FIX SHIPPED**
59
+ **Initial symptom:** `eval/results.md` (2026-05-13 21:52 UTC) showed 30.0% factual accuracy on 10 questions. Full 96-Q re-run (2026-05-14 16:32 UTC, before fix) showed **41.7%**.
60
+ **Diagnostic breakthrough — by brain accuracy:**
61
+ - `nim-chain` (the QA brain): **51.9% accuracy** when used.
62
+ - `needs_finder` (the slot-filler): **0.0% accuracy** when used.
63
+ - `nim` direct: **0.0%** (only 0 turns).
64
+ The full eval was sending **every** QA question to `needs_finder`. Sample bot answers from before the fix:
65
+ - *Q: "What is the waiting period for pre-existing diseases under Activ Assure?" → A: "Happy to help. First, your age?"*
66
+ - *Q: "Is there a cap on room rent under Activ Assure?" → A: "Sorry, I didn't catch that. Let me ask again — Who else needs cover..."*
67
+ - *Q: "Does Activ Assure cover AYUSH?" → A: "Got it. Who else needs cover — just you, spouse, kids, parents, or a mix?"*
68
+
69
+ **Root cause:** `backend/orchestrator.py:174-183`. The KI-013 guard ("never recommend without profile") force-routed every turn to fact-find when the session profile was empty — **regardless of intent**. Eval and audit sessions start with empty profiles, so 100% of QA questions got swallowed by `needs_finder`.
70
+
71
+ **Fix shipped:** restricted the `profile_is_empty` force-route to `intent ∈ {recommendation, comparison}` only. QA intent now passes through to retrieval + `nim-chain`. New comment block in source marks this as KI-018.
72
+
73
+ **Smoke validation (5 questions, no-judge regex grader, post-fix):**
74
+ - Factual accuracy: **60.0%** (was 0% on the same 5 questions before).
75
+ - Brain breakdown: `nim-chain` 100% (was `needs_finder` 100% before).
76
+ - The two PED-waiting-period questions that previously responded "First, your age?" now correctly answer "24 months".
77
+
78
+ **Projection:** if `nim-chain`'s 51.9% accuracy holds when it serves all 96 questions (instead of only some subset), headline factual accuracy goes from 41.7% → low 50s. To reach the enterprise bar of ≥90%, additional work is needed on the brain itself:
79
+ - `waiting_period`: retrieval ranks wrong section; likely chunking issue.
80
+ - `sub_limit`: structured-extraction problem — sub-limit tables don't survive chunking.
81
+ - `exclusions_oos`: refusal logic doesn't catch OOS exclusions — needs tighter regulatory_oos filter applied to exclusions too.
82
+
83
+ **Adjacent bug — D-003a, P1:** the Groq judge (Llama-3.3-70B) returned `JSONDecodeError` 11 times on the 96-Q run; each gets scored 0 factual, inflating the failure rate. Need to wrap judge response with a JSON-repair pass or fall back to the regex grader on judge-parse-failure.
84
+
85
+ **Next:** queue a fresh full 96-Q eval re-run after the 100-persona audit completes (avoid competing on NIM quota).
86
+
87
+ ---
88
+
89
+ ### D-004 · P0 · Latency p95 49s, p99 59s — **DEGRADED ON BROADER SAMPLE**
90
+ **Symptom (now confirmed on full 100-persona audit, 3000 turns):**
91
+ - p50: **9.9s** (was 7.3s on the 20-persona sample — **+36%**)
92
+ - p95: **49.1s** (was 24.2s — **+103%**)
93
+ - p99: **58.9s** (was 48.0s — **+23%**)
94
+ **Impact:** **Unacceptable for chat UX.** A 49-second p95 wait on "what's covered?" feels broken. Enterprise insurance customers expect sub-3s p95. Some persona/intent combos (recommendation × `nim-chain::v4-pro`) take ~1500s for a single 30-turn session.
95
+ **Why it degraded vs. 20-persona sample:** the 20-persona run was all first-buyer + upgrader archetypes (simpler, more fact-find-only). The full 100 includes `tax_planner`, `comparer`, `savvy` archetypes that hit the heavier `v4-pro` model + multi-call cascades, which pushed p95 up dramatically.
96
+ **Likely contributors (in order of suspected impact):**
97
+ 1. **`v4-pro` brain on comparison/recommendation** — frontier 1.6T MoE, slow per token; combine with NIM rate limit and you get the long-tail.
98
+ 2. NIM 40 req/min cap forces serial dispatch under load → queuing latency.
99
+ 3. Multi-call cascades (brain + judge + cross-check) per docstring in `run_audit.py`.
100
+ 4. **20 HTTP timeouts** (0.7%) — the bot occasionally hangs on a turn entirely (P100 had 3 ReadTimeouts in a row).
101
+ **Fix proposal:**
102
+ - Streaming responses (TTFT instead of full response time) — most of the 49s wait is the user staring at a blank screen.
103
+ - Tier `comparer`/`tax_planner` archetypes to `v4-flash`; reserve `v4-pro` for truly heavy recommendation synthesis.
104
+ - Hard timeout on NIM calls (currently apparently >60s) — fail-fast at 20s, retry with smaller context.
105
+ - Cache the intent classifier output for the first N turns of a session.
106
+ **Owner:** needs design discussion before changing brain routing.
107
+
108
+ ---
109
+
110
+ ### D-005 · P0 · Profile-capture / slot-filling broken — **CONFIRMED REAL BUG ON 100-PERSONA SAMPLE**
111
+ **Symptom (full 100-persona audit, post-fix):**
112
+ | Field | Personas hit |
113
+ |---|---|
114
+ | `health_conditions` | 20 / 100 |
115
+ | `existing_cover_inr` | 12 / 100 |
116
+ | `age` | **12 / 100** |
117
+ | `parents_to_insure` | 7 / 100 |
118
+ | `primary_goal` | 6 / 100 |
119
+ | `parents_age_max` | 6 / 100 |
120
+ | `parents_has_ped` | 1 / 100 |
121
+ | All other fields (dependents, income, location, marital, conditions ≠ health_conditions, budget) | **0 / 100** |
122
+
123
+ **Re-diagnosis:** This is NOT a downstream effect of D-003. The audit pre-dated the D-003 fix, but more importantly: 1281 of 3000 turns ran `needs_finder` (43%) — i.e. the slot-filler DID run on plenty of turns. Yet age was captured for only 12% of personas, even though every persona is canonically asked "First, your age?" on turn 1 and answers it. **The slot-filler is asking but not retaining the answer.**
124
+
125
+ **Likely root causes:**
126
+ 1. `backend/fact_find_normalizer.normalize_answer()` is rejecting valid Indian-accented age responses ("twenty-five" / "मेरी उम्र पचास साल है" / "I'm 28 going on 29").
127
+ 2. `_reask_count` is hitting its cap (2 fails → give up + move on without storing).
128
+ 3. The 258 fact-find re-asks correlate with this — almost every persona had a re-ask, often multiple.
129
+ 4. Hindi-primary personas captured `health_conditions` (Devanagari numerals in the canned persona response) but failed on age (free-text Hindi).
130
+
131
+ **Impact:** Bot cannot recommend a policy because it doesn't have the user's profile. The 263 faithfulness-gate refusals are largely because retrieval has no profile to constrain against. **Recommendation flow is fundamentally broken** for the majority of users.
132
+
133
+ **Next step:** Read 5 P-files (across archetypes), grep for `_reask_counts` increments, identify the specific normalizer regex that's misfiring.
134
+
135
+ ---
136
+
137
+ ### D-006 · P1 · Refusal-rate fairness — **REDIAGNOSED ON 100-PERSONA SAMPLE; HINGLISH CONCERN CLEARED, REAL OUTLIERS IDENTIFIED**
138
+
139
+ **Refusal rate by archetype** (avg refusals/persona over 30 turns):
140
+ | Archetype | Refusals | Faithfulness fails |
141
+ |---|---:|---:|
142
+ | `tax_planner` | **4.6** | 46 |
143
+ | `code_switcher` | 4.4 | 44 |
144
+ | `savvy` | 3.3 | 33 |
145
+ | `specific_condition` | 3.2 | 32 |
146
+ | `low_trust` | 2.8 | 28 |
147
+ | `anxious` | 2.5 | 25 |
148
+ | `senior_care` | 2.1 | 21 |
149
+ | `comparer` | 2.0 | 20 |
150
+ | `upgrader` | 1.0 | 10 |
151
+ | `first_buyer` | **0.4** | 4 |
152
+
153
+ **Refusal rate by conversational style:**
154
+ | Style | Refusals |
155
+ |---|---:|
156
+ | `stream` | **4.3** (highest) |
157
+ | `tester` | 3.4 |
158
+ | `casual_en` | 3.1 |
159
+ | `anxious_q` | 2.8 |
160
+ | `verbose` | 2.7 |
161
+ | `hindi_primary` | 2.5 |
162
+ | `numbers_heavy` | 2.1 |
163
+ | `formal_en` | 1.9 |
164
+ | `hinglish` | 1.9 |
165
+ | `terse` | **1.6** (lowest) |
166
+
167
+ **Re-diagnosis:** The earlier 20-persona "hinglish 2× more refusals" claim was a small-sample artifact (2 hinglish personas × 1 refusal each). On the full sample, hinglish (1.9) is actually slightly BETTER than hindi_primary (2.5) and is the second-lowest style. **Hinglish is fine.**
168
+
169
+ **Real fairness issues:**
170
+ 1. **`tax_planner` and `code_switcher` archetypes get refused 11× more than `first_buyer`** (4.6 / 4.4 vs 0.4). These users ask tax-deduction questions (80D / 80DD / 80U) and code-switched comparison questions — the bot's faithfulness gate refuses both heavily. **This is an India-market segment we cannot afford to alienate.**
171
+ 2. **`stream` style refused 2.7× more than `terse`** (4.3 vs 1.6). Long stream-of-consciousness input is failing the faithfulness gate. Likely the gate is matching the wrong span of the user's question.
172
+
173
+ **Worst refusers (real users to debug against):** P081 (Saif Banerjee, code_switcher/stream, **9 refusals**), P069 (Vikram Banerjee, tax_planner/casual_en, 8), P063+P064 (tax_planner, 7 each), P091 (specific_condition/tester, 7).
174
+
175
+ **Fix proposal:**
176
+ - Add tax-related gold-QA questions (80D, 80DD, 80U) — currently the gold set has zero.
177
+ - Loosen faithfulness gate for `stream`-style: chunk the question, take the most retrieval-rich span, not the whole thing.
178
+ - For `code_switcher`, run Sarvam translation pass at gate-evaluation time, not only at brain time.
179
+
180
+ ---
181
+
182
+ ### D-007 · P1 · No unit tests; only `live_verify.py` — **OPEN**
183
+ **Symptom:** `tests/` contains only `live_verify.py`. Backend modules (`orchestrator.py`, `faithfulness.py`, `security.py`, `scorecard.py`, `profile_rag.py`) have no isolated tests.
184
+ **Impact:** Enterprise procurement (and SOC 2 / ISO 27001 audits) require test coverage evidence. The eval/audit suites are integration-level; they don't catch unit regressions.
185
+ **Fix proposal:** Add `tests/unit/` with pytest, target ≥70% line coverage on `backend/`. Block PRs that drop coverage.
186
+
187
+ ---
188
+
189
+ ### D-008 · P1 · `except Exception:` audit — **PARTIAL**
190
+ **Symptom:** ~17 sites across `backend/main.py`, `admin.py`, `security.py`, `profile_rag.py`, `scorecard.py` catch broad exceptions. Most legitimate (defensive deletes, malformed-line-skip, fail-open availability tradeoffs). But several swallow legitimate errors:
191
+ - `backend/main.py:518` after `record_accept(sha, sid, len(chunks))` — telemetry write silently swallowed.
192
+ - `backend/main.py:660` after building `hint` — silent failure for what could be a routing bug.
193
+ - `backend/profile_rag.py:142-144` — `coll.delete(where=...)` failure swallowed; if a stale chunk exists, the new chunk will collide on ID.
194
+ **Impact:** Real errors get masked; debugging in production becomes archaeology.
195
+ **Fix:** Each `except Exception: pass` should at minimum log to `LOG_DIR/turns.jsonl` (or the structured logger) with `level=warn` and an event name.
196
+ **Note:** Recent commit `2412797 fix(observability): KI-001..006 — log silent failures + fail-CLOSED judge` already addresses some of these. Need to confirm coverage.
197
+
198
+ ---
199
+
200
+ ### D-009 · P2 · Loose `tmp_*.py` files in project root — **FIXED**
201
+ **Symptom:** `tmp_extract.py`, `tmp_count_fields.py`, `tmp_batch_extract.py` in repo root. Were git-tracked.
202
+ **Fix (deployed):** `git rm` issued. Confirmed gone. Pending commit.
203
+
204
+ ---
205
+
206
+ ### D-010 · P2 · TODO/FIXME density in `backend/` + `rag/` — **OPEN**
207
+ **Count:** 29 TODO/FIXME/XXX/HACK markers across backend + rag (excludes `__pycache__`).
208
+ **Action:** Triage list; convert to GitHub issues; resolve before enterprise audit.
209
+
210
+ ---
211
+
212
+ ## Active workstreams (status)
213
+
214
+ 1. **100-persona full audit** — ✅ COMPLETE. 100/100 transcripts, 3000 turns. Findings populated into D-004 / D-005 / D-006.
215
+ 2. **Full 96-Q gold eval** — ✅ COMPLETE. 41.7% factual (pre-fix). Findings in D-003.
216
+ 3. **LaunchAgent smoke-test** — ✅ COMPLETE. 3/3 scripts now produce real log output.
217
+ 4. **Post-fix smoke validation** — ✅ COMPLETE. 5-Q smoke shows 60% factual + 100% nim-chain routing.
218
+
219
+ **Next runs needed:**
220
+ - Deploy D-003 fix to live HF Space (commit + push → Docker rebuild).
221
+ - Re-run 100-persona audit against patched live endpoint to get clean post-fix latency + refusal numbers.
222
+ - Re-run full 96-Q gold eval (local) for clean post-fix accuracy headline. Estimate based on smoke: 50-60% factual.
223
+
224
+ ---
225
+
226
+ ## What "enterprise-grade" actually means for this product
227
+
228
+ Before insurers will pilot this, the following must be true:
229
+
230
+ 1. **Factual accuracy ≥ 90%** on gold-QA across all question types (currently 30% headline).
231
+ 2. **Latency p95 ≤ 3s** on chat turns (currently 24s).
232
+ 3. **Zero silent failures** — every `except Exception:` either re-raises or logs.
233
+ 4. **Production observability** — every brain decision, every retrieval, every refusal logged with correlation IDs; dashboards for accuracy/latency/refusal-rate over time.
234
+ 5. **Test coverage ≥ 70%** with unit + integration tests in CI.
235
+ 6. **Fairness audit** — accuracy/refusal-rate within ±5% across language styles (hinglish gap is currently 2×).
236
+ 7. **Disaster recovery runbook** — what happens when ChromaDB corrupts, when HF Space is down, when NIM rate-limits.
237
+ 8. **PII handling per DPDP Act** — chat logs, uploaded policies, user profiles must have retention policies + deletion workflows.
238
+ 9. **IRDAI compliance review** — every recommended product must be IRDAI-registered; the bot must never invent a product or premium.
239
+ 10. **SOC 2 Type II readiness** — secrets management, access logs, change management.
240
+
241
+ This audit so far covers items 1-3, 5 (in progress), and 6. Items 7-10 require a separate scoping pass.
242
+
243
+ ---
244
+
245
+ *This file regenerates as new evidence lands. Last updated: 2026-05-14 (initial pass).*
backend/main.py CHANGED
@@ -565,6 +565,38 @@ class ProfileUpdateRequest(BaseModel):
565
  budget_band: Optional[str] = None
566
 
567
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
568
  @app.post("/api/profile", response_model=ProfileCompletenessResponse)
569
  async def profile_update(req: ProfileUpdateRequest):
570
  """Write user-provided profile fields into session_state. Returns the new
 
565
  budget_band: Optional[str] = None
566
 
567
 
568
+ class SessionResetRequest(BaseModel):
569
+ session_id: str
570
+ drop_profile: bool = False # True = nuke session entirely; False = clear chat only
571
+
572
+
573
+ class SessionResetResponse(BaseModel):
574
+ ok: bool
575
+ session_id: Optional[str] = None # new session_id when drop_profile=True
576
+ cleared_state: bool
577
+
578
+
579
+ @app.post("/api/session/reset", response_model=SessionResetResponse)
580
+ async def session_reset(req: SessionResetRequest):
581
+ """KI-020 — User-facing chat clear / fresh-start toggle.
582
+
583
+ Two modes:
584
+ - drop_profile=False: caller (frontend) wipes its own message history; the
585
+ server-side profile is preserved so the next message resumes with what
586
+ the bot already knows. Light-touch "clear visible chat".
587
+ - drop_profile=True: server-side session state (profile + awaiting_question
588
+ + free_form_session flag + on-disk JSON) is deleted entirely. The response
589
+ returns a fresh session_id the frontend should adopt as its new id.
590
+ """
591
+ from backend.session_state import reset_session
592
+ cleared = False
593
+ new_sid: Optional[str] = None
594
+ if req.drop_profile:
595
+ cleared = reset_session(req.session_id)
596
+ new_sid = uuid.uuid4().hex[:12]
597
+ return SessionResetResponse(ok=True, session_id=new_sid, cleared_state=cleared)
598
+
599
+
600
  @app.post("/api/profile", response_model=ProfileCompletenessResponse)
601
  async def profile_update(req: ProfileUpdateRequest):
602
  """Write user-provided profile fields into session_state. Returns the new
backend/orchestrator.py CHANGED
@@ -165,21 +165,34 @@ async def handle_turn(
165
  session = get_session(session_id or "anonymous")
166
 
167
  in_fact_find_continuation = bool(session.awaiting_question_id) and not session.free_form_session
168
- # KI-013 — if the user has NO profile fields yet, FORCE fact-find regardless
169
- # of intent classifier. Real user testing surfaced: a vague opener
170
- # ("I want health insurance") got classified as "recommendation" and the
171
- # bot retrieved "Care Senior" (a senior-citizen-only policy) and pitched it.
172
- # Bot should never recommend without knowing the user's age / dependents /
173
- # conditions / budget. Force fact-find until ≥1 profile field is set.
 
 
 
 
 
 
 
 
174
  profile_is_empty = (
175
  session.profile.age is None
176
  and session.profile.dependents is None
177
  and session.profile.income_band is None
178
  )
 
179
  treat_as_fact_find = (
180
  (intent == "fact_find" and not session.free_form_session)
181
  or in_fact_find_continuation
182
- or (profile_is_empty and not session.free_form_session)
 
 
 
 
183
  )
184
 
185
  if treat_as_fact_find:
@@ -194,6 +207,14 @@ async def handle_turn(
194
  # and proceed to the next. Better to have an incomplete profile
195
  # than an infinite reask loop.
196
  ambiguous_or_failed = False
 
 
 
 
 
 
 
 
197
  if session.awaiting_question_id:
198
  from backend.fact_find_normalizer import is_valid_answer, normalize_answer
199
  qid = session.awaiting_question_id
@@ -211,6 +232,7 @@ async def handle_turn(
211
  q_obj = next((q for q in __import__('backend.needs_finder', fromlist=['GRAPH']).GRAPH if q.id == qid), None)
212
  if q_obj is not None:
213
  session.update_profile_field(q_obj.field, normalized)
 
214
  if qid not in session.profile.asked:
215
  session.profile.asked.append(qid)
216
  session.set_awaiting(None)
@@ -294,6 +316,7 @@ async def handle_turn(
294
  raw_reply=reply,
295
  faithfulness_passed=True,
296
  blocked=False,
 
297
  )
298
 
299
  # User explicitly asked a specific question — leave fact-find mode if they were in one.
 
165
  session = get_session(session_id or "anonymous")
166
 
167
  in_fact_find_continuation = bool(session.awaiting_question_id) and not session.free_form_session
168
+ # KI-013 — if the user has NO profile fields yet, FORCE fact-find for
169
+ # intents that depend on user context (recommendation, comparison).
170
+ # Real user testing surfaced: a vague opener ("I want health insurance")
171
+ # got classified as "recommendation" and the bot retrieved "Care Senior"
172
+ # (a senior-citizen-only policy) and pitched it. The bot must never
173
+ # recommend without knowing the user's age / dependents / conditions /
174
+ # budget.
175
+ #
176
+ # KI-018 (2026-05-14) — intent='qa' was previously also force-routed to
177
+ # fact-find on empty profile, which dropped factual accuracy to 30% on
178
+ # gold-QA: the bot answered "What is the waiting period for PED?" with
179
+ # "First, your age?". QA is policy-fact lookup, doesn't depend on user
180
+ # profile — it must pass through to retrieval. Only context-dependent
181
+ # intents (recommendation/comparison) need a profile first.
182
  profile_is_empty = (
183
  session.profile.age is None
184
  and session.profile.dependents is None
185
  and session.profile.income_band is None
186
  )
187
+ CONTEXT_DEPENDENT_INTENTS = {"recommendation", "comparison"}
188
  treat_as_fact_find = (
189
  (intent == "fact_find" and not session.free_form_session)
190
  or in_fact_find_continuation
191
+ or (
192
+ profile_is_empty
193
+ and not session.free_form_session
194
+ and intent in CONTEXT_DEPENDENT_INTENTS
195
+ )
196
  )
197
 
198
  if treat_as_fact_find:
 
207
  # and proceed to the next. Better to have an incomplete profile
208
  # than an infinite reask loop.
209
  ambiguous_or_failed = False
210
+ # Telemetry: KI-019 (2026-05-14) — populate this dict whenever the
211
+ # slot-filler successfully captures a normalized answer so the API
212
+ # response's `profile_updates` field reflects fact-find captures (it
213
+ # previously only reflected free-form mode captures, which made the
214
+ # 100-persona audit appear to show age captured for only 12/100 when
215
+ # the slot-filler was actually working — see readback summaries in
216
+ # `needs_finder::fact_find_complete` turns).
217
+ fact_find_profile_updates: dict = {}
218
  if session.awaiting_question_id:
219
  from backend.fact_find_normalizer import is_valid_answer, normalize_answer
220
  qid = session.awaiting_question_id
 
232
  q_obj = next((q for q in __import__('backend.needs_finder', fromlist=['GRAPH']).GRAPH if q.id == qid), None)
233
  if q_obj is not None:
234
  session.update_profile_field(q_obj.field, normalized)
235
+ fact_find_profile_updates[q_obj.field] = normalized
236
  if qid not in session.profile.asked:
237
  session.profile.asked.append(qid)
238
  session.set_awaiting(None)
 
316
  raw_reply=reply,
317
  faithfulness_passed=True,
318
  blocked=False,
319
+ profile_updates=fact_find_profile_updates, # KI-019 telemetry fix
320
  )
321
 
322
  # User explicitly asked a specific question — leave fact-find mode if they were in one.
backend/providers/nvidia_nim_llm.py CHANGED
@@ -264,12 +264,18 @@ class NimChainLLM(LLMProvider):
264
  """
265
  def __init__(self, chain: list[str], api_key: Optional[str] = None,
266
  timeout: float = 30.0, per_model_attempts: int = 1,
267
- role: str = "unknown"):
268
  if not chain:
269
  raise ValueError("chain must have at least one model")
270
  self.chain = chain
271
  self.api_key = api_key or getattr(settings, "NVIDIA_NIM_API_KEY", "")
272
  self.timeout = timeout
 
 
 
 
 
 
273
  self.per_model_attempts = per_model_attempts
274
  self.role = role # 'brain' | 'fast_brain' | 'judge' | 'unknown' — flows into usage log
275
  self.model = chain[0]
@@ -375,8 +381,20 @@ class NimChainLLM(LLMProvider):
375
 
376
  last_err: Optional[Exception] = None
377
  for model in chain_to_try:
 
 
 
 
 
 
 
 
 
378
  try:
379
- worker = self._get_worker_for(model, self.timeout)
 
 
 
380
  result = await worker.chat(messages=messages, temperature=temperature,
381
  max_tokens=max_tokens, response_format=response_format)
382
  # Successful response — record which model answered + return.
@@ -412,8 +430,13 @@ class NimChainLLM(LLMProvider):
412
  for model in refreshed:
413
  if model in chain_to_try:
414
  continue # already tried this turn
 
 
 
 
415
  try:
416
- worker = self._get_worker_for(model, self.timeout)
 
417
  result = await worker.chat(messages=messages, temperature=temperature,
418
  max_tokens=max_tokens, response_format=response_format)
419
  self.model = model
@@ -453,14 +476,20 @@ class NimChainLLM(LLMProvider):
453
 
454
  def get_brain_llm() -> NimChainLLM:
455
  """Heavy brain — multi-model NIM chain with automatic fallback.
456
- Primary: Qwen 3-Next 80B. See BRAIN_CHAIN for fallback order."""
457
- return NimChainLLM(chain=BRAIN_CHAIN, timeout=30.0, role="brain")
 
 
 
458
 
459
 
460
  def get_fast_brain_llm() -> NimChainLLM:
461
  """Fast brain — multi-model NIM chain optimized for low TTFT.
462
- Primary: Qwen 3-Next 80B. See FAST_BRAIN_CHAIN for fallback order."""
463
- return NimChainLLM(chain=FAST_BRAIN_CHAIN, timeout=20.0, role="fast_brain")
 
 
 
464
 
465
 
466
  def get_judge_llm(language: str = "en") -> NimChainLLM:
 
264
  """
265
  def __init__(self, chain: list[str], api_key: Optional[str] = None,
266
  timeout: float = 30.0, per_model_attempts: int = 1,
267
+ role: str = "unknown", total_budget_s: Optional[float] = None):
268
  if not chain:
269
  raise ValueError("chain must have at least one model")
270
  self.chain = chain
271
  self.api_key = api_key or getattr(settings, "NVIDIA_NIM_API_KEY", "")
272
  self.timeout = timeout
273
+ # KI-021 (2026-05-14) — cumulative chain budget. If the chain has 8
274
+ # fallbacks at 30s each, worst-case wall-clock is 4 min per turn — that
275
+ # produced the p99 58s+ tail in the 100-persona audit. Default to a
276
+ # cumulative ceiling of ~2.5× the per-link timeout so a healthy primary
277
+ # always completes, but a cascading-failure chain bails fast.
278
+ self.total_budget_s = total_budget_s if total_budget_s is not None else max(timeout * 2.5, 30.0)
279
  self.per_model_attempts = per_model_attempts
280
  self.role = role # 'brain' | 'fast_brain' | 'judge' | 'unknown' — flows into usage log
281
  self.model = chain[0]
 
381
 
382
  last_err: Optional[Exception] = None
383
  for model in chain_to_try:
384
+ # KI-021 — bail out if the cumulative chain budget is gone so a single
385
+ # turn can never wedge for minutes through a long fallback cascade.
386
+ elapsed = time.time() - call_t0
387
+ if elapsed >= self.total_budget_s:
388
+ last_err = TimeoutError(
389
+ f"chain budget exhausted ({elapsed:.1f}s ≥ {self.total_budget_s:.1f}s) "
390
+ f"after trying {[m for m in chain_to_try if chain_to_try.index(m) < chain_to_try.index(model)]}"
391
+ )
392
+ break
393
  try:
394
+ # Cap per-link timeout to remaining chain budget so the final
395
+ # link can't single-handedly blow past the ceiling.
396
+ per_link_timeout = min(self.timeout, max(2.0, self.total_budget_s - elapsed))
397
+ worker = self._get_worker_for(model, per_link_timeout)
398
  result = await worker.chat(messages=messages, temperature=temperature,
399
  max_tokens=max_tokens, response_format=response_format)
400
  # Successful response — record which model answered + return.
 
430
  for model in refreshed:
431
  if model in chain_to_try:
432
  continue # already tried this turn
433
+ # KI-021 — respect chain budget here too
434
+ elapsed = time.time() - call_t0
435
+ if elapsed >= self.total_budget_s:
436
+ break
437
  try:
438
+ per_link_timeout = min(self.timeout, max(2.0, self.total_budget_s - elapsed))
439
+ worker = self._get_worker_for(model, per_link_timeout)
440
  result = await worker.chat(messages=messages, temperature=temperature,
441
  max_tokens=max_tokens, response_format=response_format)
442
  self.model = model
 
476
 
477
  def get_brain_llm() -> NimChainLLM:
478
  """Heavy brain — multi-model NIM chain with automatic fallback.
479
+ Primary: Qwen 3-Next 80B. See BRAIN_CHAIN for fallback order.
480
+ KI-021 — per-link 20s, total chain budget 35s. Tail beyond that is just
481
+ the user staring at a blank screen on the live audit (was 49s p95)."""
482
+ return NimChainLLM(chain=BRAIN_CHAIN, timeout=20.0, role="brain",
483
+ total_budget_s=35.0)
484
 
485
 
486
  def get_fast_brain_llm() -> NimChainLLM:
487
  """Fast brain — multi-model NIM chain optimized for low TTFT.
488
+ Primary: Qwen 3-Next 80B. See FAST_BRAIN_CHAIN for fallback order.
489
+ KI-021 — per-link 12s, total chain budget 22s. Fact-find / QA needs
490
+ sub-3s p95 to feel like chat; budget keeps the worst case bounded."""
491
+ return NimChainLLM(chain=FAST_BRAIN_CHAIN, timeout=12.0, role="fast_brain",
492
+ total_budget_s=22.0)
493
 
494
 
495
  def get_judge_llm(language: str = "en") -> NimChainLLM:
backend/session_state.py CHANGED
@@ -160,6 +160,29 @@ def set_free_form(session_id: str, free_form: bool = True) -> None:
160
  s._flush()
161
 
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  def purge_old_files() -> int:
164
  """Delete on-disk session files older than _DISK_TTL_SECONDS. Returns count."""
165
  if not _DATA_ROOT.exists():
 
160
  s._flush()
161
 
162
 
163
+ def reset_session(session_id: str) -> bool:
164
+ """Delete a session — evict from in-memory cache and remove the disk file.
165
+ Returns True if anything was actually deleted.
166
+ KI-020 (2026-05-14) — backs the user-facing "Clear chat / start fresh" toggle."""
167
+ deleted_any = False
168
+ with _lock:
169
+ if session_id in _sessions:
170
+ del _sessions[session_id]
171
+ deleted_any = True
172
+ target = _DATA_ROOT / f"{session_id}.json"
173
+ if target.exists():
174
+ try:
175
+ target.unlink()
176
+ deleted_any = True
177
+ except Exception as e:
178
+ import logging
179
+ logging.warning(
180
+ "reset_session unlink failed for %s: %s: %s",
181
+ session_id, type(e).__name__, str(e)[:200],
182
+ )
183
+ return deleted_any
184
+
185
+
186
  def purge_old_files() -> int:
187
  """Delete on-disk session files older than _DISK_TTL_SECONDS. Returns count."""
188
  if not _DATA_ROOT.exists():
eval/run.py CHANGED
@@ -89,6 +89,37 @@ def get_judge():
89
  return _judge
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def _regex_factual_grade(gold_answer: str, bot_answer: str) -> tuple[bool, str]:
93
  """Deterministic factual grader for sweep runs (no LLM judge).
94
 
@@ -159,13 +190,24 @@ Grade now."""
159
  max_tokens=200,
160
  response_format={"type": "json_object"},
161
  )
162
- d = json.loads(res.text)
 
 
 
 
 
 
 
 
163
  return (bool(d.get("factual_match", False)),
164
  citation_present,
165
  float(d.get("score", 0.0)),
166
  str(d.get("reason", ""))[:200])
167
  except Exception as e:
168
- return (False, citation_present, 0.0, f"judge_error: {type(e).__name__}: {e}")
 
 
 
169
 
170
 
171
  async def run_one(gold: dict, *, no_judge: bool = False) -> EvalRecord:
 
89
  return _judge
90
 
91
 
92
+ def _parse_judge_json(raw: str) -> Optional[dict]:
93
+ """KI-022 — robust JSON parse for the Groq/NIM judge response.
94
+
95
+ Groq Llama-3.3 occasionally returns truncated or trailing-comma JSON even
96
+ with response_format=json_object. Try strict, then repair, then None.
97
+ Caller falls back to the regex grader on None instead of scoring 0.
98
+ """
99
+ if not raw or not raw.strip():
100
+ return None
101
+ try:
102
+ return json.loads(raw)
103
+ except Exception:
104
+ pass
105
+ # Repair pass: extract the first balanced {...} block + drop trailing commas
106
+ try:
107
+ m = re.search(r"\{.*\}", raw, flags=re.DOTALL)
108
+ if not m:
109
+ return None
110
+ candidate = m.group(0)
111
+ candidate = re.sub(r",(\s*[}\]])", r"\1", candidate) # trailing commas
112
+ # Stitch a closing quote if the string ended mid-value
113
+ if candidate.count('"') % 2 == 1:
114
+ candidate = candidate + '"'
115
+ # If still missing a closing brace, append one
116
+ if candidate.count("{") > candidate.count("}"):
117
+ candidate = candidate + "}"
118
+ return json.loads(candidate)
119
+ except Exception:
120
+ return None
121
+
122
+
123
  def _regex_factual_grade(gold_answer: str, bot_answer: str) -> tuple[bool, str]:
124
  """Deterministic factual grader for sweep runs (no LLM judge).
125
 
 
190
  max_tokens=200,
191
  response_format={"type": "json_object"},
192
  )
193
+ d = _parse_judge_json(res.text)
194
+ if d is None:
195
+ # KI-022 (2026-05-14) — JSON-parse failure on 11/96 questions in the
196
+ # 2026-05-14 baseline caused those questions to count as 0 factual
197
+ # even when the bot answered correctly. Fall back to the regex
198
+ # grader instead of dropping a 0 on the floor.
199
+ ok, reason = _regex_factual_grade(gold["expected_answer"], bot_answer)
200
+ return (ok, citation_present, 1.0 if ok else 0.0,
201
+ f"judge_json_unparseable→regex_fallback: {reason}")
202
  return (bool(d.get("factual_match", False)),
203
  citation_present,
204
  float(d.get("score", 0.0)),
205
  str(d.get("reason", ""))[:200])
206
  except Exception as e:
207
+ # KI-022 — same fallback for actual exceptions (timeout, network, etc.)
208
+ ok, reason = _regex_factual_grade(gold["expected_answer"], bot_answer)
209
+ return (ok, citation_present, 1.0 if ok else 0.0,
210
+ f"judge_error→regex_fallback ({type(e).__name__}): {reason}")
211
 
212
 
213
  async def run_one(gold: dict, *, no_judge: bool = False) -> EvalRecord:
frontend/src/app/page.tsx CHANGED
@@ -21,6 +21,7 @@ import {
21
  postChat,
22
  postPremiumEstimate,
23
  postProfileUpdate,
 
24
  postTranscribe,
25
  PremiumEstimateResponse,
26
  ProfileCompletenessResponse,
@@ -203,6 +204,40 @@ export default function Page() {
203
  ].includes(s);
204
  }
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  async function send(text: string) {
207
  if (!text.trim() || busy) return;
208
  setBusy(true);
@@ -615,10 +650,35 @@ export default function Page() {
615
  {messages.length === 0 ? (
616
  <EmptyState onSuggest={(q) => send(q)} coverage={coverage} t={t} />
617
  ) : (
618
- <div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin space-y-4 mb-4 pr-1">
619
- {messages.map((m) => <Message key={m.id} m={m} />)}
620
- {busy && <ThinkingDots />}
621
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
622
  )}
623
 
624
  {uploadStatus && (
 
21
  postChat,
22
  postPremiumEstimate,
23
  postProfileUpdate,
24
+ postSessionReset,
25
  postTranscribe,
26
  PremiumEstimateResponse,
27
  ProfileCompletenessResponse,
 
204
  ].includes(s);
205
  }
206
 
207
+ // KI-020 — user-facing chat clear / fresh-start
208
+ async function handleClearChat(dropProfile: boolean) {
209
+ // Always wipe the visible chat + local storage.
210
+ setMessages([]);
211
+ setInput("");
212
+ if (typeof window !== "undefined") {
213
+ localStorage.removeItem("insurance_chat_messages");
214
+ }
215
+ if (dropProfile && sessionId) {
216
+ try {
217
+ const res = await postSessionReset({ session_id: sessionId, drop_profile: true });
218
+ // Adopt the new server-issued session id (or clear it if backend didn't return one)
219
+ if (res.session_id) {
220
+ setSessionId(res.session_id);
221
+ if (typeof window !== "undefined") {
222
+ localStorage.setItem("insurance_session_id", res.session_id);
223
+ }
224
+ } else {
225
+ setSessionId(undefined);
226
+ if (typeof window !== "undefined") {
227
+ localStorage.removeItem("insurance_session_id");
228
+ }
229
+ }
230
+ } catch (e) {
231
+ console.warn("session reset failed", e);
232
+ // Even if backend failed, drop client-side session so next message starts fresh
233
+ setSessionId(undefined);
234
+ if (typeof window !== "undefined") {
235
+ localStorage.removeItem("insurance_session_id");
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
  async function send(text: string) {
242
  if (!text.trim() || busy) return;
243
  setBusy(true);
 
650
  {messages.length === 0 ? (
651
  <EmptyState onSuggest={(q) => send(q)} coverage={coverage} t={t} />
652
  ) : (
653
+ <>
654
+ {/* KI-020 chat-clear controls; only visible once there's a conversation */}
655
+ <div className="flex items-center justify-end gap-2 mb-2 text-[11px]">
656
+ <button
657
+ onClick={() => handleClearChat(false)}
658
+ disabled={busy}
659
+ className="px-2 py-1 rounded-md border border-[var(--border)] text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:border-[var(--primary)] disabled:opacity-40 transition"
660
+ title="Clear visible chat — bot keeps what it already knows about you"
661
+ >
662
+ Clear chat
663
+ </button>
664
+ <button
665
+ onClick={() => {
666
+ if (confirm("Start fresh? This forgets your profile (age, dependents, etc.) and starts a brand-new session.")) {
667
+ handleClearChat(true);
668
+ }
669
+ }}
670
+ disabled={busy}
671
+ className="px-2 py-1 rounded-md border border-[var(--border)] text-[var(--muted-foreground)] hover:text-rose-600 hover:border-rose-400 disabled:opacity-40 transition"
672
+ title="Wipe profile + start a fresh session"
673
+ >
674
+ Start fresh
675
+ </button>
676
+ </div>
677
+ <div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin space-y-4 mb-4 pr-1">
678
+ {messages.map((m) => <Message key={m.id} m={m} />)}
679
+ {busy && <ThinkingDots />}
680
+ </div>
681
+ </>
682
  )}
683
 
684
  {uploadStatus && (
frontend/src/lib/api.ts CHANGED
@@ -401,6 +401,29 @@ export async function postPremiumEstimate(req: PremiumEstimateRequest): Promise<
401
  }
402
 
403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  export async function uploadPolicy(file: File): Promise<UploadResponse> {
405
  const fd = new FormData();
406
  fd.append("file", file);
 
401
  }
402
 
403
 
404
+ // KI-020 — User-facing chat clear / session restart.
405
+ export interface SessionResetResponse {
406
+ ok: boolean;
407
+ session_id?: string | null; // new session_id returned when drop_profile=true
408
+ cleared_state: boolean;
409
+ }
410
+
411
+ export async function postSessionReset(
412
+ args: { session_id: string; drop_profile?: boolean }
413
+ ): Promise<SessionResetResponse> {
414
+ const resp = await fetch(`${BACKEND_URL}/api/session/reset`, {
415
+ method: "POST",
416
+ headers: { "Content-Type": "application/json" },
417
+ body: JSON.stringify({
418
+ session_id: args.session_id,
419
+ drop_profile: args.drop_profile ?? false,
420
+ }),
421
+ });
422
+ if (!resp.ok) throw new Error(`session reset failed: ${resp.status}`);
423
+ return resp.json();
424
+ }
425
+
426
+
427
  export async function uploadPolicy(file: File): Promise<UploadResponse> {
428
  const fd = new FormData();
429
  fd.append("file", file);
rag/ingest.py CHANGED
@@ -33,6 +33,32 @@ from backend.providers.local_embeddings import LocalEmbeddings as ActiveEmbeddin
33
 
34
  ROOT = settings.CORPUS_DIR.parent.parent # project root
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
  # ---------- chunking ----------
38
 
@@ -287,11 +313,13 @@ async def ingest_one(
287
  embeddings=vectors,
288
  metadatas=metadatas,
289
  )
 
290
  return len(chunks)
291
 
292
 
293
  async def main():
294
  settings.VECTORS_DIR.mkdir(parents=True, exist_ok=True)
 
295
  pdfs = discover_pdfs()
296
  manifest = load_manifest()
297
  collection = get_chroma_collection()
 
33
 
34
  ROOT = settings.CORPUS_DIR.parent.parent # project root
35
 
36
+ # Hard cap on HNSW link_lists.bin size — guards against the ChromaDB bloat
37
+ # pathology that filled the disk on 2026-05-14 (single file reached 277 GB
38
+ # logical / 136 GB on-disk for only ~5K chunks). At M=16 link_lists.bin
39
+ # should be ~1 MB for a corpus this size; 500 MB is 500× safety margin.
40
+ # When tripped we abort the ingest run loudly rather than letting the index
41
+ # keep growing into a disk-fill incident.
42
+ HNSW_BLOAT_THRESHOLD_BYTES = 500 * 1024 * 1024 # 500 MB
43
+
44
+
45
+ def _abort_if_hnsw_bloated() -> None:
46
+ if not settings.VECTORS_DIR.exists():
47
+ return
48
+ for f in settings.VECTORS_DIR.rglob("link_lists.bin"):
49
+ try:
50
+ sz = f.stat().st_size
51
+ except OSError:
52
+ continue
53
+ if sz > HNSW_BLOAT_THRESHOLD_BYTES:
54
+ raise RuntimeError(
55
+ "ChromaDB HNSW bloat tripwire: "
56
+ f"{f} is {sz / 1e9:.2f} GB (threshold "
57
+ f"{HNSW_BLOAT_THRESHOLD_BYTES / 1e6:.0f} MB). Aborting ingest. "
58
+ "Delete rag/vectors and re-clone the dataset from HF Hub, then "
59
+ "investigate the ChromaDB version / batch-size that triggered the bloat."
60
+ )
61
+
62
 
63
  # ---------- chunking ----------
64
 
 
313
  embeddings=vectors,
314
  metadatas=metadatas,
315
  )
316
+ _abort_if_hnsw_bloated()
317
  return len(chunks)
318
 
319
 
320
  async def main():
321
  settings.VECTORS_DIR.mkdir(parents=True, exist_ok=True)
322
+ _abort_if_hnsw_bloated() # fail fast if a prior run left a bloated index
323
  pdfs = discover_pdfs()
324
  manifest = load_manifest()
325
  collection = get_chroma_collection()
tmp_batch_extract.py DELETED
@@ -1,90 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Extract text from all PDFs in the batch list. Write each to /tmp/pdfraw/<policy_id>.txt."""
3
- import os
4
- import sys
5
- import re
6
- import pdfplumber
7
-
8
- BATCH_FILE = "/tmp/extract_batch_4.txt"
9
- OUT_DIR = "/tmp/pdfraw"
10
- os.makedirs(OUT_DIR, exist_ok=True)
11
-
12
- MAX_CHARS = 28000
13
-
14
- PATTERNS = [
15
- (re.compile(r"waiting period", re.I), 3),
16
- (re.compile(r"pre[- ]?existing", re.I), 3),
17
- (re.compile(r"sum insured", re.I), 2),
18
- (re.compile(r"entry age|age limit|renewal", re.I), 2),
19
- (re.compile(r"grace period|free look", re.I), 3),
20
- (re.compile(r"room rent|icu", re.I), 3),
21
- (re.compile(r"co[- ]?pay", re.I), 3),
22
- (re.compile(r"deductible", re.I), 2),
23
- (re.compile(r"day care", re.I), 2),
24
- (re.compile(r"domiciliary|ayush|maternity|new\s*born|organ donor|ambulance", re.I), 2),
25
- (re.compile(r"cumulative bonus|no claim|recharge|reload|restoration", re.I), 2),
26
- (re.compile(r"network|hospitals across", re.I), 1),
27
- (re.compile(r"critical illness", re.I), 1),
28
- (re.compile(r"exclusion|excluded", re.I), 2),
29
- (re.compile(r"sub[- ]?limit|cataract|knee|joint replacement", re.I), 2),
30
- (re.compile(r"UIN", re.I), 2),
31
- (re.compile(r"family floater|self.*spouse|dependent", re.I), 1),
32
- (re.compile(r"₹|Rs\.|INR|lakh|crore", re.I), 1),
33
- ]
34
-
35
-
36
- def score(text):
37
- s = 0
38
- for pat, w in PATTERNS:
39
- s += len(pat.findall(text)) * w
40
- defs = len(re.findall(r"Def\.\s*\d+", text))
41
- s -= defs * 2
42
- return s
43
-
44
-
45
- def slug_for(rel_path):
46
- parts = rel_path.split("/")
47
- # rag/corpus/<insurer-slug>/<file-stem>.pdf
48
- insurer = parts[2]
49
- stem = parts[3].replace(".pdf", "")
50
- return f"{insurer}__{stem}"
51
-
52
-
53
- with open(BATCH_FILE) as f:
54
- paths = [ln.strip() for ln in f if ln.strip()]
55
-
56
- base = "/Users/rohitsar/Documents/Personal/AI Work/Insurance Sales Bot"
57
-
58
- for rel in paths:
59
- pid = slug_for(rel)
60
- out = os.path.join(OUT_DIR, pid + ".txt")
61
- if os.path.exists(out):
62
- continue
63
- full = os.path.join(base, rel)
64
- try:
65
- with pdfplumber.open(full) as pdf:
66
- pages_data = []
67
- for i, p in enumerate(pdf.pages):
68
- t = p.extract_text() or ""
69
- pages_data.append((i, t, score(t)))
70
- # First 3 + top-score
71
- selected_idx = set([0, 1, 2])
72
- remaining = sorted([(i, t, s) for i, t, s in pages_data[3:]], key=lambda x: -x[2])
73
- total = sum(len(pages_data[i][1]) for i in selected_idx if i < len(pages_data))
74
- for i, t, sc in remaining:
75
- if total >= MAX_CHARS:
76
- break
77
- if sc <= 0:
78
- continue
79
- selected_idx.add(i)
80
- total += len(t)
81
- out_text = []
82
- for i, t, sc in pages_data:
83
- if i in selected_idx:
84
- out_text.append(f"=== PAGE {i+1} (score={sc}) ===\n{t}")
85
- result = ("\n".join(out_text))[:MAX_CHARS]
86
- with open(out, "w") as fo:
87
- fo.write(result)
88
- print(f"OK {pid} pages={len(pages_data)} chars={len(result)}")
89
- except Exception as e:
90
- print(f"ERR {pid} {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tmp_count_fields.py DELETED
@@ -1,65 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Validate batch 4 JSONs and count populated fields."""
3
- import json
4
- import os
5
-
6
- BASE = "/Users/rohitsar/Documents/Personal/AI Work/Insurance Sales Bot/rag/extracted"
7
- BATCH = [
8
- "hdfc-ergo__total-health-plan__wordings",
9
- "icici-lombard__arogya-sanjeevani__wordings",
10
- "icici-lombard__complete-health-insurance-health-shield__wordings",
11
- "icici-lombard__complete-health-insurance-umbrella__wordings",
12
- "icici-lombard__elevate__wordings",
13
- "icici-lombard__health-advantedge__wordings",
14
- "icici-lombard__health-booster-top-up__wordings",
15
- "icici-lombard__health-elite-plus__wordings",
16
- "icici-lombard__health-shield-360-retail__cis",
17
- "icici-lombard__health-shield-360-retail__wordings",
18
- "iffco-tokio__critical-illness-benefit__wordings",
19
- "iffco-tokio__essential-health-plan__wordings",
20
- "iffco-tokio__family-health-protector__wordings",
21
- "iffco-tokio__health-protector-assure__wordings",
22
- "iffco-tokio__health-protector-plus__wordings",
23
- "iffco-tokio__individual-health-protector__wordings",
24
- "manipalcigna__prohealth-insurance-all-variants__wordings",
25
- "manipalcigna__prohealth-select__wordings",
26
- "manipalcigna__sarvah-param__brochure",
27
- ]
28
-
29
-
30
- def is_populated(v):
31
- if v is None:
32
- return False
33
- if isinstance(v, (list, dict)) and len(v) == 0:
34
- return False
35
- if isinstance(v, dict):
36
- # CoverageItem: populated if any subfield set
37
- return any(is_populated(x) for x in v.values())
38
- if isinstance(v, str) and v.strip() == "":
39
- return False
40
- return True
41
-
42
-
43
- total = 0
44
- ok = 0
45
- field_counts = []
46
- for pid in BATCH:
47
- path = os.path.join(BASE, pid + ".json")
48
- if not os.path.exists(path):
49
- print(f"MISSING {pid}")
50
- continue
51
- try:
52
- with open(path) as f:
53
- data = json.load(f)
54
- except Exception as e:
55
- print(f"ERR {pid} {e}")
56
- continue
57
- ok += 1
58
- n_pop = sum(1 for v in data.values() if is_populated(v))
59
- field_counts.append((pid, n_pop, len(data)))
60
-
61
- print(f"OK: {ok}/{len(BATCH)}")
62
- print(f"Average populated fields: {sum(c[1] for c in field_counts)/len(field_counts):.1f}")
63
- print()
64
- for pid, n_pop, total_f in field_counts:
65
- print(f" {pid}: {n_pop}/{total_f}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tmp_extract.py DELETED
@@ -1,71 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Extract relevant policy text from a PDF for schema extraction.
3
-
4
- Strategy:
5
- 1. First 3 pages (preamble + ToC + insurer/UIN).
6
- 2. Pages with high keyword density for benefits / waiting / exclusions / sub-limits.
7
- """
8
- import sys
9
- import pdfplumber
10
- import re
11
-
12
- path = sys.argv[1]
13
- maxchars = int(sys.argv[2]) if len(sys.argv) > 2 else 25000
14
-
15
- # Keyword groups with weights
16
- PATTERNS = [
17
- (re.compile(r"waiting period", re.I), 3),
18
- (re.compile(r"pre[- ]?existing", re.I), 3),
19
- (re.compile(r"sum insured", re.I), 2),
20
- (re.compile(r"entry age|age limit|renewal", re.I), 2),
21
- (re.compile(r"grace period|free look", re.I), 3),
22
- (re.compile(r"room rent|icu", re.I), 3),
23
- (re.compile(r"co[- ]?pay", re.I), 3),
24
- (re.compile(r"deductible", re.I), 2),
25
- (re.compile(r"day care", re.I), 2),
26
- (re.compile(r"domiciliary|ayush|maternity|new\s*born|organ donor|ambulance", re.I), 2),
27
- (re.compile(r"cumulative bonus|no claim|recharge|reload|restoration", re.I), 2),
28
- (re.compile(r"network|hospitals across", re.I), 1),
29
- (re.compile(r"critical illness", re.I), 1),
30
- (re.compile(r"exclusion|excluded", re.I), 2),
31
- (re.compile(r"sub[- ]?limit|cataract|knee|joint replacement", re.I), 2),
32
- (re.compile(r"UIN", re.I), 2),
33
- (re.compile(r"family floater|self.*spouse|dependent", re.I), 1),
34
- (re.compile(r"₹|Rs\.|INR|lakh|crore", re.I), 1),
35
- ]
36
-
37
- def score(text):
38
- s = 0
39
- for pat, w in PATTERNS:
40
- s += len(pat.findall(text)) * w
41
- # Penalty for "Def." dense definition pages
42
- defs = len(re.findall(r"Def\.\s*\d+", text))
43
- s -= defs * 2
44
- return s
45
-
46
- with pdfplumber.open(path) as pdf:
47
- pages_data = []
48
- for i, p in enumerate(pdf.pages):
49
- t = p.extract_text() or ""
50
- pages_data.append((i, t, score(t)))
51
-
52
- # Always include first 3 pages
53
- selected_idx = set([0, 1, 2])
54
- # Sort remaining pages by score
55
- remaining = sorted(pages_data[3:], key=lambda x: -x[2])
56
- total = sum(len(pages_data[i][1]) for i in selected_idx)
57
- for i, t, sc in remaining:
58
- if total >= maxchars:
59
- break
60
- if sc <= 0:
61
- continue
62
- selected_idx.add(i)
63
- total += len(t)
64
-
65
- # Output in page order
66
- out = []
67
- for i, t, sc in pages_data:
68
- if i in selected_idx:
69
- out.append(f"=== PAGE {i+1} (score={sc}) ===\n{t}")
70
-
71
- print(("\n".join(out))[:maxchars])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tools/ingest_kb_summaries.py CHANGED
@@ -162,6 +162,8 @@ async def main() -> None:
162
  "local_path": str(f.relative_to(ROOT)),
163
  })
164
  coll.add(ids=ids, documents=texts, embeddings=vecs, metadatas=metadatas)
 
 
165
  total_policies += 1
166
  total_chunks += len(sections)
167
  if total_policies % 25 == 0:
 
162
  "local_path": str(f.relative_to(ROOT)),
163
  })
164
  coll.add(ids=ids, documents=texts, embeddings=vecs, metadatas=metadatas)
165
+ from rag.ingest import _abort_if_hnsw_bloated
166
+ _abort_if_hnsw_bloated()
167
  total_policies += 1
168
  total_chunks += len(sections)
169
  if total_policies % 25 == 0:
tools/ingest_reviews.py CHANGED
@@ -231,6 +231,8 @@ async def main():
231
  "local_path": str(f),
232
  })
233
  coll.add(ids=ids, documents=texts, embeddings=vecs, metadatas=metadatas)
 
 
234
  print(f" OK {slug:18s} {len(chunks)} chunks ({sum(len(t) for t in texts):>5d} chars)")
235
  ok_insurers += 1
236
  ok_chunks += len(chunks)
 
231
  "local_path": str(f),
232
  })
233
  coll.add(ids=ids, documents=texts, embeddings=vecs, metadatas=metadatas)
234
+ from rag.ingest import _abort_if_hnsw_bloated
235
+ _abort_if_hnsw_bloated()
236
  print(f" OK {slug:18s} {len(chunks)} chunks ({sum(len(t) for t in texts):>5d} chars)")
237
  ok_insurers += 1
238
  ok_chunks += len(chunks)