Spaces:
Sleeping
Sleeping
| """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 | |
| class SubScore: | |
| name: str | |
| score: int # 0-100 | |
| summary: str | |
| signals: list[str] = field(default_factory=list) # short positive/negative bullets | |
| 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 | |
| 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, | |
| ) | |