Spaces:
Sleeping
feat(profile+pricing+voice): KI-275 — F-bundle: smoker + recap-verify + premium scope + price consolidation
Browse filesF2 — 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 +55 -1
- backend/main.py +169 -4
- backend/needs_finder.py +1 -0
- backend/premium_calculator.py +14 -1
- backend/single_brain.py +29 -1
- frontend/src/app/page.tsx +16 -5
- frontend/src/components/PolicyPremiumWidget.tsx +95 -58
- frontend/src/lib/api.ts +13 -0
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 3006 |
-
low_inr=
|
| 3007 |
-
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 |
|
|
@@ -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
|
|
@@ -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=
|
| 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 |
|
|
@@ -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 |
═══════════════════════════════════
|
|
@@ -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" ? "
|
| 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.
|
| 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">
|
| 2250 |
<p className="text-xs text-[var(--muted-foreground)]">
|
| 2251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
| 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>
|
|
@@ -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/
|
| 8 |
* (debounced 300ms) whenever the user moves the SI / tenure / deductible
|
| 9 |
-
* sliders. When the backend
|
| 10 |
-
* actuarial
|
| 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 |
-
|
| 18 |
type PremiumBulkProfile,
|
| 19 |
-
type
|
|
|
|
| 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 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
"
|
| 84 |
-
"
|
| 85 |
-
"
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 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<
|
| 107 |
-
const [deductibleInr, setDeductibleInr] = useState<
|
| 108 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
});
|
| 137 |
if (signal.aborted) return;
|
| 138 |
-
|
| 139 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 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 && !
|
| 256 |
<div style={{ color: "#666" }}>Calculating estimate…</div>
|
| 257 |
-
) :
|
| 258 |
<>
|
| 259 |
<div style={resultHeadlineStyle}>
|
| 260 |
Estimated premium:
|
| 261 |
-
<strong>₹{formatInr(
|
| 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 |
-
{
|
| 273 |
-
<div style={noteStyle}>{
|
| 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:
|
| 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}
|
|
@@ -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 = {
|