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
3 changed files with 42 additions and 4 deletions
Showing only changes of commit 4b4ab8b34c - Show all commits

View file

@ -116,13 +116,26 @@ def _map_renovation_type(repair_state: str | None) -> str:
лишь 36%).
Неизвестный ремонт (498 домов из 2685 ни одного объявления с repair_state)
ОСТАЁТСЯ 'cosmetic', в отличие от неизвестного типа дома: это середина
порядковой шкалы (required < cosmetic < euro < designer), а не её край,
поэтому системного сдвига цены в одну сторону не даёт.
ОСТАЁТСЯ 'cosmetic', в отличие от неизвестного типа дома: 'cosmetic'
(=standard) это одновременно МОДА и МЕДИАННАЯ категория популяции
(standard 7984 / good 7116 / needs_repair 4738 / excellent 2562; кумулятивно
needs_repair 21.2%, +standard 56.8%), то есть наилучшая одиночная догадка.
У типа дома такой догадки нет: 'panel' почти край шкалы, а не её середина.
Асимметрия осознанная, а не недосмотр: поштучный путь эстиматора при
неизвестном ремонте IMV вообще не зовёт (estimator.py, `imv_renovation is not
None`), а домовой дефолтит иначе теряем ещё ~32% домов очереди поверх тех,
что уже отсекает неизвестный тип дома.
"""
from app.services.estimator import _IMV_REPAIR_MAP # lazy — см. import-блок
return _IMV_REPAIR_MAP.get(repair_state) or "cosmetic"
mapped = _IMV_REPAIR_MAP.get(repair_state)
if mapped is None and repair_state:
# Непустое, но незнакомое значение — признак дрейфа вокабуляра на ингесте
# (сырых repair-значений в listings больше, чем нормализованных). Паритет
# с house_type_normalizer, который такой случай уже логирует.
logger.debug("house_imv: unmapped repair_state %r — падаем в 'cosmetic'", repair_state)
return mapped or "cosmetic"
# ── Region bbox prefix для Avito geocoder ────────────────────────────────────

View file

@ -407,6 +407,17 @@ async def _job_house_imv_backfill(
"skipped": result.skipped,
"errors": result.errors,
"duration_sec": int(result.duration_sec),
# #2674: _column_counts (scrape_runs.py) берёт выделенные колонки из
# ключей total_seen|lots_fetched и new_count|lots_inserted — ни одного
# из них тут не было, поэтому все 39 прогонов этого source лежат в БД
# с total_seen=0. А mark_done по этой же колонке шлёт алерт «3 подряд
# done с нулевым результатом» (#2625) — то есть даже идеальный прогон
# с 50 сохранёнными считался бы нулевым и через три дня выстрелил бы
# ложной тревогой про капчу.
# Трейд-офф: на исчерпанной очереди checked=0 три дня подряд тоже даст
# алерт — но пустая очередь при ежедневном расписании это и правда сигнал.
"total_seen": result.checked,
"new_count": result.saved,
}
# Честный статус (#2674, тот же класс, что #2670/#2657): успех — это
# «сделали то, что собирались», а не «не поймали известное исключение».

View file

@ -204,3 +204,17 @@ async def test_partial_success_stays_done() -> None:
"""Анти-оверрич: что-то сохранили — прогон успешен, даже если были ошибки."""
calls = await _drive_job(saved=3, errors=7)
assert calls[-1][0] == "mark_done"
@pytest.mark.asyncio
async def test_counters_feed_total_seen_and_new_count() -> None:
"""#2674: без этих ключей _column_counts оставляет колонку total_seen=0,
и алерт «3 подряд done с нулевым результатом» (#2625) выстрелил бы даже на
полностью успешном прогоне. На проде так лежат все 39 прогонов source'а.
"""
from app.services.scrape_runs import _column_counts
calls = await _drive_job(saved=50, errors=0)
counters = calls[-1][1]
assert _column_counts(counters) == (50, 50)