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
+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 {