fix(tradein/estimator): дедуп кросс-source дублей в anchor-пути (#2265 follow-up) #2311

Merged
lekss361 merged 1 commit from fix/tradein-anchor-dedup-gap into main 2026-07-03 20:35:38 +00:00
2 changed files with 196 additions and 0 deletions

View file

@ -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)

View file

@ -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"]))