diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 89949dc2..88a7c33d 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -244,6 +244,7 @@ def get_estimate( _fetch_house_imv_anchor, _fetch_price_trend, _qc_geo_to_precision, + _resolve_target_city, _source_counts, ) @@ -283,7 +284,13 @@ def get_estimate( else None ) - dkp_raw = _fetch_dkp_corridor(db, address=row.address, rooms=row.rooms, area=area_f) + # (oblast C2): city-scope корридора на GET-rehydrate тоже — тот же + # resolver, что и на POST (estimate_quality), над персистированным адресом + # (canonical_address приоритетнее сырого row.address, если DaData отработал). + target_city = _resolve_target_city(row.canonical_address or row.address) + dkp_raw = _fetch_dkp_corridor( + db, address=row.address, rooms=row.rooms, area=area_f, city=target_city + ) dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None imv_raw = _fetch_house_imv_anchor( diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 14a92f90..c7c71931 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -69,7 +69,12 @@ from app.services.dadata import DadataAddressResult # CLEAN выключена → house_fias_id/гео берём из token-only /suggest). Локальный алиас # оставлен как dadata_clean_address — это точка патча в 11 тестах (без churn). from app.services.dadata import enrich_address as dadata_clean_address -from app.services.geocoder import GeocodeResult, geocode +from app.services.geocoder import ( + SVERDLOVSK_OBLAST_CITIES, + GeocodeResult, + geocode, + is_within_oblast66_bbox, +) from app.services.house_metadata import get_house_metadata from app.services.matching.houses import match_house_readonly, match_or_create_house from app.services.scraper_adapters import RealScraperConfig @@ -1265,12 +1270,57 @@ 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). +_TARGET_CITY_RE = re.compile( + r"\b(?:" + + "|".join(re.escape(c) for c in sorted(SVERDLOVSK_OBLAST_CITIES, key=len, reverse=True)) + + r")\b" +) + + +def _resolve_target_city(address_text: str | None) -> str | None: + """Извлекает город региона 66 из геокодированного адреса (best-effort). + + Ищет ЛЮБОЕ известное имя города (geocoder.SVERDLOVSK_OBLAST_CITIES) по + границе слова/фразы — работает и для forward-формата («Екатеринбург, ул. + Ленина, 5»), и для reverse/Yandex raw-текста («Россия, Свердловская + область, Нижний Тагил, улица Ленина, 50»), позиция города в строке не + важна. ё→е нормализация ДО поиска (симметрично _normalize_building_key). + + Returns None если ни один город не распознан (адрес вне известного + списка) — caller (_fetch_dkp_corridor) откатывается на behaviour без + city-фильтра. NB: коридор НЕ чисто advisory — при + n>=estimate_corridor_clamp_min_n участвует в clamp headline + radius-floor + + Tier-C anchor gate, поэтому city-scope влияет на итоговую цену. + """ + if not address_text: + return None + norm = address_text.replace("ё", "е").replace("Ё", "Е").lower() + # #C2: пропускаем город-имя, которому предшествует district-маркер — «мкр + # Заречный» это микрорайон ВНУТРИ другого города, а не город-ЗАТО Заречный + # (единственная ЕКБ-коллизия имени города и микрорайона). Первый non-district + # match по позиции = целевой город. + district_markers = {"мкр", "мкрн", "микрорайон", "р-н", "рн", "район"} + for m in _TARGET_CITY_RE.finditer(norm): + prefix_words = norm[: m.start()].split() + if prefix_words and prefix_words[-1].rstrip(".") in district_markers: + continue + return m.group(0) + return None + + def _fetch_dkp_corridor( db: Session, *, address: str | None, rooms: int | None, area: float | None, + city: str | None = None, period_months: int = DEALS_PERIOD_MONTHS, area_tolerance: float = AREA_TOLERANCE, ) -> dict[str, Any] | None: @@ -1281,6 +1331,15 @@ def _fetch_dkp_corridor( period_months) и нормализуем per-m². Возвращаем low/median/high ₽/м². ADVISORY — caller не клампит, только сурфейсит + опциональная пометка. Best-effort: None при отсутствии улицы / сделок / любой ошибке. + NB: при n>=clamp_min_n коридор клампит headline + radius-floor (НЕ чисто + advisory) — city-scope важен для корректной итоговой цены. + + city: (oblast C2) — резолвленный целевой город (см. _resolve_target_city, + вызывающая сторона передаёт lower-case имя). Улица БЕЗ city-фильтра ловит + одноимённые улицы ЛЮБОГО города региона (напр. «Ленина» есть в Нижнем + Тагиле/Лесном/Краснотурьинске/Серове/ЕКБ/Первоуральске) — коридор превращался + в кросс-областной мусор для не-ЕКБ таргетов. None (город не определился) → + фильтр не применяется, прежнее (pre-oblast) поведение сохраняется как есть. """ if not address or rooms is None or not area: return None @@ -1289,11 +1348,15 @@ 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. + city_filter_sql = "AND LOWER(d.city) = CAST(:target_city AS text)" if city else "" try: rows = ( db.execute( text( - """ + f""" SELECT d.price_per_m2, d.deal_date FROM deals d LEFT JOIN deal_city_price_bands b ON b.city = d.city @@ -1313,6 +1376,7 @@ def _fetch_dkp_corridor( AND d.deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval AND d.price_per_m2 > 0 + {city_filter_sql} -- #699 + #2478: режем нерыночные ppm²-выбросы из коридора -- expected_sold. Per-city band (deal_city_price_bands, миграция -- 178) когда для города сделки есть строка (не-ЕКБ область); @@ -1332,6 +1396,7 @@ def _fetch_dkp_corridor( "period_months": period_months, "ppm_min": DEAL_MIN_PPM2, "ppm_max": DEAL_MAX_PPM2, + "target_city": city.lower() if city else None, }, ) .mappings() @@ -1433,14 +1498,25 @@ _STREET_TYPE_TOKENS: frozenset[str] = frozenset( # Административные токены-маркеры: всё, что относится к городу/району/мкр — НЕ часть # имени дома. Дропаем токен-маркер ВМЕСТЕ со следующим за ним словом-значением -# («р-н Октябрьский», «мкр Парковый», «г Екатеринбург»). Города ЕКБ-агломерации тоже -# чистим (стоят как ведущий токен «Екатеринбург,»/«Первоуральск,»). +# («р-н Октябрьский», «мкр Парковый», «г Екатеринбург»). Города региона тоже +# чистим (стоят как ведущий токен «Екатеринбург,»/«Первоуральск,»/«Нижний Тагил,»). _ADMIN_MARKER_TOKENS: frozenset[str] = frozenset( {"рн", "р-н", "район", "мкр", "микрорайон", "г", "гор", "город", "обл", "область"} ) -_CITY_TOKENS: frozenset[str] = frozenset( - {"екатеринбург", "первоуральск", "березовский", "верхняя", "пышма", "среднеуральск", "россия"} +# (oblast C2): было hardcoded 6-town ЕКБ-агломерация +# ({"екатеринбург","первоуральск","березовский","верхняя","пышма","среднеуральск"}). +# Генерализовано на весь регион 66 — derived из geocoder.SVERDLOVSK_OBLAST_CITIES +# (~30 городов области), НЕ повторно захардкожено здесь. Многословные имена +# («нижний тагил», «верхняя пышма», «верхняя салда») бьём на отдельные слова — +# _normalize_building_key сравнивает ОДИН токен адреса за раз (адрес уже разбит +# на токены по пробелу/запятой _TOKEN_SPLIT_RE), а не всю фразу целиком. ё→е +# нормализуем при построении сета — симметрично _normalize_building_key +# (норм. `norm.replace("ё","е")` идёт ДО токенизации), избегаем недостижимых +# ё-токенов. +_OBLAST_CITY_TOKENS: frozenset[str] = frozenset( + word.replace("ё", "е") for city in SVERDLOVSK_OBLAST_CITIES for word in city.split() ) +_CITY_TOKENS: frozenset[str] = _OBLAST_CITY_TOKENS | frozenset({"россия"}) # Литера корпуса, прилипшая к номеру: «16а», «204г», «57а» → base + letter (рус/лат). _HOUSE_LETTER_RE = re.compile(r"^(?P\d+)\s*(?P[а-яa-z])?$", flags=re.UNICODE) @@ -2899,13 +2975,17 @@ async def estimate_quality( # multiple network round-trips + 1s Nominatim rate-limit sleeps). geo: GeocodeResult | None = None # Variant A: trust client-provided coords (resolved by autocomplete/map) when present - # and inside the EKB bbox — skips the geocode() chain that fails on DaData-format + # and inside the oblast bbox — skips the geocode() chain that fails on DaData-format # addresses with the Yandex key dead. Out-of-bbox / partial → ignore, geocode normally. + # (oblast C2): was tight EKB-only bbox (60.40-60.85 / 56.65-56.95) — widened to + # geocoder.is_within_oblast66_bbox (region 66) so client-coords from oblast towns also + # get this perf fast-path instead of always paying the geocode() round-trip. Perf-only, + # not a correctness gate — narrower fast-path just meant more oblast requests fell + # through to geocode() (still correct, just slower). if ( payload.lat is not None and payload.lon is not None - and 60.40 <= payload.lon <= 60.85 - and 56.65 <= payload.lat <= 56.95 + and is_within_oblast66_bbox(payload.lat, payload.lon) ): geo = GeocodeResult( lat=payload.lat, @@ -3124,12 +3204,19 @@ async def estimate_quality( # ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ────────────── # #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы # corridor_high был доступен для Tier C-гейта и soft-клампа headline. + # (oblast C2): city-scope корридора — та же precedence, что и для + # match_house_readonly выше (dadata.canonical_address приоритетнее сырого + # geo.full_address, если DaData отработал). + target_city = _resolve_target_city( + (dadata.canonical_address if dadata else None) or geo.full_address + ) dkp_raw = await asyncio.to_thread( _fetch_dkp_corridor, db, address=geo.full_address, rooms=payload.rooms, area=payload.area_m2, + city=target_city, ) # ── Stage 3: Avito IMV evaluation as 5-th source (on-demand cached) ── @@ -4999,11 +5086,15 @@ _DEDUP_HOUSE_MARKER_WORDS = frozenset( {"дом", "д", "корпус", "корп", "к", "строение", "стр", "литера", "лит", "владение", "вл"} ) # Шум-токены (город/страна/район/микрорайон) на случай, если попали в название. -_DEDUP_ADDR_NOISE_WORDS = frozenset( +# (oblast C2): "екатеринбург" был единственным city-словом здесь (hardcoded) +# — генерализовано на весь регион 66 через _OBLAST_CITY_TOKENS (derived из +# geocoder.SVERDLOVSK_OBLAST_CITIES, см. определение выше _CITY_TOKENS), плюс +# admin/generic-шум ниже. _parse_street_house уже делает s.replace("ё","е") ДО +# токенизации (см. выше) — _OBLAST_CITY_TOKENS тоже нормализован на «е». +_DEDUP_ADDR_NOISE_WORDS = _OBLAST_CITY_TOKENS | frozenset( { "россия", "рф", - "екатеринбург", "г", "город", "область", diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index 3583f0ac..40234a3e 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -139,6 +139,9 @@ def _stub_precision_and_pdf(): _fetch_price_trend=lambda *a, **k: None, _fetch_dkp_corridor=lambda *a, **k: None, _fetch_house_imv_anchor=lambda *a, **k: None, + # (oblast C2): GET-rehydrate also resolves the target city (for + # _fetch_dkp_corridor city-scoping) before calling the corridor helper. + _resolve_target_city=lambda *a, **k: None, # #2043 (BE-1): GET-rehydrate also recomputes cv / source_counts from the # persisted analogs. Empty analogs in the fixture → None / {} (real behaviour). _cv_from_ppm2=lambda *a, **k: None,