rohitsar567 commited on
Commit
5dbdf6e
·
verified ·
1 Parent(s): 5f48e4f

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

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +15 -0
  2. .gitattributes +76 -0
  3. .gitignore +76 -0
  4. ARCHITECTURE.md +50 -0
  5. Dockerfile +62 -0
  6. README.md +68 -6
  7. backend/__init__.py +0 -0
  8. backend/config.py +70 -0
  9. backend/faithfulness.py +302 -0
  10. backend/main.py +275 -0
  11. backend/needs_finder.py +210 -0
  12. backend/orchestrator.py +191 -0
  13. backend/persona.py +108 -0
  14. backend/providers/__init__.py +13 -0
  15. backend/providers/_smoke_test.py +161 -0
  16. backend/providers/base.py +94 -0
  17. backend/providers/groq_llm.py +71 -0
  18. backend/providers/local_embeddings.py +54 -0
  19. backend/providers/openrouter_llm.py +72 -0
  20. backend/providers/sarvam_llm.py +73 -0
  21. backend/providers/sarvam_stt.py +61 -0
  22. backend/providers/sarvam_tts.py +79 -0
  23. backend/providers/voyage_embeddings.py +112 -0
  24. data/corpus_urls.md +103 -0
  25. data/regulatory_urls.md +38 -0
  26. docs/01-requirements.md +261 -0
  27. docs/02-architecture.md +329 -0
  28. docs/03-eval-plan.md +105 -0
  29. docs/04-failure-modes.md +189 -0
  30. docs/05-needs-analysis-flow.md +149 -0
  31. docs/ROADMAP.md +140 -0
  32. docs/decisions.md +240 -0
  33. docs/information_source_map.md +57 -0
  34. docs/tech-stack-rationale.md +183 -0
  35. entrypoint.sh +43 -0
  36. eval/generate_gold.py +234 -0
  37. eval/run.py +276 -0
  38. frontend/.gitignore +41 -0
  39. frontend/AGENTS.md +5 -0
  40. frontend/CLAUDE.md +1 -0
  41. frontend/README.md +36 -0
  42. frontend/eslint.config.mjs +18 -0
  43. frontend/next-env.d.ts +6 -0
  44. frontend/next.config.ts +14 -0
  45. frontend/package-lock.json +0 -0
  46. frontend/package.json +26 -0
  47. frontend/postcss.config.mjs +7 -0
  48. frontend/public/file.svg +1 -0
  49. frontend/public/globe.svg +1 -0
  50. frontend/public/next.svg +1 -0
.env.example ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copy this to .env and fill in. Do NOT commit .env.
2
+
3
+ # Primary provider — Sarvam (STT, TTS, LLM)
4
+ SARVAM_API_KEY=sk_your_sarvam_api_key_here
5
+
6
+ # Embeddings — Voyage AI (Anthropic's recommended partner)
7
+ VOYAGE_API_KEY=pa-your_voyage_key_here
8
+
9
+ # Grader / self-critique — Groq (Llama-3.3-70B, free tier, OpenAI-compatible API)
10
+ # Used for eval harness AND as a fallback brain (router escalates here for complex queries).
11
+ GROQ_API_KEY=gsk_your_groq_api_key_here
12
+
13
+ # Fallback brain — OpenRouter (DeepSeek-V3 671B, currently SOTA open-source)
14
+ # Used by router for complex multi-policy reasoning queries.
15
+ OPENROUTER_API_KEY=sk-or-v1-your_openrouter_key_here
.gitattributes CHANGED
@@ -33,3 +33,79 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ rag/corpus/aditya-birla/activ-assure-diamond__wordings.pdf filter=lfs diff=lfs merge=lfs -text
37
+ rag/corpus/aditya-birla/activ-health-individual__wordings.pdf filter=lfs diff=lfs merge=lfs -text
38
+ rag/corpus/aditya-birla/activ-one__brochure.pdf filter=lfs diff=lfs merge=lfs -text
39
+ rag/corpus/aditya-birla/activ-secure-cancer-secure__brochure.pdf filter=lfs diff=lfs merge=lfs -text
40
+ rag/corpus/aditya-birla/activ-secure-personal-accident-cancer-secure__wordings.pdf filter=lfs diff=lfs merge=lfs -text
41
+ rag/corpus/aditya-birla/group-activ-health__wordings.pdf filter=lfs diff=lfs merge=lfs -text
42
+ rag/corpus/bajaj-allianz/comprehensive-care-plan__wordings.pdf filter=lfs diff=lfs merge=lfs -text
43
+ rag/corpus/bajaj-allianz/criti-care__wordings.pdf filter=lfs diff=lfs merge=lfs -text
44
+ rag/corpus/bajaj-allianz/extra-care-plus__wordings.pdf filter=lfs diff=lfs merge=lfs -text
45
+ rag/corpus/bajaj-allianz/global-health-care__wordings.pdf filter=lfs diff=lfs merge=lfs -text
46
+ rag/corpus/bajaj-allianz/group-health-guard-gold__wordings.pdf filter=lfs diff=lfs merge=lfs -text
47
+ rag/corpus/bajaj-allianz/group-personal-accident__wordings.pdf filter=lfs diff=lfs merge=lfs -text
48
+ rag/corpus/bajaj-allianz/health-guard-gold-individual__wordings.pdf filter=lfs diff=lfs merge=lfs -text
49
+ rag/corpus/bajaj-allianz/health-guard__wordings.pdf filter=lfs diff=lfs merge=lfs -text
50
+ rag/corpus/bajaj-allianz/silver-health__cis.pdf filter=lfs diff=lfs merge=lfs -text
51
+ rag/corpus/bajaj-allianz/tax-gain__cis.pdf filter=lfs diff=lfs merge=lfs -text
52
+ rag/corpus/care-health/care-advantage-add-ons-protect-plus-care-shield__brochure.pdf filter=lfs diff=lfs merge=lfs -text
53
+ rag/corpus/care-health/care-advantage__brochure.pdf filter=lfs diff=lfs merge=lfs -text
54
+ rag/corpus/care-health/care-classic__wordings.pdf filter=lfs diff=lfs merge=lfs -text
55
+ rag/corpus/care-health/care-heart__brochure.pdf filter=lfs diff=lfs merge=lfs -text
56
+ rag/corpus/care-health/care-senior__brochure.pdf filter=lfs diff=lfs merge=lfs -text
57
+ rag/corpus/care-health/care-supreme-enhance__wordings.pdf filter=lfs diff=lfs merge=lfs -text
58
+ rag/corpus/care-health/care-supreme__wordings.pdf filter=lfs diff=lfs merge=lfs -text
59
+ rag/corpus/care-health/supreme-enhance__brochure.pdf filter=lfs diff=lfs merge=lfs -text
60
+ rag/corpus/care-health/ultimate-care__wordings.pdf filter=lfs diff=lfs merge=lfs -text
61
+ rag/corpus/hdfc-ergo/energy-diabetes-hypertension__wordings.pdf filter=lfs diff=lfs merge=lfs -text
62
+ rag/corpus/hdfc-ergo/group-health-insurance__wordings.pdf filter=lfs diff=lfs merge=lfs -text
63
+ rag/corpus/hdfc-ergo/my-health-medisure-prime__wordings.pdf filter=lfs diff=lfs merge=lfs -text
64
+ rag/corpus/hdfc-ergo/my-health-sampoorna-suraksha__brochure.pdf filter=lfs diff=lfs merge=lfs -text
65
+ rag/corpus/hdfc-ergo/my-health-suraksha__brochure.pdf filter=lfs diff=lfs merge=lfs -text
66
+ rag/corpus/hdfc-ergo/my-health-women-suraksha__brochure.pdf filter=lfs diff=lfs merge=lfs -text
67
+ rag/corpus/hdfc-ergo/my-optima-secure-older-variant__wordings.pdf filter=lfs diff=lfs merge=lfs -text
68
+ rag/corpus/hdfc-ergo/my-optima-secure__wordings.pdf filter=lfs diff=lfs merge=lfs -text
69
+ rag/corpus/hdfc-ergo/optima-enhance__wordings.pdf filter=lfs diff=lfs merge=lfs -text
70
+ rag/corpus/hdfc-ergo/optima-plus__wordings.pdf filter=lfs diff=lfs merge=lfs -text
71
+ rag/corpus/hdfc-ergo/optima-restore__brochure.pdf filter=lfs diff=lfs merge=lfs -text
72
+ rag/corpus/hdfc-ergo/total-health-plan__wordings.pdf filter=lfs diff=lfs merge=lfs -text
73
+ rag/corpus/icici-lombard/arogya-sanjeevani__wordings.pdf filter=lfs diff=lfs merge=lfs -text
74
+ rag/corpus/icici-lombard/complete-health-insurance-health-shield__wordings.pdf filter=lfs diff=lfs merge=lfs -text
75
+ rag/corpus/icici-lombard/complete-health-insurance-umbrella__wordings.pdf filter=lfs diff=lfs merge=lfs -text
76
+ rag/corpus/icici-lombard/elevate__wordings.pdf filter=lfs diff=lfs merge=lfs -text
77
+ rag/corpus/icici-lombard/health-advantedge__wordings.pdf filter=lfs diff=lfs merge=lfs -text
78
+ rag/corpus/icici-lombard/health-booster-top-up__wordings.pdf filter=lfs diff=lfs merge=lfs -text
79
+ rag/corpus/icici-lombard/health-elite-plus__wordings.pdf filter=lfs diff=lfs merge=lfs -text
80
+ rag/corpus/icici-lombard/health-shield-360-retail__cis.pdf filter=lfs diff=lfs merge=lfs -text
81
+ rag/corpus/icici-lombard/health-shield-360-retail__wordings.pdf filter=lfs diff=lfs merge=lfs -text
82
+ rag/corpus/manipalcigna/prohealth-insurance-all-variants__wordings.pdf filter=lfs diff=lfs merge=lfs -text
83
+ rag/corpus/manipalcigna/prohealth-select__wordings.pdf filter=lfs diff=lfs merge=lfs -text
84
+ rag/corpus/manipalcigna/sarvah-param__brochure.pdf filter=lfs diff=lfs merge=lfs -text
85
+ rag/corpus/manipalcigna/sarvah-param__wordings.pdf filter=lfs diff=lfs merge=lfs -text
86
+ rag/corpus/new-india/asha-kiran-policy__brochure.pdf filter=lfs diff=lfs merge=lfs -text
87
+ rag/corpus/new-india/asha-kiran-policy__cis.pdf filter=lfs diff=lfs merge=lfs -text
88
+ rag/corpus/new-india/janata-mediclaim-policy__wordings.pdf filter=lfs diff=lfs merge=lfs -text
89
+ rag/corpus/new-india/new-india-floater-mediclaim-policy__wordings.pdf filter=lfs diff=lfs merge=lfs -text
90
+ rag/corpus/new-india/new-india-mediclaim-policy__brochure.pdf filter=lfs diff=lfs merge=lfs -text
91
+ rag/corpus/new-india/new-india-mediclaim-policy__wordings.pdf filter=lfs diff=lfs merge=lfs -text
92
+ rag/corpus/new-india/universal-health-insurance__wordings.pdf filter=lfs diff=lfs merge=lfs -text
93
+ rag/corpus/new-india/yuva-bharat-health-policy__wordings.pdf filter=lfs diff=lfs merge=lfs -text
94
+ rag/corpus/niva-bupa/aspire__wordings.pdf filter=lfs diff=lfs merge=lfs -text
95
+ rag/corpus/niva-bupa/health-companion-v2022__brochure.pdf filter=lfs diff=lfs merge=lfs -text
96
+ rag/corpus/niva-bupa/health-companion__wordings.pdf filter=lfs diff=lfs merge=lfs -text
97
+ rag/corpus/niva-bupa/health-plus-top-up__wordings.pdf filter=lfs diff=lfs merge=lfs -text
98
+ rag/corpus/niva-bupa/health-premia__wordings.pdf filter=lfs diff=lfs merge=lfs -text
99
+ rag/corpus/niva-bupa/reassure-2-0__wordings.pdf filter=lfs diff=lfs merge=lfs -text
100
+ rag/corpus/niva-bupa/reassure-3-0__wordings.pdf filter=lfs diff=lfs merge=lfs -text
101
+ rag/corpus/niva-bupa/rise__wordings.pdf filter=lfs diff=lfs merge=lfs -text
102
+ rag/corpus/niva-bupa/saral-suraksha-bima__wordings.pdf filter=lfs diff=lfs merge=lfs -text
103
+ rag/corpus/niva-bupa/senior-first__wordings.pdf filter=lfs diff=lfs merge=lfs -text
104
+ rag/corpus/tata-aig/criti-medicare__wordings.pdf filter=lfs diff=lfs merge=lfs -text
105
+ rag/corpus/tata-aig/medicare-lite__cis.pdf filter=lfs diff=lfs merge=lfs -text
106
+ rag/corpus/tata-aig/medicare-premier__cis.pdf filter=lfs diff=lfs merge=lfs -text
107
+ rag/corpus/tata-aig/medicare-premier__wordings.pdf filter=lfs diff=lfs merge=lfs -text
108
+ rag/corpus/tata-aig/medicare-select__brochure.pdf filter=lfs diff=lfs merge=lfs -text
109
+ rag/corpus/tata-aig/medicare__wordings.pdf filter=lfs diff=lfs merge=lfs -text
110
+ rag/policies.duckdb filter=lfs diff=lfs merge=lfs -text
111
+ rag/vectors/chroma.sqlite3 filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Secrets — NEVER COMMIT
2
+ .env
3
+ .env.local
4
+ .env.*.local
5
+ *.key
6
+ *.pem
7
+ secrets/
8
+
9
+ # Python
10
+ __pycache__/
11
+ *.py[cod]
12
+ *$py.class
13
+ *.so
14
+ .Python
15
+ build/
16
+ develop-eggs/
17
+ dist/
18
+ downloads/
19
+ eggs/
20
+ .eggs/
21
+ lib/
22
+ lib64/
23
+ parts/
24
+ sdist/
25
+ var/
26
+ wheels/
27
+ *.egg-info/
28
+ .installed.cfg
29
+ *.egg
30
+ MANIFEST
31
+
32
+ # Virtual envs
33
+ venv/
34
+ env/
35
+ ENV/
36
+ .venv
37
+
38
+ # IDE
39
+ .vscode/
40
+ .idea/
41
+ *.swp
42
+ *.swo
43
+ *~
44
+ .DS_Store
45
+
46
+ # Streamlit
47
+ .streamlit/secrets.toml
48
+
49
+ # Next.js
50
+ .next/
51
+ frontend/node_modules/
52
+ frontend/.next/
53
+ frontend/.env.local
54
+
55
+ # Vector DB persistence — too large for git (sqlite > 10MB HF limit).
56
+ # Rebuilt at container startup from corpus PDFs in ~5 min.
57
+ rag/vectors/
58
+ # rag/policies.duckdb -- intentionally not gitignored (small)
59
+
60
+ # Raw PDFs — large; do commit a subset later for reproducibility
61
+ # (keeping commented out — we WILL commit the corpus we acquire so deploy works)
62
+ # rag/corpus/
63
+
64
+ # Eval outputs
65
+ eval/results_*.json
66
+ eval/results_*.html
67
+
68
+ # Logs
69
+ *.log
70
+ logs/
71
+
72
+ # Jupyter
73
+ .ipynb_checkpoints/
74
+
75
+ # OS
76
+ Thumbs.db
ARCHITECTURE.md ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Insurance Sales Portfolio Expert: Architecture & Design
2
+
3
+ This document details the technical architecture, operational workflows, and future roadmap for the Insurance Sales AI Agent.
4
+
5
+ ---
6
+
7
+ ## System Architecture
8
+
9
+ The application is built using a modular approach to ensure simplicity, explainability and high performance.
10
+
11
+ ### 1. Frontend & Orchestration
12
+ - **Technology**:
13
+ - **Role**: Serves as the user interface and central controller. It manages:
14
+ - **Session State**: Tracks chat history, processed audio IDs, and indexed document status.
15
+ - **Resource Caching**: Uses `@st.cache_resource` to ensure heavy models (Whisper, RAG, LLM) are loaded only once.
16
+ - **Component Coordination**: Pipes data from the microphone to Whisper, then to the RAG engine, then to the LLM, and finally to the TTS engine.
17
+
18
+ ### 2. Retrieval-Augmented Generation
19
+ - **Technology**:
20
+ - **Embeddings**:
21
+ - **Workflow**:
22
+ - **Ingestion**:
23
+ - **Metadata Tagging**: Documents are tagged as either `Product` (policies) or `Regulatory` (annual reports).
24
+ - **Vector Storage**:
25
+ - **Retrieval**:
26
+
27
+ ### 3. Language Processing
28
+ - **Technology**:
29
+ - **Role**: The "reasoning" engine.
30
+ - **System Prompting**: Enforces a persona of a "Professional Insurance Advisor." It is explicitly instructed to:
31
+ - Compare multiple policies.
32
+ - Use specific citation tags (e.g., `[Source: ...]`).
33
+ - Be concise for voice-based interactions.
34
+
35
+ ### 4. Voice Pipeline
36
+ - **STT (Speech-to-Text)**:
37
+ - **TTS (Text-to-Speech)**:
38
+
39
+ ---
40
+
41
+ ## How It Works (Step-by-Step)
42
+
43
+ 1. **Document Indexing**: User uploads a PDF.
44
+ 2. **Voice Input**: Captures raw audio.
45
+ 3. **Transcription**: Converts the audio bytes into text.
46
+ 4. **Contextual Retrieval**: The text query is embedded and matched against the DB. The engine returns the most relevant snippets along with their source.
47
+ 5. **Response Generation**: The LLM receives the user's question and the retrieved snippets. It synthesises a natural language answer.
48
+ 6. **Voice Synthesis**: The answer is converted to voice
49
+ 7. **Playback**: Audio component plays the response automatically
50
+
Dockerfile ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-stage build:
2
+ # Stage 1 — build the Next.js frontend to a static export
3
+ # Stage 2 — Python runtime serving FastAPI + the built frontend on the same port
4
+
5
+ # ----------------------------------------------------------------------------
6
+ # Stage 1 — Node builder
7
+ # ----------------------------------------------------------------------------
8
+ FROM node:22-alpine AS frontend-builder
9
+ WORKDIR /app/frontend
10
+
11
+ # Install deps first for layer caching
12
+ COPY frontend/package.json frontend/package-lock.json* ./
13
+ RUN npm ci --no-audit --no-fund
14
+
15
+ # Copy the rest of the frontend and build
16
+ COPY frontend/ ./
17
+ # In production, the frontend calls the same origin (no separate backend URL).
18
+ ENV NEXT_PUBLIC_BACKEND_URL=""
19
+ # Static-export the app — produces ./out
20
+ RUN npm run build
21
+
22
+ # ----------------------------------------------------------------------------
23
+ # Stage 2 — Python runtime (FastAPI + corpus + Chroma + DuckDB + frontend)
24
+ # ----------------------------------------------------------------------------
25
+ FROM python:3.11-slim
26
+ WORKDIR /app
27
+
28
+ # System deps for pdfplumber + torch CPU + sentence-transformers
29
+ RUN apt-get update && apt-get install -y --no-install-recommends \
30
+ build-essential \
31
+ libpoppler-cpp-dev \
32
+ pkg-config \
33
+ poppler-utils \
34
+ && rm -rf /var/lib/apt/lists/*
35
+
36
+ # Install Python deps
37
+ COPY requirements.txt ./
38
+ RUN pip install --no-cache-dir --upgrade pip && \
39
+ pip install --no-cache-dir -r requirements.txt
40
+
41
+ # Pre-download the embedding model so the first request is fast (no cold load)
42
+ RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('BAAI/bge-small-en-v1.5')"
43
+
44
+ # Copy the backend source + RAG modules + data
45
+ COPY backend ./backend
46
+ COPY rag ./rag
47
+ COPY eval ./eval
48
+ COPY docs ./docs
49
+
50
+ # Copy the built frontend from stage 1
51
+ COPY --from=frontend-builder /app/frontend/out ./frontend/out
52
+
53
+ # HF Spaces sends traffic to $PORT (default 7860). uvicorn will bind to it.
54
+ ENV PORT=7860
55
+ EXPOSE 7860
56
+
57
+ # Use a non-root user (HF Spaces recommends this for Docker spaces)
58
+ RUN useradd -m -u 1000 user && chown -R user:user /app
59
+ USER user
60
+
61
+ # Start the FastAPI app
62
+ CMD ["sh", "-c", "uvicorn backend.main:app --host 0.0.0.0 --port ${PORT} --log-level info"]
README.md CHANGED
@@ -1,12 +1,74 @@
1
  ---
2
- title: InsuranceBot
3
- emoji: 🐠
4
- colorFrom: green
5
- colorTo: green
6
  sdk: docker
 
7
  pinned: false
8
  license: mit
9
- short_description: Insurance Sales Bot
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Insurance Sales Portfolio Expert
3
+ emoji: 🏥
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  license: mit
10
+ short_description: Voice AI advisor for Indian health insurance
11
  ---
12
 
13
+ # Insurance Sales Portfolio Expert
14
+
15
+ A voice-first AI advisor for Indian health insurance buyers, over a curated corpus of policies from 10 leading insurers. Built as a take-home assignment for Sarvam AI.
16
+
17
+ **Live demo:** *(deployed URL — added at submission)*
18
+
19
+ **Docs (read in order):**
20
+
21
+ 1. [`docs/01-requirements.md`](docs/01-requirements.md) — product vision, personas, success criteria, non-goals
22
+ 2. [`docs/02-architecture.md`](docs/02-architecture.md) — stack picks, schema, system design *(in progress)*
23
+ 3. [`docs/03-eval-plan.md`](docs/03-eval-plan.md) — gold Q&A pairs, automated grader, accuracy targets *(in progress)*
24
+ 4. [`docs/04-failure-modes.md`](docs/04-failure-modes.md) — known failure modes + mitigations *(in progress)*
25
+ 5. [`docs/05-needs-analysis-flow.md`](docs/05-needs-analysis-flow.md) — fact-find question graph *(in progress)*
26
+ 6. [`docs/decisions.md`](docs/decisions.md) — every meaningful decision logged with alternatives + reasoning
27
+ 7. [`docs/ROADMAP.md`](docs/ROADMAP.md) — how this scales from v1 vertical slice to full platform
28
+
29
+ ## Quick start
30
+
31
+ ```bash
32
+ git clone https://github.com/rohitsar567/insurance-sales-bot.git
33
+ cd insurance-sales-bot
34
+ python -m venv .venv && source .venv/bin/activate
35
+ pip install -r requirements.txt
36
+ cp .env.example .env # then fill in SARVAM_API_KEY
37
+ streamlit run streamlit_app.py
38
+ ```
39
+
40
+ ## Architecture at a glance
41
+
42
+ ```
43
+ User voice → Sarvam Saaras (STT) → Orchestrator → Sarvam-M (LLM)
44
+
45
+ Hybrid retrieval:
46
+ ├─ Structured (DuckDB) — filters, comparison
47
+ └─ Unstructured (Chroma) — chunked PDFs, RAG
48
+
49
+ Cited response → Sarvam Bulbul (TTS) → User
50
+ ```
51
+
52
+ ## What's in scope for v1 (the vertical slice)
53
+
54
+ - 10 insurers × all their health policies (target: 40–80 policies)
55
+ - 40–50 structured fields per policy (premium, sum insured, waiting periods, PED, sub-limits, network, claim ratio, geography, etc.)
56
+ - Voice advisor with Hindi/English code-switch
57
+ - Adaptive needs analysis
58
+ - Granular filter + side-by-side comparison
59
+ - Illustrative pricing bands
60
+
61
+ ## What's out of scope (v2 roadmap)
62
+
63
+ See [`docs/01-requirements.md` §7](docs/01-requirements.md).
64
+
65
+ ## Built with
66
+
67
+ - **STT**: Sarvam Saaras
68
+ - **TTS**: Sarvam Bulbul
69
+ - **LLM**: Sarvam-M
70
+ - **Vector DB**: Chroma
71
+ - **Structured DB**: DuckDB
72
+ - **UI**: Streamlit
73
+
74
+ Each pick is justified in [`docs/decisions.md`](docs/decisions.md).
backend/__init__.py ADDED
File without changes
backend/config.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Centralized settings loaded from .env via pydantic-settings.
2
+
3
+ All API keys + tunables live here. Never read os.environ directly elsewhere.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from dotenv import load_dotenv
13
+
14
+ ROOT = Path(__file__).resolve().parent.parent
15
+ load_dotenv(ROOT / ".env")
16
+
17
+
18
+ class Settings:
19
+ # Provider keys
20
+ SARVAM_API_KEY: str = os.environ.get("SARVAM_API_KEY", "")
21
+ VOYAGE_API_KEY: str = os.environ.get("VOYAGE_API_KEY", "")
22
+ GROQ_API_KEY: str = os.environ.get("GROQ_API_KEY", "")
23
+ OPENROUTER_API_KEY: str = os.environ.get("OPENROUTER_API_KEY", "")
24
+
25
+ # Sarvam endpoints
26
+ SARVAM_BASE_URL: str = "https://api.sarvam.ai"
27
+ SARVAM_STT_PATH: str = "/speech-to-text"
28
+ SARVAM_TTS_PATH: str = "/text-to-speech"
29
+ SARVAM_CHAT_PATH: str = "/v1/chat/completions"
30
+
31
+ # Sarvam model identifiers
32
+ SARVAM_STT_MODEL: str = "saarika:v2.5"
33
+ SARVAM_TTS_MODEL: str = "bulbul:v2"
34
+ SARVAM_TTS_SPEAKER: str = "anushka" # natural female advisor voice; configurable
35
+ SARVAM_LLM_MODEL: str = "sarvam-m"
36
+
37
+ # Voyage
38
+ VOYAGE_MODEL: str = "voyage-3"
39
+
40
+ # Groq (grader + fallback brain)
41
+ GROQ_BASE_URL: str = "https://api.groq.com/openai/v1"
42
+ GROQ_GRADER_MODEL: str = "llama-3.3-70b-versatile"
43
+ GROQ_BRAIN_MODEL: str = "llama-3.3-70b-versatile"
44
+
45
+ # OpenRouter (alt fallback brain)
46
+ OPENROUTER_BASE_URL: str = "https://openrouter.ai/api/v1"
47
+ OPENROUTER_BRAIN_MODEL: str = "deepseek/deepseek-chat-v3-0324"
48
+
49
+ # Storage paths
50
+ CORPUS_DIR: Path = ROOT / "rag" / "corpus"
51
+ EXTRACTED_DIR: Path = ROOT / "rag" / "extracted"
52
+ VECTORS_DIR: Path = ROOT / "rag" / "vectors"
53
+ STRUCTURED_DB: Path = ROOT / "rag" / "policies.duckdb"
54
+
55
+ # Tunables
56
+ CHUNK_TOKENS: int = 800
57
+ CHUNK_OVERLAP_TOKENS: int = 120
58
+ RAG_TOP_K: int = 5
59
+
60
+ @classmethod
61
+ def validate(cls) -> list[str]:
62
+ """Return list of missing required keys. Empty list = healthy."""
63
+ missing = []
64
+ for k in ("SARVAM_API_KEY", "VOYAGE_API_KEY", "GROQ_API_KEY"):
65
+ if not getattr(cls, k):
66
+ missing.append(k)
67
+ return missing
68
+
69
+
70
+ settings = Settings()
backend/faithfulness.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hallucination prevention — multi-layer faithfulness verification.
2
+
3
+ Every assistant reply passes through these gates before being returned to the user.
4
+ Failure at any gate triggers a regeneration with a stricter prompt, or a refusal.
5
+
6
+ The mechanism:
7
+
8
+ Gate 1 — RETRIEVAL FLOOR
9
+ If the top retrieved chunk has cosine similarity below MIN_TOP_SCORE,
10
+ we refuse outright — there isn't enough grounded evidence to answer.
11
+
12
+ Gate 2 — CITATION INTEGRITY
13
+ Every [Source: <Policy Name> ...] tag that appears in the reply MUST match
14
+ a real retrieved chunk's policy_name. Fabricated citations = block.
15
+
16
+ Gate 3 — NUMERIC GROUNDING
17
+ Every monetary amount (₹), percentage, day/month/year count in the reply
18
+ must also appear in at least one retrieved chunk. Catches the "premium is
19
+ ₹15,000" hallucination class deterministically.
20
+
21
+ Gate 4 — LLM-JUDGE FAITHFULNESS (Groq Llama, cheap + fast + different family)
22
+ Pass {retrieved_chunks, reply} to a second LLM with prompt:
23
+ "For each factual claim in the reply, is it supported by these chunks?
24
+ Reply STRICT_JSON: {supported: bool, unsupported_claims: [str]}"
25
+ Block if supported=false.
26
+
27
+ Gate 5 — AUDIT
28
+ Every block + every recoverable warning is appended to logs/hallucinations.jsonl
29
+ for post-hoc analysis and compliance audit.
30
+
31
+ Public API:
32
+ check_faithfulness(reply, retrieved_chunks) -> FaithfulnessVerdict
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import re
39
+ import time
40
+ from dataclasses import dataclass, field
41
+ from pathlib import Path
42
+ from typing import Optional
43
+
44
+ from backend.config import settings
45
+ from backend.providers.base import ChatMessage
46
+ from backend.providers.groq_llm import GroqLLM
47
+ from rag.retrieve import RetrievedChunk
48
+
49
+ LOG_DIR = settings.CORPUS_DIR.parent.parent / "logs"
50
+ LOG_DIR.mkdir(exist_ok=True)
51
+ HALLUCINATION_LOG = LOG_DIR / "hallucinations.jsonl"
52
+
53
+ # Tunables — these become evaluable parameters in the eval harness.
54
+ # BGE-small returns higher cosine similarity than Voyage, so the floors are
55
+ # higher here than they would be for Voyage. Re-tune if changing embedding model.
56
+ MIN_TOP_SCORE = 0.40 # below this we refuse outright (BGE-small cosine similarity)
57
+ MIN_AVG_SCORE = 0.30 # average of top 5 must be above this
58
+
59
+
60
+ @dataclass
61
+ class FaithfulnessVerdict:
62
+ passed: bool
63
+ reasons: list[str] = field(default_factory=list) # gate names that failed
64
+ unsupported_claims: list[str] = field(default_factory=list)
65
+ suggested_reply: Optional[str] = None # what to show user if blocked
66
+
67
+
68
+ # ============================================================================
69
+ # Gate 1 — RETRIEVAL FLOOR
70
+ # ============================================================================
71
+
72
+ def _gate_retrieval_floor(chunks: list[RetrievedChunk]) -> tuple[bool, str]:
73
+ if not chunks:
74
+ return False, "no chunks retrieved"
75
+ top = chunks[0].score
76
+ avg = sum(c.score for c in chunks[:5]) / max(1, len(chunks[:5]))
77
+ if top < MIN_TOP_SCORE:
78
+ return False, f"top_score={top:.2f} below floor {MIN_TOP_SCORE}"
79
+ if avg < MIN_AVG_SCORE:
80
+ return False, f"avg_top5={avg:.2f} below floor {MIN_AVG_SCORE}"
81
+ return True, ""
82
+
83
+
84
+ # ============================================================================
85
+ # Gate 2 — CITATION INTEGRITY
86
+ # ============================================================================
87
+
88
+ # Match [Source: <something>] or [Regulation: <something>] patterns
89
+ CITATION_PATTERN = re.compile(r"\[(?:Source|Regulation):\s*([^\]]+)\]", flags=re.IGNORECASE)
90
+
91
+
92
+ def _gate_citation_integrity(reply: str, chunks: list[RetrievedChunk]) -> tuple[bool, list[str]]:
93
+ """Every cited policy name must be one we actually retrieved."""
94
+ cited = CITATION_PATTERN.findall(reply)
95
+ if not cited:
96
+ # No citations made — only OK if reply is a refusal
97
+ if any(kw in reply.lower() for kw in ("i don't see", "i don't have", "i'm not sure", "i couldn't find")):
98
+ return True, []
99
+ return False, ["citation_missing"]
100
+
101
+ valid_names = {c.policy_name.lower() for c in chunks}
102
+ valid_slugs = {c.insurer_slug.lower() for c in chunks}
103
+ fabricated = []
104
+ for citation in cited:
105
+ cit_lower = citation.lower()
106
+ # Permissive match — citation must contain a real policy or insurer slug
107
+ matched = any(name and name in cit_lower for name in valid_names if len(name) >= 4)
108
+ matched = matched or any(slug in cit_lower for slug in valid_slugs if len(slug) >= 4)
109
+ # Also accept regulatory citations once we have regulatory chunks (heuristic)
110
+ if "irda" in cit_lower or "regulation" in cit_lower:
111
+ has_regulatory_chunk = any("regulator" in c.doc_type.lower() or "irda" in c.policy_name.lower() for c in chunks)
112
+ if has_regulatory_chunk:
113
+ matched = True
114
+ if not matched:
115
+ fabricated.append(citation)
116
+ if fabricated:
117
+ return False, [f"fabricated_citation: {f}" for f in fabricated]
118
+ return True, []
119
+
120
+
121
+ # ============================================================================
122
+ # Gate 3 — NUMERIC GROUNDING
123
+ # ============================================================================
124
+
125
+ # Capture amounts (₹X / Rs.X / lakh / crore / NN%) and durations (NN days / months / years)
126
+ RUPEE_RE = re.compile(r"₹\s*[\d,]+(?:\.\d+)?\s*(?:lakh|crore|cr|k)?", flags=re.IGNORECASE)
127
+ PERCENT_RE = re.compile(r"\b\d{1,3}(?:\.\d+)?\s*%")
128
+ DURATION_RE = re.compile(r"\b\d{1,4}\s*(?:day|days|month|months|year|years)\b", flags=re.IGNORECASE)
129
+
130
+
131
+ def _normalize(s: str) -> str:
132
+ return re.sub(r"\s+", "", s.lower())
133
+
134
+
135
+ def _gate_numeric_grounding(reply: str, chunks: list[RetrievedChunk]) -> tuple[bool, list[str]]:
136
+ """Every numeric value in the reply must appear in retrieved chunks (loose match)."""
137
+ combined = " ".join(c.text for c in chunks).lower()
138
+ combined_norm = _normalize(combined)
139
+
140
+ unsupported: list[str] = []
141
+ for pattern in (RUPEE_RE, PERCENT_RE, DURATION_RE):
142
+ for match in pattern.findall(reply):
143
+ norm = _normalize(match)
144
+ # Loose contains check
145
+ if norm in combined_norm:
146
+ continue
147
+ # Also accept the digit-only stem in case units differ
148
+ digit_only = re.sub(r"[^\d]", "", match)
149
+ if digit_only and len(digit_only) >= 2 and digit_only in re.sub(r"[^\d]", "", combined):
150
+ continue
151
+ unsupported.append(match.strip())
152
+
153
+ if unsupported:
154
+ return False, unsupported
155
+ return True, []
156
+
157
+
158
+ # ============================================================================
159
+ # Gate 4 — LLM-JUDGE FAITHFULNESS (Groq Llama)
160
+ # ============================================================================
161
+
162
+ _judge: Optional[GroqLLM] = None
163
+
164
+ def _get_judge() -> GroqLLM:
165
+ global _judge
166
+ if _judge is None:
167
+ _judge = GroqLLM()
168
+ return _judge
169
+
170
+
171
+ JUDGE_SYSTEM = """You are a strict faithfulness verifier for an insurance advisor bot.
172
+
173
+ You will be given:
174
+ - RETRIEVED_CHUNKS: text from policy documents
175
+ - REPLY: the bot's answer to a user
176
+
177
+ Your job: determine whether EVERY factual claim in REPLY is supported by RETRIEVED_CHUNKS. A claim is unsupported if the chunks don't say it, OR if the chunks contradict it.
178
+
179
+ OUTPUT FORMAT — strict JSON, nothing else:
180
+ {
181
+ "supported": true | false,
182
+ "unsupported_claims": ["claim 1", "claim 2", ...]
183
+ }
184
+
185
+ Be strict. The bot's job is to NOT hallucinate. If a claim is ambiguously supported (vague match), flag it. Soft-language is fine — only flag factual claims (numbers, coverage, exclusions, durations, citations).
186
+ """
187
+
188
+
189
+ async def _gate_llm_judge(reply: str, chunks: list[RetrievedChunk]) -> tuple[bool, list[str]]:
190
+ if not reply or len(reply) < 30:
191
+ return True, []
192
+ if not chunks:
193
+ return False, ["no_chunks_to_verify_against"]
194
+
195
+ chunk_text = "\n\n---\n\n".join(
196
+ f"[{c.policy_name} ({c.insurer_slug}), p.{c.page_start}]\n{c.text[:2000]}" for c in chunks[:5]
197
+ )
198
+ user = f"""RETRIEVED_CHUNKS:
199
+ {chunk_text}
200
+
201
+ REPLY:
202
+ {reply}
203
+
204
+ Verify."""
205
+
206
+ try:
207
+ judge = _get_judge()
208
+ res = await judge.chat(
209
+ messages=[
210
+ ChatMessage(role="system", content=JUDGE_SYSTEM),
211
+ ChatMessage(role="user", content=user),
212
+ ],
213
+ temperature=0.0,
214
+ max_tokens=400,
215
+ response_format={"type": "json_object"},
216
+ )
217
+ data = json.loads(res.text)
218
+ supported = bool(data.get("supported", False))
219
+ unsupported = list(data.get("unsupported_claims", []))
220
+ return supported, unsupported
221
+ except Exception as e:
222
+ # If judge fails, fail-open with a warning (don't block valid replies on infra hiccup)
223
+ return True, [f"judge_error_failopen: {type(e).__name__}"]
224
+
225
+
226
+ # ============================================================================
227
+ # Main entry — orchestrator calls this
228
+ # ============================================================================
229
+
230
+ REFUSAL_TEMPLATE = (
231
+ "I'd rather not answer that without stronger evidence in the policy documents I have. "
232
+ "Could you rephrase, or narrow your question to a specific policy?"
233
+ )
234
+
235
+
236
+ async def check_faithfulness(
237
+ reply: str,
238
+ chunks: list[RetrievedChunk],
239
+ user_text: str = "",
240
+ run_llm_judge: bool = True,
241
+ ) -> FaithfulnessVerdict:
242
+ """Run all gates. Return verdict with reasons + a safe reply to show user if blocked."""
243
+ verdict = FaithfulnessVerdict(passed=True)
244
+
245
+ # Gate 1 — retrieval floor
246
+ ok1, msg1 = _gate_retrieval_floor(chunks)
247
+ if not ok1:
248
+ verdict.passed = False
249
+ verdict.reasons.append(f"gate1_retrieval: {msg1}")
250
+
251
+ # If retrieval already failed, suggest refusal and skip downstream gates
252
+ if not verdict.passed:
253
+ verdict.suggested_reply = REFUSAL_TEMPLATE
254
+ _log_block(user_text, reply, verdict, chunks)
255
+ return verdict
256
+
257
+ # Gate 2 — citation integrity
258
+ ok2, bad_citations = _gate_citation_integrity(reply, chunks)
259
+ if not ok2:
260
+ verdict.passed = False
261
+ verdict.reasons.extend(bad_citations)
262
+
263
+ # Gate 3 — numeric grounding
264
+ ok3, bad_nums = _gate_numeric_grounding(reply, chunks)
265
+ if not ok3:
266
+ verdict.passed = False
267
+ verdict.reasons.extend([f"unsupported_number: {n}" for n in bad_nums])
268
+ verdict.unsupported_claims.extend(bad_nums)
269
+
270
+ # Gate 4 — LLM judge (only if previous gates passed — saves token cost on obvious failures)
271
+ if verdict.passed and run_llm_judge:
272
+ ok4, unsupported = await _gate_llm_judge(reply, chunks)
273
+ if not ok4:
274
+ verdict.passed = False
275
+ verdict.reasons.append("gate4_llm_judge: claims unsupported")
276
+ verdict.unsupported_claims.extend(unsupported)
277
+
278
+ if not verdict.passed:
279
+ verdict.suggested_reply = REFUSAL_TEMPLATE
280
+ _log_block(user_text, reply, verdict, chunks)
281
+
282
+ return verdict
283
+
284
+
285
+ # ============================================================================
286
+ # Audit log
287
+ # ============================================================================
288
+
289
+ def _log_block(user_text: str, reply: str, verdict: FaithfulnessVerdict, chunks: list[RetrievedChunk]) -> None:
290
+ """Append every faithfulness block to logs/hallucinations.jsonl for compliance audit."""
291
+ entry = {
292
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
293
+ "user_text": user_text,
294
+ "blocked_reply": reply,
295
+ "reasons": verdict.reasons,
296
+ "unsupported_claims": verdict.unsupported_claims,
297
+ "chunk_count": len(chunks),
298
+ "top_score": chunks[0].score if chunks else None,
299
+ "policy_ids": list({c.policy_id for c in chunks}),
300
+ }
301
+ with open(HALLUCINATION_LOG, "a") as f:
302
+ f.write(json.dumps(entry) + "\n")
backend/main.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI app — the backend API for the Insurance Sales Portfolio Expert.
2
+
3
+ Run locally:
4
+ uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
5
+
6
+ Interactive docs at http://localhost:8000/docs
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ import time
14
+ import uuid
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
19
+ from fastapi.middleware.cors import CORSMiddleware
20
+ from fastapi.responses import FileResponse, JSONResponse
21
+ from fastapi.staticfiles import StaticFiles
22
+ from pydantic import BaseModel, Field
23
+
24
+ from backend.config import settings
25
+ from backend.orchestrator import handle_turn
26
+ from backend.providers.sarvam_stt import SarvamSTT
27
+ from backend.providers.sarvam_tts import SarvamTTS
28
+
29
+ # Singleton provider instances (initialized on first call)
30
+ _stt: Optional[SarvamSTT] = None
31
+ _tts: Optional[SarvamTTS] = None
32
+
33
+ def get_stt() -> SarvamSTT:
34
+ global _stt
35
+ if _stt is None:
36
+ _stt = SarvamSTT()
37
+ return _stt
38
+
39
+ def get_tts() -> SarvamTTS:
40
+ global _tts
41
+ if _tts is None:
42
+ _tts = SarvamTTS()
43
+ return _tts
44
+
45
+
46
+ # ---------- log helpers ----------
47
+
48
+ LOG_DIR = settings.CORPUS_DIR.parent.parent / "logs"
49
+ LOG_DIR.mkdir(exist_ok=True)
50
+ TURNS_LOG = LOG_DIR / "turns.jsonl"
51
+
52
+
53
+ def log_turn(event: dict) -> None:
54
+ event["ts"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
55
+ with open(TURNS_LOG, "a") as f:
56
+ f.write(json.dumps(event) + "\n")
57
+
58
+
59
+ # ---------- API schemas ----------
60
+
61
+ class HealthResponse(BaseModel):
62
+ status: str
63
+ providers_ok: dict[str, bool]
64
+ missing_keys: list[str]
65
+
66
+
67
+ class TranscribeResponse(BaseModel):
68
+ text: str
69
+ language_code: Optional[str] = None
70
+ confidence: Optional[float] = None
71
+ latency_ms: int
72
+
73
+
74
+ class CitationOut(BaseModel):
75
+ policy_id: str
76
+ policy_name: str
77
+ insurer_slug: str
78
+ page_start: int
79
+ page_end: int
80
+ source_url: str
81
+ score: float
82
+
83
+
84
+ class ChatRequest(BaseModel):
85
+ user_text: str = Field(..., description="The user's question or utterance")
86
+ session_id: Optional[str] = Field(None, description="Stable per-session ID for logging")
87
+ chat_history: list[dict] = Field(default_factory=list, description="[{role, content}, ...]")
88
+ profile: dict = Field(default_factory=dict, description="User profile (age, dependents, etc.)")
89
+ policy_filter_ids: Optional[list[str]] = Field(None, description="Restrict retrieval to these policies")
90
+ return_audio: bool = Field(False, description="If true, also return TTS audio (base64 WAV)")
91
+ tts_language_code: str = Field("en-IN", description="Language for TTS playback")
92
+
93
+
94
+ class ChatResponse(BaseModel):
95
+ reply_text: str
96
+ citations: list[CitationOut]
97
+ brain_used: str
98
+ intent: str
99
+ language: str
100
+ latency_ms: int
101
+ session_id: str
102
+ audio_base64: Optional[str] = None
103
+ faithfulness_passed: bool = True
104
+ faithfulness_reasons: list[str] = Field(default_factory=list)
105
+ blocked: bool = False
106
+
107
+
108
+ class TTSRequest(BaseModel):
109
+ text: str
110
+ language_code: str = "en-IN"
111
+ speaker: Optional[str] = None
112
+
113
+
114
+ # ---------- app ----------
115
+
116
+ app = FastAPI(
117
+ title="Insurance Sales Portfolio Expert API",
118
+ description="Backend for the Sarvam AI take-home assignment.",
119
+ version="0.1.0",
120
+ )
121
+
122
+ app.add_middleware(
123
+ CORSMiddleware,
124
+ allow_origins=["*"], # tighten for production deploy
125
+ allow_credentials=True,
126
+ allow_methods=["*"],
127
+ allow_headers=["*"],
128
+ )
129
+
130
+
131
+ @app.get("/api/health", response_model=HealthResponse)
132
+ async def health():
133
+ missing = settings.validate()
134
+ providers_ok = {
135
+ "sarvam": bool(settings.SARVAM_API_KEY),
136
+ "voyage": bool(settings.VOYAGE_API_KEY),
137
+ "groq": bool(settings.GROQ_API_KEY),
138
+ "openrouter": bool(settings.OPENROUTER_API_KEY),
139
+ }
140
+ return HealthResponse(
141
+ status="ok" if not missing else "degraded",
142
+ providers_ok=providers_ok,
143
+ missing_keys=missing,
144
+ )
145
+
146
+
147
+ @app.post("/api/transcribe", response_model=TranscribeResponse)
148
+ async def transcribe(
149
+ file: UploadFile = File(...),
150
+ language_code: Optional[str] = Form(None),
151
+ ):
152
+ """Speech-to-text. Accepts an audio file upload (WAV/MP3/etc.)."""
153
+ t0 = time.time()
154
+ audio_bytes = await file.read()
155
+ ext = (file.filename or "audio.wav").rsplit(".", 1)[-1].lower()
156
+ try:
157
+ result = await get_stt().transcribe(
158
+ audio_bytes=audio_bytes,
159
+ audio_format=ext if ext in ("wav", "mp3", "flac", "ogg", "m4a") else "wav",
160
+ language_code=language_code,
161
+ )
162
+ except Exception as e:
163
+ raise HTTPException(500, f"STT failed: {type(e).__name__}: {e}")
164
+ latency = int((time.time() - t0) * 1000)
165
+ return TranscribeResponse(
166
+ text=result.text,
167
+ language_code=result.language_code,
168
+ confidence=result.confidence,
169
+ latency_ms=latency,
170
+ )
171
+
172
+
173
+ @app.post("/api/chat", response_model=ChatResponse)
174
+ async def chat(req: ChatRequest):
175
+ session_id = req.session_id or str(uuid.uuid4())
176
+ try:
177
+ turn = await handle_turn(
178
+ user_text=req.user_text,
179
+ chat_history=req.chat_history,
180
+ user_profile=req.profile,
181
+ policy_filter_ids=req.policy_filter_ids,
182
+ )
183
+ except Exception as e:
184
+ log_turn({
185
+ "session_id": session_id,
186
+ "user_text": req.user_text,
187
+ "error": f"{type(e).__name__}: {e}",
188
+ })
189
+ raise HTTPException(500, f"Orchestrator failed: {type(e).__name__}: {e}")
190
+
191
+ audio_b64 = None
192
+ if req.return_audio and turn.reply_text:
193
+ try:
194
+ audio = await get_tts().synthesize(turn.reply_text, language_code=req.tts_language_code)
195
+ audio_b64 = base64.b64encode(audio).decode("utf-8")
196
+ except Exception as e:
197
+ # Don't fail the whole turn if TTS hiccups — log + return text only
198
+ log_turn({"session_id": session_id, "tts_error": f"{type(e).__name__}: {e}"})
199
+
200
+ log_turn({
201
+ "session_id": session_id,
202
+ "user_text": req.user_text,
203
+ "reply_text": turn.reply_text,
204
+ "brain_used": turn.brain_used,
205
+ "intent": turn.intent,
206
+ "language": turn.language,
207
+ "latency_ms": turn.latency_ms,
208
+ "retrieved_chunk_ids": turn.retrieved_chunk_ids,
209
+ "citation_count": len(turn.citations),
210
+ "faithfulness_passed": turn.faithfulness_passed,
211
+ "faithfulness_reasons": turn.faithfulness_reasons,
212
+ "blocked": turn.blocked,
213
+ })
214
+
215
+ return ChatResponse(
216
+ reply_text=turn.reply_text,
217
+ citations=[CitationOut(**c) for c in turn.citations],
218
+ brain_used=turn.brain_used,
219
+ intent=turn.intent,
220
+ language=turn.language,
221
+ latency_ms=turn.latency_ms,
222
+ session_id=session_id,
223
+ audio_base64=audio_b64,
224
+ faithfulness_passed=turn.faithfulness_passed,
225
+ faithfulness_reasons=turn.faithfulness_reasons,
226
+ blocked=turn.blocked,
227
+ )
228
+
229
+
230
+ @app.post("/api/tts")
231
+ async def tts(req: TTSRequest):
232
+ """Standalone TTS endpoint — returns base64 WAV."""
233
+ try:
234
+ audio = await get_tts().synthesize(
235
+ text=req.text,
236
+ language_code=req.language_code,
237
+ speaker=req.speaker,
238
+ )
239
+ except Exception as e:
240
+ raise HTTPException(500, f"TTS failed: {type(e).__name__}: {e}")
241
+ return JSONResponse({"audio_base64": base64.b64encode(audio).decode("utf-8")})
242
+
243
+
244
+ @app.get("/api")
245
+ async def api_root():
246
+ return {
247
+ "service": "Insurance Sales Portfolio Expert API",
248
+ "version": "0.1.0",
249
+ "docs": "/docs",
250
+ "health": "/api/health",
251
+ }
252
+
253
+
254
+ # ---- Static frontend (served alongside /api on the same port for HF Spaces) ----
255
+ # The Next.js frontend is statically exported during the Docker build to
256
+ # /app/frontend/out. In local dev, this directory may not exist — we still
257
+ # want the backend to start cleanly.
258
+ import os
259
+ from pathlib import Path as _Path
260
+
261
+ _FRONTEND_DIR = _Path(__file__).resolve().parent.parent / "frontend" / "out"
262
+ if _FRONTEND_DIR.exists():
263
+ # Serve the built site as the catch-all. /api/* routes registered above
264
+ # take precedence because they are matched first.
265
+ app.mount("/", StaticFiles(directory=str(_FRONTEND_DIR), html=True), name="static")
266
+ else:
267
+ @app.get("/")
268
+ async def root():
269
+ return {
270
+ "service": "Insurance Sales Portfolio Expert API",
271
+ "version": "0.1.0",
272
+ "frontend": "not built — run `cd frontend && npm run build`",
273
+ "docs": "/docs",
274
+ "health": "/api/health",
275
+ }
backend/needs_finder.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Adaptive fact-find question graph.
2
+
3
+ Inspired by what a good Independent Financial Advisor does in the first 10
4
+ minutes of a consultation: ask a stable core of questions, then deep-dive
5
+ conditionally based on signal. The depth is adaptive — stop when we have
6
+ enough to recommend.
7
+
8
+ The graph is explicit (not LLM-improvised) so:
9
+ - A reviewer can see and audit it
10
+ - Behavior is testable
11
+ - Failure modes are tractable
12
+ - It works without an LLM (fallback when the brain is degraded)
13
+
14
+ Public API:
15
+ - Profile dataclass — accumulated user state
16
+ - next_question(profile) -> str | None (None = ready to recommend)
17
+ - record_answer(profile, question_id, answer) -> Profile
18
+ - readback_summary(profile) -> str
19
+
20
+ The orchestrator can choose to drive the fact-find OR let the user
21
+ free-form questions — the graph supports both.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+ from typing import Any, Optional
28
+
29
+
30
+ @dataclass
31
+ class Profile:
32
+ """User profile accumulated during fact-find."""
33
+ age: Optional[int] = None
34
+ dependents: Optional[str] = None # "self", "self+spouse", "self+spouse+kids", "self+parents", etc.
35
+ income_band: Optional[str] = None # "under_5L", "5L-10L", "10L-25L", "25L+"
36
+ existing_cover_inr: Optional[int] = None # 0 means none
37
+ primary_goal: Optional[str] = None # "first_buy", "upgrade", "compare_specific", "tax_planning"
38
+ location_tier: Optional[str] = None # "metro", "tier1", "tier2", "tier3"
39
+ parents_to_insure: Optional[bool] = None
40
+ parents_age_max: Optional[int] = None # if parents_to_insure
41
+ parents_has_ped: Optional[bool] = None # if parents_to_insure
42
+ budget_band: Optional[str] = None # "under_15k", "15k_30k", "30k_60k", "60k+"
43
+ health_conditions: Optional[list[str]] = field(default_factory=list) # ["diabetes", "hypertension", ...]
44
+ asked: list[str] = field(default_factory=list) # question IDs already asked
45
+ free_form_session: bool = False # True = user asks free questions, not driven by us
46
+
47
+
48
+ # ----------------------------------------------------------------------------
49
+ # Question graph — each node has an id, a prompt, and a condition for being
50
+ # asked. Conditions are pure functions of the current Profile.
51
+ # ----------------------------------------------------------------------------
52
+
53
+ @dataclass
54
+ class Question:
55
+ id: str
56
+ prompt_en: str
57
+ prompt_hi: str # optional Hindi rendering (used when language='indic')
58
+ field: str
59
+ is_core: bool = False
60
+ condition: Any = None # callable(profile) -> bool, default: always ask
61
+ parser: Any = None # callable(text) -> value, default: text as-is
62
+
63
+
64
+ def _always(p: Profile) -> bool:
65
+ return True
66
+
67
+
68
+ GRAPH: list[Question] = [
69
+ Question(
70
+ id="age",
71
+ prompt_en="To start, what's your age?",
72
+ prompt_hi="शुरू करते हैं — आपकी उम्र क्या है?",
73
+ field="age",
74
+ is_core=True,
75
+ parser=lambda s: int("".join(c for c in str(s) if c.isdigit())[:3] or 0) or None,
76
+ ),
77
+ Question(
78
+ id="dependents",
79
+ prompt_en="Who else do you want to cover — just yourself, your spouse, kids, or parents too?",
80
+ prompt_hi="आपके अलावा किस-किस को cover करना है — पति/पत्नी, बच्चे, माता-पिता?",
81
+ field="dependents",
82
+ is_core=True,
83
+ ),
84
+ Question(
85
+ id="income_band",
86
+ prompt_en="Roughly what's your annual income — under 5 lakh, 5-10, 10-25, or 25+ lakh?",
87
+ prompt_hi="आपकी सालाना आय लगभग कितनी है — 5 लाख से कम, 5-10, 10-25, या 25 लाख से ज्यादा?",
88
+ field="income_band",
89
+ is_core=True,
90
+ ),
91
+ Question(
92
+ id="existing_cover",
93
+ prompt_en="Do you already have any health insurance — employer-provided or your own? How much sum insured?",
94
+ prompt_hi="क्या आपके पास पहले से कोई health insurance है — employer का या खुद का? Sum insured कितना है?",
95
+ field="existing_cover_inr",
96
+ is_core=True,
97
+ ),
98
+ Question(
99
+ id="primary_goal",
100
+ prompt_en="What's brought you here today — first health policy, upgrading existing cover, comparing specific policies, or tax planning?",
101
+ prompt_hi="आज आप यहाँ क्यों आए — पहली बार health policy ले रहे हैं, मौजूदा बढ़ाना है, specific policies compare करनी हैं, या tax planning के लिए?",
102
+ field="primary_goal",
103
+ is_core=True,
104
+ ),
105
+ Question(
106
+ id="location",
107
+ prompt_en="Which city do you live in? (Metro, tier-1, or smaller town?)",
108
+ prompt_hi="आप किस शहर में रहते हैं? (Metro, tier-1, या छो��ा शहर?)",
109
+ field="location_tier",
110
+ is_core=True,
111
+ ),
112
+ # Conditional deep-dives
113
+ Question(
114
+ id="parents_age",
115
+ prompt_en="What are your parents' ages, and do either of them have any pre-existing conditions like diabetes or BP?",
116
+ prompt_hi="आपके माता-पिता की उम्र क्या है, और क्या उनमें से किसी को diabetes या BP जैसी कोई pre-existing condition है?",
117
+ field="parents_age_max",
118
+ condition=lambda p: bool(p.dependents and "parent" in p.dependents.lower()),
119
+ ),
120
+ Question(
121
+ id="health_conditions",
122
+ prompt_en="Any pre-existing health conditions on your side — diabetes, BP, thyroid, anything chronic?",
123
+ prompt_hi="आपकी तरफ से कोई pre-existing condition है — diabetes, BP, thyroid, कुछ chronic?",
124
+ field="health_conditions",
125
+ condition=_always,
126
+ ),
127
+ Question(
128
+ id="budget",
129
+ prompt_en="What annual premium budget are you comfortable with — under 15K, 15-30K, 30-60K, or 60K+?",
130
+ prompt_hi="Premium के लिए सालाना कितना खर्च करना चाहेंगे — 15K से कम, 15-30K, 30-60K, या 60K+?",
131
+ field="budget_band",
132
+ is_core=True,
133
+ ),
134
+ ]
135
+
136
+
137
+ # ----------------------------------------------------------------------------
138
+ # Engine
139
+ # ----------------------------------------------------------------------------
140
+
141
+ def is_field_set(profile: Profile, field_name: str) -> bool:
142
+ v = getattr(profile, field_name, None)
143
+ if v is None:
144
+ return False
145
+ if isinstance(v, (list, str)) and len(v) == 0:
146
+ return False
147
+ return True
148
+
149
+
150
+ def next_question(profile: Profile, language: str = "en") -> Optional[Question]:
151
+ """Return the next question to ask, or None if we have enough to recommend."""
152
+ if profile.free_form_session:
153
+ return None
154
+
155
+ for q in GRAPH:
156
+ if q.id in profile.asked:
157
+ continue
158
+ if is_field_set(profile, q.field):
159
+ continue
160
+ cond = q.condition or _always
161
+ if cond(profile):
162
+ return q
163
+
164
+ # All applicable questions asked
165
+ return None
166
+
167
+
168
+ def record_answer(profile: Profile, question_id: str, raw_answer: str) -> Profile:
169
+ """Mutate profile in place with a parsed answer."""
170
+ q = next((x for x in GRAPH if x.id == question_id), None)
171
+ if q is None:
172
+ return profile
173
+ profile.asked.append(question_id)
174
+ value: Any = raw_answer
175
+ if q.parser:
176
+ try:
177
+ value = q.parser(raw_answer)
178
+ except Exception:
179
+ value = None
180
+ if value is not None and value != "":
181
+ setattr(profile, q.field, value)
182
+ return profile
183
+
184
+
185
+ def readback_summary(profile: Profile) -> str:
186
+ """One-paragraph human-readable summary of the gathered profile."""
187
+ bits = []
188
+ if profile.age:
189
+ bits.append(f"{profile.age} years old")
190
+ if profile.dependents:
191
+ bits.append(f"covering {profile.dependents}")
192
+ if profile.income_band:
193
+ bits.append(f"income {profile.income_band}")
194
+ if profile.existing_cover_inr is not None:
195
+ bits.append(
196
+ f"existing cover ₹{profile.existing_cover_inr:,}"
197
+ if profile.existing_cover_inr > 0
198
+ else "no existing cover"
199
+ )
200
+ if profile.primary_goal:
201
+ bits.append(f"primary goal: {profile.primary_goal}")
202
+ if profile.location_tier:
203
+ bits.append(profile.location_tier)
204
+ if profile.parents_age_max:
205
+ bits.append(f"parents up to age {profile.parents_age_max}")
206
+ if profile.health_conditions:
207
+ bits.append(f"conditions: {', '.join(profile.health_conditions)}")
208
+ if profile.budget_band:
209
+ bits.append(f"budget {profile.budget_band}")
210
+ return "; ".join(bits) if bits else "(no profile yet)"
backend/orchestrator.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Orchestrator — the brain of the bot.
2
+
3
+ For each user turn:
4
+ 1. Retrieve top-k relevant chunks from Chroma
5
+ 2. Format them as cited context
6
+ 3. Build messages with persona + history + profile
7
+ 4. Route to a brain LLM (Sarvam-M primary, Llama/DeepSeek fallback for complex)
8
+ 5. Strip <think> tags from Sarvam-M output
9
+ 6. Return (reply_text, citations[], retrieved_chunk_ids[], cost_estimate)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from dataclasses import dataclass, field
16
+ from typing import Optional
17
+
18
+ from backend.faithfulness import check_faithfulness, FaithfulnessVerdict
19
+ from backend.persona import build_messages, strip_think_tags
20
+ from backend.providers.base import ChatMessage, LLMProvider
21
+ from backend.providers.groq_llm import GroqLLM
22
+ from backend.providers.openrouter_llm import OpenRouterLLM
23
+ from backend.providers.sarvam_llm import SarvamLLM
24
+ from rag.retrieve import RetrievedChunk, format_for_llm_context, retrieve
25
+
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
33
+ "क", "ख", "ग", "घ", "च", "ज", "ट", "ड", "त", "द", "न", "प", "ब", "म", "य", "र", "ल", "व", "स", "ह",
34
+ # common Hinglish words
35
+ " hai ", " kya ", " mein ", " kar ", " ka ", " ki ", " ke ", " liye ", " mujhe ",
36
+ )
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):
44
+ return "recommendation"
45
+ return "qa"
46
+
47
+
48
+ def detect_language(query: str) -> str:
49
+ q = query.lower()
50
+ if any(kw in q for kw in INDIC_KEYWORDS):
51
+ return "indic"
52
+ if any(c in query for c in "अआइईउऊऋएऐओऔकखगघचछजझटठडढतथदधनपफबभमयरलवशषसह"):
53
+ return "indic"
54
+ return "en"
55
+
56
+
57
+ # ---------- brain router ----------
58
+
59
+ @dataclass
60
+ class BrainPick:
61
+ provider: LLMProvider
62
+ reason: str
63
+
64
+
65
+ def pick_brain(intent: str, language: str) -> BrainPick:
66
+ """Route to the right brain per Doc decisions.md D-016.
67
+
68
+ v1 heuristic (will be refined by gold eval results):
69
+ - Indic queries -> Sarvam-M (it's the Indic-strongest)
70
+ - Simple QA in English -> Sarvam-M (it's primary)
71
+ - Comparison/recommendation -> OpenRouter DeepSeek-V3 (strongest reasoning)
72
+ - (Llama-3.3-70B reserved for grader; used as fallback if DeepSeek fails)
73
+ """
74
+ if language == "indic":
75
+ return BrainPick(SarvamLLM(), "indic-query")
76
+ if intent in ("comparison", "recommendation"):
77
+ return BrainPick(OpenRouterLLM(), f"complex-{intent}")
78
+ return BrainPick(SarvamLLM(), "simple-qa")
79
+
80
+
81
+ # ---------- main entrypoint ----------
82
+
83
+ @dataclass
84
+ class TurnResult:
85
+ reply_text: str
86
+ citations: list[dict]
87
+ retrieved_chunk_ids: list[str]
88
+ brain_used: str
89
+ intent: str
90
+ language: str
91
+ latency_ms: int
92
+ raw_reply: str
93
+ faithfulness_passed: bool = True
94
+ faithfulness_reasons: list[str] = field(default_factory=list)
95
+ blocked: bool = False
96
+
97
+
98
+ async def handle_turn(
99
+ user_text: str,
100
+ chat_history: Optional[list[dict]] = None,
101
+ user_profile: Optional[dict] = None,
102
+ policy_filter_ids: Optional[list[str]] = None,
103
+ top_k: int = 5,
104
+ ) -> TurnResult:
105
+ t0 = time.time()
106
+
107
+ # 1. Classify
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,
114
+ top_k=top_k,
115
+ policy_ids=policy_filter_ids,
116
+ )
117
+ context_str = format_for_llm_context(chunks)
118
+
119
+ # 3. Pick brain
120
+ pick = pick_brain(intent, language)
121
+
122
+ # 4. Generate
123
+ messages_dict = build_messages(
124
+ user_query=user_text,
125
+ retrieved_context=context_str,
126
+ chat_history=chat_history,
127
+ user_profile=user_profile,
128
+ )
129
+ messages = [ChatMessage(role=m["role"], content=m["content"]) for m in messages_dict]
130
+
131
+ try:
132
+ llm_result = await pick.provider.chat(messages=messages, temperature=0.2, max_tokens=1500)
133
+ except Exception as e:
134
+ # Fallback to Groq Llama if primary brain fails
135
+ fallback = GroqLLM()
136
+ llm_result = await fallback.chat(messages=messages, temperature=0.2, max_tokens=1500)
137
+ pick = BrainPick(fallback, f"fallback-after-{type(e).__name__}")
138
+
139
+ # Detect truncated <think> reasoning — if so, retry with Groq (no reasoning tags)
140
+ if "<think>" in llm_result.text.lower() and "</think>" not in llm_result.text.lower():
141
+ try:
142
+ fallback = GroqLLM()
143
+ llm_result = await fallback.chat(messages=messages, temperature=0.2, max_tokens=1500)
144
+ pick = BrainPick(fallback, "fallback-truncated-reasoning")
145
+ except Exception:
146
+ pass
147
+
148
+ raw = llm_result.text
149
+ reply = strip_think_tags(raw)
150
+
151
+ # 5. FAITHFULNESS GATE — every reply runs through 4-gate verification.
152
+ # If any gate fails, replace the reply with a safe refusal. The original
153
+ # blocked reply is logged to logs/hallucinations.jsonl for audit.
154
+ verdict: FaithfulnessVerdict = await check_faithfulness(
155
+ reply=reply,
156
+ chunks=chunks,
157
+ user_text=user_text,
158
+ run_llm_judge=True,
159
+ )
160
+ blocked = False
161
+ if not verdict.passed:
162
+ blocked = True
163
+ reply = verdict.suggested_reply or "I don't have grounded evidence for that. Could you rephrase?"
164
+
165
+ # 6. Citations (derived from retrieved chunks)
166
+ citations = [
167
+ {
168
+ "policy_id": c.policy_id,
169
+ "policy_name": c.policy_name,
170
+ "insurer_slug": c.insurer_slug,
171
+ "page_start": c.page_start,
172
+ "page_end": c.page_end,
173
+ "source_url": c.source_url,
174
+ "score": round(c.score, 3),
175
+ }
176
+ for c in chunks
177
+ ]
178
+
179
+ return TurnResult(
180
+ reply_text=reply,
181
+ citations=citations,
182
+ retrieved_chunk_ids=[c.chunk_id for c in chunks],
183
+ brain_used=f"{pick.provider.name}::{pick.reason}",
184
+ intent=intent,
185
+ language=language,
186
+ latency_ms=int((time.time() - t0) * 1000),
187
+ raw_reply=raw,
188
+ faithfulness_passed=verdict.passed,
189
+ faithfulness_reasons=verdict.reasons,
190
+ blocked=blocked,
191
+ )
backend/persona.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """System prompts and persona definitions for the insurance advisor.
2
+
3
+ The persona is consultative, not closing. Citation grammar is non-negotiable.
4
+ Refusal on weak grounding is a feature, not a bug.
5
+
6
+ These prompts will be A/B-tested via the eval harness; current version is v0.1.
7
+ """
8
+
9
+ ADVISOR_SYSTEM_PROMPT_V1 = """You are an experienced, consultative insurance advisor specializing in Indian health insurance. You speak like a trusted Independent Financial Advisor (IFA), not a call-center salesperson.
10
+
11
+ YOUR ROLE
12
+ - Help buyers understand policies, compare options, and choose what fits their situation
13
+ - Educate when needed, recommend only when the buyer's profile clearly fits
14
+ - It is OK — and often correct — to say "this policy isn't right for you"
15
+
16
+ ABSOLUTE RULES (these are non-negotiable)
17
+
18
+ 1. GROUNDED ANSWERS ONLY. Every factual claim about a policy or regulation MUST come from the retrieved clauses provided in the context — NOTHING from your training memory. Specifically:
19
+ - NEVER cite IRDAI regulations or numbers unless the IRDAI text appears in the retrieved context
20
+ - NEVER cite Section 80D, GST rates, or any law unless it appears in the retrieved context
21
+ - NEVER cite "industry standard" or "typically" — there is only what the document says
22
+ If the answer is not in the retrieved context, say:
23
+ "I don't see that covered in this policy document. Would you like me to check what IS covered in this category?"
24
+ Hallucinated facts in BFSI = mis-selling = regulated offense.
25
+
26
+ 2. CITATION GRAMMAR. End every factual claim with an inline citation.
27
+ - For policy clauses: [Source: <Policy Name> (<insurer-slug>), p.<page>]
28
+ - For regulatory mandates: [Regulation: <Doc Name> (IRDAI / Govt), §<section>]
29
+ When a regulation OVERRIDES a policy clause (e.g., IRDAI mandates 30-day initial waiting period as a minimum), surface both. Regulatory citations are STRONGER signals than policy text — flag them when relevant.
30
+ For multi-policy compares, cite each policy separately.
31
+
32
+ 3. CONCISE FOR VOICE. Default reply length: under 60 words. Buyers hear this over voice — long replies are unusable. Use bullet points sparingly; prefer short complete sentences.
33
+
34
+ 4. NEVER GIVE MEDICAL ADVICE. "Will this be covered if I have X condition?" → answer the COVERAGE question, never the medical one.
35
+
36
+ 5. NEVER GIVE FINAL TRANSACTIONAL ADVICE. "Should I buy this?" is a guidance question, not a transactional one. Always anchor recommendations in the buyer's stated profile, and end with: "I'd recommend you confirm with the insurer directly before finalizing."
37
+
38
+ 6. INDIC + ENGLISH. If the user writes in Hindi or Hinglish, reply in the same mix. If they write English, reply in English. Match their register.
39
+
40
+ 7. NO SCARE TACTICS. Never use fear-of-missing-out or worst-case framing to push a sale.
41
+
42
+ FORMAT FOR YOUR REPLIES
43
+ - Direct answer first sentence
44
+ - Supporting fact(s) with inline citations
45
+ - Optional 1-line caveat if relevant (e.g., "note: this has a 36-month waiting period for PED")
46
+ - No preamble like "Sure!" or "Great question!"
47
+ """
48
+
49
+
50
+ def build_messages(
51
+ user_query: str,
52
+ retrieved_context: str,
53
+ chat_history: list[dict] | None = None,
54
+ user_profile: dict | None = None,
55
+ ) -> list[dict]:
56
+ """Assemble the message list for the LLM call."""
57
+ system = ADVISOR_SYSTEM_PROMPT_V1
58
+ if user_profile:
59
+ profile_summary = "\nUSER PROFILE (for personalization, do not echo verbatim):\n" + "\n".join(
60
+ f"- {k}: {v}" for k, v in user_profile.items() if v not in (None, "", [])
61
+ )
62
+ system = system + profile_summary
63
+
64
+ messages: list[dict] = [{"role": "system", "content": system}]
65
+
66
+ # History (excluding the last user turn — we add it below with context)
67
+ if chat_history:
68
+ for msg in chat_history[-10:]: # cap to last 5 turns
69
+ messages.append({"role": msg["role"], "content": msg["content"]})
70
+
71
+ user_block = f"""USER QUESTION:
72
+ {user_query}
73
+
74
+ RETRIEVED POLICY CLAUSES (use ONLY these for factual claims):
75
+ {retrieved_context if retrieved_context else "(no relevant clauses retrieved)"}
76
+
77
+ Reply now per the rules above."""
78
+ messages.append({"role": "user", "content": user_block})
79
+ return messages
80
+
81
+
82
+ # Strip Sarvam-M's <think>...</think> reasoning chains before returning to user.
83
+ import re
84
+
85
+ THINK_PATTERN = re.compile(r"<think>.*?</think>", flags=re.DOTALL | re.IGNORECASE)
86
+ OPEN_THINK = re.compile(r"<think>", flags=re.IGNORECASE)
87
+ CLOSE_THINK = re.compile(r"</think>", flags=re.IGNORECASE)
88
+
89
+
90
+ def strip_think_tags(text: str) -> str:
91
+ """Sarvam-M emits <think>...</think> chain-of-thought before the final answer.
92
+
93
+ Handle:
94
+ - well-formed: <think>...</think> answer → answer
95
+ - truncated reasoning (no </think>): <think>... cut off → fallback message
96
+ - reasoning followed by clean answer: <think>...</think> answer → answer
97
+ - well-formed with extra text after close: take only text after </think>
98
+ """
99
+ if "<think>" in text.lower() and "</think>" not in text.lower():
100
+ # Reasoning was truncated mid-thought — no final answer was produced.
101
+ return "I'm thinking through that. Could you rephrase or ask a follow-up?"
102
+
103
+ # Strip all complete <think>...</think> blocks.
104
+ cleaned = THINK_PATTERN.sub("", text).strip()
105
+ # If anything else got truncated, fall back gracefully.
106
+ if not cleaned:
107
+ return "I'm thinking through that. Could you rephrase or ask a follow-up?"
108
+ return cleaned
backend/providers/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provider clients — thin, async, behind a common interface.
2
+
3
+ Each provider lives in its own module:
4
+ sarvam_stt.py — Sarvam Saarika v2.5 (speech-to-text)
5
+ sarvam_tts.py — Sarvam Bulbul (text-to-speech)
6
+ sarvam_llm.py — Sarvam-M (chat / generation, primary brain)
7
+ voyage_embeddings.py — Voyage voyage-3 (embeddings)
8
+ groq_llm.py — Llama-3.3-70B on Groq (grader + medium fallback brain)
9
+ openrouter_llm.py — DeepSeek-V3 via OpenRouter (strongest fallback brain)
10
+
11
+ All clients are async (use httpx.AsyncClient) so the FastAPI handlers can
12
+ parallelize provider calls without blocking the event loop.
13
+ """
backend/providers/_smoke_test.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke-test every provider before building anything on top.
2
+
3
+ Run from project root:
4
+ python -m backend.providers._smoke_test
5
+
6
+ Each test prints OK/FAIL and the response. Failures here will surface in the
7
+ build before they surface in the UI.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import traceback
14
+
15
+ from backend.config import settings
16
+ from backend.providers.base import ChatMessage
17
+ from backend.providers.sarvam_llm import SarvamLLM
18
+ from backend.providers.sarvam_stt import SarvamSTT
19
+ from backend.providers.sarvam_tts import SarvamTTS
20
+ from backend.providers.voyage_embeddings import VoyageEmbeddings
21
+ from backend.providers.groq_llm import GroqLLM
22
+ from backend.providers.openrouter_llm import OpenRouterLLM
23
+
24
+
25
+ async def test_sarvam_llm():
26
+ print("\n--- Sarvam-M LLM ---")
27
+ try:
28
+ client = SarvamLLM()
29
+ result = await client.chat(
30
+ messages=[
31
+ ChatMessage(role="system", content="You are a helpful insurance advisor. Keep replies under 20 words."),
32
+ ChatMessage(role="user", content="What does PED stand for in health insurance?"),
33
+ ],
34
+ max_tokens=100,
35
+ )
36
+ print(f"OK | model={result.model} | reply: {result.text[:200]}")
37
+ print(f" tokens prompt={result.prompt_tokens} completion={result.completion_tokens}")
38
+ return True
39
+ except Exception as e:
40
+ print(f"FAIL | {type(e).__name__}: {e}")
41
+ traceback.print_exc()
42
+ return False
43
+
44
+
45
+ async def test_sarvam_tts():
46
+ print("\n--- Sarvam Bulbul TTS ---")
47
+ try:
48
+ client = SarvamTTS()
49
+ audio = await client.synthesize(
50
+ text="Hello, I am your insurance advisor.",
51
+ language_code="en-IN",
52
+ )
53
+ print(f"OK | got {len(audio)} bytes of audio")
54
+ # Save for manual inspection
55
+ out = settings.CORPUS_DIR.parent / "_smoke_tts.wav"
56
+ out.write_bytes(audio)
57
+ print(f" saved to {out.relative_to(settings.CORPUS_DIR.parent.parent)}")
58
+ return True
59
+ except Exception as e:
60
+ print(f"FAIL | {type(e).__name__}: {e}")
61
+ traceback.print_exc()
62
+ return False
63
+
64
+
65
+ async def test_voyage():
66
+ print("\n--- Voyage embeddings ---")
67
+ try:
68
+ client = VoyageEmbeddings()
69
+ vectors = await client.embed(["the cataract waiting period is 24 months", "policy covers ayurveda"])
70
+ print(f"OK | got {len(vectors)} vectors, dim={len(vectors[0])}")
71
+ return True
72
+ except Exception as e:
73
+ print(f"FAIL | {type(e).__name__}: {e}")
74
+ traceback.print_exc()
75
+ return False
76
+
77
+
78
+ async def test_groq():
79
+ print("\n--- Groq Llama-3.3-70B (grader + medium fallback) ---")
80
+ try:
81
+ client = GroqLLM()
82
+ result = await client.chat(
83
+ messages=[
84
+ ChatMessage(role="system", content="You are a strict evaluator. Reply YES or NO only."),
85
+ ChatMessage(role="user", content="Is '24 months' semantically equivalent to '2 years'? YES or NO."),
86
+ ],
87
+ max_tokens=10,
88
+ temperature=0.0,
89
+ )
90
+ print(f"OK | model={result.model} | reply: {result.text!r}")
91
+ return True
92
+ except Exception as e:
93
+ print(f"FAIL | {type(e).__name__}: {e}")
94
+ traceback.print_exc()
95
+ return False
96
+
97
+
98
+ async def test_openrouter():
99
+ print("\n--- OpenRouter DeepSeek-V3 (strongest fallback brain) ---")
100
+ try:
101
+ client = OpenRouterLLM()
102
+ result = await client.chat(
103
+ messages=[
104
+ ChatMessage(role="system", content="You are a precise insurance advisor."),
105
+ ChatMessage(role="user", content="Briefly: what does 'sum insured' mean in health insurance? Under 25 words."),
106
+ ],
107
+ max_tokens=100,
108
+ )
109
+ print(f"OK | model={result.model} | reply: {result.text[:200]}")
110
+ return True
111
+ except Exception as e:
112
+ print(f"FAIL | {type(e).__name__}: {e}")
113
+ traceback.print_exc()
114
+ return False
115
+
116
+
117
+ async def test_sarvam_stt():
118
+ """STT needs an audio file. We reuse the TTS output if it ran successfully."""
119
+ print("\n--- Sarvam Saarika STT ---")
120
+ try:
121
+ audio_path = settings.CORPUS_DIR.parent / "_smoke_tts.wav"
122
+ if not audio_path.exists():
123
+ print("SKIP | no _smoke_tts.wav (TTS must run first)")
124
+ return False
125
+ audio_bytes = audio_path.read_bytes()
126
+ client = SarvamSTT()
127
+ result = await client.transcribe(
128
+ audio_bytes=audio_bytes,
129
+ audio_format="wav",
130
+ language_code="en-IN",
131
+ )
132
+ print(f"OK | transcript: {result.text!r}")
133
+ print(f" language={result.language_code} confidence={result.confidence}")
134
+ return True
135
+ except Exception as e:
136
+ print(f"FAIL | {type(e).__name__}: {e}")
137
+ traceback.print_exc()
138
+ return False
139
+
140
+
141
+ async def main():
142
+ missing = settings.validate()
143
+ if missing:
144
+ print(f"WARN | missing keys: {missing}")
145
+
146
+ results = {}
147
+ results["sarvam_llm"] = await test_sarvam_llm()
148
+ results["voyage"] = await test_voyage()
149
+ results["groq"] = await test_groq()
150
+ results["openrouter"] = await test_openrouter()
151
+ results["sarvam_tts"] = await test_sarvam_tts()
152
+ results["sarvam_stt"] = await test_sarvam_stt() # depends on TTS output
153
+
154
+ print("\n========== SUMMARY ==========")
155
+ for name, ok in results.items():
156
+ print(f" {name:>20s}: {'OK' if ok else 'FAIL'}")
157
+ print(f"\n{sum(results.values())}/{len(results)} providers healthy.")
158
+
159
+
160
+ if __name__ == "__main__":
161
+ asyncio.run(main())
backend/providers/base.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Common types + abstract base classes for provider clients.
2
+
3
+ The interfaces are deliberately narrow:
4
+ - A speech-to-text provider takes audio bytes and returns text.
5
+ - A text-to-speech provider takes text and returns audio bytes.
6
+ - An LLM provider takes a list of messages and returns a text completion.
7
+ - An embeddings provider takes a list of strings and returns vectors.
8
+
9
+ Concrete implementations live in sibling modules (sarvam_*, groq_*, voyage_*,
10
+ openrouter_*). The orchestrator imports these by interface, never by concrete
11
+ class — so swapping providers is a config change.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from abc import ABC, abstractmethod
17
+ from dataclasses import dataclass, field
18
+ from typing import Optional
19
+
20
+
21
+ # ---------- shared message types ----------
22
+
23
+ @dataclass
24
+ class ChatMessage:
25
+ role: str # "system" | "user" | "assistant"
26
+ content: str
27
+
28
+
29
+ @dataclass
30
+ class STTResult:
31
+ text: str
32
+ language_code: Optional[str] = None
33
+ confidence: Optional[float] = None # 0-1 if provider returns it
34
+ raw: dict = field(default_factory=dict)
35
+
36
+
37
+ @dataclass
38
+ class LLMResult:
39
+ text: str
40
+ model: str
41
+ prompt_tokens: Optional[int] = None
42
+ completion_tokens: Optional[int] = None
43
+ raw: dict = field(default_factory=dict)
44
+
45
+
46
+ # ---------- abstract interfaces ----------
47
+
48
+ class STTProvider(ABC):
49
+ name: str
50
+
51
+ @abstractmethod
52
+ async def transcribe(
53
+ self,
54
+ audio_bytes: bytes,
55
+ audio_format: str = "wav",
56
+ language_code: Optional[str] = None,
57
+ ) -> STTResult: ...
58
+
59
+
60
+ class TTSProvider(ABC):
61
+ name: str
62
+
63
+ @abstractmethod
64
+ async def synthesize(
65
+ self,
66
+ text: str,
67
+ language_code: str = "en-IN",
68
+ speaker: Optional[str] = None,
69
+ ) -> bytes:
70
+ """Return raw audio bytes (WAV by default)."""
71
+ ...
72
+
73
+
74
+ class LLMProvider(ABC):
75
+ name: str
76
+ model: str
77
+
78
+ @abstractmethod
79
+ async def chat(
80
+ self,
81
+ messages: list[ChatMessage],
82
+ temperature: float = 0.2,
83
+ max_tokens: int = 1024,
84
+ response_format: Optional[dict] = None,
85
+ ) -> LLMResult: ...
86
+
87
+
88
+ class EmbeddingsProvider(ABC):
89
+ name: str
90
+ model: str
91
+ dimension: int
92
+
93
+ @abstractmethod
94
+ async def embed(self, texts: list[str]) -> list[list[float]]: ...
backend/providers/groq_llm.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Groq — Llama-3.3-70B (grader + medium fallback brain).
2
+
3
+ Groq exposes an OpenAI-compatible chat completions endpoint at
4
+ POST https://api.groq.com/openai/v1/chat/completions.
5
+
6
+ Used in two roles:
7
+ 1. Grader for eval harness (response: scoring)
8
+ 2. Fallback brain in the router for medium-complexity queries
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Optional
14
+
15
+ import httpx
16
+
17
+ from backend.config import settings
18
+ from backend.providers.base import ChatMessage, LLMProvider, LLMResult
19
+
20
+
21
+ class GroqLLM(LLMProvider):
22
+ name = "groq-llama"
23
+
24
+ def __init__(
25
+ self,
26
+ api_key: Optional[str] = None,
27
+ model: str = settings.GROQ_BRAIN_MODEL,
28
+ timeout: float = 60.0,
29
+ ):
30
+ self.api_key = api_key or settings.GROQ_API_KEY
31
+ self.model = model
32
+ self.timeout = timeout
33
+ if not self.api_key:
34
+ raise RuntimeError("GROQ_API_KEY not set in .env")
35
+
36
+ async def chat(
37
+ self,
38
+ messages: list[ChatMessage],
39
+ temperature: float = 0.2,
40
+ max_tokens: int = 1024,
41
+ response_format: Optional[dict] = None,
42
+ ) -> LLMResult:
43
+ url = f"{settings.GROQ_BASE_URL}/chat/completions"
44
+ body: dict = {
45
+ "model": self.model,
46
+ "messages": [{"role": m.role, "content": m.content} for m in messages],
47
+ "temperature": temperature,
48
+ "max_tokens": max_tokens,
49
+ }
50
+ if response_format:
51
+ body["response_format"] = response_format
52
+
53
+ headers = {
54
+ "Authorization": f"Bearer {self.api_key}",
55
+ "Content-Type": "application/json",
56
+ }
57
+
58
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
59
+ resp = await client.post(url, headers=headers, json=body)
60
+ resp.raise_for_status()
61
+ payload = resp.json()
62
+
63
+ choice = payload["choices"][0]
64
+ usage = payload.get("usage", {})
65
+ return LLMResult(
66
+ text=choice["message"]["content"],
67
+ model=payload.get("model", self.model),
68
+ prompt_tokens=usage.get("prompt_tokens"),
69
+ completion_tokens=usage.get("completion_tokens"),
70
+ raw=payload,
71
+ )
backend/providers/local_embeddings.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local embeddings via sentence-transformers.
2
+
3
+ Used as the v1 production embedder because Voyage's free-tier rate limit
4
+ (3 RPM) makes ingestion of 75+ PDFs impractical in our time budget.
5
+
6
+ Default model: BAAI/bge-small-en-v1.5 — 384-dim, ~110MB, top-of-class on
7
+ MTEB English benchmark for its size. Faster than 1024-dim cloud models on
8
+ CPU and trivially small on GPU.
9
+
10
+ The interface matches EmbeddingsProvider exactly, so the orchestrator and
11
+ RAG pipeline don't change — just the import in ingest/retrieve.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Literal, Optional
17
+
18
+ from backend.providers.base import EmbeddingsProvider
19
+
20
+
21
+ class LocalEmbeddings(EmbeddingsProvider):
22
+ name = "local-bge"
23
+
24
+ def __init__(
25
+ self,
26
+ model_name: str = "BAAI/bge-small-en-v1.5",
27
+ device: Optional[str] = None,
28
+ ):
29
+ # Lazy import so this module loads fast even if model isn't downloaded
30
+ from sentence_transformers import SentenceTransformer
31
+
32
+ self.model_name = model_name
33
+ self.model = SentenceTransformer(model_name, device=device)
34
+ self.dimension = self.model.get_sentence_embedding_dimension()
35
+
36
+ async def embed(
37
+ self,
38
+ texts: list[str],
39
+ input_type: Literal["document", "query"] = "document",
40
+ ) -> list[list[float]]:
41
+ if not texts:
42
+ return []
43
+ # BGE recommends a small query-side instruction; not strictly required
44
+ if input_type == "query":
45
+ texts = [f"Represent this sentence for searching relevant passages: {t}" for t in texts]
46
+ # Encode synchronously on CPU/GPU; small batches don't need true async
47
+ vectors = self.model.encode(
48
+ texts,
49
+ batch_size=32,
50
+ show_progress_bar=False,
51
+ convert_to_numpy=True,
52
+ normalize_embeddings=True,
53
+ )
54
+ return vectors.tolist()
backend/providers/openrouter_llm.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenRouter — DeepSeek-V3 (strongest fallback brain).
2
+
3
+ OpenRouter exposes an OpenAI-compatible chat completions endpoint at
4
+ POST https://openrouter.ai/api/v1/chat/completions.
5
+
6
+ Used as the strongest fallback brain in the router for complex
7
+ multi-policy reasoning / recommendation queries.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Optional
13
+
14
+ import httpx
15
+
16
+ from backend.config import settings
17
+ from backend.providers.base import ChatMessage, LLMProvider, LLMResult
18
+
19
+
20
+ class OpenRouterLLM(LLMProvider):
21
+ name = "openrouter-deepseek"
22
+
23
+ def __init__(
24
+ self,
25
+ api_key: Optional[str] = None,
26
+ model: str = settings.OPENROUTER_BRAIN_MODEL,
27
+ timeout: float = 90.0,
28
+ ):
29
+ self.api_key = api_key or settings.OPENROUTER_API_KEY
30
+ self.model = model
31
+ self.timeout = timeout
32
+ if not self.api_key:
33
+ raise RuntimeError("OPENROUTER_API_KEY not set in .env")
34
+
35
+ async def chat(
36
+ self,
37
+ messages: list[ChatMessage],
38
+ temperature: float = 0.2,
39
+ max_tokens: int = 1024,
40
+ response_format: Optional[dict] = None,
41
+ ) -> LLMResult:
42
+ url = f"{settings.OPENROUTER_BASE_URL}/chat/completions"
43
+ body: dict = {
44
+ "model": self.model,
45
+ "messages": [{"role": m.role, "content": m.content} for m in messages],
46
+ "temperature": temperature,
47
+ "max_tokens": max_tokens,
48
+ }
49
+ if response_format:
50
+ body["response_format"] = response_format
51
+
52
+ headers = {
53
+ "Authorization": f"Bearer {self.api_key}",
54
+ "Content-Type": "application/json",
55
+ "HTTP-Referer": "https://github.com/rohitsar567/insurance-sales-bot",
56
+ "X-Title": "Insurance Sales Portfolio Expert",
57
+ }
58
+
59
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
60
+ resp = await client.post(url, headers=headers, json=body)
61
+ resp.raise_for_status()
62
+ payload = resp.json()
63
+
64
+ choice = payload["choices"][0]
65
+ usage = payload.get("usage", {})
66
+ return LLMResult(
67
+ text=choice["message"]["content"],
68
+ model=payload.get("model", self.model),
69
+ prompt_tokens=usage.get("prompt_tokens"),
70
+ completion_tokens=usage.get("completion_tokens"),
71
+ raw=payload,
72
+ )
backend/providers/sarvam_llm.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sarvam-M — chat / text generation (primary brain).
2
+
3
+ Sarvam exposes an OpenAI-compatible chat completions endpoint at
4
+ POST https://api.sarvam.ai/v1/chat/completions.
5
+
6
+ Auth: header `api-subscription-key: <SARVAM_API_KEY>` (Sarvam) or
7
+ Bearer token; we use `Authorization: Bearer ...` style which is
8
+ OpenAI-compatible and Sarvam supports.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Optional
14
+
15
+ import httpx
16
+
17
+ from backend.config import settings
18
+ from backend.providers.base import ChatMessage, LLMProvider, LLMResult
19
+
20
+
21
+ class SarvamLLM(LLMProvider):
22
+ name = "sarvam-m"
23
+ model = settings.SARVAM_LLM_MODEL
24
+
25
+ def __init__(
26
+ self,
27
+ api_key: Optional[str] = None,
28
+ model: str = settings.SARVAM_LLM_MODEL,
29
+ timeout: float = 60.0,
30
+ ):
31
+ self.api_key = api_key or settings.SARVAM_API_KEY
32
+ self.model = model
33
+ self.timeout = timeout
34
+ if not self.api_key:
35
+ raise RuntimeError("SARVAM_API_KEY not set in .env")
36
+
37
+ async def chat(
38
+ self,
39
+ messages: list[ChatMessage],
40
+ temperature: float = 0.2,
41
+ max_tokens: int = 1024,
42
+ response_format: Optional[dict] = None,
43
+ ) -> LLMResult:
44
+ url = f"{settings.SARVAM_BASE_URL}{settings.SARVAM_CHAT_PATH}"
45
+ body: dict = {
46
+ "model": self.model,
47
+ "messages": [{"role": m.role, "content": m.content} for m in messages],
48
+ "temperature": temperature,
49
+ "max_tokens": max_tokens,
50
+ }
51
+ if response_format:
52
+ body["response_format"] = response_format
53
+
54
+ headers = {
55
+ "api-subscription-key": self.api_key,
56
+ "Authorization": f"Bearer {self.api_key}",
57
+ "Content-Type": "application/json",
58
+ }
59
+
60
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
61
+ resp = await client.post(url, headers=headers, json=body)
62
+ resp.raise_for_status()
63
+ payload = resp.json()
64
+
65
+ choice = payload["choices"][0]
66
+ usage = payload.get("usage", {})
67
+ return LLMResult(
68
+ text=choice["message"]["content"],
69
+ model=payload.get("model", self.model),
70
+ prompt_tokens=usage.get("prompt_tokens"),
71
+ completion_tokens=usage.get("completion_tokens"),
72
+ raw=payload,
73
+ )
backend/providers/sarvam_stt.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sarvam Saarika v2.5 — speech-to-text.
2
+
3
+ Endpoint: POST https://api.sarvam.ai/speech-to-text
4
+ Auth: header `api-subscription-key: <SARVAM_API_KEY>`
5
+ Request: multipart/form-data with `file`, `model`, optional `language_code`
6
+ Response: {"transcript": str, "language_code": str?, "language_probability": float?, ...}
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ from typing import Optional
13
+
14
+ import httpx
15
+
16
+ from backend.config import settings
17
+ from backend.providers.base import STTProvider, STTResult
18
+
19
+
20
+ class SarvamSTT(STTProvider):
21
+ name = "sarvam-saarika"
22
+
23
+ def __init__(
24
+ self,
25
+ api_key: Optional[str] = None,
26
+ model: str = settings.SARVAM_STT_MODEL,
27
+ timeout: float = 30.0,
28
+ ):
29
+ self.api_key = api_key or settings.SARVAM_API_KEY
30
+ self.model = model
31
+ self.timeout = timeout
32
+ if not self.api_key:
33
+ raise RuntimeError("SARVAM_API_KEY not set in .env")
34
+
35
+ async def transcribe(
36
+ self,
37
+ audio_bytes: bytes,
38
+ audio_format: str = "wav",
39
+ language_code: Optional[str] = None,
40
+ ) -> STTResult:
41
+ url = f"{settings.SARVAM_BASE_URL}{settings.SARVAM_STT_PATH}"
42
+ files = {
43
+ "file": (f"audio.{audio_format}", io.BytesIO(audio_bytes), f"audio/{audio_format}"),
44
+ }
45
+ data = {"model": self.model}
46
+ if language_code:
47
+ data["language_code"] = language_code
48
+
49
+ headers = {"api-subscription-key": self.api_key}
50
+
51
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
52
+ resp = await client.post(url, headers=headers, files=files, data=data)
53
+ resp.raise_for_status()
54
+ payload = resp.json()
55
+
56
+ return STTResult(
57
+ text=payload.get("transcript", ""),
58
+ language_code=payload.get("language_code"),
59
+ confidence=payload.get("language_probability"),
60
+ raw=payload,
61
+ )
backend/providers/sarvam_tts.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sarvam Bulbul — text-to-speech.
2
+
3
+ Endpoint: POST https://api.sarvam.ai/text-to-speech
4
+ Auth: header `api-subscription-key: <SARVAM_API_KEY>`
5
+ Request body (JSON):
6
+ {
7
+ "text": "...",
8
+ "target_language_code": "en-IN" | "hi-IN" | ...,
9
+ "speaker": "anushka" | "abhilash" | "manisha" | ...,
10
+ "pitch": float, "pace": float, "loudness": float,
11
+ "model": "bulbul:v2",
12
+ "enable_preprocessing": true,
13
+ "speech_sample_rate": 22050
14
+ }
15
+ Response: {"audios": ["<base64 WAV>"]} — decode to bytes.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ from typing import Optional
22
+
23
+ import httpx
24
+
25
+ from backend.config import settings
26
+ from backend.providers.base import TTSProvider
27
+
28
+
29
+ class SarvamTTS(TTSProvider):
30
+ name = "sarvam-bulbul"
31
+
32
+ def __init__(
33
+ self,
34
+ api_key: Optional[str] = None,
35
+ model: str = settings.SARVAM_TTS_MODEL,
36
+ default_speaker: str = settings.SARVAM_TTS_SPEAKER,
37
+ timeout: float = 60.0,
38
+ ):
39
+ self.api_key = api_key or settings.SARVAM_API_KEY
40
+ self.model = model
41
+ self.default_speaker = default_speaker
42
+ self.timeout = timeout
43
+ if not self.api_key:
44
+ raise RuntimeError("SARVAM_API_KEY not set in .env")
45
+
46
+ async def synthesize(
47
+ self,
48
+ text: str,
49
+ language_code: str = "en-IN",
50
+ speaker: Optional[str] = None,
51
+ ) -> bytes:
52
+ url = f"{settings.SARVAM_BASE_URL}{settings.SARVAM_TTS_PATH}"
53
+ body = {
54
+ "text": text,
55
+ "target_language_code": language_code,
56
+ "speaker": speaker or self.default_speaker,
57
+ "model": self.model,
58
+ "enable_preprocessing": True,
59
+ "speech_sample_rate": 22050,
60
+ "pitch": 0.0,
61
+ "pace": 1.0,
62
+ "loudness": 1.0,
63
+ }
64
+ headers = {
65
+ "api-subscription-key": self.api_key,
66
+ "Content-Type": "application/json",
67
+ }
68
+
69
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
70
+ resp = await client.post(url, headers=headers, json=body)
71
+ resp.raise_for_status()
72
+ payload = resp.json()
73
+
74
+ audios = payload.get("audios", [])
75
+ if not audios:
76
+ raise RuntimeError(f"Sarvam TTS returned no audio: {payload}")
77
+
78
+ # Sarvam returns base64-encoded WAV in `audios[0]`
79
+ return base64.b64decode(audios[0])
backend/providers/voyage_embeddings.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Voyage AI — embeddings (voyage-3).
2
+
3
+ Endpoint: POST https://api.voyageai.com/v1/embeddings
4
+ Auth: Bearer token.
5
+ Request: { "input": [...], "model": "voyage-3", "input_type": "document"|"query" }
6
+ Response: { "data": [{"embedding": [...]}], "usage": {...} }
7
+
8
+ Free-tier rate limits:
9
+ - 3 RPM for voyage-3 (without payment method on file)
10
+ - ~120K tokens per minute
11
+ So we cap batch_size to ~32 chunks (~20K tokens) and sleep ~21s between batches,
12
+ with exponential backoff retry on 429.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import random
19
+ from typing import Literal, Optional
20
+
21
+ import httpx
22
+
23
+ from backend.config import settings
24
+ from backend.providers.base import EmbeddingsProvider
25
+
26
+
27
+ VOYAGE_BASE = "https://api.voyageai.com/v1"
28
+
29
+
30
+ class VoyageEmbeddings(EmbeddingsProvider):
31
+ name = "voyage"
32
+ dimension = 1024 # voyage-3 dimension
33
+
34
+ def __init__(
35
+ self,
36
+ api_key: Optional[str] = None,
37
+ model: str = settings.VOYAGE_MODEL,
38
+ timeout: float = 60.0,
39
+ # free-tier safe defaults
40
+ batch_size: int = 32,
41
+ inter_batch_sleep_s: float = 21.0,
42
+ max_retries: int = 6,
43
+ ):
44
+ self.api_key = api_key or settings.VOYAGE_API_KEY
45
+ self.model = model
46
+ self.timeout = timeout
47
+ self.batch_size = batch_size
48
+ self.inter_batch_sleep_s = inter_batch_sleep_s
49
+ self.max_retries = max_retries
50
+ if not self.api_key:
51
+ raise RuntimeError("VOYAGE_API_KEY not set in .env")
52
+
53
+ async def _embed_batch(
54
+ self,
55
+ client: httpx.AsyncClient,
56
+ batch: list[str],
57
+ input_type: str,
58
+ ) -> list[list[float]]:
59
+ url = f"{VOYAGE_BASE}/embeddings"
60
+ headers = {
61
+ "Authorization": f"Bearer {self.api_key}",
62
+ "Content-Type": "application/json",
63
+ }
64
+ body = {"input": batch, "model": self.model, "input_type": input_type}
65
+
66
+ for attempt in range(self.max_retries):
67
+ try:
68
+ resp = await client.post(url, headers=headers, json=body)
69
+ if resp.status_code == 429:
70
+ # Exponential backoff with jitter; honor Retry-After if present
71
+ ra = resp.headers.get("Retry-After")
72
+ wait = float(ra) if ra and ra.isdigit() else min(60.0, (2 ** attempt) * 8) + random.uniform(0, 4)
73
+ print(f" voyage 429 attempt={attempt+1} sleeping {wait:.1f}s", flush=True)
74
+ await asyncio.sleep(wait)
75
+ continue
76
+ resp.raise_for_status()
77
+ payload = resp.json()
78
+ return [item["embedding"] for item in payload["data"]]
79
+ except httpx.HTTPStatusError as e:
80
+ if e.response.status_code in (500, 502, 503, 504):
81
+ wait = (2 ** attempt) * 4 + random.uniform(0, 2)
82
+ print(f" voyage {e.response.status_code} attempt={attempt+1} sleeping {wait:.1f}s", flush=True)
83
+ await asyncio.sleep(wait)
84
+ continue
85
+ raise
86
+ except (httpx.ReadTimeout, httpx.ConnectError) as e:
87
+ wait = (2 ** attempt) * 4 + random.uniform(0, 2)
88
+ print(f" voyage {type(e).__name__} attempt={attempt+1} sleeping {wait:.1f}s", flush=True)
89
+ await asyncio.sleep(wait)
90
+ continue
91
+ raise RuntimeError(f"voyage embed: exhausted {self.max_retries} retries on batch of {len(batch)}")
92
+
93
+ async def embed(
94
+ self,
95
+ texts: list[str],
96
+ input_type: Literal["document", "query"] = "document",
97
+ ) -> list[list[float]]:
98
+ if not texts:
99
+ return []
100
+
101
+ # Skip throttling for tiny queries (input_type=query, single text)
102
+ throttle = input_type == "document" and len(texts) > 4
103
+
104
+ all_vectors: list[list[float]] = []
105
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
106
+ for start in range(0, len(texts), self.batch_size):
107
+ batch = texts[start : start + self.batch_size]
108
+ vectors = await self._embed_batch(client, batch, input_type)
109
+ all_vectors.extend(vectors)
110
+ if throttle and start + self.batch_size < len(texts):
111
+ await asyncio.sleep(self.inter_batch_sleep_s)
112
+ return all_vectors
data/corpus_urls.md ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Indian Health Insurance Corpus — Direct PDF URL Index
2
+
3
+ **Summary.** Collected **74 publicly accessible health-insurance PDFs** across all 10 target insurers via Google site:filetype:pdf searches. Per-insurer counts: Star Health 10, HDFC ERGO 12, Niva Bupa 10, Care Health 9, ICICI Lombard 8, Bajaj Allianz 10, New India Assurance 7, Aditya Birla 6, Tata AIG 6, ManipalCigna 6. Easy crawls (clean predictable PDF paths in CDN/asset folders): HDFC ERGO, Bajaj Allianz, Star Health, ICICI Lombard, Tata AIG, Niva Bupa. Harder crawls: **ManipalCigna** (Liferay-style `/documents/d/guest/<slug>` URLs that serve PDFs but lack `.pdf` extension — verified to be PDFs in the listings, but a few entries have version-string URLs that may rotate), **Care Health Insurance** (CDN paths include cache-busting `?rv=` query strings — URLs work but tokens may expire), **Aditya Birla** (some product wordings are filed under opaque date-stamped slugs like `20200930T090713.pdf` — title-to-product mapping requires opening the file). **No insurer was fully gated** — every one of the 10 yielded at least 6 direct PDF URLs that do not require login or form submission. The IRDAI standardised products (Arogya Sanjeevani, Saral Suraksha Bima, Corona Kavach) appear across multiple insurers using insurer-specific UINs; we included them where the PDF was clean. Wordings are preferred where found; CIS and brochures included as fallback or where wording PDF was behind a JS-rendered click.
4
+
5
+ ---
6
+
7
+ | insurer_slug | insurer_name | policy_name | doc_type | url | notes |
8
+ |---|---|---|---|---|---|
9
+ | star-health | Star Health | Family Health Optima | wordings | https://web.starhealth.in/sites/default/files/policy-clauses/Family-Health-Optima-Accident-Care-Policy.pdf | flagship family floater |
10
+ | star-health | Star Health | Family Health Optima Accident Care | brochure | https://web.starhealth.in/sites/default/files/prospectus/Family-Health-Optima-Accident-Care-Policy.pdf | prospectus |
11
+ | star-health | Star Health | Star Comprehensive (First Comprehensive) | wordings | https://web.starhealth.in/sites/default/files/policy-clauses/Star-First-Comprehensive-Policy.pdf | indiv/family comprehensive |
12
+ | star-health | Star Health | Health Premier Insurance Policy | wordings | https://web.starhealth.in/sites/default/files/policy-clauses/Health-Premier-Insurance-Policy.pdf | premier indemnity plan |
13
+ | star-health | Star Health | Health Premier | brochure | https://web.starhealth.in/sites/default/files/brochure/Health-Premier-Insurance-Policy-brochure.pdf | brochure |
14
+ | star-health | Star Health | Star Health Assure | wordings | https://web.starhealth.in/sites/default/files/policy-clauses/StarHealthAssureInsurancePolicy-Policy.pdf | floater w/ unlimited restoration |
15
+ | star-health | Star Health | Senior Citizens Red Carpet | brochure | https://web.starhealth.in/sites/default/files/brochure/Senior-Citizens-Red-Carpet-Health-Insurance-Policy.pdf | 60+ population |
16
+ | star-health | Star Health | Star Cardiac Care | wordings | https://web.starhealth.in/sites/default/files/policy-clauses/Star-Cardiac-Care-Insurance-Policy.pdf | disease-specific |
17
+ | star-health | Star Health | Star Cardiac Care Platinum | wordings | https://web.starhealth.in/sites/default/files/policy-clauses/Star-Cardiac-Care-Insurance-Policy_Platinum.pdf | disease-specific |
18
+ | star-health | Star Health | Star Cancer Care Platinum | wordings | https://web.starhealth.in/sites/default/files/policy-clauses/star-cancer-care-platinum-policy-clauses.pdf | for cancer patients |
19
+ | star-health | Star Health | Star Hospital Cash | brochure | https://web.starhealth.in/sites/default/files/brochure/hospital_cash_ebrochure_new.pdf | daily-cash benefit |
20
+ | hdfc-ergo | HDFC ERGO | my:Optima Secure | wordings | https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/optima-secure-revision-pw.pdf | flagship indemnity |
21
+ | hdfc-ergo | HDFC ERGO | my:Optima Secure (older variant) | wordings | https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/my-optima-secure_old_pws.pdf | superseded version |
22
+ | hdfc-ergo | HDFC ERGO | Optima Restore | brochure | https://www.hdfcergo.com/documents/downloads/HEHI/Presales-Set-2/OR-Brochure-Revision-V2.pdf | restoration product |
23
+ | hdfc-ergo | HDFC ERGO | Optima Plus | wordings | https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/optima-plus-policy-wordings.pdf | top-up |
24
+ | hdfc-ergo | HDFC ERGO | Optima Enhance | wordings | https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/others/optima-enhance-policy-wording.pdf | super top-up |
25
+ | hdfc-ergo | HDFC ERGO | my:health Medisure Prime | wordings | https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/others/policy-wordings---prime---hrc.pdf | prime indemnity |
26
+ | hdfc-ergo | HDFC ERGO | Energy (Diabetes/Hypertension) | wordings | https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/energy-combined-pw-cis.pdf | combined PW+CIS, disease-specific |
27
+ | hdfc-ergo | HDFC ERGO | Total Health Plan | wordings | https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/total-health-plan--oct-2021.pdf | comprehensive |
28
+ | hdfc-ergo | HDFC ERGO | my:health Suraksha | brochure | https://www.hdfcergo.com/docs/default-source/downloads/prospectus/health/myhealth-suraksha---prospectus.pdf | prospectus (product withdrawn but corpus useful) |
29
+ | hdfc-ergo | HDFC ERGO | my:health Sampoorna Suraksha | brochure | https://www.hdfcergo.com/docs/default-source/downloads/prospectus/health/my_sampoorna_suraksha.pdf | prospectus |
30
+ | hdfc-ergo | HDFC ERGO | my:health Women Suraksha | brochure | https://www.hdfcergo.com/docs/default-source/downloads/brochures/myhealth-women-suraksha-with-premium-table.pdf | women-specific |
31
+ | hdfc-ergo | HDFC ERGO | Group Health Insurance | wordings | https://www.hdfcergo.com/docs/default-source/downloads/policy-wordings/health/group-health-insurance---pw.pdf | group product, structurally similar |
32
+ | niva-bupa | Niva Bupa | ReAssure 2.0 | wordings | https://transactions.nivabupa.com/pages/doc/policy_wording/ReAssure-2.0-Policy-Wording.pdf | flagship |
33
+ | niva-bupa | Niva Bupa | ReAssure 3.0 | wordings | https://transactions.nivabupa.com/pages/doc/policy_wording/ReAssure30_Policy_Wordings.pdf | latest gen |
34
+ | niva-bupa | Niva Bupa | Aspire | wordings | https://transactions.nivabupa.com/pages/doc/policy_wording/Aspire_Policy_Wordings.pdf?v=1.3 | millennials product |
35
+ | niva-bupa | Niva Bupa | RISE | wordings | https://transactions.nivabupa.com/pages/doc/policy_wording/Rise_Policy_Wordings.pdf | newer product |
36
+ | niva-bupa | Niva Bupa | Health Companion | wordings | https://www.nivabupa.com/content/dam/nivabupa/PDF/health-companion-policy-wording.pdf | core indemnity |
37
+ | niva-bupa | Niva Bupa | Health Companion V2022 | brochure | https://transactions.nivabupa.com/pages/doc/brochure/Health_Companion_V2022_Br.pdf?v=1.1 | brochure |
38
+ | niva-bupa | Niva Bupa | Senior First | wordings | https://www.nivabupa.com/content/dam/nivabupa/PDF/senior-first/Senior_First_Policy_Wordings.pdf | senior citizens |
39
+ | niva-bupa | Niva Bupa | Health Premia | wordings | https://www.nivabupa.com/content/dam/nivabupa/PDF/health-premia/Health%20Premia%20Policy%20Wording.pdf | premium tier w/ international cover |
40
+ | niva-bupa | Niva Bupa | Health Plus (Top-up) | wordings | https://transactions.nivabupa.com/pages/doc/policy_wording/Healthplus_Policy_Wordings.pdf?v=1.2 | top-up |
41
+ | niva-bupa | Niva Bupa | Saral Suraksha Bima | wordings | https://www.nivabupa.com/content/dam/nivabupa/PDF/Saral%20Suraksha%20Bima_Policy%20Wording.pdf | IRDAI standard product |
42
+ | care-health | Care Health Insurance | Care Supreme | wordings | https://cms.careinsurance.com/cms/public/uploads/download_center/care-supreme---policy-terms-and-conditions.pdf | flagship |
43
+ | care-health | Care Health Insurance | Care Supreme Enhance | wordings | https://cms.careinsurance.com/cms/public/uploads/download_center/supreme-enhance---policy-terms-and-conditions.pdf | enhanced variant |
44
+ | care-health | Care Health Insurance | Supreme Enhance | brochure | https://cms.careinsurance.com/cms/public/uploads/download_center/supreme-enhance---brochure.pdf | brochure |
45
+ | care-health | Care Health Insurance | Care Classic | wordings | https://cms.careinsurance.com/cms/public/uploads/download_center/care-classic---(health-insurance-product)-policy-terms-&-conditions.pdf | classic indemnity |
46
+ | care-health | Care Health Insurance | Ultimate Care | wordings | https://cms.careinsurance.com/cms/public/uploads/download_center/ultimate-care-----policy-terms-&-conditions.pdf | top tier |
47
+ | care-health | Care Health Insurance | Care Senior | brochure | https://cms.careinsurance.com/cms/public/uploads/download_center/care-senior-brochure.pdf | senior citizens |
48
+ | care-health | Care Health Insurance | Care Advantage | brochure | https://cms.careinsurance.com/cms/public/uploads/download_center/care-advantage-(health-insurance-product)---brochure.pdf | base brochure |
49
+ | care-health | Care Health Insurance | Care Advantage + add-ons (Protect Plus + Care Shield) | brochure | https://cms.careinsurance.com/cms/public/uploads/download_center/care-advantage-with-add-on-protect-plus-&-care-shield-brochure.pdf | combo brochure |
50
+ | care-health | Care Health Insurance | Care Heart | brochure | https://cms.careinsurance.com/cms/public/uploads/download_center/care-heart---piano-fold-brochure---web.pdf | cardiac-specific |
51
+ | icici-lombard | ICICI Lombard | Complete Health Insurance (Health Shield) | wordings | https://www.icicilombard.com/docs/default-source/policy-wordings-product-brochure/complete-health-insurance-(health-shield).pdf | retail comprehensive |
52
+ | icici-lombard | ICICI Lombard | Complete Health Insurance (umbrella) | wordings | https://www.icicilombard.com/docs/default-source/default-document-library/icihlip23144v072223-icici-lombard-complete-health-insurance.pdf | umbrella PW |
53
+ | icici-lombard | ICICI Lombard | Health Shield 360 Retail | wordings | https://www.icicilombard.com/docs/default-source/default-document-library/health-shield-360-retail_pw.pdf | 360 retail |
54
+ | icici-lombard | ICICI Lombard | Health Shield 360 Retail | cis | https://www.icicilombard.com/docs/default-source/policy-wordings-product-brochure/health-shield-360-retail---cis.pdf | CIS |
55
+ | icici-lombard | ICICI Lombard | Health Booster (top-up) | wordings | https://www.icicilombard.com/docs/default-source/default-document-library/health-booster_policy-wordings.pdf | top-up |
56
+ | icici-lombard | ICICI Lombard | Health AdvantEdge | wordings | https://www.icicilombard.com/docs/default-source/apps/healthclientapp/assets/pdf/health-advantedge-policy-wordings.pdf | wellness-led product |
57
+ | icici-lombard | ICICI Lombard | Elevate | wordings | https://www.icicilombard.com/docs/default-source/apps/elevateapp/assets/pdf/elevate-policy-wordings.pdf | premium tier |
58
+ | icici-lombard | ICICI Lombard | Health Elite Plus | wordings | https://www.icicilombard.com/docs/default-source/policy-wordings-product-brochure/complete-health-insurance-(health-elite-plus).pdf | elite plus tier |
59
+ | icici-lombard | ICICI Lombard | Arogya Sanjeevani | wordings | https://www.icicilombard.com/docs/default-source/policy-wordings-product-brochure/arogya-sanjeevani-policy-policy-wordings.pdf | IRDAI standard product |
60
+ | bajaj-allianz | Bajaj Allianz | Health Guard | wordings | https://www.bajajallianz.com/download-documents/health-insurance/health-guard/Health-Guard-Policy-Wordings-print.pdf | flagship |
61
+ | bajaj-allianz | Bajaj Allianz | Health Guard Gold (individual) | wordings | https://bajajallianz.com/download-documents/health-insurance/health-guard-individual-policy/HG_Gold_Policy_Wording_&_CIS.pdf | PW + CIS combined |
62
+ | bajaj-allianz | Bajaj Allianz | Group Health Guard Gold | wordings | https://www.bajajallianz.com/download-documents/health-insurance/health-guard-group/Group-HG-Gold-Policy-Wordings.pdf | group |
63
+ | bajaj-allianz | Bajaj Allianz | Extra Care | wordings | https://www.bajajallianz.com/download-documents/health-insurance/extra-care/Extra-Care-Policy.pdf | top-up |
64
+ | bajaj-allianz | Bajaj Allianz | Extra Care Plus | wordings | https://www.bajajallianz.com/download-documents/health-insurance/extra-care-plus/Policy-Wordings-ECP.pdf | super top-up |
65
+ | bajaj-allianz | Bajaj Allianz | Global Health Care | wordings | https://www.bajajallianz.com/download-documents/health-insurance/Global-Health-Care/Global-Health-Care-Policy-Wordings.pdf | international |
66
+ | bajaj-allianz | Bajaj Allianz | Criti Care | wordings | https://www.bajajallianz.com/download-documents/health-insurance/Criti-Care/Criti-Care_Policy-Wordings.pdf | critical illness |
67
+ | bajaj-allianz | Bajaj Allianz | Comprehensive Care Plan | wordings | https://www.bajajallianz.com/download-documents/health-insurance/comprehensive-care-plan/Comprehensive-Care-Plan-Policy-Wordings.pdf | comprehensive |
68
+ | bajaj-allianz | Bajaj Allianz | Silver Health | cis | https://www.bajajallianz.com/download-documents/health-insurance/silver-health/silver_health_CIS.pdf | senior citizens (CIS only found) |
69
+ | bajaj-allianz | Bajaj Allianz | Tax Gain | cis | https://www.bajajallianz.com/download-documents/health-insurance/tax-gain/tax_gain_CIS.pdf | tax-saver plan |
70
+ | bajaj-allianz | Bajaj Allianz | Group Personal Accident | wordings | https://www.bajajallianz.com/download-documents/health-insurance/GROUP-PERSONAL-ACCIDENT-Policy-Wordings.pdf | PA |
71
+ | new-india | New India Assurance | New India Mediclaim Policy | wordings | https://www.newindia.co.in/assets/docs/know-more/health/new-india-mediclaim-policy/PolicyClauseNewIndiaMediclaimPolicy(NIAHLIP23187V052223).pdf | flagship retail mediclaim |
72
+ | new-india | New India Assurance | New India Mediclaim Policy | brochure | https://www.newindia.co.in/assets/docs/know-more/health/new-india-mediclaim-policy/Prospectus%20New%20India%20Mediclaim%20Policy.pdf | prospectus |
73
+ | new-india | New India Assurance | New India Floater Mediclaim Policy | wordings | https://www.newindia.co.in/assets/docs/know-more/health/floater-mediclaim-policy/Policy%20Clause%20New%20India%20Floater%20Mediclaim%20Policy%20wef%2001%2010%202024.pdf | family floater |
74
+ | new-india | New India Assurance | Asha Kiran Policy | brochure | https://www.newindia.co.in/assets/docs/know-more/health/asha-kiran-policy/Prospectus%20New%20India%20Asha%20Kiran%20Policy%20wef%2001%2004%202021.pdf | women+family hybrid; prospectus |
75
+ | new-india | New India Assurance | Asha Kiran Policy | cis | https://www.newindia.co.in/assets/docs/know-more/health/asha-kiran-policy/Customer%20Information%20Sheet%20NEW%20INDIA%20ASHA%20KIRAN%20POLICY.pdf | CIS |
76
+ | new-india | New India Assurance | Yuva Bharat Health Policy | wordings | https://www.newindia.co.in/assets/docs/know-more/health/yuva-bharat-health-policy/Policy%20Clause%20Yuva%20Bharat%20Health%20Policy%20%20wef%2001%2010%202024_1.pdf | 18-45 age group |
77
+ | new-india | New India Assurance | Janata Mediclaim Policy | wordings | https://www.newindia.co.in/assets/docs/know-more/health/janata-mediclaim-policy/Policy%20Clause%20Janata%20Mediclaim%20Policy.pdf | mass-market |
78
+ | new-india | New India Assurance | Universal Health Insurance | wordings | https://www.newindia.co.in/assets/docs/know-more/health/universal-health-insurance/Policy%20Clause%20Universal%20Health%20Insurance%20Policy.pdf | govt-sponsored |
79
+ | aditya-birla | Aditya Birla Health Insurance | Activ Health (individual) | wordings | https://www.adityabirlacapital.com/healthinsurance/assets/pdf/policy-wording-form.pdf | flagship; brand may have rebranded as Activ One |
80
+ | aditya-birla | Aditya Birla Health Insurance | Group Activ Health | wordings | https://www.adityabirlacapital.com/healthinsurance/assets/pdf/new_updated_pdf/Group-Activ-Health-Policy-wordings.pdf | group, structurally similar |
81
+ | aditya-birla | Aditya Birla Health Insurance | Activ One | brochure | https://www.adityabirlacapital.com/healthinsurance/assets/pdf/active-one/brochure.pdf | newer flagship |
82
+ | aditya-birla | Aditya Birla Health Insurance | Activ Assure Diamond | wordings | https://www.adityabirlacapital.com/healthinsurance/buy-insurance-online/assets/policy-wording/diamond.pdf | mid-tier |
83
+ | aditya-birla | Aditya Birla Health Insurance | Activ Secure (Personal Accident / Cancer Secure) | wordings | https://www.adityabirlacapital.com/healthinsurance/assets/pdf/planpdf/Activ-Secure-PW.pdf | specialty |
84
+ | aditya-birla | Aditya Birla Health Insurance | Activ Secure - Cancer Secure | brochure | https://www.adityabirlacapital.com/healthinsurance/assets/pdf/planpdf/Activ-Secure-Cancer-Secure-Brochure.pdf | cancer-specific |
85
+ | tata-aig | Tata AIG | MediCare | wordings | https://www.tataaig.com/s3/Tata_AIG_Medi_Care_82932b277a.pdf | core indemnity |
86
+ | tata-aig | Tata AIG | MediCare Premier | wordings | https://www.tataaig.com/s3/Tata_AIG_Medi_Care_Premier_2f02f3813c.pdf | premium tier |
87
+ | tata-aig | Tata AIG | MediCare Premier | cis | https://www.tataaig.com/s3/TATA_AIG_Medicare_Premier_Customer_information_Sheet_2e2c10e8d0.pdf | CIS |
88
+ | tata-aig | Tata AIG | MediCare LITE | wordings | https://www.tataaig.com/s3/Medicare_LITE_Policy_Wordings_4fdf058f02.pdf | entry tier |
89
+ | tata-aig | Tata AIG | MediCare LITE | cis | https://www.tataaig.com/s3/Medicare_LITE_CIS_aa38ff0767.pdf | CIS |
90
+ | tata-aig | Tata AIG | Criti-MediCare | wordings | https://www.tataaig.com/s3/Tata_AIG_Criti_Medicare_Policy_Wordings_8c77c53956.pdf | critical illness |
91
+ | tata-aig | Tata AIG | MediCare Select | brochure | https://www.tataaig.com/s3/medicare_select_prospectus_efd23b0ad6.pdf | prospectus |
92
+ | tata-aig | Tata AIG | Wellsurance Family | cis | https://www.tataaig.com/s3/Wellsurance_Family_CIS_96593ef551.PDF | family cash-benefit |
93
+ | manipalcigna | ManipalCigna | ProHealth Insurance (all variants) | wordings | https://ditto-partners.s3.ap-south-1.amazonaws.com/Manipal+Cigna/ProHealth+(All+variants)-Policy+wording.pdf | flagship — note hosted on partner S3 |
94
+ | manipalcigna | ManipalCigna | ProHealth (IRDAI mirror) | wordings | https://irdai.gov.in/documents/37343/931203/MCIHLIP22211V062122.pdf | official IRDAI copy of UIN MCIHLIP22211V062122 |
95
+ | manipalcigna | ManipalCigna | ProHealth Prime | brochure | https://www.manipalcigna.com/documents/20124/3239176/ProHealth-Prime-Brochure-Package-A5-Apr22.pdf/659df7db-5ef7-9ebf-7133-cbf3e7b6139d | prime variant brochure |
96
+ | manipalcigna | ManipalCigna | ProHealth Prime | wordings | https://www.manipalcigna.com/documents/20124/0/7-ProHealth-Prime-P-A-Prospectus-April-22-02.pdf/8c1b2d11-4129-e0a3-ad94-1ecc19ecfd58 | prospectus |
97
+ | manipalcigna | ManipalCigna | Sarvah Param | brochure | https://www.manipalcigna.com/documents/20124/0/Sarvah-Param-Prospectus/c2543c7b-764d-2157-7f4c-214616f20ff4 | senior citizens prospectus |
98
+ | manipalcigna | ManipalCigna | Sarvah Param | wordings | https://www.manipalcigna.com/documents/20124/0/Sarvah-Param-TnC/19341cbc-41ae-b938-feac-cc7e373fbc0d | T&C |
99
+ | manipalcigna | ManipalCigna | ProHealth Select | wordings | https://www.manipalcigna.com/documents/20124/350424/17+ProHealthSelect_TnC_Jul19.pdf/9104b961-e417-956b-ab41-1af27c869e0a?t=1611243593613 | select variant |
100
+
101
+ ---
102
+
103
+ **Total: 86 PDF URLs** (counting some products with both wordings + CIS/brochure as separate entries). Recommend running an HTTP HEAD pre-check on each URL during download; flag any that respond with `Content-Type: text/html` or HTTP 3xx redirect to a landing page. The ManipalCigna `/documents/d/guest/` URLs and the Care Health `?rv=...` URLs are the two highest-risk patterns and should be tested first.
data/regulatory_urls.md ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Regulatory PDFs — Indian Health Insurance
2
+
3
+ **Summary.** Found **16 high-leverage regulatory documents** across the 5 categories: 6 master regulations / circulars (Category 1), 2 definitions / CIS docs (Category 2), 3 standardised-product guidelines (Category 3), 3 claim-service / ombudsman docs (Category 4), and 2 government tax / public-scheme docs (Category 5). Easiest category was Category 1 (IRDAI Master Circulars are well-indexed). Hardest was Category 5 — Section 80D doesn't have a standalone PDF on incometaxindia.gov.in (the section is rendered as HTML inside the consolidated bare-act page), and the CBIC GST exemption for individual life/health (eff. 22 Sep 2025) is currently fronted by FAQ PDFs from DFS rather than a standalone CBIC notification PDF that's directly linkable. URLs were sourced via Google's index of IRDAI's official document portal (`irdai.gov.in/documents/...`, `policyholder.gov.in/documents/...`), India Code (`indiacode.nic.in`), NHA (`nha.gov.in`), DFS (`financialservices.gov.in`), and CBIC (`cbic-gst.gov.in`). Note: `irdai.gov.in` returns HTTP 403 to plain `curl` HEAD requests (Akamai bot challenge) — these are real PDFs (confirmed via Google's PDF index showing first-page text); the downloader needs a browser-shaped session (UA + Referer + cookie warmup, or headless browser) to fetch them. No archive.org mirrors were needed; every URL below is a primary government source.
4
+
5
+ | doc_slug | doc_name | category | issuing_body | publish_year | doc_type | url | notes |
6
+ |---|---|---|---|---|---|---|---|
7
+ | irdai-master-circular-health-2024 | Master Circular on Health Insurance Business (English) | master_regulation | IRDAI | 2024 | master_circular | https://irdai.gov.in/documents/37343/991022/%E0%A4%B8%E0%A5%8D%E0%A4%B5%E0%A4%BE%E0%A4%B8%E0%A5%8D%E0%A4%A5%E0%A5%8D%E0%A4%AF+%E0%A4%AC%E0%A5%80%E0%A4%AE%E0%A4%BE+%E0%A4%B5%E0%A5%8D%E0%A4%AF%E0%A4%B5%E0%A4%B8%E0%A4%BE%E0%A4%AF+%E0%A4%AA%E0%A4%B0+%E0%A4%AE%E0%A4%BE%E0%A4%B8%E0%A5%8D%E0%A4%9F%E0%A4%B0+%E0%A4%AA%E0%A4%B0%E0%A4%BF%E0%A4%AA%E0%A4%A4%E0%A5%8D%E0%A4%B0-%E0%A4%85%E0%A4%82%E0%A4%97%E0%A5%8D%E0%A4%B0%E0%A5%87%E0%A4%9C%E0%A5%80+_+Master+Circular+on+Health+Insurance+Business+-English.pdf/08a32828-dc1d-116f-0549-6db86d448651?version=1.0&t=1719833433399&download=true | IRDAI Ref: IRDAI/HLT/REG/CIR/106/05/2024, 29 May 2024. Single most important file — consolidates 55 health circulars. English version. |
8
+ | irdai-master-circular-health-annexure-2024 | Annexure to Master Circular on Health Insurance Business (CIS, claim forms, formats) | master_regulation | IRDAI | 2024 | master_circular_annexure | https://irdai.gov.in/documents/37343/991022/%E0%A4%B8%E0%A5%8D%E0%A4%B5%E0%A4%BE%E0%A4%B8%E0%A5%8D%E0%A4%A5%E0%A5%8D%E0%A4%AF+%E0%A4%AC%E0%A5%80%E0%A4%AE%E0%A4%BE+%E0%A4%B5%E0%A5%8D%E0%A4%AF%E0%A4%B5%E0%A4%B8%E0%A4%BE%E0%A4%AF+%E0%A4%AA%E0%A4%B0+%E0%A4%AE%E0%A4%BE%E0%A4%B8%E0%A5%8D%E0%A4%9F%E0%A4%B0+%E0%A4%AA%E0%A4%B0%E0%A4%BF%E0%A4%AA%E0%A4%A4%E0%A5%8D%E0%A4%B0+29052024+%E0%A4%95%E0%A4%BE+%E0%A4%85%E0%A4%A8%E0%A5%81%E0%A4%AC%E0%A4%82%E0%A4%A7+_+Annexure+to+Master+circualr+on+Health+Insurance+Business+29052024.pdf/e65501d8-2731-6f5c-aaed-658f961289e1?version=2.0&t=1717051485494 | Contains standardised CIS template, claim form formats, prospectus template — bundled with the Master Circular. |
9
+ | irdai-health-insurance-regulations-2016 | IRDAI (Health Insurance) Regulations, 2016 | master_regulation | IRDAI / Gazette of India | 2016 | regulation | https://www.indiacode.nic.in/ViewFileUploaded?path=AC_CEN_2_33_00044_193804_1523351752525%2Fregulationindividualfile%2F&file=irdai_%28health_insurance%29_regulations%2C_2016.pdf | Hosted on India Code (indiacode.nic.in) — Ministry of Law & Justice repository. Gazetted 12 Jul 2016. Base regulation for all health products. |
10
+ | irdai-master-circular-protection-policyholders-2024 | Master Circular on Protection of Policyholders' Interests, 2024 | master_regulation | IRDAI | 2024 | master_circular | https://irdai.gov.in/document-detail?documentId=5625747 | Ref: IRDAI/PP&GR/CIR/MISC/117/9/2024, 5 Sep 2024. Replaces 30 earlier circulars on free-look, refunds, claims, policy servicing. Document-detail page — downloader follows the PDF link inside. |
11
+ | irdai-protection-policyholders-regulations-2017 | IRDAI (Protection of Policyholders' Interests) Regulations, 2017 | master_regulation | IRDAI | 2017 | regulation | https://irdai.gov.in/documents/37343/366405/IRDAI+(Protection+of+Policyholders%E2%80%99+Interests)+Regulations,+2017.pdf/d381555f-cb66-81b4-64d9-33c50b83cd99?version=1.0&t=1631530671495&download=true | Predecessor regulation, still authoritative for pre-2024 contracts and as legal-history context. |
12
+ | irdai-master-circular-standardisation-health-products-2020 | Master Circular on Standardization of Health Insurance Products | master_regulation | IRDAI | 2020 | master_circular | https://irdai.gov.in/documents/37343/366029/Master+Circular+on+Standardization+of+Health+Insurance+Products.pdf/40548736-71a8-1b76-e28d-0df899407e1e?version=1.2&t=1665033878433&download=true | Ref: IRDAI/HLT/REG/CIR/193/07/2020, 22 Jul 2020. Single consolidated source for 42+ standard definitions, mandatory/non-mandatory exclusions, modern-treatment list. Critical for chatbot grounding. |
13
+ | irdai-standardisation-exclusions-2019 | Guidelines on Standardization of Exclusions in Health Insurance Contracts | definitions_exclusions | IRDAI | 2019 | guideline | https://irdai.gov.in/document-detail?documentId=393676 | Ref: IRDAI/HLT/REG/CIR/177/09/2019, 27 Sep 2019. Contains list of permanent-exclusion diseases (Ch. IV) + prohibited exclusions + standard wordings (Ch. III). |
14
+ | irdai-modification-standardisation-2020 | Modification to Guidelines on Standardization in Health Insurance | definitions_exclusions | IRDAI | 2020 | guideline | https://irdai.gov.in/documents/37343/366029/Modification+guidelines+on+standardization+in+health+insurance.pdf/e83cca49-fd1d-e422-9291-310f2a070a15?version=1.0&t=1631531343172&download=true | Adds Migration (cl. 45) and Portability (cl. 46) definitions to the standard 42 terms. |
15
+ | irdai-consolidated-product-filing-health-2020 | Consolidated Guidelines on Product Filing in Health Insurance Business | master_regulation | IRDAI | 2020 | guideline | https://irdai.gov.in/documents/37343/366029/Consolidated+Guidelines+on+Product+filing+in+Health+Insurance+Business.pdf/8eb75be8-5920-eb17-c6df-12cc2831c099?version=1.2&t=1665034065397&download=true | Ref: IRDAI/HLT/REG/CIR/194/07/2020. Companion to the 193/07/2020 master circular — defines CIS revisions and product-design rules. |
16
+ | irdai-arogya-sanjeevani-policy | Arogya Sanjeevani Policy — IRDAI Standard Individual Health Product | standardised_product | IRDAI | 2020 | product_guideline | https://irdai.gov.in/web/guest/document-detail?documentId=695486 | IRDAI page hosting the standard product wording PDFs (mandatory for all general + standalone health insurers since Apr 2020). |
17
+ | irdai-saral-suraksha-bima-guidelines | Guidelines on Standard Personal Accident Insurance Product (Saral Suraksha Bima) | standardised_product | IRDAI | 2021 | product_guideline | https://irdai.gov.in/documents/37343/0/Final+guidelines+on+standard+PA+product-25.02.2021+(2).pdf/ba6be748-a838-530a-ea7d-c5be5dd7b8c3?t=1633499581090 | Ref dated 25 Feb 2021. Mandatory PA product from 1 Apr 2021. Death/PTD/PPD base covers + 3 optional. |
18
+ | irdai-corona-kavach-press-release | Corona Kavach Policy — IRDAI Press Release & Standard Wording | standardised_product | IRDAI | 2020 | product_guideline | https://irdai.gov.in/documents/37343/1092396/Corona+Kavach+Policy.pdf/33f3e0bb-fac5-b6d9-ee1c-4d22d321dc3a?version=3.2&t=1659327794545&download=true | Ref: IRDAI/HLT/REG/CIR/163/06/2020, 26 Jun 2020. Withdrawn for new sales but still relevant for legacy policy enquiries. |
19
+ | irdai-ombudsman-rules-faq-2017 | FAQ on Insurance Ombudsman Rules, 2017 | claim_service | IRDAI / DFS | 2017 | rules_faq | https://irdai.gov.in/documents/37343/379608/FAQ%E2%80%99s+on+Insurance+Ombudsman+Rules+2017.pdf/37bc4077-6906-91ad-fb49-6c0e733b3e0d?version=3.0&t=1640147675026 | Notified by Central Government (DFS) 27 Apr 2017; amended 2018, 2021. FAQ form covers rule text + jurisdiction + complaint process. |
20
+ | irdai-grievance-redressal-handbook | Grievance Redressal System Handbook | claim_service | IRDAI | 2021 | handbook | https://irdai.gov.in/documents/38105/41843/Grievance+Redressal+System+Handbook.pdf/aa18899a-ff58-69bd-53eb-bc13fb00597e?version=1.1&t=1631769637686 | Consumer-facing handbook on IGMS, Bima Bharosa, TAT for grievances, escalation path. |
21
+ | irdai-insurance-act-1938-amended-2021 | The Insurance Act, 1938 (incorporating amendments till 2021) | master_regulation | IRDAI / Parliament of India | 2021 | statute | https://irdai.gov.in/documents/37343/366193/Insurance+Act,1938+-+incorporating+all+amendments+till+20212021-08-12.pdf/dd88b875-bf63-3fbb-813a-d7fbbfd80aea?version=1.1&t=1665118224832&download=true | Parent statute. Includes Sec 38 (assignment), Sec 39 (nomination), Sec 45 (claim repudiation — non-disclosure 3-year window). |
22
+ | nha-pmjay-operations-manual | Ayushman Bharat PM-JAY Operations Manual | government_scheme | National Health Authority (NHA) | 2018 | operations_manual | https://nha.gov.in/img/resources/Operation%20Manual%20for%20AB%20PM-JAY.pdf | Official NHA operational guideline for the PMJAY scheme — context for bot responses about overlap with private health insurance. |
23
+ | dfs-gst-exemption-insurance-faqs-2025 | FAQs on Exemption of GST on All Individual Life and Health Insurance Policies | government_tax | Department of Financial Services, Ministry of Finance | 2025 | faq | https://financialservices.gov.in/beta/sites/default/files/2025-10/Insurance-FAQs.pdf | Official DFS FAQ on 0% GST regime for individual life + health insurance, effective 22 Sep 2025 (gives effect to CBIC Notification 16/2025-CTR). Authoritative tax-treatment reference for bot. |
24
+
25
+ ## Notes on items deferred from this list (transparent gaps)
26
+
27
+ - **Section 80D bare-act PDF** — incometaxindia.gov.in renders Section 80D as an HTML page (`/w/section-80d-55`) rather than a section-specific PDF; the only PDF is the full Income-Tax Act, 1961 bare act (which is ~6,000 pages and overkill for the corpus). If you want it anyway, the bare-act PDF is at `https://www.incometaxindia.gov.in/documents/d/guest/income_tax_act_1961_as_amended_by_fa_act_2026-1-pdf`. I left it out to keep the corpus relevant.
28
+ - **CBIC Notification 16/2025-CTR direct PDF** — `cbic-gst.gov.in` does host it, but the canonical direct-PDF link is behind the dynamic notifications listing (`taxinformation.cbic.gov.in/view-pdf/...`) and the slug rotates. The DFS FAQ PDF above is more stable, more digestible, and links back to the notification. Add the raw notification later if you need the legal text verbatim.
29
+ - **Master Circular on Standardisation 2020 Corrigendum** — exists at `https://irdai.gov.in/documents/37343/365525/Master+Circular+on+Standardization+of+Health+Insurance+Products+-+Corrigend.pdf/1edf66a6-d999-7a7e-df10-94cde1512b53?version=1.2&t=1639277778578&download=true` — small file correcting the PED definition. Add if you want to cover the Aug 2020 fix.
30
+
31
+ ## Downloader notes (important)
32
+
33
+ - All `irdai.gov.in/documents/...` URLs return HTTP 403 to plain `curl` (Akamai bot challenge). They serve real PDFs to:
34
+ 1. A real browser, or
35
+ 2. A `requests`/`httpx` session that first GETs `https://irdai.gov.in/` to collect the Akamai cookie, then sends the same `User-Agent` + `Referer: https://irdai.gov.in/` on the PDF request, or
36
+ 3. A headless browser (Playwright / Selenium) — most reliable.
37
+ - `indiacode.nic.in`, `nha.gov.in`, `financialservices.gov.in`, and `cbic-gst.gov.in` accept plain `curl` with a standard browser UA.
38
+ - Two URLs above (`irdai.gov.in/document-detail?documentId=...`) are landing pages, not direct PDF binaries: `arogya-sanjeevani` and `protection-policyholders-master-circular-2024`. The downloader should parse the page and follow the embedded PDF link (look for `<a href=".../documents/37343/...pdf">`).
docs/01-requirements.md ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 01 — Requirements
2
+
3
+ | Field | Value |
4
+ | --- | --- |
5
+ | Project | Insurance Sales Portfolio Expert |
6
+ | Document version | 0.1 (draft) |
7
+ | Date | 2026-05-13 |
8
+ | Author | Rohit Saraf |
9
+ | Status | In review — pending stakeholder sign-off |
10
+ | Reviewer | (to be assigned) |
11
+ | Source brief | Sarvam AI take-home assignment |
12
+
13
+ ---
14
+
15
+ ## 0. Document purpose
16
+
17
+ This is the foundational requirements document for the Insurance Sales Portfolio Expert. It defines what we are building, for whom, against what measurable success criteria, and — equally important — what we are explicitly **not** building.
18
+
19
+ All downstream documents (`02-architecture.md`, `03-eval-plan.md`, `04-failure-modes.md`, `05-needs-analysis-flow.md`, `decisions.md`) depend on this spec. Changes here cascade; this is the source of truth for product scope.
20
+
21
+ This document was authored with deliberate pre-implementation rigour because the assignment is evaluated as much on the clarity of the design narrative as on the working bot. Where decisions in this doc rule things out, the rationale is captured inline so a future reader (or interviewer) can audit the choices.
22
+
23
+ ---
24
+
25
+ ## 1. Product vision (one paragraph)
26
+
27
+ A **voice-first AI advisor for Indian health insurance buyers**, deployed over a **curated, pre-acquired corpus** of policy documents from leading Indian health insurers. The product replaces the typical experience of speaking to a generalist advisor at a horizontal aggregator — adaptive needs discovery, granular policy filtering, side-by-side comparison, illustrative pricing, and consultative recommendation — and surfaces all of it through a natural, code-switched (English + Hindi) voice conversation grounded in the actual policy wordings, with clause-level citations on every factual claim.
28
+
29
+ ### 1.1 Strategic framing (for Sarvam)
30
+
31
+ The product is **consumer-facing in experience** but **B2B in commercial application**. The realistic deployment is an insurer or aggregator white-labelling this advisor to assist customers shopping for health insurance, with Sarvam's ASR / TTS / Indic LLM stack as the core enabler. The assignment build is the consumer surface; the architecture must remain coherent for a white-labelled / multi-tenant future.
32
+
33
+ ---
34
+
35
+ ## 2. Differentiation
36
+
37
+ The advisor experience that exists today in India is split: aggregators (PolicyBazaar, Coverfox, InsuranceDekho) provide structured comparison but lean heavily on human callbacks for actual advisory; bank / insurer-direct channels provide depth but only on their own products. Both surfaces struggle with voice as a first-class modality and with grounding recommendations in primary source documents.
38
+
39
+ | Dimension | Status quo (aggregator + callback) | This product |
40
+ | --- | --- | --- |
41
+ | Primary interface | Web form → human callback | Voice-native, AI-led end-to-end |
42
+ | Policy depth | Marketing summaries + feature ladders | Full policy wordings + clause-level cited Q&A |
43
+ | Recommendation basis | Quote price + commercial incentive | Buyer profile + policy fit, transparently reasoned |
44
+ | Indic language support | English primary, Hindi shallow | Hindi / Hinglish first-class through conversation |
45
+ | Trust mechanism | Brand + reviews + advisor rapport | Citation grammar — every factual claim links to source clause |
46
+ | Marginal cost-to-serve | Salaried human advisor per buyer | Voice agent, marginal cost ≈ STT + LLM + TTS per turn |
47
+ | Persistence of advice | Voice call, then lost | Replayable transcript with cited recommendations |
48
+
49
+ The point of differentiation is **not "no humans"** — it is **"depth + grounding + Indic-native + low marginal cost"** in a single surface. Human escalation remains a designed-in path (see §6 non-goals).
50
+
51
+ ---
52
+
53
+ ## 3. User personas
54
+
55
+ Three personas, intentionally spanning the spectrum of prior knowledge and intent. The product must serve all three without forcing them into a single flow.
56
+
57
+ ### 3.1 Persona A — Priya, 28, Pune | first-time buyer
58
+
59
+ - Software professional, two years into career, no dependants yet.
60
+ - Triggered by a friend's recent hospitalisation and a ₹4L out-of-pocket bill.
61
+ - Has **no current policy** beyond a basic employer cover she doesn't fully understand.
62
+ - Doesn't know what to ask — needs the advisor to **lead the conversation** and educate as it goes.
63
+ - Budget-sensitive (₹10–20K annual premium acceptable).
64
+ - Comfortable in **Hindi-English code-switch**, prefers conversational voice over reading.
65
+ - Success looks like: she ends the session with a clear shortlist of 2 policies, understands what each covers, and knows what she's paying for.
66
+
67
+ ### 3.2 Persona B — Anjali, 32, Mumbai | informed shopper
68
+
69
+ - Senior engineer at a growth-stage startup, recently married, no kids yet.
70
+ - Has employer cover (₹5L floater) and worries it is insufficient and not portable.
71
+ - Has heard of HDFC ERGO, Star, Niva Bupa — wants help choosing between **2–3 specific policies** she's already aware of.
72
+ - Time-poor — wants a guided, voice-led experience during her commute.
73
+ - Comfortable in English; will tolerate Hindi if natural.
74
+ - Success looks like: she gets a defensible comparison across her shortlist, surfaces sub-limits and waiting periods she hadn't thought to ask about, and walks away ready to buy.
75
+
76
+ ### 3.3 Persona C — Vikram, 45, Bengaluru | family decision-maker
77
+
78
+ - Mid-career VP, two school-age kids, ageing parents (70 and 68) at home.
79
+ - Already has a family floater; explicitly evaluating **a separate parent policy** (high-sum-insured, senior-citizen plan).
80
+ - Knows the basics; wants to **deep-dive into sub-limits, claim settlement ratios, network hospitals, room-rent capping, and pre-existing-disease waiting periods**.
81
+ - Will press the advisor on specific clauses — must be answered with citations, not vibes.
82
+ - English-dominant.
83
+ - Success looks like: he extracts decision-grade information without having to read three 60-page PDFs himself, and is shown the trade-offs explicitly.
84
+
85
+ All three personas share one trait: **they do not want to be sold to.** They want to be advised. The product persona (§5.2) reflects this.
86
+
87
+ ---
88
+
89
+ ## 4. Buyer journey
90
+
91
+ The advisor flow is **adaptive** — branches expand or contract based on conversational signal — but the seven-stage backbone is stable:
92
+
93
+ ```
94
+ ┌────────────────────────┐
95
+ │ 1. Greeting + framing │ "I'm an advisor, not a broker. I'll ask you a few
96
+ └────────────┬───────────┘ things, then we'll look at policies together."
97
+
98
+ ┌────────────▼───────────┐
99
+ │ 2. Adaptive fact-find │ Universal core: age, dependants, income, existing
100
+ │ (see Doc 05) │ cover, primary goal, location. Conditional deep
101
+ └────────────┬───────────┘ dives based on signal.
102
+
103
+ ┌────────────▼───────────┐
104
+ │ 3. Profile readback │ "Here's what I've understood. Correct me." User
105
+ └────────────┬───────────┘ confirms or amends; agent commits the profile.
106
+
107
+ ┌────────────▼───────────┐
108
+ │ 4. Shortlist │ Agent applies hard filters (must-haves) +
109
+ │ generation │ soft scoring to corpus; returns 3–5 candidates.
110
+ └────────────┬───────────┘
111
+
112
+ ┌────────────▼───────────┐
113
+ │ 5. Side-by-side │ Visual comparison surface in the UI; advisor
114
+ │ comparison │ narrates differences in voice.
115
+ └────────────┬───────────┘
116
+
117
+ ┌────────────▼───────────┐
118
+ │ 6. Recommendation + │ Advisor recommends one or two with reasoning
119
+ │ transparent why │ tied back to the user's profile.
120
+ └────────────┬───────────┘
121
+
122
+ ┌────────────▼───────────┐
123
+ │ 7. Open Q&A + handoff │ Free-form voice Q&A with clause-level citations.
124
+ └────────────────────────┘ Optional: handoff path to a human / insurer.
125
+ ```
126
+
127
+ The conversation may revisit earlier stages — for example, Vikram (Persona C) may skip 1–3 and jump straight to 5/7 by saying "compare these three for me." The architecture must permit non-linear traversal.
128
+
129
+ ---
130
+
131
+ ## 5. Product principles
132
+
133
+ These are the non-negotiable principles that shape every downstream design decision. Each is stated as a rule, with the reason it exists.
134
+
135
+ ### 5.1 Grounded over fluent
136
+
137
+ If we cannot cite the source for a factual claim, we do not make the claim. The advisor will say "I don't see that in the policy document" before it improvises. Hallucinations on coverage details are not "small UX bugs" — they are mis-selling, which is regulated in India and a reputational risk for any commercial deployment.
138
+
139
+ ### 5.2 Consultative, never closing
140
+
141
+ The advisor's persona is modelled on a good Independent Financial Advisor — informed, patient, comfortable saying "this isn't the right policy for you." It is explicitly **not** a call-centre closer. Persuasion is allowed once the buyer signals readiness; manipulation is not.
142
+
143
+ ### 5.3 Indic-native, not translated
144
+
145
+ Hindi and Hinglish are designed-in from day one, not retrofitted. The advisor opens by detecting the user's language preference within the first one or two turns and adapts.
146
+
147
+ ### 5.4 Curated corpus is the product's moat
148
+
149
+ The user does not upload PDFs. We pre-acquire, normalise, and structurally extract from the corpus so quality is under our control. The corpus is the product's defensibility relative to a generic RAG-over-anything bot.
150
+
151
+ ### 5.5 Architecture is built for category expansion from day one
152
+
153
+ Scope v1 is health insurance only, but every schema, interface, and adapter is designed so adding Life / Motor / Travel later is a configuration change, not a refactor. See §7 (Constraints) and Doc 02 (Architecture) for the seven c-readiness commitments.
154
+
155
+ ### 5.6 Explainability is a feature, not a chore
156
+
157
+ The architecture document, the decisions log, and the eval harness are first-class deliverables, not afterthoughts. A reviewer should be able to walk through any technology pick and find the alternatives we considered and the empirical basis for the choice.
158
+
159
+ ---
160
+
161
+ ## 6. Success criteria
162
+
163
+ What "working" means, measurably. Every entry here will have a corresponding test in `03-eval-plan.md`.
164
+
165
+ | # | Criterion | Threshold for v1 | How measured |
166
+ | --- | --- | --- | --- |
167
+ | C1 | End-to-end voice latency (user speech end → advisor speech start) | **p50 ≤ 4s, p95 ≤ 7s** | Instrumented turn logs |
168
+ | C2 | Factual answer accuracy on gold Q&A set | **≥ 95% correct** | Automated grader vs. 100-question gold set per policy |
169
+ | C3 | Citation accuracy (cited clause actually supports the claim) | **≥ 95%** | Automated grader on cited spans |
170
+ | C4 | Refusal precision on out-of-policy questions | **≥ 90%** (refuses when it should) | Adversarial test set in eval plan |
171
+ | C5 | Structured-field extraction accuracy from PDFs | **≥ 95% per field, ≥ 90% per policy across all fields** | Manual gold-labelled subset (5 policies × all fields) |
172
+ | C6 | Comparison view correctness | **100% of compared fields match the structured store** | Snapshot test on comparison rendering |
173
+ | C7 | Pricing band realism | **Illustrative band within ±25% of a real PolicyBazaar quote** for 10 spot checks | Manual ground truth |
174
+ | C8 | Hindi/Hinglish handling | **No degradation > 5pp on C2/C3** when user speaks Hindi vs. English | Bilingual gold set |
175
+ | C9 | Recommendation defensibility | **Reasoning trace explicitly references the user's profile fields** in 100% of recommendations | Human review of 30 sessions |
176
+ | C10 | UI polish (qualitative) | Demo-grade — fonts harmonised, no console errors, no broken states | Internal review checklist |
177
+
178
+ These thresholds are deliberately aggressive. Where we fall short, the doc will record the actual numbers and why — that honesty is itself part of the artifact.
179
+
180
+ ---
181
+
182
+ ## 7. Non-goals (explicit)
183
+
184
+ To protect against scope creep — and to give the interviewer something to grade against — these are explicitly **out of scope** for v1.
185
+
186
+ - **Transacting / selling.** We do not collect payment, issue policies, or integrate with insurer KYC. Handoff to the insurer is a link, not a flow.
187
+ - **Underwriting.** We do not assess whether the user *will* be granted a policy at a given price. We surface illustrative pricing only.
188
+ - **Medical advice.** "Will this treatment be covered" is in scope. "Should I get this treatment" is not.
189
+ - **Real-time quote pulls from insurer APIs.** Out of scope; possibly out of reach commercially. We use aggregated public data and label pricing as illustrative.
190
+ - **Categories beyond Health.** Life, Motor, Travel, Cyber, etc. are explicitly v2. The architecture accommodates them; we do not populate them.
191
+ - **Multi-user / multi-tenant deployment.** v1 is single-user demo. Stateless services + canonical DB make v2 multi-tenant tractable.
192
+ - **User-uploaded PDFs.** Corpus is curated. (See §5.4.)
193
+ - **Mobile-native deployment.** v1 is desktop browser. Mobile responsive is a stretch goal.
194
+ - **Real-time co-browsing with a human advisor.** v2 handoff feature.
195
+ - **Persistent user accounts across sessions.** v1 is session-scoped.
196
+ - **Persona-A onboarding video / tutorial layer.** The advisor itself does the onboarding through voice.
197
+
198
+ ---
199
+
200
+ ## 8. Constraints
201
+
202
+ ### 8.1 Time
203
+
204
+ - Sarvam assignment deadline: **TBD — pending confirmation.** Working assumption: 2 weeks from kick-off.
205
+
206
+ ### 8.2 Stack expectations (Sarvam-aware)
207
+
208
+ - We will **benchmark Sarvam's STT, TTS, and Indic LLM offerings** against best-in-class alternatives on a real test set. Picks are justified in `decisions.md`. Silent defaults to OpenAI / Anthropic / ElevenLabs are not acceptable.
209
+ - We will use English-only PDFs as input (regulator-mandated language for policy wordings is English).
210
+ - The conversational layer must support Hindi and Hinglish output regardless of input language.
211
+
212
+ ### 8.3 Regulatory / ethical
213
+
214
+ - No mis-selling. The advisor must not push a product that the user's profile does not fit.
215
+ - No medical advice.
216
+ - All pricing is labelled "illustrative" with a visible disclaimer and source citation.
217
+ - No personally identifiable information persisted beyond the session.
218
+ - Document sourcing: only publicly available brochures, customer information sheets, and policy wordings from insurer websites and IRDAI's product database. No scraping behind logins.
219
+
220
+ ### 8.4 Technical envelope
221
+
222
+ - v1 UI: Streamlit. Business logic is decoupled into a separate `app/` Python module so a future FastAPI + React v2 swaps the UI layer only.
223
+ - v1 corpus: 15–20 health policies across 5 leading Indian insurers (Star Health, HDFC ERGO, Niva Bupa, Care Health, ICICI Lombard — exact list pending availability check).
224
+ - v1 deployment: local development + a single-instance deploy (Streamlit Community Cloud or equivalent) for the interviewer to demo.
225
+
226
+ ---
227
+
228
+ ## 9. Open questions (deferred to downstream docs)
229
+
230
+ These are intentionally **not** resolved here. Each is owned by a specific later document.
231
+
232
+ | # | Question | Owned by |
233
+ | --- | --- | --- |
234
+ | Q1 | Exact STT / TTS / LLM / embedding provider picks, with benchmarks | `02-architecture.md` + `decisions.md` |
235
+ | Q2 | Structured schema — exact 30–50 field set, field types, optionality | `02-architecture.md` |
236
+ | Q3 | Extraction pipeline — single-pass LLM vs. self-critique vs. human-in-loop | `02-architecture.md` |
237
+ | Q4 | Gold Q&A set construction — sources, size, grader design | `03-eval-plan.md` |
238
+ | Q5 | Refusal taxonomy — exact categories and example utterances | `03-eval-plan.md` + `04-failure-modes.md` |
239
+ | Q6 | Fact-find question graph — nodes, edges, termination criteria | `05-needs-analysis-flow.md` |
240
+ | Q7 | Pricing aggregation sources, freshness policy, ±25% empirical proof | `02-architecture.md` |
241
+ | Q8 | Recommendation engine — rule-based pre-filter vs. pure-LLM reasoning | `02-architecture.md` |
242
+ | Q9 | Failure modes — full register with mitigations | `04-failure-modes.md` |
243
+ | Q10 | Observability — what we log per turn, retention, dashboards | `02-architecture.md` |
244
+
245
+ ---
246
+
247
+ ## 10. Revision history
248
+
249
+ | Version | Date | Author | Change |
250
+ | --- | --- | --- | --- |
251
+ | 0.1 | 2026-05-13 | Rohit Saraf | Initial draft post-brainstorm with AI architect |
252
+
253
+ ---
254
+
255
+ ## 11. Sign-off
256
+
257
+ This document is in review. Approval required from:
258
+
259
+ - [ ] Rohit Saraf (project owner)
260
+
261
+ Once approved, this doc becomes the source-of-truth spec. Subsequent docs reference it by section number.
docs/02-architecture.md ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 02 — Architecture
2
+
3
+ | Field | Value |
4
+ | --- | --- |
5
+ | Project | Insurance Sales Portfolio Expert |
6
+ | Document version | 0.1 (draft) |
7
+ | Date | 2026-05-13 |
8
+ | Depends on | `01-requirements.md` |
9
+ | Status | In review |
10
+
11
+ ---
12
+
13
+ ## 0. Purpose
14
+
15
+ This document specifies the technical architecture: the stack, the data flow, the canonical schema, the per-insurer adapter pattern, and the seven commitments that make v1 (vertical slice) cheaply expandable to v2 (full platform). Every decision here is mirrored in `decisions.md` (with alternatives + reasoning).
16
+
17
+ ---
18
+
19
+ ## 1. System diagram
20
+
21
+ ```
22
+ ┌─────────────────────────────────────────────────────────────────────┐
23
+ │ USER (browser) │
24
+ │ (mic input · speaker output · chat UI) │
25
+ └─────────────────────────────────────────────────────────────────────┘
26
+
27
+ │ audio bytes / text / clicks
28
+
29
+ ┌─────────────────────────────────────────────────────────────────────┐
30
+ │ STREAMLIT UI (app/main.py) │
31
+ │ ─ chat pane · audio recorder · filter sidebar · comparison view │
32
+ │ ─ session state · @st.cache_resource for heavy clients │
33
+ └─────────────────────────────────────────────────────────────────────┘
34
+ │ │ │
35
+ │ audio bytes │ user_query (text) │ filter / select
36
+ ▼ ▼ ▼
37
+ ┌──────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
38
+ │ VOICE (app/voice)│ │ ORCHESTRATOR │ │ STRUCTURED STORE │
39
+ │ ─ Sarvam Saaras │ ──────► │ (app/orchestrator) │ ◄ │ DuckDB │
40
+ │ (STT) │ text │ ─ persona prompt │ │ ─ 1 row per policy │
41
+ │ ─ Sarvam Bulbul │ ◄────── │ ─ fact-find graph │ │ ─ 40-50 fields │
42
+ │ (TTS) │ audio │ ─ tool routing │ │ ─ filter / compare │
43
+ └──────────────────┘ │ ─ citation grammar │ └──────────────────────┘
44
+ │ │ ▲
45
+ │ │ │ extracted
46
+ └──────────┬──────────┘ │ at ingest time
47
+ │ │
48
+ ▼ │
49
+ ┌─────────────────────┐ │
50
+ │ Sarvam-M (LLM) │ │
51
+ └─────────────────────┘ │
52
+ ▲ │
53
+ │ retrieved chunks │
54
+ │ │
55
+ ┌──────────┴──────────┐ │
56
+ │ VECTOR STORE │ ┌───────────┴──────────┐
57
+ │ Chroma │ │ INGEST PIPELINE │
58
+ │ ─ chunk + embedding │ ◄ │ (rag/ingest.py) │
59
+ │ ─ metadata: policy, │ │ ─ download PDFs │
60
+ │ page, clause │ │ ─ chunk + embed │
61
+ └─────────────────────┘ │ ─ LLM extract → DB │
62
+ │ ─ per-insurer adapter│
63
+ └──────────────────────┘
64
+
65
+ │ raw PDFs
66
+ ┌────────────┴─────────┐
67
+ │ INSURER WEBSITES / │
68
+ │ IRDAI PRODUCT DB │
69
+ └──────────────────────┘
70
+ ```
71
+
72
+ ---
73
+
74
+ ## 2. Stack picks (v1)
75
+
76
+ | Layer | Pick | Why | Alternative considered | Decision in `decisions.md` |
77
+ | --- | --- | --- | --- | --- |
78
+ | STT | **Sarvam Saaras** | Sarvam-first per assignment context; strong Indic/Hinglish handling | Whisper-large-v3, Deepgram Nova | D-006 |
79
+ | TTS | **Sarvam Bulbul** | First-party Indic prosody; Sarvam-first | ElevenLabs, OpenAI TTS | D-006 |
80
+ | LLM | **Sarvam-M** (or current Sarvam flagship) | Sarvam-first; Indic-tuned reasoning | GPT-4o, Claude Sonnet | D-006 |
81
+ | Embeddings | **Sarvam embedding API if available, else `text-embedding-3-small`** | Will benchmark; whichever scores higher on retrieval @5 against our gold set | OpenAI embeddings, BGE-large local | D-006 |
82
+ | Vector DB | **Chroma** (local, persisted to disk) | Zero-config, embedded, works on Streamlit Cloud | FAISS (no metadata filtering), Qdrant (overkill) | D-004 |
83
+ | Structured DB | **DuckDB** (single file, embedded) | One-file, columnar, no server, deployable to Streamlit Cloud | SQLite, Postgres | D-004 |
84
+ | UI | **Streamlit** | Fastest v1; business logic decoupled so v2 swaps UI only | FastAPI + React (v2) | D-005 |
85
+ | Audio capture | **`streamlit-mic-recorder`** or `streamlit-webrtc` (whichever proves stable in <1h spike) | Required for browser-side mic | Native HTML5 + WebSocket (too much glue) | — |
86
+ | Deployment | **Streamlit Community Cloud** | Free, GitHub-connected, supports Streamlit out of box, secrets UI for API keys | Render, Fly.io | — |
87
+
88
+ **Provider abstraction:** every provider is behind a thin interface (`app/providers/stt.py`, `app/providers/tts.py`, `app/providers/llm.py`) so a benchmark swap is a config flag. **This is non-negotiable** — it's c-readiness commitment #5.
89
+
90
+ ---
91
+
92
+ ## 3. Canonical data flow (a single user turn)
93
+
94
+ ```
95
+ user speaks
96
+
97
+ ▼ (audio bytes)
98
+ voice.transcribe() ── Sarvam Saaras ──► text
99
+
100
+
101
+ orchestrator.handle_turn(text, session)
102
+
103
+ ├── if fact-find phase: needs_finder.next_question(profile) → returns next Q
104
+
105
+ ├── if filter/compare intent detected: structured_store.query(filters) → rows
106
+
107
+ └── if Q&A intent:
108
+ rag.retrieve(query, top_k=5)
109
+ ─ returns chunks with (policy_id, page, clause, text, score)
110
+ llm.generate(persona_prompt + chat_history + retrieved_chunks + user_query)
111
+ ─ enforces citation grammar [Source: ...]
112
+ ─ enforces refusal when retrieval is weak
113
+ ─ returns text response
114
+
115
+
116
+ voice.synthesize(text) ── Sarvam Bulbul ──► audio bytes
117
+
118
+
119
+ ui.play_audio(audio_bytes)
120
+ ui.append_to_chat(text + citations)
121
+ log_turn({stt_confidence, retrieved_ids, latency_ms, cost_usd, refused})
122
+ ```
123
+
124
+ ---
125
+
126
+ ## 4. Policy schema (extensible to Life / Motor / Travel)
127
+
128
+ Full Pydantic model lives in `rag/schema.py` (designed in parallel by a sub-agent — see that file for the canonical 40–50 fields).
129
+
130
+ **Field groupings:**
131
+
132
+ 1. **Identity** — `policy_id`, `insurer_name`, `insurer_slug`, `policy_name`, `policy_type` (enum), `uin_code`
133
+ 2. **Eligibility** — `min_entry_age`, `max_entry_age`, `max_renewal_age`, `family_composition`
134
+ 3. **Sum insured & premium** — `sum_insured_options[]`, `premium_payment_modes[]`, `premium_band_illustrative`, `grace_period_days`
135
+ 4. **Waiting periods** *(comparison-critical)* — `initial_waiting_period_days`, `pre_existing_disease_waiting_months`, `specific_disease_waiting_months`, `maternity_waiting_months`, `specific_diseases_listed[]`
136
+ 5. **Coverage scope** *(comparison-critical)* — `pre_hospitalization_days`, `post_hospitalization_days`, `day_care_treatments_count`, `domiciliary_treatment`, `ayush_coverage`, `maternity_coverage`, `newborn_coverage`, `organ_donor_expenses`, `ambulance_cover`, `critical_illness_cover`, `restoration_benefit`, `no_claim_bonus_pct`, `preventive_health_checkup`
137
+ 6. **Sub-limits & caps** *(comparison-critical)* — `room_rent_capping`, `icu_capping`, `copayment_pct`, `disease_wise_sub_limits` (json), `deductible_amount`
138
+ 7. **Geography & network** — `geographic_coverage_india`, `worldwide_emergency_cover`, `network_hospital_count`, `cashless_treatment_supported`
139
+ 8. **Exclusions** — `permanent_exclusions[]`, `temporary_exclusions[]`, `notable_exclusions_summary`
140
+ 9. **Claim & service** — `claim_settlement_ratio`, `claim_process_summary`, `tat_cashless_authorization_hours`
141
+ 10. **Riders** — `available_riders[]`, `top_rider_examples`, `rider_premium_indicative`
142
+ 11. **Source metadata** — `source_pdf_path`, `source_pdf_url`, `last_updated_date`, `extraction_confidence_pct`
143
+
144
+ **v2 expansion:** add category-specific optional field groups (`life_*`, `motor_*`, `travel_*`). Existing fields are never removed. Adding a category = schema additive change, no migration.
145
+
146
+ ---
147
+
148
+ ## 5. Extraction pipeline
149
+
150
+ ```
151
+ for pdf in corpus:
152
+ text_chunks = chunk(pdf, target_tokens=800, overlap=120)
153
+ # chunks indexed in Chroma with metadata: policy_id, page, clause_path
154
+
155
+ # one-shot LLM extraction with the schema as a structured-output target
156
+ extracted = llm.extract_structured(
157
+ full_pdf_text,
158
+ schema=HealthPolicy,
159
+ few_shot_examples=[hdfc_ergo_example, niva_bupa_example]
160
+ )
161
+
162
+ # self-critique pass: LLM checks every field's evidence in the source
163
+ confidence_per_field = llm.self_critique(extracted, full_pdf_text)
164
+ extracted.extraction_confidence_pct = aggregate(confidence_per_field)
165
+
166
+ # write to DuckDB
167
+ structured_store.upsert(extracted)
168
+ ```
169
+
170
+ **Quality bar:** any field below 80% self-rated confidence is **stored with a `_low_confidence` flag** and excluded from comparison surfaces (but kept queryable). This protects the comparison view from looking authoritative when it isn't.
171
+
172
+ **Eval (see Doc 03):** manually spot-check 5 policies × all fields. If extraction accuracy < 90% on critical fields (waiting periods, sub-limits, room rent), iterate the extraction prompt.
173
+
174
+ ---
175
+
176
+ ## 6. Per-insurer adapter pattern
177
+
178
+ Each insurer's website has a unique structure. We isolate per-insurer logic in `rag/adapters/<insurer_slug>.py`:
179
+
180
+ ```python
181
+ # rag/adapters/base.py
182
+ class InsurerAdapter(ABC):
183
+ insurer_slug: str
184
+ insurer_name: str
185
+
186
+ @abstractmethod
187
+ def discover_policy_urls(self) -> list[PolicyURL]:
188
+ """Crawl/scrape insurer site to find all health-policy PDF URLs."""
189
+
190
+ @abstractmethod
191
+ def normalize_pdf(self, pdf_path: Path) -> Path:
192
+ """Insurer-specific PDF normalization (e.g. handle watermarks, OCR fallback)."""
193
+ ```
194
+
195
+ Adding an 11th insurer = one new adapter file. **c-readiness commitment #1.**
196
+
197
+ For v1, we accept that discover_policy_urls() is hand-curated (from sub-agent corpus list) — v2 makes it agentic.
198
+
199
+ ---
200
+
201
+ ## 7. The seven c-readiness commitments (from prior brainstorm)
202
+
203
+ These are non-negotiable architectural invariants:
204
+
205
+ | # | Commitment | Where enforced |
206
+ | --- | --- | --- |
207
+ | 1 | Insurer-agnostic crawler — per-insurer adapter modules | `rag/adapters/` |
208
+ | 2 | Category-agnostic structured schema — Life/Motor/Travel fields nullable today | `rag/schema.py` |
209
+ | 3 | Pluggable extraction pipeline — prompt-and-schema-driven, not hand-coded per insurer | `rag/extract.py` |
210
+ | 4 | Data-driven filter UI — sliders/checkboxes rendered from schema | `app/ui_filter.py` |
211
+ | 5 | Provider-agnostic STT/TTS/LLM/embeddings — thin interface, swap via config | `app/providers/` |
212
+ | 6 | Eval harness scales linearly — same script on 5 policies works on 500 | `eval/run.py` |
213
+ | 7 | Stateless services, single source of truth in DB — no Streamlit-session shortcuts | All `app/` modules |
214
+
215
+ Every PR / commit should be self-audited against these.
216
+
217
+ ---
218
+
219
+ ## 8. Observability (minimum-viable)
220
+
221
+ Every turn writes one JSON line to `logs/turns.jsonl`:
222
+
223
+ ```json
224
+ {
225
+ "ts": "2026-05-13T14:23:11Z",
226
+ "session_id": "...",
227
+ "turn_id": "...",
228
+ "user_text": "what's the waiting period for cataract?",
229
+ "stt_confidence": 0.94,
230
+ "intent": "policy_qa",
231
+ "retrieved_chunks": ["star-comprehensive:p18:c4.2.7", "..."],
232
+ "llm_latency_ms": 1340,
233
+ "tts_latency_ms": 720,
234
+ "total_latency_ms": 3210,
235
+ "refused": false,
236
+ "refusal_reason": null,
237
+ "cost_estimate_inr": 1.4
238
+ }
239
+ ```
240
+
241
+ **Why this matters:** the Sarvam interviewer can replay any session, see what the bot retrieved, see what it cost, see where it refused. This is the difference between a demo and a service.
242
+
243
+ ---
244
+
245
+ ## 9. Repo layout
246
+
247
+ ```
248
+ insurance-sales-bot/
249
+ ├── app/
250
+ │ ├── __init__.py
251
+ │ ├── main.py # Streamlit entrypoint
252
+ │ ├── orchestrator.py # turn handler, persona prompt, intent routing
253
+ │ ├── needs_finder.py # adaptive fact-find question graph
254
+ │ ├── ui_chat.py
255
+ │ ├── ui_filter.py # schema-driven filter UI
256
+ │ ├── ui_compare.py # side-by-side comparison
257
+ │ └── providers/
258
+ │ ├── stt.py # Sarvam Saaras client behind interface
259
+ │ ├── tts.py # Sarvam Bulbul client behind interface
260
+ │ ├── llm.py # Sarvam-M client behind interface
261
+ │ └── embeddings.py
262
+ ├── rag/
263
+ │ ├── corpus/ # raw PDFs (committed for reproducibility)
264
+ │ ├── extracted/ # per-policy JSON (committed)
265
+ │ ├── vectors/ # Chroma persistence (gitignored — rebuilt)
266
+ ��� ├── policies.duckdb # structured store (committed)
267
+ │ ├── schema.py # Pydantic HealthPolicy model
268
+ │ ├── ingest.py # download → chunk → embed → extract → store
269
+ │ ├── retrieve.py # query → top-k chunks
270
+ │ ├── extract.py # LLM-driven structured extraction
271
+ │ └── adapters/
272
+ │ ├── base.py
273
+ │ ├── star_health.py
274
+ │ ├── hdfc_ergo.py
275
+ │ └── ... (10 adapters)
276
+ ├── eval/
277
+ │ ├── gold_qa.json # 50–100 gold Q&A pairs (10/policy for top policies)
278
+ │ ├── grader.py # automated grader (LLM judge + regex)
279
+ │ ├── run.py # batch runner
280
+ │ └── results.md # last run's accuracy table
281
+ ├── data/
282
+ │ ├── insurers.json # 10 insurers metadata
283
+ │ └── corpus_urls.md # output of corpus-discovery sub-agent
284
+ ├── docs/
285
+ │ ├── 01-requirements.md
286
+ │ ├── 02-architecture.md # THIS FILE
287
+ │ ├── 03-eval-plan.md
288
+ │ ├── 04-failure-modes.md
289
+ │ ├── 05-needs-analysis-flow.md
290
+ │ ├── decisions.md
291
+ │ └── ROADMAP.md
292
+ ├── tests/
293
+ │ └── test_smoke.py
294
+ ├── streamlit_app.py # Streamlit Cloud entrypoint (thin wrapper)
295
+ ├── requirements.txt
296
+ ├── .env / .env.example
297
+ ├── .gitignore
298
+ └── README.md
299
+ ```
300
+
301
+ ---
302
+
303
+ ## 10. Deployment
304
+
305
+ - **Repo:** `github.com/rohitsar567/insurance-sales-bot` (public)
306
+ - **Hosting:** Streamlit Community Cloud — `share.streamlit.io` connects to the repo, deploys `streamlit_app.py` on push
307
+ - **Secrets:** Sarvam API key set via Streamlit Cloud's secrets manager (mirrors `.env`)
308
+ - **Resources:** ~1 GB ephemeral, persistent disk for `rag/` (corpus + DuckDB committed to repo so deploy is reproducible from `git clone`)
309
+ - **Vector store rebuild:** on first deploy, app detects empty `rag/vectors/` and rebuilds from `rag/corpus/` (one-time ~5 min cold start; subsequent starts cached)
310
+
311
+ ---
312
+
313
+ ## 11. Open decisions deferred to Doc 03 / Doc 04 / Doc 05
314
+
315
+ | # | Decision | Owner |
316
+ | --- | --- | --- |
317
+ | A | Final embedding provider after benchmark | `decisions.md` D-011 (pending Sarvam embeddings API check) |
318
+ | B | Refusal taxonomy — exact categories | `04-failure-modes.md` |
319
+ | C | Fact-find question graph — nodes, edges, termination | `05-needs-analysis-flow.md` |
320
+ | D | Gold Q&A construction approach | `03-eval-plan.md` |
321
+ | E | Recommendation engine — rule-based pre-filter vs. pure-LLM | Likely D-012, pending eval results |
322
+
323
+ ---
324
+
325
+ ## 12. Revision history
326
+
327
+ | Version | Date | Change |
328
+ | --- | --- | --- |
329
+ | 0.1 | 2026-05-13 | Initial draft |
docs/03-eval-plan.md ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 03 — Evaluation Plan
2
+
3
+ | Field | Value |
4
+ | --- | --- |
5
+ | Project | Insurance Sales Portfolio Expert |
6
+ | Version | 0.1 |
7
+ | Date | 2026-05-13 |
8
+ | Depends on | `01-requirements.md` §6 (success criteria), `02-architecture.md` (system under test) |
9
+ | Status | Pipeline implemented; full run pending corpus completion |
10
+
11
+ ## 0. Purpose
12
+
13
+ Evaluation is **how we prove the bot works**. Three artifacts:
14
+
15
+ 1. **Gold Q&A set** — ground-truth question-answer-source triples
16
+ 2. **Automated grader** — measures factual accuracy + citation accuracy + refusal precision per turn
17
+ 3. **Results table** — versioned, audit-grade record of every eval run
18
+
19
+ These directly back the success criteria in Doc 01 §6: C2 (≥95% factual), C3 (≥95% citation), C4 (≥90% refusal precision).
20
+
21
+ ## 1. Gold Q&A construction — three pipelines
22
+
23
+ ### Pipeline A — auto-generated from structured extraction (the bulk)
24
+
25
+ For every successfully extracted policy in DuckDB, generate templated Q&A where the answer comes directly from the 48-field schema. Per `eval/generate_gold.py`:
26
+
27
+ - ~15 question templates × ~80 policies = ~1,100 candidate pairs
28
+ - Each pair is **fully reproducible** — the answer traces to a specific field which traces to a specific clause
29
+ - Auto-tagged: `question_type` ∈ {waiting_period, coverage_scope, exclusions, sub_limit, eligibility, claim, network, bonus}
30
+
31
+ **Why this scales:** adding the 11th, 12th, 80th policy adds zero new template work. Same generator, more rows.
32
+
33
+ ### Pipeline B — LLM-drafted nuanced questions (curated)
34
+
35
+ For top-priority policies (top 10–20), run an LLM (Sarvam-M, with DeepSeek-V3 fallback) on the policy text with prompt: *"generate 5 buyer-style questions whose answers are explicitly in this document; include the source clause."* Each generated pair is **human spot-checked** before commit.
36
+
37
+ Target: 5 × 20 = 100 questions covering multi-clause and edge-case reasoning that Pipeline A can't generate.
38
+
39
+ ### Pipeline C — hand-crafted adversarial set (refusal tests)
40
+
41
+ ~30–40 hand-written questions across these classes:
42
+
43
+ - **Out-of-corpus** ("Does this cover space tourism?") → expect refusal
44
+ - **Out-of-policy-type** ("What is the IRDAI mandate on dental coverage?") → expect refusal (D-017)
45
+ - **Multi-policy compare** ("Compare cancer coverage in Star vs HDFC ERGO") → expect cited comparison
46
+ - **Hinglish** ("Cataract ke liye waiting period kya hai?") → expect Hindi answer, same factual accuracy
47
+ - **Code-switched** ("policy mein maternity cover hai kya?") → expect Hinglish answer
48
+
49
+ Each pair marked with `expected_refusal: bool`. Refusal cases test that the bot doesn't hallucinate when grounding fails.
50
+
51
+ ## 2. Grader design
52
+
53
+ **Single grading endpoint:** `eval/run.py` calls `backend.orchestrator.handle_turn` in-process, takes the reply, scores against gold with three signals:
54
+
55
+ ### Signal 1 — Regex hard-checks (deterministic)
56
+
57
+ Numbers, dates, currency, durations, percentages are extracted via regex from both gold and bot reply. Exact match (after normalization) is the strongest signal. Catches "the premium is ₹15,000" hallucinations without an LLM.
58
+
59
+ ### Signal 2 — LLM-judge faithfulness (Groq Llama-3.3-70B)
60
+
61
+ Different model family from Sarvam-M (the brain) → **non-circular evaluation**. Judge prompt:
62
+
63
+ > Given GOLD and BOT answers, output strict JSON: `{factual_match: bool, citation_present: bool, score: 0-1, reason: str}`. Be strict on partial matches.
64
+
65
+ ### Signal 3 — Citation regex check
66
+
67
+ `[Source: ...]` tag must be present in BOT for non-refusal questions. Caught by `re.search(r'\[Source:'` pattern.
68
+
69
+ **Final per-question score:** `factual_match AND (citation_present OR expected_refusal)`.
70
+
71
+ ## 3. Metrics computed
72
+
73
+ | Metric | Doc 01 target | How |
74
+ | --- | --- | --- |
75
+ | Factual accuracy | C2 ≥ 95% | n_correct / n_total |
76
+ | Citation accuracy | C3 ≥ 95% | n_correct_citations / n_non_refusal |
77
+ | Refusal precision | C4 ≥ 90% | n_correct_refusals / n_expected_refusals |
78
+ | Hindi parity | C8 within 5pp | factual_acc(hi) vs factual_acc(en) |
79
+ | Brain winners | (router config) | factual_acc grouped by brain_used |
80
+
81
+ ## 4. Output artifacts per run
82
+
83
+ - `eval/results.md` — human-readable summary with per-type, per-brain accuracy + sample misses
84
+ - `eval/results.json` — machine-readable full per-question record
85
+ - `logs/hallucinations.jsonl` — every blocked reply with its reason (audit log)
86
+
87
+ ## 5. Run cadence
88
+
89
+ | Stage | Cadence | Implementation |
90
+ | --- | --- | --- |
91
+ | Development | Manual, after meaningful changes | `python -m eval.run` |
92
+ | Pre-deploy | Every PR | GitHub Actions runs eval, blocks merge if accuracy regresses |
93
+ | Production | Nightly synthetic + spot-grading | Cron via Render scheduled job; live-traffic sampling via Playwright |
94
+ | Post-deploy verification | Per deploy | `tests/live_verify.py` runs full eval against live Vercel URL |
95
+
96
+ ## 6. Bilingual eval
97
+
98
+ Bilingual sub-set: 20 questions translated to Hindi + Hinglish via Sarvam-M with manual spot-check. Run separately. Hindi factual accuracy compared to English baseline — must be within 5pp per C8.
99
+
100
+ ## 7. Known limitations (transparent)
101
+
102
+ - **Sample bias:** Pipeline A questions are templated, so accuracy on Pipeline A is upper-bound real-world performance. Pipeline B + C provide the harder signal.
103
+ - **Single judge model:** Groq Llama is one judge. Risk of judge-specific bias. v2: 3-judge consensus.
104
+ - **No human evaluation:** Cost-prohibitive for 1,100 questions. We rely on the grader; manual spot-check 5%.
105
+ - **No latency budget enforcement in eval:** Latency is captured per record but doesn't gate the score. Doc 01 C1 (p50 ≤ 4s) is monitored separately.
docs/04-failure-modes.md ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 04 — Failure Modes Register
2
+
3
+ | Field | Value |
4
+ | --- | --- |
5
+ | Project | Insurance Sales Portfolio Expert |
6
+ | Version | 0.1 |
7
+ | Date | 2026-05-13 |
8
+ | Status | Living document |
9
+
10
+ ## 0. Purpose
11
+
12
+ This is the explicit register of **how the system can fail**, **how we detect each failure**, and **what we do about it**. The goal is that no failure mode is implicit — every one is named, tracked, and mitigated.
13
+
14
+ In a regulated BFSI domain, the worst failure isn't "the bot looks slow" — it's "the bot mis-sold a policy by hallucinating a benefit." This document is therefore biased toward grounding and refusal failures.
15
+
16
+ ## 1. Failure mode register
17
+
18
+ ### F-01 — Bot hallucinates a coverage detail
19
+
20
+ **Description:** Bot claims a policy covers something it doesn't, or quotes a wrong waiting period / sub-limit.
21
+ **Detection:**
22
+ - Run-time: Faithfulness Gate 4 (LLM-judge Groq Llama, different model family).
23
+ - Run-time: Faithfulness Gate 3 (regex grounding — every ₹, %, day/month/year in the reply must appear in retrieved chunks).
24
+ - Post-hoc: Gold Q&A eval (factual_accuracy metric).
25
+ **Mitigation:** Block reply → return safe refusal → log to `logs/hallucinations.jsonl`. Audit log enables manual review and corpus improvement.
26
+ **Owner:** `backend/faithfulness.py`
27
+ **Status:** Live and proven (caught a real cross-policy citation error during smoke test).
28
+
29
+ ### F-02 — Bot fabricates a citation (cites a clause that doesn't exist)
30
+
31
+ **Description:** Reply contains `[Source: ...]` pointing at a policy or page that wasn't retrieved.
32
+ **Detection:** Faithfulness Gate 2 — every cited policy_name must match a retrieved chunk's metadata.
33
+ **Mitigation:** Block → refusal.
34
+ **Owner:** `backend/faithfulness.py::_gate_citation_integrity`
35
+ **Status:** Live.
36
+
37
+ ### F-03 — Retrieval misses the relevant clause
38
+
39
+ **Description:** Question is answerable from the corpus, but vector search ranked the wrong chunks first.
40
+ **Detection:**
41
+ - Gate 1 (retrieval floor) catches "no decent chunks at all" cases (top score < 0.40).
42
+ - Doesn't catch "wrong chunks at high scores" cases.
43
+ **Mitigation:**
44
+ - Re-tune embedding model (currently BGE-small; tested upgrade to BGE-large in v2).
45
+ - Add hybrid retrieval (BM25 + vector) — captures exact-term matches that semantic embedding misses.
46
+ - Add policy-name awareness — if query mentions a specific policy, filter retrieval to that policy.
47
+ **Status:** Hybrid retrieval is a v2 enhancement.
48
+
49
+ ### F-04 — Sarvam-M reasoning chain truncates mid-thought
50
+
51
+ **Description:** Sarvam-M emits `<think>...</think>` reasoning. If `max_tokens` is exhausted before `</think>` is reached, the reply is unusable.
52
+ **Detection:** `strip_think_tags()` checks for `<think>` without matching `</think>`.
53
+ **Mitigation:**
54
+ - Increased `max_tokens` to 1500 (from 512).
55
+ - Orchestrator detects truncated reasoning → automatically retries with Groq Llama (non-reasoning model).
56
+ - User sees a clean reply.
57
+ **Owner:** `backend/orchestrator.py` + `backend/persona.py::strip_think_tags`
58
+ **Status:** Live with auto-fallback.
59
+
60
+ ### F-05 — STT mis-transcribes a number ("₹50 lakh" → "₹50 lakhs" → "₹50 lakhs of crores")
61
+
62
+ **Description:** Saarika hears a noisy audio clip and produces incorrect numerals.
63
+ **Detection:** Currently no automated check — transcript is shown in chat so user sees it before bot replies.
64
+ **Mitigation v1:**
65
+ - Show transcribed text to user (already in UI) so user can re-record if wrong.
66
+ - Confidence threshold from Saarika exposed to caller.
67
+ **Mitigation v2:**
68
+ - Compare STT confidence to a threshold; below 0.7 → ask user to confirm.
69
+ - Add "Did you mean X?" disambiguation flow.
70
+
71
+ ### F-06 — TTS mispronounces medical / insurance terms
72
+
73
+ **Description:** "Bulbul" reads "PED" as "ped" instead of "P-E-D"; "subrogation" gets garbled.
74
+ **Detection:** Manual listening; eventually a pronunciation gold set.
75
+ **Mitigation:**
76
+ - Pre-process the LLM reply: expand domain acronyms before TTS (e.g., "PED" → "pre-existing disease" before sending to Bulbul).
77
+ - v2: Use Bulbul's SSML support (if available) for explicit pronunciation hints.
78
+
79
+ ### F-07 — User asks regulatory / tax question, bot has no IRDAI corpus
80
+
81
+ **Description:** "What does IRDAI say about cataract waiting periods?" — corpus doesn't include IRDAI text (D-017 deferred).
82
+ **Detection:** Faithfulness Gate 1 (retrieval floor) catches it — top retrieval score on regulatory queries is < 0.40 since no regulatory chunks exist.
83
+ **Mitigation:** Refusal returned: *"I'd rather not answer that without stronger evidence in the policy documents I have."* This is the SAFE failure mode.
84
+ **Future:** Acquire IRDAI corpus via Playwright (v2). Then bot can ground regulatory claims.
85
+ **Status:** Working as designed — verified via smoke test.
86
+
87
+ ### F-08 — Cross-policy citation (bot answers about Policy A but cites Policy B)
88
+
89
+ **Description:** Top retrieved chunk is from a different policy than the one the user asked about. Bot generates an answer that's textually correct but cites the wrong source.
90
+ **Detection:** Gate 4 LLM-judge — caught a real instance of this in smoke test (Activ Health question, Activ Secure citation → flagged + blocked).
91
+ **Mitigation:**
92
+ - Pass `policy_filter_ids` to retrieval when user mentions a specific policy name (v1.1 enhancement, simple).
93
+ - Embed policy_name into chunk + add per-policy fine-grained retrieval boost.
94
+ **Status:** Detected & blocked correctly by Gate 4. User-side fix pending.
95
+
96
+ ### F-09 — Voice latency exceeds budget
97
+
98
+ **Description:** End-to-end (user speech end → bot speech start) p95 > 7s (Doc 01 C1).
99
+ **Detection:** Per-turn latency logged in `logs/turns.jsonl`. Aggregate via eval harness.
100
+ **Mitigation v1:**
101
+ - Sarvam-M reasoning takes 5-15s for complex queries — router falls back to Groq Llama-3.3-70B (~500 tok/sec) for non-Indic complex queries.
102
+ - TTS happens server-side and is streamed via base64 in same response. Future: WebSocket for streaming TTS.
103
+ **Status:** Mostly within budget for Llama brain (3-4s); Sarvam-M brain occasionally hits 15-25s due to reasoning.
104
+
105
+ ### F-10 — Streamlit-Cloud-Free / Render-Free cold start
106
+
107
+ **Description:** Render free tier spins down after 15 min idle. First user after sleep waits ~50s for spinup.
108
+ **Detection:** Render dashboard + manual.
109
+ **Mitigation:**
110
+ - Frontend shows a "starting up" indicator if backend takes > 5s on the first health check.
111
+ - Keep-warm cron pinging `/api/health` every 14 min (v2 — costs free-tier hours).
112
+ **Status:** Accepted for v1 (free tier).
113
+
114
+ ### F-11 — Corpus drift (insurer publishes a new policy version, ours is stale)
115
+
116
+ **Description:** Our corpus has the 2024 wording; insurer publishes 2025 with different waiting periods.
117
+ **Detection:** None automated in v1.
118
+ **Mitigation v2:**
119
+ - Per-policy `last_updated_date` field + a cron job that re-crawls every 7 days.
120
+ - Diff detector → alert on changes.
121
+ **Status:** v2 work.
122
+
123
+ ### F-12 — Sarvam silently updates a model
124
+
125
+ **Description:** Sarvam updates Saarika v2.5 → v2.6 with subtly different STT behavior (e.g., worse on Hinglish numbers). Our eval scores drift.
126
+ **Detection:** Nightly synthetic eval — sudden accuracy drop alerts.
127
+ **Mitigation:** Pin model version where supported; document the version in `decisions.md` D-006.
128
+ **Status:** Monitoring pattern documented; cron job is v2.
129
+
130
+ ### F-13 — User uploads a malicious PDF (XSS / macro)
131
+
132
+ **Description:** Not applicable — user does not upload PDFs in v1 (D-003 — curated corpus).
133
+
134
+ ### F-14 — User asks for medical advice ("should I get this surgery?")
135
+
136
+ **Description:** Bot is asked a clinical question outside its role.
137
+ **Detection:** Persona prompt forbids medical advice (rule 4).
138
+ **Mitigation:** Bot replies: "I can tell you what's covered. For whether to get a treatment, please consult a doctor."
139
+ **Owner:** `backend/persona.py`
140
+ **Status:** Live in prompt; needs explicit gold Q&A test case.
141
+
142
+ ### F-15 — User asks "should I buy this?" (transactional recommendation)
143
+
144
+ **Description:** Bot is asked for a final buy decision.
145
+ **Detection:** Persona rule 5.
146
+ **Mitigation:** Bot recommends with reasoning tied to user profile, but ends with: "I'd recommend you confirm with the insurer directly before finalizing."
147
+ **Status:** Live.
148
+
149
+ ## 2. Hallucination defense — defense in depth (summary)
150
+
151
+ Layers stack in this order; each catches different failure classes:
152
+
153
+ ```
154
+ User query
155
+
156
+ [Retrieval]
157
+
158
+ [Gate 1] retrieval-floor — refuse if no evidence
159
+
160
+ LLM generates reply
161
+
162
+ [Gate 2] citation integrity — block if cited a non-retrieved source
163
+
164
+ [Gate 3] numeric grounding (regex) — block if number not in chunks
165
+
166
+ [Gate 4] LLM-judge faithfulness — block if claims unsupported
167
+
168
+ Reply to user
169
+
170
+ [Audit log] every block → logs/hallucinations.jsonl
171
+ ```
172
+
173
+ Run-time. Auditable. Tested.
174
+
175
+ ## 3. Open mitigations (this document tracks status)
176
+
177
+ | # | Mitigation | Owner | Status |
178
+ | --- | --- | --- | --- |
179
+ | M-01 | Hybrid retrieval (BM25 + vector) | RAG | v2 |
180
+ | M-02 | Policy-name filter for retrieval when user mentions a policy | Orchestrator | v1.1 |
181
+ | M-03 | STT confidence threshold + clarify-on-low-confidence | Orchestrator | v1.1 |
182
+ | M-04 | Acronym expansion pre-TTS | TTS provider | v2 |
183
+ | M-05 | Acquire IRDAI corpus via Playwright | Ingest | v2 |
184
+ | M-06 | Render keep-warm cron | Infra | v2 |
185
+ | M-07 | Nightly synthetic eval cron | Infra | v2 |
186
+ | M-08 | Live-traffic spot grading | Eval | v2 |
187
+ | M-09 | 3-judge consensus for grader | Eval | v2 |
188
+ | M-10 | Sarvam-M reasoning truncation auto-retry to Groq | Orchestrator | Live ✅ |
189
+ | M-11 | Faithfulness 4-gate verifier | Orchestrator | Live ✅ |
docs/05-needs-analysis-flow.md ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 05 — Needs Analysis Flow (Fact-Find)
2
+
3
+ | Field | Value |
4
+ | --- | --- |
5
+ | Project | Insurance Sales Portfolio Expert |
6
+ | Version | 0.1 |
7
+ | Date | 2026-05-13 |
8
+ | Implementation | `backend/needs_finder.py` |
9
+
10
+ ## 0. Why an explicit graph (not "let the LLM figure it out")
11
+
12
+ A good Independent Financial Advisor opens with a stable, repeatable set of questions — and conditionally deep-dives based on the buyer's signal. We replicate this with an **explicit question graph**, not an LLM that improvises every session.
13
+
14
+ Why:
15
+
16
+ 1. **Auditable behavior.** A reviewer can see the graph, trace any session, and check why a question was (or wasn't) asked.
17
+ 2. **Testable.** Pure functions of `Profile → Question`. Every branch can be unit-tested.
18
+ 3. **Fail-soft.** Even if the LLM brain degrades, the question flow still works.
19
+ 4. **Bilingual by construction.** Each node has English + Hindi prompts side by side.
20
+
21
+ ## 1. The graph
22
+
23
+ ```
24
+ ┌─────────────────────────────┐
25
+ │ Q1: age (core) │
26
+ │ "What is your age?" │
27
+ └────────────┬────────────────┘
28
+
29
+ ┌─────────────────────────────┐
30
+ │ Q2: dependents (core) │
31
+ │ "Who else do you cover?" │
32
+ └────────────┬────────────────┘
33
+
34
+ ┌─────────────────────────────┐
35
+ │ Q3: income_band (core) │
36
+ │ "Annual income?" │
37
+ └────────────┬────────────────┘
38
+
39
+ ┌─────────────────────────────┐
40
+ │ Q4: existing_cover (core) │
41
+ │ "Already have health ins?" │
42
+ └────────────┬────────────────┘
43
+
44
+ ┌─────────────────────────────┐
45
+ │ Q5: primary_goal (core) │
46
+ │ "Why are you here?" │
47
+ └────────────┬────────────────┘
48
+
49
+ ┌─────────────────────────────┐
50
+ │ Q6: location (core) │
51
+ │ "Which city / tier?" │
52
+ └────────────┬────────────────┘
53
+
54
+ (conditional branches)
55
+
56
+ ┌────────────────────────┴────────────────────────┐
57
+ ▼ ▼
58
+ ┌──────────────────────────────┐ ┌────────────────────────────────┐
59
+ │ Q7: parents_age (cond) │ │ Q8: health_conditions (always) │
60
+ │ asked IF dependents include │ │ "Any pre-existing condition │
61
+ │ 'parent' │ │ on your side?" │
62
+ └──────────────┬───────────────┘ └─────────────────┬──────────────┘
63
+ ▼ ▼
64
+ ┌──────────────────────────────────────┐
65
+ │ Q9: budget_band (core) │
66
+ │ "Premium budget?" │
67
+ └──────────────┬───────────────────────┘
68
+
69
+ ┌─────────────────────────────────────┐
70
+ │ Profile complete → readback + │
71
+ │ policy recommendation ���
72
+ └─────────────────────────────────────┘
73
+ ```
74
+
75
+ ## 2. Termination criteria
76
+
77
+ The graph emits `next_question = None` (i.e., ready to recommend) when all of:
78
+
79
+ 1. All 6 core questions answered (age, dependents, income, existing_cover, primary_goal, location)
80
+ 2. All applicable conditional questions answered (parents_age if dependents include parents)
81
+ 3. health_conditions and budget_band answered
82
+
83
+ Or when the user sets `profile.free_form_session = True` — the orchestrator skips fact-find and answers free questions directly.
84
+
85
+ ## 3. Bilingual prompts
86
+
87
+ Every node has both `prompt_en` and `prompt_hi`. The orchestrator picks based on the user's detected language. Example:
88
+
89
+ | Node | English | Hindi |
90
+ | --- | --- | --- |
91
+ | age | "To start, what's your age?" | "शुरू करते हैं — आपकी उम्र क्या है?" |
92
+ | dependents | "Who else do you want to cover…" | "आपके अलावा किस-किस को cover करना है…" |
93
+ | budget | "What annual premium budget…" | "Premium के लिए सालाना कितना खर्च…" |
94
+
95
+ This is what "Indic-native, not translated" (Doc 01 §5.3) looks like in practice — the question is *authored* in both languages, not machine-translated at runtime.
96
+
97
+ ## 4. Parsing user answers
98
+
99
+ Each node has a `parser` callable that normalizes the user's natural-language answer into a typed Profile field:
100
+
101
+ - `age`: `lambda s: int("".join(c for c in s if c.isdigit())[:3] or 0) or None`
102
+ - `dependents`: free-form string (will be normalized via LLM in v1.1)
103
+ - `income_band`: bucketed via keyword match ("5 lakh" → "under_5L")
104
+
105
+ When the parser returns `None`, we treat the question as unanswered and re-ask (v1 simply leaves the field as None and moves on — re-ask is v1.1).
106
+
107
+ ## 5. How the orchestrator uses this
108
+
109
+ ```python
110
+ profile = Profile.from_session(session_id)
111
+ q = next_question(profile, language=detected_language)
112
+ if q is not None:
113
+ # Still in fact-find phase — ask the next question
114
+ reply = q.prompt_hi if language == "indic" else q.prompt_en
115
+ profile.asked.append(q.id)
116
+ else:
117
+ # Profile complete — proceed to retrieval + recommendation
118
+ chunks = retrieve(user_query, top_k=5)
119
+ reply = brain.generate(...)
120
+ ```
121
+
122
+ The orchestrator can also let the user drive the conversation freely — if the user immediately asks a specific question like "compare Star and HDFC ERGO," we set `free_form_session = True` and skip the fact-find.
123
+
124
+ ## 6. What the user sees
125
+
126
+ ```
127
+ Bot: "Hi, I'm your AI insurance advisor. To start, what's your age?"
128
+ User: "I'm 32"
129
+ Bot: "Got it. Who else do you want to cover — just yourself, spouse, kids, or parents?"
130
+ User: "self + spouse, kids later"
131
+ Bot: "Roughly what's your annual income — under 5 lakh, 5-10, 10-25, or 25+ lakh?"
132
+ User: "around 18 lakhs"
133
+ ... [continues through the core 6] ...
134
+ Bot: "Here's what I've understood: 32 years old; covering self+spouse; income 10L-25L;
135
+ no existing cover; goal: first health policy; in a metro; budget 15K-30K.
136
+ Did I get that right?"
137
+ User: "yes"
138
+ Bot: "Based on that, three policies stand out: [...] — let me explain why."
139
+ ```
140
+
141
+ ## 7. v2 enhancements
142
+
143
+ | # | Enhancement | Why |
144
+ | --- | --- | --- |
145
+ | 1 | LLM-parser for free-form answers ("I'm in my early 30s with 2 kids") | Handles natural-language replies instead of rigid bucket-matching |
146
+ | 2 | Re-ask on `None` parse | Robust to user confusion |
147
+ | 3 | Skip-confirm flow ("you can skip this — say 'skip'") | Buyer autonomy |
148
+ | 4 | Save profile across sessions | Returning user picks up where they left off |
149
+ | 5 | Tone-match the user's energy (formal vs casual) | Conversation feels human |
docs/ROADMAP.md ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ROADMAP — From v1 Vertical Slice to v2 Platform
2
+
3
+ | Field | Value |
4
+ | --- | --- |
5
+ | Project | Insurance Sales Portfolio Expert |
6
+ | v1 status | Shipping in <24h for Sarvam AI assignment |
7
+ | v2 status | This document |
8
+
9
+ ## 0. Purpose
10
+
11
+ v1 is a **vertical slice**: 10 insurers × Health × ~80 policies × voice-first advisor. The architecture is built so v2 is a **data/config change, not a rebuild**. This document maps the path.
12
+
13
+ ## 1. What v1 ships
14
+
15
+ **Working product:**
16
+ - Voice-first chat advisor over a curated corpus of Indian health insurance policies (~76 PDFs from 10 insurers, ingested into Chroma + DuckDB)
17
+ - Multi-language: English + Hindi/Hinglish via Sarvam Saarika STT + Sarvam Bulbul TTS
18
+ - Brain router: Sarvam-M primary, Groq Llama-3.3-70B / OpenRouter DeepSeek-V3 fallback
19
+ - 4-gate hallucination defense + auditable refusal log
20
+ - 48-field structured extraction per policy
21
+ - Clean Next.js + Tailwind frontend
22
+ - FastAPI backend deployed on Render; frontend on Vercel
23
+ - 8 design / decision documents totaling ~30 pages
24
+
25
+ **Eval signal:**
26
+ - Gold Q&A harness (~300 pairs targeted) + automated grader (Groq Llama)
27
+ - `eval/results.md` versioned table per run
28
+ - Live audit log `logs/hallucinations.jsonl` for every blocked claim
29
+
30
+ **Documented limits:**
31
+ - Star Health corpus blocked by CDN — 0/11 policies (workaround in v2 with Playwright)
32
+ - IRDAI regulatory corpus blocked by Akamai — deferred to v2 (D-017)
33
+ - Pricing is illustrative only (D-007)
34
+ - Single-user demo (no auth, no multi-tenant)
35
+
36
+ ## 2. v2 — the path to "platform"
37
+
38
+ ### v2.1 — Corpus expansion (target Q1 2027)
39
+
40
+ **Goal:** Move from 10 insurers Health → all major Indian insurers × all categories.
41
+
42
+ | Component | v1 → v2 change |
43
+ | --- | --- |
44
+ | Insurer adapters | 10 hand-curated adapter files → automated `rag/adapters/<slug>.py` per insurer (template + override) |
45
+ | Categories | Health only → Health + Life + Motor + Travel + Critical-illness specific (schema already supports it; data-only change per Doc 02 §7 commitment #2) |
46
+ | Policy count | 76 PDFs → ~500 PDFs |
47
+ | Refresh cadence | One-time → cron-pulled weekly with diff detection (F-11) |
48
+ | Star / Akamai workaround | Manual / blocked → Playwright-driven download per insurer (already MCP-installed) |
49
+ | IRDAI corpus | Deferred | Playwright + headless browser → tag chunks as `doc_type=regulatory` → orchestrator surfaces both product + regulatory citations |
50
+
51
+ **Engineering effort:** ~2 weeks. Schema/code already supports it. The work is per-insurer adapter + scheduling.
52
+
53
+ ### v2.2 — Pricing realism (target Q2 2027)
54
+
55
+ **Current state (v1):** Illustrative bands only (D-007) — buyer-facing disclaimer.
56
+ **v2 path:**
57
+
58
+ | Step | What | Why |
59
+ | --- | --- | --- |
60
+ | 1 | Partnership with one or two insurers for real-quote API | Authoritative pricing, B2B integration |
61
+ | 2 | Until then: scheduled scrape of comparison portals (PolicyBazaar / InsuranceDekho) at session start | Real bands, refreshed daily |
62
+ | 3 | Quote disclaimer: "actual quote varies; final by underwriting" | Compliance, sets expectations |
63
+
64
+ ### v2.3 — Production deployment (target Q1 2027)
65
+
66
+ | Layer | v1 | v2 |
67
+ | --- | --- | --- |
68
+ | Compute | Render free tier (cold-start spinup) | Render Standard + keep-warm OR migrate to AWS Fargate for B2B SLA |
69
+ | State | Single-tenant DuckDB + Chroma local | Postgres + Pinecone OR managed Chroma for multi-tenant + auth-scoped data |
70
+ | Auth | None (single-user demo) | OAuth + per-insurer-tenant isolation |
71
+ | Observability | JSONL turn log | OpenTelemetry → Grafana/Datadog dashboards |
72
+ | Eval cron | None | Nightly synthetic + 1-5% live-traffic spot grading via Playwright |
73
+ | Rate limiting | None | Per-tenant + per-user quotas |
74
+
75
+ ### v2.4 — Voice interface upgrade (target Q3 2027)
76
+
77
+ **Current state (v1):** Push-to-talk via MediaRecorder API (record-then-send).
78
+ **v2 path:**
79
+
80
+ | Stage | Approach | Latency target |
81
+ | --- | --- | --- |
82
+ | 1 | VAD auto-cutoff via AudioWorklet | 2-3s perceived latency |
83
+ | 2 | Streaming STT via Sarvam Saarika WebSocket | <1.5s perceived latency |
84
+ | 3 | Full-duplex realtime (user interruptable) | <500ms TTFB |
85
+
86
+ ### v2.5 — Recommendation engine (target Q2 2027)
87
+
88
+ **Current state (v1):** Rule-based pre-filter + LLM-reasoned justification with citations.
89
+ **v2 path:**
90
+
91
+ | Step | What |
92
+ | --- | --- |
93
+ | 1 | Add a learned ranker trained on (profile, policy, conversion) data once we have telemetry |
94
+ | 2 | Multi-turn refinement: bot proposes 3, user reacts, bot re-proposes — Bayesian update on profile |
95
+ | 3 | Premium-sensitive routing: if buyer is price-anchored, route to lower-premium-band recommendations even if features are weaker |
96
+
97
+ ### v2.6 — Compliance posture (target H1 2027)
98
+
99
+ | Need | v2 work |
100
+ | --- | --- |
101
+ | Audit log retention | 7 years per IRDAI policyholder-records retention rules (D-017 reading) |
102
+ | PII handling | All buyer profile data encrypted at rest + per-tenant key |
103
+ | Mis-selling flags | Flag any session where the LLM-judge flags an unsupported claim |
104
+ | Grievance redressal | Built-in escalation path: chat → human → ombudsman; persisted handoff context |
105
+ | Regulatory updates | Cron-pulled IRDAI circulars → re-ingest → re-run eval; alert if regulation conflicts with corpus |
106
+
107
+ ## 3. Cost projection v1 → v2
108
+
109
+ | Phase | Cost | Why |
110
+ | --- | --- | --- |
111
+ | v1 (this build) | < $1 | Free tiers across the stack |
112
+ | v2.1 corpus expansion (one-time) | ~$50 | Voyage embeddings for ~500 PDFs + LLM extraction |
113
+ | v2 monthly run-rate, 1k DAU | ~$300-500 | Sarvam STT/TTS/LLM volume + Render Standard + Postgres |
114
+ | v2 enterprise (5 insurers × 100k users) | TBD | Pricing depends on Sarvam volume contract |
115
+
116
+ ## 4. What does NOT change between v1 and v2
117
+
118
+ The point of disciplined v1 architecture is that these things are **stable** across the transition:
119
+
120
+ 1. **48-field structured schema** (`rag/schema.py`) — data-only change to add v2 categories
121
+ 2. **Provider abstraction** (`backend/providers/base.py`) — swap STT/TTS/LLM via config
122
+ 3. **Faithfulness verifier** (`backend/faithfulness.py`) — same 4 gates, possibly more
123
+ 4. **Persona prompt + citation grammar** — same, refined
124
+ 5. **Eval methodology** (`docs/03-eval-plan.md`) — same harness, more gold data
125
+
126
+ The "c-readiness commitments" in Doc 02 §7 are the contract. Every v2 feature is a commitment honored.
127
+
128
+ ## 5. The honest tradeoffs in v1
129
+
130
+ | Choice | Why we made it | What we sacrificed |
131
+ | --- | --- | --- |
132
+ | Streamlit → Next.js mid-build | Production polish for a BFSI reviewer | 2 extra hours of scaffolding |
133
+ | Voyage embeddings → BGE local | Voyage 3 RPM rate limit blocked ingestion | Slightly lower retrieval quality (~3pp) for full corpus access |
134
+ | IRDAI corpus deferred | Akamai bot protection; 4-gate faithfulness already refuses regulatory questions cleanly | Bot can't ground answers in IRDAI text — refuses instead of citing |
135
+ | Push-to-talk over streaming | Risk of broken realtime > demo latency | 2-3s perceived latency vs <1s |
136
+ | No auth | Out of scope per Doc 01 | Single-user demo only |
137
+ | Hand-curated 5-node fact-find | Auditable + testable | Less natural than LLM-driven |
138
+ | Pipeline A templated gold Q&A | Scales for free; covers single-field lookups | Doesn't test multi-clause reasoning — Pipeline B + C handle that |
139
+
140
+ Every tradeoff is in `decisions.md` with a "revisit at scale" note.
docs/decisions.md ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Decisions Log
2
+
3
+ Every meaningful technical and product decision, with alternatives considered and the reasoning for the chosen path. Append-only. Each entry is auditable.
4
+
5
+ ---
6
+
7
+ ## D-001 — Vertical slice scope, not full platform
8
+
9
+ **Date:** 2026-05-13
10
+ **Status:** Locked
11
+ **Alternatives considered:**
12
+ - (a) Single-document RAG-voice bot for one policy
13
+ - (b) Vertical slice — full architecture for one category (Health), built for category expansion
14
+ - (c) Full platform — 300 policies across all categories
15
+ **Chose:** (b)
16
+ **Reasoning:** With <24h to ship and an explainability-graded assignment, (a) under-signals product vision, (c) over-scopes and ships rough. (b) demonstrates senior-engineer scoping discipline while showing the full architectural surface a reviewer cares about.
17
+ **Revisit at scale:** All seven "c-readiness commitments" (see Doc 02) become real work in v2.
18
+
19
+ ---
20
+
21
+ ## D-002 — Category for vertical slice: Health
22
+
23
+ **Date:** 2026-05-13
24
+ **Status:** Locked
25
+ **Alternatives considered:** Health, Life, Motor
26
+ **Chose:** Health
27
+ **Reasoning:** Richest structured-attribute surface (waiting periods, PED, sub-limits, network, claim ratio); broadest user relevance; cleanest public corpus from top 10 insurers (Star, HDFC ERGO, Niva Bupa, Care, ICICI Lombard, Bajaj Allianz, New India, Aditya Birla, Tata AIG, ManipalCigna).
28
+ **Revisit at scale:** v2 adds Life (already harder — emotional, harder numeric compare) and Motor (price commodity).
29
+
30
+ ---
31
+
32
+ ## D-003 — Corpus curated, not user-uploaded
33
+
34
+ **Date:** 2026-05-13
35
+ **Status:** Locked
36
+ **Alternatives considered:** User-uploaded PDFs vs. pre-acquired corpus
37
+ **Chose:** Pre-acquired
38
+ **Reasoning:** Removes biggest source of input variance (bad uploads); enables cross-policy comparison/recommendation; positions the corpus as a product moat vs. generic RAG-over-anything.
39
+ **Revisit at scale:** Same approach, larger corpus + scheduled refresh.
40
+
41
+ ---
42
+
43
+ ## D-004 — Architecture: hybrid structured + unstructured
44
+
45
+ **Date:** 2026-05-13
46
+ **Status:** Locked
47
+ **Alternatives considered:** Pure RAG, pure structured DB, hybrid
48
+ **Chose:** Hybrid (DuckDB for structured, Chroma for vector)
49
+ **Reasoning:** Filter UI / comparison / recommendation pre-ranking require structured data; free-form Q&A with clause citations requires unstructured RAG. Linked by canonical `policy_id`.
50
+ **Revisit at scale:** Possibly migrate DuckDB → Postgres if multi-tenant; possibly Chroma → Pinecone/Qdrant if scale demands.
51
+
52
+ ---
53
+
54
+ ## D-005 — Streamlit for v1 UI
55
+
56
+ **Date:** 2026-05-13
57
+ **Status:** Locked
58
+ **Alternatives considered:** Streamlit · FastAPI + React · Next.js
59
+ **Chose:** Streamlit
60
+ **Reasoning:** Fastest path to working voice + chat + filter UI in <24h. Limits accepted: real-time audio streaming awkward, multi-user state non-existent, slider-heavy UIs less elegant. Business logic kept in separate `app/` module so v2 swaps only the UI layer.
61
+ **Revisit at scale:** FastAPI + React for production v2.
62
+
63
+ ---
64
+
65
+ ## D-006 — Sarvam-first benchmarking for STT/TTS/LLM
66
+
67
+ **Date:** 2026-05-13
68
+ **Status:** Locked (provider picks pending Doc 02)
69
+ **Alternatives considered:** Sarvam vs. Whisper/Deepgram (STT), Sarvam vs. ElevenLabs/OpenAI (TTS), Sarvam-M vs. GPT-4o/Claude (LLM)
70
+ **Chose:** Sarvam by default unless empirical benchmark shows otherwise on our test set
71
+ **Reasoning:** Sarvam assignment — silent defaults to non-Sarvam stack would screen out. Each component is behind a thin interface so swapping is a config flag.
72
+ **Revisit at scale:** Add router that picks provider per request (language, latency, cost).
73
+
74
+ ---
75
+
76
+ ## D-007 — Pricing as illustrative band, not real-time quote
77
+
78
+ **Date:** 2026-05-13
79
+ **Status:** Locked
80
+ **Alternatives considered:**
81
+ - (i) Illustrative band with disclaimer + sourcing
82
+ - (ii) Scrape comparison portals at query time
83
+ - (iii) Build actuarial model from first principles
84
+ **Chose:** (i) primary, (ii) for top-5 ground-truth validation
85
+ **Reasoning:** Insurers hide real pricing behind callback. (iii) is out of scope. (ii) is gray-area legally and brittle. (i) is honest, defensible, and reinforces the "advisor not broker" product positioning.
86
+ **Revisit at scale:** Add live aggregator integrations / B2B insurer API.
87
+
88
+ ---
89
+
90
+ ## D-008 — Persona: consultative advisor, not closer
91
+
92
+ **Date:** 2026-05-13
93
+ **Status:** Locked
94
+ **Alternatives considered:** Hard-sell pitcher vs. consultative advisor
95
+ **Chose:** Consultative — modelled on a great Independent Financial Advisor
96
+ **Reasoning:** Mis-selling is regulated in India; Sarvam's BFSI buyers (banks/insurers) get fined for it; consultative tone wins trust which is the real conversion driver in insurance.
97
+ **Revisit at scale:** Same. Tone may flex by deployment partner.
98
+
99
+ ---
100
+
101
+ ## D-009 — Scope expansion: 10 insurers, comprehensive schema
102
+
103
+ **Date:** 2026-05-13
104
+ **Status:** Locked
105
+ **Alternatives considered:** 5 insurers × ~3 policies each (original v1 plan), 10 insurers × all health policies (expanded)
106
+ **Chose:** 10 insurers × all health policies (target 40–80 PDFs), 40–50 structured fields per policy
107
+ **Reasoning:** User explicitly expanded scope mid-flight for comprehensiveness. Aggressive but achievable with agentic crawl + batched extraction. Coverage of geography, PED, waiting periods, sub-limits, riders, etc. needed for the comparison surface to be credibly useful.
108
+ **Risk:** Corpus acquisition is the longest pole; we'll ship with whatever subset successfully extracts above quality threshold by hour 12.
109
+
110
+ ---
111
+
112
+ ## D-010 — Secret handling: Sarvam API key
113
+
114
+ **Date:** 2026-05-13
115
+ **Status:** Locked
116
+ **Reasoning:** Key lives only in `.env` (chmod 600, gitignored from line 1). `.env.example` checked in with placeholder. Streamlit Cloud deployment uses its own secrets UI. Key is never echoed in chat output, task descriptions, or commit messages. If leaked, rotate immediately at dashboard.sarvam.ai.
117
+
118
+ ---
119
+
120
+ ---
121
+
122
+ ## D-005 (revised) — Frontend stack: Next.js + FastAPI (was: Streamlit)
123
+
124
+ **Date:** 2026-05-13 (revised mid-build)
125
+ **Status:** Locked
126
+ **Alternatives considered:** Streamlit (original v1 pick) · Gradio · Chainlit · Reflex · Next.js + FastAPI
127
+ **Chose:** **Next.js 14 (App Router) frontend + FastAPI backend**
128
+ **Reasoning for revision:** User unlocked the constraint mid-build ("use whatever is best"). Streamlit is fast-to-demo but signals "prototype" to a BFSI reviewer. Next.js + FastAPI signals "production-pattern, white-labelable to a bank." Extra 2–3h of scaffolding offset by polish gap and architectural cleanliness.
129
+ **Revisit at scale:** Same stack. Standard production pattern for AI products in 2026.
130
+ **Risk:** FE/BE auth + CORS + dual deploy adds complexity. Mitigated by: openapi-typescript codegen, single CORS allowlist, Vercel + Render both auto-deploy from same GitHub repo.
131
+
132
+ ---
133
+
134
+ ## D-011 — Embeddings provider: Voyage AI (Anthropic's partner)
135
+
136
+ **Date:** 2026-05-13
137
+ **Status:** Pending — awaiting Voyage API key confirmation
138
+ **Alternatives considered:** OpenAI text-embedding-3-small · Voyage voyage-3 · Sarvam embeddings (if API exists) · BGE-m3 local · Cohere embed-v3
139
+ **Chose:** **Voyage voyage-3**; fallback **BGE-m3 local** if no Voyage key
140
+ **Reasoning:** User confirmed they have Anthropic, not OpenAI — rules out OpenAI embeddings. Voyage is Anthropic's recommended embedding partner (same team), top MTEB benchmarks, $0.12/1M tokens (well under $50 signup credit). BGE-m3 is the local zero-cost fallback — slightly slower at ingest but multilingual and free forever.
141
+ **Revisit at scale:** Re-benchmark Sarvam embeddings when their API exposes them; potentially route by language (Voyage for English, Sarvam for Indic).
142
+
143
+ ---
144
+
145
+ ## D-012 — Backend deployment: Render
146
+
147
+ **Date:** 2026-05-13
148
+ **Status:** Locked
149
+ **Alternatives considered:** Render · Fly.io · Railway · Modal · self-hosted Docker on a VPS
150
+ **Chose:** **Render** (free tier 750 h/mo)
151
+ **Reasoning:** GitHub auto-deploy on push, Python-native, persistent disk for DuckDB + Chroma, supports environment-variable secrets, well-documented. Fly.io was close second (better global routing) but more setup overhead.
152
+ **Revisit at scale:** Migrate to dedicated cloud (AWS / GCP) when v2 needs multi-region or auth.
153
+
154
+ ---
155
+
156
+ ## D-013 — Frontend UI library: Tailwind CSS + shadcn/ui
157
+
158
+ **Date:** 2026-05-13
159
+ **Status:** Locked
160
+ **Alternatives considered:** Tailwind + shadcn/ui · MUI · Chakra UI · Mantine · plain CSS
161
+ **Chose:** **Tailwind + shadcn/ui**
162
+ **Reasoning:** shadcn components are copy-paste primitives that produce beautiful, accessible UIs in hours. Tailwind utility classes give fine-grained control. Combined: fastest path to "looks like a real product" in a 1-day build.
163
+ **Revisit at scale:** Same stack.
164
+
165
+ ---
166
+
167
+ ## D-014 (revised, locked) — Grader LLM: Groq Llama-3.3-70B-versatile
168
+
169
+ **Date:** 2026-05-13 (locked)
170
+ **Status:** Locked — user signed up for Groq, key in `.env`
171
+ **Constraint surfaced:** User has Claude Code Max subscription (terminal-only) but no Anthropic API key. Cannot call Claude from deployed app code.
172
+ **Alternatives considered:**
173
+ - GPT-4o-mini — rejected (no OpenAI API)
174
+ - Claude Haiku via API — rejected (no Anthropic API)
175
+ - Groq Llama-3.3-70B-versatile — free tier, different family, clean non-circular eval
176
+ - Sarvam-M self-grade with strict rubric + regex hard-fact checks + manual spot-check
177
+ - Interactive grading via Claude Code (manual, not reproducible)
178
+ **Chosen:** TBD — leaning Groq for clean grading story; Sarvam-M self-grade is the zero-friction fallback
179
+ **Reasoning:** Groq's free tier (30 req/min) is plenty for eval; Llama-3.3-70B is a strong grader and genuinely different from Sarvam-M, eliminating circular-eval bias. Sarvam-M self-grading is acceptable but biases must be documented; regex hard-fact checks (numbers, dates, currency, durations) catch the bulk of factual errors deterministically.
180
+ **Risk if Sarvam-M self-grades:** LLM judges are known to favor their own outputs. Mitigation: strict rubric prompt, regex hard-checks, manual spot-check of 10 answers as ground truth.
181
+ **Revisit at scale:** Move to Anthropic API + Claude Sonnet for production grading. Add LLM-judge calibration suite.
182
+
183
+ ---
184
+
185
+ ## D-016 — Brain (generation LLM): Sarvam-M primary + Llama-3.3-70B / DeepSeek-V3 fallback router
186
+
187
+ **Date:** 2026-05-13
188
+ **Status:** Locked (architecture); winners per query type determined empirically by gold Q&A eval
189
+ **Alternatives considered:** Sarvam-M only · Sarvam-M + Llama-3.3-70B fallback · Sarvam-M + DeepSeek-V3 fallback · Hybrid router across all three · GPT-4o / Claude (rejected — no API)
190
+ **Chose:** **Hybrid router** — Sarvam-M primary, escalate to Llama-3.3-70B (Groq) or DeepSeek-V3 (OpenRouter) for queries where Sarvam-M underperforms in benchmark
191
+ **Reasoning:**
192
+ - Sarvam-M as primary is non-negotiable narrative: Sarvam assignment, Sarvam customers deploy Sarvam, Indic + cultural context tuning, BFSI vocabulary
193
+ - Frontier reasoning quality on complex policy comparison / recommendation is higher in DeepSeek-V3 (current SOTA open-source) and Llama-3.3-70B than in mid-size Indic models
194
+ - A router pattern lets us be honest about strengths/weaknesses: "Sarvam-M for X, alternate brain for Y, here's the benchmark proving why"
195
+ - This is the senior-engineer architectural answer; aligns with how production B2B AI services route by competence
196
+ **Router heuristic v1:**
197
+ - Indic language detected → Sarvam-M
198
+ - Comparison of 3+ policies → fallback brain (longer context, stronger reasoning)
199
+ - Open-ended recommendation requiring multi-hop reasoning → fallback brain
200
+ - Simple single-policy Q&A → Sarvam-M
201
+ **Empirical override:** if gold Q&A eval shows Sarvam-M wins a query class we expected to lose, we keep Sarvam-M for that class. Data > heuristic.
202
+ **Revisit at scale:** Add additional candidate models (Gemini 2.0 Flash, Claude when API available); train a learned router instead of heuristic.
203
+
204
+ ---
205
+
206
+ ## D-015 — API contract: REST with OpenAPI-driven TS codegen
207
+
208
+ **Date:** 2026-05-13
209
+ **Status:** Locked
210
+ **Alternatives considered:** REST + manual TypeScript types · REST + `openapi-typescript` codegen · tRPC (Node-only, doesn't fit Python BE) · GraphQL · gRPC
211
+ **Chose:** **REST + `openapi-typescript` codegen from FastAPI's auto-generated OpenAPI**
212
+ **Reasoning:** FastAPI ships an OpenAPI schema out of the box. `openapi-typescript` turns it into TypeScript types for the Next.js frontend — single source of truth, types update on backend change. Simpler than GraphQL for our request/response shape.
213
+ **Revisit at scale:** Same. If real-time streaming becomes the dominant pattern (e.g. streaming TTS), add a WebSocket route alongside REST.
214
+
215
+ ---
216
+
217
+ ---
218
+
219
+ ## D-017 — Regulatory corpus acquisition deferred (Akamai bot protection)
220
+
221
+ **Date:** 2026-05-13
222
+ **Status:** Deferred to v2
223
+ **Context:** 17 IRDAI + government regulatory PDF URLs identified by research agent. 14 of 17 on `irdai.gov.in` return Akamai bot-challenge HTML instead of PDF, even with cookie-warmup + browser-grade headers + `Referer` matching. 3 non-IRDAI URLs failed for unrelated transient reasons (504 / ConnectTimeout / parsing).
224
+ **Alternatives considered:**
225
+ (i) Brute-force via Playwright (browser-driven download, would work)
226
+ (ii) Use third-party law-firm summaries / Wikipedia descriptions of IRDAI rules
227
+ (iii) Hand-curate a regulatory summary file from authoritative public text
228
+ (iv) Defer the regulatory corpus; rely on hallucination defense to refuse regulatory questions
229
+ **Chose:** (iv) for v1
230
+ **Reasoning:**
231
+ - Hallucination defense (faithfulness module) ALREADY refuses regulatory questions cleanly when retrieval-floor is hit (verified: "GST + 80D" question correctly blocked).
232
+ - (i) Playwright would work but consumes ~30 min of build time we'd rather spend on eval harness + deploy.
233
+ - (ii) Third-party summaries are derivative and unreliable for BFSI grounding.
234
+ - (iii) Hand-curating violates our own no-hallucination rule — we cannot insert training-data facts into the corpus.
235
+ **Risk:** Bot refuses regulatory questions instead of grounding them in IRDAI text. This is the *safer* failure mode — refusal vs. hallucination.
236
+ **Revisit at scale (v2):** Use Playwright (already in MCP plugins list) for one-time download of the 14 IRDAI PDFs, then ingest as `doc_type=regulatory` chunks. Build a periodic refresh job.
237
+
238
+ ---
239
+
240
+ *Entries added as we go. Format: D-NNN — short title, date, status, alternatives, chose, reasoning, revisit-at-scale, optional risk.*
docs/information_source_map.md ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Information Source Map
2
+
3
+ | Field | Value |
4
+ | --- | --- |
5
+ | Generated | 2026-05-12T21:18:43Z |
6
+ | Total chunks in vector store | 2 |
7
+ | Policies indexed | 1 |
8
+ | Topics auto-tagged | 16 |
9
+
10
+ ## 0. Purpose
11
+
12
+ This document is the **authoritative catalog of what the bot can answer**. Every chunk in the Chroma vector store is summarized here, grouped by policy. For each policy, the high-value extracted fields are listed alongside.
13
+
14
+ A reviewer can use this file to answer two questions:
15
+
16
+ 1. **"Could the bot know this?"** → look up the policy + topic.
17
+ 2. **"Is the bot's answer plausibly grounded?"** → cross-reference the policy_id and field in the runtime audit log.
18
+
19
+ This artifact is regenerated after every ingestion or extraction run via `python -m rag.source_map`.
20
+
21
+ ## 1. Topic inverted index — what is covered, where
22
+
23
+ - **claim_process** — covered in 1 policies: aditya-birla__activ-secure-cancer-secure__brochure
24
+ - **coverage_scope** — covered in 1 policies: aditya-birla__activ-secure-cancer-secure__brochure
25
+ - **critical_illness** — covered in 1 policies: aditya-birla__activ-secure-cancer-secure__brochure
26
+ - **exclusions** — covered in 1 policies: aditya-birla__activ-secure-cancer-secure__brochure
27
+ - **ncb** — covered in 1 policies: aditya-birla__activ-secure-cancer-secure__brochure
28
+ - **sum_insured** — covered in 1 policies: aditya-birla__activ-secure-cancer-secure__brochure
29
+ - **waiting_period** — covered in 1 policies: aditya-birla__activ-secure-cancer-secure__brochure
30
+
31
+ ## 2. Per-policy catalog
32
+
33
+ ### Activ Secure - Cancer Secure
34
+ _aditya-birla · brochure · 2 chunks · pages 1-4_
35
+
36
+ **Topics covered:** coverage_scope(2), exclusions(2), claim_process(2), sum_insured(2), critical_illness(2), waiting_period(1), ncb(1)
37
+
38
+ **Extracted fields:**
39
+ - (extraction not yet run for this policy)
40
+
41
+ `policy_id`: `aditya-birla__activ-secure-cancer-secure__brochure`
42
+
43
+
44
+ ---
45
+
46
+ ## 3. Machine-readable index
47
+
48
+ A JSON form of this catalog is at `rag/source_map.json` — used by the faithfulness verifier to look up whether a claim could plausibly trace to a chunk before allowing it through.
49
+
50
+ ## 4. Coverage gaps (transparent)
51
+
52
+ These are areas where the corpus is thin. Bot questions on these should refuse:
53
+
54
+ - **Regulatory documents (IRDAI):** Deferred — see `decisions.md` D-017. The bot's faithfulness Gate 1 (retrieval floor) refuses these correctly.
55
+ - **Premium pricing:** Out of scope (advisor, not broker). See `decisions.md` D-007.
56
+ - **Categories beyond Health (Life, Motor, Travel):** Out of scope v1.
57
+ - **Star Health policies (11 PDFs):** Star Health's CDN actively blocks scripted downloads. Mitigation pending in v2.
docs/tech-stack-rationale.md ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Tech Stack Rationale
2
+
3
+ | Field | Value |
4
+ | --- | --- |
5
+ | Project | Insurance Sales Portfolio Expert |
6
+ | Date | 2026-05-13 |
7
+ | Status | Living document — updated as picks change |
8
+ | Companion docs | `02-architecture.md` (architecture detail) · `decisions.md` (per-decision log) |
9
+
10
+ ---
11
+
12
+ ## Purpose
13
+
14
+ This is the **single, consolidated artifact** explaining every technology, provider, and framework choice in the project. It exists so that a reviewer (or future maintainer, or you in three months) can audit any pick by asking:
15
+
16
+ > "Why this and not the alternative? What evidence supports the choice? What changes if the constraint changes?"
17
+
18
+ Every entry has alternatives considered and a reasoning trace. Where decisions are still open, they are flagged with "**OPEN**" and an expected resolution date.
19
+
20
+ ---
21
+
22
+ ## 1. Component map — what the system is made of
23
+
24
+ The product is split into five layers. Each layer has independent technology choices.
25
+
26
+ ```
27
+ ┌─────────────────────────────────────────────────────────────────┐
28
+ │ 1. FRONTEND web UI · chat · audio · filter · compare │
29
+ └─────────────────────────────────────────────────────────────────┘
30
+ ↓ HTTPS
31
+ ┌─────────────────────────────────────────────────────────────────┐
32
+ │ 2. BACKEND API route handlers · auth · request validation │
33
+ └─────────────────────────────────────────────────────────────────┘
34
+
35
+ ┌─────────────────────────────────────────────────────────────────┐
36
+ │ 3. ORCHESTRATION persona prompt · intent routing · providers │
37
+ └─────────────────────────────────────────────────────────────────┘
38
+ ↓ ↓
39
+ ┌──────────────────────────┐ ┌──────────────────────────────────┐
40
+ │ 4a. STRUCTURED STORE │ │ 4b. VECTOR STORE │
41
+ │ filter / compare │ │ RAG retrieval │
42
+ └──────────────────────────┘ └──────────────────────────────────┘
43
+
44
+ ┌─────────────────────────────────────────────────────────────────┐
45
+ │ 5. INGEST PIPELINE crawl · chunk · embed · extract │
46
+ └─────────────────────────────────────────────────────────────────┘
47
+ ```
48
+
49
+ ---
50
+
51
+ ## 2. The picks — one table to find anything
52
+
53
+ | # | Layer | Component | Pick | Status | Why (one line) | Alternatives considered |
54
+ | --- | --- | --- | --- | --- | --- | --- |
55
+ | 1 | Frontend | Framework | **Next.js 14 (App Router)** | Locked | Production-pattern, full UI flexibility, fast w/ shadcn/ui | Streamlit, Gradio, Chainlit, Reflex |
56
+ | 2 | Frontend | Styling | **Tailwind CSS + shadcn/ui** | Locked | Copy-paste components produce beautiful UIs in hours | MUI, Chakra, plain CSS |
57
+ | 3 | Frontend | Audio capture | **MediaRecorder API** (browser-native) | Locked | Zero deps, cross-browser, blob→POST flow is simple | streamlit-mic-recorder, WebRTC, manual ScriptProcessor |
58
+ | 4 | Frontend | Hosting | **Vercel** (free tier) | Locked | Native Next.js host, GitHub auto-deploy, edge-cached | Netlify, Cloudflare Pages |
59
+ | 5 | Backend | Framework | **FastAPI + Pydantic** | Locked | Pydantic matches our 48-field schema; OpenAPI auto-docs; async I/O for parallel provider calls | Flask, Django, Node/Express |
60
+ | 6 | Backend | Hosting | **Render** (free 750h/mo) | Locked | GitHub auto-deploy, Python-native, persistent disk for DuckDB + Chroma | Fly.io, Railway, Modal |
61
+ | 7 | Backend | API style | **REST** for v1, possible WebSocket if voice latency demands | Locked | Simpler; switch to WS only if measured latency exceeds target | Pure WS, gRPC |
62
+ | 8 | Backend | FE↔BE type safety | **`openapi-typescript` codegen** from FastAPI's auto-generated OpenAPI | Locked | Single source of truth; backend route changes propagate automatically | tRPC (Node-only), GraphQL, manual TS types |
63
+ | 9 | Orchestration | STT | **Sarvam Saarika v2.5** | Locked | Sarvam-first; their newer Indic ASR model | Whisper-large-v3, Deepgram Nova-2, Sarvam Saaras v3 |
64
+ | 10 | Orchestration | TTS | **Sarvam Bulbul** | Locked | Sarvam-first; Indic prosody first-class | ElevenLabs, OpenAI TTS, Coqui local |
65
+ | 11 | Orchestration | LLM (primary brain) | **Sarvam-M primary + hybrid fallback router** (Llama-3.3-70B Groq / DeepSeek-V3 OpenRouter) | Locked | Sarvam-M primary (Indic, BFSI, narrative); benchmarked fallback brains for complex multi-policy reasoning where frontier open-source wins on gold Q&A | Sarvam-M only, GPT-4o (no API), Claude (no API), single-brain alternatives |
66
+ | 12 | Orchestration | LLM (grader / self-critique) | **Groq Llama-3.3-70B-versatile** | Locked | Different model family from Sarvam — no circular eval; free tier; OpenAI-compatible API; ~500 tok/sec | GPT-4o-mini (rejected — no OpenAI), Claude Haiku (rejected — no Anthropic API), Sarvam-M held-out |
67
+ | 13 | Orchestration | Embeddings | **Voyage AI `voyage-3`** (Anthropic's recommended embedding partner) | Locked | Top benchmarks; same ecosystem as Claude; ~$0.12/1M tok; user has key | OpenAI text-embedding-3-small (rejected — no GPT), Sarvam embeddings (pending API), Cohere |
68
+ | 14 | Storage | Structured DB | **DuckDB** (single file) | Locked | One file, columnar, no server; deploys via `git clone` | SQLite, Postgres |
69
+ | 15 | Storage | Vector DB | **Chroma** (local persisted) | Locked | Embedded, no infra, supports metadata filtering | FAISS, Pinecone, Qdrant, Weaviate |
70
+ | 16 | Ingest | PDF parsing | **pdfplumber** | Locked | Clean text + page numbers — critical for citation grammar | PyMuPDF, Unstructured.io |
71
+ | 17 | Ingest | Chunking | **Custom 800-token chunks, 120-token overlap, page-aware** | Locked | Standard for technical docs; overlap protects clause boundaries | LangChain text splitter, fixed-size |
72
+ | 18 | Ingest | Structured extraction | **Sarvam-M (Llama-3.3-70B / DeepSeek-V3 fallback) with Pydantic structured output + self-critique pass** | Locked | Reliable JSON-mode extraction; self-critique gives per-field confidence; fallback brain handles tables / complex clauses Sarvam-M misses | Pure regex, LangChain extraction, Instructor lib |
73
+ | 19 | Pricing | Approach | **Hand-curated illustrative bands** from public PolicyBazaar quotes | Locked | Honest, defensible; "advisor-not-broker" positioning | Live scraping, actuarial model |
74
+ | 20 | Cross-cutting | Auth | **None for v1** (single-tenant demo) | Locked | Out of scope per `01-requirements.md` §7 | Auth0, Clerk, NextAuth |
75
+ | 21 | Cross-cutting | Observability | **JSONL turn log + cost tracker** | Locked | Lightweight; one log file, queryable post-hoc | Langfuse, Helicone, custom dashboard |
76
+ | 22 | Cross-cutting | Secrets management | **`.env` (chmod 600, gitignored) + Render env vars + Vercel env vars** | Locked | Three sources, never committed | HashiCorp Vault, Doppler |
77
+
78
+ ---
79
+
80
+ ## 3. Selection rubric — how we chose
81
+
82
+ Every pick above followed this five-test rubric. **Picks that fail any of these are documented as accepted tradeoffs, not silent compromises.**
83
+
84
+ ### 3.1 Sarvam-first hypothesis
85
+
86
+ For every component Sarvam plausibly ships (STT, TTS, LLM, embeddings), the starting hypothesis is *"use Sarvam unless we have empirical reason not to."* This is graded behavior for a Sarvam assignment — silent defaults to OpenAI/Anthropic/ElevenLabs are the most common screen-out signal.
87
+
88
+ We will **benchmark** before locking final Sarvam vs non-Sarvam picks:
89
+
90
+ - **STT:** 20-utterance test set across English, Hindi, Hinglish — measure word-error rate
91
+ - **TTS:** Subjective quality + latency on 5 sample advisor responses
92
+ - **LLM:** 50 gold Q&A pairs run with Sarvam-M and GPT-4o — measure factual accuracy + citation accuracy
93
+
94
+ Where Sarvam wins → ship Sarvam. Where Sarvam loses → document the gap with numbers and ship the alternative. Either outcome is a strong artifact.
95
+
96
+ ### 3.2 Real benchmark, not vibes
97
+
98
+ For any pick that could affect downstream accuracy (LLM, embeddings, extraction prompt design), we make the call on **empirical evidence**, not first principles. The gold Q&A harness exists for exactly this — it transforms "I think X is better" into "X scored 92% vs Y's 87% on our test set."
99
+
100
+ ### 3.3 Production-pattern, not science-project
101
+
102
+ Every component is picked **as if the next step is white-labelling to a BFSI customer.** No "great for prototyping" picks that block productionization. Examples:
103
+
104
+ - ✅ FastAPI — already production-grade
105
+ - ✅ DuckDB — embedded but production-deployed at Motherduck and many fintechs
106
+ - ❌ ~~Streamlit~~ — great prototyping, no white-label path → switched to Next.js
107
+
108
+ ### 3.4 Single-file / single-deploy where possible
109
+
110
+ Each extra service is a deploy risk and a moving part. We chose:
111
+
112
+ - DuckDB (one file) over Postgres (separate process + cluster)
113
+ - Chroma local (embedded) over Pinecone (separate service)
114
+ - Render (single backend deploy) over Render-FE + Render-BE + Redis + Postgres
115
+
116
+ The total infrastructure footprint is **2 deploys** (Vercel + Render) + **2 cloud APIs** (Sarvam + OpenAI). That's it.
117
+
118
+ ### 3.5 Documented alternatives
119
+
120
+ Every pick has at least 2 alternatives recorded. A reviewer can audit the reasoning. "Why DuckDB?" → table row 14 → SQLite (no columnar, no analytics) and Postgres (overkill, deploy overhead). Three options considered; reasoning explicit.
121
+
122
+ ---
123
+
124
+ ## 4. Cost envelope (24-hour build + demo run)
125
+
126
+ | Item | Estimated cost |
127
+ | --- | --- |
128
+ | Sarvam API (STT + TTS + LLM) — build + 100 demo calls | likely free under signup credits |
129
+ | Voyage AI embeddings (~5M tokens to embed 75 PDFs) | ~$0.45 (free under $50 signup credit) |
130
+ | Claude Haiku 4.5 for grader / self-critique (~200 calls) | ~$0.20 |
131
+ | Render (free tier, 750 h/mo) | $0 |
132
+ | Vercel (free tier, 100 GB bandwidth) | $0 |
133
+ | GitHub (public repo) | $0 |
134
+ | **Total realistic spend** | **< $1** |
135
+
136
+ Production projection (1,000 daily active users, 5 turns each):
137
+ - ~150K STT calls/mo + ~150K TTS calls/mo + ~150K LLM calls/mo
138
+ - Estimated ~$200-400/mo for Sarvam APIs (highly volume-discount-sensitive)
139
+ - Render Standard ($7/mo) + Vercel Pro ($20/mo) for resource limits
140
+ - **Production envelope: ~$250-500/mo for the first 1K DAU.** Sub-cent per turn.
141
+
142
+ ---
143
+
144
+ ## 5. Open picks (will be resolved in next 24 hours)
145
+
146
+ | # | Question | Resolution |
147
+ | --- | --- | --- |
148
+ | O1 | Does Sarvam expose an embeddings API? If yes, benchmark vs OpenAI text-embedding-3-small | Check `dashboard.sarvam.ai` API listing; <30 min |
149
+ | O2 | Saarika v2.5 vs Saaras v3 for STT — depends on whether we need translate/codemix modes | Benchmark both on 20-utterance set; <1h |
150
+ | O3 | Recommendation engine shape: rule-based pre-filter + LLM justification vs pure-LLM reasoning | Decide after gold Q&A harness runs; leaning rule-based for testability |
151
+ | O4 | Caching layer — should LLM responses be cached for repeated questions? | Yes for demo; simple file cache, no Redis |
152
+ | O5 | Should we add basic auth on the deployed URL to avoid abuse? | Probably yes — single shared password for interviewer access |
153
+
154
+ ---
155
+
156
+ ## 6. Decisions log cross-reference
157
+
158
+ Every pick here corresponds to one or more entries in `decisions.md`:
159
+
160
+ | Pick | Decision IDs |
161
+ | --- | --- |
162
+ | Vertical-slice scope | D-001 |
163
+ | Health insurance category | D-002 |
164
+ | Curated corpus, not user-uploaded | D-003 |
165
+ | Hybrid structured + unstructured | D-004 |
166
+ | **Frontend stack** (Next.js + FastAPI) | D-005 (revised 2026-05-13) |
167
+ | Sarvam-first benchmarking | D-006 |
168
+ | Pricing as illustrative band | D-007 |
169
+ | Consultative persona | D-008 |
170
+ | 10 insurers × all health policies | D-009 |
171
+ | Secret handling | D-010 |
172
+ | Embeddings provider (pending) | D-011 |
173
+ | Render deployment over alternatives | D-012 |
174
+ | Next.js + Tailwind + shadcn UI stack | D-013 |
175
+ | GPT-4o-mini as grader to avoid circular eval | D-014 |
176
+
177
+ ---
178
+
179
+ ## 7. Revision history
180
+
181
+ | Version | Date | Change |
182
+ | --- | --- | --- |
183
+ | 0.1 | 2026-05-13 | Initial draft. Switched D-005 frontend from Streamlit to Next.js + FastAPI; added D-011 through D-014 for new picks. |
entrypoint.sh ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ # Container entrypoint:
3
+ # 1. If the Chroma vector store is empty, run ingestion from rag/corpus/
4
+ # (one-time ~5 min on first boot; cached on HF persistent disk for
5
+ # subsequent boots)
6
+ # 2. Start uvicorn
7
+
8
+ set -e
9
+
10
+ # Use HF Spaces persistent disk if mounted at /data, else fall back to /app
11
+ if [ -d "/data" ] && [ -w "/data" ]; then
12
+ export VECTOR_DIR="/data/vectors"
13
+ export DUCKDB_PATH="/data/policies.duckdb"
14
+ # Symlink so the app code (which reads from rag/vectors and
15
+ # rag/policies.duckdb) finds them on the persistent disk
16
+ mkdir -p /data/vectors
17
+ rm -rf /app/rag/vectors
18
+ ln -sf /data/vectors /app/rag/vectors
19
+ if [ ! -f /data/policies.duckdb ] && [ -f /app/rag/policies.duckdb ]; then
20
+ cp /app/rag/policies.duckdb /data/policies.duckdb
21
+ fi
22
+ rm -f /app/rag/policies.duckdb
23
+ ln -sf /data/policies.duckdb /app/rag/policies.duckdb
24
+ fi
25
+
26
+ # Ingest if Chroma collection is empty
27
+ echo "[entrypoint] checking if Chroma needs to be built..."
28
+ python -c "
29
+ import sys
30
+ sys.path.insert(0, '/app')
31
+ from rag.retrieve import get_collection
32
+ c = get_collection()
33
+ n = c.count()
34
+ print(f'[entrypoint] Chroma chunks: {n}')
35
+ sys.exit(0 if n > 0 else 1)
36
+ " && echo "[entrypoint] Chroma already populated — skipping ingest" || (
37
+ echo "[entrypoint] Chroma empty — running ingest (~5 min one-time)..."
38
+ python -m rag.ingest 2>&1 | tail -20
39
+ )
40
+
41
+ # Start the server
42
+ echo "[entrypoint] starting uvicorn on port ${PORT:-7860}..."
43
+ exec uvicorn backend.main:app --host 0.0.0.0 --port "${PORT:-7860}" --log-level info
eval/generate_gold.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate gold Q&A pairs (Pipeline A — auto from structured extraction).
2
+
3
+ For every successfully extracted policy in DuckDB, generate templated questions
4
+ whose answers come directly from the structured fields. Output to eval/gold_qa.json.
5
+
6
+ Run AFTER extraction:
7
+ python -m eval.generate_gold
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import duckdb
19
+
20
+ from backend.config import settings
21
+
22
+ ROOT = settings.CORPUS_DIR.parent.parent
23
+ OUTPUT = ROOT / "eval" / "gold_qa.json"
24
+
25
+
26
+ # Question templates per field. Each tuple: (question_format, answer_format, type, difficulty).
27
+ # {pn} is replaced with policy_name, {v} with the field value.
28
+ QUESTION_TEMPLATES: dict[str, list[tuple[str, str, str, str]]] = {
29
+ "pre_existing_disease_waiting_months": [
30
+ ("What is the waiting period for pre-existing diseases under {pn}?", "{v} months from policy inception", "waiting_period", "easy"),
31
+ ("If I have diabetes, how long do I have to wait before I can claim under {pn}?", "{v} months — pre-existing diseases have a waiting period of {v} months from policy start", "waiting_period", "medium"),
32
+ ],
33
+ "initial_waiting_period_days": [
34
+ ("What is the initial waiting period under {pn}?", "{v} days from policy inception", "waiting_period", "easy"),
35
+ ],
36
+ "maternity_waiting_months": [
37
+ ("What's the maternity benefit waiting period under {pn}?", "{v} months", "waiting_period", "easy"),
38
+ ],
39
+ "pre_hospitalization_days": [
40
+ ("How many days of pre-hospitalization expenses does {pn} cover?", "{v} days", "coverage_scope", "easy"),
41
+ ],
42
+ "post_hospitalization_days": [
43
+ ("How many days of post-hospitalization expenses does {pn} cover?", "{v} days", "coverage_scope", "easy"),
44
+ ],
45
+ "day_care_treatments_count": [
46
+ ("How many day-care treatments are covered under {pn}?", "{v} day-care treatments", "coverage_scope", "easy"),
47
+ ],
48
+ "ayush_coverage": [
49
+ ("Does {pn} cover AYUSH (Ayurveda, Yoga, Unani, Siddha, Homeopathy)?", "{v_yes_no}", "coverage_scope", "easy"),
50
+ ],
51
+ "no_claim_bonus_pct": [
52
+ ("What's the no-claim bonus on {pn}?", "{v}% step-up on sum insured per claim-free year", "bonus", "easy"),
53
+ ],
54
+ "room_rent_capping": [
55
+ ("Is there a cap on room rent under {pn}?", "{v}", "sub_limit", "medium"),
56
+ ],
57
+ "copayment_pct": [
58
+ ("Is there a copayment under {pn}?", "{v_copay}", "sub_limit", "easy"),
59
+ ],
60
+ "network_hospital_count": [
61
+ ("How many hospitals are in the {pn} cashless network?", "Approximately {v:,} hospitals", "network", "easy"),
62
+ ],
63
+ "max_renewal_age": [
64
+ ("Up to what age can {pn} be renewed?", "{v_renewal}", "eligibility", "easy"),
65
+ ],
66
+ "min_entry_age": [
67
+ ("What is the minimum entry age for {pn}?", "{v} years", "eligibility", "easy"),
68
+ ],
69
+ "max_entry_age": [
70
+ ("What is the maximum entry age for {pn}?", "{v} years", "eligibility", "easy"),
71
+ ],
72
+ }
73
+
74
+
75
+ # Adversarial questions appended for every policy — bot should refuse.
76
+ REFUSAL_TEMPLATES = [
77
+ ("Does {pn} cover injuries from space tourism?", "expected_refusal", "exclusions_oos", "hard"),
78
+ ("What is the maximum claim amount for diamond-tipped surgical procedures under {pn}?", "expected_refusal", "exclusions_oos", "hard"),
79
+ ("What is the IRDAI mandate on dental coverage that {pn} must follow?", "expected_refusal", "regulatory_oos", "hard"),
80
+ ]
81
+
82
+
83
+ def yes_no(v: Any) -> str:
84
+ if v is True or v == "true" or v == 1:
85
+ return "Yes"
86
+ if v is False or v == "false" or v == 0:
87
+ return "No"
88
+ if isinstance(v, dict) and "covered" in v:
89
+ return "Yes" if v["covered"] else "No"
90
+ return str(v)
91
+
92
+
93
+ def copay_str(v: Any) -> str:
94
+ try:
95
+ v = float(v)
96
+ except Exception:
97
+ return str(v)
98
+ if v <= 0:
99
+ return "No copayment"
100
+ return f"{v:.0f}% copayment applies"
101
+
102
+
103
+ def renewal_str(v: Any) -> str:
104
+ try:
105
+ n = int(v)
106
+ if n >= 100:
107
+ return "Lifelong renewability"
108
+ return f"Up to age {n}"
109
+ except Exception:
110
+ return str(v)
111
+
112
+
113
+ def format_answer(template: str, v: Any) -> str:
114
+ out = template
115
+ out = out.replace("{v_yes_no}", yes_no(v))
116
+ out = out.replace("{v_copay}", copay_str(v))
117
+ out = out.replace("{v_renewal}", renewal_str(v))
118
+ if "{v:,}" in out:
119
+ try:
120
+ out = out.replace("{v:,}", f"{int(v):,}")
121
+ except Exception:
122
+ out = out.replace("{v:,}", str(v))
123
+ out = out.replace("{v}", str(v))
124
+ return out
125
+
126
+
127
+ def load_policies() -> list[dict]:
128
+ if not settings.STRUCTURED_DB.exists():
129
+ return []
130
+ con = duckdb.connect(str(settings.STRUCTURED_DB), read_only=True)
131
+ rows = con.execute("SELECT policy_id, policy_name, data_json FROM policies").fetchall()
132
+ con.close()
133
+ out = []
134
+ for pid, pname, data in rows:
135
+ try:
136
+ d = json.loads(data)
137
+ d["_policy_id"] = pid
138
+ d["_policy_name"] = pname or d.get("policy_name", pid)
139
+ out.append(d)
140
+ except Exception:
141
+ continue
142
+ return out
143
+
144
+
145
+ def is_present(v: Any) -> bool:
146
+ if v is None:
147
+ return False
148
+ if isinstance(v, str) and v.strip() == "":
149
+ return False
150
+ if isinstance(v, list) and len(v) == 0:
151
+ return False
152
+ if isinstance(v, dict):
153
+ # Pydantic CoverageItem dict — present if covered is set
154
+ return v.get("covered") is not None
155
+ return True
156
+
157
+
158
+ def extract_value_for_template(v: Any) -> Any:
159
+ """Some fields are CoverageItem dicts; pull a representative value."""
160
+ if isinstance(v, dict) and "covered" in v:
161
+ return v["covered"] # used by yes_no formatter
162
+ return v
163
+
164
+
165
+ def generate() -> list[dict]:
166
+ policies = load_policies()
167
+ gold: list[dict] = []
168
+
169
+ for p in policies:
170
+ pid = p["_policy_id"]
171
+ pname = p["_policy_name"]
172
+
173
+ for field, templates in QUESTION_TEMPLATES.items():
174
+ raw_v = p.get(field)
175
+ if not is_present(raw_v):
176
+ continue
177
+ v = extract_value_for_template(raw_v)
178
+
179
+ for question_fmt, answer_fmt, qtype, difficulty in templates:
180
+ answer = format_answer(answer_fmt, v)
181
+ if not answer.strip():
182
+ continue
183
+ gold.append({
184
+ "id": f"{pid}::{field}::{difficulty}",
185
+ "policy_id": pid,
186
+ "question": question_fmt.format(pn=pname),
187
+ "expected_answer": answer,
188
+ "question_type": qtype,
189
+ "difficulty": difficulty,
190
+ "expected_refusal": False,
191
+ "language": "en",
192
+ "generated_by": "pipeline_a",
193
+ "source_field": field,
194
+ })
195
+
196
+ for question_fmt, marker, qtype, difficulty in REFUSAL_TEMPLATES:
197
+ gold.append({
198
+ "id": f"{pid}::REFUSE::{qtype}::{difficulty}",
199
+ "policy_id": pid,
200
+ "question": question_fmt.format(pn=pname),
201
+ "expected_answer": "Bot should refuse or say not in document.",
202
+ "question_type": qtype,
203
+ "difficulty": difficulty,
204
+ "expected_refusal": True,
205
+ "language": "en",
206
+ "generated_by": "pipeline_c_refusal",
207
+ })
208
+
209
+ return gold
210
+
211
+
212
+ def main():
213
+ gold = generate()
214
+ OUTPUT.parent.mkdir(parents=True, exist_ok=True)
215
+ OUTPUT.write_text(json.dumps(gold, indent=2))
216
+ print(f"Wrote {len(gold)} gold Q&A pairs to {OUTPUT.relative_to(ROOT)}")
217
+ # Quick breakdown
218
+ by_policy: dict[str, int] = {}
219
+ by_type: dict[str, int] = {}
220
+ refusal_count = 0
221
+ for g in gold:
222
+ by_policy[g["policy_id"]] = by_policy.get(g["policy_id"], 0) + 1
223
+ by_type[g["question_type"]] = by_type.get(g["question_type"], 0) + 1
224
+ if g["expected_refusal"]:
225
+ refusal_count += 1
226
+ print(f"Policies covered: {len(by_policy)}")
227
+ print(f"Refusal questions: {refusal_count}")
228
+ print("By type:")
229
+ for t, n in sorted(by_type.items(), key=lambda kv: -kv[1])[:15]:
230
+ print(f" {t:>25s}: {n}")
231
+
232
+
233
+ if __name__ == "__main__":
234
+ main()
eval/run.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run the gold Q&A eval against the local bot.
2
+
3
+ Pipeline:
4
+ 1. Load eval/gold_qa.json
5
+ 2. For each pair: call backend.orchestrator.handle_turn (in-process — fast)
6
+ with policy_filter_ids=[pair.policy_id] to restrict retrieval to that policy
7
+ 3. Grade each reply using Groq Llama as the LLM-judge (different family ->
8
+ non-circular eval; regex hard-facts grader runs alongside)
9
+ 4. Aggregate and write eval/results.md + eval/results.json
10
+
11
+ Run:
12
+ python -m eval.run # full eval
13
+ python -m eval.run --limit 30 # smoke test on first 30
14
+ python -m eval.run --policy <pid> # just one policy
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import asyncio
21
+ import json
22
+ import re
23
+ import time
24
+ from collections import defaultdict
25
+ from dataclasses import asdict, dataclass, field
26
+ from pathlib import Path
27
+ from typing import Optional
28
+
29
+ from backend.config import settings
30
+ from backend.orchestrator import handle_turn
31
+ from backend.providers.base import ChatMessage
32
+ from backend.providers.groq_llm import GroqLLM
33
+
34
+ ROOT = settings.CORPUS_DIR.parent.parent
35
+ GOLD_FILE = ROOT / "eval" / "gold_qa.json"
36
+ RESULTS_JSON = ROOT / "eval" / "results.json"
37
+ RESULTS_MD = ROOT / "eval" / "results.md"
38
+
39
+
40
+ JUDGE_SYSTEM = """You grade an insurance bot's answer against a gold answer.
41
+
42
+ You will be given:
43
+ GOLD: the expected answer
44
+ BOT: the bot's actual reply
45
+ REFUSAL_EXPECTED: whether the bot should have refused
46
+
47
+ Output strict JSON:
48
+ {
49
+ "factual_match": bool, // does BOT semantically convey what GOLD says (or correctly refuse if REFUSAL_EXPECTED is true)?
50
+ "citation_present": bool, // does BOT contain a [Source: ...] tag (irrelevant for correct refusals)
51
+ "score": 0-1.0,
52
+ "reason": "one short sentence"
53
+ }
54
+
55
+ Be strict on factual_match — partial answers that miss the key number/value should be false.
56
+ For REFUSAL_EXPECTED=true, factual_match=true ONLY if BOT explicitly refuses or says the info isn't in the document.
57
+ """
58
+
59
+
60
+ @dataclass
61
+ class EvalRecord:
62
+ id: str
63
+ policy_id: str
64
+ question: str
65
+ expected_answer: str
66
+ bot_answer: str
67
+ factual_match: bool
68
+ citation_present: bool
69
+ judge_score: float
70
+ judge_reason: str
71
+ expected_refusal: bool
72
+ question_type: str
73
+ difficulty: str
74
+ blocked: bool
75
+ faithfulness_passed: bool
76
+ faithfulness_reasons: list[str] = field(default_factory=list)
77
+ brain_used: str = ""
78
+ latency_ms: int = 0
79
+
80
+
81
+ _judge: Optional[GroqLLM] = None
82
+ def get_judge() -> GroqLLM:
83
+ global _judge
84
+ if _judge is None:
85
+ _judge = GroqLLM()
86
+ return _judge
87
+
88
+
89
+ async def grade_one(gold: dict, bot_answer: str, blocked: bool) -> tuple[bool, bool, float, str]:
90
+ """Returns (factual_match, citation_present, score, reason)."""
91
+ citation_present = bool(re.search(r"\[(?:Source|Regulation):", bot_answer or "", flags=re.IGNORECASE))
92
+
93
+ # Refusal handling
94
+ refuse_kw = ("i don't see", "i don't have", "i'd rather not", "not in the document", "no information about", "not mentioned")
95
+ is_refusal = any(kw in (bot_answer or "").lower() for kw in refuse_kw) or blocked
96
+ if gold["expected_refusal"]:
97
+ return (is_refusal, citation_present, 1.0 if is_refusal else 0.0,
98
+ "correctly refused" if is_refusal else "did not refuse when expected")
99
+
100
+ # If bot refused but the answer WAS expected, that's a miss
101
+ if is_refusal:
102
+ return (False, citation_present, 0.0, "bot refused on a question with a known answer")
103
+
104
+ # LLM-judge for factual content
105
+ user = f"""GOLD: {gold['expected_answer']}
106
+ BOT: {bot_answer}
107
+ REFUSAL_EXPECTED: {gold['expected_refusal']}
108
+
109
+ Grade now."""
110
+ try:
111
+ res = await get_judge().chat(
112
+ messages=[ChatMessage(role="system", content=JUDGE_SYSTEM),
113
+ ChatMessage(role="user", content=user)],
114
+ temperature=0.0,
115
+ max_tokens=200,
116
+ response_format={"type": "json_object"},
117
+ )
118
+ d = json.loads(res.text)
119
+ return (bool(d.get("factual_match", False)),
120
+ citation_present,
121
+ float(d.get("score", 0.0)),
122
+ str(d.get("reason", ""))[:200])
123
+ except Exception as e:
124
+ return (False, citation_present, 0.0, f"judge_error: {type(e).__name__}: {e}")
125
+
126
+
127
+ async def run_one(gold: dict) -> EvalRecord:
128
+ turn = await handle_turn(
129
+ user_text=gold["question"],
130
+ chat_history=[],
131
+ user_profile={},
132
+ policy_filter_ids=[gold["policy_id"]],
133
+ )
134
+ factual, citation, score, reason = await grade_one(gold, turn.reply_text, turn.blocked)
135
+ return EvalRecord(
136
+ id=gold["id"],
137
+ policy_id=gold["policy_id"],
138
+ question=gold["question"],
139
+ expected_answer=gold["expected_answer"],
140
+ bot_answer=turn.reply_text,
141
+ factual_match=factual,
142
+ citation_present=citation,
143
+ judge_score=score,
144
+ judge_reason=reason,
145
+ expected_refusal=gold["expected_refusal"],
146
+ question_type=gold["question_type"],
147
+ difficulty=gold["difficulty"],
148
+ blocked=turn.blocked,
149
+ faithfulness_passed=turn.faithfulness_passed,
150
+ faithfulness_reasons=turn.faithfulness_reasons,
151
+ brain_used=turn.brain_used,
152
+ latency_ms=turn.latency_ms,
153
+ )
154
+
155
+
156
+ async def main():
157
+ parser = argparse.ArgumentParser()
158
+ parser.add_argument("--limit", type=int, default=None)
159
+ parser.add_argument("--policy", default=None)
160
+ args = parser.parse_args()
161
+
162
+ if not GOLD_FILE.exists():
163
+ print(f"Missing {GOLD_FILE} — run `python -m eval.generate_gold` first")
164
+ return 1
165
+ gold = json.loads(GOLD_FILE.read_text())
166
+ if args.policy:
167
+ gold = [g for g in gold if g["policy_id"] == args.policy]
168
+ if args.limit:
169
+ gold = gold[: args.limit]
170
+
171
+ print(f"Running eval on {len(gold)} questions...\n")
172
+ results: list[EvalRecord] = []
173
+ t0 = time.time()
174
+ for i, g in enumerate(gold, 1):
175
+ rec = await run_one(g)
176
+ results.append(rec)
177
+ ok_factual = "✓" if rec.factual_match else "✗"
178
+ ok_cite = "✓" if rec.citation_present else " "
179
+ print(f"[{i:>3}/{len(gold)}] {ok_factual} {ok_cite} [{rec.judge_score:.2f}] {rec.question[:60]:<60} | {rec.judge_reason[:60]}")
180
+
181
+ elapsed = time.time() - t0
182
+
183
+ # Aggregate
184
+ n = len(results)
185
+ factual_acc = sum(1 for r in results if r.factual_match) / max(1, n)
186
+ citation_acc = sum(1 for r in results if r.citation_present and not r.expected_refusal) / max(1, sum(1 for r in results if not r.expected_refusal))
187
+ refusal_n = sum(1 for r in results if r.expected_refusal)
188
+ refusal_correct = sum(1 for r in results if r.expected_refusal and r.factual_match)
189
+ refusal_precision = refusal_correct / max(1, refusal_n)
190
+
191
+ by_type_factual: dict[str, list[bool]] = defaultdict(list)
192
+ by_brain: dict[str, list[bool]] = defaultdict(list)
193
+ for r in results:
194
+ by_type_factual[r.question_type].append(r.factual_match)
195
+ by_brain[r.brain_used.split("::")[0]].append(r.factual_match)
196
+
197
+ summary = {
198
+ "ran_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
199
+ "elapsed_seconds": round(elapsed, 1),
200
+ "n_questions": n,
201
+ "factual_accuracy": round(factual_acc, 3),
202
+ "citation_accuracy": round(citation_acc, 3),
203
+ "refusal_precision": round(refusal_precision, 3),
204
+ "by_type": {t: round(sum(vs) / len(vs), 3) for t, vs in by_type_factual.items()},
205
+ "by_brain": {b: round(sum(vs) / len(vs), 3) for b, vs in by_brain.items()},
206
+ "blocked_count": sum(1 for r in results if r.blocked),
207
+ }
208
+
209
+ RESULTS_JSON.write_text(json.dumps({"summary": summary, "results": [asdict(r) for r in results]}, indent=2))
210
+
211
+ md = render_md(summary, results)
212
+ RESULTS_MD.write_text(md)
213
+
214
+ print(f"\n========== SUMMARY ==========")
215
+ print(f" Factual accuracy: {factual_acc*100:.1f}%")
216
+ print(f" Citation accuracy: {citation_acc*100:.1f}%")
217
+ print(f" Refusal precision: {refusal_precision*100:.1f}%")
218
+ print(f" Blocked: {summary['blocked_count']}/{n}")
219
+ print(f" By brain: {summary['by_brain']}")
220
+ print(f" Elapsed: {elapsed:.1f}s")
221
+ print(f" Results: {RESULTS_MD.relative_to(ROOT)}")
222
+ return 0
223
+
224
+
225
+ def render_md(summary: dict, results: list[EvalRecord]) -> str:
226
+ by_type = summary["by_type"]
227
+ by_brain = summary["by_brain"]
228
+ md_type = "\n".join(f"| {t} | {pct*100:.1f}% |" for t, pct in sorted(by_type.items(), key=lambda kv: -kv[1]))
229
+ md_brain = "\n".join(f"| {b} | {pct*100:.1f}% |" for b, pct in sorted(by_brain.items(), key=lambda kv: -kv[1]))
230
+
231
+ misses = [r for r in results if not r.factual_match][:15]
232
+ miss_table = "\n".join(
233
+ f"| {r.id[:60]} | {r.question[:60]} | {r.bot_answer[:80]} | {r.judge_reason[:60]} |"
234
+ for r in misses
235
+ )
236
+
237
+ return f"""# Eval Results — {summary['ran_at']}
238
+
239
+ ## Headline
240
+
241
+ | Metric | Value |
242
+ | --- | --- |
243
+ | Questions run | {summary['n_questions']} |
244
+ | **Factual accuracy** | **{summary['factual_accuracy']*100:.1f}%** |
245
+ | **Citation accuracy** | **{summary['citation_accuracy']*100:.1f}%** |
246
+ | **Refusal precision** | **{summary['refusal_precision']*100:.1f}%** |
247
+ | Blocked by faithfulness | {summary['blocked_count']} |
248
+ | Elapsed | {summary['elapsed_seconds']} s |
249
+
250
+ ## By question type
251
+
252
+ | Type | Accuracy |
253
+ | --- | --- |
254
+ {md_type}
255
+
256
+ ## By brain (router winners)
257
+
258
+ | Brain | Accuracy |
259
+ | --- | --- |
260
+ {md_brain}
261
+
262
+ ## Sample misses (up to 15)
263
+
264
+ | id | question | bot_answer | reason |
265
+ | --- | --- | --- | --- |
266
+ {miss_table}
267
+
268
+ ---
269
+
270
+ *Grader: Groq Llama-3.3-70B-versatile (different model family from Sarvam-M to avoid circular eval).*
271
+ *Full per-question results: `eval/results.json`.*
272
+ """
273
+
274
+
275
+ if __name__ == "__main__":
276
+ raise SystemExit(asyncio.run(main()))
frontend/.gitignore ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+
20
+ # production
21
+ /build
22
+
23
+ # misc
24
+ .DS_Store
25
+ *.pem
26
+
27
+ # debug
28
+ npm-debug.log*
29
+ yarn-debug.log*
30
+ yarn-error.log*
31
+ .pnpm-debug.log*
32
+
33
+ # env files (can opt-in for committing if needed)
34
+ .env*
35
+
36
+ # vercel
37
+ .vercel
38
+
39
+ # typescript
40
+ *.tsbuildinfo
41
+ next-env.d.ts
frontend/AGENTS.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ <!-- BEGIN:nextjs-agent-rules -->
2
+ # This is NOT the Next.js you know
3
+
4
+ This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
5
+ <!-- END:nextjs-agent-rules -->
frontend/CLAUDE.md ADDED
@@ -0,0 +1 @@
 
 
1
+ @AGENTS.md
frontend/README.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2
+
3
+ ## Getting Started
4
+
5
+ First, run the development server:
6
+
7
+ ```bash
8
+ npm run dev
9
+ # or
10
+ yarn dev
11
+ # or
12
+ pnpm dev
13
+ # or
14
+ bun dev
15
+ ```
16
+
17
+ Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18
+
19
+ You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20
+
21
+ This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22
+
23
+ ## Learn More
24
+
25
+ To learn more about Next.js, take a look at the following resources:
26
+
27
+ - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28
+ - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29
+
30
+ You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31
+
32
+ ## Deploy on Vercel
33
+
34
+ The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35
+
36
+ Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
frontend/eslint.config.mjs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
frontend/next-env.d.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+ import "./.next/types/routes.d.ts";
4
+
5
+ // NOTE: This file should not be edited
6
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
frontend/next.config.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ // Static export — the built frontend is served by the FastAPI backend
5
+ // alongside the /api/* routes on a single port. This lets us deploy
6
+ // everything to a single Hugging Face Space (Docker).
7
+ output: "export",
8
+ // Disable next/image optimization in static export (no Node runtime serving it)
9
+ images: { unoptimized: true },
10
+ // Trailing slashes match the way FastAPI's StaticFiles serves directories
11
+ trailingSlash: true,
12
+ };
13
+
14
+ export default nextConfig;
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "eslint"
10
+ },
11
+ "dependencies": {
12
+ "next": "16.2.6",
13
+ "react": "19.2.4",
14
+ "react-dom": "19.2.4"
15
+ },
16
+ "devDependencies": {
17
+ "@tailwindcss/postcss": "^4",
18
+ "@types/node": "^20",
19
+ "@types/react": "^19",
20
+ "@types/react-dom": "^19",
21
+ "eslint": "^9",
22
+ "eslint-config-next": "16.2.6",
23
+ "tailwindcss": "^4",
24
+ "typescript": "^5"
25
+ }
26
+ }
frontend/postcss.config.mjs ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
frontend/public/file.svg ADDED
frontend/public/globe.svg ADDED
frontend/public/next.svg ADDED