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 }