Files
go-sip/internal/ai/approved_mock.go
T

143 lines
4.6 KiB
Go

package ai
import (
"bytes"
"context"
"errors"
"strings"
"sync"
"unicode/utf8"
)
// ApprovedMockScript is explicit synthetic media and final ASR text for local
// isolation only. It is never a substitute for an actual provider response.
type ApprovedMockScript struct {
OpeningPCM16 []byte
Turns []ApprovedMockTurn
}
type ApprovedMockTurn struct {
Transcript string
Reply string
ReplyPCM16 []byte
}
// ApprovedMockPipeline is private to one approved Mock call. The signed AI
// parameters remain owned by Binding; this adapter neither contacts a
// provider nor silently manufactures absent script turns or audio.
type ApprovedMockPipeline struct {
mu sync.Mutex
mode string
openingPCM []byte
turns []ApprovedMockTurn
keyword *KeywordHangup
opened bool
ended bool
next int
}
func NewApprovedMockPipeline(bound Binding, script ApprovedMockScript, hangup func(context.Context) error) (*ApprovedMockPipeline, error) {
keyword, err := NewKeywordHangup(bound.HangupKeywords, hangup)
if err != nil {
return nil, err
}
maxTurns := bound.Conversation.MaxTurns
if maxTurns == 0 {
maxTurns = 1 // executeFlow uses one turn when this optional limit is absent.
}
if maxTurns < 1 || len(script.Turns) != maxTurns {
return nil, errors.New("Mock script must cover exactly the approved maximum turns")
}
switch bound.Mode {
case string(ModeASROnly):
if bound.Opening != "" || bound.LLM != nil || bound.TTS != nil || len(script.OpeningPCM16) != 0 {
return nil, errors.New("ASR-only Mock cannot synthesize opening or assistant audio")
}
case string(ModeFullAI):
if bound.LLM == nil || bound.TTS == nil {
return nil, errors.New("full-AI Mock requires approved LLM and TTS configuration")
}
if (bound.Opening == "" && len(script.OpeningPCM16) != 0) || (bound.Opening != "" && !validMockPCM(script.OpeningPCM16)) {
return nil, errors.New("Mock opening audio must match the approved opening")
}
default:
return nil, errors.New("Mock AI mode is unsupported")
}
frozen := make([]ApprovedMockTurn, len(script.Turns))
for i, turn := range script.Turns {
if !utf8.ValidString(turn.Transcript) || !utf8.ValidString(turn.Reply) {
return nil, errors.New("Mock text is not UTF-8")
}
if bound.Mode == string(ModeASROnly) {
if turn.Reply != "" || len(turn.ReplyPCM16) != 0 {
return nil, errors.New("ASR-only Mock contains assistant audio")
}
} else {
keywordTurn := false
for _, literal := range bound.HangupKeywords {
if strings.Contains(turn.Transcript, literal) {
keywordTurn = true
break
}
}
if !keywordTurn && (turn.Reply == "" || !validMockPCM(turn.ReplyPCM16)) {
return nil, errors.New("full-AI Mock reply is missing or invalid")
}
if len(turn.ReplyPCM16)%2 != 0 {
return nil, errors.New("Mock reply PCM16 has an odd byte count")
}
}
frozen[i] = ApprovedMockTurn{Transcript: turn.Transcript, Reply: turn.Reply, ReplyPCM16: bytes.Clone(turn.ReplyPCM16)}
}
return &ApprovedMockPipeline{mode: bound.Mode, openingPCM: bytes.Clone(script.OpeningPCM16), turns: frozen, keyword: keyword}, nil
}
func validMockPCM(pcm []byte) bool { return len(pcm) > 0 && len(pcm)%2 == 0 }
func (p *ApprovedMockPipeline) Open(ctx context.Context) ([]byte, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
p.mu.Lock()
defer p.mu.Unlock()
if p.opened {
return nil, errors.New("Mock opening already attempted")
}
p.opened = true
if p.mode == string(ModeASROnly) {
return nil, nil
}
return bytes.Clone(p.openingPCM), nil
}
func (p *ApprovedMockPipeline) RunTurn(ctx context.Context, pcm []byte) (TurnResult, error) {
if err := ctx.Err(); err != nil {
return TurnResult{}, err
}
p.mu.Lock()
defer p.mu.Unlock()
// The shared ASR-only flow never calls Open; full-AI must play its opening first.
if (p.mode == string(ModeFullAI) && !p.opened) || p.ended || p.next >= len(p.turns) {
return TurnResult{}, errors.New("Mock turn was not approved or has already ended")
}
if len(pcm) < 3200 || len(pcm)%2 != 0 {
return TurnResult{}, errors.New("Mock ASR requires observed 16-kHz PCM16 media")
}
turn := p.turns[p.next]
p.next++ // A transport or hangup failure cannot replay this turn.
stopped, err := p.keyword.Handle(ctx, ASRSegment{Source: "user", Text: turn.Transcript, Final: true})
if stopped {
p.ended = true
}
if err != nil {
return TurnResult{}, err
}
if stopped {
return TurnResult{Transcript: turn.Transcript, EndedByKeyword: true}, nil
}
if p.mode == string(ModeASROnly) {
return TurnResult{Transcript: turn.Transcript}, nil
}
return TurnResult{Transcript: turn.Transcript, Reply: turn.Reply, AudioPCM16: bytes.Clone(turn.ReplyPCM16)}, nil
}