Оценка: при якоре того же дома видно, что коридор сделок цену не ограничивал #3554

Merged
bot-backend merged 4 commits from fix/corridor-advisory-tier-a into main 2026-09-17 09:22:51 +00:00
15 changed files with 529 additions and 34 deletions

View file

@ -718,6 +718,7 @@ def load_estimate(
_qc_geo_to_precision, _qc_geo_to_precision,
_resolve_target_city, _resolve_target_city,
_source_counts, _source_counts,
analog_tier_from_explanation,
rehydrate_search_radius_m, rehydrate_search_radius_m,
) )
@ -896,6 +897,10 @@ def load_estimate(
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить # осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
# сюда дефолт значило бы выдать догадку за то, что просил пользователь. # сюда дефолт значило бы выдать догадку за то, что просил пользователь.
search_radius_m=search_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),
) )

View file

@ -166,6 +166,10 @@ class DkpCorridor(BaseModel):
#3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n #3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
порога не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only. порога не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
#3466: исключение сверх порога — якорь того же дома (AggregatedEstimate.analog_tier
== "same_building"): кламп его не трогает, radius-floor тоже, и коридор в цену не
входит при любом count. Признак живёт в analog_tier, а не здесь: тир не выводится
из коридора, а поле обязано быть верным и на GET-rehydrate.
""" """
count: int # число ДКП-сделок в выборке count: int # число ДКП-сделок в выборке
@ -195,7 +199,10 @@ class DkpCorridor(BaseModel):
Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ
путей коридора: soft-кламп headline сверху и radius-floor снизу путей коридора: soft-кламп headline сверху и radius-floor снизу
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит (estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
цену зона n=3..9 на экране была неотличима от работающей. цену зона n=1..9 на экране была неотличима от работающей.
False тоже НЕ значит «коридор в цену вошёл»: при analog_tier="same_building"
обе страховки выключены якорем, а не порогом (#3466).
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и (#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
@ -344,6 +351,9 @@ class AggregatedEstimate(BaseModel):
# "city" — radius Tier W: широкий fallback # "city" — radius Tier W: широкий fallback
# null — нет данных / оценка не построена # null — нет данных / оценка не построена
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него). # НЕ удаляет/заменяет 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 analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
# search_radius_m — фактический радиус (метры), по которому реально отбирались # search_radius_m — фактический радиус (метры), по которому реально отбирались
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m: # listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:

View file

@ -283,6 +283,32 @@ def radius_from_relaxations(relaxations: list[str] | None) -> int | None:
return 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( def rehydrate_search_radius_m(
relaxations: list[str] | None, analog_distances_m: list[int | None] relaxations: list[str] | None, analog_distances_m: list[int | None]
) -> int | None: ) -> int | None:
@ -3699,10 +3725,10 @@ def _price_from_inputs(
range_low = new_range_low range_low = new_range_low
range_high = new_range_high range_high = new_range_high
confidence = anchor["confidence"] confidence = anchor["confidence"]
tier_label = "того же дома" if anchor_tier == "A" else "ближайшего окружения (≤500 м)" # #695: explanation описывает ИМЕННО якорные комплы. #3466: подпись читает
# #695: explanation описывает ИМЕННО якорные комплы. # GET-rehydrate (analog_tier_from_explanation) — менять только через константу.
explanation = ( explanation = (
f"Оценка построена по {anchor['n']} аналогам из {tier_label}" f"Оценка построена по {anchor['n']} аналогам из {ANCHOR_TIER_LABELS[anchor_tier]}"
f"{' (топ-уровень в доме)' if anchor['used_uplift'] else ''}." f"{' (топ-уровень в доме)' if anchor['used_uplift'] else ''}."
) + repair_note ) + repair_note
# #695 (QA fixup): n_analogs по anchor-популяции. # #695 (QA fixup): n_analogs по anchor-популяции.
@ -5313,8 +5339,9 @@ async def estimate_quality(
# #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок # #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок
# меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и # меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и
# soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе). # 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`. # `docker logs tradein-backend --since 24h 2>&1 | grep -c corridor_advisory_zone`.
# Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит. # Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит.
if dkp_corridor is not None and dkp_corridor.advisory_only: 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". # radius-путь: analog_tier "W" → "city", остальные → "district".
# None только если нет аналогов (median=0, insufficient_data=True). # None только если нет аналогов (median=0, insufficient_data=True).
if median_price > 0: if median_price > 0:
if anchor_tier == "A": if anchor_tier in ANCHOR_API_TIERS:
api_analog_tier: str | None = "same_building" api_analog_tier: str | None = ANCHOR_API_TIERS[anchor_tier]
elif anchor_tier == "C":
api_analog_tier = "micro_radius"
elif analog_tier == "W": elif analog_tier == "W":
api_analog_tier = "city" api_analog_tier = "city"
else: else:

View file

@ -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 снизу гейтятся по коридору soft-кламп headline сверху и radius-floor снизу гейтятся
`estimate_corridor_clamp_min_n` (10). Между ними лежит зона, где коридор `estimate_corridor_clamp_min_n` (10). Между ними лежит зона, где коридор
существует, показывается и участвует в fallback-путях, а цену не держит; на существует, показывается и участвует в fallback-путях, а цену не держит; на

View file

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

View file

@ -28,6 +28,8 @@ import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402 from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # 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" _ESTIMATE_ID = "11111111-1111-1111-1111-111111111111"
@ -170,6 +172,9 @@ def _stub_precision_and_pdf():
# фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее # фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее
# поведение (см. tests/test_estimator_search_radius_2632.py). # поведение (см. tests/test_estimator_search_radius_2632.py).
rehydrate_search_radius_m=lambda *a, **k: None, 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") real_estimator = sys.modules.get("app.services.estimator")
sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment] 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 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: def test_get_estimate_requires_authenticated_user(trade_in_app: FastAPI) -> None:
"""No X-Authenticated-User header → 401.""" """No X-Authenticated-User header → 401."""
db_mock = _make_db_mock(_make_estimate_row(created_by="kopylov")) db_mock = _make_db_mock(_make_estimate_row(created_by="kopylov"))

View file

@ -277,6 +277,8 @@ def _estimator_stub():
# этой фикстуры analogs пусты → None и есть настоящее поведение # этой фикстуры analogs пусты → None и есть настоящее поведение
# (см. tests/test_estimator_search_radius_2632.py). # (см. tests/test_estimator_search_radius_2632.py).
rehydrate_search_radius_m=lambda *a, **k: None, 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, estimate_quality=_default_estimate_quality,
) )
sys.modules["app.services.estimator"] = stub # type: ignore[assignment] sys.modules["app.services.estimator"] = stub # type: ignore[assignment]

View file

@ -8,6 +8,8 @@
- radius median выше dkp_low × factor no-op (медиана не изменена) - radius median выше dkp_low × factor no-op (медиана не изменена)
- dkp_raw is None no-op (нет базы для floor) - dkp_raw is None no-op (нет базы для floor)
- anchor-путь (anchor_tier != None) не затронут floor'ом - anchor-путь (anchor_tier != None) не затронут floor'ом
- коридор ниже estimate_corridor_clamp_min_n floor не применяется (#3466)
- коридор ровно estimate_corridor_clamp_min_n floor применяется (#3466)
""" """
from __future__ import annotations from __future__ import annotations
@ -114,17 +116,25 @@ def _run_estimate(
return anyio.run(_run) 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 → поднята ─────────────────────────────── # ── тест 1: radius median ниже floor → поднята ───────────────────────────────
def test_radius_median_below_dkp_floor_is_lifted() -> None: def test_radius_median_below_dkp_floor_is_lifted() -> None:
"""Radius median = 80k, dkp_low = 150k, factor 0.8 → floor = 120k → median поднята.""" """Radius median = 80k, dkp_low = 150k, factor 0.8 → floor = 120k → median поднята."""
# analogs с median ~80k analogs = _six(80_000.0)
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),
]
dkp_raw = { dkp_raw = {
"count": 15, "count": 15,
"low_ppm2": 150_000, "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) est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8)
floor_ppm2 = 150_000 * 0.8 # 120_000 floor_ppm2 = 150_000 * 0.8 # 120_000
assert est.median_price_per_m2 >= floor_ppm2, ( # Ровно floor, а не «не ниже»: медиана коридора (180k) тоже «не ниже».
f"median_ppm2={est.median_price_per_m2} должна быть >= floor={floor_ppm2}" 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: def test_radius_median_above_dkp_floor_unchanged() -> None:
"""Radius median = 200k > dkp_low × factor = 150k × 0.8 = 120k → no-op.""" """Radius median = 200k > dkp_low × factor = 150k × 0.8 = 120k → no-op."""
analogs = [ analogs = _six(200_000.0)
_make_listing(price_per_m2=190_000.0),
_make_listing(price_per_m2=200_000.0),
_make_listing(price_per_m2=210_000.0),
]
dkp_raw = { dkp_raw = {
"count": 15, "count": 15,
"low_ppm2": 150_000, "low_ppm2": 150_000,
@ -163,9 +170,9 @@ def test_radius_median_above_dkp_floor_unchanged() -> None:
# Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor. # Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor.
floor_ppm2 = 150_000 * 0.8 # 120_000 floor_ppm2 = 150_000 * 0.8 # 120_000
assert est.median_price_per_m2 > floor_ppm2, "median должна быть выше floor (no-op)" assert est.median_price_per_m2 > floor_ppm2, "median должна быть выше floor (no-op)"
# Медиана соответствует аналогам (~200k), а не floor # Медиана — ровно медиана аналогов, а не floor и не медиана коридора (180k).
assert 180_000 <= est.median_price_per_m2 <= 220_000, ( assert est.median_price_per_m2 == 200_000, (
f"median_ppm2={est.median_price_per_m2} должна остаться в диапазоне аналогов (no-op)" 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, ( assert est.median_price_per_m2 < 100_000, (
f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти" 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"
)

View file

@ -34,6 +34,7 @@ from fastapi.testclient import TestClient # noqa: E402
from app.services.estimator import ( # noqa: E402 from app.services.estimator import ( # noqa: E402
DEFAULT_RADIUS_M, DEFAULT_RADIUS_M,
_compute_confidence, _compute_confidence,
analog_tier_from_explanation,
radius_from_relaxations, radius_from_relaxations,
radius_relaxation_label, radius_relaxation_label,
rehydrate_search_radius_m, rehydrate_search_radius_m,
@ -193,6 +194,8 @@ def _light_estimator_stub():
_source_counts=lambda *a, **k: {}, _source_counts=lambda *a, **k: {},
_canonical_sources=lambda *a, **k: ["cian"], _canonical_sources=lambda *a, **k: ["cian"],
rehydrate_search_radius_m=rehydrate_search_radius_m, 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] sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
yield stub yield stub

View file

@ -207,7 +207,11 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
// с числами обязан стоять размер выборки, иначе коридор читается как опора // с числами обязан стоять размер выборки, иначе коридор читается как опора
// расчёта. n_analogs === 0 → headline посчитан по этим же сделкам, а не по // расчёта. n_analogs === 0 → headline посчитан по этим же сделкам, а не по
// объявлениям (та же причина, что у analogsCaption ниже) → подпись другая. // объявлениям (та же причина, что у analogsCaption ниже) → подпись другая.
const dkpAdvisory = corridorAdvisoryNote(dkp, estimate.n_analogs === 0); const dkpAdvisory = corridorAdvisoryNote(
dkp,
estimate.n_analogs === 0,
estimate.analog_tier,
);
// Progressive enrichment state // Progressive enrichment state
const needsHouseType = estimate.house_type === null; const needsHouseType = estimate.house_type === null;

View file

@ -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(
<HeroSummary estimate={estimate} input={FIXTURE_INPUT} onResubmit={() => {}} />,
);
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("коридор её не ограничивает");
});
});

View file

@ -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<AggregatedEstimate>): 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) — коридор ориентировочный");
});
});

View file

@ -696,7 +696,7 @@ function resolveDealTier(
asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"), asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"),
// n_analogs === 0 → headline и есть медиана этого коридора (dealsOnlyPrice // 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) { if (e.actual_deals.length > 0) {

View file

@ -1,4 +1,4 @@
import type { DkpCorridor } from "@/types/trade-in"; import type { AnalogTier, DkpCorridor } from "@/types/trade-in";
export const ROSREESTR_REQUEST_URL = export const ROSREESTR_REQUEST_URL =
"https://rosreestr.gov.ru/eservices/request_info_from_egrn/"; "https://rosreestr.gov.ru/eservices/request_info_from_egrn/";
@ -53,13 +53,23 @@ export function dealsAsOfLabel(
* min_n коридор из пяти сделок там способен уронить headline на треть. * min_n коридор из пяти сделок там способен уронить headline на треть.
* Утверждение про размер выборки истинно во всех достижимых состояниях. * Утверждение про размер выборки истинно во всех достижимых состояниях.
* *
* #3466: сделок может быть и много, а коридор цену всё равно не держит когда
* она посчитана по аналогам того же дома (analog_tier "same_building"): кламп
* этот тир не трогает, radius-floor тоже. Здесь утверждение про алгоритм уже
* истинно при любом count (гейт Tier C к этому тиру не относится) его и говорим.
*
* null (флага нет оценка старше деплоя, либо сделок хватает) подписи нет. * null (флага нет оценка старше деплоя, либо сделок хватает) подписи нет.
*/ */
export function corridorAdvisoryNote( export function corridorAdvisoryNote(
dkp: DkpCorridor | null | undefined, dkp: DkpCorridor | null | undefined,
headlineFromDeals: boolean, headlineFromDeals: boolean,
analogTier?: AnalogTier | null,
): string | 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 — // headline ПОСТРОЕН из этого же коридора (deals-fallback, n_analogs === 0 —
// похожих объявлений рядом не нашлось): коридор тут не сбоку от цены, он и // похожих объявлений рядом не нашлось): коридор тут не сбоку от цены, он и
// есть цена, и клиент обязан знать, на скольких сделках она стоит. // есть цена, и клиент обязан знать, на скольких сделках она стоит.

View file

@ -170,7 +170,8 @@ export interface DkpCorridor {
// #3452: true = сделок меньше порога доверия (бэкенд считает порог сам, // #3452: true = сделок меньше порога доверия (бэкенд считает порог сам,
// estimate_corridor_clamp_min_n) → коридор показан, но цену по нему НЕ // estimate_corridor_clamp_min_n) → коридор показан, но цену по нему НЕ
// поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего // поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего
// коридора не про это — при count >= порога он в цену как раз идёт. // коридора не про это — при count >= порога он в цену как раз идёт, кроме
// analog_tier === "same_building" (#3466: якорь того же дома, кламп exempt).
// optional: оценки, посчитанные до деплоя бэкенда, поля не содержат. // optional: оценки, посчитанные до деплоя бэкенда, поля не содержат.
advisory_only?: boolean; advisory_only?: boolean;
} }