Spaces:
Sleeping
fix(#64): preserve EXACT budget ₹ — slider shows what the user said
Browse filesThe 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 +34 -0
- backend/main.py +2 -1
- backend/needs_finder.py +3 -0
- frontend/src/app/page.tsx +17 -2
- frontend/src/lib/api.ts +2 -0
|
@@ -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:
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
@@ -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.
|