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:
addrkit
2026-10-09 20:40:20 +07:00
parent 5cc3556ac5
commit 78691f3a22
17 changed files with 590 additions and 48 deletions
+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)