Spaces:
Sleeping
Sleeping
Deploy v1 — single-Docker FastAPI + Next.js + RAG + voice + faithfulness
Browse files- backend/orchestrator.py +30 -7
- backend/translation_check.py +229 -0
- docs/04-failure-modes.md +8 -0
- frontend/src/app/page.tsx +131 -0
- frontend/src/lib/api.ts +29 -0
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 |
-
#
|
| 267 |
-
#
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
if reply_indic and reply_indic.strip():
|
| 274 |
-
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
except Exception:
|
| 277 |
-
pass # if
|
| 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);
|