rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
f6901d8
·
1 Parent(s): 96d766d

feat(profile+pricing): KI-259..KI-266 — B-bundle (compare modal + per-policy + scorecard + premium band + budget/SI + union + persistence + recall)

Browse files

B1 — 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 CHANGED
@@ -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
  ]
backend/main.py CHANGED
@@ -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
backend/needs_finder.py CHANGED
@@ -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
backend/premium_calculator.py CHANGED
@@ -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
+ }
backend/profile_persistence.py ADDED
@@ -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
+ ]
backend/scorecard.py CHANGED
@@ -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["Bonus & Loyalty"] += 0.01 # free checkups for family
495
- w["Cost Predictability"] -= 0.02 # family floater premiums are higher
496
- w["Renewal Protection"] -= 0.02
 
497
  if any(k in deps for k in ("spouse", "wife", "husband", "partner")):
498
- w["Coverage Breadth"] += 0.02 # maternity becomes relevant
 
499
  w["Waiting-Period Friction"] += 0.02 # maternity 36mo wait matters
500
- w["Bonus & Loyalty"] -= 0.02
501
- w["Renewal Protection"] -= 0.02
 
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
backend/single_brain.py CHANGED
@@ -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. Required ingredients:
 
 
154
  family-shape (individual / family floater / parents-cover),
155
  city tier (metro / tier-2 / tier-3),
156
- sum-insured band (~5-7× annual income, e.g. "10-15 lakh"),
157
- age band (e.g. "adult 30-40"),
158
- health-condition keywords (or "no PED"),
159
- primary goal keyword.
 
 
 
 
 
160
 
161
- Worked example. Profile = {age=34, location_tier=metro, income_band=10L-25L, dependents=spouse+1 kid, primary_goal=first_buy, health_conditions=[]}:
162
- retrieve_policies(query="family floater plan metro sum insured 15-20 lakh adult 30-40 with spouse and one child no pre-existing diseases first-time buyer", top_k=8)
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
- WORKED EXAMPLE
250
- User: "I'll go with that one"
251
- Your flow:
252
- i. IDENTIFY which policy "that one" refers to. With no ordinal cue,
253
- default to the most recent recommendation =
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, gender)."
 
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 string; health_conditions may "
349
- "be a comma-joined string of conditions."
 
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 (
frontend/src/app/page.tsx CHANGED
@@ -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} />}
frontend/src/components/PolicyCompareModal.tsx ADDED
@@ -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
+ }
frontend/src/components/PolicyPremiumWidget.tsx ADDED
@@ -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:&nbsp;
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
+ };
frontend/src/components/PolicyScorecardWidget.tsx ADDED
@@ -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
+ }
frontend/src/lib/api.ts CHANGED
@@ -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;