fix(tradein): сегментный гард в «медианном торге», свежесть в индексе локации, честные админ-счётчики (#2660) #2664

Merged
bot-backend merged 2 commits from fix/2660-display-freshness-segment into main 2026-08-05 18:16:58 +00:00
Collaborator

Пользовательская половина разбора #2574: витрины читают listings без сегмента и без свежести, поэтому показывают числа, посчитанные не по тому пулу. Денежная половина — #2661, здесь её не трогаю.

Все цифры ниже — живые SELECT'ы к прод-БД tradein 2026-08-05 (только чтение).

1. «Медианный торг» больше не считается с участием первички

Было. CTE window_listings в street_sales_vs_listings() (м.205) читал listings без сегментного гарда #1186 — к ДКП-сделке вторички могло подставиться объявление застройщика. Девелоперский прайс фиксирован и не торгуется, но именно он формировал показываемый пользователю процент.

Стало. Миграция 211_sales_vs_listings_segment_guard.sqlCREATE OR REPLACE того же тела + канонический предикат (l.listing_segment IS NULL OR l.listing_segment = 'vtorichka').

Замер (прод):

популяция кандидатов на пейринг (окно 30 мес, price_rub > 0) 93 241
из них novostroyki 25 428 (27.3%)
vtorichka / NULL (legacy до м.011, остаётся в пуле) 66 256 / 1 557

Симуляция на 20 самых «густых» парах (улица, комнаты) по ЕКБ, 2 120 сделок:

было стало
сделок с listing-match 1 219 994
из них матч против новостройки 299 (24.5%) 0
медианный торг −18.18% −17.11%

Торг перестал «утяжеляться» на 1.07 п.п. за счёт первички.

Поштучный сдвиг кратно больше сводного — и доходит до смены знака

Сводные +1.07 п.п. не видит никто: эндпоинт вызывается на конкретную улицу. По группам (улица, комнаты) разброс до 20+ п.п.:

группа пар было пар стало торг было торг стало
%Космонавтов% 2-комн. 72 41 −11.9% +36.4% (смена знака)
%Академика Ландау% 1-комн. 75 31 −36.1% −14.1%
%Блюхера% 2-комн. 78 78 −16.8% −5.4%
%Краснолесья% 1-комн. 179 128 −21.7% −28.6%
%Академика Ландау% 4-комн. 12 0 +15.9% — (пар не осталось)

Обратите внимание на %Блюхера%: число пар не изменилось, а медиана уехала на 11.4 п.п. Это подстановка партнёраDISTINCT ON выбирает не ближайшую по дате новостройку, а следующее по близости вторичное объявление. Разложение всех 2 120 сделок: 225 потеряли матч с новостройкой (цель PR), 74 получили подстановку вторички (в среднем безобидно), 920 не изменились.

Направление верное — но на отдельной улице пользователь увидит не «стало на процент точнее», а другое число, иногда другого знака.

linkage_rate_pct просядет: 57.5% → 46.9%

Это видимое пользователю «N% сделок имеют историческую цену в объявлении» (1 219 → 994 матча из 2 120 сделок). Число стало честнее — ушли фальшивые пары «вторичка ↔ новостройка», — но поддержка прочитает падение на 10.6 п.п. как регресс покрытия. Ожидаемо и правильно.

Что осознанно НЕ меняется: is_active в этой функции по-прежнему не фильтруется. Функция намеренно смотрит и снятые объявления — объявление снимают ПОСЛЕ продажи, активные для пейринга бесполезны. Свежесть здесь тоже не при чём: пейринг привязан к дате СДЕЛКИ (window_days ± grace), а не к «сейчас». Оба «отсутствия» закреплены тестами, чтобы следующий заход не «дочинил» их по аналогии.

Про грабли #2627. Сигнатура не менялась — те же 7 аргументов, что после м.205, значит CREATE OR REPLACE — замена in-place, а не вторая перегрузка. Проверяется тестом test_migration_211_signature_identical_to_205_no_new_overload: он посимвольно сравнивает блок сигнатуры 211 с 205 (фальсификация: добавил 8-й параметр — тест покраснел). DROP FUNCTION намеренно нет: 205 уже дропнула старую 6-арг сигнатуру, а дроп текущей дал бы окно, в котором caller получает function does not exist.

2. Индекс локации считается по тому же пулу, что и цена

Было. Оба запроса медианы в location_index.py фильтровали is_active + geo_precision, но не свежесть и не сегмент. is_active на проде не означает «живо»: деактиватор протухших покрывает не все источники.

Стало. Оба предиката добавлены в оба запроса (локальный и общегородской) — симметрично _COMMON_WHERE эстиматора. Окно свежести берётся импортом LISTINGS_FRESH_DAYS из estimator.py; второго определения константы не завожу (PR #2661 переносит её в config.py — этот PR от него не зависит и не конфликтует за неё, импорт останется валидным).

Замер (прод, пул location_index — bbox ЕКБ + sanity ₽/м² + geo_precision):

пул строк медиана ₽/м²
было (только is_active) 30 222 172 984
только свежесть 11 453 163 363
только сегмент 11 219 147 632
стало (оба) 7 715 147 368 (−14.8%)

Вклад предикатов разный, и не тот, на который легко подумать: из −14.8% сдвига −14.7 п.п. даёт сегментный гард и −0.18 п.п. свежесть. Мертвецы живут почти целиком в новостройках (из 18 769 протухших строк пула 15 265 — первичка), поэтому сегментный гард выносит их заодно.

Значит для этой метрики свежесть — не коррекция смещения, а страховка на будущее, и она не бесплатна: выбрасывает 3 504 вторичных строки, из которых 2 724 — живые объявления, отскрейпленные 15–30 дней назад. Пул −31%, шум растёт: на центре ЕКБ n падает 423 → 86, а сам индекс гуляет по выбору окна на 12–14 п.п. (7 д +75.7% / 14 д +77.0% / 21 д +79.1% / 30 д +64.7%) — при n=86 это в пределах шума выборки медианы. Размен «меньше смещения ↔ больше дисперсии» сделан осознанно (старое число было предвзятым), но он записан в комментарии как размен, а не как чистая победа. Окно не меняю — вопрос вынесен отдельно.

Новый режим отказа (знать обязательно). Свежесть связала витрину со здоровьем сбора: встанет скрейпинг на 14 дней — городская выборка не наберёт MIN_SAMPLE_SIZE, и insufficient_data прилетит всем пользователям разом, тогда как раньше виджет продолжал показывать устаревшее число. Учитывая, что #2574 — это месяц молчаливой поломки сбора, сценарий не гипотетический. Деградация честная (прочерк, не выдуманное число), но теперь массовая. Задокументировано в location_index.py.

На конкретной точке (центр ЕКБ 56.838/60.605, первая ступень 800 м) видно, почему это важно именно для показателя, а не только для абсолютного числа:

было стало
локальная выборка 423 86
локальная медиана 414 838 260 841
location_index_pct +139.8% +77.0%

Числитель и знаменатель были завышены по-разному, поэтому центр читался как «дороже среднего в 2.4 раза».

Замер по MIN_SAMPLE_SIZE (то, о чём просили проверить отдельно)

Симуляция лестницы радиусов на 246 реальных точках оценок (trade_in_estimates, ЕКБ-bbox), порог 20, ступени 800/1500/2500 м:

было стало
insufficient_data (отказ на макс. радиусе) 0 1 (0.4%)
хватило 800 м 244 241
хватило 1500 м 2 2
потребовалось 2500 м 0 2
средняя выборка на 2500 м 3 605 980

Порог не трогаю: лестница упирается в отказ на одной точке из 246, запас по выборке остаётся большой (980 при пороге 20). Подгонять MIN_SAMPLE_SIZE под сужение пула не за чем.

3. Админские счётчики: «активно» отделено от «живо»

Было. /scraper/data-quality и /cache-stats показывали count(*) WHERE is_active — и это читалось как «столько объявлений живо». Именно поэтому #2574 месяц выглядела как «всё собирается».

Стало. Ничего не прячем — разделяем. Рядом с «активно» отдаётся «из них не виделись N дней» (last_seen_at, порог = LISTINGS_FRESH_DAYS), плюс сам порог в ответе, чтобы UI подписывал число, а не заводил своё второе определение.

Схема ответа типизирована — обновлена: SourceCoverage.stale_count, DataQualityResponse.stale_days. /cache-stats не типизирован (dict[str, object]) — добавлены ключи listings_active_stale, listings_stale_days.

Что покажет прод после деплоя:

источник активно не виделись 14+ дн. не виделись 30+ дн.
cian 18 530 12 683 10 212
yandex 14 019 8 252 4 304
avito 4 975 0 0
domklik 376 0 0
всего (cache-stats) 37 900 20 935 14 516

last_seen_at, а не scraped_at: счётчик отвечает буквально на «сколько не виделись». На проде колонки не расходятся (0 активных строк с разницей ≥ суток), но название колонки должно называть семантику счётчика.

Фронт не трогал (не мой скоуп): числа уже в ответе API, но DataQualitySection.tsx и cache/page.tsx их пока не рисуют — нужен отдельный заход frontend-engineer, иначе правка останется «честной, но невидимой».


Вне скоупа: listings_search_mv — материал для решения владельца

Фильтром сознательно не чиню: спрятать 38.8% выдачи — продуктовое решение, а не техническое.

Факты. listings_search_mv (м.050) фильтрует is_active AND COALESCE(canonical, true), свежести нет. Прод: 37 595 строк, из них 14 842 (39.5%) не виделись 30+ дней и 21 400 (56.9%) — 14+ дней. Матвьюха рефрешится ежедневно в 3:00 (refresh_search_matview, CONCURRENTLY).

Две вещи, которые меняют цену вопроса:

  1. У POST /api/v1/search сегодня нет потребителя во фронте tradein (grep по tradein-mvp/frontend/src — ни одного вызова; страницы поиска нет). То есть прямо сейчас это проблема API-поверхности, а не выдачи, которую видит пользователь. Решение можно принять ДО того, как поисковый UI поедет в прод — и это самый дешёвый момент.
  2. Корень не в поиске. Протухшее — это cian (12 683) и yandex (8 252); у avito и domklik ноль. У них работает деактиватор протухших, у cian/yandex — нет (сторона данных, #2659). Почини сбор — и 38.8% схлопнутся сами, без продуктового решения. Варианты ниже — это то, что делать, пока сбор не починен.

Вариант A — спрятать (фильтр по свежести).

  • Где: либо в самой матвьюхе (миграция, DROP + CREATE MATERIALIZED VIEW, полный ребилд), либо одной строкой в search_query.py (scraped_at > NOW() - N days).
  • Рекомендация, если выбирается A: на уровне запроса, не матвьюхи. Дешевле (нет миграции и ребилда), обратимо одним коммитом, и не наступает на грабли «DROP MV теряет гранты» (обжигались на C3 FDW-grant). Колонка scraped_at в матвьюхе уже есть.
  • Цена: выдача сжимается на 56.9% (порог 14 дн.) или 39.5% (30 дн.). Строка, которую перестали пересобирать, молча исчезает — при текущем состоянии cian/yandex это выключает больше половины инвентаря.
  • Фронт: 0 работы. Но число «найдено N» падает вдвое без объяснения.

Вариант B — пометить («последний раз видели N дней назад»).

  • Бэкенд: 0 работы. scraped_at уже лежит в матвьюхе, уже в SELECT'е search_query.py и уже отдаётся в SearchResultItem.scraped_at. Метка = чистый рендер.
  • Фронт: бейдж на карточке + микрокопия (правила ui-microcopy.md).
  • Цена: 38.8% остаются в выдаче, но пользователь про них знает. Самый честный и самый дешёвый вариант; не уменьшает шум.
  • Оговорка: scraped_at на проде равен last_seen_at (0 активных строк с расхождением ≥ суток), так что подпись «видели N дней назад» правдива.

Вариант C — фильтр с дефолтом (компромисс).

  • Бэкенд: параметр freshness_days в SearchParams + одна строка в build_search_query (~20 строк с валидацией). build_count_query подхватывает автоматически. Дефолт — 14 или 30, «показать все» — явное действие пользователя.
  • Фронт: контрол в панели фильтров + подсказка «скрыто N объявлений, которые не видели 30+ дней».
  • Цена: дороже B на один параметр и один контрол, но даёт и умолчание «показываем живое», и обратимость на стороне пользователя — ничего не теряется безвозвратно.
  • Дополнительно можно комбинировать с B (пометка на тех, что остались в выдаче при снятом фильтре).

Нюанс про сортировку, который стоит знать при выборе: дефолтный sort=date_desc — это scraped_at DESC, то есть протухшее и так уезжает в конец выдачи. Больнее всего 38.8% бьют по price_asc (самое дешёвое — часто как раз давно снятое) и по числу «найдено N».

Моя рекомендация как автора, если нужно короткое: B сейчас (ноль бэкенда, честно, не ломает инвентарь) + C, когда поедет поисковый UI. A — только после того, как #2659 починит деактивацию cian/yandex, иначе фильтр прячет не мертвецов, а собственную дыру в сборе.


Что НЕ входит

  • Денежный путь (#2661) — не трогал ни строкой; estimator.py изменён не был.
  • listings_search_mv / search_query.py — код поиска не менял вообще (см. раздел выше).
  • Фронт (DataQualitySection.tsx, cache/page.tsx, будущий поиск) — отдельный заход frontend-engineer.
  • Сторона данных — почему cian/yandex не деактивируются (#2659).

Test plan

  • pytest -q весь backend: 3 372 passed, 1 failed — падение test_search_api.py::test_search_cache_hit (401 вместо 200) воспроизводится на чистом origin/main, к этому PR отношения не имеет.
  • Новые тесты: test_migration_211_sales_vs_listings_segment_guard.py (14), test_admin_stale_counters.py (6), +4 в test_location_index.py; поправлена фикстура test_scraper_admin_apis.py под новое поле.
  • Фальсификация — застэшил реализацию, тесты покраснели:
    • откат location_index.py → 4 failed (свежесть, сегмент, единственность константы, биндинг окна в оба запроса);
    • откат admin.py + trade_in.py → 6 failed (5 новых + фикстура test_scraper_admin_apis);
    • удаление гарда из м.211 → 2 failed; удаление файла миграции целиком → 14 failed; добавление 8-го параметра в сигнатуру (имитация #2627) → 1 failed.
  • Исправлено по ревью: test_freshness_window_is_the_estimator_constant_not_a_copy проверял is-идентичность — из-за кэша малых int в CPython скопированный литерал LISTINGS_FRESH_DAYS = 14 тест бы прошёл, хотя докстринг обещает ловить ровно это (прошлая фальсификация срабатывала лишь потому, что откат удалял имя целиком). Теперь проверка через inspect.getsource; фальсифицирована подстановкой копии литерала вместо импорта — краснеет.
  • Ruff (check + format) на изменённых файлах чисто; pre-commit прошёл на коммите.
  • Текст SQL из location_index.py (после подстановки биндов) прогнан на проде — выполняется, цифры выше из него.
  • Post-deploy: убедиться, что м.211 применилась (_schema_migrations), и что \df street_sales_vs_listings показывает РОВНО одну перегрузку (7 аргументов), а не две.
  • Post-deploy smoke: /scraper/data-quality отдаёт stale_count/stale_days, /cache-statslistings_active_stale.

Refs #2660

Пользовательская половина разбора #2574: витрины читают `listings` без сегмента и без свежести, поэтому показывают числа, посчитанные не по тому пулу. Денежная половина — #2661, здесь её не трогаю. Все цифры ниже — живые SELECT'ы к прод-БД `tradein` 2026-08-05 (только чтение). ## 1. «Медианный торг» больше не считается с участием первички **Было.** CTE `window_listings` в `street_sales_vs_listings()` (м.205) читал `listings` без сегментного гарда #1186 — к ДКП-сделке вторички могло подставиться объявление застройщика. Девелоперский прайс фиксирован и не торгуется, но именно он формировал показываемый пользователю процент. **Стало.** Миграция `211_sales_vs_listings_segment_guard.sql` — `CREATE OR REPLACE` того же тела + канонический предикат `(l.listing_segment IS NULL OR l.listing_segment = 'vtorichka')`. **Замер (прод):** | | | |---|---| | популяция кандидатов на пейринг (окно 30 мес, `price_rub > 0`) | 93 241 | | из них `novostroyki` | 25 428 (**27.3%**) | | `vtorichka` / `NULL` (legacy до м.011, остаётся в пуле) | 66 256 / 1 557 | Симуляция на 20 самых «густых» парах (улица, комнаты) по ЕКБ, 2 120 сделок: | | было | стало | |---|---|---| | сделок с listing-match | 1 219 | 994 | | из них матч против новостройки | 299 (24.5%) | 0 | | медианный торг | −18.18% | **−17.11%** | Торг перестал «утяжеляться» на 1.07 п.п. за счёт первички. ### Поштучный сдвиг кратно больше сводного — и доходит до смены знака Сводные +1.07 п.п. **не видит никто**: эндпоинт вызывается на конкретную улицу. По группам (улица, комнаты) разброс до 20+ п.п.: | группа | пар было | пар стало | торг было | торг стало | |---|---|---|---|---| | `%Космонавтов%` 2-комн. | 72 | 41 | −11.9% | **+36.4%** (смена знака) | | `%Академика Ландау%` 1-комн. | 75 | 31 | −36.1% | −14.1% | | `%Блюхера%` 2-комн. | 78 | **78** | −16.8% | −5.4% | | `%Краснолесья%` 1-комн. | 179 | 128 | −21.7% | −28.6% | | `%Академика Ландау%` 4-комн. | 12 | **0** | +15.9% | — (пар не осталось) | Обратите внимание на `%Блюхера%`: число пар не изменилось, а медиана уехала на 11.4 п.п. Это **подстановка партнёра** — `DISTINCT ON` выбирает не ближайшую по дате новостройку, а следующее по близости вторичное объявление. Разложение всех 2 120 сделок: **225** потеряли матч с новостройкой (цель PR), **74** получили подстановку вторички (в среднем безобидно), **920** не изменились. Направление верное — но на отдельной улице пользователь увидит не «стало на процент точнее», а другое число, иногда другого знака. ### `linkage_rate_pct` просядет: 57.5% → 46.9% Это видимое пользователю «N% сделок имеют историческую цену в объявлении» (1 219 → 994 матча из 2 120 сделок). Число стало **честнее** — ушли фальшивые пары «вторичка ↔ новостройка», — но поддержка прочитает падение на 10.6 п.п. как регресс покрытия. Ожидаемо и правильно. **Что осознанно НЕ меняется:** `is_active` в этой функции по-прежнему не фильтруется. Функция намеренно смотрит и снятые объявления — объявление снимают ПОСЛЕ продажи, активные для пейринга бесполезны. Свежесть здесь тоже не при чём: пейринг привязан к дате СДЕЛКИ (`window_days` ± grace), а не к «сейчас». Оба «отсутствия» закреплены тестами, чтобы следующий заход не «дочинил» их по аналогии. **Про грабли #2627.** Сигнатура не менялась — те же 7 аргументов, что после м.205, значит `CREATE OR REPLACE` — замена in-place, а не вторая перегрузка. Проверяется тестом `test_migration_211_signature_identical_to_205_no_new_overload`: он посимвольно сравнивает блок сигнатуры 211 с 205 (фальсификация: добавил 8-й параметр — тест покраснел). `DROP FUNCTION` намеренно нет: 205 уже дропнула старую 6-арг сигнатуру, а дроп текущей дал бы окно, в котором caller получает `function does not exist`. ## 2. Индекс локации считается по тому же пулу, что и цена **Было.** Оба запроса медианы в `location_index.py` фильтровали `is_active` + `geo_precision`, но не свежесть и не сегмент. `is_active` на проде не означает «живо»: деактиватор протухших покрывает не все источники. **Стало.** Оба предиката добавлены в **оба** запроса (локальный и общегородской) — симметрично `_COMMON_WHERE` эстиматора. Окно свежести берётся импортом `LISTINGS_FRESH_DAYS` из `estimator.py`; второго определения константы не завожу (PR #2661 переносит её в `config.py` — этот PR от него не зависит и не конфликтует за неё, импорт останется валидным). **Замер (прод, пул location_index — bbox ЕКБ + sanity ₽/м² + geo_precision):** | пул | строк | медиана ₽/м² | |---|---|---| | было (только `is_active`) | 30 222 | 172 984 | | только свежесть | 11 453 | 163 363 | | только сегмент | 11 219 | **147 632** | | **стало (оба)** | **7 715** | **147 368** (−14.8%) | **Вклад предикатов разный, и не тот, на который легко подумать: из −14.8% сдвига −14.7 п.п. даёт сегментный гард и −0.18 п.п. свежесть.** Мертвецы живут почти целиком в новостройках (из 18 769 протухших строк пула 15 265 — первичка), поэтому сегментный гард выносит их заодно. Значит для **этой** метрики свежесть — не коррекция смещения, а **страховка на будущее**, и она не бесплатна: выбрасывает 3 504 вторичных строки, из которых 2 724 — живые объявления, отскрейпленные 15–30 дней назад. Пул −31%, шум растёт: на центре ЕКБ n падает 423 → 86, а сам индекс гуляет по выбору окна на 12–14 п.п. (7 д +75.7% / 14 д +77.0% / 21 д +79.1% / 30 д +64.7%) — при n=86 это в пределах шума выборки медианы. Размен «меньше смещения ↔ больше дисперсии» сделан осознанно (старое число было предвзятым), но он записан в комментарии как размен, а не как чистая победа. **Окно не меняю — вопрос вынесен отдельно.** **Новый режим отказа (знать обязательно).** Свежесть связала витрину со здоровьем сбора: встанет скрейпинг на 14 дней — городская выборка не наберёт `MIN_SAMPLE_SIZE`, и `insufficient_data` прилетит **всем пользователям разом**, тогда как раньше виджет продолжал показывать устаревшее число. Учитывая, что #2574 — это месяц молчаливой поломки сбора, сценарий не гипотетический. Деградация честная (прочерк, не выдуманное число), но теперь массовая. Задокументировано в `location_index.py`. На конкретной точке (центр ЕКБ 56.838/60.605, первая ступень 800 м) видно, почему это важно именно для показателя, а не только для абсолютного числа: | | было | стало | |---|---|---| | локальная выборка | 423 | 86 | | локальная медиана | 414 838 | 260 841 | | **location_index_pct** | **+139.8%** | **+77.0%** | Числитель и знаменатель были завышены по-разному, поэтому центр читался как «дороже среднего в 2.4 раза». ### Замер по MIN_SAMPLE_SIZE (то, о чём просили проверить отдельно) Симуляция лестницы радиусов на **246 реальных точках оценок** (`trade_in_estimates`, ЕКБ-bbox), порог 20, ступени 800/1500/2500 м: | | было | стало | |---|---|---| | `insufficient_data` (отказ на макс. радиусе) | 0 | **1 (0.4%)** | | хватило 800 м | 244 | 241 | | хватило 1500 м | 2 | 2 | | потребовалось 2500 м | 0 | 2 | | средняя выборка на 2500 м | 3 605 | 980 | Порог **не трогаю**: лестница упирается в отказ на одной точке из 246, запас по выборке остаётся большой (980 при пороге 20). Подгонять `MIN_SAMPLE_SIZE` под сужение пула не за чем. ## 3. Админские счётчики: «активно» отделено от «живо» **Было.** `/scraper/data-quality` и `/cache-stats` показывали `count(*) WHERE is_active` — и это читалось как «столько объявлений живо». Именно поэтому #2574 месяц выглядела как «всё собирается». **Стало.** Ничего не прячем — разделяем. Рядом с «активно» отдаётся «из них не виделись N дней» (`last_seen_at`, порог = `LISTINGS_FRESH_DAYS`), плюс сам порог в ответе, чтобы UI подписывал число, а не заводил своё второе определение. Схема ответа типизирована — обновлена: `SourceCoverage.stale_count`, `DataQualityResponse.stale_days`. `/cache-stats` не типизирован (`dict[str, object]`) — добавлены ключи `listings_active_stale`, `listings_stale_days`. **Что покажет прод после деплоя:** | источник | активно | не виделись 14+ дн. | не виделись 30+ дн. | |---|---|---|---| | cian | 18 530 | **12 683** | 10 212 | | yandex | 14 019 | 8 252 | 4 304 | | avito | 4 975 | 0 | 0 | | domklik | 376 | 0 | 0 | | **всего (cache-stats)** | **37 900** | **20 935** | 14 516 | `last_seen_at`, а не `scraped_at`: счётчик отвечает буквально на «сколько не виделись». На проде колонки не расходятся (0 активных строк с разницей ≥ суток), но название колонки должно называть семантику счётчика. **Фронт не трогал** (не мой скоуп): числа уже в ответе API, но `DataQualitySection.tsx` и `cache/page.tsx` их пока не рисуют — нужен отдельный заход frontend-engineer, иначе правка останется «честной, но невидимой». --- ## Вне скоупа: `listings_search_mv` — материал для решения владельца Фильтром **сознательно не чиню**: спрятать 38.8% выдачи — продуктовое решение, а не техническое. **Факты.** `listings_search_mv` (м.050) фильтрует `is_active AND COALESCE(canonical, true)`, свежести нет. Прод: **37 595 строк, из них 14 842 (39.5%) не виделись 30+ дней и 21 400 (56.9%) — 14+ дней.** Матвьюха рефрешится ежедневно в 3:00 (`refresh_search_matview`, `CONCURRENTLY`). **Две вещи, которые меняют цену вопроса:** 1. **У `POST /api/v1/search` сегодня нет потребителя во фронте tradein** (grep по `tradein-mvp/frontend/src` — ни одного вызова; страницы поиска нет). То есть прямо сейчас это проблема API-поверхности, а не выдачи, которую видит пользователь. Решение можно принять ДО того, как поисковый UI поедет в прод — и это самый дешёвый момент. 2. **Корень не в поиске.** Протухшее — это cian (12 683) и yandex (8 252); у avito и domklik ноль. У них работает деактиватор протухших, у cian/yandex — нет (сторона данных, #2659). Почини сбор — и 38.8% схлопнутся сами, без продуктового решения. Варианты ниже — это то, что делать, **пока** сбор не починен. **Вариант A — спрятать (фильтр по свежести).** - Где: либо в самой матвьюхе (миграция, `DROP` + `CREATE MATERIALIZED VIEW`, полный ребилд), либо одной строкой в `search_query.py` (`scraped_at > NOW() - N days`). - Рекомендация, если выбирается A: **на уровне запроса, не матвьюхи.** Дешевле (нет миграции и ребилда), обратимо одним коммитом, и не наступает на грабли «`DROP MV` теряет гранты» (обжигались на C3 FDW-grant). Колонка `scraped_at` в матвьюхе уже есть. - Цена: выдача сжимается на 56.9% (порог 14 дн.) или 39.5% (30 дн.). Строка, которую перестали пересобирать, молча исчезает — при текущем состоянии cian/yandex это выключает больше половины инвентаря. - Фронт: 0 работы. Но число «найдено N» падает вдвое без объяснения. **Вариант B — пометить («последний раз видели N дней назад»).** - Бэкенд: **0 работы.** `scraped_at` уже лежит в матвьюхе, уже в SELECT'е `search_query.py` и уже отдаётся в `SearchResultItem.scraped_at`. Метка = чистый рендер. - Фронт: бейдж на карточке + микрокопия (правила `ui-microcopy.md`). - Цена: 38.8% остаются в выдаче, но пользователь про них знает. Самый честный и самый дешёвый вариант; не уменьшает шум. - Оговорка: `scraped_at` на проде равен `last_seen_at` (0 активных строк с расхождением ≥ суток), так что подпись «видели N дней назад» правдива. **Вариант C — фильтр с дефолтом (компромисс).** - Бэкенд: параметр `freshness_days` в `SearchParams` + одна строка в `build_search_query` (~20 строк с валидацией). `build_count_query` подхватывает автоматически. Дефолт — 14 или 30, «показать все» — явное действие пользователя. - Фронт: контрол в панели фильтров + подсказка «скрыто N объявлений, которые не видели 30+ дней». - Цена: дороже B на один параметр и один контрол, но даёт и умолчание «показываем живое», и обратимость на стороне пользователя — ничего не теряется безвозвратно. - Дополнительно можно комбинировать с B (пометка на тех, что остались в выдаче при снятом фильтре). **Нюанс про сортировку, который стоит знать при выборе:** дефолтный `sort=date_desc` — это `scraped_at DESC`, то есть протухшее и так уезжает в конец выдачи. Больнее всего 38.8% бьют по `price_asc` (самое дешёвое — часто как раз давно снятое) и по числу «найдено N». Моя рекомендация как автора, если нужно короткое: **B сейчас** (ноль бэкенда, честно, не ломает инвентарь) + **C, когда поедет поисковый UI**. A — только после того, как #2659 починит деактивацию cian/yandex, иначе фильтр прячет не мертвецов, а собственную дыру в сборе. --- ## Что НЕ входит - Денежный путь (#2661) — не трогал ни строкой; `estimator.py` изменён не был. - `listings_search_mv` / `search_query.py` — код поиска не менял вообще (см. раздел выше). - Фронт (`DataQualitySection.tsx`, `cache/page.tsx`, будущий поиск) — отдельный заход frontend-engineer. - Сторона данных — почему cian/yandex не деактивируются (#2659). ## Test plan - [x] `pytest -q` весь backend: **3 372 passed, 1 failed** — падение `test_search_api.py::test_search_cache_hit` (401 вместо 200) **воспроизводится на чистом `origin/main`**, к этому PR отношения не имеет. - [x] Новые тесты: `test_migration_211_sales_vs_listings_segment_guard.py` (14), `test_admin_stale_counters.py` (6), +4 в `test_location_index.py`; поправлена фикстура `test_scraper_admin_apis.py` под новое поле. - [x] **Фальсификация** — застэшил реализацию, тесты покраснели: - откат `location_index.py` → 4 failed (свежесть, сегмент, единственность константы, биндинг окна в оба запроса); - откат `admin.py` + `trade_in.py` → 6 failed (5 новых + фикстура `test_scraper_admin_apis`); - удаление гарда из м.211 → 2 failed; удаление файла миграции целиком → 14 failed; добавление 8-го параметра в сигнатуру (имитация #2627) → 1 failed. - [x] **Исправлено по ревью:** `test_freshness_window_is_the_estimator_constant_not_a_copy` проверял `is`-идентичность — из-за кэша малых int в CPython скопированный литерал `LISTINGS_FRESH_DAYS = 14` тест бы **прошёл**, хотя докстринг обещает ловить ровно это (прошлая фальсификация срабатывала лишь потому, что откат удалял имя целиком). Теперь проверка через `inspect.getsource`; фальсифицирована подстановкой копии литерала вместо импорта — краснеет. - [x] Ruff (`check` + `format`) на изменённых файлах чисто; pre-commit прошёл на коммите. - [x] Текст SQL из `location_index.py` (после подстановки биндов) прогнан на проде — выполняется, цифры выше из него. - [ ] Post-deploy: убедиться, что м.211 применилась (`_schema_migrations`), и что `\df street_sales_vs_listings` показывает РОВНО одну перегрузку (7 аргументов), а не две. - [ ] Post-deploy smoke: `/scraper/data-quality` отдаёт `stale_count`/`stale_days`, `/cache-stats` — `listings_active_stale`. Refs #2660
bot-backend added 1 commit 2026-08-05 17:44:24 +00:00
fix(tradein): сегментный гард в «медианном торге», свежесть в индексе локации, честные админ-счётчики (#2660)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 6s
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 2m42s
837ad8cfd4
Пользовательская половина разбора #2574: витрины читают listings без сегмента
и без свежести, поэтому показывают числа, посчитанные не по тому пулу.

1. Миграция 211 — гард #1186 в window_listings у street_sales_vs_listings().
   27.3% кандидатов на пару «ДКП ↔ объявление» были новостройками, и
   девелоперский прайс (который не торгуется) формировал показываемый процент
   торга. is_active здесь по-прежнему НЕ фильтруется — осознанно: функция
   намеренно смотрит и снятые объявления, иначе к сделке нечего подставить.
   Сигнатура не меняется, значит CREATE OR REPLACE — замена, а не вторая
   перегрузка (грабли #2627 закрыты тестом-сравнением сигнатур с м.205).

2. location_index — предикат свежести + сегментный гард в обоих запросах
   медианы, симметрично _COMMON_WHERE эстиматора. Витрина обязана смотреть на
   тот же пул, на котором считается цена; окно свежести берётся импортом
   LISTINGS_FRESH_DAYS, второго определения константы не заводим.

3. /scraper/data-quality и /cache-stats — «активно» не прячем, а разделяем:
   рядом отдаётся «из них не виделись N дней» (+ сам порог N в ответе).
   Именно слепой count(*) WHERE is_active заставлял #2574 месяц выглядеть
   как «всё собирается».

Refs #2660
Light1YT added 1 commit 2026-08-05 18:13:38 +00:00
fix(tradein): тест ловит копию константы, а не equality; честный комментарий про вклад свежести (#2660)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 2m44s
d173163025
По ревью PR #2664.

1. test_freshness_window_is_the_estimator_constant_not_a_copy проверял
   `lc.LISTINGS_FRESH_DAYS is estimator.LISTINGS_FRESH_DAYS` — CPython кэширует
   малые int, поэтому скопированный литерал `LISTINGS_FRESH_DAYS = 14` тест бы
   ПРОШЁЛ, хотя докстринг обещает ловить ровно это. Прошлая фальсификация
   срабатывала лишь потому, что откат удалял имя целиком (AttributeError).
   Теперь проверяем исходник через inspect.getsource — фальсифицировано
   подстановкой копии литерала вместо импорта: тест краснеет.

2. Комментарий в location_index.py приписывал свежести чужую заслугу.
   Прод-разложение: из −14.8% сдвига городской медианы −14.7 п.п. даёт
   сегментный гард и лишь −0.18 п.п. свежесть. Для этой метрики свежесть —
   не коррекция смещения, а страховка на будущее, оплаченная третью пула
   (3 504 вторичных строки, из них 2 724 живые) и ростом дисперсии: на центре
   ЕКБ n 423 → 86, индекс гуляет по выбору окна на 12-14 п.п. Размен верный,
   но он должен быть написан как размен.

   Там же задокументирован новый режим отказа: свежесть связала витрину со
   здоровьем сбора — встанет скрейпинг на 14 дней, и insufficient_data
   прилетит всем пользователям разом. Учитывая, что #2574 это месяц молчаливой
   поломки сбора, сценарий не гипотетический.

Окно свежести не меняю — вопрос вынесен отдельно.

Refs #2660
bot-backend merged commit 63ea44fdd2 into main 2026-08-05 18:16:58 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
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#2664
No description provided.