fix(mera/b2c): восемь находок финального аудита прода — включая три моих собственных #3247

Merged
bot-backend merged 9 commits from fix/audit-all into main 2026-08-29 19:23:45 +00:00
Collaborator

Полный проход по живому сайту шестью независимыми линзами (числа, работоспособность, дизайн глазами, мобильный, периметр, данные под витриной). Здесь — правки по найденному. Пять веток сведены в одну, чтобы не гнать пять деплоев подряд.

Что в аудите оказалось в порядке (тоже результат): периметр держится — шесть попыток обхода белого списка отбиты, выключенные ручки не выдают схему, noindex на всех восьми страницах, внешних ресурсов не подгружается. Тракт расчёта честен от подсказки адреса до результата, включая «дата известна у 5 из 25» и исчезающую плитку при нехватке данных.

Три находки — мои собственные ошибки

1. Подпись замера называла не то окно. На витрине стояло «сделки с июня 2025 года, 327 сделок». Выборка бэктеста берётся ORDER BY id DESC LIMIT :sample, и все 327 попавших сделок оказались одного квартала. Витрина это подтверждала: 20 из 20 строк — «II квартал 2026».

Побочно вскрылось важное: deals.deal_date у Росреестра — метка квартала, а не дата сделки (в окне всего 4 различных значения). Значит квартал и есть предельная точность, которую данные позволяют назвать.

Чиню подпись, а не замер: случайной выборки в скрипте нет, и честнее назвать окно, которое покрыто. Стало «сделки II квартала 2026 года».

2. Число опубликовано без даты замера. «14,5% / 88% / 325 из 327» — разовый ручной прогон, дата лежала в поле source и на страницу не выводилась. Он старел бы молча. Дата теперь печатается прямо над плитками, а протухание сделано чьей-то обязанностью: тест краснеет, когда замеру больше 100 дней, с инструкцией «перегнать либо снять блок; двигать дату без пересчёта — враньё». Фальсифицирован.

Заодно показана доля выборки: 327 из 5 954 годных сделок квартала — 5,5%, а не «столько и было».

3. Колонка «Цена ДКП» показывала реконструкцию. fact_rub = price_per_m2 × area_m2, при том что deals.price_rub лежит в той же строке. Корень: price_per_m2 в базе целочисленный, поэтому произведение промахивается на единицы рублей — 4 799 995 против 4 800 000 в договоре. Подпись обещала документ, значение было производной.

Теперь берётся price_rub; err_pct считается от того же числа, что показано, — разъехаться не может по построению. Строка без цены не показывается, реконструкцию не подставляем (это вернуло бы дефект). Покрытие замерено: 33 555 из 33 555.

Две находки аудита опровергнуты замером

«Экспозиция занижена втрое» — неверно. Аудитор сравнил listing_date (26 дн.) с publish_date (75 дн.) и предположил разный смысл полей. Проверка: там, где заполнены оба, они совпадают — у Яндекса 10 761 из 10 903, медиана разницы 0 дней. Разница в 75 против 26 — артефакт разных выборок, а не семантики.

Но рядом нашёлся настоящий дефект: Домклик выпадал из метрики целиком (0 из 3 061 активной строки — у него нет listing_date), метрика считалась по 83,6% объявлений, и подпись об этом молчала. Исправлено COALESCE → 95,2%, медиана не изменилась. Охват теперь назван в подписи.

«Половина городов из списка ведёт в отказ» — довод негоден. Аудитор считал покрытие по listings.city, а там хранится город свипа скрейпера, не геокод объявления: вокруг Берёзовского все 90 строк лежат с city='Екатеринбург'. Симуляция настоящей когорты /coverage на случайных адресах: пустая когорта — 3% в ЕКБ и 21% в Среднеуральске, то есть редкий край, а не половина. Реальная разница в другом — доля проверок, дотягивающих до городского порога: ЕКБ 83%, Ревда 16%. Это и показано.

Остальные находки

FAQ обещал процесс, которого нет, и спорил с самой страницей. Ответ утверждал, что мы «отслеживаем снятие объявлений с публикации» и по этим данным считаем расхождение. Расхождение считается только по ценам ДКП; признак снятия не пишется намеренно — в коде прямо сказано, что он невыводим из наших данных (is_active означает «мы видели», покрытие обхода 10–35%). На проде delisted — 0 строк. Ответ переписан на то, что делается, плюс отдельный абзац о том, почему снятие не считается сделкой.

«150 ₽ против двух месяцев вашей жизни» и «не зависли на полгода» — обе длительности взяты из воздуха при собственной плитке страницы в 26 дней. Убраны, а не заменены измеренными: единственный близкий замер цензурирован (считается по тем, кто ещё висит) и сроком продажи не является.

Результат формы уходил под сгиб. На телефоне после отправки на экране оставалась та же форма — визуально «ничего не произошло». Добавлены перевод фокуса на область ответа и прокрутка с уважением к prefers-reduced-motion. Форма не убирается: её замена сломала бы редактирование параметров.

Публичная ручка падала в 500 мимо рейт-лимита. {"lon": 1e400} → 500, двенадцать запросов подряд без единого 429. Механизм: json.loads принимает Infinity, pydantic отбивает по границе, значение попадает в поле input ответа 422 — и сам ответ об ошибке не сериализуется с allow_nan=False, падая уже после входа в ответ.

Починено на уровне сборки ответа, а не поля: один обработчик на приложение закрывает любое число любой будущей схемы. Рейт-лимит перенесён в зависимости у всех шести публичных ручек — там же, где сегодня уже чинили гейт флага по той же причине (FastAPI валидирует тело раньше тела хендлера). Не закрыто осознанно и записано в докстринге: тело, вообще не разбираемое как JSON, даёт 422 до зависимостей и остаётся под общим лимитером.

Проверено

Бэкенд 5113 passed / 35 skipped, ruff чисто. Фронтенд tsc · eslint · vitest 161/161 · изоляция публичного дерева. Каждая починка фальсифицирована руками: сломал → красный по значению → вернул.

После мержа нужен ручной пересчёт витрины — иначе на сайте останутся старые реконструированные цены:

ssh poincare "docker exec tradein-backend python -m app.tasks.landing_showcase_deals --sample 200 --limit 20"
Полный проход по живому сайту шестью независимыми линзами (числа, работоспособность, дизайн глазами, мобильный, периметр, данные под витриной). Здесь — правки по найденному. Пять веток сведены в одну, чтобы не гнать пять деплоев подряд. **Что в аудите оказалось в порядке** (тоже результат): периметр держится — шесть попыток обхода белого списка отбиты, выключенные ручки не выдают схему, `noindex` на всех восьми страницах, внешних ресурсов не подгружается. Тракт расчёта честен от подсказки адреса до результата, включая «дата известна у 5 из 25» и исчезающую плитку при нехватке данных. ## Три находки — мои собственные ошибки **1. Подпись замера называла не то окно.** На витрине стояло «сделки с июня 2025 года, 327 сделок». Выборка бэктеста берётся `ORDER BY id DESC LIMIT :sample`, и все 327 попавших сделок оказались одного квартала. Витрина это подтверждала: 20 из 20 строк — «II квартал 2026». Побочно вскрылось важное: `deals.deal_date` у Росреестра — **метка квартала, а не дата сделки** (в окне всего 4 различных значения). Значит квартал и есть предельная точность, которую данные позволяют назвать. Чиню подпись, а не замер: случайной выборки в скрипте нет, и честнее назвать окно, которое покрыто. Стало «сделки II квартала 2026 года». **2. Число опубликовано без даты замера.** «14,5% / 88% / 325 из 327» — разовый ручной прогон, дата лежала в поле `source` и на страницу не выводилась. Он старел бы молча. Дата теперь печатается прямо над плитками, а протухание сделано **чьей-то обязанностью**: тест краснеет, когда замеру больше 100 дней, с инструкцией «перегнать либо снять блок; двигать дату без пересчёта — враньё». Фальсифицирован. Заодно показана доля выборки: 327 из 5 954 годных сделок квартала — 5,5%, а не «столько и было». **3. Колонка «Цена ДКП» показывала реконструкцию.** `fact_rub = price_per_m2 × area_m2`, при том что `deals.price_rub` лежит в той же строке. Корень: `price_per_m2` в базе целочисленный, поэтому произведение промахивается на единицы рублей — 4 799 995 против 4 800 000 в договоре. Подпись обещала документ, значение было производной. Теперь берётся `price_rub`; `err_pct` считается от того же числа, что показано, — разъехаться не может по построению. Строка без цены не показывается, реконструкцию не подставляем (это вернуло бы дефект). Покрытие замерено: 33 555 из 33 555. ## Две находки аудита опровергнуты замером **«Экспозиция занижена втрое» — неверно.** Аудитор сравнил `listing_date` (26 дн.) с `publish_date` (75 дн.) и предположил разный смысл полей. Проверка: там, где заполнены оба, они **совпадают** — у Яндекса 10 761 из 10 903, медиана разницы 0 дней. Разница в 75 против 26 — артефакт разных выборок, а не семантики. **Но рядом нашёлся настоящий дефект:** Домклик выпадал из метрики **целиком** (0 из 3 061 активной строки — у него нет `listing_date`), метрика считалась по 83,6% объявлений, и подпись об этом молчала. Исправлено `COALESCE` → 95,2%, медиана не изменилась. Охват теперь назван в подписи. **«Половина городов из списка ведёт в отказ» — довод негоден.** Аудитор считал покрытие по `listings.city`, а там хранится город **свипа скрейпера**, не геокод объявления: вокруг Берёзовского все 90 строк лежат с `city='Екатеринбург'`. Симуляция настоящей когорты `/coverage` на случайных адресах: пустая когорта — 3% в ЕКБ и 21% в Среднеуральске, то есть редкий край, а не половина. Реальная разница в другом — доля проверок, дотягивающих до городского порога: ЕКБ 83%, Ревда 16%. Это и показано. ## Остальные находки **FAQ обещал процесс, которого нет, и спорил с самой страницей.** Ответ утверждал, что мы «отслеживаем снятие объявлений с публикации» и по этим данным считаем расхождение. Расхождение считается только по ценам ДКП; признак снятия не пишется **намеренно** — в коде прямо сказано, что он невыводим из наших данных (`is_active` означает «мы видели», покрытие обхода 10–35%). На проде `delisted` — 0 строк. Ответ переписан на то, что делается, плюс отдельный абзац о том, почему снятие не считается сделкой. **«150 ₽ против двух месяцев вашей жизни»** и **«не зависли на полгода»** — обе длительности взяты из воздуха при собственной плитке страницы в 26 дней. Убраны, а не заменены измеренными: единственный близкий замер цензурирован (считается по тем, кто ещё висит) и сроком продажи не является. **Результат формы уходил под сгиб.** На телефоне после отправки на экране оставалась та же форма — визуально «ничего не произошло». Добавлены перевод фокуса на область ответа и прокрутка с уважением к `prefers-reduced-motion`. Форма не убирается: её замена сломала бы редактирование параметров. **Публичная ручка падала в 500 мимо рейт-лимита.** `{"lon": 1e400}` → 500, двенадцать запросов подряд без единого 429. Механизм: `json.loads` принимает `Infinity`, pydantic отбивает по границе, значение попадает в поле `input` ответа 422 — и **сам ответ об ошибке** не сериализуется с `allow_nan=False`, падая уже после входа в ответ. Починено на уровне сборки ответа, а не поля: один обработчик на приложение закрывает любое число любой будущей схемы. Рейт-лимит перенесён в зависимости у **всех шести** публичных ручек — там же, где сегодня уже чинили гейт флага по той же причине (FastAPI валидирует тело раньше тела хендлера). Не закрыто осознанно и записано в докстринге: тело, вообще не разбираемое как JSON, даёт 422 до зависимостей и остаётся под общим лимитером. ## Проверено Бэкенд **5113 passed / 35 skipped**, `ruff` чисто. Фронтенд `tsc` · `eslint` · `vitest 161/161` · изоляция публичного дерева. Каждая починка фальсифицирована руками: сломал → красный по значению → вернул. **После мержа нужен ручной пересчёт витрины** — иначе на сайте останутся старые реконструированные цены: ```bash ssh poincare "docker exec tradein-backend python -m app.tasks.landing_showcase_deals --sample 200 --limit 20" ```
bot-backend added 9 commits 2026-08-29 19:17:58 +00:00
FAQ обещал, что мы «отслеживаем снятие объявлений с публикации» и считаем по
этому расхождение прогноза с реальностью. Ни того, ни другого нет: расхождение
считают landing_showcase_deals.py и backtest_estimator.py, оба берут только цену
ДКП; delisted/relisted listing_source_snapshot.py не пишет намеренно (не выводимы
при покрытии обхода 10-35%), на проде 0 таких строк в listing_source_events,
deals.days_on_market заполнена 0 из 108 623. Ответ приведён к тому, что делается,
и прямо говорит, что снятие сделкой не считаем — двумя блоками выше AccuracyV3
по той же причине зовёт величину «экспозицией АКТИВНОГО объявления».

Два срока без источника убраны, а не заменены числом: «против двух месяцев вашей
жизни» (CostOfErrorV3) и «не зависли на полгода» (HeroV3). Измеренная экспозиция
считается по тем, кто ещё висит, и сроком продажи не является — подставлять её
на место этих сроков значило бы подменить величину.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Подпись обещала «сделки с июня 2025 года», а выборка бэктеста берётся
ORDER BY id DESC LIMIT :sample (backend/scripts/backtest_estimator.py,
_SAMPLE_SQL) — это последние по порядку загрузки строки, а не срез окна.
Проверка на проде 30.08.2026: у всех 327 сделок deal_date = 2026-04-01,
то есть один квартал; проверены оба варианта запуска (без --city и с
--city Екатеринбург) — результат одинаковый. Случайной выборки в скрипте
нет, поэтому чинится подпись, а не замер: числа те же, окно названо своё.

Заодно:
- доля выборки на витрине (5,5 % сделок квартала). Знаменатель — из ТОГО ЖЕ
  окна (5 954 годных сделки ЕКБ за II кв 2026), а не 24 333 за всё окно
  с июня 2025: доля от непокрытого окна повторила бы ту же ошибку;
- дата замера выведена рядом с числами: регулярного пересчёта у них нет,
  без даты они стареют молча;
- __tests__/backtest-freshness.test.ts краснеет, когда замеру больше
  BACKTEST_MAX_AGE_DAYS (100 дн. = квартальная пачка Росреестра + запас).
  Фальсифицирован: дата 2026-01-05 → красный с текстом «замеру 236 дн.».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Витрина показывала fact_rub = price_per_m2 * area_m2, хотя deals.price_rub
лежит в той же строке и не использовалась. price_per_m2 в базе integer,
поэтому под подписью «Цена ДКП» ехала реконструкция: 4 799 995 вместо
4 800 000, 3 649 995 вместо 3 650 000 (прод, сделки 5777343 и др.).
Теперь price_rub едет из выборки (DealSample.price_rub) и показывается как
есть; err_pct считается от той же величины. Строка без price_rub НЕ
показывается — подставлять реконструкцию в одну строку из двадцати значило бы
спрятать тот же дефект (на проде price_rub заполнен у 33 555 из 33 555 сделок
выборки витрины).

Вторая находка аудита (listing_date якобы «когда увидели МЫ», экспозиция
занижена втрое) НЕ ПОДТВЕРДИЛАСЬ. listing_date пишут cian (added_ts), yandex
(creationDate) и avito (дата карточки выдачи) — это дата публикации у
источника. Там, где заполнены и listing_date, и publish_date, они совпадают:
yandex 10 761 из 10 903, avito 474 из 569, медиана разницы 0 дней. 75 дней у
аудитора — эффект другой ВЫБОРКИ: publish_date есть у 15 058 активных строк
(yandex + Домклик, оба старые), listing_date — у 25 982 (плюс cian с медианой
17 дней и 87% avito с медианой 19).

Настоящий дефект рядом: по одному listing_date Домклик выпадал целиком (0 из
3061 активной строки), метрика считалась по 83.6% активных объявлений, и
подпись об этом молчала. COALESCE(listing_date, publish_date) → охват 95.2%
(29 568 из 31 068), медиана та же — 26 дней; охват теперь назван в note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Аудит живого сайта 30.08.2026: POST /api/public/mera/coverage с
{"lat":56.8,"lon":1e400,...} отвечал 500, и двенадцать таких запросов подряд
дали двенадцать пятисоток и ни одного 429. Две независимые поломки в одном
месте, обе воспроизведены локально до правки.

1. 500 вместо 422. json.loads принимает Infinity/-Infinity/NaN, а 1e400 даёт
   inf переполнением. Pydantic отбивает такое поле по границам и кладёт
   значение в input ошибки, а ответ об ошибке сериализуется
   json.dumps(allow_nan=False) и падает уже после входа в ответ. Ломается не
   поле, а сборка ответа об ошибке — одна на всё приложение, поэтому и
   обработчик один (app/core/http_errors.py), а не валидатор на lon.

2. Лимитер мимо. _enforce стоял первой строкой тела хендлера, а FastAPI
   валидирует тело позже зависимостей, но раньше тела — до проверки просто не
   доходило. Та же поправка места, что уже сделана сегодня у
   _require_public_estimate_enabled: перенос в dependencies. Сделано для всех
   ручек файла, не только coverage. У /estimate и /estimate/read флаг остаётся
   первой зависимостью — 429 на выключенной ручке подтверждал бы её
   существование.

Тесты двусторонние: снятие обработчика роняет 4 проверки 422, возврат лимитера
в тело роняет проверку бюджета (проверено).
Экран проверки дорисовывал результат НИЖЕ формы и никуда не уводил: на 375 px
человек после нажатия видел ту же форму, а заголовок ответа оставался за нижней
кромкой — нажатие читается как «ничего не произошло». Ответ теперь получает
фокус и прокрутку; анимация прокрутки спрашивается у prefers-reduced-motion, той
же медиа-функции, что глушит остальную анимацию витрины. Фокус здесь не
украшение: без него клавиатурный пользователь остаётся на кнопке и следующим Tab
уходит в обход ответа, а живая область объявляет текст, но не перемещает точку
ввода.

Второе: в дропдауне девять городов, и они не равны по данным, но узнать об этом
можно было только ПОСЛЕ нажатия. Замер на проде (30.08.2026, симуляция когорты
самой ручки /coverage по случайным адресам активных объявлений, собственный
адрес исключён): доля проверок с выборкой не ниже городского порога — ЕКБ 83 %,
Верхняя Пышма 70, Серов 58, Нижний Тагил 52, Первоуральск 50,
Каменск-Уральский 45, Среднеуральск 39, Берёзовский 38, Ревда 16 (по 120
адресов, Среднеуральск — 56, столько их там есть). Величина и её источник лежат
в landing-facts.ts, формулировка — в coverage-copy.ts, в компонент не вписано
ни одного числа.

Города из списка НЕ убраны: систематического отказа нет ни в одном (пустая
когорта у худшего — 11 случаев из 100), разница между ними количественная, и её
честнее назвать числом, чем снятием опции. Счёт по listings.city, дающий ноль по
трём городам-спутникам, здесь не годится — колонка хранит город свипа скрейпера,
а не геокод объявления (разбор над _CITY_CENTROIDS_DEG в trade_in.py).

Тест требует замера на каждый город из OBLAST_CITIES — добавить город в дропдаун,
не измерив его, теперь нельзя.
Merge remote-tracking branch 'origin/fix/audit-accuracy-window' into fix/audit-all
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (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 / frontend-checks (pull_request) Successful in 1m2s
CI Trade-In / backend-tests (pull_request) Successful in 4m55s
2eebc680a6
bot-backend merged commit 5e9619d662 into main 2026-08-29 19:23:45 +00:00
bot-backend deleted branch fix/audit-all 2026-08-29 19:23:45 +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#3247
No description provided.