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) } // 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 } // NewResolver builds a resolver. rewriter may be nil to disable rewriting. func NewResolver(index *Index, rewriter Rewriter) *Resolver { return &Resolver{index: index, rewriter: rewriter} } // 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 } // Resolve parses raw and resolves it, consulting the rewriter when enabled. func (r *Resolver) Resolve(ctx context.Context, raw string) Result { parsed := address.Parse(raw) 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 || len(alternatives) == 0 { return res } best := res for _, alt := range alternatives { variant := address.Parse(alt) // 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 } // 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 !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 } minLen := len([]rune(a)) if lb := len([]rune(b)); lb < minLen { minLen = lb } 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 } // 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 } }