Files
ai-operator/internal/dialogue/region/resolver.go
T

276 lines
8.4 KiB
Go

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