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
}