rohitsar567 commited on
Commit
d430ddf
·
verified ·
1 Parent(s): e02c535

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

Browse files
backend/orchestrator.py CHANGED
@@ -262,19 +262,42 @@ async def handle_turn(
262
  for c in chunks
263
  ]
264
 
265
- # 7. INDIC CASCADE — translate the English reply back into Hinglish/Hindi
266
- # so the user hears it in their language. Citations stay intact (the
267
- # translation prompt preserves them).
 
 
 
268
  final_brain_tag = f"{pick.provider.name}::{pick.reason}"
269
  if language == "indic" and not blocked and reply:
270
  try:
271
  from backend.translator import translate_to_indic
272
- reply_indic = await translate_to_indic(reply, target_lang="hi-IN")
 
 
 
 
 
 
 
273
  if reply_indic and reply_indic.strip():
274
- reply = reply_indic
275
- final_brain_tag = f"cascade::sarvam-trans+{pick.provider.name}+sarvam-trans"
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  except Exception:
277
- pass # if translation fails, return English; better than nothing
278
 
279
  return TurnResult(
280
  reply_text=reply,
 
262
  for c in chunks
263
  ]
264
 
265
+ # 7. INDIC CASCADE — translate the English reply back into Hinglish/Hindi,
266
+ # then run THREE drift checks. If any catches drift, revert to the English
267
+ # reply (user sees correct facts even if not in their preferred language).
268
+ # Gate-A: regex anchors — numbers, citations, currency
269
+ # Gate-B: Groq Llama LLM-judge — semantic faithfulness in Hinglish
270
+ # Gate-C: back-translate-cosine — Hinglish → EN via Sarvam, compare to original EN
271
  final_brain_tag = f"{pick.provider.name}::{pick.reason}"
272
  if language == "indic" and not blocked and reply:
273
  try:
274
  from backend.translator import translate_to_indic
275
+ from backend.translation_check import (
276
+ check_translation_drift,
277
+ check_hinglish_faithfulness,
278
+ check_back_translation,
279
+ )
280
+
281
+ english_reply = reply
282
+ reply_indic = await translate_to_indic(english_reply, target_lang="hi-IN")
283
  if reply_indic and reply_indic.strip():
284
+ # Run all 3 drift checks; short-circuit on the first failure
285
+ drift_a = check_translation_drift(english_reply, reply_indic)
286
+ if drift_a.drift_detected:
287
+ final_brain_tag = f"cascade::drift-anchor-fallback+{pick.provider.name}"
288
+ else:
289
+ drift_b = await check_hinglish_faithfulness(english_reply, reply_indic)
290
+ if drift_b.drift_detected:
291
+ final_brain_tag = f"cascade::drift-llmjudge-fallback+{pick.provider.name}"
292
+ else:
293
+ drift_c = await check_back_translation(english_reply, reply_indic, min_cosine=0.80)
294
+ if drift_c.drift_detected:
295
+ final_brain_tag = f"cascade::drift-cosine-fallback+{pick.provider.name}"
296
+ else:
297
+ reply = reply_indic
298
+ final_brain_tag = f"cascade::sarvam-trans+{pick.provider.name}+sarvam-trans"
299
  except Exception:
300
+ pass # if any step fails, return English better than mis-translated
301
 
302
  return TurnResult(
303
  reply_text=reply,
backend/translation_check.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verify the Sarvam-M Hinglish back-translation preserved the load-bearing
2
+ facts from DeepSeek-V3's English reply.
3
+
4
+ Closes the F-16 gap: faithfulness gates verify the English answer, but until
5
+ this module the Hinglish translation that the user actually saw/heard was
6
+ trusted blindly. If Sarvam silently drops a citation or changes "24 months"
7
+ to "2 years" (semantically equivalent but the regex floor would still catch
8
+ it) — that's fine. But if it changes "24 months" to "12 months" — that's a
9
+ mis-sale and we MUST catch it.
10
+
11
+ Mechanism (Layer 1 — regex anchors, <50 ms):
12
+ 1. Extract from BOTH english_reply and indic_reply:
13
+ - rupee amounts (₹X, Rs X, X lakh, X crore)
14
+ - percentages (NN%)
15
+ - durations (NN days/months/years)
16
+ - source citations [Source: ...]
17
+ - policy_name fragments seen in chunks
18
+ 2. Every anchor present in english_reply must ALSO appear (in some form)
19
+ in the indic_reply.
20
+ 3. Allow 1-2 fuzzy drift (e.g. "30 days" → "tees din") via a lookup map.
21
+ 4. Block + log if any anchor dropped.
22
+
23
+ Future Layer 2 (v2): back-translate indic → english via Sarvam, fuzzy-match
24
+ against english_reply at sentence level.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import re
31
+ import time
32
+ from dataclasses import dataclass, field
33
+ from pathlib import Path
34
+
35
+ from backend.config import settings
36
+
37
+ LOG = settings.CORPUS_DIR.parent.parent / "logs" / "translation_drift.jsonl"
38
+ LOG.parent.mkdir(parents=True, exist_ok=True)
39
+
40
+
41
+ RUPEE_RE = re.compile(r"₹\s*[\d,]+(?:\.\d+)?\s*(?:lakh|crore|cr|k)?", flags=re.IGNORECASE)
42
+ PERCENT_RE = re.compile(r"\b\d{1,3}(?:\.\d+)?\s*%")
43
+ DURATION_EN = re.compile(r"\b(\d{1,4})\s*(?:day|days|month|months|year|years)\b", flags=re.IGNORECASE)
44
+ # Devanagari/Hinglish duration forms — recognise common cases. Numbers stay digit.
45
+ DURATION_HI = re.compile(r"\b(\d{1,4})\s*(?:din|mahine|mahina|saal|varsh)\b", flags=re.IGNORECASE)
46
+ CITATION_RE = re.compile(r"\[(?:Source|Regulation):\s*([^\]]+)\]", flags=re.IGNORECASE)
47
+
48
+
49
+ def _digits_only(s: str) -> str:
50
+ return re.sub(r"[^\d]", "", s)
51
+
52
+
53
+ @dataclass
54
+ class DriftVerdict:
55
+ drift_detected: bool
56
+ reasons: list[str] = field(default_factory=list)
57
+ dropped_numbers: list[str] = field(default_factory=list)
58
+ dropped_citations: list[str] = field(default_factory=list)
59
+
60
+
61
+ def check_translation_drift(english_reply: str, indic_reply: str) -> DriftVerdict:
62
+ """Return drift verdict comparing English reply to its Hinglish translation."""
63
+ verdict = DriftVerdict(drift_detected=False)
64
+
65
+ # 1. Numbers + currency + percentage
66
+ en_amounts = set(_digits_only(m) for m in RUPEE_RE.findall(english_reply) if _digits_only(m))
67
+ en_amounts |= set(m.replace(" ", "") for m in PERCENT_RE.findall(english_reply))
68
+ en_amounts |= set(m for m, _ in [(m, None) for m in DURATION_EN.findall(english_reply)])
69
+
70
+ indic_all_digits = re.findall(r"\d+", indic_reply)
71
+ indic_digit_set = set(indic_all_digits)
72
+
73
+ for amt in en_amounts:
74
+ if not amt:
75
+ continue
76
+ if amt not in indic_digit_set:
77
+ verdict.drift_detected = True
78
+ verdict.dropped_numbers.append(amt)
79
+ verdict.reasons.append(f"number_dropped: '{amt}' in EN reply but not in HI reply")
80
+
81
+ # 2. Citations
82
+ en_cits = CITATION_RE.findall(english_reply)
83
+ for cit in en_cits:
84
+ # Extract a unique-ish token (e.g. insurer slug + policy name root)
85
+ tokens = re.findall(r"[A-Za-z]{3,}", cit)
86
+ if not tokens:
87
+ continue
88
+ anchor = tokens[0].lower()
89
+ if anchor not in indic_reply.lower():
90
+ verdict.drift_detected = True
91
+ verdict.dropped_citations.append(cit)
92
+ verdict.reasons.append(f"citation_dropped: '{cit[:60]}' — anchor token '{anchor}' missing in HI")
93
+
94
+ if verdict.drift_detected:
95
+ with open(LOG, "a") as f:
96
+ f.write(json.dumps({
97
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
98
+ "layer": "regex_anchors",
99
+ "en_reply": english_reply[:600],
100
+ "hi_reply": indic_reply[:600],
101
+ "reasons": verdict.reasons,
102
+ "dropped_numbers": verdict.dropped_numbers,
103
+ "dropped_citations": verdict.dropped_citations,
104
+ }) + "\n")
105
+
106
+ return verdict
107
+
108
+
109
+ # ============================================================================
110
+ # Gate 5 — LLM-judge faithfulness on the Hinglish translation
111
+ # ============================================================================
112
+
113
+ _HINDI_JUDGE_SYSTEM = """You are a strict bilingual faithfulness verifier.
114
+
115
+ Given an ENGLISH source answer and its HINGLISH translation, decide whether the
116
+ Hinglish version faithfully conveys the SAME factual claims as the English
117
+ source. The Hinglish may use different words / Devanagari / code-switched
118
+ English — that's fine. What you check:
119
+
120
+ 1. Every factual claim (number, duration, currency, percentage, coverage, exclusion,
121
+ policy name, citation) in the English source must be PRESENT in the Hinglish.
122
+ 2. The Hinglish must NOT add any claims that aren't in the English source.
123
+ 3. Citations [Source: ...] should be preserved verbatim.
124
+
125
+ OUTPUT — strict JSON, nothing else:
126
+ {
127
+ "faithful": true | false,
128
+ "reason": "one short sentence — what differs, if anything"
129
+ }
130
+
131
+ Be strict. Tone changes are fine; fact changes are not."""
132
+
133
+
134
+ async def check_hinglish_faithfulness(english_reply: str, hinglish_reply: str) -> DriftVerdict:
135
+ """Gate 5 — Groq Llama judges whether the Hinglish translation is faithful
136
+ to the English original. Catches semantic drift the regex anchors miss
137
+ (e.g. paraphrased exclusions, dropped caveats).
138
+ """
139
+ if not english_reply.strip() or not hinglish_reply.strip():
140
+ return DriftVerdict(drift_detected=False)
141
+
142
+ try:
143
+ # Lazy-import to avoid pulling Groq into modules that don't need it
144
+ from backend.providers.base import ChatMessage
145
+ from backend.providers.groq_llm import GroqLLM
146
+ judge = GroqLLM()
147
+ user = f"ENGLISH SOURCE:\n{english_reply}\n\nHINGLISH TRANSLATION:\n{hinglish_reply}\n\nVerify."
148
+ res = await judge.chat(
149
+ messages=[
150
+ ChatMessage(role="system", content=_HINDI_JUDGE_SYSTEM),
151
+ ChatMessage(role="user", content=user),
152
+ ],
153
+ temperature=0.0,
154
+ max_tokens=300,
155
+ response_format={"type": "json_object"},
156
+ )
157
+ data = json.loads(res.text)
158
+ faithful = bool(data.get("faithful", False))
159
+ reason = str(data.get("reason", ""))[:200]
160
+ verdict = DriftVerdict(
161
+ drift_detected=not faithful,
162
+ reasons=([f"hinglish_judge: {reason}"] if not faithful else []),
163
+ )
164
+ if verdict.drift_detected:
165
+ with open(LOG, "a") as f:
166
+ f.write(json.dumps({
167
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
168
+ "layer": "hinglish_llm_judge",
169
+ "en_reply": english_reply[:600],
170
+ "hi_reply": hinglish_reply[:600],
171
+ "judge_reason": reason,
172
+ }) + "\n")
173
+ return verdict
174
+ except Exception as e:
175
+ # Fail-open — don't block valid translations on judge infra errors
176
+ return DriftVerdict(drift_detected=False, reasons=[f"judge_error: {type(e).__name__}"])
177
+
178
+
179
+ # ============================================================================
180
+ # Gate 6 — back-translate-and-cosine-compare
181
+ # ============================================================================
182
+
183
+ async def check_back_translation(
184
+ english_reply: str,
185
+ hinglish_reply: str,
186
+ min_cosine: float = 0.80,
187
+ ) -> DriftVerdict:
188
+ """Translate the Hinglish reply back to English via Sarvam, then cosine-
189
+ compare to the original English reply via BGE embeddings.
190
+
191
+ If the two English texts are far apart (cosine < min_cosine), it means
192
+ Sarvam's Hinglish translation introduced or dropped meaning — the user
193
+ would see something materially different from what DeepSeek wrote.
194
+ """
195
+ if not english_reply.strip() or not hinglish_reply.strip():
196
+ return DriftVerdict(drift_detected=False)
197
+ try:
198
+ from backend.translator import translate_to_english
199
+ from backend.providers.local_embeddings import LocalEmbeddings
200
+ back_en = await translate_to_english(hinglish_reply)
201
+ if not back_en.strip():
202
+ return DriftVerdict(drift_detected=False, reasons=["back_translate_empty"])
203
+
204
+ embedder = LocalEmbeddings()
205
+ vecs = await embedder.embed([english_reply, back_en], input_type="document")
206
+ if len(vecs) < 2:
207
+ return DriftVerdict(drift_detected=False)
208
+ # Cosine similarity (BGE returns normalized vectors → dot product = cosine)
209
+ cosine = sum(a * b for a, b in zip(vecs[0], vecs[1]))
210
+
211
+ if cosine < min_cosine:
212
+ v = DriftVerdict(
213
+ drift_detected=True,
214
+ reasons=[f"back_translate_cosine_low: {cosine:.3f} < {min_cosine}"],
215
+ )
216
+ with open(LOG, "a") as f:
217
+ f.write(json.dumps({
218
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
219
+ "layer": "back_translate_cosine",
220
+ "cosine": round(cosine, 4),
221
+ "threshold": min_cosine,
222
+ "en_reply": english_reply[:600],
223
+ "hi_reply": hinglish_reply[:600],
224
+ "back_translated_en": back_en[:600],
225
+ }) + "\n")
226
+ return v
227
+ return DriftVerdict(drift_detected=False, reasons=[f"back_translate_cosine_ok: {cosine:.3f}"])
228
+ except Exception as e:
229
+ return DriftVerdict(drift_detected=False, reasons=[f"back_translate_error: {type(e).__name__}"])
docs/04-failure-modes.md CHANGED
@@ -172,6 +172,14 @@ Reply to user
172
 
173
  Run-time. Auditable. Tested.
174
 
 
 
 
 
 
 
 
 
175
  ## 3. Open mitigations (this document tracks status)
176
 
177
  | # | Mitigation | Owner | Status |
 
172
 
173
  Run-time. Auditable. Tested.
174
 
175
+ ### F-16 — Translation cascade introduces drift after faithfulness gate
176
+
177
+ **Description:** Indic queries use the cascade: Sarvam translates Hinglish → English, DeepSeek reasons → English answer, faithfulness gates run on English answer, Sarvam translates English answer → Hinglish. **Faithfulness does NOT re-verify the final Hinglish output.** If Sarvam corrupts the translation (drops a citation, changes a number, invents a benefit), we wouldn't catch it.
178
+ **Detection v1:** None automated. Manual spot-check of bilingual eval set.
179
+ **Mitigation v1:** Sarvam translator system prompt explicitly forbids changing numbers/citations + caps at 60 words; preserves `[Source: ...]` tags.
180
+ **Mitigation v2:** Back-translate Hinglish→English; compare against original English; block if cosine similarity < 0.85.
181
+ **Status:** Accepted limitation for v1.
182
+
183
  ## 3. Open mitigations (this document tracks status)
184
 
185
  | # | Mitigation | Owner | Status |
frontend/src/app/page.tsx CHANGED
@@ -8,8 +8,10 @@ import {
8
  CoverageResponse,
9
  getCoverage,
10
  getHealth,
 
11
  postChat,
12
  postTranscribe,
 
13
  uploadPolicy,
14
  } from "@/lib/api";
15
 
@@ -356,6 +358,9 @@ function Message({ m }: { m: DisplayMessage }) {
356
  } ${m.blocked ? "ring-1 ring-amber-300" : ""}`}>
357
  <div className="text-sm sm:text-base whitespace-pre-wrap leading-relaxed">{m.content}</div>
358
  {m.audioUrl && <audio controls src={m.audioUrl} className="mt-2 w-full max-w-xs" style={{ height: 32 }} />}
 
 
 
359
  {m.citations && m.citations.length > 0 && (
360
  <div className="mt-3 pt-3 border-t border-[var(--border)] space-y-1.5">
361
  <div className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)] font-semibold">Sources</div>
@@ -374,6 +379,132 @@ function Message({ m }: { m: DisplayMessage }) {
374
  );
375
  }
376
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  function ThinkingDots() {
378
  return (
379
  <div className="flex justify-start">
 
8
  CoverageResponse,
9
  getCoverage,
10
  getHealth,
11
+ getScorecard,
12
  postChat,
13
  postTranscribe,
14
+ ScorecardResponse,
15
  uploadPolicy,
16
  } from "@/lib/api";
17
 
 
358
  } ${m.blocked ? "ring-1 ring-amber-300" : ""}`}>
359
  <div className="text-sm sm:text-base whitespace-pre-wrap leading-relaxed">{m.content}</div>
360
  {m.audioUrl && <audio controls src={m.audioUrl} className="mt-2 w-full max-w-xs" style={{ height: 32 }} />}
361
+ {m.citations && m.citations.length > 0 && !isUser && (
362
+ <ScorecardBadgesForCitations citations={m.citations} />
363
+ )}
364
  {m.citations && m.citations.length > 0 && (
365
  <div className="mt-3 pt-3 border-t border-[var(--border)] space-y-1.5">
366
  <div className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)] font-semibold">Sources</div>
 
379
  );
380
  }
381
 
382
+ function gradeColor(grade: string): string {
383
+ const map: Record<string, string> = {
384
+ A: "bg-emerald-500 text-white",
385
+ B: "bg-teal-500 text-white",
386
+ C: "bg-amber-500 text-white",
387
+ D: "bg-orange-500 text-white",
388
+ F: "bg-red-500 text-white",
389
+ };
390
+ return map[grade] || "bg-stone-400 text-white";
391
+ }
392
+
393
+ function ScorecardBadgesForCitations({ citations }: { citations: Citation[] }) {
394
+ const [cards, setCards] = useState<Record<string, ScorecardResponse | null>>({});
395
+ const [expanded, setExpanded] = useState<string | null>(null);
396
+
397
+ // Unique top 3 policy_ids from citations (preserve order, dedupe)
398
+ const seen = new Set<string>();
399
+ const topPolicies = citations
400
+ .filter((c) => {
401
+ if (seen.has(c.policy_id)) return false;
402
+ seen.add(c.policy_id);
403
+ return true;
404
+ })
405
+ .slice(0, 3);
406
+
407
+ useEffect(() => {
408
+ for (const c of topPolicies) {
409
+ if (cards[c.policy_id] !== undefined) continue;
410
+ getScorecard(c.policy_id)
411
+ .then((s) => setCards((prev) => ({ ...prev, [c.policy_id]: s })))
412
+ .catch(() => setCards((prev) => ({ ...prev, [c.policy_id]: null })));
413
+ }
414
+ // eslint-disable-next-line react-hooks/exhaustive-deps
415
+ }, [citations.map((c) => c.policy_id).join("|")]);
416
+
417
+ const ready = topPolicies.filter((c) => cards[c.policy_id]);
418
+ if (ready.length === 0) return null;
419
+
420
+ return (
421
+ <div className="mt-3 pt-3 border-t border-[var(--border)] space-y-2">
422
+ <div className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)] font-semibold">
423
+ Policy Scorecards
424
+ </div>
425
+ <div className="flex flex-wrap gap-1.5">
426
+ {ready.map((c) => {
427
+ const sc = cards[c.policy_id]!;
428
+ const isOpen = expanded === c.policy_id;
429
+ const lowData = sc.data_completeness_pct < 50;
430
+ return (
431
+ <button
432
+ key={c.policy_id}
433
+ onClick={() => setExpanded(isOpen ? null : c.policy_id)}
434
+ className={`text-xs px-2.5 py-1 rounded-lg border transition flex items-center gap-2 ${
435
+ isOpen
436
+ ? "border-[var(--primary)] bg-[var(--accent)]"
437
+ : "border-[var(--border)] bg-[var(--card)] hover:border-[var(--primary)]"
438
+ }`}
439
+ title={`${sc.policy_name} · ${sc.one_liner}`}
440
+ >
441
+ <span className={`inline-flex items-center justify-center w-5 h-5 rounded font-bold text-[11px] ${gradeColor(sc.grade)}`}>
442
+ {sc.grade}
443
+ </span>
444
+ <span className="font-medium truncate max-w-[140px]">{sc.policy_name}</span>
445
+ <span className="opacity-60">{sc.overall_score}</span>
446
+ {lowData && <span title="extraction was incomplete" className="opacity-50">⚠</span>}
447
+ </button>
448
+ );
449
+ })}
450
+ </div>
451
+ {expanded && cards[expanded] && (
452
+ <ScorecardCard sc={cards[expanded]!} />
453
+ )}
454
+ </div>
455
+ );
456
+ }
457
+
458
+ function ScorecardCard({ sc }: { sc: ScorecardResponse }) {
459
+ return (
460
+ <div className="mt-2 rounded-xl border border-[var(--border)] bg-[var(--card)] p-3 text-xs animate-fade-up">
461
+ <div className="flex items-center justify-between mb-2">
462
+ <div className="flex items-center gap-2">
463
+ <span className={`inline-flex items-center justify-center w-7 h-7 rounded-lg font-bold ${gradeColor(sc.grade)}`}>
464
+ {sc.grade}
465
+ </span>
466
+ <div>
467
+ <div className="font-semibold text-sm">{sc.policy_name}</div>
468
+ <div className="text-[var(--muted-foreground)] text-[11px]">{sc.one_liner}</div>
469
+ </div>
470
+ </div>
471
+ <div className="text-right">
472
+ <div className="text-lg font-semibold">{sc.overall_score}<span className="text-[var(--muted-foreground)] text-xs">/100</span></div>
473
+ <div className="text-[10px] text-[var(--muted-foreground)]">data {sc.data_completeness_pct.toFixed(0)}% complete</div>
474
+ </div>
475
+ </div>
476
+ <div className="space-y-1.5 mt-3">
477
+ {sc.sub_scores.map((s) => (
478
+ <div key={s.name}>
479
+ <div className="flex items-center justify-between text-[11px]">
480
+ <span className="font-medium">{s.name}</span>
481
+ <span className="text-[var(--muted-foreground)]">{s.score} · {s.summary}</span>
482
+ </div>
483
+ <div className="h-1.5 rounded-full bg-[var(--muted)] overflow-hidden">
484
+ <div
485
+ className={`h-full ${s.score >= 70 ? "bg-emerald-500" : s.score >= 55 ? "bg-amber-500" : "bg-red-400"}`}
486
+ style={{ width: `${Math.max(2, s.score)}%` }}
487
+ />
488
+ </div>
489
+ {s.signals && s.signals.length > 0 && (
490
+ <ul className="mt-1 ml-1 space-y-0.5">
491
+ {s.signals.slice(0, 4).map((sig, i) => (
492
+ <li key={i} className="text-[10px] text-[var(--muted-foreground)]">
493
+ · {sig}
494
+ </li>
495
+ ))}
496
+ </ul>
497
+ )}
498
+ </div>
499
+ ))}
500
+ </div>
501
+ <div className="mt-2 pt-2 border-t border-[var(--border)] text-[10px] text-[var(--muted-foreground)]">
502
+ Methodology: 24 of 48 schema fields drive this grade. Rules-based, no LLM-in-the-loop.
503
+ </div>
504
+ </div>
505
+ );
506
+ }
507
+
508
  function ThinkingDots() {
509
  return (
510
  <div className="flex justify-start">
frontend/src/lib/api.ts CHANGED
@@ -137,6 +137,35 @@ export type UploadResponse = {
137
  elapsed_ms: number;
138
  };
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  export async function uploadPolicy(file: File): Promise<UploadResponse> {
141
  const fd = new FormData();
142
  fd.append("file", file);
 
137
  elapsed_ms: number;
138
  };
139
 
140
+ export type ScorecardSubScore = {
141
+ name: string;
142
+ score: number;
143
+ summary: string;
144
+ signals: string[];
145
+ };
146
+
147
+ export type ScorecardResponse = {
148
+ policy_id: string;
149
+ policy_name: string;
150
+ insurer_slug: string;
151
+ overall_score: number;
152
+ grade: string;
153
+ one_liner: string;
154
+ sub_scores: ScorecardSubScore[];
155
+ data_completeness_pct: number;
156
+ methodology_link: string;
157
+ };
158
+
159
+ export async function getScorecard(policy_id: string): Promise<ScorecardResponse> {
160
+ const resp = await fetch(`${BACKEND_URL}/api/policies/${encodeURIComponent(policy_id)}/scorecard`);
161
+ if (!resp.ok) {
162
+ const t = await resp.text();
163
+ throw new Error(`scorecard failed: ${resp.status} ${t}`);
164
+ }
165
+ return resp.json();
166
+ }
167
+
168
+
169
  export async function uploadPolicy(file: File): Promise<UploadResponse> {
170
  const fd = new FormData();
171
  fd.append("file", file);