feat(tradein-estimator): tiered house-match S→H→W (same-house, class, wide)

Currently year/house_type only influence relevance sort. For a 17-эт. 1975
building we returned 5-9 эт. брежневки as analogs.

Three tiers:
- S (same building): address ILIKE prefix, ≥3 → return only these
- H (same class): year ±15, total_floors ±30%, ≥5 → return
- W (wide): current logic without year/floors WHERE filter

Tier propagated to confidence_explanation so user sees why analogs may differ.

Source: estimate a0a0b820-e8a8-4eee-aa73-0ab3b98ac233.
This commit is contained in:
lekss361 2026-05-24 13:59:12 +03:00
parent 9402702f32
commit c4f1978ed0

View file

@ -22,6 +22,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import logging import logging
import math
import re import re
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
@ -493,39 +494,45 @@ async def estimate_quality(
if target_house_type is None: if target_house_type is None:
target_house_type = house_meta.house_type target_house_type = house_meta.house_type
# 3. Three-tier fallback: # 3. House-match: S → H → W tiered lookup (see _fetch_analogs docstring).
# a) 1km + ±15% area # Radius fallback still applies when W tier has < 5 results at 1km.
# b) 2km + ±15% area (fallback_used = True) listings, fallback_used, analog_tier = _fetch_analogs(
# c) 2km + ±25% area (fallback_used = True, area_widened = True)
listings, fallback_used = _fetch_analogs(
db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2, db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2,
radius_m=DEFAULT_RADIUS_M, radius_m=DEFAULT_RADIUS_M,
full_address=geo.full_address,
year_built=target_year, house_type=target_house_type, year_built=target_year, house_type=target_house_type,
total_floors=payload.total_floors,
) )
area_widened = False area_widened = False
if len(listings) < 5: if len(listings) < 5:
listings_wide, _ = _fetch_analogs( listings_wide, _, analog_tier_wide = _fetch_analogs(
db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2, db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2,
radius_m=FALLBACK_RADIUS_M, radius_m=FALLBACK_RADIUS_M,
full_address=geo.full_address,
year_built=target_year, house_type=target_house_type, year_built=target_year, house_type=target_house_type,
total_floors=payload.total_floors,
) )
if len(listings_wide) > len(listings): if len(listings_wide) > len(listings):
listings = listings_wide listings = listings_wide
fallback_used = True fallback_used = True
analog_tier = analog_tier_wide
# Tier C: если даже на 2км мало — расширяем area tolerance до ±25% # Tier C: если даже на 2км мало — расширяем area tolerance до ±25%
# (актуально для отдалённых районов / новостроек с нестандартной планировкой) # (актуально для отдалённых районов / новостроек с нестандартной планировкой)
if len(listings) < 3: if len(listings) < 3:
listings_widearea, _ = _fetch_analogs( listings_widearea, _, analog_tier_wa = _fetch_analogs(
db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2, db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2,
radius_m=FALLBACK_RADIUS_M, area_tolerance=0.25, radius_m=FALLBACK_RADIUS_M, area_tolerance=0.25,
full_address=geo.full_address,
year_built=target_year, house_type=target_house_type, year_built=target_year, house_type=target_house_type,
total_floors=payload.total_floors,
) )
if len(listings_widearea) > len(listings): if len(listings_widearea) > len(listings):
listings = listings_widearea listings = listings_widearea
fallback_used = True fallback_used = True
area_widened = True area_widened = True
analog_tier = analog_tier_wa
# 3. Outlier filter # 3. Outlier filter
listings_clean = _filter_outliers(listings) listings_clean = _filter_outliers(listings)
@ -567,7 +574,20 @@ async def estimate_quality(
q3_ppm2 if listings_clean else 0, fallback_used, area_widened, q3_ppm2 if listings_clean else 0, fallback_used, area_widened,
listings=listings_clean, listings=listings_clean,
) )
explanation = (explanation or "") + repair_note
# Tier note — информируем пользователя о качестве house-match
tier_note = ""
if analog_tier == "S":
tier_note = " (аналоги из того же дома)"
elif analog_tier == "H":
tf_str = f"{payload.total_floors}-эт." if payload.total_floors else ""
yr_str = f"{target_year}±15 г." if target_year else ""
parts_str = ", ".join(p for p in [yr_str, tf_str] if p)
tier_note = f" (аналоги из домов того же класса: {parts_str})" if parts_str else ""
else:
tier_note = " (нет аналогов в том же доме/классе — расширили поиск)"
explanation = (explanation or "") + tier_note + repair_note
# ── Stage 3: Avito IMV evaluation as 5-th source (on-demand cached) ── # ── Stage 3: Avito IMV evaluation as 5-th source (on-demand cached) ──
imv_eval: IMVEvaluation | None = None imv_eval: IMVEvaluation | None = None
@ -841,17 +861,119 @@ def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None:
# ── Internals ──────────────────────────────────────────────────────────────── # ── Internals ────────────────────────────────────────────────────────────────
def _extract_short_addr(full_address: str) -> str | None:
"""Извлекает «улица + номер дома» из полного адреса для поиска в том же доме.
Примеры:
"г. Екатеринбург, ул. Крауля, 48/2, кв. 5" "ул. Крауля, 48/2"
"Екатеринбург, Ленина, 36, корп. 2, кв. 10" "Ленина, 36"
"Свердловская обл., г. Екатеринбург, пр-т Ленина, 36 к2" "пр-т Ленина, 36"
Алгоритм:
1. Разбиваем по запятой.
2. Отбрасываем сегменты, которые выглядят как «г.», «обл.», «р-н» (city/region prefix).
3. Берём первые 2 оставшихся токена (улица + дом), strip кв/корп/к-суффикс из последнего.
4. Возвращаем None, если результат слишком короткий (< 3 символов) не с чем матчить.
"""
if not full_address:
return None
parts = [p.strip() for p in full_address.split(",")]
# Паттерн для «административных» сегментов: г., обл., р-н, с., д. (населённый пункт)
admin_re = re.compile(
r"^(г\.?|обл\.?|р\.?|пгт\.?|с\.?|д\.?|мкр\.?)\s",
re.IGNORECASE,
)
meaningful = [p for p in parts if not admin_re.match(p) and len(p) > 1]
if len(meaningful) < 2:
return None
street = meaningful[0]
house_raw = meaningful[1]
# Убираем «кв. N», «корп. N», «к2», «к 2» из номера дома
house = re.sub(r"\s*(кв\.?|корп\.?|к\.?)\s*\d+.*$", "", house_raw, flags=re.IGNORECASE).strip()
result = f"{street}, {house}"
return result if len(result) >= 3 else None
def _stratify_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Стратифицированная выборка Approach B — гарантирует MIN_ANALOGS_PER_SOURCE слотов.
Candidates должны быть уже отсортированы по relevance_score (ASC).
"""
guaranteed: list[dict[str, Any]] = []
guaranteed_ids: set[int] = set()
by_source: dict[str, list[dict[str, Any]]] = {}
for row in candidates:
src = row.get("source") or "unknown"
by_source.setdefault(src, []).append(row)
for _src, src_rows in by_source.items():
quota = min(len(src_rows), MIN_ANALOGS_PER_SOURCE)
for row in src_rows[:quota]:
if id(row) not in guaranteed_ids:
guaranteed.append(row)
guaranteed_ids.add(id(row))
remaining_slots = 50 - len(guaranteed)
remainder: list[dict[str, Any]] = []
if remaining_slots > 0:
for row in candidates:
if id(row) not in guaranteed_ids:
remainder.append(row)
if len(remainder) >= remaining_slots:
break
result = guaranteed + remainder
result.sort(key=lambda r: r.get("relevance_score") or 0.0)
return result[:50]
_ANALOG_SELECT_COLS = """
source, source_url, address, lat, lon,
rooms, area_m2, floor, total_floors,
price_rub, price_per_m2,
listing_date, days_on_market, photo_urls,
scraped_at
"""
_COMMON_WHERE = """
AND rooms = :rooms
AND area_m2 BETWEEN :area_min AND :area_max
AND is_active = true
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND price_rub > 0
"""
def _fetch_analogs( def _fetch_analogs(
db: Session, *, lat: float, lon: float, rooms: int, area: float, radius_m: int, db: Session, *, lat: float, lon: float, rooms: int, area: float, radius_m: int,
full_address: str | None = None,
area_tolerance: float = AREA_TOLERANCE, area_tolerance: float = AREA_TOLERANCE,
year_built: int | None = None, house_type: str | None = None, year_built: int | None = None, house_type: str | None = None,
) -> tuple[list[dict[str, Any]], bool]: total_floors: int | None = None,
"""SELECT аналогов с PostGIS distance + house-match relevance. ext_house_id: str | None = None,
) -> tuple[list[dict[str, Any]], bool, str]:
"""SELECT аналогов — трёхуровневый house-match (S → H → W).
House-match (встреча Птицы «соразмерные квартиры»): сортировка не просто **Tier S (same building):** address ILIKE prefix OR ext_house_id match.
по расстоянию, а по relevance-скору, где учитывается близость года постройки Если 3 результатов возвращаем только их; tier='S'.
и совпадение типа дома. Так аналог «рядом + та же эпоха дома» побеждает
аналог «чуть ближе, но дом на 30 лет старше». **Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors ±30%.
Если 5 результатов возвращаем; tier='H'.
Пропускается если year_built или total_floors неизвестны.
**Tier W (wide / current):** текущая логика без year/floors WHERE фильтра.
tier='W'.
House-match relevance_score используется для сортировки в Tier H и W.
Стратифицированная выборка (Approach B): Стратифицированная выборка (Approach B):
1. SQL вытягивает до 300 кандидатов с per-address row_number (cap MAX_ANALOGS_PER_ADDRESS). 1. SQL вытягивает до 300 кандидатов с per-address row_number (cap MAX_ANALOGS_PER_ADDRESS).
@ -860,9 +982,177 @@ def _fetch_analogs(
4. Итоговый список отсортирован по relevance, LIMIT 50. 4. Итоговый список отсортирован по relevance, LIMIT 50.
Returns: Returns:
(list_of_listings_as_dicts, fallback_radius_used_flag) (list_of_listings_as_dicts, fallback_radius_used_flag, tier)
tier: 'S' | 'H' | 'W'
""" """
rows = db.execute( area_min = area * (1 - area_tolerance)
area_max = area * (1 + area_tolerance)
base_params: dict[str, Any] = {
"rooms": rooms,
"area_min": area_min,
"area_max": area_max,
"fresh_days": LISTINGS_FRESH_DAYS,
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
}
# ── Tier S: same building ─────────────────────────────────────────────────
short_addr = _extract_short_addr(full_address) if full_address else None
has_ext_id = ext_house_id is not None
if short_addr or has_ext_id:
addr_clause = ""
if short_addr and has_ext_id:
addr_clause = (
"(address ILIKE :short_addr_prefix"
" OR (ext_house_id IS NOT NULL AND ext_house_id = :ext_house_id))"
)
elif short_addr:
addr_clause = "address ILIKE :short_addr_prefix"
else:
addr_clause = "ext_house_id IS NOT NULL AND ext_house_id = :ext_house_id"
tier_s_params = {**base_params}
if short_addr:
tier_s_params["short_addr_prefix"] = short_addr + "%"
if has_ext_id:
tier_s_params["ext_house_id"] = ext_house_id
tier_s_rows = db.execute(
text(
f"""
WITH base AS (
SELECT
{_ANALOG_SELECT_COLS},
0.0 AS distance_m,
0.0 AS relevance_score,
row_number() OVER (PARTITION BY address ORDER BY scraped_at DESC) AS rn_addr
FROM listings
WHERE {addr_clause}
{_COMMON_WHERE}
)
SELECT
source, source_url, address, lat, lon,
rooms, area_m2, floor, total_floors,
price_rub, price_per_m2,
listing_date, days_on_market, photo_urls,
scraped_at, distance_m, relevance_score
FROM base
WHERE rn_addr <= :max_per_addr
ORDER BY relevance_score
LIMIT 300
"""
),
tier_s_params,
).mappings().all()
tier_s = [dict(r) for r in tier_s_rows]
if len(tier_s) >= 3:
logger.info(
"analogs tier=S addr_prefix=%r%d results",
short_addr,
len(tier_s),
)
return _stratify_candidates(tier_s), radius_m > DEFAULT_RADIUS_M, "S"
# ── Tier H: same class (year ±15, total_floors ±30%) ─────────────────────
if year_built is not None and total_floors is not None:
year_min = year_built - 15
year_max = year_built + 15
tf_min = math.floor(total_floors * 0.7)
tf_max = math.ceil(total_floors * 1.3)
tier_h_rows = db.execute(
text(
f"""
WITH base AS (
SELECT
{_ANALOG_SELECT_COLS},
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
AS distance_m,
(
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
/ 1000.0
+ CASE
WHEN year_built IS NOT NULL
THEN abs(year_built - CAST(:target_year AS integer)) / 12.0
ELSE 0
END
+ CASE
WHEN CAST(:target_house_type AS text) IS NOT NULL
AND house_type IS NOT NULL
AND house_type <> CAST(:target_house_type AS text)
THEN 1.5
ELSE 0
END
) AS relevance_score,
row_number() OVER (
PARTITION BY address
ORDER BY (
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
/ 1000.0
+ CASE
WHEN year_built IS NOT NULL
THEN abs(year_built - CAST(:target_year AS integer)) / 12.0
ELSE 0
END
+ CASE
WHEN CAST(:target_house_type AS text) IS NOT NULL
AND house_type IS NOT NULL
AND house_type <> CAST(:target_house_type AS text)
THEN 1.5
ELSE 0
END
)
) AS rn_addr
FROM listings
WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
{_COMMON_WHERE}
AND total_floors BETWEEN CAST(:tf_min AS integer)
AND CAST(:tf_max AS integer)
AND year_built BETWEEN CAST(:year_min AS integer)
AND CAST(:year_max AS integer)
)
SELECT
source, source_url, address, lat, lon,
rooms, area_m2, floor, total_floors,
price_rub, price_per_m2,
listing_date, days_on_market, photo_urls,
scraped_at, distance_m, relevance_score
FROM base
WHERE rn_addr <= :max_per_addr
ORDER BY relevance_score
LIMIT 300
"""
),
{
**base_params,
"lat": lat,
"lon": lon,
"radius": radius_m,
"target_year": year_built,
"target_house_type": house_type,
"tf_min": tf_min,
"tf_max": tf_max,
"year_min": year_min,
"year_max": year_max,
},
).mappings().all()
tier_h = [dict(r) for r in tier_h_rows]
if len(tier_h) >= 5:
logger.info(
"analogs tier=H year=%d±15 tf=%d-%d%d results",
year_built, tf_min, tf_max, len(tier_h),
)
return _stratify_candidates(tier_h), radius_m > DEFAULT_RADIUS_M, "H"
logger.info(
"analogs tier=H year=%d±15 tf=%d-%d → only %d (fallthrough to W)",
year_built, tf_min, tf_max, len(tier_h),
)
# ── Tier W: wide (current logic, year/floors only in relevance sort) ──────
tier_w_rows = db.execute(
text( text(
""" """
WITH base AS ( WITH base AS (
@ -945,8 +1235,8 @@ def _fetch_analogs(
"lon": lon, "lon": lon,
"radius": radius_m, "radius": radius_m,
"rooms": rooms, "rooms": rooms,
"area_min": area * (1 - area_tolerance), "area_min": area_min,
"area_max": area * (1 + area_tolerance), "area_max": area_max,
"fresh_days": LISTINGS_FRESH_DAYS, "fresh_days": LISTINGS_FRESH_DAYS,
"target_year": year_built, "target_year": year_built,
"target_house_type": house_type, "target_house_type": house_type,
@ -954,41 +1244,9 @@ def _fetch_analogs(
}, },
).mappings().all() ).mappings().all()
candidates: list[dict[str, Any]] = [dict(r) for r in rows] candidates: list[dict[str, Any]] = [dict(r) for r in tier_w_rows]
logger.info("analogs tier=W radius=%dm → %d candidates", radius_m, len(candidates))
# Stratified quota: гарантируем MIN_ANALOGS_PER_SOURCE слотов каждому source. return _stratify_candidates(candidates), radius_m > DEFAULT_RADIUS_M, "W"
# Candidates уже отсортированы по relevance_score (лучшие первые) из SQL.
guaranteed: list[dict[str, Any]] = []
guaranteed_ids: set[int] = set() # по object id, не по внешнему ключу
by_source: dict[str, list[dict[str, Any]]] = {}
for row in candidates:
src = row.get("source") or "unknown"
by_source.setdefault(src, []).append(row)
for _src, src_rows in by_source.items():
quota = min(len(src_rows), MIN_ANALOGS_PER_SOURCE)
for row in src_rows[:quota]:
if id(row) not in guaranteed_ids:
guaranteed.append(row)
guaranteed_ids.add(id(row))
# Оставшиеся слоты из candidates, которые ещё не попали в guaranteed.
remaining_slots = 50 - len(guaranteed)
remainder: list[dict[str, Any]] = []
if remaining_slots > 0:
for row in candidates:
if id(row) not in guaranteed_ids:
remainder.append(row)
if len(remainder) >= remaining_slots:
break
result = guaranteed + remainder
# Финальная сортировка по relevance (candidates из SQL уже отсортированы,
# но guaranteed + remainder смешиваются). relevance_score присутствует в каждом dict.
result.sort(key=lambda r: r.get("relevance_score") or 0.0)
result = result[:50]
return result, radius_m > DEFAULT_RADIUS_M
def _fetch_deals( def _fetch_deals(