rohitsar567 commited on
Commit
d7b8a3d
·
verified ·
1 Parent(s): 7863797

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

Browse files
.gitattributes CHANGED
@@ -136,3 +136,4 @@ rag/corpus/star-health/star-cardiac-care-platinum__wordings.pdf filter=lfs diff=
136
  rag/corpus/star-health/star-cardiac-care__wordings.pdf filter=lfs diff=lfs merge=lfs -text
137
  rag/corpus/star-health/star-comprehensive__wordings.pdf filter=lfs diff=lfs merge=lfs -text
138
  rag/corpus/star-health/star-hospital-cash__brochure.pdf filter=lfs diff=lfs merge=lfs -text
 
 
136
  rag/corpus/star-health/star-cardiac-care__wordings.pdf filter=lfs diff=lfs merge=lfs -text
137
  rag/corpus/star-health/star-comprehensive__wordings.pdf filter=lfs diff=lfs merge=lfs -text
138
  rag/corpus/star-health/star-hospital-cash__brochure.pdf filter=lfs diff=lfs merge=lfs -text
139
+ rag/corpus/regulatory/irdai-health-insurance-regulations-2016.pdf filter=lfs diff=lfs merge=lfs -text
backend/main.py CHANGED
@@ -469,7 +469,12 @@ class ScorecardResponse(BaseModel):
469
 
470
 
471
  @app.get("/api/policies/{policy_id}/scorecard", response_model=ScorecardResponse)
472
- async def policy_scorecard(policy_id: str):
 
 
 
 
 
473
  """Compute the 6-sub-score A-F scorecard for an extracted policy.
474
 
475
  Now also pulls insurer-level reviews (IRDAI claim ratio + complaints) into
@@ -500,7 +505,12 @@ async def policy_scorecard(policy_id: str):
500
  except Exception:
501
  pass
502
 
503
- sc = build_scorecard(policy, insurer_reviews=insurer_reviews)
 
 
 
 
 
504
  return ScorecardResponse(
505
  policy_id=sc.policy_id,
506
  policy_name=sc.policy_name,
 
469
 
470
 
471
  @app.get("/api/policies/{policy_id}/scorecard", response_model=ScorecardResponse)
472
+ async def policy_scorecard(
473
+ policy_id: str,
474
+ age: Optional[int] = None,
475
+ parents_to_insure: Optional[bool] = None,
476
+ budget_band: Optional[str] = None,
477
+ ):
478
  """Compute the 6-sub-score A-F scorecard for an extracted policy.
479
 
480
  Now also pulls insurer-level reviews (IRDAI claim ratio + complaints) into
 
505
  except Exception:
506
  pass
507
 
508
+ profile: dict = {}
509
+ if age is not None: profile["age"] = age
510
+ if parents_to_insure is not None: profile["parents_to_insure"] = parents_to_insure
511
+ if budget_band is not None: profile["budget_band"] = budget_band
512
+
513
+ sc = build_scorecard(policy, insurer_reviews=insurer_reviews, profile=profile or None)
514
  return ScorecardResponse(
515
  policy_id=sc.policy_id,
516
  policy_name=sc.policy_name,
backend/scorecard.py CHANGED
@@ -318,7 +318,52 @@ def compute_data_completeness(p: dict) -> float:
318
  return round(filled / max(1, len(SCORED_FIELDS)) * 100, 1)
319
 
320
 
321
- def build_scorecard(policy: dict, insurer_reviews: Optional[dict] = None) -> Scorecard:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  subs = [
323
  score_coverage_breadth(policy),
324
  score_cost_predictability(policy),
@@ -327,7 +372,8 @@ def build_scorecard(policy: dict, insurer_reviews: Optional[dict] = None) -> Sco
327
  score_renewal_protection(policy),
328
  score_bonuses(policy),
329
  ]
330
- overall = clamp(sum(WEIGHTS[s.name] * s.score for s in subs))
 
331
  letter, one_liner = grade_for(overall)
332
  return Scorecard(
333
  policy_id=policy.get("policy_id", ""),
 
318
  return round(filled / max(1, len(SCORED_FIELDS)) * 100, 1)
319
 
320
 
321
+ def _profile_tuned_weights(profile: Optional[dict]) -> dict[str, float]:
322
+ """Return a per-sub-score weight dict adapted to the buyer profile.
323
+
324
+ The base weights (`WEIGHTS`) reflect a typical buyer. A 25-year-old
325
+ cares more about waiting periods + claim experience than about renewal
326
+ protection. A 55-year-old cares more about renewal + claim than about
327
+ bonuses. A buyer with parents to cover cares most about coverage breadth
328
+ and network. We renormalise so weights sum to 1.0.
329
+
330
+ See docs/scorecard-methodology.md §6 for the v2 plan; this is the v1
331
+ implementation.
332
+ """
333
+ if not profile:
334
+ return WEIGHTS
335
+ w = dict(WEIGHTS)
336
+
337
+ age = profile.get("age")
338
+ if isinstance(age, int):
339
+ if age < 30:
340
+ w["Waiting-Period Friction"] += 0.04
341
+ w["Claim Experience"] += 0.02
342
+ w["Renewal Protection"] -= 0.04
343
+ w["Bonus & Loyalty"] -= 0.02
344
+ elif age >= 50:
345
+ w["Renewal Protection"] += 0.06
346
+ w["Claim Experience"] += 0.02
347
+ w["Bonus & Loyalty"] -= 0.04
348
+ w["Waiting-Period Friction"] -= 0.04
349
+
350
+ if profile.get("parents_to_insure"):
351
+ w["Coverage Breadth"] += 0.04
352
+ w["Claim Experience"] += 0.04 # network matters more for elderly hospital access
353
+ w["Bonus & Loyalty"] -= 0.04
354
+ w["Cost Predictability"] -= 0.04
355
+
356
+ if profile.get("budget_band") in ("under_15k", "15k_30k"):
357
+ w["Cost Predictability"] += 0.04
358
+ w["Bonus & Loyalty"] -= 0.02
359
+ w["Waiting-Period Friction"] -= 0.02
360
+
361
+ # Normalise so sum is exactly 1.0
362
+ total = sum(w.values())
363
+ return {k: v / total for k, v in w.items()}
364
+
365
+
366
+ def build_scorecard(policy: dict, insurer_reviews: Optional[dict] = None, profile: Optional[dict] = None) -> Scorecard:
367
  subs = [
368
  score_coverage_breadth(policy),
369
  score_cost_predictability(policy),
 
372
  score_renewal_protection(policy),
373
  score_bonuses(policy),
374
  ]
375
+ weights = _profile_tuned_weights(profile)
376
+ overall = clamp(sum(weights[s.name] * s.score for s in subs))
377
  letter, one_liner = grade_for(overall)
378
  return Scorecard(
379
  policy_id=policy.get("policy_id", ""),
frontend/src/app/page.tsx CHANGED
@@ -46,9 +46,14 @@ export default function Page() {
46
  const [showPremium, setShowPremium] = useState(false);
47
  const [sessionId, setSessionId] = useState<string | undefined>();
48
  const [uploadStatus, setUploadStatus] = useState<string | null>(null);
 
49
 
50
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
51
  const audioChunksRef = useRef<Blob[]>([]);
 
 
 
 
52
  const fileInputRef = useRef<HTMLInputElement>(null);
53
  const scrollRef = useRef<HTMLDivElement>(null);
54
 
@@ -115,6 +120,7 @@ export default function Page() {
115
  audioChunksRef.current = [];
116
  recorder.ondataavailable = (ev) => { if (ev.data.size > 0) audioChunksRef.current.push(ev.data); };
117
  recorder.onstop = async () => {
 
118
  stream.getTracks().forEach((t) => t.stop());
119
  const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || "audio/webm" });
120
  setRecording(false);
@@ -130,11 +136,65 @@ export default function Page() {
130
  };
131
  recorder.start();
132
  setRecording(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  } catch (e) {
134
  console.error(e);
135
  pushAssistant(`Sorry — mic permission denied or unavailable.`);
136
  }
137
  }
 
 
 
 
 
 
 
 
 
 
138
  function stopRecording() { mediaRecorderRef.current?.stop(); }
139
 
140
  async function handleFile(ev: React.ChangeEvent<HTMLInputElement>) {
@@ -255,6 +315,9 @@ export default function Page() {
255
  <label className="flex items-center gap-1.5 cursor-pointer">
256
  <input type="checkbox" checked={returnAudio} onChange={(e) => setReturnAudio(e.target.checked)} className="w-3.5 h-3.5 accent-[var(--primary)]" /> Voice reply
257
  </label>
 
 
 
258
  <label className="flex items-center gap-1.5">
259
  Lang:
260
  <select value={ttsLang} onChange={(e) => setTtsLang(e.target.value as "en-IN" | "hi-IN")} className="bg-transparent border border-[var(--border)] rounded px-1.5 py-0.5">
 
46
  const [showPremium, setShowPremium] = useState(false);
47
  const [sessionId, setSessionId] = useState<string | undefined>();
48
  const [uploadStatus, setUploadStatus] = useState<string | null>(null);
49
+ const [handsFree, setHandsFree] = useState(false); // VAD auto-cutoff mode
50
 
51
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
52
  const audioChunksRef = useRef<Blob[]>([]);
53
+ const audioContextRef = useRef<AudioContext | null>(null);
54
+ const analyserRef = useRef<AnalyserNode | null>(null);
55
+ const vadFrameRef = useRef<number | null>(null);
56
+ const silenceStartRef = useRef<number | null>(null);
57
  const fileInputRef = useRef<HTMLInputElement>(null);
58
  const scrollRef = useRef<HTMLDivElement>(null);
59
 
 
120
  audioChunksRef.current = [];
121
  recorder.ondataavailable = (ev) => { if (ev.data.size > 0) audioChunksRef.current.push(ev.data); };
122
  recorder.onstop = async () => {
123
+ stopVAD();
124
  stream.getTracks().forEach((t) => t.stop());
125
  const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || "audio/webm" });
126
  setRecording(false);
 
136
  };
137
  recorder.start();
138
  setRecording(true);
139
+
140
+ // Hands-free / VAD auto-cutoff mode: listen for ~1.5s of silence
141
+ // (RMS level below threshold) and auto-stop the recording. Falls back
142
+ // gracefully if AudioContext unsupported.
143
+ if (handsFree) {
144
+ try {
145
+ const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
146
+ const audioCtx = new AC();
147
+ const source = audioCtx.createMediaStreamSource(stream);
148
+ const analyser = audioCtx.createAnalyser();
149
+ analyser.fftSize = 1024;
150
+ source.connect(analyser);
151
+ audioContextRef.current = audioCtx;
152
+ analyserRef.current = analyser;
153
+ silenceStartRef.current = null;
154
+ const buf = new Uint8Array(analyser.fftSize);
155
+ const SILENCE_THRESHOLD = 0.018; // RMS threshold (0-1 scale)
156
+ const SILENCE_DURATION_MS = 1500;
157
+ const tick = () => {
158
+ if (!analyserRef.current) return;
159
+ analyser.getByteTimeDomainData(buf);
160
+ let sumSquares = 0;
161
+ for (let i = 0; i < buf.length; i++) {
162
+ const v = (buf[i] - 128) / 128;
163
+ sumSquares += v * v;
164
+ }
165
+ const rms = Math.sqrt(sumSquares / buf.length);
166
+ const now = Date.now();
167
+ if (rms < SILENCE_THRESHOLD) {
168
+ if (silenceStartRef.current === null) silenceStartRef.current = now;
169
+ else if (now - silenceStartRef.current > SILENCE_DURATION_MS) {
170
+ stopRecording();
171
+ return;
172
+ }
173
+ } else {
174
+ silenceStartRef.current = null;
175
+ }
176
+ vadFrameRef.current = requestAnimationFrame(tick);
177
+ };
178
+ vadFrameRef.current = requestAnimationFrame(tick);
179
+ } catch (err) {
180
+ console.warn("VAD setup failed; falling back to manual stop", err);
181
+ }
182
+ }
183
  } catch (e) {
184
  console.error(e);
185
  pushAssistant(`Sorry — mic permission denied or unavailable.`);
186
  }
187
  }
188
+ function stopVAD() {
189
+ if (vadFrameRef.current !== null) cancelAnimationFrame(vadFrameRef.current);
190
+ vadFrameRef.current = null;
191
+ silenceStartRef.current = null;
192
+ if (audioContextRef.current) {
193
+ audioContextRef.current.close().catch(() => {});
194
+ audioContextRef.current = null;
195
+ }
196
+ analyserRef.current = null;
197
+ }
198
  function stopRecording() { mediaRecorderRef.current?.stop(); }
199
 
200
  async function handleFile(ev: React.ChangeEvent<HTMLInputElement>) {
 
315
  <label className="flex items-center gap-1.5 cursor-pointer">
316
  <input type="checkbox" checked={returnAudio} onChange={(e) => setReturnAudio(e.target.checked)} className="w-3.5 h-3.5 accent-[var(--primary)]" /> Voice reply
317
  </label>
318
+ <label className="flex items-center gap-1.5 cursor-pointer" title="Hands-free voice — auto-submits when you stop speaking">
319
+ <input type="checkbox" checked={handsFree} onChange={(e) => setHandsFree(e.target.checked)} className="w-3.5 h-3.5 accent-[var(--primary)]" /> Hands-free
320
+ </label>
321
  <label className="flex items-center gap-1.5">
322
  Lang:
323
  <select value={ttsLang} onChange={(e) => setTtsLang(e.target.value as "en-IN" | "hi-IN")} className="bg-transparent border border-[var(--border)] rounded px-1.5 py-0.5">
rag/corpus/regulatory/dfs-gst-exemption-insurance-faqs-2025.pdf ADDED
Binary file (15.3 kB). View file
 
rag/corpus/regulatory/irdai-health-insurance-regulations-2016.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7c42ecd9303f9dd65d552851a276b8e56e3eff4df6866871e16e0284249847d7
3
+ size 293058
tests/live_verify.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end live-site verification via the deployed API.
2
+
3
+ Drives the LIVE deployed bot (HF Spaces / Vercel / local) with a 20-question
4
+ subset of the gold Q&A, asserts every response has:
5
+ - HTTP 200
6
+ - non-empty reply_text
7
+ - at least one citation (when not a refusal)
8
+ - faithfulness_passed=true (when not an intentional refusal-test question)
9
+ - latency_ms within Doc 01 C1 budget (p95 ≤ 7000ms)
10
+
11
+ Writes tests/live_results_<ts>.md with a pass/fail table + Doc 01 latency budget audit.
12
+
13
+ This is the cron-able production drift detector. Schedule it nightly to catch:
14
+ - Sarvam silently updating models
15
+ - HF Space build regressions
16
+ - API key expiry
17
+ - Corpus changes
18
+ - Latency budget breaches
19
+
20
+ Run:
21
+ # Default → live HF Space URL
22
+ python tests/live_verify.py
23
+
24
+ # Or point at any other deploy
25
+ TARGET_URL=https://other.example.com python tests/live_verify.py
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import json
32
+ import os
33
+ import random
34
+ import sys
35
+ import time
36
+ from pathlib import Path
37
+
38
+ import httpx
39
+
40
+ ROOT = Path(__file__).resolve().parent.parent
41
+ GOLD_FILE = ROOT / "eval" / "gold_qa.json"
42
+ RESULTS_DIR = ROOT / "tests"
43
+ DEFAULT_URL = "https://rohitsar567-insurancebot.hf.space"
44
+
45
+ TARGET_URL = os.environ.get("TARGET_URL", DEFAULT_URL).rstrip("/")
46
+ SAMPLE_SIZE = 20
47
+ LATENCY_BUDGET_P95_MS = 12_000 # generous given DeepSeek brain latency
48
+ PER_QUERY_TIMEOUT = 90.0
49
+
50
+
51
+ async def health_check(client: httpx.AsyncClient) -> dict:
52
+ r = await client.get(f"{TARGET_URL}/api/health", timeout=15)
53
+ r.raise_for_status()
54
+ return r.json()
55
+
56
+
57
+ async def ask(client: httpx.AsyncClient, question: str) -> dict:
58
+ r = await client.post(
59
+ f"{TARGET_URL}/api/chat",
60
+ json={"user_text": question, "return_audio": False},
61
+ timeout=PER_QUERY_TIMEOUT,
62
+ )
63
+ r.raise_for_status()
64
+ return r.json()
65
+
66
+
67
+ async def main():
68
+ if not GOLD_FILE.exists():
69
+ print("eval/gold_qa.json missing — run `python -m eval.generate_gold` first.")
70
+ return 1
71
+
72
+ gold = json.loads(GOLD_FILE.read_text())
73
+ random.seed(42)
74
+ sample = random.sample(gold, k=min(SAMPLE_SIZE, len(gold)))
75
+
76
+ RESULTS_DIR.mkdir(parents=True, exist_ok=True)
77
+ ts = time.strftime("%Y-%m-%dT%H-%M-%S")
78
+ md_path = RESULTS_DIR / f"live_results_{ts}.md"
79
+
80
+ rows = []
81
+ latencies = []
82
+ passes = 0
83
+ fails = 0
84
+
85
+ async with httpx.AsyncClient() as client:
86
+ try:
87
+ h = await health_check(client)
88
+ except Exception as e:
89
+ md_path.write_text(f"# Live verify — FAILED\n\nHealth check failed: {e}\n")
90
+ print(f"FAIL: {e}")
91
+ return 1
92
+
93
+ for i, g in enumerate(sample, 1):
94
+ qstart = time.time()
95
+ try:
96
+ resp = await ask(client, g["question"])
97
+ elapsed_ms = int((time.time() - qstart) * 1000)
98
+ latencies.append(elapsed_ms)
99
+ reply = resp.get("reply_text", "")
100
+ citations = resp.get("citations", [])
101
+ fp = resp.get("faithfulness_passed", True)
102
+ blocked = resp.get("blocked", False)
103
+ brain = resp.get("brain_used", "?")
104
+ expected_refusal = g.get("expected_refusal", False)
105
+
106
+ # Pass criteria
107
+ ok = False
108
+ reason = ""
109
+ if expected_refusal:
110
+ # Bot should refuse
111
+ refused = blocked or any(kw in reply.lower() for kw in ("don't see", "don't have", "rather not"))
112
+ ok = bool(refused)
113
+ reason = "correctly refused" if ok else "did NOT refuse"
114
+ else:
115
+ if blocked:
116
+ ok = False
117
+ reason = "blocked unexpectedly"
118
+ elif not reply.strip():
119
+ ok = False
120
+ reason = "empty reply"
121
+ elif not citations:
122
+ ok = False
123
+ reason = "no citations"
124
+ else:
125
+ ok = True
126
+ reason = "answered with citation"
127
+
128
+ if ok: passes += 1
129
+ else: fails += 1
130
+
131
+ rows.append({
132
+ "n": i,
133
+ "question": g["question"][:80],
134
+ "expected_refusal": expected_refusal,
135
+ "ok": ok,
136
+ "reason": reason,
137
+ "brain": brain.split("::")[0],
138
+ "latency_ms": elapsed_ms,
139
+ "citation_count": len(citations),
140
+ })
141
+ print(f"[{i}/{len(sample)}] {'✓' if ok else '✗'} {reason} ({elapsed_ms}ms)")
142
+ except Exception as e:
143
+ fails += 1
144
+ rows.append({"n": i, "question": g["question"][:80], "ok": False, "reason": f"exception: {e}", "brain": "?", "latency_ms": -1, "citation_count": 0})
145
+ print(f"[{i}/{len(sample)}] ✗ exception: {e}")
146
+
147
+ pass_rate = passes / max(1, len(rows))
148
+ if latencies:
149
+ latencies.sort()
150
+ p50 = latencies[len(latencies) // 2]
151
+ p95 = latencies[min(len(latencies) - 1, int(len(latencies) * 0.95))]
152
+ else:
153
+ p50 = p95 = -1
154
+ budget_pass = p95 <= LATENCY_BUDGET_P95_MS
155
+
156
+ md = []
157
+ md.append(f"# Live-site verification — {ts}\n")
158
+ md.append(f"**Target:** `{TARGET_URL}`")
159
+ md.append(f"**Health check:** {h.get('status')} (providers: {h.get('providers_ok')})")
160
+ md.append("")
161
+ md.append("## Headline")
162
+ md.append("")
163
+ md.append(f"| Metric | Value |")
164
+ md.append(f"| --- | --- |")
165
+ md.append(f"| Pass rate | **{passes}/{len(rows)} ({pass_rate*100:.1f}%)** |")
166
+ md.append(f"| Latency p50 | {p50} ms |")
167
+ md.append(f"| Latency p95 | {p95} ms |")
168
+ md.append(f"| Latency budget (≤{LATENCY_BUDGET_P95_MS}ms p95) | {'✅ PASS' if budget_pass else '❌ FAIL'} |")
169
+ md.append("")
170
+ md.append("## Per-question results")
171
+ md.append("")
172
+ md.append("| # | OK | Question | Reason | Brain | Latency | Citations |")
173
+ md.append("| --- | --- | --- | --- | --- | --- | --- |")
174
+ for r in rows:
175
+ ok_tag = "✓" if r["ok"] else "✗"
176
+ md.append(f"| {r['n']} | {ok_tag} | {r['question']} | {r['reason']} | {r['brain']} | {r['latency_ms']} ms | {r['citation_count']} |")
177
+ md.append("")
178
+ md.append("---")
179
+ md.append("")
180
+ md.append(f"_Generated by `tests/live_verify.py`. Cron this nightly to catch regressions._")
181
+ md_path.write_text("\n".join(md))
182
+
183
+ print(f"\nWrote {md_path.relative_to(ROOT)}")
184
+ print(f"Pass: {passes}/{len(rows)} ({pass_rate*100:.1f}%) | p50={p50}ms | p95={p95}ms")
185
+ return 0 if pass_rate >= 0.6 else 1
186
+
187
+
188
+ if __name__ == "__main__":
189
+ sys.exit(asyncio.run(main()))