201 lines
9.2 KiB
Go
201 lines
9.2 KiB
Go
package callflow
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.ipao.vip/rogee/go-sip/internal/ai"
|
|
"git.ipao.vip/rogee/go-sip/internal/media"
|
|
)
|
|
|
|
type approvedFlowPipeline struct {
|
|
openingCalls int
|
|
turnCalls int
|
|
turn ai.TurnResult
|
|
}
|
|
|
|
func (p *approvedFlowPipeline) Open(context.Context) ([]byte, error) {
|
|
p.openingCalls++
|
|
return make([]byte, 6400), nil
|
|
}
|
|
|
|
func (p *approvedFlowPipeline) RunTurn(context.Context, []byte) (ai.TurnResult, error) {
|
|
p.turnCalls++
|
|
return p.turn, nil
|
|
}
|
|
|
|
type scriptedTurnSession struct {
|
|
turns [][]byte
|
|
index int
|
|
served bool
|
|
stats media.RTPStats
|
|
}
|
|
|
|
func (s *scriptedTurnSession) ReadPayload(ctx context.Context) ([]byte, error) {
|
|
if !s.served && s.index < len(s.turns) {
|
|
s.served = true
|
|
payload := append([]byte(nil), s.turns[s.index]...)
|
|
s.index++
|
|
s.stats.ReceivedPackets++
|
|
s.stats.ReceivedBytes += uint64(len(payload))
|
|
return payload, nil
|
|
}
|
|
<-ctx.Done()
|
|
s.served = false
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
func (s *scriptedTurnSession) SendPCM16(ctx context.Context, pcm []byte, _ int) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
s.stats.SentPackets++
|
|
s.stats.SentBytes += uint64(len(pcm))
|
|
return nil
|
|
}
|
|
|
|
func (s *scriptedTurnSession) Stats() media.RTPStats { return s.stats }
|
|
|
|
type rejectedOpeningSession struct{ MediaSession }
|
|
|
|
func (rejectedOpeningSession) SendPCM16(context.Context, []byte, int) error {
|
|
return context.Canceled
|
|
}
|
|
|
|
func TestApprovedInitialMediaWaitIsBounded(t *testing.T) {
|
|
pipeline := &approvedFlowPipeline{turn: ai.TurnResult{Transcript: "不应执行"}}
|
|
_, err := ExecuteApproved(context.Background(), NewMemorySession(nil), ai.ModeASROnly, pipeline, CaptureConfig{
|
|
FirstSpeechTimeout: time.Millisecond, MaxDuration: time.Millisecond, MaxTurns: 1,
|
|
})
|
|
if err == nil || pipeline.turnCalls != 0 {
|
|
t.Fatalf("missing speech was not bounded: calls=%d err=%v", pipeline.turnCalls, err)
|
|
}
|
|
}
|
|
|
|
func TestApprovedSharedFlowRunsThreeConversationTurns(t *testing.T) {
|
|
pipeline := &approvedFlowPipeline{turn: ai.TurnResult{Transcript: "最终识别", Reply: "获批回答", AudioPCM16: make([]byte, 6400)}}
|
|
session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400), make([]byte, 6400), make([]byte, 6400)}}
|
|
result, err := ExecuteApproved(context.Background(), session, ai.ModeFullAI, pipeline, CaptureConfig{
|
|
FirstSpeechTimeout: time.Second, MaxDuration: 2 * time.Millisecond, MaxTurns: 3,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pipeline.openingCalls != 1 || pipeline.turnCalls != 3 || len(result.Turns) != 3 || len(result.InboundTurns) != 3 || len(result.OutboundTurns) != 4 || result.RTP.ReceivedBytes != 19200 {
|
|
t.Fatalf("approved three-turn flow incomplete: opening=%d turns=%d inbound=%d outbound=%d received=%d", pipeline.openingCalls, pipeline.turnCalls, len(result.InboundTurns), len(result.OutboundTurns), result.RTP.ReceivedBytes)
|
|
}
|
|
}
|
|
|
|
func TestApprovedOpeningSendFailureDoesNotInventPlayback(t *testing.T) {
|
|
session := rejectedOpeningSession{MediaSession: NewMemorySession(nil)}
|
|
pipeline := &approvedFlowPipeline{}
|
|
result, err := ExecuteApproved(context.Background(), session, ai.ModeFullAI, pipeline, CaptureConfig{MaxTurns: 1})
|
|
if err == nil || len(result.OutboundTurns) != 0 || result.RTP.SentPackets != 0 || pipeline.turnCalls != 0 {
|
|
t.Fatalf("failed opening was reported as played: err=%v outbound=%d sent=%d turns=%d", err, len(result.OutboundTurns), result.RTP.SentPackets, pipeline.turnCalls)
|
|
}
|
|
}
|
|
|
|
func TestApprovedInvalidCallStopsBeforeReply(t *testing.T) {
|
|
pipeline := &approvedFlowPipeline{turn: ai.TurnResult{Transcript: "打错了", InvalidCall: true, InvalidReason: "llm_invalid_call_marker", AudioPCM16: make([]byte, 6400)}}
|
|
session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}}
|
|
result, err := ExecuteApproved(context.Background(), session, ai.ModeFullAI, pipeline, CaptureConfig{
|
|
FirstSpeechTimeout: time.Second, MaxDuration: 2 * time.Millisecond, MaxTurns: 3,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !result.Turn.InvalidCall || result.Turn.InvalidReason != "llm_invalid_call_marker" || len(result.Turns) != 1 || session.stats.SentPackets != 1 {
|
|
t.Fatalf("invalid call did not stop before reply: turn=%+v turns=%d sent=%d", result.Turn, len(result.Turns), session.stats.SentPackets)
|
|
}
|
|
}
|
|
|
|
func TestApprovedKeywordStopsBeforeSendingReply(t *testing.T) {
|
|
pipeline := &approvedFlowPipeline{turn: ai.TurnResult{Transcript: "我不用了", EndedByKeyword: true, AudioPCM16: make([]byte, 6400)}}
|
|
session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}}
|
|
result, err := ExecuteApproved(context.Background(), session, ai.ModeFullAI, pipeline, CaptureConfig{
|
|
FirstSpeechTimeout: time.Second, MaxDuration: 2 * time.Millisecond, MaxTurns: 3,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !result.Turn.EndedByKeyword || len(result.Turns) != 1 || len(result.OutboundTurns) != 1 || session.stats.SentPackets != 1 || pipeline.openingCalls != 1 || pipeline.turnCalls != 1 {
|
|
t.Fatalf("keyword hangup must stop before LLM/TTS reply: turns=%d outbound=%d sent=%d", len(result.Turns), len(result.OutboundTurns), session.stats.SentPackets)
|
|
}
|
|
}
|
|
|
|
func TestApprovedUnconfiguredRefusalTextDoesNotInventHangup(t *testing.T) {
|
|
pipeline := &approvedFlowPipeline{turn: ai.TurnResult{Transcript: "不用了", Reply: "继续为您服务", AudioPCM16: make([]byte, 6400)}}
|
|
session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}}
|
|
result, err := ExecuteApproved(context.Background(), session, ai.ModeFullAI, pipeline, CaptureConfig{
|
|
FirstSpeechTimeout: time.Second, MaxDuration: 2 * time.Millisecond, MaxTurns: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Turn.InvalidCall || result.Turn.EndedByKeyword || len(result.OutboundTurns) != 2 || session.stats.SentPackets != 2 {
|
|
t.Fatalf("no unapproved hardcoded refusal match may terminate call: %+v", result.Turn)
|
|
}
|
|
}
|
|
|
|
func TestApprovedASROnlySkipsOpeningAndReplyEvenIfPipelineHasAudio(t *testing.T) {
|
|
pipeline := &approvedFlowPipeline{turn: ai.TurnResult{Transcript: "最终识别", AudioPCM16: make([]byte, 6400)}}
|
|
session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}}
|
|
before := time.Now()
|
|
result, err := ExecuteApproved(context.Background(), session, ai.ModeASROnly, pipeline, CaptureConfig{
|
|
FirstSpeechTimeout: time.Second, MaxDuration: 2 * time.Millisecond, MaxTurns: 1,
|
|
})
|
|
after := time.Now()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(result.CaptureWindows) != 1 || result.CaptureWindows[0].StartedAt.Before(before) || result.CaptureWindows[0].EndedAt.After(after) || result.CaptureWindows[0].EndedAt.Before(result.CaptureWindows[0].StartedAt) {
|
|
t.Fatalf("ASR-only has no observed media interval: %+v", result.CaptureWindows)
|
|
}
|
|
if pipeline.openingCalls != 0 || pipeline.turnCalls != 1 || len(result.OutboundTurns) != 0 || session.stats.SentPackets != 0 {
|
|
t.Fatalf("ASR-only cannot play opening or TTS reply: opening=%d turns=%d sent=%d", pipeline.openingCalls, pipeline.turnCalls, session.stats.SentPackets)
|
|
}
|
|
}
|
|
|
|
func TestApprovedCapturePreservesConversationControls(t *testing.T) {
|
|
bound := ai.Binding{Conversation: ai.ConversationConfig{SilenceTimeout: 3 * time.Second, MaxDuration: 2 * time.Minute, MaxTurns: 20, SentenceMaxChars: 80, MaxPendingAudioChunks: 32}}
|
|
capture := ApprovedCapture(bound)
|
|
if capture.FirstSpeechTimeout != 3*time.Second || capture.EndSilence != 3*time.Second || capture.CallDuration != 2*time.Minute || capture.MaxDuration != 0 || capture.MaxTurns != 20 || capture.VoiceThreshold <= 0 || capture.MaxPendingAudioChunks != 32 {
|
|
t.Fatalf("approved conversation limits were not handed to media controller: %+v", capture)
|
|
}
|
|
}
|
|
|
|
type approvedDeadlineProbe struct{ deadlinePresent bool }
|
|
|
|
func (*approvedDeadlineProbe) Open(context.Context) ([]byte, error) { return nil, nil }
|
|
func (p *approvedDeadlineProbe) RunTurn(ctx context.Context, _ []byte) (ai.TurnResult, error) {
|
|
deadline, ok := ctx.Deadline()
|
|
p.deadlinePresent = ok && time.Until(deadline) > 0 && time.Until(deadline) <= time.Second
|
|
return ai.TurnResult{Transcript: "最终识别", EndedByKeyword: true}, nil
|
|
}
|
|
|
|
func TestApprovedConversationDurationBoundsWholeCall(t *testing.T) {
|
|
pipeline := &approvedDeadlineProbe{}
|
|
session := &scriptedTurnSession{turns: [][]byte{bytes.Repeat([]byte{1, 0}, 3200)}}
|
|
capture := ApprovedCapture(ai.Binding{Conversation: ai.ConversationConfig{
|
|
SilenceTimeout: 2 * time.Millisecond, MaxDuration: time.Second, MaxTurns: 1,
|
|
}})
|
|
if _, err := ExecuteApproved(context.Background(), session, ai.ModeASROnly, pipeline, capture); err != nil || !pipeline.deadlinePresent {
|
|
t.Fatalf("approved overall duration was not enforced throughout media and AI: deadline=%t err=%v", pipeline.deadlinePresent, err)
|
|
}
|
|
}
|
|
|
|
func TestApprovedSilenceTimeoutRejectsMissingSpeech(t *testing.T) {
|
|
pipeline := &approvedFlowPipeline{turn: ai.TurnResult{Transcript: "不应执行"}}
|
|
session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}}
|
|
capture := ApprovedCapture(ai.Binding{Conversation: ai.ConversationConfig{
|
|
SilenceTimeout: 10 * time.Millisecond, MaxDuration: 100 * time.Millisecond, MaxTurns: 1,
|
|
}})
|
|
_, err := ExecuteApproved(context.Background(), session, ai.ModeASROnly, pipeline, capture)
|
|
if err == nil || !strings.Contains(err.Error(), "speech") || pipeline.turnCalls != 0 {
|
|
t.Fatalf("configured silence timeout did not stop before AI: turns=%d err=%v", pipeline.turnCalls, err)
|
|
}
|
|
}
|