sync: migrate ai-operator to Gitea (2026-08-10)
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
package language
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"ai-operator/internal/dialogue/state"
|
||||
)
|
||||
|
||||
type Detector struct{}
|
||||
|
||||
func NewDetector() *Detector { return &Detector{} }
|
||||
func (d *Detector) Normalize(input string) string { return Normalize(input) }
|
||||
func (d *Detector) DetectToolLanguage(value string) DetectionResult {
|
||||
return d.Detect(value, SourceToolArgs)
|
||||
}
|
||||
|
||||
func (d *Detector) Detect(input string, source DetectionSource) DetectionResult {
|
||||
n := Normalize(input)
|
||||
res := DetectionResult{Language: LanguageUnknown, Intent: IntentNotLanguage, Source: source, NormalizedText: n, ReasonCode: "no_match"}
|
||||
if n == "" {
|
||||
res.ReasonCode = "empty_input"
|
||||
res.NeedsClarification = true
|
||||
return res
|
||||
}
|
||||
if len([]rune(n)) < 2 {
|
||||
res.ReasonCode = "too_short"
|
||||
return res
|
||||
}
|
||||
if containsAny(n, ambiguousPhrases) || mentionsBoth(n) {
|
||||
res.Intent = IntentAmbiguous
|
||||
res.ReasonCode = "ambiguous_ru_kk"
|
||||
res.Confidence = 0.4
|
||||
res.NeedsClarification = true
|
||||
return res
|
||||
}
|
||||
if containsAny(n, falsePositivePhrases) {
|
||||
res.ReasonCode = "no_match"
|
||||
return res
|
||||
}
|
||||
if reason, ok := ruExact[n]; ok {
|
||||
return result(LanguageRU, IntentLanguageSelect, 0.99, n, n, source, reason, false)
|
||||
}
|
||||
if reason, ok := kkExact[n]; ok {
|
||||
return result(LanguageKK, IntentLanguageSelect, 0.95, n, n, source, reason, false)
|
||||
}
|
||||
if d.IsExplicitChangeRequest(n) {
|
||||
if p := phraseMatch(n, explicitRu); p != "" {
|
||||
return result(LanguageRU, IntentLanguageChange, 0.95, p, n, source, "explicit_language_request", false)
|
||||
}
|
||||
if p := phraseMatch(n, explicitKK); p != "" {
|
||||
return result(LanguageKK, IntentLanguageChange, 0.95, p, n, source, "explicit_language_request", false)
|
||||
}
|
||||
return DetectionResult{Language: LanguageUnknown, Intent: IntentAmbiguous, Confidence: 0.5, NormalizedText: n, Source: source, ReasonCode: "explicit_language_request", NeedsClarification: true}
|
||||
}
|
||||
if p := exactOrPhrase(n, ruPhrases); p != "" {
|
||||
conf := 0.95
|
||||
if n == "я русский" {
|
||||
conf = 0.55
|
||||
}
|
||||
return result(LanguageRU, IntentLanguageSelect, conf, p, n, source, "exact_phrase", conf < MediumConfidence)
|
||||
}
|
||||
if p := exactOrPhrase(n, kkPhrases); p != "" {
|
||||
return result(LanguageKK, IntentLanguageSelect, 0.95, p, n, source, "exact_phrase", false)
|
||||
}
|
||||
if p := exactOrPhrase(n, ruASR); p != "" {
|
||||
return result(LanguageRU, IntentLanguageSelect, 0.80, p, n, source, "weak_match", false)
|
||||
}
|
||||
if p := exactOrPhrase(n, kkASR); p != "" {
|
||||
return result(LanguageKK, IntentLanguageSelect, 0.80, p, n, source, "weak_match", false)
|
||||
}
|
||||
if n == "я русский" {
|
||||
return result(LanguageRU, IntentAmbiguous, 0.55, n, n, source, "weak_match", true)
|
||||
}
|
||||
if n == "я казах" {
|
||||
return result(LanguageKK, IntentAmbiguous, 0.55, n, n, source, "weak_match", true)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (d *Detector) IsExplicitChangeRequest(input string) bool {
|
||||
n := Normalize(input)
|
||||
return containsAny(n, explicitChangeMarkers) || containsAny(n, explicitRu) || containsAny(n, explicitKK)
|
||||
}
|
||||
|
||||
func ShouldApplyLanguageDetection(ctx SelectionContext, res DetectionResult) LanguageDecision {
|
||||
if ctx.CurrentState == state.StateEnded || ctx.CurrentState == state.StateHandoff || ctx.CurrentState == state.StateClosing {
|
||||
return LanguageDecision{ReasonCode: "state_not_allowed", MessageKey: "language.change.denied"}
|
||||
}
|
||||
if res.NeedsClarification || res.Intent == IntentAmbiguous {
|
||||
return LanguageDecision{NeedsClarification: true, ReasonCode: res.ReasonCode, MessageKey: "language.ask_clarify"}
|
||||
}
|
||||
if res.Language != LanguageRU && res.Language != LanguageKK {
|
||||
return LanguageDecision{ReasonCode: res.ReasonCode, MessageKey: "language.not_understood"}
|
||||
}
|
||||
switch ctx.CurrentState {
|
||||
case state.StateLanguageSelection:
|
||||
if res.Confidence >= MediumConfidence && res.Intent == IntentLanguageSelect {
|
||||
return LanguageDecision{Apply: true, Language: res.Language, ReasonCode: res.ReasonCode, MessageKey: "language.selected." + string(res.Language)}
|
||||
}
|
||||
case state.StateRegionSelection:
|
||||
if res.Confidence >= HighConfidence && (res.Intent == IntentLanguageChange || res.Intent == IntentLanguageSelect) {
|
||||
return LanguageDecision{Apply: true, Language: res.Language, ReasonCode: res.ReasonCode, MessageKey: "language.changed." + string(res.Language)}
|
||||
}
|
||||
case state.StateReadyToHelp, state.StateQuestionAnswering:
|
||||
if ctx.AllowChange && res.Intent == IntentLanguageChange && res.Confidence >= HighConfidence {
|
||||
return LanguageDecision{Apply: true, Language: res.Language, ReasonCode: res.ReasonCode, MessageKey: "language.changed." + string(res.Language)}
|
||||
}
|
||||
}
|
||||
return LanguageDecision{ReasonCode: "mixed_language_no_explicit_switch", MessageKey: "language.not_understood"}
|
||||
}
|
||||
|
||||
func result(lang Language, intent DetectionIntent, conf float64, phrase, norm string, src DetectionSource, reason string, clarify bool) DetectionResult {
|
||||
return DetectionResult{Language: lang, Intent: intent, Confidence: conf, MatchedPhrase: phrase, NormalizedText: norm, Source: src, ReasonCode: reason, NeedsClarification: clarify}
|
||||
}
|
||||
func exactOrPhrase(n string, phrases []string) string {
|
||||
for _, p := range phrases {
|
||||
p = Normalize(p)
|
||||
if n == p || strings.Contains(n, p) && len([]rune(p)) > 4 {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func phraseMatch(n string, phrases []string) string { return exactOrPhrase(n, phrases) }
|
||||
func containsAny(n string, phrases []string) bool { return exactOrPhrase(n, phrases) != "" }
|
||||
func mentionsBoth(n string) bool {
|
||||
ru := exactOrPhrase(n, append(ruPhrases, ruASR...)) != "" || strings.Contains(" "+n+" ", " ru ")
|
||||
kk := exactOrPhrase(n, append(kkPhrases, kkASR...)) != "" || strings.Contains(" "+n+" ", " kk ")
|
||||
return ru && kk
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package language
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"ai-operator/internal/dialogue/state"
|
||||
)
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
" RU ": "ru",
|
||||
"Қазақша!": "қазақша",
|
||||
"по-русски": "по русски",
|
||||
"ёж тест": "еж тест",
|
||||
"kk.": "kk",
|
||||
"қазақ тілі": "қазақ тілі",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := Normalize(in); got != want {
|
||||
t.Fatalf("Normalize(%q)=%q want=%q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRUDetection(t *testing.T) {
|
||||
d := NewDetector()
|
||||
inputs := []string{"ru", "rus", "русский", "русский язык", "на русском", "по русски", "по-русски", "хочу русский", "хочу на русском", "давайте на русском", "говорите на русском", "продолжим на русском", "выбираю русский", "мне русский", "нужен русский", "русский пожалуйста", "russian", "in russian", "speak russian", "russki", "russkiy", "po russki", "руский", "русски", "порусски", "по руски"}
|
||||
for _, in := range inputs {
|
||||
got := d.Detect(in, SourceUserText)
|
||||
if got.Language != LanguageRU || got.Confidence < MediumConfidence || got.NeedsClarification {
|
||||
t.Fatalf("%q => %+v", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKKDetection(t *testing.T) {
|
||||
d := NewDetector()
|
||||
inputs := []string{"kk", "kz", "қазақша", "қазақ тілі", "қазақ тілінде", "қазақша сөйлейік", "қазақша болсын", "қазақ тілін таңдадым", "қазақша жауап беріңіз", "маған қазақша", "мен қазақша", "қазакша", "казакша", "казахский", "казахский язык", "на казахском", "по казахски", "по-казахски", "хочу казахский", "говорите на казахском", "qazaqsha", "qazaq tili", "kazakh", "in kazakh", "kazaksha", "казақша", "показахски"}
|
||||
for _, in := range inputs {
|
||||
got := d.Detect(in, SourceUserText)
|
||||
if got.Language != LanguageKK || got.Confidence < MediumConfidence || got.NeedsClarification {
|
||||
t.Fatalf("%q => %+v", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmbiguousAndFalsePositive(t *testing.T) {
|
||||
d := NewDetector()
|
||||
amb := []string{"я русский", "я казах", "русский или қазақша?", "можно русский, нет қазақша", "сначала русский потом казахский", "я не знаю русский или қазақша"}
|
||||
for _, in := range amb {
|
||||
got := d.Detect(in, SourceUserText)
|
||||
if !got.NeedsClarification || got.Intent != IntentAmbiguous {
|
||||
t.Fatalf("ambiguous %q => %+v", in, got)
|
||||
}
|
||||
}
|
||||
falsePos := []string{"Казахтелеком", "русский клиент спрашивает", "У меня вопрос на русском сайте", "Алматы русский театр", "тариф ru123", "abckkdef", "какой у меня тариф", "хочу узнать баланс", "оператор нужен"}
|
||||
for _, in := range falsePos {
|
||||
got := d.Detect(in, SourceUserText)
|
||||
if got.Language != LanguageUnknown || got.Intent != IntentNotLanguage {
|
||||
t.Fatalf("false positive %q => %+v", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitChangeAndPolicy(t *testing.T) {
|
||||
d := NewDetector()
|
||||
cases := map[string]Language{"перейдите на русский": LanguageRU, "переключите на русский": LanguageRU, "switch to russian": LanguageRU, "қазақшаға ауысайық": LanguageKK, "қазақша сөйлейік": LanguageKK, "switch to kazakh": LanguageKK}
|
||||
for in, want := range cases {
|
||||
got := d.Detect(in, SourceTranscript)
|
||||
if got.Intent != IntentLanguageChange || got.Language != want {
|
||||
t.Fatalf("change %q => %+v", in, got)
|
||||
}
|
||||
}
|
||||
clarify := d.Detect("сменить язык", SourceTranscript)
|
||||
if !clarify.NeedsClarification || clarify.Language != LanguageUnknown {
|
||||
t.Fatalf("expected clarification: %+v", clarify)
|
||||
}
|
||||
if !ShouldApplyLanguageDetection(SelectionContext{CurrentState: state.StateLanguageSelection}, d.Detect("русский", SourceUserText)).Apply {
|
||||
t.Fatal("language selection did not apply ru")
|
||||
}
|
||||
if !ShouldApplyLanguageDetection(SelectionContext{CurrentState: state.StateLanguageSelection}, d.Detect("қазақша", SourceUserText)).Apply {
|
||||
t.Fatal("language selection did not apply kk")
|
||||
}
|
||||
if ShouldApplyLanguageDetection(SelectionContext{CurrentState: state.StateLanguageSelection}, d.Detect("какой у меня тариф", SourceUserText)).Apply {
|
||||
t.Fatal("business question applied language")
|
||||
}
|
||||
if !ShouldApplyLanguageDetection(SelectionContext{CurrentState: state.StateLanguageSelection}, d.Detect("русский или қазақша?", SourceUserText)).NeedsClarification {
|
||||
t.Fatal("ambiguous did not request clarification")
|
||||
}
|
||||
if ShouldApplyLanguageDetection(SelectionContext{CurrentState: state.StateRegionSelection, AllowChange: true}, d.Detect("русский клиент", SourceUserText)).Apply {
|
||||
t.Fatal("random mention switched language")
|
||||
}
|
||||
if !ShouldApplyLanguageDetection(SelectionContext{CurrentState: state.StateReadyToHelp, AllowChange: true}, d.Detect("перейдите на казахский", SourceUserText)).Apply {
|
||||
t.Fatal("explicit ready switch did not apply")
|
||||
}
|
||||
if ShouldApplyLanguageDetection(SelectionContext{CurrentState: state.StateEnded, AllowChange: true}, d.Detect("русский", SourceUserText)).Apply {
|
||||
t.Fatal("ended state applied language")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package language
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var spaceRE = regexp.MustCompile(`\s+`)
|
||||
var edgePunctRE = regexp.MustCompile(`^[\s\.,!\?;:"'«»\(\)\[\]\{\}]+|[\s\.,!\?;:"'«»\(\)\[\]\{\}]+$`)
|
||||
|
||||
func Normalize(input string) string {
|
||||
s := strings.TrimSpace(strings.ToLower(input))
|
||||
s = strings.ReplaceAll(s, "ё", "е")
|
||||
s = strings.ReplaceAll(s, "-", " ")
|
||||
s = edgePunctRE.ReplaceAllString(s, "")
|
||||
s = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '.', ',', '!', '?', ';', ':', '"', '\'', '«', '»', '(', ')', '[', ']', '{', '}':
|
||||
return ' '
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, s)
|
||||
s = spaceRE.ReplaceAllString(strings.TrimSpace(s), " ")
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package language
|
||||
|
||||
var ruExact = map[string]string{"ru": "exact_code", "rus": "exact_code"}
|
||||
var kkExact = map[string]string{"kk": "exact_code", "kz": "exact_code_alias"}
|
||||
|
||||
var ruPhrases = []string{
|
||||
"русский", "русский язык", "на русском", "по русски", "хочу русский", "хочу на русском", "давайте на русском", "говорите на русском", "продолжим на русском", "выбираю русский", "мне русский", "нужен русский", "рус", "русский пожалуйста", "можно на русском",
|
||||
"russian", "in russian", "speak russian", "russki", "russkiy", "po russki",
|
||||
}
|
||||
var ruASR = []string{"руский", "русски", "руссский", "руский язык", "порусски", "по руски"}
|
||||
|
||||
var kkPhrases = []string{
|
||||
"қазақша", "қазақ тілі", "қазақ тілінде", "қазақша сөйлейік", "қазақша сөйлесеміз", "қазақша болсын", "қазақ тілін таңдадым", "қазақша қызмет", "қазақша жауап беріңіз", "маған қазақша", "мен қазақша", "қазакша", "казакша", "казахский", "казахский язык", "на казахском", "по казахски", "хочу казахский", "говорите на казахском", "продолжим на казахском",
|
||||
"qazaqsha", "qazaq tili", "qazaq tilinde", "kazakh", "kazakh language", "in kazakh", "kazaksha", "qazaq",
|
||||
}
|
||||
var kkASR = []string{"казакша", "қазақшаа", "казақша", "казахски", "показахски", "по казахски"}
|
||||
|
||||
var ambiguousPhrases = []string{"я русский", "я казах", "русский или қазақша", "русский или казахский", "можно русский нет қазақша", "сначала русский потом казахский", "я не знаю русский или қазақша", "я не знаю русский или казахский"}
|
||||
var falsePositivePhrases = []string{"казахтелеком", "русский клиент", "русский клиент спрашивает", "у меня вопрос на русском сайте", "алматы русский театр", "какой у меня тариф", "хочу узнать баланс", "оператор нужен"}
|
||||
|
||||
var explicitChangeMarkers = []string{"сменить язык", "поменять язык", "давайте сменим язык", "можно сменить язык", "хочу сменить язык", "перейдите", "переключите", "switch language", "change language", "switch to", "тілді ауыстыру", "тілді өзгерту", "ауысайық", "ауысамын"}
|
||||
var explicitRu = []string{"говорите по русски", "говорите на русском", "перейдите на русский", "переключите на русский", "давайте на русском", "лучше на русском", "орысша сөйлейік", "орыс тіліне ауысайық", "switch to russian", "russian please", "русскийға ауысайық"}
|
||||
var explicitKK = []string{"қазақшаға ауысайық", "қазақша сөйлейік", "қазақша жауап беріңіз", "перейдите на казахский", "переключите на казахский", "switch to kazakh", "qazaqsha"}
|
||||
@@ -0,0 +1,60 @@
|
||||
package language
|
||||
|
||||
import "ai-operator/internal/dialogue/state"
|
||||
|
||||
type Language = state.Language
|
||||
|
||||
const (
|
||||
LanguageUnknown Language = state.LanguageUnknown
|
||||
LanguageRU Language = state.LanguageRU
|
||||
LanguageKK Language = state.LanguageKK
|
||||
)
|
||||
|
||||
type DetectionSource string
|
||||
|
||||
const (
|
||||
SourceUserText DetectionSource = "user_text"
|
||||
SourceToolArgs DetectionSource = "tool_args"
|
||||
SourceTranscript DetectionSource = "transcript"
|
||||
SourceCLI DetectionSource = "cli"
|
||||
)
|
||||
|
||||
type DetectionIntent string
|
||||
|
||||
const (
|
||||
IntentLanguageSelect DetectionIntent = "language_select"
|
||||
IntentLanguageChange DetectionIntent = "language_change"
|
||||
IntentNotLanguage DetectionIntent = "not_language"
|
||||
IntentAmbiguous DetectionIntent = "ambiguous"
|
||||
)
|
||||
|
||||
const (
|
||||
HighConfidence = 0.90
|
||||
MediumConfidence = 0.70
|
||||
LowConfidence = 0.50
|
||||
)
|
||||
|
||||
type DetectionResult struct {
|
||||
Language Language
|
||||
Intent DetectionIntent
|
||||
Confidence float64
|
||||
MatchedPhrase string
|
||||
NormalizedText string
|
||||
Source DetectionSource
|
||||
ReasonCode string
|
||||
NeedsClarification bool
|
||||
}
|
||||
|
||||
type SelectionContext struct {
|
||||
CurrentState state.ConversationState
|
||||
CurrentLanguage Language
|
||||
AllowChange bool
|
||||
}
|
||||
|
||||
type LanguageDecision struct {
|
||||
Apply bool
|
||||
Language Language
|
||||
NeedsClarification bool
|
||||
ReasonCode string
|
||||
MessageKey string
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package messages
|
||||
|
||||
import "ai-operator/internal/dialogue/state"
|
||||
|
||||
var catalog = map[string]map[state.Language]string{
|
||||
"greeting.initial": {
|
||||
state.LanguageUnknown: "Здравствуйте, меня зовут Жанна. Я AI-оператор QazAimaqGas. Чем могу помочь?",
|
||||
state.LanguageRU: "Здравствуйте, меня зовут Жанна. Я AI-оператор QazAimaqGas. Чем могу помочь?",
|
||||
state.LanguageKK: "Сәлеметсіз бе, менің атым Жанна. Мен QazAimaqGas компаниясының AI-операторымын. Қалай көмектесе аламын?",
|
||||
},
|
||||
"language.ask": {
|
||||
state.LanguageUnknown: "Выберите язык обслуживания: русский или қазақша.\nҚызмет көрсету тілін таңдаңыз: қазақша немесе русский.",
|
||||
state.LanguageRU: "Выберите язык обслуживания: русский или қазақша.",
|
||||
state.LanguageKK: "Қызмет көрсету тілін таңдаңыз: қазақша немесе русский.",
|
||||
},
|
||||
"language.ask_clarify": {
|
||||
state.LanguageUnknown: "Я не уверен, какой язык вы выбрали. Скажите, пожалуйста: русский или қазақша.\nҚай тілді таңдағаныңызды нақты түсінбедім. Айтыңызшы: қазақша немесе русский.",
|
||||
state.LanguageRU: "Я не уверен, какой язык вы выбрали. Скажите, пожалуйста: русский или қазақша.",
|
||||
state.LanguageKK: "Қай тілді таңдағаныңызды нақты түсінбедім. Айтыңызшы: қазақша немесе русский.",
|
||||
},
|
||||
"language.not_understood": {
|
||||
state.LanguageUnknown: "Выберите язык обслуживания: русский или қазақша.\nҚызмет көрсету тілін таңдаңыз: қазақша немесе русский.",
|
||||
},
|
||||
"language.selected.ru": {
|
||||
state.LanguageRU: "Хорошо, продолжим на русском. Чем могу помочь?",
|
||||
},
|
||||
"language.selected.kk": {
|
||||
state.LanguageKK: "Жақсы, қазақ тілінде жалғастырамыз. Қалай көмектесе аламын?",
|
||||
},
|
||||
"language.changed.ru": {
|
||||
state.LanguageRU: "Хорошо, перехожу на русский.",
|
||||
},
|
||||
"language.changed.kk": {
|
||||
state.LanguageKK: "Жақсы, қазақ тіліне ауысамын.",
|
||||
},
|
||||
"language.change.confirm": {
|
||||
state.LanguageRU: "Язык изменен.",
|
||||
state.LanguageKK: "Тіл өзгертілді.",
|
||||
},
|
||||
"language.change.denied": {
|
||||
state.LanguageRU: "Сейчас язык нельзя изменить.",
|
||||
state.LanguageKK: "Қазір тілді өзгертуге болмайды.",
|
||||
},
|
||||
"language.unsupported": {
|
||||
state.LanguageRU: "Сейчас доступны русский и қазақша. Выберите один из этих языков.",
|
||||
state.LanguageKK: "Қазір русский және қазақша тілдері қолжетімді. Осы екі тілдің бірін таңдаңыз.",
|
||||
},
|
||||
"language.options": {
|
||||
state.LanguageUnknown: "русский или қазақша",
|
||||
},
|
||||
"language.selected": {
|
||||
state.LanguageRU: "Хорошо, продолжим на русском. Чем могу помочь?",
|
||||
state.LanguageKK: "Жақсы, қазақ тілінде жалғастырамыз. Қалай көмектесе аламын?",
|
||||
},
|
||||
"region.ask": {
|
||||
state.LanguageRU: "Подскажите, пожалуйста, ваш город или область?",
|
||||
state.LanguageKK: "Қалаңызды немесе облысыңызды нақтылап жіберіңізші.",
|
||||
},
|
||||
"region.ask_clarify": {
|
||||
state.LanguageRU: "Уточните, пожалуйста, какой именно регион вы имеете в виду.",
|
||||
state.LanguageKK: "Қай өңірді айтқаныңызды нақтылаңыз.",
|
||||
},
|
||||
"region.not_understood": {
|
||||
state.LanguageRU: "Я не понял регион. Назовите, пожалуйста, город или область.",
|
||||
state.LanguageKK: "Аймағыңызды түсінбедім. Қалаңызды немесе облысыңызды атаңыз.",
|
||||
},
|
||||
"region.unsupported": {
|
||||
state.LanguageRU: "Этот регион сейчас не поддерживается. Назовите, пожалуйста, область Казахстана или город Астана, Алматы, Шымкент.",
|
||||
state.LanguageKK: "Бұл аймақ қазір қолдау көрсетілмейді. Қазақстан облысын немесе Астана, Алматы, Шымкент қаласын атаңыз.",
|
||||
},
|
||||
"region.almaty_clarify": {
|
||||
state.LanguageRU: "Вы имеете в виду город Алматы или Алматинскую область?",
|
||||
state.LanguageKK: "Алматы қаласын айттыңыз ба, әлде Алматы облысын ба?",
|
||||
},
|
||||
"region.selected": {
|
||||
state.LanguageRU: "Спасибо. Ваш регион выбран. Теперь можете задать вопрос.",
|
||||
state.LanguageKK: "Рақмет. Аймағыңыз таңдалды. Енді сұрағыңызды қоя аласыз.",
|
||||
},
|
||||
"region.selected.ru": {
|
||||
state.LanguageRU: "Спасибо. Ваш регион: {region}. Теперь можете задать вопрос.",
|
||||
},
|
||||
"region.selected.kk": {
|
||||
state.LanguageKK: "Рақмет. Аймағыңыз: {region}. Енді сұрағыңызды қоя аласыз.",
|
||||
},
|
||||
"region.changed": {
|
||||
state.LanguageRU: "Хорошо, регион изменен.",
|
||||
state.LanguageKK: "Жақсы, аймақ өзгертілді.",
|
||||
},
|
||||
"region.changed.ru": {
|
||||
state.LanguageRU: "Хорошо, регион изменен: {region}.",
|
||||
},
|
||||
"region.changed.kk": {
|
||||
state.LanguageKK: "Жақсы, аймақ өзгертілді: {region}.",
|
||||
},
|
||||
"region.options": {
|
||||
state.LanguageRU: "область Казахстана или город Астана, Алматы, Шымкент",
|
||||
state.LanguageKK: "Қазақстан облысы немесе Астана, Алматы, Шымкент қаласы",
|
||||
},
|
||||
"region.required_before_help": {
|
||||
state.LanguageRU: "Сначала уточните регион.",
|
||||
state.LanguageKK: "Алдымен аймағыңызды нақтылаңыз.",
|
||||
},
|
||||
"ready.to_help": {
|
||||
state.LanguageRU: "Спасибо. Теперь можете задать вопрос.",
|
||||
state.LanguageKK: "Рақмет. Енді сұрағыңызды қоя аласыз.",
|
||||
},
|
||||
"denied.language_required": {
|
||||
state.LanguageUnknown: "Сначала выберите язык обслуживания.",
|
||||
},
|
||||
"denied.region_required": {
|
||||
state.LanguageRU: "Сначала уточните регион.",
|
||||
state.LanguageKK: "Алдымен аймағыңызды нақтылаңыз.",
|
||||
},
|
||||
"denied.not_ready": {
|
||||
state.LanguageRU: "Пока нельзя выполнить это действие.",
|
||||
state.LanguageKK: "Бұл әрекетті әзірше орындауға болмайды.",
|
||||
},
|
||||
"handoff.started": {
|
||||
state.LanguageRU: "Запрос на перевод оператору принят.",
|
||||
state.LanguageKK: "Операторға қосу сұрауы қабылданды.",
|
||||
},
|
||||
"handoff.requested": {
|
||||
state.LanguageRU: "Запрос на оператора принят.",
|
||||
state.LanguageKK: "Операторға сұрау қабылданды.",
|
||||
},
|
||||
"handoff.stub": {
|
||||
state.LanguageUnknown: "Я могу зафиксировать запрос на оператора, но прямой перевод пока не подключен.\nОператорға сұрауды белгілей аламын, бірақ тікелей аудару әзірге қосылмаған.",
|
||||
state.LanguageRU: "Я могу зафиксировать запрос на оператора, но прямой перевод пока не подключен.",
|
||||
state.LanguageKK: "Операторға сұрауды белгілей аламын, бірақ тікелей аудару әзірге қосылмаған.",
|
||||
},
|
||||
"handoff.transfer_started": {
|
||||
state.LanguageRU: "Соединяю вас с оператором.",
|
||||
state.LanguageKK: "Сізді операторға қосып жатырмын.",
|
||||
},
|
||||
"handoff.transfer_failed": {
|
||||
state.LanguageRU: "Не удалось соединить с оператором. Попробуйте обратиться позже.",
|
||||
state.LanguageKK: "Операторға қосу мүмкін болмады. Кейінірек қайталап көріңіз.",
|
||||
},
|
||||
"handoff.not_configured": {
|
||||
state.LanguageRU: "Перевод на оператора сейчас не настроен.",
|
||||
state.LanguageKK: "Операторға аудару қазір бапталмаған.",
|
||||
},
|
||||
"handoff.already_requested": {
|
||||
state.LanguageRU: "Запрос на оператора уже зафиксирован.",
|
||||
state.LanguageKK: "Операторға сұрау тіркелді.",
|
||||
},
|
||||
"handoff.denied": {
|
||||
state.LanguageRU: "Сейчас перевод на оператора недоступен.",
|
||||
state.LanguageKK: "Қазір операторға аудару қолжетімсіз.",
|
||||
},
|
||||
"fallback.kb_unavailable": {
|
||||
state.LanguageRU: "База знаний временно недоступна. Могу предложить обратиться к оператору.",
|
||||
state.LanguageKK: "Білім базасы уақытша қолжетімсіз. Операторға жүгінуді ұсына аламын.",
|
||||
},
|
||||
"fallback.no_answer": {
|
||||
state.LanguageRU: "В базе знаний нет точной информации по этому вопросу. Могу предложить обратиться к оператору.",
|
||||
state.LanguageKK: "Бұл сұрақ бойынша білім базасында нақты ақпарат жоқ. Операторға жүгінуді ұсына аламын.",
|
||||
},
|
||||
"fallback.ai_error": {
|
||||
state.LanguageRU: "Возникла техническая ошибка AI-оператора. Могу предложить обратиться к оператору.",
|
||||
state.LanguageKK: "AI-операторда техникалық қате пайда болды. Операторға жүгінуді ұсына аламын.",
|
||||
},
|
||||
"fallback.media_error": {
|
||||
state.LanguageRU: "Возникла ошибка аудиосвязи. Могу предложить обратиться к оператору.",
|
||||
state.LanguageKK: "Аудио байланысында қате пайда болды. Операторға жүгінуді ұсына аламын.",
|
||||
},
|
||||
"fallback.timeout": {
|
||||
state.LanguageRU: "Время разговора истекло. Завершаю обращение.",
|
||||
state.LanguageKK: "Сөйлесу уақыты аяқталды. Өтінішті аяқтаймын.",
|
||||
},
|
||||
"fallback.tool_error": {
|
||||
state.LanguageRU: "Не удалось выполнить действие. Могу предложить обратиться к оператору.",
|
||||
state.LanguageKK: "Әрекетті орындау мүмкін болмады. Операторға жүгінуді ұсына аламын.",
|
||||
},
|
||||
"fallback.language_failures": {
|
||||
state.LanguageRU: "Не удалось выбрать язык. Могу предложить обратиться к оператору.",
|
||||
state.LanguageKK: "Тілді таңдау мүмкін болмады. Операторға жүгінуді ұсына аламын.",
|
||||
},
|
||||
"fallback.region_failures": {
|
||||
state.LanguageRU: "Не удалось определить регион. Могу предложить обратиться к оператору.",
|
||||
state.LanguageKK: "Аймақты анықтау мүмкін болмады. Операторға жүгінуді ұсына аламын.",
|
||||
},
|
||||
"closing.started": {
|
||||
state.LanguageRU: "Завершаю звонок.",
|
||||
state.LanguageKK: "Қоңырауды аяқтаймын.",
|
||||
},
|
||||
"error.invalid_language": {
|
||||
state.LanguageUnknown: "Поддерживаются только русский и қазақша.",
|
||||
},
|
||||
"error.invalid_region": {
|
||||
state.LanguageRU: "Регион не распознан.",
|
||||
state.LanguageKK: "Аймақ анықталмады.",
|
||||
},
|
||||
|
||||
"knowledge.no_answer": {
|
||||
state.LanguageRU: "В базе знаний нет точной информации по этому вопросу.",
|
||||
state.LanguageKK: "Бұл сұрақ бойынша білім базасында нақты ақпарат жоқ.",
|
||||
},
|
||||
"knowledge.unavailable": {
|
||||
state.LanguageRU: "База знаний временно недоступна.",
|
||||
state.LanguageKK: "Білім базасы уақытша қолжетімсіз.",
|
||||
},
|
||||
"knowledge.results_found": {
|
||||
state.LanguageRU: "Я нашел информацию в базе знаний.",
|
||||
state.LanguageKK: "Білім базасынан ақпарат таптым.",
|
||||
},
|
||||
"knowledge.query_too_short": {
|
||||
state.LanguageRU: "Уточните вопрос для поиска в базе знаний.",
|
||||
state.LanguageKK: "Білім базасынан іздеу үшін сұрақты нақтылаңыз.",
|
||||
},
|
||||
"knowledge.query_too_long": {
|
||||
state.LanguageRU: "Вопрос слишком длинный для поиска в базе знаний.",
|
||||
state.LanguageKK: "Сұрақ білім базасынан іздеу үшін тым ұзын.",
|
||||
},
|
||||
"knowledge.search_denied_language": {
|
||||
state.LanguageUnknown: "Сначала выберите язык обслуживания.",
|
||||
},
|
||||
"knowledge.search_denied_region": {
|
||||
state.LanguageRU: "Сначала уточните регион.",
|
||||
state.LanguageKK: "Алдымен аймағыңызды нақтылаңыз.",
|
||||
},
|
||||
"error.knowledge_not_implemented": {
|
||||
state.LanguageRU: "База знаний еще не подключена на этом этапе.",
|
||||
state.LanguageKK: "Бұл кезеңде білім базасы әлі қосылмаған.",
|
||||
},
|
||||
}
|
||||
|
||||
func Get(key string, lang state.Language) string {
|
||||
m, ok := catalog[key]
|
||||
if !ok {
|
||||
return key
|
||||
}
|
||||
if v := m[lang]; v != "" {
|
||||
return v
|
||||
}
|
||||
if v := m[state.LanguageUnknown]; v != "" {
|
||||
return v
|
||||
}
|
||||
if v := m[state.LanguageRU]; v != "" {
|
||||
return v
|
||||
}
|
||||
for _, v := range m {
|
||||
return v
|
||||
}
|
||||
return key
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package messages
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ai-operator/internal/dialogue/state"
|
||||
)
|
||||
|
||||
func TestCatalog(t *testing.T) {
|
||||
if got := Get("greeting.initial", state.LanguageUnknown); !strings.Contains(got, "Жанна") || !strings.Contains(got, "QazAimaqGas") || strings.Contains(strings.ToLower(got), "выберите язык") {
|
||||
t.Fatalf("missing natural greeting: %q", got)
|
||||
}
|
||||
if got := Get("greeting.initial", state.LanguageKK); !strings.Contains(got, "Жанна") || !strings.Contains(got, "QazAimaqGas") || !strings.Contains(got, "Қалай көмектесе аламын") {
|
||||
t.Fatalf("missing kk natural greeting: %q", got)
|
||||
}
|
||||
if got := Get("region.ask", state.LanguageRU); got == "" || got == "region.ask" {
|
||||
t.Fatal("missing ru region prompt")
|
||||
}
|
||||
if got := Get("region.ask", state.LanguageKK); got == "" || got == "region.ask" {
|
||||
t.Fatal("missing kk region prompt")
|
||||
}
|
||||
if got := Get("unknown.key", state.LanguageRU); got != "unknown.key" {
|
||||
t.Fatal("unknown fallback failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanguageSelectionMessages(t *testing.T) {
|
||||
keys := []string{"language.ask", "language.ask_clarify", "language.selected.ru", "language.selected.kk", "language.changed.ru", "language.changed.kk", "language.unsupported"}
|
||||
for _, key := range keys {
|
||||
if got := Get(key, state.LanguageRU); got == "" || got == key {
|
||||
t.Fatalf("missing %s ru", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package dialogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"ai-operator/internal/dialogue/language"
|
||||
"ai-operator/internal/dialogue/state"
|
||||
)
|
||||
|
||||
func inferLanguageFromText(text string) state.Language {
|
||||
n := language.Normalize(text)
|
||||
if n == "" {
|
||||
return state.LanguageRU
|
||||
}
|
||||
kkSignals := []string{"қ", "ә", "ө", "ү", "ұ", "ң", "ғ", "і", "һ", "сәлем", "қалай", "көмек", "құны", "мекенжай", "байланыс", "облыс", "қала", "қазақша"}
|
||||
for _, sig := range kkSignals {
|
||||
if strings.Contains(n, sig) {
|
||||
return state.LanguageKK
|
||||
}
|
||||
}
|
||||
return state.LanguageRU
|
||||
}
|
||||
|
||||
func isRegionRequiredQuery(query string) bool {
|
||||
n := language.Normalize(query)
|
||||
if n == "" {
|
||||
return false
|
||||
}
|
||||
phrases := []string{
|
||||
"филиал", "адрес", "контакт", "контакты", "где находится", "куда обратиться", "город", "область", "регион",
|
||||
"мекенжай", "байланыс", "қайда жүгіну", "қайда орналасқан", "қала", "облыс", "аймақ", "өңір",
|
||||
}
|
||||
for _, p := range phrases {
|
||||
if strings.Contains(n, language.Normalize(p)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package dialogue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ai-operator/internal/ai"
|
||||
"ai-operator/internal/call"
|
||||
"ai-operator/internal/dialogue/state"
|
||||
"ai-operator/internal/tools"
|
||||
)
|
||||
|
||||
func TestOrchestratorToolFlow(t *testing.T) {
|
||||
o := NewMemoryOrchestrator(nil)
|
||||
_, err := o.StartCall(context.Background(), call.CallSession{CallID: "c1", AsteriskChannelID: "c1", CallerNumber: "+77771234567", Route: "test", StartedAt: time.Now()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, ok := o.GetSession("c1")
|
||||
if !ok || s.State != state.StateReadyToHelp {
|
||||
t.Fatalf("state=%s ok=%t", s.State, ok)
|
||||
}
|
||||
denied := o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "t1", Name: tools.SearchKnowledgeBase})
|
||||
if denied.Error != "knowledge_base_unavailable" {
|
||||
t.Fatalf("expected knowledge_base_unavailable, got %+v", denied)
|
||||
}
|
||||
res := o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "t2", Name: tools.SetLanguage, Arguments: map[string]any{"language": "ru"}})
|
||||
if res.Error != "" {
|
||||
t.Fatalf("set language failed: %+v", res)
|
||||
}
|
||||
s, _ = o.GetSession("c1")
|
||||
if s.State != state.StateReadyToHelp {
|
||||
t.Fatalf("state=%s", s.State)
|
||||
}
|
||||
denied = o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "t3", Name: tools.SearchKnowledgeBase})
|
||||
if denied.Error != "knowledge_base_unavailable" {
|
||||
t.Fatalf("expected knowledge_base_unavailable, got %+v", denied)
|
||||
}
|
||||
res = o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "t4", Name: tools.SetRegion, Arguments: map[string]any{"region_code": "almaty_city"}})
|
||||
if res.Error != "" {
|
||||
t.Fatalf("set region failed: %+v", res)
|
||||
}
|
||||
s, _ = o.GetSession("c1")
|
||||
if s.State != state.StateReadyToHelp {
|
||||
t.Fatalf("state=%s", s.State)
|
||||
}
|
||||
res = o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "t5", Name: tools.SearchKnowledgeBase})
|
||||
if res.Error != "knowledge_base_unavailable" {
|
||||
t.Fatalf("expected knowledge_base_unavailable, got %+v", res)
|
||||
}
|
||||
res = o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "t6", Name: tools.RequestHumanHandoff})
|
||||
if res.Error != "" {
|
||||
t.Fatalf("handoff failed: %+v", res)
|
||||
}
|
||||
s, _ = o.GetSession("c1")
|
||||
if s.State != state.StateHandoff {
|
||||
t.Fatalf("state=%s", s.State)
|
||||
}
|
||||
res = o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "t7", Name: tools.EndCall})
|
||||
if res.Error != "" {
|
||||
t.Fatalf("end failed: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictLanguageAndRegion(t *testing.T) {
|
||||
o := NewMemoryOrchestrator(nil)
|
||||
_, _ = o.StartCall(context.Background(), call.CallSession{CallID: "c1", AsteriskChannelID: "c1"})
|
||||
if res := o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "x", Name: tools.SetLanguage, Arguments: map[string]any{"language": "en"}}); res.Error != "invalid_language" {
|
||||
t.Fatalf("expected invalid language, got %+v", res)
|
||||
}
|
||||
if res := o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "x", Name: tools.SetRegion, Arguments: map[string]any{"region_code": "almaty_city"}}); res.Error != "" {
|
||||
t.Fatalf("expected region to be accepted before explicit language, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndCallCleanupAndConcurrency(t *testing.T) {
|
||||
o := NewMemoryOrchestrator(nil)
|
||||
for i := 0; i < 10; i++ {
|
||||
_, _ = o.StartCall(context.Background(), call.CallSession{CallID: string(rune('a' + i)), AsteriskChannelID: "x"})
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
id := string(rune('a' + i))
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = o.GetSession(id)
|
||||
_ = o.EndCall(context.Background(), id, "test")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if o.Count() != 0 {
|
||||
t.Fatalf("count=%d", o.Count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUserTextLanguageSelection(t *testing.T) {
|
||||
o := NewMemoryOrchestrator(nil)
|
||||
_, _ = o.StartCall(context.Background(), call.CallSession{CallID: "l1", AsteriskChannelID: "l1"})
|
||||
res, err := o.HandleUserText(context.Background(), "l1", "русский")
|
||||
if err != nil || !res.Applied || res.Language != state.LanguageRU || res.State != state.StateReadyToHelp {
|
||||
t.Fatalf("ru selection: res=%+v err=%v", res, err)
|
||||
}
|
||||
o = NewMemoryOrchestrator(nil)
|
||||
_, _ = o.StartCall(context.Background(), call.CallSession{CallID: "l2", AsteriskChannelID: "l2"})
|
||||
res, err = o.HandleUserText(context.Background(), "l2", "қазақша")
|
||||
if err != nil || !res.Applied || res.Language != state.LanguageKK || res.State != state.StateReadyToHelp {
|
||||
t.Fatalf("kk selection: res=%+v err=%v", res, err)
|
||||
}
|
||||
o = NewMemoryOrchestrator(nil)
|
||||
_, _ = o.StartCall(context.Background(), call.CallSession{CallID: "l3", AsteriskChannelID: "l3"})
|
||||
res, _ = o.HandleUserText(context.Background(), "l3", "какой у меня тариф?")
|
||||
if res.Applied || res.State != state.StateReadyToHelp || res.Language != state.LanguageRU {
|
||||
t.Fatalf("business should auto-detect ru without IVR: %+v", res)
|
||||
}
|
||||
res, _ = o.HandleUserText(context.Background(), "l3", "русский или қазақша?")
|
||||
if res.Applied || res.State != state.StateReadyToHelp {
|
||||
t.Fatalf("ambiguous: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetLanguageNaturalVariantsAndTranscript(t *testing.T) {
|
||||
o := NewMemoryOrchestrator(nil)
|
||||
_, _ = o.StartCall(context.Background(), call.CallSession{CallID: "v1", AsteriskChannelID: "v1"})
|
||||
for _, value := range []string{"русский", "russian", "қазақша", "kazakh"} {
|
||||
o2 := NewMemoryOrchestrator(nil)
|
||||
_, _ = o2.StartCall(context.Background(), call.CallSession{CallID: value, AsteriskChannelID: value})
|
||||
res := o2.HandleToolCall(context.Background(), value, ai.ToolCall{ID: "t", Name: tools.SetLanguage, Arguments: map[string]any{"language": value}})
|
||||
if res.Error != "" {
|
||||
t.Fatalf("%s failed: %+v", value, res)
|
||||
}
|
||||
}
|
||||
bad := o.HandleToolCall(context.Background(), "v1", ai.ToolCall{ID: "bad", Name: tools.SetLanguage, Arguments: map[string]any{"language": "english"}})
|
||||
if bad.Error != "invalid_language" {
|
||||
t.Fatalf("expected invalid_language, got %+v", bad)
|
||||
}
|
||||
o = NewMemoryOrchestrator(nil)
|
||||
_, _ = o.StartCall(context.Background(), call.CallSession{CallID: "tr", AsteriskChannelID: "tr"})
|
||||
if err := o.HandleVoiceEvent(context.Background(), "tr", ai.VoiceEvent{Type: ai.VoiceEventUserTranscriptDone, Text: "русский"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _ := o.GetSession("tr")
|
||||
if s.Language != state.LanguageRU || s.State != state.StateReadyToHelp {
|
||||
t.Fatalf("transcript not applied: %+v", s)
|
||||
}
|
||||
if err := o.HandleVoiceEvent(context.Background(), "tr", ai.VoiceEvent{Type: ai.VoiceEventAssistantTranscriptDelta, Text: "қазақша"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _ = o.GetSession("tr")
|
||||
if s.Language != state.LanguageRU {
|
||||
t.Fatal("assistant transcript changed language")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanguageChangeAfterReadyPreservesRegion(t *testing.T) {
|
||||
o := NewMemoryOrchestrator(nil)
|
||||
_, _ = o.StartCall(context.Background(), call.CallSession{CallID: "r", AsteriskChannelID: "r"})
|
||||
_, _ = o.HandleUserText(context.Background(), "r", "русский")
|
||||
_ = o.HandleToolCall(context.Background(), "r", ai.ToolCall{ID: "reg", Name: tools.SetRegion, Arguments: map[string]any{"region_code": "almaty_city"}})
|
||||
res, _ := o.HandleUserText(context.Background(), "r", "перейдите на казахский")
|
||||
if !res.Applied || res.Language != state.LanguageKK || res.RegionCode != "almaty_city" || res.State != state.StateReadyToHelp {
|
||||
t.Fatalf("switch failed: %+v", res)
|
||||
}
|
||||
res, _ = o.HandleUserText(context.Background(), "r", "русский клиент спрашивает про тариф")
|
||||
if res.Applied || res.Language != state.LanguageKK {
|
||||
t.Fatalf("accidental switch: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUserTextRegionSelection(t *testing.T) {
|
||||
o := NewMemoryOrchestrator(nil)
|
||||
_, _ = o.StartCall(context.Background(), call.CallSession{CallID: "reg1", AsteriskChannelID: "reg1"})
|
||||
res, _ := o.HandleUserText(context.Background(), "reg1", "Астана")
|
||||
if !res.Applied || res.State != state.StateReadyToHelp || res.RegionCode != "astana_city" {
|
||||
t.Fatalf("region should be usable without explicit language: %+v", res)
|
||||
}
|
||||
res, _ = o.HandleUserText(context.Background(), "reg1", "русский")
|
||||
if res.State != state.StateReadyToHelp {
|
||||
t.Fatalf("language not selected: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlmatyPendingClarification(t *testing.T) {
|
||||
o := NewMemoryOrchestrator(nil)
|
||||
_, _ = o.StartCall(context.Background(), call.CallSession{CallID: "alm", AsteriskChannelID: "alm"})
|
||||
_, _ = o.HandleUserText(context.Background(), "alm", "русский")
|
||||
res, _ := o.HandleUserText(context.Background(), "alm", "Алматы")
|
||||
if res.Applied || !res.NeedsClarification || res.State != state.StateReadyToHelp || res.MessageKey != "region.almaty_clarify" {
|
||||
t.Fatalf("expected almaty clarification: %+v", res)
|
||||
}
|
||||
s, _ := o.GetSession("alm")
|
||||
if s.Region.Status != state.RegionPendingClarification || s.Metadata[state.PendingRegionCandidatesMetadataKey] == "" {
|
||||
t.Fatalf("pending not stored: %+v", s)
|
||||
}
|
||||
res, _ = o.HandleUserText(context.Background(), "alm", "область")
|
||||
if !res.Applied || res.RegionCode != "almaty_region" || res.State != state.StateReadyToHelp {
|
||||
t.Fatalf("oblast clarification failed: %+v", res)
|
||||
}
|
||||
kb := o.HandleToolCall(context.Background(), "alm", ai.ToolCall{ID: "kb", Name: tools.SearchKnowledgeBase})
|
||||
if kb.Error != "knowledge_base_unavailable" {
|
||||
t.Fatalf("search after region should reach kb unavailable without service: %+v", kb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRegionNaturalVariantsAndDisabled(t *testing.T) {
|
||||
o := NewMemoryOrchestrator(nil)
|
||||
_, _ = o.StartCall(context.Background(), call.CallSession{CallID: "tool-region", AsteriskChannelID: "tool-region"})
|
||||
_, _ = o.HandleUserText(context.Background(), "tool-region", "русский")
|
||||
res := o.HandleToolCall(context.Background(), "tool-region", ai.ToolCall{ID: "r", Name: tools.SetRegion, Arguments: map[string]any{"region": "Шымкент"}})
|
||||
if res.Error != "" {
|
||||
t.Fatalf("natural set_region failed: %+v", res)
|
||||
}
|
||||
s, _ := o.GetSession("tool-region")
|
||||
if s.Region.Code != "shymkent_city" || s.State != state.StateReadyToHelp {
|
||||
t.Fatalf("bad region session: %+v", s)
|
||||
}
|
||||
o = NewMemoryOrchestrator(nil)
|
||||
_, _ = o.StartCall(context.Background(), call.CallSession{CallID: "disabled-region", AsteriskChannelID: "disabled-region"})
|
||||
_, _ = o.HandleUserText(context.Background(), "disabled-region", "русский")
|
||||
res = o.HandleToolCall(context.Background(), "disabled-region", ai.ToolCall{ID: "b", Name: tools.SetRegion, Arguments: map[string]any{"region": "Байконур"}})
|
||||
if res.Error != "disabled_region" {
|
||||
t.Fatalf("expected disabled_region, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegionChangeAfterReadyPreservesLanguage(t *testing.T) {
|
||||
o := NewMemoryOrchestrator(nil)
|
||||
_, _ = o.StartCall(context.Background(), call.CallSession{CallID: "rch", AsteriskChannelID: "rch"})
|
||||
_, _ = o.HandleUserText(context.Background(), "rch", "русский")
|
||||
_, _ = o.HandleUserText(context.Background(), "rch", "Астана")
|
||||
res, _ := o.HandleUserText(context.Background(), "rch", "сменить регион на Шымкент")
|
||||
if !res.Applied || res.RegionCode != "shymkent_city" || res.Language != state.LanguageRU || res.State != state.StateReadyToHelp {
|
||||
t.Fatalf("switch region failed: %+v", res)
|
||||
}
|
||||
res, _ = o.HandleUserText(context.Background(), "rch", "Алматы тарифы")
|
||||
if res.Applied || res.RegionCode != "shymkent_city" {
|
||||
t.Fatalf("accidental region switch: %+v", res)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"ai-operator/internal/dialogue/state"
|
||||
"ai-operator/internal/tools"
|
||||
)
|
||||
|
||||
type Decision struct {
|
||||
Allowed bool
|
||||
ToolName string
|
||||
State state.ConversationState
|
||||
ReasonCode string
|
||||
UserMessageKey string
|
||||
RequiredNextAction string
|
||||
}
|
||||
|
||||
func AuthorizeTool(session state.ConversationSession, toolName string) Decision {
|
||||
d := Decision{ToolName: toolName, State: session.State, ReasonCode: "ok"}
|
||||
if !tools.Known(toolName) {
|
||||
return deny(d, "unknown_tool", "denied.not_ready", "none")
|
||||
}
|
||||
if session.State == state.StateEnded {
|
||||
return deny(d, "call_ended", "closing.started", "none")
|
||||
}
|
||||
if toolName == tools.SearchKnowledgeBase {
|
||||
return authorizeSearch(session, d)
|
||||
}
|
||||
allowed := allowedByState(session.State, toolName)
|
||||
if !allowed {
|
||||
return deny(d, "tool_not_allowed_in_state", "denied.not_ready", nextAction(session))
|
||||
}
|
||||
d.Allowed = true
|
||||
return d
|
||||
}
|
||||
|
||||
func authorizeSearch(session state.ConversationSession, d Decision) Decision {
|
||||
if session.State == state.StateEnded || session.State == state.StateClosing || session.State == state.StateHandoff {
|
||||
return deny(d, "state_not_ready", "denied.not_ready", nextAction(session))
|
||||
}
|
||||
d.Allowed = true
|
||||
return d
|
||||
}
|
||||
|
||||
func allowedByState(s state.ConversationState, tool string) bool {
|
||||
switch s {
|
||||
case state.StateLanguageSelection:
|
||||
return tool == tools.SearchKnowledgeBase || tool == tools.SetLanguage || tool == tools.SetRegion || tool == tools.RequestHumanHandoff || tool == tools.EndCall
|
||||
case state.StateRegionSelection:
|
||||
return tool == tools.SearchKnowledgeBase || tool == tools.SetRegion || tool == tools.SetLanguage || tool == tools.RequestHumanHandoff || tool == tools.EndCall
|
||||
case state.StateReadyToHelp:
|
||||
return tool == tools.SearchKnowledgeBase || tool == tools.SetLanguage || tool == tools.SetRegion || tool == tools.RequestHumanHandoff || tool == tools.EndCall
|
||||
case state.StateQuestionAnswering:
|
||||
return tool == tools.SearchKnowledgeBase || tool == tools.SetLanguage || tool == tools.SetRegion || tool == tools.RequestHumanHandoff || tool == tools.EndCall
|
||||
case state.StateHandoff:
|
||||
return tool == tools.RequestHumanHandoff || tool == tools.EndCall
|
||||
case state.StateClosing:
|
||||
return tool == tools.EndCall
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func deny(d Decision, reason, key, action string) Decision {
|
||||
d.Allowed = false
|
||||
d.ReasonCode = reason
|
||||
d.UserMessageKey = key
|
||||
d.RequiredNextAction = action
|
||||
return d
|
||||
}
|
||||
|
||||
func nextAction(session state.ConversationSession) string {
|
||||
if session.Language != state.LanguageRU && session.Language != state.LanguageKK {
|
||||
return "infer_language"
|
||||
}
|
||||
if session.Region.Status != state.RegionSelected || session.Region.Code == "" {
|
||||
return "use_global_or_ask_region_if_needed"
|
||||
}
|
||||
return "wait_for_question"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"ai-operator/internal/dialogue/state"
|
||||
"ai-operator/internal/tools"
|
||||
)
|
||||
|
||||
func TestToolPolicyAndGuardrail(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
session state.ConversationSession
|
||||
tool string
|
||||
allowed bool
|
||||
reason string
|
||||
}{
|
||||
{"search call started", state.ConversationSession{State: state.StateCallStarted}, tools.SearchKnowledgeBase, true, "ok"},
|
||||
{"search language selection", state.ConversationSession{State: state.StateLanguageSelection}, tools.SearchKnowledgeBase, true, "ok"},
|
||||
{"set language allowed", state.ConversationSession{State: state.StateLanguageSelection}, tools.SetLanguage, true, "ok"},
|
||||
{"search region selection", state.ConversationSession{State: state.StateRegionSelection, Language: state.LanguageRU}, tools.SearchKnowledgeBase, true, "ok"},
|
||||
{"set region allowed", state.ConversationSession{State: state.StateRegionSelection, Language: state.LanguageRU}, tools.SetRegion, true, "ok"},
|
||||
{"search ready", state.ConversationSession{State: state.StateReadyToHelp, Language: state.LanguageRU, Region: state.RegionSelection{Code: "global", Status: state.RegionSelected}}, tools.SearchKnowledgeBase, true, "ok"},
|
||||
{"search ended", state.ConversationSession{State: state.StateEnded, Language: state.LanguageRU, Region: state.RegionSelection{Code: "global", Status: state.RegionSelected}}, tools.SearchKnowledgeBase, false, "call_ended"},
|
||||
{"unknown", state.ConversationSession{State: state.StateReadyToHelp}, "bad_tool", false, "unknown_tool"},
|
||||
{"handoff non-ended", state.ConversationSession{State: state.StateRegionSelection}, tools.RequestHumanHandoff, true, "ok"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := AuthorizeTool(tc.session, tc.tool)
|
||||
if got.Allowed != tc.allowed || got.ReasonCode != tc.reason {
|
||||
t.Fatalf("decision=%+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHardGuardrail(t *testing.T) {
|
||||
base := state.ConversationSession{State: state.StateReadyToHelp}
|
||||
if !AuthorizeTool(base, tools.SearchKnowledgeBase).Allowed {
|
||||
t.Fatal("search denied without explicit language")
|
||||
}
|
||||
base.Language = state.LanguageRU
|
||||
if !AuthorizeTool(base, tools.SearchKnowledgeBase).Allowed {
|
||||
t.Fatal("search denied without region")
|
||||
}
|
||||
base.Region = state.RegionSelection{Code: "almaty_city", Status: state.RegionSelected}
|
||||
if !AuthorizeTool(base, tools.SearchKnowledgeBase).Allowed {
|
||||
t.Fatal("search denied after language and region")
|
||||
}
|
||||
base.State = state.StateQuestionAnswering
|
||||
if !AuthorizeTool(base, tools.SearchKnowledgeBase).Allowed {
|
||||
t.Fatal("search denied in question answering")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-operator/internal/dialogue/state"
|
||||
)
|
||||
|
||||
type PromptContext struct {
|
||||
State state.ConversationState
|
||||
Language state.Language
|
||||
RegionCode string
|
||||
RegionDisplayName string
|
||||
}
|
||||
|
||||
func BuildSystemPromptFragment(ctx PromptContext) string {
|
||||
base := fmt.Sprintf("Conversation state: %s.\nSelected language: %s.\nSelected region: %s.\nSelected region display: %s.\n", ctx.State, valueOrUnknown(string(ctx.Language)), valueOrUnknown(ctx.RegionCode), valueOrUnknown(ctx.RegionDisplayName))
|
||||
natural := "Assistant is Жанна (Zhanna), AI operator of QazAimaqGas. Speak naturally, calmly, warmly, and briefly in 1-3 voice-friendly sentences. Do not sound robotic. Avoid bureaucratic wording. Use conversational Russian or simple clear Kazakh. Do not repeat that you are an AI operator in every answer. Do not use IVR-style language/region selection. Infer language from speech. Ask region only when a regional answer is needed. General questions may use global KB without selected region. Answer only from KB and do not invent. "
|
||||
switch ctx.State {
|
||||
case state.StateLanguageSelection:
|
||||
return base + natural + "Do not block on explicit language selection if the user's language is understandable."
|
||||
case state.StateRegionSelection:
|
||||
return base + natural + "Ask for city or oblast naturally only because a regional answer is needed. If the user says Almaty, ask whether they mean Almaty city or Almaty region."
|
||||
case state.StateReadyToHelp:
|
||||
return base + natural + "For regional branch/address/contact questions, ask region if missing. Do not reveal internal chunk IDs. Do not mention embeddings, vector search, SQL, or database internals."
|
||||
case state.StateQuestionAnswering:
|
||||
return base + natural + "Continue question answering only through search_knowledge_base and approved tools."
|
||||
default:
|
||||
return base + natural
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrUnknown(v string) string {
|
||||
if v == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ai-operator/internal/dialogue/state"
|
||||
)
|
||||
|
||||
func TestPromptBuilder(t *testing.T) {
|
||||
p := BuildSystemPromptFragment(PromptContext{State: state.StateLanguageSelection})
|
||||
if !strings.Contains(p, "Zhanna") || !strings.Contains(p, "Do not use IVR-style") {
|
||||
t.Fatalf("bad language prompt: %s", p)
|
||||
}
|
||||
p = BuildSystemPromptFragment(PromptContext{State: state.StateRegionSelection})
|
||||
if !strings.Contains(p, "Ask for city or oblast naturally") {
|
||||
t.Fatalf("bad region prompt: %s", p)
|
||||
}
|
||||
p = BuildSystemPromptFragment(PromptContext{State: state.StateReadyToHelp, Language: state.LanguageRU, RegionCode: "almaty_city"})
|
||||
if !strings.Contains(p, "almaty_city") || strings.Contains(p, "SECRET") {
|
||||
t.Fatalf("bad ready prompt: %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanguageSelectionPromptGuardrails(t *testing.T) {
|
||||
p := BuildSystemPromptFragment(PromptContext{State: state.StateLanguageSelection})
|
||||
for _, want := range []string{"Zhanna", "QazAimaqGas", "Infer language from speech", "General questions may use global KB"} {
|
||||
if !strings.Contains(p, want) {
|
||||
t.Fatalf("prompt missing %q: %s", want, p)
|
||||
}
|
||||
}
|
||||
p = BuildSystemPromptFragment(PromptContext{State: state.StateReadyToHelp, Language: state.LanguageRU, RegionCode: "almaty_city"})
|
||||
if !strings.Contains(p, "For regional branch/address/contact questions") {
|
||||
t.Fatalf("ready prompt missing regional guardrail: %s", p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package region
|
||||
|
||||
func DefaultCatalog() []Region {
|
||||
return []Region{
|
||||
{Code: "astana_city", Type: RegionTypeRepublicCity, NameRU: "Астана", NameKK: "Астана", DisplayNameRU: "Астана", DisplayNameKK: "Астана", Enabled: true, AliasesRU: []string{"астана", "город астана", "г астана"}, AliasesKK: []string{"астана қаласы"}, AliasesLatin: []string{"astana"}, LegacyAliases: []string{"nur sultan", "нурсултан", "нур султан", "нұр сұлтан"}},
|
||||
{Code: "almaty_city", Type: RegionTypeRepublicCity, NameRU: "Алматы", NameKK: "Алматы", DisplayNameRU: "город Алматы", DisplayNameKK: "Алматы қаласы", AmbiguityGroup: "almaty", Enabled: true, AliasesRU: []string{"город алматы", "г алматы"}, AliasesKK: []string{"алматы қаласы"}, AliasesLatin: []string{"almaty city"}, LegacyAliases: []string{"alma ata", "алмата", "алма ата"}},
|
||||
{Code: "shymkent_city", Type: RegionTypeRepublicCity, NameRU: "Шымкент", NameKK: "Шымкент", DisplayNameRU: "Шымкент", DisplayNameKK: "Шымкент", Enabled: true, AliasesRU: []string{"шымкент", "шимкент", "город шымкент", "г шымкент"}, AliasesKK: []string{"шымкент қаласы"}, AliasesLatin: []string{"shymkent", "chimkent"}},
|
||||
{Code: "akmola_region", Type: RegionTypeOblast, NameRU: "Акмолинская область", NameKK: "Ақмола облысы", DisplayNameRU: "Акмолинская область", DisplayNameKK: "Ақмола облысы", Enabled: true, AliasesRU: []string{"акмолинская область", "акмолинская", "акмола"}, AliasesKK: []string{"ақмола облысы", "ақмола"}, AliasesLatin: []string{"akmola"}},
|
||||
{Code: "aktobe_region", Type: RegionTypeOblast, NameRU: "Актюбинская область", NameKK: "Ақтөбе облысы", DisplayNameRU: "Актюбинская область", DisplayNameKK: "Ақтөбе облысы", Enabled: true, AliasesRU: []string{"актюбинская область", "актюбинская", "актобе", "актюбинск"}, AliasesKK: []string{"ақтөбе облысы", "ақтөбе"}, AliasesLatin: []string{"aktobe", "aktoebe"}},
|
||||
{Code: "almaty_region", Type: RegionTypeOblast, NameRU: "Алматинская область", NameKK: "Алматы облысы", DisplayNameRU: "Алматинская область", DisplayNameKK: "Алматы облысы", AmbiguityGroup: "almaty", Enabled: true, AliasesRU: []string{"алматинская область", "алматинская обл", "алматинская", "область алматы", "алматы обл"}, AliasesKK: []string{"алматы облысы", "алматы обл", "алматы облыс"}, AliasesLatin: []string{"almaty region", "almaty oblast"}},
|
||||
{Code: "atyrau_region", Type: RegionTypeOblast, NameRU: "Атырауская область", NameKK: "Атырау облысы", DisplayNameRU: "Атырауская область", DisplayNameKK: "Атырау облысы", Enabled: true, AliasesRU: []string{"атырауская область", "атырауская", "атырау"}, AliasesKK: []string{"атырау облысы"}, AliasesLatin: []string{"atyrau"}},
|
||||
{Code: "east_kazakhstan_region", Type: RegionTypeOblast, NameRU: "Восточно-Казахстанская область", NameKK: "Шығыс Қазақстан облысы", DisplayNameRU: "Восточно-Казахстанская область", DisplayNameKK: "Шығыс Қазақстан облысы", Enabled: true, AliasesRU: []string{"восточно казахстанская область", "вко"}, AliasesKK: []string{"шығыс қазақстан облысы", "шығыс қазақстан"}, AliasesLatin: []string{"east kazakhstan", "vko"}},
|
||||
{Code: "zhambyl_region", Type: RegionTypeOblast, NameRU: "Жамбылская область", NameKK: "Жамбыл облысы", DisplayNameRU: "Жамбылская область", DisplayNameKK: "Жамбыл облысы", Enabled: true, AliasesRU: []string{"жамбылская область", "жамбылская", "жамбыл", "тараз"}, AliasesKK: []string{"жамбыл облысы"}, AliasesLatin: []string{"zhambyl", "jambyl"}},
|
||||
{Code: "west_kazakhstan_region", Type: RegionTypeOblast, NameRU: "Западно-Казахстанская область", NameKK: "Батыс Қазақстан облысы", DisplayNameRU: "Западно-Казахстанская область", DisplayNameKK: "Батыс Қазақстан облысы", Enabled: true, AliasesRU: []string{"западно казахстанская область", "зко"}, AliasesKK: []string{"батыс қазақстан облысы", "батыс қазақстан"}, AliasesLatin: []string{"west kazakhstan", "zko"}},
|
||||
{Code: "karaganda_region", Type: RegionTypeOblast, NameRU: "Карагандинская область", NameKK: "Қарағанды облысы", DisplayNameRU: "Карагандинская область", DisplayNameKK: "Қарағанды облысы", Enabled: true, AliasesRU: []string{"карагандинская область", "карагандинская", "караганда"}, AliasesKK: []string{"қарағанды облысы", "қарағанды"}, AliasesLatin: []string{"karaganda", "qaragandy"}},
|
||||
{Code: "kostanay_region", Type: RegionTypeOblast, NameRU: "Костанайская область", NameKK: "Қостанай облысы", DisplayNameRU: "Костанайская область", DisplayNameKK: "Қостанай облысы", Enabled: true, AliasesRU: []string{"костанайская область", "костанайская", "костанай"}, AliasesKK: []string{"қостанай облысы", "қостанай"}, AliasesLatin: []string{"kostanay", "qostanay"}},
|
||||
{Code: "kyzylorda_region", Type: RegionTypeOblast, NameRU: "Кызылординская область", NameKK: "Қызылорда облысы", DisplayNameRU: "Кызылординская область", DisplayNameKK: "Қызылорда облысы", Enabled: true, AliasesRU: []string{"кызылординская область", "кызылординская", "кызылорда"}, AliasesKK: []string{"қызылорда облысы", "қызылорда"}, AliasesLatin: []string{"kyzylorda", "qyzylorda"}},
|
||||
{Code: "mangystau_region", Type: RegionTypeOblast, NameRU: "Мангистауская область", NameKK: "Маңғыстау облысы", DisplayNameRU: "Мангистауская область", DisplayNameKK: "Маңғыстау облысы", Enabled: true, AliasesRU: []string{"мангистауская область", "мангистауская", "мангыстау", "мангистау"}, AliasesKK: []string{"маңғыстау облысы", "маңғыстау"}, AliasesLatin: []string{"mangystau"}},
|
||||
{Code: "pavlodar_region", Type: RegionTypeOblast, NameRU: "Павлодарская область", NameKK: "Павлодар облысы", DisplayNameRU: "Павлодарская область", DisplayNameKK: "Павлодар облысы", Enabled: true, AliasesRU: []string{"павлодарская область", "павлодарская", "павлодар"}, AliasesKK: []string{"павлодар облысы"}, AliasesLatin: []string{"pavlodar"}},
|
||||
{Code: "north_kazakhstan_region", Type: RegionTypeOblast, NameRU: "Северо-Казахстанская область", NameKK: "Солтүстік Қазақстан облысы", DisplayNameRU: "Северо-Казахстанская область", DisplayNameKK: "Солтүстік Қазақстан облысы", Enabled: true, AliasesRU: []string{"северо казахстанская область", "ско"}, AliasesKK: []string{"солтүстік қазақстан облысы", "солтүстік қазақстан"}, AliasesLatin: []string{"north kazakhstan", "sko"}},
|
||||
{Code: "turkistan_region", Type: RegionTypeOblast, NameRU: "Туркестанская область", NameKK: "Түркістан облысы", DisplayNameRU: "Туркестанская область", DisplayNameKK: "Түркістан облысы", Enabled: true, AliasesRU: []string{"туркестанская область", "туркестанская", "туркестан"}, AliasesKK: []string{"түркістан облысы", "түркістан"}, AliasesLatin: []string{"turkistan", "turkestan"}},
|
||||
{Code: "abai_region", Type: RegionTypeOblast, NameRU: "область Абай", NameKK: "Абай облысы", DisplayNameRU: "область Абай", DisplayNameKK: "Абай облысы", Enabled: true, AliasesRU: []string{"область абай", "абайская область", "абай", "семей"}, AliasesKK: []string{"абай облысы"}, AliasesLatin: []string{"semey"}},
|
||||
{Code: "jetisu_region", Type: RegionTypeOblast, NameRU: "область Жетісу", NameKK: "Жетісу облысы", DisplayNameRU: "область Жетісу", DisplayNameKK: "Жетісу облысы", Enabled: true, AliasesRU: []string{"область жетісу", "жетысуская область", "жетісу", "жетысу"}, AliasesKK: []string{"жетісу облысы"}, AliasesLatin: []string{"zhetysu", "jetisu"}},
|
||||
{Code: "ulytau_region", Type: RegionTypeOblast, NameRU: "область Ұлытау", NameKK: "Ұлытау облысы", DisplayNameRU: "область Ұлытау", DisplayNameKK: "Ұлытау облысы", Enabled: true, AliasesRU: []string{"область улытау", "область ұлытау", "улытауская область", "улытау", "жезказган"}, AliasesKK: []string{"ұлытау облысы", "ұлытау", "жезқазған"}, AliasesLatin: []string{"ulytau", "ulutau"}},
|
||||
{Code: "baikonur_special", Type: RegionTypeSpecial, NameRU: "Байконур", NameKK: "Байқоңыр", DisplayNameRU: "Байконур", DisplayNameKK: "Байқоңыр", Enabled: false, AliasesRU: []string{"байконур"}, AliasesKK: []string{"байқоңыр"}, AliasesLatin: []string{"baikonur"}},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package region
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var spaceRE = regexp.MustCompile(`\s+`)
|
||||
var edgePunctRE = regexp.MustCompile(`^[\s\.,!\?;:"'«»\(\)\[\]\{\}]+|[\s\.,!\?;:"'«»\(\)\[\]\{\}]+$`)
|
||||
|
||||
func Normalize(input string) string {
|
||||
s := strings.TrimSpace(strings.ToLower(input))
|
||||
s = strings.ReplaceAll(s, "ё", "е")
|
||||
s = strings.ReplaceAll(s, "-", " ")
|
||||
s = strings.ReplaceAll(s, "г.", "город ")
|
||||
s = strings.ReplaceAll(s, "обл.", "обл")
|
||||
s = edgePunctRE.ReplaceAllString(s, "")
|
||||
s = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '.', ',', '!', '?', ';', ':', '"', '\'', '«', '»', '(', ')', '[', ']', '{', '}':
|
||||
return ' '
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, s)
|
||||
return spaceRE.ReplaceAllString(strings.TrimSpace(s), " ")
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package region
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"ai-operator/internal/dialogue/state"
|
||||
)
|
||||
|
||||
func ShouldApplyRegionResolution(ctx SelectionContext, result ResolutionResult) RegionDecision {
|
||||
d := RegionDecision{RegionCode: result.RegionCode, Candidates: result.Candidates, ReasonCode: result.ReasonCode}
|
||||
if ctx.CurrentState == state.StateEnded || ctx.CurrentState == state.StateClosing || ctx.CurrentState == state.StateHandoff {
|
||||
d.ReasonCode = "call_ended"
|
||||
d.MessageKey = "closing.started"
|
||||
return d
|
||||
}
|
||||
if result.Intent == IntentUnsupported || result.ReasonCode == "disabled_region" {
|
||||
d.ReasonCode = result.ReasonCode
|
||||
d.MessageKey = "region.unsupported"
|
||||
return d
|
||||
}
|
||||
if result.NeedsClarification || result.Intent == IntentAmbiguous {
|
||||
d.NeedsClarification = true
|
||||
d.MessageKey = nonEmpty(result.ClarificationMessageKey, "region.ask_clarify")
|
||||
return d
|
||||
}
|
||||
if result.RegionCode == "" || result.Region == nil {
|
||||
d.MessageKey = "region.not_understood"
|
||||
return d
|
||||
}
|
||||
switch ctx.CurrentState {
|
||||
case state.StateLanguageSelection:
|
||||
d.ReasonCode = "language_required"
|
||||
d.MessageKey = "language.ask"
|
||||
return d
|
||||
case state.StateRegionSelection:
|
||||
if result.Confidence >= MediumConfidence && result.Region.Enabled {
|
||||
d.Apply = true
|
||||
d.MessageKey = "region.selected"
|
||||
}
|
||||
return d
|
||||
case state.StateReadyToHelp, state.StateQuestionAnswering:
|
||||
if ctx.CurrentRegionCode == "" && result.Confidence >= MediumConfidence && result.Region.Enabled {
|
||||
d.Apply = true
|
||||
d.MessageKey = "region.selected"
|
||||
return d
|
||||
}
|
||||
if ctx.AllowChange && result.Intent == IntentRegionChange && result.Confidence >= HighConfidence && result.Region.Enabled {
|
||||
d.Apply = true
|
||||
d.MessageKey = "region.changed"
|
||||
return d
|
||||
}
|
||||
d.ReasonCode = "state_not_ready"
|
||||
d.MessageKey = "ready.to_help"
|
||||
return d
|
||||
default:
|
||||
d.ReasonCode = "state_not_ready"
|
||||
d.MessageKey = "region.ask"
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
func IsExplicitChangeRequest(input string) bool {
|
||||
n := Normalize(input)
|
||||
phrases := []string{
|
||||
"сменить регион", "поменять регион", "изменить регион", "другой регион", "я из другого региона", "мой регион", "регион ", "я в ", "я из ", "выберите регион", "укажите регион",
|
||||
"аймақты ауыстыру", "өңірді өзгерту", "басқа өңір", "менің өңірім", "өңірім", "аймағым", "мен астанадамын", "мен алматыдамын", "облысынанмын",
|
||||
"change region", "switch region", "my region is", "i am in", "i am from",
|
||||
}
|
||||
for _, p := range phrases {
|
||||
if strings.Contains(n, Normalize(p)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nonEmpty(v, fallback string) string {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package region
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"ai-operator/internal/dialogue/state"
|
||||
)
|
||||
|
||||
type Resolver struct {
|
||||
catalog []Region
|
||||
byCode map[string]Region
|
||||
aliases map[string][]Candidate
|
||||
}
|
||||
|
||||
func NewResolver(catalog []Region) (*Resolver, error) {
|
||||
r := &Resolver{catalog: catalog, byCode: map[string]Region{}, aliases: map[string][]Candidate{}}
|
||||
for _, reg := range catalog {
|
||||
if reg.Code == "" {
|
||||
return nil, fmt.Errorf("empty region code")
|
||||
}
|
||||
if _, ok := r.byCode[reg.Code]; ok {
|
||||
return nil, fmt.Errorf("duplicate region code: %s", reg.Code)
|
||||
}
|
||||
r.byCode[reg.Code] = reg
|
||||
for _, alias := range allAliases(reg) {
|
||||
r.index(alias, reg)
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func NewDefaultResolver() *Resolver {
|
||||
r, err := NewResolver(DefaultCatalog())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Resolver) Normalize(input string) string { return Normalize(input) }
|
||||
func (r *Resolver) GetByCode(code string) (Region, bool) {
|
||||
v, ok := r.byCode[code]
|
||||
return v, ok
|
||||
}
|
||||
func (r *Resolver) ListEnabled() []Region {
|
||||
out := []Region{}
|
||||
for _, reg := range r.catalog {
|
||||
if reg.Enabled {
|
||||
out = append(out, reg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (r *Resolver) ListDisabled() []Region {
|
||||
out := []Region{}
|
||||
for _, reg := range r.catalog {
|
||||
if !reg.Enabled {
|
||||
out = append(out, reg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Resolver) Resolve(input string, source ResolutionSource) ResolutionResult {
|
||||
n := Normalize(input)
|
||||
res := ResolutionResult{Intent: IntentNotRegion, Source: source, NormalizedText: n, ReasonCode: "no_match"}
|
||||
if n == "" {
|
||||
res.ReasonCode = "empty_input"
|
||||
return res
|
||||
}
|
||||
if len([]rune(n)) < 2 {
|
||||
res.ReasonCode = "too_short"
|
||||
return res
|
||||
}
|
||||
if isGeneric(n) {
|
||||
res.ReasonCode = "false_positive"
|
||||
if isGenericClarifier(n) {
|
||||
res.ReasonCode = "no_match"
|
||||
res.NeedsClarification = true
|
||||
}
|
||||
return res
|
||||
}
|
||||
if reg, ok := r.byCode[n]; ok {
|
||||
return r.resultFor(reg, 0.99, n, source, "exact_code")
|
||||
}
|
||||
if isAlmatyMention(n) && !isSpecificAlmatyRegion(n) && !isSpecificAlmatyCity(n) {
|
||||
return r.almatyAmbiguous(n, source)
|
||||
}
|
||||
cands := r.matchCandidates(n)
|
||||
if len(cands) == 0 {
|
||||
return res
|
||||
}
|
||||
if disabledOnly(cands) {
|
||||
c := cands[0]
|
||||
return ResolutionResult{Intent: IntentUnsupported, Confidence: c.Score, Candidates: cands, MatchedPhrase: c.MatchedAlias, NormalizedText: n, Source: source, ReasonCode: "disabled_region", ClarificationMessageKey: "region.unsupported"}
|
||||
}
|
||||
cands = enabledCandidates(cands)
|
||||
if len(cands) > 1 {
|
||||
return ResolutionResult{Intent: IntentAmbiguous, Confidence: cands[0].Score, Candidates: cands, MatchedPhrase: cands[0].MatchedAlias, NormalizedText: n, Source: source, ReasonCode: ambiguityReason(cands), NeedsClarification: true, ClarificationMessageKey: clarificationKey(cands)}
|
||||
}
|
||||
c := cands[0]
|
||||
return r.resultFor(c.Region, c.Score, c.MatchedAlias, source, c.ReasonCode)
|
||||
}
|
||||
|
||||
func (r *Resolver) ResolveToolRegion(args map[string]any) ResolutionResult {
|
||||
if code, _ := args["region_code"].(string); code != "" {
|
||||
return r.Resolve(code, SourceToolArgs)
|
||||
}
|
||||
if v, _ := args["region"].(string); v != "" {
|
||||
return r.Resolve(v, SourceToolArgs)
|
||||
}
|
||||
return ResolutionResult{Intent: IntentNotRegion, Source: SourceToolArgs, ReasonCode: "empty_input"}
|
||||
}
|
||||
|
||||
func (r *Resolver) ClarificationOptions(result ResolutionResult, lang state.Language) []string {
|
||||
out := []string{}
|
||||
for _, c := range result.Candidates {
|
||||
if lang == state.LanguageKK {
|
||||
out = append(out, c.Region.DisplayNameKK)
|
||||
} else {
|
||||
out = append(out, c.Region.DisplayNameRU)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Resolver) ResolvePending(codes []string, input string, source ResolutionSource) ResolutionResult {
|
||||
n := Normalize(input)
|
||||
wantCity := n == "город" || n == "қала" || strings.Contains(n, "қаласы") || strings.Contains(n, "город")
|
||||
wantRegion := n == "область" || n == "облыс" || strings.Contains(n, "облысы") || strings.Contains(n, "обл") || strings.Contains(n, "область")
|
||||
for _, code := range codes {
|
||||
reg, ok := r.byCode[code]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if wantCity && reg.Type == RegionTypeRepublicCity {
|
||||
return r.resultFor(reg, 0.95, n, source, "clarification")
|
||||
}
|
||||
if wantRegion && reg.Type == RegionTypeOblast {
|
||||
return r.resultFor(reg, 0.95, n, source, "clarification")
|
||||
}
|
||||
}
|
||||
return r.Resolve(input, source)
|
||||
}
|
||||
|
||||
func (r *Resolver) resultFor(reg Region, score float64, phrase string, src ResolutionSource, reason string) ResolutionResult {
|
||||
rr := ResolutionResult{RegionCode: reg.Code, Region: ®, Intent: IntentRegionSelect, Confidence: score, MatchedPhrase: phrase, NormalizedText: Normalize(phrase), Source: src, ReasonCode: reason}
|
||||
if !reg.Enabled {
|
||||
rr.Intent = IntentUnsupported
|
||||
rr.ReasonCode = "disabled_region"
|
||||
rr.RegionCode = ""
|
||||
}
|
||||
return rr
|
||||
}
|
||||
|
||||
func (r *Resolver) almatyAmbiguous(n string, src ResolutionSource) ResolutionResult {
|
||||
city := r.byCode["almaty_city"]
|
||||
oblast := r.byCode["almaty_region"]
|
||||
return ResolutionResult{Intent: IntentAmbiguous, Confidence: 0.60, Candidates: []Candidate{{Region: city, Score: 0.95, MatchedAlias: n, ReasonCode: "ambiguous_almaty"}, {Region: oblast, Score: 0.95, MatchedAlias: n, ReasonCode: "ambiguous_almaty"}}, MatchedPhrase: n, NormalizedText: n, Source: src, ReasonCode: "ambiguous_almaty", NeedsClarification: true, ClarificationMessageKey: "region.almaty_clarify"}
|
||||
}
|
||||
|
||||
func (r *Resolver) index(alias string, reg Region) {
|
||||
a := Normalize(alias)
|
||||
if a == "" {
|
||||
return
|
||||
}
|
||||
score := 0.95
|
||||
reason := "exact_alias"
|
||||
if a == Normalize(reg.NameRU) || a == Normalize(reg.NameKK) {
|
||||
score = 0.98
|
||||
reason = "exact_name"
|
||||
}
|
||||
if a == "вко" || a == "зко" || a == "ско" || a == "vko" || a == "zko" || a == "sko" {
|
||||
reason = "abbreviation"
|
||||
}
|
||||
r.aliases[a] = append(r.aliases[a], Candidate{Region: reg, Score: score, MatchedAlias: a, ReasonCode: reason})
|
||||
}
|
||||
|
||||
func (r *Resolver) matchCandidates(n string) []Candidate {
|
||||
var out []Candidate
|
||||
if c := r.aliases[n]; len(c) > 0 {
|
||||
out = append(out, c...)
|
||||
}
|
||||
for alias, cands := range r.aliases {
|
||||
if len([]rune(alias)) > 4 && strings.Contains(n, alias) {
|
||||
out = append(out, cands...)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Score > out[j].Score })
|
||||
return dedupe(out)
|
||||
}
|
||||
|
||||
func allAliases(reg Region) []string {
|
||||
out := []string{reg.Code, reg.DisplayNameRU, reg.DisplayNameKK}
|
||||
if reg.Code != "almaty_city" {
|
||||
out = append(out, reg.NameRU, reg.NameKK)
|
||||
}
|
||||
out = append(out, reg.AliasesRU...)
|
||||
out = append(out, reg.AliasesKK...)
|
||||
out = append(out, reg.AliasesLatin...)
|
||||
out = append(out, reg.LegacyAliases...)
|
||||
return out
|
||||
}
|
||||
|
||||
func dedupe(in []Candidate) []Candidate {
|
||||
seen := map[string]bool{}
|
||||
out := []Candidate{}
|
||||
for _, c := range in {
|
||||
if !seen[c.Region.Code] {
|
||||
seen[c.Region.Code] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func enabledCandidates(in []Candidate) []Candidate {
|
||||
out := []Candidate{}
|
||||
for _, c := range in {
|
||||
if c.Region.Enabled {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func disabledOnly(in []Candidate) bool {
|
||||
if len(in) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, c := range in {
|
||||
if c.Region.Enabled {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func ambiguityReason(c []Candidate) string {
|
||||
for _, x := range c {
|
||||
if x.Region.AmbiguityGroup == "almaty" {
|
||||
return "ambiguous_almaty"
|
||||
}
|
||||
}
|
||||
return "multiple_candidates"
|
||||
}
|
||||
func clarificationKey(c []Candidate) string {
|
||||
if ambiguityReason(c) == "ambiguous_almaty" {
|
||||
return "region.almaty_clarify"
|
||||
}
|
||||
return "region.ask_clarify"
|
||||
}
|
||||
func isGeneric(n string) bool {
|
||||
falsePos := []string{"казахтелеком", "мой тариф", "астана балет", "карагандинский уголь", "у меня вопрос по шымкентскому номеру", "алматинский район"}
|
||||
if n == "казахстан" {
|
||||
return true
|
||||
}
|
||||
for _, fp := range falsePos {
|
||||
if strings.Contains(n, fp) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return isGenericClarifier(n)
|
||||
}
|
||||
func isGenericClarifier(n string) bool {
|
||||
return n == "область" || n == "облыс" || n == "город" || n == "қала"
|
||||
}
|
||||
func isAlmatyMention(n string) bool {
|
||||
return n == "алматы" || n == "almaty" || strings.Contains(n, "алматы ") || strings.Contains(n, " almaty")
|
||||
}
|
||||
func isSpecificAlmatyRegion(n string) bool {
|
||||
return strings.Contains(n, "алматы облы") || strings.Contains(n, "алматинская") || strings.Contains(n, "almaty region") || strings.Contains(n, "almaty oblast")
|
||||
}
|
||||
func isSpecificAlmatyCity(n string) bool {
|
||||
return strings.Contains(n, "город алматы") || strings.Contains(n, "алматы қаласы") || strings.Contains(n, "almaty city")
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package region
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"ai-operator/internal/dialogue/state"
|
||||
)
|
||||
|
||||
func TestCatalogCountsAndNames(t *testing.T) {
|
||||
r := NewDefaultResolver()
|
||||
if got := len(r.ListEnabled()); got != 20 {
|
||||
t.Fatalf("enabled=%d", got)
|
||||
}
|
||||
if got := len(r.ListDisabled()); got != 1 {
|
||||
t.Fatalf("disabled=%d", got)
|
||||
}
|
||||
city, oblast := 0, 0
|
||||
seen := map[string]bool{}
|
||||
for _, reg := range r.ListEnabled() {
|
||||
if reg.Code == "" || seen[reg.Code] {
|
||||
t.Fatalf("bad code %q", reg.Code)
|
||||
}
|
||||
seen[reg.Code] = true
|
||||
if reg.DisplayNameRU == "" || reg.DisplayNameKK == "" {
|
||||
t.Fatalf("missing display names for %s", reg.Code)
|
||||
}
|
||||
switch reg.Type {
|
||||
case RegionTypeRepublicCity:
|
||||
city++
|
||||
case RegionTypeOblast:
|
||||
oblast++
|
||||
}
|
||||
}
|
||||
if city != 3 || oblast != 17 {
|
||||
t.Fatalf("city=%d oblast=%d", city, oblast)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizer(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
" Г. Алматы! ": "город алматы",
|
||||
"Алматинская обл.": "алматинская обл",
|
||||
"Нұр-Сұлтан": "нұр сұлтан",
|
||||
"Восточно-Казахстанская": "восточно казахстанская",
|
||||
"Қазақша, Астана": "қазақша астана",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := Normalize(input); got != want {
|
||||
t.Fatalf("Normalize(%q)=%q want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepublicCityDetection(t *testing.T) {
|
||||
r := NewDefaultResolver()
|
||||
cases := map[string]string{
|
||||
"астана": "astana_city",
|
||||
"город астана": "astana_city",
|
||||
"астана қаласы": "astana_city",
|
||||
"astana": "astana_city",
|
||||
"нурсултан": "astana_city",
|
||||
"нұр-сұлтан": "astana_city",
|
||||
"город алматы": "almaty_city",
|
||||
"алматы қаласы": "almaty_city",
|
||||
"шымкент": "shymkent_city",
|
||||
"шимкент": "shymkent_city",
|
||||
"shymkent": "shymkent_city",
|
||||
}
|
||||
for input, want := range cases {
|
||||
got := r.Resolve(input, SourceCLI)
|
||||
if got.RegionCode != want || got.Confidence < MediumConfidence {
|
||||
t.Fatalf("%q => %+v want %s", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOblastDetection(t *testing.T) {
|
||||
r := NewDefaultResolver()
|
||||
cases := map[string]string{
|
||||
"Акмолинская область": "akmola_region",
|
||||
"Ақмола облысы": "akmola_region",
|
||||
"Актюбинская область": "aktobe_region",
|
||||
"Ақтөбе облысы": "aktobe_region",
|
||||
"Алматинская область": "almaty_region",
|
||||
"Алматы облысы": "almaty_region",
|
||||
"Атырауская область": "atyrau_region",
|
||||
"Атырау облысы": "atyrau_region",
|
||||
"Восточно-Казахстанская область": "east_kazakhstan_region",
|
||||
"Шығыс Қазақстан облысы": "east_kazakhstan_region",
|
||||
"Жамбылская область": "zhambyl_region",
|
||||
"Жамбыл облысы": "zhambyl_region",
|
||||
"Западно-Казахстанская область": "west_kazakhstan_region",
|
||||
"Батыс Қазақстан облысы": "west_kazakhstan_region",
|
||||
"Карагандинская область": "karaganda_region",
|
||||
"Қарағанды облысы": "karaganda_region",
|
||||
"Костанайская область": "kostanay_region",
|
||||
"Қостанай облысы": "kostanay_region",
|
||||
"Кызылординская область": "kyzylorda_region",
|
||||
"Қызылорда облысы": "kyzylorda_region",
|
||||
"Мангистауская область": "mangystau_region",
|
||||
"Маңғыстау облысы": "mangystau_region",
|
||||
"Павлодарская область": "pavlodar_region",
|
||||
"Павлодар облысы": "pavlodar_region",
|
||||
"Северо-Казахстанская область": "north_kazakhstan_region",
|
||||
"Солтүстік Қазақстан облысы": "north_kazakhstan_region",
|
||||
"Туркестанская область": "turkistan_region",
|
||||
"Түркістан облысы": "turkistan_region",
|
||||
"область Абай": "abai_region",
|
||||
"Абай облысы": "abai_region",
|
||||
"область Жетісу": "jetisu_region",
|
||||
"Жетісу облысы": "jetisu_region",
|
||||
"область Ұлытау": "ulytau_region",
|
||||
"Ұлытау облысы": "ulytau_region",
|
||||
}
|
||||
for input, want := range cases {
|
||||
got := r.Resolve(input, SourceCLI)
|
||||
if got.RegionCode != want || got.Confidence < MediumConfidence {
|
||||
t.Fatalf("%q => %+v want %s", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbbreviations(t *testing.T) {
|
||||
r := NewDefaultResolver()
|
||||
cases := map[string]string{"ВКО": "east_kazakhstan_region", "ЗКО": "west_kazakhstan_region", "СКО": "north_kazakhstan_region", "vko": "east_kazakhstan_region", "zko": "west_kazakhstan_region", "sko": "north_kazakhstan_region"}
|
||||
for input, want := range cases {
|
||||
if got := r.Resolve(input, SourceCLI); got.RegionCode != want || got.ReasonCode != "abbreviation" {
|
||||
t.Fatalf("%q => %+v want %s", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlmatyAmbiguityAndClarification(t *testing.T) {
|
||||
r := NewDefaultResolver()
|
||||
for _, input := range []string{"Алматы", "almaty", "Алматы тарифы"} {
|
||||
got := r.Resolve(input, SourceCLI)
|
||||
if !got.NeedsClarification || got.ReasonCode != "ambiguous_almaty" || len(got.Candidates) != 2 {
|
||||
t.Fatalf("%q => %+v", input, got)
|
||||
}
|
||||
}
|
||||
pending := []string{"almaty_city", "almaty_region"}
|
||||
if got := r.ResolvePending(pending, "город", SourceCLI); got.RegionCode != "almaty_city" {
|
||||
t.Fatalf("city clarification: %+v", got)
|
||||
}
|
||||
if got := r.ResolvePending(pending, "қала", SourceCLI); got.RegionCode != "almaty_city" {
|
||||
t.Fatalf("kk city clarification: %+v", got)
|
||||
}
|
||||
if got := r.ResolvePending(pending, "область", SourceCLI); got.RegionCode != "almaty_region" {
|
||||
t.Fatalf("region clarification: %+v", got)
|
||||
}
|
||||
if got := r.ResolvePending(pending, "облыс", SourceCLI); got.RegionCode != "almaty_region" {
|
||||
t.Fatalf("kk region clarification: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFalsePositivesAndDisabled(t *testing.T) {
|
||||
r := NewDefaultResolver()
|
||||
for _, input := range []string{"Казахтелеком", "Казахстан", "область", "город", "мой тариф", "астана балет", "карагандинский уголь", "у меня вопрос по шымкентскому номеру", "алматинский район"} {
|
||||
got := r.Resolve(input, SourceCLI)
|
||||
if got.RegionCode != "" {
|
||||
t.Fatalf("false positive %q => %+v", input, got)
|
||||
}
|
||||
}
|
||||
got := r.Resolve("Байконур", SourceCLI)
|
||||
if got.Intent != IntentUnsupported || got.ReasonCode != "disabled_region" {
|
||||
t.Fatalf("disabled: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectionPolicy(t *testing.T) {
|
||||
r := NewDefaultResolver()
|
||||
astana := r.Resolve("Астана", SourceCLI)
|
||||
if d := ShouldApplyRegionResolution(SelectionContext{CurrentState: state.StateLanguageSelection, CurrentLanguage: state.LanguageUnknown}, astana); d.Apply || d.ReasonCode != "language_required" {
|
||||
t.Fatalf("language selection applied region: %+v", d)
|
||||
}
|
||||
if d := ShouldApplyRegionResolution(SelectionContext{CurrentState: state.StateRegionSelection, CurrentLanguage: state.LanguageRU}, astana); !d.Apply || d.RegionCode != "astana_city" {
|
||||
t.Fatalf("region selection did not apply: %+v", d)
|
||||
}
|
||||
amb := r.Resolve("Алматы", SourceCLI)
|
||||
if d := ShouldApplyRegionResolution(SelectionContext{CurrentState: state.StateRegionSelection, CurrentLanguage: state.LanguageRU}, amb); !d.NeedsClarification {
|
||||
t.Fatalf("ambiguous not clarified: %+v", d)
|
||||
}
|
||||
if d := ShouldApplyRegionResolution(SelectionContext{CurrentState: state.StateReadyToHelp, CurrentLanguage: state.LanguageRU, CurrentRegionCode: "astana_city", AllowChange: true}, astana); d.Apply {
|
||||
t.Fatalf("random mention switched: %+v", d)
|
||||
}
|
||||
astana.Intent = IntentRegionChange
|
||||
if d := ShouldApplyRegionResolution(SelectionContext{CurrentState: state.StateReadyToHelp, CurrentLanguage: state.LanguageRU, CurrentRegionCode: "shymkent_city", AllowChange: true}, astana); !d.Apply {
|
||||
t.Fatalf("explicit change not applied: %+v", d)
|
||||
}
|
||||
if d := ShouldApplyRegionResolution(SelectionContext{CurrentState: state.StateEnded, CurrentLanguage: state.LanguageRU}, astana); d.Apply {
|
||||
t.Fatalf("ended applied: %+v", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package region
|
||||
|
||||
import "ai-operator/internal/dialogue/state"
|
||||
|
||||
type Language = state.Language
|
||||
|
||||
type RegionStatus = state.RegionStatus
|
||||
|
||||
const (
|
||||
RegionUnknown = state.RegionUnknown
|
||||
RegionPendingClarification = state.RegionPendingClarification
|
||||
RegionSelected = state.RegionSelected
|
||||
)
|
||||
|
||||
type RegionType string
|
||||
|
||||
const (
|
||||
RegionTypeRepublicCity RegionType = "republic_city"
|
||||
RegionTypeOblast RegionType = "oblast"
|
||||
RegionTypeSpecial RegionType = "special"
|
||||
)
|
||||
|
||||
type Region struct {
|
||||
Code string
|
||||
Type RegionType
|
||||
NameRU string
|
||||
NameKK string
|
||||
DisplayNameRU string
|
||||
DisplayNameKK string
|
||||
CapitalRU string
|
||||
CapitalKK string
|
||||
AliasesRU []string
|
||||
AliasesKK []string
|
||||
AliasesLatin []string
|
||||
LegacyAliases []string
|
||||
AmbiguityGroup string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type ResolutionSource string
|
||||
|
||||
const (
|
||||
SourceUserText ResolutionSource = "user_text"
|
||||
SourceToolArgs ResolutionSource = "tool_args"
|
||||
SourceTranscript ResolutionSource = "transcript"
|
||||
SourceCLI ResolutionSource = "cli"
|
||||
)
|
||||
|
||||
type ResolutionIntent string
|
||||
|
||||
const (
|
||||
IntentRegionSelect ResolutionIntent = "region_select"
|
||||
IntentRegionChange ResolutionIntent = "region_change"
|
||||
IntentNotRegion ResolutionIntent = "not_region"
|
||||
IntentAmbiguous ResolutionIntent = "ambiguous"
|
||||
IntentUnsupported ResolutionIntent = "unsupported"
|
||||
)
|
||||
|
||||
type Candidate struct {
|
||||
Region Region
|
||||
Score float64
|
||||
MatchedAlias string
|
||||
ReasonCode string
|
||||
}
|
||||
|
||||
type ResolutionResult struct {
|
||||
RegionCode string
|
||||
Region *Region
|
||||
Intent ResolutionIntent
|
||||
Confidence float64
|
||||
Candidates []Candidate
|
||||
MatchedPhrase string
|
||||
NormalizedText string
|
||||
Source ResolutionSource
|
||||
ReasonCode string
|
||||
NeedsClarification bool
|
||||
ClarificationMessageKey string
|
||||
}
|
||||
|
||||
const (
|
||||
HighConfidence = 0.90
|
||||
MediumConfidence = 0.70
|
||||
LowConfidence = 0.50
|
||||
)
|
||||
|
||||
type SelectionContext struct {
|
||||
CurrentState state.ConversationState
|
||||
CurrentLanguage state.Language
|
||||
CurrentRegionCode string
|
||||
AllowChange bool
|
||||
}
|
||||
|
||||
type RegionDecision struct {
|
||||
Apply bool
|
||||
RegionCode string
|
||||
NeedsClarification bool
|
||||
Candidates []Candidate
|
||||
ReasonCode string
|
||||
MessageKey string
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const PendingRegionCandidatesMetadataKey = "pending_region_candidates"
|
||||
|
||||
type Machine struct {
|
||||
session ConversationSession
|
||||
}
|
||||
|
||||
func NewMachine(initial ConversationSession) *Machine {
|
||||
now := time.Now().UTC()
|
||||
if initial.StartedAt.IsZero() {
|
||||
initial.StartedAt = now
|
||||
}
|
||||
if initial.UpdatedAt.IsZero() {
|
||||
initial.UpdatedAt = initial.StartedAt
|
||||
}
|
||||
if initial.State == "" {
|
||||
initial.State = StateCallStarted
|
||||
}
|
||||
if initial.Region.Status == "" {
|
||||
initial.Region.Status = RegionUnknown
|
||||
}
|
||||
if initial.Metadata == nil {
|
||||
initial.Metadata = map[string]string{}
|
||||
}
|
||||
return &Machine{session: initial}
|
||||
}
|
||||
|
||||
func (m *Machine) Session() ConversationSession { return cloneSession(m.session) }
|
||||
func (m *Machine) CurrentState() ConversationState { return m.session.State }
|
||||
|
||||
func (m *Machine) CanTransition(event ConversationEvent) bool {
|
||||
_, err := m.next(event)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (m *Machine) Apply(event ConversationEvent) (TransitionResult, error) {
|
||||
from := m.session.State
|
||||
to, err := m.next(event)
|
||||
if err != nil {
|
||||
m.recordDenied(string(event.Type), err.Error())
|
||||
return TransitionResult{From: from, To: from, Changed: false, MessageKey: "error.invalid_transition", RequiredNextAction: requiredActionForState(from)}, err
|
||||
}
|
||||
if event.At.IsZero() {
|
||||
event.At = time.Now().UTC()
|
||||
}
|
||||
if from == StateEnded {
|
||||
return TransitionResult{From: from, To: from, Changed: false, MessageKey: "error.invalid_transition", RequiredNextAction: "none"}, fmt.Errorf("ended state is immutable")
|
||||
}
|
||||
m.session.State = to
|
||||
switch event.Type {
|
||||
case EventLanguageSelected:
|
||||
m.session.Language = event.Language
|
||||
case EventRegionSelected:
|
||||
m.session.Region = event.Region
|
||||
m.ClearPendingRegion()
|
||||
case EventCallEnded:
|
||||
t := event.At
|
||||
m.session.EndedAt = &t
|
||||
}
|
||||
m.session.UpdatedAt = event.At
|
||||
m.session.TransitionHistory = append(m.session.TransitionHistory, TransitionRecord{From: from, To: to, Event: event.Type, Reason: event.Reason, At: event.At})
|
||||
return TransitionResult{From: from, To: to, Changed: from != to, MessageKey: messageKeyForState(to), RequiredNextAction: requiredActionForState(to)}, nil
|
||||
}
|
||||
|
||||
func (m *Machine) AddDenied(action, reason string) {
|
||||
m.recordDenied(action, reason)
|
||||
}
|
||||
|
||||
func (m *Machine) SetRegionPending(codes []string) {
|
||||
if m.session.Metadata == nil {
|
||||
m.session.Metadata = map[string]string{}
|
||||
}
|
||||
m.session.Region = RegionSelection{Status: RegionPendingClarification, Source: "resolver"}
|
||||
m.session.Metadata[PendingRegionCandidatesMetadataKey] = strings.Join(codes, ",")
|
||||
m.session.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
func (m *Machine) PendingRegionCandidates() []string {
|
||||
v := strings.TrimSpace(m.session.Metadata[PendingRegionCandidatesMetadataKey])
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Machine) ClearPendingRegion() {
|
||||
if m.session.Metadata != nil {
|
||||
delete(m.session.Metadata, PendingRegionCandidatesMetadataKey)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Machine) recordDenied(action, reason string) {
|
||||
m.session.DeniedActions = append(m.session.DeniedActions, DeniedActionRecord{Action: action, State: m.session.State, ReasonCode: reason, At: time.Now().UTC()})
|
||||
m.session.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
func (m *Machine) next(event ConversationEvent) (ConversationState, error) {
|
||||
if m.session.State == StateEnded {
|
||||
return StateEnded, fmt.Errorf("ended_state")
|
||||
}
|
||||
switch event.Type {
|
||||
case EventCallStarted:
|
||||
if m.session.State == StateCallStarted {
|
||||
return StateGreeting, nil
|
||||
}
|
||||
case EventGreetingPlayed, EventLanguageRequested:
|
||||
if m.session.State == StateGreeting {
|
||||
return StateReadyToHelp, nil
|
||||
}
|
||||
case EventLanguageSelected:
|
||||
if event.Language != LanguageRU && event.Language != LanguageKK {
|
||||
return m.session.State, fmt.Errorf("invalid_language")
|
||||
}
|
||||
switch m.session.State {
|
||||
case StateLanguageSelection:
|
||||
return StateRegionSelection, nil
|
||||
case StateRegionSelection, StateReadyToHelp, StateQuestionAnswering:
|
||||
return m.session.State, nil
|
||||
}
|
||||
case EventRegionSelected:
|
||||
if event.Region.Code == "" || event.Region.Status != RegionSelected {
|
||||
return m.session.State, fmt.Errorf("invalid_region")
|
||||
}
|
||||
switch m.session.State {
|
||||
case StateRegionSelection, StateReadyToHelp, StateQuestionAnswering:
|
||||
return StateReadyToHelp, nil
|
||||
}
|
||||
case EventQuestionReceived:
|
||||
if m.session.State == StateReadyToHelp {
|
||||
return StateQuestionAnswering, nil
|
||||
}
|
||||
return m.session.State, fmt.Errorf("state_not_ready")
|
||||
case EventAnswerCompleted:
|
||||
if m.session.State == StateQuestionAnswering {
|
||||
return StateReadyToHelp, nil
|
||||
}
|
||||
case EventHandoffRequested:
|
||||
return StateHandoff, nil
|
||||
case EventClosingRequested:
|
||||
return StateClosing, nil
|
||||
case EventCallEnded:
|
||||
return StateEnded, nil
|
||||
}
|
||||
return m.session.State, fmt.Errorf("invalid_transition")
|
||||
}
|
||||
|
||||
func messageKeyForState(s ConversationState) string {
|
||||
switch s {
|
||||
case StateGreeting:
|
||||
return "greeting.initial"
|
||||
case StateLanguageSelection:
|
||||
return "language.ask"
|
||||
case StateRegionSelection:
|
||||
return "region.ask"
|
||||
case StateReadyToHelp:
|
||||
return "ready.to_help"
|
||||
case StateQuestionAnswering:
|
||||
return "answer.allowed"
|
||||
case StateHandoff:
|
||||
return "handoff.started"
|
||||
case StateClosing:
|
||||
return "closing.started"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func requiredActionForState(s ConversationState) string {
|
||||
switch s {
|
||||
case StateGreeting:
|
||||
return "play_greeting"
|
||||
case StateLanguageSelection:
|
||||
return "ask_language"
|
||||
case StateRegionSelection:
|
||||
return "ask_region"
|
||||
case StateReadyToHelp:
|
||||
return "wait_for_question"
|
||||
case StateQuestionAnswering:
|
||||
return "answer_question"
|
||||
case StateHandoff:
|
||||
return "handoff"
|
||||
case StateClosing, StateEnded:
|
||||
return "close_call"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
func cloneSession(s ConversationSession) ConversationSession {
|
||||
s.TransitionHistory = append([]TransitionRecord(nil), s.TransitionHistory...)
|
||||
s.DeniedActions = append([]DeniedActionRecord(nil), s.DeniedActions...)
|
||||
if s.Metadata != nil {
|
||||
cp := make(map[string]string, len(s.Metadata))
|
||||
for k, v := range s.Metadata {
|
||||
cp[k] = v
|
||||
}
|
||||
s.Metadata = cp
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package state
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidTransitions(t *testing.T) {
|
||||
m := NewMachine(ConversationSession{CallID: "c"})
|
||||
steps := []ConversationEvent{
|
||||
{Type: EventCallStarted},
|
||||
{Type: EventGreetingPlayed},
|
||||
{Type: EventLanguageSelected, Language: LanguageRU},
|
||||
{Type: EventRegionSelected, Region: RegionSelection{Code: "almaty_city", Status: RegionSelected}},
|
||||
{Type: EventQuestionReceived, Question: "q"},
|
||||
{Type: EventAnswerCompleted},
|
||||
{Type: EventHandoffRequested},
|
||||
{Type: EventCallEnded},
|
||||
}
|
||||
want := []ConversationState{StateGreeting, StateReadyToHelp, StateReadyToHelp, StateReadyToHelp, StateQuestionAnswering, StateReadyToHelp, StateHandoff, StateEnded}
|
||||
for i, step := range steps {
|
||||
res, err := m.Apply(step)
|
||||
if err != nil {
|
||||
t.Fatalf("step %d: %v", i, err)
|
||||
}
|
||||
if res.To != want[i] {
|
||||
t.Fatalf("step %d state=%s want=%s", i, res.To, want[i])
|
||||
}
|
||||
}
|
||||
if len(m.Session().TransitionHistory) != len(steps) {
|
||||
t.Fatalf("transition history length mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidTransitionsDoNotMutate(t *testing.T) {
|
||||
m := NewMachine(ConversationSession{CallID: "c"})
|
||||
if _, err := m.Apply(ConversationEvent{Type: EventQuestionReceived}); err == nil {
|
||||
t.Fatal("expected invalid transition")
|
||||
}
|
||||
if m.CurrentState() != StateCallStarted {
|
||||
t.Fatalf("state mutated to %s", m.CurrentState())
|
||||
}
|
||||
_, _ = m.Apply(ConversationEvent{Type: EventCallEnded})
|
||||
if _, err := m.Apply(ConversationEvent{Type: EventLanguageSelected, Language: LanguageRU}); err == nil {
|
||||
t.Fatal("expected ended state error")
|
||||
}
|
||||
if m.CurrentState() != StateEnded {
|
||||
t.Fatalf("ended state mutated to %s", m.CurrentState())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuestionAllowedWithoutExplicitLanguageAndRegion(t *testing.T) {
|
||||
m := NewMachine(ConversationSession{CallID: "c", State: StateRegionSelection, Language: LanguageRU})
|
||||
if _, err := m.Apply(ConversationEvent{Type: EventQuestionReceived}); err == nil {
|
||||
t.Fatal("region selection state should not answer directly")
|
||||
}
|
||||
m = NewMachine(ConversationSession{CallID: "c", State: StateReadyToHelp, Region: RegionSelection{Code: "almaty_city", Status: RegionSelected}})
|
||||
if _, err := m.Apply(ConversationEvent{Type: EventQuestionReceived}); err == nil {
|
||||
return
|
||||
}
|
||||
t.Fatal("question should be allowed without explicit language")
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"ai-operator/internal/call"
|
||||
)
|
||||
|
||||
type ConversationState = call.CallState
|
||||
type Language = call.Language
|
||||
|
||||
const (
|
||||
StateCallStarted ConversationState = call.StateCallStarted
|
||||
StateGreeting ConversationState = call.StateGreeting
|
||||
StateLanguageSelection ConversationState = call.StateLanguageSelection
|
||||
StateRegionSelection ConversationState = call.StateRegionSelection
|
||||
StateReadyToHelp ConversationState = call.StateReadyToHelp
|
||||
StateQuestionAnswering ConversationState = call.StateQuestionAnswering
|
||||
StateHandoff ConversationState = call.StateHandoff
|
||||
StateClosing ConversationState = call.StateClosing
|
||||
StateEnded ConversationState = call.StateEnded
|
||||
|
||||
LanguageUnknown Language = call.LanguageUnknown
|
||||
LanguageRU Language = call.LanguageRU
|
||||
LanguageKK Language = call.LanguageKK
|
||||
)
|
||||
|
||||
type RegionStatus string
|
||||
|
||||
const (
|
||||
RegionUnknown RegionStatus = "unknown"
|
||||
RegionPendingClarification RegionStatus = "pending_clarification"
|
||||
RegionSelected RegionStatus = "selected"
|
||||
)
|
||||
|
||||
type RegionSelection struct {
|
||||
Code string
|
||||
DisplayNameRU string
|
||||
DisplayNameKK string
|
||||
Status RegionStatus
|
||||
Source string
|
||||
}
|
||||
|
||||
type ConversationSession struct {
|
||||
CallID string
|
||||
AsteriskChannelID string
|
||||
CallerNumberMasked string
|
||||
State ConversationState
|
||||
Language Language
|
||||
Region RegionSelection
|
||||
StartedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
EndedAt *time.Time
|
||||
TransitionHistory []TransitionRecord
|
||||
DeniedActions []DeniedActionRecord
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
type TransitionRecord struct {
|
||||
From ConversationState
|
||||
To ConversationState
|
||||
Event ConversationEventType
|
||||
Reason string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
type DeniedActionRecord struct {
|
||||
Action string
|
||||
State ConversationState
|
||||
ReasonCode string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
type ConversationEventType string
|
||||
|
||||
const (
|
||||
EventCallStarted ConversationEventType = "call.started"
|
||||
EventGreetingPlayed ConversationEventType = "greeting.played"
|
||||
EventLanguageRequested ConversationEventType = "language.requested"
|
||||
EventLanguageSelected ConversationEventType = "language.selected"
|
||||
EventRegionRequested ConversationEventType = "region.requested"
|
||||
EventRegionSelected ConversationEventType = "region.selected"
|
||||
EventQuestionReceived ConversationEventType = "question.received"
|
||||
EventAnswerStarted ConversationEventType = "answer.started"
|
||||
EventAnswerCompleted ConversationEventType = "answer.completed"
|
||||
EventHandoffRequested ConversationEventType = "handoff.requested"
|
||||
EventClosingRequested ConversationEventType = "closing.requested"
|
||||
EventCallEnded ConversationEventType = "call.ended"
|
||||
EventError ConversationEventType = "error"
|
||||
)
|
||||
|
||||
type ConversationEvent struct {
|
||||
Type ConversationEventType
|
||||
Language Language
|
||||
Region RegionSelection
|
||||
Question string
|
||||
Reason string
|
||||
Metadata map[string]string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
type TransitionResult struct {
|
||||
From ConversationState
|
||||
To ConversationState
|
||||
Changed bool
|
||||
MessageKey string
|
||||
RequiredNextAction string
|
||||
}
|
||||
Reference in New Issue
Block a user