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:
Maksim Totmin
2026-10-09 21:40:46 +07:00
parent ba4705fc4f
commit f1537f1d6b
17 changed files with 590 additions and 48 deletions
+25 -2
View File
@@ -120,13 +120,16 @@ func parseStreet(p *ParsedAddress, s string) {
p.StreetKey = streetKey(p.Ordinal, p.NameKey)
}
// extractOrdinal recognises a leading ordinal ("2-я", "2 я", "вторая") and
// removes it from the name tokens.
// extractOrdinal recognises an ordinal and removes it from the name tokens.
// It handles both the usual leading form ("2-я Сухарная") and an ordinal that
// appears after the name ("Сухарная 2-я улица"), which both people and models
// produce.
func extractOrdinal(p *ParsedAddress, tokens []string) []string {
if len(tokens) == 0 {
return tokens
}
// Leading forms first: they are by far the most common.
if m := reOrdinalToken.FindStringSubmatch(tokens[0]); m != nil {
p.Ordinal = normalizeOrdinal(m[1])
return tokens[1:]
@@ -139,9 +142,29 @@ func extractOrdinal(p *ParsedAddress, tokens []string) []string {
p.Ordinal = normalizeOrdinal(d)
return tokens[1:]
}
// Otherwise look for an ordinal anywhere among the name tokens.
for i, tok := range tokens {
if m := reOrdinalToken.FindStringSubmatch(tok); m != nil {
p.Ordinal = normalizeOrdinal(m[1])
return removeRange(tokens, i, i+1)
}
if isDigits(tok) && i+1 < len(tokens) && ordinalSuffixes[tokens[i+1]] {
p.Ordinal = normalizeOrdinal(tok)
return removeRange(tokens, i, i+2)
}
}
return tokens
}
// removeRange returns a copy of tokens with [from, to) removed.
func removeRange(tokens []string, from, to int) []string {
out := make([]string, 0, len(tokens)-(to-from))
out = append(out, tokens[:from]...)
out = append(out, tokens[to:]...)
return out
}
// matchSuffix returns the capture groups of the match of re that ends exactly
// at the end of s, together with the byte offset where it starts. Go's regexp
// is leftmost-first, so we scan all matches and pick the right-anchored one.
+14 -4
View File
@@ -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 {
+4 -3
View File
@@ -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",
+39
View File
@@ -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 {
+70 -4
View File
@@ -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)
+52
View File
@@ -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)
}
}