fix(tradein/estimator): дедуп кросс-source дублей в anchor-пути (#2265 follow-up) (#2311)
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
This commit is contained in:
parent
0e2b4998c8
commit
4fedfc5970
2 changed files with 196 additions and 0 deletions
|
|
@ -2088,6 +2088,16 @@ def _price_from_inputs(
|
||||||
# агрегации, чтобы n_analogs/median/cv считались по уникальным лотам, а не
|
# агрегации, чтобы n_analogs/median/cv считались по уникальным лотам, а не
|
||||||
# по кросс-постам. No-op при estimate_dedup_analogs_enabled=False.
|
# по кросс-постам. No-op при estimate_dedup_analogs_enabled=False.
|
||||||
listings = _dedup_cross_source(listings)
|
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
|
# 3. Outlier filter
|
||||||
listings_clean = _filter_outliers(listings)
|
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