rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
ce7f6d0
·
1 Parent(s): e04b308

fix(ux+voice): KI-274 — landing page enterprise redesign + Sarvam 429 friendly error

Browse files

Two parallel bundles committed together:

A — Landing page enterprise 3-section redesign (frontend/src/app/page.tsx):
- Removed: 4 example suggestion chips ("nonsense"), yellow standalone callout
"Tell me the truth", "You can type below" narrative, "166 policies"
standalone line, header EN/HI lang toggle (already removed in KI-257).
- Section 1 "How this works": 3-step grid with numbered teal circles —
(1) ask Qs → profile, (2) match 150+ policies, (3) customised premium
estimate. Lightbulb icon header.
- Section 2 "Using the bot": 4-mode card grid — Type / Push-to-talk / Hold
SPACE / Live (BETA). Mic icon header. Note about Sarvam TTS + Hindi.
- Section 3 "Two things before we start": 2 callouts side-by-side —
Left (amber/teal): "Tell me the truth, even on hard things" (absorbed
yellow callout). Right (muted): "Speaking to the bot" — speak slowly,
English OR Hindi, the bot mirrors. Shield icon header.
- Visual: rounded cards with subtle borders + shadows, hero gradient
band, all driven by existing CSS variables (no new deps). Mobile
collapses to single column under sm breakpoint.

B — Sarvam STT 429 friendly error (backend/main.py + sarvam_stt.py +
frontend api.ts + page.tsx PTT path):
- Bug from screenshot: raw httpx error dumped to chat
"Sorry — transcribe error: transcribe failed: 500 {detail: STT failed:
HTTPStatusError: Client error '429 Too Many Requests'..."
- backend/providers/sarvam_stt.py: STT_ERROR_* enum, STT_ERROR_USER_MESSAGES
dict, classify_stt_exception() helper. Maps 429/401/403/5xx/timeout/
network to clean codes.
- backend/main.py /api/transcribe:
* Single retry with 2s backoff for 429 only (mirrors KI-242 pattern).
* Returns HTTP 200 (not 500) with shape {text:"", error_code:"rate_limit",
user_message:"Voice is busy right now..."}.
* Server-side logging.warning keeps diagnostics intact.
- frontend/src/lib/api.ts: TranscribeResponse type with error_code +
user_message optional fields.
- frontend/src/app/page.tsx PTT recorder.onstop:
* Checks error_code first; falls back to browser-SR transcript if present.
* Else pushAssistant(user_message) — never raw httpx text.
* Never calls send() on empty text.

Error classification table:
- 429 (after 1 retry) → "Voice is busy right now — please try again in a
moment, or type your question."
- 5xx → "Voice service is temporarily unavailable..."
- 401/403 → "Voice service is unavailable right now — please type your question."
- Timeout/network → "Network hiccup while transcribing..."
- Unknown → "Couldn't transcribe that — please try again..."

Verification:
- py_compile clean on backend/main.py + backend/providers/sarvam_stt.py
- npx tsc --noEmit clean
- next build succeeds (4/4 static pages)
- All 5 error categories + timeout + network classify correctly
- Grep confirms no raw httpx error path remains in page.tsx PTT branch

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

backend/main.py CHANGED
@@ -96,6 +96,13 @@ class TranscribeResponse(BaseModel):
96
  language_code: Optional[str] = None
97
  confidence: Optional[float] = None
98
  latency_ms: int
 
 
 
 
 
 
 
99
 
100
 
101
  class CitationOut(BaseModel):
@@ -554,21 +561,86 @@ async def transcribe(
554
  file: UploadFile = File(...),
555
  language_code: Optional[str] = Form(None),
556
  ):
557
- """Speech-to-text. Accepts an audio file upload (WAV/MP3/etc.)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
558
  t0 = time.time()
559
  audio_bytes = await file.read()
560
  ext = (file.filename or "audio.wav").rsplit(".", 1)[-1].lower()
561
- # Pass the real extension through; sarvam_stt.py transcodes non-native
562
- # containers (webm/opus from browser MediaRecorder) to WAV before upload.
563
- try:
564
- result = await get_stt().transcribe(
 
 
 
565
  audio_bytes=audio_bytes,
566
- audio_format=ext if ext in ("wav", "mp3", "flac", "ogg", "m4a", "webm", "opus", "mp4") else "wav",
567
  language_code=language_code,
568
  )
569
- except Exception as e:
570
- raise HTTPException(500, f"STT failed: {type(e).__name__}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
571
  latency = int((time.time() - t0) * 1000)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
  return TranscribeResponse(
573
  text=result.text,
574
  language_code=result.language_code,
 
96
  language_code: Optional[str] = None
97
  confidence: Optional[float] = None
98
  latency_ms: int
99
+ # KI-242 — When Sarvam STT fails, the endpoint returns HTTP 200 with
100
+ # `text=""` plus these two fields set so the frontend can render a
101
+ # friendly message instead of parsing raw httpx error strings.
102
+ # error_code is a closed enum: rate_limit | service_unavailable |
103
+ # network | auth | unknown. Absent on the success path.
104
+ error_code: Optional[str] = None
105
+ user_message: Optional[str] = None
106
 
107
 
108
  class CitationOut(BaseModel):
 
561
  file: UploadFile = File(...),
562
  language_code: Optional[str] = Form(None),
563
  ):
564
+ """Speech-to-text. Accepts an audio file upload (WAV/MP3/etc.).
565
+
566
+ KI-242 — Sarvam errors are classified into a closed `error_code` enum
567
+ and the endpoint always returns HTTP 200 with a friendly `user_message`
568
+ on failure. The frontend never parses raw httpx text. 429 (rate limit)
569
+ is retried ONCE with a 2 s backoff before being surfaced as
570
+ `error_code: "rate_limit"`.
571
+ """
572
+ import httpx as _httpx
573
+ from backend.providers.sarvam_stt import (
574
+ classify_stt_exception,
575
+ STT_ERROR_USER_MESSAGES,
576
+ STT_ERROR_RATE_LIMIT,
577
+ )
578
+
579
  t0 = time.time()
580
  audio_bytes = await file.read()
581
  ext = (file.filename or "audio.wav").rsplit(".", 1)[-1].lower()
582
+ audio_format = (
583
+ ext if ext in ("wav", "mp3", "flac", "ogg", "m4a", "webm", "opus", "mp4")
584
+ else "wav"
585
+ )
586
+
587
+ async def _try_once():
588
+ return await get_stt().transcribe(
589
  audio_bytes=audio_bytes,
590
+ audio_format=audio_format,
591
  language_code=language_code,
592
  )
593
+
594
+ last_exc: Optional[BaseException] = None
595
+ try:
596
+ result = await _try_once()
597
+ except Exception as e: # noqa: BLE001 — classifier narrows
598
+ last_exc = e
599
+ # 429-only single retry with 2s backoff, mirroring KI-242. Only retry
600
+ # on a positively identified rate-limit; other failures surface fast.
601
+ is_rate_limited = (
602
+ isinstance(e, _httpx.HTTPStatusError)
603
+ and e.response is not None
604
+ and e.response.status_code == 429
605
+ )
606
+ if is_rate_limited:
607
+ await asyncio.sleep(2.0)
608
+ try:
609
+ result = await _try_once()
610
+ last_exc = None
611
+ except Exception as e2: # noqa: BLE001
612
+ last_exc = e2
613
+
614
  latency = int((time.time() - t0) * 1000)
615
+
616
+ if last_exc is not None:
617
+ code = classify_stt_exception(last_exc)
618
+ # Log the underlying error server-side so we keep diagnostics, but
619
+ # never leak the raw httpx string to the user-facing response.
620
+ logging.warning(
621
+ "STT failed: error_code=%s exc=%s: %s",
622
+ code,
623
+ type(last_exc).__name__,
624
+ last_exc,
625
+ )
626
+ # Force rate_limit code when the retry-arm exhausted on 429 too.
627
+ if (
628
+ isinstance(last_exc, _httpx.HTTPStatusError)
629
+ and last_exc.response is not None
630
+ and last_exc.response.status_code == 429
631
+ ):
632
+ code = STT_ERROR_RATE_LIMIT
633
+ return TranscribeResponse(
634
+ text="",
635
+ language_code=language_code,
636
+ confidence=0.0,
637
+ latency_ms=latency,
638
+ error_code=code,
639
+ user_message=STT_ERROR_USER_MESSAGES.get(
640
+ code, STT_ERROR_USER_MESSAGES["unknown"]
641
+ ),
642
+ )
643
+
644
  return TranscribeResponse(
645
  text=result.text,
646
  language_code=result.language_code,
backend/providers/sarvam_stt.py CHANGED
@@ -17,6 +17,69 @@ from backend.config import settings
17
  from backend.providers.base import STTProvider, STTResult
18
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  class SarvamSTT(STTProvider):
21
  name = "sarvam-saarika"
22
 
 
17
  from backend.providers.base import STTProvider, STTResult
18
 
19
 
20
+ # Public error-code vocabulary returned by /api/transcribe when Sarvam fails.
21
+ # Frontends (PTT + live voice) consume these as a closed enum so they can map
22
+ # each cause to a user-friendly reply without ever parsing httpx error text.
23
+ STT_ERROR_RATE_LIMIT = "rate_limit"
24
+ STT_ERROR_SERVICE = "service_unavailable"
25
+ STT_ERROR_NETWORK = "network"
26
+ STT_ERROR_AUTH = "auth"
27
+ STT_ERROR_UNKNOWN = "unknown"
28
+
29
+ # Human-readable reply per error_code. Kept here so the message lives next to
30
+ # the classifier — single source of truth for both backend response shaping
31
+ # and any place a tool wants to render the same string.
32
+ STT_ERROR_USER_MESSAGES = {
33
+ STT_ERROR_RATE_LIMIT: (
34
+ "Voice is busy right now — please try again in a moment, "
35
+ "or type your question."
36
+ ),
37
+ STT_ERROR_SERVICE: (
38
+ "Voice service is temporarily unavailable — please type your "
39
+ "question or try voice again shortly."
40
+ ),
41
+ STT_ERROR_NETWORK: (
42
+ "Network hiccup while transcribing — please try again, or "
43
+ "type your question."
44
+ ),
45
+ STT_ERROR_AUTH: (
46
+ "Voice service is unavailable right now — please type your question."
47
+ ),
48
+ STT_ERROR_UNKNOWN: (
49
+ "Couldn't transcribe that — please try again or type your question."
50
+ ),
51
+ }
52
+
53
+
54
+ def classify_stt_exception(exc: BaseException) -> str:
55
+ """Map an httpx / network exception to a STT_ERROR_* code.
56
+
57
+ KI-242 pattern — the frontend never reads raw httpx text. Backend
58
+ classifies once at the boundary so PTT + live voice + any future
59
+ caller share one closed vocabulary.
60
+ """
61
+ # httpx raises HTTPStatusError on resp.raise_for_status() with the
62
+ # original Response attached. Status code is the most reliable signal.
63
+ if isinstance(exc, httpx.HTTPStatusError):
64
+ status = exc.response.status_code if exc.response is not None else 0
65
+ if status == 429:
66
+ return STT_ERROR_RATE_LIMIT
67
+ if status in (401, 403):
68
+ return STT_ERROR_AUTH
69
+ if 500 <= status < 600:
70
+ return STT_ERROR_SERVICE
71
+ return STT_ERROR_UNKNOWN
72
+ # TimeoutException covers connect/read/write/pool timeouts.
73
+ if isinstance(exc, httpx.TimeoutException):
74
+ return STT_ERROR_NETWORK
75
+ # NetworkError covers ConnectError, ReadError, RemoteProtocolError, etc.
76
+ if isinstance(exc, httpx.NetworkError):
77
+ return STT_ERROR_NETWORK
78
+ if isinstance(exc, httpx.HTTPError):
79
+ return STT_ERROR_UNKNOWN
80
+ return STT_ERROR_UNKNOWN
81
+
82
+
83
  class SarvamSTT(STTProvider):
84
  name = "sarvam-saarika"
85
 
frontend/src/app/page.tsx CHANGED
@@ -50,13 +50,6 @@ type DisplayMessage = ChatMessage & {
50
  blocked?: boolean;
51
  };
52
 
53
- const SUGGESTED_QUESTIONS = [
54
- "I'm looking for a new health insurance policy.",
55
- "What is the waiting period for pre-existing diseases?",
56
- "Does HDFC ERGO Optima Secure cover AYUSH?",
57
- "What's the room rent cap on Care Supreme?",
58
- ];
59
-
60
  export default function Page() {
61
  const [messages, setMessages] = useState<DisplayMessage[]>([]);
62
  const [input, setInput] = useState("");
@@ -1039,15 +1032,31 @@ export default function Page() {
1039
  // placeholder while Sarvam runs. send() will clear it when (and only
1040
  // when) we actually submit, so the user sees their words throughout.
1041
  try {
1042
- const { text } = await postTranscribe(blob, ttsLang);
1043
- if (text && text.trim()) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1044
  // KI-213 — replace the interim SR transcript with Sarvam's
1045
  // authoritative version, then submit. send() clears the input
1046
  // itself so the brief flash here is intentional UX feedback.
1047
  // V4 FIX 4 — transcript-sourced.
1048
- setInputFromTranscript(text);
1049
  // send() flips voicePhase to "thinking" itself; no need to set here
1050
- await send(text);
1051
  } else if (srFallback) {
1052
  // KI-213 — Sarvam returned empty but the browser caught
1053
  // something. Better than telling the user "couldn't hear that
@@ -1059,14 +1068,20 @@ export default function Page() {
1059
  pushAssistant("Sorry, I couldn't hear that clearly. Please try again.");
1060
  }
1061
  } catch (e: unknown) {
1062
- // KI-213Sarvam failed (network / 5xx / rate limit). Fall back to
1063
- // the SR transcript if we have one rather than dropping the turn.
 
 
 
 
1064
  if (srFallback) {
1065
  setInputFromTranscript(srFallback);
1066
  try { await send(srFallback); } catch { /* send handles its own errors */ }
1067
  } else {
1068
  setInput("");
1069
- pushAssistant(`Sorry — transcribe error: ${e instanceof Error ? e.message : String(e)}`);
 
 
1070
  }
1071
  } finally {
1072
  setBusy(false);
@@ -1482,7 +1497,7 @@ export default function Page() {
1482
  }`}>
1483
  {messages.length === 0 ? (
1484
  <>
1485
- <EmptyState onSuggest={(q) => send(q)} coverage={coverage} t={t} />
1486
  {/* KI-038 — dots visible even on the very first turn (no messages
1487
  yet but the bot is hearing you / thinking) */}
1488
  {(busy || voicePhase) && (
@@ -2418,46 +2433,323 @@ function HealthBadge({ health }: { health: { status: string; missing: string[] }
2418
  );
2419
  }
2420
 
2421
- function EmptyState({ onSuggest, coverage, t }: { onSuggest: (q: string) => void; coverage: CoverageResponse | null; t: (k: StringKey, v?: Record<string, string | number>) => string }) {
2422
- const suggested: StringKey[] = ["suggested.q1", "suggested.q2", "suggested.q3", "suggested.q4"];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2423
  return (
2424
- <div className="flex-1 flex flex-col items-center justify-center text-center px-4 py-6">
2425
- <div className="w-16 h-16 rounded-2xl bg-[var(--primary)] text-[var(--primary-foreground)] flex items-center justify-center text-2xl font-bold mb-5">IA</div>
2426
- <h2 className="text-xl sm:text-2xl font-semibold mb-2">{t("welcome.heading_a")}<em className="not-italic text-[var(--primary)]">{t("welcome.heading_b")}</em>{t("welcome.heading_c")}</h2>
2427
- <p className="text-sm text-[var(--muted-foreground)] max-w-xl mb-4">
2428
- {t("welcome.subtitle")} <strong className="text-[var(--foreground)]">{t("welcome.no_commissions")}</strong> {t("welcome.source_link")}
2429
- </p>
2430
- {coverage && (
2431
- <p className="text-xs text-[var(--muted-foreground)] mb-5">
2432
- {t("welcome.coverage_template", { policies: coverage.total_policies, insurers: coverage.total_insurers })}
2433
- </p>
2434
- )}
2435
- <div className="bg-[var(--accent)] border border-[var(--primary)] rounded-xl px-4 py-3 max-w-xl mb-4 text-left">
2436
- <div className="text-xs font-semibold text-[var(--primary)] mb-1">{t("welcome.trust_title")}</div>
2437
- <p className="text-xs text-[var(--muted-foreground)] leading-snug">{t("welcome.trust_body")}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2438
  </div>
2439
- {/* KI-042 — voice is OFF by default; user opts in via the pill. */}
2440
- <div className="flex items-center gap-2 max-w-xl mb-6 text-sm text-[var(--muted-foreground)]">
2441
- <span className="inline-block w-2.5 h-2.5 rounded-full bg-gray-400" />
2442
- <span>
2443
- You can <strong className="text-[var(--foreground)]">type</strong> below, click <strong className="text-[var(--foreground)]">🎤 Push-to-talk</strong> for one voice turn, or turn on <strong className="text-[var(--foreground)]">Voice</strong> (the grey pill at the bottom) for always-on listening with barge-in.
2444
- </span>
 
 
 
 
2445
  </div>
2446
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 w-full max-w-2xl">
2447
- {suggested.map((key, i) => {
2448
- const q = t(key);
2449
- return (
2450
- <button
2451
- key={i}
2452
- onClick={() => onSuggest(q)}
2453
- className="text-left text-sm px-4 py-3 rounded-xl border border-[var(--border)] bg-[var(--card)] hover:border-[var(--primary)] transition"
2454
- >
2455
- <span className="opacity-50 text-xs">→</span> {q}
2456
- </button>
2457
- );
2458
- })}
2459
  </div>
2460
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2461
  );
2462
  }
2463
 
 
50
  blocked?: boolean;
51
  };
52
 
 
 
 
 
 
 
 
53
  export default function Page() {
54
  const [messages, setMessages] = useState<DisplayMessage[]>([]);
55
  const [input, setInput] = useState("");
 
1032
  // placeholder while Sarvam runs. send() will clear it when (and only
1033
  // when) we actually submit, so the user sees their words throughout.
1034
  try {
1035
+ const tr = await postTranscribe(blob, ttsLang);
1036
+ // KI-242 Backend now returns HTTP 200 + error_code on Sarvam
1037
+ // failures (rate_limit / service_unavailable / network / auth /
1038
+ // unknown). Prefer the SR fallback transcript when present so the
1039
+ // turn still goes through; otherwise surface the friendly
1040
+ // user_message and DO NOT call send() with empty text.
1041
+ if (tr.error_code) {
1042
+ if (srFallback) {
1043
+ setInputFromTranscript(srFallback);
1044
+ try { await send(srFallback); } catch { /* send handles its own errors */ }
1045
+ } else {
1046
+ setInput("");
1047
+ pushAssistant(
1048
+ tr.user_message ||
1049
+ "Couldn't transcribe that — please try again or type your question.",
1050
+ );
1051
+ }
1052
+ } else if (tr.text && tr.text.trim()) {
1053
  // KI-213 — replace the interim SR transcript with Sarvam's
1054
  // authoritative version, then submit. send() clears the input
1055
  // itself so the brief flash here is intentional UX feedback.
1056
  // V4 FIX 4 — transcript-sourced.
1057
+ setInputFromTranscript(tr.text);
1058
  // send() flips voicePhase to "thinking" itself; no need to set here
1059
+ await send(tr.text);
1060
  } else if (srFallback) {
1061
  // KI-213 — Sarvam returned empty but the browser caught
1062
  // something. Better than telling the user "couldn't hear that
 
1068
  pushAssistant("Sorry, I couldn't hear that clearly. Please try again.");
1069
  }
1070
  } catch (e: unknown) {
1071
+ // KI-242postTranscribe only throws on transport-level failures
1072
+ // now (5xx from a different middlebox, abort, etc.). Sarvam errors
1073
+ // arrive as HTTP 200 + error_code above and never reach this
1074
+ // branch. Fall back to SR if we have one; else show a friendly
1075
+ // generic message — NEVER leak raw httpx text to the user.
1076
+ void e;
1077
  if (srFallback) {
1078
  setInputFromTranscript(srFallback);
1079
  try { await send(srFallback); } catch { /* send handles its own errors */ }
1080
  } else {
1081
  setInput("");
1082
+ pushAssistant(
1083
+ "Voice service is temporarily unavailable — please type your question or try voice again shortly.",
1084
+ );
1085
  }
1086
  } finally {
1087
  setBusy(false);
 
1497
  }`}>
1498
  {messages.length === 0 ? (
1499
  <>
1500
+ <EmptyState coverage={coverage} t={t} uiLang={uiLang} />
1501
  {/* KI-038 — dots visible even on the very first turn (no messages
1502
  yet but the bot is hearing you / thinking) */}
1503
  {(busy || voicePhase) && (
 
2433
  );
2434
  }
2435
 
2436
+ // EmptyState landing page shown before the user's first message.
2437
+ // Redesigned 2026-05-15: removed the four hard-coded example chips
2438
+ // (unused conversational starters) and the standalone yellow "tell me the
2439
+ // truth" callout. Replaced with three structured cards: (1) how the bot
2440
+ // works, (2) the four input modes, (3) two "before we begin" callouts
2441
+ // (honesty + voice tips). Localized headings switch to Hindi when the UI
2442
+ // language toggle is on; long body copy reuses the existing welcome.*
2443
+ // translation keys (already bilingual) and supplements them with English
2444
+ // step / mode copy that mirrors the chat composer the user is about to use.
2445
+ function EmptyState({
2446
+ coverage,
2447
+ t,
2448
+ uiLang,
2449
+ }: {
2450
+ coverage: CoverageResponse | null;
2451
+ t: (k: StringKey, v?: Record<string, string | number>) => string;
2452
+ uiLang: UILang;
2453
+ }) {
2454
+ const isHi = uiLang === "hi";
2455
+ const sectionHeading = (en: string, hi: string) => (isHi ? hi : en);
2456
+
2457
  return (
2458
+ <div className="flex-1 flex flex-col items-center px-4 py-6 sm:py-8">
2459
+ <div className="w-full max-w-3xl flex flex-col gap-8 sm:gap-10">
2460
+ {/* Hero band subtle teal-to-white gradient, IA logo, headline. */}
2461
+ <section
2462
+ className="relative overflow-hidden rounded-2xl border border-[var(--border)] px-5 sm:px-7 py-6 sm:py-8 text-center"
2463
+ style={{
2464
+ background:
2465
+ "linear-gradient(180deg, color-mix(in srgb, var(--primary) 8%, var(--card)) 0%, var(--card) 100%)",
2466
+ }}
2467
+ >
2468
+ <div className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl bg-[var(--primary)] text-[var(--primary-foreground)] flex items-center justify-center text-xl sm:text-2xl font-bold mb-4 mx-auto shadow-sm">
2469
+ IA
2470
+ </div>
2471
+ <h1 className="text-2xl sm:text-[2.5rem] sm:leading-tight font-semibold tracking-tight mb-3">
2472
+ {t("welcome.heading_a")}
2473
+ <em className="not-italic text-[var(--primary)]">{t("welcome.heading_b")}</em>
2474
+ {t("welcome.heading_c")}
2475
+ </h1>
2476
+ <p className="text-[15px] sm:text-base text-[var(--muted-foreground)] max-w-xl mx-auto leading-relaxed">
2477
+ {t("welcome.subtitle")}{" "}
2478
+ <strong className="text-[var(--foreground)]">{t("welcome.no_commissions")}</strong>{" "}
2479
+ {t("welcome.source_link")}
2480
+ </p>
2481
+ </section>
2482
+
2483
+ {/* Section 1 — How this works. Three numbered step cards. The
2484
+ coverage line ("X policies across Y insurers") is folded into
2485
+ step 2 here so it doesn't float as a standalone caption. */}
2486
+ <section className="rounded-2xl border border-[var(--border)] bg-[var(--card)] shadow-sm px-5 sm:px-6 py-5 sm:py-6">
2487
+ <div className="flex items-center gap-2 mb-4">
2488
+ <SectionIcon kind="lightbulb" />
2489
+ <h2 className="text-lg sm:text-xl font-semibold text-[var(--foreground)]">
2490
+ {sectionHeading("How this works", "यह कैसे काम करता है")}
2491
+ </h2>
2492
+ </div>
2493
+ <ol className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
2494
+ <StepCard
2495
+ n={1}
2496
+ title={sectionHeading(
2497
+ "I'll ask you a few questions",
2498
+ "मैं आपसे कुछ सवाल पूछूंगा"
2499
+ )}
2500
+ body={sectionHeading(
2501
+ "We'll build your profile — age, family, location, budget, conditions — one short answer at a time.",
2502
+ "हम आपकी प्रोफ़ाइल बनाएंगे — उम्र, परिवार, location, budget, conditions — एक-एक करके।"
2503
+ )}
2504
+ />
2505
+ <StepCard
2506
+ n={2}
2507
+ title={sectionHeading(
2508
+ "I'll match you with the right policies",
2509
+ "सही policies match करूंगा"
2510
+ )}
2511
+ body={
2512
+ coverage
2513
+ ? sectionHeading(
2514
+ `Ranked by fit-to-you, not by commission. ${coverage.total_policies} policies across ${coverage.total_insurers} Indian insurers are indexed — you can also upload your own policy PDF.`,
2515
+ `कमीशन से नहीं, आपके लिए fit के हिसाब से। ${coverage.total_policies} policies, ${coverage.total_insurers} बीमाकर्ताओं से indexed हैं — अपनी policy PDF भी upload कर सकते हैं।`
2516
+ )
2517
+ : sectionHeading(
2518
+ "Ranked by fit-to-you, not by commission. 150+ Indian health policies are indexed — you can also upload your own policy PDF.",
2519
+ "कमीशन से नहीं, आपके लिए fit के हिसाब से। 150+ Indian health policies indexed हैं — अपनी policy PDF भी upload कर सकते हैं।"
2520
+ )
2521
+ }
2522
+ />
2523
+ <StepCard
2524
+ n={3}
2525
+ title={sectionHeading(
2526
+ "I'll give you a premium estimate",
2527
+ "Premium अनुमान दूंगा"
2528
+ )}
2529
+ body={sectionHeading(
2530
+ "An illustrative annual premium band, tuned to your profile — so you know what you're walking into before you talk to any insurer.",
2531
+ "आपकी profile पर आधारित illustrative वार्षिक premium band — ताकि किसी insurer से बात करने से पहले आपको पता हो क्या उम्मीद रखें।"
2532
+ )}
2533
+ />
2534
+ </ol>
2535
+ </section>
2536
+
2537
+ {/* Section 2 — Using the bot. Four input-mode rows with mini icons.
2538
+ Mirrors the composer pill below (mic / spacebar / Live BETA). */}
2539
+ <section className="rounded-2xl border border-[var(--border)] bg-[var(--card)] shadow-sm px-5 sm:px-6 py-5 sm:py-6">
2540
+ <div className="flex items-center gap-2 mb-4">
2541
+ <SectionIcon kind="mic" />
2542
+ <h2 className="text-lg sm:text-xl font-semibold text-[var(--foreground)]">
2543
+ {sectionHeading("Using the bot", "Bot का इस्तेमाल कैसे करें")}
2544
+ </h2>
2545
+ </div>
2546
+ <ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
2547
+ <ModeRow
2548
+ icon="keyboard"
2549
+ title={sectionHeading("Type your message", "Message type करें")}
2550
+ body={sectionHeading(
2551
+ "Write in the chat box below and press Enter to send.",
2552
+ "नीचे के chat box में लिखें और Enter दबाएं।"
2553
+ )}
2554
+ />
2555
+ <ModeRow
2556
+ icon="mic"
2557
+ title={sectionHeading("Push-to-talk", "Push-to-talk")}
2558
+ body={sectionHeading(
2559
+ "Click the green mic button to dictate one voice turn.",
2560
+ "हरे mic बटन पर click करके एक voice turn बोलें।"
2561
+ )}
2562
+ />
2563
+ <ModeRow
2564
+ icon="space"
2565
+ title={sectionHeading("Hold SPACE to talk", "SPACE दबाकर बोलें")}
2566
+ body={sectionHeading(
2567
+ "Hold the space bar to talk hands-free; release to submit.",
2568
+ "बिना हाथ लगाए बोलने के लिए spacebar दबाए रखें; छोड़ने पर submit हो जाएगा।"
2569
+ )}
2570
+ />
2571
+ <ModeRow
2572
+ icon="wave"
2573
+ title={sectionHeading("Live (BETA)", "Live (BETA)")}
2574
+ body={sectionHeading(
2575
+ "Toggle on for always-on listening with barge-in. Experimental — may hear background noise.",
2576
+ "हमेशा सुनने और बीच में रोकने (barge-in) के लिए toggle on करें। Experimental — background noise सुन सकता है।"
2577
+ )}
2578
+ />
2579
+ </ul>
2580
+ <p className="mt-4 text-xs text-[var(--muted-foreground)] leading-relaxed">
2581
+ {sectionHeading(
2582
+ "The bot speaks back automatically (Sarvam TTS, Hindi-capable).",
2583
+ "Bot आवाज़ में जवाब देता है (Sarvam TTS, हिन्दी में भी)।"
2584
+ )}
2585
+ </p>
2586
+ </section>
2587
+
2588
+ {/* Section 3 — Before we begin. Two side-by-side callouts:
2589
+ (a) the honesty callout that previously lived as a standalone
2590
+ yellow box; (b) voice-tips so the user knows how to speak. */}
2591
+ <section className="rounded-2xl border border-[var(--border)] bg-[var(--card)] shadow-sm px-5 sm:px-6 py-5 sm:py-6">
2592
+ <div className="flex items-center gap-2 mb-4">
2593
+ <SectionIcon kind="shield" />
2594
+ <h2 className="text-lg sm:text-xl font-semibold text-[var(--foreground)]">
2595
+ {sectionHeading("Two things before we start", "शुरू करने से पहले दो बातें")}
2596
+ </h2>
2597
+ </div>
2598
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
2599
+ {/* Left — honesty callout. Uses the existing welcome.trust_* keys
2600
+ so the Hindi copy comes through untouched. */}
2601
+ <div className="rounded-xl border border-[var(--primary)] bg-[var(--accent)] px-4 py-4 text-left">
2602
+ <div className="text-sm font-semibold text-[var(--primary)] mb-1.5">
2603
+ {t("welcome.trust_title")}
2604
+ </div>
2605
+ <p className="text-[13px] text-[var(--muted-foreground)] leading-relaxed">
2606
+ {t("welcome.trust_body")}
2607
+ </p>
2608
+ </div>
2609
+ {/* Right — voice tips. */}
2610
+ <div className="rounded-xl border border-[var(--border)] bg-[var(--muted)] px-4 py-4 text-left">
2611
+ <div className="text-sm font-semibold text-[var(--foreground)] mb-1.5">
2612
+ {sectionHeading("Speaking to the bot", "Bot से बात करना")}
2613
+ </div>
2614
+ <p className="text-[13px] text-[var(--muted-foreground)] leading-relaxed">
2615
+ {sectionHeading(
2616
+ "Please speak slowly and clearly so the voice AI can understand you. You can speak in English OR Hindi — I'll respond in whichever language you used. If you say something unclear, just repeat or rephrase — no penalties.",
2617
+ "धीरे और साफ़ बोलिए ताकि voice AI समझ सके। आप English या हिन्दी, किसी भी भाषा में बोल सकते हैं — मैं उसी भाषा में जवाब दूंगा। अगर कुछ अस्पष्ट हो, तो दोबारा या अलग शब्दों में बोलिए — कोई penalty नहीं।"
2618
+ )}
2619
+ </p>
2620
+ </div>
2621
+ </div>
2622
+ </section>
2623
  </div>
2624
+ </div>
2625
+ );
2626
+ }
2627
+
2628
+ // StepCard one of the three numbered cards in Section 1.
2629
+ function StepCard({ n, title, body }: { n: number; title: string; body: string }) {
2630
+ return (
2631
+ <li className="rounded-xl border border-[var(--border)] bg-[var(--background)] px-4 py-4 flex flex-col">
2632
+ <div className="w-7 h-7 rounded-full bg-[var(--primary)] text-[var(--primary-foreground)] text-sm font-semibold flex items-center justify-center mb-3">
2633
+ {n}
2634
  </div>
2635
+ <div className="text-sm font-semibold text-[var(--foreground)] mb-1.5 leading-snug">
2636
+ {title}
 
 
 
 
 
 
 
 
 
 
 
2637
  </div>
2638
+ <p className="text-[13px] text-[var(--muted-foreground)] leading-relaxed">{body}</p>
2639
+ </li>
2640
+ );
2641
+ }
2642
+
2643
+ // ModeRow — one of the four input-mode rows in Section 2.
2644
+ function ModeRow({
2645
+ icon,
2646
+ title,
2647
+ body,
2648
+ }: {
2649
+ icon: "keyboard" | "mic" | "space" | "wave";
2650
+ title: string;
2651
+ body: string;
2652
+ }) {
2653
+ return (
2654
+ <li className="flex items-start gap-3 rounded-xl border border-[var(--border)] bg-[var(--background)] px-3.5 py-3">
2655
+ <div className="shrink-0 w-9 h-9 rounded-lg bg-[var(--muted)] text-[var(--primary)] flex items-center justify-center">
2656
+ <ModeIcon kind={icon} />
2657
+ </div>
2658
+ <div className="min-w-0">
2659
+ <div className="text-sm font-semibold text-[var(--foreground)] leading-snug">{title}</div>
2660
+ <p className="text-[13px] text-[var(--muted-foreground)] leading-relaxed mt-0.5">{body}</p>
2661
+ </div>
2662
+ </li>
2663
+ );
2664
+ }
2665
+
2666
+ // SectionIcon — leading icon for each section heading.
2667
+ function SectionIcon({ kind }: { kind: "lightbulb" | "mic" | "shield" }) {
2668
+ const common = {
2669
+ width: 20,
2670
+ height: 20,
2671
+ viewBox: "0 0 24 24",
2672
+ fill: "none",
2673
+ stroke: "currentColor",
2674
+ strokeWidth: 2,
2675
+ strokeLinecap: "round" as const,
2676
+ strokeLinejoin: "round" as const,
2677
+ className: "text-[var(--primary)]",
2678
+ };
2679
+ if (kind === "lightbulb") {
2680
+ return (
2681
+ <svg {...common}>
2682
+ <path d="M9 18h6" />
2683
+ <path d="M10 22h4" />
2684
+ <path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.2 1 2V17h6v-.3c0-.8.4-1.5 1-2A7 7 0 0 0 12 2Z" />
2685
+ </svg>
2686
+ );
2687
+ }
2688
+ if (kind === "mic") {
2689
+ return (
2690
+ <svg {...common}>
2691
+ <rect x="9" y="2" width="6" height="12" rx="3" />
2692
+ <path d="M5 10a7 7 0 0 0 14 0" />
2693
+ <path d="M12 17v4" />
2694
+ </svg>
2695
+ );
2696
+ }
2697
+ // shield
2698
+ return (
2699
+ <svg {...common}>
2700
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" />
2701
+ <path d="m9 12 2 2 4-4" />
2702
+ </svg>
2703
+ );
2704
+ }
2705
+
2706
+ // ModeIcon — small icons next to each input mode.
2707
+ function ModeIcon({ kind }: { kind: "keyboard" | "mic" | "space" | "wave" }) {
2708
+ const common = {
2709
+ width: 18,
2710
+ height: 18,
2711
+ viewBox: "0 0 24 24",
2712
+ fill: "none",
2713
+ stroke: "currentColor",
2714
+ strokeWidth: 2,
2715
+ strokeLinecap: "round" as const,
2716
+ strokeLinejoin: "round" as const,
2717
+ };
2718
+ if (kind === "keyboard") {
2719
+ return (
2720
+ <svg {...common}>
2721
+ <rect x="2" y="6" width="20" height="12" rx="2" />
2722
+ <path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10" />
2723
+ </svg>
2724
+ );
2725
+ }
2726
+ if (kind === "mic") {
2727
+ return (
2728
+ <svg {...common}>
2729
+ <rect x="9" y="2" width="6" height="12" rx="3" />
2730
+ <path d="M5 10a7 7 0 0 0 14 0" />
2731
+ <path d="M12 17v4" />
2732
+ </svg>
2733
+ );
2734
+ }
2735
+ if (kind === "space") {
2736
+ return (
2737
+ <svg {...common}>
2738
+ <rect x="3" y="8" width="18" height="9" rx="2" />
2739
+ <path d="M7 13h10" />
2740
+ </svg>
2741
+ );
2742
+ }
2743
+ // wave
2744
+ return (
2745
+ <svg {...common}>
2746
+ <path d="M3 12h2" />
2747
+ <path d="M7 8v8" />
2748
+ <path d="M11 5v14" />
2749
+ <path d="M15 8v8" />
2750
+ <path d="M19 11v2" />
2751
+ <path d="M21 12h.01" />
2752
+ </svg>
2753
  );
2754
  }
2755
 
frontend/src/lib/api.ts CHANGED
@@ -141,11 +141,23 @@ export async function postChat(args: {
141
  return resp.json();
142
  }
143
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  export async function postTranscribe(
145
  blob: Blob,
146
  language_code?: string,
147
  signal?: AbortSignal,
148
- ): Promise<{ text: string; language_code?: string; latency_ms: number }> {
149
  const fd = new FormData();
150
  // Use blob's mime to derive extension; default to wav
151
  const mime = blob.type || "audio/wav";
 
141
  return resp.json();
142
  }
143
 
144
+ // KI-242 — Backend now returns a clean error_code + user_message on STT
145
+ // failures (HTTP 200 with empty text). Frontend consumes these directly
146
+ // instead of parsing raw httpx text. error_code is a closed enum:
147
+ // rate_limit | service_unavailable | network | auth | unknown.
148
+ export type TranscribeResponse = {
149
+ text: string;
150
+ language_code?: string;
151
+ latency_ms: number;
152
+ error_code?: "rate_limit" | "service_unavailable" | "network" | "auth" | "unknown";
153
+ user_message?: string;
154
+ };
155
+
156
  export async function postTranscribe(
157
  blob: Blob,
158
  language_code?: string,
159
  signal?: AbortSignal,
160
+ ): Promise<TranscribeResponse> {
161
  const fd = new FormData();
162
  // Use blob's mime to derive extension; default to wav
163
  const mime = blob.type || "audio/wav";