diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 734077c8..00f3b7e6 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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, diff --git a/tradein-mvp/backend/tests/test_segment_guard_1186.py b/tradein-mvp/backend/tests/test_segment_guard_1186.py index 2ca3dc58..e93fe51c 100644 --- a/tradein-mvp/backend/tests/test_segment_guard_1186.py +++ b/tradein-mvp/backend/tests/test_segment_guard_1186.py @@ -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"