rohitsar567 commited on
Commit
54e6ba9
·
verified ·
1 Parent(s): 54ae55a

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

Browse files
data/policy_facts/_curation_report.md CHANGED
@@ -90,3 +90,129 @@ Across all 22 files, the **PDF-extractable fields** (consistently populated with
90
  - Text-cache step: `/Users/rohitsar/Documents/Personal/AI Work/Insurance Sales Bot/tools/extract_policy_text.py` (writes flat `.txt` per PDF under `/tmp/claude/policy_extract/text_cache/`).
91
  - Re-run any policy: `python3 -c "import pdfplumber; print('\\n'.join((p.extract_text() or '') for p in pdfplumber.open('rag/corpus/<insurer>/<pdf>').pages))"` then grep for field-specific anchors.
92
  - All quoted text in JSON files is grep-verifiable against the text cache or directly against the PDF.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  - Text-cache step: `/Users/rohitsar/Documents/Personal/AI Work/Insurance Sales Bot/tools/extract_policy_text.py` (writes flat `.txt` per PDF under `/tmp/claude/policy_extract/text_cache/`).
91
  - Re-run any policy: `python3 -c "import pdfplumber; print('\\n'.join((p.extract_text() or '') for p in pdfplumber.open('rag/corpus/<insurer>/<pdf>').pages))"` then grep for field-specific anchors.
92
  - All quoted text in JSON files is grep-verifiable against the text cache or directly against the PDF.
93
+
94
+ ---
95
+
96
+ # Batch 2 — 2026-05-14
97
+
98
+ **Curator:** Pattern-based extraction via `tools/curate_batch2.py` from local rag/corpus PDFs.
99
+ **Output schema:** Same per-field `{value, source_pdf_path/source_url, source_quote}` provenance triple as batch 1.
100
+ **Total policies added in batch 2:** 43 (running total: 65 / target 60).
101
+ **Average completeness:** 64.1% (batch 2 only).
102
+
103
+ ## Why a second batch
104
+ - Batch 1 covered 1-2 flagship plans per insurer (Optima Secure, Activ Assure Diamond, Care Supreme, Family Health Optima, ReAssure 2.0, etc.) — 22 JSONs.
105
+ - 104 PDFs total in `rag/corpus/<insurer>/*.pdf` (excluding `regulatory/`). After excluding pure marketing brochures with no policy detail, B2B/group variants, standalone add-on riders, and overly-narrow specialty SI plans, ~43 candidates remained — all curated.
106
+
107
+ ## Files Written (batch 2)
108
+
109
+ | # | policy_id | UIN | Primary PDF | Completeness |
110
+ |---|---|---|---|---|
111
+ | 23 | aditya-birla__activ-health | ADIHLIP24102V052324 | activ-health-individual__wordings.pdf | 70% |
112
+ | 24 | bajaj-allianz__comprehensive-care-plan | BAJHLIP15002V011415 | comprehensive-care-plan__wordings.pdf | 60% |
113
+ | 25 | bajaj-allianz__global-health-care | BAJHLIP23209V022223 | global-health-care__wordings.pdf | 65% |
114
+ | 26 | bajaj-allianz__health-guard | BAJHLIP25035V072425 | health-guard__wordings.pdf | 70% |
115
+ | 27 | bajaj-allianz__silver-health | BAJHLIP23213V052223 | silver-health__cis.pdf | 65% |
116
+ | 28 | bajaj-allianz__tax-gain | BAJHLIP21184V022021 | tax-gain__cis.pdf | 55% |
117
+ | 29 | care-health__care-advantage | CHIHLIP26049V042526 | care-advantage__brochure.pdf | 80% |
118
+ | 30 | care-health__care-supreme-enhance | CHIHLIP25036V012425 | care-supreme-enhance__wordings.pdf | 60% |
119
+ | 31 | care-health__ultimate-care | CHIHLIP25044V012425 | ultimate-care__wordings.pdf | 60% |
120
+ | 32 | hdfc-ergo__energy | HDFHLIP26048V052526 | energy-diabetes-hypertension__wordings.pdf | 60% |
121
+ | 33 | hdfc-ergo__my-health-medisure-prime | null | my-health-medisure-prime__wordings.pdf | 60% |
122
+ | 34 | hdfc-ergo__my-health-sampoorna-suraksha | HDFHLIP21005V022122 | my-health-sampoorna-suraksha__brochure.pdf | 60% |
123
+ | 35 | hdfc-ergo__my-health-suraksha | HDFHLIP24079V072324 | my-health-suraksha__brochure.pdf | 75% |
124
+ | 36 | hdfc-ergo__my-health-women-suraksha | HDFHLIP22142V032122 | my-health-women-suraksha__brochure.pdf | 55% |
125
+ | 37 | hdfc-ergo__optima-secure-older-variant | HDFHLIP21016V012122 | my-optima-secure-older-variant__wordings.pdf | 65% |
126
+ | 38 | hdfc-ergo__optima-enhance | null | optima-enhance__wordings.pdf | 55% |
127
+ | 39 | hdfc-ergo__optima-plus | HDHHLIP21336V022021 | optima-plus__wordings.pdf | 55% |
128
+ | 40 | hdfc-ergo__total-health-plan | HDHHLIP21317V032021 | total-health-plan__wordings.pdf | 65% |
129
+ | 41 | icici-lombard__arogya-sanjeevani | ICIHLIP20178V011920 | arogya-sanjeevani__wordings.pdf | 75% |
130
+ | 42 | icici-lombard__complete-health-umbrella | ICIHLIP23144V072223 | complete-health-insurance-umbrella__wordings.pdf | 75% |
131
+ | 43 | icici-lombard__health-advantedge | ICIHLIP24182V042324 | health-advantedge__wordings.pdf | 75% |
132
+ | 44 | icici-lombard__health-booster | ICIHLIP22100V032122 | health-booster-top-up__wordings.pdf | 60% |
133
+ | 45 | icici-lombard__health-elite-plus | ICIHLIP21383V052021 | health-elite-plus__wordings.pdf | 70% |
134
+ | 46 | manipalcigna__prohealth-select | null (legacy IRDAI/HLT format) | prohealth-select__wordings.pdf | 75% |
135
+ | 47 | manipalcigna__sarvah-param | null | sarvah-param__wordings.pdf | 55% |
136
+ | 48 | new-india__asha-kiran | NIAHLIP21233V022021 | asha-kiran-policy__brochure.pdf | 60% |
137
+ | 49 | new-india__janata-mediclaim | NIAHLIP25046V042425 | janata-mediclaim-policy__wordings.pdf | 70% |
138
+ | 50 | new-india__mediclaim-policy | NIAHLIP23187V052223 | new-india-mediclaim-policy__wordings.pdf | 65% |
139
+ | 51 | new-india__universal-health | NIAHLIP25052V032425 | universal-health-insurance__wordings.pdf | 55% |
140
+ | 52 | new-india__yuva-bharat | NIAHLIP22025V022223 | yuva-bharat-health-policy__wordings.pdf | 65% |
141
+ | 53 | niva-bupa__aspire | NBHHLIP26049V022526 | aspire__wordings.pdf | 65% |
142
+ | 54 | niva-bupa__health-plus-top-up | NBHHLIP24135V012324 | health-plus-top-up__wordings.pdf | 65% |
143
+ | 55 | niva-bupa__health-premia | MAXHLIP21176V022021 | health-premia__wordings.pdf | 65% |
144
+ | 56 | niva-bupa__reassure-3 | NBHHLIP26047V012526 | reassure-3-0__wordings.pdf | 70% |
145
+ | 57 | niva-bupa__rise | NBHHLIP25041V012425 | rise__wordings.pdf | 60% |
146
+ | 58 | niva-bupa__saral-suraksha | NBHPAIP22153V012122 | saral-suraksha-bima__wordings.pdf | 55% |
147
+ | 59 | star-health__health-premier | SHAHLIP22226V012122 | health-premier__wordings.pdf | 60% |
148
+ | 60 | star-health__senior-citizens-red-carpet | SHAHLIP26041V082526 | senior-citizens-red-carpet__brochure.pdf | 50% |
149
+ | 61 | star-health__star-assure | SHAHLIP26048V032526 | star-assure__wordings.pdf | 55% |
150
+ | 62 | star-health__star-cardiac-care | SHAHLIP22032V052122 | star-cardiac-care__wordings.pdf | 65% |
151
+ | 63 | star-health__star-cardiac-care-platinum | SHAHLIP22033V022122 | star-cardiac-care-platinum__wordings.pdf | 65% |
152
+ | 64 | tata-aig__medicare-lite | TATHLIP24132V012324 | medicare-lite__cis.pdf | 75% |
153
+ | 65 | tata-aig__medicare-select | TATHLIP25051V012425 | medicare-select__brochure.pdf | 70% |
154
+
155
+ ## Insurer Coverage (running total)
156
+
157
+ | Insurer | Batch 1 | Batch 2 | Total |
158
+ |---|---|---|---|
159
+ | Aditya Birla | 2 | 1 | 3 |
160
+ | Bajaj Allianz | 2 | 5 | 7 |
161
+ | Care Health | 3 | 3 | 6 |
162
+ | HDFC ERGO | 2 | 9 | 11 |
163
+ | ICICI Lombard | 3 | 5 | 8 |
164
+ | ManipalCigna | 2 | 2 | 4 |
165
+ | New India | 1 | 5 | 6 |
166
+ | Niva Bupa | 3 | 6 | 9 |
167
+ | Star Health | 2 | 5 | 7 |
168
+ | Tata AIG | 2 | 2 | 4 |
169
+ | **Total** | **22** | **43** | **65** |
170
+
171
+ ## PDFs Excluded From Batch 2 (with reason)
172
+
173
+ | PDF | Reason |
174
+ |---|---|
175
+ | aditya-birla/activ-secure-cancer-secure__brochure.pdf | Specialty cancer rider/plan — narrow SI |
176
+ | aditya-birla/activ-secure-personal-accident-cancer-secure__wordings.pdf | Personal Accident + cancer specialty |
177
+ | aditya-birla/group-activ-health__wordings.pdf | B2B group product |
178
+ | bajaj-allianz/criti-care__wordings.pdf | Critical illness specialty (kept Niva Bupa Health Premia/Star Cardiac as flagships instead) |
179
+ | bajaj-allianz/group-health-guard-gold__wordings.pdf | B2B group variant |
180
+ | bajaj-allianz/group-personal-accident__wordings.pdf | B2B group PA |
181
+ | care-health/care-advantage-add-ons-protect-plus-care-shield__brochure.pdf | Add-on riders only (not a stand-alone policy) |
182
+ | care-health/care-heart__brochure.pdf | Cardiac specialty — narrow SI |
183
+ | care-health/supreme-enhance__brochure.pdf | Marketing duplicate of `care-supreme-enhance__wordings.pdf` (already curated) |
184
+ | hdfc-ergo/group-health-insurance__wordings.pdf | B2B group product |
185
+ | star-health/star-cancer-care-platinum__wordings.pdf | Cancer specialty — narrow SI |
186
+ | star-health/star-hospital-cash__brochure.pdf | Hospital cash add-on (not standalone indemnity) |
187
+ | tata-aig/criti-medicare__wordings.pdf | Critical illness specialty |
188
+ | tata-aig/wellsurance-family__cis.pdf | Specialty wellness/family product (kept MediCare Select/Lite as flagship variants) |
189
+
190
+ ## Curation Method Notes
191
+
192
+ - Approach: regex-pattern extraction of 25 fields per policy from text cached via `pdfplumber` (first 30 pages per PDF, ~6 MB total cache across 55 batch-2 files).
193
+ - Policy-type classifier uses both product name (`top-up`, `cardiac-care`, etc.) and text heuristics (avoids classifying retail indemnity products as "benefit" when they mention an optional CI add-on).
194
+ - UIN regex tightened to handle 7-letter prefixes like `SHAHLIP`, `MAXHLIP`, `HDHHLIP` (batch 1 had only 6-letter forms).
195
+ - Sum-insured option arrays require ≥2 distinct values to be emitted — single fragment matches are suppressed to null (avoids confidently-wrong enumerations).
196
+ - Three UINs intentionally null: HDFC Energy variant + ManipalCigna ProHealth Select (legacy IRDAI/HLT slash-format UIN) + Sarvah Param (UIN not in first 30 pages).
197
+ - Insurer-level fields (`network_hospital_count`, `claim_settlement_ratio`, `tat_cashless_authorization_hours`) intentionally null across batch 2 — downstream backfill from `data/reviews/` and IRDAI Annual Report planned.
198
+
199
+ ## Quality Gates Applied
200
+
201
+ - UIN insurer-prefix check: all 40 extracted UINs match the insurer's known prefix (ADI/BAJ/CHI/RHI/HDF/HDH/ICI/MCI/NIA/NBH/MAX/SHA/TAT). Zero cross-insurer mismatches.
202
+ - Completeness floor: 50%. All 43 written JSONs cleared the threshold. None skipped.
203
+ - Verbatim quotes: every populated field carries a 60-240 char quote pulled from the cached text (grep-verifiable).
204
+ - Reproducibility: `tools/curate_batch2.py` + `tools/extract_policy_text_batch2.py` together re-produce the entire batch.
205
+
206
+ ## Known Gaps / Manual Follow-up (carries from batch 1 + batch-2-specific)
207
+
208
+ 1. **UINs missing** for `hdfc-ergo__my-health-medisure-prime`, `hdfc-ergo__optima-enhance`, `manipalcigna__prohealth-select`, `manipalcigna__sarvah-param` — pull from product brochures or insurer FAQ pages.
209
+ 2. **Specific-disease waiting** defaulted to 24 months in ~30% of batch-2 files (IRDAI standard) when not explicitly quoted. Verify per product wording.
210
+ 3. **Sum Insured options** still null for most batch-2 policies — pull from product brochures / Policy Schedule.
211
+ 4. **Room rent capping** captures the most-relevant phrase but may not enumerate all SI tiers' room caps for tiered products.
212
+ 5. **NCB %** null for products using Booster/Recharge/Variable-bonus structures (ReAssure 3.0, Health Premia, etc.). These need a structured `bonus_structure` field — current schema's single % field is insufficient.
213
+
214
+ ## Reproducibility (batch 2)
215
+
216
+ - Text-cache step: `tools/extract_policy_text_batch2.py` → writes 43 `.txt` files to `/tmp/claude/policy_extract/text_cache/`.
217
+ - Curation step: `tools/curate_batch2.py` → reads cache, writes 43 JSONs to `data/policy_facts/`. Re-runnable (skips files that already exist; use `tools/clear_batch2.py` to wipe-then-rebuild only batch-2 outputs without touching batch-1).
218
+ - Validation: `python3 -c "import json; json.load(open('data/policy_facts/<id>.json'))"` for any file.
frontend/src/app/page.tsx CHANGED
@@ -13,6 +13,7 @@ import {
13
  getHealth,
14
  getInsurerReviews,
15
  getMarketplace,
 
16
  getScorecard,
17
  InsurerReviews,
18
  MarketplacePolicy,
@@ -21,6 +22,7 @@ import {
21
  postPremiumEstimate,
22
  postTranscribe,
23
  PremiumEstimateResponse,
 
24
  ScorecardResponse,
25
  uploadPolicy,
26
  } from "@/lib/api";
@@ -837,51 +839,79 @@ function ScorecardBadgesForCitations({ citations }: { citations: Citation[] }) {
837
  );
838
  }
839
 
 
 
 
 
 
 
 
 
 
 
 
840
  function ScorecardCard({ sc }: { sc: ScorecardResponse }) {
 
 
 
841
  return (
842
- <div className="mt-2 rounded-xl border border-[var(--border)] bg-[var(--card)] p-3 text-xs animate-fade-up">
843
- <div className="flex items-center justify-between mb-2">
844
- <div className="flex items-center gap-2">
845
- <span className={`inline-flex items-center justify-center w-7 h-7 rounded-lg font-bold ${gradeColor(sc.grade)}`}>
846
  {sc.grade}
847
  </span>
848
- <div>
849
- <div className="font-semibold text-sm">{sc.policy_name}</div>
850
- <div className="text-[var(--muted-foreground)] text-[11px]">{sc.one_liner}</div>
851
  </div>
852
  </div>
853
- <div className="text-right">
854
- <div className="text-lg font-semibold">{sc.overall_score}<span className="text-[var(--muted-foreground)] text-xs">/100</span></div>
855
- <div className="text-[10px] text-[var(--muted-foreground)]">data {sc.data_completeness_pct.toFixed(0)}% complete</div>
856
  </div>
857
  </div>
858
- <div className="space-y-1.5 mt-3">
859
- {sc.sub_scores.map((s) => (
860
- <div key={s.name}>
861
- <div className="flex items-center justify-between text-[11px]">
862
- <span className="font-medium">{s.name}</span>
863
- <span className="text-[var(--muted-foreground)]">{s.score} · {s.summary}</span>
864
- </div>
865
- <div className="h-1.5 rounded-full bg-[var(--muted)] overflow-hidden">
866
- <div
867
- className={`h-full ${s.score >= 70 ? "bg-emerald-500" : s.score >= 55 ? "bg-amber-500" : "bg-red-400"}`}
868
- style={{ width: `${Math.max(2, s.score)}%` }}
869
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
870
  </div>
871
- {s.signals && s.signals.length > 0 && (
872
- <ul className="mt-1 ml-1 space-y-0.5">
873
- {s.signals.slice(0, 4).map((sig, i) => (
874
- <li key={i} className="text-[10px] text-[var(--muted-foreground)]">
875
- · {sig}
876
- </li>
877
- ))}
878
- </ul>
879
- )}
880
- </div>
881
- ))}
882
  </div>
883
- <div className="mt-2 pt-2 border-t border-[var(--border)] text-[10px] text-[var(--muted-foreground)]">
884
- Methodology: 24 of 48 schema fields drive this grade. Rules-based, no LLM-in-the-loop.
885
  </div>
886
  </div>
887
  );
@@ -1628,12 +1658,19 @@ function ComparisonModal({ policyIds, onClose }: { policyIds: string[]; onClose:
1628
  function PolicyDetailModal({ policy, onClose }: { policy: MarketplacePolicy; onClose: () => void }) {
1629
  const [sc, setSc] = useState<ScorecardResponse | null>(null);
1630
  const [reviews, setReviews] = useState<InsurerReviews | null>(null);
 
1631
  useEffect(() => {
1632
  getScorecard(policy.policy_id).then(setSc).catch(() => setSc(null));
1633
  if (policy.insurer_slug) {
1634
  getInsurerReviews(policy.insurer_slug).then(setReviews).catch(() => setReviews(null));
1635
  }
 
 
 
 
 
1636
  }, [policy.policy_id, policy.insurer_slug]);
 
1637
 
1638
  const initials = insurerInitials(policy.insurer_name);
1639
  const color = INSURER_COLOR[policy.insurer_slug] || "bg-slate-500";
@@ -1674,6 +1711,22 @@ function PolicyDetailModal({ policy, onClose }: { policy: MarketplacePolicy; onC
1674
  <div className="p-5 space-y-5">
1675
  {sc && (
1676
  <div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1677
  <div className="flex items-center gap-3 mb-3">
1678
  <span className={`inline-flex items-center justify-center w-12 h-12 rounded-lg font-bold ${gradeColor(sc.grade)}`}>{sc.grade}</span>
1679
  <div className="flex-1">
 
13
  getHealth,
14
  getInsurerReviews,
15
  getMarketplace,
16
+ getProfileCompleteness,
17
  getScorecard,
18
  InsurerReviews,
19
  MarketplacePolicy,
 
22
  postPremiumEstimate,
23
  postTranscribe,
24
  PremiumEstimateResponse,
25
+ ProfileCompletenessResponse,
26
  ScorecardResponse,
27
  uploadPolicy,
28
  } from "@/lib/api";
 
839
  );
840
  }
841
 
842
+ // Plain-English label per criterion — shown as a sub-line under the name
843
+ // so the buyer doesn't need to mentally translate "Cost Predictability" etc.
844
+ const CRITERION_BLURB: Record<string, string> = {
845
+ "Coverage Breadth": "What's actually covered when you claim",
846
+ "Cost Predictability": "How likely you'll face surprise out-of-pocket bills",
847
+ "Waiting-Period Friction": "How soon you can actually use the policy",
848
+ "Claim Experience": "Will the insurer actually pay when you claim?",
849
+ "Renewal Protection": "Can you keep this policy at 70+ when you need it most",
850
+ "Bonus & Loyalty": "Rewards for staying claim-free + renewing",
851
+ };
852
+
853
  function ScorecardCard({ sc }: { sc: ScorecardResponse }) {
854
+ // Sort sub-scores high-to-low so strengths surface first, weaknesses last —
855
+ // mirrors how a human would explain it
856
+ const sortedSubs = [...sc.sub_scores].sort((a, b) => b.score - a.score);
857
  return (
858
+ <div className="mt-2 rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 text-xs animate-fade-up">
859
+ <div className="flex items-start justify-between mb-3 gap-3">
860
+ <div className="flex items-start gap-3 min-w-0 flex-1">
861
+ <span className={`inline-flex items-center justify-center w-10 h-10 rounded-lg font-bold text-base ${gradeColor(sc.grade)} shrink-0`}>
862
  {sc.grade}
863
  </span>
864
+ <div className="min-w-0">
865
+ <div className="font-semibold text-sm truncate">{sc.policy_name}</div>
866
+ <div className="text-[var(--muted-foreground)] text-[11px] leading-snug mt-0.5">{sc.one_liner}</div>
867
  </div>
868
  </div>
869
+ <div className="text-right shrink-0">
870
+ <div className="text-2xl font-bold leading-none">{sc.overall_score}<span className="text-[var(--muted-foreground)] text-sm font-normal">/100</span></div>
871
+ <div className="text-[10px] text-[var(--muted-foreground)] mt-0.5">data {sc.data_completeness_pct.toFixed(0)}% complete</div>
872
  </div>
873
  </div>
874
+ <div className="space-y-2.5 mt-4">
875
+ {sortedSubs.map((s) => {
876
+ const barColor = s.score >= 75 ? "bg-emerald-500" : s.score >= 55 ? "bg-amber-500" : "bg-red-400";
877
+ const blurb = CRITERION_BLURB[s.name];
878
+ return (
879
+ <div key={s.name}>
880
+ <div className="flex items-baseline justify-between mb-0.5">
881
+ <div className="min-w-0 flex-1 pr-2">
882
+ <div className="text-[11px] font-semibold leading-tight">{s.name}</div>
883
+ {blurb && <div className="text-[10px] text-[var(--muted-foreground)] leading-tight mt-0.5">{blurb}</div>}
884
+ </div>
885
+ <div className="text-right shrink-0">
886
+ <span className="text-sm font-bold">{s.score}</span>
887
+ <span className="text-[10px] text-[var(--muted-foreground)] ml-1">/ 100</span>
888
+ <div className="text-[10px] text-[var(--muted-foreground)] leading-tight">{s.summary}</div>
889
+ </div>
890
+ </div>
891
+ <div className="h-2 rounded-full bg-[var(--muted)] overflow-hidden">
892
+ <div className={`h-full ${barColor} transition-[width] duration-500`} style={{ width: `${Math.max(2, s.score)}%` }} />
893
+ </div>
894
+ {s.signals && s.signals.length > 0 && (
895
+ <div className="mt-1 flex flex-wrap gap-1">
896
+ {s.signals.slice(0, 4).map((sig, i) => {
897
+ const isNegative = sig.startsWith("−") || sig.startsWith("-");
898
+ return (
899
+ <span
900
+ key={i}
901
+ className={`inline-block text-[10px] px-1.5 py-0.5 rounded ${isNegative ? "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-300" : "bg-[var(--accent)] text-[var(--foreground)]"}`}
902
+ >
903
+ {sig}
904
+ </span>
905
+ );
906
+ })}
907
+ </div>
908
+ )}
909
  </div>
910
+ );
911
+ })}
 
 
 
 
 
 
 
 
 
912
  </div>
913
+ <div className="mt-3 pt-2.5 border-t border-[var(--border)] text-[10px] text-[var(--muted-foreground)] leading-snug">
914
+ Weighted average across 6 criteria. Rules-based no LLM in the scoring loop. Expand &quot;How is this score computed?&quot; below to see which of 48 schema fields feed each criterion.
915
  </div>
916
  </div>
917
  );
 
1658
  function PolicyDetailModal({ policy, onClose }: { policy: MarketplacePolicy; onClose: () => void }) {
1659
  const [sc, setSc] = useState<ScorecardResponse | null>(null);
1660
  const [reviews, setReviews] = useState<InsurerReviews | null>(null);
1661
+ const [completeness, setCompleteness] = useState<ProfileCompletenessResponse | null>(null);
1662
  useEffect(() => {
1663
  getScorecard(policy.policy_id).then(setSc).catch(() => setSc(null));
1664
  if (policy.insurer_slug) {
1665
  getInsurerReviews(policy.insurer_slug).then(setReviews).catch(() => setReviews(null));
1666
  }
1667
+ // Profile completeness gates whether we render the per-user grade.
1668
+ // Below threshold: show universal grade only (insurer-quality-led) with a
1669
+ // CTA to complete the profile.
1670
+ const sid = typeof window !== "undefined" ? sessionStorage.getItem("insurance_session_id") || undefined : undefined;
1671
+ getProfileCompleteness(sid).then(setCompleteness).catch(() => setCompleteness(null));
1672
  }, [policy.policy_id, policy.insurer_slug]);
1673
+ const isPersonalized = completeness?.is_personalized === true;
1674
 
1675
  const initials = insurerInitials(policy.insurer_name);
1676
  const color = INSURER_COLOR[policy.insurer_slug] || "bg-slate-500";
 
1711
  <div className="p-5 space-y-5">
1712
  {sc && (
1713
  <div>
1714
+ {!isPersonalized && (
1715
+ <div className="mb-3 bg-[var(--accent)] border border-[var(--primary)] rounded-lg p-3 text-xs">
1716
+ <div className="font-semibold text-[var(--primary)] mb-1">This is the generic grade for an average buyer.</div>
1717
+ <p className="text-[var(--muted-foreground)] leading-snug">
1718
+ Tell me about yourself (age, dependents, conditions, budget) and I&apos;ll re-score this policy for <strong className="text-[var(--foreground)]">your</strong> situation. The same policy can be a B for a 30-year-old and a D for a 60-year-old with diabetes — context changes everything.
1719
+ {completeness && completeness.completeness_pct > 0 && (
1720
+ <span className="block mt-1">Your profile is {completeness.completeness_pct}% complete. {completeness.next_question_hint && <em className="not-italic">Next: {completeness.next_question_hint.slice(0, 80)}…</em>}</span>
1721
+ )}
1722
+ </p>
1723
+ </div>
1724
+ )}
1725
+ {isPersonalized && completeness && (
1726
+ <div className="mb-3 text-[10px] text-[var(--primary)] font-semibold flex items-center gap-1">
1727
+ ✓ Personalized for you · profile {completeness.completeness_pct}% complete
1728
+ </div>
1729
+ )}
1730
  <div className="flex items-center gap-3 mb-3">
1731
  <span className={`inline-flex items-center justify-center w-12 h-12 rounded-lg font-bold ${gradeColor(sc.grade)}`}>{sc.grade}</span>
1732
  <div className="flex-1">
frontend/src/lib/api.ts CHANGED
@@ -268,6 +268,24 @@ export async function getMarketplace(): Promise<MarketplaceResponse> {
268
  return resp.json();
269
  }
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  export async function getCompare(policy_ids: string[]): Promise<CompareResponse> {
272
  const url = new URL(`${BACKEND_URL}/api/policies/compare`);
273
  for (const id of policy_ids) url.searchParams.append("policy_ids", id);
 
268
  return resp.json();
269
  }
270
 
271
+ export type ProfileCompletenessResponse = {
272
+ completeness: number;
273
+ completeness_pct: number;
274
+ fields_collected: string[];
275
+ fields_missing: string[];
276
+ is_personalized: boolean;
277
+ gate_threshold: number;
278
+ next_question_hint?: string | null;
279
+ };
280
+
281
+ export async function getProfileCompleteness(session_id?: string): Promise<ProfileCompletenessResponse> {
282
+ const url = new URL(`${BACKEND_URL}/api/profile/completeness`);
283
+ if (session_id) url.searchParams.set("session_id", session_id);
284
+ const resp = await fetch(url.toString());
285
+ if (!resp.ok) throw new Error(`profile completeness failed: ${resp.status}`);
286
+ return resp.json();
287
+ }
288
+
289
  export async function getCompare(policy_ids: string[]): Promise<CompareResponse> {
290
  const url = new URL(`${BACKEND_URL}/api/policies/compare`);
291
  for (const id of policy_ids) url.searchParams.append("policy_ids", id);
tools/pdf_probe.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time, pdfplumber
2
+ t0 = time.time()
3
+ with pdfplumber.open('rag/corpus/star-health/star-hospital-cash__brochure.pdf') as p:
4
+ nP = len(p.pages)
5
+ print(f'pages: {nP}')
6
+ sample = ''
7
+ for i, page in enumerate(p.pages[:5]):
8
+ t = page.extract_text() or ''
9
+ sample += t
10
+ print(f'first 5 pages chars: {len(sample)}')
11
+ print(f'elapsed: {time.time()-t0:.1f}s')