Spaces:
Sleeping
fix: KI-018..022 — routing, profile telemetry, NIM timeouts, judge JSON, chat clear
Browse filesFive 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 +245 -0
- backend/main.py +32 -0
- backend/orchestrator.py +30 -7
- backend/providers/nvidia_nim_llm.py +36 -7
- backend/session_state.py +23 -0
- eval/run.py +44 -2
- frontend/src/app/page.tsx +64 -4
- frontend/src/lib/api.ts +23 -0
- rag/ingest.py +28 -0
- tmp_batch_extract.py +0 -90
- tmp_count_fields.py +0 -65
- tmp_extract.py +0 -71
- tools/ingest_kb_summaries.py +2 -0
- tools/ingest_reviews.py +2 -0
|
@@ -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).*
|
|
@@ -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
|
|
@@ -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
|
| 169 |
-
#
|
| 170 |
-
# ("I want health insurance")
|
| 171 |
-
#
|
| 172 |
-
#
|
| 173 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
@@ -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():
|
|
@@ -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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
@@ -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 |
-
<
|
| 619 |
-
{
|
| 620 |
-
|
| 621 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 && (
|
|
@@ -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);
|
|
@@ -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()
|
|
@@ -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}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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:
|
|
@@ -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)
|