gendesign/tradein-mvp/backend/tests/test_tier_a_primary_1774.py
Light1YT 07d9cc8e8b
All checks were successful
CI / changes (pull_request) Successful in 6s
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
fix(estimator): #1774 Tier A admits same-building novostroyki resales when secondary-dominant
Tier A (same building) dropped cian listings tagged listing_segment='novostroyki'
even in delivered buildings where that tag marks owner resales/переуступки
(sale_type=free). Gated relaxation of the #1186 guard, Tier A ONLY: include
novostroyki rows iff the same house has secondary (vtorichka/NULL) listings AND
secondary_count >= primary_count — secondary-dominant signal of a delivered house.
Prevents a lone vtorichka + clustered developer-priced novostroyki from anchoring
on застройщик prices after MAD-clip drops the rare secondary (~27% overvaluation).
Pure-primary / primary-dominated houses keep the #1186 guard. Behind
estimate_sb_tier_a_allow_primary_if_secondary_present (default True).

Dedup duplicate active rows by (source, source_id) primary key (codebase canon,
namespaced id/url tags, source_url fallback) — collapses rows whose source_url
differs only by trailing slash / query-param (e.g. cian source_id=330047129 ×2).

Tier C / radius / asking_to_sold_ratio paths untouched. 84 unit tests pass.
2026-06-19 14:00:13 +05:00

293 lines
14 KiB
Python
Raw 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.

"""#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