From 86174e87200f0f5d6e8a44d1e871f53fbfbd6bdc Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 23:32:25 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/estimator):=20=D0=B4=D0=B5=D0=B4?= =?UTF-8?q?=D1=83=D0=BF=20=D0=BA=D1=80=D0=BE=D1=81=D1=81-source=20=D0=B4?= =?UTF-8?q?=D1=83=D0=B1=D0=BB=D0=B5=D0=B9=20=D0=B2=20anchor-=D0=BF=D1=83?= =?UTF-8?q?=D1=82=D0=B8=20(#2265=20follow-up)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2273 применял _dedup_cross_source() только к радиусному списку listings внутри _price_from_inputs(). Anchor-тиры (Tier A same_building / Tier C micro_radius, _fetch_anchor_comps()) строятся отдельным raw SQL-запросом и никогда не проходили через дедуп, хотя именно anchor_comps_used фидит показываемые analogs[] и n_analogs, как только сработал анкорный тир. Живой QA 2026-07-03 воспроизвёл ровно этот класс бага: физлот ул. Сыромолотова 11 (33.6м², эт.3, 4.0млн) показан дважды через cian+domklik (оба с пустым cadastral_number) на реальном адресе с analog_tier=micro_radius. Фикс: anchor_comps = _dedup_cross_source(anchor_comps) перед _compute_same_building_anchor и перед anchor_comps_used — оба потребителя (n_analogs и показываемые analogs) читают один и тот же постдедуп список, синхронизация не требует отдельной логики. Refs #2265, #2262 --- tradein-mvp/backend/app/services/estimator.py | 10 + .../test_estimator_anchor_dedup_gap_2265.py | 186 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index a6391be9..3cdb0ef0 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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) diff --git a/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py b/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py new file mode 100644 index 00000000..1a7386f1 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py @@ -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"])) -- 2.45.3