Spaces:
Sleeping
feat(profile+pricing): KI-259..KI-266 — B-bundle (compare modal + per-policy + scorecard + premium band + budget/SI + union + persistence + recall)
Browse filesB1 — Structured recommendation cards + Compare modal
- NEW frontend/src/components/PolicyCompareModal.tsx: side-by-side compare
for up to 3 cited policies. Each column = insurer logo + policy name +
premium-calc slot + scorecard slot + policy details expandable + PDF.
- page.tsx Message renderer now uses CitedPolicyCards (replaces flat
PolicyChipsFromCitations). "Compare all" pill opens the modal when >=2
cited policies present. Removed dead PolicyChipsFromCitations (-67 LOC).
B2 — Per-policy premium widget with sliders + profile defaults
- NEW frontend/src/components/PolicyPremiumWidget.tsx: SI/tenure/deductible
sliders, 300ms debounced fetch, AbortController cleanup, profile-summary
line, "Estimate" badge when assumed=true, breakdown bullets.
- backend/premium_calculator.py + main.py /api/premium/bulk:
multiplicative model (base x SI x age-band x location x family x tenure
x deductible). Anchors to illustrative_premiums.json when available
(assumed=false); flat heuristic otherwise (assumed=true).
B3 — Profile-aware scorecard widget
- NEW frontend/src/components/PolicyScorecardWidget.tsx: grade tile +
score + 6 sub-score bars + Why-this-score bullets + limited-data warning.
- backend/main.py /api/scorecard/bulk: takes (policy_ids, profile) ->
per-policy {grade, score, sub_scores, profile_rationale, completeness}.
- backend/scorecard.py: _profile_tuned_weights audited + fixed two bugs
(family dependents now BOOST cost predictability; senior+family keeps
renewal-weight boost instead of being silently cancelled).
B4 — Aggregate predicted premium band chip (header next to profile %)
- backend/premium_calculator.py: estimate_premium_band fans out to
26 curated policies, returns {min, median, max, sample_size, assumed}
rounded to nearest Rs.500.
- backend/main.py GET /api/profile/predicted-premium-band?session_id=...
- page.tsx: amber/orange chip after the violet profile-completeness pill,
500ms debounced on completeness change, gated on completeness_pct >= 50.
B5 — RULE 2.5 capture for desired_sum_insured + budget
- single_brain.py SYSTEM_PROMPT new RULE 2.5: after recap confirmation,
ask for sum insured + premium budget. SOFT capture (does not block
recommendation if user skips).
- brain_tools.py _ACCEPTED_FIELDS adds desired_sum_insured_inr.
- brain_tools.py _coerce_desired_sum_insured parses "10L"/"1 crore"/
"Rs.500000" via _parse_inr_amount, clamps [50000, 5e8].
- needs_finder.py Profile dataclass adds desired_sum_insured_inr field.
B6 — Unified SLOT_UNION drives both profile match AND pricing
- brain_tools.py SLOT_UNION constant (13 fields: 7 required + 3 pricing
+ 3 family). union_snapshot(profile) helper. Import-time assert that
SLOT_UNION ⊆ _ACCEPTED_FIELDS. Added parents_to_insure /
parents_age_max / parents_has_ped to accepted fields + _coerce_bool.
- premium_calculator.py: three new loadings used by both estimate() and
bulk_estimate(): health_loading (1.0-1.5x by chronic-condition severity),
existing_cover_loading (1.0/0.95/0.85x by current cover signal),
parents_loading (1.0-1.8x when dependents include parents +
parents_age_max).
desired_sum_insured_inr now overrides default 10L SI for the estimate.
- single_brain.py SYSTEM_PROMPT RULE 2 and RULE 2.5 extended to ask for
the full union (health, existing cover, parents age) and to include
health/cover signals in the retrieve_policies query.
B7 — Persist union to profile JSON + vector + returning-user recall
- NEW backend/profile_persistence.py:
- auto_persist_session(session) flushes the 13-slot union to
profile_store.save_profile + profile_rag.upsert_profile_chunk.
Try/except wrapped; persistence failure never blocks reply.
- extract_potential_name(text) regex w/ stopword filter
(I'm/My name is/this is/name's <Name>, <Name> here).
- try_recall_by_name(session, name) and recall_by_name_payload(name,
session_id) helpers.
- main.py:
- ChatResponse.returning_user_recalled: bool field.
- Pre/post-turn snapshot logic detects genuine recall (vs first-time
capture on turn 1) and stamps the flag.
- auto_persist_session called after every /api/chat turn (best-effort).
- NEW POST /api/profile/recall-by-name endpoint.
- single_brain.py handle_turn:
- If turn_idx==1 AND session.profile.name empty, extract_potential_name
from user_text + try_recall_by_name. On hit, profile is hydrated
before KNOWN PROFILE block is built and KI-255's is_returning_user
flips True so RULE 4 fires on this same Gemini iteration.
- frontend:
- api.ts: ChatResponse.returning_user_recalled? boolean +
postProfileRecallByName helper + RecallByNameResponse type.
- page.tsx: welcomeBack state + banner above messages when
returning_user_recalled. Banner shows name + last predicted band +
Use-this-profile / Update-my-info actions. Cleared on Clear Chat.
Verification:
- python -m py_compile clean on all 7 backend files
- npx tsc --noEmit clean on frontend
- Sample pricing comparison: 65yo+diabetes+BP+parents75+5L existing
cover = 12.09x baseline (vs healthy 34yo metro couple).
- Recall heuristic test: 8/8 cases pass incl. negation
("I am okay, thanks" returns None).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/brain_tools.py +168 -0
- backend/main.py +534 -0
- backend/needs_finder.py +1 -0
- backend/premium_calculator.py +537 -1
- backend/profile_persistence.py +312 -0
- backend/scorecard.py +22 -6
- backend/single_brain.py +96 -49
- frontend/src/app/page.tsx +85 -0
- frontend/src/components/PolicyCompareModal.tsx +372 -0
- frontend/src/components/PolicyPremiumWidget.tsx +401 -0
- frontend/src/components/PolicyScorecardWidget.tsx +388 -0
- frontend/src/lib/api.ts +165 -0
|
@@ -15,6 +15,40 @@ Each function:
|
|
| 15 |
The Gemini function-calling DSL (JSON Schema-flavoured) for these three
|
| 16 |
tools is generated by `single_brain.TOOL_SCHEMAS` from this module's
|
| 17 |
metadata.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
"""
|
| 19 |
|
| 20 |
from __future__ import annotations
|
|
@@ -41,6 +75,14 @@ _ACCEPTED_FIELDS = {
|
|
| 41 |
"health_conditions",
|
| 42 |
"existing_cover_inr",
|
| 43 |
"budget_band",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
"gender", # tolerated; not persisted unless Profile gains the field
|
| 45 |
}
|
| 46 |
|
|
@@ -58,6 +100,66 @@ _REQUIRED_FOR_READY = (
|
|
| 58 |
)
|
| 59 |
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
def _profile_complete(profile) -> bool:
|
| 62 |
"""Return True when every slot in _REQUIRED_FOR_READY is non-empty on
|
| 63 |
the live Profile dataclass."""
|
|
@@ -110,6 +212,12 @@ def save_profile_field(session, field: str, value: Any) -> dict:
|
|
| 110 |
normalized = _coerce_health_conditions(value)
|
| 111 |
elif fld == "existing_cover_inr":
|
| 112 |
normalized = _coerce_existing_cover(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
elif fld == "name":
|
| 114 |
normalized = (str(value).strip() if value is not None else None) or None
|
| 115 |
elif fld == "gender":
|
|
@@ -525,6 +633,64 @@ def _coerce_existing_cover(value: Any) -> Optional[int]:
|
|
| 525 |
return None
|
| 526 |
|
| 527 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 528 |
def _coerce_health_conditions(value: Any) -> Optional[list[str]]:
|
| 529 |
"""Always return list[str] lowercase, stripped, empties dropped.
|
| 530 |
|
|
@@ -566,4 +732,6 @@ __all__ = [
|
|
| 566 |
"save_profile_field",
|
| 567 |
"retrieve_policies",
|
| 568 |
"mark_recommendation",
|
|
|
|
|
|
|
| 569 |
]
|
|
|
|
| 15 |
The Gemini function-calling DSL (JSON Schema-flavoured) for these three
|
| 16 |
tools is generated by `single_brain.TOOL_SCHEMAS` from this module's
|
| 17 |
metadata.
|
| 18 |
+
|
| 19 |
+
═══════════════════════════════════════════════════════════════════════════
|
| 20 |
+
SLOT_UNION — SINGLE SOURCE OF TRUTH FOR CAPTURED FIELDS (B6, 2026-05-15)
|
| 21 |
+
═══════════════════════════════════════════════════════════════════════════
|
| 22 |
+
SLOT_UNION enumerates every captured field that influences EITHER the
|
| 23 |
+
recommendation pipeline (retrieval query + scorecard) OR the pricing
|
| 24 |
+
pipeline (premium_calculator.estimate / bulk_estimate). It is the contract
|
| 25 |
+
between brain_tools (capture surface), single_brain (LLM tool calls),
|
| 26 |
+
premium_calculator (pricing inputs) and scorecard (match scoring).
|
| 27 |
+
|
| 28 |
+
Slot → consumer matrix:
|
| 29 |
+
|
| 30 |
+
RECOMMENDATION SLOTS (in _REQUIRED_FOR_READY — hard gate)
|
| 31 |
+
name → profile identity (no pricing influence)
|
| 32 |
+
age → retrieval query + scorecard + pricing (age band)
|
| 33 |
+
dependents → retrieval query + scorecard + pricing (family loading)
|
| 34 |
+
location_tier → retrieval query + scorecard + pricing (location loading)
|
| 35 |
+
income_band → retrieval query + scorecard
|
| 36 |
+
primary_goal → retrieval query + scorecard
|
| 37 |
+
health_conditions → retrieval query + scorecard + pricing (health loading)
|
| 38 |
+
|
| 39 |
+
PRICING-ONLY SLOTS (B5 + B6 additions — SOFT capture, post-recap)
|
| 40 |
+
budget_band → pricing band match
|
| 41 |
+
desired_sum_insured_inr → pricing SI override (per-policy estimate)
|
| 42 |
+
existing_cover_inr → pricing (existing-cover discount loading)
|
| 43 |
+
|
| 44 |
+
FAMILY-DETAIL SLOTS (used by pricing if dependents includes parents)
|
| 45 |
+
parents_to_insure → triggers parents_loading branch
|
| 46 |
+
parents_age_max → pricing (parents age loading 1.0× / 1.4× / 1.8×)
|
| 47 |
+
parents_has_ped → pricing (PED loading inflation for parents)
|
| 48 |
+
|
| 49 |
+
Total: 13 slots. `gender` is tolerated by save_profile_field for forward
|
| 50 |
+
compat but is NOT on the Profile dataclass today; it does not appear in
|
| 51 |
+
SLOT_UNION because no consumer reads it.
|
| 52 |
"""
|
| 53 |
|
| 54 |
from __future__ import annotations
|
|
|
|
| 75 |
"health_conditions",
|
| 76 |
"existing_cover_inr",
|
| 77 |
"budget_band",
|
| 78 |
+
"desired_sum_insured_inr", # SOFT capture (pricing input, post-recap)
|
| 79 |
+
# Family-detail pricing inputs (B6) — already on the Profile dataclass
|
| 80 |
+
# via needs_finder.Profile (parents_to_insure / parents_age_max /
|
| 81 |
+
# parents_has_ped). Listed here so save_profile_field can persist them
|
| 82 |
+
# when Gemini extracts them post-recap.
|
| 83 |
+
"parents_to_insure",
|
| 84 |
+
"parents_age_max",
|
| 85 |
+
"parents_has_ped",
|
| 86 |
"gender", # tolerated; not persisted unless Profile gains the field
|
| 87 |
}
|
| 88 |
|
|
|
|
| 100 |
)
|
| 101 |
|
| 102 |
|
| 103 |
+
# ───────────────────────────────────────────────────────────────────────────
|
| 104 |
+
# SLOT_UNION (B6, 2026-05-15)
|
| 105 |
+
# ───────────────────────────────────────────────────────────────────────────
|
| 106 |
+
# Single source of truth for every captured field that drives EITHER the
|
| 107 |
+
# profile (recommendation match + scorecard) OR the pricing pipeline
|
| 108 |
+
# (premium_calculator.estimate / bulk_estimate). See module docstring above
|
| 109 |
+
# for the full slot→consumer matrix.
|
| 110 |
+
#
|
| 111 |
+
# Ordering convention (do not re-order without auditing union_snapshot
|
| 112 |
+
# callers): required slots first, then pricing-only slots, then
|
| 113 |
+
# family-detail slots. Total = 13.
|
| 114 |
+
SLOT_UNION: tuple[str, ...] = (
|
| 115 |
+
# Recommendation slots (in _REQUIRED_FOR_READY)
|
| 116 |
+
"name",
|
| 117 |
+
"age",
|
| 118 |
+
"dependents",
|
| 119 |
+
"location_tier",
|
| 120 |
+
"income_band",
|
| 121 |
+
"primary_goal",
|
| 122 |
+
"health_conditions",
|
| 123 |
+
# Pricing slots (B5 + B6 additions)
|
| 124 |
+
"budget_band",
|
| 125 |
+
"desired_sum_insured_inr",
|
| 126 |
+
"existing_cover_inr",
|
| 127 |
+
# Family-detail slots (used by pricing if applicable)
|
| 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
|
| 134 |
+
# (otherwise the LLM can never capture it). Validated at import time so a
|
| 135 |
+
# future delete here is loud, not silent.
|
| 136 |
+
assert all(_s in _ACCEPTED_FIELDS for _s in SLOT_UNION), (
|
| 137 |
+
"SLOT_UNION contains a field not in _ACCEPTED_FIELDS: "
|
| 138 |
+
f"{[s for s in SLOT_UNION if s not in _ACCEPTED_FIELDS]}"
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def union_snapshot(profile) -> dict:
|
| 143 |
+
"""Return a JSON-safe dict of every SLOT_UNION field currently captured
|
| 144 |
+
on `profile`. Empty / None / [] slots are EXCLUDED so the pricing
|
| 145 |
+
pipeline can safely treat presence as "captured" (KI-091 null-overwrite
|
| 146 |
+
rule — never pass None where 0 is meaningful).
|
| 147 |
+
|
| 148 |
+
Used by premium_calculator.estimate / bulk_estimate to read pricing
|
| 149 |
+
inputs without re-implementing the field-name list on each side.
|
| 150 |
+
"""
|
| 151 |
+
snap: dict = {}
|
| 152 |
+
for fld in SLOT_UNION:
|
| 153 |
+
try:
|
| 154 |
+
v = getattr(profile, fld, None)
|
| 155 |
+
except Exception: # noqa: BLE001
|
| 156 |
+
v = None
|
| 157 |
+
if v in (None, "", []):
|
| 158 |
+
continue
|
| 159 |
+
snap[fld] = v
|
| 160 |
+
return snap
|
| 161 |
+
|
| 162 |
+
|
| 163 |
def _profile_complete(profile) -> bool:
|
| 164 |
"""Return True when every slot in _REQUIRED_FOR_READY is non-empty on
|
| 165 |
the live Profile dataclass."""
|
|
|
|
| 212 |
normalized = _coerce_health_conditions(value)
|
| 213 |
elif fld == "existing_cover_inr":
|
| 214 |
normalized = _coerce_existing_cover(value)
|
| 215 |
+
elif fld == "desired_sum_insured_inr":
|
| 216 |
+
normalized = _coerce_desired_sum_insured(value)
|
| 217 |
+
elif fld in ("parents_to_insure", "parents_has_ped"):
|
| 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":
|
|
|
|
| 633 |
return None
|
| 634 |
|
| 635 |
|
| 636 |
+
def _coerce_bool(value: Any) -> Optional[bool]:
|
| 637 |
+
"""Tri-state bool coercion for parents_to_insure / parents_has_ped.
|
| 638 |
+
|
| 639 |
+
Accepts: True / False / "yes" / "no" / "y" / "n" / "true" / "false"
|
| 640 |
+
/ 1 / 0. Anything else → None (so the KI-091 null-overwrite guard
|
| 641 |
+
refuses to clobber a previously-captured value).
|
| 642 |
+
"""
|
| 643 |
+
if value is None:
|
| 644 |
+
return None
|
| 645 |
+
if isinstance(value, bool):
|
| 646 |
+
return value
|
| 647 |
+
if isinstance(value, (int, float)):
|
| 648 |
+
return bool(value)
|
| 649 |
+
s = str(value).strip().lower()
|
| 650 |
+
if s in ("true", "yes", "y", "1"):
|
| 651 |
+
return True
|
| 652 |
+
if s in ("false", "no", "n", "0"):
|
| 653 |
+
return False
|
| 654 |
+
return None
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
def _coerce_desired_sum_insured(value: Any) -> Optional[int]:
|
| 658 |
+
"""Parse desired sum insured (cover amount) as integer rupees.
|
| 659 |
+
|
| 660 |
+
Accepts: "10L" / "10 lakh" / "1 crore" / "1Cr" / 1000000 /
|
| 661 |
+
"₹10,00,000" / "five lakh" (rejected — words not numerals).
|
| 662 |
+
Delegates to `_parse_inr_amount` from needs_finder for the heavy lift,
|
| 663 |
+
falls back to bare-digit extraction. Clamps to [50_000, 500_000_000]
|
| 664 |
+
(₹50K floor, ₹50Cr ceiling) — anything outside is implausible for a
|
| 665 |
+
health-insurance sum insured and likely a parse error.
|
| 666 |
+
"""
|
| 667 |
+
if value is None:
|
| 668 |
+
return None
|
| 669 |
+
if isinstance(value, bool):
|
| 670 |
+
return None
|
| 671 |
+
if isinstance(value, (int, float)):
|
| 672 |
+
n = int(value)
|
| 673 |
+
return max(50_000, min(500_000_000, n))
|
| 674 |
+
try:
|
| 675 |
+
from backend.needs_finder import _parse_inr_amount
|
| 676 |
+
|
| 677 |
+
parsed = _parse_inr_amount(str(value))
|
| 678 |
+
if parsed is not None:
|
| 679 |
+
return max(50_000, min(500_000_000, int(parsed)))
|
| 680 |
+
except Exception: # noqa: BLE001
|
| 681 |
+
pass
|
| 682 |
+
# Last-ditch: strip non-digits (handles "₹10,00,000" if parser missed).
|
| 683 |
+
try:
|
| 684 |
+
digits = "".join(ch for ch in str(value) if ch.isdigit())
|
| 685 |
+
if digits:
|
| 686 |
+
n = int(digits)
|
| 687 |
+
if n >= 50_000:
|
| 688 |
+
return min(500_000_000, n)
|
| 689 |
+
except Exception: # noqa: BLE001
|
| 690 |
+
pass
|
| 691 |
+
return None
|
| 692 |
+
|
| 693 |
+
|
| 694 |
def _coerce_health_conditions(value: Any) -> Optional[list[str]]:
|
| 695 |
"""Always return list[str] lowercase, stripped, empties dropped.
|
| 696 |
|
|
|
|
| 732 |
"save_profile_field",
|
| 733 |
"retrieve_policies",
|
| 734 |
"mark_recommendation",
|
| 735 |
+
"SLOT_UNION",
|
| 736 |
+
"union_snapshot",
|
| 737 |
]
|
|
@@ -161,6 +161,17 @@ class ChatResponse(BaseModel):
|
|
| 161 |
"non-empty on the live session.profile at end-of-turn."
|
| 162 |
),
|
| 163 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
|
| 166 |
class TTSRequest(BaseModel):
|
|
@@ -213,6 +224,40 @@ class UploadResponse(BaseModel):
|
|
| 213 |
# uses the same _REQUIRED_FOR_READY tuple; we mirror the call here instead of
|
| 214 |
# duplicating the slot list so a future addition (e.g. risk_appetite) only
|
| 215 |
# needs to be applied in brain_tools.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
def _compute_profile_complete(session_id: str) -> bool:
|
| 217 |
"""Read the live session profile and return True iff every required slot
|
| 218 |
is populated. Tolerant of every failure mode (no session yet, session
|
|
@@ -545,6 +590,11 @@ async def chat(req: ChatRequest, request: Request):
|
|
| 545 |
if preferred_codec not in _allowed_codecs:
|
| 546 |
preferred_codec = "audio/wav"
|
| 547 |
t_chat0 = time.time()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
# KI-106 — never let an inner TimeoutError / unhandled exception bubble out
|
| 549 |
# of handle_turn as a 500. C4 NRI persona saw 5× HTTP 500s with
|
| 550 |
# "Orchestrator failed: TimeoutError" because the outer non-fact-find
|
|
@@ -562,6 +612,13 @@ async def chat(req: ChatRequest, request: Request):
|
|
| 562 |
from backend.session_state import get_session
|
| 563 |
|
| 564 |
_sb_session = get_session(session_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
# Z2 fix — Issue 3 (brain election bouncing). Once a session
|
| 566 |
# has had ANY successful single_brain turn, it must stay on
|
| 567 |
# single_brain for the rest of its lifetime. Falling back to
|
|
@@ -831,6 +888,63 @@ async def chat(req: ChatRequest, request: Request):
|
|
| 831 |
except Exception: # noqa: BLE001 — log IO must never block a reply
|
| 832 |
pass
|
| 833 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 834 |
# Bug B defense — CitationOut requires page_start/page_end as ints, but
|
| 835 |
# single_brain.TurnResult.citations dicts don't carry those fields (its
|
| 836 |
# citation shape is {chunk_id, policy_id, policy_name, insurer_slug,
|
|
@@ -880,6 +994,7 @@ async def chat(req: ChatRequest, request: Request):
|
|
| 880 |
blocked=turn.blocked,
|
| 881 |
profile_updates=turn.profile_updates,
|
| 882 |
profile_complete=_compute_profile_complete(session_id),
|
|
|
|
| 883 |
)
|
| 884 |
except Exception as _resp_err: # noqa: BLE001
|
| 885 |
# Anything else (TypeError/AttributeError/ValidationError) on the
|
|
@@ -2498,6 +2613,249 @@ async def policy_scorecard(
|
|
| 2498 |
)
|
| 2499 |
|
| 2500 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2501 |
class ReviewsResponse(BaseModel):
|
| 2502 |
insurer_slug: str
|
| 2503 |
insurer_name: str
|
|
@@ -2587,6 +2945,84 @@ async def premium_estimate(req: PremiumEstimateRequest):
|
|
| 2587 |
)
|
| 2588 |
|
| 2589 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2590 |
@app.post("/api/tts")
|
| 2591 |
async def tts(req: TTSRequest):
|
| 2592 |
"""Standalone TTS endpoint — returns base64 WAV."""
|
|
@@ -2611,6 +3047,104 @@ async def api_root():
|
|
| 2611 |
}
|
| 2612 |
|
| 2613 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2614 |
# ---- Static frontend (served alongside /api on the same port for HF Spaces) ----
|
| 2615 |
# The Next.js frontend is statically exported during the Docker build to
|
| 2616 |
# /app/frontend/out. In local dev, this directory may not exist — we still
|
|
|
|
| 161 |
"non-empty on the live session.profile at end-of-turn."
|
| 162 |
),
|
| 163 |
)
|
| 164 |
+
# KI-Z7 (2026-05-15) — Feature B. True when single_brain.handle_turn's
|
| 165 |
+
# turn-1 name heuristic matched a stored profile and hydrated the
|
| 166 |
+
# session. Frontend renders a "Welcome back, <name>!" banner with the
|
| 167 |
+
# last predicted-premium band when this flips True on the first turn.
|
| 168 |
+
returning_user_recalled: bool = Field(
|
| 169 |
+
False,
|
| 170 |
+
description=(
|
| 171 |
+
"True iff a stored named-profile was matched + hydrated on the "
|
| 172 |
+
"current turn (typically only on turn 1)."
|
| 173 |
+
),
|
| 174 |
+
)
|
| 175 |
|
| 176 |
|
| 177 |
class TTSRequest(BaseModel):
|
|
|
|
| 224 |
# uses the same _REQUIRED_FOR_READY tuple; we mirror the call here instead of
|
| 225 |
# duplicating the slot list so a future addition (e.g. risk_appetite) only
|
| 226 |
# needs to be applied in brain_tools.
|
| 227 |
+
# KI-Z7 (2026-05-15) — Feature B helper. Distinguishes "first-time capture
|
| 228 |
+
# on turn 1" from "stored profile recalled on turn 1". Both end with a
|
| 229 |
+
# non-empty Profile on the session; only the latter should flip
|
| 230 |
+
# returning_user_recalled. Heuristic: if EVERY currently-filled slot was
|
| 231 |
+
# written THIS turn (i.e. lives in `profile_updates`), the user just typed
|
| 232 |
+
# their facts — NOT a recall. If at least one filled slot is NOT in
|
| 233 |
+
# profile_updates, those slots came from the recall hydration.
|
| 234 |
+
_FEATURE_B_SLOT_LIST: tuple[str, ...] = (
|
| 235 |
+
"name", "age", "dependents", "location_tier",
|
| 236 |
+
"income_band", "primary_goal", "health_conditions",
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _every_filled_slot_was_set_this_turn(profile, profile_updates: dict) -> bool:
|
| 241 |
+
"""Return True iff every populated slot on `profile` was also set in
|
| 242 |
+
`profile_updates` this turn. Used by the recall detector to suppress
|
| 243 |
+
the banner when the user just freshly introduced themselves.
|
| 244 |
+
"""
|
| 245 |
+
if not isinstance(profile_updates, dict):
|
| 246 |
+
profile_updates = {}
|
| 247 |
+
pu = set(profile_updates.keys())
|
| 248 |
+
any_filled = False
|
| 249 |
+
for fld in _FEATURE_B_SLOT_LIST:
|
| 250 |
+
v = getattr(profile, fld, None)
|
| 251 |
+
if v in (None, "", []):
|
| 252 |
+
continue
|
| 253 |
+
any_filled = True
|
| 254 |
+
if fld not in pu:
|
| 255 |
+
return False
|
| 256 |
+
# If nothing was filled at all, "every filled" is vacuously True →
|
| 257 |
+
# detector should NOT flip returning_user_recalled.
|
| 258 |
+
return True if any_filled else True
|
| 259 |
+
|
| 260 |
+
|
| 261 |
def _compute_profile_complete(session_id: str) -> bool:
|
| 262 |
"""Read the live session profile and return True iff every required slot
|
| 263 |
is populated. Tolerant of every failure mode (no session yet, session
|
|
|
|
| 590 |
if preferred_codec not in _allowed_codecs:
|
| 591 |
preferred_codec = "audio/wav"
|
| 592 |
t_chat0 = time.time()
|
| 593 |
+
# KI-Z7 (2026-05-15) — pre-turn snapshot for the returning-user-recall
|
| 594 |
+
# detector below. Defaults match the "no session yet" case so the
|
| 595 |
+
# detector simply doesn't fire on the legacy orchestrator path.
|
| 596 |
+
_pre_turn_name: str = ""
|
| 597 |
+
_pre_turn_idx: int = 0
|
| 598 |
# KI-106 — never let an inner TimeoutError / unhandled exception bubble out
|
| 599 |
# of handle_turn as a 500. C4 NRI persona saw 5× HTTP 500s with
|
| 600 |
# "Orchestrator failed: TimeoutError" because the outer non-fact-find
|
|
|
|
| 612 |
from backend.session_state import get_session
|
| 613 |
|
| 614 |
_sb_session = get_session(session_id)
|
| 615 |
+
# KI-Z7 — snapshot the pre-turn (name, turn_idx) so we can tell
|
| 616 |
+
# AFTER handle_turn whether the turn-1 name heuristic actually
|
| 617 |
+
# recalled a stored profile (and stamp ChatResponse accordingly).
|
| 618 |
+
_pre_turn_name = (
|
| 619 |
+
getattr(_sb_session.profile, "name", None) or ""
|
| 620 |
+
).strip()
|
| 621 |
+
_pre_turn_idx = int(getattr(_sb_session, "turn_idx", 0) or 0)
|
| 622 |
# Z2 fix — Issue 3 (brain election bouncing). Once a session
|
| 623 |
# has had ANY successful single_brain turn, it must stay on
|
| 624 |
# single_brain for the rest of its lifetime. Falling back to
|
|
|
|
| 888 |
except Exception: # noqa: BLE001 — log IO must never block a reply
|
| 889 |
pass
|
| 890 |
|
| 891 |
+
# KI-Z7 (2026-05-15) — Feature A. Auto-persist the live profile to disk
|
| 892 |
+
# + Chroma AFTER handle_turn returns. brain_tools.save_profile_field
|
| 893 |
+
# only mutates the in-memory Profile; this flushes the union to the
|
| 894 |
+
# canonical name-keyed JSON and refreshes the user's profile chunk so a
|
| 895 |
+
# NEW session can recover the full profile via the turn-1 name recall.
|
| 896 |
+
# Wrapped in try/except — persistence failure NEVER breaks the reply.
|
| 897 |
+
_returning_user_recalled = False
|
| 898 |
+
try:
|
| 899 |
+
from backend.session_state import get_session as _get_session_p
|
| 900 |
+
from backend.profile_persistence import auto_persist_session
|
| 901 |
+
|
| 902 |
+
_persist_session = _get_session_p(session_id)
|
| 903 |
+
await auto_persist_session(_persist_session)
|
| 904 |
+
|
| 905 |
+
# KI-Z7 — Feature B detection. The pre-turn snapshot (captured before
|
| 906 |
+
# single_brain.handle_turn ran) lets us tell whether the turn-1 name
|
| 907 |
+
# heuristic actually hydrated a stored profile. Conditions:
|
| 908 |
+
# (a) this WAS the first turn (pre_turn_idx == 0),
|
| 909 |
+
# (b) no name on the profile BEFORE the turn ("" → recalled),
|
| 910 |
+
# (c) a name AND at least one other slot are present AFTER the turn.
|
| 911 |
+
# Frontend uses this flag to render the "Welcome back" banner.
|
| 912 |
+
if USE_SINGLE_BRAIN:
|
| 913 |
+
try:
|
| 914 |
+
_post_name = (
|
| 915 |
+
getattr(_persist_session.profile, "name", None) or ""
|
| 916 |
+
).strip()
|
| 917 |
+
_post_has_other_slots = any(
|
| 918 |
+
getattr(_persist_session.profile, fld, None) not in (None, "", [])
|
| 919 |
+
for fld in (
|
| 920 |
+
"age", "dependents", "location_tier",
|
| 921 |
+
"income_band", "primary_goal", "health_conditions",
|
| 922 |
+
)
|
| 923 |
+
)
|
| 924 |
+
if (
|
| 925 |
+
_pre_turn_idx == 0
|
| 926 |
+
and not _pre_turn_name
|
| 927 |
+
and _post_name
|
| 928 |
+
and _post_has_other_slots
|
| 929 |
+
# save_profile_field captures a NEW name on turn 1 too —
|
| 930 |
+
# only flag as returning when prior slots came from the
|
| 931 |
+
# recall path, NOT from in-this-turn extraction. The
|
| 932 |
+
# profile_updates dict carries fields written THIS turn;
|
| 933 |
+
# if EVERY filled slot is in profile_updates, it's a
|
| 934 |
+
# first-time capture, NOT a recall.
|
| 935 |
+
and not _every_filled_slot_was_set_this_turn(
|
| 936 |
+
_persist_session.profile, turn.profile_updates,
|
| 937 |
+
)
|
| 938 |
+
):
|
| 939 |
+
_returning_user_recalled = True
|
| 940 |
+
except Exception: # noqa: BLE001
|
| 941 |
+
pass
|
| 942 |
+
except Exception as _persist_err: # noqa: BLE001
|
| 943 |
+
logging.warning(
|
| 944 |
+
"auto_persist_session failed (session=%s): %s: %s",
|
| 945 |
+
session_id, type(_persist_err).__name__, _persist_err,
|
| 946 |
+
)
|
| 947 |
+
|
| 948 |
# Bug B defense — CitationOut requires page_start/page_end as ints, but
|
| 949 |
# single_brain.TurnResult.citations dicts don't carry those fields (its
|
| 950 |
# citation shape is {chunk_id, policy_id, policy_name, insurer_slug,
|
|
|
|
| 994 |
blocked=turn.blocked,
|
| 995 |
profile_updates=turn.profile_updates,
|
| 996 |
profile_complete=_compute_profile_complete(session_id),
|
| 997 |
+
returning_user_recalled=_returning_user_recalled,
|
| 998 |
)
|
| 999 |
except Exception as _resp_err: # noqa: BLE001
|
| 1000 |
# Anything else (TypeError/AttributeError/ValidationError) on the
|
|
|
|
| 2613 |
)
|
| 2614 |
|
| 2615 |
|
| 2616 |
+
# ----------------------------------------------------------------------------
|
| 2617 |
+
# Bulk scorecard endpoint — powers the PolicyCompareModal scorecard widget.
|
| 2618 |
+
# ----------------------------------------------------------------------------
|
| 2619 |
+
# Why bulk: the compare modal renders 2-4 scorecards in parallel and each is
|
| 2620 |
+
# profile-tuned. Doing N sequential GETs from the client wastes the per-policy
|
| 2621 |
+
# JSON I/O cost (we re-load every reviews file even for the same insurer) and
|
| 2622 |
+
# fans out N renders. One POST with the full profile + id list lets us:
|
| 2623 |
+
# - load each reviews file once per slug (memoized in the loop)
|
| 2624 |
+
# - return missing policies as N/A so the client renders a clean placeholder
|
| 2625 |
+
# - share one profile dict — no copy-paste of every field in N query strings
|
| 2626 |
+
class BulkScorecardRequest(BaseModel):
|
| 2627 |
+
policy_ids: list[str]
|
| 2628 |
+
profile: Optional[dict] = None
|
| 2629 |
+
|
| 2630 |
+
|
| 2631 |
+
class BulkScorecardEntry(BaseModel):
|
| 2632 |
+
policy_id: str
|
| 2633 |
+
policy_name: str
|
| 2634 |
+
insurer_slug: str
|
| 2635 |
+
overall_grade: str # "A" / "B+" / etc — letter only for missing
|
| 2636 |
+
overall_score: int # 0-100
|
| 2637 |
+
sub_scores: dict[str, int] # {coverage_breadth: 82, cost_predictability: 64, ...}
|
| 2638 |
+
profile_rationale: list[str] # bullets explaining WHY this score for this user
|
| 2639 |
+
data_completeness_pct: float
|
| 2640 |
+
one_liner: str = ""
|
| 2641 |
+
# raw signals per sub-score so the widget can pop-out a tooltip with detail
|
| 2642 |
+
signals: dict[str, list[str]] = Field(default_factory=dict)
|
| 2643 |
+
|
| 2644 |
+
|
| 2645 |
+
class BulkScorecardResponse(BaseModel):
|
| 2646 |
+
per_policy: dict[str, BulkScorecardEntry]
|
| 2647 |
+
|
| 2648 |
+
|
| 2649 |
+
def _slugify_subscore(name: str) -> str:
|
| 2650 |
+
"""'Coverage Breadth' -> 'coverage_breadth' (stable key for the widget)."""
|
| 2651 |
+
return name.lower().replace("-", "_").replace("&", "and").replace(" ", "_").replace("__", "_")
|
| 2652 |
+
|
| 2653 |
+
|
| 2654 |
+
def _profile_rationale_for(policy: dict, profile: Optional[dict], sub_scores) -> list[str]:
|
| 2655 |
+
"""Turn raw signals + profile facts into 2-5 plain-English bullets.
|
| 2656 |
+
|
| 2657 |
+
Each bullet is shaped as 'Strong fit:' or 'Weak fit:' so the buyer can scan
|
| 2658 |
+
pros and cons at a glance. We anchor each bullet to a concrete profile
|
| 2659 |
+
attribute (you mentioned X) so the user trusts the personalization is real.
|
| 2660 |
+
"""
|
| 2661 |
+
if not profile:
|
| 2662 |
+
return []
|
| 2663 |
+
bullets: list[str] = []
|
| 2664 |
+
conditions = profile.get("health_conditions") or []
|
| 2665 |
+
cond_str = " ".join(str(c).lower() for c in conditions) if isinstance(conditions, list) else ""
|
| 2666 |
+
age = profile.get("age") if isinstance(profile.get("age"), int) else None
|
| 2667 |
+
deps = (profile.get("dependents") or "").lower()
|
| 2668 |
+
loc = profile.get("location_tier")
|
| 2669 |
+
goal = (profile.get("primary_goal") or "").lower()
|
| 2670 |
+
existing = profile.get("existing_cover_inr")
|
| 2671 |
+
|
| 2672 |
+
# Pre-existing disease handling
|
| 2673 |
+
if cond_str and any(c in cond_str for c in ("diab", "bp", "hyper", "thyroid", "heart")):
|
| 2674 |
+
ped = policy.get("pre_existing_disease_waiting_months")
|
| 2675 |
+
try:
|
| 2676 |
+
ped_n = int(ped) if ped is not None else None
|
| 2677 |
+
except (TypeError, ValueError):
|
| 2678 |
+
ped_n = None
|
| 2679 |
+
if ped_n is not None:
|
| 2680 |
+
if ped_n <= 24:
|
| 2681 |
+
bullets.append(f"Strong fit: PED waiting is only {ped_n} months — short for your {cond_str.strip()}.")
|
| 2682 |
+
elif ped_n >= 48:
|
| 2683 |
+
bullets.append(f"Weak fit: {ped_n}-month PED waiting is long for your {cond_str.strip()} — alternatives offer 24-36 months.")
|
| 2684 |
+
else:
|
| 2685 |
+
bullets.append(f"Fair fit: {ped_n}-month PED waiting is standard for your {cond_str.strip()}.")
|
| 2686 |
+
|
| 2687 |
+
# Senior + claim reliability
|
| 2688 |
+
if age and age >= 60:
|
| 2689 |
+
nh = policy.get("network_hospital_count")
|
| 2690 |
+
try:
|
| 2691 |
+
nh_n = int(nh) if nh is not None else None
|
| 2692 |
+
except (TypeError, ValueError):
|
| 2693 |
+
nh_n = None
|
| 2694 |
+
if nh_n is not None and nh_n >= 7000:
|
| 2695 |
+
bullets.append(f"Strong fit: {nh_n:,}+ cashless hospitals matters at age {age} when access speed counts.")
|
| 2696 |
+
elif nh_n is not None and nh_n < 3000:
|
| 2697 |
+
bullets.append(f"Weak fit: only {nh_n} cashless hospitals — thin network for age {age}.")
|
| 2698 |
+
maxr = policy.get("max_renewal_age")
|
| 2699 |
+
if maxr and int(maxr) >= 99:
|
| 2700 |
+
bullets.append(f"Strong fit: lifelong renewability — keeps protecting you past 70.")
|
| 2701 |
+
|
| 2702 |
+
# Family + room-rent / maternity
|
| 2703 |
+
if any(k in deps for k in ("spouse", "wife", "husband", "partner", "kid", "child", "family")):
|
| 2704 |
+
rrc = policy.get("room_rent_capping")
|
| 2705 |
+
rrc_text = rrc if isinstance(rrc, str) else (rrc.get("limit_text") if isinstance(rrc, dict) else None)
|
| 2706 |
+
if rrc_text and "no cap" in rrc_text.lower():
|
| 2707 |
+
bullets.append("Strong fit: no room-rent cap — works for any hospital your family chooses.")
|
| 2708 |
+
elif rrc_text and ("1%" in rrc_text or "%" in rrc_text):
|
| 2709 |
+
metro_qual = " in a metro" if loc == "metro" else ""
|
| 2710 |
+
bullets.append(f"Weak fit: room rent capped ({rrc_text[:40].strip()}) may be tight for hospitals{metro_qual}.")
|
| 2711 |
+
if any(k in deps for k in ("spouse", "wife", "husband", "partner")):
|
| 2712 |
+
mc = policy.get("maternity_coverage")
|
| 2713 |
+
covered = mc.get("covered") if isinstance(mc, dict) else mc
|
| 2714 |
+
if covered is True:
|
| 2715 |
+
mw = policy.get("maternity_waiting_months")
|
| 2716 |
+
bullets.append(
|
| 2717 |
+
f"Strong fit: maternity covered with {mw}-month wait — relevant to your spouse."
|
| 2718 |
+
if mw else
|
| 2719 |
+
"Strong fit: maternity covered — relevant to your spouse."
|
| 2720 |
+
)
|
| 2721 |
+
elif covered is False:
|
| 2722 |
+
bullets.append("Weak fit: no maternity coverage — you'd need a separate rider.")
|
| 2723 |
+
|
| 2724 |
+
# First-time buyer — simplicity / premium predictability
|
| 2725 |
+
if existing == 0:
|
| 2726 |
+
copay = policy.get("copayment_pct")
|
| 2727 |
+
try:
|
| 2728 |
+
copay_n = float(copay) if copay is not None else None
|
| 2729 |
+
except (TypeError, ValueError):
|
| 2730 |
+
copay_n = None
|
| 2731 |
+
if copay_n is not None and copay_n == 0:
|
| 2732 |
+
bullets.append("Strong fit: zero co-pay — simpler to budget for as a first-time buyer.")
|
| 2733 |
+
elif copay_n is not None and copay_n >= 20:
|
| 2734 |
+
bullets.append(f"Weak fit: {copay_n:.0f}% co-pay adds a surprise out-of-pocket — hard to plan as a first-time buyer.")
|
| 2735 |
+
|
| 2736 |
+
# Tax-saving goal anchor
|
| 2737 |
+
if "tax" in goal:
|
| 2738 |
+
bullets.append("Note: premium qualifies for Section 80D deduction — aligned with your tax-saving goal.")
|
| 2739 |
+
|
| 2740 |
+
# If we still have <2 bullets, fall back to top sub-score deltas vs neutral
|
| 2741 |
+
if len(bullets) < 2:
|
| 2742 |
+
ranked = sorted(sub_scores, key=lambda s: s.score, reverse=True)
|
| 2743 |
+
if ranked:
|
| 2744 |
+
top = ranked[0]
|
| 2745 |
+
bullets.append(f"Strongest area: {top.name} ({top.score}/100) — {top.summary.lower()}.")
|
| 2746 |
+
if len(ranked) > 1:
|
| 2747 |
+
bot = ranked[-1]
|
| 2748 |
+
if bot.score < 60:
|
| 2749 |
+
bullets.append(f"Watch out: {bot.name} ({bot.score}/100) — {bot.summary.lower()}.")
|
| 2750 |
+
|
| 2751 |
+
return bullets[:5]
|
| 2752 |
+
|
| 2753 |
+
|
| 2754 |
+
def _letter_grade_with_plus(score: int) -> str:
|
| 2755 |
+
"""Convert 0-100 to A / A- / B+ / B / B- / C+ / C / C- / D / F.
|
| 2756 |
+
|
| 2757 |
+
The base grade_for() returns flat letters (A/B/C/D/F). For the compare
|
| 2758 |
+
widget the buyer wants finer distinction between e.g. an 84 (top of B) and
|
| 2759 |
+
a 71 (bottom of B). Thresholds:
|
| 2760 |
+
90+ A, 85-89 A-, 80-84 B+, 75-79 B, 70-74 B-,
|
| 2761 |
+
65-69 C+, 60-64 C, 55-59 C-, 40-54 D, <40 F.
|
| 2762 |
+
"""
|
| 2763 |
+
if score >= 90: return "A"
|
| 2764 |
+
if score >= 85: return "A-"
|
| 2765 |
+
if score >= 80: return "B+"
|
| 2766 |
+
if score >= 75: return "B"
|
| 2767 |
+
if score >= 70: return "B-"
|
| 2768 |
+
if score >= 65: return "C+"
|
| 2769 |
+
if score >= 60: return "C"
|
| 2770 |
+
if score >= 55: return "C-"
|
| 2771 |
+
if score >= 40: return "D"
|
| 2772 |
+
return "F"
|
| 2773 |
+
|
| 2774 |
+
|
| 2775 |
+
@app.post("/api/scorecard/bulk", response_model=BulkScorecardResponse)
|
| 2776 |
+
async def scorecard_bulk(req: BulkScorecardRequest):
|
| 2777 |
+
"""Compute profile-tuned scorecards for N policies in one round-trip.
|
| 2778 |
+
|
| 2779 |
+
Body: { policy_ids: [...], profile: {...} }
|
| 2780 |
+
Returns: { per_policy: { <policy_id>: { overall_grade, overall_score,
|
| 2781 |
+
sub_scores, profile_rationale,
|
| 2782 |
+
data_completeness_pct } } }
|
| 2783 |
+
|
| 2784 |
+
Missing policy_ids get overall_grade="N/A" + rationale=["Data not indexed"].
|
| 2785 |
+
"""
|
| 2786 |
+
import json as _json
|
| 2787 |
+
from backend.scorecard import build_scorecard
|
| 2788 |
+
|
| 2789 |
+
if not req.policy_ids:
|
| 2790 |
+
raise HTTPException(400, "policy_ids must be a non-empty list")
|
| 2791 |
+
if len(req.policy_ids) > 8:
|
| 2792 |
+
raise HTTPException(400, "bulk scorecard caps at 8 policies per call")
|
| 2793 |
+
|
| 2794 |
+
profile = req.profile or None
|
| 2795 |
+
insurer_cache: dict[str, Optional[dict]] = {}
|
| 2796 |
+
out: dict[str, BulkScorecardEntry] = {}
|
| 2797 |
+
|
| 2798 |
+
for pid in req.policy_ids:
|
| 2799 |
+
extracted_path = settings.EXTRACTED_DIR / f"{pid}.json"
|
| 2800 |
+
if not extracted_path.exists():
|
| 2801 |
+
out[pid] = BulkScorecardEntry(
|
| 2802 |
+
policy_id=pid,
|
| 2803 |
+
policy_name=pid,
|
| 2804 |
+
insurer_slug="?",
|
| 2805 |
+
overall_grade="N/A",
|
| 2806 |
+
overall_score=0,
|
| 2807 |
+
sub_scores={},
|
| 2808 |
+
profile_rationale=["Data not indexed"],
|
| 2809 |
+
data_completeness_pct=0.0,
|
| 2810 |
+
one_liner="No extraction available for this policy.",
|
| 2811 |
+
signals={},
|
| 2812 |
+
)
|
| 2813 |
+
continue
|
| 2814 |
+
try:
|
| 2815 |
+
policy = _json.loads(extracted_path.read_text())
|
| 2816 |
+
except Exception as e:
|
| 2817 |
+
out[pid] = BulkScorecardEntry(
|
| 2818 |
+
policy_id=pid, policy_name=pid, insurer_slug="?",
|
| 2819 |
+
overall_grade="N/A", overall_score=0, sub_scores={},
|
| 2820 |
+
profile_rationale=[f"Data unreadable: {e}"],
|
| 2821 |
+
data_completeness_pct=0.0,
|
| 2822 |
+
one_liner="Extraction file is corrupted.",
|
| 2823 |
+
signals={},
|
| 2824 |
+
)
|
| 2825 |
+
continue
|
| 2826 |
+
|
| 2827 |
+
slug = policy.get("insurer_slug") or "?"
|
| 2828 |
+
if slug not in insurer_cache:
|
| 2829 |
+
insurer_cache[slug] = None
|
| 2830 |
+
rp = settings.CORPUS_DIR.parent.parent / "40-data" / "reviews" / f"{slug}.json"
|
| 2831 |
+
if rp.exists():
|
| 2832 |
+
try:
|
| 2833 |
+
insurer_cache[slug] = _json.loads(rp.read_text())
|
| 2834 |
+
except Exception:
|
| 2835 |
+
insurer_cache[slug] = None
|
| 2836 |
+
|
| 2837 |
+
sc = build_scorecard(policy, insurer_reviews=insurer_cache[slug], profile=profile)
|
| 2838 |
+
|
| 2839 |
+
sub_map = {_slugify_subscore(s.name): s.score for s in sc.sub_scores}
|
| 2840 |
+
signal_map = {_slugify_subscore(s.name): s.signals for s in sc.sub_scores}
|
| 2841 |
+
rationale = _profile_rationale_for(policy, profile, sc.sub_scores)
|
| 2842 |
+
|
| 2843 |
+
out[pid] = BulkScorecardEntry(
|
| 2844 |
+
policy_id=sc.policy_id or pid,
|
| 2845 |
+
policy_name=sc.policy_name or pid,
|
| 2846 |
+
insurer_slug=sc.insurer_slug or slug,
|
| 2847 |
+
overall_grade=_letter_grade_with_plus(sc.overall_score),
|
| 2848 |
+
overall_score=sc.overall_score,
|
| 2849 |
+
sub_scores=sub_map,
|
| 2850 |
+
profile_rationale=rationale,
|
| 2851 |
+
data_completeness_pct=sc.data_completeness_pct,
|
| 2852 |
+
one_liner=sc.one_liner,
|
| 2853 |
+
signals=signal_map,
|
| 2854 |
+
)
|
| 2855 |
+
|
| 2856 |
+
return BulkScorecardResponse(per_policy=out)
|
| 2857 |
+
|
| 2858 |
+
|
| 2859 |
class ReviewsResponse(BaseModel):
|
| 2860 |
insurer_slug: str
|
| 2861 |
insurer_name: str
|
|
|
|
| 2945 |
)
|
| 2946 |
|
| 2947 |
|
| 2948 |
+
# ---------------------------------------------------------------------------
|
| 2949 |
+
# /api/premium/bulk — multi-policy slider-driven premium calculator
|
| 2950 |
+
# Powers the PolicyPremiumWidget inside PolicyCompareModal.
|
| 2951 |
+
# ---------------------------------------------------------------------------
|
| 2952 |
+
|
| 2953 |
+
class PremiumBulkProfile(BaseModel):
|
| 2954 |
+
age: Optional[int] = Field(None, ge=0, le=120)
|
| 2955 |
+
dependents: Optional[str] = None
|
| 2956 |
+
location_tier: Optional[str] = None
|
| 2957 |
+
family_size: Optional[int] = Field(None, ge=0, le=10)
|
| 2958 |
+
smoker: Optional[bool] = False
|
| 2959 |
+
pre_existing_conditions: Optional[str] = "none"
|
| 2960 |
+
|
| 2961 |
+
|
| 2962 |
+
class PremiumBulkOverride(BaseModel):
|
| 2963 |
+
sum_insured_inr: Optional[int] = Field(None, ge=100_000, le=100_000_000)
|
| 2964 |
+
tenure_years: Optional[int] = Field(None, ge=1, le=3)
|
| 2965 |
+
deductible_inr: Optional[int] = Field(None, ge=0, le=200_000)
|
| 2966 |
+
|
| 2967 |
+
|
| 2968 |
+
class PremiumBulkRequest(BaseModel):
|
| 2969 |
+
policy_ids: list[str] = Field(..., min_length=1, max_length=20)
|
| 2970 |
+
profile: PremiumBulkProfile = Field(default_factory=PremiumBulkProfile)
|
| 2971 |
+
overrides: Optional[dict[str, PremiumBulkOverride]] = None
|
| 2972 |
+
|
| 2973 |
+
|
| 2974 |
+
class PremiumBulkRow(BaseModel):
|
| 2975 |
+
policy_id: str
|
| 2976 |
+
premium_inr_annual: int
|
| 2977 |
+
breakdown: dict
|
| 2978 |
+
sum_insured_inr: int
|
| 2979 |
+
tenure_years: int
|
| 2980 |
+
deductible_inr: int
|
| 2981 |
+
assumed: bool
|
| 2982 |
+
notes: list[str] = []
|
| 2983 |
+
|
| 2984 |
+
|
| 2985 |
+
class PremiumBulkResponse(BaseModel):
|
| 2986 |
+
per_policy: dict[str, PremiumBulkRow]
|
| 2987 |
+
profile_used: PremiumBulkProfile
|
| 2988 |
+
disclaimer: str = (
|
| 2989 |
+
"Illustrative estimates only — actual premiums depend on underwriting, "
|
| 2990 |
+
"medical history, and quote-time risk factors. Confirm with the insurer."
|
| 2991 |
+
)
|
| 2992 |
+
|
| 2993 |
+
|
| 2994 |
+
@app.post("/api/premium/bulk", response_model=PremiumBulkResponse)
|
| 2995 |
+
async def premium_bulk(req: PremiumBulkRequest):
|
| 2996 |
+
"""Bulk slider-driven premium estimator for the PolicyCompareModal widget."""
|
| 2997 |
+
from backend.premium_calculator import bulk_estimate as _bulk
|
| 2998 |
+
|
| 2999 |
+
overrides = {
|
| 3000 |
+
pid: (ov.model_dump(exclude_none=True) if ov else {})
|
| 3001 |
+
for pid, ov in (req.overrides or {}).items()
|
| 3002 |
+
}
|
| 3003 |
+
rows = _bulk(
|
| 3004 |
+
policy_ids=req.policy_ids,
|
| 3005 |
+
profile=req.profile.model_dump(exclude_none=True),
|
| 3006 |
+
overrides=overrides,
|
| 3007 |
+
)
|
| 3008 |
+
return PremiumBulkResponse(
|
| 3009 |
+
per_policy={
|
| 3010 |
+
pid: PremiumBulkRow(
|
| 3011 |
+
policy_id=r.policy_id,
|
| 3012 |
+
premium_inr_annual=r.premium_inr_annual,
|
| 3013 |
+
breakdown=r.breakdown,
|
| 3014 |
+
sum_insured_inr=r.sum_insured_inr,
|
| 3015 |
+
tenure_years=r.tenure_years,
|
| 3016 |
+
deductible_inr=r.deductible_inr,
|
| 3017 |
+
assumed=r.assumed,
|
| 3018 |
+
notes=r.notes,
|
| 3019 |
+
)
|
| 3020 |
+
for pid, r in rows.items()
|
| 3021 |
+
},
|
| 3022 |
+
profile_used=req.profile,
|
| 3023 |
+
)
|
| 3024 |
+
|
| 3025 |
+
|
| 3026 |
@app.post("/api/tts")
|
| 3027 |
async def tts(req: TTSRequest):
|
| 3028 |
"""Standalone TTS endpoint — returns base64 WAV."""
|
|
|
|
| 3047 |
}
|
| 3048 |
|
| 3049 |
|
| 3050 |
+
# ---------------------------------------------------------------------------
|
| 3051 |
+
# Profile-level predicted-premium BAND — feeds the chat-UI chip that sits
|
| 3052 |
+
# next to the "X% DONE" profile-completeness pill. Updates reactively as the
|
| 3053 |
+
# profile fills in (frontend refetches whenever completeness_pct changes).
|
| 3054 |
+
# ---------------------------------------------------------------------------
|
| 3055 |
+
class PredictedPremiumBandResponse(BaseModel):
|
| 3056 |
+
min_inr: int
|
| 3057 |
+
median_inr: int
|
| 3058 |
+
max_inr: int
|
| 3059 |
+
sample_size: int
|
| 3060 |
+
assumed: bool
|
| 3061 |
+
|
| 3062 |
+
|
| 3063 |
+
@app.get(
|
| 3064 |
+
"/api/profile/predicted-premium-band",
|
| 3065 |
+
response_model=PredictedPremiumBandResponse,
|
| 3066 |
+
)
|
| 3067 |
+
async def predicted_premium_band(session_id: Optional[str] = None):
|
| 3068 |
+
"""Return the user's estimated premium band aggregated across a
|
| 3069 |
+
representative basket of marketplace policies. Mirrors the slot-shape
|
| 3070 |
+
used by /api/profile/completeness so the chip and the bar share triggers.
|
| 3071 |
+
"""
|
| 3072 |
+
from backend.premium_calculator import estimate_premium_band
|
| 3073 |
+
from backend.session_state import get_session
|
| 3074 |
+
|
| 3075 |
+
if not session_id:
|
| 3076 |
+
return PredictedPremiumBandResponse(
|
| 3077 |
+
min_inr=0, median_inr=0, max_inr=0, sample_size=0, assumed=True,
|
| 3078 |
+
)
|
| 3079 |
+
|
| 3080 |
+
sess = get_session(session_id)
|
| 3081 |
+
p = sess.profile
|
| 3082 |
+
profile_dict = {
|
| 3083 |
+
"name": p.name,
|
| 3084 |
+
"age": p.age,
|
| 3085 |
+
"dependents": p.dependents,
|
| 3086 |
+
"income_band": p.income_band,
|
| 3087 |
+
"existing_cover_inr": p.existing_cover_inr,
|
| 3088 |
+
"primary_goal": p.primary_goal,
|
| 3089 |
+
"location_tier": p.location_tier,
|
| 3090 |
+
"parents_to_insure": p.parents_to_insure,
|
| 3091 |
+
"parents_age_max": p.parents_age_max,
|
| 3092 |
+
"parents_has_ped": p.parents_has_ped,
|
| 3093 |
+
"health_conditions": p.health_conditions,
|
| 3094 |
+
"budget_band": p.budget_band,
|
| 3095 |
+
}
|
| 3096 |
+
# Same answered-only gate as profile_completeness_view (KI-196 / ADR-041) —
|
| 3097 |
+
# only feed slots the user has actually answered, not pre-populated
|
| 3098 |
+
# defaults. Keeps the band stable until the user has actually said
|
| 3099 |
+
# something meaningful.
|
| 3100 |
+
answered = set(getattr(p, "asked", []) or [])
|
| 3101 |
+
filtered_profile = {
|
| 3102 |
+
k: (v if k in answered else None) for k, v in profile_dict.items()
|
| 3103 |
+
}
|
| 3104 |
+
band = estimate_premium_band(filtered_profile)
|
| 3105 |
+
return PredictedPremiumBandResponse(**band)
|
| 3106 |
+
|
| 3107 |
+
|
| 3108 |
+
# ---------------------------------------------------------------------------
|
| 3109 |
+
# KI-Z7 (2026-05-15) — Feature B. POST /api/profile/recall-by-name.
|
| 3110 |
+
#
|
| 3111 |
+
# The chat hot path runs the same recall heuristic server-side inside
|
| 3112 |
+
# single_brain.handle_turn (turn-1 name sniff + try_recall_by_name) and
|
| 3113 |
+
# stamps `returning_user_recalled` on the ChatResponse, so the frontend
|
| 3114 |
+
# usually doesn't need this endpoint. It exists as an explicit, idempotent
|
| 3115 |
+
# escape hatch — e.g. the user types their name into the profile builder
|
| 3116 |
+
# AFTER turn 1 and we want to load their stored facts without sending a
|
| 3117 |
+
# chat turn. The hydration is server-side: the in-memory session_state for
|
| 3118 |
+
# `session_id` is mutated in place, so the next /api/chat turn already sees
|
| 3119 |
+
# the recalled slots.
|
| 3120 |
+
# ---------------------------------------------------------------------------
|
| 3121 |
+
class RecallByNameRequest(BaseModel):
|
| 3122 |
+
name: str = Field(..., description="User-provided name (display form OK).")
|
| 3123 |
+
session_id: str = Field(..., description="Session id to hydrate on a hit.")
|
| 3124 |
+
|
| 3125 |
+
|
| 3126 |
+
class RecallByNameResponse(BaseModel):
|
| 3127 |
+
found: bool
|
| 3128 |
+
profile: Optional[dict] = None
|
| 3129 |
+
predicted_band: Optional[dict] = None
|
| 3130 |
+
session_id: str
|
| 3131 |
+
|
| 3132 |
+
|
| 3133 |
+
@app.post("/api/profile/recall-by-name", response_model=RecallByNameResponse)
|
| 3134 |
+
async def recall_profile_by_name(req: RecallByNameRequest):
|
| 3135 |
+
"""Hydrate `session_id` from the stored named-profile JSON (if any).
|
| 3136 |
+
|
| 3137 |
+
Returns `found=False` when the slug doesn't resolve to a stored file.
|
| 3138 |
+
On a hit, the live in-memory session is populated and the response
|
| 3139 |
+
carries the full union dict + predicted premium band so the UI can
|
| 3140 |
+
render the welcome-back banner without a second roundtrip.
|
| 3141 |
+
"""
|
| 3142 |
+
from backend.profile_persistence import recall_by_name_payload
|
| 3143 |
+
|
| 3144 |
+
payload = await recall_by_name_payload(req.name, req.session_id)
|
| 3145 |
+
return RecallByNameResponse(**payload)
|
| 3146 |
+
|
| 3147 |
+
|
| 3148 |
# ---- Static frontend (served alongside /api on the same port for HF Spaces) ----
|
| 3149 |
# The Next.js frontend is statically exported during the Docker build to
|
| 3150 |
# /app/frontend/out. In local dev, this directory may not exist — we still
|
|
@@ -40,6 +40,7 @@ class Profile:
|
|
| 40 |
parents_age_max: Optional[int] = None # if parents_to_insure
|
| 41 |
parents_has_ped: Optional[bool] = None # if parents_to_insure
|
| 42 |
budget_band: Optional[str] = None # "under_15k", "15k_30k", "30k_60k", "60k+"
|
|
|
|
| 43 |
health_conditions: Optional[list[str]] = field(default_factory=list) # ["diabetes", "hypertension", ...]
|
| 44 |
asked: list[str] = field(default_factory=list) # question IDs / field names already asked
|
| 45 |
free_form_session: bool = False # True = user asks free questions, not driven by us
|
|
|
|
| 40 |
parents_age_max: Optional[int] = None # if parents_to_insure
|
| 41 |
parents_has_ped: Optional[bool] = None # if parents_to_insure
|
| 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
|
|
@@ -15,13 +15,33 @@ How it works:
|
|
| 15 |
family_floater
|
| 16 |
3. Return a band of (low, mid, high) — low/high are ±15% wings around the
|
| 17 |
point estimate, reflecting underwriting variance.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
"""
|
| 19 |
|
| 20 |
from __future__ import annotations
|
| 21 |
|
| 22 |
import bisect
|
| 23 |
import json
|
| 24 |
-
from dataclasses import dataclass
|
| 25 |
from pathlib import Path
|
| 26 |
from typing import Optional
|
| 27 |
|
|
@@ -73,6 +93,102 @@ FALLBACK_PED = {
|
|
| 73 |
"multiple": 1.55,
|
| 74 |
}
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
# Co-pay reduces premium. Industry norm (PolicyBazaar/Acko): each 10 pct
|
| 77 |
# points of co-pay yields ~7% premium reduction, capped at 40% co-pay.
|
| 78 |
def _copay_multiplier(pct: float) -> float:
|
|
@@ -133,6 +249,13 @@ def estimate(
|
|
| 133 |
policy_id: Optional[str] = None,
|
| 134 |
pre_existing_conditions: str = "none",
|
| 135 |
copayment_pct: float = 0.0,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
) -> PremiumEstimate:
|
| 137 |
data = _load_data()
|
| 138 |
base_premiums = data.get("base_premiums", {})
|
|
@@ -183,6 +306,18 @@ def estimate(
|
|
| 183 |
# Co-pay discount — opting into co-payment lowers premium
|
| 184 |
base *= _copay_multiplier(copayment_pct)
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
point = int(round(base / 100) * 100) # round to nearest ₹100
|
| 187 |
return PremiumEstimate(
|
| 188 |
policy_id=policy_id or "generic",
|
|
@@ -196,3 +331,404 @@ def estimate(
|
|
| 196 |
),
|
| 197 |
sources=sources or [],
|
| 198 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
family_floater
|
| 16 |
3. Return a band of (low, mid, high) — low/high are ±15% wings around the
|
| 17 |
point estimate, reflecting underwriting variance.
|
| 18 |
+
|
| 19 |
+
═══════════════════════════════════════════════════════════════════════════
|
| 20 |
+
SLOT_UNION → pricing-influence map (B6, 2026-05-15)
|
| 21 |
+
═══════════════════════════════════════════════════════════════════════════
|
| 22 |
+
The full slot list lives in `backend/brain_tools.py::SLOT_UNION`. Slots
|
| 23 |
+
that influence the per-policy premium estimate (in addition to age /
|
| 24 |
+
location / family_size that B2 already handles):
|
| 25 |
+
|
| 26 |
+
health_conditions → health_loading 1.0× / 1.2× / 1.4× / 1.5×
|
| 27 |
+
existing_cover_inr → existing_cover_loading 1.0× / 0.95× / 0.85×
|
| 28 |
+
desired_sum_insured_inr → overrides default SI per-policy
|
| 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
|
| 35 |
+
multiplicative chain). budget_band is a band-MATCH input downstream
|
| 36 |
+
(e.g. scorecard fit), not a premium-direction input.
|
| 37 |
+
═══════════════════════════════════════════════════════════════════════════
|
| 38 |
"""
|
| 39 |
|
| 40 |
from __future__ import annotations
|
| 41 |
|
| 42 |
import bisect
|
| 43 |
import json
|
| 44 |
+
from dataclasses import dataclass, field
|
| 45 |
from pathlib import Path
|
| 46 |
from typing import Optional
|
| 47 |
|
|
|
|
| 93 |
"multiple": 1.55,
|
| 94 |
}
|
| 95 |
|
| 96 |
+
# ───────────────────────────────────────────────────────────────────────────
|
| 97 |
+
# B6 loadings — profile-driven multipliers consumed by BOTH estimate() and
|
| 98 |
+
# bulk_estimate() so the per-policy point estimate and the slider widget
|
| 99 |
+
# agree by construction.
|
| 100 |
+
# ───────────────────────────────────────────────────────────────────────────
|
| 101 |
+
|
| 102 |
+
# Health condition loading — applied multiplicatively after PED loading.
|
| 103 |
+
# Source band: PolicyBazaar PED articles + Acko underwriting guides.
|
| 104 |
+
# • diabetes / BP (hypertension) → 1.20×
|
| 105 |
+
# • heart / cancer (severe chronic) → 1.40×
|
| 106 |
+
# • 2+ chronic conditions (compounded) → 1.50× (overrides the above)
|
| 107 |
+
_HEALTH_DIABETES_BP = {"diabetes", "bp", "hypertension", "high bp", "hi-bp", "high-bp"}
|
| 108 |
+
_HEALTH_SEVERE = {"heart", "heart disease", "cardiac", "cancer", "stroke"}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _health_loading(health_conditions) -> tuple[float, str]:
|
| 112 |
+
"""Return (multiplier, label) for a health_conditions list.
|
| 113 |
+
|
| 114 |
+
Accepts list[str] (canonical), comma-joined string, or None. The empty
|
| 115 |
+
list and the sentinel ["none"] both map to 1.0×. Real conditions are
|
| 116 |
+
matched against the diabetes/BP and severe buckets case-insensitively.
|
| 117 |
+
"""
|
| 118 |
+
if not health_conditions:
|
| 119 |
+
return 1.0, "no_conditions"
|
| 120 |
+
if isinstance(health_conditions, str):
|
| 121 |
+
items = [t.strip().lower() for t in health_conditions.split(",") if t.strip()]
|
| 122 |
+
else:
|
| 123 |
+
items = [str(t).strip().lower() for t in health_conditions if str(t).strip()]
|
| 124 |
+
# Strip the explicit-negation sentinel.
|
| 125 |
+
items = [t for t in items if t != "none"]
|
| 126 |
+
if not items:
|
| 127 |
+
return 1.0, "no_conditions"
|
| 128 |
+
has_diabetes_bp = any(t in _HEALTH_DIABETES_BP for t in items)
|
| 129 |
+
has_severe = any(any(s in t for s in _HEALTH_SEVERE) for t in items)
|
| 130 |
+
# 2+ chronic conditions → highest multiplier (overrides the others).
|
| 131 |
+
if len(items) >= 2:
|
| 132 |
+
return 1.50, "two_plus_chronic"
|
| 133 |
+
if has_severe:
|
| 134 |
+
return 1.40, "severe_chronic"
|
| 135 |
+
if has_diabetes_bp:
|
| 136 |
+
return 1.20, "diabetes_or_bp"
|
| 137 |
+
# Unrecognised single condition — treat as mild loading.
|
| 138 |
+
return 1.10, "other_single"
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _existing_cover_loading(existing_cover_inr) -> tuple[float, str]:
|
| 142 |
+
"""Return (multiplier, label) for existing_cover_inr.
|
| 143 |
+
|
| 144 |
+
Rationale: if the user already has cover, a top-up policy is cheaper
|
| 145 |
+
than a full base policy (insurer collects less risk + can price for the
|
| 146 |
+
cover gap only). Thresholds: <₹5L = mild discount (corporate top-up),
|
| 147 |
+
≥₹5L = larger discount (only super-top-up needed).
|
| 148 |
+
"""
|
| 149 |
+
try:
|
| 150 |
+
ec = int(existing_cover_inr or 0)
|
| 151 |
+
except (TypeError, ValueError):
|
| 152 |
+
ec = 0
|
| 153 |
+
if ec <= 0:
|
| 154 |
+
return 1.0, "no_existing_cover"
|
| 155 |
+
if ec < 500_000:
|
| 156 |
+
return 0.95, "corporate_topup"
|
| 157 |
+
return 0.85, "significant_existing_cover"
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _parents_loading(dependents, parents_age_max, parents_has_ped=None) -> tuple[float, str]:
|
| 161 |
+
"""Return (multiplier, label) for parents-on-cover scenarios.
|
| 162 |
+
|
| 163 |
+
Only fires when `dependents` mentions "parent" (case-insensitive). The
|
| 164 |
+
multiplier is age-banded:
|
| 165 |
+
• <60 → 1.0× (parents counted in family loading already)
|
| 166 |
+
• 60–70 → 1.40×
|
| 167 |
+
• 70+ → 1.80×
|
| 168 |
+
`parents_has_ped=True` adds a flat +0.10× on top (PED loading inflated
|
| 169 |
+
for the older age cohort).
|
| 170 |
+
"""
|
| 171 |
+
has_parents = False
|
| 172 |
+
if dependents:
|
| 173 |
+
has_parents = "parent" in str(dependents).lower()
|
| 174 |
+
if not has_parents or parents_age_max in (None, "", 0):
|
| 175 |
+
return 1.0, "no_parents_on_cover"
|
| 176 |
+
try:
|
| 177 |
+
age = int(parents_age_max)
|
| 178 |
+
except (TypeError, ValueError):
|
| 179 |
+
return 1.0, "no_parents_on_cover"
|
| 180 |
+
if age < 60:
|
| 181 |
+
base, label = 1.0, "parents_under_60"
|
| 182 |
+
elif age <= 70:
|
| 183 |
+
base, label = 1.40, "parents_60_70"
|
| 184 |
+
else:
|
| 185 |
+
base, label = 1.80, "parents_70_plus"
|
| 186 |
+
if parents_has_ped is True and base > 1.0:
|
| 187 |
+
base += 0.10
|
| 188 |
+
label = f"{label}_with_ped"
|
| 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:
|
|
|
|
| 249 |
policy_id: Optional[str] = None,
|
| 250 |
pre_existing_conditions: str = "none",
|
| 251 |
copayment_pct: float = 0.0,
|
| 252 |
+
# B6 additions — SLOT_UNION pricing inputs. All optional so legacy
|
| 253 |
+
# callers (B2's bulk_estimate, tests) keep working unchanged.
|
| 254 |
+
health_conditions: Optional[list] = None,
|
| 255 |
+
existing_cover_inr: Optional[int] = None,
|
| 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", {})
|
|
|
|
| 306 |
# Co-pay discount — opting into co-payment lowers premium
|
| 307 |
base *= _copay_multiplier(copayment_pct)
|
| 308 |
|
| 309 |
+
# B6 loadings — health, existing cover, parents-on-cover. Each is
|
| 310 |
+
# 1.0× when the corresponding SLOT_UNION field is absent so legacy
|
| 311 |
+
# callers see no change in output.
|
| 312 |
+
health_mult, health_label = _health_loading(health_conditions)
|
| 313 |
+
base *= health_mult
|
| 314 |
+
ec_mult, ec_label = _existing_cover_loading(existing_cover_inr)
|
| 315 |
+
base *= ec_mult
|
| 316 |
+
parents_mult, parents_label = _parents_loading(
|
| 317 |
+
dependents, parents_age_max, parents_has_ped
|
| 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",
|
|
|
|
| 331 |
),
|
| 332 |
sources=sources or [],
|
| 333 |
)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
# ---------------------------------------------------------------------------
|
| 337 |
+
# Bulk / slider widget heuristic — used by /api/premium/bulk so the
|
| 338 |
+
# PolicyCompareModal premium widget can render fast estimates for several
|
| 339 |
+
# policies at once. Same shape per policy: a transparent multiplicative
|
| 340 |
+
# breakdown the UI can render as bullets.
|
| 341 |
+
#
|
| 342 |
+
# This is intentionally simpler than estimate(): a fixed ₹500 per ₹1L SI per
|
| 343 |
+
# year base × age × location × family × deductible × tenure. When the curated
|
| 344 |
+
# illustrative_premiums.json HAS a real sample for a policy we anchor the base
|
| 345 |
+
# to it (assumed=False); otherwise we use the flat base rate (assumed=True)
|
| 346 |
+
# and the UI labels the value "Estimate".
|
| 347 |
+
# ---------------------------------------------------------------------------
|
| 348 |
+
|
| 349 |
+
# ₹500 per ₹1L SI per year — typical Indian retail health entry-tier base.
|
| 350 |
+
BULK_BASE_INR_PER_LAKH = 500
|
| 351 |
+
|
| 352 |
+
BULK_AGE_BANDS = [
|
| 353 |
+
(30, 1.0), # 18–30
|
| 354 |
+
(45, 1.5), # 30–45
|
| 355 |
+
(60, 2.5), # 45–60
|
| 356 |
+
(200, 4.0), # 60+
|
| 357 |
+
]
|
| 358 |
+
|
| 359 |
+
BULK_LOCATION_LOADING = {
|
| 360 |
+
"metro": 1.2,
|
| 361 |
+
"tier1": 1.0,
|
| 362 |
+
"tier-1": 1.0,
|
| 363 |
+
"tier_1": 1.0,
|
| 364 |
+
"tier2": 1.0,
|
| 365 |
+
"tier-2": 1.0,
|
| 366 |
+
"tier_2": 1.0,
|
| 367 |
+
"tier3": 0.85,
|
| 368 |
+
"tier-3": 0.85,
|
| 369 |
+
"tier_3": 0.85,
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
# Family-floater uplift over individual (1.6× family floater per spec).
|
| 373 |
+
BULK_FAMILY_FLOATER_MULT = 1.6
|
| 374 |
+
|
| 375 |
+
# Deductible discount — higher voluntary deductible lowers the premium.
|
| 376 |
+
# Linear approximation; sources: PolicyBazaar deductible guides.
|
| 377 |
+
BULK_DEDUCTIBLE_DISCOUNT = {
|
| 378 |
+
0: 1.0,
|
| 379 |
+
25000: 0.92,
|
| 380 |
+
50000: 0.85,
|
| 381 |
+
100000: 0.75,
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
# Tenure loading — multi-year policies typically get a 5–10% per-year discount.
|
| 385 |
+
BULK_TENURE_MULT = {
|
| 386 |
+
1: 1.0,
|
| 387 |
+
2: 0.95,
|
| 388 |
+
3: 0.90,
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def _bulk_age_mult(age: int) -> tuple[float, str]:
|
| 393 |
+
for ceiling, mult in BULK_AGE_BANDS:
|
| 394 |
+
if age < ceiling:
|
| 395 |
+
band = (
|
| 396 |
+
"18-30" if ceiling == 30 else
|
| 397 |
+
"30-45" if ceiling == 45 else
|
| 398 |
+
"45-60" if ceiling == 60 else
|
| 399 |
+
"60+"
|
| 400 |
+
)
|
| 401 |
+
return mult, band
|
| 402 |
+
return 4.0, "60+"
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def _bulk_location_mult(tier: Optional[str]) -> tuple[float, str]:
|
| 406 |
+
key = (tier or "metro").lower().strip()
|
| 407 |
+
return BULK_LOCATION_LOADING.get(key, 1.0), key
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def _bulk_family_size_from_dependents(dependents: Optional[str], family_size: Optional[int]) -> int:
|
| 411 |
+
"""Coerce the profile's free-text `dependents` string OR explicit
|
| 412 |
+
family_size into an integer headcount (self + dependents)."""
|
| 413 |
+
if isinstance(family_size, int) and family_size > 0:
|
| 414 |
+
return family_size
|
| 415 |
+
if not dependents:
|
| 416 |
+
return 1
|
| 417 |
+
s = str(dependents).lower()
|
| 418 |
+
# Count keyword hits + digit-prefixed counts (e.g. "2 kids", "1 child").
|
| 419 |
+
import re as _re
|
| 420 |
+
headcount = 1 # self
|
| 421 |
+
has_spouse = any(k in s for k in ("spouse", "wife", "husband", "partner"))
|
| 422 |
+
if has_spouse:
|
| 423 |
+
headcount += 1
|
| 424 |
+
# Children: try "N kid(s)/child/children" first, else any "kid/child" keyword = +1
|
| 425 |
+
kid_match = _re.search(r"(\d+)\s*(kid|child|son|daughter)", s)
|
| 426 |
+
if kid_match:
|
| 427 |
+
headcount += max(1, int(kid_match.group(1)))
|
| 428 |
+
elif any(k in s for k in ("kid", "child", "son", "daughter")):
|
| 429 |
+
headcount += 1
|
| 430 |
+
# Parents — explicit "parent(s)" keyword adds +1 each on a single mention.
|
| 431 |
+
if "parent" in s:
|
| 432 |
+
headcount += 1
|
| 433 |
+
# Family-of-N pattern: "family of 4"
|
| 434 |
+
fof = _re.search(r"family\s+of\s+(\d+)", s)
|
| 435 |
+
if fof:
|
| 436 |
+
headcount = max(headcount, int(fof.group(1)))
|
| 437 |
+
# Bare integer at sentence start ("3 dependents") — only honour if no keywords matched
|
| 438 |
+
if headcount == 1 and not has_spouse:
|
| 439 |
+
m = _re.search(r"(\d+)", s)
|
| 440 |
+
if m:
|
| 441 |
+
try:
|
| 442 |
+
headcount = max(1, int(m.group(1)))
|
| 443 |
+
except ValueError:
|
| 444 |
+
pass
|
| 445 |
+
return max(1, headcount)
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
def _round_inr(x: float) -> int:
|
| 449 |
+
return int(round(x / 10) * 10)
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
@dataclass
|
| 453 |
+
class BulkPolicyPremium:
|
| 454 |
+
policy_id: str
|
| 455 |
+
premium_inr_annual: int
|
| 456 |
+
breakdown: dict
|
| 457 |
+
sum_insured_inr: int
|
| 458 |
+
tenure_years: int
|
| 459 |
+
deductible_inr: int
|
| 460 |
+
assumed: bool
|
| 461 |
+
notes: list[str] = field(default_factory=list)
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
def bulk_estimate(
|
| 465 |
+
policy_ids: list[str],
|
| 466 |
+
profile: Optional[dict] = None,
|
| 467 |
+
overrides: Optional[dict] = None,
|
| 468 |
+
) -> dict[str, BulkPolicyPremium]:
|
| 469 |
+
"""Compute heuristic per-policy premiums for the widget.
|
| 470 |
+
|
| 471 |
+
profile keys (all optional): age, dependents, location_tier, family_size,
|
| 472 |
+
smoker, pre_existing_conditions.
|
| 473 |
+
overrides[policy_id]: sum_insured_inr / tenure_years / deductible_inr.
|
| 474 |
+
"""
|
| 475 |
+
profile = profile or {}
|
| 476 |
+
overrides = overrides or {}
|
| 477 |
+
|
| 478 |
+
age = int(profile.get("age") or 35)
|
| 479 |
+
location_tier = profile.get("location_tier") or "metro"
|
| 480 |
+
family_size = _bulk_family_size_from_dependents(
|
| 481 |
+
profile.get("dependents"), profile.get("family_size")
|
| 482 |
+
)
|
| 483 |
+
|
| 484 |
+
# B6 SLOT_UNION pricing inputs — read from the same profile dict so the
|
| 485 |
+
# bulk widget and the per-policy estimate() agree by construction.
|
| 486 |
+
health_conditions = profile.get("health_conditions")
|
| 487 |
+
existing_cover_inr = profile.get("existing_cover_inr")
|
| 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).
|
| 494 |
+
desired_si = profile.get("desired_sum_insured_inr")
|
| 495 |
+
|
| 496 |
+
data = _load_data()
|
| 497 |
+
base_premiums_curated = data.get("base_premiums", {})
|
| 498 |
+
|
| 499 |
+
age_mult, age_band = _bulk_age_mult(age)
|
| 500 |
+
loc_mult, loc_label = _bulk_location_mult(location_tier)
|
| 501 |
+
family_mult = BULK_FAMILY_FLOATER_MULT if family_size >= 2 else 1.0
|
| 502 |
+
health_mult, health_label = _health_loading(health_conditions)
|
| 503 |
+
ec_mult, ec_label = _existing_cover_loading(existing_cover_inr)
|
| 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:
|
| 510 |
+
ov = overrides.get(pid) or {}
|
| 511 |
+
# Override precedence: per-policy override > desired_sum_insured_inr
|
| 512 |
+
# from profile > ₹10L hard default. This is how
|
| 513 |
+
# desired_sum_insured_inr propagates through the widget.
|
| 514 |
+
sum_insured_inr = int(
|
| 515 |
+
ov.get("sum_insured_inr") or desired_si or 1_000_000
|
| 516 |
+
)
|
| 517 |
+
tenure_years = int(ov.get("tenure_years") or 1)
|
| 518 |
+
if tenure_years not in BULK_TENURE_MULT:
|
| 519 |
+
tenure_years = 1
|
| 520 |
+
deductible_inr = int(ov.get("deductible_inr") or 0)
|
| 521 |
+
if deductible_inr not in BULK_DEDUCTIBLE_DISCOUNT:
|
| 522 |
+
# snap to nearest known bucket
|
| 523 |
+
deductible_inr = min(BULK_DEDUCTIBLE_DISCOUNT.keys(), key=lambda d: abs(d - deductible_inr))
|
| 524 |
+
|
| 525 |
+
notes: list[str] = []
|
| 526 |
+
assumed = True
|
| 527 |
+
|
| 528 |
+
# Anchor base to curated sample if we have one, else flat per-lakh rate.
|
| 529 |
+
anchored_base: Optional[int] = None
|
| 530 |
+
if pid in base_premiums_curated:
|
| 531 |
+
try:
|
| 532 |
+
ce = estimate(
|
| 533 |
+
age=age,
|
| 534 |
+
sum_insured_inr=sum_insured_inr,
|
| 535 |
+
city_tier="metro" if loc_label == "metro" else ("tier1" if "1" in loc_label else "tier2"),
|
| 536 |
+
smoker=bool(profile.get("smoker", False)),
|
| 537 |
+
family_size=max(0, family_size - 1),
|
| 538 |
+
policy_id=pid,
|
| 539 |
+
pre_existing_conditions=profile.get("pre_existing_conditions") or "none",
|
| 540 |
+
copayment_pct=0.0,
|
| 541 |
+
# B6 — pass SLOT_UNION pricing inputs through so the
|
| 542 |
+
# curated path absorbs health/existing-cover/parents
|
| 543 |
+
# loadings inside estimate(). We then mark these as
|
| 544 |
+
# 1.0× in the breakdown to avoid double-counting.
|
| 545 |
+
health_conditions=health_conditions,
|
| 546 |
+
existing_cover_inr=existing_cover_inr,
|
| 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
|
| 553 |
+
# multiplicative bullets uniformly.
|
| 554 |
+
anchored_base = ce.point_estimate_inr
|
| 555 |
+
assumed = False
|
| 556 |
+
notes.append("Anchored to curated public-quote sample.")
|
| 557 |
+
except Exception:
|
| 558 |
+
anchored_base = None
|
| 559 |
+
|
| 560 |
+
si_lakhs = max(1, sum_insured_inr // 100_000)
|
| 561 |
+
flat_base = BULK_BASE_INR_PER_LAKH * si_lakhs
|
| 562 |
+
|
| 563 |
+
if anchored_base is not None:
|
| 564 |
+
# Apply tenure + deductible only — the curated path already
|
| 565 |
+
# absorbed age/location/family + B6 loadings inside estimate().
|
| 566 |
+
tenure_mult = BULK_TENURE_MULT.get(tenure_years, 1.0)
|
| 567 |
+
ded_mult = BULK_DEDUCTIBLE_DISCOUNT.get(deductible_inr, 1.0)
|
| 568 |
+
final = anchored_base * tenure_mult * ded_mult
|
| 569 |
+
breakdown = {
|
| 570 |
+
"base_inr": int(anchored_base),
|
| 571 |
+
"age_loading_x": 1.0,
|
| 572 |
+
"location_loading_x": 1.0,
|
| 573 |
+
"family_loading_x": 1.0,
|
| 574 |
+
"tenure_discount_x": round(tenure_mult, 3),
|
| 575 |
+
"deductible_discount_x": round(ded_mult, 3),
|
| 576 |
+
}
|
| 577 |
+
else:
|
| 578 |
+
tenure_mult = BULK_TENURE_MULT.get(tenure_years, 1.0)
|
| 579 |
+
ded_mult = BULK_DEDUCTIBLE_DISCOUNT.get(deductible_inr, 1.0)
|
| 580 |
+
final = (
|
| 581 |
+
flat_base
|
| 582 |
+
* age_mult
|
| 583 |
+
* loc_mult
|
| 584 |
+
* family_mult
|
| 585 |
+
* health_mult
|
| 586 |
+
* ec_mult
|
| 587 |
+
* parents_mult
|
| 588 |
+
* tenure_mult
|
| 589 |
+
* ded_mult
|
| 590 |
+
)
|
| 591 |
+
breakdown = {
|
| 592 |
+
"base_inr": int(flat_base),
|
| 593 |
+
"age_loading_x": round(age_mult, 3),
|
| 594 |
+
"age_band": age_band,
|
| 595 |
+
"location_loading_x": round(loc_mult, 3),
|
| 596 |
+
"location_tier": loc_label,
|
| 597 |
+
"family_loading_x": round(family_mult, 3),
|
| 598 |
+
"family_size": family_size,
|
| 599 |
+
"tenure_discount_x": round(tenure_mult, 3),
|
| 600 |
+
"deductible_discount_x": round(ded_mult, 3),
|
| 601 |
+
}
|
| 602 |
+
notes.append(
|
| 603 |
+
"Heuristic estimate — no exact actuarial data for this policy. "
|
| 604 |
+
"Base ₹500 per ₹1L SI per year × age × location × family × tenure × deductible."
|
| 605 |
+
)
|
| 606 |
+
|
| 607 |
+
# B6 — surface non-1.0× SLOT_UNION loadings in the breakdown
|
| 608 |
+
# regardless of which branch produced the base. UI can render
|
| 609 |
+
# "Diabetes/BP loading × 1.20" bullets when the user has the
|
| 610 |
+
# corresponding profile slot captured.
|
| 611 |
+
if health_mult != 1.0:
|
| 612 |
+
breakdown["health_loading_x"] = round(health_mult, 3)
|
| 613 |
+
breakdown["health_loading_reason"] = health_label
|
| 614 |
+
if ec_mult != 1.0:
|
| 615 |
+
breakdown["existing_cover_loading_x"] = round(ec_mult, 3)
|
| 616 |
+
breakdown["existing_cover_loading_reason"] = ec_label
|
| 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 |
+
|
| 623 |
+
out[pid] = BulkPolicyPremium(
|
| 624 |
+
policy_id=pid,
|
| 625 |
+
premium_inr_annual=_round_inr(final),
|
| 626 |
+
breakdown=breakdown,
|
| 627 |
+
sum_insured_inr=sum_insured_inr,
|
| 628 |
+
tenure_years=tenure_years,
|
| 629 |
+
deductible_inr=deductible_inr,
|
| 630 |
+
assumed=assumed,
|
| 631 |
+
notes=notes,
|
| 632 |
+
)
|
| 633 |
+
return out
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
# ---------------------------------------------------------------------------
|
| 637 |
+
# Profile-level premium BAND — used by the chat-UI "Est. premium ₹X–₹Y/yr"
|
| 638 |
+
# chip that sits next to the profile-completeness pill. Aggregates the bulk
|
| 639 |
+
# heuristic across a representative basket of marketplace policies so the
|
| 640 |
+
# user sees what their personal premium envelope looks like as the profile
|
| 641 |
+
# fills in (reactively updates with each completeness change).
|
| 642 |
+
# ---------------------------------------------------------------------------
|
| 643 |
+
|
| 644 |
+
# Representative basket for the band — 26 curated marketplace policies that
|
| 645 |
+
# span every major insurer + product tier. Mirrors keys in
|
| 646 |
+
# 40-data/premiums/illustrative_premiums.json so anchored samples are used
|
| 647 |
+
# where available and the flat per-lakh fallback fills the rest.
|
| 648 |
+
_DEFAULT_BAND_POLICY_IDS: list[str] = [
|
| 649 |
+
"hdfc-ergo__optima-secure",
|
| 650 |
+
"hdfc-ergo__optima-restore",
|
| 651 |
+
"hdfc-ergo__optima-plus",
|
| 652 |
+
"hdfc-ergo__energy",
|
| 653 |
+
"care-health__care-supreme",
|
| 654 |
+
"care-health__care-classic",
|
| 655 |
+
"care-health__care-senior",
|
| 656 |
+
"care-health__care-advantage",
|
| 657 |
+
"aditya-birla__activ-assure-diamond",
|
| 658 |
+
"aditya-birla__group-activ-health",
|
| 659 |
+
"bajaj-allianz__health-guard",
|
| 660 |
+
"bajaj-allianz__silver-health",
|
| 661 |
+
"bajaj-allianz__tax-gain",
|
| 662 |
+
"icici-lombard__elevate",
|
| 663 |
+
"icici-lombard__health-advantedge",
|
| 664 |
+
"niva-bupa__reassure",
|
| 665 |
+
"niva-bupa__health-premia",
|
| 666 |
+
"niva-bupa__aspire",
|
| 667 |
+
"new-india__asha-kiran",
|
| 668 |
+
"new-india__mediclaim",
|
| 669 |
+
"tata-aig__medicare",
|
| 670 |
+
"tata-aig__medicare-premier",
|
| 671 |
+
"manipalcigna__prohealth-prime-active",
|
| 672 |
+
"star-health__family-health-optima",
|
| 673 |
+
"star-health__comprehensive",
|
| 674 |
+
"star-health__senior-citizens-red-carpet",
|
| 675 |
+
]
|
| 676 |
+
|
| 677 |
+
|
| 678 |
+
def _round_to_500(x: float) -> int:
|
| 679 |
+
"""Round to nearest ₹500 — band-display granularity (per spec)."""
|
| 680 |
+
return int(round(float(x) / 500.0) * 500)
|
| 681 |
+
|
| 682 |
+
|
| 683 |
+
def _median(xs: list[int]) -> int:
|
| 684 |
+
n = len(xs)
|
| 685 |
+
if n == 0:
|
| 686 |
+
return 0
|
| 687 |
+
s = sorted(xs)
|
| 688 |
+
mid = n // 2
|
| 689 |
+
if n % 2 == 1:
|
| 690 |
+
return int(s[mid])
|
| 691 |
+
return int((s[mid - 1] + s[mid]) / 2)
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
def estimate_premium_band(
|
| 695 |
+
profile: Optional[dict] = None,
|
| 696 |
+
candidate_policy_ids: Optional[list[str]] = None,
|
| 697 |
+
sum_insured_default: int = 1_000_000,
|
| 698 |
+
) -> dict:
|
| 699 |
+
"""Compute the user's predicted-premium BAND across a representative basket.
|
| 700 |
+
|
| 701 |
+
Returns: {min_inr, median_inr, max_inr, sample_size, assumed}. Rounded
|
| 702 |
+
to the nearest ₹500. `assumed` is True whenever ANY policy in the basket
|
| 703 |
+
used the heuristic fallback (which is effectively always for now).
|
| 704 |
+
"""
|
| 705 |
+
profile = profile or {}
|
| 706 |
+
pids = candidate_policy_ids or list(_DEFAULT_BAND_POLICY_IDS)
|
| 707 |
+
|
| 708 |
+
# Reuse B2's bulk heuristic so the chip and the slider widget agree by
|
| 709 |
+
# construction. Default to ₹10L SI per policy unless the caller overrides.
|
| 710 |
+
overrides = {pid: {"sum_insured_inr": sum_insured_default} for pid in pids}
|
| 711 |
+
try:
|
| 712 |
+
rows = bulk_estimate(policy_ids=pids, profile=profile, overrides=overrides)
|
| 713 |
+
except Exception:
|
| 714 |
+
rows = {}
|
| 715 |
+
|
| 716 |
+
premiums = [int(r.premium_inr_annual) for r in rows.values() if r.premium_inr_annual]
|
| 717 |
+
any_assumed = any(r.assumed for r in rows.values()) if rows else True
|
| 718 |
+
|
| 719 |
+
if not premiums:
|
| 720 |
+
return {
|
| 721 |
+
"min_inr": 0,
|
| 722 |
+
"median_inr": 0,
|
| 723 |
+
"max_inr": 0,
|
| 724 |
+
"sample_size": 0,
|
| 725 |
+
"assumed": True,
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
+
return {
|
| 729 |
+
"min_inr": _round_to_500(min(premiums)),
|
| 730 |
+
"median_inr": _round_to_500(_median(premiums)),
|
| 731 |
+
"max_inr": _round_to_500(max(premiums)),
|
| 732 |
+
"sample_size": len(premiums),
|
| 733 |
+
"assumed": bool(any_assumed),
|
| 734 |
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Post-turn auto-persistence + returning-user recall helpers.
|
| 2 |
+
|
| 3 |
+
Coupled features (KI-Z7, 2026-05-15):
|
| 4 |
+
|
| 5 |
+
Feature A — auto_persist_session(session)
|
| 6 |
+
Called from /api/chat AFTER single_brain.handle_turn returns and BEFORE
|
| 7 |
+
the ChatResponse is built. Reads the union of all 13 slot_union fields
|
| 8 |
+
from session.profile and pushes them to:
|
| 9 |
+
- profile_store.save_profile(...) — canonical JSON on disk
|
| 10 |
+
- profile_rag.upsert_profile_chunk(...) — Chroma vector chunk
|
| 11 |
+
Gated on session.profile.name being non-empty (anonymous turns never
|
| 12 |
+
persist — KI-118).
|
| 13 |
+
|
| 14 |
+
Wrapped in try/except internally; persistence failure NEVER raises and
|
| 15 |
+
NEVER affects the reply path.
|
| 16 |
+
|
| 17 |
+
Feature B — extract_potential_name(text)
|
| 18 |
+
Cheap regex heuristic that recovers a first name from a turn-1 user
|
| 19 |
+
utterance ("Hi I'm Priya", "My name is Rajesh", "Anjali here"). Returns
|
| 20 |
+
None when no clear name is present (e.g. "I'm 34 years old"). Used by
|
| 21 |
+
single_brain.handle_turn so a returning user is recognised BEFORE
|
| 22 |
+
Gemini's first tool-call iteration.
|
| 23 |
+
|
| 24 |
+
Feature B — try_recall_by_name(session, name)
|
| 25 |
+
Loads the named profile JSON via profile_store.load_profile and hydrates
|
| 26 |
+
every empty slot on session.profile. Returns True on a successful merge
|
| 27 |
+
so single_brain can stamp `is_returning_user=True` for the RULE 4
|
| 28 |
+
"welcome back" greeting.
|
| 29 |
+
|
| 30 |
+
Feature B — recall_by_name_payload(name, session_id)
|
| 31 |
+
Builds the response payload for the POST /api/profile/recall-by-name
|
| 32 |
+
endpoint: {found, profile, predicted_band, session_id}. predicted_band
|
| 33 |
+
is computed via premium_calculator.estimate_premium_band against the
|
| 34 |
+
just-hydrated profile.
|
| 35 |
+
|
| 36 |
+
Design notes:
|
| 37 |
+
- This module sits between profile_store + profile_rag and is the SINGLE
|
| 38 |
+
write path the /api/chat hot loop reaches; brain_tools.save_profile_field
|
| 39 |
+
still only mutates the in-memory Profile (B6 owns that file).
|
| 40 |
+
- No async required for the JSON write; profile_rag.upsert_profile_chunk
|
| 41 |
+
is async (embedder), so auto_persist_session is async too.
|
| 42 |
+
- All callers MUST await it and swallow exceptions defensively. We do the
|
| 43 |
+
defensive swallow internally as belt-and-suspenders so callers can
|
| 44 |
+
simply `await auto_persist_session(session)` without try/except.
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
from __future__ import annotations
|
| 48 |
+
|
| 49 |
+
import logging
|
| 50 |
+
import re
|
| 51 |
+
from typing import Any, Optional
|
| 52 |
+
|
| 53 |
+
_log = logging.getLogger(__name__)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ---------------------------------------------------------------------------
|
| 57 |
+
# Feature A — post-turn persistence
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# The 13 fields that make up the "slot_union" — what we persist on every
|
| 62 |
+
# auto_persist_session call. Kept in sync with the schemas in
|
| 63 |
+
# needs_finder.Profile and brain_tools.save_profile_field's accepted fields.
|
| 64 |
+
_UNION_FIELDS: tuple[str, ...] = (
|
| 65 |
+
"name",
|
| 66 |
+
"age",
|
| 67 |
+
"dependents",
|
| 68 |
+
"income_band",
|
| 69 |
+
"existing_cover_inr",
|
| 70 |
+
"primary_goal",
|
| 71 |
+
"location_tier",
|
| 72 |
+
"parents_to_insure",
|
| 73 |
+
"parents_age_max",
|
| 74 |
+
"parents_has_ped",
|
| 75 |
+
"health_conditions",
|
| 76 |
+
"budget_band",
|
| 77 |
+
"desired_sum_insured_inr",
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _build_union_dict(profile) -> dict[str, Any]:
|
| 82 |
+
"""Build the persistence payload — dict of all 13 union fields."""
|
| 83 |
+
return {f: getattr(profile, f, None) for f in _UNION_FIELDS}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
async def auto_persist_session(session) -> bool:
|
| 87 |
+
"""Persist the live session profile to disk + Chroma.
|
| 88 |
+
|
| 89 |
+
No-op (returns False) when:
|
| 90 |
+
- session is None
|
| 91 |
+
- session.profile.name is empty / None
|
| 92 |
+
- either underlying write raises (logged, then swallowed)
|
| 93 |
+
|
| 94 |
+
Returns True iff BOTH save_profile() AND upsert_profile_chunk() ran
|
| 95 |
+
without raising. Persistence failure NEVER bubbles out — the chat
|
| 96 |
+
reply must not be blocked by a stuck disk or a Chroma hiccup.
|
| 97 |
+
"""
|
| 98 |
+
if session is None:
|
| 99 |
+
return False
|
| 100 |
+
profile = getattr(session, "profile", None)
|
| 101 |
+
if profile is None:
|
| 102 |
+
return False
|
| 103 |
+
name = (getattr(profile, "name", None) or "").strip()
|
| 104 |
+
if not name:
|
| 105 |
+
# Anonymous turn — never write to disk, never embed (KI-118).
|
| 106 |
+
return False
|
| 107 |
+
|
| 108 |
+
union = _build_union_dict(profile)
|
| 109 |
+
saved_json = False
|
| 110 |
+
saved_chunk = False
|
| 111 |
+
|
| 112 |
+
# 1) Canonical JSON on disk.
|
| 113 |
+
try:
|
| 114 |
+
from backend.profile_store import save_profile
|
| 115 |
+
|
| 116 |
+
saved_json = save_profile(
|
| 117 |
+
name,
|
| 118 |
+
profile,
|
| 119 |
+
session_id=getattr(session, "session_id", None),
|
| 120 |
+
)
|
| 121 |
+
except Exception as e: # noqa: BLE001
|
| 122 |
+
_log.warning(
|
| 123 |
+
"auto_persist_session: save_profile failed (name=%r): %s: %s",
|
| 124 |
+
name, type(e).__name__, str(e)[:200],
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
# 2) Vector chunk (gated on a derivable name_slug — same gate as the
|
| 128 |
+
# POST /api/profile path).
|
| 129 |
+
try:
|
| 130 |
+
from backend.profile_store import _normalise_name
|
| 131 |
+
from backend.profile_rag import upsert_profile_chunk
|
| 132 |
+
|
| 133 |
+
name_slug = _normalise_name(name)
|
| 134 |
+
if name_slug:
|
| 135 |
+
await upsert_profile_chunk(name_slug, union)
|
| 136 |
+
saved_chunk = True
|
| 137 |
+
except Exception as e: # noqa: BLE001
|
| 138 |
+
_log.warning(
|
| 139 |
+
"auto_persist_session: upsert_profile_chunk failed (name=%r): %s: %s",
|
| 140 |
+
name, type(e).__name__, str(e)[:200],
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
return saved_json and saved_chunk
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ---------------------------------------------------------------------------
|
| 147 |
+
# Feature B — turn-1 name heuristic + returning-user recall
|
| 148 |
+
# ---------------------------------------------------------------------------
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# Match an explicit self-introduction. Three forms cover the common cases:
|
| 152 |
+
# 1. "I'm <name>" / "I am <name>"
|
| 153 |
+
# 2. "My name is <name>" / "this is <name>"
|
| 154 |
+
# 3. "<name> here"
|
| 155 |
+
# We deliberately require a verb-ish anchor — bare "Priya." with no
|
| 156 |
+
# surrounding context is too noisy on turn 1 (it could be a question subject)
|
| 157 |
+
# and the existing brain_tools.save_profile_field path will pick it up
|
| 158 |
+
# safely if the user repeats it.
|
| 159 |
+
_NAME_RE = re.compile(
|
| 160 |
+
r"""
|
| 161 |
+
(?:
|
| 162 |
+
\b(?:i\s*am|i'?m|my\s+name\s+is|this\s+is|name'?s)\s+
|
| 163 |
+
(?P<n1>[A-Z][a-zA-Z]{1,30}(?:\s+[A-Z][a-zA-Z]{1,30})?)
|
| 164 |
+
\b
|
| 165 |
+
|
|
| 166 |
+
^\s*(?P<n2>[A-Z][a-zA-Z]{1,30}(?:\s+[A-Z][a-zA-Z]{1,30})?)\s+here\b
|
| 167 |
+
)
|
| 168 |
+
""",
|
| 169 |
+
re.VERBOSE | re.IGNORECASE,
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
# Words that look name-like to the regex (capitalised at sentence start)
|
| 173 |
+
# but are NOT names. Filter post-match so "I'm 34" and "I am okay" never
|
| 174 |
+
# resolve to "okay" / "34" as a name.
|
| 175 |
+
_NAME_STOPWORDS: set[str] = {
|
| 176 |
+
"ok", "okay", "fine", "good", "great", "well", "yes", "no", "yeah",
|
| 177 |
+
"nope", "sure", "looking", "trying", "thinking", "interested",
|
| 178 |
+
"here", "back", "ready", "done", "free", "busy", "tired", "young",
|
| 179 |
+
"old", "married", "single", "alone", "happy", "sad",
|
| 180 |
+
"from", "in", "at", "on", "with", "for", "to",
|
| 181 |
+
"the", "a", "an",
|
| 182 |
+
# Time + age
|
| 183 |
+
"twenty", "thirty", "forty", "fifty", "sixty",
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def extract_potential_name(text: str) -> Optional[str]:
|
| 188 |
+
"""Return a probable first-name capture from a turn-1 user utterance.
|
| 189 |
+
|
| 190 |
+
Examples:
|
| 191 |
+
"Hi I'm Priya" -> "Priya"
|
| 192 |
+
"Hello, my name is Rajesh" -> "Rajesh"
|
| 193 |
+
"Anjali here" -> "Anjali"
|
| 194 |
+
"I'm 34 years old" -> None
|
| 195 |
+
"I am okay, thanks" -> None
|
| 196 |
+
"" -> None
|
| 197 |
+
|
| 198 |
+
Caller should slug + try_recall_by_name; no DB or LLM cost.
|
| 199 |
+
"""
|
| 200 |
+
if not text or not text.strip():
|
| 201 |
+
return None
|
| 202 |
+
# Normalize whitespace, keep original casing for the regex.
|
| 203 |
+
s = text.strip()
|
| 204 |
+
m = _NAME_RE.search(s)
|
| 205 |
+
if not m:
|
| 206 |
+
return None
|
| 207 |
+
raw = (m.group("n1") or m.group("n2") or "").strip()
|
| 208 |
+
if not raw:
|
| 209 |
+
return None
|
| 210 |
+
# Reject digit-laden captures ("I'm 34") and stop-words ("I'm okay").
|
| 211 |
+
first_token = raw.split()[0]
|
| 212 |
+
if first_token.lower() in _NAME_STOPWORDS:
|
| 213 |
+
return None
|
| 214 |
+
if not first_token.isalpha():
|
| 215 |
+
return None
|
| 216 |
+
if len(first_token) < 2:
|
| 217 |
+
return None
|
| 218 |
+
return raw
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def try_recall_by_name(session, name: str) -> bool:
|
| 222 |
+
"""Look up a stored profile by name and hydrate `session.profile`.
|
| 223 |
+
|
| 224 |
+
Wraps session_state.rehydrate_by_name so the call-site (single_brain
|
| 225 |
+
handle_turn entry) doesn't need to know which helper is canonical.
|
| 226 |
+
|
| 227 |
+
Returns True iff a stored profile was found AND at least one slot was
|
| 228 |
+
merged into the live session. False on no-match or any error.
|
| 229 |
+
"""
|
| 230 |
+
if not name or not name.strip():
|
| 231 |
+
return False
|
| 232 |
+
try:
|
| 233 |
+
from backend.session_state import rehydrate_by_name
|
| 234 |
+
|
| 235 |
+
return bool(rehydrate_by_name(session, name))
|
| 236 |
+
except Exception as e: # noqa: BLE001
|
| 237 |
+
_log.warning(
|
| 238 |
+
"try_recall_by_name failed (name=%r): %s: %s",
|
| 239 |
+
name, type(e).__name__, str(e)[:200],
|
| 240 |
+
)
|
| 241 |
+
return False
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
async def recall_by_name_payload(
|
| 245 |
+
name: str,
|
| 246 |
+
session_id: str,
|
| 247 |
+
) -> dict[str, Any]:
|
| 248 |
+
"""Build the response payload for POST /api/profile/recall-by-name.
|
| 249 |
+
|
| 250 |
+
Returns:
|
| 251 |
+
{
|
| 252 |
+
"found": bool,
|
| 253 |
+
"profile": dict | None, # the hydrated union dict
|
| 254 |
+
"predicted_band": dict | None, # {min_inr, median_inr, max_inr, ...}
|
| 255 |
+
"session_id": str,
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
Side effect: when a match is found, the live in-memory session for
|
| 259 |
+
`session_id` is hydrated (so the next /api/chat turn sees the recalled
|
| 260 |
+
slots in session.profile WITHOUT requiring a separate /api/profile POST).
|
| 261 |
+
"""
|
| 262 |
+
out: dict[str, Any] = {
|
| 263 |
+
"found": False,
|
| 264 |
+
"profile": None,
|
| 265 |
+
"predicted_band": None,
|
| 266 |
+
"session_id": session_id,
|
| 267 |
+
}
|
| 268 |
+
if not name or not name.strip() or not session_id:
|
| 269 |
+
return out
|
| 270 |
+
|
| 271 |
+
try:
|
| 272 |
+
from backend.session_state import get_session
|
| 273 |
+
|
| 274 |
+
sess = get_session(session_id)
|
| 275 |
+
except Exception as e: # noqa: BLE001
|
| 276 |
+
_log.warning(
|
| 277 |
+
"recall_by_name_payload: get_session failed (session_id=%r): %s: %s",
|
| 278 |
+
session_id, type(e).__name__, str(e)[:200],
|
| 279 |
+
)
|
| 280 |
+
return out
|
| 281 |
+
|
| 282 |
+
found = try_recall_by_name(sess, name)
|
| 283 |
+
if not found:
|
| 284 |
+
return out
|
| 285 |
+
|
| 286 |
+
# Build the union dict snapshot post-hydration.
|
| 287 |
+
union = _build_union_dict(sess.profile)
|
| 288 |
+
out["found"] = True
|
| 289 |
+
out["profile"] = union
|
| 290 |
+
|
| 291 |
+
# Predicted-premium band — same path /api/profile/predicted-premium-band
|
| 292 |
+
# uses, so the banner number matches the chip number exactly.
|
| 293 |
+
try:
|
| 294 |
+
from backend.premium_calculator import estimate_premium_band
|
| 295 |
+
|
| 296 |
+
out["predicted_band"] = estimate_premium_band(union)
|
| 297 |
+
except Exception as e: # noqa: BLE001
|
| 298 |
+
_log.warning(
|
| 299 |
+
"recall_by_name_payload: estimate_premium_band failed (name=%r): %s: %s",
|
| 300 |
+
name, type(e).__name__, str(e)[:200],
|
| 301 |
+
)
|
| 302 |
+
out["predicted_band"] = None
|
| 303 |
+
|
| 304 |
+
return out
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
__all__ = [
|
| 308 |
+
"auto_persist_session",
|
| 309 |
+
"extract_potential_name",
|
| 310 |
+
"try_recall_by_name",
|
| 311 |
+
"recall_by_name_payload",
|
| 312 |
+
]
|
|
@@ -488,17 +488,33 @@ def _profile_tuned_weights(profile: Optional[dict]) -> dict[str, float]:
|
|
| 488 |
w["Waiting-Period Friction"] -= 0.04
|
| 489 |
|
| 490 |
# ---- DEPENDENTS ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
deps = (profile.get("dependents") or "").lower()
|
|
|
|
| 492 |
if any(k in deps for k in ("kid", "child")):
|
| 493 |
w["Coverage Breadth"] += 0.03 # paediatric + day-care + immunisation
|
| 494 |
-
w["
|
| 495 |
-
w["
|
| 496 |
-
|
|
|
|
| 497 |
if any(k in deps for k in ("spouse", "wife", "husband", "partner")):
|
| 498 |
-
w["Coverage Breadth"] += 0.
|
|
|
|
| 499 |
w["Waiting-Period Friction"] += 0.02 # maternity 36mo wait matters
|
| 500 |
-
w["Bonus & Loyalty"] -= 0.
|
| 501 |
-
|
|
|
|
| 502 |
|
| 503 |
if profile.get("parents_to_insure") or "parent" in deps:
|
| 504 |
w["Coverage Breadth"] += 0.04
|
|
|
|
| 488 |
w["Waiting-Period Friction"] -= 0.04
|
| 489 |
|
| 490 |
# ---- DEPENDENTS ----
|
| 491 |
+
# Family signals push two specific dials per task spec:
|
| 492 |
+
# maternity coverage -> sits inside Coverage Breadth
|
| 493 |
+
# room-rent capping -> sits inside Cost Predictability
|
| 494 |
+
# Both go UP when a spouse / kid is on the policy because multi-occupant
|
| 495 |
+
# families absorb sub-limit pain harder than singles.
|
| 496 |
+
#
|
| 497 |
+
# We DON'T let dependents pull Renewal Protection or Claim Experience
|
| 498 |
+
# downward when the buyer is already in the senior bracket — for a 55+
|
| 499 |
+
# buyer with a family, both renewal lock-in and claim reliability matter
|
| 500 |
+
# MORE, not less. Earlier versions had the family penalty silently cancel
|
| 501 |
+
# the age boost, so senior+family ended up with renewal-weight BELOW
|
| 502 |
+
# default. Now the family discount applies only to younger buyers.
|
| 503 |
deps = (profile.get("dependents") or "").lower()
|
| 504 |
+
is_senior = isinstance(age, int) and age >= 50
|
| 505 |
if any(k in deps for k in ("kid", "child")):
|
| 506 |
w["Coverage Breadth"] += 0.03 # paediatric + day-care + immunisation
|
| 507 |
+
w["Cost Predictability"] += 0.02 # room-rent caps hurt families more
|
| 508 |
+
w["Bonus & Loyalty"] -= 0.03 # de-emphasise sweeteners
|
| 509 |
+
if not is_senior:
|
| 510 |
+
w["Renewal Protection"] -= 0.02
|
| 511 |
if any(k in deps for k in ("spouse", "wife", "husband", "partner")):
|
| 512 |
+
w["Coverage Breadth"] += 0.03 # maternity becomes relevant
|
| 513 |
+
w["Cost Predictability"] += 0.02 # room-rent cap matters when both hospitalise
|
| 514 |
w["Waiting-Period Friction"] += 0.02 # maternity 36mo wait matters
|
| 515 |
+
w["Bonus & Loyalty"] -= 0.04
|
| 516 |
+
if not is_senior:
|
| 517 |
+
w["Renewal Protection"] -= 0.03
|
| 518 |
|
| 519 |
if profile.get("parents_to_insure") or "parent" in deps:
|
| 520 |
w["Coverage Breadth"] += 0.04
|
|
@@ -82,9 +82,9 @@ YOUR JOB:
|
|
| 82 |
|
| 83 |
REQUIRED slots before recommending: name, age, dependents, location_tier, income_band, primary_goal, health_conditions.
|
| 84 |
|
| 85 |
-
═══════════════════════════════════
|
| 86 |
ABSOLUTE RULE — NO POLICY NAMES WITHOUT RETRIEVE
|
| 87 |
-
═══════════════════════════════════
|
| 88 |
NEVER mention a policy name, UIN, insurer, or product (Star Health,
|
| 89 |
HDFC Ergo, Niva Bupa, Care, Aditya Birla, ICICI Lombard, Bajaj Allianz,
|
| 90 |
Manipal Cigna, Acko, Go Digit, Max Bupa, Reliance General, SBI General,
|
|
@@ -102,9 +102,9 @@ If retrieve_policies returns nothing for that name, say "I couldn't find
|
|
| 102 |
that policy in our index. Let me suggest some alternatives" and call
|
| 103 |
retrieve_policies with a broader query based on the profile.
|
| 104 |
|
| 105 |
-
═══════════════════════════════════
|
| 106 |
RULE 1 (HIGHEST PRIORITY) — save_profile_field is MANDATORY
|
| 107 |
-
═══════════════════════════════════
|
| 108 |
Every turn, BEFORE you write any prose reply, scan the user's last message for any
|
| 109 |
of these facts and call save_profile_field ONCE PER FACT:
|
| 110 |
• A name (proper noun) → save_profile_field(field="name", value="...")
|
|
@@ -145,33 +145,60 @@ Worked example B (negation — DO NOT SKIP). User says: "No medical issues"
|
|
| 145 |
|
| 146 |
NEVER ask the user for a fact you can already extract from their last message. Capture FIRST, then ask only for what's missing.
|
| 147 |
|
| 148 |
-
═══════════════════════════════════
|
| 149 |
RULE 2 — retrieve_policies query MUST be profile-aware
|
| 150 |
-
═══════════════════════════════════
|
| 151 |
Only call retrieve_policies AFTER all 7 required slots are saved AND the user has confirmed your recap.
|
| 152 |
|
| 153 |
-
Build the query string from the profile snapshot.
|
|
|
|
|
|
|
| 154 |
family-shape (individual / family floater / parents-cover),
|
| 155 |
city tier (metro / tier-2 / tier-3),
|
| 156 |
-
sum-insured band (~5-7× annual income
|
| 157 |
-
age band (e.g. "adult 30-40"),
|
| 158 |
-
health-condition keywords (
|
| 159 |
-
primary goal keyword
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
-
Worked example. Profile = {age=
|
| 162 |
-
retrieve_policies(query="family floater plan metro sum insured
|
| 163 |
|
| 164 |
If the first call returns 0 or 1 chunk, retry ONCE with a broader query (drop the most specific filter or broaden SI band by one tier) before asking the user to relax criteria.
|
| 165 |
|
| 166 |
-
═══════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
RULE 3 — Follow-ups + mark_recommendation
|
| 168 |
-
═══════════════════════════════════
|
| 169 |
- After producing a ranked shortlist, call mark_recommendation(policy_ids=[...ordered IDs you cited...]).
|
| 170 |
- For "tell me about #2" / "second one" follow-ups, call retrieve_policies(query, policy_filter_ids=[policy_id_of_#2]) to narrow to that policy.
|
| 171 |
|
| 172 |
-
═══════════════════════════════════
|
| 173 |
RULE 4 — Returning-user greeting (pre-populated profile)
|
| 174 |
-
═══════════════════════════════════
|
| 175 |
If the KNOWN PROFILE block below is non-empty AT TURN 1 (no chat history,
|
| 176 |
session.profile arrived pre-populated from a prior conversation), your FIRST
|
| 177 |
reply MUST:
|
|
@@ -196,9 +223,9 @@ fields. Your flow on that turn:
|
|
| 196 |
Explicit confirmation is only required when the user's reply is a literal
|
| 197 |
"yes/no/that's right" with no new data. Bypass the WAIT in any other case.
|
| 198 |
|
| 199 |
-
═══════════════════════════════════
|
| 200 |
RULE 5 — Comparison view ("compare #1 and #3")
|
| 201 |
-
═══════════════════════════════════
|
| 202 |
When the user asks to compare two or more shortlisted policies ("compare
|
| 203 |
#1 and #3", "what's the difference between Plan A and Plan B",
|
| 204 |
"#2 vs #4"):
|
|
@@ -211,9 +238,9 @@ When the user asks to compare two or more shortlisted policies ("compare
|
|
| 211 |
3. Cite each cell with [Source: ..., UIN]. Do NOT just dump retrieved
|
| 212 |
text — explicitly contrast.
|
| 213 |
|
| 214 |
-
═══════════════════════════════════
|
| 215 |
RULE 6 — Out-of-scope refusal (non-health products)
|
| 216 |
-
═══════════════════════════════════
|
| 217 |
You ONLY advise on Indian health insurance. If the user asks about life
|
| 218 |
insurance, term plans, ULIPs, car / motor / two-wheeler insurance, home
|
| 219 |
insurance, travel insurance, mutual funds, or any non-health product,
|
|
@@ -223,9 +250,9 @@ politely refuse and redirect:
|
|
| 223 |
coverage?"
|
| 224 |
Do NOT call retrieve_policies for out-of-scope queries.
|
| 225 |
|
| 226 |
-
═══════════════════════════════════
|
| 227 |
RULE 7 — Soft close after the customer picks one
|
| 228 |
-
═══════════════════════════════════
|
| 229 |
Once you have recommended AND the user has chosen a single policy ("I'll
|
| 230 |
go with #2", "let's pick the HDFC one", "sounds good", "I'll take that",
|
| 231 |
"let's do the first one", "sign me up", "buy this", "I want to purchase"):
|
|
@@ -246,25 +273,13 @@ go with #2", "let's pick the HDFC one", "sounds good", "I'll take that",
|
|
| 246 |
you like me to walk through the purchase steps, or summarise the key
|
| 247 |
benefits?"
|
| 248 |
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
session.last_recommendation_ids[0].
|
| 255 |
-
ii. Call mark_recommendation(policy_ids=[chosen_id], is_final=true)
|
| 256 |
-
FIRST. This is non-negotiable — the recommendation MUST be
|
| 257 |
-
recorded for analytics before any prose is written.
|
| 258 |
-
iii. THEN write the prose reply offering next steps.
|
| 259 |
-
DO NOT skip step (ii). Offering "would you like purchase steps?" without
|
| 260 |
-
the mark_recommendation tool call is a RULE 7 violation.
|
| 261 |
-
|
| 262 |
-
Do not re-pitch alternatives after the user has chosen — only act on
|
| 263 |
-
their next instruction.
|
| 264 |
-
|
| 265 |
-
═══════════════════════════════════════════════════════════
|
| 266 |
RULE 8 — Indic-language mirroring
|
| 267 |
-
═══════════════════════════════════
|
| 268 |
If the user's last message is in an Indian language (Hindi, Marathi,
|
| 269 |
Tamil, Telugu, Bengali, Kannada, Gujarati, Punjabi, Malayalam, etc.) or
|
| 270 |
Hinglish (Latin-script Hindi), respond in the SAME language. Use the same
|
|
@@ -272,9 +287,9 @@ tools regardless of language — tool args (field names, policy queries)
|
|
| 272 |
remain English; only your prose reply mirrors the user's language.
|
| 273 |
Citations stay in the canonical [Source: ..., UIN] format.
|
| 274 |
|
| 275 |
-
═══════════════════════════════════
|
| 276 |
GROUND RULES
|
| 277 |
-
═══════════════════════════════════
|
| 278 |
- NEVER invent policies, UINs, premiums, or sums insured. Only cite what retrieve_policies returns.
|
| 279 |
- If retrieve_policies returns zero chunks after both attempts, ask the user one clarifying question.
|
| 280 |
- Be concise: 2-3 sentence turns. No emoji unless the user used one first.
|
|
@@ -327,7 +342,8 @@ TOOL_SCHEMAS: list[dict] = [
|
|
| 327 |
"Persist a captured profile field on the live session. Call once "
|
| 328 |
"per field every time the user reveals something new (name, age, "
|
| 329 |
"dependents, location_tier, income_band, primary_goal, "
|
| 330 |
-
"health_conditions, existing_cover_inr, budget_band,
|
|
|
|
| 331 |
),
|
| 332 |
"parameters": {
|
| 333 |
"type": "OBJECT",
|
|
@@ -338,15 +354,16 @@ TOOL_SCHEMAS: list[dict] = [
|
|
| 338 |
"Field name. One of: name, age, dependents, "
|
| 339 |
"location_tier, income_band, primary_goal, "
|
| 340 |
"health_conditions, existing_cover_inr, budget_band, "
|
| 341 |
-
"gender."
|
| 342 |
),
|
| 343 |
},
|
| 344 |
"value": {
|
| 345 |
"type": "STRING",
|
| 346 |
"description": (
|
| 347 |
-
"Value as a string. Numbers (age, existing_cover_inr
|
| 348 |
-
"may be sent as a digit
|
| 349 |
-
"
|
|
|
|
| 350 |
),
|
| 351 |
},
|
| 352 |
},
|
|
@@ -447,7 +464,7 @@ def _profile_to_snapshot(profile) -> dict:
|
|
| 447 |
for fld in (
|
| 448 |
"name", "age", "dependents", "location_tier", "income_band",
|
| 449 |
"primary_goal", "health_conditions", "existing_cover_inr",
|
| 450 |
-
"budget_band",
|
| 451 |
):
|
| 452 |
try:
|
| 453 |
v = getattr(profile, fld, None)
|
|
@@ -978,6 +995,36 @@ async def handle_turn(
|
|
| 978 |
# save_profile_field calls within THIS conversation — not a
|
| 979 |
# returning user, do NOT trigger RULE 4 Welcome Back.
|
| 980 |
_current_turn = int(getattr(session, "turn_idx", 1) or 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 981 |
_has_prior_profile = any(
|
| 982 |
getattr(session.profile, fld, None) not in (None, "", [])
|
| 983 |
for fld in (
|
|
|
|
| 82 |
|
| 83 |
REQUIRED slots before recommending: name, age, dependents, location_tier, income_band, primary_goal, health_conditions.
|
| 84 |
|
| 85 |
+
═══════════════════════════════════
|
| 86 |
ABSOLUTE RULE — NO POLICY NAMES WITHOUT RETRIEVE
|
| 87 |
+
═══════════════════════════════════
|
| 88 |
NEVER mention a policy name, UIN, insurer, or product (Star Health,
|
| 89 |
HDFC Ergo, Niva Bupa, Care, Aditya Birla, ICICI Lombard, Bajaj Allianz,
|
| 90 |
Manipal Cigna, Acko, Go Digit, Max Bupa, Reliance General, SBI General,
|
|
|
|
| 102 |
that policy in our index. Let me suggest some alternatives" and call
|
| 103 |
retrieve_policies with a broader query based on the profile.
|
| 104 |
|
| 105 |
+
═══════════════════════════════════
|
| 106 |
RULE 1 (HIGHEST PRIORITY) — save_profile_field is MANDATORY
|
| 107 |
+
═══════════════════════════════════
|
| 108 |
Every turn, BEFORE you write any prose reply, scan the user's last message for any
|
| 109 |
of these facts and call save_profile_field ONCE PER FACT:
|
| 110 |
• A name (proper noun) → save_profile_field(field="name", value="...")
|
|
|
|
| 145 |
|
| 146 |
NEVER ask the user for a fact you can already extract from their last message. Capture FIRST, then ask only for what's missing.
|
| 147 |
|
| 148 |
+
═══════════════════════════════════
|
| 149 |
RULE 2 — retrieve_policies query MUST be profile-aware
|
| 150 |
+
═══════════════════════════════════
|
| 151 |
Only call retrieve_policies AFTER all 7 required slots are saved AND the user has confirmed your recap.
|
| 152 |
|
| 153 |
+
Build the query string from the profile snapshot. The query MUST be profile+pricing aware — include both recommendation and pricing slots so retrieval scores reflect what the user actually needs.
|
| 154 |
+
|
| 155 |
+
Required ingredients:
|
| 156 |
family-shape (individual / family floater / parents-cover),
|
| 157 |
city tier (metro / tier-2 / tier-3),
|
| 158 |
+
sum-insured band — use `desired_sum_insured_inr` if captured (RULE 2.5), else derive ~5-7× annual income (e.g., "10-15 lakh"),
|
| 159 |
+
age band (e.g., "adult 30-40"),
|
| 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)
|
| 167 |
|
| 168 |
+
Worked example B (diabetes + employer top-up + parents). Profile = {age=42, location_tier=metro, dependents=self+spouse+parents, primary_goal=upgrade, health_conditions=["diabetes"], desired_sum_insured_inr=2500000, existing_cover_inr=500000, parents_age_max=68}:
|
| 169 |
+
retrieve_policies(query="family floater plan metro sum insured 25 lakh adult 40-50 with spouse and parents diabetes managed top-up over existing 5 lakh employer cover parents age 68 upgrade plan", top_k=8)
|
| 170 |
|
| 171 |
If the first call returns 0 or 1 chunk, retry ONCE with a broader query (drop the most specific filter or broaden SI band by one tier) before asking the user to relax criteria.
|
| 172 |
|
| 173 |
+
═══════════════════════════════════
|
| 174 |
+
RULE 2.5 — Pricing inputs (SOFT capture, post-recap)
|
| 175 |
+
═══════════════════════════════════
|
| 176 |
+
After all 7 slots are saved AND the user has confirmed the recap (RULE 4 implicit confirmation or explicit yes), BEFORE you call retrieve_policies, ask — in ONE compact prompt:
|
| 177 |
+
"A few quick pricing inputs (you can skip any):
|
| 178 |
+
1. How much sum insured? (e.g., ₹5L / ₹10L / ₹25L / ₹1Cr)
|
| 179 |
+
2. Premium budget? (e.g., ₹10–15K/year, or ₹50K+ for premium covers)
|
| 180 |
+
3. Any existing health cover from work or otherwise? (e.g., '5L through employer' or 'no') [SKIP if existing_cover_inr already captured]
|
| 181 |
+
4. Approximate age of the eldest parent you'd cover? [ASK ONLY IF dependents mentions parents AND parents_age_max not yet captured]"
|
| 182 |
+
|
| 183 |
+
When the user answers, call save_profile_field once per provided value:
|
| 184 |
+
save_profile_field(field="desired_sum_insured_inr", value="1000000") # ₹10L
|
| 185 |
+
save_profile_field(field="budget_band", value="10K-20K")
|
| 186 |
+
save_profile_field(field="existing_cover_inr", value="500000") # 5L corporate top-up; 'no' / 'none' → value="0"
|
| 187 |
+
save_profile_field(field="parents_age_max", value="68") # eldest parent's age, only if covering parents
|
| 188 |
+
|
| 189 |
+
Gender hint: if the user mentions gender, keep it for conversational context only — Profile has no `gender` slot. Do NOT call save_profile_field(field="gender", ...) — it returns `field_not_on_profile_dataclass` and wastes a tool-call iteration.
|
| 190 |
+
|
| 191 |
+
Then call retrieve_policies and INCLUDE the new inputs in the query (e.g., "...sum insured 10 lakh, budget 10-20K/year, existing employer cover 5L, parent age 68..."). If the user skips ("just show me options", "you decide"), proceed with retrieve_policies using profile defaults — DO NOT block. SOFT capture, not a hard gate.
|
| 192 |
+
|
| 193 |
+
═══════════════════════════════════
|
| 194 |
RULE 3 — Follow-ups + mark_recommendation
|
| 195 |
+
═══════════════════════════════════
|
| 196 |
- After producing a ranked shortlist, call mark_recommendation(policy_ids=[...ordered IDs you cited...]).
|
| 197 |
- For "tell me about #2" / "second one" follow-ups, call retrieve_policies(query, policy_filter_ids=[policy_id_of_#2]) to narrow to that policy.
|
| 198 |
|
| 199 |
+
═══════════════════════════════════
|
| 200 |
RULE 4 — Returning-user greeting (pre-populated profile)
|
| 201 |
+
═══════════════════════════════════
|
| 202 |
If the KNOWN PROFILE block below is non-empty AT TURN 1 (no chat history,
|
| 203 |
session.profile arrived pre-populated from a prior conversation), your FIRST
|
| 204 |
reply MUST:
|
|
|
|
| 223 |
Explicit confirmation is only required when the user's reply is a literal
|
| 224 |
"yes/no/that's right" with no new data. Bypass the WAIT in any other case.
|
| 225 |
|
| 226 |
+
═══════════════════════════════════
|
| 227 |
RULE 5 — Comparison view ("compare #1 and #3")
|
| 228 |
+
═══════════════════════════════════
|
| 229 |
When the user asks to compare two or more shortlisted policies ("compare
|
| 230 |
#1 and #3", "what's the difference between Plan A and Plan B",
|
| 231 |
"#2 vs #4"):
|
|
|
|
| 238 |
3. Cite each cell with [Source: ..., UIN]. Do NOT just dump retrieved
|
| 239 |
text — explicitly contrast.
|
| 240 |
|
| 241 |
+
═══════════════════════════════════
|
| 242 |
RULE 6 — Out-of-scope refusal (non-health products)
|
| 243 |
+
═══════════════════════════════════
|
| 244 |
You ONLY advise on Indian health insurance. If the user asks about life
|
| 245 |
insurance, term plans, ULIPs, car / motor / two-wheeler insurance, home
|
| 246 |
insurance, travel insurance, mutual funds, or any non-health product,
|
|
|
|
| 250 |
coverage?"
|
| 251 |
Do NOT call retrieve_policies for out-of-scope queries.
|
| 252 |
|
| 253 |
+
═══════════════════════════════════
|
| 254 |
RULE 7 — Soft close after the customer picks one
|
| 255 |
+
═══════════════════════════════════
|
| 256 |
Once you have recommended AND the user has chosen a single policy ("I'll
|
| 257 |
go with #2", "let's pick the HDFC one", "sounds good", "I'll take that",
|
| 258 |
"let's do the first one", "sign me up", "buy this", "I want to purchase"):
|
|
|
|
| 273 |
you like me to walk through the purchase steps, or summarise the key
|
| 274 |
benefits?"
|
| 275 |
|
| 276 |
+
DO NOT skip STEP 1. Offering "would you like purchase steps?" without
|
| 277 |
+
the mark_recommendation tool call is a RULE 7 violation. Do not re-pitch
|
| 278 |
+
alternatives after the user has chosen — only act on their next instruction.
|
| 279 |
+
|
| 280 |
+
═══════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
RULE 8 — Indic-language mirroring
|
| 282 |
+
═══════════════════════════════════
|
| 283 |
If the user's last message is in an Indian language (Hindi, Marathi,
|
| 284 |
Tamil, Telugu, Bengali, Kannada, Gujarati, Punjabi, Malayalam, etc.) or
|
| 285 |
Hinglish (Latin-script Hindi), respond in the SAME language. Use the same
|
|
|
|
| 287 |
remain English; only your prose reply mirrors the user's language.
|
| 288 |
Citations stay in the canonical [Source: ..., UIN] format.
|
| 289 |
|
| 290 |
+
═══════════════════════════════════
|
| 291 |
GROUND RULES
|
| 292 |
+
═══════════════════════════════════
|
| 293 |
- NEVER invent policies, UINs, premiums, or sums insured. Only cite what retrieve_policies returns.
|
| 294 |
- If retrieve_policies returns zero chunks after both attempts, ask the user one clarifying question.
|
| 295 |
- Be concise: 2-3 sentence turns. No emoji unless the user used one first.
|
|
|
|
| 342 |
"Persist a captured profile field on the live session. Call once "
|
| 343 |
"per field every time the user reveals something new (name, age, "
|
| 344 |
"dependents, location_tier, income_band, primary_goal, "
|
| 345 |
+
"health_conditions, existing_cover_inr, budget_band, "
|
| 346 |
+
"desired_sum_insured_inr, gender)."
|
| 347 |
),
|
| 348 |
"parameters": {
|
| 349 |
"type": "OBJECT",
|
|
|
|
| 354 |
"Field name. One of: name, age, dependents, "
|
| 355 |
"location_tier, income_band, primary_goal, "
|
| 356 |
"health_conditions, existing_cover_inr, budget_band, "
|
| 357 |
+
"desired_sum_insured_inr, gender."
|
| 358 |
),
|
| 359 |
},
|
| 360 |
"value": {
|
| 361 |
"type": "STRING",
|
| 362 |
"description": (
|
| 363 |
+
"Value as a string. Numbers (age, existing_cover_inr, "
|
| 364 |
+
"desired_sum_insured_inr) may be sent as a digit "
|
| 365 |
+
"string or with units ('10L', '1 crore'); "
|
| 366 |
+
"health_conditions may be a comma-joined string."
|
| 367 |
),
|
| 368 |
},
|
| 369 |
},
|
|
|
|
| 464 |
for fld in (
|
| 465 |
"name", "age", "dependents", "location_tier", "income_band",
|
| 466 |
"primary_goal", "health_conditions", "existing_cover_inr",
|
| 467 |
+
"budget_band", "desired_sum_insured_inr",
|
| 468 |
):
|
| 469 |
try:
|
| 470 |
v = getattr(profile, fld, None)
|
|
|
|
| 995 |
# save_profile_field calls within THIS conversation — not a
|
| 996 |
# returning user, do NOT trigger RULE 4 Welcome Back.
|
| 997 |
_current_turn = int(getattr(session, "turn_idx", 1) or 1)
|
| 998 |
+
|
| 999 |
+
# KI-Z7 (2026-05-15) — turn-1 name heuristic. If this is the first turn
|
| 1000 |
+
# on this session AND the profile has no name captured yet, sniff a
|
| 1001 |
+
# name out of `user_text` and try to load the named profile JSON. On a
|
| 1002 |
+
# hit, session.profile is hydrated in-place so the KNOWN PROFILE block
|
| 1003 |
+
# below already contains the recalled slots — RULE 4 (Welcome Back) then
|
| 1004 |
+
# fires correctly on this same Gemini iteration. Best-effort: no error
|
| 1005 |
+
# bubbles out.
|
| 1006 |
+
if _current_turn == 1 and not (getattr(session.profile, "name", None) or ""):
|
| 1007 |
+
try:
|
| 1008 |
+
from backend.profile_persistence import (
|
| 1009 |
+
extract_potential_name,
|
| 1010 |
+
try_recall_by_name,
|
| 1011 |
+
)
|
| 1012 |
+
|
| 1013 |
+
_maybe_name = extract_potential_name(user_text)
|
| 1014 |
+
if _maybe_name:
|
| 1015 |
+
_recalled = try_recall_by_name(session, _maybe_name)
|
| 1016 |
+
if _recalled:
|
| 1017 |
+
_log.info(
|
| 1018 |
+
"single_brain turn-1 recall: name=%r matched stored "
|
| 1019 |
+
"profile; hydrated session=%s",
|
| 1020 |
+
_maybe_name, getattr(session, "session_id", "?"),
|
| 1021 |
+
)
|
| 1022 |
+
except Exception as _recall_err: # noqa: BLE001 — must never break turn
|
| 1023 |
+
_log.warning(
|
| 1024 |
+
"single_brain turn-1 recall failed: %s: %s",
|
| 1025 |
+
type(_recall_err).__name__, str(_recall_err)[:200],
|
| 1026 |
+
)
|
| 1027 |
+
|
| 1028 |
_has_prior_profile = any(
|
| 1029 |
getattr(session.profile, fld, None) not in (None, "", [])
|
| 1030 |
for fld in (
|
|
@@ -104,6 +104,16 @@ export default function Page() {
|
|
| 104 |
// tighten as they fill in slots. Debounced 500ms to coalesce bursts.
|
| 105 |
const [premiumBand, setPremiumBand] = useState<PredictedPremiumBandResponse | null>(null);
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
// Re-fetch profile completeness whenever sessionId changes (after first chat
|
| 108 |
// turn) — drives the score-gate on marketplace cards + detail modal.
|
| 109 |
useEffect(() => {
|
|
@@ -604,6 +614,9 @@ export default function Page() {
|
|
| 604 |
// Always wipe the visible chat + local storage.
|
| 605 |
setMessages([]);
|
| 606 |
setInput("");
|
|
|
|
|
|
|
|
|
|
| 607 |
// KI-073 (2026-05-15) — clear the profile-completeness chip immediately
|
| 608 |
// so the header doesn't show stale "55% DONE" for a brand-new visitor
|
| 609 |
// while the new session_id fetch is in flight.
|
|
@@ -752,6 +765,33 @@ export default function Page() {
|
|
| 752 |
latencyMs: res.latency_ms,
|
| 753 |
blocked: res.blocked,
|
| 754 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 755 |
// KI-030 (2026-05-14) — playback moved into the in-DOM <audio> element
|
| 756 |
// owned by the Message component (autoplay on mount). Detached
|
| 757 |
// `new Audio()` instances were invisible to
|
|
@@ -1462,6 +1502,51 @@ export default function Page() {
|
|
| 1462 |
Clear chat
|
| 1463 |
</button>
|
| 1464 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1465 |
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin space-y-4 mb-4 pr-1">
|
| 1466 |
{messages.map((m) => <Message key={m.id} m={m} />)}
|
| 1467 |
{(busy || voicePhase) && <ThinkingDots phase={voicePhase} />}
|
|
|
|
| 104 |
// tighten as they fill in slots. Debounced 500ms to coalesce bursts.
|
| 105 |
const [premiumBand, setPremiumBand] = useState<PredictedPremiumBandResponse | null>(null);
|
| 106 |
|
| 107 |
+
// KI-Z7 (2026-05-15) — Feature B. Welcome-back banner state. Populated
|
| 108 |
+
// when /api/chat returns `returning_user_recalled: true` on the
|
| 109 |
+
// assistant turn that hydrated the session from a stored named-profile.
|
| 110 |
+
// Cleared by either the "Use this profile" / "Update my info" actions
|
| 111 |
+
// OR by handleClearChat.
|
| 112 |
+
const [welcomeBack, setWelcomeBack] = useState<{
|
| 113 |
+
name: string;
|
| 114 |
+
bandText: string | null;
|
| 115 |
+
} | null>(null);
|
| 116 |
+
|
| 117 |
// Re-fetch profile completeness whenever sessionId changes (after first chat
|
| 118 |
// turn) — drives the score-gate on marketplace cards + detail modal.
|
| 119 |
useEffect(() => {
|
|
|
|
| 614 |
// Always wipe the visible chat + local storage.
|
| 615 |
setMessages([]);
|
| 616 |
setInput("");
|
| 617 |
+
// KI-Z7 — clear the welcome-back banner so a brand-new visitor doesn't
|
| 618 |
+
// see stale "Welcome back, …" copy after the session reset.
|
| 619 |
+
setWelcomeBack(null);
|
| 620 |
// KI-073 (2026-05-15) — clear the profile-completeness chip immediately
|
| 621 |
// so the header doesn't show stale "55% DONE" for a brand-new visitor
|
| 622 |
// while the new session_id fetch is in flight.
|
|
|
|
| 765 |
latencyMs: res.latency_ms,
|
| 766 |
blocked: res.blocked,
|
| 767 |
});
|
| 768 |
+
// KI-Z7 (2026-05-15) — Feature B. Surface the "Welcome back" banner
|
| 769 |
+
// when the backend recalled a stored named-profile on this turn.
|
| 770 |
+
// The predicted-premium band is fetched separately below by the
|
| 771 |
+
// usual completeness-pct effect, but we ALSO pre-load it eagerly
|
| 772 |
+
// here so the banner has a value to render on first paint.
|
| 773 |
+
if (res.returning_user_recalled && res.session_id) {
|
| 774 |
+
getPredictedPremiumBand(res.session_id)
|
| 775 |
+
.then((band) => {
|
| 776 |
+
let bandText: string | null = null;
|
| 777 |
+
if (band && band.min_inr && band.max_inr) {
|
| 778 |
+
const minK = Math.round(band.min_inr / 1000);
|
| 779 |
+
const maxK = Math.round(band.max_inr / 1000);
|
| 780 |
+
bandText = `₹${minK}k-₹${maxK}k/year`;
|
| 781 |
+
}
|
| 782 |
+
// Display name from the freshly-refreshed completeness fetch
|
| 783 |
+
// (it queries the same session.profile we just hydrated).
|
| 784 |
+
getProfileCompleteness(res.session_id)
|
| 785 |
+
.then((pc) => {
|
| 786 |
+
const display =
|
| 787 |
+
(pc?.profile as { name?: string } | undefined)?.name ||
|
| 788 |
+
"there";
|
| 789 |
+
setWelcomeBack({ name: display, bandText });
|
| 790 |
+
})
|
| 791 |
+
.catch(() => setWelcomeBack({ name: "there", bandText }));
|
| 792 |
+
})
|
| 793 |
+
.catch(() => setWelcomeBack({ name: "there", bandText: null }));
|
| 794 |
+
}
|
| 795 |
// KI-030 (2026-05-14) — playback moved into the in-DOM <audio> element
|
| 796 |
// owned by the Message component (autoplay on mount). Detached
|
| 797 |
// `new Audio()` instances were invisible to
|
|
|
|
| 1502 |
Clear chat
|
| 1503 |
</button>
|
| 1504 |
</div>
|
| 1505 |
+
{/* KI-Z7 (2026-05-15) — Feature B. Welcome-back banner. Renders
|
| 1506 |
+
when the backend matched + hydrated a stored named-profile on
|
| 1507 |
+
this turn. "Use this profile" dismisses the banner (keeps the
|
| 1508 |
+
hydrated profile in place); "Update my info" opens the profile
|
| 1509 |
+
builder so the user can revise any fact before continuing. */}
|
| 1510 |
+
{welcomeBack && (
|
| 1511 |
+
<div className="mb-3 rounded-xl border border-[var(--primary)] bg-[var(--primary)]/10 px-3 py-2 text-sm flex items-center justify-between gap-2">
|
| 1512 |
+
<div className="flex-1">
|
| 1513 |
+
<span className="font-semibold text-[var(--primary)]">
|
| 1514 |
+
Welcome back, {welcomeBack.name}!
|
| 1515 |
+
</span>{" "}
|
| 1516 |
+
<span className="text-[var(--muted-foreground)]">
|
| 1517 |
+
Your profile is loaded.
|
| 1518 |
+
</span>
|
| 1519 |
+
{welcomeBack.bandText && (
|
| 1520 |
+
<span className="text-[var(--muted-foreground)]">
|
| 1521 |
+
{" "}Last predicted premium:{" "}
|
| 1522 |
+
<strong className="text-[var(--foreground)]">
|
| 1523 |
+
{welcomeBack.bandText}
|
| 1524 |
+
</strong>
|
| 1525 |
+
.
|
| 1526 |
+
</span>
|
| 1527 |
+
)}
|
| 1528 |
+
</div>
|
| 1529 |
+
<div className="flex items-center gap-1 shrink-0">
|
| 1530 |
+
<button
|
| 1531 |
+
onClick={() => setWelcomeBack(null)}
|
| 1532 |
+
className="px-2 py-1 rounded-md text-xs border border-[var(--primary)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-white transition"
|
| 1533 |
+
title="Continue with the loaded profile"
|
| 1534 |
+
>
|
| 1535 |
+
Use this profile
|
| 1536 |
+
</button>
|
| 1537 |
+
<button
|
| 1538 |
+
onClick={() => {
|
| 1539 |
+
setWelcomeBack(null);
|
| 1540 |
+
setShowProfile(true);
|
| 1541 |
+
}}
|
| 1542 |
+
className="px-2 py-1 rounded-md text-xs border border-[var(--border)] text-[var(--muted-foreground)] hover:border-[var(--primary)] hover:text-[var(--foreground)] transition"
|
| 1543 |
+
title="Open the profile builder to revise"
|
| 1544 |
+
>
|
| 1545 |
+
Update my info
|
| 1546 |
+
</button>
|
| 1547 |
+
</div>
|
| 1548 |
+
</div>
|
| 1549 |
+
)}
|
| 1550 |
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin space-y-4 mb-4 pr-1">
|
| 1551 |
{messages.map((m) => <Message key={m.id} m={m} />)}
|
| 1552 |
{(busy || voicePhase) && <ThinkingDots phase={voicePhase} />}
|
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
// PolicyCompareModal — side-by-side comparison modal opened from the chat
|
| 4 |
+
// reply. Renders one column per cited policy with three slots:
|
| 5 |
+
// 1. Header — insurer logo + policy name + insurer + scorecard chip
|
| 6 |
+
// 2. Premium — pluggable widget (B2) via props.renderPremiumFor(policyId)
|
| 7 |
+
// 3. Scorecard — pluggable widget (B3) via props.renderScorecardFor(policyId)
|
| 8 |
+
// 4. Policy details — expandable section with source URL
|
| 9 |
+
// Visual style copied from MarketplacePanel's PolicyCard so the chat-side
|
| 10 |
+
// compare matches the marketplace-side compare (same fonts, borders, radii).
|
| 11 |
+
|
| 12 |
+
import { useEffect, useState, type ReactNode } from "react";
|
| 13 |
+
import { Citation, ScorecardResponse, getScorecard } from "@/lib/api";
|
| 14 |
+
|
| 15 |
+
// ----- local visual helpers (forked from page.tsx so this file is
|
| 16 |
+
// self-contained; the originals stay in page.tsx untouched). -----
|
| 17 |
+
|
| 18 |
+
const INSURER_COLOR: Record<string, string> = {
|
| 19 |
+
"aditya-birla": "bg-orange-600",
|
| 20 |
+
"bajaj-allianz": "bg-blue-700",
|
| 21 |
+
"care-health": "bg-emerald-700",
|
| 22 |
+
"hdfc-ergo": "bg-rose-700",
|
| 23 |
+
"icici-lombard": "bg-orange-500",
|
| 24 |
+
"manipalcigna": "bg-fuchsia-700",
|
| 25 |
+
"new-india": "bg-indigo-700",
|
| 26 |
+
"niva-bupa": "bg-cyan-700",
|
| 27 |
+
"star-health": "bg-amber-600",
|
| 28 |
+
"tata-aig": "bg-slate-700",
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const INSURER_LOGO_URL: Record<string, string> = {
|
| 32 |
+
"aditya-birla": "https://www.adityabirlacapital.com/healthinsurance/static/assets/images/abhi-logo.svg",
|
| 33 |
+
"bajaj-allianz": "https://www.bajajallianz.com/content/dam/bagic/header/logo.png",
|
| 34 |
+
"care-health": "https://www.careinsurance.com/upload_master/images/logo.png",
|
| 35 |
+
"hdfc-ergo": "https://www.hdfcergo.com/etc.clientlibs/hdfcergo/clientlibs/clientlib-site/resources/images/HDFC-ERGO-Logo.png",
|
| 36 |
+
"icici-lombard": "https://www.icicilombard.com/content/dam/ilom-website/icon/icici-lombard-logo-new.svg",
|
| 37 |
+
"manipalcigna": "https://www.manipalcigna.com/o/manipal-cigna-theme/images/manipal-cigna-logo.svg",
|
| 38 |
+
"new-india": "https://www.newindia.co.in/portal/readWriteData/NIAImages/NewLogo.png",
|
| 39 |
+
"niva-bupa": "https://transactions.nivabupa.com/_next/static/media/niva-bupa-logo.7b6e7f4e.svg",
|
| 40 |
+
"star-health": "https://www.starhealth.in/sites/default/files/star-logo-revised.png",
|
| 41 |
+
"tata-aig": "https://www.tataaig.com/etc/designs/tataaig/clientlibs/responsive/images/tataaig-logo.svg",
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
function insurerInitials(name: string): string {
|
| 45 |
+
return name.split(/[\s-]+/).map((w) => w[0]).filter(Boolean).join("").slice(0, 2).toUpperCase();
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function InsurerLogo({ slug, name, size = 40 }: { slug: string; name: string; size?: number }) {
|
| 49 |
+
const [failed, setFailed] = useState(false);
|
| 50 |
+
const url = INSURER_LOGO_URL[slug];
|
| 51 |
+
const color = INSURER_COLOR[slug] || "bg-slate-500";
|
| 52 |
+
if (!url || failed) {
|
| 53 |
+
return (
|
| 54 |
+
<div
|
| 55 |
+
className={`rounded-lg ${color} text-white flex items-center justify-center font-bold shrink-0`}
|
| 56 |
+
style={{ width: size, height: size, fontSize: size * 0.32 }}
|
| 57 |
+
>
|
| 58 |
+
{insurerInitials(name)}
|
| 59 |
+
</div>
|
| 60 |
+
);
|
| 61 |
+
}
|
| 62 |
+
return (
|
| 63 |
+
<div
|
| 64 |
+
className="rounded-lg bg-white border border-[var(--border)] flex items-center justify-center shrink-0 overflow-hidden p-1"
|
| 65 |
+
style={{ width: size, height: size }}
|
| 66 |
+
>
|
| 67 |
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
| 68 |
+
<img
|
| 69 |
+
src={url}
|
| 70 |
+
alt={name}
|
| 71 |
+
onError={() => setFailed(true)}
|
| 72 |
+
className="max-w-full max-h-full object-contain"
|
| 73 |
+
/>
|
| 74 |
+
</div>
|
| 75 |
+
);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function gradeColor(grade: string): string {
|
| 79 |
+
const map: Record<string, string> = {
|
| 80 |
+
A: "bg-emerald-500 text-white",
|
| 81 |
+
B: "bg-teal-500 text-white",
|
| 82 |
+
C: "bg-amber-500 text-white",
|
| 83 |
+
D: "bg-orange-500 text-white",
|
| 84 |
+
F: "bg-red-500 text-white",
|
| 85 |
+
};
|
| 86 |
+
return map[grade] || "bg-stone-400 text-white";
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// Dedupe citations by policy_id, preserving order.
|
| 90 |
+
function uniquePolicies(citations: Citation[]): Citation[] {
|
| 91 |
+
const seen = new Set<string>();
|
| 92 |
+
const out: Citation[] = [];
|
| 93 |
+
for (const c of citations) {
|
| 94 |
+
if (seen.has(c.policy_id)) continue;
|
| 95 |
+
seen.add(c.policy_id);
|
| 96 |
+
out.push(c);
|
| 97 |
+
}
|
| 98 |
+
return out;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
export type PolicyCompareModalProps = {
|
| 102 |
+
policies: Citation[];
|
| 103 |
+
onClose: () => void;
|
| 104 |
+
// B2 + B3 plug points. Both are optional; safe fallbacks render below.
|
| 105 |
+
renderPremiumFor?: (policyId: string) => ReactNode;
|
| 106 |
+
renderScorecardFor?: (policyId: string) => ReactNode;
|
| 107 |
+
// Profile hint for downstream personalized widgets (unused by the shell
|
| 108 |
+
// itself; pass-through so widgets opened via renderXxxFor can read it).
|
| 109 |
+
// Typed loosely (any) by contract; harness should narrow when wiring.
|
| 110 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 111 |
+
profile?: any;
|
| 112 |
+
// Hook for "Open in full marketplace" — defaults to no-op + closes modal.
|
| 113 |
+
onOpenMarketplace?: () => void;
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
export default function PolicyCompareModal({
|
| 117 |
+
policies,
|
| 118 |
+
onClose,
|
| 119 |
+
renderPremiumFor,
|
| 120 |
+
renderScorecardFor,
|
| 121 |
+
profile: _profile,
|
| 122 |
+
onOpenMarketplace,
|
| 123 |
+
}: PolicyCompareModalProps) {
|
| 124 |
+
const uniq = uniquePolicies(policies).slice(0, 4);
|
| 125 |
+
const n = uniq.length;
|
| 126 |
+
|
| 127 |
+
return (
|
| 128 |
+
<div
|
| 129 |
+
className="fixed inset-0 z-[70] bg-black/50 flex items-stretch sm:items-center justify-center p-0 sm:p-3 animate-fade-up"
|
| 130 |
+
onClick={onClose}
|
| 131 |
+
role="dialog"
|
| 132 |
+
aria-modal="true"
|
| 133 |
+
aria-label={`Compare ${n} polic${n === 1 ? "y" : "ies"}`}
|
| 134 |
+
>
|
| 135 |
+
<div
|
| 136 |
+
className="bg-[var(--card)] sm:rounded-2xl shadow-xl w-full sm:max-w-6xl sm:w-[80vw] max-h-screen sm:max-h-[92vh] overflow-y-auto scrollbar-thin"
|
| 137 |
+
onClick={(e) => e.stopPropagation()}
|
| 138 |
+
>
|
| 139 |
+
{/* Header */}
|
| 140 |
+
<div className="sticky top-0 z-10 bg-[var(--card)] border-b border-[var(--border)] px-5 py-4 flex items-center justify-between">
|
| 141 |
+
<div>
|
| 142 |
+
<h3 className="text-base font-bold">
|
| 143 |
+
Compare {n} polic{n === 1 ? "y" : "ies"}
|
| 144 |
+
</h3>
|
| 145 |
+
<p className="text-[11px] text-[var(--muted-foreground)] mt-0.5">
|
| 146 |
+
Premiums, fit scores and policy details — side-by-side.
|
| 147 |
+
</p>
|
| 148 |
+
</div>
|
| 149 |
+
<button
|
| 150 |
+
onClick={onClose}
|
| 151 |
+
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] text-2xl leading-none ml-2"
|
| 152 |
+
aria-label="Close comparison"
|
| 153 |
+
>
|
| 154 |
+
×
|
| 155 |
+
</button>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
{/* Body: 1 col on mobile, n cols on desktop */}
|
| 159 |
+
<div className="p-4 sm:p-5">
|
| 160 |
+
<div
|
| 161 |
+
className="grid gap-4 grid-cols-1"
|
| 162 |
+
style={{
|
| 163 |
+
gridTemplateColumns:
|
| 164 |
+
n > 1 ? `repeat(${n}, minmax(0, 1fr))` : undefined,
|
| 165 |
+
}}
|
| 166 |
+
>
|
| 167 |
+
{uniq.map((c) => (
|
| 168 |
+
<CompareColumn
|
| 169 |
+
key={c.policy_id}
|
| 170 |
+
citation={c}
|
| 171 |
+
premiumSlot={renderPremiumFor?.(c.policy_id)}
|
| 172 |
+
scorecardSlot={renderScorecardFor?.(c.policy_id)}
|
| 173 |
+
/>
|
| 174 |
+
))}
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
|
| 178 |
+
{/* Footer */}
|
| 179 |
+
<div className="sticky bottom-0 bg-[var(--card)] border-t border-[var(--border)] px-5 py-3 flex items-center justify-between text-xs">
|
| 180 |
+
<span className="text-[var(--muted-foreground)]">
|
| 181 |
+
Comparing the policies cited in this reply. Open the full
|
| 182 |
+
marketplace for filters and 30+ more options.
|
| 183 |
+
</span>
|
| 184 |
+
<button
|
| 185 |
+
onClick={() => {
|
| 186 |
+
onOpenMarketplace?.();
|
| 187 |
+
onClose();
|
| 188 |
+
}}
|
| 189 |
+
className="font-semibold text-[var(--primary)] hover:underline"
|
| 190 |
+
>
|
| 191 |
+
Open in full marketplace →
|
| 192 |
+
</button>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
// One vertical card per cited policy.
|
| 200 |
+
function CompareColumn({
|
| 201 |
+
citation,
|
| 202 |
+
premiumSlot,
|
| 203 |
+
scorecardSlot,
|
| 204 |
+
}: {
|
| 205 |
+
citation: Citation;
|
| 206 |
+
premiumSlot?: ReactNode;
|
| 207 |
+
scorecardSlot?: ReactNode;
|
| 208 |
+
}) {
|
| 209 |
+
const insurerName = citation.insurer_slug.replace(/-/g, " ");
|
| 210 |
+
return (
|
| 211 |
+
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-4 min-w-0">
|
| 212 |
+
{/* Header — logo + insurer + policy name */}
|
| 213 |
+
<div className="flex items-start gap-3">
|
| 214 |
+
<InsurerLogo slug={citation.insurer_slug} name={insurerName} size={40} />
|
| 215 |
+
<div className="flex-1 min-w-0">
|
| 216 |
+
<div className="text-[10px] uppercase tracking-wider text-[var(--muted-foreground)] truncate">
|
| 217 |
+
{insurerName}
|
| 218 |
+
</div>
|
| 219 |
+
<div className="font-semibold text-sm leading-tight break-words">
|
| 220 |
+
{citation.policy_name}
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
|
| 225 |
+
{/* PREMIUM CALCULATOR slot (B2) */}
|
| 226 |
+
<Section title="Premium calculator">
|
| 227 |
+
{premiumSlot ?? <PlaceholderWidget label="Premium calculator coming soon" />}
|
| 228 |
+
</Section>
|
| 229 |
+
|
| 230 |
+
{/* YOUR FIT SCORECARD slot (B3) */}
|
| 231 |
+
<Section title="Your fit scorecard">
|
| 232 |
+
{scorecardSlot ?? <ScorecardFallback policyId={citation.policy_id} />}
|
| 233 |
+
</Section>
|
| 234 |
+
|
| 235 |
+
{/* POLICY DETAILS expandable */}
|
| 236 |
+
<PolicyDetails citation={citation} />
|
| 237 |
+
</div>
|
| 238 |
+
);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
function Section({ title, children }: { title: string; children: ReactNode }) {
|
| 242 |
+
return (
|
| 243 |
+
<div>
|
| 244 |
+
<div className="text-[10px] uppercase tracking-wider text-[var(--muted-foreground)] font-semibold mb-1.5">
|
| 245 |
+
{title}
|
| 246 |
+
</div>
|
| 247 |
+
{children}
|
| 248 |
+
</div>
|
| 249 |
+
);
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
function PlaceholderWidget({ label }: { label: string }) {
|
| 253 |
+
return (
|
| 254 |
+
<div className="bg-[var(--muted)] border border-dashed border-[var(--border)] rounded-lg px-3 py-4 text-center text-[11px] text-[var(--muted-foreground)]">
|
| 255 |
+
{label}
|
| 256 |
+
</div>
|
| 257 |
+
);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
// Default scorecard preview — fetches `/api/policies/:id/scorecard` and
|
| 261 |
+
// renders a compact grade + one-liner card. Used when the parent doesn't
|
| 262 |
+
// pass a renderScorecardFor() prop.
|
| 263 |
+
function ScorecardFallback({ policyId }: { policyId: string }) {
|
| 264 |
+
const [sc, setSc] = useState<ScorecardResponse | null>(null);
|
| 265 |
+
const [loading, setLoading] = useState(true);
|
| 266 |
+
const [error, setError] = useState(false);
|
| 267 |
+
useEffect(() => {
|
| 268 |
+
let cancelled = false;
|
| 269 |
+
setLoading(true);
|
| 270 |
+
setError(false);
|
| 271 |
+
getScorecard(policyId)
|
| 272 |
+
.then((r) => {
|
| 273 |
+
if (!cancelled) setSc(r);
|
| 274 |
+
})
|
| 275 |
+
.catch(() => {
|
| 276 |
+
if (!cancelled) setError(true);
|
| 277 |
+
})
|
| 278 |
+
.finally(() => {
|
| 279 |
+
if (!cancelled) setLoading(false);
|
| 280 |
+
});
|
| 281 |
+
return () => {
|
| 282 |
+
cancelled = true;
|
| 283 |
+
};
|
| 284 |
+
}, [policyId]);
|
| 285 |
+
if (loading) {
|
| 286 |
+
return <PlaceholderWidget label="Loading scorecard…" />;
|
| 287 |
+
}
|
| 288 |
+
if (error || !sc) {
|
| 289 |
+
return <PlaceholderWidget label="Scorecard unavailable" />;
|
| 290 |
+
}
|
| 291 |
+
return (
|
| 292 |
+
<div className="bg-[var(--muted)] border border-[var(--border)] rounded-lg p-3">
|
| 293 |
+
<div className="flex items-center gap-2">
|
| 294 |
+
<span
|
| 295 |
+
className={`inline-flex items-center justify-center w-9 h-9 rounded-md font-bold text-sm ${gradeColor(
|
| 296 |
+
sc.grade,
|
| 297 |
+
)}`}
|
| 298 |
+
>
|
| 299 |
+
{sc.grade}
|
| 300 |
+
</span>
|
| 301 |
+
<div className="min-w-0">
|
| 302 |
+
<div className="text-sm font-semibold leading-tight">
|
| 303 |
+
{sc.overall_score}
|
| 304 |
+
<span className="text-[var(--muted-foreground)] text-[10px] font-normal">
|
| 305 |
+
/100
|
| 306 |
+
</span>
|
| 307 |
+
</div>
|
| 308 |
+
<div className="text-[10px] text-[var(--muted-foreground)] truncate">
|
| 309 |
+
{sc.one_liner}
|
| 310 |
+
</div>
|
| 311 |
+
</div>
|
| 312 |
+
</div>
|
| 313 |
+
</div>
|
| 314 |
+
);
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
function PolicyDetails({ citation }: { citation: Citation }) {
|
| 318 |
+
const [open, setOpen] = useState(false);
|
| 319 |
+
const hasSource = !!citation.source_url && citation.source_url.startsWith("http");
|
| 320 |
+
const pageRange =
|
| 321 |
+
citation.page_start && citation.page_end
|
| 322 |
+
? citation.page_start === citation.page_end
|
| 323 |
+
? `p. ${citation.page_start}`
|
| 324 |
+
: `pp. ${citation.page_start}–${citation.page_end}`
|
| 325 |
+
: null;
|
| 326 |
+
return (
|
| 327 |
+
<div className="border border-[var(--border)] rounded-lg bg-[var(--card)]">
|
| 328 |
+
<button
|
| 329 |
+
onClick={() => setOpen((v) => !v)}
|
| 330 |
+
className="w-full text-left px-3 py-2 text-[11px] font-semibold flex items-center justify-between hover:bg-[var(--muted)] rounded-lg"
|
| 331 |
+
aria-expanded={open}
|
| 332 |
+
>
|
| 333 |
+
<span className="uppercase tracking-wider text-[var(--muted-foreground)]">
|
| 334 |
+
Policy details
|
| 335 |
+
</span>
|
| 336 |
+
<span className="text-[var(--muted-foreground)]">{open ? "−" : "+"}</span>
|
| 337 |
+
</button>
|
| 338 |
+
{open && (
|
| 339 |
+
<div className="px-3 pb-3 pt-1 border-t border-[var(--border)] space-y-2 text-[11px]">
|
| 340 |
+
<DetailRow label="Policy" value={citation.policy_name} />
|
| 341 |
+
<DetailRow label="Insurer" value={citation.insurer_slug.replace(/-/g, " ")} />
|
| 342 |
+
{pageRange && <DetailRow label="Cited" value={pageRange} />}
|
| 343 |
+
{hasSource ? (
|
| 344 |
+
<a
|
| 345 |
+
href={citation.source_url}
|
| 346 |
+
target="_blank"
|
| 347 |
+
rel="noopener"
|
| 348 |
+
className="inline-flex items-center gap-1 text-[var(--primary)] hover:underline font-semibold"
|
| 349 |
+
>
|
| 350 |
+
Open policy PDF →
|
| 351 |
+
</a>
|
| 352 |
+
) : (
|
| 353 |
+
<span className="text-[var(--muted-foreground)] italic">
|
| 354 |
+
No source PDF link available
|
| 355 |
+
</span>
|
| 356 |
+
)}
|
| 357 |
+
</div>
|
| 358 |
+
)}
|
| 359 |
+
</div>
|
| 360 |
+
);
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
function DetailRow({ label, value }: { label: string; value: string }) {
|
| 364 |
+
return (
|
| 365 |
+
<div className="flex gap-2">
|
| 366 |
+
<span className="text-[var(--muted-foreground)] uppercase tracking-wide text-[10px] w-16 shrink-0">
|
| 367 |
+
{label}
|
| 368 |
+
</span>
|
| 369 |
+
<span className="text-[var(--foreground)] break-words">{value}</span>
|
| 370 |
+
</div>
|
| 371 |
+
);
|
| 372 |
+
}
|
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* PolicyPremiumWidget — per-policy slider-driven premium calculator.
|
| 5 |
+
*
|
| 6 |
+
* Embedded inside PolicyCompareModal (B1). Fetches an initial estimate from
|
| 7 |
+
* /api/premium/bulk using the user's profile defaults, then re-fetches
|
| 8 |
+
* (debounced 300ms) whenever the user moves the SI / tenure / deductible
|
| 9 |
+
* sliders. When the backend marks the row `assumed: true` (no curated
|
| 10 |
+
* actuarial data for this policy) the widget shows an "Estimate" badge so
|
| 11 |
+
* the user understands the number is heuristic, not a quote.
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
| 15 |
+
|
| 16 |
+
import {
|
| 17 |
+
postPremiumBulk,
|
| 18 |
+
type PremiumBulkProfile,
|
| 19 |
+
type PremiumBulkRow,
|
| 20 |
+
} from "@/lib/api";
|
| 21 |
+
|
| 22 |
+
export type PolicyPremiumWidgetProps = {
|
| 23 |
+
policyId: string;
|
| 24 |
+
policyName: string;
|
| 25 |
+
profile?: PremiumBulkProfile;
|
| 26 |
+
initialSumInsured?: number;
|
| 27 |
+
initialTenureYears?: 1 | 2 | 3;
|
| 28 |
+
initialDeductibleInr?: 0 | 25000 | 50000 | 100000;
|
| 29 |
+
onCalculated?: (premium: number) => void;
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
const SUM_INSURED_MIN = 500_000;
|
| 33 |
+
const SUM_INSURED_MAX = 10_000_000;
|
| 34 |
+
const SUM_INSURED_STEP = 500_000;
|
| 35 |
+
const TENURE_CHOICES = [1, 2, 3] as const;
|
| 36 |
+
const DEDUCTIBLE_CHOICES = [0, 25_000, 50_000, 100_000] as const;
|
| 37 |
+
|
| 38 |
+
function formatInr(value: number): string {
|
| 39 |
+
// Indian-style 1,23,456 grouping.
|
| 40 |
+
return value.toLocaleString("en-IN");
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function formatSiLabel(inr: number): string {
|
| 44 |
+
if (inr >= 10_000_000) return `${(inr / 10_000_000).toFixed(inr % 10_000_000 === 0 ? 0 : 1)}Cr`;
|
| 45 |
+
return `${Math.round(inr / 100_000)}L`;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function formatDeductibleLabel(inr: number): string {
|
| 49 |
+
if (inr === 0) return "₹0";
|
| 50 |
+
if (inr >= 100_000) return `₹${(inr / 100_000).toFixed(0)}L`;
|
| 51 |
+
return `₹${Math.round(inr / 1_000)}K`;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function summariseProfile(profile?: PremiumBulkProfile): string | null {
|
| 55 |
+
if (!profile) return null;
|
| 56 |
+
const parts: string[] = [];
|
| 57 |
+
if (typeof profile.age === "number") parts.push(`age ${profile.age}`);
|
| 58 |
+
if (typeof profile.family_size === "number" && profile.family_size > 1) {
|
| 59 |
+
parts.push(`family of ${profile.family_size}`);
|
| 60 |
+
} else if (profile.dependents) {
|
| 61 |
+
parts.push(profile.dependents);
|
| 62 |
+
}
|
| 63 |
+
if (profile.location_tier) parts.push(String(profile.location_tier).toLowerCase());
|
| 64 |
+
return parts.length ? parts.join(", ") : null;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
const BREAKDOWN_LABELS: Record<string, string> = {
|
| 68 |
+
base_inr: "Base",
|
| 69 |
+
age_loading_x: "Age loading",
|
| 70 |
+
location_loading_x: "Location loading",
|
| 71 |
+
family_loading_x: "Family loading",
|
| 72 |
+
tenure_discount_x: "Tenure discount",
|
| 73 |
+
deductible_discount_x: "Deductible discount",
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
function renderBreakdownBullets(breakdown: Record<string, number | string>): string[] {
|
| 77 |
+
const bullets: string[] = [];
|
| 78 |
+
const base = breakdown.base_inr;
|
| 79 |
+
if (typeof base === "number") bullets.push(`Base: ₹${formatInr(base)}`);
|
| 80 |
+
for (const key of [
|
| 81 |
+
"age_loading_x",
|
| 82 |
+
"location_loading_x",
|
| 83 |
+
"family_loading_x",
|
| 84 |
+
"tenure_discount_x",
|
| 85 |
+
"deductible_discount_x",
|
| 86 |
+
]) {
|
| 87 |
+
const v = breakdown[key];
|
| 88 |
+
if (typeof v === "number" && Math.abs(v - 1.0) > 0.001) {
|
| 89 |
+
const label = BREAKDOWN_LABELS[key];
|
| 90 |
+
bullets.push(`${label}: ${v.toFixed(2)}×`);
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
return bullets;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
export default function PolicyPremiumWidget({
|
| 97 |
+
policyId,
|
| 98 |
+
policyName,
|
| 99 |
+
profile,
|
| 100 |
+
initialSumInsured = 1_000_000,
|
| 101 |
+
initialTenureYears = 1,
|
| 102 |
+
initialDeductibleInr = 0,
|
| 103 |
+
onCalculated,
|
| 104 |
+
}: PolicyPremiumWidgetProps) {
|
| 105 |
+
const [sumInsured, setSumInsured] = useState<number>(initialSumInsured);
|
| 106 |
+
const [tenureYears, setTenureYears] = useState<number>(initialTenureYears);
|
| 107 |
+
const [deductibleInr, setDeductibleInr] = useState<number>(initialDeductibleInr);
|
| 108 |
+
const [row, setRow] = useState<PremiumBulkRow | null>(null);
|
| 109 |
+
const [loading, setLoading] = useState<boolean>(true);
|
| 110 |
+
const [error, setError] = useState<string | null>(null);
|
| 111 |
+
|
| 112 |
+
// Stable string key so we can put `profile` in the effect dep list without
|
| 113 |
+
// triggering refetches on every parent re-render (object identity changes).
|
| 114 |
+
const profileKey = useMemo(() => JSON.stringify(profile ?? {}), [profile]);
|
| 115 |
+
|
| 116 |
+
const onCalculatedRef = useRef(onCalculated);
|
| 117 |
+
useEffect(() => {
|
| 118 |
+
onCalculatedRef.current = onCalculated;
|
| 119 |
+
}, [onCalculated]);
|
| 120 |
+
|
| 121 |
+
const fetchPremium = useCallback(
|
| 122 |
+
async (signal: AbortSignal) => {
|
| 123 |
+
setLoading(true);
|
| 124 |
+
setError(null);
|
| 125 |
+
try {
|
| 126 |
+
const resp = await postPremiumBulk({
|
| 127 |
+
policy_ids: [policyId],
|
| 128 |
+
profile: profile ?? {},
|
| 129 |
+
overrides: {
|
| 130 |
+
[policyId]: {
|
| 131 |
+
sum_insured_inr: sumInsured,
|
| 132 |
+
tenure_years: tenureYears,
|
| 133 |
+
deductible_inr: deductibleInr,
|
| 134 |
+
},
|
| 135 |
+
},
|
| 136 |
+
});
|
| 137 |
+
if (signal.aborted) return;
|
| 138 |
+
const r = resp.per_policy[policyId];
|
| 139 |
+
if (!r) {
|
| 140 |
+
setError("No estimate returned for this policy.");
|
| 141 |
+
return;
|
| 142 |
+
}
|
| 143 |
+
setRow(r);
|
| 144 |
+
onCalculatedRef.current?.(r.premium_inr_annual);
|
| 145 |
+
} catch (e) {
|
| 146 |
+
if (signal.aborted) return;
|
| 147 |
+
setError(e instanceof Error ? e.message : String(e));
|
| 148 |
+
} finally {
|
| 149 |
+
if (!signal.aborted) setLoading(false);
|
| 150 |
+
}
|
| 151 |
+
},
|
| 152 |
+
[policyId, profileKey, sumInsured, tenureYears, deductibleInr], // eslint-disable-line react-hooks/exhaustive-deps
|
| 153 |
+
);
|
| 154 |
+
|
| 155 |
+
// 300ms debounce on slider drags; immediate fetch on mount / policy switch.
|
| 156 |
+
useEffect(() => {
|
| 157 |
+
const ctrl = new AbortController();
|
| 158 |
+
const handle = window.setTimeout(() => {
|
| 159 |
+
void fetchPremium(ctrl.signal);
|
| 160 |
+
}, 300);
|
| 161 |
+
return () => {
|
| 162 |
+
window.clearTimeout(handle);
|
| 163 |
+
ctrl.abort();
|
| 164 |
+
};
|
| 165 |
+
}, [fetchPremium]);
|
| 166 |
+
|
| 167 |
+
const profileSummary = summariseProfile(profile);
|
| 168 |
+
const bullets = row ? renderBreakdownBullets(row.breakdown) : [];
|
| 169 |
+
|
| 170 |
+
return (
|
| 171 |
+
<div className="policy-premium-widget" style={widgetStyle}>
|
| 172 |
+
<header style={headerStyle}>
|
| 173 |
+
<div style={{ fontWeight: 600, fontSize: 14 }}>{policyName}</div>
|
| 174 |
+
{row?.assumed && (
|
| 175 |
+
<span style={badgeStyle} title="Heuristic — no exact actuarial data for this policy.">
|
| 176 |
+
Estimate
|
| 177 |
+
</span>
|
| 178 |
+
)}
|
| 179 |
+
</header>
|
| 180 |
+
|
| 181 |
+
{profileSummary && (
|
| 182 |
+
<div style={profileLineStyle}>
|
| 183 |
+
Your profile defaults: {profileSummary}
|
| 184 |
+
</div>
|
| 185 |
+
)}
|
| 186 |
+
|
| 187 |
+
<div style={sliderGroupStyle}>
|
| 188 |
+
<label style={labelStyle}>
|
| 189 |
+
<span style={labelHeadStyle}>
|
| 190 |
+
Sum insured
|
| 191 |
+
<strong>₹{formatSiLabel(sumInsured)}</strong>
|
| 192 |
+
</span>
|
| 193 |
+
<input
|
| 194 |
+
type="range"
|
| 195 |
+
min={SUM_INSURED_MIN}
|
| 196 |
+
max={SUM_INSURED_MAX}
|
| 197 |
+
step={SUM_INSURED_STEP}
|
| 198 |
+
value={sumInsured}
|
| 199 |
+
onChange={(e) => setSumInsured(Number(e.target.value))}
|
| 200 |
+
aria-label="Sum insured"
|
| 201 |
+
style={{ width: "100%" }}
|
| 202 |
+
/>
|
| 203 |
+
<div style={tickRowStyle}>
|
| 204 |
+
<span>₹5L</span>
|
| 205 |
+
<span>₹1Cr</span>
|
| 206 |
+
</div>
|
| 207 |
+
</label>
|
| 208 |
+
|
| 209 |
+
<label style={labelStyle}>
|
| 210 |
+
<span style={labelHeadStyle}>
|
| 211 |
+
Tenure
|
| 212 |
+
<strong>{tenureYears} {tenureYears === 1 ? "year" : "years"}</strong>
|
| 213 |
+
</span>
|
| 214 |
+
<div role="radiogroup" aria-label="Tenure" style={pillRowStyle}>
|
| 215 |
+
{TENURE_CHOICES.map((y) => (
|
| 216 |
+
<button
|
| 217 |
+
key={y}
|
| 218 |
+
type="button"
|
| 219 |
+
role="radio"
|
| 220 |
+
aria-checked={tenureYears === y}
|
| 221 |
+
onClick={() => setTenureYears(y)}
|
| 222 |
+
style={pillStyle(tenureYears === y)}
|
| 223 |
+
>
|
| 224 |
+
{y}y
|
| 225 |
+
</button>
|
| 226 |
+
))}
|
| 227 |
+
</div>
|
| 228 |
+
</label>
|
| 229 |
+
|
| 230 |
+
<label style={labelStyle}>
|
| 231 |
+
<span style={labelHeadStyle}>
|
| 232 |
+
Deductible
|
| 233 |
+
<strong>{formatDeductibleLabel(deductibleInr)}</strong>
|
| 234 |
+
</span>
|
| 235 |
+
<div role="radiogroup" aria-label="Deductible" style={pillRowStyle}>
|
| 236 |
+
{DEDUCTIBLE_CHOICES.map((d) => (
|
| 237 |
+
<button
|
| 238 |
+
key={d}
|
| 239 |
+
type="button"
|
| 240 |
+
role="radio"
|
| 241 |
+
aria-checked={deductibleInr === d}
|
| 242 |
+
onClick={() => setDeductibleInr(d)}
|
| 243 |
+
style={pillStyle(deductibleInr === d)}
|
| 244 |
+
>
|
| 245 |
+
{formatDeductibleLabel(d)}
|
| 246 |
+
</button>
|
| 247 |
+
))}
|
| 248 |
+
</div>
|
| 249 |
+
</label>
|
| 250 |
+
</div>
|
| 251 |
+
|
| 252 |
+
<div style={resultBoxStyle} aria-live="polite">
|
| 253 |
+
{error ? (
|
| 254 |
+
<div style={{ color: "#b00020" }}>Failed: {error}</div>
|
| 255 |
+
) : loading && !row ? (
|
| 256 |
+
<div style={{ color: "#666" }}>Calculating estimate…</div>
|
| 257 |
+
) : row ? (
|
| 258 |
+
<>
|
| 259 |
+
<div style={resultHeadlineStyle}>
|
| 260 |
+
Estimated premium:
|
| 261 |
+
<strong>₹{formatInr(row.premium_inr_annual)}</strong>
|
| 262 |
+
<span style={resultSuffixStyle}>/year</span>
|
| 263 |
+
{loading && <span style={spinnerHintStyle}> updating…</span>}
|
| 264 |
+
</div>
|
| 265 |
+
{bullets.length > 0 && (
|
| 266 |
+
<ul style={breakdownListStyle}>
|
| 267 |
+
{bullets.map((b) => (
|
| 268 |
+
<li key={b}>{b}</li>
|
| 269 |
+
))}
|
| 270 |
+
</ul>
|
| 271 |
+
)}
|
| 272 |
+
{row.notes && row.notes.length > 0 && (
|
| 273 |
+
<div style={noteStyle}>{row.notes.join(" ")}</div>
|
| 274 |
+
)}
|
| 275 |
+
</>
|
| 276 |
+
) : null}
|
| 277 |
+
</div>
|
| 278 |
+
</div>
|
| 279 |
+
);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
/* ------------------------------------------------------------------ */
|
| 283 |
+
/* Inline styles — kept local so the widget drops into any modal */
|
| 284 |
+
/* without a CSS-module dependency. */
|
| 285 |
+
/* ------------------------------------------------------------------ */
|
| 286 |
+
|
| 287 |
+
const widgetStyle: React.CSSProperties = {
|
| 288 |
+
border: "1px solid #e5e7eb",
|
| 289 |
+
borderRadius: 12,
|
| 290 |
+
padding: 16,
|
| 291 |
+
background: "#fff",
|
| 292 |
+
display: "flex",
|
| 293 |
+
flexDirection: "column",
|
| 294 |
+
gap: 12,
|
| 295 |
+
fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
|
| 296 |
+
};
|
| 297 |
+
|
| 298 |
+
const headerStyle: React.CSSProperties = {
|
| 299 |
+
display: "flex",
|
| 300 |
+
alignItems: "center",
|
| 301 |
+
justifyContent: "space-between",
|
| 302 |
+
gap: 8,
|
| 303 |
+
};
|
| 304 |
+
|
| 305 |
+
const badgeStyle: React.CSSProperties = {
|
| 306 |
+
fontSize: 11,
|
| 307 |
+
fontWeight: 600,
|
| 308 |
+
padding: "2px 8px",
|
| 309 |
+
borderRadius: 999,
|
| 310 |
+
background: "#fff8e1",
|
| 311 |
+
color: "#8a6d00",
|
| 312 |
+
border: "1px solid #f1d680",
|
| 313 |
+
};
|
| 314 |
+
|
| 315 |
+
const profileLineStyle: React.CSSProperties = {
|
| 316 |
+
fontSize: 12,
|
| 317 |
+
color: "#666",
|
| 318 |
+
};
|
| 319 |
+
|
| 320 |
+
const sliderGroupStyle: React.CSSProperties = {
|
| 321 |
+
display: "flex",
|
| 322 |
+
flexDirection: "column",
|
| 323 |
+
gap: 14,
|
| 324 |
+
};
|
| 325 |
+
|
| 326 |
+
const labelStyle: React.CSSProperties = {
|
| 327 |
+
display: "flex",
|
| 328 |
+
flexDirection: "column",
|
| 329 |
+
gap: 6,
|
| 330 |
+
fontSize: 12,
|
| 331 |
+
color: "#374151",
|
| 332 |
+
};
|
| 333 |
+
|
| 334 |
+
const labelHeadStyle: React.CSSProperties = {
|
| 335 |
+
display: "flex",
|
| 336 |
+
alignItems: "center",
|
| 337 |
+
justifyContent: "space-between",
|
| 338 |
+
gap: 8,
|
| 339 |
+
fontWeight: 500,
|
| 340 |
+
};
|
| 341 |
+
|
| 342 |
+
const tickRowStyle: React.CSSProperties = {
|
| 343 |
+
display: "flex",
|
| 344 |
+
justifyContent: "space-between",
|
| 345 |
+
fontSize: 11,
|
| 346 |
+
color: "#9ca3af",
|
| 347 |
+
};
|
| 348 |
+
|
| 349 |
+
const pillRowStyle: React.CSSProperties = {
|
| 350 |
+
display: "flex",
|
| 351 |
+
gap: 6,
|
| 352 |
+
flexWrap: "wrap",
|
| 353 |
+
};
|
| 354 |
+
|
| 355 |
+
const pillStyle = (active: boolean): React.CSSProperties => ({
|
| 356 |
+
border: `1px solid ${active ? "#2563eb" : "#d1d5db"}`,
|
| 357 |
+
background: active ? "#2563eb" : "#fff",
|
| 358 |
+
color: active ? "#fff" : "#374151",
|
| 359 |
+
padding: "4px 10px",
|
| 360 |
+
borderRadius: 999,
|
| 361 |
+
fontSize: 12,
|
| 362 |
+
cursor: "pointer",
|
| 363 |
+
});
|
| 364 |
+
|
| 365 |
+
const resultBoxStyle: React.CSSProperties = {
|
| 366 |
+
borderTop: "1px solid #f1f5f9",
|
| 367 |
+
paddingTop: 12,
|
| 368 |
+
display: "flex",
|
| 369 |
+
flexDirection: "column",
|
| 370 |
+
gap: 8,
|
| 371 |
+
};
|
| 372 |
+
|
| 373 |
+
const resultHeadlineStyle: React.CSSProperties = {
|
| 374 |
+
fontSize: 14,
|
| 375 |
+
};
|
| 376 |
+
|
| 377 |
+
const resultSuffixStyle: React.CSSProperties = {
|
| 378 |
+
color: "#6b7280",
|
| 379 |
+
fontWeight: 400,
|
| 380 |
+
};
|
| 381 |
+
|
| 382 |
+
const spinnerHintStyle: React.CSSProperties = {
|
| 383 |
+
marginLeft: 8,
|
| 384 |
+
fontSize: 11,
|
| 385 |
+
color: "#9ca3af",
|
| 386 |
+
fontStyle: "italic",
|
| 387 |
+
};
|
| 388 |
+
|
| 389 |
+
const breakdownListStyle: React.CSSProperties = {
|
| 390 |
+
margin: 0,
|
| 391 |
+
paddingLeft: 18,
|
| 392 |
+
color: "#4b5563",
|
| 393 |
+
fontSize: 12,
|
| 394 |
+
lineHeight: 1.5,
|
| 395 |
+
};
|
| 396 |
+
|
| 397 |
+
const noteStyle: React.CSSProperties = {
|
| 398 |
+
fontSize: 11,
|
| 399 |
+
color: "#6b7280",
|
| 400 |
+
fontStyle: "italic",
|
| 401 |
+
};
|
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// PolicyScorecardWidget — profile-aware A/B+/etc grade card for a single
|
| 2 |
+
// policy. Mounted inside PolicyCompareModal once per policy.
|
| 3 |
+
//
|
| 4 |
+
// Why a per-policy widget (rather than a single multi-policy table):
|
| 5 |
+
// - PolicyCompareModal renders 2-4 of these side-by-side in a flex row.
|
| 6 |
+
// - Each card is self-contained: header grade + overall score, sub-score
|
| 7 |
+
// bars, profile rationale bullets, data-completeness warning.
|
| 8 |
+
// - The fetch is one bulk POST per widget mount (single-policy), but the
|
| 9 |
+
// SAME endpoint can be batched by a parent that wants to issue one call
|
| 10 |
+
// for all N policies — we expose `precomputed` for that case.
|
| 11 |
+
//
|
| 12 |
+
// Ranking is UNIQUE TO THE USER's PROFILE. The backend's
|
| 13 |
+
// _profile_tuned_weights() re-balances the 6 sub-score weights based on:
|
| 14 |
+
// - diabetes / BP / hyper -> heavier waiting-period + claim-experience
|
| 15 |
+
// - age >= 50 -> heavier renewal-protection
|
| 16 |
+
// - dependents = spouse -> heavier coverage-breadth + cost-predictability
|
| 17 |
+
// - existing_cover_inr=0 -> heavier cost-predictability (first-time buyer)
|
| 18 |
+
// So the same policy will literally score differently for different users.
|
| 19 |
+
|
| 20 |
+
"use client";
|
| 21 |
+
|
| 22 |
+
import { useEffect, useMemo, useState } from "react";
|
| 23 |
+
import {
|
| 24 |
+
postScorecardBulk,
|
| 25 |
+
type BulkScorecardEntry,
|
| 26 |
+
type BulkScorecardProfile,
|
| 27 |
+
} from "@/lib/api";
|
| 28 |
+
|
| 29 |
+
export type PolicyScorecardWidgetProps = {
|
| 30 |
+
policyId: string;
|
| 31 |
+
policyName: string;
|
| 32 |
+
profile?: BulkScorecardProfile;
|
| 33 |
+
// When the parent has already fetched a bulk response, pass the entry
|
| 34 |
+
// directly — avoids a second network call per widget.
|
| 35 |
+
precomputed?: BulkScorecardEntry;
|
| 36 |
+
// Optional callback so the parent can collect entries for analytics /
|
| 37 |
+
// a ranking row above the cards.
|
| 38 |
+
onLoaded?: (entry: BulkScorecardEntry) => void;
|
| 39 |
+
className?: string;
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
// Sub-score keys we know about — controls render order. Anything else the
|
| 43 |
+
// backend returns gets appended after these in arrival order.
|
| 44 |
+
const SUBSCORE_ORDER: { key: string; label: string }[] = [
|
| 45 |
+
{ key: "coverage_breadth", label: "Coverage Breadth" },
|
| 46 |
+
{ key: "cost_predictability", label: "Cost Predictability" },
|
| 47 |
+
{ key: "waiting_period_friction", label: "Waiting Periods" },
|
| 48 |
+
{ key: "claim_experience", label: "Claim Experience" },
|
| 49 |
+
{ key: "renewal_protection", label: "Renewal Protection" },
|
| 50 |
+
{ key: "bonus_and_loyalty", label: "Bonuses" },
|
| 51 |
+
];
|
| 52 |
+
|
| 53 |
+
function gradeColor(grade: string): { fg: string; bg: string; ring: string } {
|
| 54 |
+
// Same palette family for every variant of a letter so a B+ looks like a B-
|
| 55 |
+
// but slightly stronger.
|
| 56 |
+
const head = grade.charAt(0).toUpperCase();
|
| 57 |
+
switch (head) {
|
| 58 |
+
case "A":
|
| 59 |
+
return { fg: "#0f5132", bg: "#d1fadf", ring: "#16a34a" };
|
| 60 |
+
case "B":
|
| 61 |
+
return { fg: "#0c4a6e", bg: "#dbeafe", ring: "#2563eb" };
|
| 62 |
+
case "C":
|
| 63 |
+
return { fg: "#854d0e", bg: "#fef3c7", ring: "#d97706" };
|
| 64 |
+
case "D":
|
| 65 |
+
return { fg: "#7c2d12", bg: "#fed7aa", ring: "#ea580c" };
|
| 66 |
+
case "F":
|
| 67 |
+
return { fg: "#7f1d1d", bg: "#fecaca", ring: "#dc2626" };
|
| 68 |
+
default:
|
| 69 |
+
return { fg: "#374151", bg: "#f3f4f6", ring: "#9ca3af" };
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function barColor(score: number): string {
|
| 74 |
+
if (score >= 80) return "#16a34a"; // green
|
| 75 |
+
if (score >= 65) return "#2563eb"; // blue
|
| 76 |
+
if (score >= 50) return "#d97706"; // amber
|
| 77 |
+
if (score >= 35) return "#ea580c"; // orange
|
| 78 |
+
return "#dc2626"; // red
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function rationaleTone(bullet: string): "pos" | "neg" | "neutral" {
|
| 82 |
+
const lower = bullet.toLowerCase();
|
| 83 |
+
if (lower.startsWith("strong fit") || lower.startsWith("strongest")) return "pos";
|
| 84 |
+
if (lower.startsWith("weak fit") || lower.startsWith("watch out")) return "neg";
|
| 85 |
+
return "neutral";
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
export default function PolicyScorecardWidget({
|
| 89 |
+
policyId,
|
| 90 |
+
policyName,
|
| 91 |
+
profile,
|
| 92 |
+
precomputed,
|
| 93 |
+
onLoaded,
|
| 94 |
+
className,
|
| 95 |
+
}: PolicyScorecardWidgetProps) {
|
| 96 |
+
const [entry, setEntry] = useState<BulkScorecardEntry | null>(precomputed ?? null);
|
| 97 |
+
const [loading, setLoading] = useState(!precomputed);
|
| 98 |
+
const [error, setError] = useState<string | null>(null);
|
| 99 |
+
|
| 100 |
+
// Stabilise profile dependency: callers usually rebuild the object each
|
| 101 |
+
// render but the values rarely change. Stringify-key the effect so we don't
|
| 102 |
+
// re-fetch on identity churn.
|
| 103 |
+
const profileKey = useMemo(
|
| 104 |
+
() => (profile ? JSON.stringify(profile) : ""),
|
| 105 |
+
[profile],
|
| 106 |
+
);
|
| 107 |
+
|
| 108 |
+
useEffect(() => {
|
| 109 |
+
if (precomputed) {
|
| 110 |
+
setEntry(precomputed);
|
| 111 |
+
setLoading(false);
|
| 112 |
+
return;
|
| 113 |
+
}
|
| 114 |
+
let cancelled = false;
|
| 115 |
+
setLoading(true);
|
| 116 |
+
setError(null);
|
| 117 |
+
postScorecardBulk({
|
| 118 |
+
policy_ids: [policyId],
|
| 119 |
+
profile: profile ?? undefined,
|
| 120 |
+
})
|
| 121 |
+
.then((resp) => {
|
| 122 |
+
if (cancelled) return;
|
| 123 |
+
const e = resp.per_policy?.[policyId];
|
| 124 |
+
if (!e) {
|
| 125 |
+
setError("No scorecard returned for this policy.");
|
| 126 |
+
setEntry(null);
|
| 127 |
+
} else {
|
| 128 |
+
setEntry(e);
|
| 129 |
+
onLoaded?.(e);
|
| 130 |
+
}
|
| 131 |
+
})
|
| 132 |
+
.catch((err: unknown) => {
|
| 133 |
+
if (cancelled) return;
|
| 134 |
+
setError(err instanceof Error ? err.message : "Failed to load scorecard.");
|
| 135 |
+
})
|
| 136 |
+
.finally(() => {
|
| 137 |
+
if (!cancelled) setLoading(false);
|
| 138 |
+
});
|
| 139 |
+
return () => {
|
| 140 |
+
cancelled = true;
|
| 141 |
+
};
|
| 142 |
+
// policyId + profileKey + precomputed identity are the real deps; onLoaded
|
| 143 |
+
// is intentionally excluded to avoid re-fetch loops if the parent passes
|
| 144 |
+
// an inline arrow.
|
| 145 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 146 |
+
}, [policyId, profileKey, precomputed]);
|
| 147 |
+
|
| 148 |
+
if (loading) {
|
| 149 |
+
return (
|
| 150 |
+
<div
|
| 151 |
+
className={className}
|
| 152 |
+
style={{
|
| 153 |
+
border: "1px solid #e5e7eb",
|
| 154 |
+
borderRadius: 12,
|
| 155 |
+
padding: 16,
|
| 156 |
+
background: "#fff",
|
| 157 |
+
minHeight: 220,
|
| 158 |
+
display: "flex",
|
| 159 |
+
alignItems: "center",
|
| 160 |
+
justifyContent: "center",
|
| 161 |
+
color: "#6b7280",
|
| 162 |
+
fontSize: 13,
|
| 163 |
+
}}
|
| 164 |
+
aria-busy="true"
|
| 165 |
+
aria-live="polite"
|
| 166 |
+
>
|
| 167 |
+
Scoring {policyName}…
|
| 168 |
+
</div>
|
| 169 |
+
);
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
if (error || !entry) {
|
| 173 |
+
return (
|
| 174 |
+
<div
|
| 175 |
+
className={className}
|
| 176 |
+
style={{
|
| 177 |
+
border: "1px solid #fecaca",
|
| 178 |
+
borderRadius: 12,
|
| 179 |
+
padding: 16,
|
| 180 |
+
background: "#fef2f2",
|
| 181 |
+
color: "#991b1b",
|
| 182 |
+
fontSize: 13,
|
| 183 |
+
}}
|
| 184 |
+
role="alert"
|
| 185 |
+
>
|
| 186 |
+
Couldn’t score this policy: {error ?? "unknown error"}
|
| 187 |
+
</div>
|
| 188 |
+
);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
const isNA = entry.overall_grade === "N/A";
|
| 192 |
+
const colors = gradeColor(entry.overall_grade);
|
| 193 |
+
const completeness = entry.data_completeness_pct;
|
| 194 |
+
const showLimitedWarning = completeness < 50 && !isNA;
|
| 195 |
+
|
| 196 |
+
// Render sub-scores in the canonical order first, then any extras.
|
| 197 |
+
const knownKeys = new Set(SUBSCORE_ORDER.map((s) => s.key));
|
| 198 |
+
const extras = Object.keys(entry.sub_scores).filter((k) => !knownKeys.has(k));
|
| 199 |
+
const renderable = [
|
| 200 |
+
...SUBSCORE_ORDER.filter((s) => entry.sub_scores[s.key] !== undefined),
|
| 201 |
+
...extras.map((k) => ({ key: k, label: k.replace(/_/g, " ") })),
|
| 202 |
+
];
|
| 203 |
+
|
| 204 |
+
return (
|
| 205 |
+
<div
|
| 206 |
+
className={className}
|
| 207 |
+
style={{
|
| 208 |
+
border: "1px solid #e5e7eb",
|
| 209 |
+
borderRadius: 12,
|
| 210 |
+
padding: 16,
|
| 211 |
+
background: "#fff",
|
| 212 |
+
display: "flex",
|
| 213 |
+
flexDirection: "column",
|
| 214 |
+
gap: 14,
|
| 215 |
+
}}
|
| 216 |
+
data-policy-id={policyId}
|
| 217 |
+
>
|
| 218 |
+
{/* Header: grade + overall score */}
|
| 219 |
+
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
| 220 |
+
<div
|
| 221 |
+
style={{
|
| 222 |
+
width: 64,
|
| 223 |
+
height: 64,
|
| 224 |
+
borderRadius: 14,
|
| 225 |
+
background: colors.bg,
|
| 226 |
+
color: colors.fg,
|
| 227 |
+
border: `2px solid ${colors.ring}`,
|
| 228 |
+
display: "flex",
|
| 229 |
+
alignItems: "center",
|
| 230 |
+
justifyContent: "center",
|
| 231 |
+
fontSize: entry.overall_grade.length > 1 ? 26 : 32,
|
| 232 |
+
fontWeight: 800,
|
| 233 |
+
letterSpacing: "-0.04em",
|
| 234 |
+
flexShrink: 0,
|
| 235 |
+
}}
|
| 236 |
+
aria-label={`Grade ${entry.overall_grade}`}
|
| 237 |
+
>
|
| 238 |
+
{entry.overall_grade}
|
| 239 |
+
</div>
|
| 240 |
+
<div style={{ minWidth: 0, flex: 1 }}>
|
| 241 |
+
<div
|
| 242 |
+
style={{
|
| 243 |
+
fontSize: 13,
|
| 244 |
+
fontWeight: 600,
|
| 245 |
+
color: "#111827",
|
| 246 |
+
whiteSpace: "nowrap",
|
| 247 |
+
overflow: "hidden",
|
| 248 |
+
textOverflow: "ellipsis",
|
| 249 |
+
}}
|
| 250 |
+
title={entry.policy_name || policyName}
|
| 251 |
+
>
|
| 252 |
+
{entry.policy_name || policyName}
|
| 253 |
+
</div>
|
| 254 |
+
<div style={{ display: "flex", alignItems: "baseline", gap: 6, marginTop: 2 }}>
|
| 255 |
+
<span style={{ fontSize: 22, fontWeight: 700, color: "#111827" }}>
|
| 256 |
+
{isNA ? "—" : `${entry.overall_score}`}
|
| 257 |
+
</span>
|
| 258 |
+
{!isNA && (
|
| 259 |
+
<span style={{ fontSize: 12, color: "#6b7280" }}>/ 100</span>
|
| 260 |
+
)}
|
| 261 |
+
{profile && !isNA && (
|
| 262 |
+
<span
|
| 263 |
+
style={{
|
| 264 |
+
marginLeft: "auto",
|
| 265 |
+
fontSize: 10,
|
| 266 |
+
textTransform: "uppercase",
|
| 267 |
+
letterSpacing: "0.08em",
|
| 268 |
+
color: "#0c4a6e",
|
| 269 |
+
background: "#e0f2fe",
|
| 270 |
+
padding: "2px 6px",
|
| 271 |
+
borderRadius: 4,
|
| 272 |
+
fontWeight: 600,
|
| 273 |
+
}}
|
| 274 |
+
title="Score weights adjusted for your profile"
|
| 275 |
+
>
|
| 276 |
+
Personalised
|
| 277 |
+
</span>
|
| 278 |
+
)}
|
| 279 |
+
</div>
|
| 280 |
+
{entry.one_liner && (
|
| 281 |
+
<div style={{ fontSize: 11, color: "#4b5563", marginTop: 2 }}>
|
| 282 |
+
{entry.one_liner}
|
| 283 |
+
</div>
|
| 284 |
+
)}
|
| 285 |
+
</div>
|
| 286 |
+
</div>
|
| 287 |
+
|
| 288 |
+
{/* Sub-scores */}
|
| 289 |
+
{renderable.length > 0 && (
|
| 290 |
+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
| 291 |
+
{renderable.map(({ key, label }) => {
|
| 292 |
+
const v = entry.sub_scores[key] ?? 0;
|
| 293 |
+
return (
|
| 294 |
+
<div key={key} style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
| 295 |
+
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11 }}>
|
| 296 |
+
<span style={{ color: "#374151", textTransform: "capitalize" }}>
|
| 297 |
+
{label}
|
| 298 |
+
</span>
|
| 299 |
+
<span style={{ color: "#6b7280", fontVariantNumeric: "tabular-nums" }}>
|
| 300 |
+
{v}
|
| 301 |
+
</span>
|
| 302 |
+
</div>
|
| 303 |
+
<div
|
| 304 |
+
style={{
|
| 305 |
+
height: 6,
|
| 306 |
+
borderRadius: 999,
|
| 307 |
+
background: "#f3f4f6",
|
| 308 |
+
overflow: "hidden",
|
| 309 |
+
}}
|
| 310 |
+
>
|
| 311 |
+
<div
|
| 312 |
+
style={{
|
| 313 |
+
width: `${Math.max(0, Math.min(100, v))}%`,
|
| 314 |
+
height: "100%",
|
| 315 |
+
background: barColor(v),
|
| 316 |
+
transition: "width 200ms ease-out",
|
| 317 |
+
}}
|
| 318 |
+
/>
|
| 319 |
+
</div>
|
| 320 |
+
</div>
|
| 321 |
+
);
|
| 322 |
+
})}
|
| 323 |
+
</div>
|
| 324 |
+
)}
|
| 325 |
+
|
| 326 |
+
{/* Profile rationale */}
|
| 327 |
+
{entry.profile_rationale.length > 0 && (
|
| 328 |
+
<div
|
| 329 |
+
style={{
|
| 330 |
+
background: "#f9fafb",
|
| 331 |
+
border: "1px solid #f3f4f6",
|
| 332 |
+
borderRadius: 8,
|
| 333 |
+
padding: 10,
|
| 334 |
+
}}
|
| 335 |
+
>
|
| 336 |
+
<div
|
| 337 |
+
style={{
|
| 338 |
+
fontSize: 10,
|
| 339 |
+
textTransform: "uppercase",
|
| 340 |
+
letterSpacing: "0.08em",
|
| 341 |
+
color: "#6b7280",
|
| 342 |
+
fontWeight: 600,
|
| 343 |
+
marginBottom: 6,
|
| 344 |
+
}}
|
| 345 |
+
>
|
| 346 |
+
Why this score for you
|
| 347 |
+
</div>
|
| 348 |
+
<ul style={{ margin: 0, paddingLeft: 16, display: "flex", flexDirection: "column", gap: 4 }}>
|
| 349 |
+
{entry.profile_rationale.map((b, i) => {
|
| 350 |
+
const tone = rationaleTone(b);
|
| 351 |
+
const color =
|
| 352 |
+
tone === "pos" ? "#0f5132" : tone === "neg" ? "#991b1b" : "#374151";
|
| 353 |
+
return (
|
| 354 |
+
<li key={i} style={{ fontSize: 12, color, lineHeight: 1.4 }}>
|
| 355 |
+
{b}
|
| 356 |
+
</li>
|
| 357 |
+
);
|
| 358 |
+
})}
|
| 359 |
+
</ul>
|
| 360 |
+
</div>
|
| 361 |
+
)}
|
| 362 |
+
|
| 363 |
+
{/* Limited-data warning */}
|
| 364 |
+
{showLimitedWarning && (
|
| 365 |
+
<div
|
| 366 |
+
style={{
|
| 367 |
+
display: "flex",
|
| 368 |
+
alignItems: "center",
|
| 369 |
+
gap: 8,
|
| 370 |
+
fontSize: 11,
|
| 371 |
+
color: "#854d0e",
|
| 372 |
+
background: "#fef3c7",
|
| 373 |
+
border: "1px solid #fde68a",
|
| 374 |
+
borderRadius: 8,
|
| 375 |
+
padding: "6px 10px",
|
| 376 |
+
}}
|
| 377 |
+
role="status"
|
| 378 |
+
>
|
| 379 |
+
<span style={{ fontWeight: 700 }}>Limited data:</span>
|
| 380 |
+
<span>
|
| 381 |
+
Only {completeness.toFixed(0)}% of scoring fields are filled for this policy —
|
| 382 |
+
grade may shift once more details are indexed.
|
| 383 |
+
</span>
|
| 384 |
+
</div>
|
| 385 |
+
)}
|
| 386 |
+
</div>
|
| 387 |
+
);
|
| 388 |
+
}
|
|
@@ -34,6 +34,11 @@ export type ChatResponse = {
|
|
| 34 |
faithfulness_passed?: boolean;
|
| 35 |
faithfulness_reasons?: string[];
|
| 36 |
blocked?: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
};
|
| 38 |
|
| 39 |
export type ChatMessage = {
|
|
@@ -243,6 +248,61 @@ export async function getScorecard(policy_id: string): Promise<ScorecardResponse
|
|
| 243 |
return resp.json();
|
| 244 |
}
|
| 245 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
export type PreExistingCondition =
|
| 247 |
| "none"
|
| 248 |
| "diabetes_or_hypertension"
|
|
@@ -385,6 +445,50 @@ export async function getProfileCompleteness(session_id?: string): Promise<Profi
|
|
| 385 |
return resp.json();
|
| 386 |
}
|
| 387 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
export async function postProfileUpdate(req: UserProfile & { session_id: string }): Promise<ProfileCompletenessResponse> {
|
| 389 |
const resp = await fetch(`${BACKEND_URL}/api/profile`, {
|
| 390 |
method: "POST",
|
|
@@ -421,6 +525,67 @@ export async function postPremiumEstimate(req: PremiumEstimateRequest): Promise<
|
|
| 421 |
}
|
| 422 |
|
| 423 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
// KI-020 — User-facing chat clear / session restart.
|
| 425 |
export interface SessionResetResponse {
|
| 426 |
ok: boolean;
|
|
|
|
| 34 |
faithfulness_passed?: boolean;
|
| 35 |
faithfulness_reasons?: string[];
|
| 36 |
blocked?: boolean;
|
| 37 |
+
// KI-Z7 (2026-05-15) — Feature B. True when single_brain.handle_turn's
|
| 38 |
+
// turn-1 name heuristic matched a stored named-profile and hydrated the
|
| 39 |
+
// live session. Frontend renders a "Welcome back" banner when this is
|
| 40 |
+
// true on the assistant turn it arrives with.
|
| 41 |
+
returning_user_recalled?: boolean;
|
| 42 |
};
|
| 43 |
|
| 44 |
export type ChatMessage = {
|
|
|
|
| 248 |
return resp.json();
|
| 249 |
}
|
| 250 |
|
| 251 |
+
// ----------------------------------------------------------------------------
|
| 252 |
+
// Bulk profile-tuned scorecards (powers PolicyCompareModal scorecard widget).
|
| 253 |
+
// One POST returns N scorecards in parallel — each weighted by the same
|
| 254 |
+
// profile so the widget can render comparable ranks at a glance.
|
| 255 |
+
// ----------------------------------------------------------------------------
|
| 256 |
+
export type BulkScorecardProfile = {
|
| 257 |
+
age?: number;
|
| 258 |
+
dependents?: string;
|
| 259 |
+
health_conditions?: string[];
|
| 260 |
+
primary_goal?: string;
|
| 261 |
+
location_tier?: string;
|
| 262 |
+
income_band?: string;
|
| 263 |
+
budget_band?: string;
|
| 264 |
+
existing_cover_inr?: number;
|
| 265 |
+
parents_to_insure?: boolean;
|
| 266 |
+
parents_age_max?: number;
|
| 267 |
+
parents_has_ped?: boolean;
|
| 268 |
+
};
|
| 269 |
+
|
| 270 |
+
export type BulkScorecardEntry = {
|
| 271 |
+
policy_id: string;
|
| 272 |
+
policy_name: string;
|
| 273 |
+
insurer_slug: string;
|
| 274 |
+
overall_grade: string; // "A" | "A-" | "B+" | ... | "N/A"
|
| 275 |
+
overall_score: number; // 0-100
|
| 276 |
+
sub_scores: Record<string, number>; // {coverage_breadth: 82, ...}
|
| 277 |
+
profile_rationale: string[];
|
| 278 |
+
data_completeness_pct: number; // 0-100
|
| 279 |
+
one_liner?: string;
|
| 280 |
+
signals?: Record<string, string[]>;
|
| 281 |
+
};
|
| 282 |
+
|
| 283 |
+
export type BulkScorecardResponse = {
|
| 284 |
+
per_policy: Record<string, BulkScorecardEntry>;
|
| 285 |
+
};
|
| 286 |
+
|
| 287 |
+
export async function postScorecardBulk(args: {
|
| 288 |
+
policy_ids: string[];
|
| 289 |
+
profile?: BulkScorecardProfile;
|
| 290 |
+
}): Promise<BulkScorecardResponse> {
|
| 291 |
+
const resp = await fetch(`${BACKEND_URL}/api/scorecard/bulk`, {
|
| 292 |
+
method: "POST",
|
| 293 |
+
headers: { "Content-Type": "application/json" },
|
| 294 |
+
body: JSON.stringify({
|
| 295 |
+
policy_ids: args.policy_ids,
|
| 296 |
+
profile: args.profile ?? null,
|
| 297 |
+
}),
|
| 298 |
+
});
|
| 299 |
+
if (!resp.ok) {
|
| 300 |
+
const t = await resp.text();
|
| 301 |
+
throw new Error(`scorecard/bulk failed: ${resp.status} ${t}`);
|
| 302 |
+
}
|
| 303 |
+
return resp.json();
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
export type PreExistingCondition =
|
| 307 |
| "none"
|
| 308 |
| "diabetes_or_hypertension"
|
|
|
|
| 445 |
return resp.json();
|
| 446 |
}
|
| 447 |
|
| 448 |
+
export type PredictedPremiumBandResponse = {
|
| 449 |
+
min_inr: number;
|
| 450 |
+
median_inr: number;
|
| 451 |
+
max_inr: number;
|
| 452 |
+
sample_size: number;
|
| 453 |
+
assumed: boolean;
|
| 454 |
+
};
|
| 455 |
+
|
| 456 |
+
export async function getPredictedPremiumBand(
|
| 457 |
+
sessionId: string,
|
| 458 |
+
): Promise<PredictedPremiumBandResponse> {
|
| 459 |
+
const qs = `?session_id=${encodeURIComponent(sessionId)}`;
|
| 460 |
+
const resp = await fetch(`${BACKEND_URL}/api/profile/predicted-premium-band${qs}`);
|
| 461 |
+
if (!resp.ok) throw new Error(`predicted premium band failed: ${resp.status}`);
|
| 462 |
+
return resp.json();
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
// KI-Z7 (2026-05-15) — Feature B. POST /api/profile/recall-by-name.
|
| 466 |
+
// Asks the backend to look up a stored named-profile and hydrate the live
|
| 467 |
+
// session_id with it. The chat path runs the same recall server-side on
|
| 468 |
+
// turn 1, so this client helper is only needed for non-chat triggers (e.g.
|
| 469 |
+
// the user types their name into the profile builder AFTER turn 1).
|
| 470 |
+
export type RecallByNameResponse = {
|
| 471 |
+
found: boolean;
|
| 472 |
+
profile: Record<string, unknown> | null;
|
| 473 |
+
predicted_band:
|
| 474 |
+
| { min_inr: number; median_inr: number; max_inr: number; sample_size: number; assumed: boolean }
|
| 475 |
+
| null;
|
| 476 |
+
session_id: string;
|
| 477 |
+
};
|
| 478 |
+
|
| 479 |
+
export async function postProfileRecallByName(args: {
|
| 480 |
+
name: string;
|
| 481 |
+
session_id: string;
|
| 482 |
+
}): Promise<RecallByNameResponse> {
|
| 483 |
+
const resp = await fetch(`${BACKEND_URL}/api/profile/recall-by-name`, {
|
| 484 |
+
method: "POST",
|
| 485 |
+
headers: { "Content-Type": "application/json" },
|
| 486 |
+
body: JSON.stringify({ name: args.name, session_id: args.session_id }),
|
| 487 |
+
});
|
| 488 |
+
if (!resp.ok) throw new Error(`profile recall failed: ${resp.status}`);
|
| 489 |
+
return resp.json();
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
export async function postProfileUpdate(req: UserProfile & { session_id: string }): Promise<ProfileCompletenessResponse> {
|
| 493 |
const resp = await fetch(`${BACKEND_URL}/api/profile`, {
|
| 494 |
method: "POST",
|
|
|
|
| 525 |
}
|
| 526 |
|
| 527 |
|
| 528 |
+
// ---------------------------------------------------------------------------
|
| 529 |
+
// /api/premium/bulk — multi-policy slider-driven calculator. Powers
|
| 530 |
+
// PolicyPremiumWidget inside PolicyCompareModal.
|
| 531 |
+
// ---------------------------------------------------------------------------
|
| 532 |
+
|
| 533 |
+
export type PremiumBulkProfile = {
|
| 534 |
+
age?: number | null;
|
| 535 |
+
dependents?: string | null;
|
| 536 |
+
location_tier?: string | null;
|
| 537 |
+
family_size?: number | null;
|
| 538 |
+
smoker?: boolean | null;
|
| 539 |
+
pre_existing_conditions?: PreExistingCondition | null;
|
| 540 |
+
};
|
| 541 |
+
|
| 542 |
+
export type PremiumBulkOverride = {
|
| 543 |
+
sum_insured_inr?: number;
|
| 544 |
+
tenure_years?: number;
|
| 545 |
+
deductible_inr?: number;
|
| 546 |
+
};
|
| 547 |
+
|
| 548 |
+
export type PremiumBulkRequest = {
|
| 549 |
+
policy_ids: string[];
|
| 550 |
+
profile?: PremiumBulkProfile;
|
| 551 |
+
overrides?: Record<string, PremiumBulkOverride>;
|
| 552 |
+
};
|
| 553 |
+
|
| 554 |
+
export type PremiumBulkRow = {
|
| 555 |
+
policy_id: string;
|
| 556 |
+
premium_inr_annual: number;
|
| 557 |
+
breakdown: Record<string, number | string>;
|
| 558 |
+
sum_insured_inr: number;
|
| 559 |
+
tenure_years: number;
|
| 560 |
+
deductible_inr: number;
|
| 561 |
+
assumed: boolean;
|
| 562 |
+
notes: string[];
|
| 563 |
+
};
|
| 564 |
+
|
| 565 |
+
export type PremiumBulkResponse = {
|
| 566 |
+
per_policy: Record<string, PremiumBulkRow>;
|
| 567 |
+
profile_used: PremiumBulkProfile;
|
| 568 |
+
disclaimer: string;
|
| 569 |
+
};
|
| 570 |
+
|
| 571 |
+
export async function postPremiumBulk(req: PremiumBulkRequest): Promise<PremiumBulkResponse> {
|
| 572 |
+
const resp = await fetch(`${BACKEND_URL}/api/premium/bulk`, {
|
| 573 |
+
method: "POST",
|
| 574 |
+
headers: { "Content-Type": "application/json" },
|
| 575 |
+
body: JSON.stringify({
|
| 576 |
+
policy_ids: req.policy_ids,
|
| 577 |
+
profile: req.profile ?? {},
|
| 578 |
+
overrides: req.overrides ?? {},
|
| 579 |
+
}),
|
| 580 |
+
});
|
| 581 |
+
if (!resp.ok) {
|
| 582 |
+
const t = await resp.text();
|
| 583 |
+
throw new Error(`premium bulk failed: ${resp.status} ${t}`);
|
| 584 |
+
}
|
| 585 |
+
return resp.json();
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
|
| 589 |
// KI-020 — User-facing chat clear / session restart.
|
| 590 |
export interface SessionResetResponse {
|
| 591 |
ok: boolean;
|