From d31b1e01ee965ae534d47cd578497eb5137eb27c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 12 Jul 2026 23:18:02 +0300 Subject: [PATCH] =?UTF-8?q?refactor(tradein):=20=D0=BA=D0=BE=D0=BD=D1=81?= =?UTF-8?q?=D0=BE=D0=BB=D0=B8=D0=B4=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20corridor/street-deals=20city-scope=20=D0=BD=D0=B0=20?= =?UTF-8?q?=5Fresolve=5Ftarget=5Fcity=20(=D0=BF.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2488 (_resolve_target_city + LOWER(d.city)=:target_city) смёржен НА ВЕРХ моего #2489 (substring :address ILIKE '%'||d.city||'%') → в _fetch_dkp_corridor образовался двойной city-фильтр (benign, но избыточный + латентный ё/е edge в substring). - estimator._fetch_dkp_corridor: убран мой substring-фильтр, оставлен #2488 _resolve_target_city (словарь ~30 городов обл.66 вкл. ЕКБ + все sweep-города; city=None → фильтр не применяется — прежнее #2488-поведение). Убран unused :address bind. - api/v1/trade_in.get_street_deals: substring заменён на тот же _resolve_target_city паттерн (консистентность; #2488 не трогал street-deals). city_filter — литерал, значение bind-параметром (не инъекция). Live-verified: Ленина 2к ЕКБ → median 118052, 1 город (байт-в-байт как substring). Regression-гейт байт-зелёный (коридор заморожен в фикстуре), 53 теста, ruff clean. --- tradein-mvp/backend/app/api/v1/trade_in.py | 25 +++++++++++++------ tradein-mvp/backend/app/services/estimator.py | 18 +++++-------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 094c5ca4..c463f520 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -1638,7 +1638,12 @@ def get_street_deals( После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована в import-rosreestr.sh). """ - from app.services.estimator import _deal_to_analog, _percentile, extract_street_name + from app.services.estimator import ( + _deal_to_analog, + _percentile, + _resolve_target_city, + extract_street_name, + ) now = datetime.now(tz=UTC) # #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который @@ -1673,21 +1678,25 @@ def get_street_deals( area_min = area_m2 * (1.0 - area_tolerance) area_max = area_m2 * (1.0 + area_tolerance) + # #C1 city-scope (п.3, консистентно с estimator._fetch_dkp_corridor): резолвим + # город целевого адреса через _resolve_target_city (словарь ~30 городов обл.66 + # вкл. ЕКБ + sweep-города) и фильтруем сделки по этому городу — одноимённые улицы + # др. городов не контаминируют витрину. None (адрес вне словаря) → фильтр не + # применяется. `city_filter` — литерал (не user-input), значение идёт bind-параметром. + target_city = _resolve_target_city(address) + city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else "" + rows = ( db.execute( text( - """ + f""" SELECT address, area_m2, rooms, floor, total_floors, price_rub, price_per_m2, deal_date, source FROM deals WHERE source = 'rosreestr' AND address ILIKE :street_pattern AND address ~* :street_regex - -- #C1 city-scope (см. estimator._fetch_dkp_corridor): сделка в городе - -- целевого адреса — одноимённые улицы др. городов обл.66 (миграция 177) - -- не контаминируют витрину. Матчим канонический city как подстроку адреса. - AND city IS NOT NULL - AND :address ILIKE '%' || city || '%' + {city_filter} AND rooms = CAST(:rooms AS integer) AND area_m2 BETWEEN :area_min AND :area_max AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval @@ -1698,7 +1707,7 @@ def get_street_deals( { "street_pattern": "%" + street_name + "%", "street_regex": r"\m" + street_name + r"\M", - "address": address, + "target_city": target_city.lower() if target_city else None, "rooms": rooms, "area_min": area_min, "area_max": area_max, diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 5fc8ff0c..290305fd 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1363,9 +1363,12 @@ def _fetch_dkp_corridor( return None area_min = area * (1.0 - area_tolerance) area_max = area * (1.0 + area_tolerance) - # (oblast C2): city приходит уже lower-cased (_resolve_target_city) — deals.city в БД - # хранится с исходным регистром («Нижний Тагил»), поэтому сравниваем через - # LOWER(d.city), а не byte-equal. + # #C1/#C2 city-scope (консолидировано, п.3 dedup #2488↔#2489): сделка обязана + # быть в ГОРОДЕ целевого адреса. city приходит из _resolve_target_city (lower-case, + # словарь ~30 крупных городов обл.66 вкл. ЕКБ + все sweep-города). deals.city в БД + # с исходным регистром → сравниваем через LOWER(d.city). city=None (адрес вне + # словаря, редкий мелкий н.п.) → фильтр не применяется (прежнее #2488-поведение). + # Прежний #2489 substring-фильтр УБРАН — был двойным с этим (benign, но ё/е edge). city_filter_sql = "AND LOWER(d.city) = CAST(:target_city AS text)" if city else "" try: rows = ( @@ -1378,14 +1381,6 @@ def _fetch_dkp_corridor( WHERE d.source = 'rosreestr' AND d.address ILIKE :street_pattern AND d.address ~* :street_regex - -- #C1 city-scope: сделка должна быть в ГОРОДЕ целевого адреса. - -- Миграция 177 залила ДКП по всей обл.66 (deals.city, 368 городов) — - -- без этого одноимённые улицы др. городов (Ленина/Мира/…) - -- контаминируют коридор (median занижался ~−46% для ЕКБ). Матчим - -- канонический deals.city как подстроку целевого адреса (адрес - -- всегда содержит город: «…г. Екатеринбург…»). self-normalizing. - AND d.city IS NOT NULL - AND :address ILIKE '%' || d.city || '%' AND d.rooms = CAST(:rooms AS integer) AND d.area_m2 BETWEEN :area_min AND :area_max AND d.deal_date > NOW() @@ -1404,7 +1399,6 @@ def _fetch_dkp_corridor( { "street_pattern": "%" + street_name + "%", "street_regex": r"\m" + street_name + r"\M", - "address": address, "rooms": rooms, "area_min": area_min, "area_max": area_max,