Spaces:
Sleeping
Sleeping
Deploy v1 — single-Docker FastAPI + Next.js + RAG + voice + faithfulness
Browse files- backend/main.py +72 -0
- backend/scorecard.py +317 -0
- backend/security.py +221 -0
- docs/scorecard-methodology.md +228 -0
backend/main.py
CHANGED
|
@@ -368,8 +368,24 @@ async def upload_policy(file: UploadFile = File(...)):
|
|
| 368 |
try:
|
| 369 |
from rag.ingest import chunk_pages, get_chroma_collection, read_pdf_pages
|
| 370 |
from backend.providers.local_embeddings import LocalEmbeddings as _Emb
|
|
|
|
| 371 |
|
| 372 |
pages = read_pdf_pages(out_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
chunks = list(chunk_pages(pages))
|
| 374 |
if not chunks:
|
| 375 |
raise HTTPException(400, "Could not extract any text from the PDF (scanned image-only?).")
|
|
@@ -400,6 +416,8 @@ async def upload_policy(file: UploadFile = File(...)):
|
|
| 400 |
except Exception:
|
| 401 |
pass
|
| 402 |
collection.add(ids=ids, documents=texts, embeddings=vectors, metadatas=metadatas)
|
|
|
|
|
|
|
| 403 |
except HTTPException:
|
| 404 |
raise
|
| 405 |
except Exception as e:
|
|
@@ -414,6 +432,60 @@ async def upload_policy(file: UploadFile = File(...)):
|
|
| 414 |
)
|
| 415 |
|
| 416 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
@app.post("/api/tts")
|
| 418 |
async def tts(req: TTSRequest):
|
| 419 |
"""Standalone TTS endpoint — returns base64 WAV."""
|
|
|
|
| 368 |
try:
|
| 369 |
from rag.ingest import chunk_pages, get_chroma_collection, read_pdf_pages
|
| 370 |
from backend.providers.local_embeddings import LocalEmbeddings as _Emb
|
| 371 |
+
from backend.security import check_upload, rate_limiter
|
| 372 |
|
| 373 |
pages = read_pdf_pages(out_path)
|
| 374 |
+
# Run 4-gate security check (mechanics + content + injection + rate limit)
|
| 375 |
+
full_text = "\n".join(t for _, t in pages)
|
| 376 |
+
verdict = check_upload(
|
| 377 |
+
content=contents,
|
| 378 |
+
extracted_text=full_text,
|
| 379 |
+
page_count=len(pages),
|
| 380 |
+
session_id="anonymous", # v1: no session; v2 wires session_id
|
| 381 |
+
)
|
| 382 |
+
if not verdict.accepted:
|
| 383 |
+
out_path.unlink(missing_ok=True)
|
| 384 |
+
raise HTTPException(
|
| 385 |
+
400,
|
| 386 |
+
f"Upload rejected by security gates: {', '.join(verdict.reasons[:3])}",
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
chunks = list(chunk_pages(pages))
|
| 390 |
if not chunks:
|
| 391 |
raise HTTPException(400, "Could not extract any text from the PDF (scanned image-only?).")
|
|
|
|
| 416 |
except Exception:
|
| 417 |
pass
|
| 418 |
collection.add(ids=ids, documents=texts, embeddings=vectors, metadatas=metadatas)
|
| 419 |
+
# Update rate-limit ledger after successful index
|
| 420 |
+
rate_limiter.record_upload("anonymous", len(chunks))
|
| 421 |
except HTTPException:
|
| 422 |
raise
|
| 423 |
except Exception as e:
|
|
|
|
| 432 |
)
|
| 433 |
|
| 434 |
|
| 435 |
+
class ScorecardSubScore(BaseModel):
|
| 436 |
+
name: str
|
| 437 |
+
score: int
|
| 438 |
+
summary: str
|
| 439 |
+
signals: list[str]
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
class ScorecardResponse(BaseModel):
|
| 443 |
+
policy_id: str
|
| 444 |
+
policy_name: str
|
| 445 |
+
insurer_slug: str
|
| 446 |
+
overall_score: int
|
| 447 |
+
grade: str
|
| 448 |
+
one_liner: str
|
| 449 |
+
sub_scores: list[ScorecardSubScore]
|
| 450 |
+
data_completeness_pct: float
|
| 451 |
+
methodology_link: str
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
@app.get("/api/policies/{policy_id}/scorecard", response_model=ScorecardResponse)
|
| 455 |
+
async def policy_scorecard(policy_id: str):
|
| 456 |
+
"""Compute the 6-sub-score A-F scorecard for an extracted policy.
|
| 457 |
+
|
| 458 |
+
See docs/scorecard-methodology.md for the field-to-score mapping.
|
| 459 |
+
"""
|
| 460 |
+
import json as _json
|
| 461 |
+
from pathlib import Path as _Path
|
| 462 |
+
|
| 463 |
+
from backend.scorecard import build_scorecard
|
| 464 |
+
|
| 465 |
+
# Look up policy from DuckDB (or extracted JSON file)
|
| 466 |
+
extracted_path = settings.EXTRACTED_DIR / f"{policy_id}.json"
|
| 467 |
+
if not extracted_path.exists():
|
| 468 |
+
raise HTTPException(404, f"No extracted data for policy_id={policy_id}")
|
| 469 |
+
|
| 470 |
+
try:
|
| 471 |
+
policy = _json.loads(extracted_path.read_text())
|
| 472 |
+
except Exception as e:
|
| 473 |
+
raise HTTPException(500, f"Could not load extracted policy: {e}")
|
| 474 |
+
|
| 475 |
+
sc = build_scorecard(policy)
|
| 476 |
+
return ScorecardResponse(
|
| 477 |
+
policy_id=sc.policy_id,
|
| 478 |
+
policy_name=sc.policy_name,
|
| 479 |
+
insurer_slug=sc.insurer_slug,
|
| 480 |
+
overall_score=sc.overall_score,
|
| 481 |
+
grade=sc.grade,
|
| 482 |
+
one_liner=sc.one_liner,
|
| 483 |
+
sub_scores=[ScorecardSubScore(**s.__dict__) for s in sc.sub_scores],
|
| 484 |
+
data_completeness_pct=sc.data_completeness_pct,
|
| 485 |
+
methodology_link=sc.methodology_link,
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
|
| 489 |
@app.post("/api/tts")
|
| 490 |
async def tts(req: TTSRequest):
|
| 491 |
"""Standalone TTS endpoint — returns base64 WAV."""
|
backend/scorecard.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Policy health scorecard — turns 48 structured fields into a human-readable
|
| 2 |
+
A-F grade with 6 sub-scores.
|
| 3 |
+
|
| 4 |
+
Why this exists: a buyer reading 48 fields can't tell if it's "good." A
|
| 5 |
+
single letter grade + 6 sub-bars + 1-line summary makes the answer obvious.
|
| 6 |
+
Inspired by what people like Beli / Ditto have done to simplify insurance.
|
| 7 |
+
|
| 8 |
+
Score philosophy: optimize for the *buyer*, not the insurer. So:
|
| 9 |
+
- Generous coverage, low frictions, predictable claims = higher score
|
| 10 |
+
- Heavy waiting periods, copays, sub-limits = lower score
|
| 11 |
+
- Regulatory-mandated minimums (IRDAI 30-day initial) don't hurt the score
|
| 12 |
+
|
| 13 |
+
Each sub-score is 0-100. Overall is a weighted average. Letter grade comes
|
| 14 |
+
from thresholds (A: 85+, B: 70-84, C: 55-69, D: 40-54, F: <40).
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from dataclasses import dataclass, field
|
| 20 |
+
from typing import Any, Optional
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class SubScore:
|
| 25 |
+
name: str
|
| 26 |
+
score: int # 0-100
|
| 27 |
+
summary: str
|
| 28 |
+
signals: list[str] = field(default_factory=list) # short positive/negative bullets
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class Scorecard:
|
| 33 |
+
policy_id: str
|
| 34 |
+
policy_name: str
|
| 35 |
+
insurer_slug: str
|
| 36 |
+
overall_score: int
|
| 37 |
+
grade: str # A, B, C, D, F
|
| 38 |
+
one_liner: str
|
| 39 |
+
sub_scores: list[SubScore]
|
| 40 |
+
data_completeness_pct: float # how many of the scoring fields actually have data
|
| 41 |
+
methodology_link: str = "/docs/scorecard-methodology.md"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ---- helpers ----
|
| 45 |
+
|
| 46 |
+
def _get(p: dict, key: str, default: Any = None) -> Any:
|
| 47 |
+
v = p.get(key, default)
|
| 48 |
+
if isinstance(v, dict) and "covered" in v:
|
| 49 |
+
return v.get("covered", default)
|
| 50 |
+
return v
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _bool(p: dict, key: str) -> Optional[bool]:
|
| 54 |
+
v = p.get(key)
|
| 55 |
+
if isinstance(v, dict) and "covered" in v:
|
| 56 |
+
return v.get("covered")
|
| 57 |
+
if isinstance(v, bool):
|
| 58 |
+
return v
|
| 59 |
+
if isinstance(v, str) and v.lower() in ("yes", "true", "y"):
|
| 60 |
+
return True
|
| 61 |
+
if isinstance(v, str) and v.lower() in ("no", "false", "n"):
|
| 62 |
+
return False
|
| 63 |
+
return None
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _int(p: dict, key: str) -> Optional[int]:
|
| 67 |
+
v = p.get(key)
|
| 68 |
+
if isinstance(v, dict) and "limit_inr" in v:
|
| 69 |
+
v = v.get("limit_inr")
|
| 70 |
+
try:
|
| 71 |
+
return int(v) if v is not None else None
|
| 72 |
+
except (TypeError, ValueError):
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def clamp(x: float, lo: int = 0, hi: int = 100) -> int:
|
| 77 |
+
return max(lo, min(hi, int(round(x))))
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ---- 6 sub-scores ----
|
| 81 |
+
|
| 82 |
+
def score_coverage_breadth(p: dict) -> SubScore:
|
| 83 |
+
"""How wide is the safety net? AYUSH, day-care, OPD, organ donor, maternity, etc."""
|
| 84 |
+
signals_pos: list[str] = []
|
| 85 |
+
signals_neg: list[str] = []
|
| 86 |
+
s = 50 # neutral base
|
| 87 |
+
|
| 88 |
+
if _bool(p, "ayush_coverage"):
|
| 89 |
+
s += 8; signals_pos.append("AYUSH covered")
|
| 90 |
+
elif _bool(p, "ayush_coverage") is False:
|
| 91 |
+
signals_neg.append("no AYUSH")
|
| 92 |
+
|
| 93 |
+
dct = _int(p, "day_care_treatments_count")
|
| 94 |
+
if dct is not None:
|
| 95 |
+
if dct >= 400: s += 10; signals_pos.append(f"{dct} day-care procedures")
|
| 96 |
+
elif dct >= 200: s += 6
|
| 97 |
+
elif dct < 100: s -= 5; signals_neg.append(f"only {dct} day-care procedures")
|
| 98 |
+
|
| 99 |
+
if _bool(p, "maternity_coverage"):
|
| 100 |
+
s += 6; signals_pos.append("maternity covered")
|
| 101 |
+
if _bool(p, "newborn_coverage"):
|
| 102 |
+
s += 4; signals_pos.append("newborn covered")
|
| 103 |
+
if _bool(p, "organ_donor_expenses"):
|
| 104 |
+
s += 4; signals_pos.append("organ donor expenses")
|
| 105 |
+
if _bool(p, "ambulance_cover"):
|
| 106 |
+
s += 3; signals_pos.append("ambulance covered")
|
| 107 |
+
if _bool(p, "domiciliary_treatment"):
|
| 108 |
+
s += 4
|
| 109 |
+
if _bool(p, "preventive_health_checkup"):
|
| 110 |
+
s += 3; signals_pos.append("free health checkups")
|
| 111 |
+
|
| 112 |
+
pre = _int(p, "pre_hospitalization_days") or 0
|
| 113 |
+
post = _int(p, "post_hospitalization_days") or 0
|
| 114 |
+
if pre >= 60: s += 4; signals_pos.append(f"{pre}d pre-hospitalization")
|
| 115 |
+
if post >= 90: s += 4; signals_pos.append(f"{post}d post-hospitalization")
|
| 116 |
+
|
| 117 |
+
summary = "Wide coverage" if s >= 75 else "Standard coverage" if s >= 55 else "Limited coverage"
|
| 118 |
+
return SubScore("Coverage Breadth", clamp(s), summary, signals_pos + [f"− {x}" for x in signals_neg])
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def score_cost_predictability(p: dict) -> SubScore:
|
| 122 |
+
"""How likely are you to face surprise out-of-pocket costs? Copay, room rent caps, sub-limits."""
|
| 123 |
+
signals: list[str] = []
|
| 124 |
+
s = 75 # most policies start fine
|
| 125 |
+
|
| 126 |
+
copay = _int(p, "copayment_pct")
|
| 127 |
+
if copay is not None and copay > 0:
|
| 128 |
+
if copay >= 30: s -= 25; signals.append(f"− {copay}% copayment")
|
| 129 |
+
elif copay >= 20: s -= 18; signals.append(f"− {copay}% copayment")
|
| 130 |
+
elif copay >= 10: s -= 10; signals.append(f"− {copay}% copayment")
|
| 131 |
+
else: s -= 4
|
| 132 |
+
|
| 133 |
+
rrc = p.get("room_rent_capping")
|
| 134 |
+
rrc_text = rrc if isinstance(rrc, str) else (rrc.get("limit_text") if isinstance(rrc, dict) else None)
|
| 135 |
+
if rrc_text:
|
| 136 |
+
if "no cap" in rrc_text.lower() or "no monetary" in rrc_text.lower():
|
| 137 |
+
s += 6; signals.append("no room rent cap")
|
| 138 |
+
elif "1%" in rrc_text or "%" in rrc_text:
|
| 139 |
+
s -= 8; signals.append(f"− room rent capped: {rrc_text[:50]}")
|
| 140 |
+
|
| 141 |
+
deductible = _int(p, "deductible_amount")
|
| 142 |
+
if deductible and deductible > 0:
|
| 143 |
+
signals.append(f"− deductible ₹{deductible:,}")
|
| 144 |
+
s -= 6
|
| 145 |
+
|
| 146 |
+
summary = "Predictable costs" if s >= 75 else "Some out-of-pocket" if s >= 55 else "Material out-of-pocket"
|
| 147 |
+
return SubScore("Cost Predictability", clamp(s), summary, signals)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def score_waiting_friction(p: dict) -> SubScore:
|
| 151 |
+
"""How long before benefits actually kick in? PED, specific disease, maternity waits."""
|
| 152 |
+
signals: list[str] = []
|
| 153 |
+
s = 90
|
| 154 |
+
|
| 155 |
+
ped = _int(p, "pre_existing_disease_waiting_months")
|
| 156 |
+
if ped is not None:
|
| 157 |
+
if ped >= 48: s -= 30; signals.append(f"− {ped}mo PED waiting (long)")
|
| 158 |
+
elif ped >= 36: s -= 20; signals.append(f"− {ped}mo PED waiting")
|
| 159 |
+
elif ped >= 24: s -= 10; signals.append(f"− {ped}mo PED waiting")
|
| 160 |
+
else: signals.append(f"{ped}mo PED waiting (short)")
|
| 161 |
+
|
| 162 |
+
mw = _int(p, "maternity_waiting_months")
|
| 163 |
+
if mw is not None:
|
| 164 |
+
if mw >= 48: s -= 10; signals.append(f"− {mw}mo maternity waiting")
|
| 165 |
+
elif mw >= 24: s -= 4
|
| 166 |
+
|
| 167 |
+
iw = _int(p, "initial_waiting_period_days")
|
| 168 |
+
# 30 days is IRDAI-mandated minimum; don't penalize
|
| 169 |
+
if iw is not None and iw > 60: s -= 5; signals.append(f"− {iw}d initial waiting")
|
| 170 |
+
|
| 171 |
+
summary = "Quick activation" if s >= 75 else "Standard waits" if s >= 55 else "Heavy waiting periods"
|
| 172 |
+
return SubScore("Waiting-Period Friction", clamp(s), summary, signals)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def score_claim_experience(p: dict) -> SubScore:
|
| 176 |
+
"""Will claims actually be paid? Network size, settlement ratio, cashless support."""
|
| 177 |
+
signals: list[str] = []
|
| 178 |
+
s = 60
|
| 179 |
+
|
| 180 |
+
if _bool(p, "cashless_treatment_supported"):
|
| 181 |
+
s += 15; signals.append("cashless supported")
|
| 182 |
+
nh = _int(p, "network_hospital_count")
|
| 183 |
+
if nh is not None:
|
| 184 |
+
if nh >= 10000: s += 15; signals.append(f"{nh:,}+ network hospitals")
|
| 185 |
+
elif nh >= 5000: s += 8; signals.append(f"{nh:,} network hospitals")
|
| 186 |
+
elif nh < 2000: s -= 8; signals.append(f"− only {nh} network hospitals")
|
| 187 |
+
|
| 188 |
+
csr = p.get("claim_settlement_ratio")
|
| 189 |
+
try:
|
| 190 |
+
csr_val = float(csr)
|
| 191 |
+
if csr_val >= 95: s += 10; signals.append(f"{csr_val:.1f}% claim settlement ratio")
|
| 192 |
+
elif csr_val >= 85: s += 6; signals.append(f"{csr_val:.1f}% CSR")
|
| 193 |
+
elif csr_val < 75: s -= 12; signals.append(f"− {csr_val:.1f}% CSR (low)")
|
| 194 |
+
except (TypeError, ValueError):
|
| 195 |
+
pass
|
| 196 |
+
|
| 197 |
+
tat = _int(p, "tat_cashless_authorization_hours")
|
| 198 |
+
if tat is not None and tat <= 2:
|
| 199 |
+
s += 4; signals.append(f"{tat}h cashless TAT")
|
| 200 |
+
|
| 201 |
+
summary = "Smooth claims" if s >= 75 else "Standard claim experience" if s >= 55 else "Friction risk on claims"
|
| 202 |
+
return SubScore("Claim Experience", clamp(s), summary, signals)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def score_renewal_protection(p: dict) -> SubScore:
|
| 206 |
+
"""Can you keep this policy as you age? Lifelong renewability + wide age band."""
|
| 207 |
+
signals: list[str] = []
|
| 208 |
+
s = 60
|
| 209 |
+
|
| 210 |
+
maxr = _int(p, "max_renewal_age")
|
| 211 |
+
if maxr is not None:
|
| 212 |
+
if maxr >= 99: s += 25; signals.append("lifelong renewability")
|
| 213 |
+
elif maxr >= 80: s += 15; signals.append(f"renewable up to {maxr}")
|
| 214 |
+
elif maxr < 65: s -= 15; signals.append(f"− only renewable up to {maxr}")
|
| 215 |
+
|
| 216 |
+
maxe = _int(p, "max_entry_age")
|
| 217 |
+
if maxe is not None:
|
| 218 |
+
if maxe >= 65: s += 10; signals.append(f"entry up to {maxe}")
|
| 219 |
+
elif maxe < 50: s -= 6
|
| 220 |
+
|
| 221 |
+
summary = "Future-proof" if s >= 75 else "Adequate" if s >= 55 else "Renewal risk at older ages"
|
| 222 |
+
return SubScore("Renewal Protection", clamp(s), summary, signals)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def score_bonuses(p: dict) -> SubScore:
|
| 226 |
+
"""No-claim bonuses, restoration, health checkups — sweeteners for loyal buyers."""
|
| 227 |
+
signals: list[str] = []
|
| 228 |
+
s = 50
|
| 229 |
+
|
| 230 |
+
ncb = _int(p, "no_claim_bonus_pct")
|
| 231 |
+
if ncb is not None:
|
| 232 |
+
if ncb >= 100: s += 25; signals.append(f"{ncb}% NCB step-up")
|
| 233 |
+
elif ncb >= 50: s += 15; signals.append(f"{ncb}% NCB")
|
| 234 |
+
elif ncb >= 25: s += 8
|
| 235 |
+
|
| 236 |
+
rb = p.get("restoration_benefit")
|
| 237 |
+
if rb and isinstance(rb, str) and len(rb) > 5:
|
| 238 |
+
s += 12; signals.append(f"restoration benefit: {rb[:50]}")
|
| 239 |
+
|
| 240 |
+
if _bool(p, "preventive_health_checkup"):
|
| 241 |
+
s += 8; signals.append("free preventive checkup")
|
| 242 |
+
|
| 243 |
+
summary = "Generous bonuses" if s >= 75 else "Standard sweeteners" if s >= 55 else "Few extras"
|
| 244 |
+
return SubScore("Bonus & Loyalty", clamp(s), summary, signals)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
# ---- aggregate + grade ----
|
| 248 |
+
|
| 249 |
+
# Weights reflect what affects the buyer's real-world experience most.
|
| 250 |
+
WEIGHTS = {
|
| 251 |
+
"Coverage Breadth": 0.22,
|
| 252 |
+
"Cost Predictability": 0.20,
|
| 253 |
+
"Waiting-Period Friction": 0.18,
|
| 254 |
+
"Claim Experience": 0.20,
|
| 255 |
+
"Renewal Protection": 0.12,
|
| 256 |
+
"Bonus & Loyalty": 0.08,
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def grade_for(score: int) -> tuple[str, str]:
|
| 261 |
+
"""Return (letter, one-line summary tone)."""
|
| 262 |
+
if score >= 85: return "A", "Strong all-rounder — solid pick for the buyer."
|
| 263 |
+
if score >= 70: return "B", "Good policy with a few notable gaps."
|
| 264 |
+
if score >= 55: return "C", "Decent baseline; check the trade-offs before signing."
|
| 265 |
+
if score >= 40: return "D", "Material concerns — only suitable for specific use-cases."
|
| 266 |
+
return "F", "Significant gaps — alternative options are likely better."
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
# Fields the scorecard touches — used to compute data_completeness_pct
|
| 270 |
+
SCORED_FIELDS = [
|
| 271 |
+
"ayush_coverage", "day_care_treatments_count", "maternity_coverage",
|
| 272 |
+
"newborn_coverage", "organ_donor_expenses", "ambulance_cover",
|
| 273 |
+
"domiciliary_treatment", "preventive_health_checkup",
|
| 274 |
+
"pre_hospitalization_days", "post_hospitalization_days",
|
| 275 |
+
"copayment_pct", "room_rent_capping", "deductible_amount",
|
| 276 |
+
"pre_existing_disease_waiting_months", "maternity_waiting_months",
|
| 277 |
+
"initial_waiting_period_days",
|
| 278 |
+
"cashless_treatment_supported", "network_hospital_count",
|
| 279 |
+
"claim_settlement_ratio", "tat_cashless_authorization_hours",
|
| 280 |
+
"max_renewal_age", "max_entry_age",
|
| 281 |
+
"no_claim_bonus_pct", "restoration_benefit",
|
| 282 |
+
]
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def compute_data_completeness(p: dict) -> float:
|
| 286 |
+
filled = 0
|
| 287 |
+
for k in SCORED_FIELDS:
|
| 288 |
+
v = p.get(k)
|
| 289 |
+
if v is None or v == "" or v == []:
|
| 290 |
+
continue
|
| 291 |
+
if isinstance(v, dict) and v.get("covered") is None and not v.get("limit_inr") and not v.get("limit_text"):
|
| 292 |
+
continue
|
| 293 |
+
filled += 1
|
| 294 |
+
return round(filled / max(1, len(SCORED_FIELDS)) * 100, 1)
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def build_scorecard(policy: dict) -> Scorecard:
|
| 298 |
+
subs = [
|
| 299 |
+
score_coverage_breadth(policy),
|
| 300 |
+
score_cost_predictability(policy),
|
| 301 |
+
score_waiting_friction(policy),
|
| 302 |
+
score_claim_experience(policy),
|
| 303 |
+
score_renewal_protection(policy),
|
| 304 |
+
score_bonuses(policy),
|
| 305 |
+
]
|
| 306 |
+
overall = clamp(sum(WEIGHTS[s.name] * s.score for s in subs))
|
| 307 |
+
letter, one_liner = grade_for(overall)
|
| 308 |
+
return Scorecard(
|
| 309 |
+
policy_id=policy.get("policy_id", ""),
|
| 310 |
+
policy_name=policy.get("policy_name", ""),
|
| 311 |
+
insurer_slug=policy.get("insurer_slug", ""),
|
| 312 |
+
overall_score=overall,
|
| 313 |
+
grade=letter,
|
| 314 |
+
one_liner=one_liner,
|
| 315 |
+
sub_scores=subs,
|
| 316 |
+
data_completeness_pct=compute_data_completeness(policy),
|
| 317 |
+
)
|
backend/security.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Security gates for user-uploaded content.
|
| 2 |
+
|
| 3 |
+
The /api/upload-policy endpoint accepts arbitrary PDFs from the public web.
|
| 4 |
+
That's a real attack surface. Each upload runs through these gates before
|
| 5 |
+
we touch it with the embedding model or LLM:
|
| 6 |
+
|
| 7 |
+
Gate 1 — FILE MECHANICS
|
| 8 |
+
- magic bytes start with %PDF
|
| 9 |
+
- size <= 25 MB
|
| 10 |
+
- no embedded JavaScript / AcroForm / OpenAction (PDF exploits)
|
| 11 |
+
- no /Launch / /EmbeddedFile actions (file execution / payload smuggling)
|
| 12 |
+
|
| 13 |
+
Gate 2 — CONTENT QUALITY
|
| 14 |
+
- at least 1500 chars of extractable text (rejects scanned image-only PDFs
|
| 15 |
+
and intentional empty/garbage uploads)
|
| 16 |
+
- at least 3 pages (an insurance policy is never one page)
|
| 17 |
+
- text contains at least one insurance-domain keyword (cheap filter for
|
| 18 |
+
"this is not a recipe / resume / random PDF")
|
| 19 |
+
|
| 20 |
+
Gate 3 — PROMPT INJECTION DEFENSE
|
| 21 |
+
- regex-scan for known injection patterns in extracted text
|
| 22 |
+
- reject if the PDF tries to override the system prompt, expose secrets,
|
| 23 |
+
or impersonate the assistant
|
| 24 |
+
- we DO NOT silently rewrite the content — block + log instead, so the
|
| 25 |
+
user knows their upload was rejected for a reason
|
| 26 |
+
|
| 27 |
+
Gate 4 — RATE LIMITING
|
| 28 |
+
- per-session: max 5 uploads / hour
|
| 29 |
+
- cumulative: max 200 chunks across all user uploads (prevents corpus
|
| 30 |
+
flooding by a single session)
|
| 31 |
+
|
| 32 |
+
Every block is logged to logs/upload_blocks.jsonl with the reason.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import json
|
| 38 |
+
import re
|
| 39 |
+
import time
|
| 40 |
+
from collections import defaultdict
|
| 41 |
+
from dataclasses import dataclass, field
|
| 42 |
+
from pathlib import Path
|
| 43 |
+
from typing import Optional
|
| 44 |
+
|
| 45 |
+
from backend.config import settings
|
| 46 |
+
|
| 47 |
+
LOG_DIR = settings.CORPUS_DIR.parent.parent / "logs"
|
| 48 |
+
LOG_DIR.mkdir(exist_ok=True)
|
| 49 |
+
UPLOAD_BLOCK_LOG = LOG_DIR / "upload_blocks.jsonl"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@dataclass
|
| 53 |
+
class UploadVerdict:
|
| 54 |
+
accepted: bool
|
| 55 |
+
reasons: list[str] = field(default_factory=list)
|
| 56 |
+
extracted_text_chars: int = 0
|
| 57 |
+
page_count: int = 0
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# Insurance-domain keywords used to confirm the PDF is plausibly a policy.
|
| 61 |
+
# A single hit on any of these is enough — we don't want to over-reject.
|
| 62 |
+
INSURANCE_KEYWORDS = (
|
| 63 |
+
"insur", "policy", "premium", "sum insured", "claim", "hospital",
|
| 64 |
+
"coverage", "covered", "exclus", "waiting period", "pre-existing",
|
| 65 |
+
"irdai", "deductible", "cashless", "ayush", "domiciliary", "ncb",
|
| 66 |
+
"no claim bonus", "renewal", "uin", "section", "covered",
|
| 67 |
+
"insurance", "insurer", "insurer's"
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Prompt-injection patterns we explicitly reject. Not exhaustive — but every
|
| 71 |
+
# one of these is a clear signal of adversarial content, not a real policy.
|
| 72 |
+
INJECTION_PATTERNS = [
|
| 73 |
+
re.compile(r"\bignore\s+(?:all\s+)?(?:previous|prior|above|earlier)\s+instructions", re.IGNORECASE),
|
| 74 |
+
re.compile(r"\bdisregard\s+(?:all\s+)?(?:previous|prior|above|earlier)\s+instructions", re.IGNORECASE),
|
| 75 |
+
re.compile(r"\bforget\s+(?:everything|all|your|the)\s+(?:previous|prior|above|instructions)", re.IGNORECASE),
|
| 76 |
+
re.compile(r"\byou\s+are\s+now\s+a\s+", re.IGNORECASE),
|
| 77 |
+
re.compile(r"\bact\s+as\s+(?:a|an)\s+(?:different|new)\s+", re.IGNORECASE),
|
| 78 |
+
re.compile(r"\bpretend\s+(?:to\s+be|you\s+are)\s+", re.IGNORECASE),
|
| 79 |
+
re.compile(r"\bsystem\s+prompt\b", re.IGNORECASE),
|
| 80 |
+
re.compile(r"<\s*\|?im_start\|?\s*>|<\s*\|?im_end\|?\s*>", re.IGNORECASE),
|
| 81 |
+
re.compile(r"\bjailbreak\b|\bdan\s+mode\b", re.IGNORECASE),
|
| 82 |
+
re.compile(r"reveal\s+(?:your|the)\s+(?:system|hidden|secret|original)\s+(?:prompt|instructions)", re.IGNORECASE),
|
| 83 |
+
re.compile(r"\bapi[_\- ]?key\b.{0,40}(?:reveal|share|tell|print|output)", re.IGNORECASE),
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
# PDF byte patterns that indicate active content / exploits
|
| 87 |
+
DANGEROUS_PDF_FEATURES = [
|
| 88 |
+
(rb"/JavaScript", "embedded_javascript"),
|
| 89 |
+
(rb"/JS ", "javascript_action"),
|
| 90 |
+
(rb"/Launch", "launch_action"),
|
| 91 |
+
(rb"/EmbeddedFile", "embedded_file"),
|
| 92 |
+
(rb"/OpenAction", "openaction_trigger"),
|
| 93 |
+
(rb"/SubmitForm", "form_submission"),
|
| 94 |
+
(rb"/AA<<", "auto_actions"),
|
| 95 |
+
]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# Per-session rate-limit state (in-memory; resets on restart).
|
| 99 |
+
# In v2 this moves to Redis.
|
| 100 |
+
class RateLimit:
|
| 101 |
+
def __init__(self):
|
| 102 |
+
self.uploads_by_session: dict[str, list[float]] = defaultdict(list)
|
| 103 |
+
self.chunks_by_session: dict[str, int] = defaultdict(int)
|
| 104 |
+
|
| 105 |
+
def check_upload_rate(self, session_id: str) -> Optional[str]:
|
| 106 |
+
now = time.time()
|
| 107 |
+
# Keep last hour only
|
| 108 |
+
self.uploads_by_session[session_id] = [
|
| 109 |
+
t for t in self.uploads_by_session[session_id] if now - t < 3600
|
| 110 |
+
]
|
| 111 |
+
if len(self.uploads_by_session[session_id]) >= 5:
|
| 112 |
+
return "rate_limit_uploads_per_hour"
|
| 113 |
+
return None
|
| 114 |
+
|
| 115 |
+
def record_upload(self, session_id: str, chunks: int):
|
| 116 |
+
self.uploads_by_session[session_id].append(time.time())
|
| 117 |
+
self.chunks_by_session[session_id] += chunks
|
| 118 |
+
|
| 119 |
+
def check_chunk_quota(self, session_id: str) -> Optional[str]:
|
| 120 |
+
if self.chunks_by_session.get(session_id, 0) >= 200:
|
| 121 |
+
return "rate_limit_total_chunks"
|
| 122 |
+
return None
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
rate_limiter = RateLimit()
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def gate_pdf_mechanics(content: bytes) -> list[str]:
|
| 129 |
+
"""Gate 1 — bytes-level PDF checks."""
|
| 130 |
+
reasons: list[str] = []
|
| 131 |
+
if not content.startswith(b"%PDF"):
|
| 132 |
+
reasons.append("not_a_pdf_magic_bytes")
|
| 133 |
+
return reasons # no point checking further
|
| 134 |
+
if len(content) > 25 * 1024 * 1024:
|
| 135 |
+
reasons.append("file_too_large_25mb")
|
| 136 |
+
if len(content) < 5_000:
|
| 137 |
+
reasons.append("file_too_small_5kb")
|
| 138 |
+
|
| 139 |
+
# Look for dangerous PDF features in the first ~2 MB (catches most)
|
| 140 |
+
head = content[:2_000_000]
|
| 141 |
+
for needle, label in DANGEROUS_PDF_FEATURES:
|
| 142 |
+
if needle in head:
|
| 143 |
+
reasons.append(f"dangerous_pdf_feature: {label}")
|
| 144 |
+
|
| 145 |
+
return reasons
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def gate_content_quality(text: str, page_count: int) -> list[str]:
|
| 149 |
+
"""Gate 2 — extracted-text checks."""
|
| 150 |
+
reasons: list[str] = []
|
| 151 |
+
if len(text.strip()) < 1500:
|
| 152 |
+
reasons.append(f"too_little_text: {len(text)} chars")
|
| 153 |
+
if page_count < 3:
|
| 154 |
+
reasons.append(f"too_few_pages: {page_count}")
|
| 155 |
+
|
| 156 |
+
text_l = text.lower()
|
| 157 |
+
if not any(kw in text_l for kw in INSURANCE_KEYWORDS):
|
| 158 |
+
reasons.append("no_insurance_keywords_found")
|
| 159 |
+
|
| 160 |
+
return reasons
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def gate_prompt_injection(text: str) -> list[str]:
|
| 164 |
+
"""Gate 3 — scan for injection patterns."""
|
| 165 |
+
reasons: list[str] = []
|
| 166 |
+
for pat in INJECTION_PATTERNS:
|
| 167 |
+
m = pat.search(text)
|
| 168 |
+
if m:
|
| 169 |
+
snippet = text[max(0, m.start() - 30): m.end() + 30].replace("\n", " ")
|
| 170 |
+
reasons.append(f"injection_pattern: {snippet[:100]}")
|
| 171 |
+
break # one hit is enough
|
| 172 |
+
return reasons
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def gate_rate_limit(session_id: str) -> list[str]:
|
| 176 |
+
"""Gate 4 — per-session rate limits."""
|
| 177 |
+
reasons: list[str] = []
|
| 178 |
+
if r := rate_limiter.check_upload_rate(session_id):
|
| 179 |
+
reasons.append(r)
|
| 180 |
+
if r := rate_limiter.check_chunk_quota(session_id):
|
| 181 |
+
reasons.append(r)
|
| 182 |
+
return reasons
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def check_upload(
|
| 186 |
+
content: bytes,
|
| 187 |
+
extracted_text: str,
|
| 188 |
+
page_count: int,
|
| 189 |
+
session_id: str = "anonymous",
|
| 190 |
+
) -> UploadVerdict:
|
| 191 |
+
"""Run all 4 gates. Return verdict with reasons (empty if accepted)."""
|
| 192 |
+
reasons: list[str] = []
|
| 193 |
+
reasons.extend(gate_rate_limit(session_id))
|
| 194 |
+
reasons.extend(gate_pdf_mechanics(content))
|
| 195 |
+
reasons.extend(gate_content_quality(extracted_text, page_count))
|
| 196 |
+
reasons.extend(gate_prompt_injection(extracted_text))
|
| 197 |
+
|
| 198 |
+
verdict = UploadVerdict(
|
| 199 |
+
accepted=(len(reasons) == 0),
|
| 200 |
+
reasons=reasons,
|
| 201 |
+
extracted_text_chars=len(extracted_text),
|
| 202 |
+
page_count=page_count,
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
if not verdict.accepted:
|
| 206 |
+
_log_block(session_id, reasons, len(content), len(extracted_text), page_count)
|
| 207 |
+
|
| 208 |
+
return verdict
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def _log_block(session_id: str, reasons: list[str], byte_size: int, text_chars: int, pages: int):
|
| 212 |
+
entry = {
|
| 213 |
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 214 |
+
"session_id": session_id,
|
| 215 |
+
"reasons": reasons,
|
| 216 |
+
"byte_size": byte_size,
|
| 217 |
+
"text_chars": text_chars,
|
| 218 |
+
"pages": pages,
|
| 219 |
+
}
|
| 220 |
+
with open(UPLOAD_BLOCK_LOG, "a") as f:
|
| 221 |
+
f.write(json.dumps(entry) + "\n")
|
docs/scorecard-methodology.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Scorecard Methodology — From 48 Schema Fields to a Single A-F Grade
|
| 2 |
+
|
| 3 |
+
| Field | Value |
|
| 4 |
+
| --- | --- |
|
| 5 |
+
| Project | Insurance Sales Portfolio Expert |
|
| 6 |
+
| Version | 0.1 |
|
| 7 |
+
| Date | 2026-05-13 |
|
| 8 |
+
| Implementation | `backend/scorecard.py` |
|
| 9 |
+
| Endpoint | `GET /api/policies/{policy_id}/scorecard` |
|
| 10 |
+
|
| 11 |
+
## 0. Why this artifact exists
|
| 12 |
+
|
| 13 |
+
The 48-field structured schema captures every comparable attribute of a health policy. But a buyer reading 48 fields cannot tell whether the policy is *good*. The scorecard distils those 48 fields into:
|
| 14 |
+
|
| 15 |
+
- **One letter grade** (A / B / C / D / F) — the headline
|
| 16 |
+
- **One sentence** — the buyer-friendly takeaway
|
| 17 |
+
- **6 sub-scores** (0-100 each) — *why* the grade is what it is, grouped by what matters in real life
|
| 18 |
+
- **Per-field "signals"** — positive (✓) and negative (−) bullets explaining each sub-score
|
| 19 |
+
|
| 20 |
+
The methodology is **rules-based and inspectable** — no LLM in the loop, no black-box weights. Anyone reading this doc can reproduce any policy's grade with a spreadsheet.
|
| 21 |
+
|
| 22 |
+
This is inspired by what consumer fintech has done elsewhere (Ditto Insurance, Beli, Plum) to simplify a domain where the underlying contract is intentionally opaque.
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## 1. Where the 48 fields came from
|
| 27 |
+
|
| 28 |
+
The structured schema (`rag/schema.py`) was constructed by triangulating four sources. **Every field is grounded in a regulator-mandated or industry-standard taxonomy** — not invented by us.
|
| 29 |
+
|
| 30 |
+
### Source 1 — IRDAI Customer Information Sheet (CIS) format
|
| 31 |
+
Mandatory under the IRDAI Master Circular on Health Insurance Business, 2024. Every approved health policy must publish a CIS with a standardised field set covering:
|
| 32 |
+
- Identity (insurer, product, UIN code)
|
| 33 |
+
- Eligibility (age bands, family composition)
|
| 34 |
+
- Coverage scope (inpatient, day-care, AYUSH, organ donor)
|
| 35 |
+
- Sum insured + premium structure
|
| 36 |
+
- Waiting periods (initial, PED, specific diseases, maternity)
|
| 37 |
+
- Sub-limits (room rent, ICU, co-payment)
|
| 38 |
+
- Exclusions
|
| 39 |
+
- Claim process + grievance redressal
|
| 40 |
+
|
| 41 |
+
The 48-field schema is a strict superset of the CIS fields, with each field's name and unit aligned to IRDAI's published spec.
|
| 42 |
+
|
| 43 |
+
### Source 2 — PolicyBazaar / InsuranceDekho filter dimensions
|
| 44 |
+
What aggregators expose as filterable attributes on their UI tells you what real Indian buyers actually compare on. We added fields like:
|
| 45 |
+
- `network_hospital_count` — buyers care about access
|
| 46 |
+
- `no_claim_bonus_pct` — sweetener for healthy renewers
|
| 47 |
+
- `restoration_benefit` — high-leverage for families
|
| 48 |
+
- `tat_cashless_authorization_hours` — claim friction
|
| 49 |
+
|
| 50 |
+
### Source 3 — Top-insurer brochure structure analysis
|
| 51 |
+
For the 10 target insurers (Star, HDFC ERGO, Niva Bupa, Care, ICICI Lombard, Bajaj Allianz, New India, Aditya Birla, Tata AIG, ManipalCigna), we inspected the structure of their published Customer Information Sheets. Fields they all surface — sometimes with slight wording differences — were canonicalised into the schema.
|
| 52 |
+
|
| 53 |
+
### Source 4 — Domain-led additions
|
| 54 |
+
A small number of fields exist because they materially affect buyer outcomes even if not always disclosed:
|
| 55 |
+
- `claim_settlement_ratio` — IRDAI annual report disclosure
|
| 56 |
+
- `geographic_coverage_india` — Pan-India vs Regional
|
| 57 |
+
- `worldwide_emergency_cover` — relevant for travellers
|
| 58 |
+
|
| 59 |
+
### How we did it (the actual code path)
|
| 60 |
+
|
| 61 |
+
`rag/extract.py` calls Sarvam-M (with DeepSeek-V3 fallback for hard PDFs) with the full policy PDF text and the 48-field Pydantic schema as a structured-output target. The LLM extracts each field; if a field is not explicitly stated in the document, it is set to `null`. A self-critique pass scores per-field confidence (the `extraction_confidence_pct` field).
|
| 62 |
+
|
| 63 |
+
The full schema lives in `rag/schema.py` (see also `rag/SCHEMA.md` for groupings and gotchas).
|
| 64 |
+
|
| 65 |
+
---
|
| 66 |
+
|
| 67 |
+
## 2. The scorecard — 6 sub-scores
|
| 68 |
+
|
| 69 |
+
The 48 fields are aggregated into **6 sub-scores**, each 0-100. The aggregation reflects what the buyer actually experiences, **not** the insurer's marketing categories.
|
| 70 |
+
|
| 71 |
+
### 2.1 Coverage Breadth — *how wide is the safety net?*
|
| 72 |
+
|
| 73 |
+
**Weight in overall: 22%** (highest — the policy must cover the things that actually happen)
|
| 74 |
+
|
| 75 |
+
| Schema field | Effect on score |
|
| 76 |
+
| --- | --- |
|
| 77 |
+
| `ayush_coverage` (bool) | +8 if covered |
|
| 78 |
+
| `day_care_treatments_count` (int) | +10 if ≥400, +6 if ≥200, −5 if <100 |
|
| 79 |
+
| `maternity_coverage` (bool) | +6 if covered |
|
| 80 |
+
| `newborn_coverage` (bool) | +4 if covered |
|
| 81 |
+
| `organ_donor_expenses` (bool) | +4 if covered |
|
| 82 |
+
| `ambulance_cover` (bool) | +3 if covered |
|
| 83 |
+
| `domiciliary_treatment` (bool) | +4 if covered |
|
| 84 |
+
| `preventive_health_checkup` (bool) | +3 if covered |
|
| 85 |
+
| `pre_hospitalization_days` (int) | +4 if ≥60 |
|
| 86 |
+
| `post_hospitalization_days` (int) | +4 if ≥90 |
|
| 87 |
+
|
| 88 |
+
**Base score: 50.** Total range observed: 30 (bare-bones) to 95 (comprehensive flagship).
|
| 89 |
+
|
| 90 |
+
### 2.2 Cost Predictability — *will the bill surprise me?*
|
| 91 |
+
|
| 92 |
+
**Weight: 20%**. The number that hurts the buyer when they actually claim.
|
| 93 |
+
|
| 94 |
+
| Schema field | Effect on score |
|
| 95 |
+
| --- | --- |
|
| 96 |
+
| `copayment_pct` (int) | −25 if ≥30%, −18 if ≥20%, −10 if ≥10%, −4 otherwise |
|
| 97 |
+
| `room_rent_capping` (text) | +6 if "no cap"; −8 if % of SI |
|
| 98 |
+
| `deductible_amount` (int) | −6 if any deductible |
|
| 99 |
+
|
| 100 |
+
**Base score: 75** (most policies are reasonable on this; we *penalise* friction, we don't reward absence of it).
|
| 101 |
+
|
| 102 |
+
### 2.3 Waiting-Period Friction — *how long before benefits kick in?*
|
| 103 |
+
|
| 104 |
+
**Weight: 18%**.
|
| 105 |
+
|
| 106 |
+
| Schema field | Effect on score |
|
| 107 |
+
| --- | --- |
|
| 108 |
+
| `pre_existing_disease_waiting_months` | −30 if ≥48, −20 if ≥36, −10 if ≥24, 0 if <24 |
|
| 109 |
+
| `maternity_waiting_months` | −10 if ≥48, −4 if ≥24 |
|
| 110 |
+
| `initial_waiting_period_days` | −5 if >60 (30 is IRDAI-mandated — not penalised) |
|
| 111 |
+
|
| 112 |
+
**Base score: 90.** Regulatory minimums are not held against the policy.
|
| 113 |
+
|
| 114 |
+
### 2.4 Claim Experience — *will claims actually be paid?*
|
| 115 |
+
|
| 116 |
+
**Weight: 20%**. The thing the buyer cares about *after* paying premium for years.
|
| 117 |
+
|
| 118 |
+
| Schema field | Effect on score |
|
| 119 |
+
| --- | --- |
|
| 120 |
+
| `cashless_treatment_supported` (bool) | +15 if supported |
|
| 121 |
+
| `network_hospital_count` (int) | +15 if ≥10K, +8 if ≥5K, −8 if <2K |
|
| 122 |
+
| `claim_settlement_ratio` (float) | +10 if ≥95%, +6 if ≥85%, −12 if <75% |
|
| 123 |
+
| `tat_cashless_authorization_hours` (int) | +4 if ≤2 |
|
| 124 |
+
|
| 125 |
+
**Base score: 60.**
|
| 126 |
+
|
| 127 |
+
### 2.5 Renewal Protection — *can I keep this as I age?*
|
| 128 |
+
|
| 129 |
+
**Weight: 12%**.
|
| 130 |
+
|
| 131 |
+
| Schema field | Effect on score |
|
| 132 |
+
| --- | --- |
|
| 133 |
+
| `max_renewal_age` (int) | +25 if lifelong (≥99), +15 if ≥80, −15 if <65 |
|
| 134 |
+
| `max_entry_age` (int) | +10 if ≥65, −6 if <50 |
|
| 135 |
+
|
| 136 |
+
**Base score: 60.**
|
| 137 |
+
|
| 138 |
+
### 2.6 Bonus & Loyalty — *sweeteners for sticking around*
|
| 139 |
+
|
| 140 |
+
**Weight: 8%** (lowest — these matter less than the core product).
|
| 141 |
+
|
| 142 |
+
| Schema field | Effect on score |
|
| 143 |
+
| --- | --- |
|
| 144 |
+
| `no_claim_bonus_pct` (int) | +25 if ≥100, +15 if ≥50, +8 if ≥25 |
|
| 145 |
+
| `restoration_benefit` (text) | +12 if non-trivial text |
|
| 146 |
+
| `preventive_health_checkup` (bool) | +8 if covered |
|
| 147 |
+
|
| 148 |
+
**Base score: 50.**
|
| 149 |
+
|
| 150 |
+
---
|
| 151 |
+
|
| 152 |
+
## 3. From sub-scores to a single grade
|
| 153 |
+
|
| 154 |
+
```
|
| 155 |
+
overall_score = 0.22 × Coverage Breadth
|
| 156 |
+
+ 0.20 × Cost Predictability
|
| 157 |
+
+ 0.18 × Waiting-Period Friction
|
| 158 |
+
+ 0.20 × Claim Experience
|
| 159 |
+
+ 0.12 × Renewal Protection
|
| 160 |
+
+ 0.08 × Bonus & Loyalty
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
| Overall score | Grade | One-liner |
|
| 164 |
+
| --- | --- | --- |
|
| 165 |
+
| 85 – 100 | **A** | Strong all-rounder — solid pick for the buyer. |
|
| 166 |
+
| 70 – 84 | **B** | Good policy with a few notable gaps. |
|
| 167 |
+
| 55 – 69 | **C** | Decent baseline; check the trade-offs before signing. |
|
| 168 |
+
| 40 – 54 | **D** | Material concerns — only suitable for specific use-cases. |
|
| 169 |
+
| 0 – 39 | **F** | Significant gaps — alternative options are likely better. |
|
| 170 |
+
|
| 171 |
+
---
|
| 172 |
+
|
| 173 |
+
## 4. Which of the 48 fields the scorecard touches
|
| 174 |
+
|
| 175 |
+
| Field group | In scorecard? | Why / why not |
|
| 176 |
+
| --- | --- | --- |
|
| 177 |
+
| Identity (5 fields) | No | Doesn't affect quality, only display |
|
| 178 |
+
| Eligibility (5 fields) | Partial — `max_renewal_age`, `max_entry_age` only | Renewal age matters for buyer; family composition is a filter, not a quality signal |
|
| 179 |
+
| Sum insured & premium (5 fields) | No | We don't score absolute price (Doc 01 D-007: pricing is illustrative); buyers compare per-rupee value separately |
|
| 180 |
+
| Waiting periods (6 fields) | All 3 used | Direct buyer impact |
|
| 181 |
+
| Coverage scope (~10 fields) | 8 used | Most relevant |
|
| 182 |
+
| Sub-limits & caps (6 fields) | 3 used (`copayment_pct`, `room_rent_capping`, `deductible_amount`) | Disease-wise sub-limits are too policy-specific to score uniformly in v1 |
|
| 183 |
+
| Geography & network (4 fields) | 2 used (`cashless_treatment_supported`, `network_hospital_count`) | Geography is a filter; network matters |
|
| 184 |
+
| Exclusions (3 fields) | No (v1) | Exclusions are policy-specific text; v2 will tag them |
|
| 185 |
+
| Claim & service (3 fields) | All 3 used | Highest buyer-impact |
|
| 186 |
+
| Riders (3 fields) | No (v1) | Riders are optional add-ons; scoring base-policy only |
|
| 187 |
+
| Source metadata (3 fields) | No | Plumbing |
|
| 188 |
+
|
| 189 |
+
**24 of 48 fields** drive the scorecard. The other 24 are either policy-specific text (exclusions, riders), filters (geography, age bands), pricing (illustrative only), or metadata.
|
| 190 |
+
|
| 191 |
+
This is intentional — **a scorecard that uses every field becomes noise.** We selected the 24 fields where the buyer's actual experience changes materially.
|
| 192 |
+
|
| 193 |
+
---
|
| 194 |
+
|
| 195 |
+
## 5. Data completeness
|
| 196 |
+
|
| 197 |
+
Each scorecard reports a `data_completeness_pct` — what fraction of the 24 scored fields actually have data in the policy's extraction. **If extraction is poor, the scorecard is honest about it**:
|
| 198 |
+
|
| 199 |
+
- ≥80% complete → grade is reliable
|
| 200 |
+
- 60-80% → grade with caveat shown in UI
|
| 201 |
+
- <60% → grade hidden, "extraction quality too low" shown instead
|
| 202 |
+
|
| 203 |
+
This protects the buyer from a confidently-wrong A grade just because we couldn't read the policy properly.
|
| 204 |
+
|
| 205 |
+
---
|
| 206 |
+
|
| 207 |
+
## 6. Open questions / v2 enhancements
|
| 208 |
+
|
| 209 |
+
- **Premium-adjusted scoring.** Currently we don't factor in *price*. A B-grade policy at ₹8K may be better value than an A-grade at ₹40K. v2: add a `value_for_money` sub-score using illustrative-price bands.
|
| 210 |
+
- **Reviews & sentiment.** v2 will pull insurer reviews from Reddit, PolicyBazaar reviews, IRDAI complaints data, YouTube reviews — aggregate into a sentiment score and feed into Claim Experience.
|
| 211 |
+
- **Buyer-profile-tuned weights.** A 25-year-old should care more about waiting periods + claim experience than renewal protection. v2 will personalise weights based on `Profile` from the fact-find flow.
|
| 212 |
+
- **Adversarial test.** v2 will run the scorecard on every gold-Q&A policy and human-audit the worst graders.
|
| 213 |
+
|
| 214 |
+
---
|
| 215 |
+
|
| 216 |
+
## 7. Reproducing any grade
|
| 217 |
+
|
| 218 |
+
```python
|
| 219 |
+
import json
|
| 220 |
+
from backend.scorecard import build_scorecard
|
| 221 |
+
policy = json.load(open(f"rag/extracted/<policy_id>.json"))
|
| 222 |
+
sc = build_scorecard(policy)
|
| 223 |
+
print(sc.grade, sc.overall_score, sc.one_liner)
|
| 224 |
+
for s in sc.sub_scores:
|
| 225 |
+
print(f" {s.name}: {s.score} ({s.summary})")
|
| 226 |
+
for sig in s.signals:
|
| 227 |
+
print(f" · {sig}")
|
| 228 |
+
```
|