fix(tradein/imv): домовая оценка перестаёт врать про ремонт и тип дома (#2674) #2675

Merged
bot-backend merged 2 commits from fix/2674-house-imv-params into main 2026-08-05 20:02:31 +00:00
Collaborator

Главное: почему бэкфилл не сохранил ни одной оценки за 34 дня

Дело не в параметрах. Даже с идеальными параметрами эффекта не было бы: запросы не доходили до Авито.

31 прогон подряд (scrape_runs.source='house_imv_backfill', 05.07–05.08): saved=0, checked=50, errors 30–50. Разбивка причин по houses.imv_error_reason:

Период Причина Домов
05.07 – 02.08 (29 прогонов) 503 browser unavailable (proxy may be down) от tradein-browser:3000/fetch-json 1240
26.06 – 05.08 500 от сайдкара: Page.evaluate: Execution context was destroyed, most likely because of a navigation 83
05.08 (последний прогон) 403 Авито (geocode-A / geocode-B / imv-evaluate) 30
весь период сетевые (All connection attempts failed, Server disconnected) ~43

То есть 93.5% отказов (1240 из 1327) произошли ДО единственного запроса к Авито — сайдкар отказывался поднимать браузер. Это гейт #2616 (browser/server.py:731): на проде без прокси мы намеренно не ходим напрямую с IP сервера. Прокси лежал ~месяц (ср. ротация #2611 и токен ASOCKS).

Последний прогон 05.08 показывает, что прокси вернулся — 503 исчезли, но появилось два новых блокера, и saved всё равно 0 (50 домов = 15 no_params + 23 сайдкар-500 + 12 403):

a) Сайдкар, Page.evaluate: Execution context was destroyed. _fetch_json_once (browser/server.py:998-1038) делает page.goto(origin, wait_until="domcontentloaded"), ждёт фиксированный FETCH_JSON_SETTLE_MS и затем page.evaluate(...). Страница Авито за это время уходит в клиентскую навигацию → контекст исполнения умирает → исключение. Ретрая нет: _is_browser_crash (server.py:1062) матчит только TargetClosedError / browser has been closed / target closed / connection closed / browser disconnected — «execution context was destroyed» в список не входит, поэтому _do_fetch_json не делает relaunch+retry и отдаёт 500. В логах хорошо видно: один и тот же URL падает дважды подряд (in-page retry внутри JS не помогает, страницы уже нет), а соседние запросы того же прогона возвращают 200.

b) 403 Авито на всех трёх шагах — анти-бот/бан IP, внешнее ограничение.

Что нужно (не входит в этот PR — другой файл, общий сайдкар всех провайдеров): добавить «execution context was destroyed» / «most likely because of a navigation» в распознавание восстановимого сбоя, чтобы _do_fetch_json делал повтор на свежей странице (или дожидаться load/networkidle вместо фиксированного settle). Это один общий на avito/cian/domclick путь — правка туда заслуживает своего PR и своего ревью. 403 кодом не чинится: это прокси/ротация IP (#2611).

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


Три дефекта: что было и что стало

1. Тип ремонта захардкожен (деньги)

Было: литерал "renovation_type": "cosmetic" в pick_lot_params — 2685 из 2685 запросов ушли как «косметический ремонт» (подтверждено: SELECT renovation_type, count(*) FROM house_imv_evaluationscosmetic 2685).
Реальная мода repair_state по объявлениям тех же домов: standard 4564 / good 4118 / needs_repair 2279 / excellent 1631 — косметика лишь 36%.

Стало: mode() WITHIN GROUP (ORDER BY repair_state) добавлен в тот же агрегат, который уже считает медианы комнат/площади/этажа (лишнего запроса нет), значение проходит через существующий estimator._IMV_REPAIR_MAP (needs_repair→required / standard→cosmetic / good→euro / excellent→designer). Второго словаря не заведено; импорт ленивый, потому что estimatorscraper_adaptershouse_imv_backfill — цикл (тот же приём, что уже применён в файле для RealScraperConfig).

Неизвестный ремонт (498 домов из 2685 — ни одного объявления с repair_state) остаётся cosmetic, в отличие от неизвестного типа дома. Обоснование по данным: cosmetic (=standard) — это одновременно мода и медианная категория популяции (standard 7984 / good 7116 / needs_repair 4738 / excellent 2562; кумулятивно needs_repair 21.2%, +standard 56.8%), то есть наилучшая одиночная догадка, а не просто «не край шкалы». У типа дома такой догадки нет: panel — почти край.

Асимметрия названа в комментарии, чтобы следующий читатель не принял её за недосмотр: поштучный путь эстиматора при неизвестном ремонте IMV вообще не зовёт, а домовой дефолтит — иначе теряем ещё ~32% домов очереди поверх тех, что уже отсекает неизвестный тип дома.

2. Неизвестный тип дома молча становился «панелью» (деньги)

Было: _HOUSE_TYPE_DEFAULT = "panel" возвращался и когда типа нет вовсе, и когда он есть, но не совпал со словарём после .lower().
Панель — почти самый дешёвый класс. Замер по нашим же 2685 оценкам (все при одном и том же cosmetic, медиана recommended_price/area):

houseType n медиана руб/м²
block 163 122 605
panel 1499 128 837
brick 656 131 115
monolithic 367 145 867

Занижение: 363 дома без типа где-либо + 75 домов с camelCase-типом (monolithBrick 56, other 18, stalin 3, aerocreteBlock/gasSilicateBlock/wireframe по 1). Для 56 домов monolithBrick это −11.7% (128.8k вместо 145.9k). .lower() тут не лечит: ключ канона пишется через подчёркивание (monolith_brick), а приходит monolithbrick.

Стало: сырое значение прогоняется через общий scraper_kit.house_type_normalizer.normalize_house_type (#2007) — он уже знает camelCase-вокабуляр Циана (monolithBrick / gasSilicateBlock / aerocreteBlock / foamConcreteBlock / stalin) и SCREAMING-вокабуляр Яндекса. Дефолт panel удалён. Тип не распознан → house_type=None → дом помечается no_params с причиной unknown house_type (тот же путь, что уже есть для «нет комнат/площади») и запрос не тратится.

other и wireframe намеренно НЕ маппятся: честного соответствия в вокабуляре Авито у них нет, а любое присвоение — то же враньё, только в другую сторону.

Цена этой честности на НЕснятых домах — больше, чем на снятых

Эффект на 2685 уже снятых посчитан ниже, но главное число — про очередь. Из домов, до которых бэкфилл ещё не дошёл, в «нет параметров» уедут:

очередь домов с параметрами из них тип неизвестен → запрос не уйдёт никогда
pending 3831 883 (23%)
transient_error 1414 411 (29%)

Причём camelCase тут ни при чём: среди этих домов нераспознаваемых токенов ноль, у всех тип отсутствует вообще нигде — ни в объявлениях, ни в houses.

Практически это значит, что у этих домов в интерфейсе не будет маркера оценки Авито — рекомендованная цена рисуется напрямую. Это не дефект, а ровно та честность, которую PR и продаёт, и она совпадает с поштучным путём эстиматора (тот при неизвестном типе IMV тоже не зовёт). Смягчающее: у фронта есть progressive enrichment — недостающий тип он умеет спросить у пользователя.

Решение о заморозке 369 строк (см. ниже) принимать в связке с этим числом, а не отдельно.

3. Прогон не умел падать (наблюдаемость)

Было: _job_house_imv_backfill всегда звал mark_done — 31 прогон с saved=0 и десятками ошибок помечен успешным.
Стало: saved == 0 and errors > 0mark_failed с текстом saved=0 при errors=N (checked=M). Честная пустота (ноль сохранённых без ошибок — всё ушло в skipped) по-прежнему done; частичный успех (saved>0 при ошибках) тоже done.

Плюс (второй коммит, по ревью): счётчики доезжают до выделенных колонок. _column_counts (scrape_runs.py) берёт total_seen из ключей total_seen|lots_fetched и new_count из new_count|lots_inserted — ни одного из них в дикте не было, поэтому все 39 прогонов этого source лежат в БД с total_seen=0 (проверено). А mark_done по этой же колонке шлёт алерт «3 подряд done с нулевым результатом» (#2625) — то есть даже идеальный прогон с 50 сохранёнными считался бы нулевым и через три дня выстрелил бы ложной тревогой про капчу. Добавлены total_seen=checked, new_count=saved. Трейд-офф назван в комментарии: на исчерпанной очереди checked=0 три дня подряд тоже даст алерт — но пустая очередь при ежедневном расписании это и правда сигнал.

Плюс: лог на дрейф вокабуляра ремонта. _IMV_REPAIR_MAP.get(x) or "cosmetic" молча схлопывал любое незнакомое непустое значение. Сегодня в проде ровно четыре канонических, живого эффекта нет, но дрейф реален — добавлен logger.debug, паритет с house_type_normalizer, который такой лог уже пишет.


План пересъёмки старых 2685 оценок (подготовлен, НА ПРОДЕ НЕ ВЫПОЛНЕН)

У house_imv_evaluations уникальность по house_id, поэтому запись перезапишется только при новом прогоне. Посчитал, сколько домов реально изменят параметры (пересчёт новых значений из тех же источников):

домов
всего оценок 2685
параметры изменятся → нужна пересъёмка 1343
— из них меняется ремонт 1096
— из них меняется тип дома 623
панель/косметика реально верны → трогать не надо 973
тип дома теперь неизвестен → запрос не пойдёт 369

Бюджет запросов. Один дом = 4 HTTP к Авито (warm-up /evaluation/realty + coords/by_address + geo/position + realty-imv/get-data). 1343 × 4 ≈ 5372 запроса.
При текущем расписании (batch_size=50, раз в сутки, request_delay_sec=5) это 27 суток только на пересъёмку — и это поверх уже стоящей очереди 5672 pending + 1414 transient_error (~142 суток).

Поэтому «разом» не предлагаю. Рекомендация — растянуть по времени, порциями, и только после того, как починен сайдкар (иначе пересъёмка просто сожжёт очередь в 500-е):

-- Порция пересъёмки: N домов, у которых сохранённые параметры реально расходятся
-- с текущими данными. Гонять раз в сутки с N=50..200, пока не опустеет.
WITH src AS (
  SELECT e.house_id, e.renovation_type AS old_ren, e.house_type AS old_ht,
         (SELECT mode() WITHIN GROUP (ORDER BY l.repair_state) FROM listings l
           WHERE l.house_id_fk = e.house_id
             AND l.rooms IS NOT NULL AND l.area_m2 IS NOT NULL) AS rs,
         COALESCE((SELECT mode() WITHIN GROUP (ORDER BY l.house_type) FROM listings l
           WHERE l.house_id_fk = e.house_id
             AND l.rooms IS NOT NULL AND l.area_m2 IS NOT NULL), h.house_type) AS ht
  FROM house_imv_evaluations e
  JOIN houses h ON h.id = e.house_id
), m AS (
  SELECT house_id, old_ren, old_ht,
         CASE rs WHEN 'needs_repair' THEN 'required' WHEN 'standard' THEN 'cosmetic'
                 WHEN 'good' THEN 'euro' WHEN 'excellent' THEN 'designer'
                 ELSE 'cosmetic' END AS new_ren,
         CASE ht WHEN 'panel' THEN 'panel' WHEN 'brick' THEN 'brick'
                 WHEN 'monolith' THEN 'monolithic' WHEN 'monolith_brick' THEN 'monolithic'
                 WHEN 'monolithBrick' THEN 'monolithic' WHEN 'stalin' THEN 'brick'
                 WHEN 'block' THEN 'block' WHEN 'gasSilicateBlock' THEN 'block'
                 WHEN 'aerocreteBlock' THEN 'block' WHEN 'foamConcreteBlock' THEN 'block'
                 WHEN 'wood' THEN 'wood' ELSE NULL END AS new_ht
  FROM src
)
UPDATE houses SET imv_status = 'pending'
WHERE id IN (
  SELECT house_id FROM m
  WHERE new_ht IS NOT NULL AND (new_ren <> old_ren OR new_ht <> old_ht)
  ORDER BY house_id
  LIMIT 100      -- размер порции
);

Миграцией это НЕ оформлено намеренно: tradein-mvp/backend/data/sql/NN_*.sql применяется на прод автоматически на деплое, а решение о пересъёмке и её темпе — за владельцем.

Отдельно на решение: у 369 домов тип дома теперь неизвестен, честно переснять их нельзя. Их старые строки panel/cosmetic остаются в таблице и продолжают читаться эстиматором. Варианты — удалить эти строки (потерять цену) либо пометить их провенансом «параметры угаданы». Не делал ни того, ни другого: это продуктовое решение, не техническое.


Что НЕ входит

  • Починка сайдкара tradein-browser (Page.evaluate: Execution context was destroyed) — другой файл, общий для avito/cian/domclick, свой blast radius и своё ревью.
  • Прокси / 403 Авито — внешнее ограничение, кодом не чинится (ср. #2611).
  • Пересъёмка на проде — только план и SQL выше.
  • Балкон/лоджия оставлены константами: покрытие listings.has_balcony 13.8%, listings.balcony_loggia 9.4%, и колонки противоречат друг другу (по has_balcony «есть» у 62%, а по balcony_loggia самый частый случай — loggia 5650 против balcony 2794). Мода по одному-двум объявлениям на таком покрытии — шум.
  • Значение monolith_brick → monolithic не трогал: по эху itemParams.rawParams из ответов Авито видно, что monolithic — валидный токен (param 498 = 5247, отличный от brick 5244 / panel 5245 / block 5246). Расхождение с estimator._IMV_HOUSE_TYPE_MAP (там monolith_brick → monolith_brick) осталось как есть — зонд monolith против monolithic идёт отдельным follow-up.
  • Follow-up от ревью (не в этом PR): repair-aware якорь, twin-миграция нормализации типа дома в houses по образцу 141, min-support и фильтр по активности для моды, сон 5 с на скипнутых домах, провенанс/удаление 369 строк.

Отдельно про денежный риск, который создаёт этот фикс (довод за осторожную пересъёмку): подбор домового якоря идёт по комнатам и площади и не смотрит на тип ремонта, а подмешивание однонаправленное вверх. Раньше все 2685 строк были cosmetic — база была кривой, но однородной. После пересъёмки дом, где объявления скошены в «евро», получит более дорогой якорь, и он применится к клиентской квартире с любым ремонтом, включая «требует ремонта» — только вверх. Поэтому пересъёмку начинать с полусотни домов и замера сдвига, а не с массового сброса.

Test plan

  • pytest tests/test_house_imv_params_honesty.py tests/test_backfill_wave2.py tests/test_house_imv_backfill_browser_flag.py tests/test_sweep_imv_phase.py tests/test_scraper_kit_scheduler_parity.py -q → 91 passed (+1 новый тест на total_seen/new_count во втором коммите)
  • Полный прогон бэкенда: 3081 passed, 8 skipped, 1 failed — test_search_api.py::test_search_cache_hit (401), падает и на чистом origin/main, к этому PR отношения не имеет
  • Фальсификация второго коммита: test_counters_feed_total_seen_and_new_count краснеет на дикте без ключей (_column_counts(None, None))
  • Фальсификация первого коммита патч-методом (код откатан, тесты прогнаны, код возвращён): 18 красных, в т.ч. все camelCase-кейсы, все «неизвестное → не panel», skip-запроса, и saved=0+errors>0 → не done. Честно: standard → cosmetic НЕ краснеет — старый хардкод случайно совпадал с правильным ответом для этой одной ветки; красноту дефекта ловят остальные три параметра и test_renovation_type_not_hardcoded_cosmetic. Анти-оверрич-тесты (честная пустота → done, частичный успех → done, неизвестный ремонт → cosmetic) зелёные и до, и после — это гейты, а не регрессии.
  • ruff check / ruff format --check по изменённым файлам чисто; pre-commit прошёл

Refs #2674

## Главное: почему бэкфилл не сохранил ни одной оценки за 34 дня Дело **не в параметрах**. Даже с идеальными параметрами эффекта не было бы: запросы не доходили до Авито. 31 прогон подряд (`scrape_runs.source='house_imv_backfill'`, 05.07–05.08): `saved=0`, `checked=50`, `errors` 30–50. Разбивка причин по `houses.imv_error_reason`: | Период | Причина | Домов | |---|---|---| | 05.07 – 02.08 (29 прогонов) | `503 browser unavailable (proxy may be down)` от `tradein-browser:3000/fetch-json` | **1240** | | 26.06 – 05.08 | `500` от сайдкара: `Page.evaluate: Execution context was destroyed, most likely because of a navigation` | 83 | | 05.08 (последний прогон) | `403` Авито (`geocode-A` / `geocode-B` / `imv-evaluate`) | 30 | | весь период | сетевые (`All connection attempts failed`, `Server disconnected`) | ~43 | То есть **93.5% отказов (1240 из 1327) произошли ДО единственного запроса к Авито** — сайдкар отказывался поднимать браузер. Это гейт #2616 (`browser/server.py:731`): на проде без прокси мы намеренно не ходим напрямую с IP сервера. Прокси лежал ~месяц (ср. ротация #2611 и токен ASOCKS). Последний прогон 05.08 показывает, что прокси вернулся — `503` исчезли, но появилось два новых блокера, и `saved` всё равно 0 (50 домов = 15 `no_params` + 23 сайдкар-500 + 12 `403`): **a) Сайдкар, `Page.evaluate: Execution context was destroyed`.** `_fetch_json_once` (`browser/server.py:998-1038`) делает `page.goto(origin, wait_until="domcontentloaded")`, ждёт фиксированный `FETCH_JSON_SETTLE_MS` и затем `page.evaluate(...)`. Страница Авито за это время уходит в клиентскую навигацию → контекст исполнения умирает → исключение. Ретрая нет: `_is_browser_crash` (`server.py:1062`) матчит только `TargetClosedError` / `browser has been closed` / `target closed` / `connection closed` / `browser disconnected` — «execution context was destroyed» в список не входит, поэтому `_do_fetch_json` не делает relaunch+retry и отдаёт 500. В логах хорошо видно: один и тот же URL падает **дважды подряд** (in-page retry внутри JS не помогает, страницы уже нет), а соседние запросы того же прогона возвращают 200. **b) `403` Авито** на всех трёх шагах — анти-бот/бан IP, внешнее ограничение. **Что нужно (не входит в этот PR — другой файл, общий сайдкар всех провайдеров):** добавить «execution context was destroyed» / «most likely because of a navigation» в распознавание восстановимого сбоя, чтобы `_do_fetch_json` делал повтор на свежей странице (или дожидаться `load`/`networkidle` вместо фиксированного settle). Это один общий на avito/cian/domclick путь — правка туда заслуживает своего PR и своего ревью. `403` кодом не чинится: это прокси/ротация IP (#2611). Иными словами: **этот PR устраняет враньё в данных, но сам по себе не заставит бэкфилл сохранять оценки** — сначала нужен сайдкар + живой прокси. --- ## Три дефекта: что было и что стало ### 1. Тип ремонта захардкожен (деньги) **Было:** литерал `"renovation_type": "cosmetic"` в `pick_lot_params` — 2685 из 2685 запросов ушли как «косметический ремонт» (подтверждено: `SELECT renovation_type, count(*) FROM house_imv_evaluations` → `cosmetic 2685`). Реальная мода `repair_state` по объявлениям тех же домов: standard 4564 / good 4118 / needs_repair 2279 / excellent 1631 — косметика лишь 36%. **Стало:** `mode() WITHIN GROUP (ORDER BY repair_state)` добавлен в тот же агрегат, который уже считает медианы комнат/площади/этажа (лишнего запроса нет), значение проходит через **существующий** `estimator._IMV_REPAIR_MAP` (`needs_repair→required` / `standard→cosmetic` / `good→euro` / `excellent→designer`). Второго словаря не заведено; импорт ленивый, потому что `estimator` → `scraper_adapters` → `house_imv_backfill` — цикл (тот же приём, что уже применён в файле для `RealScraperConfig`). Неизвестный ремонт (498 домов из 2685 — ни одного объявления с `repair_state`) **остаётся `cosmetic`**, в отличие от неизвестного типа дома. Обоснование по данным: `cosmetic` (=`standard`) — это **одновременно мода и медианная категория** популяции (standard 7984 / good 7116 / needs_repair 4738 / excellent 2562; кумулятивно needs_repair 21.2%, +standard 56.8%), то есть **наилучшая одиночная догадка**, а не просто «не край шкалы». У типа дома такой догадки нет: `panel` — почти край. Асимметрия названа в комментарии, чтобы следующий читатель не принял её за недосмотр: поштучный путь эстиматора при неизвестном ремонте IMV вообще не зовёт, а домовой дефолтит — иначе теряем ещё ~32% домов очереди поверх тех, что уже отсекает неизвестный тип дома. ### 2. Неизвестный тип дома молча становился «панелью» (деньги) **Было:** `_HOUSE_TYPE_DEFAULT = "panel"` возвращался и когда типа нет вовсе, и когда он есть, но не совпал со словарём после `.lower()`. Панель — почти самый дешёвый класс. Замер по нашим же 2685 оценкам (все при одном и том же `cosmetic`, медиана `recommended_price/area`): | houseType | n | медиана руб/м² | |---|---|---| | block | 163 | 122 605 | | **panel** | 1499 | **128 837** | | brick | 656 | 131 115 | | monolithic | 367 | 145 867 | Занижение: 363 дома без типа где-либо + 75 домов с camelCase-типом (`monolithBrick` 56, `other` 18, `stalin` 3, `aerocreteBlock`/`gasSilicateBlock`/`wireframe` по 1). Для 56 домов `monolithBrick` это −11.7% (128.8k вместо 145.9k). `.lower()` тут не лечит: ключ канона пишется через подчёркивание (`monolith_brick`), а приходит `monolithbrick`. **Стало:** сырое значение прогоняется через **общий** `scraper_kit.house_type_normalizer.normalize_house_type` (#2007) — он уже знает camelCase-вокабуляр Циана (`monolithBrick` / `gasSilicateBlock` / `aerocreteBlock` / `foamConcreteBlock` / `stalin`) и SCREAMING-вокабуляр Яндекса. Дефолт `panel` удалён. Тип не распознан → `house_type=None` → дом помечается `no_params` с причиной `unknown house_type` (тот же путь, что уже есть для «нет комнат/площади») и **запрос не тратится**. `other` и `wireframe` намеренно НЕ маппятся: честного соответствия в вокабуляре Авито у них нет, а любое присвоение — то же враньё, только в другую сторону. #### Цена этой честности на НЕснятых домах — больше, чем на снятых Эффект на 2685 уже снятых посчитан ниже, но главное число — про очередь. Из домов, до которых бэкфилл ещё не дошёл, в «нет параметров» уедут: | очередь | домов с параметрами | из них тип неизвестен → запрос не уйдёт **никогда** | |---|---|---| | `pending` | 3831 | **883 (23%)** | | `transient_error` | 1414 | **411 (29%)** | Причём **camelCase тут ни при чём**: среди этих домов нераспознаваемых токенов ноль, у всех тип отсутствует вообще нигде — ни в объявлениях, ни в `houses`. Практически это значит, что у этих домов в интерфейсе не будет маркера оценки Авито — рекомендованная цена рисуется напрямую. Это не дефект, а ровно та честность, которую PR и продаёт, и она совпадает с поштучным путём эстиматора (тот при неизвестном типе IMV тоже не зовёт). Смягчающее: у фронта есть progressive enrichment — недостающий тип он умеет спросить у пользователя. **Решение о заморозке 369 строк (см. ниже) принимать в связке с этим числом, а не отдельно.** ### 3. Прогон не умел падать (наблюдаемость) **Было:** `_job_house_imv_backfill` всегда звал `mark_done` — 31 прогон с `saved=0` и десятками ошибок помечен успешным. **Стало:** `saved == 0 and errors > 0` → `mark_failed` с текстом `saved=0 при errors=N (checked=M)`. Честная пустота (ноль сохранённых **без** ошибок — всё ушло в `skipped`) по-прежнему `done`; частичный успех (`saved>0` при ошибках) тоже `done`. **Плюс (второй коммит, по ревью): счётчики доезжают до выделенных колонок.** `_column_counts` (`scrape_runs.py`) берёт `total_seen` из ключей `total_seen|lots_fetched` и `new_count` из `new_count|lots_inserted` — ни одного из них в дикте не было, поэтому **все 39 прогонов этого source лежат в БД с `total_seen=0`** (проверено). А `mark_done` по этой же колонке шлёт алерт «3 подряд `done` с нулевым результатом» (#2625) — то есть даже идеальный прогон с 50 сохранёнными считался бы нулевым и через три дня выстрелил бы ложной тревогой про капчу. Добавлены `total_seen=checked`, `new_count=saved`. Трейд-офф назван в комментарии: на исчерпанной очереди `checked=0` три дня подряд тоже даст алерт — но пустая очередь при ежедневном расписании это и правда сигнал. **Плюс: лог на дрейф вокабуляра ремонта.** `_IMV_REPAIR_MAP.get(x) or "cosmetic"` молча схлопывал любое незнакомое непустое значение. Сегодня в проде ровно четыре канонических, живого эффекта нет, но дрейф реален — добавлен `logger.debug`, паритет с `house_type_normalizer`, который такой лог уже пишет. --- ## План пересъёмки старых 2685 оценок (подготовлен, НА ПРОДЕ НЕ ВЫПОЛНЕН) У `house_imv_evaluations` уникальность по `house_id`, поэтому запись перезапишется только при новом прогоне. Посчитал, сколько домов реально изменят параметры (пересчёт новых значений из тех же источников): | | домов | |---|---| | всего оценок | 2685 | | параметры изменятся → **нужна пересъёмка** | **1343** | | — из них меняется ремонт | 1096 | | — из них меняется тип дома | 623 | | панель/косметика реально верны → трогать не надо | 973 | | тип дома теперь неизвестен → запрос не пойдёт | 369 | **Бюджет запросов.** Один дом = 4 HTTP к Авито (warm-up `/evaluation/realty` + `coords/by_address` + `geo/position` + `realty-imv/get-data`). 1343 × 4 ≈ **5372 запроса**. При текущем расписании (`batch_size=50`, раз в сутки, `request_delay_sec=5`) это **27 суток** только на пересъёмку — и это поверх уже стоящей очереди 5672 `pending` + 1414 `transient_error` (~142 суток). **Поэтому «разом» не предлагаю.** Рекомендация — растянуть по времени, порциями, и только после того, как починен сайдкар (иначе пересъёмка просто сожжёт очередь в 500-е): ```sql -- Порция пересъёмки: N домов, у которых сохранённые параметры реально расходятся -- с текущими данными. Гонять раз в сутки с N=50..200, пока не опустеет. WITH src AS ( SELECT e.house_id, e.renovation_type AS old_ren, e.house_type AS old_ht, (SELECT mode() WITHIN GROUP (ORDER BY l.repair_state) FROM listings l WHERE l.house_id_fk = e.house_id AND l.rooms IS NOT NULL AND l.area_m2 IS NOT NULL) AS rs, COALESCE((SELECT mode() WITHIN GROUP (ORDER BY l.house_type) FROM listings l WHERE l.house_id_fk = e.house_id AND l.rooms IS NOT NULL AND l.area_m2 IS NOT NULL), h.house_type) AS ht FROM house_imv_evaluations e JOIN houses h ON h.id = e.house_id ), m AS ( SELECT house_id, old_ren, old_ht, CASE rs WHEN 'needs_repair' THEN 'required' WHEN 'standard' THEN 'cosmetic' WHEN 'good' THEN 'euro' WHEN 'excellent' THEN 'designer' ELSE 'cosmetic' END AS new_ren, CASE ht WHEN 'panel' THEN 'panel' WHEN 'brick' THEN 'brick' WHEN 'monolith' THEN 'monolithic' WHEN 'monolith_brick' THEN 'monolithic' WHEN 'monolithBrick' THEN 'monolithic' WHEN 'stalin' THEN 'brick' WHEN 'block' THEN 'block' WHEN 'gasSilicateBlock' THEN 'block' WHEN 'aerocreteBlock' THEN 'block' WHEN 'foamConcreteBlock' THEN 'block' WHEN 'wood' THEN 'wood' ELSE NULL END AS new_ht FROM src ) UPDATE houses SET imv_status = 'pending' WHERE id IN ( SELECT house_id FROM m WHERE new_ht IS NOT NULL AND (new_ren <> old_ren OR new_ht <> old_ht) ORDER BY house_id LIMIT 100 -- размер порции ); ``` Миграцией это НЕ оформлено намеренно: `tradein-mvp/backend/data/sql/NN_*.sql` применяется на прод автоматически на деплое, а решение о пересъёмке и её темпе — за владельцем. **Отдельно на решение:** у 369 домов тип дома теперь неизвестен, честно переснять их нельзя. Их старые строки `panel/cosmetic` остаются в таблице и продолжают читаться эстиматором. Варианты — удалить эти строки (потерять цену) либо пометить их провенансом «параметры угаданы». Не делал ни того, ни другого: это продуктовое решение, не техническое. --- ## Что НЕ входит - Починка сайдкара `tradein-browser` (`Page.evaluate: Execution context was destroyed`) — другой файл, общий для avito/cian/domclick, свой blast radius и своё ревью. - Прокси / `403` Авито — внешнее ограничение, кодом не чинится (ср. #2611). - Пересъёмка на проде — только план и SQL выше. - Балкон/лоджия оставлены константами: покрытие `listings.has_balcony` 13.8%, `listings.balcony_loggia` 9.4%, и колонки противоречат друг другу (по `has_balcony` «есть» у 62%, а по `balcony_loggia` самый частый случай — `loggia` 5650 против `balcony` 2794). Мода по одному-двум объявлениям на таком покрытии — шум. - Значение `monolith_brick → monolithic` не трогал: по эху `itemParams.rawParams` из ответов Авито видно, что `monolithic` — валидный токен (param 498 = 5247, отличный от brick 5244 / panel 5245 / block 5246). Расхождение с `estimator._IMV_HOUSE_TYPE_MAP` (там `monolith_brick → monolith_brick`) осталось как есть — зонд `monolith` против `monolithic` идёт отдельным follow-up. - Follow-up от ревью (не в этом PR): repair-aware якорь, twin-миграция нормализации типа дома в `houses` по образцу 141, min-support и фильтр по активности для моды, сон 5 с на скипнутых домах, провенанс/удаление 369 строк. **Отдельно про денежный риск, который создаёт этот фикс** (довод за осторожную пересъёмку): подбор домового якоря идёт по комнатам и площади и **не смотрит на тип ремонта**, а подмешивание однонаправленное вверх. Раньше все 2685 строк были `cosmetic` — база была кривой, но **однородной**. После пересъёмки дом, где объявления скошены в «евро», получит более дорогой якорь, и он применится к клиентской квартире с любым ремонтом, включая «требует ремонта» — только вверх. Поэтому пересъёмку начинать с полусотни домов и замера сдвига, а не с массового сброса. ## Test plan - [x] `pytest tests/test_house_imv_params_honesty.py tests/test_backfill_wave2.py tests/test_house_imv_backfill_browser_flag.py tests/test_sweep_imv_phase.py tests/test_scraper_kit_scheduler_parity.py -q` → 91 passed (+1 новый тест на `total_seen`/`new_count` во втором коммите) - [x] Полный прогон бэкенда: 3081 passed, 8 skipped, 1 failed — `test_search_api.py::test_search_cache_hit` (401), падает и на чистом `origin/main`, к этому PR отношения не имеет - [x] Фальсификация второго коммита: `test_counters_feed_total_seen_and_new_count` краснеет на дикте без ключей (`_column_counts` → `(None, None)`) - [x] Фальсификация первого коммита патч-методом (код откатан, тесты прогнаны, код возвращён): **18 красных**, в т.ч. все camelCase-кейсы, все «неизвестное → не panel», skip-запроса, и `saved=0+errors>0 → не done`. Честно: `standard → cosmetic` НЕ краснеет — старый хардкод случайно совпадал с правильным ответом для этой одной ветки; красноту дефекта ловят остальные три параметра и `test_renovation_type_not_hardcoded_cosmetic`. Анти-оверрич-тесты (честная пустота → done, частичный успех → done, неизвестный ремонт → cosmetic) зелёные и до, и после — это гейты, а не регрессии. - [x] `ruff check` / `ruff format --check` по изменённым файлам чисто; pre-commit прошёл Refs #2674
bot-backend added 1 commit 2026-08-05 19:32:30 +00:00
fix(tradein/imv): домовая оценка перестаёт врать про ремонт и тип дома (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
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 2m51s
0815319e1c
Три дефекта в house_imv_backfill, найденные системным поиском (эпик #2674/#2673).

1. Тип ремонта был захардкожен литералом 'cosmetic' — все 2685 запросов ушли
   как «косметический ремонт», хотя мода repair_state по объявлениям тех же
   домов другая: standard 4564 / good 4118 / needs_repair 2279 / excellent 1631
   (косметика лишь 36%). Теперь renovation_type берётся из mode(repair_state)
   в том же агрегате, что уже считает медианы комнат/площади/этажа, и проходит
   через существующий estimator._IMV_REPAIR_MAP (ленивый импорт — estimator
   тянет scraper_adapters, а тот импортирует этот модуль). Второго словаря не
   заводим. Неизвестный ремонт (498 домов из 2685) остаётся 'cosmetic': это
   середина порядковой шкалы required < cosmetic < euro < designer, а не край,
   системного сдвига в одну сторону не даёт.

2. Неизвестный тип дома молча становился 'panel' — и когда типа нет вовсе, и
   когда он есть, но не совпал со словарём. Панель почти самый дешёвый класс
   (медиана по нашим же 2685 оценкам: block 122.6k < panel 128.8k <
   brick 131.1k < monolithic 145.9k руб/м2), то есть дефолт систематически
   занижал. На проде так уехали 363 дома совсем без типа и 75 домов с
   camelCase-типом из Циана (56 из них monolithBrick — минус 11.7% против
   monolithic). Теперь сырое значение прогоняется через общий
   scraper_kit.house_type_normalizer.normalize_house_type (знает monolithBrick /
   gasSilicateBlock / aerocreteBlock / stalin и SCREAMING-вокабуляр Яндекса),
   дефолт 'panel' убран: тип не распознан → house_type=None → дом помечается
   no_params ('unknown house_type') и запрос к площадке не тратится. 'other' и
   'wireframe' намеренно НЕ маппятся — честного соответствия у них нет.

3. Прогон не умел падать: 31 прогон подряд с saved=0 и ~35 ошибками из 50
   помечен 'done'. Тот же класс, что #2670/#2657 — успех определялся как «не
   поймали известное исключение». Теперь saved=0 при errors>0 → mark_failed.
   Ноль сохранённых БЕЗ ошибок (всё отфильтровано в skipped) остаётся done.

Балкон/лоджия оставлены константами намеренно: покрытие listings.has_balcony
13.8%, listings.balcony_loggia 9.4%, и колонки противоречат друг другу (по
has_balcony «есть» у 62%, а по balcony_loggia самый частый случай — loggia
5650 против balcony 2794). Мода по одному-двум объявлениям на таком покрытии —
шум, а не данные.

Причина, по которой бэкфилл не сохранил НИ ОДНОЙ оценки за 34 дня, — вне этого
модуля и здесь не чинится (детали и числа в описании PR): 1240 домов легли на
отказе браузерного сайдкара «нет прокси» (гейт #2616, 05.07-02.08), а после
возврата прокси 05.08 — 23 на Page.evaluate «Execution context was destroyed»
в tradein-browser и 12 на 403 Авито.

Refs #2674
Light1YT added 1 commit 2026-08-05 19:58:26 +00:00
fix(tradein/imv): счётчики прогона в total_seen/new_count + лог дрейфа ремонта (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
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 2m51s
4b4ab8b34c
По ревью PR #2675.

1. counters прогона не заполняли выделенные колонки. _column_counts
   (scrape_runs.py) берёт total_seen из ключей total_seen|lots_fetched и
   new_count из new_count|lots_inserted — ни одного из них в дикте не было,
   поэтому все 39 прогонов этого source лежат в БД с total_seen=0. А mark_done
   по этой же колонке шлёт алерт «3 подряд done с нулевым результатом» (#2625):
   даже идеальный прогон с 50 сохранёнными считался бы нулевым и через три дня
   выстрелил бы ложной тревогой про капчу. Добавлены total_seen=checked и
   new_count=saved. Трейд-офф назван в комментарии: на исчерпанной очереди
   checked=0 три дня подряд тоже даст алерт — но пустая очередь при ежедневном
   расписании это и правда сигнал.

2. _map_renovation_type молча схлопывал в 'cosmetic' любое незнакомое непустое
   значение. Сегодня в проде ровно четыре канонических, живого эффекта нет, но
   дрейф вокабуляра реален (70950 строк listings с пустым нормализованным
   ремонтом). Добавлен logger.debug на случай «непустое, но не в карте» —
   паритет с house_type_normalizer, который такой лог уже пишет.

3. Обоснование дефолта 'cosmetic' в докстринге заменено на более сильное по
   данным: это одновременно МОДА и МЕДИАННАЯ категория популяции
   (standard 7984 / good 7116 / needs_repair 4738 / excellent 2562; кумулятивно
   needs_repair 21.2%, +standard 56.8%), то есть наилучшая одиночная догадка, а
   не просто «не край шкалы». Там же названа асимметрия: поштучный путь
   эстиматора при неизвестном ремонте IMV вообще не зовёт, а домовой дефолтит —
   решение осознанное (иначе теряем ещё ~32% домов очереди), чтобы следующий
   читатель не принял это за недосмотр.

Refs #2674
bot-backend merged commit 9f9086fa4d into main 2026-08-05 20:02:31 +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#2675
No description provided.