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

fix(voice+llm+admin): KI-200/201/202/203/204/205/206/207/208/210/211 bundle

Browse files

THE LAST OPERATIONAL FIX BUNDLE β€” voice + recommendation routing + LLM
election + admin clarity. Docs cascade held for post-test confirmation.

KI-200 β€” Admin LLM Chain redesigned to 2 clean tables.
Table 1 "Currently In Use" (per use case + provider + tier).
Table 2 "All Eligible Models" with explicit Primary / 1st backup /
Last resort labels. Provider column color-coded (Google blue / NVIDIA
green / OpenRouter orange).

KI-201 β€” Election by chain order (not latency score).
backend/llm_health.py get_primary + get_backup now walk the chain
definition in priority order. Returns first eligible model. nemotron
only serves as last resort. Resolves "maverick elected over qwen
when all healthy" bug.

KI-202 β€” Utterance batching with 1.5s grace.
Mid-sentence pauses < 1.5s accumulate into one submission.
pendingUtteranceRef + pendingChunksRef + grace timer per onend.

KI-203 β€” Block recognition transcripts during TTS.
dropResultsRef gates onresult while audio plays + 300ms post-TTS
delay. Eliminates "flexibility" / "perfect days to get started"
echo into the input box.

KI-204 β€” Universal audio pause on send().
Every send() pauses + resets all <audio> elements before submitting.
Prior bot TTS shuts up the instant user starts a new turn.

KI-205 β€” Broadened recommendation-query detection.
Added "show me", "side by side", "three options", "shortlist",
"give me three", etc. Faithfulness gate no longer misfires on
natural recommendation phrasings.

KI-206 β€” Regulatory chunks excluded from chat citations + brain.
insurer_slug='regulatory' filtered from chunks BEFORE brain call
(for recommendation/comparison intents) AND from citations list.

KI-207 β€” Admin LLM Chain tab stripped to bare essentials.
Removed outer card wrapper + row-between flex wrapper + actions
wrapper. Only h2 + Refresh button + chains container remain.

KI-208 β€” Defensive regulatory filter on /api/policies/all pass-2.
Mirrors pass-1 KI-132 filter for the curated facts path.

KI-210 β€” Voice utterances NEVER dropped (wait-and-retry).
KI-202's 3 drop-on-text-racing sites converted to poll + 30s cap.
User speech captured during bot's thinking gap no longer silently
lost.

KI-211 β€” TTS volume default 0.6 β†’ 0.3.
Quieter first-turn TTS so KI-189 barge-in VAD works pre-calibration.
Bot stays audible; mic-bleed minimized.

VERIFICATION:
py_compile clean (all backend files).
npx tsc --noEmit clean (frontend).
Election: chain order honored.
Voice path: no echo, no drop, barge-in capable from turn 1.

DOCS CASCADE: ADR-042 + CLAUDE.md + README.md stashed locally; will
land in a follow-up commit after live confirmation.

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

backend/llm_health.py CHANGED
@@ -27,13 +27,16 @@ Records per model:
27
  - probe_history: last PROBE_HISTORY_LEN (ok, latency_ms) tuples; powers
28
  the success_rate signal in the election score.
29
 
30
- Election (KI-080):
31
- - For each chain (brain / fast_brain / judge), compute a score per healthy
32
- candidate: score = (1 / max(50, latency_ms)) * success_rate_last_5.
33
- - CURRENT_PRIMARY = highest scorer.
34
- - CURRENT_BACKUP = highest scorer among candidates with a DIFFERENT
35
- provider (NIM vs Groq vs OpenRouter) than primary; falls back to next-
36
- best same-provider candidate when no cross-provider option qualifies.
 
 
 
37
  - DEGRADED window: when chat() calls report_failure(model), that model is
38
  sidelined for either DEGRADED_WINDOW_SEC (transient, 30s) or
39
  DEGRADE_DURATION_LONG_S (rate-limit / HTTP 429, 1h β€” KI-084) so the
@@ -420,62 +423,59 @@ def _ranked_candidates(chain_name: str) -> list[ModelHealth]:
420
 
421
 
422
  def get_primary(chain_name: str) -> Optional[str]:
423
- """Top-scoring election-eligible candidate, or None when no probe data
424
- is fresh enough.
425
-
426
- KI-087 (2026-05-15) β€” **NIM-first preference.** NIM is the strategic
427
- free provider (ADR-019, single-key, $0 cost, 110+ models, no daily
428
- cap). Groq has a hard 100K tokens/day free-tier cap and OpenRouter
429
- charges a real USD balance. Both should serve as EMERGENCY fallback
430
- only, not as primary just because their probe latency is lower.
431
- Election therefore prefers ANY eligible NIM candidate over ALL
432
- non-NIM candidates. Only when the NIM pool is empty (every NIM
433
- model is down, throttled, or out of credits) does election fall
434
- through to Groq/OpenRouter as primary. Within the NIM pool, the
435
- standard latency Γ— success_rate score still picks the fastest
436
- healthy NIM model.
 
 
 
437
  """
438
- ranked = _ranked_candidates(chain_name)
439
- if not ranked:
440
  return None
441
- # KI-087: NIM-first within the eligible set.
442
- nim_pool = [h for h in ranked if provider_of(h.model) == "nim"]
443
- if nim_pool:
444
- return nim_pool[0].model
445
- # No eligible NIM candidate β€” fall through to cross-provider primary.
446
- return ranked[0].model
 
 
447
 
448
 
449
  def get_backup(chain_name: str) -> Optional[str]:
450
- """Second-best election candidate. Prefers a DIFFERENT provider from
451
- primary so a single provider's regional outage can't take out both.
452
-
453
- KI-087 (2026-05-15) β€” when PRIMARY is NIM (the typical case under
454
- NIM-first preference), BACKUP is the best NON-NIM candidate
455
- (Groq / OpenRouter) β€” kept as the cross-provider safety net. When
456
- NIM is wholly unavailable and PRIMARY is non-NIM, BACKUP is the
457
- next-best non-NIM candidate or, failing that, the next-best
458
- same-provider candidate.
 
459
  """
460
- ranked = _ranked_candidates(chain_name)
461
- if len(ranked) < 2:
462
- # No usable backup. Either zero candidates or only one (in which
463
- # case the cold-start path in NimChainLLM falls back to chain[1]).
464
  return None
 
465
  primary = get_primary(chain_name)
466
- if primary is None:
467
- return None
468
- primary_provider = provider_of(primary)
469
- # Prefer cross-provider against the PRIMARY's provider.
470
- for h in ranked:
471
- if h.model == primary:
472
  continue
473
- if provider_of(h.model) != primary_provider:
474
- return h.model
475
- # No cross-provider candidate β€” accept same-provider next-best.
476
- for h in ranked:
477
- if h.model != primary:
478
- return h.model
479
  return None
480
 
481
 
 
27
  - probe_history: last PROBE_HISTORY_LEN (ok, latency_ms) tuples; powers
28
  the success_rate signal in the election score.
29
 
30
+ Election (KI-201, 2026-05-15 β€” supersedes KI-080/KI-087 score-based election):
31
+ - Walk the chain definition (BRAIN_CHAIN / FAST_BRAIN_CHAIN / JUDGE_CHAIN)
32
+ in priority order. CURRENT_PRIMARY = first election-eligible model.
33
+ CURRENT_BACKUP = next election-eligible model after primary.
34
+ - Chain hierarchy IS the truth β€” nemotron-49b is LAST in BRAIN_CHAIN so
35
+ it only serves when qwen + mistral + maverick are all unavailable.
36
+ - Latency / success_rate still gate ELIGIBILITY (probe-fresh, not in
37
+ sin-bin, credits above water) but no longer drive ORDERING. Pre-KI-201
38
+ the (1/latency)Γ—success_rate score let a chain-#3 model beat chain-#0
39
+ on a faster probe, violating the operator-defined hierarchy.
40
  - DEGRADED window: when chat() calls report_failure(model), that model is
41
  sidelined for either DEGRADED_WINDOW_SEC (transient, 30s) or
42
  DEGRADE_DURATION_LONG_S (rate-limit / HTTP 429, 1h β€” KI-084) so the
 
423
 
424
 
425
  def get_primary(chain_name: str) -> Optional[str]:
426
+ """KI-201 (2026-05-15) β€” elect by CHAIN ORDER, not latency score.
427
+
428
+ Walk the chain definition in priority order. Return the first model
429
+ that's election-eligible. Chain hierarchy IS the truth β€” e.g. in
430
+ BRAIN_CHAIN, nemotron-49b is LAST so it only serves when every
431
+ higher-priority model is unavailable (KI-175 last-resort rule).
432
+
433
+ Pre-KI-201 the elector picked by (1/latency) Γ— success_rate, which
434
+ meant a chain-#3 model (maverick) could beat chain-#0 (qwen) on a
435
+ faster probe. Live evidence: all 4 NIM models healthy but elector
436
+ picked maverick over qwen. User-stated spec: "If a model is not
437
+ already in use, but at the backend everything is live, then it
438
+ should always suggest it according to our set hierarchy."
439
+
440
+ Latency is no longer used for primary/backup selection β€” eligibility
441
+ filtering still uses the rolling probe state, but ordering is
442
+ purely chain-positional.
443
  """
444
+ chain = _chain_for(chain_name)
445
+ if not chain:
446
  return None
447
+ # _ranked_candidates returns the eligibility-filtered set (probe-fresh,
448
+ # not in sin-bin, credit-not-exhausted, healthy). Reduce to a set for
449
+ # O(1) lookup; we ignore its score-based ordering.
450
+ eligible_models = {h.model for h in _ranked_candidates(chain_name)}
451
+ for model in chain:
452
+ if model in eligible_models:
453
+ return model
454
+ return None # nothing eligible
455
 
456
 
457
  def get_backup(chain_name: str) -> Optional[str]:
458
+ """KI-201 (2026-05-15) β€” backup is the SECOND eligible model in chain
459
+ order, skipping the elected primary.
460
+
461
+ Walks the chain definition in priority order, skips the elected
462
+ primary, and returns the next eligible model. Same chain-as-truth
463
+ philosophy as get_primary: the chains in nvidia_nim_llm.py were
464
+ designed with family/provider diversity baked in (Qwen β†’ Mistral β†’
465
+ Meta β†’ NVIDIA), so walking past the primary in chain order naturally
466
+ preserves the cross-family backup invariant KI-087 used to enforce
467
+ via provider_of().
468
  """
469
+ chain = _chain_for(chain_name)
470
+ if not chain:
 
 
471
  return None
472
+ eligible_models = {h.model for h in _ranked_candidates(chain_name)}
473
  primary = get_primary(chain_name)
474
+ for model in chain:
475
+ if model == primary:
 
 
 
 
476
  continue
477
+ if model in eligible_models:
478
+ return model
 
 
 
 
479
  return None
480
 
481
 
backend/main.py CHANGED
@@ -1933,6 +1933,13 @@ async def policies_all(session_id: Optional[str] = None):
1933
  continue
1934
  seen_policy_ids.add(curated_policy_id)
1935
  slug = data.get("insurer_slug", "")
 
 
 
 
 
 
 
1936
  name, home = insurer_meta.get(slug, (slug, ""))
1937
  # Insurer reviews for scorecard
1938
  ir = None
 
1933
  continue
1934
  seen_policy_ids.add(curated_policy_id)
1935
  slug = data.get("insurer_slug", "")
1936
+ # KI-208 (2026-05-15) β€” defensive symmetry with pass-1 (line 1842): any
1937
+ # curated_facts entry with insurer_slug=='regulatory' must NOT surface
1938
+ # as a marketplace card. Today no curated regulatory docs exist, but
1939
+ # adding the filter here closes a future-leak vector if an operator
1940
+ # accidentally curates an IRDAI/NHA fact-sheet under 40-data/policy_facts.
1941
+ if slug == "regulatory":
1942
+ continue
1943
  name, home = insurer_meta.get(slug, (slug, ""))
1944
  # Insurer reviews for scorecard
1945
  ir = None
backend/orchestrator.py CHANGED
@@ -998,6 +998,37 @@ async def handle_turn(
998
  profile_name_slug=profile_slug_for_retrieve,
999
  session_id=session_id,
1000
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1001
  context_str = format_for_llm_context(chunks)
1002
 
1003
  # 3. Pick brain
@@ -1099,15 +1130,9 @@ async def handle_turn(
1099
  # answers stitch together evidence from many policies plus the user's
1100
  # profile β€” the judge can't grade that fairly and currently blocks valid
1101
  # recommendation flows after fact_find completes. Detect via query shape.
1102
- _user_text_lc = (user_text or "").lower()
1103
- _is_recommendation = any(
1104
- kw in _user_text_lc for kw in (
1105
- "recommend", "suggest", "best polic", "top 3", "top three",
1106
- "top 5", "top five", "show me polic", "show me what", "fits me",
1107
- "right for me", "policies for me", "which polic", "good polic",
1108
- "what polic", "your suggestion", "your recommendation",
1109
- )
1110
- )
1111
  _skip_judge = (intent == "fact_find") or bool(in_fact_find_continuation) or _is_recommendation
1112
  if _skip_judge:
1113
  skip_reason = "ki171_skip_on_recommendation" if _is_recommendation else "ki091_skip_on_fact_find"
@@ -1177,6 +1202,14 @@ async def handle_turn(
1177
  # context about the user (age/dependents/income/etc.), but it is NOT a
1178
  # "cited policy" and should not appear in the chat's "CITED POLICIES"
1179
  # card. Filter by insurer_slug=='profile' (set by profile_rag.upsert).
 
 
 
 
 
 
 
 
1180
  citations = [
1181
  {
1182
  "policy_id": c.policy_id,
@@ -1188,7 +1221,7 @@ async def handle_turn(
1188
  "score": round(c.score, 3),
1189
  }
1190
  for c in chunks
1191
- if (c.insurer_slug or "").lower() != "profile"
1192
  ]
1193
 
1194
  # 7. INDIC CASCADE β€” translate the English reply back into Hinglish/Hindi,
 
998
  profile_name_slug=profile_slug_for_retrieve,
999
  session_id=session_id,
1000
  )
1001
+
1002
+ # KI-205 (2026-05-15) β€” detect recommendation-shaped queries. The original
1003
+ # KI-171 keyword list missed phrases like "show me three options side by
1004
+ # side", which routed a recommendation turn through the faithfulness gate
1005
+ # and into a generic refusal. Expanded to cover side-by-side/shortlist/
1006
+ # options-comparison shapes. Computed BEFORE the regulatory filter below
1007
+ # (KI-206) so both the post-retrieve filter and the judge-skip can reuse it.
1008
+ _user_text_lc = (user_text or "").lower()
1009
+ _is_recommendation = any(
1010
+ kw in _user_text_lc for kw in (
1011
+ "recommend", "suggest", "best polic", "top 3", "top three",
1012
+ "top 5", "top five", "show me polic", "show me what", "fits me",
1013
+ "right for me", "policies for me", "which polic", "good polic",
1014
+ "what polic", "your suggestion", "your recommendation",
1015
+ # KI-205 (2026-05-15) β€” user said "show me show me three options
1016
+ # side by side" β†’ wasn't matched β†’ faithfulness gate misfired.
1017
+ "show me", "show me three", "show me few", "show me options",
1018
+ "show me a few", "show me some", "side by side", "side-by-side",
1019
+ "three options", "few options", "some options", "compare options",
1020
+ "shortlist", "give me options", "give me three",
1021
+ )
1022
+ )
1023
+
1024
+ # KI-206 (2026-05-15) β€” regulatory chunks (IRDAI consolidated, etc.) are
1025
+ # master regulatory text, not actual policies. They shouldn't appear in
1026
+ # user-facing recommendation citations. Drop them post-retrieve for
1027
+ # recommendation/comparison intent. Non-recommendation queries (e.g. "what
1028
+ # does IRDAI say about waiting periods") still see regulatory evidence.
1029
+ if _is_recommendation or intent == "comparison":
1030
+ chunks = [c for c in chunks if (c.insurer_slug or "").lower() != "regulatory"]
1031
+
1032
  context_str = format_for_llm_context(chunks)
1033
 
1034
  # 3. Pick brain
 
1130
  # answers stitch together evidence from many policies plus the user's
1131
  # profile β€” the judge can't grade that fairly and currently blocks valid
1132
  # recommendation flows after fact_find completes. Detect via query shape.
1133
+ # KI-205 (2026-05-15) β€” `_is_recommendation` is now computed once earlier
1134
+ # (immediately after retrieve) so the same boolean drives both the
1135
+ # regulatory-chunk post-filter (KI-206) and this judge-skip.
 
 
 
 
 
 
1136
  _skip_judge = (intent == "fact_find") or bool(in_fact_find_continuation) or _is_recommendation
1137
  if _skip_judge:
1138
  skip_reason = "ki171_skip_on_recommendation" if _is_recommendation else "ki091_skip_on_fact_find"
 
1202
  # context about the user (age/dependents/income/etc.), but it is NOT a
1203
  # "cited policy" and should not appear in the chat's "CITED POLICIES"
1204
  # card. Filter by insurer_slug=='profile' (set by profile_rag.upsert).
1205
+ # KI-206 (2026-05-15) β€” also exclude regulatory chunks (IRDAI consolidated,
1206
+ # etc.). They're master regulatory text, not policies the user can buy, so
1207
+ # showing them under "CITED POLICIES" misled users into thinking the bot
1208
+ # had policy evidence when it had only regulator text. Non-recommendation
1209
+ # queries that genuinely ask about regulations still surface regulatory
1210
+ # chunks via context_str (the post-retrieve filter above only drops them
1211
+ # for recommendation/comparison intent), but they never appear in the
1212
+ # chat's policy-citation card.
1213
  citations = [
1214
  {
1215
  "policy_id": c.policy_id,
 
1221
  "score": round(c.score, 3),
1222
  }
1223
  for c in chunks
1224
+ if (c.insurer_slug or "").lower() not in ("profile", "regulatory")
1225
  ]
1226
 
1227
  # 7. INDIC CASCADE β€” translate the English reply back into Hinglish/Hindi,
frontend/public/admin/llm-control.html CHANGED
@@ -668,28 +668,14 @@
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>
674
- <div class="actions">
675
- <button id="btn-refresh-llm-health">Refresh</button>
676
- </div>
677
- </div>
678
- <!-- KI-184 (2026-05-15) β€” Tier banner so the table below isn't
679
- confused with the actual primary serving traffic. Real-world
680
- call order: Tier 0 Google Gemini β†’ Tier 1 NIM (shown below) β†’
681
- Tier 2 OpenRouter free pool β†’ NIM nemotron-49b last resort. -->
682
- <div style="margin-bottom: 14px; padding: 10px 14px; border: 1px solid var(--border); border-radius: 8px; background: #0b0d12; font-size: 12px; line-height: 1.5;">
683
- <strong style="color: var(--text);">3-tier brain chain (KI-179 + ADR-040):</strong>
684
- <span class="muted">
685
- Tier 0 β€” <strong>Google Gemini 2.5 Flash / Flash Lite</strong> (PRIMARY, free 1500/day)
686
- &nbsp;Β·&nbsp; Tier 1 β€” NIM (shown below)
687
- &nbsp;Β·&nbsp; Tier 2 β€” OpenRouter free pool
688
- &nbsp;Β·&nbsp; Last resort β€” NIM Nemotron-49B
689
- </span>
690
- </div>
691
- <div id="llm-health-chains" class="llm-simple-tables"></div>
692
- </div>
693
  </section>
694
  </div>
695
 
@@ -1442,230 +1428,279 @@
1442
  );
1443
  }
1444
 
1445
- function renderLlmSimpleTables(host, chains) {
1446
- // ---- Table 1: What's Live ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1447
  var liveBlock = createEl('div', { className: 'simple-table-block' });
1448
- liveBlock.appendChild(createEl('h3', { text: "What's Live" }));
1449
  var liveTable = createEl('table');
1450
  var liveThead = createEl('thead');
1451
  var liveHr = createEl('tr');
1452
- ['Use', 'Currently In Use', 'Probed'].forEach(function (h) {
1453
- liveHr.appendChild(createEl('th', { text: h }));
 
 
1454
  });
1455
  liveThead.appendChild(liveHr);
1456
  liveTable.appendChild(liveThead);
1457
  var liveTbody = createEl('tbody');
1458
 
1459
- // Index chains by role for stable Brain Fast / Brain Main / Judge ordering.
1460
- var chainsByRole = {};
1461
- (chains || []).forEach(function (c) { if (c && c.role) chainsByRole[c.role] = c; });
1462
-
1463
  SIMPLE_USE_ORDER.forEach(function (role) {
1464
- var c = chainsByRole[role];
1465
  var tr = createEl('tr');
 
 
 
 
1466
  tr.appendChild(createEl('td', { className: 'use-label', text: SIMPLE_USE_LABELS[role] }));
1467
 
1468
  var modelCell = createEl('td', { className: 'mono' });
1469
- if (c && c.current_primary && !isRetiredModel(c.current_primary)) {
1470
- if (c.current_primary_available === false) {
1471
- var dot = createEl('span', { className: 'health-dot bad' });
1472
- dot.style.marginRight = '6px';
1473
- modelCell.appendChild(dot);
1474
- }
1475
- modelCell.appendChild(document.createTextNode(c.current_primary));
1476
- } else {
1477
- modelCell.textContent = 'β€”';
1478
- }
1479
  tr.appendChild(modelCell);
1480
 
1481
- var probedCell = createEl('td', { className: 'probed-cell' });
1482
- if (c && c.last_probe_age_seconds != null) {
1483
- probedCell.textContent = fmtDurationShort(c.last_probe_age_seconds) + ' ago';
1484
- } else {
1485
- probedCell.textContent = 'β€”';
1486
- }
1487
- tr.appendChild(probedCell);
 
 
 
 
 
 
 
1488
  liveTbody.appendChild(tr);
1489
  });
1490
  liveTable.appendChild(liveTbody);
1491
  liveBlock.appendChild(liveTable);
1492
  host.appendChild(liveBlock);
1493
 
1494
- // ---- Table 2: What's Available ----
1495
- // Walk every chain_members[] across the 3 chains, dedupe by model,
1496
- // and collect: healthy boolean + used-by-role list with primary-first ordering.
1497
- var modelMap = {}; // model -> { healthy, roleEntries: [{role, isPrimary, idx}] }
1498
- (chains || []).forEach(function (c) {
1499
- if (!c || !Array.isArray(c.chain_members)) return;
1500
- c.chain_members.forEach(function (mi, idx) {
1501
- if (!mi || !mi.model || isRetiredModel(mi.model)) return;
1502
- if (!modelMap[mi.model]) {
1503
- modelMap[mi.model] = {
1504
- healthy: !!mi.available_for_calls,
1505
- roleEntries: []
1506
- };
1507
- } else {
1508
- // If any chain reports the model as available, treat it as healthy.
1509
- if (mi.available_for_calls) modelMap[mi.model].healthy = true;
1510
- }
1511
- modelMap[mi.model].roleEntries.push({
1512
- role: c.role,
1513
- isPrimary: !!mi.is_current_primary,
1514
- idx: idx
1515
- });
1516
- });
1517
- });
1518
-
1519
  var availBlock = createEl('div', { className: 'simple-table-block' });
1520
- availBlock.appendChild(createEl('h3', { text: "What's Available" }));
1521
  var availTable = createEl('table');
1522
  var availThead = createEl('thead');
1523
  var availHr = createEl('tr');
1524
- // KI-186 (2026-05-15) β€” added "Tier" column so Gemini + OR rows can
1525
- // be shown alongside NIM rows. Tier 0 = Google AI Studio, Tier 1 =
1526
- // NIM (probed), Tier 2 = OpenRouter free pool.
1527
- // KI-187 (2026-05-15) β€” added explicit "Provider" column ("Google
1528
- // AI Studio" / "NVIDIA NIM" / "OpenRouter") so users don't have to
1529
- // infer provider from the model-id suffix (e.g., qwen3-next-80b
1530
- // appears in BOTH NIM and OR; only `:free` suffix disambiguated).
1531
- var availCols = ['Model', 'Provider', 'Tier', 'Status'].concat(SIMPLE_USE_ORDER.map(function (r) { return SIMPLE_USE_LABELS[r]; }));
1532
  availCols.forEach(function (h, i) {
1533
  var th = createEl('th', { text: h });
1534
- if (i >= 4) th.style.textAlign = 'center';
1535
- if (i === 1 || i === 2 || i === 3) th.style.textAlign = 'center';
1536
  availHr.appendChild(th);
1537
  });
1538
  availThead.appendChild(availHr);
1539
  availTable.appendChild(availThead);
1540
  var availTbody = createEl('tbody');
1541
 
1542
- // KI-186 β€” Tier 0 (Google) + Tier 2 (OR) are STATIC rows; they're
1543
- // not in llm_health's probe registry but they're real members of
1544
- // the production chain per sales_brain.py + tiered_brain_llm.py.
1545
- // Status defaults to 'Healthy' (we don't have probe data for these
1546
- // tiers; they fail-loud at call time per the fail-loud > silent-
1547
- // garbage invariant).
1548
- var STATIC_TIER_ROWS = [
1549
- // Tier 0 β€” Google AI Studio (Gemini, free 1500/day)
1550
- { model: 'google/gemini-2.5-flash', provider: 'Google AI Studio', tier: 'T0', healthy: true, brainFast: 'β€”', brainMain: 'primary', judge: 'β€”' },
1551
- { model: 'google/gemini-2.5-flash-lite', provider: 'Google AI Studio', tier: 'T0', healthy: true, brainFast: 'primary', brainMain: 'β€”', judge: 'β€”' },
1552
- // Tier 2 β€” OpenRouter free pool (server-side fallback inside OR)
1553
- { model: 'nvidia/nemotron-3-super-120b-a12b:free', provider: 'OpenRouter', tier: 'T2', healthy: true, brainFast: 'βœ“', brainMain: 'βœ“', judge: 'β€”' },
1554
- { model: 'qwen/qwen3-next-80b-a3b-instruct:free', provider: 'OpenRouter', tier: 'T2', healthy: true, brainFast: 'βœ“', brainMain: 'β€”', judge: 'βœ“' },
1555
- { model: 'google/gemma-4-31b-it:free', provider: 'OpenRouter', tier: 'T2', healthy: true, brainFast: 'βœ“', brainMain: 'β€”', judge: 'β€”' },
1556
- ];
1557
- // KI-187 β€” provider colors: blue for Google (matches their brand),
1558
- // green for NVIDIA NIM (matches NVIDIA), orange for OpenRouter
1559
- // (distinct from the others).
1560
- var PROVIDER_COLORS = {
1561
- 'Google AI Studio': '#4285f4', // Google blue
1562
- 'NVIDIA NIM': '#76b900', // NVIDIA green
1563
- 'OpenRouter': '#ffa657', // OR orange
1564
- };
1565
- STATIC_TIER_ROWS.forEach(function (row) {
1566
  var tr = createEl('tr');
1567
- tr.appendChild(createEl('td', { className: 'model-name', text: row.model }));
1568
- // Provider badge β€” KI-187
1569
  var provCell = createEl('td', { className: 'provider-cell' });
1570
  provCell.style.textAlign = 'center';
1571
  provCell.style.fontWeight = '600';
1572
- provCell.style.color = PROVIDER_COLORS[row.provider] || 'var(--muted)';
1573
- provCell.textContent = row.provider;
1574
  tr.appendChild(provCell);
1575
- // Tier badge
1576
  var tierCell = createEl('td', { className: 'tier-cell' });
1577
  tierCell.style.textAlign = 'center';
1578
  tierCell.style.fontWeight = '600';
1579
- tierCell.style.color = row.tier === 'T0' ? 'var(--green)' : 'var(--muted)';
1580
- tierCell.textContent = row.tier;
1581
  tr.appendChild(tierCell);
1582
- // Status cell
1583
- var statusCell = createEl('td', { className: 'status-cell' });
1584
- var hd = createEl('span', { className: 'health-dot ' + (row.healthy ? 'ok' : 'bad') });
1585
- statusCell.appendChild(hd);
1586
- statusCell.appendChild(document.createTextNode(row.healthy ? ' Healthy' : ' Down'));
1587
- tr.appendChild(statusCell);
1588
- // Per-role cells
1589
- [row.brainFast, row.brainMain, row.judge].forEach(function (val) {
1590
- var td = createEl('td', { className: 'role-cell' });
1591
- td.style.textAlign = 'center';
1592
- td.textContent = val;
1593
- if (val === 'β€”') { td.style.color = 'var(--muted)'; }
1594
- else if (val === 'primary') { td.style.color = 'var(--green)'; td.style.fontWeight = '600'; }
1595
- tr.appendChild(td);
1596
- });
1597
- availTbody.appendChild(tr);
1598
- });
1599
 
1600
- var modelNames = Object.keys(modelMap);
1601
- if (!modelNames.length) {
1602
- // After KI-186 we always have static Tier 0 + Tier 2 rows, so a
1603
- // "no models" message would be misleading even if NIM has none.
1604
- } else {
1605
- // Sort: anyone's-primary first, then alphabetical by model name.
1606
- modelNames.sort(function (a, b) {
1607
- var aPrim = modelMap[a].roleEntries.some(function (e) { return e.isPrimary; });
1608
- var bPrim = modelMap[b].roleEntries.some(function (e) { return e.isPrimary; });
1609
- if (aPrim !== bPrim) return aPrim ? -1 : 1;
1610
- return a.localeCompare(b);
1611
  });
1612
- modelNames.forEach(function (model) {
1613
- var info = modelMap[model];
1614
- var tr = createEl('tr');
1615
- tr.appendChild(createEl('td', { className: 'model-name', text: model }));
1616
-
1617
- // KI-187 β€” Provider badge (NIM-derived rows all come from NVIDIA NIM)
1618
- var provCell = createEl('td', { className: 'provider-cell' });
1619
- provCell.style.textAlign = 'center';
1620
- provCell.style.fontWeight = '600';
1621
- provCell.style.color = PROVIDER_COLORS['NVIDIA NIM'];
1622
- provCell.textContent = 'NVIDIA NIM';
1623
- tr.appendChild(provCell);
1624
-
1625
- // KI-186 β€” Tier badge (NIM models are Tier 1)
1626
- var tierCell = createEl('td', { className: 'tier-cell' });
1627
- tierCell.style.textAlign = 'center';
1628
- tierCell.style.fontWeight = '600';
1629
- tierCell.style.color = 'var(--muted)';
1630
- tierCell.textContent = 'T1';
1631
- tr.appendChild(tierCell);
1632
-
1633
- // Status cell: dot + word.
1634
- var statusCell = createEl('td', { className: 'status-cell' });
1635
- var hd = createEl('span', { className: 'health-dot ' + (info.healthy ? 'ok' : 'bad') });
1636
- statusCell.appendChild(hd);
1637
- statusCell.appendChild(document.createTextNode(info.healthy ? ' Healthy' : ' Down'));
1638
- tr.appendChild(statusCell);
1639
-
1640
- // Group role entries by role for per-column lookup.
1641
- var entriesByRole = {};
1642
- info.roleEntries.forEach(function (e) {
1643
- if (!entriesByRole[e.role]) entriesByRole[e.role] = [];
1644
- entriesByRole[e.role].push(e);
1645
- });
1646
- // Per-role cell: (primary) / βœ“ / β€”.
1647
- SIMPLE_USE_ORDER.forEach(function (role) {
1648
- var roleEntries = entriesByRole[role] || [];
1649
- var td = createEl('td', { className: 'role-cell' });
1650
- td.style.textAlign = 'center';
1651
- if (!roleEntries.length) {
1652
- td.textContent = 'β€”';
1653
- td.style.color = 'var(--muted)';
1654
- } else if (roleEntries.some(function (e) { return e.isPrimary; })) {
1655
- td.textContent = 'primary';
1656
- td.style.color = 'var(--green)';
1657
- td.style.fontWeight = '600';
1658
- } else {
1659
- td.textContent = 'βœ“';
1660
- td.style.color = 'var(--text)';
1661
- td.title = 'Backup in this chain';
1662
- }
1663
- tr.appendChild(td);
1664
- });
1665
 
1666
- availTbody.appendChild(tr);
1667
- });
1668
- }
1669
  availTable.appendChild(availTbody);
1670
  availBlock.appendChild(availTable);
1671
  host.appendChild(availBlock);
@@ -1810,7 +1845,8 @@
1810
  }
1811
 
1812
  clearChildren(chainsHost);
1813
- renderLlmSimpleTables(chainsHost, STATE.llmHealth.chains || []);
 
1814
 
1815
  renderLlmHealthCandidates(STATE.llmHealth.candidates || []);
1816
  renderLlmHealthRecent(STATE.llmHealth.recent_turns || []);
 
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
+ <!-- KI-207 (2026-05-15) β€” per user instruction "There should be nothing else
672
+ in that LLM router part of the control panel." Only the 3 elements below
673
+ may live inside this section: the h2 header (with snapshot timestamp
674
+ span), the refresh button, and the chains container that holds the
675
+ two generated tables (Currently In Use + All Eligible Models). -->
676
+ <h2>LLM health <span class="small muted" id="llm-health-snapshot-ts"></span></h2>
677
+ <button id="btn-refresh-llm-health">Refresh</button>
678
+ <div id="llm-health-chains" class="llm-simple-tables"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
679
  </section>
680
  </div>
681
 
 
1428
  );
1429
  }
1430
 
1431
+ // KI-200 (2026-05-15) β€” chain definitions mirror backend/sales_brain.py +
1432
+ // backend/providers/tiered_brain_llm.py + nvidia_nim_llm.py chain order.
1433
+ // Tier 0 = Google AI Studio (Gemini) β€” PRIMARY for fast_brain + brain.
1434
+ // Tier 1 = NVIDIA NIM (chain order matters; last entry = "Last resort").
1435
+ // Tier 2 = OpenRouter free pool β€” backstop inside NIM-failed path.
1436
+ // Judge intentionally has NO Tier 0 (cross-family invariant, ADR-040).
1437
+ var CHAIN_DEFINITIONS = {
1438
+ fast_brain: {
1439
+ tier0: { provider: 'Google AI Studio', model: 'google/gemini-2.5-flash-lite' },
1440
+ tier1: [
1441
+ 'qwen/qwen3-next-80b-a3b-instruct',
1442
+ 'mistralai/mistral-large-3-675b-instruct-2512',
1443
+ 'meta/llama-4-maverick-17b-128e-instruct',
1444
+ 'nvidia/llama-3.3-nemotron-super-49b-v1.5'
1445
+ ],
1446
+ tier2: [
1447
+ 'nvidia/nemotron-3-super-120b-a12b:free',
1448
+ 'qwen/qwen3-next-80b-a3b-instruct:free',
1449
+ 'google/gemma-4-31b-it:free'
1450
+ ]
1451
+ },
1452
+ brain: {
1453
+ tier0: { provider: 'Google AI Studio', model: 'google/gemini-2.5-flash' },
1454
+ tier1: [
1455
+ 'qwen/qwen3-next-80b-a3b-instruct',
1456
+ 'mistralai/mistral-large-3-675b-instruct-2512',
1457
+ 'meta/llama-4-maverick-17b-128e-instruct',
1458
+ 'nvidia/llama-3.3-nemotron-super-49b-v1.5'
1459
+ ],
1460
+ tier2: [
1461
+ 'nvidia/nemotron-3-super-120b-a12b:free',
1462
+ 'qwen/qwen3-next-80b-a3b-instruct:free',
1463
+ 'google/gemma-4-31b-it:free'
1464
+ ]
1465
+ },
1466
+ judge: {
1467
+ tier0: null,
1468
+ tier1: [
1469
+ 'mistralai/mistral-large-3-675b-instruct-2512',
1470
+ 'meta/llama-4-maverick-17b-128e-instruct',
1471
+ 'nvidia/llama-3.3-nemotron-super-49b-v1.5'
1472
+ ],
1473
+ tier2: [
1474
+ 'qwen/qwen3-next-80b-a3b-instruct:free'
1475
+ ]
1476
+ }
1477
+ };
1478
+
1479
+ // KI-187 β€” provider colors: blue for Google, green for NVIDIA NIM,
1480
+ // orange for OpenRouter.
1481
+ var PROVIDER_COLORS = {
1482
+ 'Google AI Studio': '#4285f4',
1483
+ 'NVIDIA NIM': '#76b900',
1484
+ 'OpenRouter': '#ffa657'
1485
+ };
1486
+
1487
+ function providerForModel(model) {
1488
+ if (!model) return 'NVIDIA NIM';
1489
+ if (model.indexOf('google/gemini') === 0) return 'Google AI Studio';
1490
+ if (model.indexOf(':free') !== -1) return 'OpenRouter';
1491
+ return 'NVIDIA NIM';
1492
+ }
1493
+
1494
+ // KI-200 β€” compute the model that would ACTUALLY serve right now for a
1495
+ // given role. Strategy:
1496
+ // 1. If Tier 0 exists for this role AND Gemini is available, return Tier 0.
1497
+ // 2. Else fall back to NIM's elected primary from chains[role].current_primary.
1498
+ // 3. Judge never uses Tier 0 (cross-family invariant) β€” always NIM primary.
1499
+ // TODO(backend): expose top-level `gemini_available: bool` in
1500
+ // /api/admin/llm-health response. Until then we ASSUME Gemini is
1501
+ // available (true) β€” matches the deployed Tier-0-primary state.
1502
+ function resolveCurrentlyInUse(role, chainsByRole, healthPayload) {
1503
+ var def = CHAIN_DEFINITIONS[role];
1504
+ var geminiAvailable = (healthPayload && typeof healthPayload.gemini_available === 'boolean')
1505
+ ? healthPayload.gemini_available
1506
+ : true; // default-assume true; backend can override later
1507
+ if (def && def.tier0 && geminiAvailable) {
1508
+ return { model: def.tier0.model, provider: def.tier0.provider, tier: 'T0', isTier0: true };
1509
+ }
1510
+ var c = chainsByRole[role];
1511
+ if (c && c.current_primary && !isRetiredModel(c.current_primary)) {
1512
+ return { model: c.current_primary, provider: providerForModel(c.current_primary), tier: 'T1', isTier0: false };
1513
+ }
1514
+ return { model: 'β€”', provider: 'β€”', tier: 'β€”', isTier0: false };
1515
+ }
1516
+
1517
+ // KI-200 β€” build the unioned model list across all chains with
1518
+ // per-chain role labels. Returns array of:
1519
+ // { model, provider, tier, roles: { fast_brain, brain, judge } }
1520
+ // where each `roles[r]` is one of:
1521
+ // 'Primary' | '1st backup' | '2nd backup' | '3rd backup' | 'Last resort' | null
1522
+ function buildEligibleModelTable() {
1523
+ var BACKUP_LABELS = ['Primary', '1st backup', '2nd backup', '3rd backup', '4th backup', '5th backup'];
1524
+ var models = {}; // model -> entry
1525
+
1526
+ function ensureEntry(model, tier) {
1527
+ if (!models[model]) {
1528
+ models[model] = {
1529
+ model: model,
1530
+ provider: providerForModel(model),
1531
+ tier: tier,
1532
+ roles: { fast_brain: null, brain: null, judge: null },
1533
+ // for sorting: minimum chain position across all chains (lower = more important)
1534
+ minPos: 999,
1535
+ anyPrimary: false
1536
+ };
1537
+ }
1538
+ return models[model];
1539
+ }
1540
+
1541
+ Object.keys(CHAIN_DEFINITIONS).forEach(function (role) {
1542
+ var def = CHAIN_DEFINITIONS[role];
1543
+ // Build the ordered chain for this role:
1544
+ // [tier0?, ...tier1, ...tier2]. The LAST entry overall is "Last resort".
1545
+ var ordered = [];
1546
+ if (def.tier0) ordered.push({ model: def.tier0.model, tier: 'T0' });
1547
+ (def.tier1 || []).forEach(function (m) { ordered.push({ model: m, tier: 'T1' }); });
1548
+ (def.tier2 || []).forEach(function (m) { ordered.push({ model: m, tier: 'T2' }); });
1549
+ var lastIdx = ordered.length - 1;
1550
+ ordered.forEach(function (slot, idx) {
1551
+ var entry = ensureEntry(slot.model, slot.tier);
1552
+ // If a model appears in multiple chains/tiers, the tier sticks to
1553
+ // its first-seen value β€” but Tier 0 wins (lowest number) since
1554
+ // Gemini fast-lite/flash are unique per role and never collide.
1555
+ if (slot.tier === 'T0') entry.tier = 'T0';
1556
+ else if (entry.tier !== 'T0' && slot.tier === 'T1') entry.tier = 'T1';
1557
+ // (T2 only overrides nothing.)
1558
+ var label;
1559
+ if (idx === lastIdx) {
1560
+ label = 'Last resort';
1561
+ } else if (idx < BACKUP_LABELS.length) {
1562
+ label = BACKUP_LABELS[idx];
1563
+ } else {
1564
+ label = (idx + 1) + 'th backup';
1565
+ }
1566
+ entry.roles[role] = label;
1567
+ if (idx < entry.minPos) entry.minPos = idx;
1568
+ if (label === 'Primary') entry.anyPrimary = true;
1569
+ });
1570
+ });
1571
+
1572
+ // Sort: tier asc (T0 < T1 < T2), then primaries first, then by minPos.
1573
+ var TIER_RANK = { 'T0': 0, 'T1': 1, 'T2': 2 };
1574
+ var arr = Object.keys(models).map(function (k) { return models[k]; });
1575
+ arr.sort(function (a, b) {
1576
+ var ta = TIER_RANK[a.tier] != null ? TIER_RANK[a.tier] : 99;
1577
+ var tb = TIER_RANK[b.tier] != null ? TIER_RANK[b.tier] : 99;
1578
+ if (ta !== tb) return ta - tb;
1579
+ if (a.anyPrimary !== b.anyPrimary) return a.anyPrimary ? -1 : 1;
1580
+ if (a.minPos !== b.minPos) return a.minPos - b.minPos;
1581
+ return a.model.localeCompare(b.model);
1582
+ });
1583
+ return arr;
1584
+ }
1585
+
1586
+ function renderRoleLabelCell(label) {
1587
+ var td = createEl('td', { className: 'role-cell' });
1588
+ td.style.textAlign = 'center';
1589
+ if (!label) {
1590
+ td.textContent = 'β€”';
1591
+ td.style.color = 'var(--muted)';
1592
+ } else if (label === 'Primary') {
1593
+ td.textContent = 'Primary';
1594
+ td.style.color = 'var(--green)';
1595
+ td.style.fontWeight = '600';
1596
+ } else if (label === 'Last resort') {
1597
+ td.textContent = 'Last resort';
1598
+ td.style.color = 'var(--muted)';
1599
+ td.style.fontStyle = 'italic';
1600
+ } else {
1601
+ td.textContent = label;
1602
+ td.style.color = 'var(--text)';
1603
+ }
1604
+ return td;
1605
+ }
1606
+
1607
+ // KI-200 β€” accepts the full llm-health payload so we can read
1608
+ // `gemini_available` (when backend exposes it). Caller signature
1609
+ // updated below in renderLlmHealth().
1610
+ function renderLlmSimpleTables(host, healthPayload) {
1611
+ var chains = (healthPayload && healthPayload.chains) || [];
1612
+ var chainsByRole = {};
1613
+ chains.forEach(function (c) { if (c && c.role) chainsByRole[c.role] = c; });
1614
+
1615
+ // ---- Table 1: Currently In Use ----
1616
  var liveBlock = createEl('div', { className: 'simple-table-block' });
1617
+ liveBlock.appendChild(createEl('h3', { text: 'Currently In Use' }));
1618
  var liveTable = createEl('table');
1619
  var liveThead = createEl('thead');
1620
  var liveHr = createEl('tr');
1621
+ ['Use', 'Current Model', 'Provider', 'Tier'].forEach(function (h, i) {
1622
+ var th = createEl('th', { text: h });
1623
+ if (i >= 2) th.style.textAlign = 'center';
1624
+ liveHr.appendChild(th);
1625
  });
1626
  liveThead.appendChild(liveHr);
1627
  liveTable.appendChild(liveThead);
1628
  var liveTbody = createEl('tbody');
1629
 
 
 
 
 
1630
  SIMPLE_USE_ORDER.forEach(function (role) {
1631
+ var live = resolveCurrentlyInUse(role, chainsByRole, healthPayload);
1632
  var tr = createEl('tr');
1633
+ if (live.isTier0) {
1634
+ // KI-200 β€” subtle green wash to signal "real primary serving traffic"
1635
+ tr.style.background = 'rgba(118, 185, 0, 0.06)';
1636
+ }
1637
  tr.appendChild(createEl('td', { className: 'use-label', text: SIMPLE_USE_LABELS[role] }));
1638
 
1639
  var modelCell = createEl('td', { className: 'mono' });
1640
+ modelCell.textContent = live.model;
 
 
 
 
 
 
 
 
 
1641
  tr.appendChild(modelCell);
1642
 
1643
+ var provCell = createEl('td', { className: 'provider-cell' });
1644
+ provCell.style.textAlign = 'center';
1645
+ provCell.style.fontWeight = '600';
1646
+ provCell.style.color = PROVIDER_COLORS[live.provider] || 'var(--muted)';
1647
+ provCell.textContent = live.provider;
1648
+ tr.appendChild(provCell);
1649
+
1650
+ var tierCell = createEl('td', { className: 'tier-cell' });
1651
+ tierCell.style.textAlign = 'center';
1652
+ tierCell.style.fontWeight = '600';
1653
+ tierCell.style.color = live.tier === 'T0' ? 'var(--green)' : 'var(--muted)';
1654
+ tierCell.textContent = live.tier;
1655
+ tr.appendChild(tierCell);
1656
+
1657
  liveTbody.appendChild(tr);
1658
  });
1659
  liveTable.appendChild(liveTbody);
1660
  liveBlock.appendChild(liveTable);
1661
  host.appendChild(liveBlock);
1662
 
1663
+ // ---- Table 2: All Eligible Models ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1664
  var availBlock = createEl('div', { className: 'simple-table-block' });
1665
+ availBlock.appendChild(createEl('h3', { text: 'All Eligible Models' }));
1666
  var availTable = createEl('table');
1667
  var availThead = createEl('thead');
1668
  var availHr = createEl('tr');
1669
+ var availCols = ['Model', 'Provider', 'Tier'].concat(SIMPLE_USE_ORDER.map(function (r) { return SIMPLE_USE_LABELS[r]; }));
 
 
 
 
 
 
 
1670
  availCols.forEach(function (h, i) {
1671
  var th = createEl('th', { text: h });
1672
+ if (i >= 1) th.style.textAlign = 'center';
 
1673
  availHr.appendChild(th);
1674
  });
1675
  availThead.appendChild(availHr);
1676
  availTable.appendChild(availThead);
1677
  var availTbody = createEl('tbody');
1678
 
1679
+ var rows = buildEligibleModelTable();
1680
+ rows.forEach(function (entry) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1681
  var tr = createEl('tr');
1682
+ tr.appendChild(createEl('td', { className: 'model-name', text: entry.model }));
1683
+
1684
  var provCell = createEl('td', { className: 'provider-cell' });
1685
  provCell.style.textAlign = 'center';
1686
  provCell.style.fontWeight = '600';
1687
+ provCell.style.color = PROVIDER_COLORS[entry.provider] || 'var(--muted)';
1688
+ provCell.textContent = entry.provider;
1689
  tr.appendChild(provCell);
1690
+
1691
  var tierCell = createEl('td', { className: 'tier-cell' });
1692
  tierCell.style.textAlign = 'center';
1693
  tierCell.style.fontWeight = '600';
1694
+ tierCell.style.color = entry.tier === 'T0' ? 'var(--green)' : 'var(--muted)';
1695
+ tierCell.textContent = entry.tier;
1696
  tr.appendChild(tierCell);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1697
 
1698
+ SIMPLE_USE_ORDER.forEach(function (role) {
1699
+ tr.appendChild(renderRoleLabelCell(entry.roles[role]));
 
 
 
 
 
 
 
 
 
1700
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1701
 
1702
+ availTbody.appendChild(tr);
1703
+ });
 
1704
  availTable.appendChild(availTbody);
1705
  availBlock.appendChild(availTable);
1706
  host.appendChild(availBlock);
 
1845
  }
1846
 
1847
  clearChildren(chainsHost);
1848
+ // KI-200 β€” pass the full payload so renderer can read gemini_available
1849
+ renderLlmSimpleTables(chainsHost, STATE.llmHealth);
1850
 
1851
  renderLlmHealthCandidates(STATE.llmHealth.candidates || []);
1852
  renderLlmHealthRecent(STATE.llmHealth.recent_turns || []);
frontend/src/app/page.tsx CHANGED
@@ -329,6 +329,20 @@ export default function Page() {
329
 
330
  async function send(text: string) {
331
  if (!text.trim() || busy) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  setBusy(true);
333
  // KI-165 (2026-05-15) β€” flip the text-in-flight flag so the voice hook
334
  // (useLiveConversation) discards any captures that close during this
 
329
 
330
  async function send(text: string) {
331
  if (!text.trim() || busy) return;
332
+ // KI-204 (2026-05-15) β€” silence any prior bot TTS BEFORE submitting.
333
+ // User starting a new turn always takes precedence over the bot's
334
+ // current reply audio. Covers typed sends, voice barge-in, manual Send
335
+ // button, programmatic submits β€” every path through send() gets this.
336
+ if (typeof document !== "undefined") {
337
+ document.querySelectorAll("audio").forEach((el) => {
338
+ try {
339
+ (el as HTMLAudioElement).pause();
340
+ (el as HTMLAudioElement).currentTime = 0;
341
+ } catch {
342
+ // ignore β€” element may be in a state that disallows pause
343
+ }
344
+ });
345
+ }
346
  setBusy(true);
347
  // KI-165 (2026-05-15) β€” flip the text-in-flight flag so the voice hook
348
  // (useLiveConversation) discards any captures that close during this
frontend/src/lib/useStreamingVoice.ts CHANGED
@@ -61,7 +61,12 @@ const BARGE_IN_BASE_THRESHOLD = 0.005;
61
  // residual mic bleed (after AEC) and the user's normal-volume speech,
62
  // making barge-in trivial. 0.6 is loud enough to hear clearly on
63
  // headphones and laptop speakers without overpowering user speech.
64
- const VOICE_MODE_TTS_VOLUME = 0.6;
 
 
 
 
 
65
  // KI-195 (2026-05-15) β€” adaptive TTS volume calibration relative to user's
66
  // own measured speech level. Architecture: while user speaks (recorder
67
  // active, NOT TTS) we sample mic RMS and track a rolling peak in
@@ -78,6 +83,21 @@ const VOLUME_CALIB_TICK_MS = 300; // calibration sample period duri
78
  const VOLUME_CALIB_DUCK_FACTOR = 0.8; // multiply el.volume by this per tick if too loud
79
  const VOLUME_CALIB_FLOOR = 0.15; // never drop bot below this β€” must stay audible
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  // Minimal types for the Web Speech API since lib.dom.d.ts ships them under
82
  // `webkitSpeechRecognition` only and the standard `SpeechRecognition` symbol
83
  // is still vendor-prefixed in most browsers as of 2026-05.
@@ -177,6 +197,24 @@ export function useStreamingVoice(
177
  // Tracked via a MutationObserver + per-element play/pause/ended hooks.
178
  const isTtsPlayingRef = useRef(false);
179
  const ttsAudioElementsRef = useRef<Set<HTMLAudioElement>>(new Set());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
  // ----------------------------------------------------------------------
182
  // KI-168 PHASE 2 β€” Sarvam authoritative-transcript layer.
@@ -207,6 +245,23 @@ export function useStreamingVoice(
207
  }
208
  }, []);
209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  const safeStart = useCallback(() => {
211
  const rec = recognitionRef.current;
212
  if (!rec) return;
@@ -341,6 +396,17 @@ export function useStreamingVoice(
341
  };
342
 
343
  rec.onresult = (ev: SpeechRecognitionEventLike) => {
 
 
 
 
 
 
 
 
 
 
 
344
  let interim = "";
345
  // Walk every result; finals get pushed onto finalsRef, interims get
346
  // concatenated into a running string that's displayed in the input.
@@ -423,109 +489,191 @@ export function useStreamingVoice(
423
  return drained;
424
  };
425
 
426
- // If there's no audio recorder, or text is racing, fall back to the
427
- // Phase 1 behaviour: submit the Web Speech transcript and bail.
428
- if (!recorderActiveRef.current || textRacing) {
429
- if (webSpeechText && !textRacing) {
430
- onFinalRef.current(webSpeechText);
431
- }
432
- // Best-effort: clear any partial audio so the next utterance isn't
433
- // contaminated with the previous one's tail.
434
- if (recorderActiveRef.current) drainChunks();
435
- scheduleRestart();
436
- return;
 
 
 
 
 
 
 
 
 
 
 
 
437
  }
 
 
 
 
 
 
 
 
 
 
438
 
439
- // Sarvam path. Fire-and-forget so we don't block the recognition
440
- // restart loop on the network round-trip.
441
- void (async () => {
442
- // Snapshot user-visible interim so the input area doesn't go blank
443
- // while Sarvam is in flight. The page-side input still shows the
444
- // Web Speech transcript; we'll overwrite it via onFinalTranscript
445
- // once Sarvam returns.
446
- if (webSpeechText) onInterimRef.current(webSpeechText);
447
-
448
- // We need to stop the recorder to get the final dataavailable
449
- // chunk; then we re-arm a new recorder for the next utterance.
450
- await stopRecorder();
451
- const drained = drainChunks();
452
- const totalSize = drained.reduce((n, b) => n + b.size, 0);
453
- console.debug("[useStreamingVoice] silence-detect", {
454
- webSpeechLen: webSpeechText.length,
455
- chunkCount: drained.length,
456
- blobBytes: totalSize,
457
- });
458
-
459
- // Re-arm audio capture for the next utterance (don't block on it).
460
- teardownAudio();
461
- // Only restart the audio pipeline if the user still wants the mic
462
- // live. Fire-and-forget; recognition restart is scheduled below.
463
- if (wantRunningRef.current) {
464
- void ensureAudioCapture();
 
 
 
 
 
 
 
 
 
 
 
 
 
465
  }
466
 
467
- // Skip submit when there's effectively no audio or no Web Speech
468
- // text. ~3 KB is the empirical noise floor used by the PTT path's
469
- // KI-134 silence guard (page.tsx uses 1 KB; we're stricter here
470
- // because Live mode auto-fires on every silence pause).
471
- const MIN_BLOB_BYTES = 3000;
472
- if (!webSpeechText && totalSize < MIN_BLOB_BYTES) {
473
- console.debug("[useStreamingVoice] skipping submit β€” no text and tiny blob");
474
- scheduleRestart();
475
- return;
476
- }
477
 
478
- // If Web Speech got nothing, but we have a real blob, still try
479
- // Sarvam β€” Web Speech occasionally drops short utterances on noisy
480
- // mics that Sarvam handles fine.
481
- if (!webSpeechText && totalSize < MIN_BLOB_BYTES) {
482
- scheduleRestart();
483
  return;
484
  }
485
 
486
- // Race-check again after the await β€” text turn may have started
487
- // while we were stopping the recorder.
488
- if (isTextRequestPendingRef.current) {
489
- console.debug("[useStreamingVoice] text turn started mid-await; dropping voice turn");
490
- scheduleRestart();
491
- return;
492
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
 
494
- let authoritativeText = webSpeechText;
495
- if (drained.length > 0 && totalSize >= MIN_BLOB_BYTES) {
496
- const blob = new Blob(drained, { type: recorderMimeRef.current || "audio/webm" });
497
- const controller = new AbortController();
498
- const timeoutId = setTimeout(() => controller.abort(), 8000);
499
- try {
500
- console.debug("[useStreamingVoice] POST /api/transcribe", { bytes: blob.size, mime: blob.type, lang: language });
501
- const sarvam = await postTranscribe(blob, language, controller.signal);
502
- const sarvamText = (sarvam.text || "").trim();
503
- if (sarvamText) {
504
- authoritativeText = sarvamText;
505
- console.debug("[useStreamingVoice] Sarvam OK", {
506
- latency_ms: sarvam.latency_ms,
507
- webSpeechLen: webSpeechText.length,
508
- sarvamLen: sarvamText.length,
509
- });
510
- } else {
511
- console.debug("[useStreamingVoice] Sarvam returned empty; using Web Speech fallback");
 
 
 
 
 
512
  }
513
- } catch (err) {
514
- console.debug("[useStreamingVoice] Sarvam failed; using Web Speech fallback", err);
515
- } finally {
516
- clearTimeout(timeoutId);
517
  }
518
- }
519
 
520
- if (authoritativeText && !isTextRequestPendingRef.current) {
521
- onFinalRef.current(authoritativeText);
522
- }
523
- scheduleRestart();
524
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
  };
526
 
527
  return rec;
528
- }, [language, isTextRequestPendingRef, clearRestartTimer, safeStart, stopRecorder, teardownAudio, ensureAudioCapture]);
529
 
530
  const start = useCallback(() => {
531
  if (!isSupported) {
@@ -558,6 +706,14 @@ export function useStreamingVoice(
558
  }
559
  teardownAudio();
560
  finalsRef.current = [];
 
 
 
 
 
 
 
 
561
  onListeningRef.current(false);
562
  }, [clearRestartTimer, teardownAudio]);
563
 
@@ -944,6 +1100,16 @@ export function useStreamingVoice(
944
  // TTS just started β€” abort any in-flight recognition so it stops
945
  // transcribing the bot voice.
946
  console.debug("[useStreamingVoice] KI-188 TTS started β€” pausing recognition");
 
 
 
 
 
 
 
 
 
 
947
  const rec = recognitionRef.current;
948
  if (rec) {
949
  try { rec.abort(); } catch { /* ignore */ }
@@ -982,6 +1148,19 @@ export function useStreamingVoice(
982
  // TTS just ended β€” let the heartbeat/visibility listeners revive.
983
  // Trigger immediately too so the user doesn't wait ~4s.
984
  console.debug("[useStreamingVoice] KI-188 TTS ended β€” resuming recognition");
 
 
 
 
 
 
 
 
 
 
 
 
 
985
  stopBargeInLoop();
986
  // KI-195 β€” freeze the per-element calibrated volume and resume
987
  // learning the user's speech RMS for the next turn.
@@ -1075,6 +1254,13 @@ export function useStreamingVoice(
1075
  });
1076
  ttsAudioElementsRef.current.clear();
1077
  isTtsPlayingRef.current = false;
 
 
 
 
 
 
 
1078
  // KI-189 β€” release AnalyserNode + AudioContext on unmount / disable.
1079
  teardownAnalyser();
1080
  };
@@ -1132,6 +1318,13 @@ export function useStreamingVoice(
1132
  }
1133
  recognitionRef.current = null;
1134
  teardownAudio();
 
 
 
 
 
 
 
1135
  };
1136
  }, [clearRestartTimer, teardownAudio]);
1137
 
 
61
  // residual mic bleed (after AEC) and the user's normal-volume speech,
62
  // making barge-in trivial. 0.6 is loud enough to hear clearly on
63
  // headphones and laptop speakers without overpowering user speech.
64
+ // KI-211 (2026-05-15) β€” was 0.6; lowered to 0.3 because first-turn barge-in
65
+ // fails when adaptive calibration (KI-195) hasn't sampled user_speech_rms yet.
66
+ // 0.3 is loud enough to hear clearly on speakers + mic bleed is well under
67
+ // the static BARGE_IN_RMS_THRESHOLD, so users can talk over the bot on the
68
+ // first turn without needing prior calibration.
69
+ const VOICE_MODE_TTS_VOLUME = 0.3;
70
  // KI-195 (2026-05-15) β€” adaptive TTS volume calibration relative to user's
71
  // own measured speech level. Architecture: while user speaks (recorder
72
  // active, NOT TTS) we sample mic RMS and track a rolling peak in
 
83
  const VOLUME_CALIB_DUCK_FACTOR = 0.8; // multiply el.volume by this per tick if too loud
84
  const VOLUME_CALIB_FLOOR = 0.15; // never drop bot below this β€” must stay audible
85
 
86
+ // KI-202 (2026-05-15) β€” utterance batching grace window.
87
+ // Web Speech API's `onend` fires after ~1.5s silence, which means a natural
88
+ // mid-sentence pause ("So it will be just [pause] me") triggers TWO separate
89
+ // onend events and the user's sentence is submitted in two halves. We delay
90
+ // the actual submission by UTTERANCE_GRACE_MS after onend; if recognition
91
+ // re-fires (next word burst) before the timer expires, we append the new
92
+ // text/audio chunks and reset the timer. Only after a full UTTERANCE_GRACE_MS
93
+ // of true silence do we submit.
94
+ const UTTERANCE_GRACE_MS = 1500;
95
+ // KI-203 (2026-05-15) β€” post-TTS result-drop window.
96
+ // `recognition.abort()` doesn't immediately stop result delivery β€” onresult
97
+ // events from the now-abandoned recognition can keep arriving for a beat
98
+ // afterwards. Keep dropping results for this many ms after TTS ends.
99
+ const POST_TTS_DROP_MS = 300;
100
+
101
  // Minimal types for the Web Speech API since lib.dom.d.ts ships them under
102
  // `webkitSpeechRecognition` only and the standard `SpeechRecognition` symbol
103
  // is still vendor-prefixed in most browsers as of 2026-05.
 
197
  // Tracked via a MutationObserver + per-element play/pause/ended hooks.
198
  const isTtsPlayingRef = useRef(false);
199
  const ttsAudioElementsRef = useRef<Set<HTMLAudioElement>>(new Set());
200
+ // KI-203 (2026-05-15) β€” silently discard SpeechRecognition.onresult events
201
+ // while this flag is true. Flipped on the instant TTS playback starts
202
+ // (closes the ~100-300ms window between `audio.play()` and our abort()
203
+ // taking effect, during which bot voice was being transcribed as user
204
+ // input). Flipped back ~POST_TTS_DROP_MS after TTS ends so any in-flight
205
+ // results from the dying recognition pipeline are still suppressed.
206
+ const dropResultsRef = useRef(false);
207
+ const dropResultsClearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
208
+ // KI-202 (2026-05-15) β€” utterance-batching state.
209
+ // pendingUtteranceRef accumulates the Web Speech transcript across multiple
210
+ // onend events separated by sub-grace-window pauses. pendingChunksRef does
211
+ // the same for MediaRecorder blobs so the Sarvam POST sees the WHOLE
212
+ // utterance, not just the tail after the last pause. pendingSubmitTimerRef
213
+ // is the grace-window setTimeout; it gets reset every time onend appends
214
+ // more content.
215
+ const pendingUtteranceRef = useRef<string>("");
216
+ const pendingChunksRef = useRef<Blob[]>([]);
217
+ const pendingSubmitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
218
 
219
  // ----------------------------------------------------------------------
220
  // KI-168 PHASE 2 β€” Sarvam authoritative-transcript layer.
 
245
  }
246
  }, []);
247
 
248
+ // KI-210 (2026-05-15) β€” wait for an in-flight text turn to clear instead of
249
+ // dropping the accumulated voice utterance. Polls isTextRequestPendingRef
250
+ // every 300ms; resolves true once the flag clears, or false if the
251
+ // maxWaitMs cap elapses first (we then proceed anyway rather than leak the
252
+ // utterance forever on a stuck text request).
253
+ const waitForTextClear = useCallback(async (maxWaitMs = 30000): Promise<boolean> => {
254
+ const startTs = Date.now();
255
+ while (isTextRequestPendingRef.current) {
256
+ if (Date.now() - startTs > maxWaitMs) {
257
+ console.debug("[useStreamingVoice] KI-210 wait timed out, submitting anyway");
258
+ return false; // gave up waiting β€” proceed anyway
259
+ }
260
+ await new Promise((r) => setTimeout(r, 300));
261
+ }
262
+ return true; // text cleared, ok to proceed
263
+ }, [isTextRequestPendingRef]);
264
+
265
  const safeStart = useCallback(() => {
266
  const rec = recognitionRef.current;
267
  if (!rec) return;
 
396
  };
397
 
398
  rec.onresult = (ev: SpeechRecognitionEventLike) => {
399
+ // KI-203 (2026-05-15) β€” early-return while TTS is playing (or within
400
+ // the POST_TTS_DROP_MS window after TTS ends). recognition.abort()
401
+ // doesn't immediately stop result delivery, so we silently discard
402
+ // every chunk that arrives during the dirty window. Without this, bot
403
+ // TTS audio ("perfect days to get started Rohit") was leaking into
404
+ // the user input field between `audio.play()` firing and our abort()
405
+ // actually taking effect.
406
+ if (dropResultsRef.current) {
407
+ console.debug("[useStreamingVoice] KI-203 dropping recognition result during/after TTS");
408
+ return;
409
+ }
410
  let interim = "";
411
  // Walk every result; finals get pushed onto finalsRef, interims get
412
  // concatenated into a running string that's displayed in the input.
 
489
  return drained;
490
  };
491
 
492
+ // KI-202 (2026-05-15) β€” utterance batching. Web Speech's onend fires
493
+ // after ~1.5s of silence, so a natural mid-sentence pause splits one
494
+ // utterance into two onend events and the user's sentence gets
495
+ // submitted in halves ("First word getting cut off. Cutoff is the
496
+ // biggest issue. Auto-submitting without capturing the first half
497
+ // or the second half"). Instead of submitting immediately, we
498
+ // append THIS onend's text + audio chunks to pendingUtterance*Ref
499
+ // buffers, then start (or reset) a UTTERANCE_GRACE_MS timer. If
500
+ // recognition restarts (auto-restart picks up the next word burst)
501
+ // within the grace window, the next onend appends more content +
502
+ // resets the timer. Only after a FULL UTTERANCE_GRACE_MS of true
503
+ // silence does the timer fire and submit the accumulated buffer.
504
+ //
505
+ // Pauses < 1.5s merge into one turn (intended fix).
506
+ // Pauses > 1.5s split (intended β€” that IS a new turn).
507
+
508
+ // Drain the CURRENT onend's chunks now so the recorder keeps capturing
509
+ // the next word burst without contamination across pending utterances.
510
+ const drainedThisEnd = recorderActiveRef.current ? drainChunks() : [];
511
+ if (webSpeechText) {
512
+ pendingUtteranceRef.current = pendingUtteranceRef.current
513
+ ? `${pendingUtteranceRef.current} ${webSpeechText}`
514
+ : webSpeechText;
515
  }
516
+ if (drainedThisEnd.length > 0) {
517
+ pendingChunksRef.current.push(...drainedThisEnd);
518
+ }
519
+ console.debug("[useStreamingVoice] KI-202 onend appended to pending utterance", {
520
+ thisTextLen: webSpeechText.length,
521
+ thisChunkCount: drainedThisEnd.length,
522
+ pendingTextLen: pendingUtteranceRef.current.length,
523
+ pendingChunkCount: pendingChunksRef.current.length,
524
+ textRacing,
525
+ });
526
 
527
+ // Mic restart happens immediately regardless of grace window β€” we
528
+ // WANT recognition to come back online so it can pick up the next
529
+ // word burst within the grace window and append to pending.
530
+ scheduleRestart();
531
+
532
+ // KI-210 (2026-05-15) β€” DO NOT drop pending utterance when text is
533
+ // racing. Previously we cleared pendingUtteranceRef + pendingChunksRef
534
+ // here, which silently lost any voice the user spoke during the bot's
535
+ // text-submit/TTS-thinking gap. The downstream wait-and-retry inside
536
+ // `submitPendingUtterance` (timer fire) + the post-await wait inside
537
+ // the Sarvam fire-and-forget now hold the buffer until the text turn
538
+ // clears, then submit. We leave `textRacing` as a debug breadcrumb in
539
+ // the log above and continue accumulating.
540
+
541
+ // KI-210 β€” refactor the grace-timer body into a named async function
542
+ // so it can re-schedule itself (wait-and-retry) when text is in flight
543
+ // instead of dropping the utterance. Capped at 30s total wait so a
544
+ // stuck text request can't leak the timer forever; if the cap fires
545
+ // we proceed with submission anyway (better to submit than drop).
546
+ const SUBMIT_WAIT_CAP_MS = 30000;
547
+ const submitStartTsRef = { ts: 0 };
548
+ const submitPendingUtterance = async () => {
549
+ pendingSubmitTimerRef.current = null;
550
+
551
+ // KI-210 β€” if text is still in flight when the grace window fires,
552
+ // wait instead of dropping. Re-schedule a 300ms retry until either
553
+ // text clears or we hit the 30s cap.
554
+ if (isTextRequestPendingRef.current) {
555
+ if (submitStartTsRef.ts === 0) submitStartTsRef.ts = Date.now();
556
+ if (Date.now() - submitStartTsRef.ts > SUBMIT_WAIT_CAP_MS) {
557
+ console.debug("[useStreamingVoice] KI-210 timer wait cap reached; submitting anyway");
558
+ // fall through and submit
559
+ } else {
560
+ console.debug("[useStreamingVoice] KI-210 timer fired but text in flight; waiting 300ms");
561
+ pendingSubmitTimerRef.current = setTimeout(() => {
562
+ void submitPendingUtterance();
563
+ }, 300);
564
+ return;
565
+ }
566
  }
567
 
568
+ const accumulatedText = pendingUtteranceRef.current.trim();
569
+ const accumulatedChunks = pendingChunksRef.current;
570
+ pendingUtteranceRef.current = "";
571
+ pendingChunksRef.current = [];
572
+ console.debug("[useStreamingVoice] KI-202 grace window elapsed β€” submitting", {
573
+ textLen: accumulatedText.length,
574
+ chunkCount: accumulatedChunks.length,
575
+ });
 
 
576
 
577
+ // No-recorder path: just submit Web Speech text.
578
+ if (!recorderActiveRef.current || accumulatedChunks.length === 0) {
579
+ if (accumulatedText) {
580
+ onFinalRef.current(accumulatedText);
581
+ }
582
  return;
583
  }
584
 
585
+ // Sarvam path. Fire-and-forget so we don't block recognition.
586
+ void (async () => {
587
+ // Snapshot user-visible interim so the input area doesn't go blank
588
+ // while Sarvam is in flight. The page-side input still shows the
589
+ // Web Speech transcript; we'll overwrite it via onFinalTranscript
590
+ // once Sarvam returns.
591
+ if (accumulatedText) onInterimRef.current(accumulatedText);
592
+
593
+ // We need to stop the recorder to get the final dataavailable
594
+ // chunk for the LAST burst (anything mid-recording when the grace
595
+ // window opened is in chunksRef, which we now flush into our
596
+ // accumulated set before posting).
597
+ await stopRecorder();
598
+ const tailChunks = drainChunks();
599
+ const allChunks = [...accumulatedChunks, ...tailChunks];
600
+ const totalSize = allChunks.reduce((n, b) => n + b.size, 0);
601
+ console.debug("[useStreamingVoice] KI-202 batched submit", {
602
+ webSpeechLen: accumulatedText.length,
603
+ chunkCount: allChunks.length,
604
+ blobBytes: totalSize,
605
+ });
606
+
607
+ // Re-arm audio capture for the next utterance (don't block on it).
608
+ teardownAudio();
609
+ if (wantRunningRef.current) {
610
+ void ensureAudioCapture();
611
+ }
612
+
613
+ // Skip submit when there's effectively no audio or no Web Speech
614
+ // text. ~3 KB is the empirical noise floor used by the PTT path's
615
+ // KI-134 silence guard.
616
+ const MIN_BLOB_BYTES = 3000;
617
+ if (!accumulatedText && totalSize < MIN_BLOB_BYTES) {
618
+ console.debug("[useStreamingVoice] KI-202 skipping submit β€” no text and tiny blob");
619
+ return;
620
+ }
621
+
622
+ // KI-210 β€” wait-and-retry instead of dropping. If a text turn
623
+ // started during the await above, hold the utterance until it
624
+ // clears (capped at 30s) instead of throwing it away.
625
+ await waitForTextClear();
626
 
627
+ let authoritativeText = accumulatedText;
628
+ if (allChunks.length > 0 && totalSize >= MIN_BLOB_BYTES) {
629
+ const blob = new Blob(allChunks, { type: recorderMimeRef.current || "audio/webm" });
630
+ const controller = new AbortController();
631
+ const timeoutId = setTimeout(() => controller.abort(), 8000);
632
+ try {
633
+ console.debug("[useStreamingVoice] POST /api/transcribe", { bytes: blob.size, mime: blob.type, lang: language });
634
+ const sarvam = await postTranscribe(blob, language, controller.signal);
635
+ const sarvamText = (sarvam.text || "").trim();
636
+ if (sarvamText) {
637
+ authoritativeText = sarvamText;
638
+ console.debug("[useStreamingVoice] Sarvam OK", {
639
+ latency_ms: sarvam.latency_ms,
640
+ webSpeechLen: accumulatedText.length,
641
+ sarvamLen: sarvamText.length,
642
+ });
643
+ } else {
644
+ console.debug("[useStreamingVoice] Sarvam returned empty; using Web Speech fallback");
645
+ }
646
+ } catch (err) {
647
+ console.debug("[useStreamingVoice] Sarvam failed; using Web Speech fallback", err);
648
+ } finally {
649
+ clearTimeout(timeoutId);
650
  }
 
 
 
 
651
  }
 
652
 
653
+ // KI-210 β€” final wait-and-retry after Sarvam round-trip. Don't
654
+ // drop the now-authoritative transcript if text raced us during
655
+ // the network call.
656
+ if (authoritativeText) {
657
+ await waitForTextClear();
658
+ onFinalRef.current(authoritativeText);
659
+ }
660
+ })();
661
+ };
662
+
663
+ // (Re)start the grace-window timer. Every onend resets it, so as long
664
+ // as the user keeps starting new word bursts within 1.5s of the last
665
+ // silence, the timer never fires and the utterance keeps growing.
666
+ if (pendingSubmitTimerRef.current !== null) {
667
+ clearTimeout(pendingSubmitTimerRef.current);
668
+ }
669
+ submitStartTsRef.ts = 0;
670
+ pendingSubmitTimerRef.current = setTimeout(() => {
671
+ void submitPendingUtterance();
672
+ }, UTTERANCE_GRACE_MS);
673
  };
674
 
675
  return rec;
676
+ }, [language, isTextRequestPendingRef, clearRestartTimer, safeStart, stopRecorder, teardownAudio, ensureAudioCapture, waitForTextClear]);
677
 
678
  const start = useCallback(() => {
679
  if (!isSupported) {
 
706
  }
707
  teardownAudio();
708
  finalsRef.current = [];
709
+ // KI-202 β€” drop any pending utterance so toggling voice off mid-grace
710
+ // doesn't auto-submit a stale half-sentence next time voice comes on.
711
+ if (pendingSubmitTimerRef.current !== null) {
712
+ clearTimeout(pendingSubmitTimerRef.current);
713
+ pendingSubmitTimerRef.current = null;
714
+ }
715
+ pendingUtteranceRef.current = "";
716
+ pendingChunksRef.current = [];
717
  onListeningRef.current(false);
718
  }, [clearRestartTimer, teardownAudio]);
719
 
 
1100
  // TTS just started β€” abort any in-flight recognition so it stops
1101
  // transcribing the bot voice.
1102
  console.debug("[useStreamingVoice] KI-188 TTS started β€” pausing recognition");
1103
+ // KI-203 (2026-05-15) β€” flip the result-drop flag the INSTANT TTS
1104
+ // starts. abort() below has a ~100-300ms tail during which onresult
1105
+ // can still fire with bot-voice transcripts; the flag closes that
1106
+ // window unconditionally.
1107
+ if (dropResultsClearTimerRef.current !== null) {
1108
+ clearTimeout(dropResultsClearTimerRef.current);
1109
+ dropResultsClearTimerRef.current = null;
1110
+ }
1111
+ dropResultsRef.current = true;
1112
+ console.debug("[useStreamingVoice] KI-203 dropResultsRef=true (TTS start)");
1113
  const rec = recognitionRef.current;
1114
  if (rec) {
1115
  try { rec.abort(); } catch { /* ignore */ }
 
1148
  // TTS just ended β€” let the heartbeat/visibility listeners revive.
1149
  // Trigger immediately too so the user doesn't wait ~4s.
1150
  console.debug("[useStreamingVoice] KI-188 TTS ended β€” resuming recognition");
1151
+ // KI-203 (2026-05-15) β€” keep dropping recognition results for
1152
+ // POST_TTS_DROP_MS after TTS ends. The recognition pipeline we
1153
+ // abort()'d at TTS-start can still deliver buffered events for a
1154
+ // beat; without this delayed clear, the tail of the bot's TTS
1155
+ // leaks into the input box as the user starts speaking.
1156
+ if (dropResultsClearTimerRef.current !== null) {
1157
+ clearTimeout(dropResultsClearTimerRef.current);
1158
+ }
1159
+ dropResultsClearTimerRef.current = setTimeout(() => {
1160
+ dropResultsRef.current = false;
1161
+ dropResultsClearTimerRef.current = null;
1162
+ console.debug("[useStreamingVoice] KI-203 dropResultsRef=false (post-TTS window over)");
1163
+ }, POST_TTS_DROP_MS);
1164
  stopBargeInLoop();
1165
  // KI-195 β€” freeze the per-element calibrated volume and resume
1166
  // learning the user's speech RMS for the next turn.
 
1254
  });
1255
  ttsAudioElementsRef.current.clear();
1256
  isTtsPlayingRef.current = false;
1257
+ // KI-203 β€” clear the post-TTS drop-results window timer so a
1258
+ // disabled-then-re-enabled voice mode doesn't inherit a stale flag.
1259
+ if (dropResultsClearTimerRef.current !== null) {
1260
+ clearTimeout(dropResultsClearTimerRef.current);
1261
+ dropResultsClearTimerRef.current = null;
1262
+ }
1263
+ dropResultsRef.current = false;
1264
  // KI-189 β€” release AnalyserNode + AudioContext on unmount / disable.
1265
  teardownAnalyser();
1266
  };
 
1318
  }
1319
  recognitionRef.current = null;
1320
  teardownAudio();
1321
+ // KI-202 β€” clear pending utterance grace timer on unmount.
1322
+ if (pendingSubmitTimerRef.current !== null) {
1323
+ clearTimeout(pendingSubmitTimerRef.current);
1324
+ pendingSubmitTimerRef.current = null;
1325
+ }
1326
+ pendingUtteranceRef.current = "";
1327
+ pendingChunksRef.current = [];
1328
  };
1329
  }, [clearRestartTimer, teardownAudio]);
1330
 
tests/test_credits_election.py CHANGED
@@ -174,16 +174,19 @@ class TestElectionCreditGate(unittest.TestCase):
174
  "Quota-exhausted Groq should be skipped despite faster latency.")
175
 
176
  def test_nim_preferred_over_faster_groq_when_eligible(self) -> None:
177
- """KI-087 NIM-first preference: even when both candidates are healthy
178
- and Groq has plenty of credits AND is 3x faster on latency, NIM must
179
- still be elected as PRIMARY. Groq + OpenRouter are emergency-fallback
180
- only, not picked-on-latency. Replaces the pre-KI-087
181
- `test_groq_above_water_picked_in_election` which asserted the
182
- opposite (Groq wins on latency)."""
 
 
 
183
  groq_model = "groq:llama-3.3-70b-versatile"
184
  nim_model = "qwen/qwen3-next-80b-a3b-instruct"
185
  gh = _healthy_now(groq_model)
186
- gh.latency_ms = 100 # faster
187
  gh.credits_remaining = 10000.0 # well above water
188
  gh.credits_unit = "tokens_day"
189
  gh.credits_low_water = GROQ_TOKENS_LOW_WATER
@@ -191,25 +194,33 @@ class TestElectionCreditGate(unittest.TestCase):
191
  llm_health._STATE[groq_model] = gh
192
 
193
  nh = _healthy_now(nim_model)
194
- nh.latency_ms = 300 # slower but NIM
195
  llm_health._STATE[nim_model] = nh
196
 
 
 
197
  with mock.patch.object(
198
- llm_health, "_chain_for", return_value=[groq_model, nim_model]
199
  ):
200
  primary = llm_health.get_primary("brain")
201
  self.assertEqual(
202
  primary, nim_model,
203
- "KI-087: NIM must beat Groq as PRIMARY even when Groq is faster + "
204
- "has credits. NIM is the strategic free provider; Groq + OpenRouter "
205
- "are emergency fallback only.",
206
  )
207
 
208
  def test_groq_picked_when_nim_pool_empty(self) -> None:
209
- """KI-087 fallthrough: when NO eligible NIM candidate exists (all NIM
210
- models down / out of credits / not in chain), election falls through
211
- to the best non-NIM candidate as PRIMARY. Locks in the safety net so
212
- a full NIM regional outage still produces a working brain call."""
 
 
 
 
 
 
213
  groq_model = "groq:llama-3.3-70b-versatile"
214
  or_model = "openrouter:openai/gpt-oss-120b"
215
  gh = _healthy_now(groq_model)
@@ -221,18 +232,18 @@ class TestElectionCreditGate(unittest.TestCase):
221
  llm_health._STATE[groq_model] = gh
222
 
223
  oh = _healthy_now(or_model)
224
- oh.latency_ms = 800 # slower
225
  llm_health._STATE[or_model] = oh
226
 
227
- # Note the chain has NO NIM candidates.
228
  with mock.patch.object(
229
- llm_health, "_chain_for", return_value=[or_model, groq_model]
230
  ):
231
  primary = llm_health.get_primary("brain")
232
  self.assertEqual(
233
  primary, groq_model,
234
- "KI-087 fallthrough: no NIM eligible β†’ election picks the highest-"
235
- "scored non-NIM candidate (Groq's 100ms beats OpenRouter's 800ms).",
236
  )
237
 
238
  def test_none_credits_is_permissive(self) -> None:
 
174
  "Quota-exhausted Groq should be skipped despite faster latency.")
175
 
176
  def test_nim_preferred_over_faster_groq_when_eligible(self) -> None:
177
+ """KI-201 chain-order: chain head wins regardless of latency.
178
+
179
+ Under KI-201 the chain-head model is always primary when eligible.
180
+ Pre-KI-201 (KI-087) the elector applied a NIM-first preference on
181
+ top of latency-score; that preference is now redundant because the
182
+ canonical chains in nvidia_nim_llm.py list NIM models first by
183
+ construction. This test exercises the new contract: with NIM as
184
+ chain-head and Groq slower in the chain, NIM wins on POSITION
185
+ not on latency-vs-NIM-preference."""
186
  groq_model = "groq:llama-3.3-70b-versatile"
187
  nim_model = "qwen/qwen3-next-80b-a3b-instruct"
188
  gh = _healthy_now(groq_model)
189
+ gh.latency_ms = 100 # faster (irrelevant under KI-201)
190
  gh.credits_remaining = 10000.0 # well above water
191
  gh.credits_unit = "tokens_day"
192
  gh.credits_low_water = GROQ_TOKENS_LOW_WATER
 
194
  llm_health._STATE[groq_model] = gh
195
 
196
  nh = _healthy_now(nim_model)
197
+ nh.latency_ms = 300 # slower but chain-head
198
  llm_health._STATE[nim_model] = nh
199
 
200
+ # NIM is chain-head; under chain-order election it wins regardless
201
+ # of Groq being faster.
202
  with mock.patch.object(
203
+ llm_health, "_chain_for", return_value=[nim_model, groq_model]
204
  ):
205
  primary = llm_health.get_primary("brain")
206
  self.assertEqual(
207
  primary, nim_model,
208
+ "KI-201: chain-head wins regardless of latency. NIM is listed "
209
+ "first in BRAIN_CHAIN by construction (KI-175); chain-order "
210
+ "election picks it without needing a separate NIM-first rule.",
211
  )
212
 
213
  def test_groq_picked_when_nim_pool_empty(self) -> None:
214
+ """KI-201 chain-order fallthrough: when NO eligible NIM candidate
215
+ exists (all NIM models down / out of credits / not in chain),
216
+ election walks the chain in order and picks the FIRST eligible
217
+ non-NIM candidate. Locks in the safety net so a full NIM regional
218
+ outage still produces a working brain call.
219
+
220
+ Pre-KI-201 (KI-087) this test asserted Groq won via latency-score
221
+ even when listed second in the chain. Under chain-order election
222
+ the chain is the truth, so we list Groq first to exercise the
223
+ same fallthrough behaviour."""
224
  groq_model = "groq:llama-3.3-70b-versatile"
225
  or_model = "openrouter:openai/gpt-oss-120b"
226
  gh = _healthy_now(groq_model)
 
232
  llm_health._STATE[groq_model] = gh
233
 
234
  oh = _healthy_now(or_model)
235
+ oh.latency_ms = 800 # slower (irrelevant under KI-201 chain-order)
236
  llm_health._STATE[or_model] = oh
237
 
238
+ # Chain has NO NIM candidates; Groq is chain-head.
239
  with mock.patch.object(
240
+ llm_health, "_chain_for", return_value=[groq_model, or_model]
241
  ):
242
  primary = llm_health.get_primary("brain")
243
  self.assertEqual(
244
  primary, groq_model,
245
+ "KI-201 chain-order: no NIM eligible β†’ election picks the "
246
+ "FIRST eligible candidate in chain order (Groq is chain-head).",
247
  )
248
 
249
  def test_none_credits_is_permissive(self) -> None: