rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
0a02616
Β·
1 Parent(s): 9ea6d75

fix(parser+admin+voice): KI-161 + KI-164 + KI-165 bundle

Browse files

KI-161 β€” _parse_inr_amount no longer interprets bare digits below β‚Ή1000
as currency, and rejects any text with age context ("29 years old",
"age 25", "I am 35"). Origin: user answered the age question with
"I am 29 years old" and the parser wrote β‚Ή29 into both budget_band
and income_band. 10/10 test cases pass (bare-age + real-budget).

KI-164 β€” LLM Chain admin tab radically stripped to ONLY the two simple
tables. Removed: Candidate health grid (8 cols Γ— N rows of retired
models), Recent turns table, Health snapshot card with counters,
chains-grid legacy. Restructured "What's Available" Table 2 with
per-role columns (Brain Fast / Brain Main / Judge) instead of
free-form "Used By" text. refreshChain() now only feeds the
2-table renderer; the legacy fetch* + render* funcs are dead but
intentionally left in place (their target elements no longer exist
so they're never called).

KI-165 β€” Voice/text crossfire fixed.
- useLiveConversation.ts: minVoicedFrames=8 floor (~130ms),
voicedFramesRef tracker, endSpeechCapture + tearDown guards
that discard near-empty captures + captures while text is
in flight. Logs `[live-mode] discarded near-empty capture
(KI-165)` and `[live-mode] discarded capture: text request
in flight (KI-165)` for browser-console verification.
- page.tsx: isTextRequestPendingRef set in send(), cleared in
finally β€” voice hook checks this ref before submitting.
Real speech (β‰₯8 voiced frames) still flows unchanged. PTT path
untouched. Notification dings, Mac screenshot clicks, and other
ambient sub-130ms triggers no longer submit empty captures.

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

backend/needs_finder.py CHANGED
@@ -96,7 +96,16 @@ def _parse_inr_amount(text: str) -> Optional[int]:
96
  - strips fluff: "maximum 30000", "I can pay 30000", "around 25000"
97
  - tolerates per-year qualifiers: "/year", "per year", "p.a."
98
 
99
- Returns the integer rupee amount, or None if no number is recognisable.
 
 
 
 
 
 
 
 
 
100
  """
101
  if not text:
102
  return None
@@ -125,14 +134,27 @@ def _parse_inr_amount(text: str) -> Optional[int]:
125
  return int(float(m.group(1)) * 1_000)
126
  except ValueError:
127
  return None
 
 
 
 
 
 
 
 
128
  # Bare digit run β€” pick the largest number-like token (handles
129
- # "maximum 30000", "around 25000", "I can pay 30000").
 
 
130
  nums = re.findall(r"\d+(?:\.\d+)?", s)
131
  if nums:
132
  try:
133
- return int(float(max(nums, key=lambda x: float(x))))
134
  except ValueError:
135
  return None
 
 
 
136
  return None
137
 
138
 
 
96
  - strips fluff: "maximum 30000", "I can pay 30000", "around 25000"
97
  - tolerates per-year qualifiers: "/year", "per year", "p.a."
98
 
99
+ KI-161 (2026-05-15) β€” REJECTS bare digits below β‚Ή1000 (no plausible
100
+ annual health insurance budget/income falls there) and REJECTS any
101
+ text whose only number is in an age context ("29 years old", "age 29",
102
+ "I am 29"). Origin: user answered the age question with "I am 29
103
+ years old" and the parser wrote β‚Ή29 into both budget_band and
104
+ income_band, leading the bot to claim it captured age + income + budget
105
+ from a single utterance.
106
+
107
+ Returns the integer rupee amount, or None if no number is recognisable
108
+ or if the only numbers in the text are clearly not currency.
109
  """
110
  if not text:
111
  return None
 
134
  return int(float(m.group(1)) * 1_000)
135
  except ValueError:
136
  return None
137
+ # KI-161 β€” bare-digit fallback now guarded against age contexts.
138
+ # If the text is clearly about age, refuse to interpret any number
139
+ # as a currency amount.
140
+ if re.search(
141
+ r"\b(?:year|years|yr|yrs|y\s*o)\s*(?:old)?\b|\bage\b|\bi\s*am\s+\d{1,3}\b",
142
+ s,
143
+ ):
144
+ return None
145
  # Bare digit run β€” pick the largest number-like token (handles
146
+ # "maximum 30000", "around 25000", "I can pay 30000"). Magnitude
147
+ # floor of β‚Ή1000 β€” anything smaller is implausible for an annual
148
+ # health-insurance budget or income.
149
  nums = re.findall(r"\d+(?:\.\d+)?", s)
150
  if nums:
151
  try:
152
+ amt = int(float(max(nums, key=lambda x: float(x))))
153
  except ValueError:
154
  return None
155
+ if amt < 1_000:
156
+ return None
157
+ return amt
158
  return None
159
 
160
 
frontend/public/admin/llm-control.html CHANGED
@@ -666,9 +666,8 @@
666
  </div>
667
  </section>
668
 
669
- <!-- Tab 3: LLM Chain (existing functionality, preserved) -->
670
  <section id="tab-chain" class="tabpane" role="tabpanel">
671
- <!-- KI-086: LLM Health & Credits snapshot (KI-080..KI-085 telemetry) -->
672
  <div class="card" id="llm-health-card">
673
  <div class="row-between" style="margin-bottom: 12px;">
674
  <h2>LLM health <span class="small muted" id="llm-health-snapshot-ts"></span></h2>
@@ -676,42 +675,8 @@
676
  <button id="btn-refresh-llm-health">Refresh</button>
677
  </div>
678
  </div>
679
- <!-- KI-162: simplified 2-table layout (What's Live + What's Available) -->
680
  <div id="llm-health-chains" class="llm-simple-tables"></div>
681
- <!-- Section B: candidate health grid -->
682
- <h3 style="margin-top: 18px;">Candidate health grid</h3>
683
- <div id="llm-health-candidates" class="llm-health-candidates"></div>
684
- <!-- Section C: recent turns -->
685
- <h3 style="margin-top: 18px;">Recent turns (last 20)</h3>
686
- <div id="llm-health-recent" class="llm-health-recent"></div>
687
  </div>
688
-
689
- <!-- Health -->
690
- <div class="card">
691
- <div class="row-between" style="margin-bottom: 12px;">
692
- <h2>Health snapshot</h2>
693
- <div class="actions">
694
- <button id="btn-refresh">Refresh (cached)</button>
695
- <button id="btn-probe" class="primary">Force fresh probe (slow)</button>
696
- </div>
697
- </div>
698
- <div id="counters" class="counters"></div>
699
- <table>
700
- <thead>
701
- <tr>
702
- <th>Model</th>
703
- <th>Status</th>
704
- <th>Latency</th>
705
- <th>Last error</th>
706
- <th>Last success</th>
707
- </tr>
708
- </thead>
709
- <tbody id="health-tbody"></tbody>
710
- </table>
711
- </div>
712
-
713
- <!-- Chains -->
714
- <div class="chains-grid" id="chains-grid"></div>
715
  </section>
716
  </div>
717
 
@@ -1358,10 +1323,11 @@
1358
  }
1359
 
1360
  function refreshChain() {
1361
- return Promise.all([fetchHealth(), fetchChains(), fetchUsage(), fetchLlmHealth()]).then(function () {
1362
- renderCounters();
1363
- renderHealthTable();
1364
- renderChains();
 
1365
  renderLlmHealth();
1366
  setLastUpdated();
1367
  STATE.chainLoaded = true;
@@ -1541,8 +1507,13 @@
1541
  var availTable = createEl('table');
1542
  var availThead = createEl('thead');
1543
  var availHr = createEl('tr');
1544
- ['Model', 'Healthy', 'Used By'].forEach(function (h) {
1545
- availHr.appendChild(createEl('th', { text: h }));
 
 
 
 
 
1546
  });
1547
  availThead.appendChild(availHr);
1548
  availTable.appendChild(availThead);
@@ -1551,13 +1522,13 @@
1551
  var modelNames = Object.keys(modelMap);
1552
  if (!modelNames.length) {
1553
  var emptyTr = createEl('tr');
1554
- var emptyTd = createEl('td', { attrs: { colspan: '3' }, text: 'No models in any chain.' });
1555
  emptyTd.style.color = 'var(--muted)';
1556
  emptyTd.style.textAlign = 'center';
1557
  emptyTr.appendChild(emptyTd);
1558
  availTbody.appendChild(emptyTr);
1559
  } else {
1560
- // Stable sort: models that are anyone's primary first, then by name.
1561
  modelNames.sort(function (a, b) {
1562
  var aPrim = modelMap[a].roleEntries.some(function (e) { return e.isPrimary; });
1563
  var bPrim = modelMap[b].roleEntries.some(function (e) { return e.isPrimary; });
@@ -1569,37 +1540,38 @@
1569
  var tr = createEl('tr');
1570
  tr.appendChild(createEl('td', { className: 'model-name', text: model }));
1571
 
1572
- var healthyCell = createEl('td', { className: 'healthy-cell' });
 
1573
  var hd = createEl('span', { className: 'health-dot ' + (info.healthy ? 'ok' : 'bad') });
1574
- hd.title = info.healthy ? 'Healthy' : 'Unhealthy';
1575
- healthyCell.appendChild(hd);
1576
- tr.appendChild(healthyCell);
1577
 
1578
- // "Used By" β€” primary first per role, then backups; relabel via SIMPLE_USE_LABELS.
1579
- // Group by role, identify primary vs. backup using is_current_primary + chain index.
1580
  var entriesByRole = {};
1581
  info.roleEntries.forEach(function (e) {
1582
  if (!entriesByRole[e.role]) entriesByRole[e.role] = [];
1583
  entriesByRole[e.role].push(e);
1584
  });
1585
- // Order: primaries first (in SIMPLE_USE_ORDER), then backups by chain position.
1586
- var primaryParts = [];
1587
- var backupParts = [];
1588
  SIMPLE_USE_ORDER.forEach(function (role) {
1589
- var roleList = entriesByRole[role] || [];
1590
- roleList.forEach(function (e) {
1591
- var label = SIMPLE_USE_LABELS[role] || role;
1592
- if (e.isPrimary) {
1593
- primaryParts.push(label);
1594
- } else {
1595
- // idx 0 inside members is the primary itself when the chain has it;
1596
- // anything else is a backup of some rank.
1597
- backupParts.push(label + ' (backup)');
1598
- }
1599
- });
 
 
 
 
 
1600
  });
1601
- var usedBy = primaryParts.concat(backupParts).join(', ');
1602
- tr.appendChild(createEl('td', { className: 'used-by-cell', text: usedBy || 'β€”' }));
1603
 
1604
  availTbody.appendChild(tr);
1605
  });
@@ -2424,27 +2396,11 @@
2424
  .then(function () { btn.disabled = false; btn.textContent = 'Refresh'; });
2425
  };
2426
 
2427
- // LLM Chain refresh + probe (preserved from original)
2428
- $('btn-refresh').onclick = function () {
2429
- var btn = this;
2430
- btn.disabled = true;
2431
- btn.textContent = 'Refreshing…';
2432
- refreshChain()
2433
- .then(function () { toast('Refreshed from cache', 'success'); })
2434
- .catch(handleFetchErr)
2435
- .then(function () { btn.disabled = false; btn.textContent = 'Refresh (cached)'; });
2436
- };
2437
-
2438
- $('btn-probe').onclick = function () {
2439
- var btn = this;
2440
- btn.disabled = true;
2441
- btn.textContent = 'Probing…';
2442
- apiPost('/api/admin/probe', null)
2443
- .then(function () { return refreshChain(); })
2444
- .then(function () { toast('Fresh probe complete', 'success'); })
2445
- .catch(handleFetchErr)
2446
- .then(function () { btn.disabled = false; btn.textContent = 'Force fresh probe (slow)'; });
2447
- };
2448
 
2449
  // KI-086 β€” manual refresh for the LLM Health & Credits card.
2450
  var llmHealthBtn = $('btn-refresh-llm-health');
 
666
  </div>
667
  </section>
668
 
669
+ <!-- Tab 3: LLM Chain β€” KI-164: stripped to ONLY the 2-table view. -->
670
  <section id="tab-chain" class="tabpane" role="tabpanel">
 
671
  <div class="card" id="llm-health-card">
672
  <div class="row-between" style="margin-bottom: 12px;">
673
  <h2>LLM health <span class="small muted" id="llm-health-snapshot-ts"></span></h2>
 
675
  <button id="btn-refresh-llm-health">Refresh</button>
676
  </div>
677
  </div>
 
678
  <div id="llm-health-chains" class="llm-simple-tables"></div>
 
 
 
 
 
 
679
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
680
  </section>
681
  </div>
682
 
 
1323
  }
1324
 
1325
  function refreshChain() {
1326
+ // KI-164: only the simplified 2-table view remains on the LLM Chain
1327
+ // tab. fetchHealth/fetchChains/fetchUsage targeted DOM elements that
1328
+ // no longer exist (#counters, #chains-grid, #health-tbody) so they're
1329
+ // no longer called. fetchLlmHealth + renderLlmHealth feed the 2 tables.
1330
+ return fetchLlmHealth().then(function () {
1331
  renderLlmHealth();
1332
  setLastUpdated();
1333
  STATE.chainLoaded = true;
 
1507
  var availTable = createEl('table');
1508
  var availThead = createEl('thead');
1509
  var availHr = createEl('tr');
1510
+ // KI-164: per-role columns. Each model row shows where it can be used
1511
+ // (β€” / βœ“ / primary) plus a single Status column.
1512
+ var availCols = ['Model', 'Status'].concat(SIMPLE_USE_ORDER.map(function (r) { return SIMPLE_USE_LABELS[r]; }));
1513
+ availCols.forEach(function (h, i) {
1514
+ var th = createEl('th', { text: h });
1515
+ if (i >= 2) th.style.textAlign = 'center';
1516
+ availHr.appendChild(th);
1517
  });
1518
  availThead.appendChild(availHr);
1519
  availTable.appendChild(availThead);
 
1522
  var modelNames = Object.keys(modelMap);
1523
  if (!modelNames.length) {
1524
  var emptyTr = createEl('tr');
1525
+ var emptyTd = createEl('td', { attrs: { colspan: String(availCols.length) }, text: 'No models in any chain.' });
1526
  emptyTd.style.color = 'var(--muted)';
1527
  emptyTd.style.textAlign = 'center';
1528
  emptyTr.appendChild(emptyTd);
1529
  availTbody.appendChild(emptyTr);
1530
  } else {
1531
+ // Sort: anyone's-primary first, then alphabetical by model name.
1532
  modelNames.sort(function (a, b) {
1533
  var aPrim = modelMap[a].roleEntries.some(function (e) { return e.isPrimary; });
1534
  var bPrim = modelMap[b].roleEntries.some(function (e) { return e.isPrimary; });
 
1540
  var tr = createEl('tr');
1541
  tr.appendChild(createEl('td', { className: 'model-name', text: model }));
1542
 
1543
+ // Status cell: dot + word.
1544
+ var statusCell = createEl('td', { className: 'status-cell' });
1545
  var hd = createEl('span', { className: 'health-dot ' + (info.healthy ? 'ok' : 'bad') });
1546
+ statusCell.appendChild(hd);
1547
+ statusCell.appendChild(document.createTextNode(info.healthy ? ' Healthy' : ' Down'));
1548
+ tr.appendChild(statusCell);
1549
 
1550
+ // Group role entries by role for per-column lookup.
 
1551
  var entriesByRole = {};
1552
  info.roleEntries.forEach(function (e) {
1553
  if (!entriesByRole[e.role]) entriesByRole[e.role] = [];
1554
  entriesByRole[e.role].push(e);
1555
  });
1556
+ // Per-role cell: (primary) / βœ“ / β€”.
 
 
1557
  SIMPLE_USE_ORDER.forEach(function (role) {
1558
+ var roleEntries = entriesByRole[role] || [];
1559
+ var td = createEl('td', { className: 'role-cell' });
1560
+ td.style.textAlign = 'center';
1561
+ if (!roleEntries.length) {
1562
+ td.textContent = 'β€”';
1563
+ td.style.color = 'var(--muted)';
1564
+ } else if (roleEntries.some(function (e) { return e.isPrimary; })) {
1565
+ td.textContent = 'primary';
1566
+ td.style.color = 'var(--green)';
1567
+ td.style.fontWeight = '600';
1568
+ } else {
1569
+ td.textContent = 'βœ“';
1570
+ td.style.color = 'var(--text)';
1571
+ td.title = 'Backup in this chain';
1572
+ }
1573
+ tr.appendChild(td);
1574
  });
 
 
1575
 
1576
  availTbody.appendChild(tr);
1577
  });
 
2396
  .then(function () { btn.disabled = false; btn.textContent = 'Refresh'; });
2397
  };
2398
 
2399
+ // KI-164: #btn-refresh and #btn-probe were removed alongside the
2400
+ // "Health snapshot" card. The LLM Chain tab now only has the
2401
+ // simplified 2-table view, refreshed via #btn-refresh-llm-health
2402
+ // and a 30s auto-poll. The legacy buttons no longer exist; their
2403
+ // handlers are gone.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2404
 
2405
  // KI-086 β€” manual refresh for the LLM Health & Credits card.
2406
  var llmHealthBtn = $('btn-refresh-llm-health');
frontend/src/app/page.tsx CHANGED
@@ -149,11 +149,19 @@ export default function Page() {
149
  // in the component body so it can reference `sessionId`, `messages`, etc.
150
  // via closure when the onUtterance handler fires. See useLiveConversation.ts.
151
  const liveOnUtteranceRef = useRef<((blob: Blob, abort: AbortController) => Promise<void>) | null>(null);
 
 
 
 
 
 
 
152
  const live = useLiveConversation({
153
  onUtterance: async (blob, abort) => {
154
  const fn = liveOnUtteranceRef.current;
155
  if (fn) await fn(blob, abort);
156
  },
 
157
  });
158
 
159
  useEffect(() => {
@@ -278,6 +286,12 @@ export default function Page() {
278
  async function send(text: string) {
279
  if (!text.trim() || busy) return;
280
  setBusy(true);
 
 
 
 
 
 
281
  setVoicePhase("thinking"); // KI-038 β€” show "..." while waiting on brain
282
  setInput("");
283
 
@@ -374,6 +388,9 @@ export default function Page() {
374
  setUploadStatus(null);
375
  setBusy(false);
376
  setVoicePhase(null);
 
 
 
377
  }
378
  }
379
 
 
149
  // in the component body so it can reference `sessionId`, `messages`, etc.
150
  // via closure when the onUtterance handler fires. See useLiveConversation.ts.
151
  const liveOnUtteranceRef = useRef<((blob: Blob, abort: AbortController) => Promise<void>) | null>(null);
152
+ // KI-165 (2026-05-15) β€” typed-text request inflight flag, observed by the
153
+ // voice hook so background-noise-triggered captures during a typed-text
154
+ // turn are discarded silently instead of clobbering the text response.
155
+ // Set to true at the start of `send()`, reset to false in its finally.
156
+ // Use a ref (not state) so the voice hook reads the latest value without
157
+ // re-rendering / re-subscribing.
158
+ const isTextRequestPendingRef = useRef(false);
159
  const live = useLiveConversation({
160
  onUtterance: async (blob, abort) => {
161
  const fn = liveOnUtteranceRef.current;
162
  if (fn) await fn(blob, abort);
163
  },
164
+ isTextRequestPendingRef,
165
  });
166
 
167
  useEffect(() => {
 
286
  async function send(text: string) {
287
  if (!text.trim() || busy) return;
288
  setBusy(true);
289
+ // KI-165 (2026-05-15) β€” flip the text-in-flight flag so the voice hook
290
+ // (useLiveConversation) discards any captures that close during this
291
+ // request. Prevents background notification dings from opening the mic,
292
+ // submitting an empty STT round-trip, and clobbering the typed-text
293
+ // response in the chat pane. Reset in finally regardless of outcome.
294
+ isTextRequestPendingRef.current = true;
295
  setVoicePhase("thinking"); // KI-038 β€” show "..." while waiting on brain
296
  setInput("");
297
 
 
388
  setUploadStatus(null);
389
  setBusy(false);
390
  setVoicePhase(null);
391
+ // KI-165 (2026-05-15) β€” clear the text-in-flight flag so subsequent
392
+ // genuine voice captures can be submitted again.
393
+ isTextRequestPendingRef.current = false;
394
  }
395
  }
396
 
frontend/src/lib/useLiveConversation.ts CHANGED
@@ -84,6 +84,14 @@ export type LiveConversationOptions = {
84
  rmsThreshold?: number;
85
  speechStartFrames?: number;
86
  silenceEndFrames?: number;
 
 
 
 
 
 
 
 
87
  };
88
 
89
  export type LiveConversationState = {
@@ -138,6 +146,14 @@ const DEFAULTS = {
138
  // β€” if HVAC noise creeps in, KI-140 will add a /api/transcribe round
139
  // trip that detects empty responses and surfaces "couldn't hear you".
140
  voiceBandMinProp: 0.20,
 
 
 
 
 
 
 
 
141
  };
142
 
143
  // AudioWorklet processor source β€” inlined as a Blob URL so we don't need
@@ -213,6 +229,16 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
213
  const rafIdRef = useRef<number | null>(null);
214
  const inflightAbortRef = useRef<AbortController | null>(null);
215
  const recStartTsRef = useRef<number>(0);
 
 
 
 
 
 
 
 
 
 
216
  // KI-057 β€” adaptive noise floor (EMA of ambient avg while idle).
217
  const noiseFloorRef = useRef<number>(0);
218
  // KI-057 β€” gates "did the bot just stop talking?" cooldown.
@@ -358,6 +384,13 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
358
  prerollRef.current = [];
359
  recordingRef.current = true;
360
  recStartTsRef.current = Date.now();
 
 
 
 
 
 
 
361
  setRecording(true);
362
  onSpeechStartRef.current?.();
363
  }, []);
@@ -372,6 +405,8 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
372
  const durationMs = Date.now() - (recStartTsRef.current || Date.now());
373
  const chunks = speechBufferRef.current;
374
  speechBufferRef.current = [];
 
 
375
 
376
  if (chunks.length === 0) return;
377
  if (durationMs < cfg.minUtteranceMs) {
@@ -380,6 +415,37 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
380
  return;
381
  }
382
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  // Concatenate Float32Array chunks
384
  let totalSamples = 0;
385
  for (const c of chunks) totalSamples += c.length;
@@ -485,6 +551,12 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
485
  }
486
  beginSpeechCapture();
487
  }
 
 
 
 
 
 
488
  } else {
489
  quiet++;
490
  loud = 0;
@@ -572,7 +644,15 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
572
  // KI-057 β€” flush a mid-utterance capture before dropping refs.
573
  // If the user toggled Live OFF while speaking, encode + fire
574
  // onUtterance once (fire-and-forget) so their words still land.
575
- if (recordingRef.current && speechBufferRef.current.length > 0) {
 
 
 
 
 
 
 
 
576
  const durationMs = Date.now() - (recStartTsRef.current || Date.now());
577
  if (durationMs >= DEFAULTS.minUtteranceMs) {
578
  let total = 0;
@@ -607,6 +687,7 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
607
  prerollRef.current = [];
608
  noiseFloorRef.current = 0;
609
  recStartTsRef.current = 0;
 
610
  if (workletRef.current) {
611
  try { workletRef.current.disconnect(); } catch {}
612
  workletRef.current = null;
 
84
  rmsThreshold?: number;
85
  speechStartFrames?: number;
86
  silenceEndFrames?: number;
87
+ // KI-165 (2026-05-15) β€” caller-owned signal indicating a typed-text chat
88
+ // request is currently in flight. When true, voice captures that close
89
+ // during this window are silently discarded (no /api/transcribe call,
90
+ // no UI mutation). Prevents the "text typed β†’ background notification
91
+ // dings β†’ empty voice capture clobbers the typed-text response" UX bug.
92
+ // Caller flips this ref true at the start of its text send() and false
93
+ // in the finally; the voice hook reads it inside endSpeechCapture.
94
+ isTextRequestPendingRef?: React.MutableRefObject<boolean>;
95
  };
96
 
97
  export type LiveConversationState = {
 
146
  // β€” if HVAC noise creeps in, KI-140 will add a /api/transcribe round
147
  // trip that detects empty responses and surfaces "couldn't hear you".
148
  voiceBandMinProp: 0.20,
149
+ // KI-165 (2026-05-15) β€” minimum genuinely-voiced frames required before
150
+ // we'll submit a captured segment. A frame β‰ˆ 1 raf tick (~16 ms). 8 frames
151
+ // β‰ˆ 130 ms of audio that actually cleared the voice-band + threshold gate.
152
+ // Anything shorter is almost certainly a notification ding / cough / chair
153
+ // creak that briefly cleared `speechLike` for the speechStartFrames burst
154
+ // and then died β€” we must not POST that to /api/transcribe + clobber the
155
+ // chat pane.
156
+ minVoicedFrames: 8,
157
  };
158
 
159
  // AudioWorklet processor source β€” inlined as a Blob URL so we don't need
 
229
  const rafIdRef = useRef<number | null>(null);
230
  const inflightAbortRef = useRef<AbortController | null>(null);
231
  const recStartTsRef = useRef<number>(0);
232
+ // KI-165 (2026-05-15) β€” count VAD frames that genuinely cleared the
233
+ // voice-band + threshold gate while a capture is in progress. Used by
234
+ // endSpeechCapture / flush-on-stop to discard captures that opened on a
235
+ // notification ding / cough but never accumulated real speech. Reset on
236
+ // every beginSpeechCapture so each segment is judged on its own merits.
237
+ const voicedFramesRef = useRef<number>(0);
238
+ // KI-165 (2026-05-15) β€” caller-owned flag indicating a typed-text chat
239
+ // request is currently awaiting its response. Voice captures closed
240
+ // during this window are discarded silently.
241
+ const isTextRequestPendingRef = opts.isTextRequestPendingRef;
242
  // KI-057 β€” adaptive noise floor (EMA of ambient avg while idle).
243
  const noiseFloorRef = useRef<number>(0);
244
  // KI-057 β€” gates "did the bot just stop talking?" cooldown.
 
384
  prerollRef.current = [];
385
  recordingRef.current = true;
386
  recStartTsRef.current = Date.now();
387
+ // KI-165 β€” reset the voiced-frame counter for this segment. The frames
388
+ // that triggered speechStart (1 burst of speechStartFrames) are
389
+ // intentionally NOT pre-counted; we want endSpeechCapture's >= 8-frame
390
+ // floor to mean "8 frames of *sustained* voiced energy DURING capture",
391
+ // not "the trigger burst was long enough" β€” a notification ding can
392
+ // easily produce 5 frames of broadband energy that clears voiceBandMinProp.
393
+ voicedFramesRef.current = 0;
394
  setRecording(true);
395
  onSpeechStartRef.current?.();
396
  }, []);
 
405
  const durationMs = Date.now() - (recStartTsRef.current || Date.now());
406
  const chunks = speechBufferRef.current;
407
  speechBufferRef.current = [];
408
+ const voicedFrames = voicedFramesRef.current;
409
+ voicedFramesRef.current = 0;
410
 
411
  if (chunks.length === 0) return;
412
  if (durationMs < cfg.minUtteranceMs) {
 
415
  return;
416
  }
417
 
418
+ // KI-165 (2026-05-15) β€” discard captures with too few genuinely-voiced
419
+ // frames. Notification dings / Mac camera screenshot clicks / chair
420
+ // creaks can briefly clear the voice-band threshold for the trigger
421
+ // burst (speechStartFrames) but never accumulate real speech. Without
422
+ // this guard, we POST a 1.5s WAV of mostly silence to /api/transcribe,
423
+ // get back an empty string, but still flap UI state (voicePhase,
424
+ // isProcessing) and β€” most damagingly β€” race with an in-flight typed
425
+ // text response.
426
+ if (voicedFrames < DEFAULTS.minVoicedFrames) {
427
+ // eslint-disable-next-line no-console
428
+ console.debug(
429
+ "[live-mode] discarded near-empty capture (KI-165)",
430
+ { voicedFrames, minRequired: DEFAULTS.minVoicedFrames, durationMs },
431
+ );
432
+ return;
433
+ }
434
+
435
+ // KI-165 (2026-05-15) β€” if the user typed a message and that chat
436
+ // request is still in flight, the voice path silently discards this
437
+ // capture. Text wins; voice never touches chat state during a typed
438
+ // turn. Prevents the "type β†’ mac notif ding opens mic β†’ empty STT
439
+ // response clobbers the typed-text response" UX bug.
440
+ if (isTextRequestPendingRef?.current) {
441
+ // eslint-disable-next-line no-console
442
+ console.debug(
443
+ "[live-mode] discarded capture: text request in flight (KI-165)",
444
+ { voicedFrames, durationMs },
445
+ );
446
+ return;
447
+ }
448
+
449
  // Concatenate Float32Array chunks
450
  let totalSamples = 0;
451
  for (const c of chunks) totalSamples += c.length;
 
551
  }
552
  beginSpeechCapture();
553
  }
554
+ // KI-165 (2026-05-15) β€” count voiced frames during capture. Used by
555
+ // endSpeechCapture to discard segments that opened on a transient
556
+ // (notification ding) but never accumulated real speech.
557
+ if (recordingRef.current) {
558
+ voicedFramesRef.current++;
559
+ }
560
  } else {
561
  quiet++;
562
  loud = 0;
 
644
  // KI-057 β€” flush a mid-utterance capture before dropping refs.
645
  // If the user toggled Live OFF while speaking, encode + fire
646
  // onUtterance once (fire-and-forget) so their words still land.
647
+ // KI-165 (2026-05-15) β€” honor the same voiced-frames + text-in-flight
648
+ // guards here so toggling Live OFF mid-noise-burst doesn't also
649
+ // submit garbage.
650
+ if (
651
+ recordingRef.current &&
652
+ speechBufferRef.current.length > 0 &&
653
+ voicedFramesRef.current >= DEFAULTS.minVoicedFrames &&
654
+ !isTextRequestPendingRef?.current
655
+ ) {
656
  const durationMs = Date.now() - (recStartTsRef.current || Date.now());
657
  if (durationMs >= DEFAULTS.minUtteranceMs) {
658
  let total = 0;
 
687
  prerollRef.current = [];
688
  noiseFloorRef.current = 0;
689
  recStartTsRef.current = 0;
690
+ voicedFramesRef.current = 0;
691
  if (workletRef.current) {
692
  try { workletRef.current.disconnect(); } catch {}
693
  workletRef.current = null;