Spaces:
Sleeping
Sleeping
Deploy v1 — single-Docker FastAPI + Next.js + RAG + voice + faithfulness
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +15 -0
- .gitattributes +76 -0
- .gitignore +76 -0
- ARCHITECTURE.md +50 -0
- Dockerfile +62 -0
- README.md +68 -6
- backend/__init__.py +0 -0
- backend/config.py +70 -0
- backend/faithfulness.py +302 -0
- backend/main.py +275 -0
- backend/needs_finder.py +210 -0
- backend/orchestrator.py +191 -0
- backend/persona.py +108 -0
- backend/providers/__init__.py +13 -0
- backend/providers/_smoke_test.py +161 -0
- backend/providers/base.py +94 -0
- backend/providers/groq_llm.py +71 -0
- backend/providers/local_embeddings.py +54 -0
- backend/providers/openrouter_llm.py +72 -0
- backend/providers/sarvam_llm.py +73 -0
- backend/providers/sarvam_stt.py +61 -0
- backend/providers/sarvam_tts.py +79 -0
- backend/providers/voyage_embeddings.py +112 -0
- data/corpus_urls.md +103 -0
- data/regulatory_urls.md +38 -0
- docs/01-requirements.md +261 -0
- docs/02-architecture.md +329 -0
- docs/03-eval-plan.md +105 -0
- docs/04-failure-modes.md +189 -0
- docs/05-needs-analysis-flow.md +149 -0
- docs/ROADMAP.md +140 -0
- docs/decisions.md +240 -0
- docs/information_source_map.md +57 -0
- docs/tech-stack-rationale.md +183 -0
- entrypoint.sh +43 -0
- eval/generate_gold.py +234 -0
- eval/run.py +276 -0
- frontend/.gitignore +41 -0
- frontend/AGENTS.md +5 -0
- frontend/CLAUDE.md +1 -0
- frontend/README.md +36 -0
- frontend/eslint.config.mjs +18 -0
- frontend/next-env.d.ts +6 -0
- frontend/next.config.ts +14 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +26 -0
- frontend/postcss.config.mjs +7 -0
- frontend/public/file.svg +1 -0
- frontend/public/globe.svg +1 -0
- 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
license: mit
|
| 9 |
-
short_description:
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|