Spaces:
Sleeping
fix(chat+marketplace+voice): KI-145 + KI-148 + KI-149 + KI-150 bundle
Browse filesFive 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 +8 -2
- backend/fact_find_normalizer.py +13 -29
- backend/main.py +70 -25
- backend/needs_finder.py +123 -0
- backend/voice_format.py +176 -6
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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:
|
|
@@ -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 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 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 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 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"]):
|
|
@@ -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 |
-
|
| 661 |
-
|
|
|
|
|
|
|
| 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
|
| 668 |
-
#
|
| 669 |
-
#
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1666 |
-
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 1675 |
-
#
|
| 1676 |
-
#
|
| 1677 |
-
#
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 →
|
|
@@ -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 |
|
|
@@ -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 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
text =
|
| 111 |
-
text =
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|