Files
addrkit/internal/domain/match/levenshtein.go
T
addrkit 78691f3a22 addrkit: strict ordinal matching, LLM-variant guard, externalized prompt
- 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.
2026-10-09 20:40:20 +07:00

163 lines
3.5 KiB
Go

package match
import "strings"
// levenshtein returns the edit distance between two strings, measured in
// runes. It uses the rolling-row formulation: O(min(n,m)) memory.
//
// The resolver uses it only to tolerate typos in the *street name*; house
// numbers are always compared exactly, because a wrong house is worse than a
// missing one.
func levenshtein(a, b string) int {
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min3(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
}
// ratio returns a 0..1 similarity where 1 means identical. It is
// 1 - distance/max(len), which is stable and easy to reason about.
func ratio(a, b string) float64 {
if a == b {
return 1
}
la, lb := len([]rune(a)), len([]rune(b))
m := la
if lb > m {
m = lb
}
if m == 0 {
return 1
}
return 1 - float64(levenshtein(a, b))/float64(m)
}
// damerau returns the optimal string alignment (Damerau-Levenshtein) distance,
// where an adjacent transposition costs 1. Typos are very often transpositions
// ("Гоглоя" vs "Гоголя"), so counting them as a single edit matters.
func damerau(a, b string) int {
ra, rb := []rune(a), []rune(b)
la, lb := len(ra), len(rb)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
// Three rolling rows: d[i-2], d[i-1], d[i].
prev2 := make([]int, lb+1)
prev := make([]int, lb+1)
curr := make([]int, lb+1)
for j := 0; j <= lb; j++ {
prev[j] = j
}
for i := 1; i <= la; i++ {
curr[0] = i
for j := 1; j <= lb; j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
best := min3(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
if i > 1 && j > 1 && ra[i-1] == rb[j-2] && ra[i-2] == rb[j-1] {
if t := prev2[j-2] + 1; t < best {
best = t
}
}
curr[j] = best
}
prev2, prev, curr = prev, curr, prev2
}
return prev[lb]
}
func min3(a, b, c int) int {
m := a
if b < m {
m = b
}
if c < m {
m = c
}
return m
}
// tokenScore scores two name tokens. A single edit is tolerated for tokens of
// length >= 4 and two edits for length >= 8; otherwise it falls back to the
// plain ratio. Short names (<= 3 chars) are intentionally not fuzzy-matched:
// one edit is too large a fraction of the word to be safe.
func tokenScore(a, b string) float64 {
if a == b {
return 1
}
d := levenshtein(a, b)
minLen := len([]rune(a))
if lb := len([]rune(b)); lb < minLen {
minLen = lb
}
switch {
case d == 1 && minLen >= 4:
return 0.95
case d == 2 && minLen >= 8:
return 0.90
default:
return ratio(a, b)
}
}
// nameSimilarity compares two canonical name keys (space-joined, sorted
// tokens) in a token-aware, order-insensitive way. A typo in one word of a
// multi-word name therefore does not sink the whole match, and reordered words
// still score 1.
func nameSimilarity(a, b string) float64 {
if a == b {
return 1
}
ta, tb := strings.Fields(a), strings.Fields(b)
if len(ta) == 0 || len(tb) == 0 {
return 0
}
sum, n := 0.0, 0
for _, x := range ta {
best := 0.0
for _, y := range tb {
if s := tokenScore(x, y); s > best {
best = s
}
}
sum += best
n++
}
for _, y := range tb {
best := 0.0
for _, x := range ta {
if s := tokenScore(x, y); s > best {
best = s
}
}
sum += best
n++
}
return sum / float64(n)
}