From d78b1f888125224da500b10cb8430ee206325a49 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 13:43:56 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20=D0=94=D0=9A=D0=9F-=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=B4=D0=BE=D1=80=20=D0=BF=D0=BE=20=D0=9C=D0=BE?= =?UTF-8?q?=D1=81=D0=BA=D0=B2=D0=B5=20=D0=BD=D0=B5=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B8=D0=BB=D1=81=D1=8F=20=E2=80=94=20=D0=B8=D0=BC=D1=8F?= =?UTF-8?q?=20=D1=83=D0=BB=D0=B8=D1=86=D1=8B=20=D0=BD=D0=B5=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BA=D0=B0=D0=BB=D0=BE=D1=81=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extract_street_name` возвращал None для любого московского адреса, потому что парсер ждёт тип улицы ПЕРЕД названием («ул. Малышева»), а в Москве он стоит после: «Тверская улица, 6». Keyword-регекс требует пробел сразу за типом, там запятая — совпадения нет вовсе; дальше fallback брал первый токен с большой буквы, получал «Москва» из стоп-списка и отдавал None. Следствие на проде (замер 12.09): оценка по московскому адресу отвечает 200 с 25 аналогами, но `dkp_corridor` в ответе — null, при 212 937 московских ДКП в базе. Коридор сделок по Москве не строился ни разу. Добавлен второй проход: ищем тип улицы без требования пробела и берём 1-3 слова ДО него в пределах той же запятой-секции. Прежний путь не тронут — «ул. X» и реверс-формат Nominatim разбираются как раньше; непустые результаты не меняются, новый проход даёт значение только там, где раньше был None. Списки типов улиц вынесены в общую константу, чтобы два регекса не разъехались при добавлении нового типа. Нумерованные проезды («Проектируемый проезд № 4062») намеренно остаются None: имя «Проектируемый» собрало бы коридор по сотням разных проездов. ## Регион-скоуп двух ручек Непустое имя улицы включает `/street-deals` и `/sales-vs-listings`, где раньше для Москвы был ранний выход. Обе скоупятся только по `_resolve_target_city` — словарю городов Свердловской области, — поэтому для Москвы фильтр города пуст, и остаётся один ILIKE по улице. Замер на проде: улица «Ясная» — 168 сделок в регионе 66 и 80 в 77, «Советская» — 1202 и 17. Без фильтра региона московский запрос смешал бы екатеринбургские сделки в медиану, то есть фикс парсера сам по себе открыл бы дыру. Поэтому в обе ручки добавлен обязательный фильтр по `region_code`; регион выводится из адреса через реестр регионов точным сравнением сегмента, а не подстрокой — иначе екатеринбургская «Московская улица» уехала бы в регион 77. В `deals` регион заполнен у всех строк (66 → 108 623, 77 → 212 937, NULL нет), так что фильтр ничего не отрезает у существующих запросов. У `/sales-vs-listings` табличная функция параметра региона не знает, её миграция в этот фикс не входит. Фильтр применён снаружи, соединением с `deals` по идентификатору сделки: сторона объявлений остаётся без регион-скоупа. Это осознанный компромисс, он описан в коде; полный фикс — отдельная миграция с параметром региона внутри функции. Тесты: 535 passed во всех файлах, затрагивающих коридор и уличную статистику (+13 новых), ruff чистый. --- tradein-mvp/backend/app/api/v1/trade_in.py | 56 +++++++- tradein-mvp/backend/app/services/estimator.py | 107 ++++++++++++++- .../backend/tests/test_sales_vs_listings.py | 51 +++++++ .../tests/test_street_deals_endpoint.py | 128 ++++++++++++++++++ 4 files changed, 328 insertions(+), 14 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 211ca88b..56db97ae 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -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() diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 8fcd11c1..b0eaf826 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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"(? 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: diff --git a/tradein-mvp/backend/tests/test_sales_vs_listings.py b/tradein-mvp/backend/tests/test_sales_vs_listings.py index e0af347b..593ecc53 100644 --- a/tradein-mvp/backend/tests/test_sales_vs_listings.py +++ b/tradein-mvp/backend/tests/test_sales_vs_listings.py @@ -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) ─────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_street_deals_endpoint.py b/tradein-mvp/backend/tests/test_street_deals_endpoint.py index 570711db..ca155206 100644 --- a/tradein-mvp/backend/tests/test_street_deals_endpoint.py +++ b/tradein-mvp/backend/tests/test_street_deals_endpoint.py @@ -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