rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
dea6883
·
1 Parent(s): 55fb938

feat(profile+pricing): KI-269 — D1+D2 bundle: chip merge + panel pre-fill + copay + family history

Browse files

D1 (frontend, page.tsx) — chip merge + panel pre-fill:
- Deleted the redundant "ESTIMATE Annual premium" CTA button.
- Made the "EST. PREMIUM ₹X-Y/yr" band chip itself clickable: same onClick
as the old CTA (toggles PremiumCalculatorPanel + closes sibling panels).
Added hover state (ring + brightness + cursor-pointer), chevron-edit
SVG, tooltip "Tap to refine premium with sliders" (en + hi).
- PremiumCalculatorPanel now reads `initialProfile` prop (passed from page
as `profileCompleteness?.profile`). Three pure derivation helpers
pre-populate the 5 sliders from the live profile:
Age → profile.age (fallback 35)
Sum insured → existing_cover_inr (fallback 10L; will widen to
desired_sum_insured_inr once UserProfile schema is regen'd)
Family covered → derived from profile.dependents string
Pre-existing conditions → mapped from profile.health_conditions
City tier → profile.location_tier (default metro)
- Users can still override via sliders post-open.

D2 (backend, 4 files) — copay_pct + family_medical_history slots end-to-end:

backend/needs_finder.py — Profile dataclass:
+ copay_pct: Optional[int] # 0-50, % of every claim user accepts
+ family_medical_history: list[str] # blood-family conditions

backend/brain_tools.py:
- SLOT_UNION constant extended with both new fields (now 15 slots total).
- _ACCEPTED_FIELDS adds copay_pct + family_medical_history.
- _coerce_copay_pct(value): int parse, accepts "20"/"20%"/20, clamps [0,50].
- _coerce_family_medical_history(value): list[str] lowercase, aliases
(BP → hypertension, sugar → diabetes), "none"/"no family history" → [].
- save_profile_field routes both new fields to coercers.
- Top docstring slot-→-consumer matrix updated.

backend/single_brain.py SYSTEM_PROMPT:
- RULE 2.5 extended with two new asks:
* "OK with co-pay 10-30% to lower premium?" → save copay_pct
* "Major conditions in your blood family — cancer/diabetes/heart?"
→ save family_medical_history
- RULE 2 retrieve_policies query construction now includes family-history
boost terms: if family has cancer → include "critical illness rider
cancer cover"; diabetes → "diabetes short waiting period"; heart →
"cardiac care rider".

backend/premium_calculator.py:
- _copay_discount(copay_pct): 0%→1.00× / 10%→0.95× / 20%→0.88× / 30%→0.80×
+ smooth interpolation for in-between values.
- _family_history_loading(family_medical_history):
* empty/["none"] → 1.00×
* 2+ family conditions → 1.10×
* cancer / heart → 1.05×
* other single → 1.03×
- estimate() folds copay_mult + fam_mult into the multiplicative chain
(already wired by D2 before stall).
- bulk_estimate() — finished D2's wiring: reads copay_pct + family_medical_history
from profile dict, computes copay_mult + fam_mult once, multiplies into
both the curated-anchor branch (via estimate() pass-through kwargs) AND
the flat-fallback branch. Surfaces both in the breakdown dict when ≠ 1.0×
(copay_discount_x / copay_discount_reason / family_history_loading_x /
family_history_loading_reason).

Live smoke (HDFC Optima Secure, age 40, metro, self+spouse):
- baseline: ₹14,400/year
- copay 20% + family cancer: ₹13,310/year (-7.6% net, matches model)
- breakdown shows copay_discount_x=0.88, family_history_loading_x=1.05

Verification:
- python -m py_compile clean on all 4 backend files
- npx tsc --noEmit clean
- bulk_estimate smoke produces expected loaded/discounted values

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

backend/brain_tools.py CHANGED
@@ -46,7 +46,11 @@ Slot → consumer matrix:
46
  parents_age_max → pricing (parents age loading 1.0× / 1.4× / 1.8×)
47
  parents_has_ped → pricing (PED loading inflation for parents)
48
 
49
- Total: 13 slots. `gender` is tolerated by save_profile_field for forward
 
 
 
 
50
  compat but is NOT on the Profile dataclass today; it does not appear in
51
  SLOT_UNION because no consumer reads it.
52
  """
@@ -83,6 +87,9 @@ _ACCEPTED_FIELDS = {
83
  "parents_to_insure",
84
  "parents_age_max",
85
  "parents_has_ped",
 
 
 
86
  "gender", # tolerated; not persisted unless Profile gains the field
87
  }
88
 
@@ -128,6 +135,9 @@ SLOT_UNION: tuple[str, ...] = (
128
  "parents_to_insure",
129
  "parents_age_max",
130
  "parents_has_ped",
 
 
 
131
  )
132
 
133
  # Invariant: every SLOT_UNION field must be accepted by save_profile_field
@@ -218,6 +228,10 @@ def save_profile_field(session, field: str, value: Any) -> dict:
218
  normalized = _coerce_bool(value)
219
  elif fld == "parents_age_max":
220
  normalized = _coerce_age(value)
 
 
 
 
221
  elif fld == "name":
222
  normalized = (str(value).strip() if value is not None else None) or None
223
  elif fld == "gender":
@@ -804,6 +818,143 @@ def _coerce_health_conditions(value: Any) -> Optional[list[str]]:
804
  return real
805
 
806
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
807
  __all__ = [
808
  "save_profile_field",
809
  "retrieve_policies",
 
46
  parents_age_max → pricing (parents age loading 1.0× / 1.4× / 1.8×)
47
  parents_has_ped → pricing (PED loading inflation for parents)
48
 
49
+ D2 ADDITIONS (2026-05-15 copay + family medical history)
50
+ copay_pct → pricing (copay discount 1.0× / 0.95× / 0.88× / 0.80×)
51
+ family_medical_history → pricing (family-history loading) + retrieval boost
52
+
53
+ Total: 15 slots. `gender` is tolerated by save_profile_field for forward
54
  compat but is NOT on the Profile dataclass today; it does not appear in
55
  SLOT_UNION because no consumer reads it.
56
  """
 
87
  "parents_to_insure",
88
  "parents_age_max",
89
  "parents_has_ped",
90
+ # D2 (2026-05-15) — coupled additions: co-pay tolerance + family medical history
91
+ "copay_pct",
92
+ "family_medical_history",
93
  "gender", # tolerated; not persisted unless Profile gains the field
94
  }
95
 
 
135
  "parents_to_insure",
136
  "parents_age_max",
137
  "parents_has_ped",
138
+ # D2 additions (2026-05-15)
139
+ "copay_pct",
140
+ "family_medical_history",
141
  )
142
 
143
  # Invariant: every SLOT_UNION field must be accepted by save_profile_field
 
228
  normalized = _coerce_bool(value)
229
  elif fld == "parents_age_max":
230
  normalized = _coerce_age(value)
231
+ elif fld == "copay_pct":
232
+ normalized = _coerce_copay_pct(value)
233
+ elif fld == "family_medical_history":
234
+ normalized = _coerce_family_medical_history(value)
235
  elif fld == "name":
236
  normalized = (str(value).strip() if value is not None else None) or None
237
  elif fld == "gender":
 
818
  return real
819
 
820
 
821
+ # ---------------------------------------------------------------------------
822
+ # D2 (2026-05-15) — copay_pct + family_medical_history coercers
823
+ # ---------------------------------------------------------------------------
824
+
825
+ # Word-number map for "twenty", "ten" etc. (RULE 2.5 asks the user in
826
+ # multiples of 10; Gemini sometimes echoes the user's word verbatim).
827
+ _COPAY_WORD_TO_INT: dict[str, int] = {
828
+ "zero": 0, "none": 0, "no": 0,
829
+ "ten": 10, "fifteen": 15, "twenty": 20,
830
+ "twenty five": 25, "twenty-five": 25,
831
+ "thirty": 30, "forty": 40, "fifty": 50,
832
+ }
833
+
834
+
835
+ def _coerce_copay_pct(value: Any) -> Optional[int]:
836
+ """Parse a co-pay tolerance percent, clamped to [0, 50].
837
+
838
+ Accepts:
839
+ - int / float → int + clamp
840
+ - "20", "20%", " 20 % ", "20 percent" → 20
841
+ - "no copay" / "zero" / "none" → 0
842
+ - word numbers like "twenty" → 20
843
+ - bool → blocked (KI-091 null-overwrite caution: bool is an int subclass)
844
+
845
+ Returns None for unrecognised input so the null-overwrite guard in
846
+ save_profile_field can refuse to clobber a previously-captured slot.
847
+ """
848
+ if value is None:
849
+ return None
850
+ if isinstance(value, bool):
851
+ return None
852
+ if isinstance(value, (int, float)):
853
+ n = int(value)
854
+ return max(0, min(50, n))
855
+ s = str(value).strip().lower()
856
+ if not s:
857
+ return None
858
+ # Explicit zero phrasings.
859
+ if s in ("no", "none", "nil", "zero", "no copay", "no co-pay", "no co pay"):
860
+ return 0
861
+ # Word-number lookup (exact match).
862
+ if s in _COPAY_WORD_TO_INT:
863
+ return _COPAY_WORD_TO_INT[s]
864
+ # Strip "%" + "percent" + "pct".
865
+ cleaned = (
866
+ s.replace("%", " ")
867
+ .replace("percent", " ")
868
+ .replace("pct", " ")
869
+ .replace("copay", " ")
870
+ .replace("co-pay", " ")
871
+ .replace("co pay", " ")
872
+ )
873
+ # Digit run.
874
+ import re as _re
875
+ m = _re.search(r"\d+(?:\.\d+)?", cleaned)
876
+ if m:
877
+ try:
878
+ n = int(float(m.group(0)))
879
+ return max(0, min(50, n))
880
+ except ValueError:
881
+ return None
882
+ # Word-number fall-through (substring on cleaned text).
883
+ for word, num in _COPAY_WORD_TO_INT.items():
884
+ if word in cleaned.split():
885
+ return num
886
+ return None
887
+
888
+
889
+ # Alias map for family medical history — same canonicalisation logic as
890
+ # health_conditions but kept inline so this slot stays self-contained.
891
+ _FAMILY_HISTORY_ALIASES: dict[str, str] = {
892
+ "bp": "hypertension",
893
+ "high bp": "hypertension",
894
+ "high-bp": "hypertension",
895
+ "hi-bp": "hypertension",
896
+ "high blood pressure": "hypertension",
897
+ "blood pressure": "hypertension",
898
+ "sugar": "diabetes",
899
+ "diabetic": "diabetes",
900
+ "type 2 diabetes": "diabetes",
901
+ "type 1 diabetes": "diabetes",
902
+ "heart attack": "heart",
903
+ "heart disease": "heart",
904
+ "cardiac": "heart",
905
+ "cardiac disease": "heart",
906
+ "stroke": "heart",
907
+ "tumor": "cancer",
908
+ "tumour": "cancer",
909
+ "carcinoma": "cancer",
910
+ }
911
+
912
+ _FAMILY_HISTORY_NEGATION = {
913
+ "none", "no", "n/a", "na", "nil", "nothing", "healthy",
914
+ "no family history", "no history", "no medical history",
915
+ }
916
+
917
+
918
+ def _coerce_family_medical_history(value: Any) -> Optional[list[str]]:
919
+ """Return list[str] lowercase canonical conditions running in BLOOD family.
920
+
921
+ Accepts:
922
+ - list / tuple of strings
923
+ - comma-joined string ("cancer, diabetes")
924
+ - "none" / "no family history" → []
925
+
926
+ Alias map collapses BP/sugar/cardiac/tumor → hypertension/diabetes/heart/
927
+ cancer respectively (same family as _coerce_health_conditions). Negation
928
+ sentinels return `[]` since downstream pricing & retrieval BOTH treat an
929
+ empty list as the "no family history" branch (different from health_
930
+ conditions where the explicit `["none"]` sentinel is needed for the
931
+ profile-completeness gate).
932
+ """
933
+ if value is None:
934
+ return None
935
+ if isinstance(value, str):
936
+ items = [t.strip() for t in value.split(",")]
937
+ elif isinstance(value, (list, tuple)):
938
+ items = [str(t).strip() for t in value]
939
+ else:
940
+ items = [str(value).strip()]
941
+ cleaned = [t.lower() for t in items if t]
942
+ # Full-string negation collapses to [].
943
+ if cleaned and all(t in _FAMILY_HISTORY_NEGATION for t in cleaned):
944
+ return []
945
+ # Drop negation noise from mixed input ("cancer, none").
946
+ cleaned = [t for t in cleaned if t not in _FAMILY_HISTORY_NEGATION]
947
+ # Canonicalise via alias map.
948
+ canonical: list[str] = []
949
+ seen: set[str] = set()
950
+ for t in cleaned:
951
+ c = _FAMILY_HISTORY_ALIASES.get(t, t)
952
+ if c and c not in seen:
953
+ seen.add(c)
954
+ canonical.append(c)
955
+ return canonical
956
+
957
+
958
  __all__ = [
959
  "save_profile_field",
960
  "retrieve_policies",
backend/needs_finder.py CHANGED
@@ -42,6 +42,12 @@ class Profile:
42
  budget_band: Optional[str] = None # "under_15k", "15k_30k", "30k_60k", "60k+"
43
  desired_sum_insured_inr: Optional[int] = None # SOFT pricing input (post-recap)
44
  health_conditions: Optional[list[str]] = field(default_factory=list) # ["diabetes", "hypertension", ...]
 
 
 
 
 
 
45
  asked: list[str] = field(default_factory=list) # question IDs / field names already asked
46
  free_form_session: bool = False # True = user asks free questions, not driven by us
47
  # KI-063 (2026-05-15) — per-user policy interaction log so the bot
 
42
  budget_band: Optional[str] = None # "under_15k", "15k_30k", "30k_60k", "60k+"
43
  desired_sum_insured_inr: Optional[int] = None # SOFT pricing input (post-recap)
44
  health_conditions: Optional[list[str]] = field(default_factory=list) # ["diabetes", "hypertension", ...]
45
+ # D2 (2026-05-15) — co-pay tolerance + family medical history. Coupled
46
+ # SLOT_UNION additions captured via RULE 2.5 post-recap, both flow into
47
+ # premium_calculator (copay discount + family-history loading) and
48
+ # retrieval (family-history rider boost keywords).
49
+ copay_pct: Optional[int] = None # 0-50, % of every claim user accepts
50
+ family_medical_history: list[str] = field(default_factory=list) # blood-family conditions
51
  asked: list[str] = field(default_factory=list) # question IDs / field names already asked
52
  free_form_session: bool = False # True = user asks free questions, not driven by us
53
  # KI-063 (2026-05-15) — per-user policy interaction log so the bot
backend/premium_calculator.py CHANGED
@@ -29,6 +29,9 @@ location / family_size that B2 already handles):
29
  parents_age_max → parents_loading 1.0× / 1.4× / 1.8×
30
  (only when `dependents` mentions "parents")
31
  parents_has_ped → adds +0.10× on top of parents_loading
 
 
 
32
 
33
  Slots that are profile-only (no pricing effect): name, primary_goal,
34
  income_band, budget_band (matched against output, not folded into the
@@ -189,6 +192,95 @@ def _parents_loading(dependents, parents_age_max, parents_has_ped=None) -> tuple
189
  return base, label
190
 
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  # Co-pay reduces premium. Industry norm (PolicyBazaar/Acko): each 10 pct
193
  # points of co-pay yields ~7% premium reduction, capped at 40% co-pay.
194
  def _copay_multiplier(pct: float) -> float:
@@ -256,6 +348,9 @@ def estimate(
256
  dependents: Optional[str] = None,
257
  parents_age_max: Optional[int] = None,
258
  parents_has_ped: Optional[bool] = None,
 
 
 
259
  ) -> PremiumEstimate:
260
  data = _load_data()
261
  base_premiums = data.get("base_premiums", {})
@@ -318,6 +413,14 @@ def estimate(
318
  )
319
  base *= parents_mult
320
 
 
 
 
 
 
 
 
 
321
  point = int(round(base / 100) * 100) # round to nearest ₹100
322
  return PremiumEstimate(
323
  policy_id=policy_id or "generic",
@@ -488,6 +591,9 @@ def bulk_estimate(
488
  dependents = profile.get("dependents")
489
  parents_age_max = profile.get("parents_age_max")
490
  parents_has_ped = profile.get("parents_has_ped")
 
 
 
491
  # desired_sum_insured_inr — when present, becomes the default SI for
492
  # any policy without an explicit overrides entry (per-policy override
493
  # still wins, since this is the DEFAULT).
@@ -504,6 +610,11 @@ def bulk_estimate(
504
  parents_mult, parents_label = _parents_loading(
505
  dependents, parents_age_max, parents_has_ped
506
  )
 
 
 
 
 
507
 
508
  out: dict[str, BulkPolicyPremium] = {}
509
  for pid in policy_ids:
@@ -547,6 +658,9 @@ def bulk_estimate(
547
  dependents=dependents,
548
  parents_age_max=parents_age_max,
549
  parents_has_ped=parents_has_ped,
 
 
 
550
  )
551
  # estimate() already folded age/location/family AND the B6
552
  # loadings — unwind so the widget can display the same
@@ -585,6 +699,8 @@ def bulk_estimate(
585
  * health_mult
586
  * ec_mult
587
  * parents_mult
 
 
588
  * tenure_mult
589
  * ded_mult
590
  )
@@ -617,6 +733,12 @@ def bulk_estimate(
617
  if parents_mult != 1.0:
618
  breakdown["parents_loading_x"] = round(parents_mult, 3)
619
  breakdown["parents_loading_reason"] = parents_label
 
 
 
 
 
 
620
  if desired_si and not ov.get("sum_insured_inr"):
621
  breakdown["desired_si_default_inr"] = int(desired_si)
622
 
 
29
  parents_age_max → parents_loading 1.0× / 1.4× / 1.8×
30
  (only when `dependents` mentions "parents")
31
  parents_has_ped → adds +0.10× on top of parents_loading
32
+ copay_pct (D2) → copay_discount 1.0× / 0.95× / 0.88× / 0.80×
33
+ family_medical_history → family_history_loading 1.0× / 1.03× / 1.05× / 1.10×
34
+ (D2) (cancer/heart +5%, 2+ conditions +10%, other +3%)
35
 
36
  Slots that are profile-only (no pricing effect): name, primary_goal,
37
  income_band, budget_band (matched against output, not folded into the
 
192
  return base, label
193
 
194
 
195
+ # ───────────────────────────────────────────────────────────────────────────
196
+ # D2 (2026-05-15) — copay_pct + family_medical_history loadings
197
+ # ───────────────────────────────────────────────────────────────────────────
198
+
199
+ def _copay_discount(copay_pct) -> tuple[float, str]:
200
+ """Return (multiplier, label) for SLOT_UNION's `copay_pct` slot.
201
+
202
+ Distinct from the legacy `_copay_multiplier` (formula-based, used by the
203
+ `copayment_pct` arg on estimate()). This is a profile-driven step-discount
204
+ grid keyed to the 4 buckets RULE 2.5 asks the user about (0/10/20/30):
205
+
206
+ 0% → 1.00× ("no copay") — insurer pays it all (highest premium)
207
+ 10% → 0.95× ("10% copay") — mild tier
208
+ 20% → 0.88× ("20% copay") — typical
209
+ 30% → 0.80× ("30% copay") — aggressive
210
+ other → linear interpolate between the two nearest buckets, clamped to [0,50]
211
+ """
212
+ if copay_pct is None:
213
+ return 1.0, "no_copay"
214
+ try:
215
+ pct = int(copay_pct)
216
+ except (TypeError, ValueError):
217
+ return 1.0, "no_copay"
218
+ if pct <= 0:
219
+ return 1.0, "no_copay"
220
+ # Clamp to [0, 50] to match _coerce_copay_pct.
221
+ pct = min(50, pct)
222
+ # Step grid (exact buckets).
223
+ if pct == 10:
224
+ return 0.95, "10_pct_copay"
225
+ if pct == 20:
226
+ return 0.88, "20_pct_copay"
227
+ if pct == 30:
228
+ return 0.80, "30_pct_copay"
229
+ # Linear interpolation for off-grid values (e.g. 15, 25, 40).
230
+ grid = [(0, 1.00), (10, 0.95), (20, 0.88), (30, 0.80), (50, 0.70)]
231
+ for i in range(len(grid) - 1):
232
+ p0, m0 = grid[i]
233
+ p1, m1 = grid[i + 1]
234
+ if p0 <= pct <= p1:
235
+ t = (pct - p0) / (p1 - p0) if p1 != p0 else 0
236
+ mult = m0 + (m1 - m0) * t
237
+ return round(mult, 3), f"{pct}_pct_copay"
238
+ return 1.0, "no_copay"
239
+
240
+
241
+ # Family medical history canonical condition keywords. Matches the canonical
242
+ # tokens emitted by brain_tools._coerce_family_medical_history (cancer /
243
+ # diabetes / heart / hypertension).
244
+ _FAM_CANCER_KEYWORDS = {"cancer"}
245
+ _FAM_HEART_KEYWORDS = {"heart"}
246
+
247
+
248
+ def _family_history_loading(family_medical_history) -> tuple[float, str]:
249
+ """Return (multiplier, label) for blood-family medical history.
250
+
251
+ Logic (D2 spec):
252
+ • empty list / None / ["none"] → (1.00, "no_family_history")
253
+ • 2+ family conditions → (1.10, "multi_family_history")
254
+ (highest — compounded genetic risk)
255
+ • contains "cancer" → (1.05, "family_cancer")
256
+ • contains "heart" → (1.05, "family_heart")
257
+ • other single condition (e.g. diabetes / hypertension) → (1.03, "family_history")
258
+
259
+ Order: 2+ check FIRST so a profile with both cancer + diabetes lands on
260
+ the multi-family multiplier (not the cancer-only +5%).
261
+ """
262
+ if not family_medical_history:
263
+ return 1.0, "no_family_history"
264
+ if isinstance(family_medical_history, str):
265
+ items = [t.strip().lower() for t in family_medical_history.split(",") if t.strip()]
266
+ else:
267
+ items = [str(t).strip().lower() for t in family_medical_history if str(t).strip()]
268
+ # Drop the "none" sentinel if a caller passed it (defensive).
269
+ items = [t for t in items if t != "none"]
270
+ if not items:
271
+ return 1.0, "no_family_history"
272
+ # 2+ conditions wins — compounded genetic risk loading.
273
+ if len(items) >= 2:
274
+ return 1.10, "multi_family_history"
275
+ # Single condition — bucket by keyword.
276
+ single = items[0]
277
+ if any(k in single for k in _FAM_CANCER_KEYWORDS):
278
+ return 1.05, "family_cancer"
279
+ if any(k in single for k in _FAM_HEART_KEYWORDS):
280
+ return 1.05, "family_heart"
281
+ return 1.03, "family_history_single"
282
+
283
+
284
  # Co-pay reduces premium. Industry norm (PolicyBazaar/Acko): each 10 pct
285
  # points of co-pay yields ~7% premium reduction, capped at 40% co-pay.
286
  def _copay_multiplier(pct: float) -> float:
 
348
  dependents: Optional[str] = None,
349
  parents_age_max: Optional[int] = None,
350
  parents_has_ped: Optional[bool] = None,
351
+ # D2 additions (2026-05-15) — copay_pct + family_medical_history.
352
+ copay_pct: Optional[int] = None,
353
+ family_medical_history: Optional[list] = None,
354
  ) -> PremiumEstimate:
355
  data = _load_data()
356
  base_premiums = data.get("base_premiums", {})
 
413
  )
414
  base *= parents_mult
415
 
416
+ # D2 — copay_pct discount + family_medical_history loading. Each is 1.0×
417
+ # when the corresponding SLOT_UNION field is None / empty, so legacy
418
+ # callers see no change.
419
+ copay_mult, copay_label = _copay_discount(copay_pct)
420
+ base *= copay_mult
421
+ fam_mult, fam_label = _family_history_loading(family_medical_history)
422
+ base *= fam_mult
423
+
424
  point = int(round(base / 100) * 100) # round to nearest ₹100
425
  return PremiumEstimate(
426
  policy_id=policy_id or "generic",
 
591
  dependents = profile.get("dependents")
592
  parents_age_max = profile.get("parents_age_max")
593
  parents_has_ped = profile.get("parents_has_ped")
594
+ # D2 — copay_pct + family_medical_history (same read pattern).
595
+ copay_pct = profile.get("copay_pct")
596
+ family_medical_history = profile.get("family_medical_history")
597
  # desired_sum_insured_inr — when present, becomes the default SI for
598
  # any policy without an explicit overrides entry (per-policy override
599
  # still wins, since this is the DEFAULT).
 
610
  parents_mult, parents_label = _parents_loading(
611
  dependents, parents_age_max, parents_has_ped
612
  )
613
+ # D2 — copay_pct discount + family_medical_history loading. Each is 1.0×
614
+ # when the corresponding SLOT_UNION field is None / empty, so legacy
615
+ # callers see no change.
616
+ copay_mult, copay_label = _copay_discount(copay_pct)
617
+ fam_mult, fam_label = _family_history_loading(family_medical_history)
618
 
619
  out: dict[str, BulkPolicyPremium] = {}
620
  for pid in policy_ids:
 
658
  dependents=dependents,
659
  parents_age_max=parents_age_max,
660
  parents_has_ped=parents_has_ped,
661
+ # D2 — copay + family-history threaded through too
662
+ copay_pct=copay_pct,
663
+ family_medical_history=family_medical_history,
664
  )
665
  # estimate() already folded age/location/family AND the B6
666
  # loadings — unwind so the widget can display the same
 
699
  * health_mult
700
  * ec_mult
701
  * parents_mult
702
+ * copay_mult
703
+ * fam_mult
704
  * tenure_mult
705
  * ded_mult
706
  )
 
733
  if parents_mult != 1.0:
734
  breakdown["parents_loading_x"] = round(parents_mult, 3)
735
  breakdown["parents_loading_reason"] = parents_label
736
+ if copay_mult != 1.0:
737
+ breakdown["copay_discount_x"] = round(copay_mult, 3)
738
+ breakdown["copay_discount_reason"] = copay_label
739
+ if fam_mult != 1.0:
740
+ breakdown["family_history_loading_x"] = round(fam_mult, 3)
741
+ breakdown["family_history_loading_reason"] = fam_label
742
  if desired_si and not ov.get("sum_insured_inr"):
743
  breakdown["desired_si_default_inr"] = int(desired_si)
744
 
backend/single_brain.py CHANGED
@@ -160,7 +160,13 @@ Required ingredients:
160
  health-condition keywords — every captured condition by name ("diabetes", "hypertension", "heart disease") OR the literal "no PED" when health_conditions == ["none"],
161
  primary goal keyword,
162
  existing cover signal — when existing_cover_inr > 0 add "top-up over existing X lakh cover"; when 0 add "fresh base policy",
163
- parents-cover signal — when dependents mentions parents add "parents age ~XX" using parents_age_max (if captured).
 
 
 
 
 
 
164
 
165
  Worked example A (no PED, no existing cover). Profile = {age=34, location_tier=metro, income_band=10L-25L, dependents=spouse+1 kid, primary_goal=first_buy, health_conditions=["none"], desired_sum_insured_inr=1500000, existing_cover_inr=0}:
166
  retrieve_policies(query="family floater plan metro sum insured 15 lakh adult 30-40 with spouse and one child no PED fresh base policy first-time buyer", top_k=8)
@@ -178,12 +184,16 @@ After all 7 slots are saved AND the user has confirmed the recap (RULE 4 implici
178
  1. How much sum insured? (e.g., ₹5L / ₹10L / ₹25L / ₹1Cr)
179
  2. Premium budget? (e.g., ₹10–15K/year, or ₹50K+ for premium covers)
180
  3. Any existing health cover from work or otherwise? (e.g., '5L through employer' or 'no') [SKIP if existing_cover_inr already captured]
181
- 4. Approximate age of the eldest parent you'd cover? [ASK ONLY IF dependents mentions parents AND parents_age_max not yet captured]"
 
 
182
 
183
  When the user answers, call save_profile_field once per provided value:
184
  save_profile_field(field="desired_sum_insured_inr", value="1000000") # ₹10L
185
  save_profile_field(field="budget_band", value="10K-20K")
186
  save_profile_field(field="existing_cover_inr", value="500000") # 5L corporate top-up; 'no' / 'none' → value="0"
 
 
187
  save_profile_field(field="parents_age_max", value="68") # eldest parent's age, only if covering parents
188
 
189
  Gender hint: if the user mentions gender, keep it for conversational context only — Profile has no `gender` slot. Do NOT call save_profile_field(field="gender", ...) — it returns `field_not_on_profile_dataclass` and wastes a tool-call iteration.
 
160
  health-condition keywords — every captured condition by name ("diabetes", "hypertension", "heart disease") OR the literal "no PED" when health_conditions == ["none"],
161
  primary goal keyword,
162
  existing cover signal — when existing_cover_inr > 0 add "top-up over existing X lakh cover"; when 0 add "fresh base policy",
163
+ parents-cover signal — when dependents mentions parents add "parents age ~XX" using parents_age_max (if captured),
164
+ family-history rider boost — if family_medical_history is non-empty, INCLUDE keywords in the query that bias retrieval toward policies with relevant coverage:
165
+ - "cancer" → "critical illness rider cancer cover"
166
+ - "diabetes" → "diabetes short waiting period reduced PED wait"
167
+ - "heart" → "cardiac care rider heart cover"
168
+ - "hypertension" → "hypertension short waiting period"
169
+ Multiple family conditions → concatenate the relevant phrases.
170
 
171
  Worked example A (no PED, no existing cover). Profile = {age=34, location_tier=metro, income_band=10L-25L, dependents=spouse+1 kid, primary_goal=first_buy, health_conditions=["none"], desired_sum_insured_inr=1500000, existing_cover_inr=0}:
172
  retrieve_policies(query="family floater plan metro sum insured 15 lakh adult 30-40 with spouse and one child no PED fresh base policy first-time buyer", top_k=8)
 
184
  1. How much sum insured? (e.g., ₹5L / ₹10L / ₹25L / ₹1Cr)
185
  2. Premium budget? (e.g., ₹10–15K/year, or ₹50K+ for premium covers)
186
  3. Any existing health cover from work or otherwise? (e.g., '5L through employer' or 'no') [SKIP if existing_cover_inr already captured]
187
+ 4. Co-pay tolerance: Are you OK with a co-pay — sharing 10-30% of every claim to lower the premium? Or do you want zero co-pay (insurer pays it all)?
188
+ 5. Family medical history: Any major conditions running in your blood family (parents/siblings) — cancer / diabetes / heart disease / hypertension?
189
+ 6. Approximate age of the eldest parent you'd cover? [ASK ONLY IF dependents mentions parents AND parents_age_max not yet captured]"
190
 
191
  When the user answers, call save_profile_field once per provided value:
192
  save_profile_field(field="desired_sum_insured_inr", value="1000000") # ₹10L
193
  save_profile_field(field="budget_band", value="10K-20K")
194
  save_profile_field(field="existing_cover_inr", value="500000") # 5L corporate top-up; 'no' / 'none' → value="0"
195
+ save_profile_field(field="copay_pct", value="0" or "10" or "20" or "30") # 0 = no co-pay (higher premium), 10-30 = typical tiers
196
+ save_profile_field(field="family_medical_history", value="cancer, diabetes" or "none") # blood family only (parents/siblings)
197
  save_profile_field(field="parents_age_max", value="68") # eldest parent's age, only if covering parents
198
 
199
  Gender hint: if the user mentions gender, keep it for conversational context only — Profile has no `gender` slot. Do NOT call save_profile_field(field="gender", ...) — it returns `field_not_on_profile_dataclass` and wastes a tool-call iteration.
frontend/src/app/page.tsx CHANGED
@@ -1367,24 +1367,10 @@ export default function Page() {
1367
  )}
1368
  </div>
1369
  </button>
1370
- <button
1371
- onClick={() => { setShowPremium(!showPremium); setShowMarketplace(false); setShowCoverage(false); setShowProfile(false); setShowAdmin(false); }}
1372
- className={`group relative overflow-hidden rounded-xl transition-all shadow-sm hover:shadow-md ${
1373
- showPremium ? "ring-2 ring-[var(--primary)]" : ""
1374
- }`}
1375
- title={t("header.annual_premium")}
1376
- >
1377
- <div className="absolute inset-0 bg-gradient-to-br from-amber-500 via-orange-500 to-rose-500" />
1378
- <div className="relative flex items-stretch text-white">
1379
- <div className="flex items-center justify-center px-3 py-2 bg-black/15">
1380
- <RupeeIcon />
1381
- </div>
1382
- <div className="px-3 py-2 text-left">
1383
- <div className="text-[10px] uppercase tracking-wider opacity-85 leading-none">{t("header.annual_premium_kicker")}</div>
1384
- <div className="text-xs font-bold leading-tight whitespace-nowrap">{t("header.annual_premium")}</div>
1385
- </div>
1386
- </div>
1387
- </button>
1388
  <button
1389
  onClick={() => { setShowProfile(!showProfile); setShowMarketplace(false); setShowPremium(false); setShowCoverage(false); setShowAdmin(false); }}
1390
  className={`group relative overflow-hidden rounded-xl transition-all shadow-sm hover:shadow-md ${
@@ -1417,9 +1403,13 @@ export default function Page() {
1417
  profileCompleteness.completeness_pct >= 50 &&
1418
  premiumBand &&
1419
  premiumBand.sample_size > 0 && (
1420
- <div
1421
- className="group relative overflow-hidden rounded-xl shadow-sm"
1422
- title={`Estimate across ${premiumBand.sample_size} polic${premiumBand.sample_size === 1 ? "y" : "ies"}. Refresh as your profile fills in.`}
 
 
 
 
1423
  >
1424
  <div className="absolute inset-0 bg-gradient-to-br from-amber-500 via-orange-500 to-amber-600" />
1425
  <div className="relative flex items-stretch text-white">
@@ -1434,8 +1424,16 @@ export default function Page() {
1434
  ₹{premiumBand.min_inr.toLocaleString("en-IN")}–₹{premiumBand.max_inr.toLocaleString("en-IN")}/yr
1435
  </div>
1436
  </div>
 
 
 
 
 
 
 
 
1437
  </div>
1438
- </div>
1439
  )}
1440
  {/* Admin access — opens the LLM control panel in an embedded view.
1441
  Backend admin API is password-gated (KI-097); enter the admin
@@ -1779,7 +1777,12 @@ export default function Page() {
1779
  isPersonalized={profileCompleteness?.is_personalized === true}
1780
  />
1781
  )}
1782
- {showPremium && <PremiumCalculatorPanel onClose={() => setShowPremium(false)} />}
 
 
 
 
 
1783
  {showProfile && (
1784
  <ProfileBuilderPanel
1785
  sessionId={sessionId}
@@ -2089,13 +2092,72 @@ function ProfileBuilderPanel({
2089
  );
2090
  }
2091
 
2092
- function PremiumCalculatorPanel({ onClose }: { onClose: () => void }) {
2093
- const [age, setAge] = useState(35);
2094
- const [sumInsured, setSumInsured] = useState(1000000);
2095
- const [cityTier, setCityTier] = useState<"metro" | "tier1" | "tier2">("metro");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2096
  const [smoker, setSmoker] = useState(false);
2097
- const [familySize, setFamilySize] = useState(0);
2098
- const [ped, setPed] = useState<"none" | "diabetes_or_hypertension" | "heart_disease" | "multiple">("none");
 
 
2099
  const [copay, setCopay] = useState(0);
2100
  const [estimate, setEstimate] = useState<PremiumEstimateResponse | null>(null);
2101
  const [busy, setBusy] = useState(false);
 
1367
  )}
1368
  </div>
1369
  </button>
1370
+ {/* KI (2026-05-15) — old "ESTIMATE Annual premium" CTA chip
1371
+ removed. The premium-band chip below is now itself the
1372
+ clickable surface to open the PremiumCalculatorPanel, so
1373
+ two redundant premium UI elements collapsed into one. */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1374
  <button
1375
  onClick={() => { setShowProfile(!showProfile); setShowMarketplace(false); setShowPremium(false); setShowCoverage(false); setShowAdmin(false); }}
1376
  className={`group relative overflow-hidden rounded-xl transition-all shadow-sm hover:shadow-md ${
 
1403
  profileCompleteness.completeness_pct >= 50 &&
1404
  premiumBand &&
1405
  premiumBand.sample_size > 0 && (
1406
+ <button
1407
+ type="button"
1408
+ onClick={() => { setShowPremium(!showPremium); setShowMarketplace(false); setShowCoverage(false); setShowProfile(false); setShowAdmin(false); }}
1409
+ className={`group relative overflow-hidden rounded-xl shadow-sm transition-all hover:shadow-md hover:brightness-110 cursor-pointer ${
1410
+ showPremium ? "ring-2 ring-[var(--primary)]" : ""
1411
+ }`}
1412
+ title={uiLang === "hi" ? "Premium को sliders से refine करने के लिए tap करें" : "Tap to refine premium with sliders"}
1413
  >
1414
  <div className="absolute inset-0 bg-gradient-to-br from-amber-500 via-orange-500 to-amber-600" />
1415
  <div className="relative flex items-stretch text-white">
 
1424
  ₹{premiumBand.min_inr.toLocaleString("en-IN")}–₹{premiumBand.max_inr.toLocaleString("en-IN")}/yr
1425
  </div>
1426
  </div>
1427
+ {/* Subtle "edit" affordance — pencil-on-slider icon hints
1428
+ that tapping the chip opens the slider panel. */}
1429
+ <div className="flex items-center justify-center px-2 py-2 bg-white/15 border-l border-white/20 transition-transform group-hover:translate-x-0.5">
1430
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
1431
+ <path d="M4 21v-4l11-11 4 4-11 11H4z" />
1432
+ <path d="M14 6l4 4" />
1433
+ </svg>
1434
+ </div>
1435
  </div>
1436
+ </button>
1437
  )}
1438
  {/* Admin access — opens the LLM control panel in an embedded view.
1439
  Backend admin API is password-gated (KI-097); enter the admin
 
1777
  isPersonalized={profileCompleteness?.is_personalized === true}
1778
  />
1779
  )}
1780
+ {showPremium && (
1781
+ <PremiumCalculatorPanel
1782
+ onClose={() => setShowPremium(false)}
1783
+ initialProfile={profileCompleteness?.profile}
1784
+ />
1785
+ )}
1786
  {showProfile && (
1787
  <ProfileBuilderPanel
1788
  sessionId={sessionId}
 
2092
  );
2093
  }
2094
 
2095
+ function PremiumCalculatorPanel({
2096
+ onClose,
2097
+ initialProfile,
2098
+ }: {
2099
+ onClose: () => void;
2100
+ initialProfile?: UserProfile;
2101
+ }) {
2102
+ // KI (2026-05-15) — Fix B. The panel previously opened with static
2103
+ // defaults (Age 35 / SI 10L / Self only / None / metro) which felt
2104
+ // disconnected from the user's already-captured profile. We now seed
2105
+ // each slider from initialProfile (forwarded by page.tsx from
2106
+ // profileCompleteness.profile) and fall back to the legacy default
2107
+ // only when a slot is missing. User can still slide to override.
2108
+ //
2109
+ // The UserProfile schema (api.ts) does not yet carry a
2110
+ // `desired_sum_insured_inr` slot, so we fall back to
2111
+ // `existing_cover_inr` as the closest available signal; if that's
2112
+ // also missing we land on the legacy 10L default.
2113
+ const deriveFamilySize = (dep?: string | null): number => {
2114
+ if (!dep) return 0;
2115
+ const d = dep.toLowerCase();
2116
+ if (d === "self" || d === "self only" || d === "self_only") return 0;
2117
+ if (d.includes("parents") && d.includes("spouse")) return 4; // self+spouse+2 parents
2118
+ if (d.includes("parents")) return 2; // self+parents
2119
+ if (d.includes("kids") || d.includes("children")) return 3; // self+spouse+kids -> floater
2120
+ if (d.includes("spouse")) return 1; // self+spouse
2121
+ return 0;
2122
+ };
2123
+ const derivePed = (
2124
+ conds?: string[] | null,
2125
+ ): "none" | "diabetes_or_hypertension" | "heart_disease" | "multiple" => {
2126
+ if (!conds || conds.length === 0) return "none";
2127
+ const lower = conds.map((c) => (c || "").toLowerCase());
2128
+ if (lower.every((c) => !c || c === "none")) return "none";
2129
+ if (lower.length >= 2 && lower.some((c) => c !== "none")) {
2130
+ const distinct = lower.filter((c) => c && c !== "none");
2131
+ if (distinct.length >= 2) return "multiple";
2132
+ }
2133
+ if (lower.some((c) => c.includes("heart"))) return "heart_disease";
2134
+ if (lower.some((c) => c.includes("diabetes") || c.includes("hypertension") || c.includes("bp")))
2135
+ return "diabetes_or_hypertension";
2136
+ return "diabetes_or_hypertension"; // any single non-none condition lands on the closest model bucket
2137
+ };
2138
+ const deriveCityTier = (loc?: string | null): "metro" | "tier1" | "tier2" => {
2139
+ const l = (loc || "metro").toLowerCase();
2140
+ if (l === "metro") return "metro";
2141
+ if (l === "tier1" || l === "tier_1" || l === "tier-1") return "tier1";
2142
+ if (l === "tier2" || l === "tier_2" || l === "tier-2") return "tier2";
2143
+ // tier3 / unknown — fold down to tier2 (closest supported bucket)
2144
+ return "tier2";
2145
+ };
2146
+
2147
+ const [age, setAge] = useState<number>(initialProfile?.age ?? 35);
2148
+ const [sumInsured, setSumInsured] = useState<number>(
2149
+ initialProfile?.existing_cover_inr && initialProfile.existing_cover_inr > 0
2150
+ ? initialProfile.existing_cover_inr
2151
+ : 1000000,
2152
+ );
2153
+ const [cityTier, setCityTier] = useState<"metro" | "tier1" | "tier2">(
2154
+ deriveCityTier(initialProfile?.location_tier),
2155
+ );
2156
  const [smoker, setSmoker] = useState(false);
2157
+ const [familySize, setFamilySize] = useState<number>(deriveFamilySize(initialProfile?.dependents));
2158
+ const [ped, setPed] = useState<"none" | "diabetes_or_hypertension" | "heart_disease" | "multiple">(
2159
+ derivePed(initialProfile?.health_conditions),
2160
+ );
2161
  const [copay, setCopay] = useState(0);
2162
  const [estimate, setEstimate] = useState<PremiumEstimateResponse | null>(null);
2163
  const [busy, setBusy] = useState(false);