rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
3fcae97
Β·
1 Parent(s): 91dbc59

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 CHANGED
@@ -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][:limit]
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
- for ovf, pid, data, sig in scored[: max(limit, 25)]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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]
backend/main.py CHANGED
@@ -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 BULK_DEDUCTIBLE_DISCOUNT:
 
 
 
 
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
  )
backend/premium_calculator.py CHANGED
@@ -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
backend/retrieval_filters.py CHANGED
@@ -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)
backend/single_brain.py CHANGED
@@ -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 β€” when existing_cover_inr > 0 add "top-up over existing X lakh cover"; when 0 add "fresh 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,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",
frontend/src/app/page.tsx CHANGED
@@ -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 bits: string[] = [];
4067
- if (s.letter_grade) bits.push(s.letter_grade);
4068
- if (s.value_0_100 != null) bits.push(`${s.value_0_100}/100`);
4069
- if (csr != null) bits.push(`${csr}% claims settled`);
4070
- // If the reviews payload came back but every headline metric is empty,
4071
- // still say so explicitly rather than rendering a blank box.
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
frontend/src/components/PolicyPremiumWidget.tsx CHANGED
@@ -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
- <label style={labelStyle}>
377
- <span style={labelHeadStyle}>
378
- <span style={labelTextStyle}>Deductible</span>
379
- <strong style={labelValueStyle}>
380
- {formatDeductibleLabel(deductibleInr)}
381
- </strong>
382
- </span>
383
- <div role="radiogroup" aria-label="Deductible" style={pillRowStyle}>
384
- {DEDUCTIBLE_CHOICES.map((d) => (
385
- <button
386
- key={d}
387
- type="button"
388
- role="radio"
389
- aria-checked={deductibleInr === d}
390
- onClick={() => setDeductibleInr(d)}
391
- style={pillStyle(deductibleInr === d)}
392
- >
393
- {formatDeductibleLabel(d)}
394
- </button>
395
- ))}
396
- </div>
397
- </label>
 
 
 
 
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">
frontend/src/lib/api.ts CHANGED
@@ -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.
tests/test_bug30_existing_cover_and_promise.py ADDED
@@ -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)
tests/test_deductible_eligibility.py ADDED
@@ -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
+ )