sync: migrate ai-operator to Gitea (2026-08-10)
This commit is contained in:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user