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.
This commit is contained in:
@@ -182,9 +182,9 @@ func (ix *Index) fuzzyStreets(p address.ParsedAddress) (entries []address.Entry,
|
||||
best := 0.0
|
||||
bestKey := ""
|
||||
for _, s := range ix.streets {
|
||||
// If both sides carry an ordinal and they differ, the streets are
|
||||
// different even if the names rhyme.
|
||||
if p.Ordinal != "" && s.ordinal != "" && p.Ordinal != s.ordinal {
|
||||
// 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 {
|
||||
@@ -197,7 +197,7 @@ func (ix *Index) fuzzyStreets(p address.ParsedAddress) (entries []address.Entry,
|
||||
}
|
||||
|
||||
for _, s := range ix.streets {
|
||||
if p.Ordinal != "" && s.ordinal != "" && p.Ordinal != s.ordinal {
|
||||
if !ordinalCompatible(p.Ordinal, s.ordinal) {
|
||||
continue
|
||||
}
|
||||
if nameSimilarity(p.NameKey, s.nameKey) >= best-ix.opts.AmbiguityMargin {
|
||||
@@ -210,6 +210,16 @@ func (ix *Index) fuzzyStreets(p address.ParsedAddress) (entries []address.Entry,
|
||||
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 {
|
||||
|
||||
@@ -28,6 +28,7 @@ func buildIndex(t *testing.T, addresses ...string) *Index {
|
||||
func TestResolveStatuses(t *testing.T) {
|
||||
ix := buildIndex(t,
|
||||
"Станционная улица 1/1",
|
||||
"2-я Станционная улица 1/1",
|
||||
"Станционная улица 2/1",
|
||||
"Станционная улица 3/1 к1",
|
||||
"Станционная улица 3/1 к2",
|
||||
@@ -44,7 +45,7 @@ func TestResolveStatuses(t *testing.T) {
|
||||
name: "found with ordinal, fraction and structure noise",
|
||||
query: "2-я Станционная улица 1/1 стр.",
|
||||
wantStatus: address.StatusFound,
|
||||
wantAddr: "Станционная улица 1/1",
|
||||
wantAddr: "2-я Станционная улица 1/1",
|
||||
},
|
||||
{
|
||||
name: "found exact house",
|
||||
@@ -60,9 +61,9 @@ func TestResolveStatuses(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "typo in street name tolerated",
|
||||
query: "станционая 1/1",
|
||||
query: "искитимсая 5",
|
||||
wantStatus: address.StatusFound,
|
||||
wantAddr: "Станционная улица 1/1",
|
||||
wantAddr: "Искитимская улица 5",
|
||||
},
|
||||
{
|
||||
name: "missing house is partial",
|
||||
|
||||
@@ -52,6 +52,45 @@ func ratio(a, b string) float64 {
|
||||
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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package match
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
|
||||
)
|
||||
@@ -9,8 +10,9 @@ import (
|
||||
// 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 and resolved
|
||||
// deterministically, and only entries that exist in the index can be returned.
|
||||
// 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.
|
||||
@@ -22,7 +24,8 @@ type Rewriter interface {
|
||||
//
|
||||
// The deterministic path is always tried first. The rewriter is consulted only
|
||||
// when the deterministic result is not a confident "found", and its
|
||||
// suggestions can only ever turn a miss into a match against real data.
|
||||
// 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
|
||||
@@ -58,7 +61,12 @@ func (r *Resolver) Resolve(ctx context.Context, raw string) Result {
|
||||
|
||||
best := res
|
||||
for _, alt := range alternatives {
|
||||
candidate := r.index.Resolve(address.Parse(alt))
|
||||
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
|
||||
}
|
||||
@@ -66,6 +74,64 @@ func (r *Resolver) Resolve(ctx context.Context, raw string) Result {
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package match
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
|
||||
)
|
||||
|
||||
// TestCompatibleVariant encodes the safety contract for LLM rewrites: only a
|
||||
// spelling fix of the same street, with the same ordinal and house unit, may be
|
||||
// considered.
|
||||
func TestCompatibleVariant(t *testing.T) {
|
||||
orig := address.Parse("2-я Сухарная улица 95")
|
||||
tests := []struct {
|
||||
name string
|
||||
variant string
|
||||
want bool
|
||||
}{
|
||||
{"spelling fix (transposition)", "2-я Сухраная улица 95", true},
|
||||
{"reordered words", "Сухарная 2-я улица 95", true},
|
||||
{"ordinal dropped", "Сухарная улица 95", false},
|
||||
{"different street", "Королева улица 95", false},
|
||||
{"house changed", "2-я Сухарная улица 96", false},
|
||||
{"building added", "2-я Сухарная улица 95 к1", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := compatibleVariant(orig, address.Parse(tt.variant))
|
||||
if got != tt.want {
|
||||
t.Errorf("compatibleVariant(%q) = %v, want %v", tt.variant, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompatibleVariantProtectsApartment ensures "кв 5" is not folded into a
|
||||
// corpus "к5" by the model.
|
||||
func TestCompatibleVariantProtectsApartment(t *testing.T) {
|
||||
orig := address.Parse("ул. Елоховская, д. 43 кв. 5")
|
||||
if compatibleVariant(orig, address.Parse("ул. Елоховская 43 к5")) {
|
||||
t.Fatal("apartment must not be rewritten as a corpus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDamerau(t *testing.T) {
|
||||
if got := damerau("гоглоя", "гоголя"); got != 1 {
|
||||
t.Errorf("transposition distance = %d, want 1", got)
|
||||
}
|
||||
if got := damerau("сееврный", "северная"); got < 2 {
|
||||
t.Errorf("expected a larger distance for a different word, got %d", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user