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