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
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:
parent
b0b2c94ece
commit
b93d26990b
3 changed files with 113 additions and 12 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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-m². Возвращаем low/median/high ₽/м².
|
period_months) и нормализуем per-m². Возвращаем 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(
|
||||||
{
|
{
|
||||||
"россия",
|
"россия",
|
||||||
"рф",
|
"рф",
|
||||||
"екатеринбург",
|
|
||||||
"г",
|
"г",
|
||||||
"город",
|
"город",
|
||||||
"область",
|
"область",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue