Files
ai-operator/internal/dialogue/orchestrator.go
T

650 lines
27 KiB
Go

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
}