rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
9a617cc
Β·
1 Parent(s): e9cfc9f

fix(voice+slots+routing): KI-212 + KI-213 + KI-215 + KI-216 bundle

Browse files

KI-212 β€” Aggressive barge-in thresholds for reliable trigger.
BARGE_IN_RMS_THRESHOLD 0.025 β†’ 0.008. BASE 0.005 β†’ 0.002. MULTIPLIER
2.0 β†’ 1.5. SUSTAINED_FRAMES 18 β†’ 6 (~100ms vs ~300ms).
Barge-in fires on much softer speech without requiring calibration.

KI-213 β€” Push-to-talk shows live interim transcript.
Added SpeechRecognition in parallel with MediaRecorder during PTT.
Interim words stream into chat input as user speaks. On release,
Sarvam transcript replaces interim text. Falls back to browser
final if Sarvam fails. Self-contained in page.tsx.

KI-215 β€” Don't prematurely flip free_form_session.
Bug: once all 6 required slots captured + ready_for_recommendations
=true, orchestrator immediately flipped free_form_session=True.
User's confirmation reply ("Yes, that sounds correct") then
routed to QA β†’ faithfulness rejected with "I'd rather not answer
that without stronger evidence." Now require the user's text to
explicitly contain a recommendation keyword (show me / recommend
/ suggest / top N / proceed / go ahead / etc.) before flipping.

KI-216 β€” health_conditions promoted REQUIRED + explicit confirmation.
Pre-existing conditions materially affect waiting periods, claim
outcomes, premium loadings. Sales_brain must ask before flipping
ready_for_recommendations=true. System prompt updated to direct
the brain to ask "Shall I put together some options for you now?"
when all required slots filled β€” wait for user affirmation before
pivoting to recommendations.

Orchestrator _REQUIRED_SLOTS now includes health_conditions; treats
empty list `[]` as a valid capture ("no conditions").

VERIFICATION:
py_compile clean (backend).
npx tsc --noEmit clean (frontend).

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

backend/orchestrator.py CHANGED
@@ -669,16 +669,42 @@ async def handle_turn(
669
  if field_name not in session.profile.asked:
670
  session.profile.asked.append(field_name)
671
 
672
- # Completion gate. The LLM owns conversation flow; we only flip
673
- # free_form_session when its own readiness signal is True AND every
674
- # required slot is filled. Otherwise leave free_form_session=False so
675
- # the next turn routes back here and the LLM continues gathering.
 
 
 
 
 
 
 
 
 
 
 
676
  _REQUIRED_SLOTS = ("name", "age", "dependents", "location_tier",
677
- "income_band", "primary_goal")
678
- if sb_result.ready_for_recommendations and all(
679
- getattr(session.profile, slot, None) not in (None, "", [])
680
- for slot in _REQUIRED_SLOTS
681
- ):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
682
  session.free_form_session = True
683
  session._flush()
684
 
 
669
  if field_name not in session.profile.asked:
670
  session.profile.asked.append(field_name)
671
 
672
+ # KI-215 (2026-05-15) β€” completion gate WITHOUT premature flip.
673
+ # Previously we flipped session.free_form_session=True as soon as
674
+ # sb_result.ready_for_recommendations=True + all 6 required slots
675
+ # were filled. Bug: user's NEXT message (e.g., "Yes, that sounds
676
+ # correct" in response to sales_brain's confirmation recap) would
677
+ # route to the QA path β†’ faithfulness gate β†’ reject with "I'd
678
+ # rather not answer that without stronger evidence...".
679
+ #
680
+ # New rule: stay in fact_find UNTIL the user's text explicitly
681
+ # contains a recommendation request keyword. Sales_brain keeps
682
+ # owning the conversation through the recap + confirmation phase;
683
+ # only when the user says "show me", "recommend", "best for me",
684
+ # "options", etc., do we flip to free_form mode.
685
+ # KI-216 β€” health_conditions now REQUIRED (pre-existing conditions
686
+ # materially affect waiting periods / exclusions / premium loadings).
687
  _REQUIRED_SLOTS = ("name", "age", "dependents", "location_tier",
688
+ "income_band", "primary_goal", "health_conditions")
689
+ # health_conditions=[] (empty list) is a VALID capture ("no conditions").
690
+ # Treat as "captured" if the field is a list (regardless of contents).
691
+ def _slot_filled(slot):
692
+ val = getattr(session.profile, slot, None)
693
+ if slot == "health_conditions":
694
+ return isinstance(val, list)
695
+ return val not in (None, "", [])
696
+ _slots_complete = all(_slot_filled(slot) for slot in _REQUIRED_SLOTS)
697
+ _utl = (user_text or "").lower()
698
+ _user_asked_for_recs = any(kw in _utl for kw in (
699
+ "recommend", "suggest", "show me", "best polic", "top 3",
700
+ "top three", "top 5", "top five", "side by side", "side-by-side",
701
+ "three options", "few options", "some options", "compare options",
702
+ "shortlist", "give me three", "give me options", "fits me",
703
+ "right for me", "policies for me", "which polic", "good polic",
704
+ "what polic", "your recommendation", "your suggestion",
705
+ "go ahead", "proceed", "let's see", "lets see",
706
+ ))
707
+ if sb_result.ready_for_recommendations and _slots_complete and _user_asked_for_recs:
708
  session.free_form_session = True
709
  session._flush()
710
 
backend/sales_brain.py CHANGED
@@ -134,12 +134,18 @@ _SLOT_DESCRIPTIONS: dict[str, str] = {
134
  # Order of slots β€” used to determine "required remaining" so the LLM has a
135
  # stable sense of what to ask next. The first six are the recommendation-
136
  # readiness minimum; the rest are deepening signals.
 
 
 
 
 
137
  _REQUIRED_FOR_READY: tuple = (
138
- "name", "age", "dependents", "location_tier", "income_band", "primary_goal",
 
139
  )
140
 
141
  _NICE_TO_HAVE: tuple = (
142
- "existing_cover_inr", "budget_band", "health_conditions",
143
  "parents_to_insure", "parents_age_max", "parents_has_ped",
144
  )
145
 
@@ -180,7 +186,8 @@ Return a SINGLE JSON object with exactly these three keys:
180
  Rules for the JSON:
181
  - "reply" β€” natural conversational prose. NO scripted prefixes ("Got that β€”", "Noted β€”", "Sure β€”", "Perfect β€”"). Acknowledge what they said by reflecting it back, then continue.
182
  - "captures" β€” only fields the user CHANGED or NEWLY revealed in their LAST message. If they didn't reveal anything new this turn, emit `"captures": {}` (empty object). Do NOT re-emit slots that haven't changed. Do NOT include null / empty-string values.
183
- - "ready_for_recommendations" β€” set to true ONLY when you have AT LEAST name, age, dependents, location, income_band, primary_goal. Otherwise false.
 
184
  - All enum values must match the slot schema EXACTLY (case-sensitive). All ints as JSON numbers, not strings. Lists as JSON arrays.
185
 
186
  EXAMPLES OF GOOD REPLIES (illustrative β€” the JSON shape is what matters):
 
134
  # Order of slots β€” used to determine "required remaining" so the LLM has a
135
  # stable sense of what to ask next. The first six are the recommendation-
136
  # readiness minimum; the rest are deepening signals.
137
+ # KI-216 (2026-05-15) β€” `health_conditions` PROMOTED from nice-to-have to
138
+ # REQUIRED. Pre-existing conditions massively affect premium, waiting
139
+ # periods, and claim outcomes β€” skipping it means recommending policies
140
+ # that may exclude the user's actual needs. The brain MUST ask before
141
+ # advancing to recommendations.
142
  _REQUIRED_FOR_READY: tuple = (
143
+ "name", "age", "dependents", "location_tier", "income_band",
144
+ "primary_goal", "health_conditions",
145
  )
146
 
147
  _NICE_TO_HAVE: tuple = (
148
+ "existing_cover_inr", "budget_band",
149
  "parents_to_insure", "parents_age_max", "parents_has_ped",
150
  )
151
 
 
186
  Rules for the JSON:
187
  - "reply" β€” natural conversational prose. NO scripted prefixes ("Got that β€”", "Noted β€”", "Sure β€”", "Perfect β€”"). Acknowledge what they said by reflecting it back, then continue.
188
  - "captures" β€” only fields the user CHANGED or NEWLY revealed in their LAST message. If they didn't reveal anything new this turn, emit `"captures": {}` (empty object). Do NOT re-emit slots that haven't changed. Do NOT include null / empty-string values.
189
+ - "ready_for_recommendations" β€” set to true ONLY when you have ALL of: name, age, dependents, location, income_band, primary_goal, AND health_conditions. **You MUST ask about pre-existing health conditions before setting this to true** β€” it materially affects which policies fit (waiting periods, exclusions, premium loadings). Empty list `[]` means "no conditions" β€” that's valid and counts as captured. Otherwise leave false.
190
+ - "reply" before flipping ready: when ALL required slots above ARE captured this turn, your reply should EXPLICITLY ask the user "Shall I put together some options for you now?" (or similar natural confirmation). Do NOT auto-recommend or auto-pivot. The user has to affirm before we move to the recommendation phase.
191
  - All enum values must match the slot schema EXACTLY (case-sensitive). All ints as JSON numbers, not strings. Lists as JSON arrays.
192
 
193
  EXAMPLES OF GOOD REPLIES (illustrative β€” the JSON shape is what matters):
frontend/src/app/page.tsx CHANGED
@@ -148,6 +148,51 @@ export default function Page() {
148
  const fileInputRef = useRef<HTMLInputElement>(null);
149
  const scrollRef = useRef<HTMLDivElement>(null);
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  // KI-168 (2026-05-15) β€” streaming-voice path replaces the legacy
152
  // useLiveConversation full-duplex VAD machinery. Interim transcript shows
153
  // in the chat input as the user speaks; browser silence-detection auto-
@@ -504,9 +549,66 @@ export default function Page() {
504
  mediaRecorderRef.current = recorder;
505
  audioChunksRef.current = [];
506
  recorder.ondataavailable = (ev) => { if (ev.data.size > 0) audioChunksRef.current.push(ev.data); };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
507
  recorder.onstop = async () => {
508
  stopVAD();
509
  stream.getTracks().forEach((t) => t.stop());
 
 
 
 
 
 
 
 
 
 
 
510
  const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || "audio/webm" });
511
  setRecording(false);
512
  // KI-028 β€” Resume Live ONLY if the user's persistent preference is
@@ -518,22 +620,47 @@ export default function Page() {
518
  // instead of returning quietly. Previously, holding PTT briefly and
519
  // releasing produced no feedback at all; now they at least see why.
520
  if (blob.size < 1000) {
 
 
 
521
  pushAssistant("Didn't catch any audio β€” try holding the mic button while speaking.");
522
  maybeResumeLive();
523
  return;
524
  }
525
  setBusy(true);
526
  setVoicePhase("transcribing"); // KI-038 β€” STT in flight on PTT
 
 
 
527
  try {
528
  const { text } = await postTranscribe(blob, ttsLang);
529
  if (text && text.trim()) {
 
 
 
 
530
  // send() flips voicePhase to "thinking" itself; no need to set here
531
  await send(text);
 
 
 
 
 
 
532
  } else {
 
533
  pushAssistant("Sorry, I couldn't hear that clearly. Please try again.");
534
  }
535
  } catch (e: unknown) {
536
- pushAssistant(`Sorry β€” transcribe error: ${e instanceof Error ? e.message : String(e)}`);
 
 
 
 
 
 
 
 
537
  } finally {
538
  setBusy(false);
539
  setVoicePhase(null);
 
148
  const fileInputRef = useRef<HTMLInputElement>(null);
149
  const scrollRef = useRef<HTMLDivElement>(null);
150
 
151
+ // KI-213 (2026-05-15) β€” PTT-scoped browser SpeechRecognition. Runs in
152
+ // PARALLEL with the existing MediaRecorder + Sarvam pipeline so the user
153
+ // sees an interim transcript in the chat input as they speak (matching the
154
+ // live-voice UX) instead of staring at an empty input until Sarvam returns.
155
+ // Final Sarvam result still wins; the recognition transcript is only used
156
+ // as a fallback if Sarvam fails or returns empty.
157
+ //
158
+ // Types redeclared locally (instead of imported from useStreamingVoice.ts)
159
+ // because that module doesn't export them and they're tiny β€” keeping PTT a
160
+ // self-contained path in page.tsx per the existing architecture.
161
+ type PTTSpeechRecognitionAlternative = { transcript: string; confidence: number };
162
+ type PTTSpeechRecognitionResult = {
163
+ isFinal: boolean;
164
+ length: number;
165
+ [index: number]: PTTSpeechRecognitionAlternative;
166
+ };
167
+ type PTTSpeechRecognitionResultList = {
168
+ length: number;
169
+ [index: number]: PTTSpeechRecognitionResult;
170
+ };
171
+ interface PTTSpeechRecognitionEventLike extends Event {
172
+ resultIndex: number;
173
+ results: PTTSpeechRecognitionResultList;
174
+ }
175
+ interface PTTSpeechRecognitionErrorEventLike extends Event {
176
+ error: string;
177
+ message?: string;
178
+ }
179
+ interface PTTSpeechRecognitionInstance extends EventTarget {
180
+ lang: string;
181
+ continuous: boolean;
182
+ interimResults: boolean;
183
+ maxAlternatives: number;
184
+ start: () => void;
185
+ stop: () => void;
186
+ abort: () => void;
187
+ onresult: ((ev: PTTSpeechRecognitionEventLike) => void) | null;
188
+ onerror: ((ev: PTTSpeechRecognitionErrorEventLike) => void) | null;
189
+ onend: ((ev: Event) => void) | null;
190
+ onstart: ((ev: Event) => void) | null;
191
+ }
192
+ type PTTSpeechRecognitionCtor = new () => PTTSpeechRecognitionInstance;
193
+ const pttRecognitionRef = useRef<PTTSpeechRecognitionInstance | null>(null);
194
+ const pttFinalTranscriptRef = useRef<string>("");
195
+
196
  // KI-168 (2026-05-15) β€” streaming-voice path replaces the legacy
197
  // useLiveConversation full-duplex VAD machinery. Interim transcript shows
198
  // in the chat input as the user speaks; browser silence-detection auto-
 
549
  mediaRecorderRef.current = recorder;
550
  audioChunksRef.current = [];
551
  recorder.ondataavailable = (ev) => { if (ev.data.size > 0) audioChunksRef.current.push(ev.data); };
552
+
553
+ // KI-213 (2026-05-15) β€” start browser SpeechRecognition in parallel
554
+ // for interim transcript display. Sarvam still produces the
555
+ // authoritative transcript; this is purely UX (so the input fills as
556
+ // the user speaks). Best-effort: if SR is unsupported or start() throws
557
+ // we silently continue with the existing Sarvam-only flow.
558
+ pttFinalTranscriptRef.current = "";
559
+ try {
560
+ const w = window as unknown as {
561
+ SpeechRecognition?: PTTSpeechRecognitionCtor;
562
+ webkitSpeechRecognition?: PTTSpeechRecognitionCtor;
563
+ };
564
+ const Ctor = w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
565
+ if (Ctor) {
566
+ const rec = new Ctor();
567
+ rec.continuous = false;
568
+ rec.interimResults = true;
569
+ rec.maxAlternatives = 1;
570
+ // ttsLang is the same locale the live-voice path uses; en-IN is the
571
+ // default fallback per the spec.
572
+ rec.lang = ttsLang || "en-IN";
573
+ rec.onresult = (ev: PTTSpeechRecognitionEventLike) => {
574
+ // Assemble interim transcript from ALL results (finals + currently
575
+ // in-progress interim). Mirror useStreamingVoice's pattern.
576
+ let interim = "";
577
+ let final = "";
578
+ for (let i = 0; i < ev.results.length; i++) {
579
+ const r = ev.results[i];
580
+ const alt = r[0];
581
+ if (!alt) continue;
582
+ if (r.isFinal) final += alt.transcript;
583
+ else interim += alt.transcript;
584
+ }
585
+ if (final) pttFinalTranscriptRef.current = final;
586
+ const display = (final + interim).trim();
587
+ if (display) setInput(display);
588
+ };
589
+ rec.onerror = () => { /* best-effort β€” Sarvam is the source of truth */ };
590
+ rec.onend = () => { /* nothing β€” recorder.onstop drives the submit */ };
591
+ pttRecognitionRef.current = rec;
592
+ rec.start();
593
+ }
594
+ } catch {
595
+ // SR unavailable or already running β€” fall through, Sarvam still works.
596
+ pttRecognitionRef.current = null;
597
+ }
598
  recorder.onstop = async () => {
599
  stopVAD();
600
  stream.getTracks().forEach((t) => t.stop());
601
+ // KI-213 (2026-05-15) β€” tear down the parallel SpeechRecognition.
602
+ // abort() is preferred over stop() to avoid a trailing onresult
603
+ // event firing AFTER we've already set the input to the Sarvam
604
+ // transcript (which would clobber it). The final transcript captured
605
+ // so far is preserved in pttFinalTranscriptRef as a Sarvam fallback.
606
+ const sr = pttRecognitionRef.current;
607
+ pttRecognitionRef.current = null;
608
+ if (sr) {
609
+ try { sr.abort(); } catch { /* already stopped */ }
610
+ }
611
+ const srFallback = pttFinalTranscriptRef.current.trim();
612
  const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || "audio/webm" });
613
  setRecording(false);
614
  // KI-028 β€” Resume Live ONLY if the user's persistent preference is
 
620
  // instead of returning quietly. Previously, holding PTT briefly and
621
  // releasing produced no feedback at all; now they at least see why.
622
  if (blob.size < 1000) {
623
+ // KI-213 β€” clear the lingering interim that SR may have left in the
624
+ // input so the user isn't confused by a stale partial transcript.
625
+ setInput("");
626
  pushAssistant("Didn't catch any audio β€” try holding the mic button while speaking.");
627
  maybeResumeLive();
628
  return;
629
  }
630
  setBusy(true);
631
  setVoicePhase("transcribing"); // KI-038 β€” STT in flight on PTT
632
+ // KI-213 β€” keep the interim transcript visible in the input as a
633
+ // placeholder while Sarvam runs. send() will clear it when (and only
634
+ // when) we actually submit, so the user sees their words throughout.
635
  try {
636
  const { text } = await postTranscribe(blob, ttsLang);
637
  if (text && text.trim()) {
638
+ // KI-213 β€” replace the interim SR transcript with Sarvam's
639
+ // authoritative version, then submit. send() clears the input
640
+ // itself so the brief flash here is intentional UX feedback.
641
+ setInput(text);
642
  // send() flips voicePhase to "thinking" itself; no need to set here
643
  await send(text);
644
+ } else if (srFallback) {
645
+ // KI-213 β€” Sarvam returned empty but the browser caught
646
+ // something. Better than telling the user "couldn't hear that
647
+ // clearly" when we actually have a usable transcript.
648
+ setInput(srFallback);
649
+ await send(srFallback);
650
  } else {
651
+ setInput("");
652
  pushAssistant("Sorry, I couldn't hear that clearly. Please try again.");
653
  }
654
  } catch (e: unknown) {
655
+ // KI-213 β€” Sarvam failed (network / 5xx / rate limit). Fall back to
656
+ // the SR transcript if we have one rather than dropping the turn.
657
+ if (srFallback) {
658
+ setInput(srFallback);
659
+ try { await send(srFallback); } catch { /* send handles its own errors */ }
660
+ } else {
661
+ setInput("");
662
+ pushAssistant(`Sorry β€” transcribe error: ${e instanceof Error ? e.message : String(e)}`);
663
+ }
664
  } finally {
665
  setBusy(false);
666
  setVoicePhase(null);
frontend/src/lib/useStreamingVoice.ts CHANGED
@@ -46,16 +46,23 @@ import { postTranscribe } from "./api";
46
  // very low RMS (~0.001-0.005) while actual user speech sits at ~0.05-0.2.
47
  // We pick a threshold in between, and require ~300ms sustained energy
48
  // to avoid firing on coughs / room thumps / single-frame spikes.
49
- const BARGE_IN_RMS_THRESHOLD = 0.025;
50
- const BARGE_IN_SUSTAINED_FRAMES = 18; // ~300ms @ 60fps rAF
 
 
 
 
51
  // KI-190 (2026-05-15) β€” adaptive threshold. The MediaRecorder mic stream
52
  // has AEC, but for very loud bot TTS the residual bleed can still cross
53
  // the static 0.025 threshold. We instead compute the threshold dynamically
54
  // from the bot's CURRENT audio level: bot_rms * MULTIPLIER + BASE. Bot
55
  // loud β†’ threshold rises so user must speak loudly to overcome residual;
56
  // bot quiet β†’ threshold drops near floor so soft speech still wins.
57
- const BARGE_IN_BOT_RMS_MULTIPLIER = 2.0;
58
- const BARGE_IN_BASE_THRESHOLD = 0.005;
 
 
 
59
  // KI-191 (2026-05-15) β€” duck bot TTS volume while voice mode is on.
60
  // Reducing playback amplitude further widens the gap between the bot's
61
  // residual mic bleed (after AEC) and the user's normal-volume speech,
 
46
  // very low RMS (~0.001-0.005) while actual user speech sits at ~0.05-0.2.
47
  // We pick a threshold in between, and require ~300ms sustained energy
48
  // to avoid firing on coughs / room thumps / single-frame spikes.
49
+ // KI-212 (2026-05-15) β€” was 0.025 / 18 frames. User reported barge-in
50
+ // completely failing: bot reads entire 14s reply uninterrupted. Lowered
51
+ // to fire on ANY decent speech burst within 100ms. Risk: false positives
52
+ // (chair creak, cough) β€” acceptable trade vs. broken barge-in.
53
+ const BARGE_IN_RMS_THRESHOLD = 0.008;
54
+ const BARGE_IN_SUSTAINED_FRAMES = 6; // ~100ms @ 60fps rAF
55
  // KI-190 (2026-05-15) β€” adaptive threshold. The MediaRecorder mic stream
56
  // has AEC, but for very loud bot TTS the residual bleed can still cross
57
  // the static 0.025 threshold. We instead compute the threshold dynamically
58
  // from the bot's CURRENT audio level: bot_rms * MULTIPLIER + BASE. Bot
59
  // loud β†’ threshold rises so user must speak loudly to overcome residual;
60
  // bot quiet β†’ threshold drops near floor so soft speech still wins.
61
+ // KI-212 β€” multiplier lowered 2.0 β†’ 1.5 + base 0.005 β†’ 0.002. Together
62
+ // with the static threshold drop, makes barge-in fire on much softer
63
+ // user speech even when bot is loud.
64
+ const BARGE_IN_BOT_RMS_MULTIPLIER = 1.5;
65
+ const BARGE_IN_BASE_THRESHOLD = 0.002;
66
  // KI-191 (2026-05-15) β€” duck bot TTS volume while voice mode is on.
67
  // Reducing playback amplitude further widens the gap between the bot's
68
  // residual mic bleed (after AEC) and the user's normal-volume speech,