rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
f81328f
Β·
1 Parent(s): 6f3c920

fix(ux): cold-start retries + retry-intent handling + insurer-prefixed citations

Browse files

Three bugs visible in the 2026-05-14 mobile screenshot:

Bug A β€” "Sorry β€” backend error: Load failed"
HF Space cold-starts after ~15min idle take ~50s; Safari's fetch surfaces
this as a generic TypeError. Fix: api.ts adds _fetchWithRetry with
exponential backoff (1.5s, 3.5s, 7s). 502/503 and network errors retry
up to 3x; AbortError (Live-mode barge-in) is NOT retried. onRetry
callback surfaces "Connection slow β€” retrying…" status. Final chat
message on failure now suggests "say 'try again'" instead of "Load failed".

Bug B β€” "Sarvah Param" rendered as unattributed citation
ManipalCigna Sarvah-Param is a real policy; chip showed only policy_name.
Fix: PolicyChipsFromCitations renders insurer_slug as kicker above the
name. "MANIPALCIGNA / Sarvah Param" β€” clear provenance, mobile-friendly.

Bug C β€” "Try again" got refused by Gate 1
"Try again" has no policy context so retrieval found nothing β†’ Gate 1
refused. Fix: page.tsx tracks lastSubmittedTextRef and _isRetryIntent()
matches English + Hinglish retry phrases ("try again", "retry", "say it
again", "ek baar aur", "phir se"). On detect, send() resubmits the
previous user_text with a "Retrying: …" acknowledgment.

AbortError silently drops the catch (Live-mode barge-in is intentional).

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

Files changed (2) hide show
  1. frontend/src/app/page.tsx +71 -4
  2. frontend/src/lib/api.ts +58 -15
frontend/src/app/page.tsx CHANGED
@@ -189,11 +189,40 @@ export default function Page() {
189
  setMessages((m) => [...m, { id: `a_${Date.now()}`, role: "assistant", content, ...extras }]);
190
  }
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  async function send(text: string) {
193
  if (!text.trim() || busy) return;
194
  setBusy(true);
195
  setInput("");
196
- pushUser(text);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  try {
198
  const history: ChatMessage[] = messages.map((m) => ({ role: m.role, content: m.content }));
199
  // Real-time copilot context β€” tells the backend what the user is
@@ -206,7 +235,7 @@ export default function Page() {
206
  showPremium ? "premium" :
207
  "chat";
208
  const res = await postChat({
209
- user_text: text,
210
  session_id: sessionId,
211
  chat_history: history,
212
  return_audio: returnAudio,
@@ -215,7 +244,18 @@ export default function Page() {
215
  active_view,
216
  active_policy_id: openPolicy?.policy_id,
217
  },
 
 
 
 
 
 
 
 
 
 
218
  });
 
219
  setSessionId(res.session_id);
220
  // Refresh profileCompleteness after every chat turn so that any profile
221
  // fields the backend extracted from the user's message (age, conditions,
@@ -254,8 +294,25 @@ export default function Page() {
254
  setTimeout(() => { if (handsFreeRef.current) startRecording(); }, 500);
255
  }
256
  } catch (e: unknown) {
257
- pushAssistant(`Sorry β€” backend error: ${e instanceof Error ? e.message : String(e)}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  } finally {
 
259
  setBusy(false);
260
  }
261
  }
@@ -1257,7 +1314,17 @@ function PolicyChipsFromCitations({ citations }: { citations: Citation[] }) {
1257
  title={sc?.one_liner || c.policy_name}
1258
  >
1259
  {sc && <span className={`inline-flex items-center justify-center w-5 h-5 rounded font-bold text-[11px] ${gradeColor(sc.grade)}`}>{sc.grade}</span>}
1260
- <span className="font-medium truncate max-w-[140px]">{c.policy_name}</span>
 
 
 
 
 
 
 
 
 
 
1261
  </button>
1262
  {c.source_url && (
1263
  <a
 
189
  setMessages((m) => [...m, { id: `a_${Date.now()}`, role: "assistant", content, ...extras }]);
190
  }
191
 
192
+ // Last user message that actually went to the LLM (post retry-detection).
193
+ // Used to resolve "try again" / "retry" intent back to the original turn.
194
+ const lastSubmittedTextRef = useRef<string>("");
195
+
196
+ // Phrases that mean "resend my previous turn", not "answer this literally".
197
+ function _isRetryIntent(text: string): boolean {
198
+ const s = text.toLowerCase().trim().replace(/[.!?,]+$/, "");
199
+ return [
200
+ "try again", "try that again", "retry", "retry that",
201
+ "say it again", "say that again", "once more", "one more time",
202
+ "repeat", "repeat that", "ek baar aur", "phir se",
203
+ ].includes(s);
204
+ }
205
+
206
  async function send(text: string) {
207
  if (!text.trim() || busy) return;
208
  setBusy(true);
209
  setInput("");
210
+
211
+ // Bug C β€” if the user is asking us to retry, resubmit the previous user
212
+ // turn (the one that actually had policy / fact-find context) instead of
213
+ // hitting Gate-1 with no retrieval.
214
+ let actualText = text;
215
+ if (_isRetryIntent(text) && lastSubmittedTextRef.current) {
216
+ actualText = lastSubmittedTextRef.current;
217
+ // Show the user we understood the retry β€” surface a system note instead
218
+ // of echoing "try again" back through retrieval.
219
+ pushUser(text);
220
+ pushAssistant(`Retrying: "${actualText}"`, {});
221
+ } else {
222
+ pushUser(text);
223
+ }
224
+ lastSubmittedTextRef.current = actualText;
225
+
226
  try {
227
  const history: ChatMessage[] = messages.map((m) => ({ role: m.role, content: m.content }));
228
  // Real-time copilot context β€” tells the backend what the user is
 
235
  showPremium ? "premium" :
236
  "chat";
237
  const res = await postChat({
238
+ user_text: actualText,
239
  session_id: sessionId,
240
  chat_history: history,
241
  return_audio: returnAudio,
 
244
  active_view,
245
  active_policy_id: openPolicy?.policy_id,
246
  },
247
+ onRetry: (attempt) => {
248
+ // Show transient "warming up" hint while postChat retries the
249
+ // cold-started Space behind the scenes. Don't push as a message;
250
+ // use the input area's status string so it doesn't clutter chat.
251
+ setUploadStatus(
252
+ attempt === 1
253
+ ? "Connection slow β€” retrying…"
254
+ : `Still warming up (attempt ${attempt} of 3)…`,
255
+ );
256
+ },
257
  });
258
+ setUploadStatus(null);
259
  setSessionId(res.session_id);
260
  // Refresh profileCompleteness after every chat turn so that any profile
261
  // fields the backend extracted from the user's message (age, conditions,
 
294
  setTimeout(() => { if (handsFreeRef.current) startRecording(); }, 500);
295
  }
296
  } catch (e: unknown) {
297
+ const err = e as { name?: string; message?: string };
298
+ if (err?.name === "AbortError") {
299
+ // Live-mode barge-in cancelled this turn intentionally; stay silent.
300
+ return;
301
+ }
302
+ const msg = err?.message || String(e);
303
+ // Bug A β€” friendlier message for the cold-start / network failure case
304
+ // that Safari surfaces as "Load failed". Suggest the retry-intent path
305
+ // so the user doesn't lose their actual question.
306
+ if (/Load failed|Failed to fetch|NetworkError|chat failed: 5\d\d/i.test(msg)) {
307
+ pushAssistant(
308
+ `Connection hiccup β€” the bot may have been sleeping (HF Space cold-start). ` +
309
+ `Say "try again" or tap Send again and I'll re-run your last question.`,
310
+ );
311
+ } else {
312
+ pushAssistant(`Sorry β€” backend error: ${msg}`);
313
+ }
314
  } finally {
315
+ setUploadStatus(null);
316
  setBusy(false);
317
  }
318
  }
 
1314
  title={sc?.one_liner || c.policy_name}
1315
  >
1316
  {sc && <span className={`inline-flex items-center justify-center w-5 h-5 rounded font-bold text-[11px] ${gradeColor(sc.grade)}`}>{sc.grade}</span>}
1317
+ <span className="flex flex-col items-start leading-tight">
1318
+ {/* Bug B β€” show the insurer label above the policy name so
1319
+ "Sarvah Param" reads as "ManipalCigna Β· Sarvah Param"
1320
+ instead of an unattributed policy fragment. */}
1321
+ {c.insurer_slug && (
1322
+ <span className="text-[9px] uppercase tracking-wider text-[var(--muted-foreground)]">
1323
+ {c.insurer_slug.replace(/-/g, " ")}
1324
+ </span>
1325
+ )}
1326
+ <span className="font-medium truncate max-w-[160px]">{c.policy_name}</span>
1327
+ </span>
1328
  </button>
1329
  {c.source_url && (
1330
  <a
frontend/src/lib/api.ts CHANGED
@@ -47,6 +47,44 @@ export type ViewContext = {
47
  filters?: Record<string, unknown>;
48
  };
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  export async function postChat(args: {
51
  user_text: string;
52
  session_id?: string;
@@ -57,22 +95,27 @@ export async function postChat(args: {
57
  tts_language_code?: string;
58
  view_context?: ViewContext;
59
  signal?: AbortSignal;
 
60
  }): Promise<ChatResponse> {
61
- const resp = await fetch(`${BACKEND_URL}/api/chat`, {
62
- method: "POST",
63
- headers: { "Content-Type": "application/json" },
64
- body: JSON.stringify({
65
- user_text: args.user_text,
66
- session_id: args.session_id,
67
- chat_history: args.chat_history ?? [],
68
- profile: args.profile ?? {},
69
- policy_filter_ids: args.policy_filter_ids,
70
- return_audio: args.return_audio ?? false,
71
- tts_language_code: args.tts_language_code ?? "en-IN",
72
- view_context: args.view_context,
73
- }),
74
- signal: args.signal,
75
- });
 
 
 
 
76
  if (!resp.ok) {
77
  const t = await resp.text();
78
  throw new Error(`chat failed: ${resp.status} ${t}`);
 
47
  filters?: Record<string, unknown>;
48
  };
49
 
50
+ /** HF Space's first request after ~15min idle takes ~50s for cold-start.
51
+ * Add retry-with-backoff so a single "Load failed" doesn't surface as an
52
+ * error in the chat β€” instead we wait and try again silently. AbortError
53
+ * is NOT retried (it's intentional cancellation from Live mode's barge-in).
54
+ */
55
+ async function _fetchWithRetry(
56
+ url: string,
57
+ init: RequestInit,
58
+ signal: AbortSignal | undefined,
59
+ onRetry?: (attempt: number) => void,
60
+ ): Promise<Response> {
61
+ const retryDelaysMs = [1500, 3500, 7000];
62
+ let lastErr: unknown = null;
63
+ for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
64
+ if (signal?.aborted) throw new DOMException("aborted", "AbortError");
65
+ try {
66
+ const resp = await fetch(url, { ...init, signal });
67
+ if (resp.status >= 500 && attempt < retryDelaysMs.length) {
68
+ // 502/503 commonly means HF Space cold-start; retry
69
+ await new Promise((r) => setTimeout(r, retryDelaysMs[attempt]));
70
+ onRetry?.(attempt + 1);
71
+ continue;
72
+ }
73
+ return resp;
74
+ } catch (e) {
75
+ const name = (e as { name?: string })?.name;
76
+ if (name === "AbortError") throw e;
77
+ lastErr = e;
78
+ if (attempt < retryDelaysMs.length) {
79
+ await new Promise((r) => setTimeout(r, retryDelaysMs[attempt]));
80
+ onRetry?.(attempt + 1);
81
+ continue;
82
+ }
83
+ }
84
+ }
85
+ throw lastErr ?? new Error("network failed after retries");
86
+ }
87
+
88
  export async function postChat(args: {
89
  user_text: string;
90
  session_id?: string;
 
95
  tts_language_code?: string;
96
  view_context?: ViewContext;
97
  signal?: AbortSignal;
98
+ onRetry?: (attempt: number) => void;
99
  }): Promise<ChatResponse> {
100
+ const resp = await _fetchWithRetry(
101
+ `${BACKEND_URL}/api/chat`,
102
+ {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({
106
+ user_text: args.user_text,
107
+ session_id: args.session_id,
108
+ chat_history: args.chat_history ?? [],
109
+ profile: args.profile ?? {},
110
+ policy_filter_ids: args.policy_filter_ids,
111
+ return_audio: args.return_audio ?? false,
112
+ tts_language_code: args.tts_language_code ?? "en-IN",
113
+ view_context: args.view_context,
114
+ }),
115
+ },
116
+ args.signal,
117
+ args.onRetry,
118
+ );
119
  if (!resp.ok) {
120
  const t = await resp.text();
121
  throw new Error(`chat failed: ${resp.status} ${t}`);