fix(tradein/estimate): город развёртки попадает в городской фильтр коридора сделок (#2583 H1)
Резолвер города знал 24 имени региона 66, а развёртки идут ещё по 23 городам (Реж, Карпинск, Кушва, Лесной, Тавда…). Город не распознан — коридор ДКП собирался по одной улице во всей области, где «Ленина» в основном екатеринбургская, и Реж получал цену Екатеринбурга. Резолвер теперь знает и города развёрток (кроме городов других регионов). Общий словарь региона не трогали: на нём гейты геокодера и токены ключа дома. Замер прода: по всем 23 городам сделки лежат под теми же е-формами, в сделках ЕКБ ни одного адреса с этими именами отдельным словом. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a130303cc9
commit
a4e97c4b70
3 changed files with 143 additions and 12 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 — ему город нужен для фильтра сделок, а не для гейта.)
|
||||
|
||||
Цена решения (осознанная): легитимный, но не перечисленный населённый пункт
|
||||
(«Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue