sync: migrate ai-operator to Gitea (2026-08-10)

This commit is contained in:
konturai-ops
2026-08-10 15:26:52 +00:00
commit 53652b95ad
173 changed files with 16676 additions and 0 deletions
+215
View File
@@ -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
}