Files
go-sip/internal/ai/provider_pipeline_edge_test.go

169 lines
7.4 KiB
Go

package ai
import (
"context"
"encoding/binary"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func providerSnapshotRaw(mode string, asr, llm, tts bool) []byte {
value := map[string]any{
"mode": mode,
"prompt": map[string]any{"text": "approved system"},
"asr": map[string]any{"provider_ref": "asr-ref", "model": "asr-model", "language": "zh-CN", "timeout_ms": 50},
"llm": map[string]any{"provider_ref": "llm-ref", "model": "llm-model", "temperature": 0.0, "max_tokens": 17, "timeout_ms": 50},
"tts": map[string]any{"provider_ref": "tts-ref", "model": "tts-model", "voice": "voice-a", "speed": 1.0, "timeout_ms": 50},
}
if !asr {
delete(value["asr"].(map[string]any), "provider_ref")
}
if !llm {
delete(value["llm"].(map[string]any), "provider_ref")
}
if !tts {
delete(value["tts"].(map[string]any), "provider_ref")
}
raw, _ := json.Marshal(value)
return raw
}
func wavPCM16(sampleRate int, pcm []byte) []byte {
data := make([]byte, 44+len(pcm))
copy(data[:4], "RIFF")
binary.LittleEndian.PutUint32(data[4:8], uint32(len(data)-8))
copy(data[8:12], "WAVE")
copy(data[12:16], "fmt ")
binary.LittleEndian.PutUint32(data[16:20], 16)
binary.LittleEndian.PutUint16(data[20:22], 1)
binary.LittleEndian.PutUint16(data[22:24], 1)
binary.LittleEndian.PutUint32(data[24:28], uint32(sampleRate))
binary.LittleEndian.PutUint32(data[28:32], uint32(sampleRate*2))
binary.LittleEndian.PutUint16(data[32:34], 2)
binary.LittleEndian.PutUint16(data[34:36], 16)
copy(data[36:40], "data")
binary.LittleEndian.PutUint32(data[40:44], uint32(len(pcm)))
copy(data[44:], pcm)
return data
}
func TestProviderPipelineFullTurnUsesConfiguredLLMAndTTS(t *testing.T) {
pcm := []byte{1, 0, 2, 0, 3, 0, 4, 0}
wav := wavPCM16(16000, pcm)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/chat/completions"):
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"approved reply"}}]}`))
case strings.HasSuffix(r.URL.Path, "/generation"):
_, _ = w.Write([]byte(`{"output":{"audio":{"url":"` + "PLACEHOLDER" + `"}}}`))
case r.URL.Path == "/audio.wav":
w.Header().Set("Content-Type", "audio/wav")
_, _ = w.Write(wav)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
// Replace the placeholder without putting a second server or a public URL in
// the fixture; the provider still uses the same bounded test HTTP client.
server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/chat/completions"):
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"approved reply"}}]}`))
case strings.HasSuffix(r.URL.Path, "/generation"):
_, _ = w.Write([]byte(`{"output":{"audio":{"url":"` + server.URL + `/audio.wav"}}}`))
case r.URL.Path == "/audio.wav":
w.Header().Set("Content-Type", "audio/wav")
_, _ = w.Write(wav)
default:
http.NotFound(w, r)
}
})
pipeline := &ProviderPipeline{cfg: ProviderPipelineConfig{
VolcAppID: "asr-app", VolcAPIKey: "asr-key", BailianAPIKey: "llm-key",
BailianBaseURL: server.URL + "/v1", HTTPClient: server.Client(), MaxAudioBytes: 1024,
}}
pipeline.recognizeFn = func(context.Context, string, string, []byte) (string, error) { return "approved transcript", nil }
result, err := pipeline.RunTurn(context.Background(), Snapshot{Mode: ModeFullAI, Raw: providerSnapshotRaw("full_ai", true, true, true)}, []byte{1, 2})
if err != nil {
t.Fatal(err)
}
if result.Transcript != "approved transcript" || result.Reply != "approved reply" || string(result.AudioPCM16) != string(pcm) {
t.Fatalf("unexpected full turn result: %+v", result)
}
}
func TestProviderPipelineRejectsInvalidTurnInputs(t *testing.T) {
base := &ProviderPipeline{cfg: ProviderPipelineConfig{VolcAppID: "asr", VolcAPIKey: "key"}}
base.recognizeFn = func(context.Context, string, string, []byte) (string, error) { return "", nil }
cases := []struct {
name string
pipeline *ProviderPipeline
snapshot Snapshot
pcm []byte
want string
}{
{"empty pcm", base, Snapshot{Mode: ModeASROnly, Raw: providerSnapshotRaw("asr_only", true, false, false)}, nil, "input PCM is empty"},
{"invalid mode", base, Snapshot{Mode: Mode("invalid"), Raw: providerSnapshotRaw("invalid", true, false, false)}, []byte{1}, "unsupported AI mode"},
{"bad snapshot", base, Snapshot{Mode: ModeASROnly, Raw: []byte("{")}, []byte{1}, "decode immutable AI snapshot"},
{"missing asr", base, Snapshot{Mode: ModeASROnly, Raw: providerSnapshotRaw("asr_only", false, false, false)}, []byte{1}, "ASR provider ref is required"},
{"empty transcript", base, Snapshot{Mode: ModeASROnly, Raw: providerSnapshotRaw("asr_only", true, false, false)}, []byte{1}, "ASR returned empty transcript"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := tc.pipeline.RunTurn(context.Background(), tc.snapshot, tc.pcm); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error=%v, want %q", err, tc.want)
}
})
}
failingASR := &ProviderPipeline{cfg: base.cfg, recognizeFn: func(context.Context, string, string, []byte) (string, error) { return "", context.DeadlineExceeded }}
if _, err := failingASR.RunTurn(context.Background(), Snapshot{Mode: ModeASROnly, Raw: providerSnapshotRaw("asr_only", true, false, false)}, []byte{1}); err == nil || !strings.Contains(err.Error(), "ASR failed") {
t.Fatalf("ASR error was hidden: %v", err)
}
}
func TestProviderPipelineWAVValidationAndResampling(t *testing.T) {
pcm := []byte{1, 0, 2, 0, 3, 0, 4, 0}
for _, tc := range []struct {
name string
data []byte
want string
}{
{"short", []byte("RIFF"), "not RIFF/WAVE"},
{"wrong container", []byte("RIFFxxxxNOPE"), "not RIFF/WAVE"},
{"bad fmt", append([]byte("RIFFxxxxWAVEfmt "), 2, 0, 0, 0, 0, 0), "invalid WAV fmt"},
{"bad format", wavPCM16(16000, pcm)[:44], "unsupported WAV format"},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := decodeWAVToPCM16(tc.data); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error=%v, want %q", err, tc.want)
}
})
}
resampled, err := decodeWAVToPCM16(wavPCM16(8000, pcm))
if err != nil || len(resampled) == 0 {
t.Fatalf("valid non-16k WAV was not resampled: len=%d err=%v", len(resampled), err)
}
if got := resamplePCM16(nil, 8000, 16000); len(got) != 0 {
t.Fatalf("empty resample returned %d bytes", len(got))
}
}
func TestProviderPipelineSynthesizeFailureModes(t *testing.T) {
p := &ProviderPipeline{cfg: ProviderPipelineConfig{VolcAppID: "asr", VolcAPIKey: "key", BailianAPIKey: "key", BailianBaseURL: "://bad"}}
if _, err := p.Synthesize(context.Background(), Snapshot{Mode: Mode("other")}, "text"); err == nil || !strings.Contains(err.Error(), "unsupported AI mode") {
t.Fatal("unsupported mode accepted")
}
if _, err := p.Synthesize(context.Background(), Snapshot{Mode: ModeFullAI, Raw: []byte(`{"mode":"full_ai"}`)}, "text"); err == nil || !strings.Contains(err.Error(), "TTS provider ref") {
t.Fatal("missing TTS provider accepted")
}
if _, err := p.Synthesize(context.Background(), Snapshot{Mode: ModeFullAI, Raw: providerSnapshotRaw("full_ai", true, true, true)}, "text"); err == nil || !strings.Contains(err.Error(), "invalid BAILIAN_BASE_URL") {
t.Fatal("invalid TTS endpoint accepted")
}
}