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
This commit is contained in:
addrkit
2026-10-09 20:05:32 +07:00
commit 3be331bb65
34 changed files with 3446 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
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
}
+143
View File
@@ -0,0 +1,143 @@
package address
import (
"regexp"
"sort"
"strings"
)
// The house block is parsed from the end of the string, component by
// component, from the outermost (structure) to the innermost (house). Working
// right-to-left is what lets us tell a genuine house number apart from a digit
// that is part of the street name (e.g. "2-я Станционная улица 1/1 стр.").
var (
// "... стр." / "... строение 3" / "... сооружение" / "... с 2"
reStructure = regexp.MustCompile(`(?:^|\s)(?:стр|строение|сооружение|с)\.?\s*(\d+)?$`)
// "... к9/1" / "... корпус 2а" / "... кД" / "... к 1/4"
reBuilding = regexp.MustCompile(`(?:^|\s)(?:к|корп|корпус)\.?\s*(\d*)\s*([а-я])?(?:/(\d+)\s*([а-я])?)?$`)
// "... 1/1" / "... 33а" / "... 12 б" / "... 45а/4" / "... 1/1а"
reHouse = regexp.MustCompile(`(?:^|\s)(\d+)\s*([а-я])?(?:/(\d+)\s*([а-я])?)?$`)
// "2-я", "10-й", "3-е"
reOrdinalToken = regexp.MustCompile(`^(\d+)-[а-я]{1,3}$`)
)
// Parse decomposes a free-form address into its structured components. It is
// total: unparseable input still yields a non-nil ParsedAddress whose street
// part is best-effort and whose HasHouse is false.
func Parse(raw string) ParsedAddress {
normalized := NormalizeText(raw)
p := ParsedAddress{Raw: raw, Normalized: normalized}
rest := normalized
if g, start, ok := matchSuffix(reStructure, rest); ok {
p.Structure = trimLeadingZeros(g[1])
rest = strings.TrimSpace(rest[:start])
}
if g, start, ok := matchSuffix(reBuilding, rest); ok {
// The regex also matches a bare "к"; only accept it when a digit or a
// letter actually follows, otherwise it is not a corpus marker.
if g[1] != "" || g[2] != "" {
p.Building = trimLeadingZeros(g[1])
p.BuildingLetter = firstNonEmpty(g[2], g[4])
p.BuildingFrac = trimLeadingZeros(g[3])
rest = strings.TrimSpace(rest[:start])
}
}
if g, start, ok := matchSuffix(reHouse, rest); ok {
p.House = trimLeadingZeros(g[1])
p.HouseLetter = firstNonEmpty(g[2], g[4])
p.HouseFrac = trimLeadingZeros(g[3])
p.HasHouse = true
rest = strings.TrimSpace(rest[:start])
}
parseStreet(&p, rest)
return p
}
// firstNonEmpty returns the first non-empty argument. House/building letters
// can appear either before or after the fraction ("45а/4" vs "1/1а").
func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
// parseStreet fills the street-related fields of p from the leftover string
// (the address with its house block removed).
func parseStreet(p *ParsedAddress, s string) {
tokens := strings.Fields(s)
if len(tokens) == 0 {
p.StreetKey = streetKey("", "")
return
}
name := make([]string, 0, len(tokens))
typeFound := false
for _, tok := range tokens {
// Only the first recognised type token is treated as the type; a
// second occurrence is part of the name.
if !typeFound {
if t, ok := streetTypes[tok]; ok {
p.StreetType = t
typeFound = true
continue
}
}
name = append(name, tok)
}
name = extractOrdinal(p, name)
p.NameDisplay = strings.Join(name, " ")
sorted := append([]string(nil), name...)
sort.Strings(sorted)
p.NameKey = strings.Join(sorted, " ")
p.StreetKey = streetKey(p.Ordinal, p.NameKey)
}
// extractOrdinal recognises a leading ordinal ("2-я", "2 я", "вторая") and
// removes it from the name tokens.
func extractOrdinal(p *ParsedAddress, tokens []string) []string {
if len(tokens) == 0 {
return tokens
}
if m := reOrdinalToken.FindStringSubmatch(tokens[0]); m != nil {
p.Ordinal = normalizeOrdinal(m[1])
return tokens[1:]
}
if isDigits(tokens[0]) && len(tokens) >= 2 && ordinalSuffixes[tokens[1]] {
p.Ordinal = normalizeOrdinal(tokens[0])
return tokens[2:]
}
if d, ok := ordinalWords[tokens[0]]; ok {
p.Ordinal = normalizeOrdinal(d)
return tokens[1:]
}
return tokens
}
// 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.
func matchSuffix(re *regexp.Regexp, s string) (groups []string, start int, ok bool) {
for _, loc := range re.FindAllStringSubmatchIndex(s, -1) {
if loc[1] != len(s) {
continue
}
n := len(loc) / 2
groups = make([]string, n)
for i := 0; i < n; i++ {
if loc[2*i] >= 0 {
groups[i] = s[loc[2*i]:loc[2*i+1]]
}
}
return groups, loc[0], true
}
return nil, 0, false
}
+148
View File
@@ -0,0 +1,148 @@
package address
import "testing"
func TestParseHouseBlock(t *testing.T) {
tests := []struct {
name string
raw string
wantOrdinal string
wantName string
wantType string
wantHouse string
wantFrac string
wantLetter string
wantBuild string
wantBFrac string
wantBLetter string
wantStruct string
wantHas bool
}{
{
name: "ordinal, fraction and structure",
raw: "2-я Станционная улица 1/1 стр.",
wantOrdinal: "2", wantName: "станционная", wantType: "улица",
wantHouse: "1", wantFrac: "1", wantStruct: "", wantHas: true,
},
{
name: "building with fraction and structure",
raw: "Станционная улица 30а к9/1 стр.",
wantName: "станционная", wantType: "улица",
wantHouse: "30", wantLetter: "а", wantBuild: "9", wantBFrac: "1", wantHas: true,
},
{
name: "letter glued to house",
raw: "10-й Бронный переулок 33а",
wantOrdinal: "10", wantName: "бронный", wantType: "переулок",
wantHouse: "33", wantLetter: "а", wantHas: true,
},
{
name: "microdistrict, two-word name",
raw: "Зеленый бор микрорайон 13",
wantName: "бор зеленый", wantType: "микрорайон",
wantHouse: "13", wantHas: true,
},
{
name: "spelled-out ordinal",
raw: "вторая Станционная улица 1",
wantOrdinal: "2", wantName: "станционная", wantType: "улица",
wantHouse: "1", wantHas: true,
},
{
name: "en dash variant",
raw: "2–я Станционная улица 1",
wantOrdinal: "2", wantName: "станционная", wantType: "улица",
wantHouse: "1", wantHas: true,
},
{
name: "no house",
raw: "Станционная улица",
wantName: "станционная", wantType: "улица", wantHas: false,
},
{
name: "structure with number only",
raw: "Станционная улица 3 стр.",
wantName: "станционная", wantType: "улица",
wantHouse: "3", wantStruct: "", wantHas: true,
},
{
name: "letter before fraction",
raw: "Выборная улица 45а/4",
wantName: "выборная", wantType: "улица",
wantHouse: "45", wantLetter: "а", wantFrac: "4", wantHas: true,
},
{
name: "corpus letter without digit",
raw: "1-я Ельцовка улица 1 кД",
wantOrdinal: "1", wantName: "ельцовка", wantType: "улица",
wantHouse: "1", wantBLetter: "д", wantHas: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Parse(tt.raw)
if got.Ordinal != tt.wantOrdinal {
t.Errorf("Ordinal = %q, want %q", got.Ordinal, tt.wantOrdinal)
}
if got.NameKey != tt.wantName {
t.Errorf("NameKey = %q, want %q", got.NameKey, tt.wantName)
}
if got.StreetType != tt.wantType {
t.Errorf("StreetType = %q, want %q", got.StreetType, tt.wantType)
}
if got.House != tt.wantHouse {
t.Errorf("House = %q, want %q", got.House, tt.wantHouse)
}
if got.HouseFrac != tt.wantFrac {
t.Errorf("HouseFrac = %q, want %q", got.HouseFrac, tt.wantFrac)
}
if got.HouseLetter != tt.wantLetter {
t.Errorf("HouseLetter = %q, want %q", got.HouseLetter, tt.wantLetter)
}
if got.Building != tt.wantBuild {
t.Errorf("Building = %q, want %q", got.Building, tt.wantBuild)
}
if got.BuildingFrac != tt.wantBFrac {
t.Errorf("BuildingFrac = %q, want %q", got.BuildingFrac, tt.wantBFrac)
}
if got.BuildingLetter != tt.wantBLetter {
t.Errorf("BuildingLetter = %q, want %q", got.BuildingLetter, tt.wantBLetter)
}
if got.Structure != tt.wantStruct {
t.Errorf("Structure = %q, want %q", got.Structure, tt.wantStruct)
}
if got.HasHouse != tt.wantHas {
t.Errorf("HasHouse = %v, want %v", got.HasHouse, tt.wantHas)
}
})
}
}
// TestParseDeterministic guards the core invariant: parsing the same input
// always yields the same structured result and the same street key.
func TestParseDeterministic(t *testing.T) {
input := "2-я Станционная улица 1/1 стр."
first := Parse(input)
for i := 0; i < 100; i++ {
if got := Parse(input); got.StreetKey != first.StreetKey || got.House != first.House {
t.Fatalf("non-deterministic parse: %+v vs %+v", got, first)
}
}
}
func TestNormalizeIdempotent(t *testing.T) {
inputs := []string{
" 2-Я Станционная\u00a0улица 1/1 стр. ",
"ЁЛКИ д. 5, кв. 3",
"СТАНИЦИОННАЯ — улица, 30а",
"",
}
for _, in := range inputs {
once := NormalizeText(in)
twice := NormalizeText(once)
if once != twice {
t.Errorf("NormalizeText not idempotent for %q: %q != %q", in, once, twice)
}
}
}
+66
View File
@@ -0,0 +1,66 @@
package address
import "strings"
// streetTypes maps a surface form (including common abbreviations) to a single
// canonical street type. The type is informational for matching (we strip it
// from the key) but useful for disambiguation and display.
var streetTypes = map[string]string{
"ул": "улица", "улица": "улица",
"пер": "переулок", "пер-к": "переулок", "переулок": "переулок",
"пр": "проезд", "проезд": "проезд",
"пр-т": "проспект", "просп": "проспект", "проспект": "проспект",
"ш": "шоссе", "шоссе": "шоссе",
"мкр": "микрорайон", "микрорайон": "микрорайон",
"туп": "тупик", "тупик": "тупик",
"спуск": "спуск",
"б-р": "бульвар", "бул": "бульвар", "бульвар": "бульвар",
"пл": "площадь", "площадь": "площадь",
"пос": "поселок", "поселок": "поселок",
"тракт": "тракт",
"аллея": "аллея",
"наб": "набережная", "набережная": "набережная",
"кольцо": "кольцо",
"просека": "просека",
"квартал": "квартал",
"территория": "территория",
"дорога": "дорога",
"разъезд": "разъезд",
}
// ordinalWords maps spelled-out ordinals to their digit form. Only the forms
// that actually occur in practice are listed; unknown words are treated as
// part of the street name.
var ordinalWords = map[string]string{
"первый": "1", "первая": "1", "первое": "1",
"второй": "2", "вторая": "2", "второе": "2",
"третий": "3", "третья": "3", "третье": "3",
"четвертый": "4", "четвертая": "4", "четвертое": "4",
"пятый": "5", "пятая": "5", "пятое": "5",
"шестой": "6", "шестая": "6", "шестое": "6",
"седьмой": "7", "седьмая": "7", "седьмое": "7",
"восьмой": "8", "восьмая": "8", "восьмое": "8",
"девятый": "9", "девятая": "9", "девятое": "9",
"десятый": "10", "десятая": "10", "десятое": "10",
}
// ordinalSuffixes are the gender/number endings that may follow an ordinal
// digit when it is written as a separate token ("2 я", "10 й").
var ordinalSuffixes = map[string]bool{
"я": true, "й": true, "е": true,
"ая": true, "ый": true, "ой": true, "ое": true,
"ий": true, "ья": true, "ье": true, "ьи": true,
}
// streetKey builds the canonical, type-independent street key:
// "<ordinal>|<sorted name tokens>". The type word is dropped so that a user
// who omits it ("станционная 1" instead of "Станционная улица 1") still
// matches; type confusion is surfaced later as ambiguity.
func streetKey(ordinal, nameKey string) string {
return ordinal + "|" + nameKey
}
// normalizeOrdinal trims leading zeros so "02-я" and "2-я" agree.
func normalizeOrdinal(s string) string {
return trimLeadingZeros(strings.TrimSpace(s))
}
+89
View File
@@ -0,0 +1,89 @@
// Package address contains the deterministic address domain: text
// normalization, structural parsing and the canonical data types shared by the
// rest of addrkit.
//
// The package is deliberately free of any I/O (no HTTP, no database, no
// filesystem). Everything here is a pure function of its inputs, which makes
// the core trivially testable and reusable by the service, the CLI and tests.
package address
// Status is the machine-readable outcome of an address resolution.
//
// It is intentionally explicit: callers must never have to guess whether an
// empty result means "not found" or "the upstream failed".
type Status string
const (
// StatusFound means the street and the exact house unit were resolved.
StatusFound Status = "found"
// StatusPartial means the street was resolved but the house unit was not
// confirmed. The answer is safe but incomplete; the caller may ask the
// user to clarify the house number.
StatusPartial Status = "partial"
// StatusAmbiguous means more than one plausible match exists and the
// resolver refuses to guess.
StatusAmbiguous Status = "ambiguous"
// StatusNotFound means nothing in the registry matched.
StatusNotFound Status = "not_found"
// StatusPortalTimeout means the external outages portal did not answer in
// time. It is an infrastructure outcome, not "no outages".
StatusPortalTimeout Status = "portal_timeout"
// StatusInfra is a generic terminal infrastructure failure.
StatusInfra Status = "infra_error"
)
// ParsedAddress is the structured form of a free-form address string.
//
// The parser is lossy on purpose: punctuation, case and spacing are folded
// away so that different surface forms of the same address converge on the
// same StreetKey.
type ParsedAddress struct {
Raw string `json:"raw"`
Normalized string `json:"normalized"`
// Street components.
//
// NameKey is the order-insensitive canonical name (tokens sorted), so
// "Станционная 2-я" and "2-я Станционная" produce the same key.
Ordinal string `json:"ordinal,omitempty"`
NameKey string `json:"name_key,omitempty"`
NameDisplay string `json:"name_display,omitempty"`
StreetType string `json:"street_type,omitempty"`
StreetKey string `json:"street_key"`
// House unit components. A house "1/1 к2 стр.3" decomposes into
// House=1, HouseFrac=1, Building=2, Structure=3.
HasHouse bool `json:"has_house"`
House string `json:"house,omitempty"`
HouseFrac string `json:"house_frac,omitempty"`
HouseLetter string `json:"house_letter,omitempty"`
Building string `json:"building,omitempty"`
BuildingFrac string `json:"building_frac,omitempty"`
BuildingLetter string `json:"building_letter,omitempty"`
Structure string `json:"structure,omitempty"`
}
// Entry is a single canonical row from the address registry.
//
// Entries are produced at seed time by parsing the registry's display address
// with the very same parser used at query time, so index and query always
// agree on how an address is decomposed.
type Entry struct {
ID string `json:"id"`
Address string `json:"address"`
District string `json:"district,omitempty"`
Ordinal string `json:"ordinal,omitempty"`
NameKey string `json:"name_key,omitempty"`
NameDisplay string `json:"name_display,omitempty"`
StreetType string `json:"street_type,omitempty"`
StreetKey string `json:"street_key"`
House string `json:"house,omitempty"`
HouseFrac string `json:"house_frac,omitempty"`
HouseLetter string `json:"house_letter,omitempty"`
Building string `json:"building,omitempty"`
BuildingFrac string `json:"building_frac,omitempty"`
BuildingLetter string `json:"building_letter,omitempty"`
Structure string `json:"structure,omitempty"`
}