Spaces:
Sleeping
Sleeping
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 +1 -1
- frontend/src/lib/useLiveConversation.ts +32 -11
- tools/canonicalize_policy_names.py +212 -0
- tools/ingest_curated_into_chroma.py +318 -0
- tools/upload_extracted_to_dataset.py +1 -1
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-
|
| 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
|
| 91 |
-
//
|
| 92 |
-
//
|
| 93 |
-
|
|
|
|
|
|
|
| 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
|
| 123 |
-
// KI-
|
| 124 |
-
//
|
| 125 |
-
//
|
| 126 |
-
//
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
| 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 *
|
| 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="
|
| 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")))
|