Spaces:
Sleeping
Sleeping
Deploy v1 — single-Docker FastAPI + Next.js + RAG + voice + faithfulness
Browse files- backend/main.py +187 -0
- backend/orchestrator.py +47 -2
- eval/verified_urls.json +432 -0
- frontend/src/app/page.tsx +163 -149
- frontend/src/lib/api.ts +51 -0
- rag/vectors/chroma.sqlite3 +1 -1
- tools/verify_urls.py +135 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 24 |
-
"
|
| 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 |
-
|
| 54 |
-
setMessages((m) => [...m, { id, role: "user", content: text }]);
|
| 55 |
-
return id;
|
| 56 |
}
|
| 57 |
-
|
| 58 |
function pushAssistant(content: string, extras: Partial<DisplayMessage> = {}) {
|
| 59 |
-
|
| 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 |
-
|
| 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 |
-
|
| 125 |
-
} else {
|
| 126 |
-
pushAssistant("Sorry, I couldn't hear that clearly. Please try again.");
|
| 127 |
-
}
|
| 128 |
} catch (e: unknown) {
|
| 129 |
-
|
| 130 |
-
|
| 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
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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" : "
|
| 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 ·
|
| 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 ? "
|
| 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 |
-
|
| 286 |
-
<
|
| 287 |
-
|
| 288 |
-
Hi, I'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 |
-
|
| 314 |
-
|
| 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 |
-
<
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
</svg>
|
| 387 |
-
);
|
| 388 |
}
|
| 389 |
|
| 390 |
function StopIcon() {
|
| 391 |
-
return (
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 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'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:
|
| 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()
|