Files
addrkit/internal/domain/address/normalize.go
T
addrkit 3be331bb65 addrkit: deterministic address resolution service (Go)
- domain: address normalization/parsing, matching with explicit statuses
- adapters: SQLite registry store, ArcGIS outages client, optional LLM rewriter, HTTP API
- seed pipeline with duplicate handling and unparsed reporting
- CLI (seed/verify/resolve), systemd unit, OpenAPI contract, tests
2026-10-09 20:05:32 +07:00

103 lines
2.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package address
import (
"strings"
"unicode"
"golang.org/x/text/cases"
"golang.org/x/text/unicode/norm"
)
// fold is a Unicode-aware case folder. It lower-cases Cyrillic and Latin
// alike, which is what we want for matching.
var fold = cases.Fold()
// NormalizeText folds a free-form string into a canonical form suitable for
// parsing and matching.
//
// The transformation is intentionally lossy:
// - Unicode NFKC (so lookalike characters collapse);
// - case folding (so "СТАНИЦИОННАЯ" == "станционная");
// - "ё" -> "е" (users and ASR disagree about the diaeresis);
// - dashes and various Unicode spaces are unified;
// - punctuation that only separates tokens becomes a space;
// - runs of whitespace collapse to a single space.
//
// NormalizeText is idempotent: NormalizeText(NormalizeText(s)) == NormalizeText(s).
func NormalizeText(s string) string {
s = norm.NFKC.String(s)
s = fold.String(s)
s = strings.ReplaceAll(s, "ё", "е")
s = mapSeparators(s)
s = strings.Join(strings.Fields(s), " ")
return strings.TrimSpace(s)
}
// mapSeparators unifies characters that carry no semantic meaning for address
// matching. It never removes digits, letters or the '/' house separator.
func mapSeparators(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch {
case r == '\u00a0' || unicode.IsSpace(r):
b.WriteByte(' ')
case isDash(r):
b.WriteByte('-')
case isSeparatorPunct(r):
b.WriteByte(' ')
default:
b.WriteRune(r)
}
}
return b.String()
}
// isDash reports whether r is one of the many dash-like code points that users
// and copy-paste bring in: "2–я" with an en dash is still an ordinal.
func isDash(r rune) bool {
switch r {
case '-', '\u2010', '\u2011', '\u2012', '\u2013', '\u2014', '\u2212':
return true
default:
return false
}
}
// isSeparatorPunct reports whether r separates tokens. Dots are included so
// that abbreviations like "ул." normalize to their stem ("ул").
func isSeparatorPunct(r rune) bool {
switch r {
case '.', ',', ';', '(', ')', '"', '\'', '«', '»', ':', '!', '?':
return true
default:
return false
}
}
// isDigits reports whether s is a non-empty run of ASCII digits.
func isDigits(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
// trimLeadingZeros removes redundant leading zeros from a numeric token:
// "007" -> "7". It leaves non-numeric strings untouched.
func trimLeadingZeros(s string) string {
if !isDigits(s) {
return s
}
t := strings.TrimLeft(s, "0")
if t == "" {
return "0"
}
return t
}