123 lines
3.4 KiB
Go
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
|
|
}
|