gendesign/tradein-mvp/backend/tests/test_house_imv_params_honesty.py
bot-backend 4b4ab8b34c
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
fix(tradein/imv): счётчики прогона в total_seen/new_count + лог дрейфа ремонта (#2674)
По ревью 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
2026-08-06 00:58:11 +05:00

220 lines
9.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""#2674 — домовая оценка Авито перестаёт врать про ремонт и тип дома.
Покрывает три дефекта из эпика:
1. renovation_type берётся из моды listings.repair_state и проходит через
существующий estimator._IMV_REPAIR_MAP (был захардкожен литерал 'cosmetic':
2685 из 2685 запросов ушли как «косметический ремонт»).
2. Неизвестный тип дома НЕ уезжает дефолтом 'panel' (самый дешёвый класс →
системное занижение), а помечает дом и экономит запрос. Отдельно —
camelCase-вокабуляр Циана (monolithBrick / gasSilicateBlock / stalin)
распознаётся, к нижнему регистру он не приводится.
3. Прогон с saved=0 и ненулевыми errors не помечается 'done'.
БД и сеть замоканы — реального Postgres/Авито не нужно.
"""
from __future__ import annotations
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import house_imv_backfill as hib
from app.services.product_handlers import _job_house_imv_backfill
# ── (1) renovation_type из данных через существующий маппинг ──────────────────
def _db_for_pick(listing_row: dict[str, Any], house_row: dict[str, Any] | None) -> MagicMock:
"""MagicMock-Session: два .mappings().first() подряд (listings-агрегат, houses)."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.side_effect = [listing_row, house_row]
return db
def _listing_row(**over: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"rooms": 2,
"area_m2": 52.0,
"floor": 3,
"total_floors": 10,
"house_type": "panel",
"repair_state": None,
}
base.update(over)
return base
@pytest.mark.parametrize(
("repair_state", "expected"),
[
("needs_repair", "required"),
("standard", "cosmetic"),
("good", "euro"),
("excellent", "designer"),
],
)
def test_renovation_type_comes_from_listings_via_estimator_map(
repair_state: str, expected: str
) -> None:
"""Мода repair_state → renovation_type ровно по estimator._IMV_REPAIR_MAP."""
from app.services.estimator import _IMV_REPAIR_MAP
params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state=repair_state), None), 1)
assert params["renovation_type"] == expected
# Не второй словарь: значение обязано совпадать с источником правды.
assert params["renovation_type"] == _IMV_REPAIR_MAP[repair_state]
def test_renovation_type_not_hardcoded_cosmetic() -> None:
"""Regression #2674: 'good' больше не превращается в 'cosmetic'."""
params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state="good"), None), 1)
assert params["renovation_type"] != "cosmetic"
def test_unknown_repair_state_stays_cosmetic() -> None:
"""Анти-оверрич: ремонт неизвестен → середина шкалы 'cosmetic', дом не теряем."""
params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state=None), None), 1)
assert params["renovation_type"] == "cosmetic"
assert params["house_type"] == "panel" # дом всё ещё пригоден к запросу
# ── (2) тип дома: неизвестный не врёт, camelCase распознаётся ─────────────────
@pytest.mark.parametrize(
("raw", "expected"),
[
# camelCase из Циана — нижним регистром НЕ лечится (ключ канона через '_').
("monolithBrick", "monolithic"),
("gasSilicateBlock", "block"),
("aerocreteBlock", "block"),
("stalin", "brick"),
# каноничные значения продолжают работать
("panel", "panel"),
("monolith", "monolithic"),
("monolith_brick", "monolithic"),
],
)
def test_map_house_type_recognises_camel_case(raw: str, expected: str) -> None:
assert hib._map_house_type(raw) == expected
@pytest.mark.parametrize("raw", [None, "", "other", "wireframe", "какая-то дичь"])
def test_map_house_type_unknown_is_none_not_panel(raw: str | None) -> None:
"""Regression #2674: нет типа / не распознан → None, а НЕ дефолт 'panel'."""
assert hib._map_house_type(raw) is None
def test_pick_lot_params_unknown_house_type_yields_none() -> None:
"""Типа нет ни в listings, ни в houses → house_type=None (не 'panel')."""
db = _db_for_pick(_listing_row(house_type=None), {"house_type": None, "total_floors": 9})
assert hib.pick_lot_params(db, 1)["house_type"] is None
@pytest.mark.asyncio
async def test_unknown_house_type_skips_request_and_marks_house() -> None:
"""Неизвестный тип → запрос к площадке НЕ уходит, дом помечен no_params."""
params = {
"rooms": 2,
"area_m2": 52.0,
"floor": 3,
"floor_at_home": 10,
"house_type": None,
"renovation_type": "cosmetic",
"has_balcony": True,
"has_loggia": False,
}
houses = [{"id": 11, "address": "ул. X, 1", "full_address": None, "lat": 56.8, "lon": 60.6}]
db = MagicMock()
with (
patch.object(hib, "pick_lot_params", return_value=params),
patch.object(hib, "evaluate_via_imv", new_callable=AsyncMock) as mock_eval,
patch.object(hib, "_mark_status") as mock_mark,
):
db.execute.return_value.mappings.return_value.all.return_value = houses
result = await hib.backfill_house_imv(db, batch_size=10, request_delay_sec=0.0)
mock_eval.assert_not_called()
mock_mark.assert_called_once_with(db, 11, "no_params", "unknown house_type")
assert result.skipped == 1
assert result.saved == 0
# ── (3) прогон с нулём сохранённых и ошибками не «успешен» ────────────────────
class _RunsRecorder:
"""Duck-typed ctx.runs: пишет, чем закончился прогон."""
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, Any]]] = []
def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
return None
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
self.calls.append(("mark_done", counters))
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
self.calls.append(("mark_failed", counters))
async def _drive_job(*, saved: int, errors: int, skipped: int = 0) -> list[tuple[str, dict]]:
runs = _RunsRecorder()
enrichment = MagicMock()
enrichment.house_imv_backfill = AsyncMock(
return_value=hib.HouseIMVBackfillResult(
checked=saved + errors + skipped,
saved=saved,
skipped=skipped,
errors=errors,
duration_sec=1.0,
)
)
ctx = MagicMock(runs=runs, enrichment=enrichment)
await _job_house_imv_backfill(MagicMock(), 1, {}, ctx)
return runs.calls
@pytest.mark.asyncio
async def test_zero_saved_with_errors_is_not_done() -> None:
"""Прод-случай: 31 прогон подряд saved=0 / errors≈35 из 50 уходил в 'done'."""
calls = await _drive_job(saved=0, errors=35, skipped=15)
assert calls[-1][0] == "mark_failed"
assert calls[-1][1]["saved"] == 0
assert calls[-1][1]["errors"] == 35
@pytest.mark.asyncio
async def test_honest_empty_stays_done() -> None:
"""Анти-оверрич: ноль сохранённых без ошибок (всё в skipped) — честная пустота."""
calls = await _drive_job(saved=0, errors=0, skipped=50)
assert calls[-1][0] == "mark_done"
@pytest.mark.asyncio
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)