ДКП-коридор по Москве не строился: имя улицы не извлекалось из московского формата адреса #3473

Merged
lekss361 merged 1 commit from fix/msk-street-name-suffix into main 2026-09-12 10:53:47 +00:00
4 changed files with 328 additions and 14 deletions

View file

@ -2201,6 +2201,7 @@ def get_street_deals(
_percentile,
_resolve_target_city,
extract_street_name,
region_code_for_address,
)
now = datetime.now(tz=UTC)
@ -2244,6 +2245,17 @@ def get_street_deals(
target_city = _resolve_target_city(address)
city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else ""
# #dkp-corridor-scope (2026-09-12): region_code — обязательный фильтр, в отличие
# от city_filter выше (который применяется, только если _resolve_target_city
# узнал город обл.66). extract_street_name после фикса московского порядка
# ("Название улица") стал возвращать имя улицы и для Москвы, а
# _resolve_target_city знает ТОЛЬКО города обл.66 → для Москвы target_city=None,
# city_filter пуст, и без region_code одноимённая улица другого региона
# подмешалась бы в выборку (прод-замер: "Ясная" — 168 сделок в 66 и 80 в 77,
# "Советская" — 1202 и 17). region_code заполнен у всех deals (66→108 623,
# 77→212 937, NULL нет) — фильтр не отрезает ни одной существующей строки.
region_code = region_code_for_address(address)
rows = (
db.execute(
text(
@ -2255,6 +2267,7 @@ def get_street_deals(
AND address ILIKE :street_pattern
AND address ~* :street_regex
{city_filter}
AND region_code = CAST(:region_code AS integer)
-- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади
-- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй
-- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто
@ -2269,6 +2282,7 @@ def get_street_deals(
"street_pattern": "%" + street_name + "%",
"street_regex": r"\m" + street_name + r"\M",
"target_city": target_city.lower() if target_city else None,
"region_code": region_code,
"area_min": area_min,
"area_max": area_max,
"period_months": period_months,
@ -2503,7 +2517,12 @@ def get_sales_vs_listings(
Per-street view: Росреестр open dataset агрегирует адреса до улицы.
"""
from app.services.estimator import _percentile, _resolve_target_city, extract_street_name
from app.services.estimator import (
_percentile,
_resolve_target_city,
extract_street_name,
region_code_for_address,
)
def _empty(reason_street: str | None = None) -> SalesVsListingsResponse:
return SalesVsListingsResponse(
@ -2533,16 +2552,36 @@ def get_sales_vs_listings(
# известная H1) → фильтр не применяется на TVF-стороне (см. миграцию 205).
target_city = _resolve_target_city(address)
# #dkp-corridor-scope (2026-09-12): region_code — фильтр ДОПОЛНИТЕЛЬНО к
# target_city выше, нужен по той же причине, что и в /street-deals: для
# Москвы _resolve_target_city (словарь городов ТОЛЬКО обл.66) возвращает
# None → target_city-фильтр внутри TVF (миграция 205) не срабатывает ни
# для deals, ни для listings, и одноимённая улица другого региона
# подмешивается в пары (см. estimator.region_code_for_address).
#
# TVF street_sales_vs_listings() (миграция 205) параметра региона не
# знает — сама TVF не трогается (это отдельная миграция, вне текущего
# фикса), фильтр применён СНАРУЖИ: оборачиваем вызов в JOIN на deals по
# deal_id и фильтруем region_code ТОЛЬКО на стороне сделок. Сторона
# listings внутри TVF остаётся НЕ отфильтрованной по региону — это
# известный узкий компромисс, не побочный эффект: у listings нет
# общего для всех источников поля региона (city заполнен частично, см.
# комментарий 205 выше), а сам JOIN внутри TVF уже требует совпадения
# street_pattern + rooms + area + date proximity, что резко сужает шанс
# чужого региона на listing-стороне. Полный фикс (передать region_code
# внутрь TVF седьмым/восьмым параметром) — отдельная миграция.
region_code = region_code_for_address(address)
rows = (
db.execute(
text(
"""
SELECT
deal_id, deal_date, deal_price_rub, deal_price_per_m2,
deal_area_m2, deal_rooms, deal_floor, deal_address,
listing_id, listing_source, listing_source_url,
listing_date, listing_price_rub, listing_price_per_m2,
listing_area_m2, days_listing_to_deal, discount_pct
sv.deal_id, sv.deal_date, sv.deal_price_rub, sv.deal_price_per_m2,
sv.deal_area_m2, sv.deal_rooms, sv.deal_floor, sv.deal_address,
sv.listing_id, sv.listing_source, sv.listing_source_url,
sv.listing_date, sv.listing_price_rub, sv.listing_price_per_m2,
sv.listing_area_m2, sv.days_listing_to_deal, sv.discount_pct
FROM street_sales_vs_listings(
CAST(:street_pattern AS text),
CAST(:area_m2 AS numeric),
@ -2551,7 +2590,9 @@ def get_sales_vs_listings(
CAST(:area_tolerance AS numeric),
CAST(:period_months AS integer),
CAST(:target_city AS text)
)
) sv
JOIN deals d ON d.id = sv.deal_id
WHERE d.region_code = CAST(:region_code AS integer)
"""
),
{
@ -2562,6 +2603,7 @@ def get_sales_vs_listings(
"area_tolerance": area_tolerance,
"period_months": period_months,
"target_city": target_city,
"region_code": region_code,
},
)
.mappings()

View file

@ -1991,6 +1991,38 @@ def _resolve_target_city(address_text: str | None) -> str | None:
return None
def region_code_for_address(address: str | None) -> int:
"""Код региона покрытия (regions.REGIONS) по городу в адресе.
Режет адрес по запятым, каждый сегмент strip+lower, ищет ТОЧНОЕ
совпадение с одним из городов региона (regions.region_by_city). Точное
равенство сегмента, а НЕ подстрока: подстрочный поиск "москва" в адресе
поймал бы екатеринбургскую "Московская улица" и увёл её в регион 77
сегмент "московская улица" целиком "москва".
#dkp-corridor-scope (2026-09-12): нужен для WHERE region_code = ... в
get_street_deals/get_sales_vs_listings extract_street_name после фикса
московского порядка ("Название улица") стал возвращать имя улицы и для
Москвы, и без явного region-скоупа одноимённая улица из другого региона
(66 vs 77) подмешивалась бы в медиану (напр. "Ясная": 168 сделок в 66 и
80 в 77, "Советская": 1202 и 17).
Returns regions.DEFAULT_REGION_CODE (66), если ни один сегмент не
распознан как город ни одного региона байт-в-байт прежнее поведение
(все ЕКБ/безымянные адреса и раньше трактовались как регион 66).
"""
if not address:
return regions_mod.DEFAULT_REGION_CODE
for raw_segment in address.split(","):
segment = raw_segment.strip()
if not segment:
continue
region = regions_mod.region_by_city(segment)
if region:
return region.code
return regions_mod.DEFAULT_REGION_CODE
def _fetch_dkp_corridor(
db: Session,
*,
@ -5987,12 +6019,34 @@ def _extract_short_addr(full_address: str | None) -> str | None:
# идентичный результат (см. test_street_deals_endpoint.py). Бывшая отдельная
# bare-альтернатива "мкр" убрана как ставшая избыточной — "мкр\.?" уже
# покрывает оба варианта (с точкой и без).
_STREET_KW_RE = re.compile(
r"(?<![А-Яа-яёЁa-zA-Z])"
# Общий список street-keyword альтернатив — единственное место, где он
# перечислен. И _STREET_KW_RE (forward, требует \s+ после), и
# _STREET_KW_SUFFIX_RE (moscow-reverse, без \s+) собираются из этой же
# константы, чтобы при добавлении нового типа улицы (аллея/линия/...) их
# списки не разъехались независимой правкой одного из двух regex.
_STREET_KW_ALTERNATION = (
r"(?:ул\.?|улица|пр\.?|пр-т|проспект|пер\.?|переулок|"
r"б-р|бульвар|ш\.?|шоссе|наб\.?|набережная|проезд|тракт|"
r"пл\.?|площадь|мкр\.?|микрорайон)"
r"\s+",
)
_STREET_KW_RE = re.compile(
r"(?<![А-Яа-яёЁa-zA-Z])" + _STREET_KW_ALTERNATION + r"\s+",
flags=re.IGNORECASE | re.UNICODE,
)
# Тот же список keyword'ов (_STREET_KW_ALTERNATION), но БЕЗ обязательного
# \s+ после них — вместо этого lookahead на запятую/пробел/конец строки.
# Нужен для московского формата "Название улица, 6" (Тверская улица,
# Ленинский проспект, Брюсов переулок): там сразу после keyword идёт запятая,
# и _STREET_KW_RE (требующий \s+) вообще не матчит эту позицию — ни keyword,
# ни имя после него найти нельзя. Используется ТОЛЬКО как fallback ниже,
# после того как forward-путь через _STREET_KW_RE не дал результата — может
# дать результат там, где раньше был None (напр. keyword найден, но имя
# после него не распарсилось: "Тверская ул. 6"); непустые результаты
# форматов, которые уже что-то возвращали, не меняются.
_STREET_KW_SUFFIX_RE = re.compile(
r"(?<![А-Яа-яёЁa-zA-Z])" + _STREET_KW_ALTERNATION + r"(?=[,\s]|$)",
flags=re.IGNORECASE | re.UNICODE,
)
@ -6017,10 +6071,29 @@ def extract_street_name(full_address: str | None) -> str | None:
"ул. Большая Конюшенная, 25" "Большая Конюшенная"
"" None
Московский формат keyword стоит ПОСЛЕ имени улицы ("Название улица, N"),
а не перед ним:
"Москва, Тверская улица, 6" "Тверская"
"Москва, 1-я Тверская-Ямская улица, 12" "1-я Тверская-Ямская"
"Москва, Брюсов переулок, 8" "Брюсов"
"Москва, Ленинский проспект, 30" "Ленинский"
"Театр имени М. Н. Ермоловой, 5/6, Тверская улица, 58,
Тверской район, Москва, Центральный федеральный округ,
125009, Россия" (reverse-формат Nominatim) → "Тверская"
"Москва, Проектируемый проезд № 4062, 5" None (безымянный
нумерованный проезд)
Алгоритм:
1. Ищем street-keyword (ул/улица/пр/проспект/...) case-insensitive.
2. После keyword берём 1-3 слова до запятой или номера дома.
3. Если keyword не нашёлся пытаемся первый capitalized токен с
1. Ищем street-keyword (ул/улица/пр/проспект/...) case-insensitive,
берём 1-3 слова ПОСЛЕ него до запятой или номера дома (forward-формат).
2. Московский формат: сразу после keyword запятая/конец строки (имени
после него нет, т.к. keyword в этом формате идёт ПОСЛЕ названия).
Ищем keyword ещё раз без требования пробела после него и берём 1-3
слова ДО keyword внутри той же запятой-секции ("Тверская улица, 6"
"Тверская"). Не срабатывает, если сразу после keyword ""
(нумерованный проезд без имени, напр. "Проектируемый проезд №
4062" — извлекать нечего, "Проектируемый" НЕ street name).
3. Если keyword не нашёлся вовсе пытаемся первый capitalized токен с
поиском до запятой или номера (fallback для адресов без keyword'а).
Returns None если ничего не извлеклось.
@ -6038,7 +6111,27 @@ def extract_street_name(full_address: str | None) -> str | None:
if nm:
return nm.group(1).strip()
# 2. Fallback: нет keyword — пробуем первый capitalized токен
# 2. Московский формат "Название улица, N": keyword идёт ПОСЛЕ имени, и
# сразу за ним запятая/конец строки — _STREET_KW_RE (шаг 1) такую позицию
# вообще не матчит, т.к. требует \s+ сразу после keyword. Берём keyword
# отдельно (без требования пробела) и смотрим на слова ДО него в пределах
# той же запятой-секции.
km = _STREET_KW_SUFFIX_RE.search(s)
if km:
# Исключение: "Проектируемый проезд № 4062" — keyword с числовым
# индексом БЕЗ имени (forward-паттерн "keyword № N", не moscow-
# reverse). Слово перед keyword ("Проектируемый") здесь — прилагательное
# к самому "проезду", а не имя улицы; сотни таких проездов имеют один
# и тот же "Проектируемый" — коридор по нему смешал бы все их сделки.
after = s[km.end() :].lstrip()
if not after.startswith(""):
segment_start = s.rfind(",", 0, km.start()) + 1
before = s[segment_start : km.start()].strip()
words = before.split()
if words:
return " ".join(words[-3:])
# 3. Fallback: нет keyword — пробуем первый capitalized токен
# Используется для "Большая Конюшенная, 25" без "ул."
nm = _STREET_NAME_RE.match(s)
if nm:

View file

@ -419,6 +419,57 @@ def test_sales_vs_listings_target_city_none_when_city_unresolved(
assert params["target_city"] is None
# ── Test: region-scope (#dkp-corridor-scope 2026-09-12) ───────────────────────
def test_sales_vs_listings_region_code_scoped_for_moscow(trade_in_app: FastAPI) -> None:
"""Московский адрес: target_city остаётся None (словарь только обл.66), но
region_code=77 обязан уйти в SQL иначе TVF смешивает московские сделки с
одноимённой улицей ЕКБ (region_code=66) на стороне deals."""
db_mock = _make_db_mock([])
_override_db(trade_in_app, db_mock)
client = TestClient(trade_in_app)
resp = client.get(
"/api/v1/trade-in/sales-vs-listings",
params={
"address": "Москва, Тверская улица, 6",
"area_m2": 44.3,
"rooms": 2,
},
)
assert resp.status_code == 200
assert db_mock.execute.called
args, kwargs = db_mock.execute.call_args
sql_text = str(args[0])
params = args[1] if len(args) > 1 else kwargs.get("parameters", {})
assert params["target_city"] is None
assert params["region_code"] == 77
assert "region_code" in sql_text and "deals" in sql_text, (
"SQL must JOIN deals and filter by region_code"
)
def test_sales_vs_listings_region_code_unchanged_for_ekaterinburg(trade_in_app: FastAPI) -> None:
"""Регресс-гейт: екатеринбургский адрес по-прежнему скоупится в регион 66."""
db_mock = _make_db_mock([])
_override_db(trade_in_app, db_mock)
client = TestClient(trade_in_app)
resp = client.get(
"/api/v1/trade-in/sales-vs-listings",
params={
"address": "г. Екатеринбург, ул. Малышева, 125",
"area_m2": 65.5,
"rooms": 3,
},
)
assert resp.status_code == 200
args, kwargs = db_mock.execute.call_args
params = args[1] if len(args) > 1 else kwargs.get("parameters", {})
assert params["region_code"] == 66
# ── Test: response shape (Pydantic validation) ───────────────────────────────

View file

@ -107,6 +107,50 @@ def test_extract_street_name_parametrized(address: str | None, expected: str | N
assert extract_street_name(address) == expected
@pytest.mark.parametrize(
"address,expected",
[
# Live-prod repro (2026-09-12): московский формат — keyword стоит
# ПОСЛЕ имени улицы ("Название улица, N"), а не перед ним. Сразу
# после keyword идёт запятая — _STREET_KW_RE (форматы "ул. X")
# такую позицию вообще не матчит (требует \s+), из-за чего
# extract_street_name возвращал None для ВСЕХ московских адресов,
# и ДКП-коридор (_fetch_dkp_corridor) для Москвы никогда не строился
# (212 937 московских сделок в БД, dkp_corridor=null на проде).
("Москва, Тверская улица, 6", "Тверская"),
("Москва, 1-я Тверская-Ямская улица, 12", "1-я Тверская-Ямская"),
("Москва, Брюсов переулок, 8", "Брюсов"),
("Москва, Ленинский проспект, 30", "Ленинский"),
# Reverse-формат Nominatim с московским порядком keyword'а.
(
"Театр имени М. Н. Ермоловой, 5/6, Тверская улица, 58, Тверской"
" район, Москва, Центральный федеральный округ, 125009, Россия",
"Тверская",
),
# Площадь — тот же реверс-порядок, keyword не только "улица"/"переулок".
("Москва, Красная площадь, 1", "Красная"),
# Новая Москва — Coммунарка/поселения — тот же keyword-порядок работает
# без явного упоминания слова "Москва" в адресе.
("поселение Сосенское, Коммунарка, Ясная улица, 5", "Ясная"),
# Голый keyword без имени перед ним (пустая запятая-секция) — извлекать
# нечего, а не "" или мусор из соседнего сегмента.
("Москва, улица, 5", None),
# Нумерованный проезд БЕЗ имени: "Проектируемый" — прилагательное к
# самому "проезду" (сотни таких проездов в Москве имеют один и тот же
# префикс), keyword+"№" — НЕ moscow-reverse формат с именем.
("Москва, Проектируемый проезд № 4062, 5", None),
],
)
def test_extract_street_name_moscow_reverse_order(
address: str | None, expected: str | None
) -> None:
"""Московский формат "Название улица, N" — keyword после имени, не перед
ним (#dkp-corridor live-prod fix 2026-09-12)."""
from app.services.estimator import extract_street_name
assert extract_street_name(address) == expected
@pytest.mark.parametrize(
"address,expected",
[
@ -358,3 +402,87 @@ def test_street_regex_param_passed_to_db(trade_in_app: FastAPI) -> None:
assert r"\m" in regex_val or r"\b" in regex_val or regex_val.startswith(r"\m"), (
f"Expected word-boundary in regex, got: {regex_val!r}"
)
# ── Test: region-scope (#dkp-corridor-scope 2026-09-12) ───────────────────────
@pytest.mark.parametrize(
"address,expected_region_code",
[
("Москва, Тверская улица, 6", 77),
("Екатеринбург, ул. Космонавтов, 50", 66),
# Точное совпадение сегмента, НЕ подстрока: "Московская улица" в ЕКБ
# содержит "москва" как подстроку, но сегмент целиком ≠ "москва" — не
# должна уехать в регион 77 (иначе одноимённая улица ЕКБ подмешалась
# бы в московскую выборку, и наоборот).
("Екатеринбург, Московская улица, 12", 66),
(None, 66),
("", 66),
],
)
def test_region_code_for_address(address: str | None, expected_region_code: int) -> None:
from app.services.estimator import region_code_for_address
assert region_code_for_address(address) == expected_region_code
def test_street_deals_region_code_scoped_for_moscow(trade_in_app: FastAPI) -> None:
"""Московский адрес: extract_street_name теперь распознаёт улицу, но
_resolve_target_city знает только города обл.66 без region_code фильтра
одноимённая улица ЕКБ (region_code=66) подмешалась бы в московскую выборку.
Ручка обязана передать region_code=77 в SQL-фильтр."""
db_mock = _make_db_mock([])
from app.core.db import get_db
def _override():
yield db_mock
trade_in_app.dependency_overrides[get_db] = _override
client = TestClient(trade_in_app)
client.get(
"/api/v1/trade-in/street-deals",
params={
"address": "Москва, Тверская улица, 6",
"area_m2": 50.0,
"rooms": 2,
},
)
assert db_mock.execute.called
call_args = db_mock.execute.call_args
sql_text = str(call_args[0][0])
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {})
assert "region_code" in params, f"region_code not passed to SQL: {params}"
assert params["region_code"] == 77
assert "region_code" in sql_text, "SQL must filter by region_code"
def test_street_deals_region_code_unchanged_for_ekaterinburg(trade_in_app: FastAPI) -> None:
"""Регресс-гейт: екатеринбургский адрес по-прежнему скоупится в регион 66
(DEFAULT_REGION_CODE) фильтр не отрезает существующие ЕКБ-результаты."""
db_mock = _make_db_mock([])
from app.core.db import get_db
def _override():
yield db_mock
trade_in_app.dependency_overrides[get_db] = _override
client = TestClient(trade_in_app)
client.get(
"/api/v1/trade-in/street-deals",
params={
"address": "Екатеринбург, ул. Космонавтов, 50",
"area_m2": 50.0,
"rooms": 2,
},
)
assert db_mock.execute.called
call_args = db_mock.execute.call_args
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {})
assert params["region_code"] == 66