Spaces:
Sleeping
fix(parser+admin+voice): KI-161 + KI-164 + KI-165 bundle
Browse filesKI-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 +25 -3
- frontend/public/admin/llm-control.html +43 -87
- frontend/src/app/page.tsx +17 -0
- frontend/src/lib/useLiveConversation.ts +82 -1
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
|
|
@@ -666,9 +666,8 @@
|
|
| 666 |
</div>
|
| 667 |
</section>
|
| 668 |
|
| 669 |
-
<!-- Tab 3: LLM Chain
|
| 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 |
-
|
| 1362 |
-
|
| 1363 |
-
|
| 1364 |
-
|
|
|
|
| 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 |
-
|
| 1545 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 1555 |
emptyTd.style.color = 'var(--muted)';
|
| 1556 |
emptyTd.style.textAlign = 'center';
|
| 1557 |
emptyTr.appendChild(emptyTd);
|
| 1558 |
availTbody.appendChild(emptyTr);
|
| 1559 |
} else {
|
| 1560 |
-
//
|
| 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 |
-
|
|
|
|
| 1573 |
var hd = createEl('span', { className: 'health-dot ' + (info.healthy ? 'ok' : 'bad') });
|
| 1574 |
-
|
| 1575 |
-
|
| 1576 |
-
tr.appendChild(
|
| 1577 |
|
| 1578 |
-
//
|
| 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 |
-
//
|
| 1586 |
-
var primaryParts = [];
|
| 1587 |
-
var backupParts = [];
|
| 1588 |
SIMPLE_USE_ORDER.forEach(function (role) {
|
| 1589 |
-
var
|
| 1590 |
-
|
| 1591 |
-
|
| 1592 |
-
|
| 1593 |
-
|
| 1594 |
-
|
| 1595 |
-
|
| 1596 |
-
|
| 1597 |
-
|
| 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 |
-
//
|
| 2428 |
-
|
| 2429 |
-
|
| 2430 |
-
|
| 2431 |
-
|
| 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');
|
|
@@ -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 |
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|