gendesign/tradein-mvp/backend/app/tasks/cadastral_geo_match.py
bot-backend 3fd6550a16
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
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 2m54s
fix(tradein/matching): честность тиров сопоставления домов (#2674)
Три находки эпика #2674 про верхние тиры матчинга домов. Замеры — прод
tradein-postgres, 2026-08-05/06.

Кадастр от площадок не приходит вообще. listings.cadastral_number (кадастр
КВАРТИРЫ) — 0 из 93 408; единственный писатель, парсер Циана, читает
offer["cadastralNumber"], которого в ответе нет. Все 28 504 заполненных
building_cadastral_number на 100% пришли из локального гео-зеркала ЕГРН
(tasks/cadastral_geo_match.py, KNN <=50 м) — проверено джойном к
cad_buildings_local. Поэтому снят фильтр поиска has_kadastr: предикат
`cadastral_number IS NOT NULL` мог вернуть только пустую выдачу. Колонка и
писатель оставлены — заработают сами, если площадка начнёт отдавать кадастр.

Tier 0 cadastr_exact оставлен, но не подключён к гео-кадастру. Он достижим по
построению (ScrapedLot -> адаптер -> матчер), просто данных нет; подать туда
KNN-заполнение НЕЛЬЗЯ: как ключ здания оно не инъективно — 656 из 3 260
значений накрывают >1 здание ГАР (20.1%), 751 из 2 864 зданий получают >1
значение (26.2%). Это был бы over-merge с confidence 1.0. Заодно исправлено
ложное утверждение в шапке cadastral_geo_match.py, будто Tier 0 трактует эту
колонку как подсказку.

Tier 0.5 fias_exact удалён из match_or_create_house. Параметра house_fias_id
не было ни в Protocol scraper_kit.contracts.HouseMatcher, ни в
RealMatcherAdapter, ни у двух прямых вызывающих — передать его было некому.
В match_house_readonly тир оставлен: у estimate-пути источник ФИАС есть
(payload.target_fias_id / DaData).

Что чинит сопоставление на самом деле: ключ идентичности в house_dedup_merge
расширен с house_fias_id до COALESCE(house_fias_id, gar_house_guid). Это один
и тот же UUID здания в ГАР (3 666 совпадений из 3 667 домов, где заполнены оба),
но заполняют его разные источники, и половина в проход не входила. Read-only
прогон отрендеренного mapping-SQL на проде: старый ключ — 0 пар, новый — 781
(8.3% таблицы houses, 6 389 объявлений на них). Канон-проход эти дома узнаёт
(900 пар из 919 имеют один канон-адрес), но блокирует гео-стражем: 356 пар
с NULL geom, 457 дальше 250 м (максимум 5 065 км — битый геокод). Ровно
аргумент #2187: общий UUID здания старше близости.

Качество сопоставления сейчас: 0 из 49 502 строк house_sources сматчены
верхними тирами; fingerprint 58.97%, new 22.65%, geo_proximity 18.36%.

Тесты: новый tests/test_matching_tier_reachability_2674.py сверяет параметры
матчера с границей вызова (Protocol + адаптер) — ловит класс «ветка есть,
передать некому», который обычный тест не видит, потому что зовёт функцию
напрямую. Удалены два теста мёртвого fias-тира: они были зелёными ровно
потому, что обходили границу вызова.

Refs #2674
2026-08-06 04:37:12 +05:00

332 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Geo-nearest cadastral building matcher for listings (#cadastral-geo-match).
Fills `listings.building_cadastral_number` (currently 0% / ~43k rows) from the nearest
cadastral building, via a LOCAL materialized mirror of the gendesign_cad_buildings FDW.
WHY a local mirror (perf fact, measured live):
gendesign_cad_buildings is a postgres_fdw foreign table (~36.7k EKB rows) with NO
PostGIS geom — only scalar lat/lon. A per-listing nearest-building query over the FDW
takes ~1.16s/row → ~13h for 43k listings. UNUSABLE per-row.
⇒ We materialize the FDW once (single bulk scan) into cad_buildings_local with a real
Point geom + GIST, then run a fast LOCAL KNN nearest-neighbour join (geom <-> point).
The whole local backfill runs in well under a minute (see PR EXPLAIN evidence).
APPROXIMATION (deliberate first increment):
This is a GEO-NEAREST match — a street-level-geocoded listing is matched to the nearest
cadastral building within `threshold_m`, NOT necessarily its exact cadastral building.
The threshold is always logged. Exact cadastral resolution + parcel-containment are
deferred (cad_parcels FDW not exposed).
ЭТО HINT, И ТОЛЬКО HINT (#2674 — правка прежнего утверждения в этой шапке).
Раньше здесь было написано, что Tier-0 матчинга домов «уже трактует
building_cadastral_number как подсказку»; это неверно — Tier 0 в
`app/services/matching/houses.py` отдаёт confidence 1.0, т.е. точное совпадение.
Замер на проде 2026-08-05: 656 из 3 260 заполненных здесь значений накрывают более
одного здания ГАР (20.1%), а 751 из 2 864 зданий ГАР получают более одного значения
(26.2%) — ключ не инъективен ни в одну сторону. Поэтому подавать эту колонку в Tier 0
(ни на пере-скрейпе, ни бэкфиллом в houses.cadastral_number) НЕЛЬЗЯ: это склеит разные
здания с максимальной уверенностью. Колонка годится как признак/подсказка, не как
идентичность здания.
Pipeline (one combined run, scheduler source='cadastral_geo_match'):
1. refresh_cad_buildings_local(db) — TRUNCATE + bulk INSERT from FDW (one scan).
2. match_listings_to_buildings(db, threshold_m) — set-based LATERAL KNN UPDATE.
Refresh-then-match in ONE run guarantees the match always reads a fresh mirror.
psycopg v3: app SQL uses CAST(:x AS type), never :x::type.
"""
from __future__ import annotations
import logging
import math
import time
from dataclasses import dataclass, field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
DEFAULT_THRESHOLD_M = 50
DEFAULT_BATCH_SIZE = 5000
# EKB latitude — used to convert the metric threshold into a degree gate radius for the
# GIST-indexable geometry-space pre-filter (the gate is a SUPERSET of the true circle; the
# ST_DistanceSphere recheck decides acceptance, so a slightly-too-wide gate is harmless).
_EKB_LAT_DEG = 56.84
_M_PER_DEG_LAT = 111_320.0
def _deg_gate_for(threshold_m: float) -> float:
"""Degree radius that always encloses `threshold_m` metres at EKB latitude.
Longitude degrees shrink by cos(lat), so we size the gate on the tighter (longitude)
axis and apply a 1.5× safety factor. The gate only pre-filters candidates for the GIST
KNN scan; the exact ST_DistanceSphere(...) <= threshold_m recheck is authoritative, so
over-sizing the gate never produces a false match, only a few extra candidates to rank.
"""
m_per_deg_lon = _M_PER_DEG_LAT * math.cos(math.radians(_EKB_LAT_DEG))
return 1.5 * threshold_m / m_per_deg_lon
# ── Refresh: materialize FDW → cad_buildings_local (single bulk scan) ─────────
def refresh_cad_buildings_local(db: Session) -> int:
"""TRUNCATE cad_buildings_local; bulk INSERT from gendesign_cad_buildings FDW.
Single FDW scan (no per-row round-trips). Builds Point(4326) geom from lon/lat.
Idempotent: TRUNCATE+INSERT in one transaction — the table is fully replaced atomically.
Returns the number of rows loaded.
"""
db.execute(text("TRUNCATE cad_buildings_local"))
db.execute(
text(
"""
INSERT INTO cad_buildings_local (
cad_num, readable_address, year_built, floors, area_m2, purpose,
lat, lon, geom
)
SELECT
cad_num, readable_address, year_built, floors, area_m2, purpose,
lat, lon,
ST_SetSRID(ST_MakePoint(lon, lat), 4326)
FROM gendesign_cad_buildings
WHERE lat IS NOT NULL
AND lon IS NOT NULL
AND cad_num IS NOT NULL
ON CONFLICT (cad_num) DO NOTHING
"""
)
)
count = db.execute(text("SELECT count(*) FROM cad_buildings_local")).scalar() or 0
db.commit()
logger.info("refresh_cad_buildings_local: loaded %d rows from FDW", count)
return int(count)
# ── Match: LATERAL KNN nearest building → listings.building_cadastral_number ──
# Set-based UPDATE. For each candidate listing (geo present, optionally only NULL bcn) the
# LATERAL subquery picks the single nearest cad_buildings_local building and accepts it only
# if its TRUE distance is within :threshold_m metres.
#
# PERF (measured live, the whole point of this design):
# The gate is a GEOMETRY-space ST_DWithin(cb.geom, l_geom, :deg_gate) — planar degrees,
# fully GIST-indexable, NO per-row geography cast. The `<-> ` KNN order then returns the
# single nearest candidate via the GIST index. ONLY that one nearest row is converted to a
# true metric distance via ST_DistanceSphere (spherical metres) and compared to :threshold_m.
# This is ~175× faster than a geography-cast ST_DWithin in the WHERE: a geography filter
# forced a per-candidate cast+recheck that the planner could not push into the index, so the
# full 41k-listing UPDATE ran 58s+ and never finished; the geometry-gate version does a
# 5000-row chunk in ~330ms and the full ~41k backfill in a few seconds.
#
# :deg_gate is a degree radius that ALWAYS encloses :threshold_m at EKB latitude (~56.8°):
# 1° lat ≈ 111_320 m, and we widen by /cos(lat) for longitude + a safety factor, so the
# gate is a superset of the true threshold circle and the ST_DistanceSphere recheck is what
# actually decides acceptance (no false negatives from the gate).
#
# Batching: pick a chunk of candidate listing ids first (LIMIT :batch_size), then run the
# LATERAL UPDATE only over that chunk — bounds lock/WAL footprint on the ~43k table and lets
# the run loop checkpoint a heartbeat between chunks. only_missing=true makes each chunk drain
# monotonically (filled rows drop out of the candidate set), so the loop terminates.
# Single set-based LATERAL KNN UPDATE over ALL candidates in one statement.
# NOT chunked by OFFSET: an OFFSET walk over the `building_cadastral_number IS NULL`
# predicate is unsound because the UPDATE mutates that very predicate mid-walk —
# matched rows drop out of the candidate set, so OFFSET (reset OR advance) skips
# un-processed rows. The full UPDATE is ~5s for ~41k listings (GIST KNN, measured),
# so a single statement is both correct and fast enough; no chunking needed.
_MATCH_SQL = text(
"""
WITH matched AS (
SELECT l.id AS id, m.cad_num AS cad_num
FROM listings l
JOIN LATERAL (
SELECT cb.cad_num,
ST_DistanceSphere(cb.geom, l.geom) AS dist_m
FROM cad_buildings_local cb
WHERE ST_DWithin(cb.geom, l.geom, CAST(:deg_gate AS double precision))
ORDER BY cb.geom <-> l.geom
LIMIT 1
) m ON true
WHERE l.geom IS NOT NULL
AND (
CAST(:only_missing AS boolean) = false
OR l.building_cadastral_number IS NULL
)
AND m.dist_m <= CAST(:threshold_m AS double precision)
)
UPDATE listings l
SET building_cadastral_number = matched.cad_num
FROM matched
WHERE l.id = matched.id
"""
)
_CANDIDATES_SQL = text(
"""
SELECT count(*) FROM listings l
WHERE l.geom IS NOT NULL
AND (CAST(:only_missing AS boolean) = false OR l.building_cadastral_number IS NULL)
"""
)
@dataclass
class CadMatchResult:
buildings_loaded: int = 0 # rows in cad_buildings_local after refresh
candidates_total: int = 0 # listings eligible for matching (geo present + filter)
listings_matched: int = 0 # listings updated with a building_cadastral_number
threshold_m: int = DEFAULT_THRESHOLD_M
duration_sec: float = field(default=0.0)
def to_counters(self) -> dict[str, int]:
return {
"buildings_loaded": self.buildings_loaded,
"candidates_total": self.candidates_total,
"listings_matched": self.listings_matched,
"threshold_m": self.threshold_m,
"duration_sec": int(self.duration_sec),
}
def match_listings_to_buildings(
db: Session,
*,
threshold_m: int = DEFAULT_THRESHOLD_M,
batch_size: int = DEFAULT_BATCH_SIZE,
only_missing: bool = True,
) -> int:
"""Set-based LATERAL KNN UPDATE: fill listings.building_cadastral_number.
GEO-NEAREST (approximate): each candidate listing is matched to the nearest
cad_buildings_local building within `threshold_m` metres. The threshold is logged.
Single set-based UPDATE over ALL candidates (no chunking). An OFFSET walk would be
unsound: the UPDATE mutates the `building_cadastral_number IS NULL` predicate it pages
over, so matched rows drop out and OFFSET (reset OR advance) skips un-processed rows.
The full UPDATE is ~5s for ~41k listings (GIST KNN), so one statement is correct and
fast. `batch_size` is accepted for schedule-param compatibility but no longer used.
Returns listings matched.
"""
logger.info(
"match_listings_to_buildings: GEO-NEAREST match threshold_m=%d "
"only_missing=%s (approximate: nearest building, not exact cadastral)",
threshold_m,
only_missing,
)
deg_gate = _deg_gate_for(float(threshold_m))
result = db.execute(
_MATCH_SQL,
{
"threshold_m": float(threshold_m),
"deg_gate": deg_gate,
"only_missing": only_missing,
},
)
total_matched = result.rowcount
db.commit()
logger.info(
"match_listings_to_buildings: DONE total_matched=%d threshold_m=%d",
total_matched,
threshold_m,
)
return total_matched
# ── Combined refresh + match (callable directly or via run wrapper) ───────────
def refresh_and_match(
db: Session,
*,
threshold_m: int = DEFAULT_THRESHOLD_M,
batch_size: int = DEFAULT_BATCH_SIZE,
only_missing: bool = True,
) -> CadMatchResult:
"""Refresh the local cad mirror, then geo-match listings. Returns a CadMatchResult."""
start = time.monotonic()
res = CadMatchResult(threshold_m=threshold_m)
res.buildings_loaded = refresh_cad_buildings_local(db)
res.candidates_total = int(
db.execute(_CANDIDATES_SQL, {"only_missing": only_missing}).scalar() or 0
)
res.listings_matched = match_listings_to_buildings(
db, threshold_m=threshold_m, batch_size=batch_size, only_missing=only_missing
)
res.duration_sec = time.monotonic() - start
coverage = 100.0 * res.listings_matched / res.candidates_total if res.candidates_total else 0.0
logger.info(
"refresh_and_match: DONE buildings=%d candidates=%d matched=%d "
"coverage=%.1f%% threshold_m=%d duration=%.1fs",
res.buildings_loaded,
res.candidates_total,
res.listings_matched,
coverage,
threshold_m,
res.duration_sec,
)
return res
# ── Run lifecycle wrapper (scheduler entrypoint) ─────────────────────────────
def run_cadastral_geo_match(db: Session, *, run_id: int, params: dict) -> CadMatchResult:
"""Run-lifecycle wrapper for the combined refresh+match job (sync, DB-only).
Launched by the kit scheduler (source='cadastral_geo_match') via
product_handlers._job_cadastral_geo_match, or manually. Refreshes cad_buildings_local
from the FDW (one bulk scan) then geo-matches listings.building_cadastral_number to the
nearest cadastral building within threshold_m.
Params (default_params jsonb):
threshold_m: max nearest-building distance to accept (metres, default 50).
batch_size: listings per LATERAL UPDATE chunk (default 5000).
only_missing: only fill WHERE building_cadastral_number IS NULL (default true).
Finalises scrape_runs (mark_done / mark_failed) with counters.
"""
threshold_m = int(params.get("threshold_m", DEFAULT_THRESHOLD_M))
batch_size = int(params.get("batch_size", DEFAULT_BATCH_SIZE))
only_missing = bool(params.get("only_missing", True))
counters: dict[str, int] = {
"buildings_loaded": 0,
"candidates_total": 0,
"listings_matched": 0,
"threshold_m": threshold_m,
}
try:
runs_mod.update_heartbeat(db, run_id, counters)
res = refresh_and_match(
db,
threshold_m=threshold_m,
batch_size=batch_size,
only_missing=only_missing,
)
counters = res.to_counters()
runs_mod.mark_done(db, run_id, counters)
logger.info(
"run_cadastral_geo_match: run_id=%d DONE buildings=%d candidates=%d "
"matched=%d threshold_m=%d duration=%.1fs",
run_id,
res.buildings_loaded,
res.candidates_total,
res.listings_matched,
threshold_m,
res.duration_sec,
)
return res
except Exception as exc:
logger.exception("run_cadastral_geo_match: run_id=%d FAILED", run_id)
try:
db.rollback()
except Exception:
pass
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise