diff --git a/tradein-mvp/backend/app/services/house_imv_backfill.py b/tradein-mvp/backend/app/services/house_imv_backfill.py index cc172e1c..80b90a02 100644 --- a/tradein-mvp/backend/app/services/house_imv_backfill.py +++ b/tradein-mvp/backend/app/services/house_imv_backfill.py @@ -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 ──────────────────────────────────── diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 1f484ac4..726ce48a 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -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): успех — это # «сделали то, что собирались», а не «не поймали известное исключение». diff --git a/tradein-mvp/backend/tests/test_house_imv_params_honesty.py b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py index 224d9566..c36aa891 100644 --- a/tradein-mvp/backend/tests/test_house_imv_params_honesty.py +++ b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py @@ -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)