- 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.
315 lines
9.3 KiB
Go
315 lines
9.3 KiB
Go
// Package match resolves a ParsedAddress against a canonical registry index.
|
|
//
|
|
// It is the deterministic heart of addrkit. It knows nothing about storage,
|
|
// HTTP or LLMs: it operates on an in-memory index built from []address.Entry
|
|
// and always answers from that data. A wrong house number is never invented —
|
|
// when the exact house unit is not present the result is "partial", and when
|
|
// several streets are plausible the result is "ambiguous".
|
|
package match
|
|
|
|
import (
|
|
"sort"
|
|
|
|
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
|
|
)
|
|
|
|
// Candidate is one plausible match, returned alongside a non-"found" result so
|
|
// the caller (or an operator) can see what the resolver was choosing between.
|
|
type Candidate struct {
|
|
Address string `json:"address"`
|
|
District string `json:"district,omitempty"`
|
|
House string `json:"house,omitempty"`
|
|
Score float64 `json:"score"`
|
|
}
|
|
|
|
// Result is the outcome of a resolution.
|
|
type Result struct {
|
|
Status address.Status `json:"status"`
|
|
Address string `json:"address,omitempty"`
|
|
District string `json:"district,omitempty"`
|
|
Confidence float64 `json:"confidence"`
|
|
Candidates []Candidate `json:"candidates,omitempty"`
|
|
DataVersion string `json:"data_version,omitempty"`
|
|
}
|
|
|
|
// Options tunes matching behaviour.
|
|
type Options struct {
|
|
// FuzzyThreshold is the minimum similarity (0..1) for a street to be
|
|
// accepted as a typo-tolerant match. Below it the result is not_found.
|
|
FuzzyThreshold float64
|
|
// AmbiguityMargin: streets within this similarity of the best match make
|
|
// the result ambiguous instead of silently picking one.
|
|
AmbiguityMargin float64
|
|
// MaxCandidates caps the candidates returned to the caller.
|
|
MaxCandidates int
|
|
}
|
|
|
|
// DefaultOptions returns conservative defaults.
|
|
//
|
|
// The ambiguity margin is deliberately generous relative to the token scores:
|
|
// a one-edit match scores 0.95 and a two-edit match 0.90, so a 0.06 margin
|
|
// makes near-collisions (e.g. "Елоховская" vs "Волховская") ambiguous rather
|
|
// than silently picking the closer-but-wrong street.
|
|
func DefaultOptions() Options {
|
|
return Options{
|
|
FuzzyThreshold: 0.82,
|
|
AmbiguityMargin: 0.06,
|
|
MaxCandidates: 5,
|
|
}
|
|
}
|
|
|
|
type streetRef struct {
|
|
key string
|
|
ordinal string
|
|
nameKey string
|
|
streetType string
|
|
}
|
|
|
|
// Index is an immutable, in-memory registry index. It is safe for concurrent
|
|
// reads once constructed.
|
|
type Index struct {
|
|
version string
|
|
opts Options
|
|
byKey map[string][]address.Entry
|
|
streets []streetRef
|
|
}
|
|
|
|
// NewIndex builds an index from canonical entries. Entries sharing a StreetKey
|
|
// are grouped; distinct streets are collected for typo-tolerant matching.
|
|
func NewIndex(version string, entries []address.Entry, opts Options) *Index {
|
|
ix := &Index{
|
|
version: version,
|
|
opts: opts,
|
|
byKey: make(map[string][]address.Entry, len(entries)),
|
|
}
|
|
seen := make(map[string]struct{}, len(entries))
|
|
for _, e := range entries {
|
|
ix.byKey[e.StreetKey] = append(ix.byKey[e.StreetKey], e)
|
|
if _, ok := seen[e.StreetKey]; !ok {
|
|
seen[e.StreetKey] = struct{}{}
|
|
ix.streets = append(ix.streets, streetRef{
|
|
key: e.StreetKey,
|
|
ordinal: e.Ordinal,
|
|
nameKey: e.NameKey,
|
|
streetType: e.StreetType,
|
|
})
|
|
}
|
|
}
|
|
sort.Slice(ix.streets, func(i, j int) bool { return ix.streets[i].key < ix.streets[j].key })
|
|
return ix
|
|
}
|
|
|
|
// Version returns the data version the index was built from.
|
|
func (ix *Index) Version() string { return ix.version }
|
|
|
|
// Size returns the number of indexed entries.
|
|
func (ix *Index) Size() int {
|
|
n := 0
|
|
for _, v := range ix.byKey {
|
|
n += len(v)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// Resolve resolves a parsed address. It never returns an address that is not
|
|
// present in the index.
|
|
func (ix *Index) Resolve(p address.ParsedAddress) Result {
|
|
res := Result{DataVersion: ix.version}
|
|
|
|
entries := ix.byKey[p.StreetKey]
|
|
similarity := 1.0
|
|
|
|
if len(entries) == 0 {
|
|
var ambiguousKeys []string
|
|
entries, similarity, ambiguousKeys = ix.fuzzyStreets(p)
|
|
if len(ambiguousKeys) > 0 {
|
|
res.Status = address.StatusAmbiguous
|
|
res.Confidence = similarity
|
|
res.Candidates = ix.representatives(ambiguousKeys, similarity)
|
|
return res
|
|
}
|
|
}
|
|
if len(entries) == 0 {
|
|
res.Status = address.StatusNotFound
|
|
return res
|
|
}
|
|
|
|
if !p.HasHouse {
|
|
// Street is known but the caller did not supply a house: safe but
|
|
// incomplete.
|
|
res.Status = address.StatusPartial
|
|
res.Confidence = clamp01(similarity * 0.8)
|
|
res.Candidates = candidates(entries, similarity, ix.opts.MaxCandidates)
|
|
return res
|
|
}
|
|
|
|
matched := matchHouseUnit(entries, p)
|
|
switch len(matched) {
|
|
case 1:
|
|
res.Status = address.StatusFound
|
|
res.Address = matched[0].Address
|
|
res.District = matched[0].District
|
|
res.Confidence = clamp01(similarity)
|
|
return res
|
|
case 0:
|
|
// Street recognised, exact house unit absent, or several units share
|
|
// the same number (ambiguous at the unit level).
|
|
if sameHouseSeveral(entries, p) {
|
|
res.Status = address.StatusAmbiguous
|
|
res.Confidence = clamp01(similarity * 0.9)
|
|
res.Candidates = candidates(matchHouseUnitAll(entries, p), similarity, ix.opts.MaxCandidates)
|
|
return res
|
|
}
|
|
res.Status = address.StatusPartial
|
|
res.Confidence = clamp01(similarity * 0.9)
|
|
res.Candidates = candidates(entries, similarity, ix.opts.MaxCandidates)
|
|
return res
|
|
default:
|
|
res.Status = address.StatusAmbiguous
|
|
res.Confidence = clamp01(similarity * 0.9)
|
|
res.Candidates = candidates(matched, similarity, ix.opts.MaxCandidates)
|
|
return res
|
|
}
|
|
}
|
|
|
|
// fuzzyStreets finds streets similar to the query. It returns the entries of
|
|
// the best street when the match is unambiguous, or the list of competing
|
|
// street keys when several are within the ambiguity margin.
|
|
func (ix *Index) fuzzyStreets(p address.ParsedAddress) (entries []address.Entry, similarity float64, ambiguousKeys []string) {
|
|
if p.NameKey == "" {
|
|
return nil, 0, nil
|
|
}
|
|
best := 0.0
|
|
bestKey := ""
|
|
for _, s := range ix.streets {
|
|
// If the query carries an ordinal, the candidate's ordinal must match:
|
|
// a user who says "1-й Северный" does not mean the plain "Северная".
|
|
if !ordinalCompatible(p.Ordinal, s.ordinal) {
|
|
continue
|
|
}
|
|
if sim := nameSimilarity(p.NameKey, s.nameKey); sim > best {
|
|
best = sim
|
|
bestKey = s.key
|
|
}
|
|
}
|
|
if best < ix.opts.FuzzyThreshold {
|
|
return nil, best, nil
|
|
}
|
|
|
|
for _, s := range ix.streets {
|
|
if !ordinalCompatible(p.Ordinal, s.ordinal) {
|
|
continue
|
|
}
|
|
if nameSimilarity(p.NameKey, s.nameKey) >= best-ix.opts.AmbiguityMargin {
|
|
ambiguousKeys = append(ambiguousKeys, s.key)
|
|
}
|
|
}
|
|
if len(ambiguousKeys) > 1 {
|
|
return nil, best, ambiguousKeys
|
|
}
|
|
return ix.byKey[bestKey], best, nil
|
|
}
|
|
|
|
// ordinalCompatible reports whether a candidate street may compete with the
|
|
// query given the query's ordinal. When the query specifies an ordinal, only
|
|
// candidates with the same ordinal are allowed; otherwise any candidate is.
|
|
func ordinalCompatible(queryOrdinal, candidateOrdinal string) bool {
|
|
if queryOrdinal == "" {
|
|
return true
|
|
}
|
|
return queryOrdinal == candidateOrdinal
|
|
}
|
|
|
|
// representatives returns one candidate per competing street, which is far
|
|
// more useful to a user than five houses of a single street.
|
|
func (ix *Index) representatives(keys []string, score float64) []Candidate {
|
|
sort.Strings(keys)
|
|
out := make([]Candidate, 0, len(keys))
|
|
for _, k := range keys {
|
|
es := ix.byKey[k]
|
|
if len(es) == 0 {
|
|
continue
|
|
}
|
|
e := es[0]
|
|
out = append(out, Candidate{
|
|
Address: e.Address,
|
|
District: e.District,
|
|
House: e.House,
|
|
Score: clamp01(score),
|
|
})
|
|
if len(out) == ix.opts.MaxCandidates {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// matchHouseUnit returns entries whose house unit equals p's, exactly.
|
|
func matchHouseUnit(entries []address.Entry, p address.ParsedAddress) []address.Entry {
|
|
var out []address.Entry
|
|
for _, e := range entries {
|
|
if houseUnitEqual(e, p) {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// matchHouseUnitAll returns all entries for entries that share the same house
|
|
// number as p (used to surface ambiguity).
|
|
func matchHouseUnitAll(entries []address.Entry, p address.ParsedAddress) []address.Entry {
|
|
var out []address.Entry
|
|
for _, e := range entries {
|
|
if e.House == p.House && e.HouseFrac == p.HouseFrac && e.HouseLetter == p.HouseLetter {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sameHouseSeveral reports whether more than one entry matches the house
|
|
// number but with differing buildings/letters, i.e. the unit is ambiguous.
|
|
func sameHouseSeveral(entries []address.Entry, p address.ParsedAddress) bool {
|
|
return len(matchHouseUnitAll(entries, p)) > 1
|
|
}
|
|
|
|
// houseUnitEqual compares the parts of a house unit. Unspecified (empty) parts
|
|
// must also be empty on the entry side, so "60/1" never matches "60/1 к148".
|
|
func houseUnitEqual(e address.Entry, p address.ParsedAddress) bool {
|
|
return e.House == p.House &&
|
|
e.HouseFrac == p.HouseFrac &&
|
|
e.HouseLetter == p.HouseLetter &&
|
|
e.Building == p.Building &&
|
|
e.BuildingFrac == p.BuildingFrac &&
|
|
e.BuildingLetter == p.BuildingLetter
|
|
}
|
|
|
|
func candidates(entries []address.Entry, score float64, limit int) []Candidate {
|
|
if limit <= 0 || len(entries) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]Candidate, 0, len(entries))
|
|
for _, e := range entries {
|
|
out = append(out, Candidate{
|
|
Address: e.Address,
|
|
District: e.District,
|
|
House: e.House,
|
|
Score: clamp01(score),
|
|
})
|
|
if len(out) == limit {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func clamp01(v float64) float64 {
|
|
if v < 0 {
|
|
return 0
|
|
}
|
|
if v > 1 {
|
|
return 1
|
|
}
|
|
return v
|
|
}
|