feat(tradein): бесплатная проба покрытия POST /coverage (#2894) #2909

Merged
lekss361 merged 3 commits from feat/tradein-coverage-probe into main 2026-08-15 18:28:09 +00:00
Owner

Первый бэкенд-контур под публичный лэндинг: до оплаты человек видит, сколько похожих квартир продаётся рядом — и ни одной рублёвой цифры.

POST /api/v1/trade-in/coverage
→ {lat, lon, rooms, area_m2, city_hint?}
← {status: ok|thin|not_covered, n_listings,
   median_listing_age_days, n_with_age, radius_m, city, threshold}

Один SQL, ноль внешних вызовов, ноль записей — ручка будет открыта анонимам, поэтому обязана быть дешёвой. EXPLAIN ANALYZE на проде: Bitmap Index Scan по listings_geom_geog_idx, ~81 мс, отдельная миграция индекса не потребовалась.

Три круга ревью — что нашли и починили

Ветка прошла независимую проверку в свежем контексте трижды. Два MAJOR и один посеянный по дороге дефект — все с замерами на проде, не по рассуждению.

MAJOR-1. Проба обещала больше, чем платный расчёт может дать. В когорте не было трёх предикатов, которые есть у эстиматора: guard новостроек, geo_precision IS DISTINCT FROM 'city' и price_rub > 0. Замер: точка 56.868904/60.837955, 2к, 50 м² — проба насчитала 22 аналога, когорта эстиматора в тех же 1000 м пустая (все 54 строки — новостройки). Человек платит по обещанию и получает расчёт без аналогов. В активном пуле 28% новостроек, 421 строка на городском центроиде.
Починено копией канона из estimator.py со ссылками на строки. Мутационная проверка: удаление любого из трёх предикатов роняет ровно один тест.

MAJOR-2. «Срок продажи» считался бы по одному источнику. days_on_market заполнен только у Яндекса (8127/8127; Avito 0/8663, Циан 0/7133, ДомКлик 0/470). Возраст известен у 23,5% строк когорты; в 8% случаев медиана считалась бы по одному-двум объявлениям, 15% значений больше года (максимум 4261 день).
Починено: в ответ добавлено n_with_age, медиана отдаётся только при n_with_age >= 5, значения свыше 365 дней в расчёт не берутся. Поле называется median_listing_age_days, а не «срок продажи» — у активного объявления это возраст висящего объявления, выборка цензурированная; в докстринге зафиксировано, что данные фактически по одному источнику.

NEW (посеян фиксом MINOR). listings.city — город обхода скрапера, а не адреса. Замер: в Берёзовском 90/90 объявлений имеют city='Екатеринбург', в Ревде 74/74 — city='Первоуральск'. Приоритет моды когорты над клиентской подсказкой означал бы, что продавец в Берёзовском видит «Екатеринбург», а три города из списков недостижимы вовсе.
Починено: город определяется по координатам запроса — ближайший центроид из восьми поддерживаемых городов в пределах 25 км, иначе not_covered. Ни данные объявлений, ни клиентская подсказка на порог больше не влияют. Факт про listings.city зафиксирован комментарием со ссылкой на замер, чтобы следующий раз не наступить.

Дыра в тестах. test_max_age_outlier_days_passed_to_sql искал подстроку, встречающуюся в SQL дважды, — мутация «убрать FILTER у percentile_cont, оставив у count» проходила зелёной. Запинено поведением: строка с возрастом 4000 дней вставляется в когорту, медиана обязана остаться 8.

мутация применена → 2 failed, 19 passed
  test_max_age_outlier_excluded_from_median_live
  AssertionError: median_listing_age_days=9 shifted by outlier — assert 9 == 8
мутация откачена → 21 passed

Плюс закрыта утечка соединений в тестах (_live_session() в skipif открывал висящую сессию на каждый collect): после полного прогона pg_stat_activity → 0.

Проверено

  • Полный бэкенд-сьют против живого Postgres+PostGIS (схема как в ci-tradein.yml): 4507 passed, 1 skip (WeasyPrint native deps, не связан).
  • Мок-лейн без БД: 19 passed, 2 live-теста корректно self-skip, оба зарегистрированы в skip_allowlist.txt.
  • ruff чисто, pre-commit без правок.

Что этот PR НЕ делает

RBAC не тронут — /coverage остаётся закрытой. Открытие анонимного периметра это #2895, и у него свой гейт: согласие 152-ФЗ до первого INSERT, переписанная privacy-страница, rate-limit. Фронт к ручке пока не подключён — карточка на лэндинге по-прежнему показывает пример из макета.

Остаточное: координаты восьми центроидов внесены руками из общедоступных сведений и не сверены с геодезическим источником. Для радиуса матчинга 25 км это некритично, но формально это не выверенные данные.

Closes #2894

Первый бэкенд-контур под публичный лэндинг: до оплаты человек видит, сколько похожих квартир продаётся рядом — и ни одной рублёвой цифры. ``` POST /api/v1/trade-in/coverage → {lat, lon, rooms, area_m2, city_hint?} ← {status: ok|thin|not_covered, n_listings, median_listing_age_days, n_with_age, radius_m, city, threshold} ``` Один SQL, ноль внешних вызовов, ноль записей — ручка будет открыта анонимам, поэтому обязана быть дешёвой. `EXPLAIN ANALYZE` на проде: Bitmap Index Scan по `listings_geom_geog_idx`, ~81 мс, отдельная миграция индекса не потребовалась. ## Три круга ревью — что нашли и починили Ветка прошла независимую проверку в свежем контексте трижды. Два MAJOR и один посеянный по дороге дефект — все с замерами на проде, не по рассуждению. **MAJOR-1. Проба обещала больше, чем платный расчёт может дать.** В когорте не было трёх предикатов, которые есть у эстиматора: guard новостроек, `geo_precision IS DISTINCT FROM 'city'` и `price_rub > 0`. Замер: точка 56.868904/60.837955, 2к, 50 м² — проба насчитала **22 аналога**, когорта эстиматора в тех же 1000 м **пустая** (все 54 строки — новостройки). Человек платит по обещанию и получает расчёт без аналогов. В активном пуле 28% новостроек, 421 строка на городском центроиде. Починено копией канона из `estimator.py` со ссылками на строки. Мутационная проверка: удаление любого из трёх предикатов роняет ровно один тест. **MAJOR-2. «Срок продажи» считался бы по одному источнику.** `days_on_market` заполнен только у Яндекса (8127/8127; Avito 0/8663, Циан 0/7133, ДомКлик 0/470). Возраст известен у 23,5% строк когорты; в 8% случаев медиана считалась бы по одному-двум объявлениям, 15% значений больше года (максимум 4261 день). Починено: в ответ добавлено `n_with_age`, медиана отдаётся только при `n_with_age >= 5`, значения свыше 365 дней в расчёт не берутся. Поле называется `median_listing_age_days`, а не «срок продажи» — у активного объявления это возраст висящего объявления, выборка цензурированная; в докстринге зафиксировано, что данные фактически по одному источнику. **NEW (посеян фиксом MINOR). `listings.city` — город обхода скрапера, а не адреса.** Замер: в Берёзовском 90/90 объявлений имеют `city='Екатеринбург'`, в Ревде 74/74 — `city='Первоуральск'`. Приоритет моды когорты над клиентской подсказкой означал бы, что продавец в Берёзовском видит «Екатеринбург», а три города из списков недостижимы вовсе. Починено: город определяется **по координатам запроса** — ближайший центроид из восьми поддерживаемых городов в пределах 25 км, иначе `not_covered`. Ни данные объявлений, ни клиентская подсказка на порог больше не влияют. Факт про `listings.city` зафиксирован комментарием со ссылкой на замер, чтобы следующий раз не наступить. **Дыра в тестах.** `test_max_age_outlier_days_passed_to_sql` искал подстроку, встречающуюся в SQL дважды, — мутация «убрать FILTER у `percentile_cont`, оставив у `count`» проходила зелёной. Запинено поведением: строка с возрастом 4000 дней вставляется в когорту, медиана обязана остаться 8. ``` мутация применена → 2 failed, 19 passed test_max_age_outlier_excluded_from_median_live AssertionError: median_listing_age_days=9 shifted by outlier — assert 9 == 8 мутация откачена → 21 passed ``` Плюс закрыта утечка соединений в тестах (`_live_session()` в `skipif` открывал висящую сессию на каждый collect): после полного прогона `pg_stat_activity` → 0. ## Проверено - Полный бэкенд-сьют против живого Postgres+PostGIS (схема как в `ci-tradein.yml`): **4507 passed**, 1 skip (WeasyPrint native deps, не связан). - Мок-лейн без БД: 19 passed, 2 live-теста корректно self-skip, оба зарегистрированы в `skip_allowlist.txt`. - `ruff` чисто, pre-commit без правок. ## Что этот PR НЕ делает RBAC не тронут — `/coverage` остаётся закрытой. Открытие анонимного периметра это #2895, и у него свой гейт: согласие 152-ФЗ до первого INSERT, переписанная privacy-страница, rate-limit. Фронт к ручке пока не подключён — карточка на лэндинге по-прежнему показывает пример из макета. **Остаточное:** координаты восьми центроидов внесены руками из общедоступных сведений и не сверены с геодезическим источником. Для радиуса матчинга 25 км это некритично, но формально это не выверенные данные. Closes #2894
lekss361 added 3 commits 2026-08-15 18:22:20 +00:00
POST /api/v1/trade-in/coverage — до оплаты пользователь видит только n похожих
объявлений в радиусе 1000м и медианный возраст листинга, без единой цены.
Один SQL (радиус GIST + rooms + area ±15% + freshness 14д + тот же дедуп/cap-
канон, что у estimator._fetch_analogs), ноль внешних вызовов, ноль записей.

Пороги ok/thin/not_covered — константы рядом с ручкой (зелёные города >=8,
жёлтые >=12, остальные всегда not_covered). Поле median_listing_age_days
(не "срок продажи" — возраст активного объявления, цензурированная выборка).

RBAC не тронут — путь остаётся закрытым, открытие анонимного периметра
вынесено в #2895.
Independent review found two MAJOR defects in POST /api/v1/trade-in/coverage:

MAJOR-1: the probe cohort WHERE clause was missing three predicates present
in estimator._COMMON_WHERE / Tier W (novostroyki guard, geo_precision !=
'city', price_rub > 0) — the free probe could answer "ok" at points where
the paid estimator's own 1000m radius tier sees zero real analogs. Prod
example: 56.868904/60.837955, 2 rooms, 50 m2 gave n_listings=22/status=ok
while the estimator's cohort at the same radius was 0 (all 54 rows were
novostroyki). Added the three predicates verbatim from estimator.py, plus
both a static SQL-text regression test and a real-Postgres integration test
(skip_allowlist.txt, same _live_session() pattern as test_gar_flats_loader)
that inserts novostroyka/geo_precision=city/price=0 rows and asserts they
are not counted.

MAJOR-2: median_listing_age_days was computed from days_on_market, which on
prod is populated almost exclusively by one source (yandex) — thin cohorts
produced a "median" over 1-2 listings. Added n_with_age to the response
(honest count of listings the median is based on); median is now null below
COVERAGE_MIN_AGE_SAMPLES=5, and values above COVERAGE_MAX_AGE_DAYS=365 (near
-certainly dead listings, per prod: 15% of fresh yandex rows exceed 365d,
max 4261d) are excluded as outliers before the percentile is computed.

MINOR: city_hint was trusted at face value and echoed back verbatim — a
client could pass city_hint="Екатеринбург" with coordinates in Серов and get
threshold=8/status=ok. _resolve_coverage_city now prioritizes the SQL
cohort's mode city (ground truth) over the client hint, falling back to hint
only when the cohort is empty (where status is forced not_covered anyway).
Unmatched cities no longer echo the raw client string in the city field.
fix(tradein/coverage): resolve city by coordinates, not sweep-context city_hint
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m34s
37e738c802
Повторная проверка /coverage закрыла оба MAJOR из #2894, но выявила три
новых дефекта:

1. Город больше не резолвится из моды listings.city найденной когорты —
   эта колонка хранит город SWEEP-контекста скрейпера (миграция 196), не
   геокод адреса объявления. Замер на проде: 90/90 строк в радиусе 1000м
   вокруг Берёзовского имеют city='Екатеринбург', 74/74 вокруг Ревды —
   city='Первоуральск'. Города-спутники из COVERAGE_GREEN/YELLOW_CITIES были
   физически недостижимы. Город теперь резолвится детерминированно по
   lat/lon запроса — ближайший центроид из статичной константы (8 городов,
   рядом с ручкой, не в БД — comment объясняет почему) в пределах 25 км.
   city_hint остаётся в схеме (фронт его шлёт для соседних ручек), но чисто
   информационный — на порог/статус не влияет.

2. test_max_age_outlier_days_passed_to_sql проверял подстроку, которая
   встречается в SQL дважды (count и percentile_cont) — мутация «убрать
   FILTER у percentile_cont, оставив у count» проходила зелёной. Добавлен
   живой поведенческий тест (вставляет когорту + выброс days_on_market=4000,
   проверяет что медиана не сдвигается) — ловит эту мутацию (подтверждено:
   median 8→9 при мутации).

3. _live_session() вызывался в pytest.mark.skipif на этапе сбора тестов и
   создавал никогда не закрываемый Session, плюс дублировался в теле теста.
   Заменено на _live_db_available() (open+close голого connection) для
   skipif и pytest-фикстуру live_session с гарантированным close/dispose.

4. Nit: пустая когорта в поддерживаемом городе отдавала status=not_covered
   вместе с ненулевым threshold — противоречило докстрингу
   CoverageProbeResponse.threshold ("0, когда порог неприменим"). threshold
   теперь всегда 0 при not_covered, независимо от причины.
lekss361 merged commit 12007f8615 into main 2026-08-15 18:28:09 +00:00
lekss361 deleted branch feat/tradein-coverage-probe 2026-08-15 18:28:09 +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#2909
No description provided.