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

123 lines
3.4 KiB
Go

package ai
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"unicode/utf8"
)
// ASRSegment carries the source and finality of one text observation.
// Assistant speech or interim ASR never authorizes keyword termination.
type ASRSegment struct {
Source string
Text string
Final bool
}
// HangupKeyword is one ordered group from the approved task snapshot.
type HangupKeyword struct {
Name string `json:"name"`
Triggers []string `json:"triggers"`
ClosingRemark string `json:"closingRemark"`
}
func cloneHangupKeywords(groups []HangupKeyword) []HangupKeyword {
frozen := append([]HangupKeyword(nil), groups...)
for i := range frozen {
frozen[i].Triggers = append([]string(nil), frozen[i].Triggers...)
}
return frozen
}
func validateHangupKeywords(groups []HangupKeyword) error {
valid := func(text string) bool { return utf8.ValidString(text) && strings.TrimSpace(text) != "" }
for i, group := range groups {
if !valid(group.Name) || !valid(group.ClosingRemark) || len(group.Triggers) == 0 {
return fmt.Errorf("hangup group %d requires name, triggers and closingRemark", i)
}
for _, trigger := range group.Triggers {
if !valid(trigger) {
return fmt.Errorf("hangup group %d requires nonempty UTF-8 triggers", i)
}
}
}
return nil
}
// KeywordHangup reserves the first matching group before synthesis. Finish is
// called only after closing playback, or on explicit synthesis/playback failure.
// Neither the closing nor an uncertain hangup may be retried.
type KeywordHangup struct {
groups []HangupKeyword
hangup func(context.Context) error
mu sync.Mutex
requested bool
attempted bool
}
func NewKeywordHangup(groups []HangupKeyword, hangup func(context.Context) error) (*KeywordHangup, error) {
if hangup == nil {
return nil, errors.New("keyword termination requires a real hangup action")
}
if err := validateHangupKeywords(groups); err != nil {
return nil, err
}
return &KeywordHangup{groups: cloneHangupKeywords(groups), hangup: hangup}, nil
}
func (k *KeywordHangup) Requested() bool {
k.mu.Lock()
defer k.mu.Unlock()
return k.requested
}
func (k *KeywordHangup) Match(segment ASRSegment) (*HangupKeyword, error) {
if k == nil {
return nil, errors.New("keyword terminator is unavailable")
}
if segment.Source != "user" && segment.Source != "assistant" {
return nil, errors.New("ASR text source is unknown")
}
if !segment.Final || segment.Source != "user" {
return nil, nil
}
if !utf8.ValidString(segment.Text) {
return nil, errors.New("final user ASR text is not UTF-8")
}
k.mu.Lock()
defer k.mu.Unlock()
if k.requested {
return nil, errors.New("keyword closing already requested; no further turns permitted")
}
for _, group := range k.groups {
for _, trigger := range group.Triggers {
if strings.Contains(segment.Text, trigger) {
k.requested = true
group.Triggers = append([]string(nil), group.Triggers...)
return &group, nil
}
}
}
return nil, nil
}
func (k *KeywordHangup) Finish(ctx context.Context) error {
if k == nil {
return errors.New("keyword terminator is unavailable")
}
k.mu.Lock()
if !k.requested || k.attempted {
k.mu.Unlock()
return errors.New("keyword hangup not pending or already attempted")
}
k.attempted = true
k.mu.Unlock()
if err := k.hangup(ctx); err != nil {
return fmt.Errorf("keyword-triggered hangup outcome unknown: %w", err)
}
return nil
}