diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index f16525eb..a6fe69f3 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -718,6 +718,7 @@ def load_estimate( _qc_geo_to_precision, _resolve_target_city, _source_counts, + analog_tier_from_explanation, rehydrate_search_radius_m, ) @@ -896,6 +897,10 @@ def load_estimate( # осознанно НЕ заполняем — payload.radius_m не персистится, и подставить # сюда дефолт значило бы выдать догадку за то, что просил пользователь. search_radius_m=search_radius_m, + # #3466: якорный тир (same_building/micro_radius) восстанавливается из подписи + # якорного блока в confidence_explanation; радиусный — null, колонки нет. + # Без него ссылка ?id= теряла признак «коридор в цену не вошёл» (Tier A). + analog_tier=analog_tier_from_explanation(row.confidence_explanation), ) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 00d6c074..85663a0c 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -166,6 +166,10 @@ class DkpCorridor(BaseModel): #3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only. + #3466: исключение сверх порога — якорь того же дома (AggregatedEstimate.analog_tier + == "same_building"): кламп его не трогает, radius-floor тоже, и коридор в цену не + входит при любом count. Признак живёт в analog_tier, а не здесь: тир не выводится + из коридора, а поле обязано быть верным и на GET-rehydrate. """ count: int # число ДКП-сделок в выборке @@ -195,7 +199,10 @@ class DkpCorridor(BaseModel): Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ путей коридора: soft-кламп headline сверху и radius-floor снизу (estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит - цену — зона n=3..9 на экране была неотличима от работающей. + цену — зона n=1..9 на экране была неотличима от работающей. + + False тоже НЕ значит «коридор в цену вошёл»: при analog_tier="same_building" + обе страховки выключены якорем, а не порогом (#3466). ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C (#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и @@ -344,6 +351,9 @@ class AggregatedEstimate(BaseModel): # "city" — radius Tier W: широкий fallback # null — нет данных / оценка не построена # НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него). + # #3466: на GET-rehydrate колонки нет — якорные тиры (same_building/micro_radius) + # восстанавливаются из подписи якорного блока в confidence_explanation + # (estimator.analog_tier_from_explanation), радиусные остаются null. analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None # search_radius_m — фактический радиус (метры), по которому реально отбирались # listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m: diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index cd477ac6..d3b0540f 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -283,6 +283,32 @@ def radius_from_relaxations(relaxations: list[str] | None) -> int | None: return None +# #3466: тир якоря → подпись в confidence_explanation и enum ответа (analog_tier). +# Колонки под analog_tier нет, поэтому подпись, которую пишет якорный блок +# _price_from_inputs, — единственное место, где тир попадает в persisted-состояние. +# Отсюда же его читает GET-rehydrate (analog_tier_from_explanation): тот же приём, +# что у радиуса (#2632). Нужен витрине: при same_building коридор ДКП в цену не +# входит вовсе (кламп exempt, radius-floor только без якоря), и без тира ссылка +# ?id= показывала бы такой коридор как работающий. +# ponytail: разбор прозы, а не колонка; колонка analog_tier — когда понадобятся +# радиусные тиры на GET или подпись начнут переписывать мимо этой константы. +ANCHOR_TIER_LABELS: dict[str, str] = {"A": "того же дома", "C": "ближайшего окружения (≤500 м)"} +ANCHOR_API_TIERS: dict[str, str] = {"A": "same_building", "C": "micro_radius"} + + +def analog_tier_from_explanation(explanation: str | None) -> str | None: + """Якорный analog_tier сохранённой оценки по подписи якорного блока, иначе None. + + Ищется вся фраза «Оценка построена по N аналогам из <тир>», а не хвост «из того + же дома»: радиусный тир S пишет рядом «(аналоги из того же дома)», и это другой + путь цены — коридор там клампит. None = радиусный путь либо старая формулировка. + """ + for tier, label in ANCHOR_TIER_LABELS.items(): + if re.search(rf"Оценка построена по \d+ аналогам из {re.escape(label)}", explanation or ""): + return ANCHOR_API_TIERS[tier] + return None + + def rehydrate_search_radius_m( relaxations: list[str] | None, analog_distances_m: list[int | None] ) -> int | None: @@ -3699,10 +3725,10 @@ def _price_from_inputs( range_low = new_range_low range_high = new_range_high confidence = anchor["confidence"] - tier_label = "того же дома" if anchor_tier == "A" else "ближайшего окружения (≤500 м)" - # #695: explanation описывает ИМЕННО якорные комплы. + # #695: explanation описывает ИМЕННО якорные комплы. #3466: подпись читает + # GET-rehydrate (analog_tier_from_explanation) — менять только через константу. explanation = ( - f"Оценка построена по {anchor['n']} аналогам из {tier_label}" + f"Оценка построена по {anchor['n']} аналогам из {ANCHOR_TIER_LABELS[anchor_tier]}" f"{' (топ-уровень в доме)' if anchor['used_uplift'] else ''}." ) + repair_note # #695 (QA fixup): n_analogs по anchor-популяции. @@ -5313,8 +5339,9 @@ async def estimate_quality( # #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок # меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и # soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе). - # Зона n=3..9 на экране неотличима от работающего коридора, поэтому - # попадание в неё пишется явной строкой: число оценок за сутки — + # Зона n=1..9 на экране неотличима от работающего коридора, поэтому + # попадание в неё пишется явной строкой (#3466: n=1..2 достижимы — уличный + # коридор отдаётся с одной сделки, widen только вне ЕКБ): число оценок за сутки — # `docker logs tradein-backend --since 24h 2>&1 | grep -c corridor_advisory_zone`. # Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит. if dkp_corridor is not None and dkp_corridor.advisory_only: @@ -5573,10 +5600,8 @@ async def estimate_quality( # radius-путь: analog_tier "W" → "city", остальные → "district". # None только если нет аналогов (median=0, insufficient_data=True). if median_price > 0: - if anchor_tier == "A": - api_analog_tier: str | None = "same_building" - elif anchor_tier == "C": - api_analog_tier = "micro_radius" + if anchor_tier in ANCHOR_API_TIERS: + api_analog_tier: str | None = ANCHOR_API_TIERS[anchor_tier] elif analog_tier == "W": api_analog_tier = "city" else: diff --git a/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py b/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py index afcdabf7..3d0222da 100644 --- a/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py +++ b/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py @@ -1,7 +1,8 @@ -"""#3452: зона n = 3..9 — коридор ДКП виден, но цену по нему не поправляют. +"""#3452: зона n = 1..9 — коридор ДКП виден, но цену по нему не поправляют. -Два порога на одну выборку. Показ коридора открывается с трёх сделок -(`DKP_CORRIDOR_CITY_WIDE_MIN_N`, ниже — city-wide widen), а обе ценовые страховки +Два порога на одну выборку. Показ коридора открывается с одной сделки (ниже +`DKP_CORRIDOR_CITY_WIDE_MIN_N` вне Екатеринбурга пробуется city-wide widen, но +уличная выборка из 1-2 сделок отдаётся и без него, #3466), а обе ценовые страховки по коридору — soft-кламп headline сверху и radius-floor снизу — гейтятся `estimate_corridor_clamp_min_n` (10). Между ними лежит зона, где коридор существует, показывается и участвует в fallback-путях, а цену не держит; на diff --git a/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py b/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py new file mode 100644 index 00000000..4350f621 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py @@ -0,0 +1,176 @@ +"""#3466: при якоре Tier A коридор ДКП в цену не входит — ответ обязан это нести. + +`DkpCorridor.advisory_only` (#3452) считается из размера выборки, а кламп headline +выключается ещё и якорем того же дома (`_apply_corridor_clamp`: Tier A exempt; +radius-floor требует `anchor_tier is None`). Ревьюер PR #3462 воспроизвёл: n=20, +advisory_only=False, headline 202 100 ₽/м² против потолка 140 000 — поле молчит, а +коридор цену не тронул. + +Признак — `analog_tier == "same_building"`, уже структурный в POST-ответе. На +GET-rehydrate колонки под него нет, тир восстанавливается из подписи якорного +блока (`analog_tier_from_explanation`). Тесты ПО ЗНАЧЕНИЮ: + +* цена Tier A не зависит от коридора вовсе — снятие exempt в клампе роняет тест; +* тот же коридор на радиусном пути цену прижимает — иначе первый тест пуст; +* подпись, которую пишет якорный блок, читается обратно в тот же тир, а соседняя + подпись радиусного тира S («аналоги из того же дома») — нет; +* полоса счёта маркера `corridor_advisory_zone` — n = 1..9, а не 3..9: уличный + коридор отдаётся с одной сделки (widen только вне Екатеринбурга). +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.core.config import settings +from app.services import estimator +from app.services.geocoder import GeocodeResult + +# Коридор выше порога доверия, потолок заведомо ниже цены якоря: кламп, если ему +# позволить, ОБЯЗАН прижать headline к cap = 100 000 × (1 + slack). +_CORRIDOR: dict[str, Any] = { + "count": 20, + "low_ppm2": 80_000, + "median_ppm2": 90_000, + "high_ppm2": 100_000, + "period_months": 12, + "latest_deal_date": None, + "scope": "street", +} +_ANCHOR_PPM2 = (190_000, 195_000, 200_000, 205_000, 210_000) + + +def _cap() -> float: + return _CORRIDOR["high_ppm2"] * (1.0 + settings.estimate_corridor_clamp_slack) + + +def _call( + *, + listings_ppm2: tuple[float, ...], + anchor_tier_fetched: str | None, + dkp_raw: dict[str, Any] | None, + analog_tier: str = "W", +) -> estimator.PricingResult: + return estimator._price_from_inputs( + listings=[ + {"price_per_m2": p, "address": f"ул. Тестовая, {i}", "source": "avito"} + for i, p in enumerate(listings_ppm2) + ], + area_m2=50.0, + rooms=2, + repair_state=None, + floor=5, + total_floors=10, + target_year=None, + analog_tier=analog_tier, + fallback_used=False, + area_widened=False, + anchor_comps=( + [{"price_per_m2": p, "area_m2": 50.0, "rooms": 2} for p in _ANCHOR_PPM2] + if anchor_tier_fetched + else [] + ), + anchor_tier_fetched=anchor_tier_fetched, + dkp_raw=dkp_raw, + imv_anchor=None, + imv_eval=None, + yandex_val_present=False, + cian_val_present=False, + ratio_resolver=lambda _appm2: (None, None), + quarter_index_lookup=lambda _q: None, + quarter_indexes_lookup=lambda _qs: {}, + target_house_cadnum=None, + dadata_coarse=False, + geo=GeocodeResult( + lat=56.838, lon=60.597, full_address="ул. Тестовая, 1", provider="nominatim" + ), + dadata_qc_geo=None, + ) + + +def _price(pr: estimator.PricingResult) -> tuple[float, int, int, int]: + return (pr.median_ppm2, pr.median_price, pr.range_low, pr.range_high) + + +def test_tier_a_price_does_not_depend_on_corridor() -> None: + """Ядро признака: при same_building коридор не двигает НИ ОДНО ценовое число.""" + listings = (100_000.0,) * 6 + with_corridor = _call(listings_ppm2=listings, anchor_tier_fetched="A", dkp_raw=_CORRIDOR) + without = _call(listings_ppm2=listings, anchor_tier_fetched="A", dkp_raw=None) + + assert with_corridor.anchor_tier == "A", "якорь не сработал — тест проверял бы радиусный путь" + # Предпосылка — на цене БЕЗ коридора: она выше потолка, т.е. клампу было что резать. + assert without.median_ppm2 > _cap(), ( + f"цена якоря {without.median_ppm2:.0f} не выше cap {_cap():.0f} — " + "кламп не понадобился бы и без exempt, случай #3466 не воспроизведён" + ) + assert _price(with_corridor) == _price(without), ( + "коридор из 20 сделок изменил цену Tier A — значит он в цену вошёл, и признак " + f"same_building на витрине врёт: {_price(with_corridor)} против {_price(without)}" + ) + + +def test_same_corridor_clamps_radius_path() -> None: + """Контроль: тот же коридор без якоря прижимает цену — первый тест не пустой.""" + pr = _call( + listings_ppm2=(195_000.0, 200_000.0, 200_000.0, 205_000.0, 210_000.0, 190_000.0), + anchor_tier_fetched=None, + dkp_raw=_CORRIDOR, + ) + + assert pr.anchor_tier is None + assert round(pr.median_ppm2) == round(_cap()), ( + f"без якоря коридор n=20 обязан прижать headline к cap={_cap():.0f}, " + f"получено {pr.median_ppm2:.0f}" + ) + + +def test_anchor_sentence_reads_back_to_tier() -> None: + """Подпись якорного блока → тот же enum, что POST кладёт в analog_tier.""" + tier_a = _call(listings_ppm2=(100_000.0,) * 6, anchor_tier_fetched="A", dkp_raw=None) + tier_c = _call(listings_ppm2=(200_000.0,) * 6, anchor_tier_fetched="C", dkp_raw=None) + assert (tier_a.anchor_tier, tier_c.anchor_tier) == ("A", "C") + + assert estimator.analog_tier_from_explanation(tier_a.explanation) == "same_building" + assert estimator.analog_tier_from_explanation(tier_c.explanation) == "micro_radius" + # #conf-cap ставит свою фразу ПЕРЕД подписью — тир обязан читаться и тогда. + capped = "Оценка построена с расширенными параметрами подбора — точность снижена. " + assert estimator.analog_tier_from_explanation(capped + tier_a.explanation) == "same_building" + + +def test_radius_same_house_note_is_not_anchor() -> None: + """Радиусный тир S пишет «(аналоги из того же дома)», но коридор там клампит.""" + pr = _call( + listings_ppm2=(100_000.0,) * 6, anchor_tier_fetched=None, dkp_raw=None, analog_tier="S" + ) + + assert pr.anchor_tier is None + assert "из того же дома" in pr.explanation, pr.explanation + assert estimator.analog_tier_from_explanation(pr.explanation) is None + assert estimator.analog_tier_from_explanation(None) is None + + +def test_street_corridor_is_returned_below_three_deals() -> None: + """Полоса маркера #3452 — n = 1..9: улица Екатеринбурга не расширяется до города.""" + rows = [ + {"price_per_m2": 150_000, "deal_date": None}, + {"price_per_m2": 160_000, "deal_date": None}, + ] + db = MagicMock() + db.execute.return_value.mappings.return_value.all.return_value = rows + with patch.object(estimator, "_load_sber_index_series", return_value={}): + raw = estimator._fetch_dkp_corridor( + db, + address="Екатеринбург, ул. Учителей, 18", + rooms=2, + area=50.0, + city="екатеринбург", + ) + + assert raw is not None and raw["count"] == 2, raw + assert raw["scope"] == "street" + assert raw["count"] < estimator.DKP_CORRIDOR_CITY_WIDE_MIN_N diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index d2dff0d0..5ed999e1 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -28,6 +28,8 @@ import pytest # noqa: E402 from fastapi import FastAPI # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 +from app.services.estimator import analog_tier_from_explanation # noqa: E402 + _ESTIMATE_ID = "11111111-1111-1111-1111-111111111111" @@ -170,6 +172,9 @@ def _stub_precision_and_pdf(): # фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее # поведение (см. tests/test_estimator_search_radius_2632.py). rehydrate_search_radius_m=lambda *a, **k: None, + # #3466: якорный тир из подписи в confidence_explanation. У фикстуры там + # «ok» → None и есть настоящее поведение; положительный случай — ниже. + analog_tier_from_explanation=lambda _e: None, ) real_estimator = sys.modules.get("app.services.estimator") sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment] @@ -273,6 +278,26 @@ def test_get_estimate_rehydrates_post_only_fields(trade_in_app: FastAPI) -> None assert body["last_scraped_at"] is not None +def test_get_estimate_restores_anchor_tier(trade_in_app: FastAPI) -> None: + """#3466: ссылка ?id= на оценку Tier A отдаёт analog_tier=same_building. + + Без него витрина не отличает коридор, который цену не трогал (якорь того же + дома), от работающего. Разбор — настоящий, из estimator, по подписи, которую + пишет якорный блок. + """ + est = sys.modules["app.services.estimator"] + est.analog_tier_from_explanation = analog_tier_from_explanation # type: ignore[attr-defined] + row = _make_estimate_row(created_by="kopylov") + row.confidence_explanation = "Оценка построена по 20 аналогам из того же дома." + client = _client_with(trade_in_app, _make_db_mock(row), role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["analog_tier"] == "same_building" + + def test_get_estimate_requires_authenticated_user(trade_in_app: FastAPI) -> None: """No X-Authenticated-User header → 401.""" db_mock = _make_db_mock(_make_estimate_row(created_by="kopylov")) diff --git a/tradein-mvp/backend/tests/test_estimate_revival.py b/tradein-mvp/backend/tests/test_estimate_revival.py index d41f2e4d..3957f26d 100644 --- a/tradein-mvp/backend/tests/test_estimate_revival.py +++ b/tradein-mvp/backend/tests/test_estimate_revival.py @@ -277,6 +277,8 @@ def _estimator_stub(): # этой фикстуры analogs пусты → None и есть настоящее поведение # (см. tests/test_estimator_search_radius_2632.py). rehydrate_search_radius_m=lambda *a, **k: None, + # #3466: подписи якоря у строк фикстуры нет → None (см. test_estimate_idor.py). + analog_tier_from_explanation=lambda _e: None, estimate_quality=_default_estimate_quality, ) sys.modules["app.services.estimator"] = stub # type: ignore[assignment] diff --git a/tradein-mvp/backend/tests/test_estimator_radius_floor.py b/tradein-mvp/backend/tests/test_estimator_radius_floor.py index aba07b00..ad965bc5 100644 --- a/tradein-mvp/backend/tests/test_estimator_radius_floor.py +++ b/tradein-mvp/backend/tests/test_estimator_radius_floor.py @@ -8,6 +8,8 @@ - radius median выше dkp_low × factor → no-op (медиана не изменена) - dkp_raw is None → no-op (нет базы для floor) - anchor-путь (anchor_tier != None) → не затронут floor'ом +- коридор ниже estimate_corridor_clamp_min_n → floor не применяется (#3466) +- коридор ровно estimate_corridor_clamp_min_n → floor применяется (#3466) """ from __future__ import annotations @@ -114,17 +116,25 @@ def _run_estimate( return anyio.run(_run) +def _six(center: float) -> list[dict[str, Any]]: + """Шесть лотов вокруг center — выше HEADLINE_LISTINGS_MIN_N (5). + + Три лота (как было до #3466) уводили оценку в #oblast-E: headline уступался + медиане коридора (180k), и floor не исполнялся вовсе — тесты 1 и 2 зеленели на + deals-fallback, а снятие гейта min_n у floor ничего не роняло. + """ + return [ + _make_listing(price_per_m2=center + d) + for d in (-5_000.0, -2_500.0, 0.0, 0.0, 2_500.0, 5_000.0) + ] + + # ── тест 1: radius median ниже floor → поднята ─────────────────────────────── def test_radius_median_below_dkp_floor_is_lifted() -> None: """Radius median = 80k, dkp_low = 150k, factor 0.8 → floor = 120k → median поднята.""" - # analogs с median ~80k - analogs = [ - _make_listing(price_per_m2=75_000.0), - _make_listing(price_per_m2=80_000.0), - _make_listing(price_per_m2=85_000.0), - ] + analogs = _six(80_000.0) dkp_raw = { "count": 15, "low_ppm2": 150_000, @@ -135,8 +145,9 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None: est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) floor_ppm2 = 150_000 * 0.8 # 120_000 - assert est.median_price_per_m2 >= floor_ppm2, ( - f"median_ppm2={est.median_price_per_m2} должна быть >= floor={floor_ppm2}" + # Ровно floor, а не «не ниже»: медиана коридора (180k) тоже «не ниже». + assert est.median_price_per_m2 == floor_ppm2, ( + f"median_ppm2={est.median_price_per_m2} должна быть поднята ровно до floor={floor_ppm2}" ) @@ -145,11 +156,7 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None: def test_radius_median_above_dkp_floor_unchanged() -> None: """Radius median = 200k > dkp_low × factor = 150k × 0.8 = 120k → no-op.""" - analogs = [ - _make_listing(price_per_m2=190_000.0), - _make_listing(price_per_m2=200_000.0), - _make_listing(price_per_m2=210_000.0), - ] + analogs = _six(200_000.0) dkp_raw = { "count": 15, "low_ppm2": 150_000, @@ -163,9 +170,9 @@ def test_radius_median_above_dkp_floor_unchanged() -> None: # Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor. floor_ppm2 = 150_000 * 0.8 # 120_000 assert est.median_price_per_m2 > floor_ppm2, "median должна быть выше floor (no-op)" - # Медиана соответствует аналогам (~200k), а не floor - assert 180_000 <= est.median_price_per_m2 <= 220_000, ( - f"median_ppm2={est.median_price_per_m2} должна остаться в диапазоне аналогов (no-op)" + # Медиана — ровно медиана аналогов, а не floor и не медиана коридора (180k). + assert est.median_price_per_m2 == 200_000, ( + f"median_ppm2={est.median_price_per_m2} должна остаться медианой аналогов (no-op)" ) @@ -185,3 +192,63 @@ def test_no_dkp_raw_no_floor() -> None: assert est.median_price_per_m2 < 100_000, ( f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти" ) + + +# ── тест 4: коридор ниже порога доверия → floor не поднимает (#3466) ───────── + + +def test_floor_not_applied_below_clamp_min_n() -> None: + """n = estimate_corridor_clamp_min_n − 1: тот же floor, что в тесте 1, но выключен. + + `DkpCorridor.advisory_only` (#3452) обосновывает себя общим порогом у клампа + headline И у radius-floor. Половина про кламп стережётся test_3452_*, эта — + здесь: без гейта по count медиана 80k поднялась бы до 120k. + """ + from app.core.config import settings + + analogs = _six(80_000.0) + dkp_raw = { + "count": settings.estimate_corridor_clamp_min_n - 1, + "low_ppm2": 150_000, + "median_ppm2": 180_000, + "high_ppm2": 220_000, + "period_months": 12, + } + est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) + + assert est.median_price_per_m2 < 100_000, ( + f"median_ppm2={est.median_price_per_m2}: коридор из " + f"{dkp_raw['count']} сделок ниже порога не должен поднимать цену до floor=120000" + ) + + +# ── тест 5: ровно на пороге floor уже работает (#3466) ────────────────────── + + +def test_floor_applied_at_exactly_clamp_min_n() -> None: + """n = estimate_corridor_clamp_min_n: advisory_only=False → floor обязан поднять. + + Тест 4 стережёт «ниже порога — нет», тест 1 — «15 сделок — да». Граница между + ними (`>=` против `>`) не стереглась: при n == min_n витрина не пишет подпись + «справочно», значит коридор обязан войти в цену. + """ + from app.core.config import settings + from app.schemas.trade_in import DkpCorridor + + analogs = _six(80_000.0) + dkp_raw = { + "count": settings.estimate_corridor_clamp_min_n, + "low_ppm2": 150_000, + "median_ppm2": 180_000, + "high_ppm2": 220_000, + "period_months": 12, + } + # Та же граница с другой стороны: на ней коридор уже не справочный. + assert DkpCorridor(**dkp_raw).advisory_only is False + + est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) + + assert est.median_price_per_m2 == 150_000 * 0.8, ( + f"median_ppm2={est.median_price_per_m2}: коридор из {dkp_raw['count']} сделок " + "(ровно порог) должен поднять цену ровно до floor=120000" + ) diff --git a/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py b/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py index 5b824132..1e65e2b6 100644 --- a/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py +++ b/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py @@ -34,6 +34,7 @@ from fastapi.testclient import TestClient # noqa: E402 from app.services.estimator import ( # noqa: E402 DEFAULT_RADIUS_M, _compute_confidence, + analog_tier_from_explanation, radius_from_relaxations, radius_relaxation_label, rehydrate_search_radius_m, @@ -193,6 +194,8 @@ def _light_estimator_stub(): _source_counts=lambda *a, **k: {}, _canonical_sources=lambda *a, **k: ["cian"], rehydrate_search_radius_m=rehydrate_search_radius_m, + # #3466: радиусная строка («Найдено 42 аналогов») → None, разбор настоящий. + analog_tier_from_explanation=analog_tier_from_explanation, ) sys.modules["app.services.estimator"] = stub # type: ignore[assignment] yield stub diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index db30df9a..d03f2419 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -207,7 +207,11 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals // с числами обязан стоять размер выборки, иначе коридор читается как опора // расчёта. n_analogs === 0 → headline посчитан по этим же сделкам, а не по // объявлениям (та же причина, что у analogsCaption ниже) → подпись другая. - const dkpAdvisory = corridorAdvisoryNote(dkp, estimate.n_analogs === 0); + const dkpAdvisory = corridorAdvisoryNote( + dkp, + estimate.n_analogs === 0, + estimate.analog_tier, + ); // Progressive enrichment state const needsHouseType = estimate.house_type === null; diff --git a/tradein-mvp/frontend/src/components/trade-in/__tests__/HeroSummaryCorridorNote.test.tsx b/tradein-mvp/frontend/src/components/trade-in/__tests__/HeroSummaryCorridorNote.test.tsx new file mode 100644 index 00000000..54009c31 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/__tests__/HeroSummaryCorridorNote.test.tsx @@ -0,0 +1,53 @@ +/** + * #3466 — подпись «цена по дому, коридор её не ограничивает» на корневой /trade-in. + * + * corridorAdvisoryNote проверена сама по себе и через v2 mapResultPanel + * (v2/__tests__/corridorAdvisoryNote.test.ts). Вызов в v1 HeroSummary — отдельный: + * без аргумента estimate.analog_tier функция молча вернёт null при n ≥ порога, + * и vitest этого не видел (ревью #3554, мутация F2). Здесь проверяется то, что + * реально стоит на экране hero. + */ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { HeroSummary } from "../HeroSummary"; +import { FIXTURE_ESTIMATE, FIXTURE_INPUT } from "@/app/ui-preview/estimate/fixture"; +import type { AggregatedEstimate, AnalogTier } from "@/types/trade-in"; + +// Как в thinMarketBadge.test.tsx: бренд и блок прозрачности ходят в сеть. +vi.mock("@/lib/useBrand", () => ({ + useActiveBrandSlug: () => null, + useBrand: () => ({ data: null }), +})); +vi.mock("../HeroTransparency", () => ({ HeroTransparency: () => null })); + +const SAME_BUILDING = + "справочно: цена посчитана по аналогам в этом же доме — коридор её не ограничивает"; + +function heroText(analog_tier: AnalogTier | null): string { + const estimate: AggregatedEstimate = { + ...FIXTURE_ESTIMATE, + analog_tier, + // n=20 ≥ порога: advisory_only=false, подпись может дать только тир. + dkp_corridor: { ...FIXTURE_ESTIMATE.dkp_corridor!, count: 20, advisory_only: false }, + }; + const { container } = render( + {}} />, + ); + return container.textContent ?? ""; +} + +describe("#3466 подпись коридора в v1 HeroSummary", () => { + it("same_building + n=20 → подпись про дом стоит в hero", () => { + const text = heroText("same_building"); + // Коридор отрисован (20 ДКП) и рядом — подпись, а не просто число. + expect(text).toContain("20 сд."); + expect(text).toContain(SAME_BUILDING); + }); + + it("micro_radius + n=20 → подписи нет: коридор цену держит", () => { + const text = heroText("micro_radius"); + expect(text).toContain("20 сд."); + expect(text).not.toContain("коридор её не ограничивает"); + }); +}); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/corridorAdvisoryNote.test.ts b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/corridorAdvisoryNote.test.ts new file mode 100644 index 00000000..e72710e1 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/corridorAdvisoryNote.test.ts @@ -0,0 +1,113 @@ +// #3452 / #3466 — подпись под коридором ДКП: когда он цену НЕ держит, экран +// обязан это сказать, а молчание — значить только «держит». +// +// Две разные причины выключенной страховки: +// * сделок меньше порога (advisory_only, бэкенд считает порог сам) — говорим +// про РАЗМЕР выборки: гейт Tier C держит цену и по пяти сделкам; +// * цена посчитана по аналогам того же дома (analog_tier "same_building") — +// кламп этот тир не трогает при любом count, ревьюер #3462 воспроизвёл +// n=20, headline 202 100 против потолка 140 000 и пустую подпись. +// +// Образец — dealsAsOf.test.ts: проверяется и сама функция, и то, что подпись +// доезжает до плитки v2 через mapResultPanel. + +import { describe, expect, it } from "vitest"; + +import { mapResultPanel } from "../mappers"; +import { corridorAdvisoryNote } from "@/lib/rosreestr"; +import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture"; +import type { AggregatedEstimate, DkpCorridor } from "@/types/trade-in"; + +const SAME_BUILDING = + "справочно: цена посчитана по аналогам в этом же доме — коридор её не ограничивает"; + +function corridor(count: number, advisory_only?: boolean): DkpCorridor { + const c: DkpCorridor = { + count, + low_ppm2: 80_000, + median_ppm2: 90_000, + high_ppm2: 100_000, + period_months: 12, + latest_deal_date: "2026-01-01", + }; + if (advisory_only !== undefined) c.advisory_only = advisory_only; + return c; +} + +describe("corridorAdvisoryNote", () => { + it("мало сделок — подпись про размер выборки", () => { + expect(corridorAdvisoryNote(corridor(5, true), false)).toBe( + "справочно: сделок мало (5) — коридор ориентировочный", + ); + }); + + it("мало сделок и цена из них же — подпись прямее", () => { + expect(corridorAdvisoryNote(corridor(5, true), true)).toBe( + "оценка построена на этих сделках — их всего 5", + ); + }); + + it("сделок достаточно, радиусный путь — молчим: коридор цену держит", () => { + expect(corridorAdvisoryNote(corridor(20, false), false, "district")).toBeNull(); + expect(corridorAdvisoryNote(corridor(20, false), false, "micro_radius")).toBeNull(); + }); + + it("#3466: сделок много, но цена по дому — подпись есть", () => { + // Красный на origin/main: advisory_only=false → null, при том что кламп + // Tier A exempt и коридор цену не тронул. + expect(corridorAdvisoryNote(corridor(20, false), false, "same_building")).toBe( + SAME_BUILDING, + ); + }); + + it("#3466: дом и мало сделок — говорим про дом, это причина сильнее", () => { + expect(corridorAdvisoryNote(corridor(5, true), false, "same_building")).toBe( + SAME_BUILDING, + ); + }); + + it("оценка старше деплоя (полей нет) — молчим, как раньше", () => { + expect(corridorAdvisoryNote(corridor(20), false)).toBeNull(); + expect(corridorAdvisoryNote(corridor(20), false, null)).toBeNull(); + }); + + it("коридора нет — подписи нет и у дома", () => { + expect(corridorAdvisoryNote(null, false, "same_building")).toBeNull(); + expect(corridorAdvisoryNote(undefined, false)).toBeNull(); + }); +}); + +/** Подпись плитки «ДКП · РОСРЕЕСТР» — ветка коридора (street-deals нет). */ +function dkpNote(over: Partial): string | undefined { + const e: AggregatedEstimate = { + ...FIXTURE_ESTIMATE, + actual_deals: [], + area_m2: 60, + ...over, + }; + const card = mapResultPanel(e, null).cards.find((c) => + c.title.join(" ").includes("ДКП"), + ); + if (!card) throw new Error("плитка ДКП пропала из панели результата"); + return card.note; +} + +describe("#3466 подпись доезжает до плитки v2", () => { + it("same_building + n=20 → подпись про дом", () => { + expect( + dkpNote({ dkp_corridor: corridor(20, false), analog_tier: "same_building" }), + ).toBe(SAME_BUILDING); + }); + + it("district + n=20 → без подписи", () => { + expect( + dkpNote({ dkp_corridor: corridor(20, false), analog_tier: "district" }), + ).toBeUndefined(); + }); + + it("district + n=9 → подпись про выборку", () => { + expect( + dkpNote({ dkp_corridor: corridor(9, true), analog_tier: "district" }), + ).toBe("справочно: сделок мало (9) — коридор ориентировочный"); + }); +}); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index e907526a..5f33f713 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -696,7 +696,7 @@ function resolveDealTier( asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"), // n_analogs === 0 → headline и есть медиана этого коридора (dealsOnlyPrice // ниже по файлу разбирает тот же случай) — подпись тогда прямее. - advisoryNote: corridorAdvisoryNote(c, e.n_analogs === 0), + advisoryNote: corridorAdvisoryNote(c, e.n_analogs === 0, e.analog_tier), }; } if (e.actual_deals.length > 0) { diff --git a/tradein-mvp/frontend/src/lib/rosreestr.ts b/tradein-mvp/frontend/src/lib/rosreestr.ts index 3e92bb8a..f5cb7b68 100644 --- a/tradein-mvp/frontend/src/lib/rosreestr.ts +++ b/tradein-mvp/frontend/src/lib/rosreestr.ts @@ -1,4 +1,4 @@ -import type { DkpCorridor } from "@/types/trade-in"; +import type { AnalogTier, DkpCorridor } from "@/types/trade-in"; export const ROSREESTR_REQUEST_URL = "https://rosreestr.gov.ru/eservices/request_info_from_egrn/"; @@ -53,13 +53,23 @@ export function dealsAsOfLabel( * min_n — коридор из пяти сделок там способен уронить headline на треть. * Утверждение про размер выборки истинно во всех достижимых состояниях. * + * #3466: сделок может быть и много, а коридор цену всё равно не держит — когда + * она посчитана по аналогам того же дома (analog_tier "same_building"): кламп + * этот тир не трогает, radius-floor тоже. Здесь утверждение про алгоритм уже + * истинно при любом count (гейт Tier C к этому тиру не относится) — его и говорим. + * * null (флага нет — оценка старше деплоя, либо сделок хватает) → подписи нет. */ export function corridorAdvisoryNote( dkp: DkpCorridor | null | undefined, headlineFromDeals: boolean, + analogTier?: AnalogTier | null, ): string | null { - if (dkp?.advisory_only !== true) return null; + if (!dkp) return null; + if (analogTier === "same_building") { + return "справочно: цена посчитана по аналогам в этом же доме — коридор её не ограничивает"; + } + if (dkp.advisory_only !== true) return null; // headline ПОСТРОЕН из этого же коридора (deals-fallback, n_analogs === 0 — // похожих объявлений рядом не нашлось): коридор тут не сбоку от цены, он и // есть цена, и клиент обязан знать, на скольких сделках она стоит. diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index be0846ec..bcec3816 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -170,7 +170,8 @@ export interface DkpCorridor { // #3452: true = сделок меньше порога доверия (бэкенд считает порог сам, // estimate_corridor_clamp_min_n) → коридор показан, но цену по нему НЕ // поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего - // коридора не про это — при count >= порога он в цену как раз идёт. + // коридора не про это — при count >= порога он в цену как раз идёт, кроме + // analog_tier === "same_building" (#3466: якорь того же дома, кламп exempt). // optional: оценки, посчитанные до деплоя бэкенда, поля не содержат. advisory_only?: boolean; }