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:
+13
-5
@@ -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
|
||||||
cfg.Portal.URL,
|
if cfg.Portal.URL != "" {
|
||||||
time.Duration(cfg.Portal.TimeoutSeconds)*time.Second,
|
portal = arcgis.New(
|
||||||
cfg.Portal.Retries,
|
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()
|
loc := operationalLocation()
|
||||||
srv := httpapi.New(resolver, portal, logger, loc)
|
srv := httpapi.New(resolver, portal, logger, loc)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user