From 50f180ea10dc523c70f8e075115e4dc59f39d326 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Fri, 9 Oct 2026 22:05:18 +0700 Subject: [PATCH] fix(outages): parse portal address at the boundary; strip district suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outages portal appends the district in parentheses, e.g. "Медкадры улица, 1 (Заельцовский район)". That suffix hid the house number from the parser, so every portal address failed house matching and /v1/outages always returned empty. - address: NormalizeText drops a trailing "(...)" group (root fix, benefits every source) - outages: introduce HouseUnit; the ArcGIS adapter parses the (cleaned) address at the boundary and stores the structured unit; MatchingHouse matches on the unit (with a parse fallback for sources that only provide text) - config: portal.url is now optional (core runs without the events module) - httpapi: always return "outages": [] (no more omitempty); report "detail" when the events module is not configured - tests: regression coverage for the portal district format --- cmd/addrkitd/main.go | 18 ++++++--- internal/adapters/arcgis/client.go | 19 ++++++++- internal/adapters/arcgis/client_test.go | 26 ++++++++++++ internal/adapters/httpapi/server.go | 11 ++++- internal/config/config.go | 8 ++-- internal/config/config_test.go | 7 ++-- internal/domain/address/normalize.go | 14 +++++++ internal/domain/address/parse_test.go | 7 ++++ internal/domain/outages/outages.go | 53 ++++++++++++++++++------- internal/domain/outages/outages_test.go | 22 ++++++++++ 10 files changed, 156 insertions(+), 29 deletions(-) diff --git a/cmd/addrkitd/main.go b/cmd/addrkitd/main.go index 45cad6a..d17b0d2 100644 --- a/cmd/addrkitd/main.go +++ b/cmd/addrkitd/main.go @@ -23,6 +23,7 @@ import ( "git.totmin.ru/en2zmax/addrkit/internal/adapters/registrystore" "git.totmin.ru/en2zmax/addrkit/internal/config" "git.totmin.ru/en2zmax/addrkit/internal/domain/match" + "git.totmin.ru/en2zmax/addrkit/internal/domain/outages" ) func main() { @@ -58,11 +59,18 @@ func main() { } resolver := match.NewResolver(index, rewriter) - portal := arcgis.New( - cfg.Portal.URL, - time.Duration(cfg.Portal.TimeoutSeconds)*time.Second, - cfg.Portal.Retries, - ) + var portal outages.Source + if cfg.Portal.URL != "" { + portal = arcgis.New( + cfg.Portal.URL, + time.Duration(cfg.Portal.TimeoutSeconds)*time.Second, + cfg.Portal.Retries, + ) + logger.Info("outages module enabled", "url", cfg.Portal.URL) + } else { + // portal.url is optional: without it only /v1/resolve is served. + logger.Info("outages module disabled (portal.url not set)") + } loc := operationalLocation() srv := httpapi.New(resolver, portal, logger, loc) diff --git a/internal/adapters/arcgis/client.go b/internal/adapters/arcgis/client.go index cf09182..b4380f6 100644 --- a/internal/adapters/arcgis/client.go +++ b/internal/adapters/arcgis/client.go @@ -15,13 +15,26 @@ import ( "io" "net/http" "net/url" + "regexp" "strconv" "strings" "time" + "git.totmin.ru/en2zmax/addrkit/internal/domain/address" "git.totmin.ru/en2zmax/addrkit/internal/domain/outages" ) +// reDistrictSuffix matches the administrative suffix the portal appends to its +// display address, e.g. "Медкадры улица, 1 (Заельцовский район)". It must be +// removed before the address is parsed, otherwise the trailing district text +// hides the house number. The district is available separately as a field. +var reDistrictSuffix = regexp.MustCompile(`\s*\([^)]*\)\s*$`) + +// cleanAddress strips the portal's district suffix and surrounding spaces. +func cleanAddress(s string) string { + return strings.TrimSpace(reDistrictSuffix.ReplaceAllString(s, "")) +} + // Client is a bounded HTTP client for the portal. type Client struct { baseURL string @@ -127,8 +140,9 @@ func parseResponse(body []byte) ([]outages.Outage, error) { out := make([]outages.Outage, 0, len(pr.Features)) for _, feat := range pr.Features { + addr := cleanAddress(addressOf(feat.Attributes, feat.Value)) o := outages.Outage{ - Address: addressOf(feat.Attributes, feat.Value), + Address: addr, Type: lookup(maps, "type_id", feat.Attributes), System: lookup(maps, "system_id", feat.Attributes), Time: lookup(maps, "time_category", feat.Attributes), @@ -136,6 +150,9 @@ func parseResponse(body []byte) ([]outages.Outage, error) { EndDate: stringOf(feat.Attributes["end_date"]), } o.Action = actionFor(o.System) + // Parse the address at the boundary so the domain matches on structure + // and never depends on this portal's text format. + o.Unit = outages.HouseUnitOf(address.Parse(addr)) out = append(out, o) } return out, nil diff --git a/internal/adapters/arcgis/client_test.go b/internal/adapters/arcgis/client_test.go index f9a4d14..b3b817a 100644 --- a/internal/adapters/arcgis/client_test.go +++ b/internal/adapters/arcgis/client_test.go @@ -66,3 +66,29 @@ func TestQueryRetriesThenFails(t *testing.T) { t.Errorf("calls = %d, want 3 (1 + 2 retries)", calls) } } + +// TestQueryStripsDistrictSuffix is a regression test: the portal appends the +// district in parentheses, which used to hide the house number from the parser. +func TestQueryStripsDistrictSuffix(t *testing.T) { + const env = `{"features":[{"value":"Медкадры улица, 1 (Заельцовский район)","attributes":{"address":"Медкадры улица, 1 (Заельцовский район)"}}]}` + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(env)) + })) + defer ts.Close() + + c := New(ts.URL, time.Second, 0) + rows, err := c.Query(context.Background(), "Медкадры улица 1") + if err != nil { + t.Fatalf("Query: %v", err) + } + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + if rows[0].Address != "Медкадры улица, 1" { + t.Errorf("district suffix not stripped: %q", rows[0].Address) + } + if rows[0].Unit.House != "1" { + t.Errorf("house not parsed at the boundary: %+v", rows[0].Unit) + } +} diff --git a/internal/adapters/httpapi/server.go b/internal/adapters/httpapi/server.go index bc9e14c..250bc31 100644 --- a/internal/adapters/httpapi/server.go +++ b/internal/adapters/httpapi/server.go @@ -79,9 +79,10 @@ type outagesResponse struct { Status address.Status `json:"status"` Address string `json:"address,omitempty"` District string `json:"district,omitempty"` - Outages []outages.Outage `json:"outages,omitempty"` + Outages []outages.Outage `json:"outages"` AsOf string `json:"as_of,omitempty"` DataVersion string `json:"data_version,omitempty"` + Detail string `json:"detail,omitempty"` } func (s *Server) handleOutages(w http.ResponseWriter, r *http.Request) { @@ -97,6 +98,7 @@ func (s *Server) handleOutages(w http.ResponseWriter, r *http.Request) { Address: res.Address, District: res.District, DataVersion: res.DataVersion, + Outages: []outages.Outage{}, } if res.Status != address.StatusFound { // No canonical address => nothing to query. Return the resolver's @@ -104,6 +106,13 @@ func (s *Server) handleOutages(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) return } + if s.outages == nil { + // The events module is optional and off when portal.url is empty. + out.Status = address.StatusInfra + out.Detail = "outages source is not configured" + writeJSON(w, http.StatusOK, out) + return + } raw, err := s.outages.Query(ctx, res.Address) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 3cb6d41..7616101 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,7 +32,8 @@ type ResolveConfig struct { MaxCandidates int `json:"max_candidates"` } -// PortalConfig configures the external outages portal (ArcGIS). +// PortalConfig configures the external outages portal (ArcGIS). When URL is +// empty the "events by house" module is disabled and only /v1/resolve works. type PortalConfig struct { URL string `json:"url"` TimeoutSeconds int `json:"timeout_seconds"` @@ -116,9 +117,8 @@ func (c Config) validate() error { if c.LLM.Enabled && c.LLM.URL == "" { return fmt.Errorf("config: llm.url is required when llm.enabled is true") } - if c.Portal.URL == "" { - return fmt.Errorf("config: portal.url is required") - } + // portal.url is optional: without it the "events by house" module is + // disabled and only /v1/resolve is served. return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1a0cda4..2e901c5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -60,10 +60,11 @@ func TestLoadMissingSystemPromptFileFailsClosed(t *testing.T) { } } -func TestLoadRequiresPortalURL(t *testing.T) { +func TestPortalURLOptional(t *testing.T) { dir := t.TempDir() + // No portal.url: the events module is disabled, the core still loads. path := writeFile(t, dir, "config.json", `{"addr": ":6171", "data_path": "x.db"}`) - if _, err := Load(path); err == nil { - t.Fatal("expected error for missing portal.url") + if _, err := Load(path); err != nil { + t.Fatalf("portal.url must be optional: %v", err) } } diff --git a/internal/domain/address/normalize.go b/internal/domain/address/normalize.go index 871120f..26dd703 100644 --- a/internal/domain/address/normalize.go +++ b/internal/domain/address/normalize.go @@ -1,6 +1,7 @@ package address import ( + "regexp" "strings" "unicode" @@ -12,6 +13,11 @@ import ( // alike, which is what we want for matching. var fold = cases.Fold() +// reTrailingParenthetical matches a trailing "(...)" group, e.g. the district +// suffix that the outages portal appends: "Медкадры улица, 1 (Заельцовский +// район)". Such administrative noise must not be treated as part of the house. +var reTrailingParenthetical = regexp.MustCompile(`\s*\([^)]*\)\s*$`) + // NormalizeText folds a free-form string into a canonical form suitable for // parsing and matching. // @@ -28,11 +34,19 @@ func NormalizeText(s string) string { s = norm.NFKC.String(s) s = fold.String(s) s = strings.ReplaceAll(s, "ё", "е") + s = stripTrailingParenthetical(s) s = mapSeparators(s) s = strings.Join(strings.Fields(s), " ") return strings.TrimSpace(s) } +// stripTrailingParenthetical removes a single trailing "(...)" group. It runs +// before separator mapping because that step turns parentheses into spaces and +// the group would no longer be recognisable. +func stripTrailingParenthetical(s string) string { + return strings.TrimSpace(reTrailingParenthetical.ReplaceAllString(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 { diff --git a/internal/domain/address/parse_test.go b/internal/domain/address/parse_test.go index daa3286..fa831ab 100644 --- a/internal/domain/address/parse_test.go +++ b/internal/domain/address/parse_test.go @@ -95,6 +95,12 @@ func TestParseHouseBlock(t *testing.T) { wantName: "кошурникова", wantType: "улица", wantHouse: "11", wantFrac: "1", wantHas: true, }, + { + name: "portal district suffix in parentheses", + raw: "Медкадры улица, 1 (Заельцовский район)", + wantName: "медкадры", wantType: "улица", + wantHouse: "1", wantHas: true, + }, } for _, tt := range tests { @@ -154,6 +160,7 @@ func TestNormalizeIdempotent(t *testing.T) { " 2-Я Станционная\u00a0улица 1/1 стр. ", "ЁЛКИ д. 5, кв. 3", "СТАНИЦИОННАЯ — улица, 30а", + "Медкадры улица, 1 (Заельцовский район)", "", } for _, in := range inputs { diff --git a/internal/domain/outages/outages.go b/internal/domain/outages/outages.go index aa87c08..ebeb9bc 100644 --- a/internal/domain/outages/outages.go +++ b/internal/domain/outages/outages.go @@ -21,6 +21,35 @@ type Outage struct { EndDate string `json:"end_date,omitempty"` Time string `json:"time,omitempty"` Action string `json:"action,omitempty"` + + // Unit is the structured house unit, filled by the source adapter so the + // domain never has to parse the raw external address format. Not exported + // to JSON: it is an internal matching key. + Unit HouseUnit `json:"-"` +} + +// HouseUnit identifies a concrete house within a street: number, optional +// fraction and letter, and the corpus (building) with its own fraction and +// letter. "Outages by house" matches on exactly this. +type HouseUnit struct { + House string + HouseFrac string + HouseLetter string + Building string + BuildingFrac string + BuildingLetter string +} + +// HouseUnitOf extracts the unit from a parsed address. +func HouseUnitOf(p address.ParsedAddress) HouseUnit { + return HouseUnit{ + House: p.House, + HouseFrac: p.HouseFrac, + HouseLetter: p.HouseLetter, + Building: p.Building, + BuildingFrac: p.BuildingFrac, + BuildingLetter: p.BuildingLetter, + } } // Source is anything that can answer "what outages are there for this @@ -35,29 +64,23 @@ type Source interface { // 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 { + want := HouseUnitOf(target) out := make([]Outage, 0, len(list)) for _, o := range list { - op := address.Parse(o.Address) - if houseUnitEqual(target, op) { + unit := o.Unit + if unit == (HouseUnit{}) { + // The source did not pre-parse the address (older/alternative + // adapters); fall back to parsing it here. Adapters should set + // Unit so the domain stays independent of external formats. + unit = HouseUnitOf(address.Parse(o.Address)) + } + if unit == want { 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). diff --git a/internal/domain/outages/outages_test.go b/internal/domain/outages/outages_test.go index 15e7168..005e7db 100644 --- a/internal/domain/outages/outages_test.go +++ b/internal/domain/outages/outages_test.go @@ -34,6 +34,28 @@ func TestMatchingHouseIgnoresStructure(t *testing.T) { } } +// TestMatchingHouseDistrictSuffix covers the fallback path (Unit not set by the +// adapter): the parser must itself drop the portal's "( район)". +func TestMatchingHouseDistrictSuffix(t *testing.T) { + list := []Outage{{Address: "Медкадры улица, 1 (Заельцовский район)"}} + target := address.Parse("Медкадры улица 1") + if got := MatchingHouse(list, target); len(got) != 1 { + t.Fatalf("district suffix must not break house matching; got %d", len(got)) + } +} + +// TestMatchingHouseUsesStructuredUnit verifies the domain matches on the unit +// the adapter parsed, not on the raw address text. +func TestMatchingHouseUsesStructuredUnit(t *testing.T) { + list := []Outage{{Address: "неважно", Unit: HouseUnit{House: "5", HouseFrac: "1"}}} + if got := MatchingHouse(list, address.Parse("что угодно 5/1")); len(got) != 1 { + t.Fatalf("structured unit not used for matching; got %d", len(got)) + } + if got := MatchingHouse(list, address.Parse("что угодно 5/2")); len(got) != 0 { + t.Fatalf("different unit must not match; 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)