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
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 чист.
255 lines
12 KiB
Python
255 lines
12 KiB
Python
"""#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
|