fix(realtime): strip EOU language tags from the LLM context

ASR backends like Nemotron-3.5 append end-of-utterance language tags to
transcripts ("<de-DE>", "<en-US>", ...). The committed transcript feeds
the LLM conversation (the user item Transcript field), but the per-utterance
tag is unreliable for short/ambiguous utterances (a German "Hallo." tagged
<en-US>) and the LLM treats it as a language signal, switching the reply
language against the session instructions.

commitUtteranceWithTranscript now strips the EOU tags from the transcript
that goes to appendUserItem/generateResponse (the LLM context). The emitted
input_audio_transcription event keeps the raw transcript (debugging /
client display).

Regression tests: stripEOULanguageTags unit specs + a client-commit spec
asserting the emitted event keeps the raw tag while the LLM user message is
tag-free.

Signed-off-by: nexxtmobile.de <git@nexxtmobile.de>
This commit is contained in:
nexxtmobile.de committed 2026-10-19 12:36:42 +00:00
1 parent e517470554
commit dff488044c
2 files changed
+128

No files matched your search

+23
View File
@@ -12,6 +12,7 @@ import (
"maps"
"math"
"os"
"regexp"
"strconv"
"strings"
"sync"
@@ -2017,6 +2018,19 @@ func commitUtterance(ctx context.Context, utt []byte, session *Session, conv *Co
commitUtteranceWithTranscript(ctx, utt, nil, nil, "", session, conv, t, slot)
}
// eouLangTagRe matches the end-of-utterance language tags that Nemotron-3.5
// ASR appends to transcripts (BCP-47 style: "<de-DE>", "<en-US>", ...).
var eouLangTagRe = regexp.MustCompile(`\s*<[a-z]{2,3}(?:-[A-Za-z]{2,3})?>\s*`)
// stripEOULanguageTags removes the STT EOU language tags from a transcript
// (see the call site in commitUtteranceWithTranscript).
func stripEOULanguageTags(s string) string {
if !strings.Contains(s, "<") {
return s
}
return strings.Join(strings.Fields(eouLangTagRe.ReplaceAllString(s, " ")), " ")
}
// commitUtteranceWithTranscript commits one user turn. live carries the
// transcript semantic_vad's live stream already produced (its caption deltas
// were streamed to the client during the turn, so only the completed event
@@ -2184,6 +2198,15 @@ func commitUtteranceWithTranscript(ctx context.Context, utt []byte, live *liveUt
return
}
// EOU language tags (e.g. "<de-DE>") are STT metadata: they stay in the
// transcription event emitted above (debugging, client display), but are
// stripped from the LLM context — the per-utterance tag is unreliable for
// short/ambiguous utterances (a German "Hallo." tagged <en-US>), and the
// LLM treats it as a language signal (the agent's language rule then
// switches the reply language; see CallcenterAiAgent agent.py,
// 17.10.2026).
transcript = stripEOULanguageTags(transcript)
// Sound-event detection is additive to transcription: classify the same
// committed window and emit its scored AudioSet tags as a separate event.
// A failure here is logged but must never abort the turn.
@@ -0,0 +1,105 @@
package openai
import (
"context"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/endpoints/openai/types"
)
// Regression test: ASR backends like Nemotron-3.5 append end-of-utterance
// language tags to transcripts ("<de-DE>", "<en-US>", ...). The committed
// transcript feeds the LLM conversation (the user item's Transcript field).
// The per-utterance tag is unreliable for short/ambiguous utterances (a
// German "Hallo." tagged <en-US>) and the LLM treats it as a language
// signal, switching the reply language against the session instructions.
// The tags must be stripped from the LLM context while the emitted
// transcription event keeps the raw transcript (debugging / client display).
var _ = Describe("stripEOULanguageTags", func() {
It("removes a trailing EOU tag", func() {
Expect(stripEOULanguageTags("Hallo. <en-US>")).To(Equal("Hallo."))
})
It("removes mid-text EOU tags and normalizes whitespace", func() {
Expect(stripEOULanguageTags("My name is Benjamin Zimmer. <en-US> Ich hatte gerne eine Festnetzrufnummer. <de-DE>")).
To(Equal("My name is Benjamin Zimmer. Ich hatte gerne eine Festnetzrufnummer."))
})
It("handles 3-letter primary subtags", func() {
Expect(stripEOULanguageTags("Hello. <eng>")).To(Equal("Hello."))
})
It("leaves plain text untouched", func() {
Expect(stripEOULanguageTags("Guten Tag, wie kann ich helfen?")).
To(Equal("Guten Tag, wie kann ich helfen?"))
})
})
var _ = Describe("client commit with an EOU-tagged transcript", func() {
utt := []byte{1, 2, 3, 4}
newModel := func(text string) *heldTranscribeModel {
return &heldTranscribeModel{
fakeModel: &fakeModel{
cfg: &config.ModelConfig{},
predictTokens: []string{"ok"},
predictResp: backend.LLMResponse{Response: "ok"},
ttsStreamChunks: [][]byte{{1}},
ttsStreamRate: 24000,
},
texts: []string{text},
release: make(chan struct{}),
started: make(chan int32, 8),
finished: make(chan int32, 8),
}
}
newSession := func(m *heldTranscribeModel) *Session {
on := true
session := &Session{
OutputSampleRate: 24000,
InputAudioTranscription: &types.AudioTranscription{},
ModelInterface: m,
ModelConfig: &config.ModelConfig{
Pipeline: config.Pipeline{Streaming: config.PipelineStreaming{LLM: &on, TTS: &on}},
},
respSink: newResponseSink(),
}
session.sessionCtx, session.sessionCancel = context.WithCancel(context.Background())
return session
}
It("keeps the raw tag in the emitted event but strips it from the LLM context", func() {
m := newModel("Hallo. <en-US>")
session := newSession(m)
tr := &fakeTransport{}
conv := &Conversation{}
session.issueClientCommit(utt, conv, tr)
Eventually(m.started, "2s").Should(Receive(Equal(int32(1))))
close(m.release)
session.respSink.wait()
// The emitted transcription event keeps the raw transcript.
var emitted string
for _, ev := range tr.events() {
if c, ok := ev.(types.ConversationItemInputAudioTranscriptionCompletedEvent); ok {
emitted = c.Transcript
}
}
Expect(emitted).To(Equal("Hallo. <en-US>"))
// The LLM context (user message) must be tag-free.
var llmUser string
for _, msg := range m.lastMessages {
if msg.Role == string(types.MessageRoleUser) {
llmUser = msg.StringContent
}
}
Expect(llmUser).To(Equal("Hallo."))
})
})