diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 8c0826e4..52347ceb 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -96,6 +96,12 @@ class Settings(BaseSettings): estimate_sb_floor_sigma: float = 0.25 estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol) estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD + # #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же + # доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где + # "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free). + # Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус / + # ratio — не затрагиваются. + estimate_sb_tier_a_allow_primary_if_secondary_present: bool = True asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²) estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 0b294cbf..675ccec0 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1469,14 +1469,13 @@ def _fetch_anchor_comps( """ SELECT price_per_m2, area_m2, rooms, floor, total_floors, address, source, source_url, price_rub, listing_date, - days_on_market, photo_urls, lat, lon + days_on_market, photo_urls, lat, lon, + listing_segment, source_id FROM listings WHERE is_active = true AND price_per_m2 > 0 AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re - -- novostroyki guard (#1186): NULL = legacy вторичка до м.011 - AND (listing_segment IS NULL OR listing_segment = 'vtorichka') """ ), { @@ -1494,14 +1493,60 @@ def _fetch_anchor_comps( except Exception: pass rows = [] - comps = [_anchor_comp_from_row(r) for r in rows if r["price_per_m2"]] + # #1774: gated relaxation of the #1186 guard, Tier A ONLY. В сданном доме + # cian тегирует переуступки/перепродажи собственниками как 'novostroyki'; + # впускаем novostroyki только если вторичных (vtorichka/NULL) НЕ МЕНЬШЕ, чем + # первичных — иначе MAD-clip может выкинуть редкую вторичку и заякорить на + # ценах застройщика. Чисто-первичный / primary-dominated дом → гард #1186. + raw_rows = list(rows) + secondary_count = sum( + 1 for r in raw_rows if r.get("listing_segment") in (None, "vtorichka") + ) + primary_count = len(raw_rows) - secondary_count + include_primary = ( + settings.estimate_sb_tier_a_allow_primary_if_secondary_present + and secondary_count >= 1 + and secondary_count >= primary_count + ) + if include_primary: + kept = raw_rows # mixed/delivered house: include novostroyki resales + else: + kept = [r for r in raw_rows if r.get("listing_segment") in (None, "vtorichka")] + # Dedup дублей одного объявления (#1774: source_id=330047129 → 2 active-строки, + # одна с house_id_fk, одна NULL). Ключ — (source, source_id) PRIMARY (codebase + # canon: скрейперы дедупят на source_id; source_url может отличаться trailing + # slash/query-param). Fallback source_url, затем атрибуты. Namespaced-теги + # ('id'/'url') защищают от value-collision между id и url. + deduped: dict[Any, dict[str, Any]] = {} + for r in kept: + sid = r.get("source_id") + url = r.get("source_url") + if sid is not None: + key: Any = (r.get("source"), "id", sid) + elif url is not None: + key = (r.get("source"), "url", url) + else: + key = ( + r.get("source"), + r.get("address"), + r.get("area_m2"), + r.get("price_per_m2"), + ) + if key not in deduped: + deduped[key] = r + deduped_rows = list(deduped.values()) + comps = [_anchor_comp_from_row(r) for r in deduped_rows if r["price_per_m2"]] if len(comps) >= min_comps: + primary_n = sum( + 1 for r in deduped_rows if r.get("listing_segment") == "novostroyki" + ) logger.info( - "anchor tier=A street=%r base=%s letter=%s → %d comps", + "anchor tier=A street=%r base=%s letter=%s → %d comps (primary=%d)", street, base_no, letter, len(comps), + primary_n, ) return comps, "A" diff --git a/tradein-mvp/backend/tests/test_segment_guard_1186.py b/tradein-mvp/backend/tests/test_segment_guard_1186.py index e93fe51c..e614f2c4 100644 --- a/tradein-mvp/backend/tests/test_segment_guard_1186.py +++ b/tradein-mvp/backend/tests/test_segment_guard_1186.py @@ -62,16 +62,30 @@ def test_tier_w_inline_has_canonical_guard() -> None: assert "listing_segment != 'novostroyki'" not in src -def test_tier_a_anchor_has_canonical_guard() -> None: - """Tier A anchor (same building) — ранее не имел segment-guard (#1186 fix).""" +def test_tier_a_anchor_gated_python_side_not_sql_guard() -> None: + """#1774: Tier A больше НЕ хардкодит SQL-guard — он делает gated Python-side + relaxation (впускает novostroyki только если в доме есть вторичка/NULL). + + Assert: (a) Tier C всё ещё несёт канон-guard в SQL, (b) Tier A делает + Python-side gating — источник содержит и флаг настройки, и hardened + secondary_count/primary_count gate (#1774, после code-review). + """ src = inspect.getsource(est_mod._fetch_anchor_comps) - assert _GUARD_RE.search(src), "_fetch_anchor_comps Tier A SQL lacks novostroyki guard" + # (a) Tier C SQL guard сохранён (поиск находит единственное вхождение — Tier C). + assert _GUARD_RE.search(src), "Tier C anchor SQL lost the canonical novostroyki guard" + # (b) Tier A теперь gated Python-side (#1774). + assert "estimate_sb_tier_a_allow_primary_if_secondary_present" in src, ( + "Tier A no longer references the #1774 gating flag — gated relaxation missing" + ) + # Hardened gate: secondary_count ≥ primary_count (заменил has_secondary). + assert "secondary_count" in src, "Tier A lacks secondary_count Python-side gate (#1774)" + assert "primary_count" in src, "Tier A lacks primary_count comparison in hardened gate (#1774)" def test_tier_c_anchor_has_canonical_guard_not_segment_param() -> None: """Tier C anchor — канон-предикат, не CAST(:segment AS text) (тот вариант отбрасывал NULL).""" src = inspect.getsource(est_mod._fetch_anchor_comps) - # Canonical guard present. + # Canonical guard present (Tier C; Tier A guard снят в #1774 → gated Python-side). assert _GUARD_RE.search(src) # Старый параметрический вариант убран. assert "listing_segment = CAST(:segment AS text)" not in src diff --git a/tradein-mvp/backend/tests/test_tier_a_primary_1774.py b/tradein-mvp/backend/tests/test_tier_a_primary_1774.py new file mode 100644 index 00000000..0a36f5df --- /dev/null +++ b/tradein-mvp/backend/tests/test_tier_a_primary_1774.py @@ -0,0 +1,293 @@ +"""#1774 — Tier A (same building) gated relaxation of the #1186 novostroyki guard. + +В СДАННОМ доме cian тегирует переуступки/перепродажи собственниками (sale_type=free) +как listing_segment='novostroyki'. Раньше Tier A-гард #1186 +`(listing_segment IS NULL OR listing_segment='vtorichka')` отбрасывал их, и +same-building listing пользователя не учитывался (кейс «Евгения Савкова 29», ЕКБ). + +Дизайн #1774 (Tier A ONLY), gate hardened после code-review: + - secondary_count = число vtorichka/NULL rows; primary_count = остальные. + - flag ON + secondary_count ≥ 1 И secondary_count ≥ primary_count → + впускаем ВСЕ rows (включая novostroyki-переуступки). + - flag ON + primary-dominated дом (secondary_count < primary_count) → гард + #1186 сохраняется: иначе MAD-clip выкинет редкую вторичку и заякорит на + ценах застройщика (~27% overvaluation). + - flag OFF → гард #1186 сохраняется всегда (novostroyki исключены). + - dedup: PRIMARY ключ (source, source_id) — codebase canon (скрейперы дедупят + на source_id; source_url может отличаться trailing slash/query-param). + +Tier C / радиус / ratio — НЕ затрагиваются (см. test_segment_guard_1186.py). +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import MagicMock, patch + +# Settings требует DATABASE_URL при инициализации (fail-fast, C-3). +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from app.services import estimator as est_mod +from app.services.estimator import _fetch_anchor_comps + +# Адрес сводится к ('евгения савкова', 29, None) — Tier A street+base. +_ADDRESS = "Екатеринбург, ул. Евгения Савкова, 29" + + +def _row( + *, + source: str = "avito", + source_url: str | None = None, + source_id: str | None = None, + listing_segment: str | None = None, + price_per_m2: float = 150_000.0, + area_m2: float = 50.0, + rooms: int = 2, + address: str = _ADDRESS, +) -> dict[str, Any]: + """Mock listings-row (mappings shape) для Tier A: несёт listing_segment + source_id.""" + if source_url is None: + source_url = f"https://{source}.ru/offer/{source_id or '0'}" + return { + "price_per_m2": price_per_m2, + "area_m2": area_m2, + "rooms": rooms, + "floor": 3, + "total_floors": 9, + "address": address, + "source": source, + "source_url": source_url, + "price_rub": int(price_per_m2 * area_m2), + "listing_date": None, + "days_on_market": 15, + "photo_urls": [], + "lat": 56.838, + "lon": 60.595, + "listing_segment": listing_segment, + "source_id": source_id, + } + + +def _db_mock(rows: list[dict[str, Any]]) -> MagicMock: + """Session mock: db.execute().mappings().all() → rows (Tier A path).""" + db = MagicMock() + db.execute.return_value.mappings.return_value.all.return_value = rows + return db + + +def _fetch(db: MagicMock) -> tuple[list[dict[str, Any]], str | None]: + """Вызов _fetch_anchor_comps с Tier A-релевантными аргументами (без lat/lon → Tier C skip).""" + return _fetch_anchor_comps( + db, + address=_ADDRESS, + target_house_id=None, + lat=None, + lon=None, + rooms=2, + area=50.0, + ) + + +def test_tier_a_includes_novostroyki_when_secondary_present() -> None: + """Mixed/сданный дом: 4 vtorichka/NULL + 2 novostroyki → flag ON впускает ВСЕ 6. + + secondary_count=4 ≥ primary_count=2 → hardened gate допускает novostroyki. + Зеркало «Евгения Савкова 29»: vtorichka-комплы (avito/yandex) соседствуют с + cian-переуступками (novostroyki) — последние тоже должны попасть в anchor. + """ + rows = [ + _row(source="avito", source_id="a1", listing_segment="vtorichka"), + _row(source="avito", source_id="a2", listing_segment=None), + _row(source="yandex", source_id="y1", listing_segment="vtorichka"), + _row(source="yandex", source_id="y2", listing_segment=None), + _row(source="cian", source_id="c1", listing_segment="novostroyki"), + _row(source="cian", source_id="c2", listing_segment="novostroyki"), + ] + db = _db_mock(rows) + with ( + patch.object(est_mod.settings, "estimate_sb_min_comps", 4), + patch.object( + est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True + ), + ): + comps, tier = _fetch(db) + assert tier == "A" + # Все 6 (4 вторички + 2 novostroyki-переуступки) учтены. + assert len(comps) == 6 + + +def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None: + """Hardened gate (code-review fix): 1 vtorichka + 5 novostroyki → secondary_count=1 + < primary_count=5 → novostroyki ИСКЛЮЧЕНЫ (anchor строился бы на ценах застройщика + → ~27% overvaluation после MAD-clip выкидывания одинокой вторички). + + После исключения остаётся 1 vtorichka < min_comps=4 → Tier A не срабатывает + (lat/lon None → Tier C skip → tier=None, comps=[]). Это доказывает, что primary + rows отброшены: при их допуске было бы 6 comps ≥ min_comps и tier='A'. + """ + rows = [ + _row(source="avito", source_id="a1", listing_segment="vtorichka"), + _row(source="cian", source_id="c1", listing_segment="novostroyki"), + _row(source="cian", source_id="c2", listing_segment="novostroyki"), + _row(source="cian", source_id="c3", listing_segment="novostroyki"), + _row(source="cian", source_id="c4", listing_segment="novostroyki"), + _row(source="cian", source_id="c5", listing_segment="novostroyki"), + ] + db = _db_mock(rows) + with ( + patch.object(est_mod.settings, "estimate_sb_min_comps", 4), + patch.object( + est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True + ), + ): + comps, tier = _fetch(db) + # primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip. + assert tier is None + assert comps == [] + + +def test_tier_a_excludes_novostroyki_when_pure_primary() -> None: + """Чисто-первичный дом (0 вторички, ВСЕ novostroyki) → гард #1186 сохраняется, + novostroyki исключаются → comps < min_comps → Tier A не срабатывает (tier=None). + + Tier-D-skip path намеренный: lat/lon=None → Tier C не запускается, поэтому + после отбраковки всех 4 novostroyki Tier A не набирает min_comps и возвращает + (tier=None, comps=[]). Assert именно на этом нулевом результате — фиксирует, + что чисто-первичный дом НЕ заякоривается на ценах застройщика.""" + rows = [ + _row(source="cian", source_id="c1", listing_segment="novostroyki"), + _row(source="cian", source_id="c2", listing_segment="novostroyki"), + _row(source="cian", source_id="c3", listing_segment="novostroyki"), + _row(source="cian", source_id="c4", listing_segment="novostroyki"), + ] + db = _db_mock(rows) + with ( + patch.object(est_mod.settings, "estimate_sb_min_comps", 4), + patch.object( + est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True + ), + ): + comps, tier = _fetch(db) + # Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D). + assert tier is None + assert comps == [] + + +def test_tier_a_flag_off_excludes_novostroyki_even_with_secondary() -> None: + """Flag OFF → оригинальное поведение #1186: novostroyki ВСЕГДА исключены в Tier A, + даже если в доме есть вторичка. Остаются только 4 vtorichka/NULL.""" + rows = [ + _row(source="avito", source_id="a1", listing_segment="vtorichka"), + _row(source="avito", source_id="a2", listing_segment=None), + _row(source="yandex", source_id="y1", listing_segment="vtorichka"), + _row(source="yandex", source_id="y2", listing_segment=None), + _row(source="cian", source_id="c1", listing_segment="novostroyki"), + _row(source="cian", source_id="c2", listing_segment="novostroyki"), + ] + db = _db_mock(rows) + with ( + patch.object(est_mod.settings, "estimate_sb_min_comps", 4), + patch.object( + est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", False + ), + ): + comps, tier = _fetch(db) + assert tier == "A" + # novostroyki отброшены → только 4 вторички. + assert len(comps) == 4 + + +def test_tier_a_dedup_same_source_id_collapses() -> None: + """#1774 dedup: дубликат одного объявления (source_id=330047129, 2 active-строки — + одна с house_id_fk, одна NULL) схлопывается в 1 comp по PRIMARY ключу + (source, source_id). + + 5 уникальных vtorichka + дубль одного source_id → 5 comps (не 6 — дубль убран). + """ + dup_url = "https://cian.ru/sale/flat/330047129/" + rows = [ + _row(source="avito", source_id="a1", listing_segment="vtorichka"), + _row(source="avito", source_id="a2", listing_segment="vtorichka"), + _row(source="yandex", source_id="y1", listing_segment="vtorichka"), + _row(source="yandex", source_id="y2", listing_segment="vtorichka"), + _row(source="cian", source_url=dup_url, source_id="330047129", listing_segment="vtorichka"), + # Дубль: тот же (source, source_id), но другой row (house_id_fk NULL-вариант). + _row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None), + ] + db = _db_mock(rows) + with ( + patch.object(est_mod.settings, "estimate_sb_min_comps", 4), + patch.object( + est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True + ), + ): + comps, tier = _fetch(db) + assert tier == "A" + # 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5. + assert len(comps) == 5 + cian = [c for c in comps if c["source"] == "cian"] + assert len(cian) == 1 + + +def test_tier_a_dedup_same_source_id_different_url_collapses() -> None: + """Code-review Fix 2: те же source_id, но РАЗНЫЙ source_url (trailing slash / + query-param). PRIMARY ключ (source, source_id) всё равно схлопывает в 1 comp — + старый url-primary ключ этот дубль ПРОПУСКАЛ (это и есть баг #1774 Fix 2). + + 5 уникальных vtorichka + дубль того же source_id с другим url → 5 comps. + """ + rows = [ + _row(source="avito", source_id="a1", listing_segment="vtorichka"), + _row(source="avito", source_id="a2", listing_segment="vtorichka"), + _row(source="yandex", source_id="y1", listing_segment="vtorichka"), + _row(source="yandex", source_id="y2", listing_segment="vtorichka"), + _row( + source="cian", + source_url="https://cian.ru/sale/flat/330047129/", + source_id="330047129", + listing_segment="vtorichka", + ), + # Дубль того же source_id, но url отличается trailing slash + query-param. + _row( + source="cian", + source_url="https://cian.ru/sale/flat/330047129?utm=x", + source_id="330047129", + listing_segment=None, + ), + ] + db = _db_mock(rows) + with ( + patch.object(est_mod.settings, "estimate_sb_min_comps", 4), + patch.object( + est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True + ), + ): + comps, tier = _fetch(db) + assert tier == "A" + # source_id-primary схлопывает несмотря на разные url → 5 comps. + assert len(comps) == 5 + cian = [c for c in comps if c["source"] == "cian"] + assert len(cian) == 1 + + +def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None: + """source_url=None, но source_id задан → PRIMARY ключ (source, source_id): + реально разные лоты НЕ схлопываются (разные source_id).""" + rows = [ + _row(source="avito", source_url=None, source_id="a1", area_m2=50.0), + _row(source="avito", source_url=None, source_id="a2", area_m2=60.0), + _row(source="avito", source_url=None, source_id="a3", area_m2=70.0), + _row(source="avito", source_url=None, source_id="a4", area_m2=80.0), + ] + db = _db_mock(rows) + with ( + patch.object(est_mod.settings, "estimate_sb_min_comps", 4), + patch.object( + est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True + ), + ): + comps, tier = _fetch(db) + assert tier == "A" + # 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто. + assert len(comps) == 4