gendesign/tradein-mvp/backend/scripts/audit_address_mismatch.py
Light1YT 02ad9a1f58 feat(tradein): Phase 1 of #582 — address mismatch audit infrastructure
Stratified 200-house Yandex reverse-geocode audit comparing our DB's
(address, lat/lon) pair against what Yandex.Карты returns for those
coordinates. Лежит фундамент данных для Phases 2-5 (canonical = Yandex
per user decision).

What ships:
- Migration 066: address_mismatch_audit table + FDW foreign table
  gendesign_ekb_districts_geom для stratified sampling.
- Sampling SQL: 8 EKB districts × 25 houses = 200.
- Driver scripts/audit_address_mismatch.py с двумя режимами:
  API (если YANDEX_GEOCODER_API_KEY есть) и Playwright (CAPTCHA-aware,
  персистентный контекст). Resumable по --batch.
- Report SQL: p50/p75/p95 distance, top-20 outliers, per-district breakdown.
- 19 unit-тестов: normalize, distance bind, API parser, resume idempotency,
  captcha → status=blocked.

Devops note: после деплоя миграции — выдать tradein_fdw_reader SELECT на
gendesign.public.ekb_districts_geom перед первым запуском audit.

Refs #582 (Phase 1 of 5). Phase 3 backfill заблокирован до Yandex API
key (#402).
2026-05-25 12:50:30 +05:00

595 lines
21 KiB
Python
Raw 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.

"""Audit driver — compares houses.address vs Yandex reverse geocode.
Phase 1 of Forgejo issue #582. Pulls a stratified sample of EKB houses (25
per admin district = 200 total), reverse-geocodes each via Yandex, computes
the distance between the stored coordinates and the snapped Yandex point,
and writes the result into `address_mismatch_audit`.
Design choices:
- **Resumable**: the audit table has UNIQUE (house_id, audit_batch). Re-run
with the same `--batch` skips rows already inserted, so a partial run can
be picked up after CAPTCHA / network blip.
- **Mode auto**: prefer API when `YANDEX_GEOCODER_API_KEY` is set, fall back
to Playwright otherwise. Explicit override via `--mode {api,playwright}`.
- **No prod side effects**: the script only writes to one new audit table;
it never touches `houses`, `house_sources`, or any matching/listing row.
- **Per-row SAVEPOINT**: a single Yandex error must not nuke the entire
batch — wrap each INSERT in `db.begin_nested()` per backend.md.
How to run:
DATABASE_URL=postgresql+psycopg://... \
YANDEX_GEOCODER_API_KEY=... \
python -m scripts.audit_address_mismatch --batch 2026-05-25_run1
Outputs (post-run):
- New rows in `address_mismatch_audit` with batch label.
- `scripts/address_audit_report.sql :batch=<id>` for summary.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import random
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import Any
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
# Allow running both as `python -m scripts.audit_address_mismatch` (preferred)
# and as a stand-alone file (`python scripts/audit_address_mismatch.py`)
# without requiring package install.
try:
from app.core.db import SessionLocal # type: ignore[import-not-found]
from app.services.matching.normalize import normalize_address # 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
from app.services.matching.normalize import normalize_address
# `from .` works when run via -m; the absolute import works under pytest.
try:
from scripts._yandex_reverse import ( # type: ignore[import-not-found]
YandexBlockedError,
YandexReverseResult,
reverse_via_api,
reverse_via_playwright,
)
except ImportError:
from _yandex_reverse import ( # type: ignore[no-redef]
YandexBlockedError,
YandexReverseResult,
reverse_via_api,
reverse_via_playwright,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger("audit_address_mismatch")
# Playwright persistent context location — keeps cookies/local storage between
# runs so we look like a returning user, reducing CAPTCHA frequency.
_PLAYWRIGHT_USER_DATA = Path.home() / ".cache" / "tradein-audit-playwright"
_PLAYWRIGHT_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
_SAMPLE_SQL_PATH = Path(__file__).parent / "audit_address_sample.sql"
# ---------------------------------------------------------------------------
# Domain helpers
# ---------------------------------------------------------------------------
@dataclass
class SampleRow:
"""One house from the stratified sampling query."""
id: int
address: str
lat: float
lon: float
district: str | None
# Words that introduce a street rather than identify it. We skip these so the
# comparison lands on the actual street name ('малышева' / 'ленина'). Mirrors
# the canonical forms produced by `normalize_address` (which expands all known
# abbreviations to these full words).
_STREET_TYPE_WORDS = frozenset(
{
"улица",
"проспект",
"переулок",
"бульвар",
"проезд",
"шоссе",
"площадь",
"набережная",
"тупик",
"строение",
"корпус",
"дом",
}
)
# Geographic prefix words that addresses sometimes carry before the street
# (e.g. 'россия екатеринбург улица малышева 51'). We skip them too so we
# converge on the same identifying token regardless of how verbose the
# source representation is.
_GEO_PREFIX_WORDS = frozenset(
{
"россия",
"свердловская",
"область",
"екатеринбург",
"город",
"г",
}
)
def _first_street_token(address: str | None) -> str | None:
"""Extract the first street-name token of a normalized address.
Phase-1 heuristic for "do the streets agree": skip numeric tokens (house
numbers), street-type words ('улица', 'проспект', …), and geographic
prefixes ('россия', 'екатеринбург', …) — the next token is the street
name itself, which is the identifying part we want to compare.
Returns None for an empty address or when no candidate token remains.
"""
norm = normalize_address(address or "")
if not norm:
return None
for tok in norm.split():
if not tok:
continue
# Skip purely numeric tokens (e.g. '5', '17а' if it starts with digit).
if tok[0].isdigit():
continue
# Skip street type words and geographic prefixes.
if tok in _STREET_TYPE_WORDS or tok in _GEO_PREFIX_WORDS:
continue
return tok
return None
def _street_differs(original: str | None, snapped: str | None) -> bool | None:
"""True iff first non-numeric token differs between the two addresses.
Returns None when either side is empty — we cannot compute a meaningful
diff (caller writes NULL into the audit row).
"""
a = _first_street_token(original)
b = _first_street_token(snapped)
if a is None or b is None:
return None
return a != b
def _distance_meters(
db: Session,
olat: float,
olon: float,
slat: float,
slon: float,
) -> float | None:
"""Compute great-circle distance via PostGIS geography type.
We could do this in Python with a haversine formula, but the audit table
uses ST_Distance results elsewhere so we use the same authority to avoid
drift. ST_MakePoint(lon, lat) — PostGIS convention is lon first.
"""
row = db.execute(
text(
"SELECT ST_Distance("
" ST_SetSRID(ST_MakePoint(CAST(:olon AS double precision), "
" CAST(:olat AS double precision)), 4326)::geography, "
" ST_SetSRID(ST_MakePoint(CAST(:slon AS double precision), "
" CAST(:slat AS double precision)), 4326)::geography"
") AS m"
),
{"olat": olat, "olon": olon, "slat": slat, "slon": slon},
).first()
if row is None or row[0] is None:
return None
return float(row[0])
# ---------------------------------------------------------------------------
# Sampling + resumption queries
# ---------------------------------------------------------------------------
def _load_sample(db: Session, limit_per_district: int) -> list[SampleRow]:
"""Run the stratified sampling SQL → list of SampleRow."""
sql = _SAMPLE_SQL_PATH.read_text(encoding="utf-8")
rows = db.execute(text(sql), {"limit_per_district": limit_per_district}).mappings().all()
return [
SampleRow(
id=r["id"],
address=r["address"],
lat=float(r["lat"]),
lon=float(r["lon"]),
district=r["district"],
)
for r in rows
]
def _already_processed_ids(db: Session, batch: str) -> set[int]:
"""Return the set of house_id already in the audit table for this batch.
Drives resumability: drop these from the sample before geocoding.
"""
rows = db.execute(
text("SELECT house_id FROM address_mismatch_audit WHERE audit_batch = CAST(:b AS text)"),
{"b": batch},
).all()
return {r[0] for r in rows}
# ---------------------------------------------------------------------------
# Insert helper
# ---------------------------------------------------------------------------
def _insert_audit_row(
db: Session,
*,
house_id: int,
batch: str,
district: str | None,
original_address: str | None,
original_lat: float | None,
original_lon: float | None,
snapped_address: str | None,
snapped_lat: float | None,
snapped_lon: float | None,
distance_m: float | None,
street_differs: bool | None,
audit_status: str,
error_message: str | None,
raw_payload: dict[str, Any] | None,
) -> None:
"""INSERT … ON CONFLICT DO NOTHING into address_mismatch_audit.
Wrapped in begin_nested by the caller per backend.md SAVEPOINT pattern.
"""
db.execute(
text(
"INSERT INTO address_mismatch_audit ("
" house_id, audit_batch, district,"
" original_address, original_lat, original_lon,"
" snapped_address, snapped_lat, snapped_lon,"
" distance_m, street_differs,"
" audit_status, error_message, raw_payload"
") VALUES ("
" CAST(:house_id AS bigint), CAST(:batch AS text), :district,"
" :original_address, :original_lat, :original_lon,"
" :snapped_address, :snapped_lat, :snapped_lon,"
" :distance_m, :street_differs,"
" CAST(:audit_status AS text), :error_message,"
" CAST(:raw_payload AS jsonb)"
") ON CONFLICT (house_id, audit_batch) DO NOTHING"
),
{
"house_id": house_id,
"batch": batch,
"district": district,
"original_address": original_address,
"original_lat": original_lat,
"original_lon": original_lon,
"snapped_address": snapped_address,
"snapped_lat": snapped_lat,
"snapped_lon": snapped_lon,
"distance_m": distance_m,
"street_differs": street_differs,
"audit_status": audit_status,
"error_message": error_message,
"raw_payload": json.dumps(raw_payload) if raw_payload is not None else None,
},
)
# ---------------------------------------------------------------------------
# Mode dispatcher
# ---------------------------------------------------------------------------
def _resolve_mode(mode: str, api_key: str | None) -> str:
"""Translate `--mode auto` → concrete 'api' / 'playwright' choice.
Explicit modes are passed through unchanged; auto chooses api iff a key
is configured (fail-fast: we don't want a "should have used the API but
silently fell back to slow scraping" surprise).
"""
if mode == "auto":
return "api" if api_key else "playwright"
return mode
# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
async def _run_api_mode(
db: Session,
sample: list[SampleRow],
batch: str,
api_key: str,
) -> int:
"""Geocode the sample using the HTTP Geocoder API."""
processed = 0
last_distance: float | None = None
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
for i, row in enumerate(sample, start=1):
status = "ok"
err: str | None = None
res: YandexReverseResult | None = None
try:
res = await reverse_via_api(row.lat, row.lon, api_key, client=client)
except httpx.HTTPError as e:
status = "error"
err = f"http_error: {e!s}"
except Exception as e: # pragma: no cover — defensive
status = "error"
err = f"unhandled: {e!s}"
distance = None
street_diff: bool | None = None
if res is not None and status == "ok":
if res.address is None:
status = "no_match"
else:
if res.snapped_lat is not None and res.snapped_lon is not None:
distance = _distance_meters(
db, row.lat, row.lon, res.snapped_lat, res.snapped_lon
)
last_distance = distance
street_diff = _street_differs(row.address, res.address)
try:
with db.begin_nested():
_insert_audit_row(
db,
house_id=row.id,
batch=batch,
district=row.district,
original_address=row.address,
original_lat=row.lat,
original_lon=row.lon,
snapped_address=res.address if res else None,
snapped_lat=res.snapped_lat if res else None,
snapped_lon=res.snapped_lon if res else None,
distance_m=distance,
street_differs=street_diff,
audit_status=status,
error_message=err,
raw_payload=res.raw if res else None,
)
# Per-row commit: each row is durable on disk before the next
# Yandex call; --batch resume picks up exactly where we crashed.
db.commit()
processed += 1
except Exception as e:
db.rollback()
logger.warning("insert failed for house_id=%s: %s", row.id, e)
if i % 10 == 0:
logger.info(
"progress %d/%d, mode=api, last_distance=%s",
i,
len(sample),
f"{last_distance:.1f}m" if last_distance is not None else "n/a",
)
return processed
async def _run_playwright_mode(
db: Session,
sample: list[SampleRow],
batch: str,
) -> int:
"""Geocode via a persistent Playwright context (CAPTCHA-aware)."""
try:
from playwright.async_api import async_playwright # type: ignore[import-not-found]
except ImportError as e:
raise RuntimeError(
"Playwright is required for --mode playwright. "
"Install with `uv sync --group dev` and `playwright install chromium`."
) from e
_PLAYWRIGHT_USER_DATA.mkdir(parents=True, exist_ok=True)
processed = 0
last_distance: float | None = None
async with async_playwright() as p:
context = await p.chromium.launch_persistent_context(
user_data_dir=str(_PLAYWRIGHT_USER_DATA),
headless=False,
user_agent=_PLAYWRIGHT_UA,
locale="ru-RU",
timezone_id="Asia/Yekaterinburg",
)
page = await context.new_page()
try:
for i, row in enumerate(sample, start=1):
status = "ok"
err: str | None = None
res: YandexReverseResult | None = None
stop_batch = False
try:
res = await reverse_via_playwright(row.lat, row.lon, page)
except YandexBlockedError as e:
status = "blocked"
err = str(e)
stop_batch = True
except Exception as e:
status = "error"
err = f"playwright: {e!s}"
distance = None
street_diff: bool | None = None
if res is not None and status == "ok":
if res.address is None:
status = "no_match"
else:
if res.snapped_lat is not None and res.snapped_lon is not None:
distance = _distance_meters(
db, row.lat, row.lon, res.snapped_lat, res.snapped_lon
)
last_distance = distance
street_diff = _street_differs(row.address, res.address)
try:
with db.begin_nested():
_insert_audit_row(
db,
house_id=row.id,
batch=batch,
district=row.district,
original_address=row.address,
original_lat=row.lat,
original_lon=row.lon,
snapped_address=res.address if res else None,
snapped_lat=res.snapped_lat if res else None,
snapped_lon=res.snapped_lon if res else None,
distance_m=distance,
street_differs=street_diff,
audit_status=status,
error_message=err,
raw_payload=res.raw if res else None,
)
db.commit()
processed += 1
except Exception as e:
db.rollback()
logger.warning("insert failed for house_id=%s: %s", row.id, e)
if stop_batch:
logger.error(
"Yandex CAPTCHA detected at position %d/%d (house_id=%s). "
"Stopping batch — re-run with same --batch to resume.",
i,
len(sample),
row.id,
)
break
if i % 10 == 0:
logger.info(
"progress %d/%d, mode=playwright, last_distance=%s",
i,
len(sample),
f"{last_distance:.1f}m" if last_distance is not None else "n/a",
)
# Random delay 4-7s between requests — keeps us under Yandex's
# heuristic rate limit while still finishing 200 rows in <30min.
# Skip the wait on the last iteration (no next request to space).
if i < len(sample):
await asyncio.sleep(random.uniform(4.0, 7.0))
finally:
await context.close()
return processed
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""argparse setup, factored out for testability."""
p = argparse.ArgumentParser(
description="Phase 1 audit — houses.address vs Yandex reverse geocode.",
)
p.add_argument(
"--batch",
default=f"{date.today().isoformat()}_run1",
help="Audit batch label. Same batch re-run skips already-processed houses.",
)
p.add_argument(
"--limit-per-district",
type=int,
default=25,
help="Houses to sample per district (default 25 → ~200 total for EKB).",
)
p.add_argument(
"--mode",
choices=("auto", "api", "playwright"),
default="auto",
help="auto = API if YANDEX_GEOCODER_API_KEY set, else playwright.",
)
return p.parse_args(argv)
async def main(argv: list[str] | None = None) -> int:
"""CLI entry point. Returns the number of rows processed this run."""
args = _parse_args(argv)
api_key = os.environ.get("YANDEX_GEOCODER_API_KEY")
mode = _resolve_mode(args.mode, api_key)
if mode == "api" and not api_key:
raise SystemExit("mode=api requested but YANDEX_GEOCODER_API_KEY is not set")
logger.info(
"starting audit batch=%s mode=%s limit_per_district=%d",
args.batch,
mode,
args.limit_per_district,
)
db = SessionLocal()
try:
sample = _load_sample(db, args.limit_per_district)
logger.info("loaded sample: %d houses", len(sample))
# Resume support — drop already-processed house_ids.
done = _already_processed_ids(db, args.batch)
if done:
logger.info(
"resuming batch %s: %d rows already processed, %d remaining",
args.batch,
len(done),
len(sample) - sum(1 for s in sample if s.id in done),
)
remaining = [s for s in sample if s.id not in done]
if not remaining:
logger.info("nothing to do — batch %s is complete", args.batch)
return 0
if mode == "api":
n = await _run_api_mode(db, remaining, args.batch, api_key or "")
else:
n = await _run_playwright_mode(db, remaining, args.batch)
logger.info("done: processed=%d batch=%s mode=%s", n, args.batch, mode)
return n
finally:
db.close()
if __name__ == "__main__": # pragma: no cover
asyncio.run(main())