package match import ( "context" "strings" "git.totmin.ru/en2zmax/addrkit/internal/domain/address" ) // Rewriter is an optional, pluggable query rewriter. Its only job is to // propose alternative surface forms of a user address (for example an LLM // normalising "кошурникова одиннадцать дробь один" to "Кошевого 11/1"). The // rewriter never decides the answer: every proposal is parsed, validated and // resolved deterministically, and only entries that exist in the index can be // returned. // // It is defined here (consumer side) so the domain stays free of any transport // or vendor dependency. type Rewriter interface { Rewrite(ctx context.Context, raw string) ([]string, error) } // Parser is the address parser used by the resolver. It is defined here // (consumer side) so the resolver can be tested with a stub and so a // deployment can plug in its own dictionary. type Parser interface { Parse(raw string) address.ParsedAddress ParseInCity(raw, city string) address.ParsedAddress MatchCity(query string) (address.City, bool) } // ResolverOptions tunes optional resolver behaviour. type ResolverOptions struct { // Parser defaults to address.DefaultParser(). Parser Parser // OnRewriteError, when set, observes failures of the optional rewriter. // The resolver always keeps the deterministic result, but operators still // need to know that the fallback is broken. OnRewriteError func(error) } // Resolver wraps an Index with an optional Rewriter. // // The deterministic path is always tried first. The rewriter is consulted only // when the deterministic result is not a confident "found", and its // suggestions must pass compatibleVariant before being considered, so the // model cannot substitute a different street or corrupt the house unit. type Resolver struct { index *Index rewriter Rewriter parser Parser onRewriteError func(error) } // NewResolver builds a resolver with the default parser and no rewriter // diagnostics. func NewResolver(index *Index, rewriter Rewriter) *Resolver { return NewResolverWithOptions(index, rewriter, ResolverOptions{}) } // NewResolverWithOptions builds a resolver with explicit options. func NewResolverWithOptions(index *Index, rewriter Rewriter, opts ResolverOptions) *Resolver { parser := opts.Parser if parser == nil { parser = address.DefaultParser() } return &Resolver{ index: index, rewriter: rewriter, parser: parser, onRewriteError: opts.OnRewriteError, } } // Version returns the current data version. func (r *Resolver) Version() string { return r.index.Version() } // Ready reports whether the resolver has a usable index. func (r *Resolver) Ready() bool { return r.index != nil && r.index.Size() > 0 } // Cities returns the canonical city names present in the index. func (r *Resolver) Cities() []string { if r.index == nil { return nil } return r.index.Cities() } // MatchCity reports whether query is a city name/alias and returns the // canonical name. It uses the resolver's parser dictionary, not the index, so // it also recognizes aliases for cities that have no rows yet. func (r *Resolver) MatchCity(query string) (string, bool) { city, ok := r.parser.MatchCity(query) if !ok { return "", false } return city.Name, true } // Resolve parses raw and resolves it, consulting the rewriter when enabled. func (r *Resolver) Resolve(ctx context.Context, raw string) Result { return r.ResolveInCity(ctx, raw, "") } // ResolveInCity is Resolve with an explicit city hint. The hint overrides a // city found in the address text and lets clients pass the city as a separate // field. func (r *Resolver) ResolveInCity(ctx context.Context, raw, city string) Result { parsed := r.parse(raw, city) res := r.index.Resolve(parsed) if res.Status == address.StatusFound || r.rewriter == nil { return res } if res.Status != address.StatusNotFound && res.Status != address.StatusAmbiguous { return res } alternatives, err := r.rewriter.Rewrite(ctx, raw) if err != nil { if r.onRewriteError != nil { r.onRewriteError(err) } return res } if len(alternatives) == 0 { return res } best := res for _, alt := range alternatives { variant := r.parse(alt, city) // The embedded prompt asks the model to strip administrative words // (including the city), so a correct variant often has no city. It // inherits the original's city; only a conflicting explicit city is a // disqualification. Without this, the rewriter silently contributes // nothing in multi-city deployments (where default_city is empty). if variant.CityKey == "" && parsed.CityKey != "" { variant = r.parser.ParseInCity(alt, parsed.City) } // The model may fix the spelling of the street, nothing else. if !compatibleVariant(parsed, variant) { continue } candidate := r.index.Resolve(variant) if better(candidate, best) { best = candidate } } return best } func (r *Resolver) parse(raw, city string) address.ParsedAddress { if strings.TrimSpace(city) != "" { return r.parser.ParseInCity(raw, city) } return r.parser.Parse(raw) } // compatibleVariant reports whether an LLM rewrite may be considered. A rewrite // is acceptable only when it: // - keeps the exact house unit (number, fraction, letter, corpus, structure // ignored) — so an apartment is never turned into a corpus; // - keeps the ordinal — so "2-я Сухарная" is not collapsed to "Сухарная"; // - has the same words in the street name, each within a small edit distance // — so a different street can never be substituted. func compatibleVariant(orig, variant address.ParsedAddress) bool { if orig.CityKey != variant.CityKey { return false } if !sameHouseUnit(orig, variant) { return false } if orig.Ordinal != variant.Ordinal { return false } to := strings.Fields(orig.NameKey) tv := strings.Fields(variant.NameKey) if len(to) != len(tv) { return false } // NameKey is sorted, so tokens align one-to-one. for i := range to { if !nearToken(to[i], tv[i]) { return false } } return true } // nearToken allows a small number of edits (including transpositions) between // two name tokens, scaled to their length. func nearToken(a, b string) bool { if a == b { return true } ra, rb := len([]rune(a)), len([]rune(b)) if ra == 0 || rb == 0 || ra > maxTokenRunes || rb > maxTokenRunes { return false } minLen := ra if rb < minLen { minLen = rb } d := damerau(a, b) switch { case d <= 1 && minLen >= 4: return true case d <= 2 && minLen >= 8: return true default: return false } } func sameHouseUnit(a, b address.ParsedAddress) bool { return a.House == b.House && a.HouseFrac == b.HouseFrac && a.HouseLetter == b.HouseLetter && a.Building == b.Building && a.BuildingFrac == b.BuildingFrac && a.BuildingLetter == b.BuildingLetter && a.Structure == b.Structure } // better ranks results: a stronger status wins, then higher confidence. func better(a, b Result) bool { ra, rb := statusRank(a.Status), statusRank(b.Status) if ra != rb { return ra > rb } return a.Confidence > b.Confidence } func statusRank(s address.Status) int { switch s { case address.StatusFound: return 3 case address.StatusAmbiguous: return 2 case address.StatusPartial: return 1 default: return 0 } }