276 lines
8.4 KiB
Go
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: ®, 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")
|
|
}
|