rohitsar567 commited on
Commit
9c01288
·
1 Parent(s): 737571a

fix(rag+voice): KI-137 + KI-138 + KI-139 — ingest 21 curated policies into Chroma, canonicalize 84 policy names, fix voice VAD thresholds

Browse files
Dockerfile CHANGED
@@ -70,7 +70,7 @@ COPY 40-data ./40-data
70
  # (7356 chunks from a prior ingest) instead of the freshly-uploaded
71
  # cleaned one (3799 chunks). Bump CACHE_BUST manually each time the
72
  # dataset is re-uploaded; the value just needs to change.
73
- ARG DATASET_CACHE_BUST=2026-05-15-ki126-v1
74
  RUN echo "Dataset cache bust: ${DATASET_CACHE_BUST}" && python -c "\
75
  from huggingface_hub import snapshot_download; \
76
  snapshot_download(\
 
70
  # (7356 chunks from a prior ingest) instead of the freshly-uploaded
71
  # cleaned one (3799 chunks). Bump CACHE_BUST manually each time the
72
  # dataset is re-uploaded; the value just needs to change.
73
+ ARG DATASET_CACHE_BUST=2026-05-15-ki137-v1
74
  RUN echo "Dataset cache bust: ${DATASET_CACHE_BUST}" && python -c "\
75
  from huggingface_hub import snapshot_download; \
76
  snapshot_download(\
frontend/src/lib/useLiveConversation.ts CHANGED
@@ -87,10 +87,12 @@ export type LiveConversationState = {
87
  };
88
 
89
  const DEFAULTS = {
90
- // KI-113 (2026-05-15) — raised from 18 → 26 to reject ambient background
91
- // noise (HVAC, traffic, distant chatter). Effective threshold is
92
- // max(this, adaptive noise_floor * 2.5 + 6) see KI-114 in the VAD loop.
93
- rmsThreshold: 26,
 
 
94
  // KI-113 — raised 3 → 5 (~80 ms sustained). Single clicks / cutlery /
95
  // typing transients no longer flip the gate. Preroll buffer (KI-044)
96
  // still captures the first phoneme via the 300 ms look-back.
@@ -119,12 +121,15 @@ const DEFAULTS = {
119
  // segment. Avoids bot's TTS attack transient bleeding through even
120
  // with echoCancellation on.
121
  postUtteranceCooldownMs: 700,
122
- // KI-113 raised 0.35 0.50 to gate out broadband HVAC / fan / traffic.
123
- // KI-134 (2026-05-15) — backed off to 0.35 because some laptop built-in
124
- // mics with aggressive noiseSuppression flatten the voice-band proportion
125
- // to 0.35-0.45, so the VAD never opens and the green pill renders but
126
- // no audio is ever posted matches the live-test symptom exactly.
127
- voiceBandMinProp: 0.35,
 
 
 
128
  };
129
 
130
  // AudioWorklet processor source — inlined as a Blob URL so we don't need
@@ -329,9 +334,14 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
329
  // (which sits just above ambient because high frequencies attenuate
330
  // with distance) is rejected. Close-in speech still clears the gate
331
  // because the speaker's directly-radiated energy is ~10-20× ambient.
 
 
 
 
 
332
  const effectiveThreshold = Math.max(
333
  cfg.rmsThreshold,
334
- noiseFloorRef.current * 2.5 + 6,
335
  );
336
 
337
  // KI-057 — suppress new triggers right after we closed a segment
@@ -507,6 +517,17 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
507
  return;
508
  }
509
  }
 
 
 
 
 
 
 
 
 
 
 
510
  sampleRateRef.current = ctx.sampleRate;
511
 
512
  const source = ctx.createMediaStreamSource(stream);
 
87
  };
88
 
89
  const DEFAULTS = {
90
+ // KI-113 raised from 18 → 26 to reject ambient noise.
91
+ // KI-139 (2026-05-15) backed off to 18 because voice-forensics agent
92
+ // proved 26 sat ABOVE typical speech avg on consumer mics (especially
93
+ // built-ins with active noise gate that pin noiseFloor to 0). VAD never
94
+ // opened → green pill rendered → zero audio posted.
95
+ rmsThreshold: 18,
96
  // KI-113 — raised 3 → 5 (~80 ms sustained). Single clicks / cutlery /
97
  // typing transients no longer flip the gate. Preroll buffer (KI-044)
98
  // still captures the first phoneme via the 300 ms look-back.
 
121
  // segment. Avoids bot's TTS attack transient bleeding through even
122
  // with echoCancellation on.
123
  postUtteranceCooldownMs: 700,
124
+ // KI-113 raised to 0.50, KI-134 backed off to 0.35.
125
+ // KI-139 (2026-05-15) — voice-forensics agent measured live bundle on
126
+ // user's actual hardware: voiceProp sits at 0.25–0.33 on quiet laptop
127
+ // mics with NS enabled. 0.35 still gates the user out. 0.20 puts the
128
+ // floor well below voiced-speech minimum and only rejects pure tones
129
+ // (constant whine of HVAC, traffic). This is the deepest pushback
130
+ // — if HVAC noise creeps in, KI-140 will add a /api/transcribe round
131
+ // trip that detects empty responses and surfaces "couldn't hear you".
132
+ voiceBandMinProp: 0.20,
133
  };
134
 
135
  // AudioWorklet processor source — inlined as a Blob URL so we don't need
 
334
  // (which sits just above ambient because high frequencies attenuate
335
  // with distance) is rejected. Close-in speech still clears the gate
336
  // because the speaker's directly-radiated energy is ~10-20× ambient.
337
+ // KI-139 (2026-05-15) — noise-floor multiplier 2.5 → 1.8. On built-in
338
+ // mics with active noise gate, noiseFloor EMAs to 0 → effectiveThreshold
339
+ // pinned at max(rmsThreshold, 6). User's voice avg sits 18-22 then
340
+ // never crosses. 1.8 keeps headroom for HVAC (which pins at 5-7) but
341
+ // lets quiet voice through.
342
  const effectiveThreshold = Math.max(
343
  cfg.rmsThreshold,
344
+ noiseFloorRef.current * 1.8 + 6,
345
  );
346
 
347
  // KI-057 — suppress new triggers right after we closed a segment
 
517
  return;
518
  }
519
  }
520
+ // KI-139 (2026-05-15) — Safari iOS resume() can return without
521
+ // throwing yet leave state at "suspended" — silent rejection of the
522
+ // autoplay-policy unlock. Treat anything other than "running" as a
523
+ // failure and surface to the user.
524
+ if (ctx.state !== "running") {
525
+ // eslint-disable-next-line no-console
526
+ console.error("[live-mode] AudioContext state stuck at", ctx.state, "— giving up");
527
+ setMicPermissionDenied(true);
528
+ setLive(false);
529
+ return;
530
+ }
531
  sampleRateRef.current = ctx.sampleRate;
532
 
533
  const source = ctx.createMediaStreamSource(stream);
tools/canonicalize_policy_names.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """KI-138: Canonicalize policy_name across extracted JSONs and Chroma metadata.
3
+
4
+ Strategy:
5
+ - For each `policy_id_base` (policy_id stripped of trailing `__<doctype>`),
6
+ join extracted policy_name(s) against Chroma chunk policy_name(s).
7
+ - When they disagree, treat the Chroma-majority name as the canonical label
8
+ (Chroma names are short clean labels; extracted names often contain
9
+ parenthetical descriptions or doctype suffixes).
10
+ - Overwrite the extracted JSON `policy_name` and update every Chroma chunk's
11
+ `policy_name` metadata in place.
12
+
13
+ Skip list (require human review — left untouched):
14
+ - bajaj-allianz__group-health-guard-gold (Silver/Gold conflict)
15
+ - reliance-general__hospi-care, __health-gain, __group-mediclaim (IndusInd co-brand)
16
+ - regulatory__irda-grievance-redressal-handbook (irda→irdai slug rename)
17
+ - regulatory__protection-of-policyholders-interests-2024 (slug rename)
18
+
19
+ Backup of Chroma sqlite was taken before running:
20
+ rag/vectors/chroma.sqlite3.pre-ki138.bak
21
+
22
+ Run:
23
+ /Users/rohitsar/.cache/uv-venvs/insurance-sales-bot/bin/python3 \
24
+ tools/canonicalize_policy_names.py
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import glob
30
+ import json
31
+ import os
32
+ import sys
33
+ from collections import Counter, defaultdict
34
+ from typing import Dict, List, Tuple
35
+
36
+ import chromadb
37
+
38
+ REPO = '/Users/rohitsar/Developer/Insurance Sales Bot'
39
+ EXTRACTED_DIR = os.path.join(REPO, 'rag/_hf_dataset_backup/rag/extracted')
40
+ CHROMA_PATH = os.path.join(REPO, 'rag/vectors/')
41
+ MISMATCH_REPORT = '/tmp/policy_name_mismatches.json'
42
+
43
+ SKIP_BASES = {
44
+ 'bajaj-allianz__group-health-guard-gold',
45
+ 'reliance-general__hospi-care',
46
+ 'reliance-general__health-gain',
47
+ 'reliance-general__group-mediclaim',
48
+ 'regulatory__irda-grievance-redressal-handbook',
49
+ 'regulatory__protection-of-policyholders-interests-2024',
50
+ }
51
+
52
+
53
+ def policy_id_base(pid: str) -> str:
54
+ parts = pid.split('__')
55
+ return '__'.join(parts[:-1]) if len(parts) >= 3 else pid
56
+
57
+
58
+ def load_extracted() -> Dict[str, List[Tuple[str, str, str]]]:
59
+ """base -> [(path, policy_name, policy_id), ...]"""
60
+ out: Dict[str, List[Tuple[str, str, str]]] = defaultdict(list)
61
+ for path in glob.glob(os.path.join(EXTRACTED_DIR, '*.json')):
62
+ fname = os.path.basename(path)
63
+ if fname.startswith('_'):
64
+ continue
65
+ try:
66
+ with open(path, 'r', encoding='utf-8') as fh:
67
+ d = json.load(fh)
68
+ except Exception as exc:
69
+ print(f'WARN unreadable extracted JSON {path}: {exc}', file=sys.stderr)
70
+ continue
71
+ pid = d.get('policy_id', fname.replace('.json', ''))
72
+ pname = d.get('policy_name', '')
73
+ out[policy_id_base(pid)].append((path, pname, pid))
74
+ return out
75
+
76
+
77
+ def load_chroma(coll) -> Dict[str, List[Tuple[str, str, str]]]:
78
+ """base -> [(chunk_id, policy_name, policy_id), ...]"""
79
+ got = coll.get(include=['metadatas'])
80
+ out: Dict[str, List[Tuple[str, str, str]]] = defaultdict(list)
81
+ for cid, md in zip(got['ids'], got['metadatas']):
82
+ pid = md.get('policy_id', '')
83
+ pname = md.get('policy_name', '')
84
+ out[policy_id_base(pid)].append((cid, pname, pid))
85
+ return out
86
+
87
+
88
+ def build_mismatch_table(extracted, chroma) -> List[dict]:
89
+ rows: List[dict] = []
90
+ bases = set(extracted) | set(chroma)
91
+ for base in sorted(bases):
92
+ ext = extracted.get(base, [])
93
+ chr_ = chroma.get(base, [])
94
+ if not ext or not chr_:
95
+ continue
96
+ ext_names = [n for _, n, _ in ext]
97
+ chr_names = [n for _, n, _ in chr_]
98
+ if set(ext_names) == set(chr_names):
99
+ continue
100
+ chr_majority = Counter(chr_names).most_common(1)[0][0]
101
+ rows.append({
102
+ 'policy_id_base': base,
103
+ 'extracted_name': Counter(ext_names).most_common(1)[0][0],
104
+ 'extracted_names_all': sorted(set(ext_names)),
105
+ 'chroma_name_majority': chr_majority,
106
+ 'chroma_names_all': sorted(set(chr_names)),
107
+ 'proposed_canonical': chr_majority,
108
+ 'extracted_files': [p for p, _, _ in ext],
109
+ 'chroma_doc_ids': [c for c, _, _ in chr_],
110
+ 'chroma_chunk_count': len(chr_),
111
+ })
112
+ return rows
113
+
114
+
115
+ def main() -> int:
116
+ client = chromadb.PersistentClient(path=CHROMA_PATH)
117
+ coll = client.get_collection('policies')
118
+
119
+ extracted = load_extracted()
120
+ chroma = load_chroma(coll)
121
+
122
+ rows = build_mismatch_table(extracted, chroma)
123
+ with open(MISMATCH_REPORT, 'w', encoding='utf-8') as fh:
124
+ json.dump(rows, fh, indent=2, ensure_ascii=False)
125
+ print(f'mismatch table: {len(rows)} rows -> {MISMATCH_REPORT}')
126
+
127
+ actionable = [r for r in rows if r['policy_id_base'] not in SKIP_BASES]
128
+ skipped = [r for r in rows if r['policy_id_base'] in SKIP_BASES]
129
+ print(f'actionable: {len(actionable)} skipped (human review): {len(skipped)}')
130
+
131
+ json_updates = 0
132
+ json_missing = 0
133
+ chroma_updates = 0
134
+ samples = []
135
+
136
+ for row in actionable:
137
+ canonical = row['proposed_canonical']
138
+
139
+ # 1) Update extracted JSON files
140
+ for fpath in row['extracted_files']:
141
+ if not os.path.exists(fpath):
142
+ print(f'WARN missing extracted JSON: {fpath}', file=sys.stderr)
143
+ json_missing += 1
144
+ continue
145
+ try:
146
+ with open(fpath, 'r', encoding='utf-8') as fh:
147
+ d = json.load(fh)
148
+ except Exception as exc:
149
+ print(f'WARN unreadable {fpath}: {exc}', file=sys.stderr)
150
+ continue
151
+ before = d.get('policy_name', '')
152
+ if before != canonical:
153
+ d['policy_name'] = canonical
154
+ with open(fpath, 'w', encoding='utf-8') as fh:
155
+ json.dump(d, fh, indent=2, ensure_ascii=False)
156
+ json_updates += 1
157
+
158
+ # 2) Update Chroma metadata for every chunk in this base
159
+ ids_to_update: List[str] = []
160
+ metas_to_update: List[dict] = []
161
+ existing = coll.get(ids=row['chroma_doc_ids'], include=['metadatas'])
162
+ for cid, md in zip(existing['ids'], existing['metadatas']):
163
+ if md.get('policy_name') == canonical:
164
+ continue
165
+ new_md = dict(md)
166
+ new_md['policy_name'] = canonical
167
+ ids_to_update.append(cid)
168
+ metas_to_update.append(new_md)
169
+
170
+ if ids_to_update:
171
+ # batch update for speed
172
+ BATCH = 500
173
+ for i in range(0, len(ids_to_update), BATCH):
174
+ coll.update(
175
+ ids=ids_to_update[i:i + BATCH],
176
+ metadatas=metas_to_update[i:i + BATCH],
177
+ )
178
+ chroma_updates += len(ids_to_update)
179
+
180
+ if len(samples) < 5:
181
+ samples.append({
182
+ 'policy_id_base': row['policy_id_base'],
183
+ 'before_extracted': row['extracted_name'],
184
+ 'before_chroma_majority': row['chroma_name_majority'],
185
+ 'after': canonical,
186
+ })
187
+
188
+ print(f'JSON files updated: {json_updates} (missing: {json_missing})')
189
+ print(f'Chroma chunks updated: {chroma_updates}')
190
+
191
+ # 3) Verify by re-joining
192
+ extracted2 = load_extracted()
193
+ chroma2 = load_chroma(coll)
194
+ remaining = build_mismatch_table(extracted2, chroma2)
195
+ remaining_bases = [r['policy_id_base'] for r in remaining]
196
+ print(f'mismatches_remaining: {len(remaining)}')
197
+ for r in remaining:
198
+ marker = 'SKIPPED' if r['policy_id_base'] in SKIP_BASES else 'UNEXPECTED'
199
+ print(f" [{marker}] {r['policy_id_base']}: ext={r['extracted_name']!r} chr={r['chroma_name_majority']!r}")
200
+
201
+ print('\nsample before/after:')
202
+ for s in samples:
203
+ print(f" {s['policy_id_base']}")
204
+ print(f" extracted-before: {s['before_extracted']}")
205
+ print(f" chroma-before: {s['before_chroma_majority']}")
206
+ print(f" after (canonical):{s['after']}")
207
+
208
+ return 0
209
+
210
+
211
+ if __name__ == '__main__':
212
+ sys.exit(main())
tools/ingest_curated_into_chroma.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest curated-fact JSONs into the Chroma `policies` collection.
2
+
3
+ Problem: ~21 policies appear in the marketplace (/api/policies/all) but have
4
+ ZERO Chroma chunks, because their canonical PDFs are image-only / not in the
5
+ corpus / or the curated JSON is the only data source we have. The chat bot
6
+ cannot retrieve or cite these policies — RAG returns no hits.
7
+
8
+ Fix: render each curated policy_facts JSON into a flat text representation
9
+ that mimics what a wordings PDF chunk would look like, embed it with the
10
+ SAME LocalEmbeddings (BGE-small, 384-dim) used by rag/ingest.py, and add it
11
+ to the SAME `policies` collection with metadata.doc_type='curated' so the
12
+ retriever can find these the same way it finds PDF chunks.
13
+
14
+ Run:
15
+ /Users/rohitsar/.cache/uv-venvs/insurance-sales-bot/bin/python3 \
16
+ tools/ingest_curated_into_chroma.py
17
+
18
+ Idempotent — re-running skips any policy_id already present in Chroma.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import json
25
+ import re
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ import chromadb
30
+ from chromadb.config import Settings as ChromaSettings
31
+
32
+ # Make backend importable
33
+ ROOT = Path(__file__).resolve().parent.parent
34
+ sys.path.insert(0, str(ROOT))
35
+
36
+ from backend.config import settings # noqa: E402
37
+ from backend.main import _load_curated_facts # noqa: E402
38
+ from backend.providers.local_embeddings import LocalEmbeddings # noqa: E402
39
+ from rag.ingest import ( # noqa: E402
40
+ CHUNK_CHARS,
41
+ OVERLAP_CHARS,
42
+ chunk_pages,
43
+ get_chroma_collection,
44
+ )
45
+
46
+ CURATED_DIR = ROOT / "40-data" / "policy_facts"
47
+ CORPUS_URLS_MD = ROOT / "40-data" / "corpus_urls.md"
48
+
49
+
50
+ # ---------- corpus_urls.md → policy_id → source_url ----------
51
+
52
+ def _load_corpus_urls() -> dict[str, str]:
53
+ """Parse 40-data/corpus_urls.md table rows into {insurer_slug+policy_name_slug: url}.
54
+
55
+ Used as a best-effort source_url backfill for curated policies that have
56
+ no PDF in rag/corpus/. The slug match is fuzzy — we slugify the
57
+ policy_name and check if it overlaps with the curated policy_id stem.
58
+ """
59
+ if not CORPUS_URLS_MD.exists():
60
+ return {}
61
+ text = CORPUS_URLS_MD.read_text()
62
+ rows: dict[str, str] = {}
63
+ for line in text.splitlines():
64
+ if not line.startswith("| ") or "---" in line or "insurer_slug" in line:
65
+ continue
66
+ cells = [c.strip() for c in line.strip("|").split("|")]
67
+ if len(cells) < 5:
68
+ continue
69
+ insurer_slug, _insurer_name, policy_name, _doc_type, url = cells[:5]
70
+ if not url.startswith("http"):
71
+ continue
72
+ name_slug = re.sub(r"[^a-z0-9]+", "-", policy_name.lower()).strip("-")
73
+ rows.setdefault(f"{insurer_slug}__{name_slug}", url)
74
+ # Also index a shorter token-overlap key for fuzzier matching
75
+ rows.setdefault(name_slug, url)
76
+ return rows
77
+
78
+
79
+ def _best_source_url(policy_id: str, curated_data: dict, url_index: dict[str, str]) -> str:
80
+ """Pick a source URL for a curated policy. Preference order:
81
+ 1. Any non-null `source_url` field in the curated JSON
82
+ 2. corpus_urls.md row whose insurer_slug__name_slug matches policy_id
83
+ 3. Empty string (downstream code accepts "" gracefully)
84
+ """
85
+ # 1. Look inside the curated JSON for any source_url
86
+ for k, v in curated_data.items():
87
+ if isinstance(v, dict) and v.get("source_url"):
88
+ return v["source_url"]
89
+ # 2. corpus_urls.md fuzzy match
90
+ if policy_id in url_index:
91
+ return url_index[policy_id]
92
+ # Try the stem (everything after `insurer__`)
93
+ parts = policy_id.split("__", 1)
94
+ if len(parts) == 2:
95
+ stem = parts[1]
96
+ if stem in url_index:
97
+ return url_index[stem]
98
+ # Loose match: check any url-index key that contains the stem
99
+ for key, url in url_index.items():
100
+ if stem in key or key in stem:
101
+ return url
102
+ return ""
103
+
104
+
105
+ # ---------- curated JSON → flat text representation ----------
106
+
107
+ _HUMAN_LABEL = {
108
+ "uin_code": "UIN Code",
109
+ "min_entry_age": "Minimum Entry Age",
110
+ "max_entry_age": "Maximum Entry Age",
111
+ "max_renewal_age": "Maximum Renewal Age",
112
+ "sum_insured_options": "Sum Insured Options",
113
+ "initial_waiting_period_days": "Initial Waiting Period (days)",
114
+ "pre_existing_disease_waiting_months": "Pre-Existing Disease Waiting Period (months)",
115
+ "specific_disease_waiting_months": "Specific Disease Waiting Period (months)",
116
+ "maternity_waiting_months": "Maternity Waiting Period (months)",
117
+ "pre_hospitalization_days": "Pre-Hospitalization Coverage (days)",
118
+ "post_hospitalization_days": "Post-Hospitalization Coverage (days)",
119
+ "day_care_treatments_count": "Number of Day-Care Treatments Covered",
120
+ "ayush_coverage": "AYUSH Coverage",
121
+ "maternity_coverage": "Maternity Coverage",
122
+ "newborn_coverage": "Newborn Baby Coverage",
123
+ "organ_donor_expenses": "Organ Donor Expenses",
124
+ "no_claim_bonus_pct": "No Claim Bonus (%)",
125
+ "restoration_benefit": "Restoration / Reload Benefit",
126
+ "room_rent_capping": "Room Rent Capping",
127
+ "copayment_pct": "Co-Payment (%)",
128
+ "deductible_amount": "Deductible Amount",
129
+ "network_hospital_count": "Network Hospital Count",
130
+ "cashless_treatment_supported": "Cashless Treatment Supported",
131
+ "claim_settlement_ratio": "Claim Settlement Ratio (%)",
132
+ "tat_cashless_authorization_hours": "Cashless Authorization Turnaround Time (hours)",
133
+ "policy_type": "Policy Type",
134
+ }
135
+
136
+
137
+ def render_curated_to_text(curated: dict) -> str:
138
+ """Render a curated_facts dict into a flat text document the embedder
139
+ can ingest. Includes both value and source_quote so the LLM sees the
140
+ full evidence the curator used.
141
+ """
142
+ policy_name = curated.get("policy_name") or curated.get("policy_id", "")
143
+ insurer_slug = curated.get("insurer_slug", "")
144
+ out: list[str] = []
145
+ out.append(f"Policy Name: {policy_name}")
146
+ out.append(f"Insurer: {insurer_slug}")
147
+ out.append(f"Policy ID: {curated.get('policy_id', '')}")
148
+ out.append("")
149
+ out.append("Structured Policy Facts (curated from official wordings/CIS/brochure):")
150
+ out.append("")
151
+ for key, val in curated.items():
152
+ if key.startswith("_") or key in ("policy_id", "policy_name", "insurer_slug"):
153
+ continue
154
+ label = _HUMAN_LABEL.get(key, key.replace("_", " ").title())
155
+ if isinstance(val, dict):
156
+ value = val.get("value")
157
+ quote = val.get("source_quote") or ""
158
+ unit = val.get("unit", "")
159
+ if value is None or value == "":
160
+ # Skip null fields but keep the source_quote if it gives context
161
+ if quote:
162
+ out.append(f"- {label}: not specified. Source note: {quote}")
163
+ continue
164
+ display = f"{value} {unit}".strip() if unit else str(value)
165
+ line = f"- {label}: {display}."
166
+ if quote:
167
+ line += f" Source quote: \"{quote}\""
168
+ out.append(line)
169
+ else:
170
+ if val is None or val == "" or val == []:
171
+ continue
172
+ out.append(f"- {label}: {val}")
173
+
174
+ # Meta block — curation context + primary source PDF
175
+ meta = curated.get("_meta") or {}
176
+ if meta:
177
+ out.append("")
178
+ out.append("Curation metadata:")
179
+ if meta.get("curated_at"):
180
+ out.append(f"- Curated on: {meta['curated_at']}")
181
+ if meta.get("primary_source_pdf"):
182
+ out.append(f"- Primary source PDF: {meta['primary_source_pdf']}")
183
+ if meta.get("completeness_pct") is not None:
184
+ out.append(f"- Curation completeness: {meta['completeness_pct']}%")
185
+ if meta.get("notes"):
186
+ out.append(f"- Notes: {meta['notes']}")
187
+
188
+ return "\n".join(out)
189
+
190
+
191
+ # ---------- main pipeline ----------
192
+
193
+ async def main():
194
+ # 1. Identify missing curated policies
195
+ curated_all = _load_curated_facts()
196
+ # _load_curated_facts adds __wordings/__brochure/__cis duplicate keys;
197
+ # collapse to the actual policy_id from inside each entry.
198
+ base_curated: dict[str, dict] = {}
199
+ for _key, data in curated_all.items():
200
+ real_pid = data.get("policy_id")
201
+ if real_pid and real_pid not in base_curated:
202
+ base_curated[real_pid] = data
203
+ print(f"Distinct curated policy_ids: {len(base_curated)}")
204
+
205
+ coll = get_chroma_collection()
206
+ print(f"Chroma `policies` chunks before ingest: {coll.count()}")
207
+
208
+ existing = coll.get(include=["metadatas"])
209
+ chroma_pids = set(md.get("policy_id") for md in existing["metadatas"])
210
+ print(f"Chroma unique policy_ids before ingest: {len(chroma_pids)}")
211
+
212
+ # Missing = curated pid AND no chroma pid that exactly matches or matches
213
+ # `<curated_pid>__<docvariant>` (Chroma stores e.g. `...__wordings`).
214
+ missing: list[str] = []
215
+ for pid in sorted(base_curated):
216
+ if pid in chroma_pids:
217
+ continue
218
+ if any(c.startswith(pid + "__") for c in chroma_pids):
219
+ continue
220
+ missing.append(pid)
221
+ print(f"\nMissing curated policies: {len(missing)}")
222
+ for m in missing:
223
+ print(f" - {m}")
224
+
225
+ if not missing:
226
+ print("\nNothing to ingest. Exiting.")
227
+ return
228
+
229
+ # 2. Render + embed + add
230
+ url_index = _load_corpus_urls()
231
+ embedder = LocalEmbeddings()
232
+ print(f"\nEmbedder: {embedder.name} ({embedder.model_name}, dim={embedder.dimension}, device={embedder.device})")
233
+
234
+ total_chunks = 0
235
+ per_policy: list[tuple[str, int]] = []
236
+
237
+ for pid in missing:
238
+ data = base_curated[pid]
239
+ text = render_curated_to_text(data)
240
+ n_chars = len(text)
241
+
242
+ # Single chunk if under target, else slide window via rag.ingest.chunk_pages
243
+ # (chunk_pages expects [(page_no, text)]; we feed one virtual page).
244
+ if n_chars <= CHUNK_CHARS:
245
+ chunks = [{
246
+ "chunk_idx": 0,
247
+ "text": text,
248
+ "page_start": 0,
249
+ "page_end": 0,
250
+ "char_start": 0,
251
+ "char_end": n_chars,
252
+ }]
253
+ else:
254
+ chunks = list(chunk_pages(
255
+ pages=[(0, text)],
256
+ target_chars=CHUNK_CHARS,
257
+ overlap_chars=OVERLAP_CHARS,
258
+ ))
259
+
260
+ if not chunks:
261
+ print(f" WARN empty render for {pid}, skipping")
262
+ continue
263
+
264
+ texts = [c["text"] for c in chunks]
265
+ vectors = await embedder.embed(texts, input_type="document")
266
+
267
+ insurer_slug = data.get("insurer_slug") or pid.split("__", 1)[0]
268
+ policy_name = data.get("policy_name") or pid
269
+ source_url = _best_source_url(pid, data, url_index)
270
+
271
+ ids = [f"{pid}::curated::chunk{c['chunk_idx']}" for c in chunks]
272
+ metadatas = [
273
+ {
274
+ "policy_id": pid,
275
+ "insurer_slug": insurer_slug,
276
+ "policy_name": policy_name,
277
+ "doc_type": "curated",
278
+ "source_url": source_url,
279
+ "page_start": 0,
280
+ "page_end": 0,
281
+ "chunk_idx": c["chunk_idx"],
282
+ "local_path": f"curated-facts:{pid}",
283
+ }
284
+ for c in chunks
285
+ ]
286
+
287
+ coll.add(ids=ids, documents=texts, embeddings=vectors, metadatas=metadatas)
288
+
289
+ per_policy.append((pid, len(chunks)))
290
+ total_chunks += len(chunks)
291
+ print(f" + {pid}: {len(chunks)} chunk(s), {n_chars} chars, url={'yes' if source_url else 'no'}")
292
+
293
+ # 3. Verify by re-querying
294
+ print("\n--- Verification ---")
295
+ final_count = coll.count()
296
+ print(f"Chroma `policies` chunks after ingest: {final_count}")
297
+ for pid, n in per_policy:
298
+ got = coll.get(where={"policy_id": pid})
299
+ print(f" verify {pid}: chunks_now={len(got.get('ids') or [])}")
300
+
301
+ # 4. Sample retrieval test — embed a query and pull top-5
302
+ print("\n--- Sample retrieval test ---")
303
+ sample_query = "What are the waiting periods and AYUSH coverage for Aditya Birla Activ One?"
304
+ qv = await embedder.embed([sample_query], input_type="query")
305
+ res = coll.query(query_embeddings=qv, n_results=5)
306
+ for i, (doc_id, md, dist) in enumerate(zip(res["ids"][0], res["metadatas"][0], res["distances"][0])):
307
+ snippet = (res["documents"][0][i] or "")[:120].replace("\n", " ")
308
+ print(f" rank {i+1}: {md.get('policy_id')} (dist={dist:.4f}) doc_type={md.get('doc_type')}")
309
+ print(f" {snippet}...")
310
+
311
+ # 5. Summary
312
+ print("\n--- Summary ---")
313
+ print(f"Policies ingested: {len(per_policy)}")
314
+ print(f"Total chunks added: {total_chunks}")
315
+
316
+
317
+ if __name__ == "__main__":
318
+ asyncio.run(main())
tools/upload_extracted_to_dataset.py CHANGED
@@ -20,7 +20,7 @@ def main():
20
  path_in_repo="rag/extracted",
21
  repo_id="rohitsar567/insurance-bot-data",
22
  repo_type="dataset",
23
- commit_message="sync rag/extracted JSONs (post-NIM-extraction)",
24
  ignore_patterns=["*._raw.txt"],
25
  )
26
  n = len(list((ROOT / "rag" / "extracted").glob("*.json")))
 
20
  path_in_repo="rag/extracted",
21
  repo_id="rohitsar567/insurance-bot-data",
22
  repo_type="dataset",
23
+ commit_message="feat(extracted): KI-138 — canonicalize policy_name across 84 mismatches",
24
  ignore_patterns=["*._raw.txt"],
25
  )
26
  n = len(list((ROOT / "rag" / "extracted").glob("*.json")))