gendesign/tradein-mvp/backend/scripts/README.md
Light1YT 1d8cfb967f
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 45s
Deploy Trade-In / deploy (push) Successful in 38s
feat(tradein): backfill 18k existing listings (PR J) (#596)
2026-05-27 10:29:40 +00:00

227 lines
9 KiB
Markdown
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.

# tradein-mvp/backend/scripts/
Ops scripts that touch the production database directly. Run via `python -m
scripts.<name>` from the `backend/` working directory after `uv sync`.
All scripts are idempotent / resumable where they write — re-running the same
`--batch` label skips already-processed rows (UNIQUE constraints in target
tables). Failures inside a per-row loop never roll back the outer transaction;
each row is wrapped in a SAVEPOINT (`db.begin_nested()`) per `.claude/rules/backend.md`.
---
## Production usage (canonical)
Scripts ship inside the `tradein-backend` image (PR F — `COPY scripts ./scripts`
в `backend/Dockerfile`). На VPS они уже в `/app/scripts/` — никаких manual
`docker cp` не нужно.
`YANDEX_GEOCODER_API_KEY` подтягивается из `/opt/gendesign/tradein-mvp/backend/
.env.runtime` через `env_file:` в `docker-compose.prod.yml` — никакого `-e` в
`docker exec` не нужно.
```bash
# Backfill (forward geocode 4170 houses без coords)
ssh gendesign 'docker exec tradein-backend python -m scripts.backfill_house_coords --batch 2026-05-27_backfill'
# Audit-only (reverse geocode проверка для уже geocoded houses)
ssh gendesign 'docker exec tradein-backend python -m scripts.backfill_house_coords --audit-only --batch 2026-05-27_audit'
# Canary first
ssh gendesign 'docker exec tradein-backend python -m scripts.backfill_house_coords --limit 100 --batch canary_$(date +%F)'
```
После изменения `backend/.env.runtime` нужен `--force-recreate` контейнера
(см. `.claude/rules/deploy.md`):
```bash
ssh gendesign 'cd /opt/gendesign/tradein-mvp && docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --force-recreate --no-deps backend'
```
---
## Address audit + backfill (issue #582)
End-to-end address quality pipeline. Three scripts, two helpers, two SQL files.
> Локальные примеры ниже — для dev-машины с `uv run` и переменными в shell.
> На prod используй canonical `docker exec` команды из секции выше — там
> `YANDEX_GEOCODER_API_KEY` уже подгружен из `backend/.env.runtime`.
### `audit_address_mismatch.py` — Phase 1 baseline (PR #583)
Stratified-sample audit (200 EKB houses) comparing `houses.address` vs
Yandex Geocoder reverse lookup. Writes one row per house into
`address_mismatch_audit` with the snapped point + canonical address + distance.
```bash
DATABASE_URL=postgresql+psycopg://... \
YANDEX_GEOCODER_API_KEY=... \
uv run python -m scripts.audit_address_mismatch \
--batch 2026-05-25_run1 \
--limit-per-district 25
```
Mode `auto` picks API if the key is set, otherwise Playwright (CAPTCHA-aware,
4-7s sleep between calls). API tier free is 25k req/day → 200-row sample
takes ~10s with no quota concern.
Report:
```bash
psql "$DATABASE_URL" -v batch='2026-05-25_run1' \
-f scripts/address_audit_report.sql
```
### `backfill_house_coords.py` — Phase 2-3 (PR for #582)
Two modes (`--audit-only` flag switches between them):
**Backfill (default)** — forward-geocode `houses.address` for the ~4141 rows
WHERE `lat IS NULL OR lon IS NULL`. Only writes back if Yandex returns
`precision='exact'` or `'number'` (skips street-only / locality matches).
Each processed row gets an `address_mismatch_audit` entry with status
`backfill` / `imprecise` / `no_match` / `error`.
```bash
DATABASE_URL=postgresql+psycopg://... \
YANDEX_GEOCODER_API_KEY=... \
uv run python -m scripts.backfill_house_coords \
--batch 2026-05-27_backfill
```
Expected duration (~4141 rows, 50ms between calls, ~250ms RTT per request):
20-25 min. Expected output split (rough baseline from Phase 1 numbers):
| Status | Approx rows | What it means |
|-------------|-------------|-----------------------------------------------------|
| `backfill` | ~3.3k3.7k | UPDATE landed, lat/lon now populated |
| `imprecise` | ~300500 | Match returned but precision too low — needs review |
| `no_match` | ~100300 | Yandex couldn't resolve; address probably mangled |
| `error` | <50 | HTTP errors / timeouts re-run picks them up |
**Audit-only** reverse-geocode the ~4452 houses WITH coords, write
audit rows with status `ok` (≤50m) / `mismatch` (>50m) / `no_match` / `error`.
Does NOT modify the `houses` table.
```bash
uv run python -m scripts.backfill_house_coords \
--batch 2026-05-27_audit --audit-only
```
Combined budget for both phases (~8.6k requests) is well under the 25k/day
Geocoder free tier.
### Common ops
Canary first — run with `--limit 100` and inspect the audit table before
letting the full job loose:
```bash
uv run python -m scripts.backfill_house_coords \
--batch canary_$(date +%F) --limit 100
psql "$DATABASE_URL" -c "
SELECT audit_status, COUNT(*)
FROM address_mismatch_audit
WHERE audit_batch = 'canary_$(date +%F)'
GROUP BY audit_status;
"
```
Resume after crash / quota hit — same `--batch` label, the UNIQUE
`(house_id, audit_batch)` index skips finished rows:
```bash
uv run python -m scripts.backfill_house_coords --batch 2026-05-27_backfill
# ... interruption ...
uv run python -m scripts.backfill_house_coords --batch 2026-05-27_backfill
# logs: "resuming batch 2026-05-27_backfill: N rows already processed"
```
### Helpers (not entry points)
- `_yandex_reverse.py``forward_via_api()`, `reverse_via_api()`,
`reverse_via_playwright()`, `YandexReverseResult` dataclass. Both API
paths share `_parse_api_payload` because Yandex's forward/reverse
envelopes have the same shape.
- `audit_address_sample.sql` — random sample for the Phase 1 audit (used
by `audit_address_mismatch.py`).
- `address_audit_report.sql` — psql-driven post-run summary (p50/p75/p95
distance, top-20 outliers, per-district breakdown).
---
## Matching backfill (PR J)
### `backfill_listing_sources.py` — retroactive matching for ~18k listings
PR I (commit 7e24ccb) hooked the matching service into `save_listings()` so
every **new** scrape now writes a `listing_sources` row + resolves a canonical
`houses` row. This script does the same work retroactively for all
**existing** listings — `listing_sources` only had rows from new scrapes
post-PR I.
What it does per row:
1. `match_or_create_house()` (Tier 0-3) — uses `listings.house_source` /
`house_ext_id` when present (Avito Houses Catalog, Cian newbuilding),
else falls back to address/lat/lon/cadastrals.
2. `upsert_listing_source()` with `method='backfill'`, `confidence=0.9`
(vs real-time `source_link` 1.0 — distinguishes the two in audits).
3. `UPDATE listings.house_id_fk` when the row didn't already have one.
```bash
# Canary
DATABASE_URL=postgresql+psycopg://... \
uv run python -m scripts.backfill_listing_sources \
--limit 100 --dry-run
# Real run, one source at a time (staged rollout)
uv run python -m scripts.backfill_listing_sources --source avito
# Full run
uv run python -m scripts.backfill_listing_sources --batch-size 500
```
**Idempotent / resumable** — the source query is
`WHERE NOT EXISTS (SELECT 1 FROM listing_sources ls WHERE ls.ext_source =
listings.source AND ls.ext_id = COALESCE(listings.source_id,
listings.dedup_hash))`. Re-runs only pick up rows still missing from
`listing_sources`. `upsert_listing_source` adds a second layer of safety via
`ON CONFLICT (ext_source, ext_id) DO UPDATE`.
**No network calls** — pure in-DB matching (Yandex Geocoder is blocked on
prod, and `match_or_create_house` does not call it anyway).
**Per-row SAVEPOINT** (`db.begin_nested()`) per `.claude/rules/backend.md`
one bad row never aborts the surrounding batch.
**Expected output (PR J initial run):**
| Source | Rows | Expected matched | Notes |
|--------|-------|-------------------|------------------------------------------------|
| avito | 9302 | 9000+ | Many carry `house_source`/`house_ext_id` |
| cian | 5158 | 5000+ | Most carry `house_source`/`house_ext_id` |
| yandex | 3704 | 3700+ | No `source_id` → uses `dedup_hash` as `ext_id` |
| n1 | 264 | 264 | All have address/coords |
**Expected duration**: rough estimate ~5-15 minutes on prod for ~18k rows
(advisory-lock + 1-3 DB roundtrips per listing for Tier 0-3, ~500 commit
checkpoints at default batch size). Run with `--limit 100` first to
calibrate, then let the full job loose.
Final summary in the log includes per-source coverage % so you can verify
the run landed:
```
backfill done (dry_run=False): processed=18428 matched=18428
house_resolved=18200 house_failed=228 skipped=0 errors=0
avito processed=9302 matched=9302 house_resolved=9290 ...
cian processed=5158 matched=5158 house_resolved=5100 ...
yandex processed=3704 matched=3704 house_resolved=3540 ...
n1 processed=264 matched=264 house_resolved=270 ...
final listing_sources coverage:
avito 9302 / 9302 (100.0%)
cian 5158 / 5158 (100.0%)
...
```