Skip to content

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
Natively-AI-assistant:mainfrom
Treamz:fix/stt-multilingual-and-answer-repair
Open

fix(stt/llm): multilingual recognition fixes — auto-detect, code-switching, mangled tech terms, and answer-repair stubs#401
Treamz wants to merge 10 commits into
Natively-AI-assistant:mainfrom
Treamz:fix/stt-multilingual-and-answer-repair

Conversation

@Treamz

@Treamz Treamz commented Jul 27, 2026

Copy link
Copy Markdown

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:

  1. Auto Detect silently pinned Google STT to English. AppState.setRecognitionLanguage rewrote 'auto''english-us' for every non-Natively provider, and GoogleSTT's auto branch hardcoded fr/es/de alternates (Google v1 caps them at 3), so Russian/Ukrainian could never be detected. The substitution is removed (every provider handles 'auto' natively — Deepgram multi, Soniox/ElevenLabs omit the hint), and Google's alternates are now derived from the OS preferred languages via googleAutoDetectAlternates().

  2. Every Google STT restart lost 1s+ of speech. The abandoned gRPC stream's async close/end events fired after a restart had created the new stream and nulled this.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 from stream.destroy() to a graceful end() swap, so the tail final of the last phrase is flushed instead of killed mid-flight.

  3. Auto mode never adapted its primary language. Google reports result.languageCode per 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.

  4. English tech terms inside Russian speech got phonetically mangled ("в чём разница между стейтлес виджет…"). Two-layer fix: provider-side vocabulary biasing (Google speechContexts, ElevenLabs Scribe v2 keyterms — 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.

  5. 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 new isRepairInabilityStub() guard rejects such stubs at both repair acceptance sites.

  6. 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

  • 🐛 Bug Fix (items 1, 2, 5, 6)
  • ✨ New Feature (items 3, 4)

Testing & Environment

  • Manual test performed on: macOS 26 (Apple Silicon), live meetings with ElevenLabs and Google STT, ru/uk/en speech
  • npm run typecheck:electron clean; npm run build:electron clean.
  • 24 new tests across 6 files, all passing on top of current main:
    • GoogleAutoDetectAlternates.test.mjs — locale-driven alternates, fallback, caps; guards against the auto → english-us substitution 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, Google speechContexts request 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 "стейтлес виджет" (expect Stateless Widget in the final transcript), and ask a DSA question in Russian (expect Russian prose under the English contract headings).

🤖 Generated with Claude Code

Treamz and others added 6 commits July 27, 2026 14:15
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>
@greptile-apps

greptile-apps Bot commented Jul 27, 2026

Copy link
Copy Markdown

Greptile Summary

This PR improves multilingual speech recognition and answer generation:

  • derives Google auto-detection alternatives from system language preferences and dynamically re-pins the primary language;
  • adds generation-aware Google stream swapping, stale-event guards, and graceful tail draining;
  • adds provider vocabulary hints and local repair for mangled technical terms;
  • hardens answer-repair prompts and preserves the user’s prose language in structured coding responses.

Confidence Score: 3/5

The PR is not yet safe to merge because an in-flight old-stream final can still bypass generation ordering after the drain deadline.

Destroying the expired stream prevents future data production, but the deadline immediately removes its generation and releases younger transcripts; an already queued data callback can then use the absent-generation fallback and append older speech after newer context.

Files Needing Attention: electron/audio/GoogleSTT.ts

Important Files Changed

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]
Loading

Reviews (5): Last reviewed commit: "fix(stt): destroy the drain straggler on..." | Re-trigger Greptile

Comment thread electron/audio/GoogleSTT.ts Outdated
Comment thread electron/audio/GoogleSTT.ts Outdated
Treamz and others added 2 commits July 27, 2026 14:23
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>
Comment thread electron/audio/GoogleSTT.ts Outdated
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>
Comment thread electron/audio/GoogleSTT.ts Outdated
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant