Files
addrkit/internal/domain/address/parse.go
T
Maksim Totmin ba4705fc4f addrkit: quality evaluation harness + parser hardening
- add internal/eval: deterministic citizen-style perturbation harness
  (typos, case, word order, abbreviations, filler words, dropped units)
- parser: strip all street-type tokens, filler words (город/дом/д/область),
  trailing apartment (кв N); handle letter-before-fraction and corpus letters
- matching: token-aware typo-tolerant street similarity; wider ambiguity
  margin to surface near-collisions instead of guessing
- seed: correct parse-rate accounting, structure-aware de-duplication
- CLI: 'addrkit eval' command; README with measured results

Measured on the real 73,936-row registry: 99.98% parsed, 0 wrong-house
safety violations across 34k+ perturbed cases.
2026-10-09 21:40:46 +07:00

164 lines
5.5 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 (
"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}$`)
// Trailing apartment part: " кв 5", " квартира 12", " кв. 7а". Removed
// before house parsing so it cannot leak into the street name.
reApartment = regexp.MustCompile(`(?:^|\s)(?:квартира|кв)\.?(?:\s+\d+[а-я]?)?\s*$`)
)
// 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)
// A trailing apartment ("кв 5") is not part of the address identity.
normalized = strings.TrimSpace(reApartment.ReplaceAllString(normalized, ""))
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).
//
// All recognised type tokens are removed from the name. This matters for
// registry rows such as "Гусинобродский Тракт улица 21", where both "Тракт"
// and "улица" are type-like: keeping either would mismatch a citizen who simply
// says "Гусинобродский 21". The first type token becomes the canonical type.
// If removing them would leave the name empty (a street literally called
// "Набережная"), the type token is kept as the name.
func parseStreet(p *ParsedAddress, s string) {
tokens := strings.Fields(s)
name := make([]string, 0, len(tokens))
streetType := ""
for _, tok := range tokens {
// Filler tokens ("г", "город", "дом", "область", ...) never belong to
// the street name.
if fillerWords[tok] {
continue
}
if t, ok := streetTypes[tok]; ok {
if streetType == "" {
streetType = t
}
continue
}
name = append(name, tok)
}
p.StreetType = streetType
if len(name) == 0 && streetType != "" {
name = []string{streetType}
}
if len(name) == 0 {
p.StreetKey = streetKey("", "")
return
}
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
}