sync: migrate ai-operator to Gitea (2026-08-10)
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package handoff
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type DetectionResult struct {
|
||||
Requested bool
|
||||
Confidence float64
|
||||
MatchedPhrase string
|
||||
Language string
|
||||
ReasonCode string
|
||||
NeedsClarification bool
|
||||
}
|
||||
|
||||
var handoffPhrases = []string{
|
||||
"соедините с оператором", "переведите на оператора", "хочу оператора", "нужен оператор",
|
||||
"позовите оператора", "можно с человеком", "хочу поговорить с человеком", "соедините с человеком",
|
||||
"переключите на оператора", "не хочу робота", "не хочу ии", "позовите живого человека",
|
||||
"мне нужен специалист", "соедините с консультантом", "живой оператор", "консультант",
|
||||
"операторға қосыңыз", "операторға қос", "адаммен сөйлескім келеді", "тірі оператор",
|
||||
"маман керек", "кеңесші керек", "кеңесшімен сөйлесу", "адамға қосыңыз",
|
||||
"жасанды интеллект керек емес", "роботпен сөйлеспеймін", "оператор керек",
|
||||
"human operator", "connect me to operator", "transfer to operator", "speak to human", "live agent",
|
||||
}
|
||||
|
||||
var ambiguousPhrases = []string{"оператор связи", "operator schedule", "call center operator schedule"}
|
||||
|
||||
func DetectHandoffRequest(text string, language string) DetectionResult {
|
||||
n := normalize(text)
|
||||
if n == "" {
|
||||
return DetectionResult{Language: language, ReasonCode: "empty_input"}
|
||||
}
|
||||
for _, p := range ambiguousPhrases {
|
||||
if strings.Contains(n, p) {
|
||||
return DetectionResult{Language: language, MatchedPhrase: p, Confidence: 0.45, ReasonCode: "ambiguous", NeedsClarification: true}
|
||||
}
|
||||
}
|
||||
for _, p := range handoffPhrases {
|
||||
if strings.Contains(n, p) {
|
||||
return DetectionResult{Requested: true, Confidence: 0.95, MatchedPhrase: p, Language: language, ReasonCode: "exact_phrase"}
|
||||
}
|
||||
}
|
||||
if hasWord(n, "оператор") || hasWord(n, "operator") {
|
||||
return DetectionResult{Requested: true, Confidence: 0.80, MatchedPhrase: "оператор", Language: language, ReasonCode: "keyword"}
|
||||
}
|
||||
return DetectionResult{Language: language, ReasonCode: "no_match"}
|
||||
}
|
||||
|
||||
func normalize(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = strings.ReplaceAll(s, "ё", "е")
|
||||
var b strings.Builder
|
||||
lastSpace := false
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
lastSpace = false
|
||||
continue
|
||||
}
|
||||
if !lastSpace {
|
||||
b.WriteRune(' ')
|
||||
lastSpace = true
|
||||
}
|
||||
}
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
func hasWord(text, word string) bool {
|
||||
for _, f := range strings.Fields(text) {
|
||||
if f == word {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package handoff
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDetectHandoffRequest(t *testing.T) {
|
||||
cases := []string{"оператор", "соедините с оператором", "хочу поговорить с человеком", "операторға қосыңыз", "маман керек", "live agent"}
|
||||
for _, tc := range cases {
|
||||
res := DetectHandoffRequest(tc, "ru")
|
||||
if !res.Requested || res.Confidence < 0.7 {
|
||||
t.Fatalf("%q not detected: %+v", tc, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectHandoffFalsePositive(t *testing.T) {
|
||||
res := DetectHandoffRequest("оператор связи тариф", "ru")
|
||||
if res.Requested || !res.NeedsClarification {
|
||||
t.Fatalf("expected ambiguous false positive protection: %+v", res)
|
||||
}
|
||||
res = DetectHandoffRequest("какой у меня тариф", "ru")
|
||||
if res.Requested {
|
||||
t.Fatalf("unexpected handoff: %+v", res)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package handoff
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ARIClient interface {
|
||||
RedirectChannel(ctx context.Context, channelID string, endpoint string) error
|
||||
ContinueInDialplan(ctx context.Context, channelID, dialplanContext, extension string, priority int) error
|
||||
}
|
||||
|
||||
type Executor interface {
|
||||
Execute(ctx context.Context, req HandoffRequest) (HandoffResult, error)
|
||||
}
|
||||
|
||||
type SafeExecutor struct {
|
||||
ARI ARIClient
|
||||
}
|
||||
|
||||
func (e SafeExecutor) Execute(ctx context.Context, req HandoffRequest) (HandoffResult, error) {
|
||||
result := HandoffResult{RequestID: req.ID, Mode: req.Mode}
|
||||
switch req.Mode {
|
||||
case HandoffModeDisabledStub:
|
||||
result.Status = HandoffStatusStubbed
|
||||
result.MessageKey = "handoff.stub"
|
||||
result.Message = Message(result.MessageKey, req.Language)
|
||||
return result, nil
|
||||
case HandoffModeHangupAfterMessage:
|
||||
result.Status = HandoffStatusCompleted
|
||||
result.MessageKey = "handoff.stub"
|
||||
result.Message = Message(result.MessageKey, req.Language)
|
||||
return result, nil
|
||||
case HandoffModeARIRedirect:
|
||||
result.TransferAttempted = true
|
||||
if e.ARI == nil {
|
||||
return transferFailed(req, "ari client not configured")
|
||||
}
|
||||
if err := e.ARI.RedirectChannel(ctx, req.AsteriskChannelID, req.TargetEndpoint); err != nil {
|
||||
return transferFailed(req, err.Error())
|
||||
}
|
||||
result.Status = HandoffStatusCompleted
|
||||
result.TransferSucceeded = true
|
||||
result.MessageKey = "handoff.transfer_started"
|
||||
result.Message = Message(result.MessageKey, req.Language)
|
||||
return result, nil
|
||||
case HandoffModeDialplanContinue:
|
||||
result.TransferAttempted = true
|
||||
if e.ARI == nil {
|
||||
return transferFailed(req, "ari client not configured")
|
||||
}
|
||||
if err := e.ARI.ContinueInDialplan(ctx, req.AsteriskChannelID, req.DialplanContext, req.DialplanExtension, req.DialplanPriority); err != nil {
|
||||
return transferFailed(req, err.Error())
|
||||
}
|
||||
result.Status = HandoffStatusCompleted
|
||||
result.TransferSucceeded = true
|
||||
result.MessageKey = "handoff.transfer_started"
|
||||
result.Message = Message(result.MessageKey, req.Language)
|
||||
return result, nil
|
||||
default:
|
||||
return HandoffResult{RequestID: req.ID, Mode: req.Mode, Status: HandoffStatusFailed, MessageKey: "handoff.denied", Message: Message("handoff.denied", req.Language), Error: "unknown handoff mode"}, fmt.Errorf("unknown handoff mode")
|
||||
}
|
||||
}
|
||||
|
||||
func transferFailed(req HandoffRequest, msg string) (HandoffResult, error) {
|
||||
result := HandoffResult{RequestID: req.ID, Mode: req.Mode, Status: HandoffStatusFailed, TransferAttempted: true, MessageKey: "handoff.transfer_failed", Message: Message("handoff.transfer_failed", req.Language), Error: SanitizeText(msg, 200)}
|
||||
return result, fmt.Errorf("handoff transfer failed")
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package handoff
|
||||
|
||||
import "time"
|
||||
|
||||
type FallbackCounters struct {
|
||||
LanguageFailures int
|
||||
RegionFailures int
|
||||
NoAnswerCount int
|
||||
KBUnavailable int
|
||||
AIProviderErrors int
|
||||
MediaErrors int
|
||||
ToolErrors int
|
||||
StartedAt time.Time
|
||||
}
|
||||
|
||||
type FallbackConfig struct {
|
||||
MaxLanguageFailures int
|
||||
MaxRegionFailures int
|
||||
MaxNoAnswer int
|
||||
MaxKBUnavailable int
|
||||
MaxAIErrors int
|
||||
MaxMediaErrors int
|
||||
MaxToolErrors int
|
||||
CallTimeout time.Duration
|
||||
}
|
||||
|
||||
type FallbackDecision struct {
|
||||
ShouldOfferHandoff bool
|
||||
ShouldClose bool
|
||||
ReasonCode HandoffReasonCode
|
||||
MessageKey string
|
||||
Message string
|
||||
}
|
||||
|
||||
type FallbackManager struct {
|
||||
Config FallbackConfig
|
||||
}
|
||||
|
||||
func NewFallbackManager(cfg FallbackConfig) *FallbackManager {
|
||||
if cfg.MaxLanguageFailures == 0 {
|
||||
cfg.MaxLanguageFailures = 3
|
||||
}
|
||||
if cfg.MaxRegionFailures == 0 {
|
||||
cfg.MaxRegionFailures = 3
|
||||
}
|
||||
if cfg.MaxNoAnswer == 0 {
|
||||
cfg.MaxNoAnswer = 2
|
||||
}
|
||||
if cfg.MaxKBUnavailable == 0 {
|
||||
cfg.MaxKBUnavailable = 1
|
||||
}
|
||||
if cfg.MaxAIErrors == 0 {
|
||||
cfg.MaxAIErrors = 1
|
||||
}
|
||||
if cfg.MaxMediaErrors == 0 {
|
||||
cfg.MaxMediaErrors = 1
|
||||
}
|
||||
if cfg.MaxToolErrors == 0 {
|
||||
cfg.MaxToolErrors = 2
|
||||
}
|
||||
if cfg.CallTimeout == 0 {
|
||||
cfg.CallTimeout = 5 * time.Minute
|
||||
}
|
||||
return &FallbackManager{Config: cfg}
|
||||
}
|
||||
|
||||
func (m *FallbackManager) Evaluate(c FallbackCounters, language string, now time.Time) FallbackDecision {
|
||||
if !c.StartedAt.IsZero() && now.Sub(c.StartedAt) >= m.Config.CallTimeout {
|
||||
return m.decision(true, HandoffReasonTimeout, "fallback.timeout", language)
|
||||
}
|
||||
if c.KBUnavailable >= m.Config.MaxKBUnavailable {
|
||||
return m.decision(false, HandoffReasonKBUnavailable, "fallback.kb_unavailable", language)
|
||||
}
|
||||
if c.NoAnswerCount >= m.Config.MaxNoAnswer {
|
||||
return m.decision(false, HandoffReasonRepeatedNoAnswer, "fallback.no_answer", language)
|
||||
}
|
||||
if c.LanguageFailures >= m.Config.MaxLanguageFailures {
|
||||
return m.decision(false, HandoffReasonLanguageFailures, "fallback.language_failures", language)
|
||||
}
|
||||
if c.RegionFailures >= m.Config.MaxRegionFailures {
|
||||
return m.decision(false, HandoffReasonRegionFailures, "fallback.region_failures", language)
|
||||
}
|
||||
if c.AIProviderErrors >= m.Config.MaxAIErrors {
|
||||
return m.decision(false, HandoffReasonAIProviderError, "fallback.ai_error", language)
|
||||
}
|
||||
if c.MediaErrors >= m.Config.MaxMediaErrors {
|
||||
return m.decision(false, HandoffReasonMediaError, "fallback.media_error", language)
|
||||
}
|
||||
if c.ToolErrors >= m.Config.MaxToolErrors {
|
||||
return m.decision(false, HandoffReasonToolError, "fallback.tool_error", language)
|
||||
}
|
||||
return FallbackDecision{}
|
||||
}
|
||||
|
||||
func (m *FallbackManager) decision(close bool, reason HandoffReasonCode, key string, language string) FallbackDecision {
|
||||
return FallbackDecision{ShouldOfferHandoff: !close, ShouldClose: close, ReasonCode: reason, MessageKey: key, Message: Message(key, language)}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package handoff
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFallbackManager(t *testing.T) {
|
||||
m := NewFallbackManager(FallbackConfig{MaxLanguageFailures: 3, MaxRegionFailures: 3, MaxNoAnswer: 2, MaxKBUnavailable: 1, MaxAIErrors: 1, MaxMediaErrors: 1, MaxToolErrors: 2, CallTimeout: time.Minute})
|
||||
if d := m.Evaluate(FallbackCounters{LanguageFailures: 3}, "ru", time.Now()); !d.ShouldOfferHandoff || d.ReasonCode != HandoffReasonLanguageFailures {
|
||||
t.Fatalf("language fallback failed: %+v", d)
|
||||
}
|
||||
if d := m.Evaluate(FallbackCounters{RegionFailures: 3}, "ru", time.Now()); !d.ShouldOfferHandoff || d.ReasonCode != HandoffReasonRegionFailures {
|
||||
t.Fatalf("region fallback failed: %+v", d)
|
||||
}
|
||||
if d := m.Evaluate(FallbackCounters{NoAnswerCount: 2}, "ru", time.Now()); !d.ShouldOfferHandoff || d.ReasonCode != HandoffReasonRepeatedNoAnswer {
|
||||
t.Fatalf("no answer fallback failed: %+v", d)
|
||||
}
|
||||
if d := m.Evaluate(FallbackCounters{KBUnavailable: 1}, "ru", time.Now()); !d.ShouldOfferHandoff || d.ReasonCode != HandoffReasonKBUnavailable {
|
||||
t.Fatalf("kb fallback failed: %+v", d)
|
||||
}
|
||||
if d := m.Evaluate(FallbackCounters{AIProviderErrors: 1}, "ru", time.Now()); !d.ShouldOfferHandoff || d.ReasonCode != HandoffReasonAIProviderError {
|
||||
t.Fatalf("ai fallback failed: %+v", d)
|
||||
}
|
||||
if d := m.Evaluate(FallbackCounters{MediaErrors: 1}, "ru", time.Now()); !d.ShouldOfferHandoff || d.ReasonCode != HandoffReasonMediaError {
|
||||
t.Fatalf("media fallback failed: %+v", d)
|
||||
}
|
||||
if d := m.Evaluate(FallbackCounters{ToolErrors: 2}, "ru", time.Now()); !d.ShouldOfferHandoff || d.ReasonCode != HandoffReasonToolError {
|
||||
t.Fatalf("tool fallback failed: %+v", d)
|
||||
}
|
||||
if d := m.Evaluate(FallbackCounters{StartedAt: time.Now().Add(-2 * time.Minute)}, "ru", time.Now()); !d.ShouldClose || d.ReasonCode != HandoffReasonTimeout {
|
||||
t.Fatalf("timeout fallback failed: %+v", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package handoff
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
Config Config
|
||||
Store *Store
|
||||
Executor Executor
|
||||
}
|
||||
|
||||
type RequestInput struct {
|
||||
CallID string
|
||||
AsteriskChannelID string
|
||||
State string
|
||||
Language string
|
||||
RegionCode string
|
||||
Route string
|
||||
ReasonCode HandoffReasonCode
|
||||
ReasonText string
|
||||
Summary string
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
func NewManager(cfg Config, executor Executor) *Manager {
|
||||
if cfg.Mode == "" {
|
||||
cfg = DefaultConfig()
|
||||
}
|
||||
if executor == nil {
|
||||
executor = SafeExecutor{}
|
||||
}
|
||||
return &Manager{Config: cfg, Store: NewStore(), Executor: executor}
|
||||
}
|
||||
|
||||
func (m *Manager) Request(ctx context.Context, in RequestInput) (HandoffRequest, HandoffResult, error) {
|
||||
if existing, ok := m.Store.GetByCall(in.CallID); ok {
|
||||
result := HandoffResult{RequestID: existing.ID, Status: existing.Status, Mode: existing.Mode, MessageKey: "handoff.already_requested", Message: Message("handoff.already_requested", in.Language)}
|
||||
return existing, result, nil
|
||||
}
|
||||
mode := m.Config.Mode
|
||||
hasTarget := mode == HandoffModeHangupAfterMessage || mode == HandoffModeDisabledStub ||
|
||||
(mode == HandoffModeARIRedirect && m.Config.TargetEndpoint != "") ||
|
||||
(mode == HandoffModeDialplanContinue && m.Config.DialplanContext != "" && m.Config.DialplanExtension != "")
|
||||
decision := Authorize(PolicyContext{State: in.State, Route: in.Route, HandoffEnabled: m.Config.Enabled, HandoffMode: mode, AllowInTestRouteOnly: m.Config.AllowInTestRouteOnly, HasTarget: hasTarget})
|
||||
now := time.Now().UTC()
|
||||
req := HandoffRequest{
|
||||
ID: fmt.Sprintf("handoff-%d", now.UnixNano()),
|
||||
CallID: in.CallID,
|
||||
AsteriskChannelID: in.AsteriskChannelID,
|
||||
Mode: mode,
|
||||
Status: HandoffStatusRequested,
|
||||
ReasonCode: in.ReasonCode,
|
||||
ReasonText: SanitizeText(in.ReasonText, m.Config.MaxSummaryChars),
|
||||
Summary: SanitizeText(in.Summary, m.Config.MaxSummaryChars),
|
||||
Language: in.Language,
|
||||
RegionCode: in.RegionCode,
|
||||
Route: in.Route,
|
||||
TargetEndpoint: m.Config.TargetEndpoint,
|
||||
DialplanContext: m.Config.DialplanContext,
|
||||
DialplanExtension: m.Config.DialplanExtension,
|
||||
DialplanPriority: m.Config.DialplanPriority,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Metadata: in.Metadata,
|
||||
}
|
||||
if !decision.Allowed {
|
||||
req.Status = HandoffStatusFailed
|
||||
result := HandoffResult{RequestID: req.ID, Status: req.Status, Mode: req.Mode, MessageKey: decision.MessageKey, Message: Message(decision.MessageKey, in.Language), Error: decision.ReasonCode}
|
||||
m.Store.Put(req)
|
||||
return req, result, fmt.Errorf(decision.ReasonCode)
|
||||
}
|
||||
if decision.StubOnly {
|
||||
req.Mode = HandoffModeDisabledStub
|
||||
req.Status = HandoffStatusStubbed
|
||||
result := HandoffResult{RequestID: req.ID, Status: req.Status, Mode: req.Mode, MessageKey: decision.MessageKey, Message: Message(decision.MessageKey, in.Language)}
|
||||
m.Store.Put(req)
|
||||
return req, result, nil
|
||||
}
|
||||
result, err := m.Executor.Execute(ctx, req)
|
||||
req.Status = result.Status
|
||||
req.Error = result.Error
|
||||
completed := time.Now().UTC()
|
||||
if result.Status == HandoffStatusCompleted || result.Status == HandoffStatusFailed || result.Status == HandoffStatusStubbed {
|
||||
req.CompletedAt = &completed
|
||||
}
|
||||
req.UpdatedAt = completed
|
||||
m.Store.Put(req)
|
||||
return req, result, err
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package handoff
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeARI struct {
|
||||
redirects int
|
||||
continues int
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (f *fakeARI) RedirectChannel(ctx context.Context, channelID string, endpoint string) error {
|
||||
f.redirects++
|
||||
if f.fail {
|
||||
return context.Canceled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeARI) ContinueInDialplan(ctx context.Context, channelID, dialplanContext, extension string, priority int) error {
|
||||
f.continues++
|
||||
if f.fail {
|
||||
return context.Canceled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestManagerDisabledStub(t *testing.T) {
|
||||
ari := &fakeARI{}
|
||||
m := NewManager(DefaultConfig(), SafeExecutor{ARI: ari})
|
||||
_, res, err := m.Request(context.Background(), RequestInput{CallID: "c1", AsteriskChannelID: "c1", State: "READY_TO_HELP", Route: "test", Language: "ru", ReasonCode: HandoffReasonUserRequested})
|
||||
if err != nil || res.Status != HandoffStatusStubbed || res.TransferAttempted || ari.redirects != 0 || ari.continues != 0 {
|
||||
t.Fatalf("bad stub result: %+v err=%v ari=%+v", res, err, ari)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRedirectAndContinue(t *testing.T) {
|
||||
ari := &fakeARI{}
|
||||
m := NewManager(Config{Mode: HandoffModeARIRedirect, Enabled: true, TargetEndpoint: "PJSIP/operator", AllowInTestRouteOnly: true, DialplanPriority: 1, MaxSummaryChars: 500}, SafeExecutor{ARI: ari})
|
||||
_, res, err := m.Request(context.Background(), RequestInput{CallID: "c1", AsteriskChannelID: "c1", State: "READY_TO_HELP", Route: "test", Language: "ru", ReasonCode: HandoffReasonUserRequested})
|
||||
if err != nil || !res.TransferSucceeded || ari.redirects != 1 {
|
||||
t.Fatalf("redirect failed: %+v err=%v ari=%+v", res, err, ari)
|
||||
}
|
||||
ari = &fakeARI{}
|
||||
m = NewManager(Config{Mode: HandoffModeDialplanContinue, Enabled: true, DialplanContext: "ctx", DialplanExtension: "100", DialplanPriority: 1, AllowInTestRouteOnly: true, MaxSummaryChars: 500}, SafeExecutor{ARI: ari})
|
||||
_, res, err = m.Request(context.Background(), RequestInput{CallID: "c2", AsteriskChannelID: "c2", State: "READY_TO_HELP", Route: "test", Language: "ru", ReasonCode: HandoffReasonUserRequested})
|
||||
if err != nil || !res.TransferSucceeded || ari.continues != 1 {
|
||||
t.Fatalf("continue failed: %+v err=%v ari=%+v", res, err, ari)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerSanitizesSummaryAndDeduplicates(t *testing.T) {
|
||||
m := NewManager(DefaultConfig(), nil)
|
||||
req, _, err := m.Request(context.Background(), RequestInput{CallID: "c1", AsteriskChannelID: "c1", State: "READY_TO_HELP", Route: "test", Language: "ru", Summary: "+77011234567 wants operator", ReasonCode: HandoffReasonUserRequested})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Summary == "+77011234567 wants operator" {
|
||||
t.Fatalf("summary not sanitized: %q", req.Summary)
|
||||
}
|
||||
_, res, _ := m.Request(context.Background(), RequestInput{CallID: "c1", AsteriskChannelID: "c1", State: "HANDOFF", Route: "test", Language: "ru", ReasonCode: HandoffReasonUserRequested})
|
||||
if res.MessageKey != "handoff.already_requested" {
|
||||
t.Fatalf("duplicate not idempotent: %+v", res)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package handoff
|
||||
|
||||
func Message(key, language string) string {
|
||||
m := map[string]map[string]string{
|
||||
"handoff.stub": {
|
||||
"ru": "Я могу зафиксировать запрос на оператора, но прямой перевод пока не подключен.",
|
||||
"kk": "Операторға сұрауды белгілей аламын, бірақ тікелей аудару әзірге қосылмаған.",
|
||||
"": "Я могу зафиксировать запрос на оператора, но прямой перевод пока не подключен.\nОператорға сұрауды белгілей аламын, бірақ тікелей аудару әзірге қосылмаған.",
|
||||
},
|
||||
"handoff.transfer_started": {
|
||||
"ru": "Соединяю вас с оператором.",
|
||||
"kk": "Сізді операторға қосып жатырмын.",
|
||||
},
|
||||
"handoff.transfer_failed": {
|
||||
"ru": "Не удалось соединить с оператором. Попробуйте обратиться позже.",
|
||||
"kk": "Операторға қосу мүмкін болмады. Кейінірек қайталап көріңіз.",
|
||||
},
|
||||
"handoff.not_configured": {
|
||||
"ru": "Перевод на оператора сейчас не настроен.",
|
||||
"kk": "Операторға аудару қазір бапталмаған.",
|
||||
},
|
||||
"handoff.already_requested": {
|
||||
"ru": "Запрос на оператора уже зафиксирован.",
|
||||
"kk": "Операторға сұрау тіркелді.",
|
||||
},
|
||||
"handoff.denied": {
|
||||
"ru": "Сейчас перевод на оператора недоступен.",
|
||||
"kk": "Қазір операторға аудару қолжетімсіз.",
|
||||
},
|
||||
"fallback.kb_unavailable": {
|
||||
"ru": "База знаний временно недоступна. Могу предложить обратиться к оператору.",
|
||||
"kk": "Білім базасы уақытша қолжетімсіз. Операторға жүгінуді ұсына аламын.",
|
||||
},
|
||||
"fallback.no_answer": {
|
||||
"ru": "В базе знаний нет точной информации по этому вопросу. Могу предложить обратиться к оператору.",
|
||||
"kk": "Бұл сұрақ бойынша білім базасында нақты ақпарат жоқ. Операторға жүгінуді ұсына аламын.",
|
||||
},
|
||||
"fallback.ai_error": {
|
||||
"ru": "Возникла техническая ошибка AI-оператора. Могу предложить обратиться к оператору.",
|
||||
"kk": "AI-операторда техникалық қате пайда болды. Операторға жүгінуді ұсына аламын.",
|
||||
},
|
||||
"fallback.media_error": {
|
||||
"ru": "Возникла ошибка аудиосвязи. Могу предложить обратиться к оператору.",
|
||||
"kk": "Аудио байланысында қате пайда болды. Операторға жүгінуді ұсына аламын.",
|
||||
},
|
||||
"fallback.timeout": {
|
||||
"ru": "Время разговора истекло. Завершаю обращение.",
|
||||
"kk": "Сөйлесу уақыты аяқталды. Өтінішті аяқтаймын.",
|
||||
},
|
||||
"fallback.tool_error": {
|
||||
"ru": "Не удалось выполнить действие. Могу предложить обратиться к оператору.",
|
||||
"kk": "Әрекетті орындау мүмкін болмады. Операторға жүгінуді ұсына аламын.",
|
||||
},
|
||||
"fallback.language_failures": {
|
||||
"ru": "Не удалось выбрать язык. Могу предложить обратиться к оператору.",
|
||||
"kk": "Тілді таңдау мүмкін болмады. Операторға жүгінуді ұсына аламын.",
|
||||
},
|
||||
"fallback.region_failures": {
|
||||
"ru": "Не удалось определить регион. Могу предложить обратиться к оператору.",
|
||||
"kk": "Аймақты анықтау мүмкін болмады. Операторға жүгінуді ұсына аламын.",
|
||||
},
|
||||
}
|
||||
if byLang, ok := m[key]; ok {
|
||||
if v := byLang[language]; v != "" {
|
||||
return v
|
||||
}
|
||||
if v := byLang[""]; v != "" {
|
||||
return v
|
||||
}
|
||||
if v := byLang["ru"]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handoff
|
||||
|
||||
type PolicyContext struct {
|
||||
State string
|
||||
Route string
|
||||
HandoffEnabled bool
|
||||
HandoffMode HandoffMode
|
||||
AllowInTestRouteOnly bool
|
||||
HasTarget bool
|
||||
AlreadyRequested bool
|
||||
}
|
||||
|
||||
type PolicyDecision struct {
|
||||
Allowed bool
|
||||
StubOnly bool
|
||||
ReasonCode string
|
||||
MessageKey string
|
||||
}
|
||||
|
||||
func Authorize(ctx PolicyContext) PolicyDecision {
|
||||
if ctx.State == "ENDED" {
|
||||
return PolicyDecision{Allowed: false, ReasonCode: "call_ended", MessageKey: "handoff.denied"}
|
||||
}
|
||||
if ctx.AlreadyRequested || ctx.State == "HANDOFF" {
|
||||
return PolicyDecision{Allowed: true, StubOnly: true, ReasonCode: "already_requested", MessageKey: "handoff.already_requested"}
|
||||
}
|
||||
if ctx.AllowInTestRouteOnly && ctx.Route != "test" {
|
||||
return PolicyDecision{Allowed: true, StubOnly: true, ReasonCode: "test_route_only", MessageKey: "handoff.stub"}
|
||||
}
|
||||
if !ctx.HandoffEnabled {
|
||||
return PolicyDecision{Allowed: true, StubOnly: true, ReasonCode: "disabled", MessageKey: "handoff.stub"}
|
||||
}
|
||||
switch ctx.HandoffMode {
|
||||
case HandoffModeDisabledStub:
|
||||
return PolicyDecision{Allowed: true, StubOnly: true, ReasonCode: "disabled", MessageKey: "handoff.stub"}
|
||||
case HandoffModeHangupAfterMessage:
|
||||
return PolicyDecision{Allowed: true, ReasonCode: "ok", MessageKey: "handoff.stub"}
|
||||
case HandoffModeARIRedirect, HandoffModeDialplanContinue:
|
||||
if !ctx.HasTarget {
|
||||
return PolicyDecision{Allowed: true, StubOnly: true, ReasonCode: "not_configured", MessageKey: "handoff.not_configured"}
|
||||
}
|
||||
return PolicyDecision{Allowed: true, ReasonCode: "ok", MessageKey: "handoff.transfer_started"}
|
||||
default:
|
||||
return PolicyDecision{Allowed: false, ReasonCode: "unknown_mode", MessageKey: "handoff.denied"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package handoff
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPolicy(t *testing.T) {
|
||||
d := Authorize(PolicyContext{State: "READY_TO_HELP", Route: "test", HandoffEnabled: false, HandoffMode: HandoffModeARIRedirect, AllowInTestRouteOnly: true, HasTarget: true})
|
||||
if !d.Allowed || !d.StubOnly {
|
||||
t.Fatalf("disabled must be stub only: %+v", d)
|
||||
}
|
||||
d = Authorize(PolicyContext{State: "READY_TO_HELP", Route: "test", HandoffEnabled: true, HandoffMode: HandoffModeARIRedirect, AllowInTestRouteOnly: true})
|
||||
if !d.StubOnly || d.ReasonCode != "not_configured" {
|
||||
t.Fatalf("missing target should stub: %+v", d)
|
||||
}
|
||||
d = Authorize(PolicyContext{State: "READY_TO_HELP", Route: "production", HandoffEnabled: true, HandoffMode: HandoffModeARIRedirect, AllowInTestRouteOnly: true, HasTarget: true})
|
||||
if !d.StubOnly {
|
||||
t.Fatalf("production should stub: %+v", d)
|
||||
}
|
||||
d = Authorize(PolicyContext{State: "ENDED", Route: "test", HandoffEnabled: true, HandoffMode: HandoffModeDisabledStub})
|
||||
if d.Allowed {
|
||||
t.Fatalf("ended should deny: %+v", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package handoff
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var phoneLike = regexp.MustCompile(`\+?\d[\d\s().-]{5,}\d`)
|
||||
|
||||
func SanitizeText(value string, maxRunes int) string {
|
||||
v := strings.TrimSpace(value)
|
||||
v = phoneLike.ReplaceAllString(v, "***MASKED_PHONE***")
|
||||
v = strings.Join(strings.Fields(v), " ")
|
||||
if maxRunes <= 0 {
|
||||
maxRunes = 500
|
||||
}
|
||||
r := []rune(v)
|
||||
if len(r) > maxRunes {
|
||||
return string(r[:maxRunes])
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func SanitizeEndpoint(value string) string {
|
||||
if strings.Contains(value, "@") {
|
||||
parts := strings.Split(value, "@")
|
||||
return "***MASKED***@" + parts[len(parts)-1]
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package handoff
|
||||
|
||||
import "sync"
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
requests map[string]HandoffRequest
|
||||
byCall map[string]string
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{requests: map[string]HandoffRequest{}, byCall: map[string]string{}}
|
||||
}
|
||||
|
||||
func (s *Store) Put(req HandoffRequest) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.requests[req.ID] = req
|
||||
s.byCall[req.CallID] = req.ID
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (HandoffRequest, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
req, ok := s.requests[id]
|
||||
return req, ok
|
||||
}
|
||||
|
||||
func (s *Store) GetByCall(callID string) (HandoffRequest, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
id, ok := s.byCall[callID]
|
||||
if !ok {
|
||||
return HandoffRequest{}, false
|
||||
}
|
||||
req, ok := s.requests[id]
|
||||
return req, ok
|
||||
}
|
||||
|
||||
func (s *Store) DeleteByCall(callID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if id, ok := s.byCall[callID]; ok {
|
||||
delete(s.requests, id)
|
||||
delete(s.byCall, callID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package handoff
|
||||
|
||||
import "time"
|
||||
|
||||
type HandoffMode string
|
||||
|
||||
const (
|
||||
HandoffModeDisabledStub HandoffMode = "disabled_stub"
|
||||
HandoffModeARIRedirect HandoffMode = "ari_redirect"
|
||||
HandoffModeDialplanContinue HandoffMode = "dialplan_continue"
|
||||
HandoffModeHangupAfterMessage HandoffMode = "hangup_after_message"
|
||||
)
|
||||
|
||||
type HandoffStatus string
|
||||
|
||||
const (
|
||||
HandoffStatusRequested HandoffStatus = "requested"
|
||||
HandoffStatusAccepted HandoffStatus = "accepted"
|
||||
HandoffStatusStubbed HandoffStatus = "stubbed"
|
||||
HandoffStatusFailed HandoffStatus = "failed"
|
||||
HandoffStatusCompleted HandoffStatus = "completed"
|
||||
HandoffStatusCancelled HandoffStatus = "cancelled"
|
||||
)
|
||||
|
||||
type HandoffReasonCode string
|
||||
|
||||
const (
|
||||
HandoffReasonUserRequested HandoffReasonCode = "user_requested"
|
||||
HandoffReasonRepeatedNoAnswer HandoffReasonCode = "repeated_no_answer"
|
||||
HandoffReasonKBUnavailable HandoffReasonCode = "kb_unavailable"
|
||||
HandoffReasonLanguageFailures HandoffReasonCode = "language_failures"
|
||||
HandoffReasonRegionFailures HandoffReasonCode = "region_failures"
|
||||
HandoffReasonAIProviderError HandoffReasonCode = "ai_provider_error"
|
||||
HandoffReasonMediaError HandoffReasonCode = "media_error"
|
||||
HandoffReasonToolError HandoffReasonCode = "tool_error"
|
||||
HandoffReasonTimeout HandoffReasonCode = "timeout"
|
||||
HandoffReasonSafetyFallback HandoffReasonCode = "safety_fallback"
|
||||
)
|
||||
|
||||
type HandoffRequest struct {
|
||||
ID string
|
||||
CallID string
|
||||
AsteriskChannelID string
|
||||
Mode HandoffMode
|
||||
Status HandoffStatus
|
||||
ReasonCode HandoffReasonCode
|
||||
ReasonText string
|
||||
Summary string
|
||||
Language string
|
||||
RegionCode string
|
||||
Route string
|
||||
TargetEndpoint string
|
||||
DialplanContext string
|
||||
DialplanExtension string
|
||||
DialplanPriority int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CompletedAt *time.Time
|
||||
Error string
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
type HandoffResult struct {
|
||||
RequestID string
|
||||
Status HandoffStatus
|
||||
Mode HandoffMode
|
||||
MessageKey string
|
||||
Message string
|
||||
TransferAttempted bool
|
||||
TransferSucceeded bool
|
||||
Error string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Mode HandoffMode
|
||||
Enabled bool
|
||||
TargetEndpoint string
|
||||
DialplanContext string
|
||||
DialplanExtension string
|
||||
DialplanPriority int
|
||||
Timeout time.Duration
|
||||
MaxAttempts int
|
||||
PlayMessageBeforeTransfer bool
|
||||
HangupAfterStub bool
|
||||
AllowInTestRouteOnly bool
|
||||
MaxSummaryChars int
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{Mode: HandoffModeDisabledStub, Enabled: false, DialplanPriority: 1, Timeout: 30 * time.Second, MaxAttempts: 1, PlayMessageBeforeTransfer: true, AllowInTestRouteOnly: true, MaxSummaryChars: 500}
|
||||
}
|
||||
Reference in New Issue
Block a user