feat(multimodal): 视觉分层路由(多模态检测→主/视觉/OCR 工具)+ 语音 STT→TTS 降级链(异步)
This commit is contained in:
1 parent
02aae2bbb4
commit
0a936a3202
19 files changed
+2121
No files matched your search
@@ -0,0 +1,186 @@
|
||||
package voice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// stubSTT is a mock STT returning a fixed result.
|
||||
type stubSTT struct {
|
||||
text string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubSTT) STT(_ context.Context, _ Audio) (string, error) { return s.text, s.err }
|
||||
|
||||
// stubTTS is a mock TTS returning a fixed result.
|
||||
type stubTTS struct {
|
||||
audio Audio
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubTTS) TTS(_ context.Context, _ string) (Audio, error) { return s.audio, s.err }
|
||||
|
||||
// stubProvider is a mock Provider carrying optional directions.
|
||||
type stubProvider struct {
|
||||
name string
|
||||
stt STT
|
||||
tts TTS
|
||||
}
|
||||
|
||||
func (s *stubProvider) Name() string { return s.name }
|
||||
func (s *stubProvider) STT() STT { return s.stt }
|
||||
func (s *stubProvider) TTS() TTS { return s.tts }
|
||||
|
||||
func TestChain_STT_Degrades(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
steps []Provider
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "first provider succeeds",
|
||||
steps: []Provider{&stubProvider{name: "cloud", stt: &stubSTT{text: "hello"}}},
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "first fails second succeeds",
|
||||
steps: []Provider{
|
||||
&stubProvider{name: "cloud", stt: &stubSTT{err: ErrNotConfigured}},
|
||||
&stubProvider{name: "local", stt: &stubSTT{text: "degraded"}},
|
||||
},
|
||||
want: "degraded",
|
||||
},
|
||||
{
|
||||
name: "nil direction skipped",
|
||||
steps: []Provider{
|
||||
&stubProvider{name: "edge", stt: nil},
|
||||
&stubProvider{name: "silent", stt: &stubSTT{text: "silence"}},
|
||||
},
|
||||
want: "silence",
|
||||
},
|
||||
{
|
||||
name: "all fail no silent -> error",
|
||||
steps: []Provider{
|
||||
&stubProvider{name: "cloud", stt: &stubSTT{err: ErrNotConfigured}},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "all fail silent last -> no error",
|
||||
steps: []Provider{
|
||||
&stubProvider{name: "cloud", stt: &stubSTT{err: ErrNotConfigured}},
|
||||
SilentProvider(),
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := NewChain(tt.steps...).STT(context.Background(), Audio{Format: "wav", Data: []byte("x")})
|
||||
if tt.wantErr {
|
||||
if !errors.Is(err, ErrProviderFailed) {
|
||||
t.Fatalf("err = %v, want ErrProviderFailed", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("STT error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("text = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChain_TTS_Degrades(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
steps []Provider
|
||||
want Audio
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "first provider succeeds",
|
||||
steps: []Provider{&stubProvider{name: "cloud", tts: &stubTTS{audio: Audio{Format: "mp3", Data: []byte("a")}}}},
|
||||
want: Audio{Format: "mp3", Data: []byte("a")},
|
||||
},
|
||||
{
|
||||
name: "first fails second succeeds",
|
||||
steps: []Provider{
|
||||
&stubProvider{name: "cloud", tts: &stubTTS{err: ErrNotConfigured}},
|
||||
&stubProvider{name: "edge-tts", tts: &stubTTS{audio: Audio{Format: "mp3", Data: []byte("b")}}},
|
||||
},
|
||||
want: Audio{Format: "mp3", Data: []byte("b")},
|
||||
},
|
||||
{
|
||||
name: "all fail no silent -> error",
|
||||
steps: []Provider{
|
||||
&stubProvider{name: "cloud", tts: &stubTTS{err: ErrNotConfigured}},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := NewChain(tt.steps...).TTS(context.Background(), "hi")
|
||||
if tt.wantErr {
|
||||
if !errors.Is(err, ErrProviderFailed) {
|
||||
t.Fatalf("err = %v, want ErrProviderFailed", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("TTS error: %v", err)
|
||||
}
|
||||
if got.Format != tt.want.Format || string(got.Data) != string(tt.want.Data) {
|
||||
t.Errorf("audio = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChain_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := NewChain().STT(context.Background(), Audio{}); !errors.Is(err, ErrProviderFailed) {
|
||||
t.Fatalf("err = %v, want ErrProviderFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_ConfigAssembly(t *testing.T) {
|
||||
t.Parallel()
|
||||
// With a cloud key and default edge-tts TTS, the chain starts at cloud and
|
||||
// ends at silent.
|
||||
c := New(config.VoiceConfig{DashscopeAPIKey: "k"})
|
||||
names := make([]string, 0, len(c.steps))
|
||||
for _, p := range c.steps {
|
||||
names = append(names, p.Name())
|
||||
}
|
||||
if len(names) < 2 || names[len(names)-1] != "silent" {
|
||||
t.Fatalf("steps = %v, want to end at silent", names)
|
||||
}
|
||||
if names[0] != "cloud" {
|
||||
t.Errorf("first step = %q, want cloud", names[0])
|
||||
}
|
||||
|
||||
// Without a key the cloud rung is skipped.
|
||||
c2 := New(config.VoiceConfig{})
|
||||
foundCloud := false
|
||||
for _, p := range c2.steps {
|
||||
if p.Name() == "cloud" {
|
||||
foundCloud = true
|
||||
}
|
||||
}
|
||||
if foundCloud {
|
||||
t.Error("cloud step present without an API key")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user