Spaces:
Sleeping
fix: deductible gating (#29), existing-cover steering + dangling-turn guard (#30), compare-modal full reviews (#32)
Browse files#29 β deductible selector/discount was applied to all 148 though only
2 truly offer a voluntary deductible. New premium_calculator.policy_
deductible_support() (deductible_amount>0 AND not top-up β exactly
bajaj-allianz__health-guard, star-health__star-assure); estimate +
bulk gate the discount (defense-in-depth); PremiumEstimateResponse
echoes supports_voluntary_deductible/allowed_deductibles; widget
renders the deductible row only when supported. tests/test_deductible_
eligibility.py sweeps all 148.
#30 β existing_cover_inr drove neither ranking, retrieval phrasing,
nor framing; bot promised 're-evaluate' then stopped; re-eval recycled
2/3. Decisive fix B1-c: +22 existing-cover term in _fit_score for
top-ups + seed out-of-window top-ups in _quality_seed_candidates.
B1-a: RULE 2 mandatory existing-cover phrasing + Worked example C +
RULE 2.6 framing mandate. B2: RULE 3.7 never-promise + _is_promissory_
no_action + one-shot loop guard that forces the promised tool call.
B3: RULE 3 materially-different re-eval + _CONSTRAINT_FIELD_PHRASES.
tests/test_bug30_existing_cover_and_promise.py.
#32 β in-chat compare modal now reuses the FULL InsurerReviewsBlock
(same guard as the detail modal) instead of the condensed cell.
Verified: full pytest suite exit 0 (no regression); #29 474, #30
new 11 + 555 filtered, all green; frontend tsc 0 errors, eslint
0-new (page.tsx 29=baseline, PolicyPremiumWidget 0, api.ts 0).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/brain_tools.py +54 -2
- backend/main.py +18 -1
- backend/premium_calculator.py +58 -0
- backend/retrieval_filters.py +11 -0
- backend/single_brain.py +112 -1
- frontend/src/app/page.tsx +13 -52
- frontend/src/components/PolicyPremiumWidget.tsx +47 -23
- frontend/src/lib/api.ts +6 -0
- tests/test_bug30_existing_cover_and_promise.py +508 -0
- tests/test_deductible_eligibility.py +234 -0
|
@@ -663,6 +663,22 @@ def save_profile_field(session, field: str, value: Any) -> dict:
|
|
| 663 |
|
| 664 |
|
| 665 |
_qseed_cache: dict = {} # (profile_sig) -> [seed chunk dicts]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
|
| 667 |
|
| 668 |
def _quality_seed_candidates(profile, limit: int = 25) -> list[dict]:
|
|
@@ -684,7 +700,7 @@ def _quality_seed_candidates(profile, limit: int = 25) -> list[dict]:
|
|
| 684 |
)
|
| 685 |
)) if profile is not None else "none"
|
| 686 |
if prof_sig in _qseed_cache:
|
| 687 |
-
return _qseed_cache[prof_sig]
|
| 688 |
cur = _curated_facts_all()
|
| 689 |
scored: list[tuple[float, str, dict, dict]] = []
|
| 690 |
seen: set[str] = set()
|
|
@@ -710,7 +726,41 @@ def _quality_seed_candidates(profile, limit: int = 25) -> list[dict]:
|
|
| 710 |
continue
|
| 711 |
scored.append((ovf, pid, data, sig))
|
| 712 |
scored.sort(key=lambda t: -t[0])
|
| 713 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
ch = {
|
| 715 |
"chunk_id": f"{pid}__qseed",
|
| 716 |
"policy_id": pid,
|
|
@@ -731,6 +781,8 @@ def _quality_seed_candidates(profile, limit: int = 25) -> list[dict]:
|
|
| 731 |
ch.update(sig)
|
| 732 |
out.append(ch)
|
| 733 |
_qseed_cache[prof_sig] = out
|
|
|
|
|
|
|
| 734 |
except Exception as e: # noqa: BLE001 β seeding must never break retrieval
|
| 735 |
_log.warning("quality-seed failed: %s", e)
|
| 736 |
return out[:limit]
|
|
|
|
| 663 |
|
| 664 |
|
| 665 |
_qseed_cache: dict = {} # (profile_sig) -> [seed chunk dicts]
|
| 666 |
+
# BUG #30 (B1-c) β per-signature count of TRAILING existing-cover top-up
|
| 667 |
+
# seeds in `_qseed_cache[sig]`. They sit AFTER the primary window and must
|
| 668 |
+
# survive the final `[:limit]` slice so a relevant super-top-up is never
|
| 669 |
+
# truncated out of contention for a user who already holds base cover.
|
| 670 |
+
_qseed_topup_n: dict = {}
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
def _qseed_slice(rows: list[dict], sig: str, limit: int) -> list[dict]:
|
| 674 |
+
"""Return up to `limit` primary seeds PLUS all existing-cover top-up
|
| 675 |
+
seeds (which trail the list), so the top-up seeds are never cut."""
|
| 676 |
+
n_top = _qseed_topup_n.get(sig, 0)
|
| 677 |
+
if n_top <= 0:
|
| 678 |
+
return rows[:limit]
|
| 679 |
+
primaries = rows[:-n_top] if n_top < len(rows) else []
|
| 680 |
+
topups = rows[-n_top:]
|
| 681 |
+
return primaries[:limit] + topups
|
| 682 |
|
| 683 |
|
| 684 |
def _quality_seed_candidates(profile, limit: int = 25) -> list[dict]:
|
|
|
|
| 700 |
)
|
| 701 |
)) if profile is not None else "none"
|
| 702 |
if prof_sig in _qseed_cache:
|
| 703 |
+
return _qseed_slice(_qseed_cache[prof_sig], prof_sig, limit)
|
| 704 |
cur = _curated_facts_all()
|
| 705 |
scored: list[tuple[float, str, dict, dict]] = []
|
| 706 |
seen: set[str] = set()
|
|
|
|
| 726 |
continue
|
| 727 |
scored.append((ovf, pid, data, sig))
|
| 728 |
scored.sort(key=lambda t: -t[0])
|
| 729 |
+
# BUG #30 (B1-c) β when the user already holds ANY base cover, also
|
| 730 |
+
# union-in the top-N top-up / super-top-up policies even if they fall
|
| 731 |
+
# outside the profile-neutral top-25 window, so a directly relevant
|
| 732 |
+
# super-top-up is seeded into contention (filter_pipeline then ranks
|
| 733 |
+
# the union via _fit_score, which now carries the existing-cover term).
|
| 734 |
+
# Deterministic: drawn from the already-sorted `scored` list, no RNG.
|
| 735 |
+
_existing = getattr(profile, "existing_cover_inr", None) \
|
| 736 |
+
if profile is not None else None
|
| 737 |
+
try:
|
| 738 |
+
_existing_int = int(str(_existing).replace(",", "").strip()) \
|
| 739 |
+
if _existing not in (None, "") else 0
|
| 740 |
+
except (TypeError, ValueError):
|
| 741 |
+
_existing_int = 0
|
| 742 |
+
window = max(limit, 25)
|
| 743 |
+
primary_rows = scored[:window]
|
| 744 |
+
extra: list[tuple[float, str, dict, dict]] = []
|
| 745 |
+
if _existing_int > 0:
|
| 746 |
+
from backend.retrieval_filters import _is_top_up as _rf_is_top_up
|
| 747 |
+
in_window = {pid for _, pid, _, _ in primary_rows}
|
| 748 |
+
_TOPUP_SEED_MAX = 3 # bounded extra seeds; keeps pool deterministic
|
| 749 |
+
for ovf, pid, data, sig in scored[window:]:
|
| 750 |
+
if pid in in_window:
|
| 751 |
+
continue
|
| 752 |
+
probe = {
|
| 753 |
+
"policy_name": data.get("policy_name", pid),
|
| 754 |
+
**_load_policy_facts(pid),
|
| 755 |
+
}
|
| 756 |
+
if _rf_is_top_up(probe):
|
| 757 |
+
extra.append((ovf, pid, data, sig))
|
| 758 |
+
if len(extra) >= _TOPUP_SEED_MAX:
|
| 759 |
+
break
|
| 760 |
+
# `_n_topup_seeds` is preserved on the cached list so the final
|
| 761 |
+
# slice keeps the existing-cover top-up seeds (they sit AFTER the
|
| 762 |
+
# primary window and would otherwise be cut by `out[:limit]`).
|
| 763 |
+
for ovf, pid, data, sig in primary_rows + extra:
|
| 764 |
ch = {
|
| 765 |
"chunk_id": f"{pid}__qseed",
|
| 766 |
"policy_id": pid,
|
|
|
|
| 781 |
ch.update(sig)
|
| 782 |
out.append(ch)
|
| 783 |
_qseed_cache[prof_sig] = out
|
| 784 |
+
_qseed_topup_n[prof_sig] = len(extra)
|
| 785 |
+
return _qseed_slice(out, prof_sig, limit)
|
| 786 |
except Exception as e: # noqa: BLE001 β seeding must never break retrieval
|
| 787 |
_log.warning("quality-seed failed: %s", e)
|
| 788 |
return out[:limit]
|
|
@@ -4211,6 +4211,12 @@ class PremiumEstimateResponse(BaseModel):
|
|
| 4211 |
# callers (PremiumCalculatorPanel ignores both).
|
| 4212 |
tenure_years: Optional[int] = None
|
| 4213 |
deductible_inr: Optional[int] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4214 |
# True when the underlying estimate() anchored to a curated quote sample.
|
| 4215 |
# PolicyPremiumWidget uses this (instead of bulk_estimate's `assumed` flag)
|
| 4216 |
# to decide whether to show its "Estimate" badge.
|
|
@@ -4255,8 +4261,17 @@ async def premium_estimate(req: PremiumEstimateRequest):
|
|
| 4255 |
point = int(round(point * tenure_mult))
|
| 4256 |
low = int(round(low * tenure_mult))
|
| 4257 |
high = int(round(high * tenure_mult))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4258 |
if req.deductible_inr is not None:
|
| 4259 |
-
if req.deductible_inr in
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4260 |
effective_ded = req.deductible_inr
|
| 4261 |
else:
|
| 4262 |
effective_ded = min(
|
|
@@ -4292,6 +4307,8 @@ async def premium_estimate(req: PremiumEstimateRequest):
|
|
| 4292 |
sources=e.sources or [],
|
| 4293 |
tenure_years=effective_tenure,
|
| 4294 |
deductible_inr=effective_ded,
|
|
|
|
|
|
|
| 4295 |
base_sample_used=e.base_sample_used is not None,
|
| 4296 |
sum_insured_disclosure=si_disclosure,
|
| 4297 |
)
|
|
|
|
| 4211 |
# callers (PremiumCalculatorPanel ignores both).
|
| 4212 |
tenure_years: Optional[int] = None
|
| 4213 |
deductible_inr: Optional[int] = None
|
| 4214 |
+
# BUG #29 β whether THIS policy genuinely offers a user-selectable
|
| 4215 |
+
# voluntary deductible (curated deductible_amount > 0 AND not a
|
| 4216 |
+
# top-up). Only ~2 of 148 do. The widget hides the deductible selector
|
| 4217 |
+
# entirely when False; allowed_deductibles is the exact pill set.
|
| 4218 |
+
supports_voluntary_deductible: bool = False
|
| 4219 |
+
allowed_deductibles: list[int] = [0]
|
| 4220 |
# True when the underlying estimate() anchored to a curated quote sample.
|
| 4221 |
# PolicyPremiumWidget uses this (instead of bulk_estimate's `assumed` flag)
|
| 4222 |
# to decide whether to show its "Estimate" badge.
|
|
|
|
| 4261 |
point = int(round(point * tenure_mult))
|
| 4262 |
low = int(round(low * tenure_mult))
|
| 4263 |
high = int(round(high * tenure_mult))
|
| 4264 |
+
# BUG #29 β resolve whether this policy genuinely supports a voluntary
|
| 4265 |
+
# deductible. Only ~2 of 148 do; for every other policy a caller-supplied
|
| 4266 |
+
# deductible must NOT discount the premium.
|
| 4267 |
+
from backend.premium_calculator import policy_deductible_support
|
| 4268 |
+
_supports, _allowed = policy_deductible_support(req.policy_id)
|
| 4269 |
if req.deductible_inr is not None:
|
| 4270 |
+
if not _supports or req.deductible_inr not in _allowed:
|
| 4271 |
+
# Unsupported policy (or a value outside this policy's allowed
|
| 4272 |
+
# set) β no phantom discount, honest echo.
|
| 4273 |
+
effective_ded = 0
|
| 4274 |
+
elif req.deductible_inr in BULK_DEDUCTIBLE_DISCOUNT:
|
| 4275 |
effective_ded = req.deductible_inr
|
| 4276 |
else:
|
| 4277 |
effective_ded = min(
|
|
|
|
| 4307 |
sources=e.sources or [],
|
| 4308 |
tenure_years=effective_tenure,
|
| 4309 |
deductible_inr=effective_ded,
|
| 4310 |
+
supports_voluntary_deductible=_supports,
|
| 4311 |
+
allowed_deductibles=_allowed,
|
| 4312 |
base_sample_used=e.base_sample_used is not None,
|
| 4313 |
sum_insured_disclosure=si_disclosure,
|
| 4314 |
)
|
|
@@ -418,6 +418,7 @@ def _per_lakh_band(policy_id: str) -> tuple[float, float]:
|
|
| 418 |
|
| 419 |
|
| 420 |
_ptype_cache: dict = {}
|
|
|
|
| 421 |
|
| 422 |
|
| 423 |
# Traceable overrides for genuinely-ambiguous IRDAI products the generic
|
|
@@ -497,6 +498,55 @@ def _policy_product_type(policy_id: Optional[str]) -> str:
|
|
| 497 |
return t
|
| 498 |
|
| 499 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
def _type_rel_cap(policy_id: Optional[str]) -> float:
|
| 501 |
"""Max fraction of the comprehensive-equivalent a non-comprehensive
|
| 502 |
product may cost at the SAME profile. A cancer / top-up / hospital-cash
|
|
@@ -1029,6 +1079,14 @@ def bulk_estimate(
|
|
| 1029 |
if deductible_inr not in BULK_DEDUCTIBLE_DISCOUNT:
|
| 1030 |
# snap to nearest known bucket
|
| 1031 |
deductible_inr = min(BULK_DEDUCTIBLE_DISCOUNT.keys(), key=lambda d: abs(d - deductible_inr))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1032 |
|
| 1033 |
notes: list[str] = []
|
| 1034 |
assumed = True
|
|
|
|
| 418 |
|
| 419 |
|
| 420 |
_ptype_cache: dict = {}
|
| 421 |
+
_ded_support_cache: dict = {}
|
| 422 |
|
| 423 |
|
| 424 |
# Traceable overrides for genuinely-ambiguous IRDAI products the generic
|
|
|
|
| 498 |
return t
|
| 499 |
|
| 500 |
|
| 501 |
+
def policy_deductible_support(policy_id: Optional[str]) -> tuple[bool, list[int]]:
|
| 502 |
+
"""Authoritative answer to "does THIS policy genuinely offer a voluntary
|
| 503 |
+
deductible the user can pick to lower the premium?" (BUG #29).
|
| 504 |
+
|
| 505 |
+
Rule: a policy supports a voluntary deductible iff it has a curated
|
| 506 |
+
`deductible_amount > 0` AND it is NOT a top-up / super-top-up (whose
|
| 507 |
+
"deductible" is a structural threshold, not a user-selectable knob).
|
| 508 |
+
Across the full 148-policy catalogue this is exactly
|
| 509 |
+
{bajaj-allianz__health-guard, star-health__star-assure}.
|
| 510 |
+
|
| 511 |
+
Returns (supports, allowed_deductibles). `allowed_deductibles` always
|
| 512 |
+
includes 0 (the no-deductible baseline) plus the curated amount when
|
| 513 |
+
supported. Never raises β pricing must never break, so any failure
|
| 514 |
+
degrades to (False, [0]). Cached per policy_id."""
|
| 515 |
+
pid = (policy_id or "").strip()
|
| 516 |
+
if not pid:
|
| 517 |
+
return (False, [0])
|
| 518 |
+
if pid in _ded_support_cache:
|
| 519 |
+
return _ded_support_cache[pid]
|
| 520 |
+
result: tuple[bool, list[int]] = (False, [0])
|
| 521 |
+
try:
|
| 522 |
+
from backend.brain_tools import _load_policy_facts # lazy: no cycle
|
| 523 |
+
|
| 524 |
+
f = _load_policy_facts(pid) or {}
|
| 525 |
+
pt = str(
|
| 526 |
+
f.get("policy_type")
|
| 527 |
+
or f.get("policy_type_indemnity_or_fixed")
|
| 528 |
+
or ""
|
| 529 |
+
).lower()
|
| 530 |
+
ded = f.get("deductible_amount")
|
| 531 |
+
try:
|
| 532 |
+
ded = float(ded) if ded not in (None, "", []) else 0.0
|
| 533 |
+
except (TypeError, ValueError):
|
| 534 |
+
ded = 0.0
|
| 535 |
+
is_topup = (
|
| 536 |
+
_policy_product_type(pid) == "topup"
|
| 537 |
+
or "top" in pt
|
| 538 |
+
or "super_top" in pt
|
| 539 |
+
)
|
| 540 |
+
if ded > 0 and not is_topup:
|
| 541 |
+
result = (True, sorted({0, int(ded)}))
|
| 542 |
+
else:
|
| 543 |
+
result = (False, [0])
|
| 544 |
+
except Exception: # noqa: BLE001 β facts optional; never break pricing
|
| 545 |
+
result = (False, [0])
|
| 546 |
+
_ded_support_cache[pid] = result
|
| 547 |
+
return result
|
| 548 |
+
|
| 549 |
+
|
| 550 |
def _type_rel_cap(policy_id: Optional[str]) -> float:
|
| 551 |
"""Max fraction of the comprehensive-equivalent a non-comprehensive
|
| 552 |
product may cost at the SAME profile. A cancer / top-up / hospital-cash
|
|
|
|
| 1079 |
if deductible_inr not in BULK_DEDUCTIBLE_DISCOUNT:
|
| 1080 |
# snap to nearest known bucket
|
| 1081 |
deductible_inr = min(BULK_DEDUCTIBLE_DISCOUNT.keys(), key=lambda d: abs(d - deductible_inr))
|
| 1082 |
+
# BUG #29 β only the ~2 policies that genuinely offer a voluntary
|
| 1083 |
+
# deductible may receive the discount. For every other policy a
|
| 1084 |
+
# caller-supplied deductible is meaningless: force it to 0 so
|
| 1085 |
+
# ded_mult resolves to 1.0 (no phantom discount) AND the echoed
|
| 1086 |
+
# BulkPolicyPremium.deductible_inr is honest.
|
| 1087 |
+
_ded_supported, _ded_allowed = policy_deductible_support(pid)
|
| 1088 |
+
if not _ded_supported or deductible_inr not in _ded_allowed:
|
| 1089 |
+
deductible_inr = 0
|
| 1090 |
|
| 1091 |
notes: list[str] = []
|
| 1092 |
assumed = True
|
|
@@ -859,6 +859,17 @@ def _fit_score(chunk_meta: dict, profile: Any, wants_zero_copay: bool,
|
|
| 859 |
else:
|
| 860 |
score -= 25.0 # shouldn't survive eligibility, belt-and-braces
|
| 861 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 862 |
# KI-280 β REQUIRED-FEATURE term. When the profile explicitly needs
|
| 863 |
# maternity / newborn cover (P3), a plan whose curated facts CONFIRM it
|
| 864 |
# must outrank one that does not, and an UNVERIFIED plan (fact absent)
|
|
|
|
| 859 |
else:
|
| 860 |
score -= 25.0 # shouldn't survive eligibility, belt-and-braces
|
| 861 |
|
| 862 |
+
# BUG #30 (B1-c) β EXISTING-COVER term. When the user already holds ANY
|
| 863 |
+
# base cover (even a small βΉ1L employer policy), a top-up / super-top-up
|
| 864 |
+
# is a directly relevant product that the profile-neutral scorecard is
|
| 865 |
+
# blind to. Surface it: the bonus (+22) clears roughly one letter-grade
|
| 866 |
+
# gap so a relevant top-up lands alongside the primary indemnity picks
|
| 867 |
+
# (which are untouched), giving a shortlist that mixes one strong primary
|
| 868 |
+
# plan with one relevant top-up. Inert when the user holds no base cover.
|
| 869 |
+
existing = _as_int(_profile_get(profile, "existing_cover_inr"))
|
| 870 |
+
if existing and existing > 0 and _is_top_up(chunk_meta):
|
| 871 |
+
score += 22.0
|
| 872 |
+
|
| 873 |
# KI-280 β REQUIRED-FEATURE term. When the profile explicitly needs
|
| 874 |
# maternity / newborn cover (P3), a plan whose curated facts CONFIRM it
|
| 875 |
# must outrank one that does not, and an UNVERIFIED plan (fact absent)
|
|
@@ -187,7 +187,7 @@ Required ingredients:
|
|
| 187 |
age band (e.g., "adult 30-40"),
|
| 188 |
health-condition keywords β every captured condition by name ("diabetes", "hypertension", "heart disease") OR the literal "no PED" when health_conditions == ["none"],
|
| 189 |
primary goal keyword,
|
| 190 |
-
existing cover signal β
|
| 191 |
parents-cover signal β when dependents mentions parents add "parents age ~XX" using parents_age_max (if captured),
|
| 192 |
family-history rider boost β if family_medical_history is non-empty, INCLUDE keywords in the query that bias retrieval toward policies with relevant coverage:
|
| 193 |
- "cancer" β "critical illness rider cancer cover"
|
|
@@ -202,6 +202,13 @@ Worked example A (no PED, no existing cover). Profile = {age=34, location_tier=m
|
|
| 202 |
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}:
|
| 203 |
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)
|
| 204 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
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.
|
| 206 |
|
| 207 |
βββββββββββββββββββββββββββββββββββ
|
|
@@ -261,11 +268,46 @@ is worse than honestly presenting fewer. Never describe a clearly weak
|
|
| 261 |
plan with recommendation language ("great pick", "top option") β be
|
| 262 |
honest about where it falls short.
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
βββββββββββββββββββββββββββββββββββ
|
| 265 |
RULE 3 β Follow-ups + mark_recommendation
|
| 266 |
βββββββββββββββββββββββββββββββββββ
|
| 267 |
- After producing a ranked shortlist, call mark_recommendation(policy_ids=[...ordered IDs you cited...]).
|
| 268 |
- 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
βββββββββββββββββββββββββββββββββββ
|
| 271 |
RULE 3.5 β Claims / denials / complaints / reputation / comparison β get_policy_facts (NEVER refuse)
|
|
@@ -879,6 +921,32 @@ def _user_skipped_pricing_inputs(user_text: str) -> bool:
|
|
| 879 |
return any(p in t for p in _PRICING_SKIP_PHRASES)
|
| 880 |
|
| 881 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 882 |
def _classify_intent(user_text: str, tool_calls_made: list[str]) -> str:
|
| 883 |
"""Best-effort intent label for logging only. Single-brain doesn't
|
| 884 |
route on intent β but the legacy `TurnResult.intent` field is logged
|
|
@@ -1582,6 +1650,7 @@ _CONSTRAINT_FIELD_PHRASES: dict[str, str] = {
|
|
| 1582 |
"parents_age_max": "of the parents' age",
|
| 1583 |
"health_conditions": "of the health condition you mentioned",
|
| 1584 |
"smoker": "of the tobacco-use detail you shared",
|
|
|
|
| 1585 |
}
|
| 1586 |
|
| 1587 |
|
|
@@ -1929,6 +1998,10 @@ async def handle_turn(
|
|
| 1929 |
# Defensive counter to break runaway loops.
|
| 1930 |
last_text: str = ""
|
| 1931 |
last_payload: dict = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1932 |
|
| 1933 |
for it in range(MAX_ITERATIONS):
|
| 1934 |
# Issue A instrumentation (KI-Z6-LATENCY, 2026-05-15) β Priya T3
|
|
@@ -1979,6 +2052,44 @@ async def handle_turn(
|
|
| 1979 |
if not function_calls:
|
| 1980 |
if text:
|
| 1981 |
last_text = text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1982 |
_log.info(
|
| 1983 |
"single_brain iter=%d gemini=%.2fs tools=%.2fs "
|
| 1984 |
"tool_calls=[] final_text=True",
|
|
|
|
| 187 |
age band (e.g., "adult 30-40"),
|
| 188 |
health-condition keywords β every captured condition by name ("diabetes", "hypertension", "heart disease") OR the literal "no PED" when health_conditions == ["none"],
|
| 189 |
primary goal keyword,
|
| 190 |
+
existing cover signal (MANDATORY, threshold-free β BUG #30) β existing_cover_inr ANY positive value, no matter how small (even a βΉ1 lakh employer policy), MUST be treated as held base cover. When existing_cover_inr > 0 you MUST (a) add the literal phrase "super top-up plan layered over existing N lakh base cover" to the retrieve_policies query (substitute N with the cover in lakh), AND (b) in the recommendation prose, state for EVERY pick how it relates to the user's existing βΉN cover β either "works as your PRIMARY plan; your βΉN employer cover supplements it" OR "this is a TOP-UP that sits above your βΉN existing cover". A recommendation that does NOT state its relation to the user's existing cover is INCOMPLETE and must not be presented. When existing_cover_inr == 0 add "fresh standalone base policy",
|
| 191 |
parents-cover signal β when dependents mentions parents add "parents age ~XX" using parents_age_max (if captured),
|
| 192 |
family-history rider boost β if family_medical_history is non-empty, INCLUDE keywords in the query that bias retrieval toward policies with relevant coverage:
|
| 193 |
- "cancer" β "critical illness rider cancer cover"
|
|
|
|
| 202 |
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}:
|
| 203 |
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)
|
| 204 |
|
| 205 |
+
Worked example C (BUG #30 β small βΉ1L employer cover + first-buy + smoker + family diabetes). Profile = {age=29, location_tier=metro, income_band=25L+, primary_goal=first_buy, health_conditions=["none"], desired_sum_insured_inr=2000000, existing_cover_inr=100000, family_medical_history="diabetes", smoker=yes}:
|
| 206 |
+
retrieve_policies(query="comprehensive base health plan individual metro sum insured 20 lakh adult 20-30 no PED first-time buyer super top-up plan layered over existing 1 lakh employer base cover diabetes short waiting period reduced PED wait smoker family history", top_k=8)
|
| 207 |
+
Model prose answer MUST surface BOTH a primary plan and a relevant super-top-up, each framed against the existing βΉ1L cover, e.g.:
|
| 208 |
+
"1. <Primary indemnity plan> β this works as your PRIMARY plan; your βΉ1L employer cover supplements it. Strong for a 29-yr-old first-time buyer at βΉ20L SI; shorter diabetes-related waiting given your family history; smoker loading is priced in.
|
| 209 |
+
2. <Super top-up> β this is a TOP-UP that sits above your βΉ1L existing cover, giving high catastrophic headroom at a low premium because it only pays above your deductible."
|
| 210 |
+
(Even though βΉ1L is small, it is positive, so RULE 2's existing-cover signal fires: the query carries the "super top-up ... layered over existing 1 lakh employer base cover" phrase AND every pick is framed relative to the βΉ1L cover.)
|
| 211 |
+
|
| 212 |
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.
|
| 213 |
|
| 214 |
βββββββββββββββββββββββββββββββββββ
|
|
|
|
| 268 |
plan with recommendation language ("great pick", "top option") β be
|
| 269 |
honest about where it falls short.
|
| 270 |
|
| 271 |
+
BUG #30 β EXISTING-COVER FRAMING IS MANDATORY: when the user holds ANY
|
| 272 |
+
existing cover (existing_cover_inr > 0, even a small βΉ1L employer policy),
|
| 273 |
+
EVERY pick you present MUST be framed relative to that existing cover β
|
| 274 |
+
explicitly state whether it is the PRIMARY plan (their existing cover
|
| 275 |
+
supplements it / sits below it), is LAYERED over it, or is a TOP-UP that
|
| 276 |
+
sits ABOVE it. Never present a plan without saying how it interacts with
|
| 277 |
+
cover the user already holds; a pick with no stated relation to existing
|
| 278 |
+
cover is incomplete and must not be shown.
|
| 279 |
+
|
| 280 |
βββββββββββββββββββββββββββββββββββ
|
| 281 |
RULE 3 β Follow-ups + mark_recommendation
|
| 282 |
βββββββββββββββββββββββββββββββββββ
|
| 283 |
- After producing a ranked shortlist, call mark_recommendation(policy_ids=[...ordered IDs you cited...]).
|
| 284 |
- 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.
|
| 285 |
+
- BUG #30 (B3) β MATERIALLY-DIFFERENT RE-EVAL: when the user asks you to
|
| 286 |
+
reconsider in light of their existing cover (or any standing profile fact
|
| 287 |
+
β "but I already have βΉ1L employer cover", "given my smoking", "factor in
|
| 288 |
+
my family diabetes"), you MUST issue a NEW retrieve_policies whose query
|
| 289 |
+
MATERIALLY DIFFERS from the prior turn's query β add the existing-cover /
|
| 290 |
+
top-up phrasing per RULE 2 (the "super top-up plan layered over existing N
|
| 291 |
+
lakh base cover" phrase) and any newly-emphasised fact. Do NOT just
|
| 292 |
+
re-narrate the ACTIVE SHORTLIST with the same wording. The revised set
|
| 293 |
+
MUST differ from the prior set by at least ONE pick UNLESS you explicitly
|
| 294 |
+
justify why the prior set is still optimal AND name the existing-cover
|
| 295 |
+
reasoning that led you there.
|
| 296 |
+
|
| 297 |
+
βββββββββββββββββββββββββββββββββββ
|
| 298 |
+
RULE 3.7 β NEVER PROMISE WITHOUT PERFORMING (BUG #30 B2/B3)
|
| 299 |
+
βββββββββββββββββββββββββββββββββββ
|
| 300 |
+
If your reply would say or imply that you will re-evaluate, re-check, look
|
| 301 |
+
into, search again, find better options, or "take another look", you MUST
|
| 302 |
+
call the tool(s) (retrieve_policies, and mark_recommendation if you are
|
| 303 |
+
recommending) THIS turn and return the ACTUAL result. Never end a turn on a
|
| 304 |
+
forward-looking promise ("let me re-evaluate", "let me check", "I'll look
|
| 305 |
+
into it", "give me a moment"). Either DO it now and present the concrete
|
| 306 |
+
result, or ask ONE specific clarifying question β never a bare promise.
|
| 307 |
+
When the user asked you to reconsider in light of existing cover (or any
|
| 308 |
+
standing profile fact), the re-evaluation MUST be a NEW retrieve_policies
|
| 309 |
+
whose query materially differs from the prior turn's per RULE 2 / RULE 3
|
| 310 |
+
(B3); re-narrating the prior shortlist verbatim is NOT a re-evaluation.
|
| 311 |
|
| 312 |
βββββββββββββββββββββββββββββββββββ
|
| 313 |
RULE 3.5 β Claims / denials / complaints / reputation / comparison β get_policy_facts (NEVER refuse)
|
|
|
|
| 921 |
return any(p in t for p in _PRICING_SKIP_PHRASES)
|
| 922 |
|
| 923 |
|
| 924 |
+
_PROMISSORY_NO_ACTION_PHRASES: tuple[str, ...] = (
|
| 925 |
+
"let me re-evaluate",
|
| 926 |
+
"let me check",
|
| 927 |
+
"let me look",
|
| 928 |
+
"i'll look into",
|
| 929 |
+
"i will re-evaluate",
|
| 930 |
+
"let me search",
|
| 931 |
+
"let me find",
|
| 932 |
+
"give me a moment",
|
| 933 |
+
"i'll check",
|
| 934 |
+
"let me see if",
|
| 935 |
+
)
|
| 936 |
+
|
| 937 |
+
|
| 938 |
+
def _is_promissory_no_action(text: str) -> bool:
|
| 939 |
+
"""BUG #30 (B2) β True when the model's reply merely PROMISES to
|
| 940 |
+
re-evaluate / re-check / look into / search again instead of doing it.
|
| 941 |
+
A turn that ends on such a forward-looking promise without performing
|
| 942 |
+
the tool call is a 'promise without action' failure; the loop guard
|
| 943 |
+
re-prompts exactly once to force the actual work this turn."""
|
| 944 |
+
t = (text or "").strip().lower()
|
| 945 |
+
if not t:
|
| 946 |
+
return False
|
| 947 |
+
return any(p in t for p in _PROMISSORY_NO_ACTION_PHRASES)
|
| 948 |
+
|
| 949 |
+
|
| 950 |
def _classify_intent(user_text: str, tool_calls_made: list[str]) -> str:
|
| 951 |
"""Best-effort intent label for logging only. Single-brain doesn't
|
| 952 |
route on intent β but the legacy `TurnResult.intent` field is logged
|
|
|
|
| 1650 |
"parents_age_max": "of the parents' age",
|
| 1651 |
"health_conditions": "of the health condition you mentioned",
|
| 1652 |
"smoker": "of the tobacco-use detail you shared",
|
| 1653 |
+
"existing_cover_inr": "of the existing cover you already hold",
|
| 1654 |
}
|
| 1655 |
|
| 1656 |
|
|
|
|
| 1998 |
# Defensive counter to break runaway loops.
|
| 1999 |
last_text: str = ""
|
| 2000 |
last_payload: dict = {}
|
| 2001 |
+
# BUG #30 (B2) β fires at most ONCE per turn so a promissory no-tool
|
| 2002 |
+
# reply ("let me re-evaluate") is re-prompted into actually calling the
|
| 2003 |
+
# tool, with no risk of an infinite loop.
|
| 2004 |
+
_b2_reprompted: bool = False
|
| 2005 |
|
| 2006 |
for it in range(MAX_ITERATIONS):
|
| 2007 |
# Issue A instrumentation (KI-Z6-LATENCY, 2026-05-15) β Priya T3
|
|
|
|
| 2052 |
if not function_calls:
|
| 2053 |
if text:
|
| 2054 |
last_text = text
|
| 2055 |
+
# BUG #30 (B2) β NEVER PROMISE WITHOUT PERFORMING. If the model
|
| 2056 |
+
# ended the turn on a forward-looking promise ("let me
|
| 2057 |
+
# re-evaluate / check / search") but called NO tool, re-prompt
|
| 2058 |
+
# exactly once to force it to actually call the tool(s) THIS
|
| 2059 |
+
# turn. Guarded by `_b2_reprompted` (fires β€1/turn) and the
|
| 2060 |
+
# iteration budget (only when a further iteration is available)
|
| 2061 |
+
# so there is no infinite loop.
|
| 2062 |
+
if (
|
| 2063 |
+
text
|
| 2064 |
+
and _is_promissory_no_action(text)
|
| 2065 |
+
and not _b2_reprompted
|
| 2066 |
+
and it < MAX_ITERATIONS - 1
|
| 2067 |
+
):
|
| 2068 |
+
_b2_reprompted = True
|
| 2069 |
+
_log.info(
|
| 2070 |
+
"single_brain iter=%d B2 promissory-no-action detected "
|
| 2071 |
+
"β re-prompting to force tool call",
|
| 2072 |
+
it,
|
| 2073 |
+
)
|
| 2074 |
+
contents.append({"role": "model", "parts": parts})
|
| 2075 |
+
contents.append(
|
| 2076 |
+
{
|
| 2077 |
+
"role": "user",
|
| 2078 |
+
"parts": [
|
| 2079 |
+
{
|
| 2080 |
+
"text": (
|
| 2081 |
+
"You said you would re-evaluate/search "
|
| 2082 |
+
"but called no tool. Do it NOW: call "
|
| 2083 |
+
"retrieve_policies (existing-cover-aware "
|
| 2084 |
+
"query) and mark_recommendation, then "
|
| 2085 |
+
"present the revised shortlist. Do not "
|
| 2086 |
+
"reply with another promise."
|
| 2087 |
+
)
|
| 2088 |
+
}
|
| 2089 |
+
],
|
| 2090 |
+
}
|
| 2091 |
+
)
|
| 2092 |
+
continue
|
| 2093 |
_log.info(
|
| 2094 |
"single_brain iter=%d gemini=%.2fs tools=%.2fs "
|
| 2095 |
"tool_calls=[] final_text=True",
|
|
@@ -4060,16 +4060,21 @@ function CompareReviewsCell({ insurerSlug }: { insurerSlug: string }) {
|
|
| 4060 |
</div>
|
| 4061 |
);
|
| 4062 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4063 |
const s = rv.aggregate_score || {};
|
| 4064 |
const cm = rv.claim_metrics || {};
|
| 4065 |
const csr = cm.claim_settlement_ratio_pct;
|
| 4066 |
-
const
|
| 4067 |
-
|
| 4068 |
-
|
| 4069 |
-
|
| 4070 |
-
|
| 4071 |
-
|
| 4072 |
-
if (bits.length === 0 && !s.headline) {
|
| 4073 |
return (
|
| 4074 |
<div
|
| 4075 |
style={{
|
|
@@ -4082,51 +4087,7 @@ function CompareReviewsCell({ insurerSlug }: { insurerSlug: string }) {
|
|
| 4082 |
</div>
|
| 4083 |
);
|
| 4084 |
}
|
| 4085 |
-
return
|
| 4086 |
-
<div style={{ fontSize: 12.5, lineHeight: 1.5, color: "var(--foreground)" }}>
|
| 4087 |
-
{bits.length > 0 && (
|
| 4088 |
-
<div style={{ fontWeight: 600 }}>{bits.join(" Β· ")}</div>
|
| 4089 |
-
)}
|
| 4090 |
-
{s.headline && (
|
| 4091 |
-
<div
|
| 4092 |
-
style={{
|
| 4093 |
-
marginTop: bits.length > 0 ? 4 : 0,
|
| 4094 |
-
color: "var(--muted-foreground)",
|
| 4095 |
-
}}
|
| 4096 |
-
>
|
| 4097 |
-
{s.headline}
|
| 4098 |
-
</div>
|
| 4099 |
-
)}
|
| 4100 |
-
{csr != null && cm.claim_settlement_ratio_year && (
|
| 4101 |
-
<div
|
| 4102 |
-
style={{
|
| 4103 |
-
marginTop: 4,
|
| 4104 |
-
fontSize: 11,
|
| 4105 |
-
color: "var(--muted-foreground)",
|
| 4106 |
-
}}
|
| 4107 |
-
>
|
| 4108 |
-
Claim settlement ratio Β· FY {cm.claim_settlement_ratio_year}
|
| 4109 |
-
{cm.source_irdai_url ? (
|
| 4110 |
-
<>
|
| 4111 |
-
{" Β· "}
|
| 4112 |
-
<a
|
| 4113 |
-
href={cm.source_irdai_url}
|
| 4114 |
-
target="_blank"
|
| 4115 |
-
rel="noopener noreferrer"
|
| 4116 |
-
style={{
|
| 4117 |
-
color: "var(--primary)",
|
| 4118 |
-
textDecoration: "underline",
|
| 4119 |
-
textUnderlineOffset: 2,
|
| 4120 |
-
}}
|
| 4121 |
-
>
|
| 4122 |
-
IRDAI source
|
| 4123 |
-
</a>
|
| 4124 |
-
</>
|
| 4125 |
-
) : null}
|
| 4126 |
-
</div>
|
| 4127 |
-
)}
|
| 4128 |
-
</div>
|
| 4129 |
-
);
|
| 4130 |
}
|
| 4131 |
|
| 4132 |
// CitedPolicyCards β structured per-policy cards rendered BELOW the
|
|
|
|
| 4060 |
</div>
|
| 4061 |
);
|
| 4062 |
}
|
| 4063 |
+
// FIX #32 β render the SAME FULL reputation section the policy DETAIL
|
| 4064 |
+
// modal shows (page.tsx:6169-6171) instead of a condensed summary.
|
| 4065 |
+
// Reuse the existing InsurerReviewsBlock component (no duplication);
|
| 4066 |
+
// mirror the detail modal's guard exactly: full 6-bucket block when
|
| 4067 |
+
// the payload has at least one headline metric, otherwise the same
|
| 4068 |
+
// one-line graceful fallback (never a blank box β #76).
|
| 4069 |
const s = rv.aggregate_score || {};
|
| 4070 |
const cm = rv.claim_metrics || {};
|
| 4071 |
const csr = cm.claim_settlement_ratio_pct;
|
| 4072 |
+
const hasHeadlineMetric =
|
| 4073 |
+
!!s.letter_grade ||
|
| 4074 |
+
s.value_0_100 != null ||
|
| 4075 |
+
csr != null ||
|
| 4076 |
+
!!s.headline;
|
| 4077 |
+
if (!hasHeadlineMetric) {
|
|
|
|
| 4078 |
return (
|
| 4079 |
<div
|
| 4080 |
style={{
|
|
|
|
| 4087 |
</div>
|
| 4088 |
);
|
| 4089 |
}
|
| 4090 |
+
return <InsurerReviewsBlock reviews={rv} />;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4091 |
}
|
| 4092 |
|
| 4093 |
// CitedPolicyCards β structured per-policy cards rendered BELOW the
|
|
@@ -197,6 +197,20 @@ export default function PolicyPremiumWidget({
|
|
| 197 |
const [loading, setLoading] = useState<boolean>(true);
|
| 198 |
const [error, setError] = useState<string | null>(null);
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
// Stable string key so we can put `profile` in the effect dep list without
|
| 201 |
// triggering refetches on every parent re-render (object identity changes).
|
| 202 |
const profileKey = useMemo(() => JSON.stringify(profile ?? {}), [profile]);
|
|
@@ -244,10 +258,16 @@ export default function PolicyPremiumWidget({
|
|
| 244 |
pre_existing_conditions: normalisePed(profile?.pre_existing_conditions),
|
| 245 |
copayment_pct: 0,
|
| 246 |
tenure_years: tenureYears,
|
| 247 |
-
deductible_inr: deductibleInr,
|
| 248 |
});
|
| 249 |
if (signal.aborted) return;
|
| 250 |
setResp(r);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
onCalculatedRef.current?.(r.point_estimate_inr);
|
| 252 |
} catch (e) {
|
| 253 |
if (signal.aborted) return;
|
|
@@ -373,28 +393,32 @@ export default function PolicyPremiumWidget({
|
|
| 373 |
</div>
|
| 374 |
</label>
|
| 375 |
|
| 376 |
-
|
| 377 |
-
<
|
| 378 |
-
<span style={
|
| 379 |
-
|
| 380 |
-
{
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
</div>
|
| 399 |
|
| 400 |
<div style={resultBoxStyle} aria-live="polite">
|
|
|
|
| 197 |
const [loading, setLoading] = useState<boolean>(true);
|
| 198 |
const [error, setError] = useState<string | null>(null);
|
| 199 |
|
| 200 |
+
// BUG #29 β only the ~2 of 148 policies that genuinely offer a
|
| 201 |
+
// user-selectable voluntary deductible expose the selector. The backend
|
| 202 |
+
// is authoritative; default to "unsupported" until the estimate arrives.
|
| 203 |
+
// Declared here (before fetchPremium / the JSX) so both the request
|
| 204 |
+
// builder and the render read the same value without a TDZ hazard.
|
| 205 |
+
// Stale-selection reset is handled in the fetch callback (an async event
|
| 206 |
+
// handler β the React-recommended place to react to a response), not an
|
| 207 |
+
// effect, so we don't trigger cascading-render lint.
|
| 208 |
+
const supportsDeductible = resp?.supports_voluntary_deductible === true;
|
| 209 |
+
const deductibleChoices: readonly number[] =
|
| 210 |
+
resp?.allowed_deductibles && resp.allowed_deductibles.length
|
| 211 |
+
? resp.allowed_deductibles
|
| 212 |
+
: DEDUCTIBLE_CHOICES;
|
| 213 |
+
|
| 214 |
// Stable string key so we can put `profile` in the effect dep list without
|
| 215 |
// triggering refetches on every parent re-render (object identity changes).
|
| 216 |
const profileKey = useMemo(() => JSON.stringify(profile ?? {}), [profile]);
|
|
|
|
| 258 |
pre_existing_conditions: normalisePed(profile?.pre_existing_conditions),
|
| 259 |
copayment_pct: 0,
|
| 260 |
tenure_years: tenureYears,
|
| 261 |
+
deductible_inr: supportsDeductible ? deductibleInr : 0,
|
| 262 |
});
|
| 263 |
if (signal.aborted) return;
|
| 264 |
setResp(r);
|
| 265 |
+
// BUG #29 β if this policy does NOT support a voluntary deductible
|
| 266 |
+
// but a stale non-zero selection carried over from a previously
|
| 267 |
+
// compared policy, clear it so it can't persist or be re-sent.
|
| 268 |
+
if (r.supports_voluntary_deductible === false && deductibleInr !== 0) {
|
| 269 |
+
setDeductibleInr(0);
|
| 270 |
+
}
|
| 271 |
onCalculatedRef.current?.(r.point_estimate_inr);
|
| 272 |
} catch (e) {
|
| 273 |
if (signal.aborted) return;
|
|
|
|
| 393 |
</div>
|
| 394 |
</label>
|
| 395 |
|
| 396 |
+
{supportsDeductible && (
|
| 397 |
+
<label style={labelStyle}>
|
| 398 |
+
<span style={labelHeadStyle}>
|
| 399 |
+
<span style={labelTextStyle}>Deductible</span>
|
| 400 |
+
<strong style={labelValueStyle}>
|
| 401 |
+
{formatDeductibleLabel(deductibleInr)}
|
| 402 |
+
</strong>
|
| 403 |
+
</span>
|
| 404 |
+
<div role="radiogroup" aria-label="Deductible" style={pillRowStyle}>
|
| 405 |
+
{deductibleChoices.map((d) => (
|
| 406 |
+
<button
|
| 407 |
+
key={d}
|
| 408 |
+
type="button"
|
| 409 |
+
role="radio"
|
| 410 |
+
aria-checked={deductibleInr === d}
|
| 411 |
+
onClick={() =>
|
| 412 |
+
setDeductibleInr(d as 0 | 25000 | 50000 | 100000)
|
| 413 |
+
}
|
| 414 |
+
style={pillStyle(deductibleInr === d)}
|
| 415 |
+
>
|
| 416 |
+
{formatDeductibleLabel(d)}
|
| 417 |
+
</button>
|
| 418 |
+
))}
|
| 419 |
+
</div>
|
| 420 |
+
</label>
|
| 421 |
+
)}
|
| 422 |
</div>
|
| 423 |
|
| 424 |
<div style={resultBoxStyle} aria-live="polite">
|
|
@@ -362,6 +362,12 @@ export type PremiumEstimateResponse = {
|
|
| 362 |
// Echoed back when caller passed tenure / deductible overrides.
|
| 363 |
tenure_years?: number | null;
|
| 364 |
deductible_inr?: number | null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
// True when the backend anchored the base to a curated quote sample (i.e.
|
| 366 |
// the policy is in illustrative_premiums.json). Drives the widget's
|
| 367 |
// "Estimate" badge β replaces bulk_estimate's `assumed` flag.
|
|
|
|
| 362 |
// Echoed back when caller passed tenure / deductible overrides.
|
| 363 |
tenure_years?: number | null;
|
| 364 |
deductible_inr?: number | null;
|
| 365 |
+
// BUG #29 β whether THIS policy genuinely offers a user-selectable
|
| 366 |
+
// voluntary deductible (only ~2 of 148 do). When false the widget hides
|
| 367 |
+
// the deductible selector entirely; allowed_deductibles is the exact set
|
| 368 |
+
// of pills to render when true.
|
| 369 |
+
supports_voluntary_deductible?: boolean;
|
| 370 |
+
allowed_deductibles?: number[];
|
| 371 |
// True when the backend anchored the base to a curated quote sample (i.e.
|
| 372 |
// the policy is in illustrative_premiums.json). Drives the widget's
|
| 373 |
// "Estimate" badge β replaces bulk_estimate's `assumed` flag.
|
|
@@ -0,0 +1,508 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BUG #30 (2026-05-19) β existing-cover-aware ranking + "never promise
|
| 2 |
+
without performing".
|
| 3 |
+
|
| 4 |
+
Failing scenario the bug pins:
|
| 5 |
+
Profile = {existing_cover_inr=100000 (βΉ1L employer), primary_goal=first_buy,
|
| 6 |
+
smoker=yes, family_medical_history=["diabetes"],
|
| 7 |
+
desired_sum_insured_inr=2_000_000}
|
| 8 |
+
|
| 9 |
+
Three sub-defects fixed:
|
| 10 |
+
|
| 11 |
+
B1-c retrieval_filters._fit_score had NO existing-cover / top-up term, so
|
| 12 |
+
the ranker was existing-cover-blind: with ANY positive existing
|
| 13 |
+
cover a relevant top-up must now out-rank a SAME-GRADE non-top-up
|
| 14 |
+
(and land in the cited set), and brain_tools._quality_seed_candidates
|
| 15 |
+
must union-in top-ups when existing_cover_inr is truthy.
|
| 16 |
+
|
| 17 |
+
B2 single_brain must NEVER end a turn on a forward-looking promise
|
| 18 |
+
("let me re-evaluate / check / search") with NO tool call:
|
| 19 |
+
`_is_promissory_no_action` detects it and the handle_turn loop
|
| 20 |
+
re-prompts EXACTLY ONCE to force the actual tool call this turn.
|
| 21 |
+
|
| 22 |
+
B3 _CONSTRAINT_FIELD_PHRASES now maps existing_cover_inr so a re-eval
|
| 23 |
+
triggered by the existing cover is explained, not silent.
|
| 24 |
+
|
| 25 |
+
Run:
|
| 26 |
+
cd /Users/rohitsar/Developer/Insurance\\ Sales\\ Bot
|
| 27 |
+
PYTHONPATH=$PWD .venv/bin/python -m pytest -q \\
|
| 28 |
+
tests/test_bug30_existing_cover_and_promise.py
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
import asyncio
|
| 34 |
+
import os
|
| 35 |
+
import sys
|
| 36 |
+
import unittest
|
| 37 |
+
import uuid
|
| 38 |
+
from pathlib import Path
|
| 39 |
+
from unittest import mock
|
| 40 |
+
|
| 41 |
+
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 42 |
+
if str(_REPO_ROOT) not in sys.path:
|
| 43 |
+
sys.path.insert(0, str(_REPO_ROOT))
|
| 44 |
+
|
| 45 |
+
from backend import brain_tools, single_brain # noqa: E402
|
| 46 |
+
from backend.retrieval_filters import ( # noqa: E402
|
| 47 |
+
rank_by_profile_fit,
|
| 48 |
+
filter_pipeline,
|
| 49 |
+
)
|
| 50 |
+
from backend.single_brain import ( # noqa: E402
|
| 51 |
+
_is_promissory_no_action,
|
| 52 |
+
_constraint_reason_clause,
|
| 53 |
+
_HONEST_EMPTY_REPLY,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ---------------------------------------------------------------------------
|
| 58 |
+
# The exact BUG #30 failing-session profile.
|
| 59 |
+
# ---------------------------------------------------------------------------
|
| 60 |
+
|
| 61 |
+
BUG30_PROFILE = {
|
| 62 |
+
"age": 29,
|
| 63 |
+
"location_tier": "metro",
|
| 64 |
+
"income_band": "25L+",
|
| 65 |
+
"primary_goal": "first_buy",
|
| 66 |
+
"existing_cover_inr": 100_000, # βΉ1L employer cover β SMALL but > 0
|
| 67 |
+
"health_conditions": ["none"],
|
| 68 |
+
"desired_sum_insured_inr": 2_000_000, # βΉ20 lakh
|
| 69 |
+
"copay_pct": 0,
|
| 70 |
+
"smoker": True,
|
| 71 |
+
"family_medical_history": ["diabetes"],
|
| 72 |
+
"dependents": "self",
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _chunk(
|
| 77 |
+
policy_id: str,
|
| 78 |
+
policy_name: str,
|
| 79 |
+
*,
|
| 80 |
+
score: float = 0.5,
|
| 81 |
+
policy_type: str | None = None,
|
| 82 |
+
deductible_amount: int | None = None,
|
| 83 |
+
copay_pct: int | None = None,
|
| 84 |
+
sum_insured_options: list[int] | None = None,
|
| 85 |
+
grade: str | None = None,
|
| 86 |
+
overall_score: int | None = None,
|
| 87 |
+
doc_type: str = "policy",
|
| 88 |
+
) -> dict:
|
| 89 |
+
"""Chunk shaped like brain_tools.retrieve_policies output AFTER the
|
| 90 |
+
policy_facts enrichment step (mirrors tests/test_eligibility_ranking)."""
|
| 91 |
+
return {
|
| 92 |
+
"policy_id": policy_id,
|
| 93 |
+
"policy_name": policy_name,
|
| 94 |
+
"insurer_slug": policy_id.split("__")[0],
|
| 95 |
+
"doc_type": doc_type,
|
| 96 |
+
"score": score,
|
| 97 |
+
"chunk_text": "",
|
| 98 |
+
"policy_type_indemnity_or_fixed": policy_type,
|
| 99 |
+
"deductible_amount": deductible_amount,
|
| 100 |
+
"co_payment_pct": copay_pct,
|
| 101 |
+
"sum_insured_options": sum_insured_options,
|
| 102 |
+
"_grade": grade,
|
| 103 |
+
"_overall_score": overall_score,
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# A SAME-GRADE pair: identical cosine, grade, overall, SI, copay. The ONLY
|
| 108 |
+
# differentiator is that one is a super-top-up. Pre-fix _fit_score scored
|
| 109 |
+
# them equally (stable sort kept incoming order β top-up second). Post-fix
|
| 110 |
+
# the +22 existing-cover term must lift the top-up above the non-top-up.
|
| 111 |
+
SAME_GRADE_TOPUP = _chunk(
|
| 112 |
+
"royal-sundaram__advanced-top-up",
|
| 113 |
+
"Advanced Top Up Health Insurance Plan",
|
| 114 |
+
score=0.60,
|
| 115 |
+
policy_type="super_top_up",
|
| 116 |
+
deductible_amount=300_000,
|
| 117 |
+
copay_pct=0,
|
| 118 |
+
sum_insured_options=[1_000_000, 2_000_000, 5_000_000],
|
| 119 |
+
grade="A",
|
| 120 |
+
overall_score=85,
|
| 121 |
+
)
|
| 122 |
+
SAME_GRADE_PRIMARY = _chunk(
|
| 123 |
+
"niva-bupa__reassure-2",
|
| 124 |
+
"ReAssure 2.0",
|
| 125 |
+
score=0.60,
|
| 126 |
+
policy_type="indemnity",
|
| 127 |
+
deductible_amount=None,
|
| 128 |
+
copay_pct=0,
|
| 129 |
+
sum_insured_options=[1_000_000, 2_000_000, 5_000_000],
|
| 130 |
+
grade="A",
|
| 131 |
+
overall_score=85,
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ---------------------------------------------------------------------------
|
| 136 |
+
# B1-c β existing-cover-aware ranking
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
|
| 139 |
+
class TestExistingCoverRanking(unittest.TestCase):
|
| 140 |
+
def test_topup_outranks_same_grade_non_topup_with_existing_cover(self):
|
| 141 |
+
"""With existing_cover_inr=100000 a SAME-GRADE top-up must rank
|
| 142 |
+
ABOVE the same-grade non-top-up (the +22 term breaks the tie that,
|
| 143 |
+
pre-fix, left the top-up buried)."""
|
| 144 |
+
ranked = rank_by_profile_fit(
|
| 145 |
+
# incoming order puts the PRIMARY first so a stable sort would,
|
| 146 |
+
# absent the new term, KEEP the top-up second.
|
| 147 |
+
[SAME_GRADE_PRIMARY, SAME_GRADE_TOPUP], BUG30_PROFILE
|
| 148 |
+
)
|
| 149 |
+
order = [c["policy_id"] for c in ranked]
|
| 150 |
+
self.assertLess(
|
| 151 |
+
order.index("royal-sundaram__advanced-top-up"),
|
| 152 |
+
order.index("niva-bupa__reassure-2"),
|
| 153 |
+
"A relevant super-top-up must out-rank a same-grade non-top-up "
|
| 154 |
+
"when the user already holds βΉ1L existing base cover.",
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
def test_topup_lands_in_cited_set_through_pipeline(self):
|
| 158 |
+
"""End-to-end: through filter_pipeline the top-up survives
|
| 159 |
+
eligibility (user HAS base cover) AND lands in the cited set."""
|
| 160 |
+
filtered, _guard = filter_pipeline(
|
| 161 |
+
[SAME_GRADE_PRIMARY, SAME_GRADE_TOPUP],
|
| 162 |
+
profile=BUG30_PROFILE,
|
| 163 |
+
query=("comprehensive base health plan metro 20 lakh super "
|
| 164 |
+
"top-up plan layered over existing 1 lakh employer base "
|
| 165 |
+
"cover diabetes smoker"),
|
| 166 |
+
intent="recommendation",
|
| 167 |
+
)
|
| 168 |
+
ids = [c["policy_id"] for c in filtered]
|
| 169 |
+
self.assertIn(
|
| 170 |
+
"royal-sundaram__advanced-top-up", ids,
|
| 171 |
+
"the relevant super-top-up must reach the brain (cited set) for "
|
| 172 |
+
"a user who already holds base cover.",
|
| 173 |
+
)
|
| 174 |
+
self.assertIn("niva-bupa__reassure-2", ids,
|
| 175 |
+
"the strong primary plan must also survive.")
|
| 176 |
+
self.assertLess(
|
| 177 |
+
ids.index("royal-sundaram__advanced-top-up"),
|
| 178 |
+
ids.index("niva-bupa__reassure-2"),
|
| 179 |
+
"post-fix the top-up ranks ahead of the same-grade primary.",
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
def test_term_inert_without_existing_cover(self):
|
| 183 |
+
"""Regression: with NO existing cover the term must be inert β a
|
| 184 |
+
first-time buyer's ranking is unchanged (stable order preserved)."""
|
| 185 |
+
no_cover = dict(BUG30_PROFILE, existing_cover_inr=0)
|
| 186 |
+
ranked = rank_by_profile_fit(
|
| 187 |
+
[SAME_GRADE_PRIMARY, SAME_GRADE_TOPUP], no_cover
|
| 188 |
+
)
|
| 189 |
+
order = [c["policy_id"] for c in ranked]
|
| 190 |
+
# Equal score β stable sort keeps the incoming order (primary first).
|
| 191 |
+
self.assertEqual(
|
| 192 |
+
order[0], "niva-bupa__reassure-2",
|
| 193 |
+
"with no existing cover the +22 term must NOT fire β ordering "
|
| 194 |
+
"is unchanged from the profile-neutral baseline.",
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
class TestQualitySeedUnionsTopUps(unittest.TestCase):
|
| 199 |
+
"""B1-c β _quality_seed_candidates must union-in top-up policies when
|
| 200 |
+
existing_cover_inr is truthy, even if they fall OUTSIDE the
|
| 201 |
+
profile-neutral top-25 window."""
|
| 202 |
+
|
| 203 |
+
class _Prof:
|
| 204 |
+
def __init__(self, **kw):
|
| 205 |
+
for k, v in kw.items():
|
| 206 |
+
setattr(self, k, v)
|
| 207 |
+
|
| 208 |
+
def _patch_curated(self, monkeyish):
|
| 209 |
+
# Build a synthetic catalogue: 26 high-overall NON-top-up policies
|
| 210 |
+
# (fill the top-25 window) + ONE top-up with a LOWER overall so it
|
| 211 |
+
# falls OUTSIDE the window. The fix must still seed it.
|
| 212 |
+
curated = {}
|
| 213 |
+
for i in range(26):
|
| 214 |
+
pid = f"insurerx__primary-{i:02d}"
|
| 215 |
+
curated[pid] = {
|
| 216 |
+
"policy_id": pid,
|
| 217 |
+
"policy_name": f"Primary Plan {i}",
|
| 218 |
+
"insurer_slug": "insurerx",
|
| 219 |
+
}
|
| 220 |
+
curated["royal-sundaram__advanced-top-up"] = {
|
| 221 |
+
"policy_id": "royal-sundaram__advanced-top-up",
|
| 222 |
+
"policy_name": "Advanced Top Up Health Insurance Plan",
|
| 223 |
+
"insurer_slug": "royal-sundaram",
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
def _fake_curated_all():
|
| 227 |
+
return curated
|
| 228 |
+
|
| 229 |
+
def _fake_scorecard_signal(pid, profile=None):
|
| 230 |
+
if pid == "royal-sundaram__advanced-top-up":
|
| 231 |
+
return {"_overall_score": 50, "_grade": "C"} # OUTSIDE top-25
|
| 232 |
+
n = int(pid.split("-")[-1])
|
| 233 |
+
return {"_overall_score": 95 - n, "_grade": "A"} # all > 50
|
| 234 |
+
|
| 235 |
+
def _fake_load_facts(pid):
|
| 236 |
+
if pid == "royal-sundaram__advanced-top-up":
|
| 237 |
+
return {
|
| 238 |
+
"policy_type_indemnity_or_fixed": "super_top_up",
|
| 239 |
+
"deductible_amount": 300_000,
|
| 240 |
+
}
|
| 241 |
+
return {"policy_type_indemnity_or_fixed": "indemnity"}
|
| 242 |
+
|
| 243 |
+
return (_fake_curated_all, _fake_scorecard_signal, _fake_load_facts)
|
| 244 |
+
|
| 245 |
+
def test_topup_seeded_when_existing_cover_truthy(self):
|
| 246 |
+
ca, sig, lf = self._patch_curated(None)
|
| 247 |
+
with mock.patch.object(brain_tools, "_curated_facts_all", ca), \
|
| 248 |
+
mock.patch.object(brain_tools, "_scorecard_signal", sig), \
|
| 249 |
+
mock.patch.object(brain_tools, "_load_policy_facts", lf), \
|
| 250 |
+
mock.patch.object(brain_tools, "_has_extraction",
|
| 251 |
+
lambda pid: True):
|
| 252 |
+
brain_tools._qseed_cache.clear()
|
| 253 |
+
prof = self._Prof(existing_cover_inr=100_000, age=29)
|
| 254 |
+
seeded = brain_tools._quality_seed_candidates(prof, limit=25)
|
| 255 |
+
ids = {c["policy_id"] for c in seeded}
|
| 256 |
+
self.assertIn(
|
| 257 |
+
"royal-sundaram__advanced-top-up", ids,
|
| 258 |
+
"a relevant super-top-up OUTSIDE the profile-neutral top-25 "
|
| 259 |
+
"must still be union-seeded when existing_cover_inr is truthy.",
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
def test_topup_not_seeded_without_existing_cover(self):
|
| 263 |
+
ca, sig, lf = self._patch_curated(None)
|
| 264 |
+
with mock.patch.object(brain_tools, "_curated_facts_all", ca), \
|
| 265 |
+
mock.patch.object(brain_tools, "_scorecard_signal", sig), \
|
| 266 |
+
mock.patch.object(brain_tools, "_load_policy_facts", lf), \
|
| 267 |
+
mock.patch.object(brain_tools, "_has_extraction",
|
| 268 |
+
lambda pid: True):
|
| 269 |
+
brain_tools._qseed_cache.clear()
|
| 270 |
+
prof = self._Prof(existing_cover_inr=0, age=29)
|
| 271 |
+
seeded = brain_tools._quality_seed_candidates(prof, limit=25)
|
| 272 |
+
ids = {c["policy_id"] for c in seeded}
|
| 273 |
+
self.assertNotIn(
|
| 274 |
+
"royal-sundaram__advanced-top-up", ids,
|
| 275 |
+
"with no existing cover the out-of-window top-up must NOT be "
|
| 276 |
+
"force-seeded (term inert for first-time buyers).",
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
# ---------------------------------------------------------------------------
|
| 281 |
+
# B2 β promissory-no-action detector
|
| 282 |
+
# ---------------------------------------------------------------------------
|
| 283 |
+
|
| 284 |
+
class TestIsPromissoryNoAction(unittest.TestCase):
|
| 285 |
+
def test_detects_all_canonical_promise_phrases(self):
|
| 286 |
+
for phrase in (
|
| 287 |
+
"Let me re-evaluate the options for you.",
|
| 288 |
+
"Sure, let me check that.",
|
| 289 |
+
"Let me look into the best plans.",
|
| 290 |
+
"I'll look into it and get back.",
|
| 291 |
+
"I will re-evaluate given your existing cover.",
|
| 292 |
+
"Let me search for better-fit plans.",
|
| 293 |
+
"Let me find a top-up that suits you.",
|
| 294 |
+
"Give me a moment to reconsider.",
|
| 295 |
+
"I'll check the shortlist again.",
|
| 296 |
+
"Let me see if there's a better option.",
|
| 297 |
+
):
|
| 298 |
+
self.assertTrue(
|
| 299 |
+
_is_promissory_no_action(phrase),
|
| 300 |
+
f"must flag promissory phrase: {phrase!r}",
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
def test_case_insensitive(self):
|
| 304 |
+
self.assertTrue(_is_promissory_no_action("LET ME RE-EVALUATE NOW"))
|
| 305 |
+
|
| 306 |
+
def test_non_promissory_text_is_not_flagged(self):
|
| 307 |
+
for ok in (
|
| 308 |
+
"Here are two strong plans for your profile: ...",
|
| 309 |
+
"Your βΉ1L employer cover supplements this primary plan.",
|
| 310 |
+
"Could you confirm your preferred sum insured?",
|
| 311 |
+
"",
|
| 312 |
+
" ",
|
| 313 |
+
):
|
| 314 |
+
self.assertFalse(
|
| 315 |
+
_is_promissory_no_action(ok),
|
| 316 |
+
f"must NOT flag non-promissory text: {ok!r}",
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
# ---------------------------------------------------------------------------
|
| 321 |
+
# B3 β existing-cover constraint phrase
|
| 322 |
+
# ---------------------------------------------------------------------------
|
| 323 |
+
|
| 324 |
+
class TestExistingCoverConstraintPhrase(unittest.TestCase):
|
| 325 |
+
def test_existing_cover_inr_maps_to_its_phrase(self):
|
| 326 |
+
clause = _constraint_reason_clause({"existing_cover_inr": "100000"})
|
| 327 |
+
self.assertIn("existing cover you already hold", clause)
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
# ---------------------------------------------------------------------------
|
| 331 |
+
# B2 β handle_turn loop guard: a promissory no-tool reply forces EXACTLY
|
| 332 |
+
# ONE re-prompt iteration (no infinite loop).
|
| 333 |
+
# ---------------------------------------------------------------------------
|
| 334 |
+
|
| 335 |
+
def _run(coro):
|
| 336 |
+
return asyncio.new_event_loop().run_until_complete(coro)
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def _fc_part(name, args):
|
| 340 |
+
return {"functionCall": {"name": name, "args": args}}
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def _text_payload(text):
|
| 344 |
+
return {"candidates": [{"content": {"parts": [{"text": text}]}}]}
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def _tool_payload(parts):
|
| 348 |
+
return {"candidates": [{"content": {"parts": parts}}]}
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def _enriched(pid, name, slug, score, cid, *, grade="A", overall=85):
|
| 352 |
+
return {
|
| 353 |
+
"chunk_id": cid, "policy_id": pid, "policy_name": name,
|
| 354 |
+
"insurer_slug": slug, "doc_type": "policy",
|
| 355 |
+
"source_url": f"https://example.com/{pid}.pdf", "score": score,
|
| 356 |
+
"_grade": grade, "_overall_score": overall,
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
class TestPromissoryLoopGuard(unittest.TestCase):
|
| 361 |
+
"""A no-tool promissory turn must trigger EXACTLY ONE re-prompt that
|
| 362 |
+
forces the actual tool call this turn (mirrors
|
| 363 |
+
tests/test_last_text_preserved_across_tool_iters harness)."""
|
| 364 |
+
|
| 365 |
+
def setUp(self):
|
| 366 |
+
self._env = mock.patch.dict(os.environ,
|
| 367 |
+
{"GOOGLE_API_KEY": "test-key"})
|
| 368 |
+
self._env.start()
|
| 369 |
+
self._gemini_script: list = []
|
| 370 |
+
self._calls: list = []
|
| 371 |
+
self._retrieve_chunks: list = []
|
| 372 |
+
|
| 373 |
+
async def _fake_gemini(*_a, **_k):
|
| 374 |
+
self._calls.append(_k.get("contents"))
|
| 375 |
+
if not self._gemini_script:
|
| 376 |
+
return _text_payload("(no more scripted turns)")
|
| 377 |
+
return self._gemini_script.pop(0)
|
| 378 |
+
|
| 379 |
+
async def _fake_retrieve(*_a, **_k):
|
| 380 |
+
chunks = list(self._retrieve_chunks)
|
| 381 |
+
sess = _k.get("session")
|
| 382 |
+
if sess is not None:
|
| 383 |
+
sess.last_retrieved_chunks = list(chunks)
|
| 384 |
+
sess.slug_to_insurer = {
|
| 385 |
+
c["policy_id"]: c["insurer_slug"] for c in chunks
|
| 386 |
+
}
|
| 387 |
+
return {"chunks": chunks, "count": len(chunks)}
|
| 388 |
+
|
| 389 |
+
self._gp = mock.patch.object(single_brain, "_gemini_call",
|
| 390 |
+
_fake_gemini)
|
| 391 |
+
self._rp = mock.patch.object(brain_tools, "retrieve_policies",
|
| 392 |
+
_fake_retrieve)
|
| 393 |
+
self._gp.start()
|
| 394 |
+
self._rp.start()
|
| 395 |
+
|
| 396 |
+
def tearDown(self):
|
| 397 |
+
self._rp.stop()
|
| 398 |
+
self._gp.stop()
|
| 399 |
+
self._env.stop()
|
| 400 |
+
|
| 401 |
+
def _ready_session(self):
|
| 402 |
+
from backend.session_state import SessionState
|
| 403 |
+
sess = SessionState(session_id=f"t_{uuid.uuid4().hex[:8]}")
|
| 404 |
+
sess.profile.name = "Asha"
|
| 405 |
+
sess.profile.age = 29
|
| 406 |
+
sess.profile.dependents = "self"
|
| 407 |
+
sess.profile.location_tier = "metro"
|
| 408 |
+
sess.profile.income_band = "25L+"
|
| 409 |
+
sess.profile.primary_goal = "first_buy"
|
| 410 |
+
sess.profile.health_conditions = ["none"]
|
| 411 |
+
sess.profile.existing_cover_inr = 100_000
|
| 412 |
+
sess.pricing_bundle_skipped = True
|
| 413 |
+
return sess
|
| 414 |
+
|
| 415 |
+
def test_promissory_no_tool_turn_forces_exactly_one_reprompt(self):
|
| 416 |
+
sess = self._ready_session()
|
| 417 |
+
self._retrieve_chunks = [
|
| 418 |
+
_enriched("hdfc-ergo__optima-secure", "Optima Secure",
|
| 419 |
+
"hdfc-ergo", 0.81, "c1"),
|
| 420 |
+
_enriched("royal-sundaram__advanced-top-up",
|
| 421 |
+
"Advanced Top Up", "royal-sundaram", 0.70, "c2"),
|
| 422 |
+
]
|
| 423 |
+
self._gemini_script = [
|
| 424 |
+
# iter 1 β PROMISSORY, NO tool call. Must trigger 1 re-prompt.
|
| 425 |
+
_text_payload(
|
| 426 |
+
"Let me re-evaluate given your βΉ1L employer cover."),
|
| 427 |
+
# iter 2 β model now actually calls the tools.
|
| 428 |
+
_tool_payload([_fc_part("retrieve_policies", {
|
| 429 |
+
"query": ("comprehensive base plan super top-up plan "
|
| 430 |
+
"layered over existing 1 lakh employer base "
|
| 431 |
+
"cover")})]),
|
| 432 |
+
_tool_payload([_fc_part("mark_recommendation", {
|
| 433 |
+
"policy_ids": ["hdfc-ergo__optima-secure",
|
| 434 |
+
"royal-sundaram__advanced-top-up"]})]),
|
| 435 |
+
# iter 3 β final prose with the revised shortlist.
|
| 436 |
+
_text_payload(
|
| 437 |
+
"Here is the revised shortlist: 1. Optima Secure works as "
|
| 438 |
+
"your PRIMARY plan; your βΉ1L employer cover supplements it. "
|
| 439 |
+
"2. Advanced Top Up sits ABOVE your βΉ1L existing cover."),
|
| 440 |
+
]
|
| 441 |
+
|
| 442 |
+
r = _run(single_brain.handle_turn(
|
| 443 |
+
sess, "But I already have βΉ1L cover from my employer β "
|
| 444 |
+
"reconsider please"))
|
| 445 |
+
|
| 446 |
+
# The re-prompt fires (a synthetic user turn injected) and the
|
| 447 |
+
# final reply is the REAL revised shortlist, never the promise.
|
| 448 |
+
self.assertIn("revised shortlist", r.reply_text)
|
| 449 |
+
self.assertNotIn("Let me re-evaluate", r.reply_text,
|
| 450 |
+
"the turn must NOT end on the promise")
|
| 451 |
+
self.assertNotEqual(r.reply_text, _HONEST_EMPTY_REPLY)
|
| 452 |
+
# _classify_intent β "recommendation" only when BOTH
|
| 453 |
+
# retrieve_policies AND mark_recommendation actually fired; proves
|
| 454 |
+
# the re-prompt forced the real tool calls.
|
| 455 |
+
self.assertEqual(
|
| 456 |
+
r.intent, "recommendation",
|
| 457 |
+
"the re-prompt must have forced the actual retrieve_policies + "
|
| 458 |
+
"mark_recommendation tool calls",
|
| 459 |
+
)
|
| 460 |
+
self.assertIn("retrieve_policies", r.brain_used)
|
| 461 |
+
|
| 462 |
+
# Exactly ONE re-prompt: find the injected guard user-turn in the
|
| 463 |
+
# contents passed to the final Gemini call.
|
| 464 |
+
final_contents = self._calls[-1]
|
| 465 |
+
guard_turns = [
|
| 466 |
+
c for c in final_contents
|
| 467 |
+
if c.get("role") == "user"
|
| 468 |
+
and any("called no tool" in p.get("text", "")
|
| 469 |
+
for p in c.get("parts", []))
|
| 470 |
+
]
|
| 471 |
+
self.assertEqual(
|
| 472 |
+
len(guard_turns), 1,
|
| 473 |
+
"the B2 loop guard must fire EXACTLY ONCE per turn "
|
| 474 |
+
"(no infinite loop / no double re-prompt).",
|
| 475 |
+
)
|
| 476 |
+
|
| 477 |
+
def test_no_reprompt_when_turn_has_a_tool_call(self):
|
| 478 |
+
"""A turn that DOES call a tool must not trigger the B2 guard even
|
| 479 |
+
if its (later) prose happens to contain a promise-like phrase."""
|
| 480 |
+
sess = self._ready_session()
|
| 481 |
+
self._retrieve_chunks = [
|
| 482 |
+
_enriched("hdfc-ergo__optima-secure", "Optima Secure",
|
| 483 |
+
"hdfc-ergo", 0.81, "c1"),
|
| 484 |
+
]
|
| 485 |
+
self._gemini_script = [
|
| 486 |
+
_tool_payload([_fc_part("retrieve_policies",
|
| 487 |
+
{"query": "metro comprehensive"})]),
|
| 488 |
+
_tool_payload([_fc_part("mark_recommendation", {
|
| 489 |
+
"policy_ids": ["hdfc-ergo__optima-secure"]})]),
|
| 490 |
+
_text_payload(
|
| 491 |
+
"Here is Optima Secure β a strong primary plan; your "
|
| 492 |
+
"βΉ1L employer cover supplements it."),
|
| 493 |
+
]
|
| 494 |
+
r = _run(single_brain.handle_turn(sess, "show options"))
|
| 495 |
+
self.assertIn("Optima Secure", r.reply_text)
|
| 496 |
+
final_contents = self._calls[-1]
|
| 497 |
+
guard_turns = [
|
| 498 |
+
c for c in final_contents
|
| 499 |
+
if c.get("role") == "user"
|
| 500 |
+
and any("called no tool" in p.get("text", "")
|
| 501 |
+
for p in c.get("parts", []))
|
| 502 |
+
]
|
| 503 |
+
self.assertEqual(len(guard_turns), 0,
|
| 504 |
+
"no B2 guard when the turn already called a tool")
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
if __name__ == "__main__":
|
| 508 |
+
unittest.main(verbosity=2)
|
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BUG #29 β voluntary-deductible eligibility.
|
| 2 |
+
|
| 3 |
+
PROBLEM (pre-fix): the deductible selector (βΉ0/25K/50K/1L) and its premium
|
| 4 |
+
discount were applied to EVERY policy, even though only 2 of the 148
|
| 5 |
+
catalogued policies genuinely offer a user-selectable voluntary deductible.
|
| 6 |
+
A top-up's "deductible" is a structural threshold, not a knob the buyer can
|
| 7 |
+
trade for a lower premium β discounting on it fabricates savings.
|
| 8 |
+
|
| 9 |
+
Authoritative rule (premium_calculator.policy_deductible_support):
|
| 10 |
+
|
| 11 |
+
supports_voluntary_deductible
|
| 12 |
+
= (curated deductible_amount > 0) AND (NOT a top-up / super-top-up)
|
| 13 |
+
|
| 14 |
+
Across the full 148-policy catalogue this resolves to EXACTLY:
|
| 15 |
+
|
| 16 |
+
{bajaj-allianz__health-guard, star-health__star-assure}
|
| 17 |
+
|
| 18 |
+
These tests:
|
| 19 |
+
1. Sweep the entire catalogue and pin the invariants (top-ups never
|
| 20 |
+
supported, deductible_amount<=0 never supported) plus the exact
|
| 21 |
+
supported set as a regression pin.
|
| 22 |
+
2. Exercise the end-to-end /api/premium/estimate path proving an
|
| 23 |
+
unsupported policy gets NO discount + an honest 0 echo, while a
|
| 24 |
+
supported policy gets the real Γ0.85 discount + a 50000 echo.
|
| 25 |
+
3. Prove bulk_estimate() forces the discount to 1.0 and echoes
|
| 26 |
+
deductible_inr=0 for an unsupported policy.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import pytest
|
| 30 |
+
from fastapi.testclient import TestClient
|
| 31 |
+
|
| 32 |
+
from backend import main
|
| 33 |
+
from backend.brain_tools import _load_policy_facts
|
| 34 |
+
from backend.main import _marketplace_catalogue
|
| 35 |
+
from backend.premium_calculator import (
|
| 36 |
+
BULK_DEDUCTIBLE_DISCOUNT,
|
| 37 |
+
_policy_product_type,
|
| 38 |
+
bulk_estimate,
|
| 39 |
+
policy_deductible_support,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
client = TestClient(main.app, raise_server_exceptions=False)
|
| 43 |
+
|
| 44 |
+
# The full catalogued set β single source of truth for the marketplace cards.
|
| 45 |
+
_ALL_CARDS = _marketplace_catalogue(None)
|
| 46 |
+
_ALL_PIDS = [c.policy_id for c in _ALL_CARDS if c.policy_id]
|
| 47 |
+
|
| 48 |
+
# Regression pin β the EXACT set the rule must select across all 148.
|
| 49 |
+
EXPECTED_SUPPORTED = {
|
| 50 |
+
"bajaj-allianz__health-guard",
|
| 51 |
+
"star-health__star-assure",
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _curated_deductible(pid: str) -> float:
|
| 56 |
+
f = _load_policy_facts(pid) or {}
|
| 57 |
+
ded = f.get("deductible_amount")
|
| 58 |
+
try:
|
| 59 |
+
return float(ded) if ded not in (None, "", []) else 0.0
|
| 60 |
+
except (TypeError, ValueError):
|
| 61 |
+
return 0.0
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
# Catalogue-wide invariants
|
| 66 |
+
# ---------------------------------------------------------------------------
|
| 67 |
+
|
| 68 |
+
def test_catalogue_has_expected_size():
|
| 69 |
+
"""Guards the regression pin: if the catalogue size drifts, the
|
| 70 |
+
EXPECTED_SUPPORTED set must be re-derived deliberately."""
|
| 71 |
+
assert len(_ALL_PIDS) == 148
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@pytest.mark.parametrize("pid", _ALL_PIDS)
|
| 75 |
+
def test_topups_never_support_a_voluntary_deductible(pid):
|
| 76 |
+
"""A top-up / super-top-up's deductible is a structural threshold, not a
|
| 77 |
+
user-selectable knob β it must NEVER be (True, ...)."""
|
| 78 |
+
if _policy_product_type(pid) == "topup":
|
| 79 |
+
assert policy_deductible_support(pid) == (False, [0]), pid
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@pytest.mark.parametrize("pid", _ALL_PIDS)
|
| 83 |
+
def test_nonpositive_curated_deductible_never_supported(pid):
|
| 84 |
+
"""No curated deductible_amount (or <=0) β no voluntary deductible."""
|
| 85 |
+
if _curated_deductible(pid) <= 0:
|
| 86 |
+
assert policy_deductible_support(pid) == (False, [0]), pid
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@pytest.mark.parametrize("pid", _ALL_PIDS)
|
| 90 |
+
def test_support_shape_is_always_valid(pid):
|
| 91 |
+
"""Whatever the answer, the shape contract holds: (bool, list[int]) with
|
| 92 |
+
0 always present and the list sorted/unique."""
|
| 93 |
+
supports, allowed = policy_deductible_support(pid)
|
| 94 |
+
assert isinstance(supports, bool)
|
| 95 |
+
assert isinstance(allowed, list) and all(isinstance(x, int) for x in allowed)
|
| 96 |
+
assert 0 in allowed
|
| 97 |
+
assert allowed == sorted(set(allowed))
|
| 98 |
+
if not supports:
|
| 99 |
+
assert allowed == [0], pid
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_exact_supported_set_regression_pin():
|
| 103 |
+
"""The EXACT set of policies with supports==True across the full
|
| 104 |
+
catalogue is the two flagship comprehensive plans β nothing else."""
|
| 105 |
+
supported = {
|
| 106 |
+
pid for pid in _ALL_PIDS if policy_deductible_support(pid)[0]
|
| 107 |
+
}
|
| 108 |
+
assert supported == EXPECTED_SUPPORTED
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_supported_policies_expose_curated_amount():
|
| 112 |
+
"""Each supported policy's allowed set is exactly {0, curated_amount}."""
|
| 113 |
+
for pid in EXPECTED_SUPPORTED:
|
| 114 |
+
supports, allowed = policy_deductible_support(pid)
|
| 115 |
+
assert supports is True
|
| 116 |
+
amt = int(_curated_deductible(pid))
|
| 117 |
+
assert amt > 0
|
| 118 |
+
assert allowed == sorted({0, amt}), (pid, allowed)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_unknown_or_blank_policy_degrades_safely():
|
| 122 |
+
assert policy_deductible_support(None) == (False, [0])
|
| 123 |
+
assert policy_deductible_support("") == (False, [0])
|
| 124 |
+
assert policy_deductible_support("does-not-exist__nope") == (False, [0])
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ---------------------------------------------------------------------------
|
| 128 |
+
# End-to-end /api/premium/estimate β the user-visible behaviour
|
| 129 |
+
# ---------------------------------------------------------------------------
|
| 130 |
+
|
| 131 |
+
_BASE_REQ = {
|
| 132 |
+
"age": 35,
|
| 133 |
+
"sum_insured_inr": 1_000_000,
|
| 134 |
+
"city_tier": "metro",
|
| 135 |
+
"smoker": False,
|
| 136 |
+
"family_size": 1,
|
| 137 |
+
"pre_existing_conditions": "none",
|
| 138 |
+
"copayment_pct": 0,
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _estimate(policy_id, deductible_inr=None):
|
| 143 |
+
body = dict(_BASE_REQ, policy_id=policy_id)
|
| 144 |
+
if deductible_inr is not None:
|
| 145 |
+
body["deductible_inr"] = deductible_inr
|
| 146 |
+
r = client.post("/api/premium/estimate", json=body)
|
| 147 |
+
assert r.status_code == 200, r.text
|
| 148 |
+
return r.json()
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_unsupported_policy_gets_no_discount_and_honest_echo():
|
| 152 |
+
"""hdfc-ergo__optima-restore is a comprehensive plan with NO curated
|
| 153 |
+
voluntary deductible. Passing deductible_inr=100000 must NOT discount
|
| 154 |
+
the premium β point == the no-deductible result β and the echoed
|
| 155 |
+
deductible_inr must be 0 (honest), with supports flag False."""
|
| 156 |
+
pid = "hdfc-ergo__optima-restore"
|
| 157 |
+
assert policy_deductible_support(pid) == (False, [0])
|
| 158 |
+
|
| 159 |
+
base = _estimate(pid, deductible_inr=0)
|
| 160 |
+
with_ded = _estimate(pid, deductible_inr=100_000)
|
| 161 |
+
|
| 162 |
+
assert with_ded["point_estimate_inr"] == base["point_estimate_inr"]
|
| 163 |
+
assert with_ded["low_inr"] == base["low_inr"]
|
| 164 |
+
assert with_ded["high_inr"] == base["high_inr"]
|
| 165 |
+
assert with_ded["deductible_inr"] == 0
|
| 166 |
+
assert with_ded["supports_voluntary_deductible"] is False
|
| 167 |
+
assert with_ded["allowed_deductibles"] == [0]
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def test_supported_policy_gets_real_discount_and_echo():
|
| 171 |
+
"""bajaj-allianz__health-guard genuinely supports a voluntary
|
| 172 |
+
deductible. A βΉ50,000 deductible must apply the real Γ0.85 discount
|
| 173 |
+
and echo deductible_inr=50000 with supports flag True."""
|
| 174 |
+
pid = "bajaj-allianz__health-guard"
|
| 175 |
+
supports, allowed = policy_deductible_support(pid)
|
| 176 |
+
assert supports is True
|
| 177 |
+
assert 50_000 in allowed
|
| 178 |
+
|
| 179 |
+
base = _estimate(pid, deductible_inr=0)
|
| 180 |
+
discounted = _estimate(pid, deductible_inr=50_000)
|
| 181 |
+
|
| 182 |
+
expected = int(round(base["point_estimate_inr"] * BULK_DEDUCTIBLE_DISCOUNT[50_000]))
|
| 183 |
+
assert discounted["point_estimate_inr"] == expected
|
| 184 |
+
assert discounted["point_estimate_inr"] < base["point_estimate_inr"]
|
| 185 |
+
assert discounted["deductible_inr"] == 50_000
|
| 186 |
+
assert discounted["supports_voluntary_deductible"] is True
|
| 187 |
+
assert 50_000 in discounted["allowed_deductibles"]
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_estimate_response_always_exposes_support_fields():
|
| 191 |
+
"""Even with no deductible in the request the response must carry the
|
| 192 |
+
BUG #29 fields so the widget can decide whether to render the selector."""
|
| 193 |
+
resp = _estimate("hdfc-ergo__optima-restore")
|
| 194 |
+
assert resp["supports_voluntary_deductible"] is False
|
| 195 |
+
assert resp["allowed_deductibles"] == [0]
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# ---------------------------------------------------------------------------
|
| 199 |
+
# bulk_estimate() β slider-driven multi-policy path
|
| 200 |
+
# ---------------------------------------------------------------------------
|
| 201 |
+
|
| 202 |
+
def test_bulk_estimate_forces_no_discount_for_unsupported():
|
| 203 |
+
"""bulk_estimate() must neutralise the deductible discount (Γ1.0) and
|
| 204 |
+
echo deductible_inr=0 for a policy that does not support it."""
|
| 205 |
+
pid = "hdfc-ergo__optima-restore"
|
| 206 |
+
assert policy_deductible_support(pid) == (False, [0])
|
| 207 |
+
|
| 208 |
+
out = bulk_estimate(
|
| 209 |
+
profile={"age": 35, "location_tier": "metro"},
|
| 210 |
+
policy_ids=[pid],
|
| 211 |
+
overrides={pid: {"deductible_inr": 100_000}},
|
| 212 |
+
)
|
| 213 |
+
row = out[pid]
|
| 214 |
+
assert row.deductible_inr == 0
|
| 215 |
+
assert row.breakdown.get("deductible_discount_x", 1.0) == 1.0
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def test_bulk_estimate_applies_discount_for_supported():
|
| 219 |
+
"""A supported policy with a valid deductible still gets the real
|
| 220 |
+
discount + an honest non-zero echo in bulk_estimate()."""
|
| 221 |
+
pid = "bajaj-allianz__health-guard"
|
| 222 |
+
supports, allowed = policy_deductible_support(pid)
|
| 223 |
+
assert supports is True and 50_000 in allowed
|
| 224 |
+
|
| 225 |
+
out = bulk_estimate(
|
| 226 |
+
profile={"age": 35, "location_tier": "metro"},
|
| 227 |
+
policy_ids=[pid],
|
| 228 |
+
overrides={pid: {"deductible_inr": 50_000}},
|
| 229 |
+
)
|
| 230 |
+
row = out[pid]
|
| 231 |
+
assert row.deductible_inr == 50_000
|
| 232 |
+
assert row.breakdown.get("deductible_discount_x") == pytest.approx(
|
| 233 |
+
BULK_DEDUCTIBLE_DISCOUNT[50_000]
|
| 234 |
+
)
|