Files
Maksim Totmin 17ff5c591f refactor!: make addrkit a multi-city address normalizer
- remove the events/outages module, ArcGIS adapter, portal config and event statuses
- add a configurable city dictionary/parser and make City part of StreetKey
- seed: read city/city_name, namespace IDs by city, keep structures distinct
- fix reliability: schema v2 with fail-closed load, deterministic ordering,
  input limits, config validation, atomic DB publish, LLM response caps
- add read-only shadow mode comparing the old ES path with addrkit
- breaking: registry.db must be re-seeded; config has no portal section
2026-10-27 14:35:51 +07:00

247 lines
7.2 KiB
Go

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