- match: require candidate ordinal to equal the query ordinal in fuzzy search (deterministic typo recall 73% -> 80%, no wrong houses) - resolver: compatibleVariant guard — LLM may only fix street spelling, keeping the same words, ordinal and house unit; damerau distance for transpositions - llm: prompt moved out of code (go:embed default_prompt.txt) with llm.system_prompt / llm.system_prompt_file overrides; guided JSON parsing tolerant of thinking blocks - address: recognize ordinals appearing after the street name - tests: first coverage for the LLM adapter and config prompt loading; README Measured (sample 200): deterministic 94.7-95.8% exact / 0 wrong across 4 seeds; LLM+guard typo 90.7-89.8% / 0 wrong. LLM fallback latency ~1.2-1.9s.
156 lines
4.4 KiB
Go
156 lines
4.4 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|