From a4e97c4b708e349c21727ffcd409d668deee37c3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 14:33:00 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/estimate):=20=D0=B3=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=20=D1=80=D0=B0=D0=B7=D0=B2=D1=91=D1=80=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=BF=D0=B0=D0=B4=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B2=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=D1=81=D0=BA=D0=BE=D0=B9?= =?UTF-8?q?=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=B4=D0=BE=D1=80=D0=B0=20=D1=81=D0=B4=D0=B5=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=20(#2583=20H1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Резолвер города знал 24 имени региона 66, а развёртки идут ещё по 23 городам (Реж, Карпинск, Кушва, Лесной, Тавда…). Город не распознан — коридор ДКП собирался по одной улице во всей области, где «Ленина» в основном екатеринбургская, и Реж получал цену Екатеринбурга. Резолвер теперь знает и города развёрток (кроме городов других регионов). Общий словарь региона не трогали: на нём гейты геокодера и токены ключа дома. Замер прода: по всем 23 городам сделки лежат под теми же е-формами, в сделках ЕКБ ни одного адреса с этими именами отдельным словом. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/services/estimator.py | 33 +++-- tradein-mvp/backend/app/services/geocoder.py | 7 +- .../test_estimator_target_city_sweep_2583.py | 115 ++++++++++++++++++ 3 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_estimator_target_city_sweep_2583.py diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 8beb6cd1..da9bdb96 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -34,6 +34,7 @@ from typing import Any, Literal from uuid import uuid4 from fastapi import HTTPException +from scraper_kit.orchestration.pipeline import CITY_DISPLAY_NAMES from scraper_kit.providers.avito.imv import ( IMVAddressNotFoundError, IMVAuthError, @@ -2115,15 +2116,29 @@ def _sber_time_factor(series: dict[date, float], deal_month: date) -> float: return max(SBER_TIME_FACTOR_MIN, min(SBER_TIME_FACTOR_MAX, factor)) -# Word/phrase-boundary regex поверх geocoder.SVERDLOVSK_OBLAST_CITIES (~30 городов -# региона 66) — resolve target-города для city-scoping ДКП-коридора (см. -# _resolve_target_city). Длинные имена раньше коротких (сортировка по len desc), -# чтобы «нижний тагил» матчился целиком, а не как «тагил» после «нижний» отдельно -# (тут это не критично т.к. паттерн — alternation по ЦЕЛЫМ именам городов, но -# сохраняем ту же дисциплину сортировки, что и geocoder._OBLAST_CITY_RE). +# #2583 H1: словарь региона 66 (24 имени) не знал 23 города, по которым уже идут +# развёртки (Реж, Карпинск, Кушва, Лесной, Тавда…). Нераспознанный город снимал с +# ДКП-коридора городской фильтр: «Реж, Ленина, 12» получал коридор по «Ленина» всей +# области, где доминирует ЕКБ. Добираем имена городов развёрток (те же, что пишутся в +# listings.city), кроме городов других регионов (Москва). В общий REGIONS[66].cities +# НЕ кладём: на нём стоят гейты геокодера и токены ключа дома (_OBLAST_CITY_TOKENS +# вырезал бы «новая»/«лесной» из имён улиц). ё→е — вход нормализуется до поиска, а +# deals.city хранит е-формы («Артемовский»). +_TARGET_CITIES: frozenset[str] = SVERDLOVSK_OBLAST_CITIES | frozenset( + name.lower().replace("ё", "е") + for name in CITY_DISPLAY_NAMES.values() + if (region := regions_mod.region_by_city(name)) is None or region.code == 66 +) + +# Word/phrase-boundary regex поверх _TARGET_CITIES — resolve target-города для +# city-scoping ДКП-коридора (см. _resolve_target_city). Длинные имена раньше коротких +# (сортировка по len desc), чтобы «нижний тагил» матчился целиком, а не как «тагил» +# после «нижний» отдельно (тут это не критично т.к. паттерн — alternation по ЦЕЛЫМ +# именам городов, но сохраняем ту же дисциплину сортировки, что и +# geocoder._OBLAST_CITY_RE). _TARGET_CITY_RE = re.compile( r"\b(?:" - + "|".join(re.escape(c) for c in sorted(SVERDLOVSK_OBLAST_CITIES, key=len, reverse=True)) + + "|".join(re.escape(c) for c in sorted(_TARGET_CITIES, key=len, reverse=True)) + r")\b" ) @@ -2131,8 +2146,8 @@ _TARGET_CITY_RE = re.compile( def _resolve_target_city(address_text: str | None) -> str | None: """Извлекает город региона 66 из геокодированного адреса (best-effort). - Ищет ЛЮБОЕ известное имя города (geocoder.SVERDLOVSK_OBLAST_CITIES) по - границе слова/фразы — работает и для forward-формата («Екатеринбург, ул. + Ищет ЛЮБОЕ известное имя города (_TARGET_CITIES: словарь региона 66 + города + развёрток) по границе слова/фразы — работает и для forward-формата («Екатеринбург, ул. Ленина, 5»), и для reverse/Yandex raw-текста («Россия, Свердловская область, Нижний Тагил, улица Ленина, 50»), позиция города в строке не важна. ё→е нормализация ДО поиска (симметрично _normalize_building_key). diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 8f7412c9..6f38125f 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -204,9 +204,10 @@ def known_city_hint(value: str | None, region_code: int = 66) -> str | None: Словарь — `SVERDLOVSK_OBLAST_CITIES`, тот же, на котором стоят городские гейты самого геокодера. Отдельного списка сознательно НЕ заводим: город, отсутствующий в этом наборе, и так обрабатывается геокодером как незнакомый - (`_names_non_ekb_city` его не увидит, `estimator._resolve_target_city` не - резолвит) — т.е. новый город области в любом случае добавляется СЮДА, и - гейт хинта не создаёт новой связности. + (`_names_non_ekb_city` его не увидит) — т.е. новый город области в любом + случае добавляется СЮДА, и гейт хинта не создаёт новой связности. + (`estimator._resolve_target_city` дополнительно знает города развёрток, + #2583 H1 — ему город нужен для фильтра сделок, а не для гейта.) Цена решения (осознанная): легитимный, но не перечисленный населённый пункт («Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по diff --git a/tradein-mvp/backend/tests/test_estimator_target_city_sweep_2583.py b/tradein-mvp/backend/tests/test_estimator_target_city_sweep_2583.py new file mode 100644 index 00000000..fcd0ab3d --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_target_city_sweep_2583.py @@ -0,0 +1,115 @@ +"""#2583 H1 — город развёртки обязан попадать в городской фильтр ДКП-коридора. + +Словарь региона 66 (24 имени) не знал 23 города, по которым уже идут развёртки +(Реж, Карпинск, Кушва, Лесной, Тавда…). `_resolve_target_city` возвращал None, +`_fetch_dkp_corridor` собирал сделки по одной улице во всей области — «Ленина» там +в основном екатеринбургская, и коридор Режа получался ценой Екатеринбурга. + +Замер прода 2026-09-17 (tradein-postgres, deals source='rosreestr', region 66): по +всем 23 городам сделки есть под теми же е-формами (Реж 745, из них 189 за 12 мес.; +Карпинск 521/161; Лесной 1215/402), а среди сделок Екатеринбурга ни один адрес не +содержит этих имён отдельным словом — ложного городского фильтра на ЕКБ-адресах нет. +""" + +from __future__ import annotations + +import os +from datetime import date +from typing import Any +from unittest.mock import MagicMock + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.orchestration.pipeline import CITY_DISPLAY_NAMES + +from app.services.estimator import _fetch_dkp_corridor, _resolve_target_city + +_REGION_66_SWEEP_CITIES = sorted( + name for slug, name in CITY_DISPLAY_NAMES.items() if slug != "moskva" +) + + +@pytest.mark.parametrize( + ("address", "city"), + [ + ("Реж, Ленина, 12", "реж"), + ("Карпинск, Ленина, 5", "карпинск"), + ("Россия, Свердловская область, Артёмовский, улица Ленина, 5", "артемовский"), + ("Свердловская обл., Нижнетуринский г.о., Нижняя Тура, ул. Ленина, 3", "нижняя тура"), + ], +) +def test_sweep_city_from_the_audit_is_resolved(address: str, city: str) -> None: + assert _resolve_target_city(address) == city + + +@pytest.mark.parametrize("name", _REGION_66_SWEEP_CITIES) +def test_every_region_66_sweep_city_is_resolved(name: str) -> None: + """Новый город развёртки не должен снова выпасть в «коридор по всей области».""" + assert _resolve_target_city(f"{name}, ул. Ленина, 12") == name.lower().replace("ё", "е") + + +def test_moscow_is_not_pulled_into_the_oblast_resolver() -> None: + """Москва — город развёртки, но другого региона: для неё прежний None.""" + assert _resolve_target_city("Москва, ул. Тверская, 1") is None + + +def test_ekb_district_named_like_a_city_still_not_a_city() -> None: + """Прежний гард «мкр Заречный» не сломан расширением словаря.""" + assert _resolve_target_city("р-н Верх-Исетский, мкр. Заречный, улица Опалихинская, 20А") is None + + +# ── коридор: значение, а не параметр ───────────────────────────────────────── + +_EKB_PPM2 = 150_000 +_REZH_PPM2 = 55_000 + + +def _deals_db() -> Any: + """Двойник сессии, исполняющий городской фильтр так же, как SQL коридора. + + Улица «Ленина» есть и в Екатеринбурге (10 сделок), и в Реже (4 сделки). Без + `LOWER(d.city) = :target_city` запрос отдаёт обе, с ним — только свой город. + """ + rows = [{"city": "екатеринбург", "ppm2": _EKB_PPM2}] * 10 + [ + {"city": "реж", "ppm2": _REZH_PPM2} + ] * 4 + + def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock: + result = MagicMock() + sql = str(query) + picked: list[dict[str, Any]] = [] + if "FROM deals d" in sql: + city = (params or {}).get("target_city") + filtered = "LOWER(d.city) = CAST(:target_city AS text)" in sql + picked = [ + {"price_per_m2": r["ppm2"], "deal_date": date(2026, 1, 1)} + for r in rows + if not filtered or r["city"] == city + ] + result.mappings.return_value.all.return_value = picked + return result + + db = MagicMock() + db.execute.side_effect = _execute + return db + + +def test_rezh_corridor_is_built_from_rezh_deals_not_the_whole_oblast() -> None: + """«Реж, Ленина, 12»: медиана коридора — цена Режа, а не Екатеринбурга. + + На main город не распознан → фильтра нет → 10 екатеринбургских сделок из 14 + и медиана 150 000 ₽/м². + """ + address = "Реж, Ленина, 12" + raw = _fetch_dkp_corridor( + _deals_db(), + address=address, + rooms=2, + area=45.0, + city=_resolve_target_city(address), + ) + assert raw is not None + assert raw["count"] == 4 + assert raw["median_ppm2"] == _REZH_PPM2