rohitsar567 commited on
Commit
37611ae
·
verified ·
1 Parent(s): a21896f

Deploy v1 — single-Docker FastAPI + Next.js + RAG + voice + faithfulness

Browse files
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
+ ```