fix(tradein/estimator): дедуп кросс-source дублей в anchor-пути (#2265 follow-up) #2311
2 changed files with 196 additions and 0 deletions
|
|
@ -2088,6 +2088,16 @@ def _price_from_inputs(
|
|||
# агрегации, чтобы n_analogs/median/cv считались по уникальным лотам, а не
|
||||
# по кросс-постам. No-op при estimate_dedup_analogs_enabled=False.
|
||||
listings = _dedup_cross_source(listings)
|
||||
# #2265 gap (live QA 2026-07-03): якорный пул (_fetch_anchor_comps, Tier A
|
||||
# same_building / Tier C micro_radius) строится ОТДЕЛЬНЫМ raw SQL и раньше
|
||||
# НИКОГДА не проходил через дедуп — только радиусные `listings` дедупились.
|
||||
# Когда anchor сработал, anchor_comps_used (и n_analogs=anchor["n"]) брались
|
||||
# из недедупленного пула → тот же физлот с разных source (пустой cadnum,
|
||||
# street-композит совпал) считался/показывался дважды. Дедупим ЗДЕСЬ, ДО
|
||||
# _compute_same_building_anchor и ДО anchor_comps_used=anchor_comps ниже —
|
||||
# оба места читают этот же (теперь задедупленный) список, так что n_analogs
|
||||
# и показанные top-N analogs остаются согласованы автоматически.
|
||||
anchor_comps = _dedup_cross_source(anchor_comps)
|
||||
|
||||
# 3. Outlier filter
|
||||
listings_clean = _filter_outliers(listings)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,186 @@
|
|||
"""#2265 gap — live QA 2026-07-03: якорный пул (Tier A/C) не дедупился (regression).
|
||||
|
||||
PR #2273 ввёл `_dedup_cross_source` и применил его к РАДИУСНОМУ списку `listings`
|
||||
внутри `_price_from_inputs` (см. test_estimator_dedup_cross_source_2087.py — 9+
|
||||
тестов покрывают именно эту ветку и НЕ трогаются здесь).
|
||||
|
||||
Живой QA (2026-07-03, POST /api/v1/trade-in/estimate, "Рассветная 5", area=33,
|
||||
rooms=1, analog_tier=micro_radius) воспроизвёл тот же класс бага на ДРУГОМ пути:
|
||||
`_fetch_anchor_comps` (same_building / micro_radius, свой raw SQL) никогда не
|
||||
проходил через `_dedup_cross_source` — физлот "ул. Сыромолотова, 11" (33.6 м²,
|
||||
эт. 3, 4.0М, cadastral_number пуст) пришёл дважды (source=domklik + source=cian)
|
||||
и оба раза попал в показанные analogs, а n_analogs считался по этой же
|
||||
недедупленной anchor-выборке (согласован сам с собой, но нечестен по сути —
|
||||
считает один физлот дважды).
|
||||
|
||||
Фикс: `_price_from_inputs` дедупит `anchor_comps` ТЕМ ЖЕ `_dedup_cross_source`
|
||||
сразу при входе — до `_compute_same_building_anchor` (даёт n) и до
|
||||
`anchor_comps_used = anchor_comps` (даёт показанные analogs). Оба места читают
|
||||
один и тот же (теперь задедупленный) список → n_analogs синхронен с analogs
|
||||
автоматически, без отдельной синхронизации.
|
||||
|
||||
Harness: переиспользует `_run_estimate`/`_make_payload` из test_same_building_anchor.py
|
||||
(тот же паттерн, что test_estimator_ghost_anchor_radius_empty_1871.py) — полный
|
||||
estimate-путь со всеми I/O застабленными, `_fetch_anchor_comps` форсирован на
|
||||
заданный anchor_comps/tier.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
import pytest
|
||||
|
||||
_ANCHOR_TEST = Path(__file__).parent / "test_same_building_anchor.py"
|
||||
_spec = importlib.util.spec_from_file_location("_anchor_harness_dedup_2265", _ANCHOR_TEST)
|
||||
assert _spec is not None and _spec.loader is not None
|
||||
_h = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_h)
|
||||
|
||||
|
||||
def _comp(
|
||||
*,
|
||||
source: str,
|
||||
address: str,
|
||||
area: float,
|
||||
price_rub: int,
|
||||
floor: int,
|
||||
rooms: int = 1,
|
||||
cad: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Мимикрирует _anchor_comp_from_row (#694) — те же ключи, что даёт SQL-строка."""
|
||||
ppm2 = round(price_rub / area)
|
||||
return {
|
||||
"price_per_m2": ppm2,
|
||||
"area_m2": area,
|
||||
"rooms": rooms,
|
||||
"floor": floor,
|
||||
"total_floors": 5,
|
||||
"address": address,
|
||||
"source": source,
|
||||
"source_url": None,
|
||||
"price_rub": price_rub,
|
||||
"listing_date": None,
|
||||
"days_on_market": None,
|
||||
"photo_urls": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
"building_cadastral_number": cad, # пуст у обоих source (живой прод-кейс)
|
||||
}
|
||||
|
||||
|
||||
def _payload(): # type: ignore[no-untyped-def]
|
||||
return _h._make_payload(area=33.0, rooms=1)
|
||||
|
||||
|
||||
# Живой физлот (QA 2026-07-03): "ул. Сыромолотова, 11", 33.6 м², эт. 3, 4.0М,
|
||||
# cadastral_number пуст, встречается ДВАЖДЫ (source=domklik, source=cian) —
|
||||
# байт-в-байт та же пара форматов адреса, что в
|
||||
# test_dedup_collapses_three_source_formats (2087-тесте, но там дедуп проверяется
|
||||
# напрямую на _dedup_cross_source, не через якорный путь _price_from_inputs).
|
||||
_DUP_LOT_CIAN = _comp(
|
||||
source="cian",
|
||||
address="р-н Кировский, мкр. ЖБИ, улица Сыромолотова, 11",
|
||||
area=33.6,
|
||||
price_rub=4_000_000,
|
||||
floor=3,
|
||||
)
|
||||
_DUP_LOT_DOMKLIK = _comp(
|
||||
source="domklik",
|
||||
address="Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова",
|
||||
area=33.6,
|
||||
price_rub=4_000_000,
|
||||
floor=3,
|
||||
)
|
||||
|
||||
# 3 доп. уникальных компла того же дома/подъезда — ppm2 близки к дубль-лоту
|
||||
# (119k-125k), MAD-clip (k=3.5) их не тронет; вместе с дубль-лотом дают
|
||||
# estimate_sb_min_comps=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает.
|
||||
_OTHER_COMPS = [
|
||||
_comp(
|
||||
source="avito", address="улица Сыромолотова, 13", area=40.0, price_rub=5_000_000, floor=5
|
||||
),
|
||||
_comp(
|
||||
source="yandex", address="улица Сыромолотова, 15", area=45.0, price_rub=5_500_000, floor=2
|
||||
),
|
||||
_comp(source="cian", address="улица Сыромолотова, 17", area=50.0, price_rub=6_000_000, floor=6),
|
||||
]
|
||||
|
||||
_ANCHOR_COMPS_WITH_DUP = [_DUP_LOT_CIAN, _DUP_LOT_DOMKLIK, *_OTHER_COMPS] # 5 raw, 4 unique
|
||||
|
||||
# Минимальный изолированный репро: РОВНО тот физлот из QA (#2265), встреченный
|
||||
# дважды (cian+domklik), БЕЗ доп. комплов и БЕЗ MAD-clip-эффектов (два одинаковых
|
||||
# ppm2 → MAD=0 → ничего не отсекается). min_comps монкипатчится до 1 в тесте, чтобы
|
||||
# изолировать именно дедуп-гейт, не threshold-гейт по числу комплов.
|
||||
_MINIMAL_DUP_PAIR = [_DUP_LOT_CIAN, _DUP_LOT_DOMKLIK]
|
||||
|
||||
|
||||
def test_anchor_path_dedups_cross_source_duplicate_lot(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Regression #2265 gap: якорный пул (Tier A) дедупится ТАК ЖЕ, как радиусный.
|
||||
|
||||
Живой прод-физлот "ул. Сыромолотова, 11" (33.6 м², эт. 3, 4.0М, cadnum пуст)
|
||||
пришёл дважды (cian + domklik) — точная пара из QA-репро.
|
||||
|
||||
До фикса: `_price_from_inputs` не дедупил anchor_comps → якорь строится на 2
|
||||
сырых комплах (один физлот дважды) → est.analogs показывает дубль ДВАЖДЫ (n=2).
|
||||
После фикса: дедуп схлопывает пару в 1 представителя ДО _compute_same_building_
|
||||
anchor → est.analogs содержит физлот ровно ОДИН раз (n=1).
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr(settings, "estimate_sb_min_comps", 1)
|
||||
est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload())
|
||||
|
||||
assert len(est.analogs) == 1 # НЕ 2 — дубль-лот схлопнут в 1 представителя
|
||||
dup_ppm2 = round(4_000_000 / 33.6)
|
||||
assert est.analogs[0].price_per_m2 == dup_ppm2
|
||||
# представитель дубль-группы — один из {cian, domklik} (tie-break детерминирован
|
||||
# в _dedup_rep_key, нам важен сам факт единственности, не кто именно победил).
|
||||
assert est.analogs[0].source in ("cian", "domklik")
|
||||
|
||||
|
||||
def test_anchor_path_n_analogs_matches_shown_count(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Честность счётчика: n_analogs в anchor-пути описывает ИМЕННО показанные
|
||||
(постдедуп) analogs, а не сырой недедупленный anchor-пул. До фикса n_analogs=2
|
||||
при одном реальном физлоте (дубль посчитан дважды) — самосогласованно, но
|
||||
нечестно. После фикса n_analogs=1, синхронно с analogs."""
|
||||
from app.core.config import settings
|
||||
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr(settings, "estimate_sb_min_comps", 1)
|
||||
est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload())
|
||||
|
||||
assert est.n_analogs == len(est.analogs) == 1
|
||||
|
||||
|
||||
def test_anchor_path_dedup_also_applies_larger_pool() -> None:
|
||||
"""Контроль на более крупном пуле (5 raw / 4 unique, дефолтный min_comps=4):
|
||||
дедуп срабатывает не только в вырожденном 2-элементном случае."""
|
||||
est = _h._run_estimate(anchor_comps=_ANCHOR_COMPS_WITH_DUP, anchor_tier="A", payload=_payload())
|
||||
assert len(est.analogs) == 4
|
||||
assert est.n_analogs == 4
|
||||
dup_ppm2 = round(4_000_000 / 33.6)
|
||||
assert [a.price_per_m2 for a in est.analogs].count(dup_ppm2) == 1
|
||||
|
||||
|
||||
def test_anchor_path_no_false_merge_of_distinct_units(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Контроль анти-катастрофы: БЕЗ дубля (4 явно различных физлота) дедуп не
|
||||
трогает выборку — n_analogs/analogs остаются 4/4, ничего лишнего не схлопнуто."""
|
||||
comps = [*_OTHER_COMPS, _DUP_LOT_CIAN]
|
||||
est = _h._run_estimate(anchor_comps=comps, anchor_tier="A", payload=_payload())
|
||||
assert len(est.analogs) == 4
|
||||
assert est.n_analogs == 4
|
||||
shown_ppm2 = sorted(a.price_per_m2 for a in est.analogs)
|
||||
expected_ppm2 = sorted(c["price_per_m2"] for c in comps)
|
||||
assert shown_ppm2 == expected_ppm2
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(pytest.main([__file__, "-q"]))
|
||||
Loading…
Add table
Reference in a new issue