rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
06c7cbc
·
1 Parent(s): 2bb7b0f

fix(#64): preserve EXACT budget ₹ — slider shows what the user said

Browse files

The annual-budget slider is continuous, but budget round-tripped
through a lossy 4-band enum: budgetBandToInr() could only ever return
12k/22k/45k/75k, so 'I don't want to spend more than 15000 a year' showed
as ₹12k. Added a lossless exact field end-to-end:
- needs_finder.Profile.budget_inr; brain_tools SLOT_UNION + _ACCEPTED_FIELDS
- save_profile_field: when budget_band is captured, also parse + store the
EXACT ₹ (numeric, or strip per-annum qualifiers then _parse_inr_amount —
same KI-161 guard _coerce_budget_band uses; verified '15000'/'₹15,000/yr'/
'15000 a year'/'more than 15000 a year'/'around 25k per year' → 15000/25000)
- main.py ProfileUpdateRequest + apply tuple accept budget_inr
- api.ts UserProfile (+nullable) gains budget_inr
- page.tsx: slider seeds from budget_inr ?? band-fallback; reseed effect
same; handleSave sends budget_inr (exact) alongside the derived
budget_band (pricing contract unchanged).

Band stays the pricing contract; budget_inr is the display truth.
Full pytest gate rc=0; frontend tsc/build green.

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

backend/brain_tools.py CHANGED
@@ -308,6 +308,7 @@ _ACCEPTED_FIELDS = {
308
  "health_conditions",
309
  "existing_cover_inr",
310
  "budget_band",
 
311
  "desired_sum_insured_inr", # SOFT capture (pricing input, post-recap)
312
  # Family-detail pricing inputs (B6) — already on the Profile dataclass
313
  # via needs_finder.Profile (parents_to_insure / parents_age_max /
@@ -454,6 +455,7 @@ SLOT_UNION: tuple[str, ...] = (
454
  "health_conditions",
455
  # Pricing slots (B5 + B6 additions)
456
  "budget_band",
 
457
  "desired_sum_insured_inr",
458
  "existing_cover_inr",
459
  # Family-detail slots (used by pricing if applicable)
@@ -611,6 +613,38 @@ def save_profile_field(session, field: str, value: Any) -> dict:
611
  }
612
 
613
  setattr(profile, fld, normalized)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
614
  # Track that the brain has "asked" this field so the rest of the
615
  # codebase's helpers (which inspect profile.asked) stay in sync.
616
  try:
 
308
  "health_conditions",
309
  "existing_cover_inr",
310
  "budget_band",
311
+ "budget_inr", # #64 — exact ₹/yr; set as a side-effect of budget_band
312
  "desired_sum_insured_inr", # SOFT capture (pricing input, post-recap)
313
  # Family-detail pricing inputs (B6) — already on the Profile dataclass
314
  # via needs_finder.Profile (parents_to_insure / parents_age_max /
 
455
  "health_conditions",
456
  # Pricing slots (B5 + B6 additions)
457
  "budget_band",
458
+ "budget_inr", # #64 — exact ₹/yr (lossless companion to budget_band)
459
  "desired_sum_insured_inr",
460
  "existing_cover_inr",
461
  # Family-detail slots (used by pricing if applicable)
 
613
  }
614
 
615
  setattr(profile, fld, normalized)
616
+ # #64 — when the user states a budget, ALSO preserve the EXACT ₹ amount
617
+ # losslessly (not just the 4-bucket band) so the slider shows what they
618
+ # actually said ("₹15,000"), never a band representative ("₹12k"). The
619
+ # band stays the pricing contract; budget_inr is the display truth.
620
+ if fld == "budget_band":
621
+ try:
622
+ if isinstance(value, bool):
623
+ _exact = None
624
+ elif isinstance(value, (int, float)):
625
+ _exact = int(value)
626
+ else:
627
+ from backend.needs_finder import _parse_inr_amount as _pinr
628
+ import re as _re2
629
+ # Strip per-annum qualifiers FIRST — same KI-161 guard
630
+ # _coerce_budget_band handles, so "₹15,000/yr" / "15000 a
631
+ # year" / "more than 15000 a year" yield the exact ₹, not
632
+ # None (the parser otherwise reads "year" as age context).
633
+ _s = str(value)
634
+ _cleaned = _re2.sub(
635
+ r"\b(?:per\s*(?:year|annum)|p\.?\s*a\.?|/\s*(?:yr|year|"
636
+ r"annum)|a\s*year|annually|yearly|/\s*yr)\b",
637
+ " ",
638
+ _s,
639
+ flags=_re2.IGNORECASE,
640
+ )
641
+ _exact = _pinr(_cleaned) or _pinr(_s)
642
+ if _exact and _exact > 0 and hasattr(profile, "budget_inr"):
643
+ profile.budget_inr = int(_exact)
644
+ if "budget_inr" not in getattr(profile, "asked", []):
645
+ profile.asked.append("budget_inr")
646
+ except Exception: # noqa: BLE001 — exact ₹ is best-effort; band still saved
647
+ pass
648
  # Track that the brain has "asked" this field so the rest of the
649
  # codebase's helpers (which inspect profile.asked) stay in sync.
650
  try:
backend/main.py CHANGED
@@ -1918,6 +1918,7 @@ class ProfileUpdateRequest(BaseModel):
1918
  parents_has_ped: Optional[bool] = None
1919
  health_conditions: Optional[list[str]] = None
1920
  budget_band: Optional[str] = None
 
1921
  # Collected by the profile-builder UI; also present on the Profile
1922
  # dataclass + chat-path save_profile_field. Whitelisted here so
1923
  # POST /api/profile accepts them.
@@ -2018,7 +2019,7 @@ async def profile_update(req: ProfileUpdateRequest):
2018
  "name", # KI-077 — accept name updates from the profile-builder UI
2019
  "age", "dependents", "income_band", "existing_cover_inr", "primary_goal",
2020
  "location_tier", "parents_to_insure", "parents_age_max", "parents_has_ped",
2021
- "health_conditions", "budget_band",
2022
  "desired_sum_insured_inr", "copay_pct", "family_medical_history", "smoker",
2023
  ):
2024
  v = getattr(req, field_name, None)
 
1918
  parents_has_ped: Optional[bool] = None
1919
  health_conditions: Optional[list[str]] = None
1920
  budget_band: Optional[str] = None
1921
+ budget_inr: Optional[int] = None # #64 — exact ₹/yr from the slider
1922
  # Collected by the profile-builder UI; also present on the Profile
1923
  # dataclass + chat-path save_profile_field. Whitelisted here so
1924
  # POST /api/profile accepts them.
 
2019
  "name", # KI-077 — accept name updates from the profile-builder UI
2020
  "age", "dependents", "income_band", "existing_cover_inr", "primary_goal",
2021
  "location_tier", "parents_to_insure", "parents_age_max", "parents_has_ped",
2022
+ "health_conditions", "budget_band", "budget_inr",
2023
  "desired_sum_insured_inr", "copay_pct", "family_medical_history", "smoker",
2024
  ):
2025
  v = getattr(req, field_name, None)
backend/needs_finder.py CHANGED
@@ -38,6 +38,9 @@ class Profile:
38
  parents_age_max: Optional[int] = None # if parents_to_insure
39
  parents_has_ped: Optional[bool] = None # if parents_to_insure
40
  budget_band: Optional[str] = None # "under_15k", "15k_30k", "30k_60k", "60k+"
 
 
 
41
  desired_sum_insured_inr: Optional[int] = None # SOFT pricing input (post-recap)
42
  health_conditions: Optional[list[str]] = field(default_factory=list) # ["diabetes", "hypertension", ...]
43
  # D2 (2026-05-15) — co-pay tolerance + family medical history. Coupled
 
38
  parents_age_max: Optional[int] = None # if parents_to_insure
39
  parents_has_ped: Optional[bool] = None # if parents_to_insure
40
  budget_band: Optional[str] = None # "under_15k", "15k_30k", "30k_60k", "60k+"
41
+ budget_inr: Optional[int] = None # #64 — EXACT ₹/yr the user stated/slid;
42
+ # preserved losslessly so the UI shows what they said (₹15,000), not a
43
+ # 4-band representative (₹12k). budget_band is still derived for pricing.
44
  desired_sum_insured_inr: Optional[int] = None # SOFT pricing input (post-recap)
45
  health_conditions: Optional[list[str]] = field(default_factory=list) # ["diabetes", "hypertension", ...]
46
  # D2 (2026-05-15) — co-pay tolerance + family medical history. Coupled
frontend/src/app/page.tsx CHANGED
@@ -2350,7 +2350,12 @@ function ProfileBuilderPanel({
2350
  // persisted: handleSave derives `budget_band` (the backend contract field)
2351
  // from this number via budgetInrToBand so the profile save round-trips it.
2352
  const [budgetInr, setBudgetInr] = useState<number | null>(
2353
- initialProfile.budget_band ? budgetBandToInr(initialProfile.budget_band) : null,
 
 
 
 
 
2354
  );
2355
  const [income, setIncome] = useState<string>(initialProfile.income_band ?? "");
2356
  const [city, setCity] = useState<string>(initialProfile.location_tier ?? "");
@@ -2378,7 +2383,14 @@ function ProfileBuilderPanel({
2378
  if (initialProfile.name && !name) setName(initialProfile.name);
2379
  if (initialProfile.age != null && age == null) setAge(initialProfile.age);
2380
  if (initialProfile.dependents && dependents === "self") setDependents(initialProfile.dependents);
2381
- if (initialProfile.budget_band && budgetInr == null) setBudgetInr(budgetBandToInr(initialProfile.budget_band));
 
 
 
 
 
 
 
2382
  if (initialProfile.income_band && !income) setIncome(initialProfile.income_band);
2383
  if (initialProfile.location_tier && !city) setCity(initialProfile.location_tier);
2384
  if (initialProfile.health_conditions?.length && !conditions.length) setConditions(initialProfile.health_conditions);
@@ -2433,6 +2445,9 @@ function ProfileBuilderPanel({
2433
  // #47b — persist the numeric annual-premium budget by mapping it
2434
  // onto the backend's documented `budget_band` contract field.
2435
  budget_band: budgetInr != null ? budgetInrToBand(budgetInr) : undefined,
 
 
 
2436
  income_band: income || undefined,
2437
  location_tier: city || undefined,
2438
  health_conditions: conditions.length ? conditions : undefined,
 
2350
  // persisted: handleSave derives `budget_band` (the backend contract field)
2351
  // from this number via budgetInrToBand so the profile save round-trips it.
2352
  const [budgetInr, setBudgetInr] = useState<number | null>(
2353
+ // #64 prefer the EXACT ₹ the user stated/slid; only fall back to the
2354
+ // lossy 4-band representative when no exact value was ever captured.
2355
+ initialProfile.budget_inr ??
2356
+ (initialProfile.budget_band
2357
+ ? budgetBandToInr(initialProfile.budget_band)
2358
+ : null),
2359
  );
2360
  const [income, setIncome] = useState<string>(initialProfile.income_band ?? "");
2361
  const [city, setCity] = useState<string>(initialProfile.location_tier ?? "");
 
2383
  if (initialProfile.name && !name) setName(initialProfile.name);
2384
  if (initialProfile.age != null && age == null) setAge(initialProfile.age);
2385
  if (initialProfile.dependents && dependents === "self") setDependents(initialProfile.dependents);
2386
+ if (budgetInr == null) {
2387
+ const _seed =
2388
+ initialProfile.budget_inr ??
2389
+ (initialProfile.budget_band
2390
+ ? budgetBandToInr(initialProfile.budget_band)
2391
+ : null);
2392
+ if (_seed != null) setBudgetInr(_seed);
2393
+ }
2394
  if (initialProfile.income_band && !income) setIncome(initialProfile.income_band);
2395
  if (initialProfile.location_tier && !city) setCity(initialProfile.location_tier);
2396
  if (initialProfile.health_conditions?.length && !conditions.length) setConditions(initialProfile.health_conditions);
 
2445
  // #47b — persist the numeric annual-premium budget by mapping it
2446
  // onto the backend's documented `budget_band` contract field.
2447
  budget_band: budgetInr != null ? budgetInrToBand(budgetInr) : undefined,
2448
+ // #64 — also persist the EXACT ₹ so the slider round-trips to the
2449
+ // precise value next load (not the band representative).
2450
+ budget_inr: budgetInr ?? undefined,
2451
  income_band: income || undefined,
2452
  location_tier: city || undefined,
2453
  health_conditions: conditions.length ? conditions : undefined,
frontend/src/lib/api.ts CHANGED
@@ -284,6 +284,7 @@ export type BulkScorecardProfile = {
284
  location_tier?: string;
285
  income_band?: string;
286
  budget_band?: string;
 
287
  existing_cover_inr?: number;
288
  parents_to_insure?: boolean;
289
  parents_age_max?: number;
@@ -477,6 +478,7 @@ export type UserProfile = {
477
  parents_has_ped?: boolean | null;
478
  health_conditions?: string[] | null;
479
  budget_band?: string | null;
 
480
  // KI-258 (B5, 2026-05-15) — Desired sum insured slot. Backend's pricing
481
  // endpoint + retrieve_policies query both read this; frontend pre-fills
482
  // the PremiumCalculatorPanel SI slider from it when present.
 
284
  location_tier?: string;
285
  income_band?: string;
286
  budget_band?: string;
287
+ budget_inr?: number | null; // #64 — exact ₹/yr (lossless; UI prefers this)
288
  existing_cover_inr?: number;
289
  parents_to_insure?: boolean;
290
  parents_age_max?: number;
 
478
  parents_has_ped?: boolean | null;
479
  health_conditions?: string[] | null;
480
  budget_band?: string | null;
481
+ budget_inr?: number | null; // #64 — exact ₹/yr (lossless; UI prefers this)
482
  // KI-258 (B5, 2026-05-15) — Desired sum insured slot. Backend's pricing
483
  // endpoint + retrieve_policies query both read this; frontend pre-fills
484
  // the PremiumCalculatorPanel SI slider from it when present.