refactor(tradein): нормализовать repair_state → enum на ингесте (#621)

listings.repair_state хранил сырые значения парсеров (cosmetic/euro/
designer/required/without/…), не совместимые с ожидаемым estimator-ом
enum needs_repair/standard/good/excellent. Покрытие ~2%, но значения
не совпадали с enum — repair_coef всегда возвращал 1.0 (no-op).

Решение: централизованный helper repair_state_normalizer.py со всеми
10 известными raw-значениями → 4 enum-buckets. Все ingest-пути
нормализуют на входе; миграция 073 бэкфилит существующие строки.
This commit is contained in:
Light1YT 2026-05-28 20:12:35 +05:00
parent 1731704ddc
commit dfd608d6f2
8 changed files with 208 additions and 14 deletions

View file

@ -132,12 +132,11 @@ _IMV_REPAIR_MAP: dict[str | None, str | None] = {
# влияет на цену).
#
# WARNING: tunable МАРКЕТ-ЭВРИСТИКА, НЕ data-derived (issue #7). Вывести из данных пока
# нельзя: listings.repair_state покрыт только ~2%, хранит un-normalized исходные
# значения (cosmetic/euro/fine/without/designer/rough — НЕ целевой enum
# needs_repair/standard/good/excellent), а медианы по нему confounded by area
# (немонотонны). Baseline = standard = 1.00 (no-op: было 0.98, срезало каждую
# «стандартную» оценку на 2% — пофикшено). Пересмотреть, когда покрытие
# repair_state вырастет и появится нормализация.
# нельзя: listings.repair_state покрыт только ~2% (coverage вырастет после #621 backfill),
# а медианы по нему confounded by area (немонотонны). Baseline = standard = 1.00 (no-op:
# было 0.98, срезало каждую «стандартную» оценку на 2% — пофикшено). Пересмотреть при
# coverage > 20% и наборе достаточной выборки по каждому bucket-у (#7).
# После #621: repair_state нормализован → needs_repair/standard/good/excellent на инgesте.
_REPAIR_COEF: dict[str, float] = {
"needs_repair": 0.94, # требует ремонта — ниже рынка
"standard": 1.00, # baseline

View file

@ -134,11 +134,13 @@ _WINDOWS_MAP: dict[str, str] = {
}
_REPAIR_MAP: dict[str, str] = {
"косметический": "cosmetic",
"евро": "euro",
"дизайнерский": "designer",
"требуется": "required",
"без ремонта": "required",
# Русские значения из Avito HTML → каноничный enum (needs_repair/standard/good/excellent)
# Маппинг выровнен по repair_state_normalizer.normalize_repair_state().
"косметический": "standard",
"евро": "good",
"дизайнерский": "excellent",
"требуется": "needs_repair",
"без ремонта": "needs_repair",
}
_SALE_TYPE_MAP: dict[str, str] = {

View file

@ -26,6 +26,7 @@ from curl_cffi.requests import AsyncSession
from app.services.scraper_settings import get_scraper_delay
from app.services.scrapers.base import BaseScraper, ScrapedLot
from app.services.scrapers.cian_state_parser import extract_state
from app.services.scrapers.repair_state_normalizer import normalize_repair_state
logger = logging.getLogger(__name__)
@ -348,7 +349,9 @@ class CianScraper(BaseScraper):
# Мебель и repair_state
has_furniture: bool | None = offer.get("hasFurniture") or offer.get("isSoldFurnished")
# decoration — для новостроек (отделка); repair_state — для вторички
repair_state: str | None = offer.get("decoration")
# Cian SERP: offer.decoration содержит raw значения (without/cosmetic/euro/design/...)
# Нормализуем до enum needs_repair/standard/good/excellent при инgesте.
repair_state: str | None = normalize_repair_state(offer.get("decoration"))
# ── Продавец ─────────────────────────────────────────────────────
phones: list[dict[str, Any]] = offer.get("phones") or []

View file

@ -21,6 +21,7 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.scrapers.cian_state_parser import extract_all_states, extract_state
from app.services.scrapers.repair_state_normalizer import normalize_repair_state
from app.services.scrapers.snapshot_writer import upsert_listing_snapshot
logger = logging.getLogger(__name__)
@ -36,7 +37,8 @@ class DetailEnrichment:
separate_wcs_count: int | None = None
combined_wcs_count: int | None = None
ceiling_height: float | None = None # meters
repair_type: str | None = None # 'cosmetic' / 'design' / 'no'
repair_type: str | None = None # 'cosmetic' / 'design' / 'no' — raw Cian value
repair_state: str | None = None # enum: needs_repair/standard/good/excellent
views_total: int | None = None # Cian stats.totalViewsFormattedString → int
views_today: int | None = None
@ -106,6 +108,7 @@ async def fetch_detail(
combined_wcs_count=offer.get("combinedWcsCount"),
ceiling_height=_parse_float(offer.get("ceilingHeight")),
repair_type=_extract_repair_type(offer),
repair_state=_extract_repair_state(offer),
raw_offer=offer,
)
@ -158,6 +161,11 @@ def _extract_repair_type(offer: dict[str, Any]) -> str | None:
return offer.get("repairType")
def _extract_repair_state(offer: dict[str, Any]) -> str | None:
"""Нормализованный enum из offer.repairType → needs_repair/standard/good/excellent."""
return normalize_repair_state(offer.get("repairType"))
def _parse_float(value: Any) -> float | None:
if value is None:
return None
@ -233,6 +241,7 @@ def save_detail_enrichment(db: Session, listing_id: int, enrichment: DetailEnric
combined_wcs_count = COALESCE(:cwc, combined_wcs_count),
ceiling_height = COALESCE(:ch, ceiling_height),
repair_type = COALESCE(:rt, repair_type),
repair_state = COALESCE(:rs, repair_state),
views_total = COALESCE(:vt, views_total),
views_today = COALESCE(:vd, views_today)
WHERE id = CAST(:lid AS bigint)
@ -244,6 +253,7 @@ def save_detail_enrichment(db: Session, listing_id: int, enrichment: DetailEnric
"cwc": enrichment.combined_wcs_count,
"ch": enrichment.ceiling_height,
"rt": enrichment.repair_type,
"rs": enrichment.repair_state,
"vt": enrichment.views_total,
"vd": enrichment.views_today,
},

View file

@ -0,0 +1,68 @@
"""Нормализация repair_state → каноничный enum для listings.repair_state.
Целевой enum: needs_repair / standard / good / excellent.
Используется во всех инgest-путях: avito_detail, cian SERP, cian_detail.
Источники raw-значений:
- Avito HTML: «Ремонт: евро» avito_detail._REPAIR_MAP «euro» good
- Cian SERP JSON: offer.decoration «euro» / «cosmetic» / «without» / ...
- Cian Detail JSON: offer.repairType «cosmetic» / «design» / «no» / «euro» / ...
Маппинг основан на _IMV_REPAIR_MAP из estimator.py (обратная форма):
needs_repair«required», standard«cosmetic», good«euro», excellent«designer».
Связь с estimator._REPAIR_COEF:
needs_repair=0.94 / standard=1.00 / good=1.05 / excellent=1.10
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
# Все известные raw-значения с источником в комментарии
_RAW_TO_ENUM: dict[str, str] = {
# ── needs_repair ────────────────────────────────────────────────────────────
"required": "needs_repair", # Avito: «требуется» / «без ремонта» (через _REPAIR_MAP)
"without": "needs_repair", # Cian: без ремонта
"no": "needs_repair", # Cian: нет ремонта (синоним without)
"rough": "needs_repair", # Cian/общий: черновая отделка
# ── standard ────────────────────────────────────────────────────────────────
"cosmetic": "standard", # Avito/Cian: косметический
# ── good ────────────────────────────────────────────────────────────────────
"euro": "good", # Avito/Cian: евро
"fine": "good", # Cian: хорошая отделка (предчистовая / whitebox)
# ── excellent ───────────────────────────────────────────────────────────────
"designer": "excellent", # Avito: дизайнерский
"design": "excellent", # Cian: дизайнерский (camelCase вариант)
}
# Допустимые значения целевого enum — для pass-through защиты
_VALID_ENUM: frozenset[str] = frozenset({"needs_repair", "standard", "good", "excellent"})
def normalize_repair_state(raw: str | None) -> str | None:
"""Преобразовать raw repair-значение в каноничный enum.
Если raw уже является корректным enum-значением вернуть as-is (идемпотентно).
Если raw не распознан вернуть None + залогировать WARNING один раз.
Args:
raw: сырое значение из парсера (e.g. «euro», «without», «design»)
или None.
Returns:
Одно из needs_repair / standard / good / excellent, либо None.
"""
if raw is None:
return None
# Pass-through: уже нормализованный enum (idempotency — нужно при ре-обработке)
if raw in _VALID_ENUM:
return raw
result = _RAW_TO_ENUM.get(raw)
if result is None:
logger.warning("repair_state_normalizer: unknown raw value %r — stored as NULL", raw)
return result

View file

@ -0,0 +1,41 @@
-- Migration 073: нормализовать listings.repair_state → enum needs_repair/standard/good/excellent
--
-- До этой миграции repair_state хранил сырые значения парсеров:
-- avito_detail: cosmetic / euro / designer / required
-- cian SERP (decoration): without / no / cosmetic / euro / design / fine / rough
--
-- После: только needs_repair / standard / good / excellent.
-- Строки с нераспознанными значениями сбрасываются в NULL (безопаснее, чем хранить мусор).
--
-- Idempotent: CASE охватывает уже нормализованные значения (pass-through).
-- Issue: #621
UPDATE listings
SET repair_state = CASE repair_state
-- ── уже нормализованные (pass-through) ───────────────────────────────────
WHEN 'needs_repair' THEN 'needs_repair'
WHEN 'standard' THEN 'standard'
WHEN 'good' THEN 'good'
WHEN 'excellent' THEN 'excellent'
-- ── needs_repair ─────────────────────────────────────────────────────────
WHEN 'required' THEN 'needs_repair' -- avito: «требуется» / «без ремонта»
WHEN 'without' THEN 'needs_repair' -- cian: без ремонта
WHEN 'no' THEN 'needs_repair' -- cian: нет ремонта
WHEN 'rough' THEN 'needs_repair' -- cian: черновая
-- ── standard ─────────────────────────────────────────────────────────────
WHEN 'cosmetic' THEN 'standard' -- avito/cian: косметический
-- ── good ─────────────────────────────────────────────────────────────────
WHEN 'euro' THEN 'good' -- avito/cian: евро
WHEN 'fine' THEN 'good' -- cian: хорошая отделка
-- ── excellent ────────────────────────────────────────────────────────────
WHEN 'designer' THEN 'excellent' -- avito: дизайнерский
WHEN 'design' THEN 'excellent' -- cian: дизайнерский
-- ── нераспознанные → NULL ────────────────────────────────────────────────
ELSE NULL
END
WHERE repair_state IS NOT NULL;

View file

@ -54,7 +54,7 @@ def test_minimal_parse() -> None:
assert result.floor == 20
assert result.total_floors == 26
assert result.balcony_loggia == "loggia"
assert result.repair_state == "euro"
assert result.repair_state == "good" # «евро» → good (normalized enum, #621)
assert result.sale_type == "free"
assert result.mortgage_available is True
assert result.house_type == "monolith"

View file

@ -0,0 +1,71 @@
"""Unit-тесты для repair_state_normalizer.normalize_repair_state() (#621).
Покрывают все известные raw-значения enum mapping,
idempotency для уже нормализованных значений и None-guard.
"""
import logging
import pytest
from app.services.scrapers.repair_state_normalizer import normalize_repair_state
# ── needs_repair ─────────────────────────────────────────────────────────────
@pytest.mark.parametrize("raw", ["required", "without", "no", "rough"])
def test_needs_repair_values(raw: str) -> None:
assert normalize_repair_state(raw) == "needs_repair"
# ── standard ─────────────────────────────────────────────────────────────────
def test_cosmetic_maps_to_standard() -> None:
assert normalize_repair_state("cosmetic") == "standard"
# ── good ─────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("raw", ["euro", "fine"])
def test_good_values(raw: str) -> None:
assert normalize_repair_state(raw) == "good"
# ── excellent ────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("raw", ["designer", "design"])
def test_excellent_values(raw: str) -> None:
assert normalize_repair_state(raw) == "excellent"
# ── None guard ───────────────────────────────────────────────────────────────
def test_none_returns_none() -> None:
assert normalize_repair_state(None) is None
# ── idempotency ──────────────────────────────────────────────────────────────
@pytest.mark.parametrize("enum_val", ["needs_repair", "standard", "good", "excellent"])
def test_already_normalized_is_passthrough(enum_val: str) -> None:
"""Уже нормализованное значение возвращается as-is (idempotent при ре-обработке)."""
assert normalize_repair_state(enum_val) == enum_val
# ── unknown value ────────────────────────────────────────────────────────────
def test_unknown_raw_returns_none(caplog: pytest.LogCaptureFixture) -> None:
"""Нераспознанное значение → None + WARNING в лог."""
with caplog.at_level(
logging.WARNING,
logger="app.services.scrapers.repair_state_normalizer",
):
result = normalize_repair_state("whitebox")
assert result is None
assert "whitebox" in caplog.text