Merge pull request 'fix(tradein/estimator): city-scope ДКП-коридора + oblast city-tokens/bbox' (#2488) from fix/tradein-oblast-c2-estimator-scope into main
All checks were successful
Deploy Trade-In / test (push) Successful in 48s
Deploy Trade-In / deploy (push) Successful in 59s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped

This commit is contained in:
lekss361 2026-07-12 17:21:55 +00:00
commit 5d7c3e1c30
3 changed files with 113 additions and 12 deletions

View file

@ -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(

View file

@ -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-. Возвращаем 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<num>\d+)\s*(?P<letter>[а-я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(
{
"россия",
"рф",
"екатеринбург",
"г",
"город",
"область",

View file

@ -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,