InsuranceBot / backend /scorecard.py
rohitsar567's picture
feat(#31): deterministic profile-aware {strengths,caveat} policy summary on all 3 surfaces
d486a56
Raw
History Blame Contribute Delete
58.8 kB
"""Policy health scorecard β€” turns 48 structured fields into a human-readable
A-F grade with 6 sub-scores.
Why this exists: a buyer reading 48 fields can't tell if it's "good." A
single letter grade + 6 sub-bars + 1-line summary makes the answer obvious.
Inspired by what people like Beli / Ditto have done to simplify insurance.
Score philosophy: optimize for the *buyer*, not the insurer. So:
- Generous coverage, low frictions, predictable claims = higher score
- Heavy waiting periods, copays, sub-limits = lower score
- Regulatory-mandated minimums (IRDAI 30-day initial) don't hurt the score
Each sub-score is 0-100. Overall is a weighted average. Letter grade comes
from ABSOLUTE thresholds, frozen post-recalibration (2026-05-16):
A: β‰₯76, B: β‰₯69, C: β‰₯61, D: β‰₯54, F: <54. See grade_for().
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, Optional
# ----------------------------------------------------------------------------
# FIELD ALIASES β€” the canonical-scorer-key β†’ list-of-acceptable-input-keys map.
#
# Why this exists: the data layer is heterogeneous.
# * 40-data/policy_facts/<insurer>__<product>.json β†’ canonical names
# (max_renewal_age, copayment_pct, day_care_treatments_count, etc.)
# * 40-data/policy_facts/<insurer>__<product>__<doctype>.json β†’ mixed; some
# fields use LLM-extracted aliases like co_payment_pct, room_rent_capped_at_pct_of_si
# * rag/extracted/*.json β†’ pure LLM output with aliases like
# max_renewal_age_years (int), deductible_amount_inr (int),
# day_care_treatments: {covered, limit_inr, limit_text/notes} (dict).
#
# Without aliasing the scorecard reads `None` for every aliased field and the
# 6 sub-scores collapse to their neutral bases β†’ 12 different policies all
# render as 72/100. Aliasing recovers the underlying data spread.
# ----------------------------------------------------------------------------
# NOTE: max_renewal_age was deliberately removed as a scored field. Lifelong
# renewability is the IRDAI norm for health-indemnity products (mandated since
# 2020), so it does not differentiate policies. The old pipeline faked it as
# `max_renewal_age=999` to trigger a now-deleted "lifelong" bonus β€” see
# score_renewal_protection. Do not re-add it to ALIASES or SCORED_FIELDS.
ALIASES: dict[str, list[str]] = {
"max_entry_age": ["max_entry_age", "max_entry_age_years"],
"deductible_amount": ["deductible_amount", "deductible_amount_inr"],
"copayment_pct": ["copayment_pct", "co_payment_pct"],
"day_care_treatments_count": ["day_care_treatments_count", "day_care_treatments"],
"network_hospital_count": ["network_hospital_count", "network_hospital_count_text"],
"room_rent_capping": ["room_rent_capping", "room_rent_capped_at_pct_of_si"],
"pre_existing_disease_waiting_months": ["pre_existing_disease_waiting_months", "ped_waiting_months"],
"initial_waiting_period_days": ["initial_waiting_period_days", "initial_waiting_days"],
"maternity_waiting_months": ["maternity_waiting_months", "maternity_wait_months"],
"pre_hospitalization_days": ["pre_hospitalization_days", "pre_hosp_days"],
"post_hospitalization_days": ["post_hospitalization_days", "post_hosp_days"],
"no_claim_bonus_pct": ["no_claim_bonus_pct", "ncb_pct", "cumulative_bonus_pct"],
"tat_cashless_authorization_hours": ["tat_cashless_authorization_hours", "tat_cashless_hours"],
"claim_settlement_ratio": ["claim_settlement_ratio", "claim_settlement_ratio_pct"],
}
def _pick_alias(p: dict, canonical_key: str):
"""Return the first non-empty value across the alias list for canonical_key.
Treats None, "", [], {} (and dicts whose 'value' is None) as empty.
The {value, source_*} wrapper shape used by curated files is unwrapped.
"""
for alias in ALIASES.get(canonical_key, [canonical_key]):
v = p.get(alias)
# Unwrap the curated {value, source_pdf_path, ...} shape if present.
if isinstance(v, dict) and "value" in v and "covered" not in v and "limit_inr" not in v:
v = v.get("value")
if v is None or v == "" or v == []:
continue
if isinstance(v, dict) and not v:
continue
return v
return None
@dataclass
class SubScore:
name: str
score: int # 0-100
summary: str
signals: list[str] = field(default_factory=list) # short positive/negative bullets
@dataclass
class ProfileSummary:
"""Deterministic, profile-aware replacement for the generic grade
one-liner β€” computed PER (profile Γ— policy) on the SAME pass that
produces the marketplace grade. NO LLM, NO fabricated numbers; every
bullet's underlying fact is read via the SAME _pick_alias/_get/_bool/_int
helpers the scorecard itself uses, so a strength can never assert a value
the grade didn't see. `strengths` is 3–5 bullets (fewer only when fewer
real facts exist β€” never padded); `caveat` is the single most
grade-capping, profile-relevant trade-off in plain language, or None
when the top sub-score carries no negative signal.
"""
strengths: list[str]
caveat: Optional[str] = None
@dataclass
class Scorecard:
policy_id: str
policy_name: str
insurer_slug: str
overall_score: int
grade: str # A, B, C, D, F β€” or "β€”" when insufficient_data is True
one_liner: str
sub_scores: list[SubScore]
data_completeness_pct: float # how many of the scoring fields actually have data
methodology_link: str = "/70-docs/scorecard-methodology.md"
# Deterministic, profile-aware {strengths, caveat} computed on the same
# pass as the grade. None on the insufficient-data branch's empty form
# (ProfileSummary([], None)). Frontends render this at the TOP of every
# scorecard surface and fall back to one_liner when it is empty.
profile_summary: Optional[ProfileSummary] = None
# True when the policy has too little structured data to produce an honest
# grade. The endpoint returns this as a DEFINED HTTP-200 response (not a
# 500 / generic Retry, and NOT a fabricated grade): grade is "β€”",
# overall_score 0, sub_scores empty, one_liner an honest message.
insufficient_data: bool = False
# Below this data-completeness %, a grade would be fabricated from neutral
# bases rather than the policy's real terms (an all-empty dict still scores a
# confident "F"/52 purely from the recalibrated bases). The real catalogue
# floor is 13.0% (~3 of 23 scored fields); this threshold sits well below it
# so NO well-populated policy is ever down-graded to the honest-unknown state
# β€” it only fires for the genuinely-bare case (fewer than ~2 of 23 fields).
MIN_GRADEABLE_COMPLETENESS_PCT = 9.0
# ---- helpers ----
def _get(p: dict, key: str, default: Any = None) -> Any:
v = _pick_alias(p, key)
if v is None:
return default
if isinstance(v, dict) and "covered" in v:
return v.get("covered", default)
return v
def _bool(p: dict, key: str) -> Optional[bool]:
v = _pick_alias(p, key)
if isinstance(v, dict) and "covered" in v:
return v.get("covered")
if isinstance(v, bool):
return v
if isinstance(v, str) and v.lower() in ("yes", "true", "y", "covered"):
return True
if isinstance(v, str) and v.lower() in ("no", "false", "n", "not covered", "excluded"):
return False
return None
_INT_FROM_TEXT_RE = re.compile(r"(\d[\d,]*)")
def _int(p: dict, key: str) -> Optional[int]:
"""Coerce the aliased value into an int. Handles:
* scalar int/float/digit-str
* dict shapes: {limit_inr: N}, {value: N}, {covered, limit_text: "N+ procedures"}
* pure text shapes: "13,000+" or "586+ procedures" (network_hospital_count_text,
day_care_treatments.limit_text/notes)
Returns None if no integer can be recovered.
"""
v = _pick_alias(p, key)
if v is None:
return None
# Dict shapes the LLM/curators use.
if isinstance(v, dict):
for nested_key in ("limit_inr", "value", "pct_of_si", "limit_text", "notes"):
if nested_key in v and v[nested_key] not in (None, ""):
v = v[nested_key]
break
else:
return None
# Now v should be a scalar.
if isinstance(v, bool):
return None # don't let True/False sneak in as 1/0
if isinstance(v, (int, float)):
try:
return int(v)
except (TypeError, ValueError):
return None
if isinstance(v, str):
s = v.strip()
if not s:
return None
# Direct numeric parse first.
try:
return int(float(s.replace(",", "")))
except (TypeError, ValueError):
pass
# Pull the leading integer out of phrases like "586+ procedures" or
# "13,000+ network hospitals".
m = _INT_FROM_TEXT_RE.search(s)
if m:
try:
return int(m.group(1).replace(",", ""))
except (TypeError, ValueError):
return None
return None
def clamp(x: float, lo: int = 0, hi: int = 100) -> int:
return max(lo, min(hi, int(round(x))))
# ---- 6 sub-scores ----
def score_coverage_breadth(p: dict) -> SubScore:
"""How wide is the safety net? AYUSH, day-care, OPD, organ donor, maternity, etc."""
signals_pos: list[str] = []
signals_neg: list[str] = []
s = 40 # true-neutral base (was 50 β€” recalibrated for real spread)
if _bool(p, "ayush_coverage"):
s += 10; signals_pos.append("AYUSH covered")
elif _bool(p, "ayush_coverage") is False:
s -= 6; signals_neg.append("no AYUSH")
dct = _int(p, "day_care_treatments_count")
if dct is not None:
if dct >= 400: s += 14; signals_pos.append(f"{dct} day-care procedures")
elif dct >= 200: s += 8
elif dct >= 100: s += 2
else: s -= 8; signals_neg.append(f"only {dct} day-care procedures")
if _bool(p, "maternity_coverage"):
s += 9; signals_pos.append("maternity covered")
if _bool(p, "newborn_coverage"):
s += 6; signals_pos.append("newborn covered")
if _bool(p, "organ_donor_expenses"):
s += 6; signals_pos.append("organ donor expenses")
if _bool(p, "ambulance_cover"):
s += 5; signals_pos.append("ambulance covered")
if _bool(p, "domiciliary_treatment"):
s += 6
if _bool(p, "preventive_health_checkup"):
s += 5; signals_pos.append("free health checkups")
pre = _int(p, "pre_hospitalization_days") or 0
post = _int(p, "post_hospitalization_days") or 0
if pre >= 60: s += 6; signals_pos.append(f"{pre}d pre-hospitalization")
if post >= 90: s += 6; signals_pos.append(f"{post}d post-hospitalization")
summary = "Wide coverage" if s >= 75 else "Standard coverage" if s >= 55 else "Limited coverage"
return SubScore("Coverage Breadth", clamp(s), summary, signals_pos + [f"βˆ’ {x}" for x in signals_neg])
def score_cost_predictability(p: dict) -> SubScore:
"""How likely are you to face surprise out-of-pocket costs? Copay, room rent caps, sub-limits."""
signals: list[str] = []
s = 60 # true-neutral base (was 75 β€” recalibrated for real spread)
copay = _int(p, "copayment_pct")
if copay is not None:
if copay >= 30: s -= 40; signals.append(f"βˆ’ {copay}% copayment")
elif copay >= 20: s -= 28; signals.append(f"βˆ’ {copay}% copayment")
elif copay >= 10: s -= 15; signals.append(f"βˆ’ {copay}% copayment")
elif copay > 0: s -= 6
else: s += 14; signals.append("0% copayment")
rrc = _pick_alias(p, "room_rent_capping")
rrc_text: Optional[str] = None
if isinstance(rrc, str):
rrc_text = rrc
elif isinstance(rrc, dict):
# Curated nested shape: {pct_of_si, limit_text, ...}
rrc_text = rrc.get("limit_text") or rrc.get("notes")
pct = rrc.get("pct_of_si")
if rrc_text is None and pct is not None:
rrc_text = f"{pct}% of SI"
elif isinstance(rrc, (int, float)):
# room_rent_capped_at_pct_of_si scalar form
rrc_text = f"{rrc}% of SI"
if rrc_text:
rtl = rrc_text.lower()
if "no cap" in rtl or "no monetary" in rtl or "no limit" in rtl or "no room rent" in rtl:
s += 14; signals.append("no room rent cap")
elif "1%" in rrc_text or "%" in rrc_text:
s -= 18; signals.append(f"βˆ’ room rent capped: {rrc_text[:50]}")
deductible = _int(p, "deductible_amount")
if deductible and deductible > 0:
signals.append(f"βˆ’ deductible β‚Ή{deductible:,}")
s -= 12
summary = "Predictable costs" if s >= 75 else "Some out-of-pocket" if s >= 55 else "Material out-of-pocket"
return SubScore("Cost Predictability", clamp(s), summary, signals)
def score_waiting_friction(p: dict) -> SubScore:
"""How long before benefits actually kick in? PED, specific disease, maternity waits."""
signals: list[str] = []
s = 72 # true-neutral base (was 90 β€” recalibrated for real spread)
ped = _int(p, "pre_existing_disease_waiting_months")
if ped is not None:
if ped >= 48: s -= 42; signals.append(f"βˆ’ {ped}mo PED waiting (long)")
elif ped >= 36: s -= 25; signals.append(f"βˆ’ {ped}mo PED waiting")
elif ped >= 24: s -= 10; signals.append(f"βˆ’ {ped}mo PED waiting")
else: s += 14; signals.append(f"{ped}mo PED waiting (short)")
mw = _int(p, "maternity_waiting_months")
if mw is not None:
if mw >= 48: s -= 10; signals.append(f"βˆ’ {mw}mo maternity waiting")
elif mw >= 24: s -= 4
iw = _int(p, "initial_waiting_period_days")
# 30 days is IRDAI-mandated minimum; don't penalize
if iw is not None and iw > 60: s -= 8; signals.append(f"βˆ’ {iw}d initial waiting")
summary = "Quick activation" if s >= 75 else "Standard waits" if s >= 55 else "Heavy waiting periods"
return SubScore("Waiting-Period Friction", clamp(s), summary, signals)
def score_claim_experience(p: dict, insurer_reviews: Optional[dict] = None) -> SubScore:
"""Will claims actually be paid? Network size, settlement ratio, cashless support.
Now also uses INSURER-LEVEL data from 40-data/reviews/<slug>.json β€” the IRDAI
Annual Report claim_settlement_ratio + complaints_per_10k_policies feed
directly into this sub-score. If insurer_reviews is None, falls back to
per-policy fields only (which are usually null in extraction).
"""
signals: list[str] = []
s = 45 # true-neutral base (was 60 β€” recalibrated for real spread)
if _bool(p, "cashless_treatment_supported"):
s += 18; signals.append("cashless supported")
elif _bool(p, "cashless_treatment_supported") is False:
s -= 12; signals.append("βˆ’ no cashless")
nh = _int(p, "network_hospital_count")
if nh is not None:
if nh >= 10000: s += 18; signals.append(f"{nh:,}+ network hospitals")
elif nh >= 5000: s += 10; signals.append(f"{nh:,} network hospitals")
elif nh < 2000: s -= 12; signals.append(f"βˆ’ only {nh} network hospitals")
# Prefer insurer-level IRDAI data (always present + authoritative) over
# per-policy claim_settlement_ratio (usually null in extraction).
csr_val = None
if insurer_reviews:
cm = insurer_reviews.get("claim_metrics", {})
csr_val = cm.get("claim_settlement_ratio_pct")
cpk = cm.get("complaints_per_10k_policies")
if csr_val is not None:
if csr_val >= 95: s += 20; signals.append(f"{csr_val:.1f}% CSR (IRDAI {cm.get('claim_settlement_ratio_year','')})")
elif csr_val >= 90: s += 12; signals.append(f"{csr_val:.1f}% CSR")
elif csr_val >= 85: s += 5; signals.append(f"{csr_val:.1f}% CSR")
elif csr_val >= 75: s -= 6; signals.append(f"βˆ’ {csr_val:.1f}% CSR")
else: s -= 20; signals.append(f"βˆ’ {csr_val:.1f}% CSR (low)")
if cpk is not None:
if cpk <= 10: s += 8; signals.append(f"{cpk}/10K complaints (low)")
elif cpk <= 25: s += 0
elif cpk <= 45: s -= 8; signals.append(f"βˆ’ {cpk}/10K complaints (above avg)")
else: s -= 16; signals.append(f"βˆ’ {cpk}/10K complaints (high)")
else:
# Fallback to per-policy. The curated `{value, source_*}` wrapper is
# unwrapped via _pick_alias; the `_pct` LLM-extracted variant is also
# tried.
csr = _pick_alias(p, "claim_settlement_ratio")
if isinstance(csr, dict):
csr = csr.get("value")
try:
csr_val = float(csr) if csr is not None else None
if csr_val is None:
pass
elif csr_val >= 95: s += 18; signals.append(f"{csr_val:.1f}% claim settlement ratio")
elif csr_val >= 90: s += 10; signals.append(f"{csr_val:.1f}% CSR")
elif csr_val >= 85: s += 4; signals.append(f"{csr_val:.1f}% CSR")
elif csr_val < 75: s -= 20; signals.append(f"βˆ’ {csr_val:.1f}% CSR (low)")
except (TypeError, ValueError):
pass
tat = _int(p, "tat_cashless_authorization_hours")
if tat is not None and tat <= 2:
s += 6; signals.append(f"{tat}h cashless TAT")
summary = "Smooth claims" if s >= 75 else "Standard claim experience" if s >= 55 else "Friction risk on claims"
return SubScore("Claim Experience", clamp(s), summary, signals)
def score_renewal_protection(p: dict) -> SubScore:
"""Can you stay covered as you age?
Lifelong renewability is the IRDAI norm for health-indemnity products
(mandated since 2020) and therefore does NOT differentiate policies β€” it
is intentionally NOT scored. This is also why the old `max_renewal_age`
field was removed entirely: it was a non-differentiator that the
extraction LLM faked as 999 to trigger a (now-deleted) "lifelong" bonus,
corrupting 137 grades. What still genuinely varies between products is the
maximum *entry* age β€” how late a first-time buyer can take the policy β€”
so that is the sole driver of this sub-score.
"""
signals: list[str] = ["Lifelong renewability guaranteed"]
s = 50 # true-neutral base (was 60 β€” recalibrated for real spread)
maxe = _int(p, "max_entry_age")
if maxe is not None:
if maxe >= 65: s += 25; signals.append(f"entry up to {maxe}")
elif maxe >= 55: s += 12; signals.append(f"entry up to {maxe}")
elif maxe >= 50: s += 0
else: s -= 20; signals.append(f"βˆ’ entry only up to {maxe}")
summary = "Future-proof" if s >= 75 else "Adequate" if s >= 55 else "Limited entry-age band"
return SubScore("Renewal Protection", clamp(s), summary, signals)
def score_bonuses(p: dict) -> SubScore:
"""No-claim bonuses, restoration, health checkups β€” sweeteners for loyal buyers."""
signals: list[str] = []
s = 38 # true-neutral base (was 50 β€” recalibrated for real spread)
ncb = _int(p, "no_claim_bonus_pct")
if ncb is not None:
if ncb >= 100: s += 35; signals.append(f"{ncb}% NCB step-up")
elif ncb >= 50: s += 20; signals.append(f"{ncb}% NCB")
elif ncb >= 25: s += 10
else: s -= 8
rb = _pick_alias(p, "restoration_benefit")
if isinstance(rb, dict):
# {covered, limit_text} or {value: "..."}
rb = rb.get("limit_text") or rb.get("value") or (
"restoration available" if rb.get("covered") else None
)
if rb and isinstance(rb, str) and len(rb) > 5:
s += 18; signals.append(f"restoration benefit: {rb[:50]}")
elif rb is True:
s += 18; signals.append("restoration benefit included")
if _bool(p, "preventive_health_checkup"):
s += 10; signals.append("free preventive checkup")
summary = "Generous bonuses" if s >= 75 else "Standard sweeteners" if s >= 55 else "Few extras"
return SubScore("Bonus & Loyalty", clamp(s), summary, signals)
# ---- aggregate + grade ----
# Weights reflect what affects the buyer's real-world experience most.
WEIGHTS = {
"Coverage Breadth": 0.22,
"Cost Predictability": 0.20,
"Waiting-Period Friction": 0.18,
"Claim Experience": 0.20,
"Renewal Protection": 0.12,
"Bonus & Loyalty": 0.08,
}
# ----------------------------------------------------------------------------
# METHODOLOGY BLUEPRINT β€” the buyer-facing transparency layer
# ----------------------------------------------------------------------------
# Maps each of the 6 sub-scores to:
# - the consumer rationale (why this matters in plain English)
# - the concrete policy fields that drive its score (subset of the 48-field
# HealthPolicy schema)
# - the regulatory / industry anchors that justify the weight
# Used by /api/scorecard/methodology to render a customer-centric explanation
# of how the headline number is computed.
#
# GRADING: the weighted 0-100 overall maps to an ABSOLUTE letter grade with
# frozen cutoffs A β‰₯ 76 / B β‰₯ 69 / C β‰₯ 61 / D β‰₯ 54 / F < 54 (see grade_for).
# A policy's grade does not move as the catalogue changes.
#
# FIXED-BENEFIT PRODUCTS: hospital-cash, personal-accident, critical-illness
# and cancer plans are scored ONLY on the sub-scores that apply to them β€”
# Claim Experience, Renewal Protection and Bonus & Loyalty. The three
# indemnity-only sub-scores (Coverage Breadth, Cost Predictability,
# Waiting-Period Friction) are dropped and the remaining weights renormalised.
#
# Every "+N / βˆ’N / threshold" string below is derived directly from the
# recalibrated sub-score functions above β€” they must stay byte-for-byte
# faithful to the code so the in-app methodology endpoint never lies.
METHODOLOGY_BLUEPRINT = [
{
"name": "Coverage Breadth",
"weight_pct": 22,
"consumer_question": "When I actually need to claim, what's covered vs what's not?",
"why_it_matters": (
"Determines whether your hospital bill is fully reimbursed or whether you pay "
"out-of-pocket for gaps like AYUSH, maternity, newborn care, or ambulance."
),
"fields_driving_score": [
{"field": "ayush_coverage", "rule": "Covered β†’ +10, explicitly not covered β†’ βˆ’6"},
{"field": "day_care_treatments_count", "rule": "β‰₯400 procedures β†’ +14, β‰₯200 β†’ +8, β‰₯100 β†’ +2, <100 β†’ βˆ’8"},
{"field": "maternity_coverage", "rule": "Covered β†’ +9"},
{"field": "newborn_coverage", "rule": "Covered β†’ +6"},
{"field": "organ_donor_expenses", "rule": "Covered β†’ +6"},
{"field": "ambulance_cover", "rule": "Covered β†’ +5"},
{"field": "domiciliary_treatment", "rule": "Covered β†’ +6"},
{"field": "preventive_health_checkup", "rule": "Free β†’ +5"},
{"field": "pre_hospitalization_days", "rule": "β‰₯60 days β†’ +6"},
{"field": "post_hospitalization_days", "rule": "β‰₯90 days β†’ +6"},
],
"anchors": [
"IRDAI Health Insurance Master Circular 2024 β€” emphasises comprehensive cover",
"Acko buying guide: coverage breadth most-cited buyer concern",
],
},
{
"name": "Cost Predictability",
"weight_pct": 20,
"consumer_question": "Will I face surprise bills I can't plan for?",
"why_it_matters": (
"Co-pay forces you to pay a % of every claim and is the single biggest "
"predictability lever; room-rent capping reduces what gets reimbursed; an "
"up-front deductible is money you pay before cover starts. These convert a "
"known sum-insured into an unpredictable out-of-pocket exposure."
),
"fields_driving_score": [
{"field": "copayment_pct", "rule": "0% β†’ +14, >0–<10% β†’ βˆ’6, 10% β†’ βˆ’15, 20% β†’ βˆ’28, 30%+ β†’ βˆ’40"},
{"field": "room_rent_capping", "rule": "No cap / no limit β†’ +14, any % cap β†’ βˆ’18"},
{"field": "deductible_amount", "rule": "Any deductible > β‚Ή0 β†’ βˆ’12"},
],
"anchors": [
"IRDAI Master Circular β€” disclosure norms on co-pay/sub-limits",
"Common consumer complaint themes (IRDAI complaint logs)",
],
},
{
"name": "Waiting-Period Friction",
"weight_pct": 18,
"consumer_question": "How soon can I actually use this policy if something happens?",
"why_it_matters": (
"Initial waiting period (the IRDAI-mandated 30-day minimum is never "
"penalised), pre-existing-disease waiting (commonly 24–48 months), and "
"maternity waits delay claims. Shorter is better β€” especially for older "
"buyers or those with diabetes/hypertension."
),
"fields_driving_score": [
{"field": "initial_waiting_period_days", "rule": "≀60 days β†’ 0 (30-day IRDAI minimum not penalised), >60 days β†’ βˆ’8"},
{"field": "pre_existing_disease_waiting_months", "rule": "<24mo β†’ +14, 24–35mo β†’ βˆ’10, 36–47mo β†’ βˆ’25, β‰₯48mo β†’ βˆ’42"},
{"field": "maternity_waiting_months", "rule": "<24mo β†’ +0, 24–47mo β†’ βˆ’4, β‰₯48mo β†’ βˆ’10"},
],
"anchors": [
"IRDAI standard product specifications (Arogya Sanjeevani UIN guideline: 36-month PED max)",
"PolicyBazaar comparison data: 24-month PED is the buyer benchmark",
],
},
{
"name": "Claim Experience",
"weight_pct": 20,
"consumer_question": "Will the insurer actually pay when I claim?",
"why_it_matters": (
"Coverage on paper means nothing if claims get denied or take weeks. We measure "
"cashless network reach, IRDAI's published Claim Settlement Ratio (CSR), the "
"complaint count per 10,000 policies, and how fast cashless pre-auth happens."
),
"fields_driving_score": [
{"field": "cashless_treatment_supported", "rule": "Yes β†’ +18, explicitly no β†’ βˆ’12"},
{"field": "network_hospital_count", "rule": "β‰₯10,000 β†’ +18, β‰₯5,000 β†’ +10, <2,000 β†’ βˆ’12"},
{"field": "claim_settlement_ratio (IRDAI)", "rule": "β‰₯95% β†’ +20, 90–94% β†’ +12, 85–89% β†’ +5, 75–84% β†’ βˆ’6, <75% β†’ βˆ’20"},
{"field": "complaints_per_10k_policies (IRDAI)", "rule": "≀10 β†’ +8, 11–25 β†’ +0, 26–45 β†’ βˆ’8, >45 β†’ βˆ’16"},
{"field": "tat_cashless_authorization_hours", "rule": "≀2h β†’ +6"},
],
"anchors": [
"IRDAI Annual Report 2023-24 β€” published CSR per insurer",
"IRDAI Grievance Redressal handbook β€” complaints/10K is the regulator's own metric",
],
},
{
"name": "Renewal Protection",
"weight_pct": 12,
"consumer_question": "Can I still take this policy if I'm buying late in life?",
"why_it_matters": (
"Lifelong renewability is mandated by IRDAI for every health-indemnity "
"product (since 2020), so it is universal and intentionally NOT scored β€” "
"scoring a constant just adds noise. What still varies is the maximum "
"ENTRY age: a policy that stops accepting new buyers at 50 is useless to a "
"55-year-old first-timer, while one open to 65+ keeps more buyers eligible."
),
"fields_driving_score": [
{"field": "max_entry_age", "rule": "β‰₯65 β†’ +25, 55–64 β†’ +12, 50–54 β†’ +0, <50 β†’ βˆ’20"},
{"field": "(lifelong renewability)", "rule": "IRDAI-universal mandate β€” shown for transparency, NOT scored (scoring a constant only adds noise)"},
],
"anchors": [
"IRDAI Master Circular 2024 β€” lifelong renewability mandate (universal β†’ not a differentiator)",
"IRDAI Portability Regulations 2020",
],
},
{
"name": "Bonus & Loyalty",
"weight_pct": 8,
"consumer_question": "What do I get for staying claim-free and renewing year after year?",
"why_it_matters": (
"Claim-free years should compound value: a 100%+ No-Claim Bonus step-up is "
"rewarded heaviest, and restoring the sum insured on exhaustion is a major "
"sweetener. Free annual health checkups are the lowest-hanging benefit most "
"buyers don't realise they have."
),
"fields_driving_score": [
{"field": "no_claim_bonus_pct", "rule": "β‰₯100% β†’ +35, 50–99% β†’ +20, 25–49% β†’ +10, <25% β†’ βˆ’8"},
{"field": "restoration_benefit", "rule": "Present β†’ +18"},
{"field": "preventive_health_checkup", "rule": "Free annually β†’ +10"},
],
"anchors": [
"IRDAI 'Cumulative Bonus' rules β€” capped at 100% under standard products",
"Industry NCB best-practice (PolicyBazaar comparison standards)",
],
},
]
def grade_for(score: int) -> tuple[str, str]:
"""Return (letter, one-line summary tone).
Thresholds re-fitted (2026-05-16) to the realized post-recalibration
distribution (range ~50–83, mean ~66, stdev ~7.7). The old 85/70/55/40
cutoffs were set for a compressed 64–86 distribution and forced ~90% of
policies to "B" regardless of quality β€” the exact bug being fixed. These
are ABSOLUTE cutoffs (a policy's grade does not change as the catalogue
changes); they were derived from the distribution once and frozen.
"""
if score >= 76: return "A", "Strong all-rounder β€” solid pick for the buyer."
if score >= 69: return "B", "Good policy with a few notable gaps."
if score >= 61: return "C", "A decent baseline β€” review the trade-offs before you decide."
if score >= 54: return "D", "Material concerns β€” only suitable for specific use-cases."
return "F", "Significant gaps β€” alternative options are likely better."
# Fields the scorecard touches β€” used to compute data_completeness_pct
SCORED_FIELDS = [
"ayush_coverage", "day_care_treatments_count", "maternity_coverage",
"newborn_coverage", "organ_donor_expenses", "ambulance_cover",
"domiciliary_treatment", "preventive_health_checkup",
"pre_hospitalization_days", "post_hospitalization_days",
"copayment_pct", "room_rent_capping", "deductible_amount",
"pre_existing_disease_waiting_months", "maternity_waiting_months",
"initial_waiting_period_days",
"cashless_treatment_supported", "network_hospital_count",
"claim_settlement_ratio", "tat_cashless_authorization_hours",
"max_entry_age", # max_renewal_age removed: lifelong is the IRDAI norm
"no_claim_bonus_pct", "restoration_benefit",
]
def compute_data_completeness(p: dict) -> float:
filled = 0
for k in SCORED_FIELDS:
v = _pick_alias(p, k)
if v is None or v == "" or v == []:
continue
if isinstance(v, dict) and v.get("covered") is None \
and not v.get("limit_inr") and not v.get("limit_text") \
and not v.get("notes") and v.get("value") in (None, "", []):
continue
filled += 1
return round(filled / max(1, len(SCORED_FIELDS)) * 100, 1)
def _profile_tuned_weights(profile: Optional[dict]) -> dict[str, float]:
"""Return a per-sub-score weight dict adapted to the buyer profile.
Every signal we collect should MOVE the weighting β€” collecting input and
then ignoring it is wasted attention. The weights re-normalise to 1.0 at
the end. Each adjustment is small (typically Β±0.02–0.06) so accumulated
drift never crosses the validity boundary of the rules.
Audit trail per delta is in 70-docs/scorecard-methodology.md Β§6 (knowledge
graph: profile-field β†’ weight-shift table).
"""
if not profile:
return WEIGHTS
w = dict(WEIGHTS)
# ---- AGE ----
age = profile.get("age")
if isinstance(age, int):
if age < 30:
w["Waiting-Period Friction"] += 0.04 # PED + maternity waits hit hardest
w["Claim Experience"] += 0.02
w["Renewal Protection"] -= 0.04
w["Bonus & Loyalty"] -= 0.02
elif age >= 50:
w["Renewal Protection"] += 0.06 # can I keep it past 70?
w["Claim Experience"] += 0.02 # actually getting paid matters more
w["Bonus & Loyalty"] -= 0.04
w["Waiting-Period Friction"] -= 0.04
# ---- DEPENDENTS ----
# Family signals push two specific dials per task spec:
# maternity coverage -> sits inside Coverage Breadth
# room-rent capping -> sits inside Cost Predictability
# Both go UP when a spouse / kid is on the policy because multi-occupant
# families absorb sub-limit pain harder than singles.
#
# We DON'T let dependents pull Renewal Protection or Claim Experience
# downward when the buyer is already in the senior bracket β€” for a 55+
# buyer with a family, both renewal lock-in and claim reliability matter
# MORE, not less. Earlier versions had the family penalty silently cancel
# the age boost, so senior+family ended up with renewal-weight BELOW
# default. Now the family discount applies only to younger buyers.
deps = (profile.get("dependents") or "").lower()
is_senior = isinstance(age, int) and age >= 50
if any(k in deps for k in ("kid", "child")):
w["Coverage Breadth"] += 0.03 # paediatric + day-care + immunisation
w["Cost Predictability"] += 0.02 # room-rent caps hurt families more
w["Bonus & Loyalty"] -= 0.03 # de-emphasise sweeteners
if not is_senior:
w["Renewal Protection"] -= 0.02
if any(k in deps for k in ("spouse", "wife", "husband", "partner")):
w["Coverage Breadth"] += 0.03 # maternity becomes relevant
w["Cost Predictability"] += 0.02 # room-rent cap matters when both hospitalise
w["Waiting-Period Friction"] += 0.02 # maternity 36mo wait matters
w["Bonus & Loyalty"] -= 0.04
if not is_senior:
w["Renewal Protection"] -= 0.03
if profile.get("parents_to_insure") or "parent" in deps:
w["Coverage Breadth"] += 0.04
w["Claim Experience"] += 0.04 # network matters more for elderly access
w["Bonus & Loyalty"] -= 0.04
w["Cost Predictability"] -= 0.04
# Older parents with PED β†’ renewal+claim become survival metrics
if profile.get("parents_has_ped") or profile.get("parents_age_max", 0) >= 65:
w["Renewal Protection"] += 0.04
w["Waiting-Period Friction"] += 0.02
w["Bonus & Loyalty"] -= 0.04
w["Cost Predictability"] -= 0.02
# ---- EXISTING COVER ----
existing = profile.get("existing_cover_inr")
if isinstance(existing, int) and existing > 0:
# Already has cover β†’ super-top-up territory; cost predictability less
# critical, claim experience more (you only need this when claim hits big)
w["Cost Predictability"] -= 0.03
w["Claim Experience"] += 0.03
elif existing == 0:
# First-time buyer β†’ predictable bill + simple terms matter most
w["Cost Predictability"] += 0.03
w["Coverage Breadth"] += 0.02
w["Bonus & Loyalty"] -= 0.03
w["Waiting-Period Friction"] -= 0.02
# ---- PRIMARY GOAL ----
goal = (profile.get("primary_goal") or "").lower()
if "tax" in goal:
w["Cost Predictability"] += 0.02 # premium is the tax-deduction itself
w["Bonus & Loyalty"] -= 0.02
if "upgrade" in goal:
w["Coverage Breadth"] += 0.03 # whole point of upgrading
w["Renewal Protection"] += 0.02
w["Bonus & Loyalty"] -= 0.05
if "compare" in goal or "specific" in goal:
# User already knows what they want β€” flatten weights, defer to facts
for k in w:
w[k] = 0.95 * w[k] + 0.05 * (1.0 / 6)
# ---- HEALTH CONDITIONS ----
conditions = profile.get("health_conditions") or []
if isinstance(conditions, list) and conditions:
condition_str = " ".join(str(c).lower() for c in conditions)
if any(c in condition_str for c in ("diab", "bp", "hyper", "thyroid", "heart", "cancer", "asthma")):
# Pre-existing β†’ PED waiting is the most important thing in the universe
w["Waiting-Period Friction"] += 0.06
w["Claim Experience"] += 0.03 # PED claim disputes are common
w["Bonus & Loyalty"] -= 0.04
w["Cost Predictability"] -= 0.03
w["Renewal Protection"] -= 0.02
# ---- BUDGET ----
budget = profile.get("budget_band")
if budget in ("under_15k", "15k_30k"):
w["Cost Predictability"] += 0.04 # every rupee counts
w["Bonus & Loyalty"] -= 0.02
w["Waiting-Period Friction"] -= 0.02
elif budget == "60k+":
# High budget β†’ comprehensive coverage + best claim experience matter
w["Coverage Breadth"] += 0.02
w["Claim Experience"] += 0.02
w["Cost Predictability"] -= 0.04
# ---- INCOME ----
income = profile.get("income_band")
if income == "under_5L":
w["Cost Predictability"] += 0.03
w["Bonus & Loyalty"] -= 0.03
elif income in ("10L-25L", "25L+"):
w["Coverage Breadth"] += 0.02
w["Claim Experience"] += 0.02
w["Cost Predictability"] -= 0.04
# ---- LOCATION ----
loc = profile.get("location_tier")
if loc in ("tier2", "tier3"):
# Smaller city β†’ network density + cashless TAT critical
w["Claim Experience"] += 0.04
w["Coverage Breadth"] -= 0.02
w["Bonus & Loyalty"] -= 0.02
elif loc == "metro":
# Metros have hospital depth β†’ coverage breadth differentiates
w["Coverage Breadth"] += 0.02
w["Claim Experience"] -= 0.02
# Clamp + normalise (no weight should go below 5%)
for k in w:
if w[k] < 0.05:
w[k] = 0.05
total = sum(w.values())
return {k: v / total for k, v in w.items()}
def profile_completeness(profile: Optional[dict]) -> float:
"""0.0–1.0 measure of how much we know about the buyer.
Aligned with the `_REQUIRED_FOR_READY` 7-slot list (see brain_tools.py
+ single_brain.py) so this measure agrees with the brain's "ready to
recommend" gate.
The 7 slots: name, age, dependents, location_tier, income_band,
primary_goal, health_conditions. `name` is the identifier; the other 6
are decision-critical for retrieval. Existing-cover and budget-band are
captured opportunistically but are NOT required to recommend.
Used by the frontend to GATE the personalized scorecard view β€” until
completeness >= 0.6, we show insurer-level metrics (CSR, complaints β€”
universal) but suppress the per-user grade since it's meaningless without
knowing who's buying.
"""
if not profile:
return 0.0
# Weights align with Path B _REQUIRED_FOR_READY. Sum = 1.0.
weights = {
"age": 0.20,
"dependents": 0.17,
"income_band": 0.16,
"primary_goal": 0.15,
"location_tier": 0.14,
"health_conditions": 0.13,
"name": 0.05,
}
total = 0.0
for field_name, weight in weights.items():
v = profile.get(field_name)
if v is None:
continue
if isinstance(v, (list, str)) and len(v) == 0:
continue
total += weight
return round(total, 2)
# Sub-scores that only make sense for an indemnity (hospitalisation-reimbursement)
# product. For fixed-benefit products (hospital daily cash, personal accident,
# critical-illness, cancer) these fields genuinely don't exist β€” judging such a
# product on them drags every one to the neutral base and re-creates the
# "everything is B" collapse. So they're dropped and the remaining weights
# renormalised, scoring the product on what actually applies to it.
_INDEMNITY_ONLY = {"Coverage Breadth", "Cost Predictability", "Waiting-Period Friction"}
_FIXED_BENEFIT_RE = re.compile(
r"hospital[\s_-]*cash|hospi[\s_-]*cash|daily[\s_-]*cash|personal[\s_-]*accident|"
r"critical[\s_-]*illness|criti[\s_-]*(?:care|medicare)|\bcancer\b|wellsurance|"
r"hospi[\s_-]*care",
re.I,
)
def _is_fixed_benefit(policy: dict) -> bool:
pt = _pick_alias(policy, "policy_type_indemnity_or_fixed")
if pt is None:
pt = policy.get("policy_type")
if isinstance(pt, dict):
pt = pt.get("value")
if isinstance(pt, str) and any(k in pt.lower() for k in ("fixed", "benefit", "defined")):
return True
blob = f"{policy.get('policy_id','')} {policy.get('policy_name','')}".lower()
return bool(_FIXED_BENEFIT_RE.search(blob))
def _safe_sub(fn, name: str, *args, **kwargs) -> SubScore:
"""Run a sub-score function but NEVER let a malformed/unexpected input
crash the whole scorecard.
The helpers (_pick_alias / _int / _bool) already degrade missing values to
the neutral base β€” that is the intended N/A-reweight behaviour. This wrap
is the last line of defence for a genuinely unexpected input shape (e.g. a
curated field that is a list where a scalar is assumed): instead of the
endpoint 500-ing for a catalogued policy, the affected sub-score falls
back to its neutral base and the rest of the card still computes. This is
a degrade-to-unknown, consistent with the existing N/A design β€” it does
NOT invent data and does NOT change any well-populated policy's grade
(those never hit this path).
"""
try:
return fn(*args, **kwargs)
except Exception as e: # pragma: no cover - defensive; no current input hits it
return SubScore(name, NEUTRAL_BASE.get(name, 50), "Not enough data to assess",
[f"βˆ’ could not assess ({type(e).__name__})"])
# Neutral base each sub-score falls back to so _safe_sub stays byte-faithful
# to the recalibrated bases in the functions above.
NEUTRAL_BASE = {
"Coverage Breadth": 40,
"Cost Predictability": 60,
"Waiting-Period Friction": 72,
"Claim Experience": 45,
"Renewal Protection": 50,
"Bonus & Loyalty": 38,
}
# ----------------------------------------------------------------------------
# Profile-aware {strengths, caveat} summary β€” deterministic, pure, no LLM.
# ----------------------------------------------------------------------------
# Replaces the generic grade one-liner ("Good policy with a few notable
# gaps.") with a profile-aware, deterministically-derived list of concrete
# strengths plus the single most grade-capping trade-off. Every fact a
# strength asserts is read via the SAME _pick_alias / _get / _bool / _int
# helpers the score itself uses, so a strength can never claim a value the
# grade didn't see (non-fabrication invariant). No randomness, no time, no
# network, no LLM: same (policy, profile) β‡’ byte-identical output.
# Tie-break order for equal-materiality strength candidates. A candidate's
# rank is (base_materiality + profile_boost, -TIE_BREAK_ORDER.index(id)) so a
# higher-materiality bullet always wins and equal ones fall in this fixed
# editorial order. Listed best-first.
_STRENGTH_TIE_BREAK = [
"zero_copay",
"high_csr",
"ped_short",
"no_room_rent_cap",
"voluntary_deductible",
"restore",
"big_network",
"ncb",
"si_headroom",
"ayush",
"maternity",
"tax_80d",
]
def build_profile_summary(
policy: dict,
subs: list[SubScore],
weights: dict[str, float],
profile: Optional[dict],
insurer_reviews: Optional[dict] = None,
) -> ProfileSummary:
"""Deterministic, profile-aware {strengths, caveat}.
STEP A β€” candidate strengths: a bullet is emitted ONLY if the underlying
fact genuinely exists on `policy` (read via the score's own helpers). Each
candidate carries (base_materiality:int, profile_boost:int); the final set
is the top 5 by (base+boost) desc with the fixed _STRENGTH_TIE_BREAK
order resolving ties. Never padded β€” if fewer than 3 real facts exist we
emit fewer (and the caller falls back to one_liner).
STEP B β€” caveat: the most grade-capping, profile-relevant sub-score is
argmax over `subs` of weights[name] * (100 - score). Its FIRST negative
signal (an element starting with "βˆ’ ", U+2212 + space) is stripped and
mapped to plain language deterministically. If the top sub has no negative
signal the caveat is None. The caveat NEVER invents or contradicts β€” it
always derives from a signal literally present in some sub.signals.
"""
if not isinstance(policy, dict):
policy = {}
prof = profile or {}
# --- STEP A: candidate strengths --------------------------------------
# candidates: list of (strength_id, base_materiality, profile_boost, text)
candidates: list[tuple[str, int, int, str]] = []
health = prof.get("health_conditions") or []
fam_hist = prof.get("family_medical_history") or []
deps = str(prof.get("dependents") or "").lower()
existing = prof.get("existing_cover_inr")
goal = str(prof.get("primary_goal") or "").lower()
has_spouse = any(k in deps for k in ("spouse", "wife", "husband", "partner"))
has_health_signal = bool(
(isinstance(health, list) and health) or (isinstance(fam_hist, list) and fam_hist)
)
# zero co-pay β€” copayment_pct == 0 (the score awards +14 here)
copay = _int(policy, "copayment_pct")
if copay is not None and copay == 0:
# No numerals in this string by design β€” "0% co-pay" would re-quote
# the policy field, but the *absence* of a co-pay is the point; a
# numeral here would also trip the non-fabrication numeric audit.
txt = "No co-payment β€” the insurer pays the full approved claim"
if isinstance(prof.get("copay_pct"), int) and prof.get("copay_pct") == 0:
txt += " (your stated preference)"
candidates.append(("zero_copay", 30, 6, txt))
# voluntary deductible β€” authoritative gate; lazy import (no cycle)
pid = policy.get("policy_id", "") or ""
try:
from backend.premium_calculator import policy_deductible_support
_ded_ok = policy_deductible_support(pid)[0] is True
except Exception: # noqa: BLE001 β€” never let pricing internals break the card
_ded_ok = False
if _ded_ok:
ded = _int(policy, "deductible_amount")
if ded and ded > 0:
txt = (
f"Optional β‚Ή{ded:,} voluntary deductible you can choose to "
"lower the premium"
)
else:
txt = "Offers an optional voluntary deductible to lower the premium"
boost = 7 if (isinstance(existing, int) and existing > 0) else 0
candidates.append(("voluntary_deductible", 16, boost, txt))
# PED short β€” pre_existing_disease_waiting_months <= 24 (score +14)
ped = _int(policy, "pre_existing_disease_waiting_months")
if ped is not None and ped <= 24:
txt = (
f"Pre-existing conditions covered after only {ped} months β€” "
"short waiting period"
)
boost = 8 if has_health_signal else 0
candidates.append(("ped_short", 24, boost, txt))
# restoration benefit
rb = _pick_alias(policy, "restoration_benefit")
if isinstance(rb, dict):
rb = rb.get("limit_text") or rb.get("value") or (
"restoration available" if rb.get("covered") else None
)
if (isinstance(rb, str) and len(rb) > 5) or rb is True:
candidates.append((
"restore", 18, 0,
"Sum insured is restored if you exhaust it during the year",
))
# no room-rent cap
rrc = _pick_alias(policy, "room_rent_capping")
rrc_text: Optional[str] = None
if isinstance(rrc, str):
rrc_text = rrc
elif isinstance(rrc, dict):
rrc_text = rrc.get("limit_text") or rrc.get("notes")
pct = rrc.get("pct_of_si")
if rrc_text is None and pct is not None:
rrc_text = f"{pct}% of SI"
elif isinstance(rrc, (int, float)):
rrc_text = f"{rrc}% of SI"
if rrc_text and any(
k in rrc_text.lower()
for k in ("no cap", "no monetary", "no limit", "no room rent")
):
candidates.append((
"no_room_rent_cap", 16, 0,
"No room-rent cap β€” stay in any room category without a deduction",
))
# high CSR β€” insurer-level IRDAI metric (>= 90%)
if insurer_reviews:
cm = insurer_reviews.get("claim_metrics", {}) or {}
csr = cm.get("claim_settlement_ratio_pct")
yr = cm.get("claim_settlement_ratio_year", "")
try:
csr_v = float(csr) if csr is not None else None
except (TypeError, ValueError):
csr_v = None
if csr_v is not None and csr_v >= 90:
yr_txt = f" (IRDAI {yr})" if yr else " (IRDAI)"
candidates.append((
"high_csr", 22, 0,
f"{csr_v:.1f}% of claims settled{yr_txt}",
))
# maternity β€” only relevant when a spouse/partner is on the policy
if has_spouse and _bool(policy, "maternity_coverage"):
mw = _int(policy, "maternity_waiting_months")
if mw is not None:
txt = f"Maternity covered (after a {mw}-month wait) β€” relevant to your spouse"
else:
txt = "Maternity covered β€” relevant to your spouse"
candidates.append(("maternity", 14, 4, txt))
# big network
nh = _int(policy, "network_hospital_count")
if nh is not None and nh >= 10000:
candidates.append((
"big_network", 14, 0,
f"{nh:,}+ cashless network hospitals",
))
# NCB step-up
ncb = _int(policy, "no_claim_bonus_pct")
if ncb is not None and ncb >= 50:
candidates.append((
"ncb", 12, 0,
f"{ncb}% no-claim bonus builds up your cover for claim-free years",
))
# SI headroom β€” max entry age (the field that actually drives renewal)
maxe = _int(policy, "max_entry_age")
if maxe is not None and maxe >= 65:
candidates.append((
"si_headroom", 10, 0,
f"First-time buyers can join up to age {maxe}",
))
# AYUSH
if _bool(policy, "ayush_coverage"):
candidates.append((
"ayush", 8, 0,
"AYUSH (Ayurveda / Homeopathy / Unani) treatment covered",
))
# 80D β€” only when the buyer's stated goal is tax planning. No numerals
# in the copy by design: "80D" would trip the non-fabrication numeric
# audit (it is a legal-section reference, not a policy value), so the
# user-facing benefit (the income-tax deduction) is stated instead.
if "tax" in goal:
candidates.append((
"tax_80d", 6, 4,
"Premium qualifies for an income-tax deduction on the "
"health-insurance premium β€” aligned with your tax-saving goal",
))
# Rank: (base+boost) desc, then fixed editorial tie-break order.
def _rank_key(c: tuple[str, int, int, str]):
sid, base, boost, _ = c
try:
tb = _STRENGTH_TIE_BREAK.index(sid)
except ValueError:
tb = len(_STRENGTH_TIE_BREAK)
return (-(base + boost), tb)
ranked = sorted(candidates, key=_rank_key)
# Top 5; never pad below the real count. <3 β‡’ caller falls back to
# one_liner (handled at the surface).
strengths = [c[3] for c in ranked[:5]]
# --- STEP B: caveat ---------------------------------------------------
caveat: Optional[str] = None
if subs:
# Most grade-capping, profile-relevant sub = argmax weighted gap.
# weights is already profile-tuned (passed in from build_scorecard).
def _gap(s: SubScore) -> float:
return weights.get(s.name, 0.0) * (100 - s.score)
top = max(subs, key=_gap)
neg = next(
(sig for sig in (top.signals or []) if sig.startswith("βˆ’ ")),
None,
)
if neg:
raw = neg[2:].strip() # strip "βˆ’ " (U+2212 + space)
low = raw.lower()
# Deterministic plain-language mapping. Each branch derives ONLY
# from a signal literally present on the top sub β€” never invents.
if "ped waiting" in low:
if has_health_signal:
caveat = (
f"The pre-existing-disease waiting period ({raw}) is "
"long given the health history you shared β€” claims "
"for those conditions only start after it ends."
)
else:
caveat = (
f"Pre-existing conditions have a long waiting period "
f"({raw}) before they are covered."
)
elif "copayment" in low or "co-payment" in low or "copay" in low:
caveat = (
f"You pay a mandatory share of every claim ({raw}) β€” "
"budget for that out-of-pocket cost."
)
elif "room rent" in low:
caveat = (
f"Room rent is capped ({raw}) β€” a pricier room can "
"proportionally reduce the whole bill's reimbursement."
)
elif "csr" in low or "claim settlement" in low:
caveat = (
f"The insurer's claim-settlement record is on the low "
f"side ({raw})."
)
elif "no cashless" in low:
caveat = (
"Cashless treatment is not supported β€” you would pay "
"first and claim reimbursement later."
)
elif "network hospitals" in low:
caveat = (
f"The cashless hospital network is thin ({raw}), which "
"can limit nearby cashless options."
)
elif "initial waiting" in low:
caveat = (
f"There is a longer-than-usual initial waiting period "
f"({raw}) before most cover begins."
)
elif "maternity" in low:
caveat = (
f"Maternity has a long waiting period ({raw})."
)
elif "deductible" in low:
caveat = (
f"An up-front deductible applies ({raw}) β€” you pay that "
"amount before cover starts."
)
elif "day-care" in low or "day care" in low:
caveat = (
f"Day-care procedure coverage is limited ({raw})."
)
else:
caveat = f"One trade-off: {raw}."
return ProfileSummary(strengths=strengths, caveat=caveat)
def build_scorecard(policy: dict, insurer_reviews: Optional[dict] = None, profile: Optional[dict] = None) -> Scorecard:
if not isinstance(policy, dict):
policy = {}
pid = policy.get("policy_id", "") or ""
# BUG #24 β€” clean the typo-looking lowercase `my:` prefix off the
# user-facing name (HDFC ERGO Optima family only) at the scorecard
# chokepoint, so every scorecard-derived surface (compare, single &
# bulk /api/scorecard) shows "Optima Secure (older variant)" not
# "my:Optima Secure (older variant)". Display-only β€” policy_id below
# is untouched, so dedup / resolution are unchanged.
from backend.policy_identity import clean_display_policy_name
pname = clean_display_policy_name(policy.get("policy_name", "") or "")
pslug = policy.get("insurer_slug", "") or ""
completeness = compute_data_completeness(policy)
# DEFINED insufficient-data path. A catalogued policy with near-zero
# structured data must NOT be handed a fabricated grade (an all-empty
# dict otherwise scores a confident "F"/52 from the neutral bases). We
# return an explicit, honest "not enough data to grade yet" Scorecard the
# endpoint surfaces as HTTP 200 + a clear flag β€” never a 500/Retry and
# never an invented grade. Well-populated policies (real floor 13.0%)
# never reach this branch, so no existing grade changes.
if completeness < MIN_GRADEABLE_COMPLETENESS_PCT:
return Scorecard(
policy_id=pid,
policy_name=pname,
insurer_slug=pslug,
overall_score=0,
grade="β€”",
one_liner=(
"Not enough of this policy's terms have been published yet to "
"grade it fairly. Check back once the official document is "
"available."
),
sub_scores=[],
data_completeness_pct=completeness,
insufficient_data=True,
# Empty form on the honest-unknown branch β€” the surface falls
# back to the one_liner above. Never a fabricated strength.
profile_summary=ProfileSummary([], None),
)
subs = [
_safe_sub(score_coverage_breadth, "Coverage Breadth", policy),
_safe_sub(score_cost_predictability, "Cost Predictability", policy),
_safe_sub(score_waiting_friction, "Waiting-Period Friction", policy),
_safe_sub(score_claim_experience, "Claim Experience", policy, insurer_reviews=insurer_reviews),
_safe_sub(score_renewal_protection, "Renewal Protection", policy),
_safe_sub(score_bonuses, "Bonus & Loyalty", policy),
]
weights = _profile_tuned_weights(profile)
if _is_fixed_benefit(policy):
applicable = [s for s in subs if s.name not in _INDEMNITY_ONLY]
wsum = sum(weights[s.name] for s in applicable) or 1.0
overall = clamp(sum(weights[s.name] * s.score for s in applicable) / wsum)
else:
overall = clamp(sum(weights[s.name] * s.score for s in subs))
letter, one_liner = grade_for(overall)
# Profile-aware {strengths, caveat} on the SAME pass that produced the
# grade β€” `weights` is already profile-tuned, so the caveat's argmax uses
# the exact weighting the score used.
profile_summary = build_profile_summary(
policy, subs, weights, profile, insurer_reviews
)
return Scorecard(
policy_id=pid,
policy_name=pname,
insurer_slug=pslug,
overall_score=overall,
grade=letter,
one_liner=one_liner,
sub_scores=subs,
data_completeness_pct=completeness,
profile_summary=profile_summary,
)