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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// Package match resolves a ParsedAddress against a canonical registry index.
|
||||
//
|
||||
// It is the deterministic heart of addrkit. It knows nothing about storage,
|
||||
// HTTP or LLMs: it operates on an in-memory index built from []address.Entry
|
||||
// and always answers from that data. A wrong house number is never invented —
|
||||
// when the exact house unit is not present the result is "partial", and when
|
||||
// several streets are plausible the result is "ambiguous".
|
||||
package match
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
|
||||
)
|
||||
|
||||
// Candidate is one plausible match, returned alongside a non-"found" result so
|
||||
// the caller (or an operator) can see what the resolver was choosing between.
|
||||
type Candidate struct {
|
||||
Address string `json:"address"`
|
||||
District string `json:"district,omitempty"`
|
||||
House string `json:"house,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// Result is the outcome of a resolution.
|
||||
type Result struct {
|
||||
Status address.Status `json:"status"`
|
||||
Address string `json:"address,omitempty"`
|
||||
District string `json:"district,omitempty"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Candidates []Candidate `json:"candidates,omitempty"`
|
||||
DataVersion string `json:"data_version,omitempty"`
|
||||
}
|
||||
|
||||
// Options tunes matching behaviour.
|
||||
type Options struct {
|
||||
// FuzzyThreshold is the minimum similarity (0..1) for a street to be
|
||||
// accepted as a typo-tolerant match. Below it the result is not_found.
|
||||
FuzzyThreshold float64
|
||||
// AmbiguityMargin: streets within this similarity of the best match make
|
||||
// the result ambiguous instead of silently picking one.
|
||||
AmbiguityMargin float64
|
||||
// MaxCandidates caps the candidates returned to the caller.
|
||||
MaxCandidates int
|
||||
}
|
||||
|
||||
// DefaultOptions returns conservative defaults.
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
FuzzyThreshold: 0.82,
|
||||
AmbiguityMargin: 0.03,
|
||||
MaxCandidates: 5,
|
||||
}
|
||||
}
|
||||
|
||||
type streetRef struct {
|
||||
key string
|
||||
ordinal string
|
||||
nameKey string
|
||||
streetType string
|
||||
}
|
||||
|
||||
// Index is an immutable, in-memory registry index. It is safe for concurrent
|
||||
// reads once constructed.
|
||||
type Index struct {
|
||||
version string
|
||||
opts Options
|
||||
byKey map[string][]address.Entry
|
||||
streets []streetRef
|
||||
}
|
||||
|
||||
// NewIndex builds an index from canonical entries. Entries sharing a StreetKey
|
||||
// are grouped; distinct streets are collected for typo-tolerant matching.
|
||||
func NewIndex(version string, entries []address.Entry, opts Options) *Index {
|
||||
ix := &Index{
|
||||
version: version,
|
||||
opts: opts,
|
||||
byKey: make(map[string][]address.Entry, len(entries)),
|
||||
}
|
||||
seen := make(map[string]struct{}, len(entries))
|
||||
for _, e := range entries {
|
||||
ix.byKey[e.StreetKey] = append(ix.byKey[e.StreetKey], e)
|
||||
if _, ok := seen[e.StreetKey]; !ok {
|
||||
seen[e.StreetKey] = struct{}{}
|
||||
ix.streets = append(ix.streets, streetRef{
|
||||
key: e.StreetKey,
|
||||
ordinal: e.Ordinal,
|
||||
nameKey: e.NameKey,
|
||||
streetType: e.StreetType,
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Slice(ix.streets, func(i, j int) bool { return ix.streets[i].key < ix.streets[j].key })
|
||||
return ix
|
||||
}
|
||||
|
||||
// Version returns the data version the index was built from.
|
||||
func (ix *Index) Version() string { return ix.version }
|
||||
|
||||
// Size returns the number of indexed entries.
|
||||
func (ix *Index) Size() int {
|
||||
n := 0
|
||||
for _, v := range ix.byKey {
|
||||
n += len(v)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Resolve resolves a parsed address. It never returns an address that is not
|
||||
// present in the index.
|
||||
func (ix *Index) Resolve(p address.ParsedAddress) Result {
|
||||
res := Result{DataVersion: ix.version}
|
||||
|
||||
entries := ix.byKey[p.StreetKey]
|
||||
similarity := 1.0
|
||||
|
||||
if len(entries) == 0 {
|
||||
var ambiguousKeys []string
|
||||
entries, similarity, ambiguousKeys = ix.fuzzyStreets(p)
|
||||
if len(ambiguousKeys) > 0 {
|
||||
res.Status = address.StatusAmbiguous
|
||||
res.Confidence = similarity
|
||||
res.Candidates = ix.representatives(ambiguousKeys, similarity)
|
||||
return res
|
||||
}
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
res.Status = address.StatusNotFound
|
||||
return res
|
||||
}
|
||||
|
||||
if !p.HasHouse {
|
||||
// Street is known but the caller did not supply a house: safe but
|
||||
// incomplete.
|
||||
res.Status = address.StatusPartial
|
||||
res.Confidence = clamp01(similarity * 0.8)
|
||||
res.Candidates = candidates(entries, similarity, ix.opts.MaxCandidates)
|
||||
return res
|
||||
}
|
||||
|
||||
matched := matchHouseUnit(entries, p)
|
||||
switch len(matched) {
|
||||
case 1:
|
||||
res.Status = address.StatusFound
|
||||
res.Address = matched[0].Address
|
||||
res.District = matched[0].District
|
||||
res.Confidence = clamp01(similarity)
|
||||
return res
|
||||
case 0:
|
||||
// Street recognised, exact house unit absent, or several units share
|
||||
// the same number (ambiguous at the unit level).
|
||||
if sameHouseSeveral(entries, p) {
|
||||
res.Status = address.StatusAmbiguous
|
||||
res.Confidence = clamp01(similarity * 0.9)
|
||||
res.Candidates = candidates(matchHouseUnitAll(entries, p), similarity, ix.opts.MaxCandidates)
|
||||
return res
|
||||
}
|
||||
res.Status = address.StatusPartial
|
||||
res.Confidence = clamp01(similarity * 0.9)
|
||||
res.Candidates = candidates(entries, similarity, ix.opts.MaxCandidates)
|
||||
return res
|
||||
default:
|
||||
res.Status = address.StatusAmbiguous
|
||||
res.Confidence = clamp01(similarity * 0.9)
|
||||
res.Candidates = candidates(matched, similarity, ix.opts.MaxCandidates)
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
// fuzzyStreets finds streets similar to the query. It returns the entries of
|
||||
// the best street when the match is unambiguous, or the list of competing
|
||||
// street keys when several are within the ambiguity margin.
|
||||
func (ix *Index) fuzzyStreets(p address.ParsedAddress) (entries []address.Entry, similarity float64, ambiguousKeys []string) {
|
||||
if p.NameKey == "" {
|
||||
return nil, 0, nil
|
||||
}
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
if sim := ratio(p.NameKey, s.nameKey); sim > best {
|
||||
best = sim
|
||||
bestKey = s.key
|
||||
}
|
||||
}
|
||||
if best < ix.opts.FuzzyThreshold {
|
||||
return nil, best, nil
|
||||
}
|
||||
|
||||
for _, s := range ix.streets {
|
||||
if p.Ordinal != "" && s.ordinal != "" && p.Ordinal != s.ordinal {
|
||||
continue
|
||||
}
|
||||
if ratio(p.NameKey, s.nameKey) >= best-ix.opts.AmbiguityMargin {
|
||||
ambiguousKeys = append(ambiguousKeys, s.key)
|
||||
}
|
||||
}
|
||||
if len(ambiguousKeys) > 1 {
|
||||
return nil, best, ambiguousKeys
|
||||
}
|
||||
return ix.byKey[bestKey], best, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
sort.Strings(keys)
|
||||
out := make([]Candidate, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
es := ix.byKey[k]
|
||||
if len(es) == 0 {
|
||||
continue
|
||||
}
|
||||
e := es[0]
|
||||
out = append(out, Candidate{
|
||||
Address: e.Address,
|
||||
District: e.District,
|
||||
House: e.House,
|
||||
Score: clamp01(score),
|
||||
})
|
||||
if len(out) == ix.opts.MaxCandidates {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchHouseUnit returns entries whose house unit equals p's, exactly.
|
||||
func matchHouseUnit(entries []address.Entry, p address.ParsedAddress) []address.Entry {
|
||||
var out []address.Entry
|
||||
for _, e := range entries {
|
||||
if houseUnitEqual(e, p) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchHouseUnitAll returns all entries for entries that share the same house
|
||||
// number as p (used to surface ambiguity).
|
||||
func matchHouseUnitAll(entries []address.Entry, p address.ParsedAddress) []address.Entry {
|
||||
var out []address.Entry
|
||||
for _, e := range entries {
|
||||
if e.House == p.House && e.HouseFrac == p.HouseFrac && e.HouseLetter == p.HouseLetter {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameHouseSeveral reports whether more than one entry matches the house
|
||||
// number but with differing buildings/letters, i.e. the unit is ambiguous.
|
||||
func sameHouseSeveral(entries []address.Entry, p address.ParsedAddress) bool {
|
||||
return len(matchHouseUnitAll(entries, p)) > 1
|
||||
}
|
||||
|
||||
// houseUnitEqual compares the parts of a house unit. Unspecified (empty) parts
|
||||
// must also be empty on the entry side, so "60/1" never matches "60/1 к148".
|
||||
func houseUnitEqual(e address.Entry, p address.ParsedAddress) bool {
|
||||
return e.House == p.House &&
|
||||
e.HouseFrac == p.HouseFrac &&
|
||||
e.HouseLetter == p.HouseLetter &&
|
||||
e.Building == p.Building &&
|
||||
e.BuildingFrac == p.BuildingFrac &&
|
||||
e.BuildingLetter == p.BuildingLetter
|
||||
}
|
||||
|
||||
func candidates(entries []address.Entry, score float64, limit int) []Candidate {
|
||||
if limit <= 0 || len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Candidate, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, Candidate{
|
||||
Address: e.Address,
|
||||
District: e.District,
|
||||
House: e.House,
|
||||
Score: clamp01(score),
|
||||
})
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package match
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
|
||||
)
|
||||
|
||||
// buildIndex parses canonical addresses into entries, mirroring what the seed
|
||||
// pipeline does, so tests exercise the real code path end to end.
|
||||
func buildIndex(t *testing.T, addresses ...string) *Index {
|
||||
t.Helper()
|
||||
entries := make([]address.Entry, 0, len(addresses))
|
||||
for i, a := range addresses {
|
||||
p := address.Parse(a)
|
||||
entries = append(entries, address.Entry{
|
||||
ID: a, Address: a, District: "Тестовый",
|
||||
Ordinal: p.Ordinal, NameKey: p.NameKey, StreetType: p.StreetType, StreetKey: p.StreetKey,
|
||||
House: p.House, HouseFrac: p.HouseFrac, HouseLetter: p.HouseLetter,
|
||||
Building: p.Building, BuildingFrac: p.BuildingFrac, BuildingLetter: p.BuildingLetter,
|
||||
Structure: p.Structure,
|
||||
})
|
||||
_ = i
|
||||
}
|
||||
return NewIndex("test", entries, DefaultOptions())
|
||||
}
|
||||
|
||||
func TestResolveStatuses(t *testing.T) {
|
||||
ix := buildIndex(t,
|
||||
"Станционная улица 1/1",
|
||||
"Станционная улица 2/1",
|
||||
"Станционная улица 3/1 к1",
|
||||
"Станционная улица 3/1 к2",
|
||||
"Искитимская улица 5",
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantStatus address.Status
|
||||
wantAddr string
|
||||
}{
|
||||
{
|
||||
name: "found with ordinal, fraction and structure noise",
|
||||
query: "2-я Станционная улица 1/1 стр.",
|
||||
wantStatus: address.StatusFound,
|
||||
wantAddr: "Станционная улица 1/1",
|
||||
},
|
||||
{
|
||||
name: "found exact house",
|
||||
query: "станционная 2/1",
|
||||
wantStatus: address.StatusFound,
|
||||
wantAddr: "Станционная улица 2/1",
|
||||
},
|
||||
{
|
||||
name: "found with corpus",
|
||||
query: "Станционная улица 3/1 корпус 1",
|
||||
wantStatus: address.StatusFound,
|
||||
wantAddr: "Станционная улица 3/1 к1",
|
||||
},
|
||||
{
|
||||
name: "typo in street name tolerated",
|
||||
query: "станционая 1/1",
|
||||
wantStatus: address.StatusFound,
|
||||
wantAddr: "Станционная улица 1/1",
|
||||
},
|
||||
{
|
||||
name: "missing house is partial",
|
||||
query: "станционная улица",
|
||||
wantStatus: address.StatusPartial,
|
||||
},
|
||||
{
|
||||
name: "wrong house fraction is partial",
|
||||
query: "станционная 1/2",
|
||||
wantStatus: address.StatusPartial,
|
||||
},
|
||||
{
|
||||
name: "ambiguous building",
|
||||
query: "станционная 3/1",
|
||||
wantStatus: address.StatusAmbiguous,
|
||||
},
|
||||
{
|
||||
name: "unknown street",
|
||||
query: "ленина 5",
|
||||
wantStatus: address.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := ix.Resolve(address.Parse(tt.query))
|
||||
if res.Status != tt.wantStatus {
|
||||
t.Fatalf("status = %q, want %q (candidates: %+v)", res.Status, tt.wantStatus, res.Candidates)
|
||||
}
|
||||
if tt.wantAddr != "" && res.Address != tt.wantAddr {
|
||||
t.Errorf("address = %q, want %q", res.Address, tt.wantAddr)
|
||||
}
|
||||
if res.DataVersion != "test" {
|
||||
t.Errorf("data version = %q, want test", res.DataVersion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveNeverInventsAddress is the central safety property: whenever the
|
||||
// status is "found", the returned address must exist in the index.
|
||||
func TestResolveNeverInventsAddress(t *testing.T) {
|
||||
addresses := []string{
|
||||
"Станционная улица 1/1",
|
||||
"Станционная улица 2/1",
|
||||
"Искитимская улица 5",
|
||||
}
|
||||
ix := buildIndex(t, addresses...)
|
||||
known := make(map[string]bool, len(addresses))
|
||||
for _, a := range addresses {
|
||||
known[a] = true
|
||||
}
|
||||
queries := []string{
|
||||
"2-я Станционная 1/1", "станционная 2/1 к9", "искитимская 5", "искитимская 5/1",
|
||||
"несуществующая 99", "станционная 1/1 стр.3", " ",
|
||||
}
|
||||
for _, q := range queries {
|
||||
res := ix.Resolve(address.Parse(q))
|
||||
if res.Status == address.StatusFound && !known[res.Address] {
|
||||
t.Fatalf("invented address %q for query %q", res.Address, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRatio(t *testing.T) {
|
||||
if got := ratio("станционная", "станционная"); got != 1 {
|
||||
t.Errorf("identical strings ratio = %v, want 1", got)
|
||||
}
|
||||
if got := ratio("станционная", "искитимская"); got > 0.6 {
|
||||
t.Errorf("unrelated strings ratio = %v, want < 0.6", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package match
|
||||
|
||||
// levenshtein returns the edit distance between two strings, measured in
|
||||
// runes. It uses the rolling-row formulation: O(min(n,m)) memory.
|
||||
//
|
||||
// The resolver uses it only to tolerate typos in the *street name*; house
|
||||
// numbers are always compared exactly, because a wrong house is worse than a
|
||||
// missing one.
|
||||
func levenshtein(a, b string) int {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
if len(ra) == 0 {
|
||||
return len(rb)
|
||||
}
|
||||
if len(rb) == 0 {
|
||||
return len(ra)
|
||||
}
|
||||
prev := make([]int, len(rb)+1)
|
||||
curr := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min3(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
|
||||
// ratio returns a 0..1 similarity where 1 means identical. It is
|
||||
// 1 - distance/max(len), which is stable and easy to reason about.
|
||||
func ratio(a, b string) float64 {
|
||||
if a == b {
|
||||
return 1
|
||||
}
|
||||
la, lb := len([]rune(a)), len([]rune(b))
|
||||
m := la
|
||||
if lb > m {
|
||||
m = lb
|
||||
}
|
||||
if m == 0 {
|
||||
return 1
|
||||
}
|
||||
return 1 - float64(levenshtein(a, b))/float64(m)
|
||||
}
|
||||
|
||||
func min3(a, b, c int) int {
|
||||
m := a
|
||||
if b < m {
|
||||
m = b
|
||||
}
|
||||
if c < m {
|
||||
m = c
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package match
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
|
||||
)
|
||||
|
||||
// Rewriter is an optional, pluggable query rewriter. Its only job is to
|
||||
// propose alternative surface forms of a user address (for example an LLM
|
||||
// normalising "кошурникова одиннадцать дробь один" to "Кошевого 11/1"). The
|
||||
// rewriter never decides the answer: every proposal is parsed and resolved
|
||||
// deterministically, and only entries that exist in the index can be returned.
|
||||
//
|
||||
// It is defined here (consumer side) so the domain stays free of any transport
|
||||
// or vendor dependency.
|
||||
type Rewriter interface {
|
||||
Rewrite(ctx context.Context, raw string) ([]string, error)
|
||||
}
|
||||
|
||||
// Resolver wraps an Index with an optional Rewriter.
|
||||
//
|
||||
// The deterministic path is always tried first. The rewriter is consulted only
|
||||
// when the deterministic result is not a confident "found", and its
|
||||
// suggestions can only ever turn a miss into a match against real data.
|
||||
type Resolver struct {
|
||||
index *Index
|
||||
rewriter Rewriter
|
||||
}
|
||||
|
||||
// NewResolver builds a resolver. rewriter may be nil to disable rewriting.
|
||||
func NewResolver(index *Index, rewriter Rewriter) *Resolver {
|
||||
return &Resolver{index: index, rewriter: rewriter}
|
||||
}
|
||||
|
||||
// Version returns the current data version.
|
||||
func (r *Resolver) Version() string { return r.index.Version() }
|
||||
|
||||
// Ready reports whether the resolver has a usable index.
|
||||
func (r *Resolver) Ready() bool { return r.index != nil && r.index.Size() > 0 }
|
||||
|
||||
// Resolve parses raw and resolves it, consulting the rewriter when enabled.
|
||||
func (r *Resolver) Resolve(ctx context.Context, raw string) Result {
|
||||
parsed := address.Parse(raw)
|
||||
res := r.index.Resolve(parsed)
|
||||
|
||||
if res.Status == address.StatusFound || r.rewriter == nil {
|
||||
return res
|
||||
}
|
||||
if res.Status != address.StatusNotFound && res.Status != address.StatusAmbiguous {
|
||||
return res
|
||||
}
|
||||
|
||||
alternatives, err := r.rewriter.Rewrite(ctx, raw)
|
||||
if err != nil || len(alternatives) == 0 {
|
||||
return res
|
||||
}
|
||||
|
||||
best := res
|
||||
for _, alt := range alternatives {
|
||||
candidate := r.index.Resolve(address.Parse(alt))
|
||||
if better(candidate, best) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// better ranks results: a stronger status wins, then higher confidence.
|
||||
func better(a, b Result) bool {
|
||||
ra, rb := statusRank(a.Status), statusRank(b.Status)
|
||||
if ra != rb {
|
||||
return ra > rb
|
||||
}
|
||||
return a.Confidence > b.Confidence
|
||||
}
|
||||
|
||||
func statusRank(s address.Status) int {
|
||||
switch s {
|
||||
case address.StatusFound:
|
||||
return 3
|
||||
case address.StatusAmbiguous:
|
||||
return 2
|
||||
case address.StatusPartial:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Package outages contains the deterministic, pure logic for selecting outages
|
||||
// that belong to a concrete house unit. It does not talk to the network: the
|
||||
// portal client lives in an adapter and feeds this package.
|
||||
package outages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
|
||||
)
|
||||
|
||||
// Outage is a single outage record as returned by the external portal.
|
||||
type Outage struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
System string `json:"system,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
StartDate string `json:"start_date,omitempty"`
|
||||
EndDate string `json:"end_date,omitempty"`
|
||||
Time string `json:"time,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
}
|
||||
|
||||
// Source is anything that can answer "what outages are there for this
|
||||
// canonical address". It is implemented by the ArcGIS portal adapter and by
|
||||
// fakes in tests.
|
||||
type Source interface {
|
||||
Query(ctx context.Context, canonical string) ([]Outage, error)
|
||||
}
|
||||
|
||||
// MatchingHouse keeps only the outages that address the same house unit as the
|
||||
// target. This is the fix for the classic bug where a substring match returned
|
||||
// a neighbouring house's outage: matching is done on the parsed structure, not
|
||||
// on the raw text.
|
||||
func MatchingHouse(list []Outage, target address.ParsedAddress) []Outage {
|
||||
out := make([]Outage, 0, len(list))
|
||||
for _, o := range list {
|
||||
op := address.Parse(o.Address)
|
||||
if houseUnitEqual(target, op) {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// houseUnitEqual compares two parsed house units. House number, fraction,
|
||||
// letter, building (corpus) and its fraction/letter must all match. The
|
||||
// structure (строение) is deliberately ignored: it is frequently omitted by
|
||||
// users and does not change which house an outage refers to.
|
||||
func houseUnitEqual(a, b address.ParsedAddress) bool {
|
||||
return a.House == b.House &&
|
||||
a.HouseFrac == b.HouseFrac &&
|
||||
a.HouseLetter == b.HouseLetter &&
|
||||
a.Building == b.Building &&
|
||||
a.BuildingFrac == b.BuildingFrac &&
|
||||
a.BuildingLetter == b.BuildingLetter
|
||||
}
|
||||
|
||||
// Current keeps outages whose [start, end] range covers now. The portal
|
||||
// already filters by date, but we re-check locally because the portal's notion
|
||||
// of "today" and ours must agree (Asia/Novosibirsk, UTC+7).
|
||||
func Current(list []Outage, now time.Time, loc *time.Location) []Outage {
|
||||
out := make([]Outage, 0, len(list))
|
||||
for _, o := range list {
|
||||
start, okStart := parsePortalTime(o.StartDate, loc)
|
||||
end, okEnd := parsePortalTime(o.EndDate, loc)
|
||||
if !okStart || !okEnd {
|
||||
// Undated records are kept: better to report a possibly-current
|
||||
// outage than to silently drop it.
|
||||
out = append(out, o)
|
||||
continue
|
||||
}
|
||||
if (now.Equal(start) || now.After(start)) && (now.Equal(end) || now.Before(end)) {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parsePortalTime accepts the shapes the portal may produce: epoch
|
||||
// milliseconds, epoch seconds, RFC3339 and a few common date formats. All
|
||||
// results are interpreted in loc.
|
||||
func parsePortalTime(s string, loc *time.Location) (time.Time, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
// Heuristic: 13+ digits is milliseconds, otherwise seconds.
|
||||
if n > 1e12 {
|
||||
return time.UnixMilli(n).In(loc), true
|
||||
}
|
||||
return time.Unix(n, 0).In(loc), true
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.ParseInLocation(layout, s, loc); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package outages
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
|
||||
)
|
||||
|
||||
func TestMatchingHouse(t *testing.T) {
|
||||
list := []Outage{
|
||||
{Address: "Станционная улица 1/1", System: "Теплоснабжение"},
|
||||
{Address: "Станционная улица 1/2", System: "Теплоснабжение"},
|
||||
{Address: "Станционная улица 1/1 к1", System: "Теплоснабжение"},
|
||||
{Address: "Станционная улица 10", System: "Теплоснабжение"},
|
||||
}
|
||||
target := address.Parse("2-я Станционная улица 1/1")
|
||||
|
||||
got := MatchingHouse(list, target)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected exactly 1 matching outage, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Address != "Станционная улица 1/1" {
|
||||
t.Errorf("matched wrong outage: %q", got[0].Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchingHouseIgnoresStructure(t *testing.T) {
|
||||
list := []Outage{{Address: "Станционная улица 1/1 стр.2"}}
|
||||
target := address.Parse("станционная 1/1 стр.3")
|
||||
if got := MatchingHouse(list, target); len(got) != 1 {
|
||||
t.Fatalf("structure must not affect house matching; got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrent(t *testing.T) {
|
||||
loc := time.FixedZone("NSK", 7*3600)
|
||||
now := time.Date(2026, 10, 9, 12, 0, 0, 0, loc)
|
||||
within := now.Add(-time.Hour).UnixMilli()
|
||||
after := now.Add(time.Hour).UnixMilli()
|
||||
before := now.Add(-48 * time.Hour).UnixMilli()
|
||||
|
||||
list := []Outage{
|
||||
{Address: "A", StartDate: itoa(within), EndDate: itoa(after)}, // current
|
||||
{Address: "B", StartDate: itoa(before), EndDate: itoa(before)}, // past
|
||||
{Address: "C", StartDate: "", EndDate: ""}, // undated -> kept
|
||||
}
|
||||
got := Current(list, now, loc)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 current outages, got %d: %+v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
Reference in New Issue
Block a user