sync: migrate ai-operator to Gitea (2026-08-10)

This commit is contained in:
konturai-ops
2026-08-10 15:26:52 +00:00
commit 53652b95ad
173 changed files with 16676 additions and 0 deletions
+130
View File
@@ -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")
}
}
+26
View File
@@ -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
}
+23
View File
@@ -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"}
+60
View File
@@ -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
}
+246
View File
@@ -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)
}
}
}
+39
View File
@@ -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
}
+649
View File
@@ -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
}
+241
View File
@@ -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)
}
}
+79
View File
@@ -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"
}
+55
View File
@@ -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")
}
}
+38
View File
@@ -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
}
+36
View File
@@ -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)
}
}
+27
View File
@@ -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"}},
}
}
+27
View File
@@ -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), " ")
}
+82
View File
@@ -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
}
+275
View File
@@ -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: &reg, 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")
}
+193
View File
@@ -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)
}
}
+100
View File
@@ -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
}
+215
View File
@@ -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
}
+59
View File
@@ -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")
}
+108
View File
@@ -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
}