rohitsar567 commited on
Commit
a21896f
·
verified ·
1 Parent(s): 3e6b7ee

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

Browse files
backend/main.py CHANGED
@@ -111,6 +111,34 @@ class TTSRequest(BaseModel):
111
  speaker: Optional[str] = None
112
 
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  # ---------- app ----------
115
 
116
  app = FastAPI(
@@ -227,6 +255,165 @@ async def chat(req: ChatRequest):
227
  )
228
 
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  @app.post("/api/tts")
231
  async def tts(req: TTSRequest):
232
  """Standalone TTS endpoint — returns base64 WAV."""
 
111
  speaker: Optional[str] = None
112
 
113
 
114
+ class PolicyEntry(BaseModel):
115
+ name: str
116
+ source_url: str = "" # PDF URL, verified at download time
117
+
118
+
119
+ class InsurerCoverage(BaseModel):
120
+ slug: str
121
+ name: str
122
+ home_url: str # insurer's main website (manually curated, verified)
123
+ policy_count: int
124
+ sample_policies: list[PolicyEntry]
125
+
126
+
127
+ class CoverageResponse(BaseModel):
128
+ total_chunks: int
129
+ total_policies: int
130
+ total_insurers: int
131
+ insurers: list[InsurerCoverage]
132
+
133
+
134
+ class UploadResponse(BaseModel):
135
+ policy_id: str
136
+ policy_name: str
137
+ chunks_added: int
138
+ pages_indexed: int
139
+ elapsed_ms: int
140
+
141
+
142
  # ---------- app ----------
143
 
144
  app = FastAPI(
 
255
  )
256
 
257
 
258
+ @app.get("/api/coverage", response_model=CoverageResponse)
259
+ async def coverage():
260
+ """What policies/insurers are indexed in the corpus.
261
+
262
+ Drives the UI's "what's covered" panel — sets user expectations + reduces
263
+ over-refusals from off-corpus queries.
264
+ """
265
+ try:
266
+ from rag.retrieve import get_collection
267
+ coll = get_collection()
268
+ total = coll.count()
269
+ except Exception:
270
+ total = 0
271
+
272
+ # Insurer metadata — names + home URLs are curated + verified
273
+ # (see eval/verified_urls.json + tools/verify_urls.py)
274
+ insurer_meta = {
275
+ "aditya-birla": ("Aditya Birla Health Insurance", "https://www.adityabirlacapital.com/healthinsurance"),
276
+ "bajaj-allianz": ("Bajaj Allianz General Insurance", "https://www.bajajallianz.com/"),
277
+ "care-health": ("Care Health Insurance", "https://www.careinsurance.com/"),
278
+ "hdfc-ergo": ("HDFC ERGO General Insurance", "https://www.hdfcergo.com/"),
279
+ "icici-lombard": ("ICICI Lombard General Insurance", "https://www.icicilombard.com/"),
280
+ "manipalcigna": ("ManipalCigna Health Insurance", "https://www.manipalcigna.com/"),
281
+ "new-india": ("New India Assurance", "https://www.newindia.co.in/"),
282
+ "niva-bupa": ("Niva Bupa Health Insurance", "https://www.nivabupa.com/"),
283
+ "star-health": ("Star Health & Allied Insurance", "https://www.starhealth.in/"),
284
+ "tata-aig": ("Tata AIG General Insurance", "https://www.tataaig.com/"),
285
+ "user-upload": ("Your uploaded policies", ""),
286
+ }
287
+
288
+ # policy -> source_url (verified at download time)
289
+ policy_urls: dict[tuple[str, str], str] = {}
290
+ by_insurer: dict[str, dict] = {}
291
+ if total > 0:
292
+ try:
293
+ res = coll.get(limit=10000, include=["metadatas"])
294
+ for m in res.get("metadatas", []):
295
+ slug = m.get("insurer_slug", "unknown")
296
+ name = m.get("policy_name", "")
297
+ url = m.get("source_url", "")
298
+ if slug not in by_insurer:
299
+ by_insurer[slug] = {"policies": set(), "chunks": 0}
300
+ by_insurer[slug]["policies"].add(name)
301
+ by_insurer[slug]["chunks"] += 1
302
+ if url and (slug, name) not in policy_urls:
303
+ policy_urls[(slug, name)] = url
304
+ except Exception:
305
+ pass
306
+
307
+ insurers_out = []
308
+ total_policies = 0
309
+ for slug, info in sorted(by_insurer.items()):
310
+ policy_names = sorted(info["policies"])
311
+ total_policies += len(policy_names)
312
+ name, home_url = insurer_meta.get(slug, (slug, ""))
313
+ sample_entries = [
314
+ PolicyEntry(name=p, source_url=policy_urls.get((slug, p), ""))
315
+ for p in policy_names[:8]
316
+ ]
317
+ insurers_out.append(
318
+ InsurerCoverage(
319
+ slug=slug,
320
+ name=name,
321
+ home_url=home_url,
322
+ policy_count=len(policy_names),
323
+ sample_policies=sample_entries,
324
+ )
325
+ )
326
+
327
+ return CoverageResponse(
328
+ total_chunks=total,
329
+ total_policies=total_policies,
330
+ total_insurers=len(insurers_out),
331
+ insurers=insurers_out,
332
+ )
333
+
334
+
335
+ @app.post("/api/upload-policy", response_model=UploadResponse)
336
+ async def upload_policy(file: UploadFile = File(...)):
337
+ """Accept a user-uploaded PDF policy doc, chunk + embed it, add to Chroma.
338
+
339
+ Note: in v1 demo this appends to the shared corpus (single-tenant).
340
+ Production would isolate by session/user.
341
+ """
342
+ import re
343
+ import tempfile
344
+ import time as _time
345
+ from pathlib import Path as _PathLib
346
+
347
+ t0 = _time.time()
348
+ contents = await file.read()
349
+ if not contents.startswith(b"%PDF"):
350
+ raise HTTPException(400, "File does not look like a PDF (magic bytes wrong).")
351
+ if len(contents) > 25 * 1024 * 1024:
352
+ raise HTTPException(413, "PDF too large (>25 MB). Use a smaller file.")
353
+
354
+ # Slugify filename for policy_id
355
+ raw = file.filename or "user_upload.pdf"
356
+ stem = _PathLib(raw).stem
357
+ slug = re.sub(r"[^a-zA-Z0-9]+", "-", stem.lower()).strip("-")[:80] or "user-upload"
358
+ policy_id = f"user-upload__{slug}"
359
+ policy_name = stem.replace("_", " ").replace("-", " ").title()
360
+
361
+ # Save to disk so ingest can read with pdfplumber
362
+ user_dir = settings.CORPUS_DIR / "user-upload"
363
+ user_dir.mkdir(parents=True, exist_ok=True)
364
+ out_path = user_dir / f"{slug}.pdf"
365
+ out_path.write_bytes(contents)
366
+
367
+ # Ingest just this one 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?).")
376
+
377
+ embedder = _Emb()
378
+ texts = [c["text"] for c in chunks]
379
+ vectors = await embedder.embed(texts, input_type="document")
380
+
381
+ ids = [f"{policy_id}::chunk{c['chunk_idx']}" for c in chunks]
382
+ metadatas = [
383
+ {
384
+ "policy_id": policy_id,
385
+ "insurer_slug": "user-upload",
386
+ "policy_name": policy_name,
387
+ "doc_type": "user_upload",
388
+ "source_url": "",
389
+ "page_start": c["page_start"],
390
+ "page_end": c["page_end"],
391
+ "chunk_idx": c["chunk_idx"],
392
+ "local_path": str(out_path),
393
+ }
394
+ for c in chunks
395
+ ]
396
+ collection = get_chroma_collection()
397
+ # Remove any existing chunks under this policy_id (re-upload case)
398
+ try:
399
+ collection.delete(where={"policy_id": policy_id})
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:
406
+ raise HTTPException(500, f"Indexing failed: {type(e).__name__}: {e}")
407
+
408
+ return UploadResponse(
409
+ policy_id=policy_id,
410
+ policy_name=policy_name,
411
+ chunks_added=len(chunks),
412
+ pages_indexed=len(pages),
413
+ elapsed_ms=int((_time.time() - t0) * 1000),
414
+ )
415
+
416
+
417
  @app.post("/api/tts")
418
  async def tts(req: TTSRequest):
419
  """Standalone TTS endpoint — returns base64 WAV."""
backend/orchestrator.py CHANGED
@@ -26,7 +26,19 @@ from rag.retrieve import RetrievedChunk, format_for_llm_context, retrieve
26
 
27
  # ---------- intent classification (v1: keyword heuristics) ----------
28
 
29
- COMPARISON_KEYWORDS = ("compare", "comparison", "vs", "versus", "between", "better")
 
 
 
 
 
 
 
 
 
 
 
 
30
  RECOMMEND_KEYWORDS = ("recommend", "should i", "which one", "best for", "suit me")
31
  INDIC_KEYWORDS = (
32
  # Devanagari letters
@@ -37,7 +49,10 @@ INDIC_KEYWORDS = (
37
 
38
 
39
  def classify_intent(query: str) -> str:
40
- q = query.lower()
 
 
 
41
  if any(kw in q for kw in COMPARISON_KEYWORDS):
42
  return "comparison"
43
  if any(kw in q for kw in RECOMMEND_KEYWORDS):
@@ -108,6 +123,36 @@ async def handle_turn(
108
  intent = classify_intent(user_text)
109
  language = detect_language(user_text)
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  # 2. Retrieve
112
  chunks: list[RetrievedChunk] = await retrieve(
113
  query=user_text,
 
26
 
27
  # ---------- intent classification (v1: keyword heuristics) ----------
28
 
29
+ # Fact-find triggers: conversational openers where the user is seeking advice,
30
+ # not asking a specific factual question about a known policy. These should
31
+ # bypass retrieval+faithfulness entirely and start the discovery flow.
32
+ FACT_FIND_TRIGGERS = (
33
+ "looking for", "i want", "i need", "help me find", "advice",
34
+ "first time", "new health insurance", "buy health insurance",
35
+ "should i get", "shopping for", "thinking about getting",
36
+ "want to buy", "best policy for me", "what do you recommend",
37
+ "i don't have", "no policy", "no insurance",
38
+ "hi", "hello", "hey", "namaste",
39
+ )
40
+
41
+ COMPARISON_KEYWORDS = ("compare", "comparison", "vs", "versus", "between policy", "which is better")
42
  RECOMMEND_KEYWORDS = ("recommend", "should i", "which one", "best for", "suit me")
43
  INDIC_KEYWORDS = (
44
  # Devanagari letters
 
49
 
50
 
51
  def classify_intent(query: str) -> str:
52
+ q = query.lower().strip()
53
+ # Greeting / advice-seeking openers → fact-find flow
54
+ if any(kw in q for kw in FACT_FIND_TRIGGERS) and len(q.split()) < 25:
55
+ return "fact_find"
56
  if any(kw in q for kw in COMPARISON_KEYWORDS):
57
  return "comparison"
58
  if any(kw in q for kw in RECOMMEND_KEYWORDS):
 
123
  intent = classify_intent(user_text)
124
  language = detect_language(user_text)
125
 
126
+ # 1a. Fact-find branch — conversational openers / advice-seeking queries
127
+ # bypass retrieval + faithfulness; we ask the next discovery question.
128
+ if intent == "fact_find":
129
+ from backend.needs_finder import Profile, next_question
130
+ profile = Profile()
131
+ if user_profile:
132
+ for k, v in user_profile.items():
133
+ if hasattr(profile, k):
134
+ setattr(profile, k, v)
135
+ q = next_question(profile, language=language)
136
+ if q is not None:
137
+ opener_en = "Happy to help. " if "hi" not in user_text.lower()[:3] else "Hi! "
138
+ opener_hi = "मदद के लिए तैयार हूँ। "
139
+ reply = (opener_hi + q.prompt_hi) if language == "indic" else (opener_en + q.prompt_en)
140
+ else:
141
+ reply = ("Great — sounds like you've thought through your needs. "
142
+ "Want to ask about a specific policy, or have me compare a few for your profile?")
143
+ return TurnResult(
144
+ reply_text=reply,
145
+ citations=[],
146
+ retrieved_chunk_ids=[],
147
+ brain_used="needs_finder::fact_find",
148
+ intent=intent,
149
+ language=language,
150
+ latency_ms=int((time.time() - t0) * 1000),
151
+ raw_reply=reply,
152
+ faithfulness_passed=True,
153
+ blocked=False,
154
+ )
155
+
156
  # 2. Retrieve
157
  chunks: list[RetrievedChunk] = await retrieve(
158
  query=user_text,
eval/verified_urls.json ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "verified_at": "2026-05-12T23:00:21Z",
3
+ "insurer_summary": {
4
+ "total": 10,
5
+ "ok": 7
6
+ },
7
+ "policy_summary": {
8
+ "total": 30,
9
+ "ok": 30
10
+ },
11
+ "insurers": {
12
+ "icici-lombard": {
13
+ "url": "https://www.icicilombard.com/",
14
+ "ok": false,
15
+ "status": 403,
16
+ "method": "GET-range",
17
+ "final_url": "https://www.icicilombard.com/",
18
+ "content_type": "text/html",
19
+ "name": "ICICI Lombard General Insurance"
20
+ },
21
+ "bajaj-allianz": {
22
+ "url": "https://www.bajajallianz.com/",
23
+ "ok": true,
24
+ "status": 200,
25
+ "method": "HEAD",
26
+ "final_url": "https://www.bajajgeneralinsurance.com/",
27
+ "content_type": "text/html;charset=utf-8",
28
+ "name": "Bajaj Allianz General Insurance"
29
+ },
30
+ "niva-bupa": {
31
+ "url": "https://www.nivabupa.com/",
32
+ "ok": true,
33
+ "status": 200,
34
+ "method": "HEAD",
35
+ "final_url": "https://www.nivabupa.com/",
36
+ "content_type": "text/html; charset=utf-8",
37
+ "name": "Niva Bupa Health Insurance"
38
+ },
39
+ "care-health": {
40
+ "url": "https://www.careinsurance.com/",
41
+ "ok": false,
42
+ "status": 403,
43
+ "method": "GET-range",
44
+ "final_url": "https://www.careinsurance.com/",
45
+ "content_type": "text/html;charset=utf-8",
46
+ "name": "Care Health Insurance"
47
+ },
48
+ "hdfc-ergo": {
49
+ "url": "https://www.hdfcergo.com/",
50
+ "ok": true,
51
+ "status": 200,
52
+ "method": "HEAD",
53
+ "final_url": "https://www.hdfcergo.com/",
54
+ "content_type": "text/html; charset=utf-8",
55
+ "name": "HDFC ERGO General Insurance"
56
+ },
57
+ "aditya-birla": {
58
+ "url": "https://www.adityabirlacapital.com/healthinsurance",
59
+ "ok": true,
60
+ "status": 200,
61
+ "method": "HEAD",
62
+ "final_url": "https://www.adityabirlacapital.com/healthinsurance/",
63
+ "content_type": "text/html; charset=UTF-8",
64
+ "name": "Aditya Birla Health Insurance"
65
+ },
66
+ "tata-aig": {
67
+ "url": "https://www.tataaig.com/",
68
+ "ok": true,
69
+ "status": 200,
70
+ "method": "HEAD",
71
+ "final_url": "https://www.tataaig.com/",
72
+ "content_type": "text/html; charset=utf-8",
73
+ "name": "Tata AIG General Insurance"
74
+ },
75
+ "manipalcigna": {
76
+ "url": "https://www.manipalcigna.com/",
77
+ "ok": true,
78
+ "status": 200,
79
+ "method": "HEAD",
80
+ "final_url": "https://www.manipalcigna.com/",
81
+ "content_type": "text/html; charset=utf-8",
82
+ "name": "ManipalCigna Health Insurance"
83
+ },
84
+ "new-india": {
85
+ "url": "https://www.newindia.co.in/",
86
+ "ok": true,
87
+ "status": 200,
88
+ "method": "HEAD",
89
+ "final_url": "https://www.newindia.co.in/",
90
+ "content_type": "text/html; charset=utf-8",
91
+ "name": "New India Assurance"
92
+ },
93
+ "star-health": {
94
+ "url": "https://www.starhealth.in/",
95
+ "ok": false,
96
+ "error": "ReadTimeout: HTTPSConnectionPool(host='www.starhealth.in', port=443): Read timed out. (read timeout=12.0)",
97
+ "name": "Star Health & Allied Insurance"
98
+ }
99
+ },
100
+ "policy_urls": [
101
+ {
102
+ "url": "https://www.hdfcergo.com/documents/downloads/HEHI/Presales-Set-2/OR-Brochure-Revision-V2.pdf",
103
+ "ok": true,
104
+ "status": 200,
105
+ "method": "HEAD",
106
+ "final_url": "https://www.hdfcergo.com/documents/downloads/HEHI/Presales-Set-2/OR-Brochure-Revision-V2.pdf",
107
+ "content_type": "application/pdf",
108
+ "policy_name": "Optima Restore",
109
+ "insurer_slug": "hdfc-ergo",
110
+ "doc_type": "brochure"
111
+ },
112
+ {
113
+ "url": "https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/optima-secure-revision-pw.pdf",
114
+ "ok": true,
115
+ "status": 200,
116
+ "method": "HEAD",
117
+ "final_url": "https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/optima-secure-revision-pw.pdf",
118
+ "content_type": "application/pdf",
119
+ "policy_name": "my:Optima Secure",
120
+ "insurer_slug": "hdfc-ergo",
121
+ "doc_type": "wordings"
122
+ },
123
+ {
124
+ "url": "https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/others/optima-enhance-policy-wording.pdf",
125
+ "ok": true,
126
+ "status": 200,
127
+ "method": "HEAD",
128
+ "final_url": "https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/others/optima-enhance-policy-wording.pdf",
129
+ "content_type": "application/pdf",
130
+ "policy_name": "Optima Enhance",
131
+ "insurer_slug": "hdfc-ergo",
132
+ "doc_type": "wordings"
133
+ },
134
+ {
135
+ "url": "https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/my-optima-secure_old_pws.pdf",
136
+ "ok": true,
137
+ "status": 200,
138
+ "method": "HEAD",
139
+ "final_url": "https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/my-optima-secure_old_pws.pdf",
140
+ "content_type": "application/pdf",
141
+ "policy_name": "my:Optima Secure (older variant)",
142
+ "insurer_slug": "hdfc-ergo",
143
+ "doc_type": "wordings"
144
+ },
145
+ {
146
+ "url": "https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/others/policy-wordings---prime---hrc.pdf",
147
+ "ok": true,
148
+ "status": 200,
149
+ "method": "HEAD",
150
+ "final_url": "https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/others/policy-wordings---prime---hrc.pdf",
151
+ "content_type": "application/pdf",
152
+ "policy_name": "my:health Medisure Prime",
153
+ "insurer_slug": "hdfc-ergo",
154
+ "doc_type": "wordings"
155
+ },
156
+ {
157
+ "url": "https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/optima-plus-policy-wordings.pdf",
158
+ "ok": true,
159
+ "status": 200,
160
+ "method": "HEAD",
161
+ "final_url": "https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/optima-plus-policy-wordings.pdf",
162
+ "content_type": "application/pdf",
163
+ "policy_name": "Optima Plus",
164
+ "insurer_slug": "hdfc-ergo",
165
+ "doc_type": "wordings"
166
+ },
167
+ {
168
+ "url": "https://www.hdfcergo.com/docs/default-source/downloads/prospectus/health/my_sampoorna_suraksha.pdf",
169
+ "ok": true,
170
+ "status": 200,
171
+ "method": "HEAD",
172
+ "final_url": "https://www.hdfcergo.com/docs/default-source/downloads/prospectus/health/my_sampoorna_suraksha.pdf",
173
+ "content_type": "application/pdf",
174
+ "policy_name": "my:health Sampoorna Suraksha",
175
+ "insurer_slug": "hdfc-ergo",
176
+ "doc_type": "brochure"
177
+ },
178
+ {
179
+ "url": "https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/energy-combined-pw-cis.pdf",
180
+ "ok": true,
181
+ "status": 200,
182
+ "method": "HEAD",
183
+ "final_url": "https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/energy-combined-pw-cis.pdf",
184
+ "content_type": "application/pdf",
185
+ "policy_name": "Energy (Diabetes/Hypertension)",
186
+ "insurer_slug": "hdfc-ergo",
187
+ "doc_type": "wordings"
188
+ },
189
+ {
190
+ "url": "https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/group-health-insurance---pw.pdf",
191
+ "ok": true,
192
+ "status": 200,
193
+ "method": "HEAD",
194
+ "final_url": "https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/group-health-insurance---pw.pdf",
195
+ "content_type": "application/pdf",
196
+ "policy_name": "Group Health Insurance",
197
+ "insurer_slug": "hdfc-ergo",
198
+ "doc_type": "wordings"
199
+ },
200
+ {
201
+ "url": "https://www.hdfcergo.com/docs/default-source/downloads/prospectus/health/myhealth-suraksha---prospectus.pdf",
202
+ "ok": true,
203
+ "status": 200,
204
+ "method": "HEAD",
205
+ "final_url": "https://www.hdfcergo.com/docs/default-source/downloads/prospectus/health/myhealth-suraksha---prospectus.pdf",
206
+ "content_type": "application/pdf",
207
+ "policy_name": "my:health Suraksha",
208
+ "insurer_slug": "hdfc-ergo",
209
+ "doc_type": "brochure"
210
+ },
211
+ {
212
+ "url": "https://www.hdfcergo.com/docs/default-source/downloads/brochures/myhealth-women-suraksha-with-premium-table.pdf",
213
+ "ok": true,
214
+ "status": 200,
215
+ "method": "HEAD",
216
+ "final_url": "https://www.hdfcergo.com/docs/default-source/downloads/brochures/myhealth-women-suraksha-with-premium-table.pdf",
217
+ "content_type": "application/pdf",
218
+ "policy_name": "my:health Women Suraksha",
219
+ "insurer_slug": "hdfc-ergo",
220
+ "doc_type": "brochure"
221
+ },
222
+ {
223
+ "url": "https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/total-health-plan--oct-2021.pdf",
224
+ "ok": true,
225
+ "status": 200,
226
+ "method": "HEAD",
227
+ "final_url": "https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/total-health-plan--oct-2021.pdf",
228
+ "content_type": "application/pdf",
229
+ "policy_name": "Total Health Plan",
230
+ "insurer_slug": "hdfc-ergo",
231
+ "doc_type": "wordings"
232
+ },
233
+ {
234
+ "url": "https://transactions.nivabupa.com/pages/doc/brochure/Health_Companion_V2022_Br.pdf?v=1.1",
235
+ "ok": true,
236
+ "status": 200,
237
+ "method": "HEAD",
238
+ "final_url": "https://transactions.nivabupa.com/pages/doc/brochure/Health_Companion_V2022_Br.pdf?v=1.1",
239
+ "content_type": "application/pdf",
240
+ "policy_name": "Health Companion V2022",
241
+ "insurer_slug": "niva-bupa",
242
+ "doc_type": "brochure"
243
+ },
244
+ {
245
+ "url": "https://transactions.nivabupa.com/pages/doc/policy_wording/ReAssure-2.0-Policy-Wording.pdf",
246
+ "ok": true,
247
+ "status": 200,
248
+ "method": "HEAD",
249
+ "final_url": "https://transactions.nivabupa.com/pages/doc/policy_wording/ReAssure-2.0-Policy-Wording.pdf",
250
+ "content_type": "application/pdf",
251
+ "policy_name": "ReAssure 2.0",
252
+ "insurer_slug": "niva-bupa",
253
+ "doc_type": "wordings"
254
+ },
255
+ {
256
+ "url": "https://transactions.nivabupa.com/pages/doc/policy_wording/Rise_Policy_Wordings.pdf",
257
+ "ok": true,
258
+ "status": 200,
259
+ "method": "HEAD",
260
+ "final_url": "https://transactions.nivabupa.com/pages/doc/policy_wording/Rise_Policy_Wordings.pdf",
261
+ "content_type": "application/pdf",
262
+ "policy_name": "RISE",
263
+ "insurer_slug": "niva-bupa",
264
+ "doc_type": "wordings"
265
+ },
266
+ {
267
+ "url": "https://transactions.nivabupa.com/pages/doc/policy_wording/ReAssure30_Policy_Wordings.pdf",
268
+ "ok": true,
269
+ "status": 200,
270
+ "method": "HEAD",
271
+ "final_url": "https://transactions.nivabupa.com/pages/doc/policy_wording/ReAssure30_Policy_Wordings.pdf",
272
+ "content_type": "application/pdf",
273
+ "policy_name": "ReAssure 3.0",
274
+ "insurer_slug": "niva-bupa",
275
+ "doc_type": "wordings"
276
+ },
277
+ {
278
+ "url": "https://transactions.nivabupa.com/pages/doc/policy_wording/Aspire_Policy_Wordings.pdf?v=1.3",
279
+ "ok": true,
280
+ "status": 200,
281
+ "method": "HEAD",
282
+ "final_url": "https://transactions.nivabupa.com/pages/doc/policy_wording/Aspire_Policy_Wordings.pdf?v=1.3",
283
+ "content_type": "application/pdf",
284
+ "policy_name": "Aspire",
285
+ "insurer_slug": "niva-bupa",
286
+ "doc_type": "wordings"
287
+ },
288
+ {
289
+ "url": "https://www.nivabupa.com/content/dam/nivabupa/PDF/health-companion-policy-wording.pdf",
290
+ "ok": true,
291
+ "status": 200,
292
+ "method": "HEAD",
293
+ "final_url": "https://www.nivabupa.com/content/dam/nivabupa/PDF/health-companion-policy-wording.pdf",
294
+ "content_type": "application/pdf",
295
+ "policy_name": "Health Companion",
296
+ "insurer_slug": "niva-bupa",
297
+ "doc_type": "wordings"
298
+ },
299
+ {
300
+ "url": "https://cms.careinsurance.com/cms/public/uploads/download_center/care-supreme---policy-terms-and-conditions.pdf",
301
+ "ok": true,
302
+ "status": 200,
303
+ "method": "HEAD",
304
+ "final_url": "https://cms.careinsurance.com/cms/public/uploads/download_center/care-supreme---policy-terms-and-conditions.pdf",
305
+ "content_type": "application/pdf",
306
+ "policy_name": "Care Supreme",
307
+ "insurer_slug": "care-health",
308
+ "doc_type": "wordings"
309
+ },
310
+ {
311
+ "url": "https://transactions.nivabupa.com/pages/doc/policy_wording/Healthplus_Policy_Wordings.pdf?v=1.2",
312
+ "ok": true,
313
+ "status": 200,
314
+ "method": "HEAD",
315
+ "final_url": "https://transactions.nivabupa.com/pages/doc/policy_wording/Healthplus_Policy_Wordings.pdf?v=1.2",
316
+ "content_type": "application/pdf",
317
+ "policy_name": "Health Plus (Top-up)",
318
+ "insurer_slug": "niva-bupa",
319
+ "doc_type": "wordings"
320
+ },
321
+ {
322
+ "url": "https://cms.careinsurance.com/cms/public/uploads/download_center/supreme-enhance---policy-terms-and-conditions.pdf",
323
+ "ok": true,
324
+ "status": 200,
325
+ "method": "HEAD",
326
+ "final_url": "https://cms.careinsurance.com/cms/public/uploads/download_center/supreme-enhance---policy-terms-and-conditions.pdf",
327
+ "content_type": "application/pdf",
328
+ "policy_name": "Care Supreme Enhance",
329
+ "insurer_slug": "care-health",
330
+ "doc_type": "wordings"
331
+ },
332
+ {
333
+ "url": "https://cms.careinsurance.com/cms/public/uploads/download_center/supreme-enhance---brochure.pdf",
334
+ "ok": true,
335
+ "status": 200,
336
+ "method": "HEAD",
337
+ "final_url": "https://cms.careinsurance.com/cms/public/uploads/download_center/supreme-enhance---brochure.pdf",
338
+ "content_type": "application/pdf",
339
+ "policy_name": "Supreme Enhance",
340
+ "insurer_slug": "care-health",
341
+ "doc_type": "brochure"
342
+ },
343
+ {
344
+ "url": "https://cms.careinsurance.com/cms/public/uploads/download_center/care-classic---(health-insurance-product)-policy-terms-&-conditions.pdf",
345
+ "ok": true,
346
+ "status": 200,
347
+ "method": "HEAD",
348
+ "final_url": "https://cms.careinsurance.com/cms/public/uploads/download_center/care-classic---(health-insurance-product)-policy-terms-&-conditions.pdf",
349
+ "content_type": "application/pdf",
350
+ "policy_name": "Care Classic",
351
+ "insurer_slug": "care-health",
352
+ "doc_type": "wordings"
353
+ },
354
+ {
355
+ "url": "https://www.nivabupa.com/content/dam/nivabupa/PDF/health-premia/Health%20Premia%20Policy%20Wording.pdf",
356
+ "ok": true,
357
+ "status": 200,
358
+ "method": "HEAD",
359
+ "final_url": "https://transactions.nivabupa.com/pages/doc/policy_wording/Health-Premia-Policy-Wording.pdf?v=1.0",
360
+ "content_type": "application/pdf",
361
+ "policy_name": "Health Premia",
362
+ "insurer_slug": "niva-bupa",
363
+ "doc_type": "wordings"
364
+ },
365
+ {
366
+ "url": "https://www.nivabupa.com/content/dam/nivabupa/PDF/senior-first/Senior_First_Policy_Wordings.pdf",
367
+ "ok": true,
368
+ "status": 200,
369
+ "method": "HEAD",
370
+ "final_url": "https://www.nivabupa.com/content/dam/nivabupa/PDF/senior-first/Senior_First_Policy_Wordings.pdf",
371
+ "content_type": "application/pdf",
372
+ "policy_name": "Senior First",
373
+ "insurer_slug": "niva-bupa",
374
+ "doc_type": "wordings"
375
+ },
376
+ {
377
+ "url": "https://www.nivabupa.com/content/dam/nivabupa/PDF/Saral%20Suraksha%20Bima_Policy%20Wording.pdf",
378
+ "ok": true,
379
+ "status": 200,
380
+ "method": "HEAD",
381
+ "final_url": "https://www.nivabupa.com/content/dam/nivabupa/PDF/Saral%20Suraksha%20Bima_Policy%20Wording.pdf",
382
+ "content_type": "application/pdf",
383
+ "policy_name": "Saral Suraksha Bima",
384
+ "insurer_slug": "niva-bupa",
385
+ "doc_type": "wordings"
386
+ },
387
+ {
388
+ "url": "https://cms.careinsurance.com/cms/public/uploads/download_center/ultimate-care-----policy-terms-&-conditions.pdf",
389
+ "ok": true,
390
+ "status": 200,
391
+ "method": "HEAD",
392
+ "final_url": "https://cms.careinsurance.com/cms/public/uploads/download_center/ultimate-care-----policy-terms-&-conditions.pdf",
393
+ "content_type": "application/pdf",
394
+ "policy_name": "Ultimate Care",
395
+ "insurer_slug": "care-health",
396
+ "doc_type": "wordings"
397
+ },
398
+ {
399
+ "url": "https://cms.careinsurance.com/cms/public/uploads/download_center/care-senior-brochure.pdf",
400
+ "ok": true,
401
+ "status": 200,
402
+ "method": "HEAD",
403
+ "final_url": "https://cms.careinsurance.com/cms/public/uploads/download_center/care-senior-brochure.pdf",
404
+ "content_type": "application/pdf",
405
+ "policy_name": "Care Senior",
406
+ "insurer_slug": "care-health",
407
+ "doc_type": "brochure"
408
+ },
409
+ {
410
+ "url": "https://cms.careinsurance.com/cms/public/uploads/download_center/care-advantage-(health-insurance-product)---brochure.pdf",
411
+ "ok": true,
412
+ "status": 200,
413
+ "method": "HEAD",
414
+ "final_url": "https://cms.careinsurance.com/cms/public/uploads/download_center/care-advantage-(health-insurance-product)---brochure.pdf",
415
+ "content_type": "application/pdf",
416
+ "policy_name": "Care Advantage",
417
+ "insurer_slug": "care-health",
418
+ "doc_type": "brochure"
419
+ },
420
+ {
421
+ "url": "https://cms.careinsurance.com/cms/public/uploads/download_center/care-advantage-with-add-on-protect-plus-&-care-shield-brochure.pdf",
422
+ "ok": true,
423
+ "status": 200,
424
+ "method": "HEAD",
425
+ "final_url": "https://cms.careinsurance.com/cms/public/uploads/download_center/care-advantage-with-add-on-protect-plus-&-care-shield-brochure.pdf",
426
+ "content_type": "application/pdf",
427
+ "policy_name": "Care Advantage + add-ons (Protect Plus + Care Shield)",
428
+ "insurer_slug": "care-health",
429
+ "doc_type": "brochure"
430
+ }
431
+ ]
432
+ }
frontend/src/app/page.tsx CHANGED
@@ -5,9 +5,12 @@ import {
5
  audioBlobURLFromBase64,
6
  Citation,
7
  ChatMessage,
 
 
8
  getHealth,
9
  postChat,
10
  postTranscribe,
 
11
  } from "@/lib/api";
12
 
13
  type DisplayMessage = ChatMessage & {
@@ -16,13 +19,14 @@ type DisplayMessage = ChatMessage & {
16
  audioUrl?: string;
17
  brain?: string;
18
  latencyMs?: number;
 
19
  };
20
 
21
  const SUGGESTED_QUESTIONS = [
 
22
  "What is the waiting period for pre-existing diseases?",
23
- "Does HDFC ERGO Optima Secure cover ayurveda?",
24
- "Compare maternity coverage across Star and Niva Bupa",
25
- "What's the room rent cap on Care Health policies?",
26
  ];
27
 
28
  export default function Page() {
@@ -33,16 +37,23 @@ export default function Page() {
33
  const [returnAudio, setReturnAudio] = useState(true);
34
  const [ttsLang, setTtsLang] = useState<"en-IN" | "hi-IN">("en-IN");
35
  const [health, setHealth] = useState<{ status: string; missing: string[] } | null>(null);
 
 
36
  const [sessionId, setSessionId] = useState<string | undefined>();
 
37
 
38
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
39
  const audioChunksRef = useRef<Blob[]>([]);
 
40
  const scrollRef = useRef<HTMLDivElement>(null);
41
 
42
  useEffect(() => {
43
  getHealth()
44
  .then((h) => setHealth({ status: h.status, missing: h.missing_keys }))
45
  .catch(() => setHealth({ status: "unreachable", missing: [] }));
 
 
 
46
  }, []);
47
 
48
  useEffect(() => {
@@ -50,15 +61,10 @@ export default function Page() {
50
  }, [messages]);
51
 
52
  function pushUser(text: string) {
53
- const id = `u_${Date.now()}`;
54
- setMessages((m) => [...m, { id, role: "user", content: text }]);
55
- return id;
56
  }
57
-
58
  function pushAssistant(content: string, extras: Partial<DisplayMessage> = {}) {
59
- const id = `a_${Date.now()}`;
60
- setMessages((m) => [...m, { id, role: "assistant", content, ...extras }]);
61
- return id;
62
  }
63
 
64
  async function send(text: string) {
@@ -66,7 +72,6 @@ export default function Page() {
66
  setBusy(true);
67
  setInput("");
68
  pushUser(text);
69
-
70
  try {
71
  const history: ChatMessage[] = messages.map((m) => ({ role: m.role, content: m.content }));
72
  const res = await postChat({
@@ -83,14 +88,14 @@ export default function Page() {
83
  audioUrl,
84
  brain: res.brain_used,
85
  latencyMs: res.latency_ms,
 
86
  });
87
  if (audioUrl) {
88
  const audio = new Audio(audioUrl);
89
  audio.play().catch(() => {});
90
  }
91
  } catch (e: unknown) {
92
- const msg = e instanceof Error ? e.message : String(e);
93
- pushAssistant(`Sorry — backend error: ${msg}`);
94
  } finally {
95
  setBusy(false);
96
  }
@@ -100,39 +105,24 @@ export default function Page() {
100
  try {
101
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
102
  const mime = MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : "";
103
- const recorder = mime
104
- ? new MediaRecorder(stream, { mimeType: mime })
105
- : new MediaRecorder(stream);
106
  mediaRecorderRef.current = recorder;
107
  audioChunksRef.current = [];
108
-
109
- recorder.ondataavailable = (ev) => {
110
- if (ev.data.size > 0) audioChunksRef.current.push(ev.data);
111
- };
112
-
113
  recorder.onstop = async () => {
114
  stream.getTracks().forEach((t) => t.stop());
115
- const blob = new Blob(audioChunksRef.current, {
116
- type: recorder.mimeType || "audio/webm",
117
- });
118
  setRecording(false);
119
  if (blob.size < 1000) return;
120
  setBusy(true);
121
  try {
122
  const { text } = await postTranscribe(blob, ttsLang);
123
- if (text && text.trim()) {
124
- await send(text);
125
- } else {
126
- pushAssistant("Sorry, I couldn't hear that clearly. Please try again.");
127
- }
128
  } catch (e: unknown) {
129
- const msg = e instanceof Error ? e.message : String(e);
130
- pushAssistant(`Sorry transcribe error: ${msg}`);
131
- } finally {
132
- setBusy(false);
133
- }
134
  };
135
-
136
  recorder.start();
137
  setRecording(true);
138
  } catch (e) {
@@ -140,78 +130,102 @@ export default function Page() {
140
  pushAssistant(`Sorry — mic permission denied or unavailable.`);
141
  }
142
  }
 
143
 
144
- function stopRecording() {
145
- mediaRecorderRef.current?.stop();
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  }
147
 
148
  return (
149
  <div className="min-h-screen flex flex-col bg-[var(--background)] text-[var(--foreground)]">
150
- {/* Header */}
151
  <header className="border-b border-[var(--border)] bg-[var(--card)]">
152
  <div className="max-w-6xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
153
  <div className="flex items-center gap-3">
154
- <div className="w-9 h-9 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] flex items-center justify-center font-bold text-sm">
155
- IA
156
- </div>
157
  <div>
158
- <h1 className="font-semibold text-base sm:text-lg leading-tight">
159
- Insurance Sales Portfolio Expert
160
- </h1>
161
- <p className="text-xs text-[var(--muted-foreground)]">
162
- Voice-first AI advisor • Indian health insurance • Sarvam AI
163
- </p>
164
  </div>
165
  </div>
166
  <div className="flex items-center gap-3">
 
 
 
 
 
 
 
 
167
  <HealthBadge health={health} />
168
  </div>
169
  </div>
 
170
  </header>
171
 
172
- {/* Main */}
173
  <main className="flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 py-4 sm:py-6 flex flex-col">
174
  {messages.length === 0 ? (
175
- <EmptyState onSuggest={(q) => send(q)} />
176
  ) : (
177
- <div
178
- ref={scrollRef}
179
- className="flex-1 overflow-y-auto scrollbar-thin space-y-4 mb-4 pr-1"
180
- >
181
- {messages.map((m) => (
182
- <Message key={m.id} m={m} />
183
- ))}
184
  {busy && <ThinkingDots />}
185
  </div>
186
  )}
187
 
188
- {/* Input bar */}
 
 
 
 
 
189
  <div className="border border-[var(--border)] rounded-2xl bg-[var(--card)] p-3 shadow-sm">
190
  <div className="flex items-end gap-2">
191
  <textarea
192
  value={input}
193
  onChange={(e) => setInput(e.target.value)}
194
- onKeyDown={(e) => {
195
- if (e.key === "Enter" && !e.shiftKey) {
196
- e.preventDefault();
197
- send(input);
198
- }
199
- }}
200
  placeholder="Ask about coverage, waiting periods, exclusions, or compare policies…"
201
  rows={1}
202
  className="flex-1 resize-none bg-transparent outline-none text-sm sm:text-base px-2 py-2 min-h-[40px] max-h-32"
203
  disabled={busy}
204
  />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  <button
206
  type="button"
207
  onClick={recording ? stopRecording : startRecording}
208
  disabled={busy && !recording}
209
  className={`shrink-0 w-11 h-11 rounded-xl flex items-center justify-center transition-all ${
210
- recording
211
- ? "bg-[var(--error)] text-white animate-record-pulse"
212
- : "bg-[var(--muted)] hover:bg-[var(--border)] text-[var(--foreground)]"
213
  } disabled:opacity-40`}
214
- title={recording ? "Stop recording" : "Click to record (push-to-talk)"}
215
  >
216
  {recording ? <StopIcon /> : <MicIcon />}
217
  </button>
@@ -224,72 +238,100 @@ export default function Page() {
224
  Send
225
  </button>
226
  </div>
227
-
228
- {/* Settings row */}
229
  <div className="flex items-center justify-between gap-3 mt-2 pt-2 px-2 text-xs text-[var(--muted-foreground)]">
230
  <div className="flex items-center gap-3">
231
  <label className="flex items-center gap-1.5 cursor-pointer">
232
- <input
233
- type="checkbox"
234
- checked={returnAudio}
235
- onChange={(e) => setReturnAudio(e.target.checked)}
236
- className="w-3.5 h-3.5 accent-[var(--primary)]"
237
- />
238
- Voice reply
239
  </label>
240
  <label className="flex items-center gap-1.5">
241
  Lang:
242
- <select
243
- value={ttsLang}
244
- onChange={(e) => setTtsLang(e.target.value as "en-IN" | "hi-IN")}
245
- className="bg-transparent border border-[var(--border)] rounded px-1.5 py-0.5"
246
- >
247
  <option value="en-IN">English</option>
248
  <option value="hi-IN">हिन्दी</option>
249
  </select>
250
  </label>
251
  </div>
252
- <div className="hidden sm:block">Enter to send · Shift+Enter for newline</div>
253
  </div>
254
  </div>
255
  </main>
256
 
257
  <footer className="border-t border-[var(--border)] py-3 px-6 text-center text-xs text-[var(--muted-foreground)]">
258
- Sarvam-M · Sarvam Saarika STT · Sarvam Bulbul TTS · Voyage embeddings · Llama-3.3-70B grader · DeepSeek-V3 fallback brain. Advisory only — verify with the insurer before purchase.
259
  </footer>
260
  </div>
261
  );
262
  }
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  function HealthBadge({ health }: { health: { status: string; missing: string[] } | null }) {
265
  if (!health) return <span className="text-xs text-[var(--muted-foreground)]">checking…</span>;
266
  const ok = health.status === "ok";
267
  return (
268
  <div className="flex items-center gap-1.5 text-xs">
269
- <span
270
- className={`w-2 h-2 rounded-full ${
271
- ok ? "bg-emerald-500" : health.status === "unreachable" ? "bg-red-500" : "bg-amber-500"
272
- }`}
273
- />
274
  <span className="text-[var(--muted-foreground)]">
275
- {ok ? "all systems healthy" : health.status === "unreachable" ? "backend unreachable" : `degraded (${health.missing.join(", ")})`}
276
  </span>
277
  </div>
278
  );
279
  }
280
 
281
- function EmptyState({ onSuggest }: { onSuggest: (q: string) => void }) {
282
  return (
283
  <div className="flex-1 flex flex-col items-center justify-center text-center px-4">
284
- <div className="w-16 h-16 rounded-2xl bg-[var(--primary)] text-[var(--primary-foreground)] flex items-center justify-center text-2xl font-bold mb-6">
285
- IA
286
- </div>
287
- <h2 className="text-xl sm:text-2xl font-semibold mb-2">
288
- Hi, I&apos;m your AI insurance advisor.
289
- </h2>
290
- <p className="text-sm text-[var(--muted-foreground)] max-w-md mb-6">
291
- Ask me about Indian health insurance policies — coverage, waiting periods, exclusions, side-by-side comparisons. Speak or type, English or हिन्दी. Every fact comes with a citation.
292
  </p>
 
 
 
 
 
293
  <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 w-full max-w-2xl">
294
  {SUGGESTED_QUESTIONS.map((q, i) => (
295
  <button
@@ -309,35 +351,16 @@ function Message({ m }: { m: DisplayMessage }) {
309
  const isUser = m.role === "user";
310
  return (
311
  <div className={`flex animate-fade-up ${isUser ? "justify-end" : "justify-start"}`}>
312
- <div
313
- className={`max-w-[85%] sm:max-w-[75%] rounded-2xl px-4 py-3 ${
314
- isUser
315
- ? "bg-[var(--primary)] text-[var(--primary-foreground)]"
316
- : "bg-[var(--card)] border border-[var(--border)]"
317
- }`}
318
- >
319
  <div className="text-sm sm:text-base whitespace-pre-wrap leading-relaxed">{m.content}</div>
320
- {m.audioUrl && (
321
- <audio
322
- controls
323
- src={m.audioUrl}
324
- className="mt-2 w-full max-w-xs"
325
- style={{ height: 32 }}
326
- />
327
- )}
328
  {m.citations && m.citations.length > 0 && (
329
  <div className="mt-3 pt-3 border-t border-[var(--border)] space-y-1.5">
330
- <div className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)] font-semibold">
331
- Sources
332
- </div>
333
  {m.citations.slice(0, 5).map((c, i) => (
334
- <a
335
- key={i}
336
- href={c.source_url || "#"}
337
- target="_blank"
338
- rel="noopener"
339
- className="block text-xs text-[var(--muted-foreground)] hover:text-[var(--primary)] transition"
340
- >
341
  <span className="font-medium">{c.policy_name}</span>
342
  <span className="opacity-60"> · {c.insurer_slug} · p.{c.page_start}</span>
343
  <span className="opacity-50"> · score {c.score.toFixed(2)}</span>
@@ -345,11 +368,7 @@ function Message({ m }: { m: DisplayMessage }) {
345
  ))}
346
  </div>
347
  )}
348
- {m.brain && (
349
- <div className="mt-2 text-[10px] text-[var(--muted-foreground)] opacity-60">
350
- {m.brain} · {m.latencyMs}ms
351
- </div>
352
- )}
353
  </div>
354
  </div>
355
  );
@@ -361,14 +380,7 @@ function ThinkingDots() {
361
  <div className="bg-[var(--card)] border border-[var(--border)] rounded-2xl px-4 py-3">
362
  <div className="flex gap-1.5">
363
  {[0, 1, 2].map((i) => (
364
- <span
365
- key={i}
366
- className="w-2 h-2 rounded-full bg-[var(--muted-foreground)] opacity-50"
367
- style={{
368
- animation: "fade-up 1.2s ease-in-out infinite",
369
- animationDelay: `${i * 0.2}s`,
370
- }}
371
- />
372
  ))}
373
  </div>
374
  </div>
@@ -377,20 +389,22 @@ function ThinkingDots() {
377
  }
378
 
379
  function MicIcon() {
380
- return (
381
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
382
- <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
383
- <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
384
- <line x1="12" y1="19" x2="12" y2="23" />
385
- <line x1="8" y1="23" x2="16" y2="23" />
386
- </svg>
387
- );
388
  }
389
 
390
  function StopIcon() {
391
- return (
392
- <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
393
- <rect x="6" y="6" width="12" height="12" rx="2" />
394
- </svg>
395
- );
 
 
 
 
396
  }
 
5
  audioBlobURLFromBase64,
6
  Citation,
7
  ChatMessage,
8
+ CoverageResponse,
9
+ getCoverage,
10
  getHealth,
11
  postChat,
12
  postTranscribe,
13
+ uploadPolicy,
14
  } from "@/lib/api";
15
 
16
  type DisplayMessage = ChatMessage & {
 
19
  audioUrl?: string;
20
  brain?: string;
21
  latencyMs?: number;
22
+ blocked?: boolean;
23
  };
24
 
25
  const SUGGESTED_QUESTIONS = [
26
+ "I'm looking for a new health insurance policy.",
27
  "What is the waiting period for pre-existing diseases?",
28
+ "Does HDFC ERGO Optima Secure cover AYUSH?",
29
+ "What's the room rent cap on Care Supreme?",
 
30
  ];
31
 
32
  export default function Page() {
 
37
  const [returnAudio, setReturnAudio] = useState(true);
38
  const [ttsLang, setTtsLang] = useState<"en-IN" | "hi-IN">("en-IN");
39
  const [health, setHealth] = useState<{ status: string; missing: string[] } | null>(null);
40
+ const [coverage, setCoverage] = useState<CoverageResponse | null>(null);
41
+ const [showCoverage, setShowCoverage] = useState(false);
42
  const [sessionId, setSessionId] = useState<string | undefined>();
43
+ const [uploadStatus, setUploadStatus] = useState<string | null>(null);
44
 
45
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
46
  const audioChunksRef = useRef<Blob[]>([]);
47
+ const fileInputRef = useRef<HTMLInputElement>(null);
48
  const scrollRef = useRef<HTMLDivElement>(null);
49
 
50
  useEffect(() => {
51
  getHealth()
52
  .then((h) => setHealth({ status: h.status, missing: h.missing_keys }))
53
  .catch(() => setHealth({ status: "unreachable", missing: [] }));
54
+ getCoverage()
55
+ .then(setCoverage)
56
+ .catch(() => setCoverage(null));
57
  }, []);
58
 
59
  useEffect(() => {
 
61
  }, [messages]);
62
 
63
  function pushUser(text: string) {
64
+ setMessages((m) => [...m, { id: `u_${Date.now()}`, role: "user", content: text }]);
 
 
65
  }
 
66
  function pushAssistant(content: string, extras: Partial<DisplayMessage> = {}) {
67
+ setMessages((m) => [...m, { id: `a_${Date.now()}`, role: "assistant", content, ...extras }]);
 
 
68
  }
69
 
70
  async function send(text: string) {
 
72
  setBusy(true);
73
  setInput("");
74
  pushUser(text);
 
75
  try {
76
  const history: ChatMessage[] = messages.map((m) => ({ role: m.role, content: m.content }));
77
  const res = await postChat({
 
88
  audioUrl,
89
  brain: res.brain_used,
90
  latencyMs: res.latency_ms,
91
+ blocked: res.blocked,
92
  });
93
  if (audioUrl) {
94
  const audio = new Audio(audioUrl);
95
  audio.play().catch(() => {});
96
  }
97
  } catch (e: unknown) {
98
+ pushAssistant(`Sorry backend error: ${e instanceof Error ? e.message : String(e)}`);
 
99
  } finally {
100
  setBusy(false);
101
  }
 
105
  try {
106
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
107
  const mime = MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : "";
108
+ const recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
 
 
109
  mediaRecorderRef.current = recorder;
110
  audioChunksRef.current = [];
111
+ recorder.ondataavailable = (ev) => { if (ev.data.size > 0) audioChunksRef.current.push(ev.data); };
 
 
 
 
112
  recorder.onstop = async () => {
113
  stream.getTracks().forEach((t) => t.stop());
114
+ const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || "audio/webm" });
 
 
115
  setRecording(false);
116
  if (blob.size < 1000) return;
117
  setBusy(true);
118
  try {
119
  const { text } = await postTranscribe(blob, ttsLang);
120
+ if (text && text.trim()) await send(text);
121
+ else pushAssistant("Sorry, I couldn't hear that clearly. Please try again.");
 
 
 
122
  } catch (e: unknown) {
123
+ pushAssistant(`Sorry transcribe error: ${e instanceof Error ? e.message : String(e)}`);
124
+ } finally { setBusy(false); }
 
 
 
125
  };
 
126
  recorder.start();
127
  setRecording(true);
128
  } catch (e) {
 
130
  pushAssistant(`Sorry — mic permission denied or unavailable.`);
131
  }
132
  }
133
+ function stopRecording() { mediaRecorderRef.current?.stop(); }
134
 
135
+ async function handleFile(ev: React.ChangeEvent<HTMLInputElement>) {
136
+ const f = ev.target.files?.[0];
137
+ if (!f) return;
138
+ setUploadStatus(`Indexing ${f.name}…`);
139
+ try {
140
+ const r = await uploadPolicy(f);
141
+ setUploadStatus(`✓ Indexed "${r.policy_name}" — ${r.chunks_added} chunks from ${r.pages_indexed} pages (${(r.elapsed_ms / 1000).toFixed(1)}s). Ask me about it.`);
142
+ // Refresh coverage so the uploaded doc shows up
143
+ getCoverage().then(setCoverage).catch(() => {});
144
+ } catch (e: unknown) {
145
+ setUploadStatus(`✗ Upload failed: ${e instanceof Error ? e.message : String(e)}`);
146
+ } finally {
147
+ if (fileInputRef.current) fileInputRef.current.value = "";
148
+ setTimeout(() => setUploadStatus(null), 8000);
149
+ }
150
  }
151
 
152
  return (
153
  <div className="min-h-screen flex flex-col bg-[var(--background)] text-[var(--foreground)]">
 
154
  <header className="border-b border-[var(--border)] bg-[var(--card)]">
155
  <div className="max-w-6xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
156
  <div className="flex items-center gap-3">
157
+ <div className="w-9 h-9 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] flex items-center justify-center font-bold text-sm">IA</div>
 
 
158
  <div>
159
+ <h1 className="font-semibold text-base sm:text-lg leading-tight">Insurance Sales Portfolio Expert</h1>
160
+ <p className="text-xs text-[var(--muted-foreground)]">Voice-first AI advisor · Indian health insurance · Sarvam AI</p>
 
 
 
 
161
  </div>
162
  </div>
163
  <div className="flex items-center gap-3">
164
+ {coverage && (
165
+ <button
166
+ onClick={() => setShowCoverage(!showCoverage)}
167
+ className="text-xs px-3 py-1.5 rounded-lg border border-[var(--border)] hover:border-[var(--primary)] transition"
168
+ >
169
+ {coverage.total_policies} policies · {coverage.total_insurers} insurers
170
+ </button>
171
+ )}
172
  <HealthBadge health={health} />
173
  </div>
174
  </div>
175
+ {showCoverage && coverage && <CoveragePanel coverage={coverage} onClose={() => setShowCoverage(false)} />}
176
  </header>
177
 
 
178
  <main className="flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 py-4 sm:py-6 flex flex-col">
179
  {messages.length === 0 ? (
180
+ <EmptyState onSuggest={(q) => send(q)} coverage={coverage} />
181
  ) : (
182
+ <div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin space-y-4 mb-4 pr-1">
183
+ {messages.map((m) => <Message key={m.id} m={m} />)}
 
 
 
 
 
184
  {busy && <ThinkingDots />}
185
  </div>
186
  )}
187
 
188
+ {uploadStatus && (
189
+ <div className="mb-3 text-xs px-3 py-2 rounded-lg bg-[var(--accent)] border border-[var(--border)] text-[var(--foreground)]">
190
+ {uploadStatus}
191
+ </div>
192
+ )}
193
+
194
  <div className="border border-[var(--border)] rounded-2xl bg-[var(--card)] p-3 shadow-sm">
195
  <div className="flex items-end gap-2">
196
  <textarea
197
  value={input}
198
  onChange={(e) => setInput(e.target.value)}
199
+ onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(input); } }}
 
 
 
 
 
200
  placeholder="Ask about coverage, waiting periods, exclusions, or compare policies…"
201
  rows={1}
202
  className="flex-1 resize-none bg-transparent outline-none text-sm sm:text-base px-2 py-2 min-h-[40px] max-h-32"
203
  disabled={busy}
204
  />
205
+ <input
206
+ ref={fileInputRef}
207
+ type="file"
208
+ accept="application/pdf"
209
+ onChange={handleFile}
210
+ className="hidden"
211
+ />
212
+ <button
213
+ type="button"
214
+ onClick={() => fileInputRef.current?.click()}
215
+ disabled={busy || !!uploadStatus}
216
+ title="Upload your own policy PDF"
217
+ className="shrink-0 w-11 h-11 rounded-xl flex items-center justify-center bg-[var(--muted)] hover:bg-[var(--border)] disabled:opacity-40 transition"
218
+ >
219
+ <UploadIcon />
220
+ </button>
221
  <button
222
  type="button"
223
  onClick={recording ? stopRecording : startRecording}
224
  disabled={busy && !recording}
225
  className={`shrink-0 w-11 h-11 rounded-xl flex items-center justify-center transition-all ${
226
+ recording ? "bg-[var(--error)] text-white animate-record-pulse" : "bg-[var(--muted)] hover:bg-[var(--border)]"
 
 
227
  } disabled:opacity-40`}
228
+ title={recording ? "Stop recording" : "Voice input"}
229
  >
230
  {recording ? <StopIcon /> : <MicIcon />}
231
  </button>
 
238
  Send
239
  </button>
240
  </div>
 
 
241
  <div className="flex items-center justify-between gap-3 mt-2 pt-2 px-2 text-xs text-[var(--muted-foreground)]">
242
  <div className="flex items-center gap-3">
243
  <label className="flex items-center gap-1.5 cursor-pointer">
244
+ <input type="checkbox" checked={returnAudio} onChange={(e) => setReturnAudio(e.target.checked)} className="w-3.5 h-3.5 accent-[var(--primary)]" /> Voice reply
 
 
 
 
 
 
245
  </label>
246
  <label className="flex items-center gap-1.5">
247
  Lang:
248
+ <select value={ttsLang} onChange={(e) => setTtsLang(e.target.value as "en-IN" | "hi-IN")} className="bg-transparent border border-[var(--border)] rounded px-1.5 py-0.5">
 
 
 
 
249
  <option value="en-IN">English</option>
250
  <option value="hi-IN">हिन्दी</option>
251
  </select>
252
  </label>
253
  </div>
254
+ <div className="hidden sm:block">Enter to send · 📎 to upload your own PDF</div>
255
  </div>
256
  </div>
257
  </main>
258
 
259
  <footer className="border-t border-[var(--border)] py-3 px-6 text-center text-xs text-[var(--muted-foreground)]">
260
+ Sarvam-M · Sarvam Saarika STT · Sarvam Bulbul TTS · Voyage-prepared embeddings · Llama-3.3-70B grader · DeepSeek-V3 fallback brain. Advisory only — verify with the insurer before purchase.
261
  </footer>
262
  </div>
263
  );
264
  }
265
 
266
+ function CoveragePanel({ coverage, onClose }: { coverage: CoverageResponse; onClose: () => void }) {
267
+ return (
268
+ <div className="border-t border-[var(--border)] bg-[var(--muted)]">
269
+ <div className="max-w-6xl mx-auto px-4 sm:px-6 py-4">
270
+ <div className="flex items-baseline justify-between mb-3">
271
+ <h2 className="text-sm font-semibold">What this bot can answer questions about</h2>
272
+ <button onClick={onClose} className="text-xs text-[var(--muted-foreground)] hover:underline">close</button>
273
+ </div>
274
+ <p className="text-xs text-[var(--muted-foreground)] mb-3">
275
+ {coverage.total_policies} policies · {coverage.total_chunks.toLocaleString()} indexed text chunks · {coverage.total_insurers} insurers. Click any insurer to open their site; click a policy to open its PDF.
276
+ </p>
277
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
278
+ {coverage.insurers.map((ins) => (
279
+ <div key={ins.slug} className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3 text-xs">
280
+ <a
281
+ href={ins.home_url || "#"}
282
+ target="_blank"
283
+ rel="noopener"
284
+ className="font-semibold text-[var(--foreground)] hover:text-[var(--primary)] block mb-1.5"
285
+ >
286
+ {ins.name} <span className="opacity-50 font-normal">· {ins.policy_count}</span>
287
+ </a>
288
+ <ul className="space-y-0.5">
289
+ {ins.sample_policies.map((p, i) => (
290
+ <li key={i} className="text-[var(--muted-foreground)]">
291
+ {p.source_url ? (
292
+ <a href={p.source_url} target="_blank" rel="noopener" className="hover:text-[var(--primary)] hover:underline">
293
+ {p.name}
294
+ </a>
295
+ ) : (
296
+ <span>{p.name}</span>
297
+ )}
298
+ </li>
299
+ ))}
300
+ </ul>
301
+ </div>
302
+ ))}
303
+ </div>
304
+ </div>
305
+ </div>
306
+ );
307
+ }
308
+
309
  function HealthBadge({ health }: { health: { status: string; missing: string[] } | null }) {
310
  if (!health) return <span className="text-xs text-[var(--muted-foreground)]">checking…</span>;
311
  const ok = health.status === "ok";
312
  return (
313
  <div className="flex items-center gap-1.5 text-xs">
314
+ <span className={`w-2 h-2 rounded-full ${ok ? "bg-emerald-500" : health.status === "unreachable" ? "bg-red-500" : "bg-amber-500"}`} />
 
 
 
 
315
  <span className="text-[var(--muted-foreground)]">
316
+ {ok ? "healthy" : health.status === "unreachable" ? "backend unreachable" : `degraded`}
317
  </span>
318
  </div>
319
  );
320
  }
321
 
322
+ function EmptyState({ onSuggest, coverage }: { onSuggest: (q: string) => void; coverage: CoverageResponse | null }) {
323
  return (
324
  <div className="flex-1 flex flex-col items-center justify-center text-center px-4">
325
+ <div className="w-16 h-16 rounded-2xl bg-[var(--primary)] text-[var(--primary-foreground)] flex items-center justify-center text-2xl font-bold mb-6">IA</div>
326
+ <h2 className="text-xl sm:text-2xl font-semibold mb-2">Hi, I&apos;m your AI insurance advisor.</h2>
327
+ <p className="text-sm text-[var(--muted-foreground)] max-w-md mb-3">
328
+ Ask me about Indian health insurance — coverage, waiting periods, exclusions, side-by-side comparisons. Speak or type, English or हिन्दी. Every fact comes with a citation.
 
 
 
 
329
  </p>
330
+ {coverage && (
331
+ <p className="text-xs text-[var(--muted-foreground)] mb-6">
332
+ Currently covering <span className="font-semibold text-[var(--foreground)]">{coverage.total_policies} policies</span> from <span className="font-semibold text-[var(--foreground)]">{coverage.total_insurers} insurers</span>. Have a different PDF? <span className="font-semibold">Click the 📎 icon to upload.</span>
333
+ </p>
334
+ )}
335
  <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 w-full max-w-2xl">
336
  {SUGGESTED_QUESTIONS.map((q, i) => (
337
  <button
 
351
  const isUser = m.role === "user";
352
  return (
353
  <div className={`flex animate-fade-up ${isUser ? "justify-end" : "justify-start"}`}>
354
+ <div className={`max-w-[85%] sm:max-w-[75%] rounded-2xl px-4 py-3 ${
355
+ isUser ? "bg-[var(--primary)] text-[var(--primary-foreground)]" : "bg-[var(--card)] border border-[var(--border)]"
356
+ } ${m.blocked ? "ring-1 ring-amber-300" : ""}`}>
 
 
 
 
357
  <div className="text-sm sm:text-base whitespace-pre-wrap leading-relaxed">{m.content}</div>
358
+ {m.audioUrl && <audio controls src={m.audioUrl} className="mt-2 w-full max-w-xs" style={{ height: 32 }} />}
 
 
 
 
 
 
 
359
  {m.citations && m.citations.length > 0 && (
360
  <div className="mt-3 pt-3 border-t border-[var(--border)] space-y-1.5">
361
+ <div className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)] font-semibold">Sources</div>
 
 
362
  {m.citations.slice(0, 5).map((c, i) => (
363
+ <a key={i} href={c.source_url || "#"} target="_blank" rel="noopener" className="block text-xs text-[var(--muted-foreground)] hover:text-[var(--primary)] transition">
 
 
 
 
 
 
364
  <span className="font-medium">{c.policy_name}</span>
365
  <span className="opacity-60"> · {c.insurer_slug} · p.{c.page_start}</span>
366
  <span className="opacity-50"> · score {c.score.toFixed(2)}</span>
 
368
  ))}
369
  </div>
370
  )}
371
+ {m.brain && (<div className="mt-2 text-[10px] text-[var(--muted-foreground)] opacity-60">{m.brain} · {m.latencyMs}ms</div>)}
 
 
 
 
372
  </div>
373
  </div>
374
  );
 
380
  <div className="bg-[var(--card)] border border-[var(--border)] rounded-2xl px-4 py-3">
381
  <div className="flex gap-1.5">
382
  {[0, 1, 2].map((i) => (
383
+ <span key={i} className="w-2 h-2 rounded-full bg-[var(--muted-foreground)] opacity-50" style={{ animation: "fade-up 1.2s ease-in-out infinite", animationDelay: `${i * 0.2}s` }} />
 
 
 
 
 
 
 
384
  ))}
385
  </div>
386
  </div>
 
389
  }
390
 
391
  function MicIcon() {
392
+ return (<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
393
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
394
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
395
+ <line x1="12" y1="19" x2="12" y2="23" />
396
+ <line x1="8" y1="23" x2="16" y2="23" />
397
+ </svg>);
 
 
398
  }
399
 
400
  function StopIcon() {
401
+ return (<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>);
402
+ }
403
+
404
+ function UploadIcon() {
405
+ return (<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
406
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
407
+ <polyline points="17 8 12 3 7 8" />
408
+ <line x1="12" y1="3" x2="12" y2="15" />
409
+ </svg>);
410
  }
frontend/src/lib/api.ts CHANGED
@@ -26,6 +26,9 @@ export type ChatResponse = {
26
  latency_ms: number;
27
  session_id: string;
28
  audio_base64?: string | null;
 
 
 
29
  };
30
 
31
  export type ChatMessage = {
@@ -100,6 +103,54 @@ export async function getHealth(): Promise<{
100
  return resp.json();
101
  }
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  // Decode a base64 string to a playable audio Blob URL.
104
  export function audioBlobURLFromBase64(b64: string, mime = "audio/wav"): string {
105
  const bin = atob(b64);
 
26
  latency_ms: number;
27
  session_id: string;
28
  audio_base64?: string | null;
29
+ faithfulness_passed?: boolean;
30
+ faithfulness_reasons?: string[];
31
+ blocked?: boolean;
32
  };
33
 
34
  export type ChatMessage = {
 
103
  return resp.json();
104
  }
105
 
106
+ export type PolicyEntry = {
107
+ name: string;
108
+ source_url: string;
109
+ };
110
+
111
+ export type CoverageInsurer = {
112
+ slug: string;
113
+ name: string;
114
+ home_url: string;
115
+ policy_count: number;
116
+ sample_policies: PolicyEntry[];
117
+ };
118
+
119
+ export type CoverageResponse = {
120
+ total_chunks: number;
121
+ total_policies: number;
122
+ total_insurers: number;
123
+ insurers: CoverageInsurer[];
124
+ };
125
+
126
+ export async function getCoverage(): Promise<CoverageResponse> {
127
+ const resp = await fetch(`${BACKEND_URL}/api/coverage`);
128
+ if (!resp.ok) throw new Error(`coverage failed: ${resp.status}`);
129
+ return resp.json();
130
+ }
131
+
132
+ export type UploadResponse = {
133
+ policy_id: string;
134
+ policy_name: string;
135
+ chunks_added: number;
136
+ pages_indexed: number;
137
+ elapsed_ms: number;
138
+ };
139
+
140
+ export async function uploadPolicy(file: File): Promise<UploadResponse> {
141
+ const fd = new FormData();
142
+ fd.append("file", file);
143
+ const resp = await fetch(`${BACKEND_URL}/api/upload-policy`, {
144
+ method: "POST",
145
+ body: fd,
146
+ });
147
+ if (!resp.ok) {
148
+ const t = await resp.text();
149
+ throw new Error(`upload failed: ${resp.status} ${t}`);
150
+ }
151
+ return resp.json();
152
+ }
153
+
154
  // Decode a base64 string to a playable audio Blob URL.
155
  export function audioBlobURLFromBase64(b64: string, mime = "audio/wav"): string {
156
  const bin = atob(b64);
rag/vectors/chroma.sqlite3 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:046dda5d699f5975831d6ee8ab1e0864c4bbd73096302ad271610140233e86ee
3
  size 72695808
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5baf3213403a2991d2c6ed70cc36d7295966a213cef4b445a48088a6106f7b91
3
  size 72695808
tools/verify_urls.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verify every URL we expose to users is real (200) and reachable.
2
+
3
+ Two sets of URLs:
4
+ 1. Insurer home URLs (10) — curated from corpus discovery agent's report
5
+ 2. Policy PDF URLs — from rag/corpus/_manifest.json (already verified at
6
+ download time, but URLs can rot, so we re-check)
7
+
8
+ Outputs:
9
+ - eval/verified_urls.json — per-URL status, last_checked timestamp
10
+ - prints summary table
11
+
12
+ Run:
13
+ python tools/verify_urls.py
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import time
20
+ from concurrent.futures import ThreadPoolExecutor, as_completed
21
+ from pathlib import Path
22
+
23
+ import requests
24
+
25
+ ROOT = Path(__file__).resolve().parent.parent
26
+ MANIFEST = ROOT / "rag" / "corpus" / "_manifest.json"
27
+ OUTPUT = ROOT / "eval" / "verified_urls.json"
28
+
29
+ HEADERS = {
30
+ "User-Agent": (
31
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
32
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
33
+ ),
34
+ "Accept": "*/*",
35
+ }
36
+
37
+ # Insurer home URLs — curated, public, primary domains.
38
+ INSURER_HOME_URLS = {
39
+ "star-health": ("Star Health & Allied Insurance", "https://www.starhealth.in/"),
40
+ "hdfc-ergo": ("HDFC ERGO General Insurance", "https://www.hdfcergo.com/"),
41
+ "niva-bupa": ("Niva Bupa Health Insurance", "https://www.nivabupa.com/"),
42
+ "care-health": ("Care Health Insurance", "https://www.careinsurance.com/"),
43
+ "icici-lombard": ("ICICI Lombard General Insurance", "https://www.icicilombard.com/"),
44
+ "bajaj-allianz": ("Bajaj Allianz General Insurance", "https://www.bajajallianz.com/"),
45
+ "new-india": ("New India Assurance", "https://www.newindia.co.in/"),
46
+ "aditya-birla": ("Aditya Birla Health Insurance", "https://www.adityabirlacapital.com/healthinsurance"),
47
+ "tata-aig": ("Tata AIG General Insurance", "https://www.tataaig.com/"),
48
+ "manipalcigna": ("ManipalCigna Health Insurance", "https://www.manipalcigna.com/"),
49
+ }
50
+
51
+
52
+ def check_url(url: str, timeout: float = 12.0) -> dict:
53
+ """Do a HEAD then fall back to GET (some sites reject HEAD)."""
54
+ try:
55
+ r = requests.head(url, headers=HEADERS, timeout=timeout, allow_redirects=True)
56
+ if r.status_code in (200, 301, 302, 303):
57
+ return {
58
+ "url": url, "ok": True, "status": r.status_code,
59
+ "method": "HEAD", "final_url": r.url,
60
+ "content_type": r.headers.get("Content-Type", ""),
61
+ }
62
+ # Try GET on a Range to avoid downloading full content
63
+ r2 = requests.get(
64
+ url, headers={**HEADERS, "Range": "bytes=0-2047"},
65
+ timeout=timeout, allow_redirects=True, stream=True,
66
+ )
67
+ ok = r2.status_code in (200, 206, 301, 302, 303)
68
+ return {
69
+ "url": url, "ok": ok, "status": r2.status_code,
70
+ "method": "GET-range", "final_url": r2.url,
71
+ "content_type": r2.headers.get("Content-Type", ""),
72
+ }
73
+ except Exception as e:
74
+ return {"url": url, "ok": False, "error": f"{type(e).__name__}: {e}"}
75
+
76
+
77
+ def main():
78
+ OUTPUT.parent.mkdir(parents=True, exist_ok=True)
79
+
80
+ # 1) Insurer home URLs
81
+ print("=== Verifying insurer home URLs ===")
82
+ insurer_results: dict[str, dict] = {}
83
+ with ThreadPoolExecutor(max_workers=6) as ex:
84
+ futures = {
85
+ ex.submit(check_url, url): slug
86
+ for slug, (_, url) in INSURER_HOME_URLS.items()
87
+ }
88
+ for f in as_completed(futures):
89
+ slug = futures[f]
90
+ result = f.result()
91
+ name, url = INSURER_HOME_URLS[slug]
92
+ insurer_results[slug] = {**result, "name": name}
93
+ ok_tag = "OK" if result["ok"] else f"FAIL {result.get('error') or result.get('status')}"
94
+ print(f" {slug:>15s}: {ok_tag:<25s} {url}")
95
+
96
+ # 2) Policy PDF URLs
97
+ print("\n=== Verifying policy PDF URLs (sample) ===")
98
+ policy_results: list[dict] = []
99
+ if MANIFEST.exists():
100
+ manifest = json.loads(MANIFEST.read_text())
101
+ ok_results = [r for r in manifest.get("results", []) if r.get("ok")]
102
+ # Verify up to 30 policy URLs (avoid hammering CDNs)
103
+ sample = ok_results[:30]
104
+ with ThreadPoolExecutor(max_workers=6) as ex:
105
+ futures = {ex.submit(check_url, r["url"]): r for r in sample}
106
+ for f in as_completed(futures):
107
+ src = futures[f]
108
+ result = f.result()
109
+ result.update({
110
+ "policy_name": src.get("policy_name"),
111
+ "insurer_slug": src.get("insurer_slug"),
112
+ "doc_type": src.get("doc_type"),
113
+ })
114
+ policy_results.append(result)
115
+ ok_tag = "OK" if result["ok"] else f"FAIL {result.get('error') or result.get('status')}"
116
+ print(f" {src['insurer_slug']:>15s} | {src['policy_name'][:40]:<40s} | {ok_tag}")
117
+
118
+ insurer_ok = sum(1 for r in insurer_results.values() if r["ok"])
119
+ policy_ok = sum(1 for r in policy_results if r["ok"])
120
+
121
+ payload = {
122
+ "verified_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
123
+ "insurer_summary": {"total": len(insurer_results), "ok": insurer_ok},
124
+ "policy_summary": {"total": len(policy_results), "ok": policy_ok},
125
+ "insurers": insurer_results,
126
+ "policy_urls": policy_results,
127
+ }
128
+ OUTPUT.write_text(json.dumps(payload, indent=2))
129
+ print(f"\nWrote {OUTPUT.relative_to(ROOT)}")
130
+ print(f"Insurer URLs: {insurer_ok}/{len(insurer_results)} OK")
131
+ print(f"Policy URLs: {policy_ok}/{len(policy_results)} OK")
132
+
133
+
134
+ if __name__ == "__main__":
135
+ main()