fix(tradein/estimator): city-scope ДКП-коридора + oblast city-tokens/bbox
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 53s

Чинит доказанную кросс-город контаминацию коридора: street-name-матч без city тянул одноимённые улицы всех городов обл. («Ленина» = Н.Тагил+Лесной+Серов+ЕКБ...).

- _resolve_target_city(address) — word-boundary поверх geocoder.SVERDLOVSK_OBLAST_CITIES (F, ~30 городов), district-guard («мкр Заречный» = район, не город-ЗАТО).
- _fetch_dkp_corridor: +param city → AND LOWER(d.city)=CAST(:target_city AS text). Проводка в estimate_quality (dadata.canonical > geo) + trade_in.py GET rehydrate. city=None → прежнее поведение (fallback).
- _CITY_TOKENS + _DEDUP_ADDR_NOISE_WORDS → derive из SVERDLOVSK_OBLAST_CITIES (было 6 EKB-городов) → same-house dedup для non-EKB.
- bbox fast-path → geocoder.is_within_oblast66_bbox.
- docstring: коридор НЕ чисто advisory (клампит headline + radius-floor при n>=clamp_min_n).

EKB regression-гейт байт-зелёный (коридор upstream от frozen _price_from_inputs replay). Deep-review ⚠️APPROVE (no блокеров). Follow-ups (task #10): DB-derived city-set (Лесной 1103 uncovered), asking_to_sold_ratio city-scope, backtest city-parity.
This commit is contained in:
bot-backend 2026-07-12 20:10:59 +03:00
parent b0b2c94ece
commit b93d26990b
3 changed files with 113 additions and 12 deletions

View file

@ -244,6 +244,7 @@ def get_estimate(
_fetch_house_imv_anchor, _fetch_house_imv_anchor,
_fetch_price_trend, _fetch_price_trend,
_qc_geo_to_precision, _qc_geo_to_precision,
_resolve_target_city,
_source_counts, _source_counts,
) )
@ -283,7 +284,13 @@ def get_estimate(
else None 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 dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None
imv_raw = _fetch_house_imv_anchor( imv_raw = _fetch_house_imv_anchor(

View file

@ -65,7 +65,12 @@ from app.schemas.trade_in import (
) )
from app.services.dadata import DadataAddressResult from app.services.dadata import DadataAddressResult
from app.services.dadata import clean_address as dadata_clean_address from app.services.dadata import clean_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.house_metadata import get_house_metadata
from app.services.matching.houses import match_house_readonly, match_or_create_house from app.services.matching.houses import match_house_readonly, match_or_create_house
from app.services.scraper_adapters import RealScraperConfig from app.services.scraper_adapters import RealScraperConfig
@ -1261,12 +1266,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)) 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( def _fetch_dkp_corridor(
db: Session, db: Session,
*, *,
address: str | None, address: str | None,
rooms: int | None, rooms: int | None,
area: float | None, area: float | None,
city: str | None = None,
period_months: int = DEALS_PERIOD_MONTHS, period_months: int = DEALS_PERIOD_MONTHS,
area_tolerance: float = AREA_TOLERANCE, area_tolerance: float = AREA_TOLERANCE,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
@ -1277,6 +1327,15 @@ def _fetch_dkp_corridor(
period_months) и нормализуем per-. Возвращаем low/median/high /м². period_months) и нормализуем per-. Возвращаем low/median/high /м².
ADVISORY caller не клампит, только сурфейсит + опциональная пометка. ADVISORY caller не клампит, только сурфейсит + опциональная пометка.
Best-effort: None при отсутствии улицы / сделок / любой ошибке. 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: if not address or rooms is None or not area:
return None return None
@ -1285,11 +1344,15 @@ def _fetch_dkp_corridor(
return None return None
area_min = area * (1.0 - area_tolerance) area_min = area * (1.0 - area_tolerance)
area_max = 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: try:
rows = ( rows = (
db.execute( db.execute(
text( text(
""" f"""
SELECT d.price_per_m2, d.deal_date SELECT d.price_per_m2, d.deal_date
FROM deals d FROM deals d
LEFT JOIN deal_city_price_bands b ON b.city = d.city LEFT JOIN deal_city_price_bands b ON b.city = d.city
@ -1301,6 +1364,7 @@ def _fetch_dkp_corridor(
AND d.deal_date > NOW() AND d.deal_date > NOW()
- (CAST(:period_months AS integer) || ' months')::interval - (CAST(:period_months AS integer) || ' months')::interval
AND d.price_per_m2 > 0 AND d.price_per_m2 > 0
{city_filter_sql}
-- #699 + #2478: режем нерыночные ppm²-выбросы из коридора -- #699 + #2478: режем нерыночные ppm²-выбросы из коридора
-- expected_sold. Per-city band (deal_city_price_bands, миграция -- expected_sold. Per-city band (deal_city_price_bands, миграция
-- 178) когда для города сделки есть строка (не-ЕКБ область); -- 178) когда для города сделки есть строка (не-ЕКБ область);
@ -1319,6 +1383,7 @@ def _fetch_dkp_corridor(
"period_months": period_months, "period_months": period_months,
"ppm_min": DEAL_MIN_PPM2, "ppm_min": DEAL_MIN_PPM2,
"ppm_max": DEAL_MAX_PPM2, "ppm_max": DEAL_MAX_PPM2,
"target_city": city.lower() if city else None,
}, },
) )
.mappings() .mappings()
@ -1420,14 +1485,25 @@ _STREET_TYPE_TOKENS: frozenset[str] = frozenset(
# Административные токены-маркеры: всё, что относится к городу/району/мкр — НЕ часть # Административные токены-маркеры: всё, что относится к городу/району/мкр — НЕ часть
# имени дома. Дропаем токен-маркер ВМЕСТЕ со следующим за ним словом-значением # имени дома. Дропаем токен-маркер ВМЕСТЕ со следующим за ним словом-значением
# («р-н Октябрьский», «мкр Парковый», «г Екатеринбург»). Города ЕКБ-агломерации тоже # («р-н Октябрьский», «мкр Парковый», «г Екатеринбург»). Города региона тоже
# чистим (стоят как ведущий токен «Екатеринбург,»/«Первоуральск,»). # чистим (стоят как ведущий токен «Екатеринбург,»/«Первоуральск,»/«Нижний Тагил,»).
_ADMIN_MARKER_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 (рус/лат). # Литера корпуса, прилипшая к номеру: «16а», «204г», «57а» → base + letter (рус/лат).
_HOUSE_LETTER_RE = re.compile(r"^(?P<num>\d+)\s*(?P<letter>[а-яa-z])?$", flags=re.UNICODE) _HOUSE_LETTER_RE = re.compile(r"^(?P<num>\d+)\s*(?P<letter>[а-яa-z])?$", flags=re.UNICODE)
@ -2886,13 +2962,17 @@ async def estimate_quality(
# multiple network round-trips + 1s Nominatim rate-limit sleeps). # multiple network round-trips + 1s Nominatim rate-limit sleeps).
geo: GeocodeResult | None = None geo: GeocodeResult | None = None
# Variant A: trust client-provided coords (resolved by autocomplete/map) when present # 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. # 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 ( if (
payload.lat is not None payload.lat is not None
and payload.lon is not None and payload.lon is not None
and 60.40 <= payload.lon <= 60.85 and is_within_oblast66_bbox(payload.lat, payload.lon)
and 56.65 <= payload.lat <= 56.95
): ):
geo = GeocodeResult( geo = GeocodeResult(
lat=payload.lat, lat=payload.lat,
@ -3111,12 +3191,19 @@ async def estimate_quality(
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ────────────── # ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
# #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы # #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы
# corridor_high был доступен для Tier C-гейта и soft-клампа headline. # 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( dkp_raw = await asyncio.to_thread(
_fetch_dkp_corridor, _fetch_dkp_corridor,
db, db,
address=geo.full_address, address=geo.full_address,
rooms=payload.rooms, rooms=payload.rooms,
area=payload.area_m2, area=payload.area_m2,
city=target_city,
) )
# ── Stage 3: Avito IMV evaluation as 5-th source (on-demand cached) ── # ── Stage 3: Avito IMV evaluation as 5-th source (on-demand cached) ──
@ -4986,11 +5073,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(
{ {
"россия", "россия",
"рф", "рф",
"екатеринбург",
"г", "г",
"город", "город",
"область", "область",

View file

@ -139,6 +139,9 @@ def _stub_precision_and_pdf():
_fetch_price_trend=lambda *a, **k: None, _fetch_price_trend=lambda *a, **k: None,
_fetch_dkp_corridor=lambda *a, **k: None, _fetch_dkp_corridor=lambda *a, **k: None,
_fetch_house_imv_anchor=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 # #2043 (BE-1): GET-rehydrate also recomputes cv / source_counts from the
# persisted analogs. Empty analogs in the fixture → None / {} (real behaviour). # persisted analogs. Empty analogs in the fixture → None / {} (real behaviour).
_cv_from_ppm2=lambda *a, **k: None, _cv_from_ppm2=lambda *a, **k: None,