gendesign/tradein-mvp/backend/tests/test_tier_a_primary_1774.py
bot-backend 23b8816863
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (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 2m13s
fix(tradein/estimator): гео-ограничение якорного тира — не брать чужие города (#2581)
Tier A (_fetch_anchor_comps, "тот же дом") матчил по normalized street+house
(_normalize_building_key намеренно дропает город) БЕЗ единого гео-предиката —
единственный запрос к listings в файле без ST_DWithin/города/house_id_fk/LIMIT.
"Серов, ул. Ленина, 5" получал якорь по ЕКБ-объявлениям (~40 191 из ~40 200
активных листингов — ЕКБ) с самым доверенным тиром 'A', завышая цену в 4-5х;
хуже — anchor_tier='A' блокировал честный #oblast-D deals-headline-fallback
(гейт `anchor_tier is None`), так что область не могла получить даже
резервную ДКП-оценку.

Фикс мирроит уже одобренный geo-bound для Tier S (f9ae6f0c, #oblast-D):
ST_DWithin(geom::geography, subject_point, ANCHOR_TIER_A_RADIUS_M) от
lat/lon субъекта, radius = DEFAULT_RADIUS_M (1000м, тот же константа что и
Tier S) — строковый match уже устанавливает "та же улица+дом", радиус нужен
только чтобы отсечь РЕАЛЬНО кросс-городские коллизии (ЕКБ/Серов — сотни км),
не для внутридомовой точности. Tier A целиком гейтится на lat/lon субъекта
(как Tier C) — без них геопредикат невозможен; в проде geo всегда есть
(_empty_estimate возвращается раньше при неудачном geocode).

Объявления без geom: `AND geom IS NOT NULL` + ST_DWithin (NULL → exclude,
не blind-include) — >99.97% листингов имеют координаты (см. f9ae6f0c),
исключение погрешности не создаёт.

_normalize_building_key НЕ тронут (город по-прежнему не входит в ключ) —
добавление городского токена сломало бы матчинг источников без города в
адресе (Avito anonymous) и внесло бы новый normalization-риск; кросс-
городская коллизия закрыта на SQL-уровне надёжнее.

Поправлен ложный комментарий _band_haircut (:1900-1908): "same-building
anchor pool для oblast не формируется" был неверен даже ДО фикса (Tier A
не имел гео-фильтра вовсе — либо матчил ЕКБ, либо честно матчил местные
листинги, если они были).

Тест-страж test_estimator_deals_headline_fallback_oblast_d.py использовал
голый db=MagicMock() для _fetch_anchor_comps — db.execute(...).mappings().all()
на unconfigured MagicMock тривиально возвращает [] (MagicMock __iter__ default),
так что anchor_tier никогда не мог стать 'A' и регрессия было бы невидима.
Пропатчено явно (документирует допущение) + добавлен новый тест, который
НЕ мокает _fetch_anchor_comps — гоняет реальную SQL-логику через фейковый
db.execute, симулирующий настоящую ST_DWithin-фильтрацию по haversine-
дистанции. На pre-fix коде (git stash) даёт headline=186 400 ₽/м² вместо
честных 30 000 (deal corridor) — воспроизводит репортнутый баг buквально.
EKB control-тест подтверждает: несколько объявлений в одном доме ЕКБ
по-прежнему формируют Tier A anchor (n=4, ~145k ₽/м²) — не деградировало.

test_tier_a_primary_1774.py: позитивные Tier A тесты теперь передают
lat/lon субъекта (гейт требует их) — тесты на python-side novostroyki-
гейтинг (ожидающие tier=None) оставлены с lat=None (конечный результат
не меняется: Tier A целиком пропускается без lat/lon, как и Tier C раньше).

Full suite: 2842 passed, 9 skipped, 1 pre-existing failure (test_search_cache_hit,
не связан). Ruff чист.
2026-07-31 17:39:00 +03:00

255 lines
12 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.

"""#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
# #2581: subject lat/lon — совпадают с _row()'s hardcoded lat/lon (56.838/60.595),
# т.е. subject и comps в одной точке ЕКБ → ST_DWithin(ANCHOR_TIER_A_RADIUS_M)
# тривиально проходит, не мешая #1774 novostroyki-gating semantics ниже.
_LAT = 56.838
_LON = 60.595
def _fetch(
db: MagicMock, *, lat: float | None = None, lon: float | None = None
) -> tuple[list[dict[str, Any]], str | None]:
"""Вызов _fetch_anchor_comps с Tier A-релевантными аргументами.
lat/lon по умолчанию None — тесты, ожидающие tier=None (novostroyki-gate
отбраковал все comps до min_comps), передают None намеренно: с #2581
геогейтом Tier A целиком пропускается без lat/lon (см. Tier C — тот же
паттерн), что и раньше давало tier=None (Tier C тоже требовал lat/lon).
Тесты, ожидающие tier=='A', передают _LAT/_LON явно.
"""
return _fetch_anchor_comps(
db,
address=_ADDRESS,
target_house_id=None,
lat=lat,
lon=lon,
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):
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
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):
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):
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_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):
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
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):
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
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):
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
assert tier == "A"
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.
assert len(comps) == 4