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