sync: migrate ai-operator to Gitea (2026-08-10)
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
package region
|
||||
|
||||
func DefaultCatalog() []Region {
|
||||
return []Region{
|
||||
{Code: "astana_city", Type: RegionTypeRepublicCity, NameRU: "Астана", NameKK: "Астана", DisplayNameRU: "Астана", DisplayNameKK: "Астана", Enabled: true, AliasesRU: []string{"астана", "город астана", "г астана"}, AliasesKK: []string{"астана қаласы"}, AliasesLatin: []string{"astana"}, LegacyAliases: []string{"nur sultan", "нурсултан", "нур султан", "нұр сұлтан"}},
|
||||
{Code: "almaty_city", Type: RegionTypeRepublicCity, NameRU: "Алматы", NameKK: "Алматы", DisplayNameRU: "город Алматы", DisplayNameKK: "Алматы қаласы", AmbiguityGroup: "almaty", Enabled: true, AliasesRU: []string{"город алматы", "г алматы"}, AliasesKK: []string{"алматы қаласы"}, AliasesLatin: []string{"almaty city"}, LegacyAliases: []string{"alma ata", "алмата", "алма ата"}},
|
||||
{Code: "shymkent_city", Type: RegionTypeRepublicCity, NameRU: "Шымкент", NameKK: "Шымкент", DisplayNameRU: "Шымкент", DisplayNameKK: "Шымкент", Enabled: true, AliasesRU: []string{"шымкент", "шимкент", "город шымкент", "г шымкент"}, AliasesKK: []string{"шымкент қаласы"}, AliasesLatin: []string{"shymkent", "chimkent"}},
|
||||
{Code: "akmola_region", Type: RegionTypeOblast, NameRU: "Акмолинская область", NameKK: "Ақмола облысы", DisplayNameRU: "Акмолинская область", DisplayNameKK: "Ақмола облысы", Enabled: true, AliasesRU: []string{"акмолинская область", "акмолинская", "акмола"}, AliasesKK: []string{"ақмола облысы", "ақмола"}, AliasesLatin: []string{"akmola"}},
|
||||
{Code: "aktobe_region", Type: RegionTypeOblast, NameRU: "Актюбинская область", NameKK: "Ақтөбе облысы", DisplayNameRU: "Актюбинская область", DisplayNameKK: "Ақтөбе облысы", Enabled: true, AliasesRU: []string{"актюбинская область", "актюбинская", "актобе", "актюбинск"}, AliasesKK: []string{"ақтөбе облысы", "ақтөбе"}, AliasesLatin: []string{"aktobe", "aktoebe"}},
|
||||
{Code: "almaty_region", Type: RegionTypeOblast, NameRU: "Алматинская область", NameKK: "Алматы облысы", DisplayNameRU: "Алматинская область", DisplayNameKK: "Алматы облысы", AmbiguityGroup: "almaty", Enabled: true, AliasesRU: []string{"алматинская область", "алматинская обл", "алматинская", "область алматы", "алматы обл"}, AliasesKK: []string{"алматы облысы", "алматы обл", "алматы облыс"}, AliasesLatin: []string{"almaty region", "almaty oblast"}},
|
||||
{Code: "atyrau_region", Type: RegionTypeOblast, NameRU: "Атырауская область", NameKK: "Атырау облысы", DisplayNameRU: "Атырауская область", DisplayNameKK: "Атырау облысы", Enabled: true, AliasesRU: []string{"атырауская область", "атырауская", "атырау"}, AliasesKK: []string{"атырау облысы"}, AliasesLatin: []string{"atyrau"}},
|
||||
{Code: "east_kazakhstan_region", Type: RegionTypeOblast, NameRU: "Восточно-Казахстанская область", NameKK: "Шығыс Қазақстан облысы", DisplayNameRU: "Восточно-Казахстанская область", DisplayNameKK: "Шығыс Қазақстан облысы", Enabled: true, AliasesRU: []string{"восточно казахстанская область", "вко"}, AliasesKK: []string{"шығыс қазақстан облысы", "шығыс қазақстан"}, AliasesLatin: []string{"east kazakhstan", "vko"}},
|
||||
{Code: "zhambyl_region", Type: RegionTypeOblast, NameRU: "Жамбылская область", NameKK: "Жамбыл облысы", DisplayNameRU: "Жамбылская область", DisplayNameKK: "Жамбыл облысы", Enabled: true, AliasesRU: []string{"жамбылская область", "жамбылская", "жамбыл", "тараз"}, AliasesKK: []string{"жамбыл облысы"}, AliasesLatin: []string{"zhambyl", "jambyl"}},
|
||||
{Code: "west_kazakhstan_region", Type: RegionTypeOblast, NameRU: "Западно-Казахстанская область", NameKK: "Батыс Қазақстан облысы", DisplayNameRU: "Западно-Казахстанская область", DisplayNameKK: "Батыс Қазақстан облысы", Enabled: true, AliasesRU: []string{"западно казахстанская область", "зко"}, AliasesKK: []string{"батыс қазақстан облысы", "батыс қазақстан"}, AliasesLatin: []string{"west kazakhstan", "zko"}},
|
||||
{Code: "karaganda_region", Type: RegionTypeOblast, NameRU: "Карагандинская область", NameKK: "Қарағанды облысы", DisplayNameRU: "Карагандинская область", DisplayNameKK: "Қарағанды облысы", Enabled: true, AliasesRU: []string{"карагандинская область", "карагандинская", "караганда"}, AliasesKK: []string{"қарағанды облысы", "қарағанды"}, AliasesLatin: []string{"karaganda", "qaragandy"}},
|
||||
{Code: "kostanay_region", Type: RegionTypeOblast, NameRU: "Костанайская область", NameKK: "Қостанай облысы", DisplayNameRU: "Костанайская область", DisplayNameKK: "Қостанай облысы", Enabled: true, AliasesRU: []string{"костанайская область", "костанайская", "костанай"}, AliasesKK: []string{"қостанай облысы", "қостанай"}, AliasesLatin: []string{"kostanay", "qostanay"}},
|
||||
{Code: "kyzylorda_region", Type: RegionTypeOblast, NameRU: "Кызылординская область", NameKK: "Қызылорда облысы", DisplayNameRU: "Кызылординская область", DisplayNameKK: "Қызылорда облысы", Enabled: true, AliasesRU: []string{"кызылординская область", "кызылординская", "кызылорда"}, AliasesKK: []string{"қызылорда облысы", "қызылорда"}, AliasesLatin: []string{"kyzylorda", "qyzylorda"}},
|
||||
{Code: "mangystau_region", Type: RegionTypeOblast, NameRU: "Мангистауская область", NameKK: "Маңғыстау облысы", DisplayNameRU: "Мангистауская область", DisplayNameKK: "Маңғыстау облысы", Enabled: true, AliasesRU: []string{"мангистауская область", "мангистауская", "мангыстау", "мангистау"}, AliasesKK: []string{"маңғыстау облысы", "маңғыстау"}, AliasesLatin: []string{"mangystau"}},
|
||||
{Code: "pavlodar_region", Type: RegionTypeOblast, NameRU: "Павлодарская область", NameKK: "Павлодар облысы", DisplayNameRU: "Павлодарская область", DisplayNameKK: "Павлодар облысы", Enabled: true, AliasesRU: []string{"павлодарская область", "павлодарская", "павлодар"}, AliasesKK: []string{"павлодар облысы"}, AliasesLatin: []string{"pavlodar"}},
|
||||
{Code: "north_kazakhstan_region", Type: RegionTypeOblast, NameRU: "Северо-Казахстанская область", NameKK: "Солтүстік Қазақстан облысы", DisplayNameRU: "Северо-Казахстанская область", DisplayNameKK: "Солтүстік Қазақстан облысы", Enabled: true, AliasesRU: []string{"северо казахстанская область", "ско"}, AliasesKK: []string{"солтүстік қазақстан облысы", "солтүстік қазақстан"}, AliasesLatin: []string{"north kazakhstan", "sko"}},
|
||||
{Code: "turkistan_region", Type: RegionTypeOblast, NameRU: "Туркестанская область", NameKK: "Түркістан облысы", DisplayNameRU: "Туркестанская область", DisplayNameKK: "Түркістан облысы", Enabled: true, AliasesRU: []string{"туркестанская область", "туркестанская", "туркестан"}, AliasesKK: []string{"түркістан облысы", "түркістан"}, AliasesLatin: []string{"turkistan", "turkestan"}},
|
||||
{Code: "abai_region", Type: RegionTypeOblast, NameRU: "область Абай", NameKK: "Абай облысы", DisplayNameRU: "область Абай", DisplayNameKK: "Абай облысы", Enabled: true, AliasesRU: []string{"область абай", "абайская область", "абай", "семей"}, AliasesKK: []string{"абай облысы"}, AliasesLatin: []string{"semey"}},
|
||||
{Code: "jetisu_region", Type: RegionTypeOblast, NameRU: "область Жетісу", NameKK: "Жетісу облысы", DisplayNameRU: "область Жетісу", DisplayNameKK: "Жетісу облысы", Enabled: true, AliasesRU: []string{"область жетісу", "жетысуская область", "жетісу", "жетысу"}, AliasesKK: []string{"жетісу облысы"}, AliasesLatin: []string{"zhetysu", "jetisu"}},
|
||||
{Code: "ulytau_region", Type: RegionTypeOblast, NameRU: "область Ұлытау", NameKK: "Ұлытау облысы", DisplayNameRU: "область Ұлытау", DisplayNameKK: "Ұлытау облысы", Enabled: true, AliasesRU: []string{"область улытау", "область ұлытау", "улытауская область", "улытау", "жезказган"}, AliasesKK: []string{"ұлытау облысы", "ұлытау", "жезқазған"}, AliasesLatin: []string{"ulytau", "ulutau"}},
|
||||
{Code: "baikonur_special", Type: RegionTypeSpecial, NameRU: "Байконур", NameKK: "Байқоңыр", DisplayNameRU: "Байконур", DisplayNameKK: "Байқоңыр", Enabled: false, AliasesRU: []string{"байконур"}, AliasesKK: []string{"байқоңыр"}, AliasesLatin: []string{"baikonur"}},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package region
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var spaceRE = regexp.MustCompile(`\s+`)
|
||||
var edgePunctRE = regexp.MustCompile(`^[\s\.,!\?;:"'«»\(\)\[\]\{\}]+|[\s\.,!\?;:"'«»\(\)\[\]\{\}]+$`)
|
||||
|
||||
func Normalize(input string) string {
|
||||
s := strings.TrimSpace(strings.ToLower(input))
|
||||
s = strings.ReplaceAll(s, "ё", "е")
|
||||
s = strings.ReplaceAll(s, "-", " ")
|
||||
s = strings.ReplaceAll(s, "г.", "город ")
|
||||
s = strings.ReplaceAll(s, "обл.", "обл")
|
||||
s = edgePunctRE.ReplaceAllString(s, "")
|
||||
s = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '.', ',', '!', '?', ';', ':', '"', '\'', '«', '»', '(', ')', '[', ']', '{', '}':
|
||||
return ' '
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, s)
|
||||
return spaceRE.ReplaceAllString(strings.TrimSpace(s), " ")
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package region
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"ai-operator/internal/dialogue/state"
|
||||
)
|
||||
|
||||
func ShouldApplyRegionResolution(ctx SelectionContext, result ResolutionResult) RegionDecision {
|
||||
d := RegionDecision{RegionCode: result.RegionCode, Candidates: result.Candidates, ReasonCode: result.ReasonCode}
|
||||
if ctx.CurrentState == state.StateEnded || ctx.CurrentState == state.StateClosing || ctx.CurrentState == state.StateHandoff {
|
||||
d.ReasonCode = "call_ended"
|
||||
d.MessageKey = "closing.started"
|
||||
return d
|
||||
}
|
||||
if result.Intent == IntentUnsupported || result.ReasonCode == "disabled_region" {
|
||||
d.ReasonCode = result.ReasonCode
|
||||
d.MessageKey = "region.unsupported"
|
||||
return d
|
||||
}
|
||||
if result.NeedsClarification || result.Intent == IntentAmbiguous {
|
||||
d.NeedsClarification = true
|
||||
d.MessageKey = nonEmpty(result.ClarificationMessageKey, "region.ask_clarify")
|
||||
return d
|
||||
}
|
||||
if result.RegionCode == "" || result.Region == nil {
|
||||
d.MessageKey = "region.not_understood"
|
||||
return d
|
||||
}
|
||||
switch ctx.CurrentState {
|
||||
case state.StateLanguageSelection:
|
||||
d.ReasonCode = "language_required"
|
||||
d.MessageKey = "language.ask"
|
||||
return d
|
||||
case state.StateRegionSelection:
|
||||
if result.Confidence >= MediumConfidence && result.Region.Enabled {
|
||||
d.Apply = true
|
||||
d.MessageKey = "region.selected"
|
||||
}
|
||||
return d
|
||||
case state.StateReadyToHelp, state.StateQuestionAnswering:
|
||||
if ctx.CurrentRegionCode == "" && result.Confidence >= MediumConfidence && result.Region.Enabled {
|
||||
d.Apply = true
|
||||
d.MessageKey = "region.selected"
|
||||
return d
|
||||
}
|
||||
if ctx.AllowChange && result.Intent == IntentRegionChange && result.Confidence >= HighConfidence && result.Region.Enabled {
|
||||
d.Apply = true
|
||||
d.MessageKey = "region.changed"
|
||||
return d
|
||||
}
|
||||
d.ReasonCode = "state_not_ready"
|
||||
d.MessageKey = "ready.to_help"
|
||||
return d
|
||||
default:
|
||||
d.ReasonCode = "state_not_ready"
|
||||
d.MessageKey = "region.ask"
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
func IsExplicitChangeRequest(input string) bool {
|
||||
n := Normalize(input)
|
||||
phrases := []string{
|
||||
"сменить регион", "поменять регион", "изменить регион", "другой регион", "я из другого региона", "мой регион", "регион ", "я в ", "я из ", "выберите регион", "укажите регион",
|
||||
"аймақты ауыстыру", "өңірді өзгерту", "басқа өңір", "менің өңірім", "өңірім", "аймағым", "мен астанадамын", "мен алматыдамын", "облысынанмын",
|
||||
"change region", "switch region", "my region is", "i am in", "i am from",
|
||||
}
|
||||
for _, p := range phrases {
|
||||
if strings.Contains(n, Normalize(p)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nonEmpty(v, fallback string) string {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package region
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"ai-operator/internal/dialogue/state"
|
||||
)
|
||||
|
||||
type Resolver struct {
|
||||
catalog []Region
|
||||
byCode map[string]Region
|
||||
aliases map[string][]Candidate
|
||||
}
|
||||
|
||||
func NewResolver(catalog []Region) (*Resolver, error) {
|
||||
r := &Resolver{catalog: catalog, byCode: map[string]Region{}, aliases: map[string][]Candidate{}}
|
||||
for _, reg := range catalog {
|
||||
if reg.Code == "" {
|
||||
return nil, fmt.Errorf("empty region code")
|
||||
}
|
||||
if _, ok := r.byCode[reg.Code]; ok {
|
||||
return nil, fmt.Errorf("duplicate region code: %s", reg.Code)
|
||||
}
|
||||
r.byCode[reg.Code] = reg
|
||||
for _, alias := range allAliases(reg) {
|
||||
r.index(alias, reg)
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func NewDefaultResolver() *Resolver {
|
||||
r, err := NewResolver(DefaultCatalog())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Resolver) Normalize(input string) string { return Normalize(input) }
|
||||
func (r *Resolver) GetByCode(code string) (Region, bool) {
|
||||
v, ok := r.byCode[code]
|
||||
return v, ok
|
||||
}
|
||||
func (r *Resolver) ListEnabled() []Region {
|
||||
out := []Region{}
|
||||
for _, reg := range r.catalog {
|
||||
if reg.Enabled {
|
||||
out = append(out, reg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (r *Resolver) ListDisabled() []Region {
|
||||
out := []Region{}
|
||||
for _, reg := range r.catalog {
|
||||
if !reg.Enabled {
|
||||
out = append(out, reg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Resolver) Resolve(input string, source ResolutionSource) ResolutionResult {
|
||||
n := Normalize(input)
|
||||
res := ResolutionResult{Intent: IntentNotRegion, Source: source, NormalizedText: n, ReasonCode: "no_match"}
|
||||
if n == "" {
|
||||
res.ReasonCode = "empty_input"
|
||||
return res
|
||||
}
|
||||
if len([]rune(n)) < 2 {
|
||||
res.ReasonCode = "too_short"
|
||||
return res
|
||||
}
|
||||
if isGeneric(n) {
|
||||
res.ReasonCode = "false_positive"
|
||||
if isGenericClarifier(n) {
|
||||
res.ReasonCode = "no_match"
|
||||
res.NeedsClarification = true
|
||||
}
|
||||
return res
|
||||
}
|
||||
if reg, ok := r.byCode[n]; ok {
|
||||
return r.resultFor(reg, 0.99, n, source, "exact_code")
|
||||
}
|
||||
if isAlmatyMention(n) && !isSpecificAlmatyRegion(n) && !isSpecificAlmatyCity(n) {
|
||||
return r.almatyAmbiguous(n, source)
|
||||
}
|
||||
cands := r.matchCandidates(n)
|
||||
if len(cands) == 0 {
|
||||
return res
|
||||
}
|
||||
if disabledOnly(cands) {
|
||||
c := cands[0]
|
||||
return ResolutionResult{Intent: IntentUnsupported, Confidence: c.Score, Candidates: cands, MatchedPhrase: c.MatchedAlias, NormalizedText: n, Source: source, ReasonCode: "disabled_region", ClarificationMessageKey: "region.unsupported"}
|
||||
}
|
||||
cands = enabledCandidates(cands)
|
||||
if len(cands) > 1 {
|
||||
return ResolutionResult{Intent: IntentAmbiguous, Confidence: cands[0].Score, Candidates: cands, MatchedPhrase: cands[0].MatchedAlias, NormalizedText: n, Source: source, ReasonCode: ambiguityReason(cands), NeedsClarification: true, ClarificationMessageKey: clarificationKey(cands)}
|
||||
}
|
||||
c := cands[0]
|
||||
return r.resultFor(c.Region, c.Score, c.MatchedAlias, source, c.ReasonCode)
|
||||
}
|
||||
|
||||
func (r *Resolver) ResolveToolRegion(args map[string]any) ResolutionResult {
|
||||
if code, _ := args["region_code"].(string); code != "" {
|
||||
return r.Resolve(code, SourceToolArgs)
|
||||
}
|
||||
if v, _ := args["region"].(string); v != "" {
|
||||
return r.Resolve(v, SourceToolArgs)
|
||||
}
|
||||
return ResolutionResult{Intent: IntentNotRegion, Source: SourceToolArgs, ReasonCode: "empty_input"}
|
||||
}
|
||||
|
||||
func (r *Resolver) ClarificationOptions(result ResolutionResult, lang state.Language) []string {
|
||||
out := []string{}
|
||||
for _, c := range result.Candidates {
|
||||
if lang == state.LanguageKK {
|
||||
out = append(out, c.Region.DisplayNameKK)
|
||||
} else {
|
||||
out = append(out, c.Region.DisplayNameRU)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Resolver) ResolvePending(codes []string, input string, source ResolutionSource) ResolutionResult {
|
||||
n := Normalize(input)
|
||||
wantCity := n == "город" || n == "қала" || strings.Contains(n, "қаласы") || strings.Contains(n, "город")
|
||||
wantRegion := n == "область" || n == "облыс" || strings.Contains(n, "облысы") || strings.Contains(n, "обл") || strings.Contains(n, "область")
|
||||
for _, code := range codes {
|
||||
reg, ok := r.byCode[code]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if wantCity && reg.Type == RegionTypeRepublicCity {
|
||||
return r.resultFor(reg, 0.95, n, source, "clarification")
|
||||
}
|
||||
if wantRegion && reg.Type == RegionTypeOblast {
|
||||
return r.resultFor(reg, 0.95, n, source, "clarification")
|
||||
}
|
||||
}
|
||||
return r.Resolve(input, source)
|
||||
}
|
||||
|
||||
func (r *Resolver) resultFor(reg Region, score float64, phrase string, src ResolutionSource, reason string) ResolutionResult {
|
||||
rr := ResolutionResult{RegionCode: reg.Code, Region: ®, Intent: IntentRegionSelect, Confidence: score, MatchedPhrase: phrase, NormalizedText: Normalize(phrase), Source: src, ReasonCode: reason}
|
||||
if !reg.Enabled {
|
||||
rr.Intent = IntentUnsupported
|
||||
rr.ReasonCode = "disabled_region"
|
||||
rr.RegionCode = ""
|
||||
}
|
||||
return rr
|
||||
}
|
||||
|
||||
func (r *Resolver) almatyAmbiguous(n string, src ResolutionSource) ResolutionResult {
|
||||
city := r.byCode["almaty_city"]
|
||||
oblast := r.byCode["almaty_region"]
|
||||
return ResolutionResult{Intent: IntentAmbiguous, Confidence: 0.60, Candidates: []Candidate{{Region: city, Score: 0.95, MatchedAlias: n, ReasonCode: "ambiguous_almaty"}, {Region: oblast, Score: 0.95, MatchedAlias: n, ReasonCode: "ambiguous_almaty"}}, MatchedPhrase: n, NormalizedText: n, Source: src, ReasonCode: "ambiguous_almaty", NeedsClarification: true, ClarificationMessageKey: "region.almaty_clarify"}
|
||||
}
|
||||
|
||||
func (r *Resolver) index(alias string, reg Region) {
|
||||
a := Normalize(alias)
|
||||
if a == "" {
|
||||
return
|
||||
}
|
||||
score := 0.95
|
||||
reason := "exact_alias"
|
||||
if a == Normalize(reg.NameRU) || a == Normalize(reg.NameKK) {
|
||||
score = 0.98
|
||||
reason = "exact_name"
|
||||
}
|
||||
if a == "вко" || a == "зко" || a == "ско" || a == "vko" || a == "zko" || a == "sko" {
|
||||
reason = "abbreviation"
|
||||
}
|
||||
r.aliases[a] = append(r.aliases[a], Candidate{Region: reg, Score: score, MatchedAlias: a, ReasonCode: reason})
|
||||
}
|
||||
|
||||
func (r *Resolver) matchCandidates(n string) []Candidate {
|
||||
var out []Candidate
|
||||
if c := r.aliases[n]; len(c) > 0 {
|
||||
out = append(out, c...)
|
||||
}
|
||||
for alias, cands := range r.aliases {
|
||||
if len([]rune(alias)) > 4 && strings.Contains(n, alias) {
|
||||
out = append(out, cands...)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Score > out[j].Score })
|
||||
return dedupe(out)
|
||||
}
|
||||
|
||||
func allAliases(reg Region) []string {
|
||||
out := []string{reg.Code, reg.DisplayNameRU, reg.DisplayNameKK}
|
||||
if reg.Code != "almaty_city" {
|
||||
out = append(out, reg.NameRU, reg.NameKK)
|
||||
}
|
||||
out = append(out, reg.AliasesRU...)
|
||||
out = append(out, reg.AliasesKK...)
|
||||
out = append(out, reg.AliasesLatin...)
|
||||
out = append(out, reg.LegacyAliases...)
|
||||
return out
|
||||
}
|
||||
|
||||
func dedupe(in []Candidate) []Candidate {
|
||||
seen := map[string]bool{}
|
||||
out := []Candidate{}
|
||||
for _, c := range in {
|
||||
if !seen[c.Region.Code] {
|
||||
seen[c.Region.Code] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func enabledCandidates(in []Candidate) []Candidate {
|
||||
out := []Candidate{}
|
||||
for _, c := range in {
|
||||
if c.Region.Enabled {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func disabledOnly(in []Candidate) bool {
|
||||
if len(in) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, c := range in {
|
||||
if c.Region.Enabled {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func ambiguityReason(c []Candidate) string {
|
||||
for _, x := range c {
|
||||
if x.Region.AmbiguityGroup == "almaty" {
|
||||
return "ambiguous_almaty"
|
||||
}
|
||||
}
|
||||
return "multiple_candidates"
|
||||
}
|
||||
func clarificationKey(c []Candidate) string {
|
||||
if ambiguityReason(c) == "ambiguous_almaty" {
|
||||
return "region.almaty_clarify"
|
||||
}
|
||||
return "region.ask_clarify"
|
||||
}
|
||||
func isGeneric(n string) bool {
|
||||
falsePos := []string{"казахтелеком", "мой тариф", "астана балет", "карагандинский уголь", "у меня вопрос по шымкентскому номеру", "алматинский район"}
|
||||
if n == "казахстан" {
|
||||
return true
|
||||
}
|
||||
for _, fp := range falsePos {
|
||||
if strings.Contains(n, fp) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return isGenericClarifier(n)
|
||||
}
|
||||
func isGenericClarifier(n string) bool {
|
||||
return n == "область" || n == "облыс" || n == "город" || n == "қала"
|
||||
}
|
||||
func isAlmatyMention(n string) bool {
|
||||
return n == "алматы" || n == "almaty" || strings.Contains(n, "алматы ") || strings.Contains(n, " almaty")
|
||||
}
|
||||
func isSpecificAlmatyRegion(n string) bool {
|
||||
return strings.Contains(n, "алматы облы") || strings.Contains(n, "алматинская") || strings.Contains(n, "almaty region") || strings.Contains(n, "almaty oblast")
|
||||
}
|
||||
func isSpecificAlmatyCity(n string) bool {
|
||||
return strings.Contains(n, "город алматы") || strings.Contains(n, "алматы қаласы") || strings.Contains(n, "almaty city")
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package region
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"ai-operator/internal/dialogue/state"
|
||||
)
|
||||
|
||||
func TestCatalogCountsAndNames(t *testing.T) {
|
||||
r := NewDefaultResolver()
|
||||
if got := len(r.ListEnabled()); got != 20 {
|
||||
t.Fatalf("enabled=%d", got)
|
||||
}
|
||||
if got := len(r.ListDisabled()); got != 1 {
|
||||
t.Fatalf("disabled=%d", got)
|
||||
}
|
||||
city, oblast := 0, 0
|
||||
seen := map[string]bool{}
|
||||
for _, reg := range r.ListEnabled() {
|
||||
if reg.Code == "" || seen[reg.Code] {
|
||||
t.Fatalf("bad code %q", reg.Code)
|
||||
}
|
||||
seen[reg.Code] = true
|
||||
if reg.DisplayNameRU == "" || reg.DisplayNameKK == "" {
|
||||
t.Fatalf("missing display names for %s", reg.Code)
|
||||
}
|
||||
switch reg.Type {
|
||||
case RegionTypeRepublicCity:
|
||||
city++
|
||||
case RegionTypeOblast:
|
||||
oblast++
|
||||
}
|
||||
}
|
||||
if city != 3 || oblast != 17 {
|
||||
t.Fatalf("city=%d oblast=%d", city, oblast)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizer(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
" Г. Алматы! ": "город алматы",
|
||||
"Алматинская обл.": "алматинская обл",
|
||||
"Нұр-Сұлтан": "нұр сұлтан",
|
||||
"Восточно-Казахстанская": "восточно казахстанская",
|
||||
"Қазақша, Астана": "қазақша астана",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := Normalize(input); got != want {
|
||||
t.Fatalf("Normalize(%q)=%q want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepublicCityDetection(t *testing.T) {
|
||||
r := NewDefaultResolver()
|
||||
cases := map[string]string{
|
||||
"астана": "astana_city",
|
||||
"город астана": "astana_city",
|
||||
"астана қаласы": "astana_city",
|
||||
"astana": "astana_city",
|
||||
"нурсултан": "astana_city",
|
||||
"нұр-сұлтан": "astana_city",
|
||||
"город алматы": "almaty_city",
|
||||
"алматы қаласы": "almaty_city",
|
||||
"шымкент": "shymkent_city",
|
||||
"шимкент": "shymkent_city",
|
||||
"shymkent": "shymkent_city",
|
||||
}
|
||||
for input, want := range cases {
|
||||
got := r.Resolve(input, SourceCLI)
|
||||
if got.RegionCode != want || got.Confidence < MediumConfidence {
|
||||
t.Fatalf("%q => %+v want %s", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOblastDetection(t *testing.T) {
|
||||
r := NewDefaultResolver()
|
||||
cases := map[string]string{
|
||||
"Акмолинская область": "akmola_region",
|
||||
"Ақмола облысы": "akmola_region",
|
||||
"Актюбинская область": "aktobe_region",
|
||||
"Ақтөбе облысы": "aktobe_region",
|
||||
"Алматинская область": "almaty_region",
|
||||
"Алматы облысы": "almaty_region",
|
||||
"Атырауская область": "atyrau_region",
|
||||
"Атырау облысы": "atyrau_region",
|
||||
"Восточно-Казахстанская область": "east_kazakhstan_region",
|
||||
"Шығыс Қазақстан облысы": "east_kazakhstan_region",
|
||||
"Жамбылская область": "zhambyl_region",
|
||||
"Жамбыл облысы": "zhambyl_region",
|
||||
"Западно-Казахстанская область": "west_kazakhstan_region",
|
||||
"Батыс Қазақстан облысы": "west_kazakhstan_region",
|
||||
"Карагандинская область": "karaganda_region",
|
||||
"Қарағанды облысы": "karaganda_region",
|
||||
"Костанайская область": "kostanay_region",
|
||||
"Қостанай облысы": "kostanay_region",
|
||||
"Кызылординская область": "kyzylorda_region",
|
||||
"Қызылорда облысы": "kyzylorda_region",
|
||||
"Мангистауская область": "mangystau_region",
|
||||
"Маңғыстау облысы": "mangystau_region",
|
||||
"Павлодарская область": "pavlodar_region",
|
||||
"Павлодар облысы": "pavlodar_region",
|
||||
"Северо-Казахстанская область": "north_kazakhstan_region",
|
||||
"Солтүстік Қазақстан облысы": "north_kazakhstan_region",
|
||||
"Туркестанская область": "turkistan_region",
|
||||
"Түркістан облысы": "turkistan_region",
|
||||
"область Абай": "abai_region",
|
||||
"Абай облысы": "abai_region",
|
||||
"область Жетісу": "jetisu_region",
|
||||
"Жетісу облысы": "jetisu_region",
|
||||
"область Ұлытау": "ulytau_region",
|
||||
"Ұлытау облысы": "ulytau_region",
|
||||
}
|
||||
for input, want := range cases {
|
||||
got := r.Resolve(input, SourceCLI)
|
||||
if got.RegionCode != want || got.Confidence < MediumConfidence {
|
||||
t.Fatalf("%q => %+v want %s", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbbreviations(t *testing.T) {
|
||||
r := NewDefaultResolver()
|
||||
cases := map[string]string{"ВКО": "east_kazakhstan_region", "ЗКО": "west_kazakhstan_region", "СКО": "north_kazakhstan_region", "vko": "east_kazakhstan_region", "zko": "west_kazakhstan_region", "sko": "north_kazakhstan_region"}
|
||||
for input, want := range cases {
|
||||
if got := r.Resolve(input, SourceCLI); got.RegionCode != want || got.ReasonCode != "abbreviation" {
|
||||
t.Fatalf("%q => %+v want %s", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlmatyAmbiguityAndClarification(t *testing.T) {
|
||||
r := NewDefaultResolver()
|
||||
for _, input := range []string{"Алматы", "almaty", "Алматы тарифы"} {
|
||||
got := r.Resolve(input, SourceCLI)
|
||||
if !got.NeedsClarification || got.ReasonCode != "ambiguous_almaty" || len(got.Candidates) != 2 {
|
||||
t.Fatalf("%q => %+v", input, got)
|
||||
}
|
||||
}
|
||||
pending := []string{"almaty_city", "almaty_region"}
|
||||
if got := r.ResolvePending(pending, "город", SourceCLI); got.RegionCode != "almaty_city" {
|
||||
t.Fatalf("city clarification: %+v", got)
|
||||
}
|
||||
if got := r.ResolvePending(pending, "қала", SourceCLI); got.RegionCode != "almaty_city" {
|
||||
t.Fatalf("kk city clarification: %+v", got)
|
||||
}
|
||||
if got := r.ResolvePending(pending, "область", SourceCLI); got.RegionCode != "almaty_region" {
|
||||
t.Fatalf("region clarification: %+v", got)
|
||||
}
|
||||
if got := r.ResolvePending(pending, "облыс", SourceCLI); got.RegionCode != "almaty_region" {
|
||||
t.Fatalf("kk region clarification: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFalsePositivesAndDisabled(t *testing.T) {
|
||||
r := NewDefaultResolver()
|
||||
for _, input := range []string{"Казахтелеком", "Казахстан", "область", "город", "мой тариф", "астана балет", "карагандинский уголь", "у меня вопрос по шымкентскому номеру", "алматинский район"} {
|
||||
got := r.Resolve(input, SourceCLI)
|
||||
if got.RegionCode != "" {
|
||||
t.Fatalf("false positive %q => %+v", input, got)
|
||||
}
|
||||
}
|
||||
got := r.Resolve("Байконур", SourceCLI)
|
||||
if got.Intent != IntentUnsupported || got.ReasonCode != "disabled_region" {
|
||||
t.Fatalf("disabled: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectionPolicy(t *testing.T) {
|
||||
r := NewDefaultResolver()
|
||||
astana := r.Resolve("Астана", SourceCLI)
|
||||
if d := ShouldApplyRegionResolution(SelectionContext{CurrentState: state.StateLanguageSelection, CurrentLanguage: state.LanguageUnknown}, astana); d.Apply || d.ReasonCode != "language_required" {
|
||||
t.Fatalf("language selection applied region: %+v", d)
|
||||
}
|
||||
if d := ShouldApplyRegionResolution(SelectionContext{CurrentState: state.StateRegionSelection, CurrentLanguage: state.LanguageRU}, astana); !d.Apply || d.RegionCode != "astana_city" {
|
||||
t.Fatalf("region selection did not apply: %+v", d)
|
||||
}
|
||||
amb := r.Resolve("Алматы", SourceCLI)
|
||||
if d := ShouldApplyRegionResolution(SelectionContext{CurrentState: state.StateRegionSelection, CurrentLanguage: state.LanguageRU}, amb); !d.NeedsClarification {
|
||||
t.Fatalf("ambiguous not clarified: %+v", d)
|
||||
}
|
||||
if d := ShouldApplyRegionResolution(SelectionContext{CurrentState: state.StateReadyToHelp, CurrentLanguage: state.LanguageRU, CurrentRegionCode: "astana_city", AllowChange: true}, astana); d.Apply {
|
||||
t.Fatalf("random mention switched: %+v", d)
|
||||
}
|
||||
astana.Intent = IntentRegionChange
|
||||
if d := ShouldApplyRegionResolution(SelectionContext{CurrentState: state.StateReadyToHelp, CurrentLanguage: state.LanguageRU, CurrentRegionCode: "shymkent_city", AllowChange: true}, astana); !d.Apply {
|
||||
t.Fatalf("explicit change not applied: %+v", d)
|
||||
}
|
||||
if d := ShouldApplyRegionResolution(SelectionContext{CurrentState: state.StateEnded, CurrentLanguage: state.LanguageRU}, astana); d.Apply {
|
||||
t.Fatalf("ended applied: %+v", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package region
|
||||
|
||||
import "ai-operator/internal/dialogue/state"
|
||||
|
||||
type Language = state.Language
|
||||
|
||||
type RegionStatus = state.RegionStatus
|
||||
|
||||
const (
|
||||
RegionUnknown = state.RegionUnknown
|
||||
RegionPendingClarification = state.RegionPendingClarification
|
||||
RegionSelected = state.RegionSelected
|
||||
)
|
||||
|
||||
type RegionType string
|
||||
|
||||
const (
|
||||
RegionTypeRepublicCity RegionType = "republic_city"
|
||||
RegionTypeOblast RegionType = "oblast"
|
||||
RegionTypeSpecial RegionType = "special"
|
||||
)
|
||||
|
||||
type Region struct {
|
||||
Code string
|
||||
Type RegionType
|
||||
NameRU string
|
||||
NameKK string
|
||||
DisplayNameRU string
|
||||
DisplayNameKK string
|
||||
CapitalRU string
|
||||
CapitalKK string
|
||||
AliasesRU []string
|
||||
AliasesKK []string
|
||||
AliasesLatin []string
|
||||
LegacyAliases []string
|
||||
AmbiguityGroup string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type ResolutionSource string
|
||||
|
||||
const (
|
||||
SourceUserText ResolutionSource = "user_text"
|
||||
SourceToolArgs ResolutionSource = "tool_args"
|
||||
SourceTranscript ResolutionSource = "transcript"
|
||||
SourceCLI ResolutionSource = "cli"
|
||||
)
|
||||
|
||||
type ResolutionIntent string
|
||||
|
||||
const (
|
||||
IntentRegionSelect ResolutionIntent = "region_select"
|
||||
IntentRegionChange ResolutionIntent = "region_change"
|
||||
IntentNotRegion ResolutionIntent = "not_region"
|
||||
IntentAmbiguous ResolutionIntent = "ambiguous"
|
||||
IntentUnsupported ResolutionIntent = "unsupported"
|
||||
)
|
||||
|
||||
type Candidate struct {
|
||||
Region Region
|
||||
Score float64
|
||||
MatchedAlias string
|
||||
ReasonCode string
|
||||
}
|
||||
|
||||
type ResolutionResult struct {
|
||||
RegionCode string
|
||||
Region *Region
|
||||
Intent ResolutionIntent
|
||||
Confidence float64
|
||||
Candidates []Candidate
|
||||
MatchedPhrase string
|
||||
NormalizedText string
|
||||
Source ResolutionSource
|
||||
ReasonCode string
|
||||
NeedsClarification bool
|
||||
ClarificationMessageKey string
|
||||
}
|
||||
|
||||
const (
|
||||
HighConfidence = 0.90
|
||||
MediumConfidence = 0.70
|
||||
LowConfidence = 0.50
|
||||
)
|
||||
|
||||
type SelectionContext struct {
|
||||
CurrentState state.ConversationState
|
||||
CurrentLanguage state.Language
|
||||
CurrentRegionCode string
|
||||
AllowChange bool
|
||||
}
|
||||
|
||||
type RegionDecision struct {
|
||||
Apply bool
|
||||
RegionCode string
|
||||
NeedsClarification bool
|
||||
Candidates []Candidate
|
||||
ReasonCode string
|
||||
MessageKey string
|
||||
}
|
||||
Reference in New Issue
Block a user