fix(tradein/estimate): город развёртки попадает в городской фильтр коридора сделок (#2583 H1)

Резолвер города знал 24 имени региона 66, а развёртки идут ещё по 23 городам
(Реж, Карпинск, Кушва, Лесной, Тавда…). Город не распознан — коридор ДКП
собирался по одной улице во всей области, где «Ленина» в основном
екатеринбургская, и Реж получал цену Екатеринбурга.

Резолвер теперь знает и города развёрток (кроме городов других регионов).
Общий словарь региона не трогали: на нём гейты геокодера и токены ключа дома.
Замер прода: по всем 23 городам сделки лежат под теми же е-формами, в сделках
ЕКБ ни одного адреса с этими именами отдельным словом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-09-17 14:33:00 +05:00
parent a130303cc9
commit a4e97c4b70
3 changed files with 143 additions and 12 deletions

View file

@ -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).

View file

@ -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 — ему город нужен для фильтра сделок, а не для гейта.)
Цена решения (осознанная): легитимный, но не перечисленный населённый пункт
(«Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по

View file

@ -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