rohitsar567 commited on
Commit
48db939
·
1 Parent(s): 261abb2

fix(chat+marketplace+voice): KI-145 + KI-148 + KI-149 + KI-150 bundle

Browse files

Five fixes from the live-test feedback cycle, all converged into one
rebuild.

KI-145 final (166 marketplace cards) — replaced strict-UIN merge with
field-comparison rule. Same UIN + <2 decision-critical diffs (copay, PED
waiting, maternity coverage+waiting, room rent, restoration, NCB,
post-hospitalization) -> merge as alias. Same UIN + 2+ material diffs ->
keep as separate card (true sub-variants). Result: ProHealth Prime +
Protect, Reassure 3, ICICI Elevate, Activ Assure Diamond and 18 others
correctly emit as standalone cards. 166 cards = 137 extracted-dedup
+ 29 curated standalone (variants + non-UIN-match).

KI-148 (TTS pronunciation) — voice_format.py tts_preprocess now expands
'15k' -> '15 thousand rupees', '1L' -> '1 lakh rupees', '1cr' ->
'1 crore rupees', '15-30k' -> '15 to 30 thousand rupees', '60k+' ->
'above 60 thousand rupees'. Context-gated so '10K marathon' stays
untouched. Stops the bot from reading '₹15k' as 'fifteen k'.

KI-149 (budget + income parser) — needs_finder.py budget and
income_band Questions had no parser; bare numerals like '30000' or
phrases like 'I maximum 30000 I can pay' returned None, the LLM bailed,
orchestrator re-asked the same question. Added _parse_inr_amount +
_parse_budget_band + _parse_income_band helpers; attached as parser= on
both Questions. fact_find_normalizer.py _keyword_normalize delegates
to the same helpers so the LLM brain's greedy capture path also picks
up bare-number budgets. Handles k/lakh/L/cr, ranges, '+' upper buckets,
'maximum X', 'around X', 'I can pay X'.

KI-150 (max_tokens) — fact_find_brain.py was calling llm_fast.chat
with max_tokens=420. System prompt's contract requires prose (~250 tok)
+ <FF>...</FF> trailer (~80 tok) + safety margin. Verbose multi-slot
replies got truncated mid-prose before the trailer, parser failed,
canonical fallback fired, user saw scripted prompt_en (the 'robotic
language' complaint). Bumped 420 -> 700 at both call sites. Inside the
25s outer wait_for budget.

Verified localhost after restart:
/api/policies/all .total = 166, .insurers_indexed = 20
/api/chat with 'I am Rohit, 29' returns conversational nim-chain
reply at 13s, not scripted fallback
tts_preprocess samples all expand correctly to natural speech
budget parser captures 'I maximum 30000 I can pay' -> 30k_60k

No DATASET_CACHE_BUST bump — none of these touch the Chroma corpus.

backend/fact_find_brain.py CHANGED
@@ -459,7 +459,12 @@ async def drive_fact_find(
459
  llm_fast = get_fast_brain_llm()
460
  try:
461
  result = await asyncio.wait_for(
462
- llm_fast.chat(messages=messages, temperature=0.6, max_tokens=420),
 
 
 
 
 
463
  timeout=_TIMEOUT_S,
464
  )
465
  except asyncio.TimeoutError:
@@ -475,7 +480,8 @@ async def drive_fact_find(
475
  llm_heavy = get_brain_llm()
476
  try:
477
  result = await asyncio.wait_for(
478
- llm_heavy.chat(messages=messages, temperature=0.6, max_tokens=420),
 
479
  timeout=_TIMEOUT_S_ESCALATION,
480
  )
481
  except asyncio.TimeoutError:
 
459
  llm_fast = get_fast_brain_llm()
460
  try:
461
  result = await asyncio.wait_for(
462
+ # KI-150 (2026-05-15) bumped 420 → 700. Diagnosis showed the
463
+ # contract requires prose (~250 tok) + <FF>...</FF> trailer
464
+ # (~80 tok); 420 truncated verbose multi-slot replies before
465
+ # the trailer, parser failed, canonical fallback fired, user
466
+ # saw scripted prompt_en (the "robotic language" complaint).
467
+ llm_fast.chat(messages=messages, temperature=0.6, max_tokens=700),
468
  timeout=_TIMEOUT_S,
469
  )
470
  except asyncio.TimeoutError:
 
480
  llm_heavy = get_brain_llm()
481
  try:
482
  result = await asyncio.wait_for(
483
+ # KI-150 — same bump on escalation path for consistency.
484
+ llm_heavy.chat(messages=messages, temperature=0.6, max_tokens=700),
485
  timeout=_TIMEOUT_S_ESCALATION,
486
  )
487
  except asyncio.TimeoutError:
backend/fact_find_normalizer.py CHANGED
@@ -168,19 +168,12 @@ def _keyword_normalize(question_id: str, raw_text: str) -> Any:
168
  return "self"
169
 
170
  elif question_id == "income_band":
171
- import re as _re
172
- if _re.search(r"(more than|above|over|>=?|>)\s*25", s) or "25l+" in s or "25 lakh+" in s:
173
- return "25L+"
174
- m = _re.search(r"(\d+(?:\.\d+)?)\s*(?:l|lakh|lac)", s)
175
- if m:
176
- val = float(m.group(1))
177
- if val >= 25: return "25L+"
178
- if val >= 10: return "10L-25L"
179
- if val >= 5: return "5L-10L"
180
- return "under_5L"
181
- if "10-25" in s or "10l-25l" in s: return "10L-25L"
182
- if "5-10" in s or "5l-10l" in s: return "5L-10L"
183
- if "under 5" in s or "<5" in s or "below 5" in s: return "under_5L"
184
 
185
  elif question_id == "primary_goal":
186
  if any(k in s for k in ["first policy", "first one", "first time", "first buy", "new policy", "buying my first"]):
@@ -208,22 +201,13 @@ def _keyword_normalize(question_id: str, raw_text: str) -> Any:
208
  if "tier 3" in s or "tier3" in s or "village" in s or "small town" in s: return "tier3"
209
 
210
  elif question_id == "budget":
211
- import re as _re
212
- if "60k+" in s or ">60k" in s or "more than 60" in s or "above 60" in s:
213
- return "60k+"
214
- if "30-60" in s or "30k_60k" in s or "30k-60k" in s:
215
- return "30k_60k"
216
- if "15-30" in s or "15k_30k" in s or "15k-30k" in s:
217
- return "15k_30k"
218
- if "under 15" in s or "<15" in s or "below 15" in s or "under_15" in s:
219
- return "under_15k"
220
- m = _re.search(r"(\d+)\s*k", s)
221
- if m:
222
- v = int(m.group(1))
223
- if v >= 60: return "60k+"
224
- if v >= 30: return "30k_60k"
225
- if v >= 15: return "15k_30k"
226
- return "under_15k"
227
 
228
  elif question_id == "health_conditions":
229
  if any(p in s for p in ["none", "no condition", "nothing", "no pre-exist", "no health", "no chronic"]):
 
168
  return "self"
169
 
170
  elif question_id == "income_band":
171
+ # KI-149 (2026-05-15) — delegate to needs_finder._parse_income_band so
172
+ # bare digits ("500000"), "thousand"/"grand"/"k" suffixes, and ""
173
+ # prefixes all parse identically here AND in the GRAPH parser used by
174
+ # `record_answer`. Single source of truth.
175
+ from backend.needs_finder import _parse_income_band
176
+ return _parse_income_band(raw_text)
 
 
 
 
 
 
 
177
 
178
  elif question_id == "primary_goal":
179
  if any(k in s for k in ["first policy", "first one", "first time", "first buy", "new policy", "buying my first"]):
 
201
  if "tier 3" in s or "tier3" in s or "village" in s or "small town" in s: return "tier3"
202
 
203
  elif question_id == "budget":
204
+ # KI-149 (2026-05-15) — delegate to needs_finder._parse_budget_band so
205
+ # "I maximum 30000 I can pay" / "30 thousand" / "₹30,000" / "30 grand"
206
+ # / bare "30000" all parse the same way the GRAPH parser does. The
207
+ # keyword path used to require a "k" suffix and lost bare-digit
208
+ # answers (user reported re-ask bug on live HF Space).
209
+ from backend.needs_finder import _parse_budget_band
210
+ return _parse_budget_band(raw_text)
 
 
 
 
 
 
 
 
 
211
 
212
  elif question_id == "health_conditions":
213
  if any(p in s for p in ["none", "no condition", "nothing", "no pre-exist", "no health", "no chronic"]):
backend/main.py CHANGED
@@ -633,6 +633,11 @@ async def coverage():
633
 
634
  direct_parent_cov: dict[str, str] = {}
635
  curated_canonical_ids_cov: list[str] = []
 
 
 
 
 
636
 
637
  # Phase B — walk curated entries deterministically (sorted by policy_id).
638
  for curated_pid, cdata in sorted(curated_facts.items()):
@@ -657,24 +662,31 @@ async def coverage():
657
  # VARIANT and stays as its own card. Pure RENAME (< 2 diffs)
658
  # falls through to the alias-merge as before.
659
  candidate = uin_to_parent_cov[curated_uin]
660
- ext_data = extracted_data_cov.get(candidate, {})
661
- if _ki145_material_diffs(cdata, ext_data) < 2:
 
 
662
  parent_id = candidate
 
 
663
  elif curated_uin:
 
 
 
664
  uin_to_parent_cov[curated_uin] = curated_pid
 
665
 
666
- if parent_id is None:
667
- # KI-142 fall back to source_pdf gate when UIN doesn't match
668
- # any prior claimant (curated UIN may be more accurate than
669
- # extracted; multi-variant PDFs naturally share one filing).
670
  fb_parent = _source_pdf_to_policy_id(cdata.get("_primary_source_pdf"))
671
  if fb_parent and fb_parent in extracted_stems_cov and fb_parent != curated_pid:
672
- # KI-145 — same material-diff gate on the source-PDF path so
673
- # multi-variant PDFs don't drag genuinely different products
674
- # onto the first claimant via source-PDF coincidence.
675
  ext_data = extracted_data_cov.get(fb_parent, {})
676
  if _ki145_material_diffs(cdata, ext_data) < 2:
677
  parent_id = fb_parent
 
 
678
 
679
  if parent_id:
680
  direct_parent_cov[curated_pid] = parent_id
@@ -752,7 +764,11 @@ async def coverage():
752
  continue # permutation alias
753
  if curated_pid in seen_policy_ids:
754
  continue
755
- if any(eid.startswith(curated_pid + "__") for eid in seen_policy_ids):
 
 
 
 
756
  continue
757
  # KI-141 — skip curated entries that have already been collapsed into
758
  # a pass-1 parent's alias list.
@@ -765,14 +781,24 @@ async def coverage():
765
  # Curated entries don't have a __doctype suffix, so use the full
766
  # policy_id as the product_key.
767
  pkey = curated_pid
768
- if pkey in seen_product_keys:
 
 
 
769
  continue
770
  seen_product_keys.add(pkey)
771
  name = data.get("policy_name", "") or curated_pid
772
  url = data.get("source_pdf_url", "")
773
  if slug not in by_insurer:
774
  by_insurer[slug] = {"products": set(), "names": [], "chunks": 0, "aliases": 0}
775
- by_insurer[slug]["products"].add(pkey)
 
 
 
 
 
 
 
776
  # KI-142 — accumulate alias count for curated parents (curated entries
777
  # that themselves became the claimant of a new UIN, with later curated
778
  # siblings aliasing onto them).
@@ -1628,6 +1654,13 @@ async def policies_all(session_id: Optional[str] = None):
1628
  # ultimate extracted parent.
1629
  direct_parent: dict[str, str] = {}
1630
  curated_canonical_ids: list[str] = []
 
 
 
 
 
 
 
1631
 
1632
  # Phase B — walk curated entries deterministically (sorted by policy_id).
1633
  for curated_policy_id, cdata in sorted(curated_facts.items()):
@@ -1662,28 +1695,35 @@ async def policies_all(session_id: Optional[str] = None):
1662
  # 2+ disagree on non-null values, treat as a VARIANT and keep
1663
  # this curated entry as its own card. < 2 = pure rename → merge.
1664
  candidate = uin_to_parent[curated_uin]
1665
- ext_data = extracted_data.get(candidate, {})
1666
- if _ki145_material_diffs(cdata, ext_data) < 2:
 
 
 
1667
  parent_id = candidate
 
 
1668
  elif curated_uin:
1669
  # New UIN — this curated entry becomes the claimant so any
1670
- # later curated sibling with the same UIN aliases onto it.
 
 
 
1671
  uin_to_parent[curated_uin] = curated_policy_id
 
1672
 
1673
  if parent_id is None and not curated_uin:
1674
- # KI-142 (user rule, 2026-05-15): source-PDF fallback only fires
1675
- # when the curated entry has NO UIN at all. If UIN exists but
1676
- # doesn't match any extracted parent, they're different
1677
- # regulator-filed products and must stay as separate cards —
1678
- # source-PDF coincidence (multi-variant wordings) does NOT merge.
1679
  fb_parent = _source_pdf_to_policy_id(cdata.get("_primary_source_pdf"))
1680
  if fb_parent and fb_parent in extracted_stems and fb_parent != curated_policy_id:
1681
- # KI-145 — apply the same material-diffs gate so multi-variant
1682
- # PDFs without UIN coverage don't drag genuinely different
1683
- # products into the parent card.
1684
  ext_data = extracted_data.get(fb_parent, {})
1685
  if _ki145_material_diffs(cdata, ext_data) < 2:
1686
  parent_id = fb_parent
 
 
1687
 
1688
  if parent_id:
1689
  direct_parent[curated_policy_id] = parent_id
@@ -1824,8 +1864,13 @@ async def policies_all(session_id: Optional[str] = None):
1824
  continue
1825
  if curated_policy_id in seen_policy_ids:
1826
  continue
1827
- # Also skip if any extracted ID matches with a suffix
1828
- if any(eid.startswith(curated_policy_id + "__") for eid in seen_policy_ids):
 
 
 
 
 
1829
  continue
1830
  # KI-141 — skip curated entries that have already been collapsed onto
1831
  # a pass-1 parent card via the aliases mechanism (e.g. Activ One →
 
633
 
634
  direct_parent_cov: dict[str, str] = {}
635
  curated_canonical_ids_cov: list[str] = []
636
+ # KI-145 — curated entries that failed the material-diffs gate (same UIN
637
+ # or source-PDF as a pass-1 card but >= 2 decision-critical fields
638
+ # disagree). These must emit as standalone pass-2 cards so the coverage
639
+ # policy_count stays in lockstep with /api/policies/all.
640
+ ki145_variant_curated_ids_cov: set[str] = set()
641
 
642
  # Phase B — walk curated entries deterministically (sorted by policy_id).
643
  for curated_pid, cdata in sorted(curated_facts.items()):
 
662
  # VARIANT and stays as its own card. Pure RENAME (< 2 diffs)
663
  # falls through to the alias-merge as before.
664
  candidate = uin_to_parent_cov[curated_uin]
665
+ # Candidate may be extracted OR curated — fall back to curated
666
+ # facts when no extracted JSON exists, so the diff has real data.
667
+ cand_data = extracted_data_cov.get(candidate) or curated_facts.get(candidate, {})
668
+ if _ki145_material_diffs(cdata, cand_data) < 2:
669
  parent_id = candidate
670
+ else:
671
+ ki145_variant_curated_ids_cov.add(curated_pid)
672
  elif curated_uin:
673
+ # New UIN — claim it. KI-145 spec: UIN unmatched against any
674
+ # extracted parent = standalone. Flag so pass-2 emits even if
675
+ # policy_id is a prefix of a seen extracted id.
676
  uin_to_parent_cov[curated_uin] = curated_pid
677
+ ki145_variant_curated_ids_cov.add(curated_pid)
678
 
679
+ if parent_id is None and not curated_uin:
680
+ # KI-142 (preserved): source-PDF fallback only when curated entry
681
+ # has NO UIN. When UIN is present but unmatched, KI-145 spec
682
+ # mandates standalone PDF coincidence cannot override.
683
  fb_parent = _source_pdf_to_policy_id(cdata.get("_primary_source_pdf"))
684
  if fb_parent and fb_parent in extracted_stems_cov and fb_parent != curated_pid:
 
 
 
685
  ext_data = extracted_data_cov.get(fb_parent, {})
686
  if _ki145_material_diffs(cdata, ext_data) < 2:
687
  parent_id = fb_parent
688
+ else:
689
+ ki145_variant_curated_ids_cov.add(curated_pid)
690
 
691
  if parent_id:
692
  direct_parent_cov[curated_pid] = parent_id
 
764
  continue # permutation alias
765
  if curated_pid in seen_policy_ids:
766
  continue
767
+ # KI-145 bypass the startswith dedup for genuine variants (same
768
+ # UIN/source-PDF as a pass-1 card but materially different fields).
769
+ # Otherwise variant cards would be silently dropped here.
770
+ if curated_pid not in ki145_variant_curated_ids_cov \
771
+ and any(eid.startswith(curated_pid + "__") for eid in seen_policy_ids):
772
  continue
773
  # KI-141 — skip curated entries that have already been collapsed into
774
  # a pass-1 parent's alias list.
 
781
  # Curated entries don't have a __doctype suffix, so use the full
782
  # policy_id as the product_key.
783
  pkey = curated_pid
784
+ # KI-145 variants share product_key with their pass-1 sibling
785
+ # (different doctype-stripped stems are identical). Allow them past
786
+ # this dedup so coverage policy_count = marketplace card count.
787
+ if pkey in seen_product_keys and curated_pid not in ki145_variant_curated_ids_cov:
788
  continue
789
  seen_product_keys.add(pkey)
790
  name = data.get("policy_name", "") or curated_pid
791
  url = data.get("source_pdf_url", "")
792
  if slug not in by_insurer:
793
  by_insurer[slug] = {"products": set(), "names": [], "chunks": 0, "aliases": 0}
794
+ # KI-145 — variants share pkey with a pass-1 sibling, so adding the
795
+ # bare pkey to the set would be a no-op (set semantics). Tag variant
796
+ # pkeys with a suffix in the counting set so the per-insurer count
797
+ # increments by 1, matching the marketplace card count.
798
+ if curated_pid in ki145_variant_curated_ids_cov:
799
+ by_insurer[slug]["products"].add(f"{pkey}__ki145variant")
800
+ else:
801
+ by_insurer[slug]["products"].add(pkey)
802
  # KI-142 — accumulate alias count for curated parents (curated entries
803
  # that themselves became the claimant of a new UIN, with later curated
804
  # siblings aliasing onto them).
 
1654
  # ultimate extracted parent.
1655
  direct_parent: dict[str, str] = {}
1656
  curated_canonical_ids: list[str] = []
1657
+ # KI-145 — curated entries whose UIN matched a candidate parent but
1658
+ # failed the material-diffs gate (>= 2 decision-critical fields disagree
1659
+ # with the parent's extracted JSON). These are genuine variants that
1660
+ # must emit as standalone cards in pass 2 even when their policy_id is
1661
+ # a prefix of a seen extracted policy_id (the old startswith-skip would
1662
+ # otherwise drop them silently).
1663
+ ki145_variant_curated_ids: set[str] = set()
1664
 
1665
  # Phase B — walk curated entries deterministically (sorted by policy_id).
1666
  for curated_policy_id, cdata in sorted(curated_facts.items()):
 
1695
  # 2+ disagree on non-null values, treat as a VARIANT and keep
1696
  # this curated entry as its own card. < 2 = pure rename → merge.
1697
  candidate = uin_to_parent[curated_uin]
1698
+ # Candidate may be an extracted stem OR a previously-claimed
1699
+ # curated entry. Look up extracted JSON first; fall back to the
1700
+ # candidate's curated facts so the diff has real data to compare.
1701
+ cand_data = extracted_data.get(candidate) or curated_facts.get(candidate, {})
1702
+ if _ki145_material_diffs(cdata, cand_data) < 2:
1703
  parent_id = candidate
1704
+ else:
1705
+ ki145_variant_curated_ids.add(curated_policy_id)
1706
  elif curated_uin:
1707
  # New UIN — this curated entry becomes the claimant so any
1708
+ # later curated sibling with the same UIN aliases onto it. Per
1709
+ # KI-145 spec ("if UIN doesn't match any extracted parent →
1710
+ # treat as standalone"), also flag this entry so pass-2 emits
1711
+ # it even when its policy_id is a prefix of a seen extracted id.
1712
  uin_to_parent[curated_uin] = curated_policy_id
1713
+ ki145_variant_curated_ids.add(curated_policy_id)
1714
 
1715
  if parent_id is None and not curated_uin:
1716
+ # KI-142 (preserved): source-PDF fallback only fires for curated
1717
+ # entries with NO UIN. When UIN is present but unmatched, the
1718
+ # KI-145 spec mandates standalone source-PDF coincidence MUST
1719
+ # NOT override the UIN-mismatch signal.
 
1720
  fb_parent = _source_pdf_to_policy_id(cdata.get("_primary_source_pdf"))
1721
  if fb_parent and fb_parent in extracted_stems and fb_parent != curated_policy_id:
 
 
 
1722
  ext_data = extracted_data.get(fb_parent, {})
1723
  if _ki145_material_diffs(cdata, ext_data) < 2:
1724
  parent_id = fb_parent
1725
+ else:
1726
+ ki145_variant_curated_ids.add(curated_policy_id)
1727
 
1728
  if parent_id:
1729
  direct_parent[curated_policy_id] = parent_id
 
1864
  continue
1865
  if curated_policy_id in seen_policy_ids:
1866
  continue
1867
+ # Also skip if any extracted ID matches with a suffix — UNLESS this
1868
+ # curated entry was classified as a KI-145 variant (same UIN/source-PDF
1869
+ # as a pass-1 card but materially different decision-critical fields).
1870
+ # Variants MUST surface as their own marketplace card; the legacy
1871
+ # startswith dedup would otherwise drop them silently.
1872
+ if curated_policy_id not in ki145_variant_curated_ids \
1873
+ and any(eid.startswith(curated_policy_id + "__") for eid in seen_policy_ids):
1874
  continue
1875
  # KI-141 — skip curated entries that have already been collapsed onto
1876
  # a pass-1 parent card via the aliases mechanism (e.g. Activ One →
backend/needs_finder.py CHANGED
@@ -76,6 +76,127 @@ def _always(p: Profile) -> bool:
76
  return True
77
 
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  GRAPH: list[Question] = [
80
  Question(
81
  id="name",
@@ -106,6 +227,7 @@ GRAPH: list[Question] = [
106
  prompt_hi="सालाना आय — ₹5L से कम, ₹5-10L, ₹10-25L, या ₹25L+? (हम सिर्फ sum insured size suggest करने के लिए पूछते हैं।)",
107
  field="income_band",
108
  is_core=True,
 
109
  ),
110
  Question(
111
  id="existing_cover",
@@ -157,6 +279,7 @@ GRAPH: list[Question] = [
157
  prompt_hi="Premium के लिए सालाना — ₹15k से कम, ₹15-30k, ₹30-60k, या ₹60k+? (अगर थोड़ा ज़्यादा budget बेहतर protection देगा, बताऊंगा।)",
158
  field="budget_band",
159
  is_core=True,
 
160
  ),
161
  ]
162
 
 
76
  return True
77
 
78
 
79
+ # ----------------------------------------------------------------------------
80
+ # KI-149 (2026-05-15) — free-text INR amount parser for budget + income.
81
+ # User said "I maximum 30000 I can pay" → bot re-asked budget because no
82
+ # parser was attached to the budget Question and the LLM brain failed to
83
+ # capture it. Bare digits ("30000"), "30 thousand", "30 grand", "₹30,000",
84
+ # "1 lakh", "1.5L" must all map cleanly to a rupee amount.
85
+ # ----------------------------------------------------------------------------
86
+
87
+ def _parse_inr_amount(text: str) -> Optional[int]:
88
+ """Extract an INR amount in rupees from free text.
89
+
90
+ Handles:
91
+ - "30000", "30,000", "₹30,000", "Rs 30000", "rs. 30000"
92
+ - "30k", "30 k", "30K"
93
+ - "30 thousand", "30 grand"
94
+ - "1 lakh", "1.5 lakh", "1L", "1.5L", "1 lac"
95
+ - "1 crore", "1cr"
96
+ - strips fluff: "maximum 30000", "I can pay 30000", "around 25000"
97
+ - tolerates per-year qualifiers: "/year", "per year", "p.a."
98
+
99
+ Returns the integer rupee amount, or None if no number is recognisable.
100
+ """
101
+ if not text:
102
+ return None
103
+ s = str(text).lower().strip()
104
+ # Strip currency symbols + thousands separators so "₹30,000" parses.
105
+ s = s.replace("₹", " ").replace("rs.", " ").replace("rs", " ")
106
+ s = s.replace(",", "")
107
+ # Crore (highest unit first so longer alternation wins).
108
+ m = re.search(r"(\d+(?:\.\d+)?)\s*(?:cr|crore|crores)\b", s)
109
+ if m:
110
+ try:
111
+ return int(float(m.group(1)) * 10_000_000)
112
+ except ValueError:
113
+ return None
114
+ # Lakh / lac.
115
+ m = re.search(r"(\d+(?:\.\d+)?)\s*(?:l(?:akh|ac)?s?)\b", s)
116
+ if m:
117
+ try:
118
+ return int(float(m.group(1)) * 100_000)
119
+ except ValueError:
120
+ return None
121
+ # Thousand / grand / k.
122
+ m = re.search(r"(\d+(?:\.\d+)?)\s*(?:thousand|grand|k)\b", s)
123
+ if m:
124
+ try:
125
+ return int(float(m.group(1)) * 1_000)
126
+ except ValueError:
127
+ return None
128
+ # Bare digit run — pick the largest number-like token (handles
129
+ # "maximum 30000", "around 25000", "I can pay 30000").
130
+ nums = re.findall(r"\d+(?:\.\d+)?", s)
131
+ if nums:
132
+ try:
133
+ return int(float(max(nums, key=lambda x: float(x))))
134
+ except ValueError:
135
+ return None
136
+ return None
137
+
138
+
139
+ def _parse_budget_band(text: str) -> Optional[str]:
140
+ """Map free-text budget text → one of under_15k / 15k_30k / 30k_60k / 60k+.
141
+
142
+ KI-149 (2026-05-15). Falls back to range hints ("15-30k", "30 to 60k")
143
+ before delegating to `_parse_inr_amount` for a single number.
144
+ """
145
+ if not text:
146
+ return None
147
+ s = str(text).lower()
148
+ # Explicit bucket hints first — order matters (more specific wins).
149
+ if re.search(r"60\s*k\s*\+|>\s*60|more\s+than\s+60|above\s+60|over\s+60", s):
150
+ return "60k+"
151
+ if re.search(r"30\s*[-to]+\s*60\s*k?|30k\s*[-_]\s*60k|30\s*to\s*60", s):
152
+ return "30k_60k"
153
+ if re.search(r"15\s*[-to]+\s*30\s*k?|15k\s*[-_]\s*30k|15\s*to\s*30", s):
154
+ return "15k_30k"
155
+ if re.search(r"under\s*15|less\s+than\s+15|below\s+15|<\s*15", s):
156
+ return "under_15k"
157
+ # Single amount → bucket.
158
+ amt = _parse_inr_amount(s)
159
+ if amt is None:
160
+ return None
161
+ if amt < 15_000:
162
+ return "under_15k"
163
+ if amt < 30_000:
164
+ return "15k_30k"
165
+ if amt < 60_000:
166
+ return "30k_60k"
167
+ return "60k+"
168
+
169
+
170
+ def _parse_income_band(text: str) -> Optional[str]:
171
+ """Map free-text income text → one of under_5L / 5L-10L / 10L-25L / 25L+.
172
+
173
+ KI-149 (2026-05-15). Same approach as `_parse_budget_band`: explicit
174
+ bucket hints first, then a single rupee amount → bucket.
175
+ """
176
+ if not text:
177
+ return None
178
+ s = str(text).lower()
179
+ if re.search(r"25\s*l\s*\+|>\s*25|more\s+than\s+25|above\s+25|over\s+25", s):
180
+ return "25L+"
181
+ if re.search(r"10\s*[-to]+\s*25\s*l?|10l\s*[-_]\s*25l|10\s*to\s*25", s):
182
+ return "10L-25L"
183
+ if re.search(r"5\s*[-to]+\s*10\s*l?|5l\s*[-_]\s*10l|5\s*to\s*10", s):
184
+ return "5L-10L"
185
+ if re.search(r"under\s*5|less\s+than\s+5|below\s+5|<\s*5", s):
186
+ return "under_5L"
187
+ amt = _parse_inr_amount(s)
188
+ if amt is None:
189
+ return None
190
+ # Income is parsed in rupees; 5 lakh = 500_000.
191
+ if amt < 500_000:
192
+ return "under_5L"
193
+ if amt < 1_000_000:
194
+ return "5L-10L"
195
+ if amt < 2_500_000:
196
+ return "10L-25L"
197
+ return "25L+"
198
+
199
+
200
  GRAPH: list[Question] = [
201
  Question(
202
  id="name",
 
227
  prompt_hi="सालाना आय — ₹5L से कम, ₹5-10L, ₹10-25L, या ₹25L+? (हम सिर्फ sum insured size suggest करने के लिए पूछते हैं।)",
228
  field="income_band",
229
  is_core=True,
230
+ parser=_parse_income_band,
231
  ),
232
  Question(
233
  id="existing_cover",
 
279
  prompt_hi="Premium के लिए सालाना — ₹15k से कम, ₹15-30k, ₹30-60k, या ₹60k+? (अगर थोड़ा ज़्यादा budget बेहतर protection देगा, बताऊंगा।)",
280
  field="budget_band",
281
  is_core=True,
282
+ parser=_parse_budget_band,
283
  ),
284
  ]
285
 
backend/voice_format.py CHANGED
@@ -77,6 +77,11 @@ ACRONYMS = {
77
  # letter-by-letter. The user said "₹25L+" was being spoken as "two five L
78
  # plus". Order in `_normalize_money` matters: handle RANGES first, then
79
  # PLUS-SUFFIXES, then bare unit-suffixes, then standalone "+".
 
 
 
 
 
80
  _MONEY_RANGE_L = re.compile(
81
  r"₹?\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*L\b",
82
  re.IGNORECASE,
@@ -91,9 +96,113 @@ _MONEY_L = re.compile(r"₹?\s*(\d+(?:\.\d+)?)\s*L\b", re.IGNORECASE)
91
  _MONEY_CR = re.compile(r"₹?\s*(\d+(?:\.\d+)?)\s*Cr\b", re.IGNORECASE)
92
  _MONEY_RS_PREFIX = re.compile(r"\bRs\.?\s*", re.IGNORECASE)
93
  _MONEY_RUPEE_SYMBOL = re.compile(r"₹\s*(\d)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  # Bare year ranges like "29-32" or "24/7" — leave alone; TTS handles dashes.
95
 
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  def _normalize_money(text: str) -> str:
98
  """Turn currency / range shorthand into spoken-language equivalents.
99
 
@@ -104,12 +213,73 @@ def _normalize_money(text: str) -> str:
104
  "₹2Cr" → "2 crores"
105
  "Rs. 5000" → "rupees 5000"
106
  """
107
- text = _MONEY_RANGE_L.sub(lambda m: f"{m.group(1)} to {m.group(2)} lakhs", text)
108
- text = _MONEY_RANGE_CR.sub(lambda m: f"{m.group(1)} to {m.group(2)} crores", text)
109
- text = _MONEY_PLUS_L.sub(lambda m: f"{m.group(1)} lakhs or more", text)
110
- text = _MONEY_PLUS_CR.sub(lambda m: f"{m.group(1)} crores or more", text)
111
- text = _MONEY_L.sub(lambda m: f"{m.group(1)} lakhs", text)
112
- text = _MONEY_CR.sub(lambda m: f"{m.group(1)} crores", text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  text = _MONEY_RS_PREFIX.sub("rupees ", text)
114
  text = _MONEY_RUPEE_SYMBOL.sub(r"rupees \1", text)
115
  return text
 
77
  # letter-by-letter. The user said "₹25L+" was being spoken as "two five L
78
  # plus". Order in `_normalize_money` matters: handle RANGES first, then
79
  # PLUS-SUFFIXES, then bare unit-suffixes, then standalone "+".
80
+ #
81
+ # KI-148 (2026-05-15) — extended to cover `k` (thousand), word-forms
82
+ # (`1 lakh`, `1 crore`), `+` suffix → "above", and bare numerics like
83
+ # `30000` → "30 thousand", `1,00,000` → "1 lakh". `k` is currency-gated to
84
+ # avoid breaking "10K marathon".
85
  _MONEY_RANGE_L = re.compile(
86
  r"₹?\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*L\b",
87
  re.IGNORECASE,
 
96
  _MONEY_CR = re.compile(r"₹?\s*(\d+(?:\.\d+)?)\s*Cr\b", re.IGNORECASE)
97
  _MONEY_RS_PREFIX = re.compile(r"\bRs\.?\s*", re.IGNORECASE)
98
  _MONEY_RUPEE_SYMBOL = re.compile(r"₹\s*(\d)")
99
+
100
+ # --- KI-148: word-form lakh/crore (e.g. "1 lakh", "5 crores") ---
101
+ # Normalize to a canonical "<n> lakh rupees" / "<n> crore rupees" so the
102
+ # downstream `L`/`Cr` regexes don't re-process them. Optional ₹ prefix.
103
+ _MONEY_WORD_LAKH = re.compile(
104
+ r"₹?\s*(\d+(?:\.\d+)?)\s*lakhs?\b",
105
+ re.IGNORECASE,
106
+ )
107
+ _MONEY_WORD_CRORE = re.compile(
108
+ r"₹?\s*(\d+(?:\.\d+)?)\s*crores?\b",
109
+ re.IGNORECASE,
110
+ )
111
+
112
+ # --- KI-148: `k` (thousand) shorthand, currency-gated ---
113
+ # Match `₹15k`, `₹15-30k`, `₹15k-30k`, `60k+`. Three contexts qualify as
114
+ # currency: (a) ₹ prefix, (b) adjacent to another currency token (handled
115
+ # by ordering — list-context like "₹15k, 30k, 60k+" gets ₹ on the first
116
+ # token then the rest cascade), (c) followed by rupee/premium/budget/
117
+ # income/sum-insured. We pre-scan and tag list-context k's by injecting
118
+ # ₹ before bare k-numbers that sit in a comma/dash list with a ₹-prefixed
119
+ # sibling. Simplest approach: run ₹-prefixed patterns first, then a
120
+ # context-aware second pass.
121
+ _MONEY_RANGE_K_BOTH = re.compile(
122
+ r"₹\s*(\d+(?:\.\d+)?)\s*k\s*-\s*(\d+(?:\.\d+)?)\s*k\b",
123
+ re.IGNORECASE,
124
+ )
125
+ _MONEY_RANGE_K = re.compile(
126
+ r"₹\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*k\b",
127
+ re.IGNORECASE,
128
+ )
129
+ _MONEY_PLUS_K = re.compile(r"₹?\s*(\d+(?:\.\d+)?)\s*k\s*\+", re.IGNORECASE)
130
+ _MONEY_K_PREFIXED = re.compile(r"₹\s*(\d+(?:\.\d+)?)\s*k\b", re.IGNORECASE)
131
+ # Bare `Nk` (no ₹) — only expand if followed by a currency-context word.
132
+ _MONEY_K_CONTEXT = re.compile(
133
+ r"\b(\d+(?:\.\d+)?)\s*k\b(?=[\s,]*(?:rupee|premium|budget|income|sum insured))",
134
+ re.IGNORECASE,
135
+ )
136
+ # Comma/`or`-list cascade: once a list contains "<digits> thousand rupees",
137
+ # subsequent bare "Nk" tokens in the same list segment inherit currency.
138
+ # We approximate by running this AFTER the first pass: any bare `Nk` that
139
+ # sits within 60 chars after a "thousand rupees" or "lakh rupees" token is
140
+ # treated as currency.
141
+ _MONEY_K_LIST_CASCADE = re.compile(
142
+ r"((?:thousand|lakh|crore) rupees[^.?!]{0,60}?)\b(\d+(?:\.\d+)?)\s*k\b",
143
+ re.IGNORECASE,
144
+ )
145
+
146
+ # --- KI-148: bare-number expansion (Indian + standard formatting) ---
147
+ # Indian comma format: `1,00,000` → "1 lakh", `15,00,000` → "15 lakh".
148
+ # Standard format: `2,500,000` → "25 lakh". Plain 4-5 digit: `30000` →
149
+ # "30 thousand". Context-gated to currency to avoid butchering "30000 km".
150
+ _MONEY_INDIAN_LAKH_COMMA = re.compile(
151
+ r"₹?\s*(\d{1,2}(?:,\d{2})+,\d{3})\b"
152
+ )
153
+ _MONEY_STD_COMMA = re.compile(
154
+ r"₹?\s*(\d{1,3}(?:,\d{3})+)\b"
155
+ )
156
+ _MONEY_BARE_THOUSANDS = re.compile(
157
+ r"₹\s*(\d{4,7})\b"
158
+ )
159
+
160
  # Bare year ranges like "29-32" or "24/7" — leave alone; TTS handles dashes.
161
 
162
 
163
+ def _expand_indian_comma_number(s: str) -> str:
164
+ """Convert `1,00,000` → "1 lakh", `15,00,000` → "15 lakh",
165
+ `1,00,00,000` → "1 crore". Indian system: rightmost 3 digits, then
166
+ pairs."""
167
+ digits = s.replace(",", "")
168
+ try:
169
+ n = int(digits)
170
+ except ValueError:
171
+ return s
172
+ return _humanize_int(n)
173
+
174
+
175
+ def _expand_standard_comma_number(s: str) -> str:
176
+ """Convert `2,500,000` → "25 lakh", `30,000` → "30 thousand"."""
177
+ digits = s.replace(",", "")
178
+ try:
179
+ n = int(digits)
180
+ except ValueError:
181
+ return s
182
+ return _humanize_int(n)
183
+
184
+
185
+ def _humanize_int(n: int) -> str:
186
+ """Render an integer rupee amount as a spoken Indian-English phrase."""
187
+ if n >= 10_000_000 and n % 100_000 == 0:
188
+ cr = n // 10_000_000
189
+ rem_lakh = (n % 10_000_000) // 100_000
190
+ if rem_lakh == 0:
191
+ return f"{cr} crore rupees"
192
+ return f"{cr} crore {rem_lakh} lakh rupees"
193
+ if n >= 100_000 and n % 1_000 == 0:
194
+ lakh = n // 100_000
195
+ return f"{lakh} lakh rupees"
196
+ if n >= 1_000 and n % 1_000 == 0:
197
+ return f"{n // 1_000} thousand rupees"
198
+ if n >= 100_000:
199
+ # Not a clean lakh — fall back to "X point Y lakh"
200
+ return f"{n / 100_000:.1f} lakh rupees".replace(".0 ", " ")
201
+ if n >= 1_000:
202
+ return f"{n / 1_000:.1f} thousand rupees".replace(".0 ", " ")
203
+ return f"{n} rupees"
204
+
205
+
206
  def _normalize_money(text: str) -> str:
207
  """Turn currency / range shorthand into spoken-language equivalents.
208
 
 
213
  "₹2Cr" → "2 crores"
214
  "Rs. 5000" → "rupees 5000"
215
  """
216
+ # KI-148: word-form lakh/crore FIRST so "₹15 lakh" / "1 crore" become
217
+ # canonical "<n> lakh rupees" / "<n> crore rupees" and downstream regexes
218
+ # don't double-process them.
219
+ text = _MONEY_WORD_CRORE.sub(lambda m: f"{m.group(1)} crore rupees", text)
220
+ text = _MONEY_WORD_LAKH.sub(lambda m: f"{m.group(1)} lakh rupees", text)
221
+
222
+ # KI-148: `k` handling — ranges first, plus second, then prefixed bare.
223
+ text = _MONEY_RANGE_K_BOTH.sub(
224
+ lambda m: f"{m.group(1)} to {m.group(2)} thousand rupees", text
225
+ )
226
+ text = _MONEY_RANGE_K.sub(
227
+ lambda m: f"{m.group(1)} to {m.group(2)} thousand rupees", text
228
+ )
229
+ # PLUS_K must be currency-gated: ₹ prefix OR cascade context. We do a
230
+ # split-pass: prefixed ₹...k+ first, then unprefixed Nk+ that appears
231
+ # after a "thousand rupees"/"lakh rupees" anchor (list-cascade).
232
+ text = re.sub(
233
+ r"₹\s*(\d+(?:\.\d+)?)\s*k\s*\+",
234
+ lambda m: f"above {m.group(1)} thousand rupees",
235
+ text,
236
+ flags=re.IGNORECASE,
237
+ )
238
+ text = _MONEY_K_PREFIXED.sub(
239
+ lambda m: f"{m.group(1)} thousand rupees", text
240
+ )
241
+ # List-cascade pass: bare "Nk" tokens that sit in a list after an
242
+ # already-expanded currency token. Run twice to catch chained items.
243
+ for _ in range(3):
244
+ new = _MONEY_K_LIST_CASCADE.sub(
245
+ lambda m: f"{m.group(1)}{m.group(2)} thousand rupees", text
246
+ )
247
+ if new == text:
248
+ break
249
+ text = new
250
+ # Also handle "+suffix" bare tokens that gained currency via cascade.
251
+ text = re.sub(
252
+ r"((?:thousand|lakh|crore) rupees[^.?!]{0,60}?)\b(\d+(?:\.\d+)?)\s*k\s*\+",
253
+ lambda m: f"{m.group(1)}above {m.group(2)} thousand rupees",
254
+ text,
255
+ flags=re.IGNORECASE,
256
+ )
257
+ # Explicit currency-context bare `Nk` (followed by rupee/premium/etc).
258
+ text = _MONEY_K_CONTEXT.sub(
259
+ lambda m: f"{m.group(1)} thousand rupees", text
260
+ )
261
+
262
+ # Existing L / Cr handling (lakh / crore short-suffix).
263
+ text = _MONEY_RANGE_L.sub(lambda m: f"{m.group(1)} to {m.group(2)} lakh rupees", text)
264
+ text = _MONEY_RANGE_CR.sub(lambda m: f"{m.group(1)} to {m.group(2)} crore rupees", text)
265
+ text = _MONEY_PLUS_L.sub(lambda m: f"above {m.group(1)} lakh rupees", text)
266
+ text = _MONEY_PLUS_CR.sub(lambda m: f"above {m.group(1)} crore rupees", text)
267
+ text = _MONEY_L.sub(lambda m: f"{m.group(1)} lakh rupees", text)
268
+ text = _MONEY_CR.sub(lambda m: f"{m.group(1)} crore rupees", text)
269
+
270
+ # KI-148: bare-number expansion (only ₹-prefixed or comma-formatted).
271
+ # Indian format `1,00,000` first (longest match), then standard
272
+ # `2,500,000`, then bare ₹30000.
273
+ text = _MONEY_INDIAN_LAKH_COMMA.sub(
274
+ lambda m: _expand_indian_comma_number(m.group(1)), text
275
+ )
276
+ text = _MONEY_STD_COMMA.sub(
277
+ lambda m: _expand_standard_comma_number(m.group(1)), text
278
+ )
279
+ text = _MONEY_BARE_THOUSANDS.sub(
280
+ lambda m: _humanize_int(int(m.group(1))), text
281
+ )
282
+
283
  text = _MONEY_RS_PREFIX.sub("rupees ", text)
284
  text = _MONEY_RUPEE_SYMBOL.sub(r"rupees \1", text)
285
  return text