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
}
+59
View File
@@ -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")
}
+108
View File
@@ -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
}