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
+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
}