sync: migrate ai-operator to Gitea (2026-08-10)
This commit is contained in:
@@ -0,0 +1,649 @@
|
||||
package dialogue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ai-operator/internal/agent"
|
||||
"ai-operator/internal/ai"
|
||||
"ai-operator/internal/audit"
|
||||
"ai-operator/internal/call"
|
||||
"ai-operator/internal/config"
|
||||
"ai-operator/internal/dialogue/language"
|
||||
"ai-operator/internal/dialogue/messages"
|
||||
"ai-operator/internal/dialogue/policy"
|
||||
"ai-operator/internal/dialogue/region"
|
||||
"ai-operator/internal/dialogue/state"
|
||||
"ai-operator/internal/handoff"
|
||||
"ai-operator/internal/kb"
|
||||
"ai-operator/internal/tools"
|
||||
)
|
||||
|
||||
type Orchestrator interface {
|
||||
StartCall(ctx context.Context, session call.CallSession) (*state.ConversationSession, error)
|
||||
HandleVoiceEvent(ctx context.Context, callID string, event ai.VoiceEvent) error
|
||||
HandleUserText(ctx context.Context, callID string, text string) (*DialogueActionResult, error)
|
||||
HandleVoiceEventResult(ctx context.Context, callID string, event ai.VoiceEvent) (*ai.ToolResult, error)
|
||||
HandleToolCall(ctx context.Context, callID string, tool ai.ToolCall) ai.ToolResult
|
||||
EndCall(ctx context.Context, callID string, reason string) error
|
||||
GetSession(callID string) (state.ConversationSession, bool)
|
||||
SystemPrompt(callID string) string
|
||||
}
|
||||
|
||||
type DialogueActionResult struct {
|
||||
CallID string
|
||||
State state.ConversationState
|
||||
Language state.Language
|
||||
RegionCode string
|
||||
Applied bool
|
||||
NeedsClarification bool
|
||||
MessageKey string
|
||||
MessageText string
|
||||
ReasonCode string
|
||||
ToolResult *ai.ToolResult
|
||||
}
|
||||
|
||||
type MemoryOrchestrator struct {
|
||||
mu sync.RWMutex
|
||||
machines map[string]*state.Machine
|
||||
detector *language.Detector
|
||||
resolver *region.Resolver
|
||||
kb *kb.Service
|
||||
handoff *handoff.Manager
|
||||
fallback *handoff.FallbackManager
|
||||
audit *audit.Service
|
||||
counters map[string]handoff.FallbackCounters
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewMemoryOrchestrator(logger *slog.Logger) *MemoryOrchestrator {
|
||||
return NewMemoryOrchestratorWithKnowledge(logger, nil)
|
||||
}
|
||||
|
||||
func NewMemoryOrchestratorWithKnowledge(logger *slog.Logger, svc *kb.Service) *MemoryOrchestrator {
|
||||
return NewMemoryOrchestratorWithServices(logger, svc, nil, nil)
|
||||
}
|
||||
|
||||
func NewMemoryOrchestratorWithServices(logger *slog.Logger, svc *kb.Service, hm *handoff.Manager, fm *handoff.FallbackManager) *MemoryOrchestrator {
|
||||
if hm == nil {
|
||||
hm = handoff.NewManager(handoff.DefaultConfig(), nil)
|
||||
}
|
||||
if fm == nil {
|
||||
fm = handoff.NewFallbackManager(handoff.FallbackConfig{})
|
||||
}
|
||||
return &MemoryOrchestrator{machines: map[string]*state.Machine{}, detector: language.NewDetector(), resolver: region.NewDefaultResolver(), kb: svc, handoff: hm, fallback: fm, counters: map[string]handoff.FallbackCounters{}, logger: logger}
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) SetAudit(svc *audit.Service) {
|
||||
o.audit = svc
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) StartCall(ctx context.Context, session call.CallSession) (*state.ConversationSession, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
m := state.NewMachine(state.ConversationSession{
|
||||
CallID: session.CallID,
|
||||
AsteriskChannelID: session.AsteriskChannelID,
|
||||
CallerNumberMasked: config.MaskPhoneNumber(session.CallerNumber),
|
||||
State: state.StateCallStarted,
|
||||
Language: state.LanguageUnknown,
|
||||
Region: state.RegionSelection{Status: state.RegionUnknown},
|
||||
StartedAt: time.Now().UTC(),
|
||||
Metadata: map[string]string{"route": session.Route},
|
||||
})
|
||||
if _, err := m.Apply(state.ConversationEvent{Type: state.EventCallStarted, Reason: "call entered stasis"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := m.Apply(state.ConversationEvent{Type: state.EventGreetingPlayed, Reason: "initial bilingual greeting prepared"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.machines[session.CallID] = m
|
||||
o.counters[session.CallID] = handoff.FallbackCounters{StartedAt: time.Now().UTC()}
|
||||
s := m.Session()
|
||||
o.auditCallStarted(ctx, session, s)
|
||||
o.log(ctx, "dialogue session started", "call_id", session.CallID, "state", s.State)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) HandleVoiceEvent(ctx context.Context, callID string, event ai.VoiceEvent) error {
|
||||
res, err := o.HandleVoiceEventResult(ctx, callID, event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res != nil && res.Error != "" {
|
||||
return fmt.Errorf("%s", res.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) HandleVoiceEventResult(ctx context.Context, callID string, event ai.VoiceEvent) (*ai.ToolResult, error) {
|
||||
if event.Type == ai.VoiceEventAssistantTranscriptDone && event.Text != "" {
|
||||
o.auditTranscript(ctx, callID, "assistant", "transcript.assistant.final", event.Text, "")
|
||||
}
|
||||
if event.Type == ai.VoiceEventError && event.Error != "" {
|
||||
o.auditProvider(ctx, callID, "voice_provider", "provider.error", event.Error)
|
||||
}
|
||||
if event.Type == ai.VoiceEventToolCall && event.ToolCall != nil {
|
||||
res := o.HandleToolCall(ctx, callID, *event.ToolCall)
|
||||
return &res, nil
|
||||
}
|
||||
if event.Type == ai.VoiceEventUserTranscriptDone && event.Text != "" {
|
||||
o.auditTranscript(ctx, callID, "user", "transcript.user.final", event.Text, "")
|
||||
action, err := o.HandleUserText(ctx, callID, event.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if action != nil && action.ToolResult != nil {
|
||||
return action.ToolResult, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) HandleToolCall(ctx context.Context, callID string, tool ai.ToolCall) ai.ToolResult {
|
||||
started := time.Now()
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
m, ok := o.machines[callID]
|
||||
if !ok {
|
||||
res := toolError(callID, tool.ID, "session_not_found")
|
||||
o.auditTool(ctx, state.ConversationSession{CallID: callID}, tool, false, true, "session_not_found", res, time.Since(started))
|
||||
return res
|
||||
}
|
||||
session := m.Session()
|
||||
decision := policy.AuthorizeTool(session, tool.Name)
|
||||
if !decision.Allowed {
|
||||
m.AddDenied(tool.Name, decision.ReasonCode)
|
||||
res := ai.ToolResult{CallID: callID, ToolCallID: tool.ID, Result: map[string]any{"ok": false, "denied": true, "reason_code": decision.ReasonCode, "message_key": decision.UserMessageKey, "required_next_action": decision.RequiredNextAction}, Error: decision.ReasonCode}
|
||||
o.auditDenied(ctx, session, tool.Name, decision.ReasonCode)
|
||||
o.auditTool(ctx, session, tool, false, true, decision.ReasonCode, res, time.Since(started))
|
||||
return res
|
||||
}
|
||||
var res ai.ToolResult
|
||||
switch tool.Name {
|
||||
case tools.SetLanguage:
|
||||
res = o.setLanguage(m, callID, tool)
|
||||
case tools.SetRegion:
|
||||
res = o.setRegion(m, callID, tool)
|
||||
case tools.SearchKnowledgeBase:
|
||||
res = o.searchKnowledgeBase(ctx, m, callID, tool)
|
||||
case tools.RequestHumanHandoff:
|
||||
res = o.requestHandoff(ctx, m, callID, tool, handoff.HandoffReasonUserRequested)
|
||||
case tools.EndCall:
|
||||
_, err := m.Apply(state.ConversationEvent{Type: state.EventClosingRequested, Reason: "tool end_call"})
|
||||
res = resultFromErr(callID, tool.ID, "Call ending requested.", err)
|
||||
default:
|
||||
res = toolError(callID, tool.ID, "unknown_tool")
|
||||
}
|
||||
reason := "ok"
|
||||
if res.Error != "" {
|
||||
reason = res.Error
|
||||
}
|
||||
o.auditTool(ctx, session, tool, res.Error == "", false, reason, res, time.Since(started))
|
||||
return res
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) searchKnowledgeBase(ctx context.Context, m *state.Machine, callID string, tool ai.ToolCall) ai.ToolResult {
|
||||
if o.kb == nil {
|
||||
o.incrementFallback(callID, func(c *handoff.FallbackCounters) { c.KBUnavailable++ })
|
||||
return ai.ToolResult{CallID: callID, ToolCallID: tool.ID, Result: map[string]any{"ok": false, "reason_code": "knowledge_base_unavailable", "message_key": "knowledge.unavailable"}, Error: "knowledge_base_unavailable"}
|
||||
}
|
||||
s := m.Session()
|
||||
query, _ := tool.Arguments["query"].(string)
|
||||
if query == "" {
|
||||
query, _ = tool.Arguments["question"].(string)
|
||||
}
|
||||
lang := s.Language
|
||||
if lang != state.LanguageRU && lang != state.LanguageKK {
|
||||
lang = inferLanguageFromText(query)
|
||||
if lang == state.LanguageRU || lang == state.LanguageKK {
|
||||
_, _ = m.Apply(state.ConversationEvent{Type: state.EventLanguageSelected, Language: lang, Reason: "auto language detection from KB query"})
|
||||
s = m.Session()
|
||||
}
|
||||
}
|
||||
if lang != state.LanguageRU && lang != state.LanguageKK {
|
||||
lang = state.LanguageRU
|
||||
}
|
||||
regionCode := s.Region.Code
|
||||
if s.Region.Status != state.RegionSelected || strings.TrimSpace(regionCode) == "" {
|
||||
if isRegionRequiredQuery(query) {
|
||||
msg := messages.Get("region.ask", lang)
|
||||
return ai.ToolResult{CallID: callID, ToolCallID: tool.ID, Result: map[string]any{"ok": false, "reason_code": "region_required_for_question", "message_key": "region.ask", "message": msg}, Error: "region_required_for_question"}
|
||||
}
|
||||
regionCode = "global"
|
||||
}
|
||||
limit := 5
|
||||
if v, ok := tool.Arguments["limit"].(float64); ok && v > 0 {
|
||||
limit = int(v)
|
||||
}
|
||||
if v, ok := tool.Arguments["limit"].(int); ok && v > 0 {
|
||||
limit = v
|
||||
}
|
||||
minScore := 0.0
|
||||
if v, ok := tool.Arguments["min_score"].(float64); ok && v > 0 {
|
||||
minScore = v
|
||||
}
|
||||
searchStarted := time.Now()
|
||||
resp, err := o.kb.Search(ctx, kb.SearchRequest{Query: query, Language: string(lang), RegionCode: regionCode, Limit: limit, MinScore: minScore, CallID: callID, IncludeGlobal: true, CrossLanguageFallback: true})
|
||||
if err != nil {
|
||||
o.incrementFallback(callID, func(c *handoff.FallbackCounters) { c.KBUnavailable++ })
|
||||
o.auditKB(ctx, callID, query, string(lang), regionCode, 0, 0, false, true, time.Since(searchStarted))
|
||||
return ai.ToolResult{CallID: callID, ToolCallID: tool.ID, Result: map[string]any{"ok": false, "reason_code": "knowledge_base_unavailable", "message_key": "knowledge.unavailable"}, Error: "knowledge_base_unavailable"}
|
||||
}
|
||||
if !resp.OK {
|
||||
if resp.ReasonCode == "no_relevant_knowledge" {
|
||||
o.incrementFallback(callID, func(c *handoff.FallbackCounters) { c.NoAnswerCount++ })
|
||||
}
|
||||
o.auditKB(ctx, callID, query, string(lang), regionCode, 0, 0, resp.CrossLanguageFallbackUsed, true, time.Since(searchStarted))
|
||||
return ai.ToolResult{CallID: callID, ToolCallID: tool.ID, Result: agent.ToolResultPayload(resp, lang), Error: resp.ReasonCode}
|
||||
}
|
||||
topScore := 0.0
|
||||
if len(resp.Results) > 0 {
|
||||
topScore = resp.Results[0].Score
|
||||
}
|
||||
o.auditKB(ctx, callID, query, string(lang), regionCode, len(resp.Results), topScore, resp.CrossLanguageFallbackUsed, false, time.Since(searchStarted))
|
||||
return ai.ToolResult{CallID: callID, ToolCallID: tool.ID, Result: agent.ToolResultPayload(resp, lang)}
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) requestHandoff(ctx context.Context, m *state.Machine, callID string, tool ai.ToolCall, reason handoff.HandoffReasonCode) ai.ToolResult {
|
||||
s := m.Session()
|
||||
reasonText, _ := tool.Arguments["reason"].(string)
|
||||
summary, _ := tool.Arguments["summary"].(string)
|
||||
if reasonText == "" {
|
||||
reasonText = string(reason)
|
||||
}
|
||||
if summary == "" {
|
||||
summary = reasonText
|
||||
}
|
||||
req, result, err := o.handoff.Request(ctx, handoff.RequestInput{CallID: callID, AsteriskChannelID: s.AsteriskChannelID, State: string(s.State), Language: string(s.Language), RegionCode: s.Region.Code, Route: s.Metadata["route"], ReasonCode: reason, ReasonText: reasonText, Summary: summary})
|
||||
o.auditHandoff(ctx, req, result, summary)
|
||||
if s.State != state.StateHandoff && s.State != state.StateEnded {
|
||||
_, _ = m.Apply(state.ConversationEvent{Type: state.EventHandoffRequested, Reason: "request_human_handoff"})
|
||||
}
|
||||
payload := map[string]any{"ok": err == nil || result.Status == handoff.HandoffStatusStubbed, "tool": tools.RequestHumanHandoff, "reason_code": string(reason), "message_key": result.MessageKey, "message": result.Message, "data": map[string]any{"handoff_id": req.ID, "status": string(result.Status), "mode": string(result.Mode), "transfer_attempted": result.TransferAttempted, "transfer_succeeded": result.TransferSucceeded}}
|
||||
if err != nil && result.Status != handoff.HandoffStatusStubbed {
|
||||
return ai.ToolResult{CallID: callID, ToolCallID: tool.ID, Result: payload, Error: result.Error}
|
||||
}
|
||||
return ai.ToolResult{CallID: callID, ToolCallID: tool.ID, Result: payload}
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) setLanguage(m *state.Machine, callID string, tool ai.ToolCall) ai.ToolResult {
|
||||
value, _ := tool.Arguments["language"].(string)
|
||||
detected := o.detector.DetectToolLanguage(value)
|
||||
if detected.Language != state.LanguageRU && detected.Language != state.LanguageKK || detected.NeedsClarification {
|
||||
return toolError(callID, tool.ID, "invalid_language")
|
||||
}
|
||||
_, err := m.Apply(state.ConversationEvent{Type: state.EventLanguageSelected, Language: detected.Language, Reason: "tool set_language"})
|
||||
if err != nil {
|
||||
return toolError(callID, tool.ID, err.Error())
|
||||
}
|
||||
return ai.ToolResult{CallID: callID, ToolCallID: tool.ID, Result: map[string]any{"ok": true, "language": string(detected.Language), "message_key": "language.selected." + string(detected.Language)}}
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) setRegion(m *state.Machine, callID string, tool ai.ToolCall) ai.ToolResult {
|
||||
resolved := o.resolver.ResolveToolRegion(tool.Arguments)
|
||||
if resolved.NeedsClarification || resolved.Intent == region.IntentAmbiguous {
|
||||
o.setPendingRegion(m, resolved.Candidates)
|
||||
return ai.ToolResult{CallID: callID, ToolCallID: tool.ID, Result: map[string]any{"ok": false, "needs_clarification": true, "reason_code": resolved.ReasonCode, "message_key": nonEmptyString(resolved.ClarificationMessageKey, "region.ask_clarify")}, Error: resolved.ReasonCode}
|
||||
}
|
||||
if resolved.Intent == region.IntentUnsupported || resolved.ReasonCode == "disabled_region" {
|
||||
return toolError(callID, tool.ID, "disabled_region")
|
||||
}
|
||||
if resolved.Region == nil || resolved.RegionCode == "" {
|
||||
return toolError(callID, tool.ID, "invalid_region")
|
||||
}
|
||||
return o.applyResolvedRegion(m, callID, tool.ID, resolved, "tool set_region", "region.selected")
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) HandleUserText(ctx context.Context, callID string, text string) (*DialogueActionResult, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
m, ok := o.machines[callID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("session_not_found")
|
||||
}
|
||||
s := m.Session()
|
||||
|
||||
if detectedHandoff := handoff.DetectHandoffRequest(text, string(s.Language)); detectedHandoff.Requested {
|
||||
toolRes := o.requestHandoff(ctx, m, callID, ai.ToolCall{ID: "user_text_handoff", Name: tools.RequestHumanHandoff, Arguments: map[string]any{"reason": detectedHandoff.MatchedPhrase, "summary": text}}, handoff.HandoffReasonUserRequested)
|
||||
res := newActionResult(callID, m.Session())
|
||||
res.Applied = true
|
||||
res.MessageKey = "handoff.stub"
|
||||
if payload, ok := toolRes.Result.(map[string]any); ok {
|
||||
if key, _ := payload["message_key"].(string); key != "" {
|
||||
res.MessageKey = key
|
||||
}
|
||||
res.MessageText, _ = payload["message"].(string)
|
||||
}
|
||||
res.ToolResult = &toolRes
|
||||
return res, nil
|
||||
}
|
||||
|
||||
detected := o.detector.Detect(text, language.SourceUserText)
|
||||
if s.Language != state.LanguageRU && s.Language != state.LanguageKK && detected.Language != state.LanguageUnknown && !detected.NeedsClarification && detected.Confidence >= language.MediumConfidence {
|
||||
return o.applyLanguageUserDecision(m, callID, language.LanguageDecision{Apply: true, Language: detected.Language, ReasonCode: detected.ReasonCode, MessageKey: "language.selected." + string(detected.Language)}), nil
|
||||
}
|
||||
o.ensureLanguageFromText(m, text)
|
||||
s = m.Session()
|
||||
languageDecision := language.ShouldApplyLanguageDetection(language.SelectionContext{CurrentState: s.State, CurrentLanguage: s.Language, AllowChange: true}, detected)
|
||||
if languageDecision.Apply || (s.State == state.StateLanguageSelection && languageDecision.NeedsClarification) {
|
||||
return o.applyLanguageUserDecision(m, callID, languageDecision), nil
|
||||
}
|
||||
|
||||
if s.State == state.StateRegionSelection || s.State == state.StateReadyToHelp || s.State == state.StateQuestionAnswering {
|
||||
res := o.handleRegionText(m, callID, text)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
|
||||
res := newActionResult(callID, m.Session())
|
||||
if res.MessageKey == "" {
|
||||
if s.State == state.StateRegionSelection {
|
||||
res.MessageKey = "region.ask"
|
||||
} else {
|
||||
res.MessageKey = "ready.to_help"
|
||||
}
|
||||
}
|
||||
res.MessageText = messages.Get(res.MessageKey, res.Language)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) applyLanguageUserDecision(m *state.Machine, callID string, decision language.LanguageDecision) *DialogueActionResult {
|
||||
res := newActionResult(callID, m.Session())
|
||||
res.Applied = decision.Apply
|
||||
res.NeedsClarification = decision.NeedsClarification
|
||||
res.MessageKey = decision.MessageKey
|
||||
res.ReasonCode = decision.ReasonCode
|
||||
if decision.Apply {
|
||||
toolRes := o.setLanguage(m, callID, ai.ToolCall{ID: "user_text_language", Name: tools.SetLanguage, Arguments: map[string]any{"language": string(decision.Language)}})
|
||||
res.ToolResult = &toolRes
|
||||
s := m.Session()
|
||||
res.State = s.State
|
||||
res.Language = s.Language
|
||||
res.RegionCode = s.Region.Code
|
||||
if s.State == state.StateRegionSelection {
|
||||
res.MessageKey = "region.ask"
|
||||
} else {
|
||||
res.MessageKey = "language.changed." + string(s.Language)
|
||||
}
|
||||
}
|
||||
if res.MessageKey == "" {
|
||||
res.MessageKey = "language.ask"
|
||||
}
|
||||
res.MessageText = messages.Get(res.MessageKey, res.Language)
|
||||
return res
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) handleRegionText(m *state.Machine, callID, text string) *DialogueActionResult {
|
||||
s := m.Session()
|
||||
pending := m.PendingRegionCandidates()
|
||||
explicit := region.IsExplicitChangeRequest(text)
|
||||
if (s.State == state.StateReadyToHelp || s.State == state.StateQuestionAnswering) && s.Region.Status == state.RegionSelected && !explicit {
|
||||
return nil
|
||||
}
|
||||
var resolved region.ResolutionResult
|
||||
if len(pending) > 0 {
|
||||
resolved = o.resolver.ResolvePending(pending, text, region.SourceUserText)
|
||||
} else {
|
||||
resolved = o.resolver.Resolve(text, region.SourceUserText)
|
||||
}
|
||||
if (s.State == state.StateReadyToHelp || s.State == state.StateQuestionAnswering) && s.Region.Status != state.RegionSelected && len(pending) == 0 && !explicit && resolved.RegionCode == "" && !resolved.NeedsClarification {
|
||||
return nil
|
||||
}
|
||||
if region.IsExplicitChangeRequest(text) && resolved.RegionCode != "" && resolved.Region != nil {
|
||||
resolved.Intent = region.IntentRegionChange
|
||||
}
|
||||
decision := region.ShouldApplyRegionResolution(region.SelectionContext{CurrentState: s.State, CurrentLanguage: s.Language, CurrentRegionCode: s.Region.Code, AllowChange: true}, resolved)
|
||||
res := newActionResult(callID, s)
|
||||
res.Applied = decision.Apply
|
||||
res.NeedsClarification = decision.NeedsClarification
|
||||
res.MessageKey = decision.MessageKey
|
||||
res.ReasonCode = decision.ReasonCode
|
||||
if decision.NeedsClarification {
|
||||
o.setPendingRegion(m, resolved.Candidates)
|
||||
s = m.Session()
|
||||
res.State = s.State
|
||||
res.Language = s.Language
|
||||
res.RegionCode = s.Region.Code
|
||||
res.MessageText = messages.Get(res.MessageKey, res.Language)
|
||||
return res
|
||||
}
|
||||
if decision.Apply {
|
||||
toolID := "user_text_region"
|
||||
toolRes := o.applyResolvedRegion(m, callID, toolID, resolved, "user text set_region", decision.MessageKey)
|
||||
res.ToolResult = &toolRes
|
||||
s = m.Session()
|
||||
res.State = s.State
|
||||
res.Language = s.Language
|
||||
res.RegionCode = s.Region.Code
|
||||
res.MessageKey = decision.MessageKey
|
||||
res.MessageText = messages.Get(res.MessageKey, res.Language)
|
||||
return res
|
||||
}
|
||||
if res.MessageKey == "" {
|
||||
res.MessageKey = "region.ask"
|
||||
}
|
||||
res.MessageText = messages.Get(res.MessageKey, res.Language)
|
||||
return res
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) ensureLanguageFromText(m *state.Machine, text string) {
|
||||
s := m.Session()
|
||||
if s.Language == state.LanguageRU || s.Language == state.LanguageKK || s.State == state.StateEnded || s.State == state.StateClosing || s.State == state.StateHandoff {
|
||||
return
|
||||
}
|
||||
lang := inferLanguageFromText(text)
|
||||
if lang != state.LanguageRU && lang != state.LanguageKK {
|
||||
return
|
||||
}
|
||||
_, _ = m.Apply(state.ConversationEvent{Type: state.EventLanguageSelected, Language: lang, Reason: "auto language detection from user text"})
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) applyResolvedRegion(m *state.Machine, callID, toolID string, resolved region.ResolutionResult, reason string, messageKey string) ai.ToolResult {
|
||||
reg := resolved.Region
|
||||
selection := state.RegionSelection{Code: reg.Code, DisplayNameRU: reg.DisplayNameRU, DisplayNameKK: reg.DisplayNameKK, Status: state.RegionSelected, Source: string(resolved.Source)}
|
||||
_, err := m.Apply(state.ConversationEvent{Type: state.EventRegionSelected, Region: selection, Reason: reason})
|
||||
if err != nil {
|
||||
return toolError(callID, toolID, err.Error())
|
||||
}
|
||||
if messageKey == "" {
|
||||
messageKey = "region.selected"
|
||||
}
|
||||
return ai.ToolResult{CallID: callID, ToolCallID: toolID, Result: map[string]any{"ok": true, "region_code": reg.Code, "display_name_ru": reg.DisplayNameRU, "display_name_kk": reg.DisplayNameKK, "message_key": messageKey}}
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) setPendingRegion(m *state.Machine, candidates []region.Candidate) {
|
||||
codes := make([]string, 0, len(candidates))
|
||||
seen := map[string]bool{}
|
||||
for _, c := range candidates {
|
||||
if c.Region.Code != "" && !seen[c.Region.Code] {
|
||||
seen[c.Region.Code] = true
|
||||
codes = append(codes, c.Region.Code)
|
||||
}
|
||||
}
|
||||
m.SetRegionPending(codes)
|
||||
}
|
||||
|
||||
func newActionResult(callID string, s state.ConversationSession) *DialogueActionResult {
|
||||
return &DialogueActionResult{CallID: callID, State: s.State, Language: s.Language, RegionCode: s.Region.Code}
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) EndCall(ctx context.Context, callID string, reason string) error {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
m, ok := o.machines[callID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
s := m.Session()
|
||||
_, _ = m.Apply(state.ConversationEvent{Type: state.EventCallEnded, Reason: reason})
|
||||
o.auditEvent(ctx, callID, "call.ended", "dialogue", string(s.State), string(state.StateEnded), "info", reason, "call_ended", nil)
|
||||
if o.audit != nil {
|
||||
_ = o.audit.EndCall(ctx, callID, reason)
|
||||
}
|
||||
delete(o.machines, callID)
|
||||
delete(o.counters, callID)
|
||||
if o.handoff != nil && o.handoff.Store != nil {
|
||||
o.handoff.Store.DeleteByCall(callID)
|
||||
}
|
||||
o.log(ctx, "dialogue session ended", "call_id", callID, "reason", reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) GetSession(callID string) (state.ConversationSession, bool) {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
m, ok := o.machines[callID]
|
||||
if !ok {
|
||||
return state.ConversationSession{}, false
|
||||
}
|
||||
return m.Session(), true
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) SystemPrompt(callID string) string {
|
||||
s, ok := o.GetSession(callID)
|
||||
if !ok {
|
||||
return agent.BuildSystemPrompt(agent.PromptContext{State: state.StateLanguageSelection})
|
||||
}
|
||||
return agent.BuildSystemPrompt(agent.PromptContext{State: s.State, Language: s.Language, RegionCode: s.Region.Code, RegionDisplayName: displayNameForLanguage(s)})
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) Count() int {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
return len(o.machines)
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) incrementFallback(callID string, fn func(*handoff.FallbackCounters)) handoff.FallbackDecision {
|
||||
if o.counters == nil {
|
||||
o.counters = map[string]handoff.FallbackCounters{}
|
||||
}
|
||||
c := o.counters[callID]
|
||||
if c.StartedAt.IsZero() {
|
||||
c.StartedAt = time.Now().UTC()
|
||||
}
|
||||
fn(&c)
|
||||
o.counters[callID] = c
|
||||
return o.fallback.Evaluate(c, "", time.Now().UTC())
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) log(ctx context.Context, msg string, args ...any) {
|
||||
if o.logger != nil {
|
||||
o.logger.InfoContext(ctx, msg, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) auditCallStarted(ctx context.Context, session call.CallSession, s state.ConversationSession) {
|
||||
if o.audit == nil {
|
||||
return
|
||||
}
|
||||
_ = o.audit.UpsertCall(ctx, audit.CallRecord{CallID: session.CallID, AsteriskChannelID: session.AsteriskChannelID, Route: session.Route, CallerNumberMasked: config.MaskPhoneNumber(session.CallerNumber), Language: string(s.Language), RegionCode: s.Region.Code, State: string(s.State), StartedAt: s.StartedAt, Metadata: map[string]any{"route": session.Route}})
|
||||
o.auditEvent(ctx, session.CallID, "call.started", "dialogue", "", string(s.State), "info", "call entered stasis", "call_started", nil)
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) auditEvent(ctx context.Context, callID, eventType, source, before, after, severity, msg, reason string, meta map[string]any) {
|
||||
if o.audit == nil {
|
||||
return
|
||||
}
|
||||
_ = o.audit.AddEvent(ctx, audit.EventRecord{CallID: callID, EventType: eventType, EventSource: source, StateBefore: before, StateAfter: after, Severity: severity, Message: msg, ReasonCode: reason, Metadata: meta})
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) auditDenied(ctx context.Context, s state.ConversationSession, toolName, reason string) {
|
||||
o.auditEvent(ctx, s.CallID, "conversation.denied_action", "dialogue", string(s.State), string(s.State), "warn", toolName, reason, map[string]any{"tool": toolName})
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) auditTranscript(ctx context.Context, callID, speaker, eventType, text, lang string) {
|
||||
if o.audit == nil {
|
||||
return
|
||||
}
|
||||
if lang == "" {
|
||||
if s, ok := o.GetSession(callID); ok {
|
||||
lang = string(s.Language)
|
||||
}
|
||||
}
|
||||
_ = o.audit.AddTranscript(ctx, audit.TranscriptRecord{CallID: callID, Speaker: speaker, EventType: eventType, Language: lang, Text: text})
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) auditTool(ctx context.Context, s state.ConversationSession, tool ai.ToolCall, allowed, denied bool, reason string, result ai.ToolResult, d time.Duration) {
|
||||
if o.audit == nil {
|
||||
return
|
||||
}
|
||||
args := map[string]any{}
|
||||
for k, v := range tool.Arguments {
|
||||
args[k] = v
|
||||
}
|
||||
res := map[string]any{"error": result.Error}
|
||||
if m, ok := result.Result.(map[string]any); ok {
|
||||
res = m
|
||||
}
|
||||
_ = o.audit.AddToolAudit(ctx, audit.ToolAuditRecord{CallID: toolCallID(s.CallID, result.CallID), ToolCallID: tool.ID, ToolName: tool.Name, State: string(s.State), Language: string(s.Language), RegionCode: s.Region.Code, Allowed: allowed, Denied: denied, ReasonCode: reason, Args: args, Result: res, DurationMS: d.Milliseconds()})
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) auditKB(ctx context.Context, callID, query, lang, regionCode string, count int, topScore float64, fallback, noAnswer bool, d time.Duration) {
|
||||
if o.audit == nil {
|
||||
return
|
||||
}
|
||||
_ = o.audit.AddKBAudit(ctx, audit.KBAuditRecord{CallID: callID, Query: query, Language: lang, RegionCode: regionCode, ResultCount: count, TopScore: topScore, CrossLanguageFallbackUsed: fallback, CitationsCount: count, NoAnswer: noAnswer, DurationMS: d.Milliseconds()})
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) auditHandoff(ctx context.Context, req handoff.HandoffRequest, result handoff.HandoffResult, summary string) {
|
||||
if o.audit == nil {
|
||||
return
|
||||
}
|
||||
_ = o.audit.AddHandoffAudit(ctx, audit.HandoffAuditRecord{CallID: req.CallID, HandoffID: req.ID, Mode: string(result.Mode), Status: string(result.Status), ReasonCode: string(req.ReasonCode), TransferAttempted: result.TransferAttempted, TransferSucceeded: result.TransferSucceeded, Target: req.TargetEndpoint, Summary: summary})
|
||||
}
|
||||
|
||||
func (o *MemoryOrchestrator) auditProvider(ctx context.Context, callID, provider, eventType, err string) {
|
||||
if o.audit == nil {
|
||||
return
|
||||
}
|
||||
_ = o.audit.AddProviderAudit(ctx, audit.ProviderAuditRecord{CallID: callID, Provider: provider, EventType: eventType, Severity: "error", Error: err})
|
||||
}
|
||||
|
||||
func toolCallID(primary, fallback string) string {
|
||||
if primary != "" {
|
||||
return primary
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func toolError(callID, toolID, code string) ai.ToolResult {
|
||||
return ai.ToolResult{CallID: callID, ToolCallID: toolID, Result: map[string]any{"ok": false, "error": code}, Error: code}
|
||||
}
|
||||
|
||||
func resultFromErr(callID, toolID, message string, err error) ai.ToolResult {
|
||||
if err != nil {
|
||||
return toolError(callID, toolID, err.Error())
|
||||
}
|
||||
return ai.ToolResult{CallID: callID, ToolCallID: toolID, Result: map[string]any{"ok": true, "message": message}}
|
||||
}
|
||||
|
||||
func ToolCallFromJSON(id, name, raw string) ai.ToolCall {
|
||||
args := map[string]any{}
|
||||
_ = json.Unmarshal([]byte(raw), &args)
|
||||
return ai.ToolCall{ID: id, Name: name, Arguments: args, RawArguments: raw}
|
||||
}
|
||||
|
||||
func MessageForSession(key string, s state.ConversationSession) string {
|
||||
return messages.Get(key, s.Language)
|
||||
}
|
||||
|
||||
func displayNameForLanguage(s state.ConversationSession) string {
|
||||
if s.Language == state.LanguageKK && s.Region.DisplayNameKK != "" {
|
||||
return s.Region.DisplayNameKK
|
||||
}
|
||||
return s.Region.DisplayNameRU
|
||||
}
|
||||
|
||||
func nonEmptyString(v, fallback string) string {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
Reference in New Issue
Block a user