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
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:
commit
5d7c3e1c30
3 changed files with 113 additions and 12 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
{
|
||||
"россия",
|
||||
"рф",
|
||||
"екатеринбург",
|
||||
"г",
|
||||
"город",
|
||||
"область",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue