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") }