3525e6b8fb
Go orchestrator: spawns audiotee for capture (system audio + mic, two tracks), segments with an energy-threshold VAD, transcribes each segment via qwen-asr (subprocess per segment, vendored as a pinned git submodule in third_party/qwen-asr), and serves the live transcript over SSE while also writing it durably to a text file. Includes a glossary/prompt-leakage guard (internal/asr/leak.go) that discards a segment if the model echoes the biasing prompt instead of transcribing. cmd/transcriptor-ai is the main orchestrator; cmd/transcript-tail is a minimal terminal SSE client. See CLAUDE.md for the full architecture and the reasoning behind each choice (Go over Python/Swift/Rust, SSE over WebSocket, why the VAD is a hand-rolled heuristic, etc). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
62 lines
2.1 KiB
Go
62 lines
2.1 KiB
Go
package asr
|
|
|
|
import (
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
// looksLikePromptLeak reports whether text appears to be the model echoing
|
|
// the --prompt biasing content back verbatim instead of genuinely
|
|
// transcribing the segment's audio — a known failure mode of
|
|
// prompt-conditioned generation, confirmed via real testing against
|
|
// qwen_asr (see transcriptor-ui/CLAUDE.md, "Glossary prompt-leakage bug"):
|
|
// a whole segment came back as literally the glossary terms, twice, on two
|
|
// separate 5-minute test runs, even after switching to the tool's
|
|
// recommended "Preserve spelling: ..." prompt framing (which reduces but
|
|
// does not eliminate the risk).
|
|
//
|
|
// Heuristic: if most of text's words also appear among prompt's words, and
|
|
// there are enough of them to rule out a legitimate short mention of one
|
|
// or two glossary terms in real speech, treat it as a leak. Both observed
|
|
// real leaks were near-total reproductions of the prompt's term list, so a
|
|
// strict threshold (80% word overlap, at least 4 words) catches them
|
|
// without plausibly false-flagging a normal sentence that happens to
|
|
// mention "Claude" or "Mistral" once — normal speech has enough other
|
|
// words to keep the overlap ratio well below that.
|
|
func looksLikePromptLeak(text, prompt string) bool {
|
|
if prompt == "" || text == "" {
|
|
return false
|
|
}
|
|
textWords := normalizeWords(text)
|
|
if len(textWords) < 4 {
|
|
return false
|
|
}
|
|
promptWords := wordSet(normalizeWords(prompt))
|
|
|
|
matched := 0
|
|
for _, w := range textWords {
|
|
if promptWords[w] {
|
|
matched++
|
|
}
|
|
}
|
|
return float64(matched)/float64(len(textWords)) >= 0.8
|
|
}
|
|
|
|
// normalizeWords lowercases and splits on anything that isn't a letter or
|
|
// digit — unicode.IsLetter (not an ASCII-only check) so accented
|
|
// characters (French: é, è, ï, ç, ...) stay part of their word instead of
|
|
// being split into fragments.
|
|
func normalizeWords(s string) []string {
|
|
return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
|
})
|
|
}
|
|
|
|
func wordSet(words []string) map[string]bool {
|
|
set := make(map[string]bool, len(words))
|
|
for _, w := range words {
|
|
set[w] = true
|
|
}
|
|
return set
|
|
}
|