rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
a7d8209
·
1 Parent(s): ce7f6d0

feat(profile+pricing+voice): KI-275 — F-bundle: smoker + recap-verify + premium scope + price consolidation

Browse files

F2 — Smoker slot end-to-end (KI-275):
- needs_finder.Profile: smoker: Optional[bool] field
- brain_tools._ACCEPTED_FIELDS + SLOT_UNION extended (now 16 slots).
_coerce_smoker accepts yes/true/smoker/tobacco→True, no/non-smoker→False,
empty/unclear→None.
- single_brain SYSTEM_PROMPT RULE 2.5 item 7: ask "Do you smoke or use
tobacco?" Save smoker: save_profile_field(field="smoker", value="yes"/"no").
- premium_calculator bulk_estimate threads smoker_mult (1.4× when True)
through both curated-anchor + flat-base paths. Surfaces
smoker_loading_x + smoker_loading_reason in breakdown when !=1.0.
- Premium delta: 35yo metro 10L SI no-PED → ₹11,500 → ₹14,300 (+24% via
curated 1.35×) / ₹9,000 → ₹12,600 (+40% flat fallback).

F3 — Recap-vs-reality fix:
- single_brain RULE 4 new "RECAP VERIFY" sub-section: forbids recapping
slots not actually saved via save_profile_field. Worked example for the
diabetes/family_medical_history split (don't conflate them).
- main.py post-turn confirmation auto-extract: when user_text matches
yes/correct/that's-right/looks-good regex AND profile_complete=False,
parses the bot's PRIOR turn for `* **Label:** value` patterns, maps
via _SLOT_ALIASES (Name / Age / Dependents / Income / Primary Goal /
Health Conditions etc.) to _REQUIRED_FOR_READY slots, and BACKFILLS
via brain_tools.save_profile_field. Never overrides existing values;
never crashes on parse failure. Logs F3 backfill events.
- Mirrors KI-253 closer + KI-254 auto-mark safety-net pattern.

F4 — Premium pill vs panel label/scope clarity:
- Header chip kicker: "Est. premium" → "Est. range · across plans"
(HI: "अनुमानित premium · सभी plans में").
- Header chip tooltip: clarifies "across all eligible plans for your
profile. Tap to estimate for a specific plan."
- PremiumCalculatorPanel title: "Illustrative premium calculator" →
"Per-plan premium estimate" (HI: "एक-policy premium अनुमान").
- Panel subtitle rewritten to emphasize single-plan / specific-settings
scope.
- NEW italic 11px disambiguation Tip caption under subtitle:
"Tip: header chip = range across plans · panel = for these specific
settings".
- Panel result kicker: "INDICATIVE ANNUAL PREMIUM" → "FOR THESE
SETTINGS · ₹/YEAR".

F5 — Consolidate per-policy pricing source of truth:
- PolicyPremiumWidget (inside compare modal) was calling /api/premium/bulk
which used flat ₹500/lakh heuristic for non-curated policies — giving
₹6,000 for Multiplier vs panel's ₹18,445-24,954 for same profile.
- Switched to /api/premium/estimate (same endpoint the standalone panel
uses) → both surfaces now hit the same estimate() code path with the
same multiplier chain.
- main.py /api/premium/estimate gained tenure_years + deductible_inr
request fields + base_sample_used response field. Post-multiplies
point/low/high by BULK_TENURE_MULT × BULK_DEDUCTIBLE_DISCOUNT.
- api.ts PremiumEstimateRequest + PremiumEstimateResponse extended.
- PolicyPremiumWidget renders point_estimate_inr as headline + low–high
band as bullet. "Estimate" badge driven by base_sample_used === false.
- Now Multiplier @ 10L/metro/29yo/no-PED via the modal widget returns
₹11,500 (range ₹9,775-₹13,224) — same as the panel for that policy.
- Header chip /api/profile/predicted-premium-band continues using
bulk_estimate (aggregate across 26 curated policies) per spec.

Verification:
- python -m py_compile clean on 5 backend files
- npx tsc --noEmit clean
- npx next build succeeds

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

backend/brain_tools.py CHANGED
@@ -50,7 +50,10 @@ Slot → consumer matrix:
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
  """
@@ -90,6 +93,8 @@ _ACCEPTED_FIELDS = {
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
 
@@ -138,6 +143,8 @@ SLOT_UNION: tuple[str, ...] = (
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
@@ -232,6 +239,8 @@ def save_profile_field(session, field: str, value: Any) -> dict:
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":
@@ -744,6 +753,51 @@ def _coerce_bool(value: Any) -> Optional[bool]:
744
  return None
745
 
746
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
747
  def _coerce_desired_sum_insured(value: Any) -> Optional[int]:
748
  """Parse desired sum insured (cover amount) as integer rupees.
749
 
 
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
+ KI-275 (2026-05-15 smoker / tobacco)
54
+ smoker → pricing (smoker_loading 1.0× / 1.40×, +30-50%)
55
+
56
+ Total: 16 slots. `gender` is tolerated by save_profile_field for forward
57
  compat but is NOT on the Profile dataclass today; it does not appear in
58
  SLOT_UNION because no consumer reads it.
59
  """
 
93
  # D2 (2026-05-15) — coupled additions: co-pay tolerance + family medical history
94
  "copay_pct",
95
  "family_medical_history",
96
+ # KI-275 (2026-05-15) — smoker / tobacco use, +30-50% premium loading.
97
+ "smoker",
98
  "gender", # tolerated; not persisted unless Profile gains the field
99
  }
100
 
 
143
  # D2 additions (2026-05-15)
144
  "copay_pct",
145
  "family_medical_history",
146
+ # KI-275 (2026-05-15) — smoker / tobacco use, +30-50% premium loading.
147
+ "smoker",
148
  )
149
 
150
  # Invariant: every SLOT_UNION field must be accepted by save_profile_field
 
239
  normalized = _coerce_copay_pct(value)
240
  elif fld == "family_medical_history":
241
  normalized = _coerce_family_medical_history(value)
242
+ elif fld == "smoker":
243
+ normalized = _coerce_smoker(value)
244
  elif fld == "name":
245
  normalized = (str(value).strip() if value is not None else None) or None
246
  elif fld == "gender":
 
753
  return None
754
 
755
 
756
+ def _coerce_smoker(value: Any) -> Optional[bool]:
757
+ """KI-275 (2026-05-15) — tri-state bool for smoker / tobacco use.
758
+
759
+ Accepts:
760
+ - True / "yes" / "true" / "smoker" / "smokes" / "tobacco" / 1 → True
761
+ - False / "no" / "false" / "non-smoker" / "doesn't smoke" / 0 → False
762
+ - None / "" / unclear → None
763
+
764
+ Returning None lets the KI-091 null-overwrite guard in
765
+ save_profile_field refuse to clobber a previously-captured value.
766
+ """
767
+ if value is None:
768
+ return None
769
+ if isinstance(value, bool):
770
+ return value
771
+ if isinstance(value, (int, float)):
772
+ return bool(value)
773
+ s = str(value).strip().lower()
774
+ if not s:
775
+ return None
776
+ _YES = {
777
+ "yes", "y", "true", "1",
778
+ "smoker", "smokes", "smoke", "i smoke",
779
+ "tobacco", "tobacco user", "uses tobacco",
780
+ "i do", "yep", "yeah", "yup",
781
+ }
782
+ _NO = {
783
+ "no", "n", "false", "0",
784
+ "non-smoker", "nonsmoker", "non smoker",
785
+ "doesn't smoke", "does not smoke", "dont smoke", "don't smoke",
786
+ "i don't", "i do not", "nope", "never", "no tobacco",
787
+ }
788
+ if s in _YES:
789
+ return True
790
+ if s in _NO:
791
+ return False
792
+ # Substring fall-through for prose ("I'm a non-smoker", "I smoke daily").
793
+ if any(tok in s for tok in ("non-smoker", "nonsmoker", "non smoker", "don't smoke",
794
+ "doesn't smoke", "do not smoke", "no tobacco")):
795
+ return False
796
+ if any(tok in s for tok in ("smoker", "smokes", "tobacco")):
797
+ return True
798
+ return None
799
+
800
+
801
  def _coerce_desired_sum_insured(value: Any) -> Optional[int]:
802
  """Parse desired sum insured (cover amount) as integer rupees.
803
 
backend/main.py CHANGED
@@ -915,6 +915,120 @@ async def chat(req: ChatRequest, request: Request):
915
  session_id, type(_rec_err).__name__, _rec_err,
916
  )
917
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
918
  audio_b64 = None
919
  audio_mime: Optional[str] = None
920
  if req.return_audio and turn.reply_text:
@@ -2970,6 +3084,14 @@ class PremiumEstimateRequest(BaseModel):
2970
  )
2971
  # Voluntary co-payment % — reduces premium ~7% per 10pp of co-pay
2972
  copayment_pct: float = Field(0.0, ge=0, le=40)
 
 
 
 
 
 
 
 
2973
 
2974
 
2975
  class PremiumEstimateResponse(BaseModel):
@@ -2984,12 +3106,25 @@ class PremiumEstimateResponse(BaseModel):
2984
  "Illustrative range only — actual premium depends on underwriting + "
2985
  "medical history + risk factors. Confirm with the insurer before purchase."
2986
  )
 
 
 
 
 
 
 
 
 
2987
 
2988
 
2989
  @app.post("/api/premium/estimate", response_model=PremiumEstimateResponse)
2990
  async def premium_estimate(req: PremiumEstimateRequest):
2991
  """Illustrative premium calculator — rules-based estimate from curated public data."""
2992
- from backend.premium_calculator import estimate as _estimate
 
 
 
 
2993
  e = _estimate(
2994
  age=req.age,
2995
  sum_insured_inr=req.sum_insured_inr,
@@ -3000,13 +3135,43 @@ async def premium_estimate(req: PremiumEstimateRequest):
3000
  pre_existing_conditions=req.pre_existing_conditions,
3001
  copayment_pct=req.copayment_pct,
3002
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3003
  return PremiumEstimateResponse(
3004
  policy_id=e.policy_id,
3005
- point_estimate_inr=e.point_estimate_inr,
3006
- low_inr=e.low_inr,
3007
- high_inr=e.high_inr,
3008
  methodology=e.methodology,
3009
  sources=e.sources or [],
 
 
 
3010
  )
3011
 
3012
 
 
915
  session_id, type(_rec_err).__name__, _rec_err,
916
  )
917
 
918
+ # F3 — confirmation auto-extract safety net.
919
+ # Symptom: Gemini emits a recap bullet list ("**Primary Goal:** first
920
+ # family policy ...") from conversation context but skips one or more
921
+ # save_profile_field calls. User says "yes this is correct"; the
922
+ # _profile_complete gate refuses retrieval and the bot embarrassingly
923
+ # re-asks. This block parses the bot's prior recap turn, maps slot
924
+ # labels -> _REQUIRED_FOR_READY field names, and backfills any slot
925
+ # that's STILL missing on the live profile. Best-effort; never blocks.
926
+ # Mirror of KI-253 closer regex + KI-254 auto-mark pattern.
927
+ try:
928
+ _CONFIRM_RE = re.compile(
929
+ r"\b(yes|correct|that'?s right|all correct|looks good)\b",
930
+ re.IGNORECASE,
931
+ )
932
+ _RECAP_BULLET_RE = re.compile(r"^\s*\*\s*\*\*[^:]+:\*\*", re.MULTILINE)
933
+ _RECAP_LINE_RE = re.compile(
934
+ r"^\s*\*\s*\*\*([^:]+):\*\*\s*(.+?)\s*$",
935
+ re.MULTILINE,
936
+ )
937
+ # Slot label aliases -> canonical _REQUIRED_FOR_READY field names.
938
+ # Keys are lowercased + whitespace-collapsed.
939
+ _SLOT_ALIASES = {
940
+ "name": "name",
941
+ "full name": "name",
942
+ "age": "age",
943
+ "dependents": "dependents",
944
+ "family": "dependents",
945
+ "family members": "dependents",
946
+ "location": "location_tier",
947
+ "city": "location_tier",
948
+ "location tier": "location_tier",
949
+ "city tier": "location_tier",
950
+ "income": "income_band",
951
+ "income band": "income_band",
952
+ "annual income": "income_band",
953
+ "primary goal": "primary_goal",
954
+ "goal": "primary_goal",
955
+ "objective": "primary_goal",
956
+ "health conditions": "health_conditions",
957
+ "health": "health_conditions",
958
+ "medical conditions": "health_conditions",
959
+ "pre-existing conditions": "health_conditions",
960
+ "medical history": "health_conditions",
961
+ }
962
+
963
+ if (
964
+ USE_SINGLE_BRAIN
965
+ and turn is not None
966
+ and _CONFIRM_RE.search(req.user_text or "")
967
+ and not getattr(turn, "profile_complete", False)
968
+ ):
969
+ # Locate the most recent bot/assistant message in chat_history.
970
+ _prior_bot_text = ""
971
+ for _msg in reversed(req.chat_history or []):
972
+ if not isinstance(_msg, dict):
973
+ continue
974
+ _role = (_msg.get("role") or "").lower()
975
+ if _role in ("assistant", "bot", "model"):
976
+ _prior_bot_text = _msg.get("content") or ""
977
+ break
978
+
979
+ if _prior_bot_text and _RECAP_BULLET_RE.search(_prior_bot_text):
980
+ from backend.session_state import get_session as _get_session_f3
981
+ from backend import brain_tools as _brain_tools_f3
982
+
983
+ _f3_session = _get_session_f3(session_id)
984
+ _profile_f3 = _f3_session.profile
985
+
986
+ _parsed: dict[str, str] = {}
987
+ for _label, _value in _RECAP_LINE_RE.findall(_prior_bot_text):
988
+ _key = " ".join(_label.strip().lower().split())
989
+ _slot = _SLOT_ALIASES.get(_key)
990
+ if not _slot:
991
+ continue
992
+ _val = (_value or "").strip().rstrip(".")
993
+ if not _val:
994
+ continue
995
+ # Don't override slots that already have values.
996
+ _existing = getattr(_profile_f3, _slot, None)
997
+ if _existing not in (None, "", []):
998
+ continue
999
+ # First mapping wins (in case a label appears twice).
1000
+ _parsed.setdefault(_slot, _val)
1001
+
1002
+ _backfilled: list[str] = []
1003
+ for _slot in _brain_tools_f3._REQUIRED_FOR_READY:
1004
+ if _slot not in _parsed:
1005
+ continue
1006
+ _existing = getattr(_profile_f3, _slot, None)
1007
+ if _existing not in (None, "", []):
1008
+ continue
1009
+ try:
1010
+ _r = _brain_tools_f3.save_profile_field(
1011
+ session=_f3_session,
1012
+ field=_slot,
1013
+ value=_parsed[_slot],
1014
+ )
1015
+ if isinstance(_r, dict) and _r.get("saved"):
1016
+ _backfilled.append(_slot)
1017
+ except Exception: # noqa: BLE001 — best-effort
1018
+ continue
1019
+
1020
+ if _backfilled:
1021
+ logging.info(
1022
+ "F3 confirmation auto-extract (session=%s): backfilled %s",
1023
+ session_id, _backfilled,
1024
+ )
1025
+ except Exception as _f3_err: # noqa: BLE001
1026
+ # Safety-net must never break the reply.
1027
+ logging.warning(
1028
+ "F3 confirmation auto-extract failed (session=%s): %s: %s",
1029
+ session_id, type(_f3_err).__name__, _f3_err,
1030
+ )
1031
+
1032
  audio_b64 = None
1033
  audio_mime: Optional[str] = None
1034
  if req.return_audio and turn.reply_text:
 
3084
  )
3085
  # Voluntary co-payment % — reduces premium ~7% per 10pp of co-pay
3086
  copayment_pct: float = Field(0.0, ge=0, le=40)
3087
+ # B2 widget parity (KI-bugfix, 2026-05-15) — optional slider overrides so
3088
+ # PolicyPremiumWidget (compare modal) can use the same curated-anchored
3089
+ # estimate() pipeline as PremiumCalculatorPanel instead of the divergent
3090
+ # bulk_estimate() flat-base path. Applied as straight multipliers on top
3091
+ # of the estimate() result using premium_calculator.BULK_TENURE_MULT /
3092
+ # BULK_DEDUCTIBLE_DISCOUNT constants — leaves estimate() math untouched.
3093
+ tenure_years: Optional[int] = Field(None, ge=1, le=3)
3094
+ deductible_inr: Optional[int] = Field(None, ge=0, le=200_000)
3095
 
3096
 
3097
  class PremiumEstimateResponse(BaseModel):
 
3106
  "Illustrative range only — actual premium depends on underwriting + "
3107
  "medical history + risk factors. Confirm with the insurer before purchase."
3108
  )
3109
+ # Echo back the effective tenure / deductible so the widget can render a
3110
+ # consistent breakdown line without re-deriving them. Optional for legacy
3111
+ # callers (PremiumCalculatorPanel ignores both).
3112
+ tenure_years: Optional[int] = None
3113
+ deductible_inr: Optional[int] = None
3114
+ # True when the underlying estimate() anchored to a curated quote sample.
3115
+ # PolicyPremiumWidget uses this (instead of bulk_estimate's `assumed` flag)
3116
+ # to decide whether to show its "Estimate" badge.
3117
+ base_sample_used: bool = False
3118
 
3119
 
3120
  @app.post("/api/premium/estimate", response_model=PremiumEstimateResponse)
3121
  async def premium_estimate(req: PremiumEstimateRequest):
3122
  """Illustrative premium calculator — rules-based estimate from curated public data."""
3123
+ from backend.premium_calculator import (
3124
+ estimate as _estimate,
3125
+ BULK_TENURE_MULT,
3126
+ BULK_DEDUCTIBLE_DISCOUNT,
3127
+ )
3128
  e = _estimate(
3129
  age=req.age,
3130
  sum_insured_inr=req.sum_insured_inr,
 
3135
  pre_existing_conditions=req.pre_existing_conditions,
3136
  copayment_pct=req.copayment_pct,
3137
  )
3138
+
3139
+ # Snap incoming tenure / deductible to the nearest supported bucket so the
3140
+ # widget can pass raw slider values without precomputing.
3141
+ point = e.point_estimate_inr
3142
+ low = e.low_inr
3143
+ high = e.high_inr
3144
+ effective_tenure: Optional[int] = None
3145
+ effective_ded: Optional[int] = None
3146
+ if req.tenure_years is not None:
3147
+ effective_tenure = req.tenure_years if req.tenure_years in BULK_TENURE_MULT else 1
3148
+ tenure_mult = BULK_TENURE_MULT.get(effective_tenure, 1.0)
3149
+ point = int(round(point * tenure_mult))
3150
+ low = int(round(low * tenure_mult))
3151
+ high = int(round(high * tenure_mult))
3152
+ if req.deductible_inr is not None:
3153
+ if req.deductible_inr in BULK_DEDUCTIBLE_DISCOUNT:
3154
+ effective_ded = req.deductible_inr
3155
+ else:
3156
+ effective_ded = min(
3157
+ BULK_DEDUCTIBLE_DISCOUNT.keys(),
3158
+ key=lambda d: abs(d - req.deductible_inr),
3159
+ )
3160
+ ded_mult = BULK_DEDUCTIBLE_DISCOUNT.get(effective_ded, 1.0)
3161
+ point = int(round(point * ded_mult))
3162
+ low = int(round(low * ded_mult))
3163
+ high = int(round(high * ded_mult))
3164
+
3165
  return PremiumEstimateResponse(
3166
  policy_id=e.policy_id,
3167
+ point_estimate_inr=point,
3168
+ low_inr=low,
3169
+ high_inr=high,
3170
  methodology=e.methodology,
3171
  sources=e.sources or [],
3172
+ tenure_years=effective_tenure,
3173
+ deductible_inr=effective_ded,
3174
+ base_sample_used=e.base_sample_used is not None,
3175
  )
3176
 
3177
 
backend/needs_finder.py CHANGED
@@ -48,6 +48,7 @@ class Profile:
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
 
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
+ smoker: Optional[bool] = None # KI-275 — tobacco use, +30-50% premium loading
52
  asked: list[str] = field(default_factory=list) # question IDs / field names already asked
53
  free_form_session: bool = False # True = user asks free questions, not driven by us
54
  # KI-063 (2026-05-15) — per-user policy interaction log so the bot
backend/premium_calculator.py CHANGED
@@ -32,6 +32,7 @@ location / family_size that B2 already handles):
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
@@ -594,6 +595,10 @@ def bulk_estimate(
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).
@@ -615,6 +620,10 @@ def bulk_estimate(
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:
@@ -644,7 +653,7 @@ def bulk_estimate(
644
  age=age,
645
  sum_insured_inr=sum_insured_inr,
646
  city_tier="metro" if loc_label == "metro" else ("tier1" if "1" in loc_label else "tier2"),
647
- smoker=bool(profile.get("smoker", False)),
648
  family_size=max(0, family_size - 1),
649
  policy_id=pid,
650
  pre_existing_conditions=profile.get("pre_existing_conditions") or "none",
@@ -701,6 +710,7 @@ def bulk_estimate(
701
  * parents_mult
702
  * copay_mult
703
  * fam_mult
 
704
  * tenure_mult
705
  * ded_mult
706
  )
@@ -739,6 +749,9 @@ def bulk_estimate(
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
 
 
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
+ smoker (KI-275) → smoker_loading 1.0× / 1.40× (+30-50% premium load)
36
 
37
  Slots that are profile-only (no pricing effect): name, primary_goal,
38
  income_band, budget_band (matched against output, not folded into the
 
595
  # D2 — copay_pct + family_medical_history (same read pattern).
596
  copay_pct = profile.get("copay_pct")
597
  family_medical_history = profile.get("family_medical_history")
598
+ # KI-275 — smoker / tobacco use (+30-50% loading). Same read pattern as
599
+ # the D2 fields above; mirrors how the panel slider already passes
600
+ # `smoker` straight through to estimate() on the curated path.
601
+ smoker = bool(profile.get("smoker") or False)
602
  # desired_sum_insured_inr — when present, becomes the default SI for
603
  # any policy without an explicit overrides entry (per-policy override
604
  # still wins, since this is the DEFAULT).
 
620
  # callers see no change.
621
  copay_mult, copay_label = _copay_discount(copay_pct)
622
  fam_mult, fam_label = _family_history_loading(family_medical_history)
623
+ # KI-275 — smoker loading. 1.40× (+40%) standard tobacco loading.
624
+ # 1.0× when smoker is False / None so legacy callers see no change.
625
+ smoker_mult = 1.4 if smoker else 1.0
626
+ smoker_label = "smoker_loading" if smoker else "non_smoker"
627
 
628
  out: dict[str, BulkPolicyPremium] = {}
629
  for pid in policy_ids:
 
653
  age=age,
654
  sum_insured_inr=sum_insured_inr,
655
  city_tier="metro" if loc_label == "metro" else ("tier1" if "1" in loc_label else "tier2"),
656
+ smoker=smoker,
657
  family_size=max(0, family_size - 1),
658
  policy_id=pid,
659
  pre_existing_conditions=profile.get("pre_existing_conditions") or "none",
 
710
  * parents_mult
711
  * copay_mult
712
  * fam_mult
713
+ * smoker_mult
714
  * tenure_mult
715
  * ded_mult
716
  )
 
749
  if fam_mult != 1.0:
750
  breakdown["family_history_loading_x"] = round(fam_mult, 3)
751
  breakdown["family_history_loading_reason"] = fam_label
752
+ if smoker_mult != 1.0:
753
+ breakdown["smoker_loading_x"] = round(smoker_mult, 3)
754
+ breakdown["smoker_loading_reason"] = smoker_label
755
  if desired_si and not ov.get("sum_insured_inr"):
756
  breakdown["desired_si_default_inr"] = int(desired_si)
757
 
backend/single_brain.py CHANGED
@@ -186,7 +186,10 @@ After all 7 slots are saved AND the user has confirmed the recap (RULE 4 implici
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
@@ -195,6 +198,7 @@ When the user answers, call save_profile_field once per provided value:
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.
200
 
@@ -233,6 +237,30 @@ fields. Your flow on that turn:
233
  Explicit confirmation is only required when the user's reply is a literal
234
  "yes/no/that's right" with no new data. Bypass the WAIT in any other case.
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  ═══════════════════════════════════
237
  RULE 5 — Comparison view ("compare #1 and #3")
238
  ═══════════════════════════════════
 
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
+ 7. Smoking status: Do you smoke or use tobacco products? (yes / no)
191
+ Save: save_profile_field(field='smoker', value='yes' or 'no')
192
+ Smokers face 30-50% premium loading; capturing this gives an accurate band."
193
 
194
  When the user answers, call save_profile_field once per provided value:
195
  save_profile_field(field="desired_sum_insured_inr", value="1000000") # ₹10L
 
198
  save_profile_field(field="copay_pct", value="0" or "10" or "20" or "30") # 0 = no co-pay (higher premium), 10-30 = typical tiers
199
  save_profile_field(field="family_medical_history", value="cancer, diabetes" or "none") # blood family only (parents/siblings)
200
  save_profile_field(field="parents_age_max", value="68") # eldest parent's age, only if covering parents
201
+ save_profile_field(field="smoker", value="yes" or "no") # KI-275 — tobacco use, +30-50% premium loading
202
 
203
  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.
204
 
 
237
  Explicit confirmation is only required when the user's reply is a literal
238
  "yes/no/that's right" with no new data. Bypass the WAIT in any other case.
239
 
240
+ ═══════════════════════════════════════════════════════════
241
+ RECAP VERIFY — DO NOT RECAP SLOTS YOU HAVEN'T SAVED
242
+ ═══════════════════════════════════════════════════════════
243
+ Before you emit a "Here's a quick recap of your profile:" summary, you MUST
244
+ have called save_profile_field for EVERY slot you're about to list. The
245
+ profile_complete=True return value from save_profile_field is your only
246
+ proof a slot is captured. Do NOT recap a slot you only inferred from
247
+ conversation context — if you "remember" the user mentioning something but
248
+ didn't call save_profile_field on it, either call save_profile_field NOW
249
+ or do NOT include it in the recap.
250
+
251
+ The most common failure: user says "I want a first-time family policy" and
252
+ you mention it in the recap but never actually called
253
+ save_profile_field(field="primary_goal", value="first_buy"). When the user
254
+ then says "yes this is correct", the profile_complete gate refuses retrieval
255
+ and you have to embarrassingly ask again.
256
+
257
+ Worked example. User says: "I have mild diabetes and a family history of diabetes."
258
+ -> You MUST call BOTH:
259
+ save_profile_field(field="health_conditions", value="diabetes")
260
+ save_profile_field(field="family_medical_history", value="diabetes")
261
+ -> Do NOT conflate them into a single save_profile_field with
262
+ "diabetes, family history of diabetes" — they are SEPARATE slots.
263
+
264
  ═══════════════════════════════════
265
  RULE 5 — Comparison view ("compare #1 and #3")
266
  ═══════════════════════════════════
frontend/src/app/page.tsx CHANGED
@@ -1429,7 +1429,7 @@ export default function Page() {
1429
  className={`group relative overflow-hidden rounded-xl shadow-sm transition-all hover:shadow-md hover:brightness-110 cursor-pointer ${
1430
  showPremium ? "ring-2 ring-[var(--primary)]" : ""
1431
  }`}
1432
- title={uiLang === "hi" ? "Premium sliders refineने के लिए tap करें" : "Tap to estimate / refine premium with sliders"}
1433
  >
1434
  <div className="absolute inset-0 bg-gradient-to-br from-amber-500 via-orange-500 to-amber-600" />
1435
  <div className="relative flex items-stretch text-white">
@@ -1438,7 +1438,7 @@ export default function Page() {
1438
  </div>
1439
  <div className="px-3 py-2 text-left">
1440
  <div className="text-[10px] uppercase tracking-wider opacity-85 leading-none">
1441
- {uiLang === "hi" ? "अनुमानित premium" : "Est. premium"}
1442
  </div>
1443
  <div className="text-xs font-bold leading-tight whitespace-nowrap">
1444
  {hasMeaningfulBand
@@ -1802,6 +1802,7 @@ export default function Page() {
1802
  <PremiumCalculatorPanel
1803
  onClose={() => setShowPremium(false)}
1804
  initialProfile={profileCompleteness?.profile}
 
1805
  />
1806
  )}
1807
  {showProfile && (
@@ -2116,10 +2117,13 @@ function ProfileBuilderPanel({
2116
  function PremiumCalculatorPanel({
2117
  onClose,
2118
  initialProfile,
 
2119
  }: {
2120
  onClose: () => void;
2121
  initialProfile?: UserProfile;
 
2122
  }) {
 
2123
  // KI (2026-05-15) — Fix B. The panel previously opened with static
2124
  // defaults (Age 35 / SI 10L / Self only / None / metro) which felt
2125
  // disconnected from the user's already-captured profile. We now seed
@@ -2246,9 +2250,16 @@ function PremiumCalculatorPanel({
2246
  <div className="max-w-6xl mx-auto px-4 sm:px-6 py-5">
2247
  <div className="flex items-baseline justify-between mb-3">
2248
  <div>
2249
- <h2 className="text-sm font-semibold">Illustrative premium calculator</h2>
2250
  <p className="text-xs text-[var(--muted-foreground)]">
2251
- Indicative annual premium range from public quote data. Not a binding quote — actual depends on underwriting.
 
 
 
 
 
 
 
2252
  </p>
2253
  </div>
2254
  <button onClick={onClose} className="text-xs text-[var(--muted-foreground)] hover:underline">close</button>
@@ -2352,7 +2363,7 @@ function PremiumCalculatorPanel({
2352
  {busy && <div className="text-xs text-[var(--muted-foreground)]">Estimating…</div>}
2353
  {!busy && estimate && (
2354
  <>
2355
- <div className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)] font-semibold">Indicative annual premium</div>
2356
  <div className="text-3xl font-bold mt-1">
2357
  {fmtINR(estimate.low_inr)} <span className="text-[var(--muted-foreground)] text-base font-normal">–</span> {fmtINR(estimate.high_inr)}
2358
  </div>
 
1429
  className={`group relative overflow-hidden rounded-xl shadow-sm transition-all hover:shadow-md hover:brightness-110 cursor-pointer ${
1430
  showPremium ? "ring-2 ring-[var(--primary)]" : ""
1431
  }`}
1432
+ title={uiLang === "hi" ? "आपकी profile सभी eligible plans का अनुमानित premium range. कि एक plan े लिए estimate देखने के लिए tap करें." : "Estimated premium range across all eligible plans for your profile. Tap to estimate for a specific plan."}
1433
  >
1434
  <div className="absolute inset-0 bg-gradient-to-br from-amber-500 via-orange-500 to-amber-600" />
1435
  <div className="relative flex items-stretch text-white">
 
1438
  </div>
1439
  <div className="px-3 py-2 text-left">
1440
  <div className="text-[10px] uppercase tracking-wider opacity-85 leading-none">
1441
+ {uiLang === "hi" ? "अनुमानित premium · सभी plans में" : "Est. range · across plans"}
1442
  </div>
1443
  <div className="text-xs font-bold leading-tight whitespace-nowrap">
1444
  {hasMeaningfulBand
 
1802
  <PremiumCalculatorPanel
1803
  onClose={() => setShowPremium(false)}
1804
  initialProfile={profileCompleteness?.profile}
1805
+ uiLang={uiLang}
1806
  />
1807
  )}
1808
  {showProfile && (
 
2117
  function PremiumCalculatorPanel({
2118
  onClose,
2119
  initialProfile,
2120
+ uiLang = "en",
2121
  }: {
2122
  onClose: () => void;
2123
  initialProfile?: UserProfile;
2124
+ uiLang?: UILang;
2125
  }) {
2126
+ const isHi = uiLang === "hi";
2127
  // KI (2026-05-15) — Fix B. The panel previously opened with static
2128
  // defaults (Age 35 / SI 10L / Self only / None / metro) which felt
2129
  // disconnected from the user's already-captured profile. We now seed
 
2250
  <div className="max-w-6xl mx-auto px-4 sm:px-6 py-5">
2251
  <div className="flex items-baseline justify-between mb-3">
2252
  <div>
2253
+ <h2 className="text-sm font-semibold">{isHi ? "एक-policy premium अनुमान" : "Per-plan premium estimate"}</h2>
2254
  <p className="text-xs text-[var(--muted-foreground)]">
2255
+ {isHi
2256
+ ? "इन specific settings के साथ एक policy का अनुमानित premium देखने के लिए sliders adjust करें."
2257
+ : "Adjust the sliders to see the estimated premium for a single plan with these specific settings."}
2258
+ </p>
2259
+ <p className="text-[11px] text-[var(--muted-foreground)] mt-1 italic">
2260
+ {isHi
2261
+ ? "Tip: header chip = सभी plans का range · panel = इन specific settings के लिए"
2262
+ : "Tip: header chip = range across plans · panel = for these specific settings"}
2263
  </p>
2264
  </div>
2265
  <button onClick={onClose} className="text-xs text-[var(--muted-foreground)] hover:underline">close</button>
 
2363
  {busy && <div className="text-xs text-[var(--muted-foreground)]">Estimating…</div>}
2364
  {!busy && estimate && (
2365
  <>
2366
+ <div className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)] font-semibold">{isHi ? "इन settings के लिए · ₹/वर्ष" : "For these settings · ₹/year"}</div>
2367
  <div className="text-3xl font-bold mt-1">
2368
  {fmtINR(estimate.low_inr)} <span className="text-[var(--muted-foreground)] text-base font-normal">–</span> {fmtINR(estimate.high_inr)}
2369
  </div>
frontend/src/components/PolicyPremiumWidget.tsx CHANGED
@@ -4,24 +4,35 @@
4
  * PolicyPremiumWidget — per-policy slider-driven premium calculator.
5
  *
6
  * Embedded inside PolicyCompareModal (B1). Fetches an initial estimate from
7
- * /api/premium/bulk using the user's profile defaults, then re-fetches
8
  * (debounced 300ms) whenever the user moves the SI / tenure / deductible
9
- * sliders. When the backend marks the row `assumed: true` (no curated
10
- * actuarial data for this policy) the widget shows an "Estimate" badge so
11
  * the user understands the number is heuristic, not a quote.
 
 
 
 
 
 
 
12
  */
13
 
14
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
15
 
16
  import {
17
- postPremiumBulk,
18
  type PremiumBulkProfile,
19
- type PremiumBulkRow,
 
20
  } from "@/lib/api";
21
 
22
  export type PolicyPremiumWidgetProps = {
23
  policyId: string;
24
  policyName: string;
 
 
 
25
  profile?: PremiumBulkProfile;
26
  initialSumInsured?: number;
27
  initialTenureYears?: 1 | 2 | 3;
@@ -64,33 +75,35 @@ function summariseProfile(profile?: PremiumBulkProfile): string | null {
64
  return parts.length ? parts.join(", ") : null;
65
  }
66
 
67
- const BREAKDOWN_LABELS: Record<string, string> = {
68
- base_inr: "Base",
69
- age_loading_x: "Age loading",
70
- location_loading_x: "Location loading",
71
- family_loading_x: "Family loading",
72
- tenure_discount_x: "Tenure discount",
73
- deductible_discount_x: "Deductible discount",
74
- };
 
 
 
 
 
 
 
75
 
76
- function renderBreakdownBullets(breakdown: Record<string, number | string>): string[] {
77
- const bullets: string[] = [];
78
- const base = breakdown.base_inr;
79
- if (typeof base === "number") bullets.push(`Base: ₹${formatInr(base)}`);
80
- for (const key of [
81
- "age_loading_x",
82
- "location_loading_x",
83
- "family_loading_x",
84
- "tenure_discount_x",
85
- "deductible_discount_x",
86
- ]) {
87
- const v = breakdown[key];
88
- if (typeof v === "number" && Math.abs(v - 1.0) > 0.001) {
89
- const label = BREAKDOWN_LABELS[key];
90
- bullets.push(`${label}: ${v.toFixed(2)}×`);
91
- }
92
- }
93
- return bullets;
94
  }
95
 
96
  export default function PolicyPremiumWidget({
@@ -103,9 +116,11 @@ export default function PolicyPremiumWidget({
103
  onCalculated,
104
  }: PolicyPremiumWidgetProps) {
105
  const [sumInsured, setSumInsured] = useState<number>(initialSumInsured);
106
- const [tenureYears, setTenureYears] = useState<number>(initialTenureYears);
107
- const [deductibleInr, setDeductibleInr] = useState<number>(initialDeductibleInr);
108
- const [row, setRow] = useState<PremiumBulkRow | null>(null);
 
 
109
  const [loading, setLoading] = useState<boolean>(true);
110
  const [error, setError] = useState<string | null>(null);
111
 
@@ -123,25 +138,26 @@ export default function PolicyPremiumWidget({
123
  setLoading(true);
124
  setError(null);
125
  try {
126
- const resp = await postPremiumBulk({
127
- policy_ids: [policyId],
128
- profile: profile ?? {},
129
- overrides: {
130
- [policyId]: {
131
- sum_insured_inr: sumInsured,
132
- tenure_years: tenureYears,
133
- deductible_inr: deductibleInr,
134
- },
135
- },
 
 
 
 
 
 
136
  });
137
  if (signal.aborted) return;
138
- const r = resp.per_policy[policyId];
139
- if (!r) {
140
- setError("No estimate returned for this policy.");
141
- return;
142
- }
143
- setRow(r);
144
- onCalculatedRef.current?.(r.premium_inr_annual);
145
  } catch (e) {
146
  if (signal.aborted) return;
147
  setError(e instanceof Error ? e.message : String(e));
@@ -165,13 +181,34 @@ export default function PolicyPremiumWidget({
165
  }, [fetchPremium]);
166
 
167
  const profileSummary = summariseProfile(profile);
168
- const bullets = row ? renderBreakdownBullets(row.breakdown) : [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  return (
171
  <div className="policy-premium-widget" style={widgetStyle}>
172
  <header style={headerStyle}>
173
  <div style={{ fontWeight: 600, fontSize: 14 }}>{policyName}</div>
174
- {row?.assumed && (
175
  <span style={badgeStyle} title="Heuristic — no exact actuarial data for this policy.">
176
  Estimate
177
  </span>
@@ -252,13 +289,13 @@ export default function PolicyPremiumWidget({
252
  <div style={resultBoxStyle} aria-live="polite">
253
  {error ? (
254
  <div style={{ color: "#b00020" }}>Failed: {error}</div>
255
- ) : loading && !row ? (
256
  <div style={{ color: "#666" }}>Calculating estimate…</div>
257
- ) : row ? (
258
  <>
259
  <div style={resultHeadlineStyle}>
260
  Estimated premium:&nbsp;
261
- <strong>₹{formatInr(row.premium_inr_annual)}</strong>
262
  <span style={resultSuffixStyle}>/year</span>
263
  {loading && <span style={spinnerHintStyle}> updating…</span>}
264
  </div>
@@ -269,8 +306,8 @@ export default function PolicyPremiumWidget({
269
  ))}
270
  </ul>
271
  )}
272
- {row.notes && row.notes.length > 0 && (
273
- <div style={noteStyle}>{row.notes.join(" ")}</div>
274
  )}
275
  </>
276
  ) : null}
 
4
  * PolicyPremiumWidget — per-policy slider-driven premium calculator.
5
  *
6
  * Embedded inside PolicyCompareModal (B1). Fetches an initial estimate from
7
+ * /api/premium/estimate using the user's profile defaults, then re-fetches
8
  * (debounced 300ms) whenever the user moves the SI / tenure / deductible
9
+ * sliders. When the backend reports `base_sample_used: false` (no curated
10
+ * actuarial sample for this policy) the widget shows an "Estimate" badge so
11
  * the user understands the number is heuristic, not a quote.
12
+ *
13
+ * KI-bugfix (2026-05-15): switched from /api/premium/bulk → /api/premium/estimate
14
+ * so per-policy pricing shares the curated-anchored math used by the standalone
15
+ * PremiumCalculatorPanel. The bulk endpoint's flat ₹500/lakh fallback was
16
+ * producing wildly low numbers (~₹6K) versus the panel's curated number
17
+ * (~₹18-25K) for the same profile. Tenure + deductible are still honoured —
18
+ * the estimate endpoint now applies the bulk multipliers post-anchor.
19
  */
20
 
21
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
 
23
  import {
24
+ postPremiumEstimate,
25
  type PremiumBulkProfile,
26
+ type PreExistingCondition,
27
+ type PremiumEstimateResponse,
28
  } from "@/lib/api";
29
 
30
  export type PolicyPremiumWidgetProps = {
31
  policyId: string;
32
  policyName: string;
33
+ // Kept the bulk-profile shape because PolicyCompareModal still hands us this
34
+ // exact object — it doubles as the predicted-premium-band profile. We map
35
+ // its fields onto the estimate-endpoint contract internally.
36
  profile?: PremiumBulkProfile;
37
  initialSumInsured?: number;
38
  initialTenureYears?: 1 | 2 | 3;
 
75
  return parts.length ? parts.join(", ") : null;
76
  }
77
 
78
+ /**
79
+ * Map the free-text `location_tier` we ship in PremiumBulkProfile onto the
80
+ * strict {metro|tier1|tier2} enum the /api/premium/estimate endpoint expects.
81
+ * Same normalization rule used inside premium_calculator.bulk_estimate.
82
+ */
83
+ function normaliseCityTier(tier: string | null | undefined): "metro" | "tier1" | "tier2" {
84
+ if (!tier) return "metro";
85
+ const t = String(tier).toLowerCase().replace(/[-_\s]/g, "");
86
+ if (t === "metro") return "metro";
87
+ if (t.includes("1")) return "tier1";
88
+ if (t.includes("2")) return "tier2";
89
+ // tier3 / unknown — fall back to tier2 (closest cheaper bucket the estimate
90
+ // endpoint accepts; matches the standalone panel's default).
91
+ return "tier2";
92
+ }
93
 
94
+ /**
95
+ * Coerce the typed `pre_existing_conditions` profile slot onto the
96
+ * estimate-endpoint's PreExistingCondition union, defaulting to "none".
97
+ */
98
+ function normalisePed(ped: string | null | undefined): PreExistingCondition {
99
+ if (!ped) return "none";
100
+ const allowed: PreExistingCondition[] = [
101
+ "none",
102
+ "diabetes_or_hypertension",
103
+ "heart_disease",
104
+ "multiple",
105
+ ];
106
+ return (allowed as string[]).includes(ped) ? (ped as PreExistingCondition) : "none";
 
 
 
 
 
107
  }
108
 
109
  export default function PolicyPremiumWidget({
 
116
  onCalculated,
117
  }: PolicyPremiumWidgetProps) {
118
  const [sumInsured, setSumInsured] = useState<number>(initialSumInsured);
119
+ const [tenureYears, setTenureYears] = useState<1 | 2 | 3>(initialTenureYears);
120
+ const [deductibleInr, setDeductibleInr] = useState<0 | 25000 | 50000 | 100000>(
121
+ initialDeductibleInr,
122
+ );
123
+ const [resp, setResp] = useState<PremiumEstimateResponse | null>(null);
124
  const [loading, setLoading] = useState<boolean>(true);
125
  const [error, setError] = useState<string | null>(null);
126
 
 
138
  setLoading(true);
139
  setError(null);
140
  try {
141
+ // Defaults match the standalone PremiumCalculatorPanel so the two
142
+ // surfaces converge on the same number for the same profile.
143
+ const age = typeof profile?.age === "number" ? profile.age : 35;
144
+ const familySize =
145
+ typeof profile?.family_size === "number" ? profile.family_size : 1;
146
+ const r = await postPremiumEstimate({
147
+ age,
148
+ sum_insured_inr: sumInsured,
149
+ city_tier: normaliseCityTier(profile?.location_tier),
150
+ smoker: profile?.smoker === true,
151
+ family_size: familySize,
152
+ policy_id: policyId,
153
+ pre_existing_conditions: normalisePed(profile?.pre_existing_conditions),
154
+ copayment_pct: 0,
155
+ tenure_years: tenureYears,
156
+ deductible_inr: deductibleInr,
157
  });
158
  if (signal.aborted) return;
159
+ setResp(r);
160
+ onCalculatedRef.current?.(r.point_estimate_inr);
 
 
 
 
 
161
  } catch (e) {
162
  if (signal.aborted) return;
163
  setError(e instanceof Error ? e.message : String(e));
 
181
  }, [fetchPremium]);
182
 
183
  const profileSummary = summariseProfile(profile);
184
+ // `base_sample_used: false` means the curated illustrative_premiums.json
185
+ // had no anchor sample for this policy → estimate() fell back to the generic
186
+ // FALLBACK_BASE_INR path. Same semantics as the old bulk row.assumed flag.
187
+ const isHeuristic = resp ? resp.base_sample_used === false : false;
188
+
189
+ // Compact, profile-only breakdown — the estimate endpoint doesn't expose
190
+ // a multiplicative chain so we surface the active overrides + methodology
191
+ // instead. This keeps the widget transparent without faking precision.
192
+ const bullets: string[] = [];
193
+ if (resp) {
194
+ bullets.push(
195
+ `Range: ₹${formatInr(resp.low_inr)} – ₹${formatInr(resp.high_inr)}/year (±15% band)`,
196
+ );
197
+ if (resp.tenure_years && resp.tenure_years !== 1) {
198
+ bullets.push(`Multi-year discount applied (${resp.tenure_years}-year policy)`);
199
+ }
200
+ if (resp.deductible_inr && resp.deductible_inr > 0) {
201
+ bullets.push(
202
+ `Voluntary deductible discount applied (${formatDeductibleLabel(resp.deductible_inr)})`,
203
+ );
204
+ }
205
+ }
206
 
207
  return (
208
  <div className="policy-premium-widget" style={widgetStyle}>
209
  <header style={headerStyle}>
210
  <div style={{ fontWeight: 600, fontSize: 14 }}>{policyName}</div>
211
+ {isHeuristic && (
212
  <span style={badgeStyle} title="Heuristic — no exact actuarial data for this policy.">
213
  Estimate
214
  </span>
 
289
  <div style={resultBoxStyle} aria-live="polite">
290
  {error ? (
291
  <div style={{ color: "#b00020" }}>Failed: {error}</div>
292
+ ) : loading && !resp ? (
293
  <div style={{ color: "#666" }}>Calculating estimate…</div>
294
+ ) : resp ? (
295
  <>
296
  <div style={resultHeadlineStyle}>
297
  Estimated premium:&nbsp;
298
+ <strong>₹{formatInr(resp.point_estimate_inr)}</strong>
299
  <span style={resultSuffixStyle}>/year</span>
300
  {loading && <span style={spinnerHintStyle}> updating…</span>}
301
  </div>
 
306
  ))}
307
  </ul>
308
  )}
309
+ {resp.methodology && (
310
+ <div style={noteStyle}>{resp.methodology}</div>
311
  )}
312
  </>
313
  ) : null}
frontend/src/lib/api.ts CHANGED
@@ -330,6 +330,12 @@ export type PremiumEstimateRequest = {
330
  policy_id?: string | null;
331
  pre_existing_conditions?: PreExistingCondition;
332
  copayment_pct?: number;
 
 
 
 
 
 
333
  };
334
 
335
  export type PremiumEstimateResponse = {
@@ -341,6 +347,13 @@ export type PremiumEstimateResponse = {
341
  sources: string[];
342
  is_illustrative: boolean;
343
  disclaimer: string;
 
 
 
 
 
 
 
344
  };
345
 
346
  export type ComparePolicyEntry = {
 
330
  policy_id?: string | null;
331
  pre_existing_conditions?: PreExistingCondition;
332
  copayment_pct?: number;
333
+ // B2 widget parity (KI-bugfix, 2026-05-15) — optional slider overrides so the
334
+ // PolicyPremiumWidget (compare modal) can share the curated-anchored
335
+ // estimate() pipeline with PremiumCalculatorPanel. Server snaps tenure to
336
+ // {1,2,3} and deductible to {0, 25k, 50k, 100k}.
337
+ tenure_years?: 1 | 2 | 3;
338
+ deductible_inr?: 0 | 25000 | 50000 | 100000;
339
  };
340
 
341
  export type PremiumEstimateResponse = {
 
347
  sources: string[];
348
  is_illustrative: boolean;
349
  disclaimer: string;
350
+ // Echoed back when caller passed tenure / deductible overrides.
351
+ tenure_years?: number | null;
352
+ deductible_inr?: number | null;
353
+ // True when the backend anchored the base to a curated quote sample (i.e.
354
+ // the policy is in illustrative_premiums.json). Drives the widget's
355
+ // "Estimate" badge — replaces bulk_estimate's `assumed` flag.
356
+ base_sample_used?: boolean;
357
  };
358
 
359
  export type ComparePolicyEntry = {