refactor(tradein): убрать dead listing_segment param из _fetch_anchor_comps + негативные guard-asserts (review fixup, #1186)
All checks were successful
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / changes (push) Successful in 6s
CI / backend-tests (push) Has been skipped

- Удалён параметр listing_segment: str | None = None из сигнатуры _fetch_anchor_comps
  (guard хардкожен в SQL-блоках Tier A/C, параметр не использовался)
- Убраны seg_counts / target_segment в caller-блоке SAME-BUILDING ANCHOR (~L2174)
- Docstring Tier C обновлён: «вторичка-канон guard (#1186): NULL = legacy вторичка»
- test_segment_guard_1186.py: добавлены 4 негативных assert'а:
  · no bare = 'vtorichka' без IS NULL OR
  · no deprecated <> / != 'novostroyki' form
  · _fetch_anchor_comps не содержит listing_segment в сигнатуре
  · Tier C не использует CAST(:segment AS text) (параметрическую форму)
This commit is contained in:
bot-backend 2026-06-12 12:01:00 +03:00
parent b70ae46885
commit a9a489bba5
2 changed files with 77 additions and 12 deletions

View file

@ -1428,15 +1428,15 @@ def _fetch_anchor_comps(
lon: float | None,
rooms: int | None,
area: float | None,
listing_segment: str | None = None,
) -> tuple[list[dict[str, Any]], str | None]:
"""Тированный набор комплов для same-building якоря. Стоп на 1-м тире с ≥ min_comps.
Tier A SAME BUILDING: normalized street + base house no (+ литера если есть).
RELAXED rooms (без фильтра), БЕЗ area±15%. Не группируем по house_id_fk
один дом дробится на несколько fk (Хохрякова 48 = 7085/9878/12797).
Tier C micro-radius 500m (ST_DWithin) + same listing_segment + rooms match
+ area±25%. (Tier B «тот же ЖК» skip: complex_id/cian_zhk_url ненадёжны.)
Tier C micro-radius 500m (ST_DWithin) + вторичка-канон guard (#1186): NULL = legacy
вторичка + rooms match + area±25%. (Tier B «тот же ЖК» skip: complex_id/cian_zhk_url
ненадёжны.)
Tier D фолбэк: None tier (caller остаётся на радиусном median-пути).
Excludes lots без price_per_m2. is_active=true. Best-effort: ([], None) на ошибке.
@ -2152,7 +2152,7 @@ async def estimate_quality(
# Якорь из комплов ТОГО ЖЕ ДОМА (Tier A) / micro-radius (Tier C). Когда он
# сработал — он ЗАМЕНЯЕТ радиусную медиану для median_price/ppm²/range (premium
# больше не размывается массовой застройкой). За флагом; OFF ⇒ точно старое
# поведение. listing_segment для Tier C берём из самого частого среди аналогов.
# поведение. Сегмент в Tier C фиксирован guard'ом (#1186): вторичка + NULL-legacy.
anchor_tier: str | None = None
# #694: комплы того же дома, на которых РЕАЛЬНО построен headline-якорь.
# Заполняется только когда anchor мутировал median (ниже) — тогда UI-аналоги
@ -2171,13 +2171,6 @@ async def estimate_quality(
and payload.area_m2
and (payload.address or (geo is not None and geo.full_address))
):
seg_counts: dict[str, int] = {}
for lot in listings_clean:
seg = lot.get("listing_segment")
if seg:
seg_counts[seg] = seg_counts.get(seg, 0) + 1
target_segment = max(seg_counts, key=seg_counts.get) if seg_counts else None
comps, anchor_tier = _fetch_anchor_comps(
db,
# payload.address (сырой ввод) ПЕРВИЧЕН: на проде геокод часто
@ -2190,7 +2183,6 @@ async def estimate_quality(
lon=geo.lon,
rooms=payload.rooms,
area=payload.area_m2,
listing_segment=target_segment,
)
anchor = _compute_same_building_anchor(
comps,

View file

@ -204,3 +204,76 @@ def test_vtorichka_segment_listing_included() -> None:
result, _, _ = _fetch_analogs(db, lat=56.838, lon=60.595, rooms=2, area=40.0, radius_m=1000)
assert len(result) >= 1, "vtorichka-segment listing was unexpectedly excluded"
# ── Негативные assert'ы: запрещённые формы guard'а во всём estimator'е ────────
def _estimator_full_src() -> str:
"""Полный исходник модуля estimator (для статической проверки всех SQL-блоков)."""
return inspect.getsource(est_mod)
def test_no_bare_vtorichka_eq_without_null_guard() -> None:
"""Запрет голого `listing_segment = 'vtorichka'` без IS NULL OR части.
Такой вариант отбросит NULL (legacy вторичку до миграции 011) баг охвата.
"""
src = _estimator_full_src()
# Ищем вхождения = 'vtorichka' которым НЕ предшествует IS NULL OR на той же строке.
bad_matches = [
line
for line in src.splitlines()
if "= 'vtorichka'" in line and "IS NULL OR" not in line and "--" not in line.lstrip()[:3]
]
assert not bad_matches, (
"Found bare `= 'vtorichka'` without IS NULL OR guard in estimator.py:\n"
+ "\n".join(bad_matches)
)
def test_no_neq_novostroyki_form_in_estimator() -> None:
"""Запрет формы `listing_segment <> 'novostroyki'` / `!= 'novostroyki'` в estimator.
Эта форма пропускает прочие сегменты (коммерция и т.п.) и не охватывает NULL.
Канон: IS NULL OR = 'vtorichka'.
"""
src = _estimator_full_src()
assert (
"<> 'novostroyki'" not in src
), "estimator.py contains deprecated `<> 'novostroyki'` form — use canonical guard"
assert (
"!= 'novostroyki'" not in src
), "estimator.py contains deprecated `!= 'novostroyki'` form — use canonical guard"
def test_anchor_comps_no_dead_listing_segment_param() -> None:
"""_fetch_anchor_comps не должна принимать listing_segment как параметр.
После #1186 guard хардкожен в SQL — параметр убран, чтобы caller не мог
случайно передать 'novostroyki' или другой сегмент и обойти guard.
"""
import inspect as _inspect
sig = _inspect.signature(est_mod._fetch_anchor_comps)
assert "listing_segment" not in sig.parameters, (
"_fetch_anchor_comps still has `listing_segment` parameter — "
"it was removed in #1186 review fixup; guard is hardcoded in SQL"
)
def test_fetch_anchor_comps_tier_c_canonical_guard_not_parametric() -> None:
"""Tier C в _fetch_anchor_comps использует канон-guard, а не параметрическое сравнение.
Параметрическая форма `listing_segment = CAST(:segment AS text)` исключала NULL
и давала caller'у возможность выбрать произвольный сегмент (включая novostroyki).
"""
src = inspect.getsource(est_mod._fetch_anchor_comps)
assert "CAST(:segment AS text)" not in src, (
"_fetch_anchor_comps Tier C still uses parametric segment filter — "
"must be hardcoded canonical guard"
)
# И канон-guard на месте (Tier C-блок).
assert _GUARD_RE.search(
src
), "_fetch_anchor_comps lacks canonical guard after removing parametric form"