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