sync: migrate ai-operator to Gitea (2026-08-10)
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const PendingRegionCandidatesMetadataKey = "pending_region_candidates"
|
||||
|
||||
type Machine struct {
|
||||
session ConversationSession
|
||||
}
|
||||
|
||||
func NewMachine(initial ConversationSession) *Machine {
|
||||
now := time.Now().UTC()
|
||||
if initial.StartedAt.IsZero() {
|
||||
initial.StartedAt = now
|
||||
}
|
||||
if initial.UpdatedAt.IsZero() {
|
||||
initial.UpdatedAt = initial.StartedAt
|
||||
}
|
||||
if initial.State == "" {
|
||||
initial.State = StateCallStarted
|
||||
}
|
||||
if initial.Region.Status == "" {
|
||||
initial.Region.Status = RegionUnknown
|
||||
}
|
||||
if initial.Metadata == nil {
|
||||
initial.Metadata = map[string]string{}
|
||||
}
|
||||
return &Machine{session: initial}
|
||||
}
|
||||
|
||||
func (m *Machine) Session() ConversationSession { return cloneSession(m.session) }
|
||||
func (m *Machine) CurrentState() ConversationState { return m.session.State }
|
||||
|
||||
func (m *Machine) CanTransition(event ConversationEvent) bool {
|
||||
_, err := m.next(event)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (m *Machine) Apply(event ConversationEvent) (TransitionResult, error) {
|
||||
from := m.session.State
|
||||
to, err := m.next(event)
|
||||
if err != nil {
|
||||
m.recordDenied(string(event.Type), err.Error())
|
||||
return TransitionResult{From: from, To: from, Changed: false, MessageKey: "error.invalid_transition", RequiredNextAction: requiredActionForState(from)}, err
|
||||
}
|
||||
if event.At.IsZero() {
|
||||
event.At = time.Now().UTC()
|
||||
}
|
||||
if from == StateEnded {
|
||||
return TransitionResult{From: from, To: from, Changed: false, MessageKey: "error.invalid_transition", RequiredNextAction: "none"}, fmt.Errorf("ended state is immutable")
|
||||
}
|
||||
m.session.State = to
|
||||
switch event.Type {
|
||||
case EventLanguageSelected:
|
||||
m.session.Language = event.Language
|
||||
case EventRegionSelected:
|
||||
m.session.Region = event.Region
|
||||
m.ClearPendingRegion()
|
||||
case EventCallEnded:
|
||||
t := event.At
|
||||
m.session.EndedAt = &t
|
||||
}
|
||||
m.session.UpdatedAt = event.At
|
||||
m.session.TransitionHistory = append(m.session.TransitionHistory, TransitionRecord{From: from, To: to, Event: event.Type, Reason: event.Reason, At: event.At})
|
||||
return TransitionResult{From: from, To: to, Changed: from != to, MessageKey: messageKeyForState(to), RequiredNextAction: requiredActionForState(to)}, nil
|
||||
}
|
||||
|
||||
func (m *Machine) AddDenied(action, reason string) {
|
||||
m.recordDenied(action, reason)
|
||||
}
|
||||
|
||||
func (m *Machine) SetRegionPending(codes []string) {
|
||||
if m.session.Metadata == nil {
|
||||
m.session.Metadata = map[string]string{}
|
||||
}
|
||||
m.session.Region = RegionSelection{Status: RegionPendingClarification, Source: "resolver"}
|
||||
m.session.Metadata[PendingRegionCandidatesMetadataKey] = strings.Join(codes, ",")
|
||||
m.session.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
func (m *Machine) PendingRegionCandidates() []string {
|
||||
v := strings.TrimSpace(m.session.Metadata[PendingRegionCandidatesMetadataKey])
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Machine) ClearPendingRegion() {
|
||||
if m.session.Metadata != nil {
|
||||
delete(m.session.Metadata, PendingRegionCandidatesMetadataKey)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Machine) recordDenied(action, reason string) {
|
||||
m.session.DeniedActions = append(m.session.DeniedActions, DeniedActionRecord{Action: action, State: m.session.State, ReasonCode: reason, At: time.Now().UTC()})
|
||||
m.session.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
func (m *Machine) next(event ConversationEvent) (ConversationState, error) {
|
||||
if m.session.State == StateEnded {
|
||||
return StateEnded, fmt.Errorf("ended_state")
|
||||
}
|
||||
switch event.Type {
|
||||
case EventCallStarted:
|
||||
if m.session.State == StateCallStarted {
|
||||
return StateGreeting, nil
|
||||
}
|
||||
case EventGreetingPlayed, EventLanguageRequested:
|
||||
if m.session.State == StateGreeting {
|
||||
return StateReadyToHelp, nil
|
||||
}
|
||||
case EventLanguageSelected:
|
||||
if event.Language != LanguageRU && event.Language != LanguageKK {
|
||||
return m.session.State, fmt.Errorf("invalid_language")
|
||||
}
|
||||
switch m.session.State {
|
||||
case StateLanguageSelection:
|
||||
return StateRegionSelection, nil
|
||||
case StateRegionSelection, StateReadyToHelp, StateQuestionAnswering:
|
||||
return m.session.State, nil
|
||||
}
|
||||
case EventRegionSelected:
|
||||
if event.Region.Code == "" || event.Region.Status != RegionSelected {
|
||||
return m.session.State, fmt.Errorf("invalid_region")
|
||||
}
|
||||
switch m.session.State {
|
||||
case StateRegionSelection, StateReadyToHelp, StateQuestionAnswering:
|
||||
return StateReadyToHelp, nil
|
||||
}
|
||||
case EventQuestionReceived:
|
||||
if m.session.State == StateReadyToHelp {
|
||||
return StateQuestionAnswering, nil
|
||||
}
|
||||
return m.session.State, fmt.Errorf("state_not_ready")
|
||||
case EventAnswerCompleted:
|
||||
if m.session.State == StateQuestionAnswering {
|
||||
return StateReadyToHelp, nil
|
||||
}
|
||||
case EventHandoffRequested:
|
||||
return StateHandoff, nil
|
||||
case EventClosingRequested:
|
||||
return StateClosing, nil
|
||||
case EventCallEnded:
|
||||
return StateEnded, nil
|
||||
}
|
||||
return m.session.State, fmt.Errorf("invalid_transition")
|
||||
}
|
||||
|
||||
func messageKeyForState(s ConversationState) string {
|
||||
switch s {
|
||||
case StateGreeting:
|
||||
return "greeting.initial"
|
||||
case StateLanguageSelection:
|
||||
return "language.ask"
|
||||
case StateRegionSelection:
|
||||
return "region.ask"
|
||||
case StateReadyToHelp:
|
||||
return "ready.to_help"
|
||||
case StateQuestionAnswering:
|
||||
return "answer.allowed"
|
||||
case StateHandoff:
|
||||
return "handoff.started"
|
||||
case StateClosing:
|
||||
return "closing.started"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func requiredActionForState(s ConversationState) string {
|
||||
switch s {
|
||||
case StateGreeting:
|
||||
return "play_greeting"
|
||||
case StateLanguageSelection:
|
||||
return "ask_language"
|
||||
case StateRegionSelection:
|
||||
return "ask_region"
|
||||
case StateReadyToHelp:
|
||||
return "wait_for_question"
|
||||
case StateQuestionAnswering:
|
||||
return "answer_question"
|
||||
case StateHandoff:
|
||||
return "handoff"
|
||||
case StateClosing, StateEnded:
|
||||
return "close_call"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
func cloneSession(s ConversationSession) ConversationSession {
|
||||
s.TransitionHistory = append([]TransitionRecord(nil), s.TransitionHistory...)
|
||||
s.DeniedActions = append([]DeniedActionRecord(nil), s.DeniedActions...)
|
||||
if s.Metadata != nil {
|
||||
cp := make(map[string]string, len(s.Metadata))
|
||||
for k, v := range s.Metadata {
|
||||
cp[k] = v
|
||||
}
|
||||
s.Metadata = cp
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package state
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidTransitions(t *testing.T) {
|
||||
m := NewMachine(ConversationSession{CallID: "c"})
|
||||
steps := []ConversationEvent{
|
||||
{Type: EventCallStarted},
|
||||
{Type: EventGreetingPlayed},
|
||||
{Type: EventLanguageSelected, Language: LanguageRU},
|
||||
{Type: EventRegionSelected, Region: RegionSelection{Code: "almaty_city", Status: RegionSelected}},
|
||||
{Type: EventQuestionReceived, Question: "q"},
|
||||
{Type: EventAnswerCompleted},
|
||||
{Type: EventHandoffRequested},
|
||||
{Type: EventCallEnded},
|
||||
}
|
||||
want := []ConversationState{StateGreeting, StateReadyToHelp, StateReadyToHelp, StateReadyToHelp, StateQuestionAnswering, StateReadyToHelp, StateHandoff, StateEnded}
|
||||
for i, step := range steps {
|
||||
res, err := m.Apply(step)
|
||||
if err != nil {
|
||||
t.Fatalf("step %d: %v", i, err)
|
||||
}
|
||||
if res.To != want[i] {
|
||||
t.Fatalf("step %d state=%s want=%s", i, res.To, want[i])
|
||||
}
|
||||
}
|
||||
if len(m.Session().TransitionHistory) != len(steps) {
|
||||
t.Fatalf("transition history length mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidTransitionsDoNotMutate(t *testing.T) {
|
||||
m := NewMachine(ConversationSession{CallID: "c"})
|
||||
if _, err := m.Apply(ConversationEvent{Type: EventQuestionReceived}); err == nil {
|
||||
t.Fatal("expected invalid transition")
|
||||
}
|
||||
if m.CurrentState() != StateCallStarted {
|
||||
t.Fatalf("state mutated to %s", m.CurrentState())
|
||||
}
|
||||
_, _ = m.Apply(ConversationEvent{Type: EventCallEnded})
|
||||
if _, err := m.Apply(ConversationEvent{Type: EventLanguageSelected, Language: LanguageRU}); err == nil {
|
||||
t.Fatal("expected ended state error")
|
||||
}
|
||||
if m.CurrentState() != StateEnded {
|
||||
t.Fatalf("ended state mutated to %s", m.CurrentState())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuestionAllowedWithoutExplicitLanguageAndRegion(t *testing.T) {
|
||||
m := NewMachine(ConversationSession{CallID: "c", State: StateRegionSelection, Language: LanguageRU})
|
||||
if _, err := m.Apply(ConversationEvent{Type: EventQuestionReceived}); err == nil {
|
||||
t.Fatal("region selection state should not answer directly")
|
||||
}
|
||||
m = NewMachine(ConversationSession{CallID: "c", State: StateReadyToHelp, Region: RegionSelection{Code: "almaty_city", Status: RegionSelected}})
|
||||
if _, err := m.Apply(ConversationEvent{Type: EventQuestionReceived}); err == nil {
|
||||
return
|
||||
}
|
||||
t.Fatal("question should be allowed without explicit language")
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"ai-operator/internal/call"
|
||||
)
|
||||
|
||||
type ConversationState = call.CallState
|
||||
type Language = call.Language
|
||||
|
||||
const (
|
||||
StateCallStarted ConversationState = call.StateCallStarted
|
||||
StateGreeting ConversationState = call.StateGreeting
|
||||
StateLanguageSelection ConversationState = call.StateLanguageSelection
|
||||
StateRegionSelection ConversationState = call.StateRegionSelection
|
||||
StateReadyToHelp ConversationState = call.StateReadyToHelp
|
||||
StateQuestionAnswering ConversationState = call.StateQuestionAnswering
|
||||
StateHandoff ConversationState = call.StateHandoff
|
||||
StateClosing ConversationState = call.StateClosing
|
||||
StateEnded ConversationState = call.StateEnded
|
||||
|
||||
LanguageUnknown Language = call.LanguageUnknown
|
||||
LanguageRU Language = call.LanguageRU
|
||||
LanguageKK Language = call.LanguageKK
|
||||
)
|
||||
|
||||
type RegionStatus string
|
||||
|
||||
const (
|
||||
RegionUnknown RegionStatus = "unknown"
|
||||
RegionPendingClarification RegionStatus = "pending_clarification"
|
||||
RegionSelected RegionStatus = "selected"
|
||||
)
|
||||
|
||||
type RegionSelection struct {
|
||||
Code string
|
||||
DisplayNameRU string
|
||||
DisplayNameKK string
|
||||
Status RegionStatus
|
||||
Source string
|
||||
}
|
||||
|
||||
type ConversationSession struct {
|
||||
CallID string
|
||||
AsteriskChannelID string
|
||||
CallerNumberMasked string
|
||||
State ConversationState
|
||||
Language Language
|
||||
Region RegionSelection
|
||||
StartedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
EndedAt *time.Time
|
||||
TransitionHistory []TransitionRecord
|
||||
DeniedActions []DeniedActionRecord
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
type TransitionRecord struct {
|
||||
From ConversationState
|
||||
To ConversationState
|
||||
Event ConversationEventType
|
||||
Reason string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
type DeniedActionRecord struct {
|
||||
Action string
|
||||
State ConversationState
|
||||
ReasonCode string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
type ConversationEventType string
|
||||
|
||||
const (
|
||||
EventCallStarted ConversationEventType = "call.started"
|
||||
EventGreetingPlayed ConversationEventType = "greeting.played"
|
||||
EventLanguageRequested ConversationEventType = "language.requested"
|
||||
EventLanguageSelected ConversationEventType = "language.selected"
|
||||
EventRegionRequested ConversationEventType = "region.requested"
|
||||
EventRegionSelected ConversationEventType = "region.selected"
|
||||
EventQuestionReceived ConversationEventType = "question.received"
|
||||
EventAnswerStarted ConversationEventType = "answer.started"
|
||||
EventAnswerCompleted ConversationEventType = "answer.completed"
|
||||
EventHandoffRequested ConversationEventType = "handoff.requested"
|
||||
EventClosingRequested ConversationEventType = "closing.requested"
|
||||
EventCallEnded ConversationEventType = "call.ended"
|
||||
EventError ConversationEventType = "error"
|
||||
)
|
||||
|
||||
type ConversationEvent struct {
|
||||
Type ConversationEventType
|
||||
Language Language
|
||||
Region RegionSelection
|
||||
Question string
|
||||
Reason string
|
||||
Metadata map[string]string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
type TransitionResult struct {
|
||||
From ConversationState
|
||||
To ConversationState
|
||||
Changed bool
|
||||
MessageKey string
|
||||
RequiredNextAction string
|
||||
}
|
||||
Reference in New Issue
Block a user