rohitsar567 commited on
Commit
63290a7
·
1 Parent(s): 7b92d5d

fix(marketplace+voice+routing): KI-136 + KI-141 + KI-142 + KI-143 + KI-144 — full bundle

Browse files

Five distinct fixes shipped together for a single rebuild cycle:

KI-141 — hybrid alias dedup. /api/coverage + /api/policies/all now collapse
curated marketing-rename entries (Activ One, Optima Secure, etc.) under
their PDF-backed parent card, with aliases surfaced as 'Also marketed as:'
on the marketplace cards. Frontend page.tsx + api.ts add aliases[] field
on MarketplacePolicy. Card count went 158 -> ~137.

KI-142 — UIN-primary merge rule. /api/coverage and /api/policies/all now
build uin_to_parent maps from extracted JSONs and merge curated entries
based on UIN equality. Falls back to source_pdf gate when UIN matches
fail. 1 UIN = 1 marketplace card invariant.

KI-136 — named-SKU comparison routing. orchestrator.py
should_route_to_fact_find now accepts query and exempts intent=comparison
when the question mentions >=2 named insurers/SKUs. Empty profile no
longer traps SKU-specific compare questions in fact_find_brain.
Verified: 'Compare HDFC ERGO Optima Secure and Niva Bupa Aspire on
maternity' now routes to nim-chain comparison brain with citations.

KI-143 — bajaj group-health-guard slug correction. PDF labeled 'Gold'
in slug was actually 'Health Guard (Group) - Silver Plan' per the PDF
cover. Renamed PDF, extracted JSON, curated facts, Chroma metadata
(43 chunks), .gitattributes LFS line. The separate
health-guard-gold-individual product is untouched.

KI-144 — reliance-general -> indusind-general migration. The 3
INDUSIND-branded products (HealthGain, Hospi Care, Group Mediclaim) are
issued by IndusInd General Insurance (formerly Reliance General per
IRDAI Reg No. 103). Migrated insurer_slug for these 3, kept
personal-accident under reliance-general. Added insurer_meta entry for
indusind-general. Added legacy_issuer field for retrieval continuity.
164 Chroma chunks migrated.

Dockerfile DATASET_CACHE_BUST bumped to 2026-05-15-ki144-v1 so the next
Space rebuild pulls the freshly-updated Chroma binary.

.gitattributes CHANGED
@@ -42,7 +42,7 @@ rag/corpus/bajaj-allianz/comprehensive-care-plan__wordings.pdf filter=lfs diff=l
42
  rag/corpus/bajaj-allianz/criti-care__wordings.pdf filter=lfs diff=lfs merge=lfs -text
43
  rag/corpus/bajaj-allianz/extra-care-plus__wordings.pdf filter=lfs diff=lfs merge=lfs -text
44
  rag/corpus/bajaj-allianz/global-health-care__wordings.pdf filter=lfs diff=lfs merge=lfs -text
45
- rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf filter=lfs diff=lfs merge=lfs -text
46
  rag/corpus/bajaj-allianz/group-personal-accident__wordings.pdf filter=lfs diff=lfs merge=lfs -text
47
  rag/corpus/bajaj-allianz/health-guard-gold-individual__wordings.pdf filter=lfs diff=lfs merge=lfs -text
48
  rag/corpus/bajaj-allianz/health-guard__wordings.pdf filter=lfs diff=lfs merge=lfs -text
 
42
  rag/corpus/bajaj-allianz/criti-care__wordings.pdf filter=lfs diff=lfs merge=lfs -text
43
  rag/corpus/bajaj-allianz/extra-care-plus__wordings.pdf filter=lfs diff=lfs merge=lfs -text
44
  rag/corpus/bajaj-allianz/global-health-care__wordings.pdf filter=lfs diff=lfs merge=lfs -text
45
+ rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf filter=lfs diff=lfs merge=lfs -text
46
  rag/corpus/bajaj-allianz/group-personal-accident__wordings.pdf filter=lfs diff=lfs merge=lfs -text
47
  rag/corpus/bajaj-allianz/health-guard-gold-individual__wordings.pdf filter=lfs diff=lfs merge=lfs -text
48
  rag/corpus/bajaj-allianz/health-guard__wordings.pdf filter=lfs diff=lfs merge=lfs -text
40-data/policy_facts/{bajaj-allianz__group-health-guard-gold__wordings.json → bajaj-allianz__group-health-guard-silver__wordings.json} RENAMED
@@ -1,121 +1,121 @@
1
  {
2
- "policy_id": "bajaj-allianz__group-health-guard-gold__wordings",
3
- "policy_name": "Group Health Guard Gold",
4
  "insurer_slug": "bajaj-allianz",
5
  "uin_code": {
6
  "value": "BAJHLGP21181V022021",
7
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
8
  "source_quote": "UIN: BAJHLGP21181V022021"
9
  },
10
  "min_entry_age": {
11
  "value": null,
12
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
13
  "source_quote": ""
14
  },
15
  "max_entry_age": {
16
  "value": null,
17
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
18
  "source_quote": ""
19
  },
20
  "max_renewal_age": {
21
  "value": null,
22
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
23
  "source_quote": ""
24
  },
25
  "pre_existing_disease_waiting_months": {
26
  "value": 36,
27
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
28
  "source_quote": "pre-existing Disease (PED) and its direct complications shall be excluded until the expiry of 36 months"
29
  },
30
  "initial_waiting_period_days": {
31
  "value": null,
32
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
33
  "source_quote": ""
34
  },
35
  "maternity_coverage": {
36
  "value": null,
37
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
38
  "source_quote": ""
39
  },
40
  "maternity_waiting_months": {
41
  "value": null,
42
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
43
  "source_quote": ""
44
  },
45
  "ayush_coverage": {
46
  "value": null,
47
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
48
  "source_quote": ""
49
  },
50
  "cashless_treatment_supported": {
51
  "value": true,
52
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
53
  "source_quote": "Cashless facility"
54
  },
55
  "copayment_pct": {
56
  "value": null,
57
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
58
  "source_quote": ""
59
  },
60
  "no_claim_bonus_pct": {
61
  "value": 100,
62
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
63
  "source_quote": "cumulative bonus (if any) is exhausted due to claims lodged during the Policy\nyear, then it is agreed that 100%"
64
  },
65
  "restoration_benefit": {
66
  "value": null,
67
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
68
  "source_quote": ""
69
  },
70
  "room_rent_capping": {
71
  "value": null,
72
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
73
  "source_quote": ""
74
  },
75
  "pre_hospitalization_days": {
76
  "value": 60,
77
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
78
  "source_quote": "Pre-Hospitalisation\nThe Medical Expenses incurred during the 60 days"
79
  },
80
  "post_hospitalization_days": {
81
  "value": 90,
82
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
83
  "source_quote": "Post-Hospitalisation\nThe Medical Expenses incurred during the 90 days"
84
  },
85
  "day_care_treatments_count": {
86
  "value": null,
87
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
88
  "source_quote": ""
89
  },
90
  "network_hospital_count": {
91
  "value": null,
92
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
93
  "source_quote": ""
94
  },
95
  "sum_insured_options": {
96
  "value": null,
97
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
98
  "source_quote": ""
99
  },
100
  "claim_settlement_ratio": {
101
  "value": null,
102
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
103
  "source_quote": ""
104
  },
105
  "tat_cashless_authorization_hours": {
106
  "value": null,
107
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
108
  "source_quote": ""
109
  },
110
  "policy_type": {
111
  "value": "group",
112
- "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
113
  "source_quote": "classified as group from PDF heuristics"
114
  },
115
  "_meta": {
116
  "curated_at": "2026-05-14",
117
  "primary_source_pdf": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
118
  "completeness_pct": 32,
119
- "notes": "Curated by tools/curate_remaining.py \u2014 pattern-based extraction from local PDF"
120
  }
121
  }
 
1
  {
2
+ "policy_id": "bajaj-allianz__group-health-guard-silver__wordings",
3
+ "policy_name": "Health Guard (Group) - Silver Plan",
4
  "insurer_slug": "bajaj-allianz",
5
  "uin_code": {
6
  "value": "BAJHLGP21181V022021",
7
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
8
  "source_quote": "UIN: BAJHLGP21181V022021"
9
  },
10
  "min_entry_age": {
11
  "value": null,
12
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
13
  "source_quote": ""
14
  },
15
  "max_entry_age": {
16
  "value": null,
17
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
18
  "source_quote": ""
19
  },
20
  "max_renewal_age": {
21
  "value": null,
22
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
23
  "source_quote": ""
24
  },
25
  "pre_existing_disease_waiting_months": {
26
  "value": 36,
27
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
28
  "source_quote": "pre-existing Disease (PED) and its direct complications shall be excluded until the expiry of 36 months"
29
  },
30
  "initial_waiting_period_days": {
31
  "value": null,
32
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
33
  "source_quote": ""
34
  },
35
  "maternity_coverage": {
36
  "value": null,
37
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
38
  "source_quote": ""
39
  },
40
  "maternity_waiting_months": {
41
  "value": null,
42
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
43
  "source_quote": ""
44
  },
45
  "ayush_coverage": {
46
  "value": null,
47
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
48
  "source_quote": ""
49
  },
50
  "cashless_treatment_supported": {
51
  "value": true,
52
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
53
  "source_quote": "Cashless facility"
54
  },
55
  "copayment_pct": {
56
  "value": null,
57
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
58
  "source_quote": ""
59
  },
60
  "no_claim_bonus_pct": {
61
  "value": 100,
62
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
63
  "source_quote": "cumulative bonus (if any) is exhausted due to claims lodged during the Policy\nyear, then it is agreed that 100%"
64
  },
65
  "restoration_benefit": {
66
  "value": null,
67
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
68
  "source_quote": ""
69
  },
70
  "room_rent_capping": {
71
  "value": null,
72
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
73
  "source_quote": ""
74
  },
75
  "pre_hospitalization_days": {
76
  "value": 60,
77
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
78
  "source_quote": "Pre-Hospitalisation\nThe Medical Expenses incurred during the 60 days"
79
  },
80
  "post_hospitalization_days": {
81
  "value": 90,
82
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
83
  "source_quote": "Post-Hospitalisation\nThe Medical Expenses incurred during the 90 days"
84
  },
85
  "day_care_treatments_count": {
86
  "value": null,
87
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
88
  "source_quote": ""
89
  },
90
  "network_hospital_count": {
91
  "value": null,
92
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
93
  "source_quote": ""
94
  },
95
  "sum_insured_options": {
96
  "value": null,
97
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
98
  "source_quote": ""
99
  },
100
  "claim_settlement_ratio": {
101
  "value": null,
102
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
103
  "source_quote": ""
104
  },
105
  "tat_cashless_authorization_hours": {
106
  "value": null,
107
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
108
  "source_quote": ""
109
  },
110
  "policy_type": {
111
  "value": "group",
112
+ "source_pdf_path": "rag/corpus/bajaj-allianz/group-health-guard-silver__wordings.pdf",
113
  "source_quote": "classified as group from PDF heuristics"
114
  },
115
  "_meta": {
116
  "curated_at": "2026-05-14",
117
  "primary_source_pdf": "rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf",
118
  "completeness_pct": 32,
119
+ "notes": "Curated by tools/curate_remaining.py pattern-based extraction from local PDF"
120
  }
121
  }
40-data/policy_facts/{reliance-general__group-mediclaim__wordings.json → indusind-general__group-mediclaim__wordings.json} RENAMED
@@ -1,7 +1,7 @@
1
  {
2
- "policy_id": "reliance-general__group-mediclaim__wordings",
3
  "policy_name": "IndusInd Group Mediclaim",
4
- "insurer_slug": "reliance-general",
5
  "source_pdf_path": "rag/corpus/reliance-general/group-mediclaim__wordings.pdf",
6
  "uin_code": {
7
  "value": "RELHLGP21523V022021",
@@ -134,5 +134,6 @@
134
  "unit": null,
135
  "source_pdf_path": "rag/corpus/reliance-general/group-mediclaim__wordings.pdf",
136
  "source_quote": "not extracted from this PDF (field absent or LLM unable to infer)"
137
- }
138
- }
 
 
1
  {
2
+ "policy_id": "indusind-general__group-mediclaim__wordings",
3
  "policy_name": "IndusInd Group Mediclaim",
4
+ "insurer_slug": "indusind-general",
5
  "source_pdf_path": "rag/corpus/reliance-general/group-mediclaim__wordings.pdf",
6
  "uin_code": {
7
  "value": "RELHLGP21523V022021",
 
134
  "unit": null,
135
  "source_pdf_path": "rag/corpus/reliance-general/group-mediclaim__wordings.pdf",
136
  "source_quote": "not extracted from this PDF (field absent or LLM unable to infer)"
137
+ },
138
+ "legacy_issuer": "Reliance General Insurance"
139
+ }
40-data/policy_facts/{reliance-general__health-gain__wordings.json → indusind-general__health-gain__wordings.json} RENAMED
@@ -1,7 +1,7 @@
1
  {
2
- "policy_id": "reliance-general__health-gain__wordings",
3
  "policy_name": "IndusInd HealthGain Policy",
4
- "insurer_slug": "reliance-general",
5
  "source_pdf_path": "rag/corpus/reliance-general/health-gain__wordings.pdf",
6
  "uin_code": {
7
  "value": "INIHLIP26040V042526",
@@ -134,5 +134,6 @@
134
  "unit": null,
135
  "source_pdf_path": "rag/corpus/reliance-general/health-gain__wordings.pdf",
136
  "source_quote": "not extracted from this PDF (field absent or LLM unable to infer)"
137
- }
138
- }
 
 
1
  {
2
+ "policy_id": "indusind-general__health-gain__wordings",
3
  "policy_name": "IndusInd HealthGain Policy",
4
+ "insurer_slug": "indusind-general",
5
  "source_pdf_path": "rag/corpus/reliance-general/health-gain__wordings.pdf",
6
  "uin_code": {
7
  "value": "INIHLIP26040V042526",
 
134
  "unit": null,
135
  "source_pdf_path": "rag/corpus/reliance-general/health-gain__wordings.pdf",
136
  "source_quote": "not extracted from this PDF (field absent or LLM unable to infer)"
137
+ },
138
+ "legacy_issuer": "Reliance General Insurance"
139
+ }
40-data/policy_facts/{reliance-general__hospi-care__wordings.json → indusind-general__hospi-care__wordings.json} RENAMED
@@ -1,7 +1,7 @@
1
  {
2
- "policy_id": "reliance-general__hospi-care__wordings",
3
  "policy_name": "IndusInd Hospi Care Insurance",
4
- "insurer_slug": "reliance-general",
5
  "source_pdf_path": "rag/corpus/reliance-general/hospi-care__wordings.pdf",
6
  "uin_code": {
7
  "value": "RELHLIP20027V012021",
@@ -134,5 +134,6 @@
134
  "unit": null,
135
  "source_pdf_path": "rag/corpus/reliance-general/hospi-care__wordings.pdf",
136
  "source_quote": "not extracted from this PDF (field absent or LLM unable to infer)"
137
- }
138
- }
 
 
1
  {
2
+ "policy_id": "indusind-general__hospi-care__wordings",
3
  "policy_name": "IndusInd Hospi Care Insurance",
4
+ "insurer_slug": "indusind-general",
5
  "source_pdf_path": "rag/corpus/reliance-general/hospi-care__wordings.pdf",
6
  "uin_code": {
7
  "value": "RELHLIP20027V012021",
 
134
  "unit": null,
135
  "source_pdf_path": "rag/corpus/reliance-general/hospi-care__wordings.pdf",
136
  "source_quote": "not extracted from this PDF (field absent or LLM unable to infer)"
137
+ },
138
+ "legacy_issuer": "Reliance General Insurance"
139
+ }
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-ki138-v2
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-ki144-v1
74
  RUN echo "Dataset cache bust: ${DATASET_CACHE_BUST}" && python -c "\
75
  from huggingface_hub import snapshot_download; \
76
  snapshot_download(\
backend/main.py CHANGED
@@ -140,6 +140,12 @@ class InsurerCoverage(BaseModel):
140
  home_url: str # insurer's main website (manually curated, verified)
141
  policy_count: int
142
  sample_policies: list[PolicyEntry]
 
 
 
 
 
 
143
 
144
 
145
  class CoverageResponse(BaseModel):
@@ -557,6 +563,7 @@ async def coverage():
557
  "national-insurance": ("National Insurance Company", "https://nationalinsurance.nic.co.in/"),
558
  "new-india": ("New India Assurance", "https://www.newindia.co.in/"),
559
  "niva-bupa": ("Niva Bupa Health Insurance", "https://www.nivabupa.com/"),
 
560
  "oriental-insurance": ("Oriental Insurance Company", "https://orientalinsurance.org.in/"),
561
  "reliance-general": ("Reliance General Insurance", "https://www.reliancegeneral.co.in/"),
562
  "royal-sundaram": ("Royal Sundaram General Insurance", "https://www.royalsundaram.in/"),
@@ -592,9 +599,103 @@ async def coverage():
592
  seen_product_keys: set[str] = set()
593
  seen_policy_ids: set[str] = set()
594
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
595
  # by_insurer entries:
596
  # products: set of product_keys (matches /api/policies/all card count)
597
  # names: ordered dict of policy_NAME -> first product_key (for sample display)
 
 
598
  # KI-135 (2026-05-15) — track product_keys (not names) for counting so the
599
  # ~1 within-insurer policy_name collision (e.g. new-india Floater listed
600
  # as both extracted + curated_facts) doesn't collapse the count below the
@@ -618,8 +719,10 @@ async def coverage():
618
  name = data.get("policy_name", "") or pid
619
  url = data.get("source_pdf_url", "")
620
  if slug not in by_insurer:
621
- by_insurer[slug] = {"products": set(), "names": [], "chunks": 0}
622
  by_insurer[slug]["products"].add(pkey)
 
 
623
  if name not in by_insurer[slug]["names"]:
624
  by_insurer[slug]["names"].append(name)
625
  by_insurer[slug]["chunks"] += 1
@@ -634,6 +737,10 @@ async def coverage():
634
  continue
635
  if any(eid.startswith(curated_pid + "__") for eid in seen_policy_ids):
636
  continue
 
 
 
 
637
  seen_policy_ids.add(curated_pid)
638
  slug = data.get("insurer_slug", "")
639
  if slug == "regulatory":
@@ -647,8 +754,12 @@ async def coverage():
647
  name = data.get("policy_name", "") or curated_pid
648
  url = data.get("source_pdf_url", "")
649
  if slug not in by_insurer:
650
- by_insurer[slug] = {"products": set(), "names": [], "chunks": 0}
651
  by_insurer[slug]["products"].add(pkey)
 
 
 
 
652
  if name not in by_insurer[slug]["names"]:
653
  by_insurer[slug]["names"].append(name)
654
  by_insurer[slug]["chunks"] += 1
@@ -680,6 +791,7 @@ async def coverage():
680
  home_url=home_url,
681
  policy_count=product_count,
682
  sample_policies=sample_entries,
 
683
  )
684
  )
685
 
@@ -1073,6 +1185,12 @@ class MarketplacePolicy(BaseModel):
1073
  maternity_coverage: Optional[bool] = None
1074
  cashless_treatment_supported: Optional[bool] = None
1075
  room_rent_capping: Optional[str] = None
 
 
 
 
 
 
1076
 
1077
 
1078
  class MarketplaceResponse(BaseModel):
@@ -1151,8 +1269,14 @@ def _load_curated_facts() -> dict[str, dict]:
1151
  `{field: {value, source_pdf_path, source_quote}}` shape. We unwrap to a
1152
  flat `{field: value}` dict for the marketplace endpoint, preserving the
1153
  provenance in a `_facts_provenance` field for transparency.
 
 
 
 
 
1154
  """
1155
  import json as _json
 
1156
  facts: dict[str, dict] = {}
1157
  facts_dir = settings.CORPUS_DIR.parent.parent / "40-data" / "policy_facts"
1158
  if not facts_dir.exists():
@@ -1165,6 +1289,7 @@ def _load_curated_facts() -> dict[str, dict]:
1165
  policy_id = d.get("policy_id") or f.stem
1166
  flat: dict = {}
1167
  provenance: dict = {}
 
1168
  for k, v in d.items():
1169
  if k.startswith("_") or k in ("policy_id", "policy_name", "insurer_slug"):
1170
  flat[k] = v
@@ -1177,9 +1302,19 @@ def _load_curated_facts() -> dict[str, dict]:
1177
  "source_quote": v.get("source_quote"),
1178
  "source_url": v.get("source_url"),
1179
  }
 
 
1180
  else:
1181
  flat[k] = v
1182
  flat["_facts_provenance"] = provenance
 
 
 
 
 
 
 
 
1183
  # Try a couple of policy_id permutations to maximise lookup hit rate
1184
  facts[policy_id] = flat
1185
  # Some extracted JSONs use `_wordings` suffix; the curated files don't
@@ -1189,6 +1324,23 @@ def _load_curated_facts() -> dict[str, dict]:
1189
  return facts
1190
 
1191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1192
  def _merge_curated(extracted: dict, curated: dict | None) -> dict:
1193
  """Curated facts override LLM extraction for every field they populate.
1194
  LLM extraction fills the long tail. Provenance pointers survive in the
@@ -1251,6 +1403,7 @@ async def policies_all(session_id: Optional[str] = None):
1251
  "national-insurance": ("National Insurance Company", "https://nationalinsurance.nic.co.in/"),
1252
  "new-india": ("New India Assurance", "https://www.newindia.co.in/"),
1253
  "niva-bupa": ("Niva Bupa Health Insurance", "https://www.nivabupa.com/"),
 
1254
  "oriental-insurance": ("Oriental Insurance Company", "https://orientalinsurance.org.in/"),
1255
  "reliance-general": ("Reliance General Insurance", "https://www.reliancegeneral.co.in/"),
1256
  "royal-sundaram": ("Royal Sundaram General Insurance", "https://www.royalsundaram.in/"),
@@ -1293,6 +1446,146 @@ async def policies_all(session_id: Optional[str] = None):
1293
  key=lambda fp: (_DOCTYPE_RANK.get(_doctype_of(fp.stem), 99), fp.stem),
1294
  )
1295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1296
  seen_product_keys: set[str] = set()
1297
  seen_policy_ids: set[str] = set()
1298
  out = []
@@ -1371,6 +1664,9 @@ async def policies_all(session_id: Optional[str] = None):
1371
  maternity_coverage=_coerce_bool(data.get("maternity_coverage")),
1372
  cashless_treatment_supported=_coerce_bool(data.get("cashless_treatment_supported")),
1373
  room_rent_capping=data.get("room_rent_capping") if isinstance(data.get("room_rent_capping"), str) else None,
 
 
 
1374
  ))
1375
  except Exception as e:
1376
  # One malformed extraction should not kill the whole feed
@@ -1390,6 +1686,11 @@ async def policies_all(session_id: Optional[str] = None):
1390
  # Also skip if any extracted ID matches with a suffix
1391
  if any(eid.startswith(curated_policy_id + "__") for eid in seen_policy_ids):
1392
  continue
 
 
 
 
 
1393
  seen_policy_ids.add(curated_policy_id)
1394
  slug = data.get("insurer_slug", "")
1395
  name, home = insurer_meta.get(slug, (slug, ""))
@@ -1440,6 +1741,10 @@ async def policies_all(session_id: Optional[str] = None):
1440
  maternity_coverage=_coerce_bool(data.get("maternity_coverage")),
1441
  cashless_treatment_supported=_coerce_bool(data.get("cashless_treatment_supported")),
1442
  room_rent_capping=data.get("room_rent_capping") if isinstance(data.get("room_rent_capping"), str) else None,
 
 
 
 
1443
  ))
1444
  except Exception as e:
1445
  print(f"[marketplace] skipping curated {curated_policy_id}: {type(e).__name__}: {str(e)[:120]}")
 
140
  home_url: str # insurer's main website (manually curated, verified)
141
  policy_count: int
142
  sample_policies: list[PolicyEntry]
143
+ # KI-141 (2026-05-15) — backward-compatible default empty. Per-product
144
+ # alias list isn't actually surfaced on the coverage card today, but the
145
+ # field is mirrored from MarketplacePolicy so callers that union the two
146
+ # endpoints see a consistent schema. Total aliases collapsed into this
147
+ # insurer's parents — useful for QA + future UI surfacing.
148
+ alias_count: int = 0
149
 
150
 
151
  class CoverageResponse(BaseModel):
 
563
  "national-insurance": ("National Insurance Company", "https://nationalinsurance.nic.co.in/"),
564
  "new-india": ("New India Assurance", "https://www.newindia.co.in/"),
565
  "niva-bupa": ("Niva Bupa Health Insurance", "https://www.nivabupa.com/"),
566
+ "indusind-general": ("IndusInd General Insurance (formerly Reliance General)", "https://www.indusind.com/general-insurance/"),
567
  "oriental-insurance": ("Oriental Insurance Company", "https://orientalinsurance.org.in/"),
568
  "reliance-general": ("Reliance General Insurance", "https://www.reliancegeneral.co.in/"),
569
  "royal-sundaram": ("Royal Sundaram General Insurance", "https://www.royalsundaram.in/"),
 
599
  seen_product_keys: set[str] = set()
600
  seen_policy_ids: set[str] = set()
601
 
602
+ # KI-141 (2026-05-15) — pre-compute the alias mapping (curated marketing
603
+ # renames whose source PDF maps to an extracted parent). These curated
604
+ # entries collapse onto the parent card; they DO NOT count separately.
605
+ # Same algorithm as /api/policies/all so the totals stay in sync.
606
+ #
607
+ # KI-142 (2026-05-15, REFACTORED) — UIN-primary invariant: 1 unique UIN
608
+ # = 1 unique marketplace card. Mirrors the /api/policies/all algorithm
609
+ # so the coverage policy_count stays in lockstep with the marketplace
610
+ # card count. See the long-form comment block in that endpoint for the
611
+ # full algorithm rationale.
612
+ extracted_stems_cov = {fp.stem for fp in sorted_files}
613
+
614
+ # Phase A — extracted parents claim their UINs first.
615
+ uin_to_parent_cov: dict[str, str] = {}
616
+ extracted_uin_cov: dict[str, str] = {}
617
+ for fp in sorted_files:
618
+ try:
619
+ _d = _json.loads(fp.read_text())
620
+ except Exception:
621
+ continue
622
+ _u = _d.get("uin_code")
623
+ if isinstance(_u, dict):
624
+ _u = _u.get("value")
625
+ _u = (_u or "").strip() if isinstance(_u, str) else ""
626
+ if _u:
627
+ extracted_uin_cov[fp.stem] = _u
628
+ uin_to_parent_cov.setdefault(_u, fp.stem)
629
+
630
+ direct_parent_cov: dict[str, str] = {}
631
+ curated_canonical_ids_cov: list[str] = []
632
+
633
+ # Phase B — walk curated entries deterministically (sorted by policy_id).
634
+ for curated_pid, cdata in sorted(curated_facts.items()):
635
+ if curated_pid != cdata.get("policy_id", curated_pid):
636
+ continue
637
+ if any(curated_pid.endswith(f"__{dt}")
638
+ for dt in ("wordings", "brochure", "cis", "prospectus")):
639
+ continue
640
+ curated_canonical_ids_cov.append(curated_pid)
641
+
642
+ curated_uin = cdata.get("uin_code")
643
+ if isinstance(curated_uin, dict):
644
+ curated_uin = curated_uin.get("value")
645
+ curated_uin = (curated_uin or "").strip() if isinstance(curated_uin, str) else ""
646
+
647
+ parent_id: str | None = None
648
+ if curated_uin and curated_uin in uin_to_parent_cov \
649
+ and uin_to_parent_cov[curated_uin] != curated_pid:
650
+ parent_id = uin_to_parent_cov[curated_uin]
651
+ elif curated_uin:
652
+ uin_to_parent_cov[curated_uin] = curated_pid
653
+
654
+ if parent_id is None:
655
+ # KI-142 — fall back to source_pdf gate when UIN doesn't match
656
+ # any prior claimant (curated UIN may be more accurate than
657
+ # extracted; multi-variant PDFs naturally share one filing).
658
+ fb_parent = _source_pdf_to_policy_id(cdata.get("_primary_source_pdf"))
659
+ if fb_parent and fb_parent in extracted_stems_cov and fb_parent != curated_pid:
660
+ parent_id = fb_parent
661
+
662
+ if parent_id:
663
+ direct_parent_cov[curated_pid] = parent_id
664
+
665
+ # Phase C — chain-compress (see /api/policies/all for rationale).
666
+ aliased_curated_ids_cov: set[str] = set()
667
+ parent_pkey_alias_count: dict[str, int] = {}
668
+
669
+ def _terminal_parent_cov(start: str) -> str | None:
670
+ seen_chain: set[str] = set()
671
+ cur = start
672
+ while True:
673
+ nxt = direct_parent_cov.get(cur)
674
+ if not nxt:
675
+ return cur if cur != start else None
676
+ if nxt in seen_chain or nxt == start:
677
+ return None
678
+ seen_chain.add(nxt)
679
+ cur = nxt
680
+
681
+ for curated_pid in curated_canonical_ids_cov:
682
+ if curated_pid not in direct_parent_cov:
683
+ continue
684
+ terminal = _terminal_parent_cov(curated_pid)
685
+ if not terminal:
686
+ continue
687
+ if terminal in extracted_stems_cov:
688
+ terminal_pkey = _product_key_of_cov(terminal)
689
+ else:
690
+ terminal_pkey = terminal
691
+ aliased_curated_ids_cov.add(curated_pid)
692
+ parent_pkey_alias_count[terminal_pkey] = parent_pkey_alias_count.get(terminal_pkey, 0) + 1
693
+
694
  # by_insurer entries:
695
  # products: set of product_keys (matches /api/policies/all card count)
696
  # names: ordered dict of policy_NAME -> first product_key (for sample display)
697
+ # aliases: KI-141 — count of curated marketing-rename entries merged
698
+ # into this insurer's parent cards (for the alias_count field).
699
  # KI-135 (2026-05-15) — track product_keys (not names) for counting so the
700
  # ~1 within-insurer policy_name collision (e.g. new-india Floater listed
701
  # as both extracted + curated_facts) doesn't collapse the count below the
 
719
  name = data.get("policy_name", "") or pid
720
  url = data.get("source_pdf_url", "")
721
  if slug not in by_insurer:
722
+ by_insurer[slug] = {"products": set(), "names": [], "chunks": 0, "aliases": 0}
723
  by_insurer[slug]["products"].add(pkey)
724
+ # KI-141 — accumulate alias count from the pre-pass
725
+ by_insurer[slug]["aliases"] += parent_pkey_alias_count.get(pkey, 0)
726
  if name not in by_insurer[slug]["names"]:
727
  by_insurer[slug]["names"].append(name)
728
  by_insurer[slug]["chunks"] += 1
 
737
  continue
738
  if any(eid.startswith(curated_pid + "__") for eid in seen_policy_ids):
739
  continue
740
+ # KI-141 — skip curated entries that have already been collapsed into
741
+ # a pass-1 parent's alias list.
742
+ if curated_pid in aliased_curated_ids_cov:
743
+ continue
744
  seen_policy_ids.add(curated_pid)
745
  slug = data.get("insurer_slug", "")
746
  if slug == "regulatory":
 
754
  name = data.get("policy_name", "") or curated_pid
755
  url = data.get("source_pdf_url", "")
756
  if slug not in by_insurer:
757
+ by_insurer[slug] = {"products": set(), "names": [], "chunks": 0, "aliases": 0}
758
  by_insurer[slug]["products"].add(pkey)
759
+ # KI-142 — accumulate alias count for curated parents (curated entries
760
+ # that themselves became the claimant of a new UIN, with later curated
761
+ # siblings aliasing onto them).
762
+ by_insurer[slug]["aliases"] += parent_pkey_alias_count.get(pkey, 0)
763
  if name not in by_insurer[slug]["names"]:
764
  by_insurer[slug]["names"].append(name)
765
  by_insurer[slug]["chunks"] += 1
 
791
  home_url=home_url,
792
  policy_count=product_count,
793
  sample_policies=sample_entries,
794
+ alias_count=info.get("aliases", 0),
795
  )
796
  )
797
 
 
1185
  maternity_coverage: Optional[bool] = None
1186
  cashless_treatment_supported: Optional[bool] = None
1187
  room_rent_capping: Optional[str] = None
1188
+ # KI-141 (2026-05-15) — marketing-rename aliases that share the same
1189
+ # source PDF (e.g. "Activ One" and "Activ Health" both point to the
1190
+ # activ-health-individual__wordings.pdf parent). Default empty list so
1191
+ # the field is backward-compatible. Frontend renders these as small
1192
+ # "Also known as: X, Y" sub-labels under the parent card title.
1193
+ aliases: list[str] = Field(default_factory=list)
1194
 
1195
 
1196
  class MarketplaceResponse(BaseModel):
 
1269
  `{field: {value, source_pdf_path, source_quote}}` shape. We unwrap to a
1270
  flat `{field: value}` dict for the marketplace endpoint, preserving the
1271
  provenance in a `_facts_provenance` field for transparency.
1272
+
1273
+ KI-141 (2026-05-15) — also computes `_primary_source_pdf`, the most-common
1274
+ `source_pdf_path` across this curated entry's fields. Used by both
1275
+ /api/policies/all and /api/coverage to alias-merge marketing-rename
1276
+ curated entries into their extracted-JSON parent card.
1277
  """
1278
  import json as _json
1279
+ from collections import Counter
1280
  facts: dict[str, dict] = {}
1281
  facts_dir = settings.CORPUS_DIR.parent.parent / "40-data" / "policy_facts"
1282
  if not facts_dir.exists():
 
1289
  policy_id = d.get("policy_id") or f.stem
1290
  flat: dict = {}
1291
  provenance: dict = {}
1292
+ all_source_pdfs: list[str] = []
1293
  for k, v in d.items():
1294
  if k.startswith("_") or k in ("policy_id", "policy_name", "insurer_slug"):
1295
  flat[k] = v
 
1302
  "source_quote": v.get("source_quote"),
1303
  "source_url": v.get("source_url"),
1304
  }
1305
+ if v.get("source_pdf_path"):
1306
+ all_source_pdfs.append(v["source_pdf_path"])
1307
  else:
1308
  flat[k] = v
1309
  flat["_facts_provenance"] = provenance
1310
+ # KI-141 — pick the most-common source PDF path as this curated
1311
+ # entry's "primary source". When this path's extracted-JSON parent
1312
+ # already exists, the marketplace collapses this curated entry into
1313
+ # the parent's aliases list instead of emitting a separate card.
1314
+ flat["_primary_source_pdf"] = (
1315
+ Counter(all_source_pdfs).most_common(1)[0][0]
1316
+ if all_source_pdfs else None
1317
+ )
1318
  # Try a couple of policy_id permutations to maximise lookup hit rate
1319
  facts[policy_id] = flat
1320
  # Some extracted JSONs use `_wordings` suffix; the curated files don't
 
1324
  return facts
1325
 
1326
 
1327
+ def _source_pdf_to_policy_id(pdf_path: str | None) -> str | None:
1328
+ """KI-141 — map a curated `source_pdf_path` like
1329
+ 'rag/corpus/aditya-birla/activ-health-individual__wordings.pdf' to the
1330
+ extracted-JSON policy_id 'aditya-birla__activ-health-individual__wordings'.
1331
+
1332
+ Returns None if the input is empty/None.
1333
+ """
1334
+ if not pdf_path:
1335
+ return None
1336
+ s = pdf_path
1337
+ if s.startswith("rag/corpus/"):
1338
+ s = s[len("rag/corpus/"):]
1339
+ if s.endswith(".pdf"):
1340
+ s = s[: -len(".pdf")]
1341
+ return s.replace("/", "__")
1342
+
1343
+
1344
  def _merge_curated(extracted: dict, curated: dict | None) -> dict:
1345
  """Curated facts override LLM extraction for every field they populate.
1346
  LLM extraction fills the long tail. Provenance pointers survive in the
 
1403
  "national-insurance": ("National Insurance Company", "https://nationalinsurance.nic.co.in/"),
1404
  "new-india": ("New India Assurance", "https://www.newindia.co.in/"),
1405
  "niva-bupa": ("Niva Bupa Health Insurance", "https://www.nivabupa.com/"),
1406
+ "indusind-general": ("IndusInd General Insurance (formerly Reliance General)", "https://www.indusind.com/general-insurance/"),
1407
  "oriental-insurance": ("Oriental Insurance Company", "https://orientalinsurance.org.in/"),
1408
  "reliance-general": ("Reliance General Insurance", "https://www.reliancegeneral.co.in/"),
1409
  "royal-sundaram": ("Royal Sundaram General Insurance", "https://www.royalsundaram.in/"),
 
1446
  key=lambda fp: (_DOCTYPE_RANK.get(_doctype_of(fp.stem), 99), fp.stem),
1447
  )
1448
 
1449
+ # KI-141 (2026-05-15) — alias-dedup pre-pass. Curated "marketing rename"
1450
+ # entries that re-describe the SAME IRDAI-filed product collapse onto a
1451
+ # single marketplace card; the marketing names surface as `aliases`.
1452
+ #
1453
+ # KI-142 (2026-05-15, REFACTORED) — UIN-primary invariant: 1 unique UIN
1454
+ # = 1 unique marketplace card. The PDF-based gate is now a fallback for
1455
+ # entries that lack a UIN.
1456
+ #
1457
+ # Algorithm (two phases so PDF-backed extracted entries always claim
1458
+ # their UIN before any curated rename does):
1459
+ # Phase A: walk extracted/*.json (sorted by doctype rank, then stem).
1460
+ # Each extracted parent claims its uin_code into `uin_to_parent`.
1461
+ # Phase B: walk curated_facts (sorted by policy_id for determinism).
1462
+ # For each canonical curated entry (skip lookup-permutation aliases
1463
+ # and entries that ARE __wordings/__brochure/__cis themselves):
1464
+ # 1. Read curated UIN (scalar OR nested .value form).
1465
+ # 2. If UIN non-empty AND already in `uin_to_parent` (claimant !=
1466
+ # self) → alias of that parent.
1467
+ # 3. Else if UIN non-empty → claim it (so subsequent curated
1468
+ # siblings with the same UIN alias onto THIS entry in pass 2).
1469
+ # 4. Else (UIN empty) OR (UIN had no prior claimant) → fall back
1470
+ # to the source_pdf gate: if `_primary_source_pdf` maps to an
1471
+ # extracted parent stem, alias under that parent.
1472
+ # 5. Otherwise the curated entry stays as a standalone card.
1473
+ #
1474
+ # Multi-variant wordings PDFs with a single filed UIN (e.g.
1475
+ # manipalcigna prohealth-insurance-all-variants.pdf — the PDF text
1476
+ # confirms only ONE UIN `MCIHLIP24011V072324` is filed for that
1477
+ # product) correctly collapse all sub-product curated entries onto one
1478
+ # card. Distinct-UIN siblings under a shared PDF would surface as
1479
+ # separate cards because their UINs claim independent parents.
1480
+ extracted_stems = {fp.stem for fp in sorted_files}
1481
+
1482
+ # Phase A — extracted parents claim their UINs first.
1483
+ uin_to_parent: dict[str, str] = {}
1484
+ extracted_uin: dict[str, str] = {} # kept for downstream introspection
1485
+ for fp in sorted_files:
1486
+ try:
1487
+ _d = _json.loads(fp.read_text())
1488
+ except Exception:
1489
+ continue
1490
+ _u = _d.get("uin_code")
1491
+ if isinstance(_u, dict):
1492
+ _u = _u.get("value")
1493
+ _u = (_u or "").strip() if isinstance(_u, str) else ""
1494
+ if _u:
1495
+ extracted_uin[fp.stem] = _u
1496
+ uin_to_parent.setdefault(_u, fp.stem)
1497
+
1498
+ # Direct-parent map for each curated entry (built in Phase B), then
1499
+ # chain-compressed in Phase C so transitive aliases (e.g. activ-one →
1500
+ # activ-health → activ-health-individual__wordings) flatten onto the
1501
+ # ultimate extracted parent.
1502
+ direct_parent: dict[str, str] = {}
1503
+ curated_canonical_ids: list[str] = []
1504
+
1505
+ # Phase B — walk curated entries deterministically (sorted by policy_id).
1506
+ for curated_policy_id, cdata in sorted(curated_facts.items()):
1507
+ # Skip the __wordings/__brochure/__cis lookup-permutation aliases
1508
+ # that _load_curated_facts adds for hit-rate (canonical policy_id is
1509
+ # stored in the JSON's "policy_id" field).
1510
+ if curated_policy_id != cdata.get("policy_id", curated_policy_id):
1511
+ continue
1512
+ # Skip curated entries that ARE their own __wordings/__brochure/__cis
1513
+ # (doctype-permutation curated files, not marketing renames; pass-2
1514
+ # dedup handles them via the seen_policy_ids prefix check).
1515
+ if any(curated_policy_id.endswith(f"__{dt}")
1516
+ for dt in ("wordings", "brochure", "cis", "prospectus")):
1517
+ continue
1518
+ curated_canonical_ids.append(curated_policy_id)
1519
+
1520
+ # Read curated UIN (scalar OR nested {value, source_pdf_path, ...}).
1521
+ curated_uin = cdata.get("uin_code")
1522
+ if isinstance(curated_uin, dict):
1523
+ curated_uin = curated_uin.get("value")
1524
+ curated_uin = (curated_uin or "").strip() if isinstance(curated_uin, str) else ""
1525
+
1526
+ parent_id: str | None = None
1527
+ if curated_uin and curated_uin in uin_to_parent \
1528
+ and uin_to_parent[curated_uin] != curated_policy_id:
1529
+ # UIN-primary path: collapse onto the prior claimant of this UIN
1530
+ # (different policy_name same regulator filing = pure rename).
1531
+ parent_id = uin_to_parent[curated_uin]
1532
+ elif curated_uin:
1533
+ # New UIN — this curated entry becomes the claimant so any
1534
+ # later curated sibling with the same UIN aliases onto it.
1535
+ uin_to_parent[curated_uin] = curated_policy_id
1536
+
1537
+ if parent_id is None and not curated_uin:
1538
+ # KI-142 (user rule, 2026-05-15): source-PDF fallback only fires
1539
+ # when the curated entry has NO UIN at all. If UIN exists but
1540
+ # doesn't match any extracted parent, they're different
1541
+ # regulator-filed products and must stay as separate cards —
1542
+ # source-PDF coincidence (multi-variant wordings) does NOT merge.
1543
+ fb_parent = _source_pdf_to_policy_id(cdata.get("_primary_source_pdf"))
1544
+ if fb_parent and fb_parent in extracted_stems and fb_parent != curated_policy_id:
1545
+ parent_id = fb_parent
1546
+
1547
+ if parent_id:
1548
+ direct_parent[curated_policy_id] = parent_id
1549
+
1550
+ # Phase C — chain-compress direct_parent so every curated alias points
1551
+ # at its terminal parent (an extracted stem, or a curated parent that
1552
+ # itself has no parent). Detect cycles defensively. After compression
1553
+ # we emit one alias entry per curated descendant onto the terminal
1554
+ # parent's product_key.
1555
+ parent_pkey_aliases: dict[str, list[str]] = {}
1556
+ aliased_curated_ids: set[str] = set()
1557
+
1558
+ def _terminal_parent(start: str) -> str | None:
1559
+ """Walk direct_parent until we hit an extracted stem or a curated id
1560
+ with no further parent. Returns None on cycle (defensive)."""
1561
+ seen_chain: set[str] = set()
1562
+ cur = start
1563
+ while True:
1564
+ nxt = direct_parent.get(cur)
1565
+ if not nxt:
1566
+ return cur if cur != start else None
1567
+ if nxt in seen_chain or nxt == start:
1568
+ return None # cycle — drop the alias attempt
1569
+ seen_chain.add(nxt)
1570
+ cur = nxt
1571
+
1572
+ for curated_policy_id in curated_canonical_ids:
1573
+ if curated_policy_id not in direct_parent:
1574
+ continue
1575
+ terminal = _terminal_parent(curated_policy_id)
1576
+ if not terminal:
1577
+ continue
1578
+ # Alias-target product_key: extracted stems use _product_key_of()
1579
+ # (strips __doctype). Curated terminals use the policy_id directly.
1580
+ if terminal in extracted_stems:
1581
+ terminal_pkey = _product_key_of(terminal)
1582
+ else:
1583
+ terminal_pkey = terminal
1584
+ alias_name = (curated_facts.get(curated_policy_id, {}).get("policy_name")
1585
+ or curated_policy_id)
1586
+ parent_pkey_aliases.setdefault(terminal_pkey, []).append(alias_name)
1587
+ aliased_curated_ids.add(curated_policy_id)
1588
+
1589
  seen_product_keys: set[str] = set()
1590
  seen_policy_ids: set[str] = set()
1591
  out = []
 
1664
  maternity_coverage=_coerce_bool(data.get("maternity_coverage")),
1665
  cashless_treatment_supported=_coerce_bool(data.get("cashless_treatment_supported")),
1666
  room_rent_capping=data.get("room_rent_capping") if isinstance(data.get("room_rent_capping"), str) else None,
1667
+ # KI-141 — merge marketing-rename curated entries onto this
1668
+ # parent card. Sorted for deterministic output.
1669
+ aliases=sorted(parent_pkey_aliases.get(product_key, [])),
1670
  ))
1671
  except Exception as e:
1672
  # One malformed extraction should not kill the whole feed
 
1686
  # Also skip if any extracted ID matches with a suffix
1687
  if any(eid.startswith(curated_policy_id + "__") for eid in seen_policy_ids):
1688
  continue
1689
+ # KI-141 — skip curated entries that have already been collapsed onto
1690
+ # a pass-1 parent card via the aliases mechanism (e.g. Activ One →
1691
+ # Activ Health Individual Wordings).
1692
+ if curated_policy_id in aliased_curated_ids:
1693
+ continue
1694
  seen_policy_ids.add(curated_policy_id)
1695
  slug = data.get("insurer_slug", "")
1696
  name, home = insurer_meta.get(slug, (slug, ""))
 
1741
  maternity_coverage=_coerce_bool(data.get("maternity_coverage")),
1742
  cashless_treatment_supported=_coerce_bool(data.get("cashless_treatment_supported")),
1743
  room_rent_capping=data.get("room_rent_capping") if isinstance(data.get("room_rent_capping"), str) else None,
1744
+ # KI-142 — curated entries can ALSO be UIN-claimants when no
1745
+ # extracted parent owns their UIN. In that case their later
1746
+ # curated siblings alias onto them and surface here.
1747
+ aliases=sorted(parent_pkey_aliases.get(curated_policy_id, [])),
1748
  ))
1749
  except Exception as e:
1750
  print(f"[marketplace] skipping curated {curated_policy_id}: {type(e).__name__}: {str(e)[:120]}")
backend/orchestrator.py CHANGED
@@ -73,6 +73,32 @@ INDIC_KEYWORDS = (
73
  )
74
 
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  def _phrase_present(phrase: str, q: str) -> bool:
77
  """Word-boundary phrase match. KI-023 (2026-05-14) — replaces naive
78
  substring matching that incorrectly tripped triggers like "hi" on words
@@ -204,6 +230,7 @@ def should_route_to_fact_find(
204
  profile_is_empty: bool,
205
  in_fact_find_continuation: bool,
206
  free_form_session: bool,
 
207
  ) -> bool:
208
  """Pure decision function for the fact-find routing branch.
209
 
@@ -217,6 +244,10 @@ def should_route_to_fact_find(
217
  if in_fact_find_continuation:
218
  return True
219
  if profile_is_empty and intent in CONTEXT_DEPENDENT_INTENTS:
 
 
 
 
220
  return True
221
  return False
222
 
@@ -476,6 +507,7 @@ async def handle_turn(
476
  profile_is_empty=profile_is_empty,
477
  in_fact_find_continuation=in_fact_find_continuation,
478
  free_form_session=session.free_form_session,
 
479
  )
480
 
481
  if treat_as_fact_find:
 
73
  )
74
 
75
 
76
+ # KI-136 (2026-05-15) — named-SKU detector. Comparisons that name >=2
77
+ # specific insurers/policies are policy-fact lookups, not profile-dependent
78
+ # recommendations — same carve-out spirit as KI-018 for qa intent.
79
+ _NAMED_INSURER_TOKENS = (
80
+ "hdfc ergo", "niva bupa", "star health", "care health", "icici lombard",
81
+ "bajaj allianz", "tata aig", "aditya birla", "manipal cigna", "sbi general",
82
+ "new india", "national insurance", "oriental insurance", "united india",
83
+ "reliance general", "go digit", "iffco tokio", "raheja qbe",
84
+ "cholamandalam", "acko", "royal sundaram",
85
+ # Common product names
86
+ "optima secure", "optima restore", "aspire", "activ assure", "activ one",
87
+ "activ health", "health companion", "young star", "senior citizen red carpet",
88
+ "arogya sanjeevani", "reassure", "health guard", "prohealth", "elevate",
89
+ "medicare", "criti", "saral suraksha", "comprehensive care",
90
+ )
91
+
92
+
93
+ def _names_two_specific_policies(q: str) -> bool:
94
+ """KI-136 — return True if the user's text mentions >=2 named SKUs/insurers,
95
+ indicating a policy-fact lookup rather than a profile-dependent
96
+ recommendation."""
97
+ ql = q.lower()
98
+ hits = sum(1 for tok in _NAMED_INSURER_TOKENS if tok in ql)
99
+ return hits >= 2
100
+
101
+
102
  def _phrase_present(phrase: str, q: str) -> bool:
103
  """Word-boundary phrase match. KI-023 (2026-05-14) — replaces naive
104
  substring matching that incorrectly tripped triggers like "hi" on words
 
230
  profile_is_empty: bool,
231
  in_fact_find_continuation: bool,
232
  free_form_session: bool,
233
+ query: str = "",
234
  ) -> bool:
235
  """Pure decision function for the fact-find routing branch.
236
 
 
244
  if in_fact_find_continuation:
245
  return True
246
  if profile_is_empty and intent in CONTEXT_DEPENDENT_INTENTS:
247
+ # KI-136 (2026-05-15) — exempt named-SKU comparisons. They're policy-fact
248
+ # lookups (route to qa), not profile-dependent recommendations.
249
+ if intent == "comparison" and _names_two_specific_policies(query):
250
+ return False
251
  return True
252
  return False
253
 
 
507
  profile_is_empty=profile_is_empty,
508
  in_fact_find_continuation=in_fact_find_continuation,
509
  free_form_session=session.free_form_session,
510
+ query=user_text,
511
  )
512
 
513
  if treat_as_fact_find:
frontend/src/app/page.tsx CHANGED
@@ -1951,7 +1951,11 @@ function MarketplacePanel({
1951
  const insurers = Array.from(new Set(data.policies.map((p) => p.insurer_slug))).sort();
1952
 
1953
  const filtered = data.policies.filter((p) => {
1954
- if (search && !p.policy_name.toLowerCase().includes(search.toLowerCase()) && !p.insurer_name.toLowerCase().includes(search.toLowerCase())) return false;
 
 
 
 
1955
  if (insurerFilter !== "all" && p.insurer_slug !== insurerFilter) return false;
1956
  if (grade !== "all" && p.grade !== grade) return false;
1957
  if (p.pre_existing_disease_waiting_months && p.pre_existing_disease_waiting_months > maxPED) return false;
@@ -2321,6 +2325,11 @@ function PolicyCard({
2321
  <div className="flex-1 min-w-0">
2322
  <div className="text-xs text-[var(--muted-foreground)] truncate">{policy.insurer_name}</div>
2323
  <div className="font-semibold text-sm truncate group-hover:text-[var(--primary)] transition">{policy.policy_name}</div>
 
 
 
 
 
2324
  </div>
2325
  {/* Score badge ONLY when we have a profile — otherwise CTA pill */}
2326
  {isPersonalized ? (
 
1951
  const insurers = Array.from(new Set(data.policies.map((p) => p.insurer_slug))).sort();
1952
 
1953
  const filtered = data.policies.filter((p) => {
1954
+ if (search) {
1955
+ const q = search.toLowerCase();
1956
+ const aliasHit = (p.aliases || []).some((a) => a.toLowerCase().includes(q));
1957
+ if (!p.policy_name.toLowerCase().includes(q) && !p.insurer_name.toLowerCase().includes(q) && !aliasHit) return false;
1958
+ }
1959
  if (insurerFilter !== "all" && p.insurer_slug !== insurerFilter) return false;
1960
  if (grade !== "all" && p.grade !== grade) return false;
1961
  if (p.pre_existing_disease_waiting_months && p.pre_existing_disease_waiting_months > maxPED) return false;
 
2325
  <div className="flex-1 min-w-0">
2326
  <div className="text-xs text-[var(--muted-foreground)] truncate">{policy.insurer_name}</div>
2327
  <div className="font-semibold text-sm truncate group-hover:text-[var(--primary)] transition">{policy.policy_name}</div>
2328
+ {policy.aliases && policy.aliases.length > 0 && (
2329
+ <div className="text-xs text-slate-500 italic mt-0.5 truncate">
2330
+ Also marketed as: {policy.aliases.join(", ")}
2331
+ </div>
2332
+ )}
2333
  </div>
2334
  {/* Score badge ONLY when we have a profile — otherwise CTA pill */}
2335
  {isPersonalized ? (
frontend/src/lib/api.ts CHANGED
@@ -274,6 +274,7 @@ export type CompareResponse = {
274
  export type MarketplacePolicy = {
275
  policy_id: string;
276
  policy_name: string;
 
277
  insurer_slug: string;
278
  insurer_name: string;
279
  insurer_home_url: string;
 
274
  export type MarketplacePolicy = {
275
  policy_id: string;
276
  policy_name: string;
277
+ aliases?: string[];
278
  insurer_slug: string;
279
  insurer_name: string;
280
  insurer_home_url: string;