rohitsar567 commited on
Commit
9559143
·
verified ·
1 Parent(s): 83532c8

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

Browse files
backend/faithfulness.py CHANGED
@@ -55,7 +55,7 @@ HALLUCINATION_LOG = LOG_DIR / "hallucinations.jsonl"
55
  # higher here than they would be for Voyage. Re-tune if changing embedding model.
56
  # Lowered 2026-05-13 based on eval data showing too-aggressive refusal at 0.40:
57
  # many real questions retrieve top chunks at 0.30-0.38 that DO contain the answer.
58
- MIN_TOP_SCORE = 0.30 # below this we refuse outright (BGE-small cosine similarity)
59
  MIN_AVG_SCORE = 0.22 # average of top 5 must be above this
60
 
61
 
 
55
  # higher here than they would be for Voyage. Re-tune if changing embedding model.
56
  # Lowered 2026-05-13 based on eval data showing too-aggressive refusal at 0.40:
57
  # many real questions retrieve top chunks at 0.30-0.38 that DO contain the answer.
58
+ MIN_TOP_SCORE = 0.18 # below this we refuse outright (BGE-small cosine similarity)
59
  MIN_AVG_SCORE = 0.22 # average of top 5 must be above this
60
 
61
 
backend/main.py CHANGED
@@ -516,6 +516,36 @@ class MarketplaceResponse(BaseModel):
516
  insurers_indexed: int
517
 
518
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
  def _build_corpus_url_index() -> dict[str, str]:
520
  """Parse data/corpus_urls.md and return {policy_id: source_url}. Used to
521
  backfill source_pdf_url when the LLM extraction didn't capture it."""
 
516
  insurers_indexed: int
517
 
518
 
519
+ @app.get("/api/scorecard/methodology")
520
+ async def scorecard_methodology():
521
+ """Transparency endpoint — returns the 6-criterion blueprint with weights,
522
+ consumer rationale, fields driving each sub-score, and regulatory anchors.
523
+
524
+ Frontend renders this inside PolicyDetailModal so the user can see exactly
525
+ how the headline number is computed and which of the 48 HealthPolicy fields
526
+ feed into which criterion.
527
+ """
528
+ from backend.scorecard import METHODOLOGY_BLUEPRINT, WEIGHTS, SCORED_FIELDS
529
+ return {
530
+ "weights": WEIGHTS,
531
+ "scored_fields_count": len(SCORED_FIELDS),
532
+ "total_schema_fields": 48,
533
+ "criteria": METHODOLOGY_BLUEPRINT,
534
+ "grade_thresholds": {
535
+ "A": "≥85 — strong all-rounder",
536
+ "B": "70–84 — good with a few gaps",
537
+ "C": "55–69 — check trade-offs",
538
+ "D": "40–54 — material concerns",
539
+ "F": "<40 — significant gaps",
540
+ },
541
+ "scoring_approach": (
542
+ "Rules-based (deterministic), no LLM-in-the-loop. Each criterion produces a "
543
+ "0–100 sub-score from concrete schema fields; the overall score is the weighted "
544
+ "average. Weights adapt to user profile when age/parents/budget are known."
545
+ ),
546
+ }
547
+
548
+
549
  def _build_corpus_url_index() -> dict[str, str]:
550
  """Parse data/corpus_urls.md and return {policy_id: source_url}. Used to
551
  backfill source_pdf_url when the LLM extraction didn't capture it."""
backend/scorecard.py CHANGED
@@ -281,6 +281,146 @@ WEIGHTS = {
281
  }
282
 
283
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  def grade_for(score: int) -> tuple[str, str]:
285
  """Return (letter, one-line summary tone)."""
286
  if score >= 85: return "A", "Strong all-rounder — solid pick for the buyer."
 
281
  }
282
 
283
 
284
+ # ----------------------------------------------------------------------------
285
+ # METHODOLOGY BLUEPRINT — the buyer-facing transparency layer
286
+ # ----------------------------------------------------------------------------
287
+ # Maps each of the 6 sub-scores to:
288
+ # - the consumer rationale (why this matters in plain English)
289
+ # - the concrete policy fields that drive its score (subset of the 48-field
290
+ # HealthPolicy schema)
291
+ # - the regulatory / industry anchors that justify the weight
292
+ # Used by /api/scorecard/methodology to render a customer-centric explanation
293
+ # of how the headline number is computed.
294
+ METHODOLOGY_BLUEPRINT = [
295
+ {
296
+ "name": "Coverage Breadth",
297
+ "weight_pct": 22,
298
+ "consumer_question": "When I actually need to claim, what's covered vs what's not?",
299
+ "why_it_matters": (
300
+ "Determines whether your hospital bill is fully reimbursed or whether you pay "
301
+ "out-of-pocket for gaps like AYUSH, maternity, newborn care, or ambulance."
302
+ ),
303
+ "fields_driving_score": [
304
+ {"field": "ayush_coverage", "rule": "AYUSH covered → +8"},
305
+ {"field": "day_care_treatments_count", "rule": "≥400 procedures → +10, ≥200 → +6, <100 → −5"},
306
+ {"field": "maternity_coverage", "rule": "Covered → +6"},
307
+ {"field": "newborn_coverage", "rule": "Covered → +4"},
308
+ {"field": "organ_donor_expenses", "rule": "Covered → +4"},
309
+ {"field": "ambulance_cover", "rule": "Covered → +3"},
310
+ {"field": "domiciliary_treatment", "rule": "Covered → +4"},
311
+ {"field": "preventive_health_checkup", "rule": "Free → +3"},
312
+ {"field": "pre_hospitalization_days", "rule": "≥60 days → +4"},
313
+ {"field": "post_hospitalization_days", "rule": "≥90 days → +4"},
314
+ ],
315
+ "anchors": [
316
+ "IRDAI Health Insurance Master Circular 2024 — emphasises comprehensive cover",
317
+ "Acko buying guide: coverage breadth most-cited buyer concern",
318
+ ],
319
+ },
320
+ {
321
+ "name": "Cost Predictability",
322
+ "weight_pct": 20,
323
+ "consumer_question": "Will I face surprise bills I can't plan for?",
324
+ "why_it_matters": (
325
+ "Co-pay forces you to pay a % of every claim; room-rent capping reduces what gets "
326
+ "reimbursed; sub-limits cap specific treatments below your sum insured. These "
327
+ "convert a known sum-insured into an unpredictable out-of-pocket exposure."
328
+ ),
329
+ "fields_driving_score": [
330
+ {"field": "copayment_pct", "rule": "0% → +0, 10% → −5, 20%+ → −12"},
331
+ {"field": "room_rent_capping", "rule": "No limit → +6, capped → −5 to −10"},
332
+ {"field": "deductible_amount", "rule": "₹0 → +0, ≥₹1L → −8"},
333
+ {"field": "sub_limits", "rule": "No condition-specific caps → +5"},
334
+ {"field": "icu_charges_capping", "rule": "No cap → +3"},
335
+ ],
336
+ "anchors": [
337
+ "IRDAI Master Circular — disclosure norms on co-pay/sub-limits",
338
+ "Common consumer complaint themes (IRDAI complaint logs)",
339
+ ],
340
+ },
341
+ {
342
+ "name": "Waiting-Period Friction",
343
+ "weight_pct": 18,
344
+ "consumer_question": "How soon can I actually use this policy if something happens?",
345
+ "why_it_matters": (
346
+ "Initial waiting period (30 days typical), pre-existing-disease waiting "
347
+ "(commonly 24–48 months), and maternity waits delay claims. Shorter is better — "
348
+ "especially for older buyers or those with diabetes/hypertension."
349
+ ),
350
+ "fields_driving_score": [
351
+ {"field": "initial_waiting_period_days", "rule": "≤30 days → 0, >30 days → −3"},
352
+ {"field": "pre_existing_disease_waiting_months", "rule": "≤24mo → +10, 36mo → 0, ≥48mo → −15"},
353
+ {"field": "maternity_waiting_months", "rule": "≤24mo → +5, ≥36mo → −5"},
354
+ {"field": "specific_disease_waiting_months", "rule": "≤24mo → +3"},
355
+ ],
356
+ "anchors": [
357
+ "IRDAI standard product specifications (Arogya Sanjeevani UIN guideline: 36-month PED max)",
358
+ "PolicyBazaar comparison data: 24-month PED is the buyer benchmark",
359
+ ],
360
+ },
361
+ {
362
+ "name": "Claim Experience",
363
+ "weight_pct": 20,
364
+ "consumer_question": "Will the insurer actually pay when I claim?",
365
+ "why_it_matters": (
366
+ "Coverage on paper means nothing if claims get denied or take weeks. We measure "
367
+ "cashless network reach, IRDAI's published Claim Settlement Ratio (CSR), the "
368
+ "complaint count per 10,000 policies, and how fast cashless pre-auth happens."
369
+ ),
370
+ "fields_driving_score": [
371
+ {"field": "cashless_treatment_supported", "rule": "Yes → +5"},
372
+ {"field": "network_hospital_count", "rule": "≥10,000 → +10, ≥5,000 → +5, <2,000 → −5"},
373
+ {"field": "claim_settlement_ratio (IRDAI)", "rule": "≥95% → +12, 90–95 → +6, <85% → −10"},
374
+ {"field": "complaints_per_10k_policies (IRDAI)", "rule": "<5 → +4, >20 → −8"},
375
+ {"field": "tat_cashless_authorization_hours", "rule": "≤2h → +4, ≥24h → −4"},
376
+ ],
377
+ "anchors": [
378
+ "IRDAI Annual Report 2023-24 — published CSR per insurer",
379
+ "IRDAI Grievance Redressal handbook — complaints/10K is the regulator's own metric",
380
+ ],
381
+ },
382
+ {
383
+ "name": "Renewal Protection",
384
+ "weight_pct": 12,
385
+ "consumer_question": "Can I keep this policy when I'm 70 and need it most?",
386
+ "why_it_matters": (
387
+ "Health insurance only works if you can keep renewing. Lifelong renewability is "
388
+ "the IRDAI default since 2020, but entry-age caps and porting friction still "
389
+ "matter. Buyers who don't check this often lose cover when claims rise."
390
+ ),
391
+ "fields_driving_score": [
392
+ {"field": "max_renewal_age", "rule": "Lifelong → +12, 80 → +6, ≤70 → −5"},
393
+ {"field": "max_entry_age", "rule": "≥65 → +4 (more buyers eligible)"},
394
+ {"field": "guaranteed_renewability", "rule": "Stated explicitly → +4"},
395
+ ],
396
+ "anchors": [
397
+ "IRDAI Master Circular 2024 — lifelong renewability mandate",
398
+ "IRDAI Portability Regulations 2020",
399
+ ],
400
+ },
401
+ {
402
+ "name": "Bonus & Loyalty",
403
+ "weight_pct": 8,
404
+ "consumer_question": "What do I get for staying claim-free and renewing year after year?",
405
+ "why_it_matters": (
406
+ "Claim-free years should compound value: most policies give 20–50% No-Claim Bonus "
407
+ "and some restore the sum insured on exhaustion. Free annual health checkups are "
408
+ "the lowest-hanging benefit most buyers don't realise they have."
409
+ ),
410
+ "fields_driving_score": [
411
+ {"field": "no_claim_bonus_pct", "rule": "≥50% → +8, ≥25% → +4"},
412
+ {"field": "restoration_benefit", "rule": "Present → +6"},
413
+ {"field": "preventive_health_checkup", "rule": "Free annually → +3"},
414
+ {"field": "wellness_program_present", "rule": "Yes → +2"},
415
+ ],
416
+ "anchors": [
417
+ "IRDAI 'Cumulative Bonus' rules — capped at 100% under standard products",
418
+ "Industry NCB best-practice (PolicyBazaar comparison standards)",
419
+ ],
420
+ },
421
+ ]
422
+
423
+
424
  def grade_for(score: int) -> tuple[str, str]:
425
  """Return (letter, one-line summary tone)."""
426
  if score >= 85: return "A", "Strong all-rounder — solid pick for the buyer."
frontend/src/app/page.tsx CHANGED
@@ -3,6 +3,7 @@
3
  import { useEffect, useRef, useState } from "react";
4
  import {
5
  audioBlobURLFromBase64,
 
6
  Citation,
7
  ChatMessage,
8
  CompareResponse,
@@ -1360,6 +1361,94 @@ function PolicyCard({
1360
  );
1361
  }
1362
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1363
  function Stat({ label, value }: { label: string; value: string }) {
1364
  return (
1365
  <div>
@@ -1586,6 +1675,7 @@ function PolicyDetailModal({ policy, onClose }: { policy: MarketplacePolicy; onC
1586
  </div>
1587
  </div>
1588
  <ScorecardCard sc={sc} />
 
1589
  </div>
1590
  )}
1591
 
 
3
  import { useEffect, useRef, useState } from "react";
4
  import {
5
  audioBlobURLFromBase64,
6
+ BACKEND_URL,
7
  Citation,
8
  ChatMessage,
9
  CompareResponse,
 
1361
  );
1362
  }
1363
 
1364
+ type MethodologyResponse = {
1365
+ weights: Record<string, number>;
1366
+ scored_fields_count: number;
1367
+ total_schema_fields: number;
1368
+ criteria: Array<{
1369
+ name: string;
1370
+ weight_pct: number;
1371
+ consumer_question: string;
1372
+ why_it_matters: string;
1373
+ fields_driving_score: Array<{ field: string; rule: string }>;
1374
+ anchors: string[];
1375
+ }>;
1376
+ grade_thresholds: Record<string, string>;
1377
+ scoring_approach: string;
1378
+ };
1379
+
1380
+ function MethodologyExpander() {
1381
+ const [open, setOpen] = useState(false);
1382
+ const [data, setData] = useState<MethodologyResponse | null>(null);
1383
+ useEffect(() => {
1384
+ if (open && !data) {
1385
+ fetch(`${BACKEND_URL}/api/scorecard/methodology`)
1386
+ .then((r) => r.json())
1387
+ .then(setData)
1388
+ .catch(() => setData(null));
1389
+ }
1390
+ }, [open, data]);
1391
+ return (
1392
+ <div className="mt-3 border border-[var(--border)] rounded-lg bg-[var(--card)]">
1393
+ <button
1394
+ onClick={() => setOpen(!open)}
1395
+ className="w-full text-left px-3 py-2 text-xs font-semibold flex items-center justify-between hover:bg-[var(--muted)]"
1396
+ >
1397
+ <span>How is this score computed? <span className="text-[var(--muted-foreground)] font-normal">(48 fields → 6 criteria, with weights)</span></span>
1398
+ <span className="text-[var(--muted-foreground)]">{open ? "−" : "+"}</span>
1399
+ </button>
1400
+ {open && (
1401
+ <div className="px-3 pb-3 space-y-3 text-xs border-t border-[var(--border)] pt-3">
1402
+ {!data && <div className="text-[var(--muted-foreground)] py-2">Loading methodology…</div>}
1403
+ {data && (
1404
+ <>
1405
+ <p className="text-[var(--muted-foreground)] leading-snug">
1406
+ {data.scoring_approach} The blueprint below shows which fields drive each criterion and what regulatory or buyer-research source justifies the weight.
1407
+ </p>
1408
+ {data.criteria.map((c) => (
1409
+ <div key={c.name} className="border border-[var(--border)] rounded-md p-2.5 bg-[var(--muted)]">
1410
+ <div className="flex items-baseline justify-between mb-1">
1411
+ <span className="text-xs font-bold">{c.name}</span>
1412
+ <span className="text-[10px] font-mono text-[var(--primary)]">{c.weight_pct}% of overall</span>
1413
+ </div>
1414
+ <div className="text-[11px] text-[var(--foreground)] italic mb-1">"{c.consumer_question}"</div>
1415
+ <div className="text-[11px] text-[var(--muted-foreground)] mb-2 leading-snug">{c.why_it_matters}</div>
1416
+ <details className="text-[11px]">
1417
+ <summary className="cursor-pointer text-[var(--primary)] hover:underline mb-1">
1418
+ {c.fields_driving_score.length} fields drive this score
1419
+ </summary>
1420
+ <ul className="mt-1 space-y-0.5 pl-2">
1421
+ {c.fields_driving_score.map((f, i) => (
1422
+ <li key={i} className="text-[10px]">
1423
+ <code className="text-[var(--primary)]">{f.field}</code>
1424
+ <span className="text-[var(--muted-foreground)]"> — {f.rule}</span>
1425
+ </li>
1426
+ ))}
1427
+ </ul>
1428
+ </details>
1429
+ {c.anchors.length > 0 && (
1430
+ <details className="text-[11px] mt-1">
1431
+ <summary className="cursor-pointer text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
1432
+ Why this weight? {c.anchors.length} source{c.anchors.length === 1 ? "" : "s"}
1433
+ </summary>
1434
+ <ul className="mt-1 space-y-0.5 pl-2 text-[10px] text-[var(--muted-foreground)]">
1435
+ {c.anchors.map((a, i) => <li key={i}>· {a}</li>)}
1436
+ </ul>
1437
+ </details>
1438
+ )}
1439
+ </div>
1440
+ ))}
1441
+ <div className="text-[10px] text-[var(--muted-foreground)] pt-1 border-t border-[var(--border)]">
1442
+ Grade bands: A ≥85, B 70–84, C 55–69, D 40–54, F &lt;40. Overall = weighted average of the 6 sub-scores (weights re-tuned to buyer profile when known).
1443
+ </div>
1444
+ </>
1445
+ )}
1446
+ </div>
1447
+ )}
1448
+ </div>
1449
+ );
1450
+ }
1451
+
1452
  function Stat({ label, value }: { label: string; value: string }) {
1453
  return (
1454
  <div>
 
1675
  </div>
1676
  </div>
1677
  <ScorecardCard sc={sc} />
1678
+ <MethodologyExpander />
1679
  </div>
1680
  )}
1681
 
tools/extract_policy_text.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Extract text from policy PDFs to a text cache for curating policy_facts JSON."""
2
+ import os, sys
3
+ import pdfplumber
4
+
5
+ BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
6
+ CACHE = "/tmp/claude/policy_extract/text_cache"
7
+ os.makedirs(CACHE, exist_ok=True)
8
+
9
+ PDFS = [
10
+ ("aditya-birla", "rag/corpus/aditya-birla/activ-assure-diamond__wordings.pdf"),
11
+ ("aditya-birla", "rag/corpus/aditya-birla/activ-one__brochure.pdf"),
12
+ ("bajaj-allianz", "rag/corpus/bajaj-allianz/health-guard-gold-individual__wordings.pdf"),
13
+ ("bajaj-allianz", "rag/corpus/bajaj-allianz/extra-care-plus__wordings.pdf"),
14
+ ("care-health", "rag/corpus/care-health/care-supreme__wordings.pdf"),
15
+ ("care-health", "rag/corpus/care-health/care-classic__wordings.pdf"),
16
+ ("care-health", "rag/corpus/care-health/care-senior__brochure.pdf"),
17
+ ("hdfc-ergo", "rag/corpus/hdfc-ergo/my-optima-secure__wordings.pdf"),
18
+ ("hdfc-ergo", "rag/corpus/hdfc-ergo/optima-restore__brochure.pdf"),
19
+ ("icici-lombard", "rag/corpus/icici-lombard/elevate__wordings.pdf"),
20
+ ("icici-lombard", "rag/corpus/icici-lombard/health-shield-360-retail__wordings.pdf"),
21
+ ("icici-lombard", "rag/corpus/icici-lombard/complete-health-insurance-health-shield__wordings.pdf"),
22
+ ("manipalcigna", "rag/corpus/manipalcigna/prohealth-insurance-all-variants__wordings.pdf"),
23
+ ("manipalcigna", "rag/corpus/manipalcigna/prohealth-select__wordings.pdf"),
24
+ ("new-india", "rag/corpus/new-india/new-india-floater-mediclaim-policy__wordings.pdf"),
25
+ ("niva-bupa", "rag/corpus/niva-bupa/reassure-2-0__wordings.pdf"),
26
+ ("niva-bupa", "rag/corpus/niva-bupa/senior-first__wordings.pdf"),
27
+ ("niva-bupa", "rag/corpus/niva-bupa/health-companion__wordings.pdf"),
28
+ ("star-health", "rag/corpus/star-health/family-health-optima__wordings.pdf"),
29
+ ("star-health", "rag/corpus/star-health/star-comprehensive__wordings.pdf"),
30
+ ("tata-aig", "rag/corpus/tata-aig/medicare-premier__wordings.pdf"),
31
+ ("tata-aig", "rag/corpus/tata-aig/medicare__wordings.pdf"),
32
+ ]
33
+
34
+ for insurer, rel in PDFS:
35
+ src = os.path.join(BASE, rel)
36
+ name = os.path.basename(rel).replace(".pdf", ".txt")
37
+ dst = os.path.join(CACHE, f"{insurer}__{name}")
38
+ if os.path.exists(dst):
39
+ print(f"skip {os.path.basename(dst)}")
40
+ continue
41
+ if not os.path.exists(src):
42
+ print(f"MISSING {src}")
43
+ continue
44
+ try:
45
+ with pdfplumber.open(src) as pdf:
46
+ text = "\n".join((p.extract_text() or "") for p in pdf.pages)
47
+ with open(dst, "w", encoding="utf-8") as f:
48
+ f.write(text)
49
+ print(f"OK {os.path.basename(dst)} ({len(text)} chars)")
50
+ except Exception as e:
51
+ print(f"ERR {src}: {e}")
52
+ print("Done.")