Флаг anchor_tier оставался равным anchor_tier_fetched ("A"/"C"), когда якорь
фактически НЕ строился — сброс делал только low-conf гейт (#audit-1), но не
Tier C corridor-гейт (#1795) и не сама _compute_same_building_anchor, когда
она отклоняет кандидата (комплов меньше estimate_sb_min_comps). Дальше по коду
залипший флаг читается как «headline построил якорь» и молча глушит IMV/Yandex
blend (#651, гейт `anchor_tier is None`) и quarter-index correction (#764
Guard-1a) — притом что радиусный headline их не получал.
Замер: 154 из 996 сделок теряют tier-флаг этой правкой, и у всех 154 изменение
цены ровно 0.000% — чинится именно залипший ФЛАГ, не ценообразование (баланс
метрик бэктеста подтверждает: единственная дельта в baseline — новая канарейка
unrecorded_lookup_calls, все остальные метрики побитово те же).
- estimator.py: сброс `anchor_tier = None` единой веткой `if anchor is None`
после всех трёх гейтов (Tier C / low-conf / _compute_same_building_anchor);
display-only IMV-карточка больше не гейтится по `anchor_tier is not None`
(иначе терялась в щели «тир добыт, якорь не построен, headline подавлен»).
- backtest_estimator.py: quarter_index_lookup/quarter_indexes_lookup в реплее
отвечают «промах» (None/{}), если сброс флага открыл путь, которого не было
в замороженной фикстуре, вместо падения с RuntimeError; счётчик таких промахов
уходит в baseline как unrecorded_lookup_calls (точное целое, канарейка на
расхождение реплея с захватом). Заодно пиннится estimate_dedup_analogs_enabled
= False внутри replay_fixture (было только в самом гейте) — иначе штатная
регенерация baseline (--from-fixture --update-baseline) писала baseline,
который тест не совпадал бы никогда.
- backtest_baseline.json: перегенерирован штатным путём, unrecorded_lookup_calls=0.
Выделено из #2656/PR #2661 — фильтры свежести (scraped_at) в якоре дома и в
знаменателе коэффициента выкупа остаются в исходном PR как отдельная, более
спорная правка (двигает деньги: знаменатель просаживается на ~1.3% по бакетам).
Ревью PR #2688 нашло, что расширение ключа дедупа было неверным. Снимаю его
полностью и добавляю три правки, которых не хватало.
СНЯТО: слияние 781 дома по COALESCE(house_fias_id, gar_house_guid).
Аргумент «общий UUID здания есть независимая идентичность» оказался круговым.
gar_flats_loader проставляет gar_house_guid предикатом
WHERE tradein_canon_addr(COALESCE(h.short_address, h.full_address, h.address)) = gp.canon
— левая часть побайтово равна ключу канон-прохода, то есть guid является
детерминированной функцией канон-адреса, а не вторым наблюдением. Проход шёл
с выключенным гео-стражем, значит #2187 обходился боковой дверью: канон-проход
отказывается слить два дома в 6 км, а этот сливал их же за «общий UUID»,
выданный за тот же адрес. Плюс gar_pick берёт DISTINCT ON (canon) — одна
ГАР-строка на канон, а 20.3% канонов накрывают несколько зданий, и ЕКБ-фильтр
стоит только на стороне ГАР. Кедровка/Советская 17 уехала бы в ЕКБ. Нужен
ключ, независимый от канона, либо включённый гео-страж — это другая задача.
Приёмник кадастра сужен до кадастра ЗДАНИЯ. Параметр cadastral_number (кадастр
КВАРТИРЫ) убран из match_or_create_house, Protocol HouseMatcher,
RealMatcherAdapter и обоих вызывающих; `cad` больше не падает на него фолбэком.
Мина была отложенной: начни Циан отдавать offer["cadastralNumber"], который
парсер уже читает, — у каждой квартиры свой номер, Tier 0 не сматчил бы
никогда, New-house INSERT записал бы номер квартиры в houses.cadastral_number
и попутно снял P1-страж «безномерный адрес без кадастра не создаём». Две
квартиры одного дома дали бы два дома — то самое дробление. В listings оба
поля пишутся как раньше.
Keeper: listing_cnt DESC NULLS LAST. Счётчик приходит из LEFT JOIN, у дома без
объявлений он NULL, а DESC в Postgres — NULLS FIRST, поэтому пустая запись
обгоняла запись со 192 объявлениями вопреки задокументированному правилу.
Дефект предсуществующий и живой для канон-прохода.
Сторож границы вызова для живого ФИАС-тира. Прежние проверки были
структурными — видели имя параметра в сигнатуре. Уберут аргумент на настоящей
границе (estimator.estimate_quality -> match_house_readonly) — сигнатура цела,
тесты зелёные, тир снова мёртв. Новый тест смотрит на сам вызов. Заявление
«тест ловит неуловимый класс» из прошлого описания снято как преувеличение:
структурная проверка ловит подслучай, и building_cadastral_number её проходит
при нуле срабатываний из 49 502.
Остаётся из первого захода: снятый фильтр поиска has_kadastr, разделение
ФИАС-тира (удалён в пути создания, оставлен в read-only), поправка ложного
утверждения в шапке cadastral_geo_match.py.
Refs #2674
Первый коммит починил только scripts/geocode_deals_nominatim.py — ручной скрипт.
Тот же дефект оставался на живом пути: POST /admin/geocode-missing?target=deals
отдавал сырой row["city"] в city_hint, а deals.city росреестровое и в хвосте
распределения содержит не-города («Бессонова», «Билейский рыбопитомник»). Любой
не-ЕКБ хинт жёстко закрывает EKB-локальные тиры и уезжает префиксом в запрос
провайдеру, то есть мусорный хинт хуже отсутствия хинта.
Гейт вынесен в geocoder.known_city_hint (сверка с SVERDLOVSK_OBLAST_CITIES —
тем же набором, который уже питает _names_non_ekb_city / _ekb_local_tiers_allowed)
и переиспользуется всеми тремя потребителями city_hint: скриптом, admin-ручкой и
задачей geocode_missing. Копий функции нет — четвёртый потребитель, если появится,
получит гейт сам.
Тесты: мусорный город -> хинт не передаётся, валидный -> передаётся; проверено
фальсификацией (без фикса все три новых теста краснеют).
Хвосты после #2601 (замыкание петли «город → геокодер»).
1. scripts/geocode_deals_nominatim.py — непрошитый sibling-caller.
Скрипт группировал `GROUP BY address` и звал `geocode(address, db)` без
города, хотя deals.city (миграция 177) заполнена на 100%: один и тот же
текст адреса из разных городов схлопывался в одну группу, один geocode-вызов
и один UPDATE по тексту адреса. Теперь — та же форма, что в #2601:
группировка по паре (address, city), city_hint в geocode(), UPDATE и
mark-tried через `city IS NOT DISTINCT FROM` (обычное `=` не ловит NULL-город
→ NULL-группа не обновлялась бы вовсе).
Хинт передаётся ТОЛЬКО для значений из geocoder.SVERDLOVSK_OBLAST_CITIES:
deals.city росреестровое, в хвосте лежит мусор («Бессонова», «Бердюгина»,
«Билейский рыбопитомник»), а любой не-ЕКБ хинт жёстко закрывает EKB-локальные
тиры и подставляется в запрос провайдеру — мусорный хинт хуже отсутствия
хинта. Словарь переиспользован, а не заведён свой: тот же набор уже питает
гейты самого геокодера (_names_non_ekb_city / _ekb_local_tiers_allowed) и
estimator._resolve_target_city.
2. tasks/backfill_listings_coords_geoportal.py — наблюдаемость городского гейта.
Добавлен skipped_non_ekb_by_column (+ в to_counters и в DONE-логи): колоночный
гейт стоит перед парсером адреса, поэтому по мере раскатки областных
развёрток (#2598) строки потекут из no_address в skipped_non_ekb и общий
счётчик поменяет смысл ровно тогда, когда по нему валидируют раскатку.
Старый счётчик не тронут — остаётся суммой обоих гейтов, вклад текстового
считается разностью.
3. tests: test_admin_geocode_missing_passes_city_hint параметризован на
target="deals" (колонка city есть в обеих таблицах, ветка была не покрыта).
4. tasks/geocode_missing.py: dry-run лог печатает city — он с #2594 часть ключа
группы, без него две строки dry-run неотличимы.
Refs #2603
Group A of the scraper_kit migration epic (#2277). Switches admin.py's manual
"run parser" debug endpoints and scripts/ingest_domclick_jsonl.py from direct
app.services.scrapers.* imports to their scraper_kit.providers.* equivalents,
using the DI adapters (RealScraperConfig/RealMatcherAdapter/RealProxyProvider,
app.services.scraper_settings.get_scraper_delay) already established by
app.scheduler_main._run_kit_scheduler.
Migrated: /scrape (AvitoScraper/CianScraper/YandexRealtyScraper + save_listings),
scrape_avito_house, scrape_avito_detail, scrape_avito_imv, scrape_yandex_detail,
scrape_yandex_valuation, scrape_cian_detail, cian_auto_login's BrowserFetcher.
scripts/ingest_domclick_jsonl.py: ScrapedLot/save_listings + (now that #2307/
Group D ported a kit equivalent while this was in flight) DomClickDetailEnrichment/
save_detail_enrichment.
Deliberately NOT migrated (documented in admin.py): scrape_yandex_newbuilding /
scrape_cian_newbuilding — their kit equivalents
(providers/{yandex,cian}/newbuilding.py) construct an internal BrowserFetcher(...)
without the mandatory `endpoint` kwarg, so any call crashes/silently-fails
regardless of caller-side DI. Bug lives in scraper_kit provider code, out of
scope here (only consuming, not touching provider logic) — flagged as follow-up.
Parity proven via tests/support/parity.py (assert_parity) against the exact
functions each debug route now calls, on offline fixtures — no live network/DB.
Refs #2305
ORDER BY has_active_listings DESC, id во всех priority-бакетах + новый
--priority listings (focused sweep: NULL cadnum + есть active listing).
Дневной 100/day budget сначала закрывает дома, которые реально появляются
в оценках → estimator #6 cadastr_exact same-building матчинг загорается на
real estimate targets за дни, а не недели.
Correlated EXISTS бьёт по partial-индексу listings_house_id_fk_idx.
Write-path / EKB-guard / SAVEPOINT / qc_geo rules не тронуты.
code-reviewer: ✅ APPROVE no critical. Tests: 40 passed. Ruff clean.
Добавляет scripts/geocode_deals_nominatim.py для ~14% сделок всё ещё
lat IS NULL после house-centroid прохода (Step 2) — улицы без геокодированных
домов, которые centroid-join не покрывает. Переиспользует geocoder.geocode
(cache → Cadastral FDW → Yandex → Nominatim), дедупит по адресу (distinct
улицы определяют число вызовов, не 7k строк), и стампит geocode_tried_at на
КАЖДОЙ попытке (успех И чистый промах) чтобы un-geocodable строки не крутились
по 1 req/s. Resume: lat IS NULL + 30-day staleness, per-address SAVEPOINT,
--limit/--dry-run/--batch/--stale-days.
Pre-push review (code-reviewer): ✅ APPROVE, no critical — проверены SQL-safety,
no-infinite-reloop (mark-tried на miss, no-stamp на raised exc), window-cap,
geom-not-manual, dedup, async. Tests: 17 passed. Ruff clean.
Dry-run на проде показал, что DaData без явного города мапит короткие
street-адреса в Москву по умолчанию («улица Просторная» → «г Москва, ул
Просторная»). Это бы привело к catastrophic false-positive enrichment'у
если бы скрипт запустили без --dry-run.
Two-layer fix:
1. `_with_city_context(addr)` — pre-pends «Екатеринбург, » если в адресе
ещё нет упоминания города. Идемпотентен, case-insensitive.
2. `_result_in_ekb(result)` — post-filter. Проверяет `raw.city`
(структурное поле DaData) с fallback на substring search в
`canonical_address`. Защищает даже если DaData проигнорирует city-префикс
и всё равно вернёт другой город (например для homonym streets).
`_is_enriched_result()` теперь требует обе проверки: qc_geo IN (0,1)
AND coords AND city == Екатеринбург. Только так делаем UPDATE coords/cadnum.
Без этого fix'а нельзя запускать backfill — все non-EKB false-positives
перезаписали бы NULL coords мусором.
9 новых тестов: city context (prepend / idempotent / case / empty),
post-filter (raw.city / canonical / Moscow reject / None).
Combined 29 passed.