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
|
||||
}
|
||||
Reference in New Issue
Block a user