fix(outages): parse portal address at the boundary; strip district suffix

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
This commit is contained in:
Maksim Totmin
2026-10-09 22:05:18 +07:00
parent 59a8b2c164
commit 50f180ea10
10 changed files with 156 additions and 29 deletions
+9 -1
View File
@@ -23,6 +23,7 @@ import (
"git.totmin.ru/en2zmax/addrkit/internal/adapters/registrystore" "git.totmin.ru/en2zmax/addrkit/internal/adapters/registrystore"
"git.totmin.ru/en2zmax/addrkit/internal/config" "git.totmin.ru/en2zmax/addrkit/internal/config"
"git.totmin.ru/en2zmax/addrkit/internal/domain/match" "git.totmin.ru/en2zmax/addrkit/internal/domain/match"
"git.totmin.ru/en2zmax/addrkit/internal/domain/outages"
) )
func main() { func main() {
@@ -58,11 +59,18 @@ func main() {
} }
resolver := match.NewResolver(index, rewriter) resolver := match.NewResolver(index, rewriter)
portal := arcgis.New( var portal outages.Source
if cfg.Portal.URL != "" {
portal = arcgis.New(
cfg.Portal.URL, cfg.Portal.URL,
time.Duration(cfg.Portal.TimeoutSeconds)*time.Second, time.Duration(cfg.Portal.TimeoutSeconds)*time.Second,
cfg.Portal.Retries, 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() loc := operationalLocation()
srv := httpapi.New(resolver, portal, logger, loc) srv := httpapi.New(resolver, portal, logger, loc)
+18 -1
View File
@@ -15,13 +15,26 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
"git.totmin.ru/en2zmax/addrkit/internal/domain/outages" "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. // Client is a bounded HTTP client for the portal.
type Client struct { type Client struct {
baseURL string baseURL string
@@ -127,8 +140,9 @@ func parseResponse(body []byte) ([]outages.Outage, error) {
out := make([]outages.Outage, 0, len(pr.Features)) out := make([]outages.Outage, 0, len(pr.Features))
for _, feat := range pr.Features { for _, feat := range pr.Features {
addr := cleanAddress(addressOf(feat.Attributes, feat.Value))
o := outages.Outage{ o := outages.Outage{
Address: addressOf(feat.Attributes, feat.Value), Address: addr,
Type: lookup(maps, "type_id", feat.Attributes), Type: lookup(maps, "type_id", feat.Attributes),
System: lookup(maps, "system_id", feat.Attributes), System: lookup(maps, "system_id", feat.Attributes),
Time: lookup(maps, "time_category", 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"]), EndDate: stringOf(feat.Attributes["end_date"]),
} }
o.Action = actionFor(o.System) 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) out = append(out, o)
} }
return out, nil return out, nil
+26
View File
@@ -66,3 +66,29 @@ func TestQueryRetriesThenFails(t *testing.T) {
t.Errorf("calls = %d, want 3 (1 + 2 retries)", calls) 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)
}
}
+10 -1
View File
@@ -79,9 +79,10 @@ type outagesResponse struct {
Status address.Status `json:"status"` Status address.Status `json:"status"`
Address string `json:"address,omitempty"` Address string `json:"address,omitempty"`
District string `json:"district,omitempty"` District string `json:"district,omitempty"`
Outages []outages.Outage `json:"outages,omitempty"` Outages []outages.Outage `json:"outages"`
AsOf string `json:"as_of,omitempty"` AsOf string `json:"as_of,omitempty"`
DataVersion string `json:"data_version,omitempty"` DataVersion string `json:"data_version,omitempty"`
Detail string `json:"detail,omitempty"`
} }
func (s *Server) handleOutages(w http.ResponseWriter, r *http.Request) { 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, Address: res.Address,
District: res.District, District: res.District,
DataVersion: res.DataVersion, DataVersion: res.DataVersion,
Outages: []outages.Outage{},
} }
if res.Status != address.StatusFound { if res.Status != address.StatusFound {
// No canonical address => nothing to query. Return the resolver's // 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) writeJSON(w, http.StatusOK, out)
return 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) raw, err := s.outages.Query(ctx, res.Address)
if err != nil { if err != nil {
+4 -4
View File
@@ -32,7 +32,8 @@ type ResolveConfig struct {
MaxCandidates int `json:"max_candidates"` 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 { type PortalConfig struct {
URL string `json:"url"` URL string `json:"url"`
TimeoutSeconds int `json:"timeout_seconds"` TimeoutSeconds int `json:"timeout_seconds"`
@@ -116,9 +117,8 @@ func (c Config) validate() error {
if c.LLM.Enabled && c.LLM.URL == "" { if c.LLM.Enabled && c.LLM.URL == "" {
return fmt.Errorf("config: llm.url is required when llm.enabled is true") return fmt.Errorf("config: llm.url is required when llm.enabled is true")
} }
if c.Portal.URL == "" { // portal.url is optional: without it the "events by house" module is
return fmt.Errorf("config: portal.url is required") // disabled and only /v1/resolve is served.
}
return nil return nil
} }
+4 -3
View File
@@ -60,10 +60,11 @@ func TestLoadMissingSystemPromptFileFailsClosed(t *testing.T) {
} }
} }
func TestLoadRequiresPortalURL(t *testing.T) { func TestPortalURLOptional(t *testing.T) {
dir := t.TempDir() 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"}`) path := writeFile(t, dir, "config.json", `{"addr": ":6171", "data_path": "x.db"}`)
if _, err := Load(path); err == nil { if _, err := Load(path); err != nil {
t.Fatal("expected error for missing portal.url") t.Fatalf("portal.url must be optional: %v", err)
} }
} }
+14
View File
@@ -1,6 +1,7 @@
package address package address
import ( import (
"regexp"
"strings" "strings"
"unicode" "unicode"
@@ -12,6 +13,11 @@ import (
// alike, which is what we want for matching. // alike, which is what we want for matching.
var fold = cases.Fold() 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 // NormalizeText folds a free-form string into a canonical form suitable for
// parsing and matching. // parsing and matching.
// //
@@ -28,11 +34,19 @@ func NormalizeText(s string) string {
s = norm.NFKC.String(s) s = norm.NFKC.String(s)
s = fold.String(s) s = fold.String(s)
s = strings.ReplaceAll(s, "ё", "е") s = strings.ReplaceAll(s, "ё", "е")
s = stripTrailingParenthetical(s)
s = mapSeparators(s) s = mapSeparators(s)
s = strings.Join(strings.Fields(s), " ") s = strings.Join(strings.Fields(s), " ")
return strings.TrimSpace(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 // mapSeparators unifies characters that carry no semantic meaning for address
// matching. It never removes digits, letters or the '/' house separator. // matching. It never removes digits, letters or the '/' house separator.
func mapSeparators(s string) string { func mapSeparators(s string) string {
+7
View File
@@ -95,6 +95,12 @@ func TestParseHouseBlock(t *testing.T) {
wantName: "кошурникова", wantType: "улица", wantName: "кошурникова", wantType: "улица",
wantHouse: "11", wantFrac: "1", wantHas: true, wantHouse: "11", wantFrac: "1", wantHas: true,
}, },
{
name: "portal district suffix in parentheses",
raw: "Медкадры улица, 1 (Заельцовский район)",
wantName: "медкадры", wantType: "улица",
wantHouse: "1", wantHas: true,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -154,6 +160,7 @@ func TestNormalizeIdempotent(t *testing.T) {
" 2-Я Станционная\u00a0улица 1/1 стр. ", " 2-Я Станционная\u00a0улица 1/1 стр. ",
"ЁЛКИ д. 5, кв. 3", "ЁЛКИ д. 5, кв. 3",
"СТАНИЦИОННАЯ — улица, 30а", "СТАНИЦИОННАЯ — улица, 30а",
"Медкадры улица, 1 (Заельцовский район)",
"", "",
} }
for _, in := range inputs { for _, in := range inputs {
+38 -15
View File
@@ -21,6 +21,35 @@ type Outage struct {
EndDate string `json:"end_date,omitempty"` EndDate string `json:"end_date,omitempty"`
Time string `json:"time,omitempty"` Time string `json:"time,omitempty"`
Action string `json:"action,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 // 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 // a neighbouring house's outage: matching is done on the parsed structure, not
// on the raw text. // on the raw text.
func MatchingHouse(list []Outage, target address.ParsedAddress) []Outage { func MatchingHouse(list []Outage, target address.ParsedAddress) []Outage {
want := HouseUnitOf(target)
out := make([]Outage, 0, len(list)) out := make([]Outage, 0, len(list))
for _, o := range list { for _, o := range list {
op := address.Parse(o.Address) unit := o.Unit
if houseUnitEqual(target, op) { 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) out = append(out, o)
} }
} }
return out 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 // 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 // already filters by date, but we re-check locally because the portal's notion
// of "today" and ours must agree (Asia/Novosibirsk, UTC+7). // of "today" and ours must agree (Asia/Novosibirsk, UTC+7).
+22
View File
@@ -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 "(<district> район)".
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) { func TestCurrent(t *testing.T) {
loc := time.FixedZone("NSK", 7*3600) loc := time.FixedZone("NSK", 7*3600)
now := time.Date(2026, 10, 9, 12, 0, 0, 0, loc) now := time.Date(2026, 10, 9, 12, 0, 0, 0, loc)