"""Backfill houses table via DaData /clean/address. PR Q3 follow-up to PR Q1 (#604) — теперь, когда сервис `dadata.clean_address()` живёт на проде, проходим по таблице `houses` daily 100/day (demo quota) и дополняем недостающие координаты + кадастровые номера + ФИАС. DB state on prod (verified 2026-05-27): - houses: 12437 rows total - lat IS NULL OR lon IS NULL: 3801 (нужны координаты) - cadastral_number IS NULL: 12437 (100% — все нужно дополнить) - house_fias_id IS NULL: 12437 (колонка добавлена в migration 070) Walltime: 12437 / 100 = ~124 дня. Acceptable — это batch backfill, не realtime. Priority order (--priority both, default): 1. NULL lat OR NULL lon (3801 rows) — нужны coords для spatial match 2. NULL cadastral_number AND lat IS NOT NULL (~8636 rows) — есть coords, дополняем только cadnum / fias / qc-codes `--priority coords` — только #1, `--priority cadnum` — только #2. Estimate-relevance ordering (accuracy-roadmap #2): Внутри каждого priority bucket'а дома с active listings (`EXISTS listings WHERE house_id_fk = houses.id AND is_active`) сортируются ПЕРВЫМИ (`ORDER BY has_active_listings DESC, id`). Так дневной 100/day budget сначала закрывает дома, которые реально появляются в оценках → estimator #6 `cadastr_exact` tier «загорается» на real estimate targets за дни, а не недели. `--priority listings` — focused sweep: только NULL cadnum + есть active listing. Per-row outcome rules (после `clean_address` ответа): - result.qc_geo IN (0, 1) AND result.lat IS NOT NULL → точное / уличное совпадение, UPDATE houses SET lat=, lon=, cadastral_number=, house_fias_id=, dadata_qc_geo=, dadata_qc_house=, dadata_enriched_at=NOW() - result.qc_geo IN (2, 3, 4) OR result IS None → не overwrite координаты, только UPDATE houses SET dadata_qc_geo=, dadata_qc_house=, dadata_enriched_at=NOW() (фиксируем факт попытки → resume пропустит row при следующем запуске) Resume-safe: `WHERE dadata_enriched_at IS NULL` фильтрует уже обработанные. Design choices: - **Per-row SAVEPOINT** (`db.begin_nested()`) — одна DaData / DB ошибка не должна завалить весь batch (тот же паттерн что и в backfill_house_coords.py). - **Rate limit**: 0.5s между requests (DaData demo allows ~5 req/sec, мы при limit=100 делаем 50 секунд непрерывной работы — далеко от quota burst-detection). - **Daily budget**: `--limit 100` default matches demo tier (100 req/day). Usage: DATABASE_URL=postgresql+psycopg://... \\ DADATA_API_TOKEN=xxx DADATA_API_SECRET=xxx \\ python -m scripts.backfill_houses_dadata --limit 100 Flags: --limit N max rows to process (default 100) --batch LABEL log label (default `dadata_YYYY-MM-DD`) --dry-run log what would happen, no DB writes --priority X coords | cadnum | both (default both) | listings """ from __future__ import annotations import argparse import asyncio import logging import os from dataclasses import dataclass, field from datetime import date from pathlib import Path from sqlalchemy import text from sqlalchemy.orm import Session # Allow running both as `python -m scripts.backfill_houses_dadata` (preferred, # матчит pattern из backfill_house_coords.py) и как stand-alone file. try: from app.core.db import SessionLocal # type: ignore[import-not-found] except ImportError: # pragma: no cover — fallback for adhoc invocation import sys sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app.core.db import SessionLocal try: from app.services.dadata import ( # type: ignore[import-not-found] DadataAddressResult, clean_address, ) except ImportError: # pragma: no cover import sys sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app.services.dadata import DadataAddressResult, clean_address logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s", ) logger = logging.getLogger("backfill_houses_dadata") # DaData demo tier: 100 requests/day, no burst limit documented but # best practice — sleep между requests, чтобы не триггерить anti-flood. # 0.5s = 2 req/sec → 50s walltime для full 100/day batch. _REQUEST_DELAY_S = 0.5 # qc_geo: 0=exact, 1=street, 2=settlement, 3=city, 4=region, 5=unknown. # Только 0/1 точны достаточно для overwrite coords дома. _QC_GEO_ACCEPT = frozenset({0, 1}) # Логировать каждые N rows _LOG_EVERY = 10 # --------------------------------------------------------------------------- # Domain types # --------------------------------------------------------------------------- @dataclass class HouseRow: """Минимальные поля из houses для enrichment loop'а. `address` — сырой адрес (приоритет houses.address, fallback на full_address). """ id: int address: str lat: float | None lon: float | None cadastral_number: str | None @dataclass class Stats: """Счётчики для финального summary log'а.""" processed: int = 0 enriched: int = 0 # qc_geo IN (0,1) — coords/cadnum/fias записаны no_change: int = 0 # qc_geo IN (2,3,4) или canonical_address None — только tracking failed: int = 0 # exception в clean_address или DB write skipped: int = 0 # пустой address (нечего geocode'ить) by_priority: dict[str, int] = field(default_factory=dict) def bump(self, priority: str, outcome: str) -> None: """Внутренний учёт по priority bucket'у.""" bucket = self.by_priority.setdefault( priority, {"enriched": 0, "no_change": 0, "failed": 0, "skipped": 0} ) bucket[outcome] = bucket.get(outcome, 0) + 1 # --------------------------------------------------------------------------- # Source-row query — приоритет: NULL coords → NULL cadnum # --------------------------------------------------------------------------- # accuracy-roadmap #2: дом «участвует в оценках» iff у него есть active listing # (`listings.house_id_fk → houses.id`, `listings.is_active`). Такие дома — # реальные target'ы estimator'а (#6 `cadastr_exact` tier), поэтому их кадастры # нужны раньше всего. Используем как ORDER BY-ключ (DESC → впереди) + как фильтр # для `--priority listings`. Correlated EXISTS бьёт по partial-индексу # `listings_house_id_fk_idx (house_id_fk) WHERE house_id_fk IS NOT NULL`. _HAS_ACTIVE_LISTINGS_EXPR = ( "EXISTS (SELECT 1 FROM listings l " "WHERE l.house_id_fk = houses.id AND l.is_active)" ) def _select_candidates( db: Session, *, priority: str, limit: int ) -> list[tuple[HouseRow, str]]: """Возвращает список (HouseRow, priority_bucket) для enrichment. priority: - 'coords' → только rows с NULL coords (max бизнес-value) - 'cadnum' → только rows с NOT NULL coords + NULL cadnum - 'both' → coords first, потом cadnum (FIFO в пределах buckets) - 'listings' → только NULL cadnum + есть active listing (focused sweep для #6 same-building матчинга — дома, которые реально в оценках) Внутри каждого bucket'а строки с active listings (`has_active_listings`) идут ПЕРВЫМИ (`ORDER BY has_active_listings DESC, id`), чтобы дневной 100/day budget сначала закрывал estimate-relevant дома, а уже потом длинный хвост. Это и есть accuracy-roadmap #2. Фильтр `dadata_enriched_at IS NULL` — resume-safe (не повторяем уже обработанные rows). После full enrichment колонка станет NOT NULL у всех rows которые мы успели тронуть, и партишен-индекс `houses_dadata_not_enriched_idx` схлопнется. """ # Skip rows без какого-либо адреса — geocode'ить нечего. address_filter = ( "(COALESCE(NULLIF(trim(address), ''), NULLIF(trim(full_address), '')) IS NOT NULL)" ) out: list[tuple[HouseRow, str]] = [] remaining = limit def _fetch(where: str, bucket: str, lim: int) -> list[tuple[HouseRow, str]]: sql = text( "SELECT id, " " COALESCE(NULLIF(trim(address), ''), NULLIF(trim(full_address), '')) " " AS address, " " lat, lon, cadastral_number, " f" {_HAS_ACTIVE_LISTINGS_EXPR} AS has_active_listings " "FROM houses " f"WHERE {where} " " AND dadata_enriched_at IS NULL " f" AND {address_filter} " "ORDER BY has_active_listings DESC, id " "LIMIT CAST(:lim AS int)" ) rows = db.execute(sql, {"lim": lim}).mappings().all() return [ ( HouseRow( id=r["id"], address=r["address"], lat=r["lat"], lon=r["lon"], cadastral_number=r["cadastral_number"], ), bucket, ) for r in rows ] if priority == "listings": # Focused sweep: только дома с active listings + NULL cadnum. # has_active_listings гарантированно TRUE здесь, но ORDER BY оставляем # консистентным (DESC, id) — деградирует до ORDER BY id. out.extend( _fetch( f"(cadastral_number IS NULL AND {_HAS_ACTIVE_LISTINGS_EXPR})", "listings", remaining, ) ) return out[:limit] if priority in ("coords", "both"): out.extend(_fetch("(lat IS NULL OR lon IS NULL)", "coords", remaining)) remaining = limit - len(out) if priority in ("cadnum", "both") and remaining > 0: out.extend( _fetch( "(lat IS NOT NULL AND lon IS NOT NULL AND cadastral_number IS NULL)", "cadnum", remaining, ) ) return out[:limit] # --------------------------------------------------------------------------- # DB writers # --------------------------------------------------------------------------- def _update_house_enriched( db: Session, *, house_id: int, result: DadataAddressResult, ) -> None: """UPDATE houses с полным набором полей (qc_geo IN (0,1) → точное матч). Не overwrite'ит lat/lon если result.lat/lon None (DaData может вернуть qc_geo=0 для города но без точки — edge case). COALESCE'им через `CASE WHEN :lat IS NOT NULL THEN :lat ELSE lat END` чтобы не ломать существующие значения. cadastral_number / house_fias_id — overwrite только если DaData вернул непустое значение (COALESCE с existing). """ db.execute( text( "UPDATE houses " " SET lat = CASE WHEN CAST(:lat AS double precision) IS NOT NULL " " THEN CAST(:lat AS double precision) " " ELSE lat END, " " lon = CASE WHEN CAST(:lon AS double precision) IS NOT NULL " " THEN CAST(:lon AS double precision) " " ELSE lon END, " " cadastral_number = COALESCE(CAST(:cadnum AS text), cadastral_number), " " house_fias_id = COALESCE(CAST(:fias AS text), house_fias_id), " " dadata_qc_geo = CAST(:qc_geo AS integer), " " dadata_qc_house = CAST(:qc_house AS integer), " " dadata_enriched_at = NOW() " " WHERE id = CAST(:id AS bigint)" ), { "id": house_id, "lat": result.lat, "lon": result.lon, "cadnum": result.house_cadnum, "fias": result.house_fias_id, "qc_geo": result.qc_geo, "qc_house": result.qc_house, }, ) def _update_house_attempt( db: Session, *, house_id: int, qc_geo: int | None, qc_house: int | None, ) -> None: """UPDATE только tracking-поля (qc_geo вне ACCEPT set, либо None result). НЕ трогаем lat/lon/cadnum/fias — DaData либо не нашёл точно (qc=2,3,4), либо вообще не распознал адрес. Фиксируем `dadata_enriched_at` чтобы resume не повторял эту row при следующем запуске. """ db.execute( text( "UPDATE houses " " SET dadata_qc_geo = CAST(:qc_geo AS integer), " " dadata_qc_house = CAST(:qc_house AS integer), " " dadata_enriched_at = NOW() " " WHERE id = CAST(:id AS bigint)" ), {"id": house_id, "qc_geo": qc_geo, "qc_house": qc_house}, ) # --------------------------------------------------------------------------- # Main loop # --------------------------------------------------------------------------- # «Екатеринбург» в любом регистре — короткие адреса типа «улица Малышева, 125» # без явного города DaData по умолчанию маппит в Москву → подсовываем префикс. _EKB_CITY_PREFIX = "Екатеринбург, " _EKB_CITY_MARKER = "екатеринбург" def _with_city_context(address: str) -> str: """Pre-pend «Екатеринбург, » если в адресе ещё нет указания города. DaData /clean без city-контекста выбирает Москву по умолчанию для тёзок (улица Просторная → ул Просторная в Москве). Этот wrapper гарантирует, что для коротких street-адресов из houses.address DaData получает явный город. Идемпотентен: «Екатеринбург, улица Малышева, 125» не префиксируется повторно. """ if not address: return address if _EKB_CITY_MARKER in address.lower(): return address return _EKB_CITY_PREFIX + address.strip() def _result_in_ekb(result: DadataAddressResult | None) -> bool: """Safety: True если DaData вернул действительно Екатеринбургский адрес. Защищает от false-positives когда DaData мапит улицу-тёзку (даже после префикса) в другой город. Использует `result.raw.get('city')` (структурное поле) c fallback на substring search в `canonical_address`. """ if result is None: return False city = (result.raw.get("city") or "") if isinstance(result.raw, dict) else "" if _EKB_CITY_MARKER in city.lower(): return True canonical = (result.canonical_address or "").lower() return _EKB_CITY_MARKER in canonical def _is_enriched_result(result: DadataAddressResult | None) -> bool: """Решает, можно ли overwrite'ить координаты + cadnum по этому result'у. Условие: qc_geo IN (0, 1) AND lat / lon заданы AND city == Екатеринбург. Возврат False означает «записать только tracking, не overwrite payload». """ if result is None: return False if result.qc_geo not in _QC_GEO_ACCEPT: return False if result.lat is None or result.lon is None: return False if not _result_in_ekb(result): return False return True async def _run_backfill( db: Session, candidates: list[tuple[HouseRow, str]], *, batch: str, dry_run: bool, ) -> Stats: """Главный цикл — для каждого candidate зовём DaData + UPDATE houses. Per-row SAVEPOINT защищает batch от падений на одной row. Per-row commit после успешного nested block → resume picks up exactly где упали. """ stats = Stats() for i, (row, priority) in enumerate(candidates, start=1): result: DadataAddressResult | None = None outcome = "no_change" try: # `clean_address` сам обрабатывает HTTP errors / quota / timeout # и возвращает None — exception будет только при unexpected error. # `_with_city_context` префиксирует «Екатеринбург, » если в адресе # ещё нет указания города (защита от Москва-by-default mapping). result = await clean_address(_with_city_context(row.address)) except Exception as exc: # defensive — одна ошибка не должна валить batch stats.failed += 1 stats.bump(priority, "failed") logger.warning( "dadata_call failed for house_id=%s addr=%r: %s", row.id, row.address[:60], exc, ) # Не пишем в DB при exception — оставляем row для следующей попытки. continue if dry_run: if _is_enriched_result(result): outcome = "enriched" logger.info( "DRY-RUN house_id=%s [%s] WOULD enrich → " "qc_geo=%s qc_house=%s lat=%s lon=%s cadnum=%s fias=%s", row.id, priority, result.qc_geo if result else None, result.qc_house if result else None, result.lat if result else None, result.lon if result else None, result.house_cadnum if result else None, result.house_fias_id if result else None, ) else: outcome = "no_change" logger.info( "DRY-RUN house_id=%s [%s] WOULD record attempt only → " "qc_geo=%s qc_house=%s (result_present=%s)", row.id, priority, result.qc_geo if result else None, result.qc_house if result else None, result is not None, ) stats.processed += 1 if outcome == "enriched": stats.enriched += 1 else: stats.no_change += 1 stats.bump(priority, outcome) else: try: with db.begin_nested(): if _is_enriched_result(result): assert result is not None # narrowed by _is_enriched_result _update_house_enriched(db, house_id=row.id, result=result) outcome = "enriched" stats.enriched += 1 else: _update_house_attempt( db, house_id=row.id, qc_geo=result.qc_geo if result else None, qc_house=result.qc_house if result else None, ) outcome = "no_change" stats.no_change += 1 # Per-row commit чтобы resume picks up exactly where we crashed. db.commit() stats.processed += 1 stats.bump(priority, outcome) except Exception as exc: # defensive — DB write failure isolation db.rollback() stats.failed += 1 stats.bump(priority, "failed") logger.warning( "db_write failed for house_id=%s: %s", row.id, exc ) if i % _LOG_EVERY == 0: logger.info( "batch=%s progress %d/%d enriched=%d no_change=%d failed=%d", batch, i, len(candidates), stats.enriched, stats.no_change, stats.failed, ) # Rate-limit: пауза только если ещё есть rows впереди. if i < len(candidates): await asyncio.sleep(_REQUEST_DELAY_S) return stats # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: """argparse setup, выделено отдельно для testability.""" p = argparse.ArgumentParser( description=( "PR Q3 — backfill houses.lat/lon/cadastral_number/house_fias_id " "через DaData /clean/address (demo tier, 100/day)." ), ) p.add_argument( "--limit", type=int, default=100, help="Max rows to process (default 100 — matches DaData demo daily quota).", ) p.add_argument( "--batch", default=f"dadata_{date.today().isoformat()}", help="Log batch label. Не влияет на DB фильтры — только для логов.", ) p.add_argument( "--dry-run", action="store_true", help="Логирует что бы произошло, но не пишет в DB.", ) p.add_argument( "--priority", choices=("coords", "cadnum", "both", "listings"), default="both", help=( "Какой набор кандидатов обрабатывать. coords = только NULL coords, " "cadnum = NOT NULL coords + NULL cadnum, both (default) = первый " "приоритет coords, listings = только NULL cadnum + есть active " "listing (focused sweep для #6 same-building матчинга). Во всех " "режимах дома с active listings сортируются вперёд." ), ) return p.parse_args(argv) async def main(argv: list[str] | None = None) -> int: """CLI entry point. Возвращает кол-во processed rows.""" args = _parse_args(argv) # Сервис graceful'но disable'ится без creds, но script без них бессмысленен — # лучше fail fast, чтобы cron-runs не съели DB-locks впустую. token = os.environ.get("DADATA_API_TOKEN", "").strip() secret = os.environ.get("DADATA_API_SECRET", "").strip() if not token or not secret: raise SystemExit( "DADATA_API_TOKEN + DADATA_API_SECRET required — " "DaData /clean/address без secret не работает." ) logger.info( "starting batch=%s limit=%s priority=%s dry_run=%s", args.batch, args.limit, args.priority, args.dry_run, ) db = SessionLocal() try: candidates = _select_candidates(db, priority=args.priority, limit=args.limit) logger.info( "loaded candidates: %d (priority=%s)", len(candidates), args.priority ) if not candidates: logger.info( "nothing to do — нет rows с dadata_enriched_at IS NULL для priority=%s", args.priority, ) return 0 stats = await _run_backfill( db, candidates, batch=args.batch, dry_run=args.dry_run ) logger.info( "done: batch=%s processed=%d enriched=%d no_change=%d " "failed=%d skipped=%d by_priority=%s", args.batch, stats.processed, stats.enriched, stats.no_change, stats.failed, stats.skipped, stats.by_priority, ) return stats.processed finally: db.close() if __name__ == "__main__": # pragma: no cover asyncio.run(main())