mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
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:
1 parent
e517470554
commit
dff488044c
2 files changed
+128
No files matched your search
@@ -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."))
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user