143 lines
4.6 KiB
Go
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
|
|
}
|