Spaces:
Sleeping
fix(closure+hydration+voice+admin): KI-248..KI-251 — V1-V4 bundle from Playwright smoke
Browse filesV1 — recommendation closure ("I lost my train of thought"):
ROOT CAUSE was a coercer/null-guard collision, NOT MAX_ITERATIONS or prompt
length. Z6 prompt told Gemini → save_profile_field(health_conditions, "none"),
_coerce_health_conditions stripped "none" → [], save_profile_field's KI-091
null-overwrite guard rejected [] as normalized_empty, profile stayed
incomplete forever, retrieve looped, MAX_ITERATIONS=5 exhausted, defensive
"lost my train of thought" fired.
Three-angle fix:
- (D primary) brain_tools._coerce_health_conditions: pure-negation tokens
("none", "no", "healthy", "n/a") now collapse to sentinel ["none"]
(non-empty list) instead of []. Mixed input ("diabetes, none") still
drops the negation noise. Downstream consumers verified safe.
- (C) brain_tools.retrieve_policies profile_incomplete return now includes
action_required="ask_user_for" + field=<first_missing> + exact_question
so Gemini relays the question verbatim instead of looping.
- (A) single_brain MAX_ITERATIONS 5→8 (headroom for chained save+retrieve)
+ new _synthesise_fallback(profile) helper replaces the generic "lost
my train of thought" with a profile-aware question for the first missing
slot, or a confirmation prompt if all 7 captured.
V2 — turn_idx admin column showing "—":
Backend wiring was correct end-to-end (session.turn_idx increments,
record_policy_event persists turn_idx in JSON). The break was at the API
boundary: GET /api/admin/recommendation-history projection dict in
admin.py omitted turn_idx entirely. Plus latent field-name mismatch:
frontend reads e.conversation_turn, backend wrote turn_idx. Fix: one line
added to the projection — "conversation_turn": entry.get("turn_idx") —
bridges both at once. Pre-Y2 events keep showing "—" (correct, no
stamping at write time); new events render the turn number.
V3 — React #418 hydration mismatch on home page:
useStreamingVoice.ts:316 isSupported defaulted false during SSR (no window
to read SpeechRecognition constructor) but true on Chrome/Safari CSR.
Different boolean drove a different pill element (<span> "Mic blocked" vs
<button> "Voice off") between SSR and CSR. Fix in page.tsx: mounted flag
(false on SSR + first CSR render, true after useEffect) gates
micPermissionDenied. SSR HTML now contains the same "Voice off" pill that
CSR renders, eliminating mismatch.
V4 — voice "green pill, zero audio" silent failure:
When getUserMedia fails (permission denied, no mic, OverconstrainedError,
etc.) the pill still said "Voice on" with zero capture — matched the
documented [[feedback_voice_silent_failure_modes]] pattern.
Fix: useStreamingVoice ensureAudioCapture catch block now emits
onVoiceError("mic_permission_denied"), flips wantRunningRef=false, halts
SR auto-restart. start() awaits ensureAudioCapture and skips safeStart on
denial. page.tsx onVoiceError handler also calls setVoicePermDenied(true)
+ setVoiceEnabled(false) to revert the pill; 4th banner case renders red
"Microphone access denied — click the lock icon and allow, then reload".
DOMException mapping: NotAllowedError / SecurityError / NotFoundError /
OverconstrainedError / NotReadableError / AbortError / unknown all
collapse to mic_permission_denied (same remediation).
Verification: py_compile clean (admin/brain_tools/single_brain), tsc
clean, next build succeeds. Playwright headless smoke with shimmed
NotAllowedError confirmed banner renders within 8s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/brain_tools.py +62 -6
- backend/single_brain.py +64 -9
- frontend/src/app/page.tsx +39 -2
- frontend/src/lib/useStreamingVoice.ts +72 -6
|
@@ -207,15 +207,51 @@ async def retrieve_policies(
|
|
| 207 |
if getattr(profile, slot, None) in (None, "", [])
|
| 208 |
]
|
| 209 |
if missing:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
return {
|
| 211 |
"chunks": [],
|
| 212 |
"count": 0,
|
| 213 |
"error": "profile_incomplete",
|
| 214 |
"missing_slots": missing,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
"instruction": (
|
| 216 |
f"Profile is incomplete — missing: {', '.join(missing)}. "
|
| 217 |
-
"Do NOT
|
| 218 |
-
"
|
|
|
|
|
|
|
| 219 |
),
|
| 220 |
}
|
| 221 |
|
|
@@ -490,7 +526,21 @@ def _coerce_existing_cover(value: Any) -> Optional[int]:
|
|
| 490 |
|
| 491 |
|
| 492 |
def _coerce_health_conditions(value: Any) -> Optional[list[str]]:
|
| 493 |
-
"""Always return list[str] lowercase, stripped, empties dropped.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
if value is None:
|
| 495 |
return None
|
| 496 |
if isinstance(value, str):
|
|
@@ -501,9 +551,15 @@ def _coerce_health_conditions(value: Any) -> Optional[list[str]]:
|
|
| 501 |
else:
|
| 502 |
items = [str(value).strip()]
|
| 503 |
cleaned = [t.lower() for t in items if t]
|
| 504 |
-
#
|
| 505 |
-
|
| 506 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
|
| 508 |
|
| 509 |
__all__ = [
|
|
|
|
| 207 |
if getattr(profile, slot, None) in (None, "", [])
|
| 208 |
]
|
| 209 |
if missing:
|
| 210 |
+
# KI-Z6-NONE follow-up (2026-05-15): make the response
|
| 211 |
+
# extremely directive so Gemini doesn't burn an iteration
|
| 212 |
+
# re-trying retrieve_policies on the same incomplete profile.
|
| 213 |
+
# Provide an exact_question string the model can literally
|
| 214 |
+
# relay to the user for the first missing slot.
|
| 215 |
+
_SLOT_QUESTIONS = {
|
| 216 |
+
"name": "What's your name?",
|
| 217 |
+
"age": "How old are you?",
|
| 218 |
+
"dependents": (
|
| 219 |
+
"Who would you like the cover to include — just you, "
|
| 220 |
+
"or spouse / kids / parents?"
|
| 221 |
+
),
|
| 222 |
+
"location_tier": "Which city do you live in?",
|
| 223 |
+
"income_band": (
|
| 224 |
+
"Roughly what's your annual household income — "
|
| 225 |
+
"under 10 lakh, 10-25 lakh, or above 25 lakh?"
|
| 226 |
+
),
|
| 227 |
+
"primary_goal": (
|
| 228 |
+
"Is this your first health policy, an upgrade, for "
|
| 229 |
+
"tax planning, or to find a cheaper option?"
|
| 230 |
+
),
|
| 231 |
+
"health_conditions": (
|
| 232 |
+
"Do you or your family have any pre-existing health "
|
| 233 |
+
"conditions like diabetes, BP, or thyroid? If none, "
|
| 234 |
+
"just say no."
|
| 235 |
+
),
|
| 236 |
+
}
|
| 237 |
+
first = missing[0]
|
| 238 |
return {
|
| 239 |
"chunks": [],
|
| 240 |
"count": 0,
|
| 241 |
"error": "profile_incomplete",
|
| 242 |
"missing_slots": missing,
|
| 243 |
+
"action_required": "ask_user_for",
|
| 244 |
+
"field": first,
|
| 245 |
+
"exact_question": _SLOT_QUESTIONS.get(
|
| 246 |
+
first,
|
| 247 |
+
f"Could you share your {first.replace('_', ' ')}?",
|
| 248 |
+
),
|
| 249 |
"instruction": (
|
| 250 |
f"Profile is incomplete — missing: {', '.join(missing)}. "
|
| 251 |
+
"Do NOT call retrieve_policies again this turn. Do NOT "
|
| 252 |
+
"retry save_profile_field for the same field. Emit a "
|
| 253 |
+
"TEXT reply that asks the user the `exact_question` "
|
| 254 |
+
"above verbatim."
|
| 255 |
),
|
| 256 |
}
|
| 257 |
|
|
|
|
| 526 |
|
| 527 |
|
| 528 |
def _coerce_health_conditions(value: Any) -> Optional[list[str]]:
|
| 529 |
+
"""Always return list[str] lowercase, stripped, empties dropped.
|
| 530 |
+
|
| 531 |
+
KI-Z6-NONE (2026-05-15): "none" / "no" / "n/a" — used to be stripped to
|
| 532 |
+
`[]`, but downstream `save_profile_field` then hits the KI-091 null-
|
| 533 |
+
overwrite guard (`normalized in (None, "", [])`) and refuses to persist
|
| 534 |
+
the slot. Result: profile.health_conditions stays empty forever,
|
| 535 |
+
`_profile_complete` returns False, retrieve_policies returns
|
| 536 |
+
profile_incomplete, the brain loops, MAX_ITERATIONS exhausts, the bot
|
| 537 |
+
emits "Sorry — I lost my train of thought" (W1 Turn 3 live blocker).
|
| 538 |
+
|
| 539 |
+
Fix: keep the explicit-negation sentinel `["none"]` so:
|
| 540 |
+
• the slot is non-empty → _profile_complete=True → retrieve fires
|
| 541 |
+
• downstream consumers can still detect "no PED" via the literal
|
| 542 |
+
token `"none"` in the list (callers already lowercase-compare).
|
| 543 |
+
"""
|
| 544 |
if value is None:
|
| 545 |
return None
|
| 546 |
if isinstance(value, str):
|
|
|
|
| 551 |
else:
|
| 552 |
items = [str(value).strip()]
|
| 553 |
cleaned = [t.lower() for t in items if t]
|
| 554 |
+
# Explicit-negation tokens — collapse to the canonical sentinel
|
| 555 |
+
# `["none"]` rather than `[]` so the slot is captured, not blanked.
|
| 556 |
+
_NEGATION = {"none", "no", "n/a", "na", "nil", "nothing", "healthy"}
|
| 557 |
+
if cleaned and all(t in _NEGATION for t in cleaned):
|
| 558 |
+
return ["none"]
|
| 559 |
+
# Mixed input ("diabetes, none") — drop the negation noise, keep real
|
| 560 |
+
# conditions.
|
| 561 |
+
real = [t for t in cleaned if t not in _NEGATION]
|
| 562 |
+
return real
|
| 563 |
|
| 564 |
|
| 565 |
__all__ = [
|
|
@@ -54,7 +54,13 @@ PER_CALL_TIMEOUT_SEC = 25.0
|
|
| 54 |
|
| 55 |
# Max iterations of the tool-call loop. Prevents runaway tool-call cycles
|
| 56 |
# where the LLM keeps calling save_profile_field on the same value.
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
# Transient-error retry policy (2026-05-15 / KI-singlebrain-503).
|
| 60 |
# Live HF Space logs (rohitsar567/InsuranceBot, 2026-05-15 08:15Z) show
|
|
@@ -457,6 +463,61 @@ def _detect_language(user_text: str) -> str:
|
|
| 457 |
return "en"
|
| 458 |
|
| 459 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
def _classify_intent(user_text: str, tool_calls_made: list[str]) -> str:
|
| 461 |
"""Best-effort intent label for logging only. Single-brain doesn't
|
| 462 |
route on intent — but the legacy `TurnResult.intent` field is logged
|
|
@@ -933,16 +994,10 @@ async def handle_turn(
|
|
| 933 |
"single_brain hit MAX_ITERATIONS=%d (tool_calls=%s)",
|
| 934 |
MAX_ITERATIONS, tool_calls_made,
|
| 935 |
)
|
| 936 |
-
last_text = (
|
| 937 |
-
last_text
|
| 938 |
-
or "Let me pause for a second — could you tell me a bit more about "
|
| 939 |
-
"what you're looking for, so I can give you a clean recommendation?"
|
| 940 |
-
)
|
| 941 |
|
| 942 |
# Build TurnResult.
|
| 943 |
-
reply_text = last_text or (
|
| 944 |
-
"Sorry — I lost my train of thought there. Could you say that again?"
|
| 945 |
-
)
|
| 946 |
|
| 947 |
# Bug C secondary defense — log a WARNING if the reply name-drops an
|
| 948 |
# insurer/product brand even though no retrieve_policies result was
|
|
|
|
| 54 |
|
| 55 |
# Max iterations of the tool-call loop. Prevents runaway tool-call cycles
|
| 56 |
# where the LLM keeps calling save_profile_field on the same value.
|
| 57 |
+
# KI-Z6-NONE (2026-05-15): bumped 5 → 8 after W1 Turn 3 live blocker.
|
| 58 |
+
# The Z6 "no medical issues" path used to need: save(health=none) +
|
| 59 |
+
# retrieve → profile_incomplete → save(health=none) + retrieve → loop
|
| 60 |
+
# exhaust. Coercer fix in brain_tools resolves the primary cause; the
|
| 61 |
+
# extra headroom protects against the next variant where Gemini chains
|
| 62 |
+
# 3-4 saves + 2 retrieves on a long pre-recommendation user turn.
|
| 63 |
+
MAX_ITERATIONS = 8
|
| 64 |
|
| 65 |
# Transient-error retry policy (2026-05-15 / KI-singlebrain-503).
|
| 66 |
# Live HF Space logs (rohitsar567/InsuranceBot, 2026-05-15 08:15Z) show
|
|
|
|
| 463 |
return "en"
|
| 464 |
|
| 465 |
|
| 466 |
+
_FALLBACK_SLOT_QUESTIONS = {
|
| 467 |
+
"name": "What's your name?",
|
| 468 |
+
"age": "How old are you?",
|
| 469 |
+
"dependents": (
|
| 470 |
+
"Who would you like the cover to include — just you, "
|
| 471 |
+
"or spouse / kids / parents?"
|
| 472 |
+
),
|
| 473 |
+
"location_tier": "Which city do you live in?",
|
| 474 |
+
"income_band": (
|
| 475 |
+
"Roughly what's your annual household income — under 10 lakh, "
|
| 476 |
+
"10-25 lakh, or above 25 lakh?"
|
| 477 |
+
),
|
| 478 |
+
"primary_goal": (
|
| 479 |
+
"Is this your first health policy, an upgrade, for tax planning, "
|
| 480 |
+
"or to find a cheaper option?"
|
| 481 |
+
),
|
| 482 |
+
"health_conditions": (
|
| 483 |
+
"Do you or your family have any pre-existing health conditions "
|
| 484 |
+
"like diabetes, BP, or thyroid? If none, just say no."
|
| 485 |
+
),
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
_FALLBACK_REQUIRED_SLOTS = (
|
| 489 |
+
"name", "age", "dependents", "location_tier",
|
| 490 |
+
"income_band", "primary_goal", "health_conditions",
|
| 491 |
+
)
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
def _synthesise_fallback(profile) -> str:
|
| 495 |
+
"""KI-Z6-NONE (2026-05-15): replace the legacy 'I lost my train of
|
| 496 |
+
thought' reply with a useful next-question synthesised from the
|
| 497 |
+
profile snapshot. If a slot is still missing, ask for the first
|
| 498 |
+
missing one verbatim. If everything's captured, ask for a recap
|
| 499 |
+
confirmation. Never empty-string — always returns user-visible text.
|
| 500 |
+
"""
|
| 501 |
+
try:
|
| 502 |
+
for slot in _FALLBACK_REQUIRED_SLOTS:
|
| 503 |
+
v = getattr(profile, slot, None)
|
| 504 |
+
if v in (None, "", []):
|
| 505 |
+
return _FALLBACK_SLOT_QUESTIONS.get(
|
| 506 |
+
slot,
|
| 507 |
+
f"Could you share your {slot.replace('_', ' ')}?",
|
| 508 |
+
)
|
| 509 |
+
# All slots present — ask the user to confirm before recommending.
|
| 510 |
+
return (
|
| 511 |
+
"Let me confirm what I have before pulling up options — "
|
| 512 |
+
"does this look right, or anything to update?"
|
| 513 |
+
)
|
| 514 |
+
except Exception: # noqa: BLE001 — never fail the fallback
|
| 515 |
+
return (
|
| 516 |
+
"Could you tell me a bit more about what you're looking for "
|
| 517 |
+
"so I can pull up the right options?"
|
| 518 |
+
)
|
| 519 |
+
|
| 520 |
+
|
| 521 |
def _classify_intent(user_text: str, tool_calls_made: list[str]) -> str:
|
| 522 |
"""Best-effort intent label for logging only. Single-brain doesn't
|
| 523 |
route on intent — but the legacy `TurnResult.intent` field is logged
|
|
|
|
| 994 |
"single_brain hit MAX_ITERATIONS=%d (tool_calls=%s)",
|
| 995 |
MAX_ITERATIONS, tool_calls_made,
|
| 996 |
)
|
| 997 |
+
last_text = last_text or _synthesise_fallback(session.profile)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 998 |
|
| 999 |
# Build TurnResult.
|
| 1000 |
+
reply_text = last_text or _synthesise_fallback(session.profile)
|
|
|
|
|
|
|
| 1001 |
|
| 1002 |
# Bug C secondary defense — log a WARNING if the reply name-drops an
|
| 1003 |
# insurer/product brand even though no retrieve_policies result was
|
|
@@ -220,6 +220,16 @@ export default function Page() {
|
|
| 220 |
const [voiceEnabled, setVoiceEnabled] = useState(false);
|
| 221 |
const [voiceListening, setVoiceListening] = useState(false);
|
| 222 |
const [voicePermDenied, setVoicePermDenied] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
// KI-223 (2026-05-15) — V1.1 / V1.2. Structured voice-error banner state.
|
| 224 |
// Populated by useStreamingVoice's onVoiceError callback when the hook hits
|
| 225 |
// a recoverable failure mode that the user can act on (tap to unlock audio,
|
|
@@ -288,11 +298,21 @@ export default function Page() {
|
|
| 288 |
},
|
| 289 |
onListening: setVoiceListening,
|
| 290 |
// KI-223 (2026-05-15) — V1.1 / V1.2. Surface recoverable voice failures
|
| 291 |
-
// as a top-right banner. The hook emits one of
|
| 292 |
// banner state stamps a fresh `ts` so the auto-dismiss timer restarts on
|
| 293 |
// every new emission (useful when the same error fires twice in a row).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
onVoiceError: (error) => {
|
| 295 |
setVoiceErrorBanner({ type: error, ts: Date.now() });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
},
|
| 297 |
});
|
| 298 |
|
|
@@ -334,7 +354,13 @@ export default function Page() {
|
|
| 334 |
const live = {
|
| 335 |
live: voiceEnabled,
|
| 336 |
recording: voiceListening,
|
| 337 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
setLive: setVoiceEnabled,
|
| 339 |
};
|
| 340 |
|
|
@@ -1066,6 +1092,17 @@ export default function Page() {
|
|
| 1066 |
{voiceErrorBanner.type === "worklet_failed" && (
|
| 1067 |
<span>Audio capture failed — please reload the page.</span>
|
| 1068 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1069 |
</div>
|
| 1070 |
<button
|
| 1071 |
type="button"
|
|
|
|
| 220 |
const [voiceEnabled, setVoiceEnabled] = useState(false);
|
| 221 |
const [voiceListening, setVoiceListening] = useState(false);
|
| 222 |
const [voicePermDenied, setVoicePermDenied] = useState(false);
|
| 223 |
+
// Hydration guard — `streamingVoice.isSupported` resolves via `typeof
|
| 224 |
+
// window` inside `useStreamingVoice`, so it's `false` on the SSR pass and
|
| 225 |
+
// (typically) `true` on the client. Rendering JSX that branches on it
|
| 226 |
+
// before hydration completes triggers React error #418 (text content
|
| 227 |
+
// mismatch / hydration failure). We pin the SSR + first-client-render
|
| 228 |
+
// output to the same shape, then flip on a post-mount effect.
|
| 229 |
+
const [mounted, setMounted] = useState(false);
|
| 230 |
+
useEffect(() => {
|
| 231 |
+
setMounted(true);
|
| 232 |
+
}, []);
|
| 233 |
// KI-223 (2026-05-15) — V1.1 / V1.2. Structured voice-error banner state.
|
| 234 |
// Populated by useStreamingVoice's onVoiceError callback when the hook hits
|
| 235 |
// a recoverable failure mode that the user can act on (tap to unlock audio,
|
|
|
|
| 298 |
},
|
| 299 |
onListening: setVoiceListening,
|
| 300 |
// KI-223 (2026-05-15) — V1.1 / V1.2. Surface recoverable voice failures
|
| 301 |
+
// as a top-right banner. The hook emits one of four error strings; the
|
| 302 |
// banner state stamps a fresh `ts` so the auto-dismiss timer restarts on
|
| 303 |
// every new emission (useful when the same error fires twice in a row).
|
| 304 |
+
// W1 (2026-05-15) — added "mic_permission_denied". When the hook reports
|
| 305 |
+
// a getUserMedia DOMException (NotAllowedError / NotFoundError / etc.)
|
| 306 |
+
// we also revert the pill back to grey + set the legacy permDenied flag
|
| 307 |
+
// so the "🔇 Mic blocked" branch fires. Without these flips the pill
|
| 308 |
+
// stayed green over a dead mic ("green pill, zero audio") until the user
|
| 309 |
+
// manually toggled off — the exact silent-failure mode this fix targets.
|
| 310 |
onVoiceError: (error) => {
|
| 311 |
setVoiceErrorBanner({ type: error, ts: Date.now() });
|
| 312 |
+
if (error === "mic_permission_denied") {
|
| 313 |
+
setVoicePermDenied(true);
|
| 314 |
+
setVoiceEnabled(false);
|
| 315 |
+
}
|
| 316 |
},
|
| 317 |
});
|
| 318 |
|
|
|
|
| 354 |
const live = {
|
| 355 |
live: voiceEnabled,
|
| 356 |
recording: voiceListening,
|
| 357 |
+
// Hydration-safe: until the post-mount effect runs, force this to false
|
| 358 |
+
// so the SSR pass and the first client render emit identical JSX (the
|
| 359 |
+
// Voice toggle pill, not the "Mic blocked" badge). After mount we trust
|
| 360 |
+
// the hook's `isSupported` flag (which reads `window.SpeechRecognition`).
|
| 361 |
+
micPermissionDenied: mounted
|
| 362 |
+
? (voicePermDenied || !streamingVoice.isSupported)
|
| 363 |
+
: false,
|
| 364 |
setLive: setVoiceEnabled,
|
| 365 |
};
|
| 366 |
|
|
|
|
| 1092 |
{voiceErrorBanner.type === "worklet_failed" && (
|
| 1093 |
<span>Audio capture failed — please reload the page.</span>
|
| 1094 |
)}
|
| 1095 |
+
{/* W1 (2026-05-15) — silent getUserMedia denial. The default
|
| 1096 |
+
non-yellow branch above already renders a red background;
|
| 1097 |
+
we just supply the human-readable copy here. Triggered when
|
| 1098 |
+
the user (or browser policy / OS / another app holding the
|
| 1099 |
+
mic) rejects the permission prompt. */}
|
| 1100 |
+
{voiceErrorBanner.type === "mic_permission_denied" && (
|
| 1101 |
+
<span>
|
| 1102 |
+
Microphone access denied. Click the lock icon next to the URL
|
| 1103 |
+
bar and allow microphone, then reload.
|
| 1104 |
+
</span>
|
| 1105 |
+
)}
|
| 1106 |
</div>
|
| 1107 |
<button
|
| 1108 |
type="button"
|
|
@@ -46,9 +46,19 @@ import {
|
|
| 46 |
retryPostTranscribe,
|
| 47 |
scaleSpeechZcrBand,
|
| 48 |
AdaptiveNoiseFloor,
|
| 49 |
-
type VoiceError,
|
| 50 |
} from "./voice_resilience";
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
// KI-189 (2026-05-15) — live-speak barge-in tuning constants.
|
| 53 |
// The MediaRecorder mic stream IS echo-cancelled by the browser (KI-185
|
| 54 |
// `getUserMedia` AEC constraints), so the bot's TTS bleed lands at a
|
|
@@ -453,8 +463,47 @@ export function useStreamingVoice(
|
|
| 453 |
console.debug("[useStreamingVoice] MediaRecorder started", { mime: recorderMimeRef.current });
|
| 454 |
return true;
|
| 455 |
} catch (err) {
|
| 456 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
recorderActiveRef.current = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
return false;
|
| 459 |
}
|
| 460 |
}, [pickRecorderMime]);
|
|
@@ -837,10 +886,27 @@ export function useStreamingVoice(
|
|
| 837 |
}
|
| 838 |
finalsRef.current = [];
|
| 839 |
finalsConsumedRef.current = 0;
|
| 840 |
-
//
|
| 841 |
-
//
|
| 842 |
-
|
| 843 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 844 |
}, [isSupported, buildRecognition, safeStart, ensureAudioCapture]);
|
| 845 |
|
| 846 |
const stop = useCallback(() => {
|
|
|
|
| 46 |
retryPostTranscribe,
|
| 47 |
scaleSpeechZcrBand,
|
| 48 |
AdaptiveNoiseFloor,
|
| 49 |
+
type VoiceError as VoiceErrorBase,
|
| 50 |
} from "./voice_resilience";
|
| 51 |
|
| 52 |
+
// W1 (2026-05-15) — additive 4th voice-error code. Surfaces a silent
|
| 53 |
+
// `getUserMedia` permission/denial failure (NotAllowedError /
|
| 54 |
+
// NotFoundError / SecurityError / generic DOMException) so page.tsx can
|
| 55 |
+
// render an actionable banner and revert the "Voice on" pill. Kept as a
|
| 56 |
+
// local widening of the base `VoiceError` union from voice_resilience.ts
|
| 57 |
+
// (which we don't touch per scope) — callers see the same
|
| 58 |
+
// `onVoiceError(err: VoiceError) => void` shape, just with one more legal
|
| 59 |
+
// string value.
|
| 60 |
+
export type VoiceError = VoiceErrorBase | "mic_permission_denied";
|
| 61 |
+
|
| 62 |
// KI-189 (2026-05-15) — live-speak barge-in tuning constants.
|
| 63 |
// The MediaRecorder mic stream IS echo-cancelled by the browser (KI-185
|
| 64 |
// `getUserMedia` AEC constraints), so the bot's TTS bleed lands at a
|
|
|
|
| 463 |
console.debug("[useStreamingVoice] MediaRecorder started", { mime: recorderMimeRef.current });
|
| 464 |
return true;
|
| 465 |
} catch (err) {
|
| 466 |
+
// W1 (2026-05-15) — DOMException name → VoiceError mapping.
|
| 467 |
+
// NotAllowedError / SecurityError → user denied or browser-blocked
|
| 468 |
+
// NotFoundError / OverconstrainedError → no usable input device
|
| 469 |
+
// NotReadableError / AbortError → OS-level mic owned by another app
|
| 470 |
+
// anything else (incl. plain Error) → treat as denial so the UI still
|
| 471 |
+
// surfaces an actionable banner
|
| 472 |
+
// ALL of these map to "mic_permission_denied" because the user-visible
|
| 473 |
+
// remediation is the same: open site permissions, allow mic, reload.
|
| 474 |
+
// Returning `false` alone was insufficient — `start()` calls this via
|
| 475 |
+
// `void ensureAudioCapture()` and never sees the rejection, so the pill
|
| 476 |
+
// stayed at "Voice on" with zero mic. Emitting onVoiceError + flipping
|
| 477 |
+
// wantRunningRef false + onListening(false) is the recovery contract.
|
| 478 |
+
const name = (err as { name?: string } | null)?.name ?? "Error";
|
| 479 |
+
console.debug(
|
| 480 |
+
"[useStreamingVoice] getUserMedia / MediaRecorder init failed",
|
| 481 |
+
{ name, err },
|
| 482 |
+
);
|
| 483 |
recorderActiveRef.current = false;
|
| 484 |
+
// `getUserMedia` rejection happens BEFORE we assign mediaStreamRef /
|
| 485 |
+
// mediaRecorderRef, so there's nothing to tear down here. The
|
| 486 |
+
// `wantRunningRef = false` + `onListening(false)` below is enough to
|
| 487 |
+
// halt the SR auto-restart loop. The parent's `enabled = false` flip
|
| 488 |
+
// (driven by the banner code) will run stop() which idempotently
|
| 489 |
+
// re-runs full cleanup.
|
| 490 |
+
// Surface to the page-level banner. Cast through the local widened
|
| 491 |
+
// VoiceError union (W1) so TS accepts the new string code.
|
| 492 |
+
try {
|
| 493 |
+
onVoiceErrorRef.current("mic_permission_denied" as VoiceError);
|
| 494 |
+
} catch {
|
| 495 |
+
/* never let a user-supplied callback crash the hook */
|
| 496 |
+
}
|
| 497 |
+
// Stop the recognition restart loop and reset listening state so the
|
| 498 |
+
// pill doesn't stay green over a dead mic. The parent (page.tsx) is
|
| 499 |
+
// expected to also flip `enabled` back to false on the banner code,
|
| 500 |
+
// which calls our `stop()` and idempotently cleans up.
|
| 501 |
+
wantRunningRef.current = false;
|
| 502 |
+
try {
|
| 503 |
+
onListeningRef.current(false);
|
| 504 |
+
} catch {
|
| 505 |
+
/* ignore */
|
| 506 |
+
}
|
| 507 |
return false;
|
| 508 |
}
|
| 509 |
}, [pickRecorderMime]);
|
|
|
|
| 886 |
}
|
| 887 |
finalsRef.current = [];
|
| 888 |
finalsConsumedRef.current = 0;
|
| 889 |
+
// W1 (2026-05-15) — gate the SR start on a successful `getUserMedia`.
|
| 890 |
+
// Previously this was `void ensureAudioCapture(); safeStart();` which
|
| 891 |
+
// raced the two in parallel: on a Chromium / iOS Safari permission
|
| 892 |
+
// denial, the recognition started, the pill flipped to "Voice on —
|
| 893 |
+
// just speak", but the mic was dead (zero audio, no banner, no log).
|
| 894 |
+
// By awaiting the capture result and skipping safeStart() on a hard
|
| 895 |
+
// denial, the pill-flip (driven by page.tsx's
|
| 896 |
+
// `onVoiceError("mic_permission_denied")` handler) lands BEFORE
|
| 897 |
+
// recognition kicks off. The ensureAudioCapture catch already sets
|
| 898 |
+
// wantRunningRef=false and emits onVoiceError on its way out.
|
| 899 |
+
void (async () => {
|
| 900 |
+
const ok = await ensureAudioCapture();
|
| 901 |
+
// Hard denial path: capture failed AND ensureAudioCapture reset
|
| 902 |
+
// wantRunningRef. Skip recognition.start — page.tsx will flip
|
| 903 |
+
// `enabled` to false on the banner code, which triggers stop().
|
| 904 |
+
if (!ok && !wantRunningRef.current) return;
|
| 905 |
+
// Soft-degraded path: capture failed but wantRunning is still true
|
| 906 |
+
// (e.g. MediaRecorder mime mismatch on a niche browser). Fall back
|
| 907 |
+
// to Web-Speech-only — onend's restart loop handles the fallback.
|
| 908 |
+
safeStart();
|
| 909 |
+
})();
|
| 910 |
}, [isSupported, buildRecognition, safeStart, ensureAudioCapture]);
|
| 911 |
|
| 912 |
const stop = useCallback(() => {
|