fix(tradein/deactivate): протухшие объявления с пустым сегментом больше не якорят оценку #2908

Merged
lekss361 merged 2 commits from fix/tradein-deactivate-null-segment into main 2026-08-15 18:22:13 +00:00
Owner

Проблема

Настоящая находка разбора 15.08 — та, ради которой пришлось переписать пункт 2 аудита.

source | segment | активных | старше 30 сут | макс возраст
yandex | (NULL)  |    544   |     533       |   86.3 сут
cian   | (NULL)  |    224   |     211       |   86.3 сут

Протухло 97% и 94%. И эти строки идут в оценку: оценщик отбирает аналоги с условием
listing_segment IS NULL OR listing_segment = 'vtorichka'.

Ни одна джоба деактивации их не трогает. deactivate_stale_yandex и _cian скоуплены на
segments=['vtorichka'], а = ANY(...) по определению никогда не матчит NULL — это
семантика SQL, а не баг. Строка с пустым сегментом не попадает ни под один сценарий снятия
и живёт вечно.

Для контраста: у avito и domklik просроченных ноль — там всё работает.

Почему это бьёт по цене

Слои подбора «тот же дом» и «микрорадиус ≤500 м» фильтруют только по is_active, без
фильтра свежести — в отличие от основных слоёв, где он есть. То есть цена трёхмесячной
давности напрямую якорит сегодняшнюю оценку, и именно там, где вес аналога максимален.

Что сделано

Отдельный режим деактивации с явным предикатом listing_segment IS NULL. Ровно на пустой
сегмент — новостройки не трогаются никогда: их 16 000, оценщик их не берёт, и паушальный
TTL там рискует убить живой инвентарь, который cian и yandex не обходят полностью.

TTL взят консервативный: эти строки по определению никем не переобходятся, поэтому пол
переобхода к ним неприменим.

Замер эффекта

Первый прогон снимет 734 из 768 строк (95.6% протухшего среза). Останется 34 — все свежее
60 суток, они защищены.

Пул сравнимых: cian −2.7%, yandex −10.2%, суммарно −5.7%.

Формально просадка у yandex выше порога, который я сам поставил как сигнал тревоги, поэтому
проговариваю явно: риска для качества оценки здесь нет. Все 734 строки заморожены дольше
60 суток и в основной путь подбора уже не попадали — там фильтр свежести есть. Реальный эффект
правки лежит в слоях без этого фильтра, где это не потеря аналогов, а удаление мусора.

Побочная находка — не чинил

ON CONFLICT DO UPDATE SET в scraper_kit/base.py никогда не обновляет listing_segment,
хотя SERP-парсеры cian и yandex вычисляют его детерминированно при каждом проходе. Поэтому
строка, родившаяся с пустым сегментом, не самочинится даже при живом ежедневном пересборе —
это объясняет хвост из двух десятков записей со свежим last_seen_at и вечным NULL.

Правка транспорта скраперов выходит за рамки этого PR. Зафиксировано в коде, вынесу отдельно.

Коллизия номеров миграций

Эта ветка и #2907 одновременно заняли 264_. Ровно тот класс проблемы, который должен
предотвращать неслитый #2786 — и он случился вживую, между двумя ветками одного дня.
Миграция перенумерована в 266_, номер поправлен во всех местах: имя файла, self-reference
в шапке, манифест, имена девяти тестов и константа в тесте.

Test plan

  • 109 passed после слияния с main (обе функциональности сохранены: bool-guard и потолок из
    #2907, режим пустого сегмента отсюда)
  • эффект замерен запросом к проду до мержа
  • после деплоя, обязательно: джоба впервые сработает по расписанию 07:00–08:00 UTC на
    следующие сутки. Сверить scrape_runs: deactivated должно быть около 734. Если
    значительно больше — немедленно откатывать, это значит, что предикат зацепил лишнее.
## Проблема Настоящая находка разбора 15.08 — та, ради которой пришлось переписать пункт 2 аудита. ``` source | segment | активных | старше 30 сут | макс возраст yandex | (NULL) | 544 | 533 | 86.3 сут cian | (NULL) | 224 | 211 | 86.3 сут ``` Протухло 97% и 94%. И эти строки **идут в оценку**: оценщик отбирает аналоги с условием `listing_segment IS NULL OR listing_segment = 'vtorichka'`. Ни одна джоба деактивации их не трогает. `deactivate_stale_yandex` и `_cian` скоуплены на `segments=['vtorichka']`, а `= ANY(...)` по определению **никогда не матчит NULL** — это семантика SQL, а не баг. Строка с пустым сегментом не попадает ни под один сценарий снятия и живёт вечно. Для контраста: у avito и domklik просроченных **ноль** — там всё работает. ## Почему это бьёт по цене Слои подбора «тот же дом» и «микрорадиус ≤500 м» фильтруют только по `is_active`, **без** фильтра свежести — в отличие от основных слоёв, где он есть. То есть цена трёхмесячной давности напрямую якорит сегодняшнюю оценку, и именно там, где вес аналога максимален. ## Что сделано Отдельный режим деактивации с явным предикатом `listing_segment IS NULL`. Ровно на пустой сегмент — **новостройки не трогаются никогда**: их 16 000, оценщик их не берёт, и паушальный TTL там рискует убить живой инвентарь, который cian и yandex не обходят полностью. TTL взят консервативный: эти строки по определению никем не переобходятся, поэтому пол переобхода к ним неприменим. ## Замер эффекта Первый прогон снимет **734 из 768** строк (95.6% протухшего среза). Останется 34 — все свежее 60 суток, они защищены. Пул сравнимых: cian −2.7%, yandex **−10.2%**, суммарно −5.7%. Формально просадка у yandex выше порога, который я сам поставил как сигнал тревоги, поэтому проговариваю явно: **риска для качества оценки здесь нет.** Все 734 строки заморожены дольше 60 суток и в основной путь подбора уже не попадали — там фильтр свежести есть. Реальный эффект правки лежит в слоях без этого фильтра, где это не потеря аналогов, а удаление мусора. ## Побочная находка — не чинил `ON CONFLICT DO UPDATE SET` в `scraper_kit/base.py` **никогда не обновляет `listing_segment`**, хотя SERP-парсеры cian и yandex вычисляют его детерминированно при каждом проходе. Поэтому строка, родившаяся с пустым сегментом, не самочинится даже при живом ежедневном пересборе — это объясняет хвост из двух десятков записей со свежим `last_seen_at` и вечным NULL. Правка транспорта скраперов выходит за рамки этого PR. Зафиксировано в коде, вынесу отдельно. ## Коллизия номеров миграций Эта ветка и #2907 одновременно заняли `264_`. Ровно тот класс проблемы, который должен предотвращать неслитый **#2786** — и он случился вживую, между двумя ветками одного дня. Миграция перенумерована в `266_`, номер поправлен во всех местах: имя файла, self-reference в шапке, манифест, имена девяти тестов и константа в тесте. ## Test plan - [x] 109 passed после слияния с main (обе функциональности сохранены: bool-guard и потолок из #2907, режим пустого сегмента отсюда) - [x] эффект замерен запросом к проду до мержа - [ ] **после деплоя, обязательно**: джоба впервые сработает по расписанию 07:00–08:00 UTC на следующие сутки. Сверить `scrape_runs`: `deactivated` должно быть около 734. Если значительно больше — немедленно откатывать, это значит, что предикат зацепил лишнее.
lekss361 added 2 commits 2026-08-15 18:17:17 +00:00
544 yandex + 224 cian active rows carry listing_segment=NULL (legacy rows
predating migration 011, plus a small trickle that can never self-heal since
the upsert ON CONFLICT never rewrites listing_segment on re-scrape). 94-97%
of them are frozen at ~86 days old, yet the estimator's Tier A (same-building)
and Tier C (micro-radius) anchor queries filter only is_active=true -- no
freshness column -- so these stale asking prices anchor live valuations.

deactivate_stale_yandex/_cian (migration 115) already run at TTL=30 but scope
segments=['vtorichka'] only: `= ANY(CAST(:segments AS text[]))` never matches
NULL, so the NULL bucket was invisible to both existing jobs and to
avito/domklik/n1 (which are source-blanket or vtorichka-only respectively).

Adds null_segment_only kwarg to deactivate_stale_listings() building an
explicit `listing_segment IS NULL` predicate (confirmations/revisit-floor
builders extended in parallel for correctness, though both gates are kept
off for this slice -- population too small for thresholds calibrated on a
full vtorichka sweep, would permanently skip as unhealthy). Two new
scrape_schedules rows (migration 264) run it per source, untouched
novostroyki/vtorichka jobs unaffected.

TTL=60d (vs 30d for vtorichka): revisit-floor is not computable here (rows
out of dedicated sweep scope have no revisit-gap history), so the margin is
folded into the TTL directly -- 2.26x/1.4x over the measured p99 revisit gaps
already on file for cian/yandex vtorichka (26.6d/43.0d). Bimodal age
distribution means this costs almost nothing in coverage (30d vs 60d: 744 vs
734 deactivated).

First run: 734 of 768 NULL rows deactivated (211 cian, 523 yandex), 34 remain
(fresher than 60d, still incidentally re-touched). Comparable pool
(is_active AND segment IN (NULL, vtorichka)) after: cian -2.7% (7739->7528),
yandex -10.2% (5133->4610) -- yandex crosses the 10% flag threshold. All 734
removed rows already had scraped_at frozen >60d, i.e. already excluded from
Tier S/H (which do filter freshness, 14-60d window) -- the drop is real for
is_active headcount but zero-impact there; it only prunes Tier A/C, where it
removes stale prices rather than live comps.

Separate finding (not fixed here): base.py's ON CONFLICT DO UPDATE omits
listing_segment from SET entirely, so a legacy NULL row can never heal even
though cian/yandex SERP always compute segment deterministically on re-scrape.
fix(tradein/deactivate): resolve migration 264 renumber collision on merge with main
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 4m27s
e00dcac177
fix/tradein-ttl-effective-cap (PR #2907, merged into main while this branch was in
flight) already claimed 264/265 for deactivate_stale_avito_cap_mult /
deactivate_stale_yandex_cap_mult. Renumbers this branch's
264_seed_deactivate_stale_null_segment_yandex_cian.sql -> 266_seed_... (git mv +
_manifest_applied.txt entry moved after 264/265 + self-references in the migration
header and in test_deactivate_stale_listings.py's _MIGRATION_264 constant/test names).

Merges main's bool-guard (ttl_days/cap_mult reject bool) + CAP_MULT ceiling +
per-source cap_mult calibration with this branch's null_segment_only kwarg
(explicit `listing_segment IS NULL` predicate, since ANY(:segments) never matches
NULL) -- both features apply to the same deactivate_stale_listings() call site in
product_handlers.py and the same function signature/docstring in
deactivate_stale_avito.py, so every conflict was signature/docstring-level, not
logic-level (git already auto-merged the function body correctly since the two
features touch disjoint lines below the signature). Also reconciled the module
docstring's "known gap" note (main) to reflect that the NULL-segment slice it
measured (cian 211 / yandex 523 rows >60d) is now closed by this migration --
novostroyki stays open, unrelated to this branch.
lekss361 merged commit 8ab720d826 into main 2026-08-15 18:22:13 +00:00
lekss361 deleted branch fix/tradein-deactivate-null-segment 2026-08-15 18:22:14 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2908
No description provided.