Spaces:
Sleeping
fix(ux): cold-start retries + retry-intent handling + insurer-prefixed citations
Browse filesThree 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>
- frontend/src/app/page.tsx +71 -4
- frontend/src/lib/api.ts +58 -15
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 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}`);
|