fix(stt/llm): multilingual recognition fixes — auto-detect, code-switching, mangled tech terms, and answer-repair stubs - #401
Open
Treamz wants to merge 10 commits into
Conversation
Auto Detect could never recognize Russian/Ukrainian on the Google provider, for two independent reasons: 1. AppState.setRecognitionLanguage silently rewrote 'auto' to 'english-us' for every non-natively provider, pinning the stream to en-US before detection was even attempted. Every provider handles 'auto' natively (Deepgram: 'multi', Soniox/ElevenLabs: omit hint, NativelyPro: server-side detection), and the createSTTProvider path already passed the raw key, so the substitution is removed. 2. GoogleSTT's auto branch hardcoded fr/es/de as the alternative language codes (Google STT v1 caps them at 3), so ru/uk were never candidates. The new googleAutoDetectAlternates() helper builds the 3-slot list from the OS preferred languages instead, falling back to fr/es/de only when nothing matches. Covered by GoogleAutoDetectAlternates.test.mjs: locale-driven slot assignment, en skipped as primary, dedup, 3-slot cap, fallback, plus structural guards against both regressions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… restarts Switching languages mid-meeting (en <-> ru) lagged and dropped audio on the Google provider, for three stacked reasons: 1. Stale-stream race: every restart (language change, 4:30 proactive restart) destroyed the old gRPC stream and created a new one, but the old stream's async 'close'/'end' events fired AFTER the new stream existed and nulled this.stream — audio then buffered until write()'s lazy reconnect (throttled to 1/s), losing 1s+ of speech per restart. Handlers now capture their own stream and ignore events once replaced. 2. Primary language never adapted: auto mode pinned en-US as primary forever, so a meeting that settled into Russian ran every phrase through the slower, less accurate alternativeLanguageCodes path. Google reports result.languageCode per phrase; after two consecutive finals in a non-primary language the stream now re-pins — detected language becomes primary, old primary joins the alternates, so switching back re-pins again. Explicit language choice never re-pins. 3. Hard stop()+start() on language change killed the in-flight final of whatever was being said. Restarts now swap streams via end() without destroy(): the old stream flushes its tail finals (still forwarded, but barred from triggering re-pins) while the new one takes over. Covered by GoogleSTTLanguageRepin.test.mjs with a fake gRPC client: re-pin threshold, streak reset, round-trip switching, stale close/end/ error immunity, tail-final forwarding, and manual-mode authority. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Meetings held in Russian/Ukrainian with English tech insertions ("в чём
разница между Stateless и Stateful Widget") got the terms phonetically
mangled: once a phrase is identified as ru-RU, the whole utterance runs
through the Russian model, which cannot spell "Stateless Widget". No
language-switching logic can help — the sentence IS Russian — so the fix
is vocabulary biasing, which both providers support but neither used:
- New curated glossary (config/sttPhraseHints.ts): ~150 terms across
Flutter/mobile, web, backend, databases, CS fundamentals, DevOps, AI,
and dev-process vocabulary. Multi-word phrases preferred — they
collide with ordinary speech far less than single tokens.
- GoogleSTT: glossary passed as speechContexts with boost 10 (moderate
on purpose — higher values hallucinate terms into normal speech).
Google v1 caps: 500 phrases / 100 chars.
- ElevenLabsStreamingSTT: glossary passed as keyterms query params on
the Scribe v2 realtime WS URL, auto-trimmed to the API cap (50 terms
of ≤20 chars). NOTE: ElevenLabs charges ~20% extra when keyterms are
used.
Covered by SttPhraseHints.test.mjs: glossary limit validation, Google
speechContexts request shape (fake gRPC client), ElevenLabs URL builder
and cap enforcement.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
STT models running under a ru/uk primary language spell embedded English
terms phonetically ("стейтлес виджет"). Provider-side vocabulary biasing
helps but is capped (ElevenLabs: 50 keyterms, Google: 500 phrases) and
provider-specific. This adds a deterministic post-processing pass on
FINAL transcript segments, applied in main.ts before any consumer
(intelligence, RAG, renderer, knowledge tracker):
- Cyrillic word runs are transliterated and reduced to a consonant
skeleton (phonetic merges -> drop vowels -> collapse repeats):
"стейтлес" -> stls, "stateless" -> stls.
- A run is replaced only on an EXACT skeleton match against the
DEFAULT_TECH_PHRASE_HINTS glossary — no fuzzy distance — so ordinary
Russian/Ukrainian speech is never rewritten.
- Latin text is never touched; interims pass through untouched.
Zero network, zero tokens, works with every STT provider, and the
glossary can grow without hitting any provider keyterm cap.
Covered by TranscriptTermFix.test.mjs: skeleton convergence pairs, the
motivating sentence, punctuation preservation, ordinary-speech and
Latin-text no-op guarantees.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Screenshot → WTA flow (observed 2026-07-26): the answer streamed fine, then got REPLACED by "I don't have the original question or answer to rewrite." Root cause, two stacked defects in the post-stream repair pipeline: 1. The scaffold-contamination repair prompt asked the model to rewrite "your previous response" — but the repair call is a fresh stateless request and the contaminated draft was never embedded in the prompt. The model had literally nothing to rewrite, so it narrated the task instead of performing it. The draft now travels in the prompt as a <draft_response> data-only block, so the rewrite preserves the real content. 2. Repair acceptance was "≥5 chars", so the canned inability stub passed and overwrote the shown answer. New isRepairInabilityStub() detector (answerPolish.ts) rejects "nothing to rewrite" stubs at BOTH repair acceptance sites (scaffold + profile-evidence); on rejection the original streamed answer is kept. Bounded to ≤300 chars so long real answers that discuss rewriting are never hit. Covered by RepairInabilityStub.test.mjs: observed stub + variants, false-positive guards on real answers, and structural asserts that the draft block and both guards stay in place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dings
A Russian question ("как работает bubble sort") classified as a DSA
answer type got an all-English answer: the coding contract mandates
exact English headings ("## Approach", "## Dry Run"), and models read
that mandate as "the whole answer is English", overriding the soft
auto-language instruction at the end of the prompt.
The auto-language suffix now explicitly decouples mandated scaffolding
from prose language: all explanatory prose — including every sentence
under required headings — follows the user's language; only code/
identifiers, contract-mandated heading text, and untranslatable
technical terms stay as-is. Russian added to the detection examples.
Guarded by LanguageInstructionCarveOut.test.mjs (structural).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
| Filename | Overview |
|---|---|
| electron/audio/GoogleSTT.ts | Adds locale-driven auto detection, language re-pinning, phrase hints, stale-stream protection, and generation-aware stream draining. |
| electron/audio/ElevenLabsStreamingSTT.ts | Adds bounded, URL-encoded technical keyterms to ElevenLabs realtime transcription. |
| electron/config/languages.ts | Adds preferred-locale selection for Google’s three alternative-language slots. |
| electron/llm/transcriptTermFix.ts | Adds deterministic glossary-based restoration of mangled technical terms in final Cyrillic transcripts. |
| electron/IntelligenceEngine.ts | Embeds the original draft in stateless repair prompts and rejects canned inability responses. |
| electron/LLMHelper.ts | Clarifies that structured English headings do not override the language of explanatory prose. |
| electron/main.ts | Preserves auto-detect language selection and applies final-transcript term repair in the shared STT pipeline. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
Audio[Audio chunks] --> Current[Current Google stream]
Current -->|language re-pin or restart| Swap[Generation-aware stream swap]
Swap --> Old[Old stream drains]
Swap --> New[New stream receives audio]
Old --> Queue[Ordered transcript generation queue]
New --> Queue
Queue --> Transcript[Transcript pipeline]
Transcript --> Repair[Term and answer repair]
Repair --> UI[Assistant output]
Reviews (5): Last reviewed commit: "fix(stt): destroy the drain straggler on..." | Re-trigger Greptile
Review finding (greptile, PR Natively-AI-assistant#401): the mismatch counter was language- agnostic, so adjacent finals in two DIFFERENT alternate languages (uk-UA then ru-RU) pooled into one streak and re-pinned the stream to the second language after a single final in it — an unnecessary restart with degraded recognition if the speaker continued in yet another language. The streak now tracks which language it belongs to: a detection in a different language restarts the streak at 1, so only CONSECUTIVE finals in the SAME language accumulate toward LANGUAGE_REPIN_FINALS. The tracked language resets alongside the counter everywhere it was reset. Covered by a new case in GoogleSTTLanguageRepin.test.mjs: uk→ru must not re-pin; ru→ru right after does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review finding (greptile, PR Natively-AI-assistant#401): after swapStream(), the abandoned stream's pending final could arrive AFTER the replacement stream had already produced results. Both were forwarded in raw callback order, so pre-swap speech got displayed and stored after newer speech — and downstream transcript storage preserves arrival order, so the LLM could treat an older utterance as the latest context. swapStream() now opens a drain window: while the old stream flushes its tail (until its end/close/error, capped at 1s — Google flushes finals well under that after end()), the NEW stream's transcripts are held in pendingSwapTranscripts and released when the drain finishes. The old stream's own tail finals forward immediately, so older speech always lands first. stop() flushes any open window so the last phrase is never silently dropped; back-to-back swaps release the previous window before opening a new one. Covered by a new ordering test in GoogleSTTLanguageRepin.test.mjs: new-stream final held during drain, pre-swap tail forwarded first, held result released on old-stream close. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review finding (greptile, PR Natively-AI-assistant#401 round 2): the one-slot drain window broke under nested swaps. A second swap before the first drain finished called finishDrain(), which released the middle stream's held transcripts and stopped tracking the oldest stream — whose still-active data handler then forwarded its remaining tail immediately, storing pre-swap speech after newer utterances again. Streams are now tracked as an ordered queue of generations (oldest → newest, last = current). Only the FRONT generation emits live; every younger generation holds its transcripts until all older generations finish draining (end/close/error, or a per-generation 1s deadline so a never-closing stream can't hold younger transcripts hostage). When the front closes, the next generation flushes what it held and takes over. stop() flushes every generation in order so nothing is dropped at session teardown. Covered by a new nested-swap test: three generations (A→B→C via two re-pins), C and B held while A drains, strict chronological order asserted end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g past it Review finding (greptile, PR Natively-AI-assistant#401 round 3): when a generation hit the 1s drain deadline, the queue advanced past it but its data listener stayed live — a final Google flushed AFTER the deadline bypassed the queue and landed after newer speech in the displayed/persisted/RAG/LLM transcript. The deadline now destroy()s the straggler stream before advancing, so a post-deadline final is impossible. Whatever the stream had not flushed within the budget is sacrificed — that is the deadline's contract, and real Google flushes complete in well under a second; the deadline only exists for streams that hang. The gen-not-found fallback in the data handler remains for an event already in flight at destroy time. Covered by a new test: with a straggler that never closes, the deadline releases held transcripts AND destroys the old stream. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Six stacked fixes for multilingual (ru/uk/en) meetings, found while debugging why Auto-Detect Language and mixed-language speech worked poorly end-to-end:
Auto Detect silently pinned Google STT to English.
AppState.setRecognitionLanguagerewrote'auto'→'english-us'for every non-Natively provider, and GoogleSTT's auto branch hardcodedfr/es/dealternates (Google v1 caps them at 3), so Russian/Ukrainian could never be detected. The substitution is removed (every provider handles'auto'natively — Deepgrammulti, Soniox/ElevenLabs omit the hint), and Google's alternates are now derived from the OS preferred languages viagoogleAutoDetectAlternates().Every Google STT restart lost 1s+ of speech. The abandoned gRPC stream's async
close/endevents fired after a restart had created the new stream and nulledthis.stream, so audio buffered until the 1/s lazy reconnect. Handlers now capture their own stream and ignore stale events. Restarts (language change, 4:30 proactive) also switched fromstream.destroy()to a gracefulend()swap, so the tail final of the last phrase is flushed instead of killed mid-flight.Auto mode never adapted its primary language. Google reports
result.languageCodeper phrase but the client ignored it — a meeting held in Russian ran every phrase through the slower alternate-language path forever. After two consecutive finals in a non-primary language, the stream now re-pins (detected language becomes primary, old primary joins the alternates; switching back re-pins again). Explicit language choice never re-pins.English tech terms inside Russian speech got phonetically mangled ("в чём разница между стейтлес виджет…"). Two-layer fix: provider-side vocabulary biasing (Google
speechContexts, ElevenLabs Scribe v2keyterms— note: ElevenLabs charges ~20% extra for keyterms) from a new curated glossary, plus a deterministic local post-processor (transcriptTermFix.ts) that restores glossary terms in final Cyrillic segments via consonant-skeleton matching — exact-match only, so ordinary Russian/Ukrainian speech is never rewritten. Zero network, works with every provider.Streamed answers got replaced by "I don't have the original question or answer to rewrite." The post-stream scaffold-contamination repair asked a stateless LLM call to rewrite "your previous response" without embedding it, and the canned inability stub passed the ≥5-char acceptance check, overwriting the real answer. The draft now travels in the repair prompt as a
<draft_response>block, and a newisRepairInabilityStub()guard rejects such stubs at both repair acceptance sites.Russian coding questions got all-English answers. The coding contract mandates exact English headings (
## Approach,## Dry Run), which models read as "the whole answer is English", overriding the soft auto-language instruction. The instruction now explicitly decouples mandated scaffolding from prose language: headings/code stay as contracted, all explanatory prose follows the user's language.Type of Change
Testing & Environment
npm run typecheck:electronclean;npm run build:electronclean.main:GoogleAutoDetectAlternates.test.mjs— locale-driven alternates, fallback, caps; guards against theauto → english-ussubstitution returning.GoogleSTTLanguageRepin.test.mjs— re-pin threshold/reset/round-trip, stale close/end/error immunity, tail-final forwarding (fake gRPC client).SttPhraseHints.test.mjs— glossary limits, GooglespeechContextsrequest shape, ElevenLabs keyterm cap enforcement.TranscriptTermFix.test.mjs— skeleton convergence (стейтлес→stateless etc.), punctuation preservation, ordinary-speech no-op guarantees.RepairInabilityStub.test.mjs— stub detection + false-positive guards + structural asserts on both repair sites.LanguageInstructionCarveOut.test.mjs— language-instruction carve-out stays in place.To verify manually: set STT language to Auto Detect with the Google provider on a system whose preferred languages include Russian/Ukrainian, speak Russian mid-meeting (expect re-pin log
Auto mode: re-pinning primary language en-US → ru-RU), say "стейтлес виджет" (expectStateless Widgetin the final transcript), and ask a DSA question in Russian (expect Russian prose under the English contract headings).🤖 Generated with Claude Code