9 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1a577fe748 |
fix(tradein/matching): снять слияние по ГАР-GUID, починить приёмник кадастра и keeper (#2674)
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 / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m54s
Ревью 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 |
||
|
|
3fd6550a16 |
fix(tradein/matching): честность тиров сопоставления домов (#2674)
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
Три находки эпика #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 |
||
|
|
85059aeb1b |
refactor(tradein/scheduler): удалить legacy scheduler_loop + scraper-scheduling, kit единственный путь (#2397 Part C)
Топология подтверждена перед удалением (docker-compose.prod.yml): tradein-backend (uvicorn app.main:app) — SCHEDULER_ENABLE=false; tradein-scraper (python -m app.scheduler_main) — SCHEDULER_ENABLE=true + USE_KIT_SCHEDULER=true. Kit-путь (_run_kit_scheduler → scraper_kit.orchestration.scheduler + product_handlers) самодостаточен: не импортирует ничего из app.services.scheduler.scheduler_loop или app.services.scrape_pipeline. Все НЕ-sweep джобы, которые kit-scheduler диспетчерит через build_product_handlers, идут напрямую в app.tasks.*/ app.services.* (либо lazy-импортят import_rosreestr_dkp/_execute_cian_backfill из scheduler.py) — мимо удаляемой legacy-машинерии. app/services/scheduler.py: 2098 → 418 строк. Удалено: scheduler_loop, get_due_schedules, reap_zombies, _claim_run, _defer_next_run_at, _spawn_tracked/ _drain_inflight/_inflight_tasks, все 27 trigger_*_run-функций, импорт app.services.scrape_pipeline, константы SCHEDULER_TICK_SEC/ZOMBIE_THRESHOLD_HOURS (достижимы были только через удалённый scheduler_loop-путь). Оставлено (живые импортёры вне удалённого): compute_next_run_at + has_running_run (admin.py), import_rosreestr_dkp + _execute_cian_backfill (lazy-импорты в product_handlers.py — job-тела kit-handler'ов). main.py: убран `from app.services.scheduler import scheduler_loop` + lifespan-блок запуска (`if settings.scheduler_enable: asyncio.create_task(scheduler_loop())`); прод-backend всегда шёл с SCHEDULER_ENABLE=false, так что это был мёртвый код. scheduler_main.py: убрана ship-dark развилка #2192 (USE_KIT_SCHEDULER=false → legacy scheduler_loop fallback) — _run_kit_scheduler() теперь безусловный путь. Поле settings.use_kit_scheduler оставлено в конфиге (Settings extra="ignore" защищает от startup-краха на leftover env var), но на ветвление не влияет. app.services.scrape_pipeline: 0 runtime-импортёров в app/+scripts/+packages/ после этого PR (только тесты, которые Part E удалит вместе с самим файлом) — подтверждено grep. scrape_pipeline.py не тронут (Part E). Тесты: удалены test_house_imv_backfill_scheduler.py (100% legacy-триггер, backfill_house_imv сервис покрыт в test_house_imv_backfill_browser_flag.py / test_backfill_wave2.py) и test_kit_registry_completeness.py (parity-инвариант против удалённого dispatch, дублирует test_scraper_kit_scheduler_parity.py). Точечно вырезаны "Scheduler wiring" секции (trigger_fn_exists/dispatch_branch_ wired/runs_in_executor) из ~10 файлов, тестирующих сами task-функции — сами task-тесты (SQL-shape, миграции, fake-db поведение) оставлены нетронутыми. test_scheduler.py: 825 → ~90 строк (остались только compute_next_run_at-тесты). test_scraper_kit_scheduler_parity.py: убрана golden-parity секция против удалённого scheduler_loop (SOURCE_TO_OLD_TRIGGER/_drive_old_one_tick/ test_routing_parity_per_source), остальное (claim/reap_zombies/dispatch/ registry-shape тесты kit-модуля) сохранено — источник этих инвариантов не app.services.scheduler, а сам scraper_kit.orchestration.scheduler. test_scheduler_main.py: 2 теста, патчившие app.services.scheduler.scheduler_loop, переведены на монкипатч sm._run_kit_scheduler (единственный путь после этого PR). test_sweep_imv_phase.py:171-371 (6 прямых импортов run_avito_city_sweep из scrape_pipeline) намеренно НЕ тронуты — Part E. Verify: полный pytest 3179 passed / 6 skipped / 1 known-unrelated fail (test_search_cache_hit, #2208, не связан с этим PR); ruff 0.7.4 чист на всех изменённых файлах; `python -c "import app.main; import app.scheduler_main"` OK. |
||
|
|
03ece36abd | fix(tradein/dedup): drop geo-guard in fias-pass — same-fias identity outranks proximity (#2187) | ||
|
|
c4b1992de5 | fix(tradein/dedup): fias-key clustering + cross-fias merge guard + identity carry-over (#1772 follow-up) | ||
| 078dadc440 |
fix(tradein): cadastre ЖКХ-fallback + COALESCE merge-key + median-floors guard
Серия sale-share data-quality (по находкам юзера): - house_dedup_merge: cluster_key → tradein_canon_addr(COALESCE(short_address, full_address, address)) — ловит дубли с район-noise в address (Вайнера 66). - zhkh_flats_loader: cadastre-fallback (searchByAddress по cadastreNumber, guid-независимо) — резолвит дома где gar_house_guid ≠ ЖКХ houseGuid (Переходный 6: guid→0, cadastre→165). CLI --cadastre. - мигр.153: плаузибилити-гард по медиане этажности листингов — denom >= GREATEST(total_floors, median(listings.total_floors), 8); отсекает gar-недосчёт башен без ЖКХ (gar=9 vs листинги 19-26 эт → NULL). ruff clean, pytest 36 green. |
|||
| 7cf6267b48 |
fix(tradein/dedup): canon cluster-key + geo/digit guard в house-merge
merge_duplicate_houses кластеризовал дома по exact lower(trim(address)) → варианты написания одного здания (ул. Вайнера,66 vs улица Вайнера, 66) не схлопывались, расщепляя долю в sale-share. Ключ кластера → tradein_canon_addr (ул→улица, стрип город/район, КОРПУС сохранён). Гарды против over-merge: - гео ≤250м (canon стрипует город; прод-дубли Мраморская 34к4 в 222м; города региона-66 в км+ → 250м безопасно от cross-town); - canon обязан содержать цифру (номер дома) — исключает вырожденные «екатеринбург»/«сооружение». Re-pointing/audit/dry-run/идемпотентность не тронуты. 26 passed. |
|||
| 566b2f9617 |
fix(tradein/scraper): drain detached run-children on SIGTERM, not just coordinator (#1182 Phase 2)
Pre-push review: _await_scheduler ждал только scheduler_loop COORDINATOR, но вся scrape-работа крутится в detached asyncio.create_task детях (каждый trigger_* делал `task = create_task(_run())` без join). На SIGTERM coordinator выходил из while True и завершался → asyncio.run() teardown хард-кансельил ещё бегущих детей mid-await = ровно #1182 failure mode. Кооперативный checkpoint спасал ребёнка лишь когда его residual-время случайно перекрывало drain — вероятностно, не гарантированно. Fix — coordinator теперь дренажит детей перед выходом: - _spawn_tracked(coro): централизованный detached-spawn, кладёт задачу в module-level registry _inflight_tasks (strong-ref = RUF006 keep-alive) + done-callback ретривит exception и убирает из set'а. Заменил 26 одинаковых `task = create_task(_run()); task.add_done_callback(...)` сайтов. - _drain_inflight(): один asyncio.wait по живым детям с бюджетом _CHILD_DRAIN_TIMEOUT_S=80s (< scheduler_main 100s < docker grace 120s). Кооперативные дети (avito_detail_backfill, rosreestr-executor) дочекивают карточку/батч + mark_done и резолвятся; некооперативные упираются в timeout и падают на внешний hard-cancel. - scheduler_loop по выходу из tick-loop (только по SIGTERM) зовёт await _drain_inflight(). NB: raw asyncio.all_tasks()-minus-self здесь НЕЛЬЗЯ — в нашей топологии он захватывает _run parent-task (блокирован на wait_for(coordinator)) и shutdown_waiter → циклическое ожидание coordinator↔_run, всегда упирающееся в timeout. Точный registry это исключает. Tests: tests/test_scheduler.py — detached cooperative child drained-not-cancelled, non-cooperative child timeout→left for hard-cancel, no-op без детей, scheduler_loop→drain wiring. Обновил 3 source-inspection теста под новый _spawn_tracked паттерн. |
|||
| 63ac12dc23 |
feat(tradein): recurring house-dedup merge (schedule dormant) (#1772) (#1933)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m23s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 51s
|