From 9b01226e73d284786411268e4ef67a39e69027ec Mon Sep 17 00:00:00 2001 From: stantheman0128 Date: Sat, 18 Jul 2026 07:34:02 +0800 Subject: [PATCH] fix(stt): match dictionary echo against the truncated Groq prompt Groq truncates custom dictionary prompts to ~890 chars, but the echo filter still compared against the full dictionary, so truncated echoes could slip through as paste. Pass the prompt that was actually sent. Co-authored-by: Cursor --- src/helpers/audioManager.js | 19 ++++++++++------- src/utils/dictionaryEchoFilter.js | 8 +++++++ test/helpers/dictionaryEchoFilter.test.js | 26 +++++++++++++++++++++++ 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/helpers/audioManager.js b/src/helpers/audioManager.js index 791c7e693..1096a43d9 100644 --- a/src/helpers/audioManager.js +++ b/src/helpers/audioManager.js @@ -43,7 +43,10 @@ import { import { resolvePrompt } from "../config/prompts"; import { syncService } from "../services/SyncService.js"; import { evaluateFinishedRecording } from "./recordingValidation"; -import { matchesDictionaryPrompt } from "../utils/dictionaryEchoFilter.js"; +import { + matchesDictionaryPrompt, + truncateDictionaryPrompt, +} from "../utils/dictionaryEchoFilter.js"; import { getDictionaryHintWords } from "../utils/snippets"; const REASONING_CACHE_TTL = 30000; // 30 seconds @@ -381,8 +384,9 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor); return words.length > 0 ? words.join(", ") : null; } - isDictionaryEcho(text) { - return matchesDictionaryPrompt(text, this.getCustomDictionaryPrompt()); + isDictionaryEcho(text, dictionaryPrompt = this.getCustomDictionaryPrompt()) { + // Prefer the prompt actually sent to STT (may be truncated for Groq). + return matchesDictionaryPrompt(text, dictionaryPrompt); } setCallbacks({ @@ -2123,9 +2127,7 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor); if (dictionaryPrompt) { if (dictionaryPrompt.length > MAX_PROMPT_CHARS) { const originalLength = dictionaryPrompt.length; - const truncated = dictionaryPrompt.slice(0, MAX_PROMPT_CHARS); - const lastComma = truncated.lastIndexOf(","); - dictionaryPrompt = lastComma > 0 ? truncated.slice(0, lastComma) : truncated; + dictionaryPrompt = truncateDictionaryPrompt(dictionaryPrompt, MAX_PROMPT_CHARS); logger.debug( "Custom dictionary prompt truncated", { @@ -2173,7 +2175,7 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor); const proxyText = result?.text; if (proxyText && proxyText.trim().length > 0) { - if (this.isDictionaryEcho(proxyText)) { + if (this.isDictionaryEcho(proxyText, dictionaryPrompt)) { throw new Error("No audio detected"); } timings.transcriptionProcessingDurationMs = Math.round(performance.now() - apiCallStart); @@ -2387,7 +2389,8 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor); // Check for text - handle both empty string and missing field if (result.text && result.text.trim().length > 0) { - if (this.isDictionaryEcho(result.text)) { + // Match against the prompt actually sent (truncated for Groq), not the full dict. + if (this.isDictionaryEcho(result.text, dictionaryPrompt)) { throw new Error("No audio detected"); } timings.transcriptionProcessingDurationMs = Math.round(performance.now() - apiCallStart); diff --git a/src/utils/dictionaryEchoFilter.js b/src/utils/dictionaryEchoFilter.js index d0847a3ee..59bea1713 100644 --- a/src/utils/dictionaryEchoFilter.js +++ b/src/utils/dictionaryEchoFilter.js @@ -5,6 +5,14 @@ const normalize = (s) => .replace(/\s+/g, " ") .trim(); +/** Truncate a dictionary prompt to maxChars, preferring a comma boundary. */ +export function truncateDictionaryPrompt(prompt, maxChars) { + if (!prompt || prompt.length <= maxChars) return prompt; + const truncated = prompt.slice(0, maxChars); + const lastComma = truncated.lastIndexOf(","); + return lastComma > 0 ? truncated.slice(0, lastComma) : truncated; +} + export function matchesDictionaryPrompt(text, dictionaryPrompt) { if (!text || !dictionaryPrompt) return false; diff --git a/test/helpers/dictionaryEchoFilter.test.js b/test/helpers/dictionaryEchoFilter.test.js index 085ba16ff..a8b5d803e 100644 --- a/test/helpers/dictionaryEchoFilter.test.js +++ b/test/helpers/dictionaryEchoFilter.test.js @@ -116,3 +116,29 @@ test("does not flag completely unrelated text", async () => { false ); }); + +test("truncateDictionaryPrompt prefers a comma boundary under maxChars", async () => { + const { truncateDictionaryPrompt } = await import("../../src/utils/dictionaryEchoFilter.js"); + const full = "Alpha, Bravo, Charlie, Delta, Echo, Foxtrot"; + assert.equal(truncateDictionaryPrompt(full, 20), "Alpha, Bravo"); + assert.equal(truncateDictionaryPrompt(full, 1000), full); + assert.equal(truncateDictionaryPrompt(null, 10), null); +}); + +test("echo filter matches the truncated prompt that Groq actually receives", async () => { + const { matchesDictionaryPrompt, truncateDictionaryPrompt } = await import( + "../../src/utils/dictionaryEchoFilter.js" + ); + // Large dict: enough unique terms that truncation drops a trailing chunk (>890 chars). + const words = Array.from({ length: 200 }, (_, i) => `DictionaryTerm${i}`); + const full = words.join(", "); + assert.ok(full.length > 890, `expected long dict, got length ${full.length}`); + const sent = truncateDictionaryPrompt(full, 890); + assert.ok(sent.length <= 890); + assert.notEqual(sent, full); + + // Whisper often echoes the prompt that was sent, not the full dictionary. + assert.equal(matchesDictionaryPrompt(sent, sent), true); + // Comparing that echo against the full dict can miss (usage ratio drops). + assert.equal(matchesDictionaryPrompt(sent, full), false); +});