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:
parent
4dc173c15b
commit
bf2886509d
2 changed files with 234 additions and 7 deletions
|
|
@ -29,6 +29,7 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.schemas.trade_in import AggregatedEstimate, AnalogLot, TradeInEstimateInput
|
from app.schemas.trade_in import AggregatedEstimate, AnalogLot, TradeInEstimateInput
|
||||||
from app.services.geocoder import GeocodeResult, geocode
|
from app.services.geocoder import GeocodeResult, geocode
|
||||||
|
from app.services.house_metadata import get_house_metadata
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -83,14 +84,27 @@ async def estimate_quality(
|
||||||
logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address)
|
logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address)
|
||||||
return _empty_estimate(payload, reason="address_not_geocoded")
|
return _empty_estimate(payload, reason="address_not_geocoded")
|
||||||
|
|
||||||
# 2. Three-tier fallback:
|
# 2. #392: обогащаем год / тип дома из картографии (OSM Overpass), если
|
||||||
# a) 800m + ±15% area
|
# пользователь их не указал — это улучшает 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)
|
# b) 2km + ±15% area (fallback_used = True)
|
||||||
# c) 2km + ±25% area (fallback_used = True, area_widened = True)
|
# c) 2km + ±25% area (fallback_used = True, area_widened = True)
|
||||||
listings, fallback_used = _fetch_analogs(
|
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,
|
||||||
year_built=payload.year_built, house_type=payload.house_type,
|
year_built=target_year, house_type=target_house_type,
|
||||||
)
|
)
|
||||||
area_widened = False
|
area_widened = False
|
||||||
|
|
||||||
|
|
@ -98,7 +112,7 @@ async def estimate_quality(
|
||||||
listings_wide, _ = _fetch_analogs(
|
listings_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,
|
||||||
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):
|
if len(listings_wide) > len(listings):
|
||||||
listings = listings_wide
|
listings = listings_wide
|
||||||
|
|
@ -110,7 +124,7 @@ async def estimate_quality(
|
||||||
listings_widearea, _ = _fetch_analogs(
|
listings_widearea, _ = _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,
|
||||||
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):
|
if len(listings_widearea) > len(listings):
|
||||||
listings = listings_widearea
|
listings = listings_widearea
|
||||||
|
|
@ -210,8 +224,8 @@ async def estimate_quality(
|
||||||
"rooms": payload.rooms,
|
"rooms": payload.rooms,
|
||||||
"floor": payload.floor,
|
"floor": payload.floor,
|
||||||
"total_floors": payload.total_floors,
|
"total_floors": payload.total_floors,
|
||||||
"year_built": payload.year_built,
|
"year_built": target_year,
|
||||||
"house_type": payload.house_type,
|
"house_type": target_house_type,
|
||||||
"repair_state": payload.repair_state,
|
"repair_state": payload.repair_state,
|
||||||
"has_balcony": payload.has_balcony,
|
"has_balcony": payload.has_balcony,
|
||||||
"median_price": median_price,
|
"median_price": median_price,
|
||||||
|
|
|
||||||
213
tradein-mvp/backend/app/services/house_metadata.py
Normal file
213
tradein-mvp/backend/app/services/house_metadata.py
Normal 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-значное число 1800–2100."""
|
||||||
|
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
|
||||||
Loading…
Add table
Reference in a new issue