feat(tradein): обогащение house_metadata через OSM Overpass (#392)

Когда пользователь не указал год постройки / тип дома, estimator
подтягивает их из OpenStreetMap (Overpass API) — building:levels,
start_date, building:material. Улучшает house-match аналогов (#6).

- app/services/house_metadata.py — Overpass-провайдер + кэш в таблице
  house_metadata (TTL 30 дней, lookup по близости координат, 40 м).
- estimator.estimate_quality — обогащает target_year / target_house_type
  перед _fetch_analogs. Best-effort: ошибка OSM → оценка без обогащения.

OSM по ЕКБ: building:levels населён хорошо, год постройки — частично.

Closes #392
This commit is contained in:
TradeIn Deploy 2026-05-22 10:39:56 +05:00
parent 4dc173c15b
commit bf2886509d
2 changed files with 234 additions and 7 deletions

View file

@ -29,6 +29,7 @@ from sqlalchemy.orm import Session
from app.schemas.trade_in import AggregatedEstimate, AnalogLot, TradeInEstimateInput
from app.services.geocoder import GeocodeResult, geocode
from app.services.house_metadata import get_house_metadata
logger = logging.getLogger(__name__)
@ -83,14 +84,27 @@ async def estimate_quality(
logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address)
return _empty_estimate(payload, reason="address_not_geocoded")
# 2. Three-tier fallback:
# a) 800m + ±15% area
# 2. #392: обогащаем год / тип дома из картографии (OSM Overpass), если
# пользователь их не указал — это улучшает house-match аналогов (#6).
# Best-effort: при недоступности OSM target_* остаются None.
target_year = payload.year_built
target_house_type = payload.house_type
if target_year is None or target_house_type is None:
house_meta = await get_house_metadata(geo.lat, geo.lon, db)
if house_meta is not None:
if target_year is None:
target_year = house_meta.year_built
if target_house_type is None:
target_house_type = house_meta.house_type
# 3. Three-tier fallback:
# a) 1km + ±15% area
# b) 2km + ±15% area (fallback_used = True)
# 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,
radius_m=DEFAULT_RADIUS_M,
year_built=payload.year_built, house_type=payload.house_type,
year_built=target_year, house_type=target_house_type,
)
area_widened = False
@ -98,7 +112,7 @@ async def estimate_quality(
listings_wide, _ = _fetch_analogs(
db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2,
radius_m=FALLBACK_RADIUS_M,
year_built=payload.year_built, house_type=payload.house_type,
year_built=target_year, house_type=target_house_type,
)
if len(listings_wide) > len(listings):
listings = listings_wide
@ -110,7 +124,7 @@ async def estimate_quality(
listings_widearea, _ = _fetch_analogs(
db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2,
radius_m=FALLBACK_RADIUS_M, area_tolerance=0.25,
year_built=payload.year_built, house_type=payload.house_type,
year_built=target_year, house_type=target_house_type,
)
if len(listings_widearea) > len(listings):
listings = listings_widearea
@ -210,8 +224,8 @@ async def estimate_quality(
"rooms": payload.rooms,
"floor": payload.floor,
"total_floors": payload.total_floors,
"year_built": payload.year_built,
"house_type": payload.house_type,
"year_built": target_year,
"house_type": target_house_type,
"repair_state": payload.repair_state,
"has_balcony": payload.has_balcony,
"median_price": median_price,

View file

@ -0,0 +1,213 @@
"""House metadata service — обогащение данных дома по координатам (#392).
Когда пользователь не указал год постройки / тип дома, подтягиваем данные
из OpenStreetMap через Overpass API: building:levels, start_date, материал.
Стратегия:
- Cache lookup в `house_metadata` (Postgres) по близости координат TTL 30 дней
- Cache miss Overpass API парсинг тегов запись в кэш
- Best-effort: ЛЮБАЯ ошибка None, оценка работает без обогащения (не 500-ит)
Используется в estimator.estimate_quality перед house-match аналогов (#6).
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
logger = logging.getLogger(__name__)
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
_CACHE_RADIUS_M = 40 # дом в пределах 40 м от точки — считаем тем же
_OVERPASS_RADIUS_M = 25 # ищем здание в 25 м от геокодированной точки
@dataclass(frozen=True, slots=True)
class HouseMetadata:
lat: float
lon: float
year_built: int | None
total_floors: int | None
house_type: str | None # panel / brick / monolith / monolith_brick / other
total_units: int | None
source: str # 'osm' / 'cache'
# ── Парсинг тегов OSM ────────────────────────────────────────────────────────
_YEAR_KEYS = (
"start_date", "construction_date", "building:year",
"year_of_construction", "building:start_date",
)
def _parse_year(tags: dict[str, str]) -> int | None:
"""Год постройки из OSM-тегов. Значение бывает '1975', '1975-06-01',
'1970s', '1960..1965', 'C20' берём первое 4-значное число 18002100."""
for key in _YEAR_KEYS:
raw = tags.get(key)
if not raw:
continue
m = re.search(r"\b(1[89]\d\d|20\d\d)\b", raw)
if m:
year = int(m.group(1))
if 1800 <= year <= 2100:
return year
return None
def _parse_int(tags: dict[str, str], *keys: str) -> int | None:
"""Первое валидное целое (0 < n < 1000) из перечисленных тегов."""
for key in keys:
raw = (tags.get(key) or "").strip()
if raw.isdigit():
value = int(raw)
if 0 < value < 1000:
return value
return None
# OSM building:material → наш house_type. Покрытие в OSM неполное — best-effort.
_MATERIAL_TO_TYPE = {
"concrete_panels": "panel",
"panels": "panel",
"panel": "panel",
"brick": "brick",
"concrete": "monolith",
"reinforced_concrete": "monolith",
}
def _parse_house_type(tags: dict[str, str]) -> str | None:
material = (tags.get("building:material") or "").lower().strip()
return _MATERIAL_TO_TYPE.get(material)
# ── Cache (house_metadata, TTL 30 дней) ──────────────────────────────────────
def _cache_get(db: Session, lat: float, lon: float) -> HouseMetadata | None:
row = db.execute(
text(
"""
SELECT lat, lon, year_built, total_floors, house_type, total_units
FROM house_metadata
WHERE expires_at > NOW()
AND ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
ORDER BY ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
LIMIT 1
"""
),
{"lat": lat, "lon": lon, "radius": _CACHE_RADIUS_M},
).fetchone()
if row is None:
return None
return HouseMetadata(
lat=row.lat, lon=row.lon,
year_built=row.year_built, total_floors=row.total_floors,
house_type=row.house_type, total_units=row.total_units,
source="cache",
)
def _cache_put(db: Session, meta: HouseMetadata, raw: dict) -> None:
db.execute(
text(
"""
INSERT INTO house_metadata
(lat, lon, year_built, total_floors, house_type,
total_units, source, raw_payload)
VALUES (:lat, :lon, :year, :floors, :htype,
:units, 'osm', CAST(:raw AS jsonb))
"""
),
{
"lat": meta.lat, "lon": meta.lon,
"year": meta.year_built, "floors": meta.total_floors,
"htype": meta.house_type, "units": meta.total_units,
"raw": json.dumps(raw, ensure_ascii=False),
},
)
db.commit()
# ── Provider: OSM Overpass ───────────────────────────────────────────────────
async def _overpass_lookup(lat: float, lon: float) -> tuple[HouseMetadata, dict] | None:
"""Запрос здания у точки через Overpass API. (meta, raw_payload) или None."""
query = (
"[out:json][timeout:20];"
f'way(around:{_OVERPASS_RADIUS_M},{lat},{lon})["building"];'
"out tags center;"
)
headers = {"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})"}
async with httpx.AsyncClient(timeout=15.0, headers=headers) as client:
response = await client.post(OVERPASS_URL, data={"data": query})
response.raise_for_status()
data = response.json()
elements = [e for e in data.get("elements", []) if e.get("tags")]
if not elements:
return None
# Ближайшее здание (по center) к геокодированной точке.
def _dist2(el: dict) -> float:
center = el.get("center") or {}
return (center.get("lat", 0.0) - lat) ** 2 + (center.get("lon", 0.0) - lon) ** 2
best = min(elements, key=_dist2)
tags = best.get("tags", {})
meta = HouseMetadata(
lat=lat, lon=lon,
year_built=_parse_year(tags),
total_floors=_parse_int(tags, "building:levels"),
house_type=_parse_house_type(tags),
total_units=_parse_int(tags, "building:flats", "flats"),
source="osm",
)
return meta, {"osm_way_id": best.get("id"), "tags": tags}
# ── Public API ───────────────────────────────────────────────────────────────
async def get_house_metadata(lat: float, lon: float, db: Session) -> HouseMetadata | None:
"""Метаданные дома по координатам: cache (TTL 30д) → Overpass.
Best-effort: при ЛЮБОЙ ошибке возвращает None и откатывает сессию
оценка обязана работать даже если картография недоступна.
"""
try:
cached = _cache_get(db, lat, lon)
if cached is not None:
logger.info("house_metadata cache hit: (%.5f, %.5f)", lat, lon)
return cached
result = await _overpass_lookup(lat, lon)
if result is None:
logger.info("house_metadata: OSM не нашёл здание у (%.5f, %.5f)", lat, lon)
return None
meta, raw = result
# Пустой результат (ни года, ни этажности) кэшировать смысла нет.
if meta.year_built is None and meta.total_floors is None:
return None
_cache_put(db, meta, raw)
logger.info(
"house_metadata OSM: (%.5f, %.5f) → year=%s floors=%s type=%s",
lat, lon, meta.year_built, meta.total_floors, meta.house_type,
)
return meta
except Exception: # noqa: BLE001
logger.warning(
"house_metadata enrichment failed at (%.5f, %.5f)", lat, lon, exc_info=True
)
try:
db.rollback()
except Exception: # noqa: BLE001
pass
return None