Spaces:
Sleeping
fix(voice+llm+admin): KI-200/201/202/203/204/205/206/207/208/210/211 bundle
Browse filesTHE 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 +54 -54
- backend/main.py +7 -0
- backend/orchestrator.py +43 -10
- frontend/public/admin/llm-control.html +237 -201
- frontend/src/app/page.tsx +14 -0
- frontend/src/lib/useStreamingVoice.ts +282 -89
- tests/test_credits_election.py +32 -21
|
@@ -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 |
-
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
|
|
|
|
|
|
|
|
|
| 437 |
"""
|
| 438 |
-
|
| 439 |
-
if not
|
| 440 |
return None
|
| 441 |
-
#
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
|
|
|
|
|
|
| 447 |
|
| 448 |
|
| 449 |
def get_backup(chain_name: str) -> Optional[str]:
|
| 450 |
-
"""
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
|
|
|
| 459 |
"""
|
| 460 |
-
|
| 461 |
-
if
|
| 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 |
-
|
| 467 |
-
|
| 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
|
| 474 |
-
return
|
| 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 |
|
|
@@ -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
|
|
@@ -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 |
-
|
| 1103 |
-
|
| 1104 |
-
|
| 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()
|
| 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,
|
|
@@ -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 |
-
<
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 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 |
-
Β· Tier 1 β NIM (shown below)
|
| 687 |
-
Β· Tier 2 β OpenRouter free pool
|
| 688 |
-
Β· 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 |
-
|
| 1446 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1447 |
var liveBlock = createEl('div', { className: 'simple-table-block' });
|
| 1448 |
-
liveBlock.appendChild(createEl('h3', { text:
|
| 1449 |
var liveTable = createEl('table');
|
| 1450 |
var liveThead = createEl('thead');
|
| 1451 |
var liveHr = createEl('tr');
|
| 1452 |
-
['Use', '
|
| 1453 |
-
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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
|
| 1482 |
-
|
| 1483 |
-
|
| 1484 |
-
|
| 1485 |
-
|
| 1486 |
-
|
| 1487 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1488 |
liveTbody.appendChild(tr);
|
| 1489 |
});
|
| 1490 |
liveTable.appendChild(liveTbody);
|
| 1491 |
liveBlock.appendChild(liveTable);
|
| 1492 |
host.appendChild(liveBlock);
|
| 1493 |
|
| 1494 |
-
// ---- Table 2:
|
| 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:
|
| 1521 |
var availTable = createEl('table');
|
| 1522 |
var availThead = createEl('thead');
|
| 1523 |
var availHr = createEl('tr');
|
| 1524 |
-
|
| 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 >=
|
| 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 |
-
|
| 1543 |
-
|
| 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:
|
| 1568 |
-
|
| 1569 |
var provCell = createEl('td', { className: 'provider-cell' });
|
| 1570 |
provCell.style.textAlign = 'center';
|
| 1571 |
provCell.style.fontWeight = '600';
|
| 1572 |
-
provCell.style.color = PROVIDER_COLORS[
|
| 1573 |
-
provCell.textContent =
|
| 1574 |
tr.appendChild(provCell);
|
| 1575 |
-
|
| 1576 |
var tierCell = createEl('td', { className: 'tier-cell' });
|
| 1577 |
tierCell.style.textAlign = 'center';
|
| 1578 |
tierCell.style.fontWeight = '600';
|
| 1579 |
-
tierCell.style.color =
|
| 1580 |
-
tierCell.textContent =
|
| 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 |
-
|
| 1601 |
-
|
| 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 |
-
|
| 1667 |
-
|
| 1668 |
-
}
|
| 1669 |
availTable.appendChild(availTbody);
|
| 1670 |
availBlock.appendChild(availTable);
|
| 1671 |
host.appendChild(availBlock);
|
|
@@ -1810,7 +1845,8 @@
|
|
| 1810 |
}
|
| 1811 |
|
| 1812 |
clearChildren(chainsHost);
|
| 1813 |
-
|
|
|
|
| 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 || []);
|
|
@@ -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
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 427 |
-
//
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
|
| 439 |
-
//
|
| 440 |
-
//
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
if
|
| 464 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
}
|
| 466 |
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
return;
|
| 476 |
-
}
|
| 477 |
|
| 478 |
-
//
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
return;
|
| 484 |
}
|
| 485 |
|
| 486 |
-
//
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
}
|
| 513 |
-
} catch (err) {
|
| 514 |
-
console.debug("[useStreamingVoice] Sarvam failed; using Web Speech fallback", err);
|
| 515 |
-
} finally {
|
| 516 |
-
clearTimeout(timeoutId);
|
| 517 |
}
|
| 518 |
-
}
|
| 519 |
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 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 |
|
|
@@ -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-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 195 |
llm_health._STATE[nim_model] = nh
|
| 196 |
|
|
|
|
|
|
|
| 197 |
with mock.patch.object(
|
| 198 |
-
llm_health, "_chain_for", return_value=[
|
| 199 |
):
|
| 200 |
primary = llm_health.get_primary("brain")
|
| 201 |
self.assertEqual(
|
| 202 |
primary, nim_model,
|
| 203 |
-
"KI-
|
| 204 |
-
"
|
| 205 |
-
"
|
| 206 |
)
|
| 207 |
|
| 208 |
def test_groq_picked_when_nim_pool_empty(self) -> None:
|
| 209 |
-
"""KI-
|
| 210 |
-
models down / out of credits / not in chain),
|
| 211 |
-
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 228 |
with mock.patch.object(
|
| 229 |
-
llm_health, "_chain_for", return_value=[
|
| 230 |
):
|
| 231 |
primary = llm_health.get_primary("brain")
|
| 232 |
self.assertEqual(
|
| 233 |
primary, groq_model,
|
| 234 |
-
"KI-
|
| 235 |
-
"
|
| 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:
|