diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index c7f5442c..55a834bb 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -987,6 +987,34 @@ def _normalize_building_key( return street_core, base_no, letter +def _anchor_comp_from_row(r: Any) -> dict[str, Any]: + """Строит comp-dict из строки SQL same-building/micro-radius (#694). + + Несёт 5 числовых полей для _compute_same_building_anchor + (price_per_m2/area_m2/rooms/floor/total_floors) + display-поля listings + (address/source/source_url/price_rub/listing_date/days_on_market/photo_urls/ + lat/lon), чтобы UI-аналоги отражали ИМЕННО комплы, на которых построен якорь, + а не радиусные (cheaper/empty). Display-ключи best-effort: SELECT их тянет, но + helper устойчив к их отсутствию (тестовые моки могут давать только числа). + """ + return { + "price_per_m2": int(r["price_per_m2"]), + "area_m2": float(r["area_m2"]) if r.get("area_m2") is not None else None, + "rooms": int(r["rooms"]) if r.get("rooms") is not None else None, + "floor": int(r["floor"]) if r.get("floor") is not None else None, + "total_floors": int(r["total_floors"]) if r.get("total_floors") is not None else None, + "address": r.get("address"), + "source": r.get("source"), + "source_url": r.get("source_url"), + "price_rub": int(r["price_rub"]) if r.get("price_rub") is not None else None, + "listing_date": r.get("listing_date"), + "days_on_market": r.get("days_on_market"), + "photo_urls": r.get("photo_urls"), + "lat": float(r["lat"]) if r.get("lat") is not None else None, + "lon": float(r["lon"]) if r.get("lon") is not None else None, + } + + def _fetch_anchor_comps( db: Session, *, @@ -1030,7 +1058,9 @@ def _fetch_anchor_comps( db.execute( text( """ - SELECT price_per_m2, area_m2, rooms, floor, total_floors + SELECT price_per_m2, area_m2, rooms, floor, total_floors, + address, source, source_url, price_rub, listing_date, + days_on_market, photo_urls, lat, lon FROM listings WHERE is_active = true AND price_per_m2 > 0 @@ -1053,17 +1083,7 @@ def _fetch_anchor_comps( except Exception: pass rows = [] - comps = [ - { - "price_per_m2": int(r["price_per_m2"]), - "area_m2": float(r["area_m2"]) if r["area_m2"] is not None else None, - "rooms": int(r["rooms"]) if r["rooms"] is not None else None, - "floor": int(r["floor"]) if r["floor"] is not None else None, - "total_floors": int(r["total_floors"]) if r["total_floors"] is not None else None, - } - for r in rows - if r["price_per_m2"] - ] + comps = [_anchor_comp_from_row(r) for r in rows if r["price_per_m2"]] if len(comps) >= min_comps: logger.info( "anchor tier=A street=%r base=%s letter=%s → %d comps", @@ -1081,7 +1101,9 @@ def _fetch_anchor_comps( db.execute( text( """ - SELECT price_per_m2, area_m2, rooms, floor, total_floors + SELECT price_per_m2, area_m2, rooms, floor, total_floors, + address, source, source_url, price_rub, listing_date, + days_on_market, photo_urls, lat, lon FROM listings WHERE is_active = true AND price_per_m2 > 0 @@ -1119,17 +1141,7 @@ def _fetch_anchor_comps( except Exception: pass rows = [] - comps = [ - { - "price_per_m2": int(r["price_per_m2"]), - "area_m2": float(r["area_m2"]) if r["area_m2"] is not None else None, - "rooms": int(r["rooms"]) if r["rooms"] is not None else None, - "floor": int(r["floor"]) if r["floor"] is not None else None, - "total_floors": int(r["total_floors"]) if r["total_floors"] is not None else None, - } - for r in rows - if r["price_per_m2"] - ] + comps = [_anchor_comp_from_row(r) for r in rows if r["price_per_m2"]] if len(comps) >= min_comps: logger.info("anchor tier=C micro-radius → %d comps", len(comps)) return comps, "C" @@ -1645,6 +1657,10 @@ async def estimate_quality( # больше не размывается массовой застройкой). За флагом; OFF ⇒ точно старое # поведение. listing_segment для Tier C берём из самого частого среди аналогов. anchor_tier: str | None = None + # #694: комплы того же дома, на которых РЕАЛЬНО построен headline-якорь. + # Заполняется только когда anchor мутировал median (ниже) — тогда UI-аналоги + # строятся из них, а не из радиусных listings_clean (cheaper/empty). + anchor_comps_used: list[dict[str, Any]] = [] # #691: гейт НЕ требует радиусных аналогов (listings_clean) / median_price>0. # На проде геокод часто = None → ST_DWithin не находит радиусные комплы → # median=0, и same-building якорь скипался, ХОТЯ комплы того же дома есть @@ -1691,6 +1707,8 @@ async def estimate_quality( floor_sigma=settings.estimate_sb_floor_sigma, ) if anchor is not None: + # #694: якорь мутирует headline — UI-аналоги должны отражать ЭТИ комплы. + anchor_comps_used = comps # Headline = recommended ASKING price (комплы — активные объявления; # golden-реалы — asking). Берём anchor_ppm2 (PRE-haircut), НЕ # anchor_sold_ppm2. asking→sold скидка применяется единственным @@ -1946,7 +1964,13 @@ async def estimate_quality( now = datetime.now(tz=UTC) expires_at = now + timedelta(hours=24) - analogs_lots = [_listing_to_analog(lot) for lot in listings_clean[:10]] + # #694: когда same-building якорь сработал, headline построен на комплах того + # же дома (anchor_comps_used) — показываем ИХ, а не радиусные listings_clean + # (другие/дешевле/пусто → premium headline «не подтверждён» аналогами). + if anchor_tier is not None and anchor_comps_used: + analogs_lots = [_anchor_comp_to_analog(c) for c in anchor_comps_used[:10]] + else: + analogs_lots = [_listing_to_analog(lot) for lot in listings_clean[:10]] deals_lots = [_deal_to_analog(d) for d in deals[:10]] freshness_pre = _compute_freshness_minutes(listings_clean) # DaData enrichment (PR Q1) — заполняется только если service отработал. @@ -3100,6 +3124,46 @@ def _listing_to_analog(row: dict[str, Any]) -> AnalogLot: ) +def _anchor_comp_to_analog(c: dict[str, Any]) -> AnalogLot: + """Same-building/micro-radius comp → AnalogLot (#694). + + Когда якорь сработал, headline построен на этих комплах — показываем их в UI. + ROBUST к отсутствию display-полей: реальные комплы несут address/source/price_rub + (SELECT их тянет), но тестовые моки и legacy-строки могут давать только числа + (price_per_m2/area_m2/rooms). price_rub тогда вычисляем ppm²×area + (fallback на ppm² если area 0/None — без zero-result/crash). distance_m не + значим для same-building → None, если явно не передан. + """ + ppm2 = int(c.get("price_per_m2") or 0) + area = float(c.get("area_m2") or 0) + price_rub_raw = c.get("price_rub") + if price_rub_raw is not None: + price_rub = int(price_rub_raw) + elif area > 0: + price_rub = round(ppm2 * area) + else: + # area неизвестна — деградируем до ppm² (никогда 0/crash при ppm²>0). + price_rub = ppm2 + photo_urls = c.get("photo_urls") + return AnalogLot( + address=c.get("address") or "", + area_m2=area, + rooms=int(c.get("rooms") or 0), + floor=c.get("floor"), + total_floors=c.get("total_floors"), + price_rub=price_rub, + price_per_m2=ppm2, + listing_date=c.get("listing_date"), + days_on_market=c.get("days_on_market"), + photo_url=(photo_urls or [None])[0] if photo_urls else None, + source=c.get("source"), + source_url=c.get("source_url"), + distance_m=int(c["distance_m"]) if c.get("distance_m") is not None else None, + lat=float(c["lat"]) if c.get("lat") is not None else None, + lon=float(c["lon"]) if c.get("lon") is not None else None, + ) + + def _deal_to_analog(row: dict[str, Any]) -> AnalogLot: """deals не имеют photo_url — упрощённо. diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index e22d57f8..7c0f6f60 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -562,6 +562,72 @@ def test_anchor_exposes_comp_max() -> None: assert res["comp_min_ppm2"] == 300_000 +# ── #694: UI-аналоги отражают комплы, на которых построен headline ────────── + + +def test_estimate_analogs_reflect_anchor_comps_when_fired() -> None: + """#694: якорь сработал (tier=A) → est.analogs строятся из SAME-BUILDING комплов + (399k/472k/684k), а НЕ из дешёвых радиусных (_RADIUS_ANALOGS 200k-220k). + price_rub вычислен ppm²×area (комплы без price_rub/address — fallback-путь).""" + est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A") + assert len(est.analogs) == 3 + ppm2_shown = {a.price_per_m2 for a in est.analogs} + assert ppm2_shown == {399_478, 472_298, 683_995} + # НЕ радиусные дешёвые лоты. + assert ppm2_shown.isdisjoint({200_000, 210_000, 220_000}) + # price_rub = round(ppm² × area) для каждого компла (нет price_rub в моке). + by_ppm2 = {a.price_per_m2: a for a in est.analogs} + for c in _SB_COMPS_PREMIUM: + a = by_ppm2[c["price_per_m2"]] + assert a.price_rub == round(c["price_per_m2"] * c["area_m2"]) + + +def test_estimate_analogs_stay_radius_when_no_anchor() -> None: + """#694: якорь НЕ сработал (tier=None, Tier D) → est.analogs отражают радиусные + аналоги (_RADIUS_ANALOGS 200k-220k) — существующее поведение сохранено.""" + est = _run_estimate(anchor_comps=[], anchor_tier=None) + assert len(est.analogs) == len(_RADIUS_ANALOGS) + ppm2_shown = {a.price_per_m2 for a in est.analogs} + assert ppm2_shown == {200_000, 210_000, 220_000} + + +def test_estimate_analogs_pass_through_display_fields() -> None: + """#694: комплы С display-полями (address/source/source_url/price_rub) пробрасывают + их в AnalogLot напрямую (без вычисления price_rub из ppm²×area).""" + comps_with_display = [ + { + "price_per_m2": 399_478, + "area_m2": 153.2, + "rooms": 3, + "address": "ЕКБ, ул. Хохрякова, 48", + "source": "cian", + "source_url": "https://cian.ru/offer/42", + "price_rub": 61_000_000, + }, + { + "price_per_m2": 472_298, + "area_m2": 110.1, + "rooms": 3, + "address": "ЕКБ, ул. Хохрякова, 48", + "source": "avito", + "source_url": "https://avito.ru/offer/7", + "price_rub": 52_000_000, + }, + {"price_per_m2": 683_995, "area_m2": 146.2, "rooms": 4}, # без display → fallback + ] + est = _run_estimate(anchor_comps=comps_with_display, anchor_tier="A") + by_ppm2 = {a.price_per_m2: a for a in est.analogs} + a1 = by_ppm2[399_478] + assert a1.address == "ЕКБ, ул. Хохрякова, 48" + assert a1.source == "cian" + assert a1.source_url == "https://cian.ru/offer/42" + assert a1.price_rub == 61_000_000 # прямой price_rub, не ppm²×area + # компл без display → price_rub вычислен, source None. + a3 = by_ppm2[683_995] + assert a3.price_rub == round(683_995 * 146.2) + assert a3.source is None + + # ── #680-WB: within-building heterogeneity refine (uplift-gate + floor weight) ──