From ce60fc214de12a0a6d1efe15a365cf02ed69b1ad Mon Sep 17 00:00:00 2001 From: lekss361 Date: Tue, 15 Sep 2026 17:28:09 +0000 Subject: [PATCH] =?UTF-8?q?=D0=93=D0=B5=D0=BE=D0=BA=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=81=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=20=D0=BE=D0=B1=D0=BB=D0=B0=D1=81=D1=82=D0=B8?= =?UTF-8?q?:=20=D0=BA=D0=BB=D1=8E=D1=87=20(=D1=80=D0=B5=D0=B3=D0=B8=D0=BE?= =?UTF-8?q?=D0=BD,=20=D0=9D=D0=9F,=20=D1=83=D0=BB=D0=B8=D1=86=D0=B0)=20?= =?UTF-8?q?=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=B3=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D0=B9=20=D1=83=D0=BB=D0=B8=D1=86=D1=8B=20(#3532)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Центроид строился по названию улицы без города — в области это схлопывало одноимённые улицы разных городов. Ключ стал (region_code, населённый пункт, улица); НП берётся из типа сегмента адреса, из реестра городов региона или из deals.city; при пустом НП ключ у области отбрасывается, а не склеивается с чужим городом. Фильтры по региону добавлены в SELECT кандидатов, в запрос домов и в UPDATE. Новый --max-spread-km (5 км) отбрасывает бакет с разбросанными домами. geocoder.py: поддержка региона 50 (маркер «московская» — намеренно не «москва», DaData-имя «Московская», новое поле Region.has_city_core=False, чтобы к адресу области не приклеивался суффикс главного города). --- tradein-mvp/backend/app/services/geocoder.py | 22 +- tradein-mvp/backend/app/services/regions.py | 14 + .../scripts/geocode_deals_from_houses.py | 465 ++++++++++++++---- .../scripts/test_geocode_deals_from_houses.py | 160 +++++- 4 files changed, 545 insertions(+), 116 deletions(-) diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index f78b0da4..8f7412c9 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -37,7 +37,11 @@ _REGION_66 = _ALL_REGIONS[66] # в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы # без записи здесь получают `marker=None` → cross-check пропускается # (fallback на bbox-only, прежнее поведение). -_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва"} +# 50 → "московская", а НЕ "москва": Nominatim отдаёт `state="Московская +# область"` для области и `state="Москва"` для города. Маркер "московская" не +# матчит "Москва" (подстроки нет) — результат внутри города Москвы, который +# щедрый `bbox_region` области накрывает целиком, будет честно отвергнут. +_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва", 50: "московская"} logger = logging.getLogger(__name__) @@ -962,7 +966,12 @@ _DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"} # «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов # хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится, # поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim. -_DADATA_REGION_NAMES: dict[int, str] = {66: SVERDLOVSK_OBLAST_REGION, 77: "Москва"} +_DADATA_REGION_NAMES: dict[int, str] = { + 66: SVERDLOVSK_OBLAST_REGION, + 77: "Москва", + # Без типа — DaData хранит `region="Московская"`, `region_type="обл"`. + 50: "Московская", +} def _dadata_region_name(region_code: int) -> str: @@ -1106,7 +1115,14 @@ async def _nominatim_query_city_aware( ) if city_specified: return await _nominatim_query_multi(client, query, limit, region_code=region_code) - # Город неизвестен — dual-query с суффиксом главного города региона + # Город неизвестен. У региона БЕЗ города-ядра (`has_city_core=False`, + # реестр регионов; на сегодня это 50 — Московская область) суффикс главного + # города подставлять НЕЛЬЗЯ: «Луговая» есть и в Красногорске, и в Сабурово, + # и суффикс уверенно притянет чужой город — ровно ловушка #2576, только + # уровнем выше. Такому региону остаётся честный bare-запрос по viewbox. + if not _ALL_REGIONS[region_code].has_city_core: + return await _nominatim_query_multi(client, query, limit, region_code=region_code) + # Дальше — регион с ядром: dual-query с суффиксом главного города # (66 → "Екатеринбург", byte-identical; прочие — см. `_region_default_city`). default_city = _region_default_city(region_code) city_data = await _nominatim_query_multi( diff --git a/tradein-mvp/backend/app/services/regions.py b/tradein-mvp/backend/app/services/regions.py index 8588995a..1b5cc32e 100644 --- a/tradein-mvp/backend/app/services/regions.py +++ b/tradein-mvp/backend/app/services/regions.py @@ -71,6 +71,15 @@ class Region: Регион без тира должен деградировать ЯВНО (потребитель спрашивает unsupported_tier_reason и логирует/маркирует), а не молча считать дальше без источника. + has_city_core — есть ли у региона ОДИН город-ядро, имя которого допустимо + молча подставлять в запрос геокодера, когда город не назван + (dual-query `_nominatim_query_city_aware`, ветка «город + неизвестен»). True у 66/77 (Екатеринбург / Москва — там это + majority-трафик). False у 50: у области 20 сопоставимых + городов и ~970 населённых пунктов в сырье, подстановка + «Красногорск» к «Сабурово, Луговая» — ровно та же ловушка + одноимённых улиц, от которой отказались в #2576. + canonical_city — #3051: имя города, которым ПЕРЕЗАПИСЫВАЕТСЯ `city` строк, приходящих из источника без надёжного city-поля (Росреестр по Москве отдаёт муниципальный округ/поселение @@ -92,6 +101,7 @@ class Region: cities: frozenset[str] enrichment_tiers: frozenset[str] canonical_city: str | None = None + has_city_core: bool = True def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool: @@ -460,6 +470,10 @@ REGIONS: dict[int, Region] = { # муниципальный округ/поселение, в отличие от Москвы) — перезаписывать # нечего и незачем, в отличие от 77. canonical_city=None, + # Города-ядра нет (см. bbox_tight выше) — подставлять «Красногорск» в + # запрос геокодера, когда город не назван, НЕЛЬЗЯ: «Луговая» есть и в + # Красногорске, и в Сабурово, и ещё в десятке НП области. + has_city_core=False, ), } diff --git a/tradein-mvp/backend/scripts/geocode_deals_from_houses.py b/tradein-mvp/backend/scripts/geocode_deals_from_houses.py index d924e67d..b2f20d93 100644 --- a/tradein-mvp/backend/scripts/geocode_deals_from_houses.py +++ b/tradein-mvp/backend/scripts/geocode_deals_from_houses.py @@ -52,6 +52,7 @@ from __future__ import annotations import argparse import logging +import math import re import unicodedata from collections import Counter @@ -66,11 +67,13 @@ from sqlalchemy.orm import Session # matches the pattern from backfill_houses_dadata.py) and as a stand-alone file. try: from app.core.db import SessionLocal # type: ignore[import-not-found] + from app.services.regions import REGIONS # type: ignore[import-not-found] except ImportError: # pragma: no cover — fallback for adhoc invocation import sys sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app.core.db import SessionLocal + from app.services.regions import REGIONS logging.basicConfig( level=logging.INFO, @@ -91,6 +94,47 @@ _LOG_EVERY = 1000 # houses (centroid) and deals (lookup) side so garbage never anchors a match. _MIN_KEY_LEN = 3 +# Default region. 66 (Свердловская обл.) — единственный регион, на котором +# скрипт исторически работал; остаётся умолчанием, чтобы существующие вызовы +# без флага вели себя ровно как раньше. +_DEFAULT_REGION_CODE = 66 + +# Sanity-порог разброса домов ВНУТРИ одного ключа (км). Ключ — (регион, НП, +# улица), так что легитимный разброс — это длина одной улицы: даже проспект в +# большом городе редко длиннее 10-15 км, а типичная улица — 1-3 км. Разброс +# больше порога означает, что в ведро слиплись ОДНОИМЁННЫЕ улицы разных +# населённых пунктов (или один НП распознан двумя написаниями) — среднее таких +# точек даёт правдоподобную координату ПОСЕРЕДИНЕ между городами, и ошибка +# тихая. Такое ведро выбрасывается целиком: дыра в покрытии честнее, чем +# сделка, посаженная в поле между Клином и Серпуховом. +_DEFAULT_MAX_SPREAD_KM = 5.0 + +# Классификация comma-сегмента адреса (см. `_split_place_street`). +# Тип населённого пункта в начале сегмента: «г Химки», «пос. Голубое», +# «д Сабурово», «рп Оболенск», «снт Заря». За типом ОБЯЗАН идти буквенный +# токен — иначе «д 5» (номер дома) распознался бы как деревня. +_LOCALITY_TYPE_RE = re.compile( + r"^(?:" + r"г|гор|город|пгт|рп|дп|снт|днп|тер" + r"|п|пос|поселок|посёлок|п/ст" + r"|д|дер|деревня|с|село|сл|слобода|ст|станция|х|хутор|аул" + r")\.?\s+(?=[а-яё])", + flags=re.UNICODE, +) + +# Сегмент — номер дома/квартиры/корпуса, а не имя: «125», «д 5», «кв 12», +# «корп 3», «литера А». Такие сегменты не могут быть ни НП, ни улицей. +_NUMERIC_SEGMENT_RE = re.compile( + r"^(?:" + r"д|дом|к|кор|корп|корпус|стр|строение|кв|квартира|литера?|уч|участок" + r"|пом|помещение|оф|офис|бокс|гараж" + # Хвост `$` обязателен: сегмент считается номером, только если КРОМЕ + # числа в нём ничего нет. Иначе «8 марта» (числовое имя улицы) было бы + # принято за номер дома и улица потерялась бы целиком. + r")?\.?\s*\d+[а-яё]?(?:\s*[/-]\s*\d+[а-яё]?)?$", + flags=re.UNICODE, +) + # --------------------------------------------------------------------------- # Street-key normalization — the crux of match rate @@ -109,9 +153,11 @@ _ADMIN_SEGMENT_RES = [ r"^[а-яё][а-яё\s.-]*\b(?:р-н|район|округ|край|республика)$", flags=re.UNICODE, ), - re.compile(r"^(?:г|гор|город)\.?\s+[а-яё][а-яё-]+$", flags=re.UNICODE), - re.compile(r"^екатеринбург$", flags=re.UNICODE), ] +# NB: сегменты-НАСЕЛЁННЫЕ ПУНКТЫ («г. Екатеринбург», «екатеринбург») здесь +# больше НЕ выбрасываются — они несут вторую половину ключа и разбираются +# `_locality_name` по реестру регионов. Хардкод `^екатеринбург$` уехал туда же: +# список городов региона живёт в `REGIONS[region_code].cities`, а не здесь. # Street-type token at the START of the street segment. Stripped because # deals.address sometimes omits it entirely ('Екатеринбург, Малышева'), so the @@ -187,63 +233,176 @@ def _strip_house_tail(segment: str) -> str: return " ".join(keep).strip() -def _street_key(address: str | None) -> str: - """Reduce any address to a bare-street-name key for the centroid join. +def _is_admin_segment(seg: str) -> bool: + """Сегмент — страна/регион/район (выбрасывается целиком).""" + return any(rx.match(seg) for rx in _ADMIN_SEGMENT_RES) - Both sides must produce the SAME key or the join under-matches: - 'Екатеринбург, ул. Малышева, 125' → 'малышева' - 'г Екатеринбург, улица Малышева' → 'малышева' - 'Екатеринбург, Малышева' → 'малышева' - 'Свердловская обл., Екатеринбург, ул. Большакова, 17' → 'большакова' - 'улица Яскина, 12 · р-н Октябрьский' → 'яскина' - 'г. Екатеринбург, проспект Ленина, 50' → 'ленина' - 'Екатеринбург, ул. 8 Марта, 100' → '8 марта' - Steps: - 1. NFC normalize, lowercase, ё→е (deals/houses differ on ё usage). - 2. Drop a trailing district marker (' · р-н ...', ' | ...'). - 3. Split on commas; drop leading segments that are admin chunks - (Россия / region / district / город / Екатеринбург). The first - non-admin segment is the street segment. - 4. Drop apartment/corpus/строение noise inside that segment. - 5. Strip the street-type token at the start (ул/улица/проспект/...). - 6. Strip the trailing house number, preserving numeric street names. - 7. Collapse whitespace. +def _is_numeric_segment(seg: str) -> bool: + """Сегмент — номер дома/квартиры/корпуса («125», «д 5», «кв 12»).""" + return bool(_NUMERIC_SEGMENT_RE.match(seg)) - Returns '' when nothing usable remains (caller filters by _MIN_KEY_LEN). - """ - if not address: + +def _normalize_place(value: str | None) -> str: + """Нормализованное имя НП: lower, ё→е, без типа («г. Химки» → «химки»).""" + if not value: return "" + v = unicodedata.normalize("NFC", value).lower().replace("ё", "е") + v = _WS_RE.sub(" ", v).strip() + v = _LOCALITY_TYPE_RE.sub("", v) + return _WS_RE.sub(" ", v).strip(" .,") + + +def _locality_name(seg: str, cities: frozenset[str]) -> str | None: + """Имя НП, если сегмент — населённый пункт, иначе None. + + Два признака: явный тип («г Химки», «д Сабурово», «снт Заря») — имя берём + как есть; ИЛИ голое имя, которое реестр региона знает как город + (`REGIONS[region_code].cities`). Голое незнакомое имя здесь НЕ считается + НП — иначе «Малышева, 125» прочиталось бы как НП «Малышева»; такой случай + ловит позиционный fallback в `_split_place_street`. + """ + m = _LOCALITY_TYPE_RE.match(seg) + if m: + name = seg[m.end() :] + else: + if _STREET_TYPE_RE.match(seg) or _is_numeric_segment(seg): + return None + if _WS_RE.sub(" ", seg).strip() not in cities: + return None + name = seg + name = _WS_RE.sub(" ", name).strip(" .,") + return name or None + + +def _split_place_street( + address: str | None, region_code: int = _DEFAULT_REGION_CODE +) -> tuple[str, str]: + """Разложить адрес на (населённый пункт, улица) — обе половины ключа. + + ПОЧЕМУ НП обязан быть в ключе. Раньше функция звалась `_street_key` и + возвращала ГОЛОЕ имя улицы, выбрасывая НП. Для одного города (скрипт жил + ЕКБ-only, с хардкодом `^екатеринбург$`) это безвредно. Для Московской + области — тихая катастрофа: «Ленина» / «Центральная» / «Советская» есть + почти в каждом из ~970 НП области, дома со всех таких улиц слиплись бы в + одно ведро, а среднее их координат — правдоподобная точка ПОСЕРЕДИНЕ между + городами. Сделка уезжает на десятки километров, и ни одна проверка этого не + замечает: координата валидная, внутри области, рядом есть дома. + + Разбор: + 1. NFC, lower, ё→е, снять хвост-район (' · р-н ...', ' | ...'). + 2. Порезать на comma-сегменты, выбросить страну/регион/район. + 3. Первый сегмент-НП (`_locality_name`) → place, улицу ищем ПОСЛЕ него. + 4. Улица — первый не-числовой сегмент из остатка. + 5. Fallback «НП без типа и вне реестра» («Сабурово, Луговая» — ровно + формат deals.address по области): если НП не нашёлся, а не-числовых + сегментов >= 2 и первый не начинается с типа улицы — первый считается + НП, второй улицей. + 6. Улицу чистим как раньше: квартира/корпус, тип улицы, номер дома. + + Неизвестный `region_code` -> KeyError реестра, не молчаливый ''. + """ + cities = REGIONS[region_code].cities + if not address: + return "", "" s = unicodedata.normalize("NFC", address).lower().replace("ё", "е") s = _WS_RE.sub(" ", s).strip() if not s: - return "" - - # 2. Drop trailing district marker (' · Октябрьский', ' | ...'). + return "", "" s = _DISTRICT_SUFFIX_RE.sub("", s) - # 3. Split on commas, drop leading admin segments. Each segment is matched - # whole, so a non-admin street segment is never partially eaten. segments = [seg.strip() for seg in s.split(",") if seg.strip()] - street_seg = "" - for seg in segments: - if any(rx.match(seg) for rx in _ADMIN_SEGMENT_RES): - continue - street_seg = seg - break + non_admin = [seg for seg in segments if not _is_admin_segment(seg)] + if not non_admin: + return "", "" + + place = "" + rest = non_admin + for i, seg in enumerate(non_admin): + name = _locality_name(seg, cities) + if name: + place = name + rest = non_admin[i + 1 :] + break + + street_seg = next((seg for seg in rest if not _is_numeric_segment(seg)), "") + + if not place: + usable = [seg for seg in non_admin if not _is_numeric_segment(seg)] + if len(usable) >= 2 and not _STREET_TYPE_RE.match(usable[0]): + place = _WS_RE.sub(" ", usable[0]).strip(" .,") + street_seg = usable[1] + if not street_seg: - return "" + return place, "" - # 4. Drop apartment/corpus/строение noise inside the street segment. street_seg = _APT_SUFFIX_RE.sub("", street_seg).strip() - - # 5. Strip the street-type token if present. street_seg = _STREET_TYPE_RE.sub("", street_seg).strip() - - # 6. Strip trailing house number (keep '8 марта' style numeric streets). street_seg = _strip_house_tail(street_seg) + return place, _WS_RE.sub(" ", street_seg).strip() - return _WS_RE.sub(" ", street_seg).strip() + +def _street_key(address: str | None, region_code: int = _DEFAULT_REGION_CODE) -> str: + """Только уличная половина ключа. Как ключ join'а САМА ПО СЕБЕ не годится + (одноимённые улицы разных НП) — см. `_address_key`; оставлена для отчётов + и тестов нормализации улицы. + + Примеры (region 66): + 'Екатеринбург, ул. Малышева, 125' -> 'малышева' + 'г Екатеринбург, улица Малышева' -> 'малышева' + 'Свердловская обл., Екатеринбург, ул. Большакова, 17' -> 'большакова' + 'улица Яскина, 12 · р-н Октябрьский' -> 'яскина' + 'Екатеринбург, ул. 8 Марта, 100' -> '8 марта' + """ + return _split_place_street(address, region_code)[1] + + +def _address_key( + address: str | None, + region_code: int = _DEFAULT_REGION_CODE, + city: str | None = None, +) -> tuple[int, str, str] | None: + """Составной ключ join'а: (регион, населённый пункт, улица). None — мусор. + + `city` (у deals колонка заполнена на 100% и надёжнее текста адреса) + переопределяет НП, разобранный из адреса. + + Пустой НП: у региона С городом-ядром (66) подставляется `city_token` — + ровно историческое поведение «всё, что без города, это Екатеринбург». У + региона БЕЗ ядра (50) подставлять нечего, и ключ отбрасывается: сделка без + распознанного НП лучше останется без координат, чем сядет в случайный + город области. + """ + place, street = _split_place_street(address, region_code) + from_city = _normalize_place(city) + if from_city: + place = from_city + if len(street) < _MIN_KEY_LEN: + return None + if not place: + region = REGIONS[region_code] + if not region.has_city_core: + return None + place = region.city_token + if len(place) < _MIN_KEY_LEN: + return None + return (region_code, place, street) + + +def _spread_km(points: list[tuple[float, float]], lat_c: float, lon_c: float) -> float: + """Максимальное удаление точки ведра от его центроида, км (equirectangular). + + Проекция плоская — на масштабе одного НП (единицы километров) ошибка + сотые доли процента, а формула дешевле haversine на каждом из десятков + тысяч домов. + """ + worst = 0.0 + cos_lat = math.cos(math.radians(lat_c)) + for lat, lon in points: + dy = (lat - lat_c) * 111.32 + dx = (lon - lon_c) * 111.32 * cos_lat + worst = max(worst, math.hypot(dx, dy)) + return worst # --------------------------------------------------------------------------- @@ -253,11 +412,13 @@ def _street_key(address: str | None) -> str: @dataclass class Centroid: - """One street's centroid, averaged over all geocoded houses on it.""" + """Центроид одного ключа (регион, НП, улица) по домам этого ключа.""" lat: float lon: float house_count: int + # Максимальное удаление дома ведра от центроида, км (sanity-чек склейки). + spread_km: float = 0.0 @dataclass @@ -266,6 +427,9 @@ class DealRow: id: int address: str | None + # deals.city — росреестровая колонка; по области заполнена у 100% строк без + # geom и надёжнее текста адреса, поэтому переопределяет НП из адреса. + city: str | None = None @dataclass @@ -276,6 +440,8 @@ class Stats: geocoded: int = 0 no_street_match: int = 0 failed: int = 0 + # Вёдер выброшено sanity-чеком разброса (склейка нескольких НП). + dropped_spread: int = 0 # street_key → count of deals that had no house centroid (dry-run report). unmatched_streets: Counter[str] = field(default_factory=Counter) @@ -285,14 +451,28 @@ class Stats: # --------------------------------------------------------------------------- -def _build_centroid_map(db: Session) -> dict[str, Centroid]: - """Per-street centroid from houses WHERE geom IS NOT NULL. +def _build_centroid_map( + db: Session, + region_code: int = _DEFAULT_REGION_CODE, + max_spread_km: float = _DEFAULT_MAX_SPREAD_KM, +) -> dict[tuple[int, str, str], Centroid]: + """Центроиды по ключу (регион, НП, улица) из houses с координатами. - We read raw (address, lat, lon) and aggregate in Python so the street-key - derivation is the SAME code path as the deals side — pushing it into SQL - would require duplicating the regex logic in plpgsql and risk drift. - 8,600 rows is trivial to hold in memory. + Фильтр региона обязателен: без него карта строится по домам ВСЕХ регионов, + и прогон по одному региону тихо тащит чужие координаты. + + `houses.region_code` добавлен поздней миграцией (272_houses_region_code.sql) + и у части строк NULL. Отбрасывать их нельзя (это ударило бы по покрытию 66), + поэтому строка без региона принимается по географии — если её координаты + внутри `bbox_region` реестра. Это именно гео-проверка, а не догадка о + происхождении строки. + + Агрегация в Python, а не в SQL: вывод ключа должен быть ОДНИМ И ТЕМ ЖЕ + кодом на обеих сторонах join'а, дублировать регексы в plpgsql — верный + дрейф. """ + region = REGIONS[region_code] + lat_min, lat_max, lon_min, lon_max = region.bbox_region rows = ( db.execute( text( @@ -302,32 +482,81 @@ def _build_centroid_map(db: Session) -> dict[str, Centroid]: " AND lat IS NOT NULL " " AND lon IS NOT NULL " " AND address IS NOT NULL " - " AND length(trim(address)) > 0" - ) + " AND length(trim(address)) > 0 " + " AND ( region_code = CAST(:rc AS smallint) " + " OR ( region_code IS NULL " + " AND lat BETWEEN CAST(:lat_min AS double precision) " + " AND CAST(:lat_max AS double precision) " + " AND lon BETWEEN CAST(:lon_min AS double precision) " + " AND CAST(:lon_max AS double precision) ) )" + ), + { + "rc": region_code, + "lat_min": lat_min, + "lat_max": lat_max, + "lon_min": lon_min, + "lon_max": lon_max, + }, ) .mappings() .all() ) - # street_key → running [lat_sum, lon_sum, n] - acc: dict[str, list[float]] = {} + acc: dict[tuple[int, str, str], list[tuple[float, float]]] = {} for r in rows: - key = _street_key(r["address"]) - if len(key) < _MIN_KEY_LEN: + key = _address_key(r["address"], region_code) + if key is None: continue - bucket = acc.setdefault(key, [0.0, 0.0, 0.0]) - bucket[0] += float(r["lat"]) - bucket[1] += float(r["lon"]) - bucket[2] += 1.0 + acc.setdefault(key, []).append((float(r["lat"]), float(r["lon"]))) - return { - key: Centroid(lat=lat_sum / n, lon=lon_sum / n, house_count=int(n)) - for key, (lat_sum, lon_sum, n) in acc.items() - } + out: dict[tuple[int, str, str], Centroid] = {} + dropped = 0 + for key, points in acc.items(): + n = len(points) + lat_c = sum(lat for lat, _ in points) / n + lon_c = sum(lon for _, lon in points) / n + spread = _spread_km(points, lat_c, lon_c) + if spread > max_spread_km: + # Одна «улица» шириной в десятки километров — это не улица, а + # слипшиеся одноимённые улицы разных НП (или один НП, записанный + # двумя способами). Среднее таких точек — координата в поле между + # городами; отдавать её сделке нельзя, ведро выбрасывается. + dropped += 1 + logger.warning( + "centroid bucket dropped: key=%s houses=%d spread=%.1f km > %.1f km", + key, + n, + spread, + max_spread_km, + ) + continue + out[key] = Centroid(lat=lat_c, lon=lon_c, house_count=n, spread_km=spread) + + if dropped: + logger.warning( + "sanity: %d/%d вёдер выброшено по разбросу > %.1f km", + dropped, + len(acc), + max_spread_km, + ) + return out -def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]: - """deals needing coords (lat IS NULL) — resume-safe candidate set. +def _region_predicate(column: str = "region_code") -> str: + """SQL-предикат «строка принадлежит региону :rc» для deals. + + `deals.region_code` проставлен миграцией 177_deals_city_region.sql; строки, + существовавшие ДО неё, по построению екатеринбургские (в скрипт импорта + был зашит префикс 'Екатеринбург, '), поэтому NULL засчитывается региону 66 + и только ему. Для любого другого региона NULL — не кандидат. + """ + return f"( {column} = CAST(:rc AS int) OR ( {column} IS NULL AND CAST(:rc AS int) = 66 ) )" + + +def _select_deals_without_coords( + db: Session, limit: int, region_code: int = _DEFAULT_REGION_CODE +) -> list[DealRow]: + """deals needing coords (lat IS NULL) в пределах ОДНОГО региона. Matches `deals_geocode_pending_idx` (WHERE lat IS NULL). A successful UPDATE sets lat NOT NULL, dropping the row out on the next run. @@ -335,20 +564,21 @@ def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]: rows = ( db.execute( text( - "SELECT id, address " + "SELECT id, address, city " "FROM deals " "WHERE lat IS NULL " " AND address IS NOT NULL " " AND length(trim(address)) > 0 " + " AND " + _region_predicate() + " " "ORDER BY id " "LIMIT CAST(:lim AS int)" ), - {"lim": limit}, + {"lim": limit, "rc": region_code}, ) .mappings() .all() ) - return [DealRow(id=r["id"], address=r["address"]) for r in rows] + return [DealRow(id=r["id"], address=r["address"], city=r.get("city")) for r in rows] # --------------------------------------------------------------------------- @@ -356,7 +586,14 @@ def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]: # --------------------------------------------------------------------------- -def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) -> None: +def _update_deal_coords( + db: Session, + *, + deal_id: int, + lat: float, + lon: float, + region_code: int = _DEFAULT_REGION_CODE, +) -> None: """UPDATE deals SET lat/lon + geocode_tried_at=NOW(); geom auto-fills. The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger @@ -370,9 +607,10 @@ def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) -> " SET lat = CAST(:lat AS double precision), " " lon = CAST(:lon AS double precision), " " geocode_tried_at = NOW() " - " WHERE id = CAST(:id AS bigint)" + " WHERE id = CAST(:id AS bigint) " + " AND " + _region_predicate() ), - {"id": deal_id, "lat": lat, "lon": lon}, + {"id": deal_id, "lat": lat, "lon": lon, "rc": region_code}, ) @@ -384,10 +622,11 @@ def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) -> def _run_backfill( db: Session, deals: list[DealRow], - centroids: dict[str, Centroid], + centroids: dict[tuple[int, str, str], Centroid], *, batch: str, dry_run: bool, + region_code: int = _DEFAULT_REGION_CODE, ) -> Stats: """For each deal, look up its street centroid and UPDATE lat/lon. @@ -399,14 +638,14 @@ def _run_backfill( stats = Stats() for i, deal in enumerate(deals, start=1): - key = _street_key(deal.address) - centroid = centroids.get(key) if len(key) >= _MIN_KEY_LEN else None + key = _address_key(deal.address, region_code, city=deal.city) + centroid = centroids.get(key) if key is not None else None if centroid is None: stats.no_street_match += 1 # Track the raw key (or a sentinel) so the dry-run report can show # which streets we're missing. Empty key → ''. - stats.unmatched_streets[key or ""] += 1 + stats.unmatched_streets["/".join(key[1:]) if key else ""] += 1 stats.processed += 1 if dry_run and i % _LOG_EVERY == 0: logger.info( @@ -423,7 +662,13 @@ def _run_backfill( else: try: with db.begin_nested(): - _update_deal_coords(db, deal_id=deal.id, lat=centroid.lat, lon=centroid.lon) + _update_deal_coords( + db, + deal_id=deal.id, + lat=centroid.lat, + lon=centroid.lon, + region_code=region_code, + ) # Per-row commit so resume picks up exactly where we crashed. db.commit() stats.geocoded += 1 @@ -454,7 +699,7 @@ def _run_backfill( def _report_dry_run( stats: Stats, - centroids: dict[str, Centroid], + centroids: dict[tuple[int, str, str], Centroid], *, total_deals_null: int, candidates: int, @@ -475,7 +720,7 @@ def _report_dry_run( logger.info("─" * 60) logger.info("DRY-RUN SUMMARY (no DB writes)") - logger.info("distinct streets with a house centroid: %d", distinct_streets) + logger.info("distinct (region, НП, улица) keys with a house centroid: %d", distinct_streets) logger.info("deals scanned this run (lat IS NULL, capped by --limit): %d", scanned) logger.info("deals matched to a centroid: %d", matched) logger.info("deals with no street match: %d", stats.no_street_match) @@ -486,7 +731,7 @@ def _report_dry_run( projected, match_rate * 100.0, ) - logger.info("top-10 unmatched deal streets (by row count):") + logger.info("top-10 unmatched deal keys НП/улица (by row count):") for street, cnt in stats.unmatched_streets.most_common(10): logger.info(" %6d %s", cnt, street) logger.info("─" * 60) @@ -497,13 +742,15 @@ def _report_dry_run( # --------------------------------------------------------------------------- -def _count_deals_null(db: Session) -> int: - """Full count of deals WHERE lat IS NULL — denominator for projection.""" +def _count_deals_null(db: Session, region_code: int = _DEFAULT_REGION_CODE) -> int: + """Full count of deals WHERE lat IS NULL в этом регионе — знаменатель.""" row = db.execute( text( "SELECT count(*) AS n FROM deals " - "WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) > 0" - ) + "WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) > 0 " + " AND " + _region_predicate() + ), + {"rc": region_code}, ).first() return int(row[0]) if row else 0 @@ -527,6 +774,25 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: default=f"deals_geo_{date.today().isoformat()}", help="Log batch label (does not affect DB filters — logs only).", ) + p.add_argument( + "--region-code", + type=int, + default=_DEFAULT_REGION_CODE, + choices=sorted(REGIONS), + help=( + "Регион прогона (default %(default)s). Отбирает и обновляет ТОЛЬКО " + "строки этого региона; входит в ключ join'а." + ), + ) + p.add_argument( + "--max-spread-km", + type=float, + default=_DEFAULT_MAX_SPREAD_KM, + help=( + "Порог разброса домов внутри ключа (default %(default)s км). Ведро " + "с большим разбросом — склейка нескольких НП, оно выбрасывается." + ), + ) p.add_argument( "--dry-run", action="store_true", @@ -543,30 +809,47 @@ def main(argv: list[str] | None = None) -> int: """CLI entry point. Returns the number of deals geocoded this run.""" args = _parse_args(argv) logger.info( - "starting batch=%s limit=%s dry_run=%s", + "starting batch=%s region=%s limit=%s max_spread_km=%s dry_run=%s", args.batch, + args.region_code, args.limit, + args.max_spread_km, args.dry_run, ) db = SessionLocal() try: - centroids = _build_centroid_map(db) - logger.info("built centroid map: %d distinct streets", len(centroids)) + centroids = _build_centroid_map( + db, region_code=args.region_code, max_spread_km=args.max_spread_km + ) + logger.info( + "built centroid map: %d distinct keys (region %s)", len(centroids), args.region_code + ) if not centroids: - logger.warning("no house centroids — houses table has no geocoded rows; nothing to do") + logger.warning( + "no house centroids for region %s — houses has no geocoded rows there; " + "nothing to do", + args.region_code, + ) return 0 - deals = _select_deals_without_coords(db, args.limit) + deals = _select_deals_without_coords(db, args.limit, region_code=args.region_code) logger.info("loaded deals without coords: %d", len(deals)) if not deals: logger.info("nothing to do — no deals with lat IS NULL and an address") return 0 - stats = _run_backfill(db, deals, centroids, batch=args.batch, dry_run=args.dry_run) + stats = _run_backfill( + db, + deals, + centroids, + batch=args.batch, + dry_run=args.dry_run, + region_code=args.region_code, + ) if args.dry_run: - total_null = _count_deals_null(db) + total_null = _count_deals_null(db, region_code=args.region_code) _report_dry_run( stats, centroids, diff --git a/tradein-mvp/backend/tests/scripts/test_geocode_deals_from_houses.py b/tradein-mvp/backend/tests/scripts/test_geocode_deals_from_houses.py index a7c05e8e..4533b815 100644 --- a/tradein-mvp/backend/tests/scripts/test_geocode_deals_from_houses.py +++ b/tradein-mvp/backend/tests/scripts/test_geocode_deals_from_houses.py @@ -32,8 +32,10 @@ from scripts.geocode_deals_from_houses import ( Centroid, DealRow, Stats, + _address_key, _build_centroid_map, _run_backfill, + _select_deals_without_coords, _street_key, _update_deal_coords, main, @@ -178,16 +180,16 @@ def test_build_centroid_map_averages_houses_on_one_street(): """Two houses on Малышева → centroid is the mean of their coords.""" house_rows = [ {"address": "Екатеринбург, ул. Малышева, 10", "lat": 56.80, "lon": 60.50}, - {"address": "Екатеринбург, ул. Малышева, 20", "lat": 56.90, "lon": 60.70}, + {"address": "Екатеринбург, ул. Малышева, 20", "lat": 56.82, "lon": 60.54}, ] db, _ = _make_db_mock(house_rows=house_rows) centroids = _build_centroid_map(db) - assert set(centroids) == {"малышева"} - c = centroids["малышева"] - assert c.lat == pytest.approx(56.85) - assert c.lon == pytest.approx(60.60) + assert set(centroids) == {(66, "екатеринбург", "малышева")} + c = centroids[(66, "екатеринбург", "малышева")] + assert c.lat == pytest.approx(56.81) + assert c.lon == pytest.approx(60.52) assert c.house_count == 2 @@ -201,12 +203,15 @@ def test_build_centroid_map_groups_distinct_streets(): centroids = _build_centroid_map(db) - assert set(centroids) == {"малышева", "ленина"} - assert centroids["малышева"].house_count == 2 - assert centroids["ленина"].house_count == 1 + assert set(centroids) == { + (66, "екатеринбург", "малышева"), + (66, "екатеринбург", "ленина"), + } + assert centroids[(66, "екатеринбург", "малышева")].house_count == 2 + assert centroids[(66, "екатеринбург", "ленина")].house_count == 1 # Малышева centroid = mean of the two Малышева rows. - assert centroids["малышева"].lat == pytest.approx(56.81) - assert centroids["ленина"].lat == pytest.approx(56.84) + assert centroids[(66, "екатеринбург", "малышева")].lat == pytest.approx(56.81) + assert centroids[(66, "екатеринбург", "ленина")].lat == pytest.approx(56.84) def test_build_centroid_map_skips_unparseable_address(): @@ -218,7 +223,7 @@ def test_build_centroid_map_skips_unparseable_address(): db, _ = _make_db_mock(house_rows=house_rows) centroids = _build_centroid_map(db) - assert set(centroids) == {"малышева"} + assert set(centroids) == {(66, "екатеринбург", "малышева")} # --------------------------------------------------------------------------- @@ -228,7 +233,7 @@ def test_build_centroid_map_skips_unparseable_address(): def test_run_backfill_matched_street_issues_update_with_centroid(): deal = DealRow(id=42, address="Екатеринбург, ул. Малышева, 125") - centroids = {"малышева": Centroid(lat=56.838, lon=60.586, house_count=3)} + centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.838, lon=60.586, house_count=3)} db, updated = _make_db_mock() stats = _run_backfill(db, [deal], centroids, batch="b1", dry_run=False) @@ -247,7 +252,7 @@ def test_run_backfill_matched_street_issues_update_with_centroid(): def test_run_backfill_deal_with_only_street_name_matches(): """Deal address with no house number / no type word still matches.""" deal = DealRow(id=7, address="Екатеринбург, Малышева") - centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)} + centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)} db, updated = _make_db_mock() stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False) @@ -264,7 +269,7 @@ def test_run_backfill_deal_with_only_street_name_matches(): def test_run_backfill_unmatched_street_no_update(): deal = DealRow(id=99, address="Екатеринбург, ул. Несуществующая, 1") - centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)} + centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)} db, updated = _make_db_mock() stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False) @@ -275,13 +280,13 @@ def test_run_backfill_unmatched_street_no_update(): assert updated == [] assert db.commit.call_count == 0 # Unmatched street tracked for the dry-run report. - assert stats.unmatched_streets["несуществующая"] == 1 + assert stats.unmatched_streets["екатеринбург/несуществующая"] == 1 def test_run_backfill_unparseable_deal_tracked_as_sentinel(): """A deal whose address yields an empty key is a no-match under a sentinel.""" deal = DealRow(id=5, address="Екатеринбург") - centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)} + centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)} db, updated = _make_db_mock() stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False) @@ -298,7 +303,7 @@ def test_run_backfill_unparseable_deal_tracked_as_sentinel(): def test_run_backfill_dry_run_issues_no_update(): deal = DealRow(id=1, address="Екатеринбург, ул. Малышева, 1") - centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)} + centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)} db, updated = _make_db_mock() stats = _run_backfill(db, [deal], centroids, batch="dry", dry_run=True) @@ -319,7 +324,7 @@ def test_run_backfill_db_write_failure_isolated_to_row(): DealRow(id=10, address="Екатеринбург, ул. Малышева, 1"), DealRow(id=11, address="Екатеринбург, ул. Малышева, 2"), ] - centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)} + centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)} db, updated = _make_db_mock() # Make the FIRST UPDATE raise, the rest succeed. @@ -359,7 +364,7 @@ def test_update_deal_coords_sets_lat_lon_tried_at_not_geom(): assert "geocode_tried_at = NOW()" in sql_str # geom must NOT be set manually — the deals_set_geom_trg trigger fills it. assert "geom" not in sql_str - assert binds == {"id": 3, "lat": 56.1, "lon": 60.2} + assert binds == {"id": 3, "lat": 56.1, "lon": 60.2, "rc": 66} # --------------------------------------------------------------------------- @@ -370,7 +375,7 @@ def test_update_deal_coords_sets_lat_lon_tried_at_not_geom(): def test_main_respects_limit_and_returns_geocoded(): house_rows = [ {"address": "Екатеринбург, ул. Малышева, 1", "lat": 56.80, "lon": 60.50}, - {"address": "Екатеринбург, ул. Малышева, 2", "lat": 56.90, "lon": 60.70}, + {"address": "Екатеринбург, ул. Малышева, 2", "lat": 56.82, "lon": 60.54}, ] deal_rows = [ {"id": 1, "address": "Екатеринбург, Малышева"}, @@ -386,8 +391,8 @@ def test_main_respects_limit_and_returns_geocoded(): assert len(updated) == 1 assert updated[0]["id"] == 1 # Centroid used = mean of the two house rows. - assert updated[0]["lat"] == pytest.approx(56.85) - assert updated[0]["lon"] == pytest.approx(60.60) + assert updated[0]["lat"] == pytest.approx(56.81) + assert updated[0]["lon"] == pytest.approx(60.52) def test_main_dry_run_writes_nothing(): @@ -432,3 +437,114 @@ def test_stats_unmatched_streets_counter_defaults_empty(): assert s.no_street_match == 0 assert s.failed == 0 assert s.unmatched_streets.most_common(3) == [] + + +# --------------------------------------------------------------------------- +# Одноимённые улицы разных НП + однорегиональность прогона (fix/oblast-deal-geocoding) +# --------------------------------------------------------------------------- + + +def test_address_key_distinguishes_same_street_in_different_localities(): + """«Луговая» в Сабурово и «Луговая» в Красногорске — РАЗНЫЕ ключи. + + Ровно та ловушка, ради которой ключ стал составным: до этого обе улицы + сливались в одно ведро 'луговая', и центроид садился между городами. + """ + saburovo = _address_key("Сабурово, Луговая", 50) + krasnogorsk = _address_key("Красногорск, Луговая", 50) + assert saburovo == (50, "сабурово", "луговая") + assert krasnogorsk == (50, "красногорск", "луговая") + assert saburovo != krasnogorsk + + +def test_address_key_distinguishes_same_street_in_different_regions(): + """Регион — тоже часть ключа: «Ленина» в Химках ≠ «Ленина» в Екатеринбурге.""" + assert _address_key("г Химки, ул Ленина, 5", 50) == (50, "химки", "ленина") + assert _address_key("Екатеринбург, ул. Ленина, 5", 66) == (66, "екатеринбург", "ленина") + + +def test_address_key_region_without_city_core_refuses_nameless_locality(): + """У области НП обязателен: без него ключа нет (а не «пусть будет Красногорск»).""" + assert _address_key("Луговая, 5", 50) is None + # У 66 город-ядро есть — историческое поведение сохранено. + assert _address_key("Малышева, 125", 66) == (66, "екатеринбург", "малышева") + + +def test_build_centroid_map_separates_same_street_of_two_localities(): + """Дома с одноимённых улиц двух НП не усредняются в одну точку.""" + house_rows = [ + {"address": "Московская обл., д Сабурово, ул Луговая, 1", "lat": 55.40, "lon": 37.60}, + {"address": "Московская обл., д Сабурово, ул Луговая, 3", "lat": 55.41, "lon": 37.61}, + {"address": "Московская обл., г Химки, ул Луговая, 2", "lat": 55.89, "lon": 37.43}, + ] + db, _ = _make_db_mock(house_rows=house_rows) + + centroids = _build_centroid_map(db, region_code=50) + + assert set(centroids) == {(50, "сабурово", "луговая"), (50, "химки", "луговая")} + assert centroids[(50, "сабурово", "луговая")].house_count == 2 + assert centroids[(50, "сабурово", "луговая")].lat == pytest.approx(55.405) + assert centroids[(50, "химки", "луговая")].lat == pytest.approx(55.89) + + +def test_build_centroid_map_drops_bucket_with_huge_spread(): + """Ведро с разбросом в десятки км — склейка НП, выбрасывается целиком.""" + house_rows = [ + {"address": "Московская обл., г Химки, ул Ленина, 1", "lat": 55.89, "lon": 37.43}, + # Тот же ключ, но точка в 60+ км — так выглядит склейка двух НП. + {"address": "Московская обл., г Химки, ул Ленина, 2", "lat": 55.30, "lon": 38.20}, + ] + db, _ = _make_db_mock(house_rows=house_rows) + + assert _build_centroid_map(db, region_code=50) == {} + # Порог поднят выше разброса → ведро остаётся. + kept = _build_centroid_map(db, region_code=50, max_spread_km=100.0) + assert set(kept) == {(50, "химки", "ленина")} + + +def test_centroid_query_filters_by_region(): + """Карта центроидов строится по домам ОДНОГО региона, а не по всем.""" + db, _ = _make_db_mock(house_rows=[]) + + _build_centroid_map(db, region_code=50) + + args, _kw = db.execute.call_args + sql_str = str(args[0]) + assert "FROM houses" in sql_str + assert "region_code = CAST(:rc AS smallint)" in sql_str + assert args[1]["rc"] == 50 + + +def test_candidate_select_and_update_are_scoped_to_one_region(): + """Прогон по региону 50 не выбирает и не обновляет строки другого региона.""" + db, _unused = _make_db_mock( + deal_rows=[{"id": 7, "address": "Сабурово, Луговая", "city": "Сабурово"}] + ) + + deals = _select_deals_without_coords(db, 10, region_code=50) + args, _kw = db.execute.call_args + select_sql = str(args[0]) + assert "FROM deals" in select_sql + assert "region_code = CAST(:rc AS int)" in select_sql + assert args[1]["rc"] == 50 + assert deals[0].city == "Сабурово" + + _update_deal_coords(db, deal_id=7, lat=55.4, lon=37.6, region_code=50) + args, _kw = db.execute.call_args + update_sql = str(args[0]) + assert "UPDATE deals" in update_sql + assert "region_code = CAST(:rc AS int)" in update_sql + assert args[1]["rc"] == 50 + + +def test_run_backfill_uses_deal_city_column_for_the_locality(): + """deals.city (заполнена на 100% по области) переопределяет НП из адреса.""" + centroids = {(50, "сабурово", "луговая"): Centroid(lat=55.4, lon=37.6, house_count=2)} + db, updated = _make_db_mock() + deals = [DealRow(id=11, address="Луговая", city="Сабурово")] + + stats = _run_backfill(db, deals, centroids, batch="t", dry_run=False, region_code=50) + + assert stats.geocoded == 1 + assert updated[0]["lat"] == pytest.approx(55.4) + assert updated[0]["rc"] == 50