- remove the events/outages module, ArcGIS adapter, portal config and event statuses - add a configurable city dictionary/parser and make City part of StreetKey - seed: read city/city_name, namespace IDs by city, keep structures distinct - fix reliability: schema v2 with fail-closed load, deterministic ordering, input limits, config validation, atomic DB publish, LLM response caps - add read-only shadow mode comparing the old ES path with addrkit - breaking: registry.db must be re-seeded; config has no portal section
224 lines
6.5 KiB
Go
224 lines
6.5 KiB
Go
// Package seed turns a registry dump into canonical address entries. It is the
|
|
// only place that knows how the source data is shaped.
|
|
//
|
|
// The same parser used at query time is used here, so the index and the query
|
|
// never disagree about how an address decomposes.
|
|
package seed
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"io"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
|
|
)
|
|
|
|
// Source is one raw registry row.
|
|
type Source struct {
|
|
ID string
|
|
Address string
|
|
District string
|
|
// City is optional: when the dump is already split per city it may be
|
|
// omitted and supplied as the default city to Build.
|
|
City string
|
|
}
|
|
|
|
// Stats summarises a build and, crucially, surfaces rows the parser could not
|
|
// structure. A non-empty UnparsedCount is a data-quality signal worth alerting
|
|
// on, not an error.
|
|
type Stats struct {
|
|
Total int `json:"total"`
|
|
Parsed int `json:"parsed"`
|
|
Unparsed int `json:"unparsed"`
|
|
Duplicates int `json:"duplicates"`
|
|
DistinctStreets int `json:"distinct_streets"`
|
|
Cities map[string]int `json:"cities,omitempty"`
|
|
UnparsedSample []string `json:"unparsed_sample,omitempty"`
|
|
}
|
|
|
|
// BuildOptions controls how source rows become canonical entries.
|
|
type BuildOptions struct {
|
|
// Parser defaults to address.DefaultParser().
|
|
Parser *address.Parser
|
|
// DefaultCity is used for rows that neither carry a city field nor name a
|
|
// city in the address. Leave it empty for multi-city dumps where every row
|
|
// is expected to identify its city.
|
|
DefaultCity string
|
|
}
|
|
|
|
// ReadBulkNDJSON reads an Elasticsearch Bulk NDJSON dump: pairs of an action
|
|
// line ({"index":{"_id":...}}) followed by a source line
|
|
// ({"address":...,"district_name":...,"city":...}). Bare source lines (no
|
|
// action line) are also accepted, which makes plain JSONL usable too.
|
|
func ReadBulkNDJSON(r io.Reader) ([]Source, error) {
|
|
sc := bufio.NewScanner(r)
|
|
// 1 MiB per line is far beyond any realistic address row.
|
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
|
|
var (
|
|
sources []Source
|
|
pendingID string
|
|
)
|
|
for sc.Scan() {
|
|
line := strings.TrimSpace(sc.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var obj map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(line), &obj); err != nil {
|
|
continue // skip malformed lines rather than aborting the whole seed
|
|
}
|
|
|
|
if action, ok := obj["index"]; ok {
|
|
var a struct {
|
|
ID string `json:"_id"`
|
|
}
|
|
_ = json.Unmarshal(action, &a)
|
|
pendingID = a.ID
|
|
continue
|
|
}
|
|
|
|
if addrRaw, ok := obj["address"]; ok {
|
|
var addr string
|
|
if err := json.Unmarshal(addrRaw, &addr); err != nil || addr == "" {
|
|
continue
|
|
}
|
|
src := Source{ID: pendingID, Address: addr}
|
|
if d, ok := obj["district_name"]; ok {
|
|
_ = json.Unmarshal(d, &src.District)
|
|
}
|
|
src.City = firstString(obj, "city", "city_name")
|
|
sources = append(sources, src)
|
|
pendingID = ""
|
|
}
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return sources, nil
|
|
}
|
|
|
|
// firstString returns the first non-empty string field among names.
|
|
func firstString(obj map[string]json.RawMessage, names ...string) string {
|
|
for _, name := range names {
|
|
raw, ok := obj[name]
|
|
if !ok {
|
|
continue
|
|
}
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err == nil && strings.TrimSpace(s) != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Build parses every source row into a canonical Entry and reports statistics.
|
|
// Rows without a house number are still indexed for street lookups but counted
|
|
// as unparsed, since a registry row without a house is suspicious.
|
|
func Build(sources []Source, opts BuildOptions) ([]address.Entry, Stats) {
|
|
parser := opts.Parser
|
|
if parser == nil {
|
|
parser = address.NewParser(address.DefaultDictionary(), opts.DefaultCity)
|
|
}
|
|
entries := make([]address.Entry, 0, len(sources))
|
|
streets := make(map[string]struct{})
|
|
seen := make(map[string]struct{})
|
|
stats := Stats{Total: len(sources), Cities: make(map[string]int)}
|
|
|
|
for _, src := range sources {
|
|
// Precedence: an explicit source field wins over a city found in the
|
|
// address text; the parser's default city is the last resort. Passing
|
|
// an empty hint is what lets the parser apply that precedence.
|
|
cityHint := strings.TrimSpace(src.City)
|
|
p := parser.ParseInCity(src.Address, cityHint)
|
|
if p.NameKey == "" {
|
|
stats.Unparsed++
|
|
appendSample(&stats, src.Address)
|
|
continue
|
|
}
|
|
// Count parse quality on every source row, before de-duplication.
|
|
if p.HasHouse {
|
|
stats.Parsed++
|
|
} else {
|
|
stats.Unparsed++
|
|
appendSample(&stats, src.Address)
|
|
}
|
|
|
|
entry := address.Entry{
|
|
ID: namespacedID(src.ID, p.CityKey),
|
|
Address: src.Address,
|
|
District: src.District,
|
|
City: p.City,
|
|
CityKey: p.CityKey,
|
|
Ordinal: p.Ordinal,
|
|
NameKey: p.NameKey,
|
|
NameDisplay: p.NameDisplay,
|
|
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,
|
|
}
|
|
|
|
// The source registry contains rows that differ only by "стр."
|
|
// (structure). They are distinct buildings, so structure is part of
|
|
// the de-duplication key and an exact structure query can be honoured.
|
|
if key := dedupKey(entry); key != "" {
|
|
if _, ok := seen[key]; ok {
|
|
stats.Duplicates++
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
}
|
|
|
|
entries = append(entries, entry)
|
|
streets[p.StreetKey] = struct{}{}
|
|
stats.Cities[cityLabel(p.City)]++
|
|
}
|
|
stats.DistinctStreets = len(streets)
|
|
sort.Strings(stats.UnparsedSample)
|
|
return entries, stats
|
|
}
|
|
|
|
// namespacedID keeps source ids unique when several city registries are merged
|
|
// into one database. Source systems often number houses independently.
|
|
func namespacedID(id, cityKey string) string {
|
|
if id == "" || cityKey == "" {
|
|
return id
|
|
}
|
|
return cityKey + "/" + id
|
|
}
|
|
|
|
// dedupKey identifies the same house unit within the same city and street,
|
|
// including its structure and building components.
|
|
func dedupKey(e address.Entry) string {
|
|
if e.House == "" {
|
|
return ""
|
|
}
|
|
return strings.Join([]string{
|
|
e.CityKey, e.StreetKey, e.House, e.HouseFrac, e.HouseLetter,
|
|
e.Building, e.BuildingFrac, e.BuildingLetter, e.Structure,
|
|
}, "|")
|
|
}
|
|
|
|
func cityLabel(city string) string {
|
|
if city == "" {
|
|
return "(none)"
|
|
}
|
|
return city
|
|
}
|
|
|
|
func appendSample(s *Stats, address string) {
|
|
const maxSamples = 20
|
|
if len(s.UnparsedSample) < maxSamples {
|
|
s.UnparsedSample = append(s.UnparsedSample, address)
|
|
}
|
|
}
|