fix(tradein): сегментный гард в «медианном торге», свежесть в индексе локации, честные админ-счётчики (#2660) #2664
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2664
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/2660-display-freshness-segment"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Пользовательская половина разбора #2574: витрины читают
listingsбез сегмента и без свежести, поэтому показывают числа, посчитанные не по тому пулу. Денежная половина — #2661, здесь её не трогаю.Все цифры ниже — живые SELECT'ы к прод-БД
tradein2026-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').Замер (прод):
price_rub > 0)novostroykivtorichka/NULL(legacy до м.011, остаётся в пуле)Симуляция на 20 самых «густых» парах (улица, комнаты) по ЕКБ, 2 120 сделок:
Торг перестал «утяжеляться» на 1.07 п.п. за счёт первички.
Поштучный сдвиг кратно больше сводного — и доходит до смены знака
Сводные +1.07 п.п. не видит никто: эндпоинт вызывается на конкретную улицу. По группам (улица, комнаты) разброс до 20+ п.п.:
%Космонавтов%2-комн.%Академика Ландау%1-комн.%Блюхера%2-комн.%Краснолесья%1-комн.%Академика Ландау%4-комн.Обратите внимание на
%Блюхера%: число пар не изменилось, а медиана уехала на 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)Вклад предикатов разный, и не тот, на который легко подумать: из −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 м) видно, почему это важно именно для показателя, а не только для абсолютного числа:
Числитель и знаменатель были завышены по-разному, поэтому центр читался как «дороже среднего в 2.4 раза».
Замер по MIN_SAMPLE_SIZE (то, о чём просили проверить отдельно)
Симуляция лестницы радиусов на 246 реальных точках оценок (
trade_in_estimates, ЕКБ-bbox), порог 20, ступени 800/1500/2500 м:insufficient_data(отказ на макс. радиусе)Порог не трогаю: лестница упирается в отказ на одной точке из 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.Что покажет прод после деплоя:
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).Две вещи, которые меняют цену вопроса:
POST /api/v1/searchсегодня нет потребителя во фронте tradein (grep поtradein-mvp/frontend/src— ни одного вызова; страницы поиска нет). То есть прямо сейчас это проблема API-поверхности, а не выдачи, которую видит пользователь. Решение можно принять ДО того, как поисковый UI поедет в прод — и это самый дешёвый момент.Вариант A — спрятать (фильтр по свежести).
DROP+CREATE MATERIALIZED VIEW, полный ребилд), либо одной строкой вsearch_query.py(scraped_at > NOW() - N days).DROP MVтеряет гранты» (обжигались на C3 FDW-grant). Колонкаscraped_atв матвьюхе уже есть.Вариант B — пометить («последний раз видели N дней назад»).
scraped_atуже лежит в матвьюхе, уже в SELECT'еsearch_query.pyи уже отдаётся вSearchResultItem.scraped_at. Метка = чистый рендер.ui-microcopy.md).scraped_atна проде равенlast_seen_at(0 активных строк с расхождением ≥ суток), так что подпись «видели N дней назад» правдива.Вариант C — фильтр с дефолтом (компромисс).
freshness_daysвSearchParams+ одна строка вbuild_search_query(~20 строк с валидацией).build_count_queryподхватывает автоматически. Дефолт — 14 или 30, «показать все» — явное действие пользователя.Нюанс про сортировку, который стоит знать при выборе: дефолтный
sort=date_desc— этоscraped_at DESC, то есть протухшее и так уезжает в конец выдачи. Больнее всего 38.8% бьют поprice_asc(самое дешёвое — часто как раз давно снятое) и по числу «найдено N».Моя рекомендация как автора, если нужно короткое: B сейчас (ноль бэкенда, честно, не ломает инвентарь) + C, когда поедет поисковый UI. A — только после того, как #2659 починит деактивацию cian/yandex, иначе фильтр прячет не мертвецов, а собственную дыру в сборе.
Что НЕ входит
estimator.pyизменён не был.listings_search_mv/search_query.py— код поиска не менял вообще (см. раздел выше).DataQualitySection.tsx,cache/page.tsx, будущий поиск) — отдельный заход frontend-engineer.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);test_freshness_window_is_the_estimator_constant_not_a_copyпроверялis-идентичность — из-за кэша малых int в CPython скопированный литералLISTINGS_FRESH_DAYS = 14тест бы прошёл, хотя докстринг обещает ловить ровно это (прошлая фальсификация срабатывала лишь потому, что откат удалял имя целиком). Теперь проверка черезinspect.getsource; фальсифицирована подстановкой копии литерала вместо импорта — краснеет.check+format) на изменённых файлах чисто; pre-commit прошёл на коммите.location_index.py(после подстановки биндов) прогнан на проде — выполняется, цифры выше из него._schema_migrations), и что\df street_sales_vs_listingsпоказывает РОВНО одну перегрузку (7 аргументов), а не две./scraper/data-qualityотдаётstale_count/stale_days,/cache-stats—listings_active_stale.Refs #2660