fix(tradein/estimate): не блокировать оценку — расширять подбор и честно предупреждать #2823
15 changed files with 1117 additions and 179 deletions
|
|
@ -319,6 +319,41 @@ class AggregatedEstimate(BaseModel):
|
|||
cv: float | None = None
|
||||
source_counts: dict[str, int] = Field(default_factory=dict)
|
||||
created_at: datetime | None = None
|
||||
# ── #oblast-F (never-block relaxation cascade, product decision 2026-08-10,
|
||||
# #oblast-E priority RESTORED same day — see estimator.py module
|
||||
# docstring for the full 3-way headline-source rule) ──────────────────
|
||||
# Product requirement: an estimate is ALWAYS surfaced — a thin base sample
|
||||
# (< HEADLINE_LISTINGS_MIN_N) no longer means "недостаточно данных". First
|
||||
# estimator.estimate_quality() progressively relaxes the analog SEARCH
|
||||
# (room-count adjacency → freshness window → novostroyki segment → radius)
|
||||
# trying to grow the sample past the threshold; if it's STILL thin,
|
||||
# _price_from_inputs() prefers a usable ДКП deals corridor over a noisy
|
||||
# thin listings median when one is available (restored #oblast-E
|
||||
# priority — the Серов repro: 3 listings must not outrank 54 deals), and
|
||||
# only falls back to the thin listings median itself when no corridor
|
||||
# exists. Real refusal happens only at genuine zero (no listings AND no
|
||||
# usable anchor/deals).
|
||||
# relaxations — RU-подписи КАЖДОГО применённого (реально помогшего) шага
|
||||
# ослабления, готовые к показу пользователю как честный дисклеймер рядом с
|
||||
# confidence_explanation. Пусто — базовой (4-tier) выборки хватило, каскад
|
||||
# не понадобился (обычный случай). Возможные значения (дословно, фронт
|
||||
# может на них завязываться): "снят фильтр по году постройки",
|
||||
# "учтены студии", "комнатность ±1", "объявления за 60 дней",
|
||||
# "учтены новостройки", "площадь ±25%", "радиус расширен до {N} м",
|
||||
# "оценка по сделкам — мало объявлений рядом" (headline ceded to the ДКП
|
||||
# deals corridor because the base listings sample was thin — a source
|
||||
# SWITCH, not a search widening, but surfaced the same way).
|
||||
# reliability — надёжность итоговой выборки, ПРОИЗВОДНАЯ от n_analogs
|
||||
# (>=8 → ok; 3..7 → low; <3 → very_low), с доп. даунгрейдом ok→low, если
|
||||
# relaxations непусто (выборка набралась только ценой ослаблений); капается
|
||||
# на 'low' (не 'very_low'), когда headline ушёл по сделкам из-за тонкой
|
||||
# выборки — реальный ДКП-коридор это настоящий сигнал, не «почти ничего».
|
||||
# НЕ персистится на GET-rehydrate (пусто/"ok" по умолчанию там — известное
|
||||
# ограничение, каскад не переигрывается из сохранённых analogs). НЕ
|
||||
# путать с `confidence` (Literal low/medium/high — старая метрика на
|
||||
# основе уникальных адресов/IQR, см. её собственный докстринг выше).
|
||||
relaxations: list[str] = Field(default_factory=list)
|
||||
reliability: Literal["ok", "low", "very_low"] = "ok"
|
||||
# ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку
|
||||
# при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ──
|
||||
area_m2: float | None = None
|
||||
|
|
|
|||
|
|
@ -185,6 +185,24 @@ DEALS_HEADLINE_FALLBACK_MIN_N = 3
|
|||
# ИТОГОВОЙ выборке как headline-источнику.
|
||||
HEADLINE_LISTINGS_MIN_N = 5
|
||||
|
||||
# #oblast-F (never-block relaxation cascade, product decision 2026-08-10, live
|
||||
# repro: Академика Парина 46/5 студия 23.1 м² — rooms=1 exact match gave n=4
|
||||
# и попадала под #oblast-E выше, хотя rooms=0 по тому же адресу давал n=34;
|
||||
# в радиусе 2 км rooms=0 17-29 м² — 327 активных лотов, rooms=1 — всего 10).
|
||||
# Продукт: НИКОГДА не отказывать в оценке. Если после существующего 4-шагового
|
||||
# каскада (tier0-когорта → без когорты → radius=fallback → area ±25%) выборка
|
||||
# всё ещё < HEADLINE_LISTINGS_MIN_N — estimate_quality() продолжает ослаблять
|
||||
# параметры подбора (см. #oblast-F блок там), от наименее к наиболее
|
||||
# искажающему: (a) смежность комнатности, (b) свежесть объявлений, (c) сегмент
|
||||
# (допустить новостройки), (d) радиус. Каждый применённый шаг попадает в
|
||||
# AggregatedEstimate.relaxations (честный дисклеймер для пользователя) — гейт
|
||||
# #oblast-E при этом больше НЕ обнуляет медиану (см. _price_from_inputs), а
|
||||
# только помечает результат как низконадёжный.
|
||||
RELAX_ROOMS_ADJACENT_DELTA = 1 # #oblast-F (a): rooms>=2 → BETWEEN rooms-1 AND rooms+1
|
||||
LISTINGS_FRESH_DAYS_RELAXED = 60 # #oblast-F (b): LISTINGS_FRESH_DAYS 14 → 60 дней
|
||||
RELAX_RADIUS_STEP1_M = 3000 # #oblast-F (d.1): max(текущий search_radius_m, 3000)
|
||||
RELAX_RADIUS_STEP2_M = 5000 # #oblast-F (d.2): финальный максимум
|
||||
|
||||
# #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals.
|
||||
# Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm²
|
||||
# to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ).
|
||||
|
|
@ -2562,12 +2580,28 @@ class PricingResult:
|
|||
# headline. Anchor-путь → CV комплов (anchor["cv"]); radius-путь → CV
|
||||
# радиусной ₽/м²-выборки. None если <2 цен (недостаточно данных).
|
||||
cv: float | None = None
|
||||
# #oblast-E: >0 когда n листингов было найдено но ниже HEADLINE_LISTINGS_MIN_N
|
||||
# (headline suppressed, listings_clean deliberately left intact — see gate
|
||||
# comment above). Caller uses this to also keep the thin listings out of the
|
||||
# display `analogs` cards when no anchor overrides the headline. 0 = either
|
||||
# sufficient listings were used, or genuinely zero were found.
|
||||
# #oblast-E/#oblast-F: >0 когда n листингов было найдено но ниже
|
||||
# HEADLINE_LISTINGS_MIN_N. С #oblast-F (2026-08-10) больше НЕ обнуляет
|
||||
# headline/listings_clean — median/n_analogs остаются реальными, поле лишь
|
||||
# маркирует «низкая надёжность» (confidence='low' + честный explanation,
|
||||
# см. gate comment ниже). 0 = либо выборка была достаточной, либо аналогов
|
||||
# вообще не нашлось.
|
||||
listings_headline_thin_n: int = 0
|
||||
# #oblast-E (restored priority, product correction 2026-08-10): True когда
|
||||
# headline построен из #oblast-D deals-corridor ИМЕННО потому, что базовая
|
||||
# выборка листингов была тонкой (0 < n < HEADLINE_LISTINGS_MIN_N) И доступен
|
||||
# достаточно надёжный ДКП-коридор (см. deals-headline-fallback блок ниже).
|
||||
# Caller (estimate_quality) читает это чтобы (a) добавить relaxation-подпись
|
||||
# «оценка по сделкам — мало объявлений рядом», (b) закэпить reliability на
|
||||
# 'low' (не выше). False во всех остальных случаях, включая genuinely-zero
|
||||
# listings deals-fallback (тот же блок, но без тонкой выборки позади).
|
||||
deals_headline_due_to_thin_listings: bool = False
|
||||
|
||||
|
||||
def _analog_word_dative(n: int) -> str:
|
||||
"""Дательный падеж существительного «аналог» для confidence_explanation
|
||||
тонкой (#oblast-E) выборки — «построена по N аналогу/аналогам»."""
|
||||
return "аналогу" if n == 1 else "аналогам"
|
||||
|
||||
|
||||
def _price_from_inputs(
|
||||
|
|
@ -2646,45 +2680,82 @@ def _price_from_inputs(
|
|||
n_analogs = 0
|
||||
cv = None
|
||||
|
||||
# 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above).
|
||||
# 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find but too thin to trust
|
||||
# as a market median — suppress the AGGREGATE (median/range/n_analogs/cv)
|
||||
# exactly like "no usable listings", so the anchor/#oblast-D-deals-fallback/
|
||||
# insufficient_data chain below all take the already-honest zero-analogs
|
||||
# path automatically (no new branches there). `listings_clean` itself is
|
||||
# deliberately LEFT INTACT (not cleared) — the same-building anchor's own
|
||||
# ghost-anchor guard (#1871, `if not listings_clean`) uses it to tell
|
||||
# "genuinely zero nearby listings" from "some nearby listings, just too few
|
||||
# to trust as THIS estimate's headline" — those are different confidence
|
||||
# signals and clearing the list here would conflate them. The caller
|
||||
# (estimate_quality) uses `listings_headline_thin_n` on the returned
|
||||
# PricingResult to also keep suppressed listings out of the display
|
||||
# `analogs` cards when no anchor overrides the headline (n_analogs
|
||||
# invariant: cards shown ⊆ what n_analogs counts).
|
||||
# 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above)
|
||||
# — priority RESTORED 2026-08-10 (product correction on top of #oblast-F):
|
||||
# 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find, but not trustworthy
|
||||
# enough to headline on their OWN — a more reliable source should win when
|
||||
# one exists. Two sub-cases:
|
||||
# (i) a usable ДКП deals corridor is available (same threshold the
|
||||
# #oblast-D deals-headline-fallback block below itself requires,
|
||||
# DEALS_HEADLINE_FALLBACK_MIN_N deals with a positive median) → the
|
||||
# listings AGGREGATE is suppressed to zero here so that block takes
|
||||
# over the headline, EXACTLY like original #oblast-E. This is the
|
||||
# Серов repro this gate exists for: n=3 listings must not outrank a
|
||||
# 54-deal corridor. `listings_clean` stays intact (never cleared) —
|
||||
# both for the anchor ghost-anchor guard (#1871) AND so
|
||||
# estimate_quality() still surfaces these listings as display
|
||||
# `analogs` cards even though they no longer drive n_analogs/median.
|
||||
# (ii) no usable corridor → #oblast-F (never-block, 2026-08-10): keep the
|
||||
# real thin median rather than refusing outright. By the time
|
||||
# control reaches this function, estimate_quality() has already run
|
||||
# the #oblast-F relaxation cascade (room-adjacency / freshness /
|
||||
# novostroyki / radius) trying to grow the sample past the
|
||||
# threshold — `listings` here is whatever that cascade could find.
|
||||
# NOTE: `gate_ceded_to_deals` (local, this function only) is DIFFERENT from
|
||||
# the `deals_headline_due_to_thin_listings` PricingResult field set later —
|
||||
# this one fires as soon as the gate DECIDES to cede (used below to skip
|
||||
# the repair-coefficient/explanation blocks safely, regardless of whether
|
||||
# anchor later overrides); the field fires only once the #oblast-D
|
||||
# deals-headline-fallback block ACTUALLY builds the headline from deals
|
||||
# (anchor may still override in between — see that block).
|
||||
listings_headline_thin_n = 0
|
||||
gate_ceded_to_deals = False
|
||||
# Outward PricingResult field — set True below, ONLY inside the actual
|
||||
# #oblast-D deals-headline-fallback block, once it fires for THIS reason.
|
||||
deals_headline_due_to_thin_listings = False
|
||||
if 0 < n_analogs < HEADLINE_LISTINGS_MIN_N:
|
||||
listings_headline_thin_n = n_analogs
|
||||
logger.info(
|
||||
"headline sufficiency gate #oblast-E: n=%d < %d listings — suppressing "
|
||||
"listings-derived median (falling back to anchor/deals/insufficient_data)",
|
||||
n_analogs,
|
||||
HEADLINE_LISTINGS_MIN_N,
|
||||
dkp_corridor_usable = (
|
||||
dkp_raw is not None
|
||||
and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N
|
||||
and dkp_raw.get("median_ppm2", 0) > 0
|
||||
)
|
||||
median_ppm2 = 0.0
|
||||
q1_ppm2 = 0.0
|
||||
q3_ppm2 = 0.0
|
||||
median_price = 0
|
||||
range_low = 0
|
||||
range_high = 0
|
||||
n_analogs = 0
|
||||
cv = None
|
||||
if dkp_corridor_usable:
|
||||
gate_ceded_to_deals = True
|
||||
logger.info(
|
||||
"headline sufficiency gate #oblast-E: n=%d < %d listings, usable ДКП "
|
||||
"corridor (n=%s) available — suppressing listings-derived median, "
|
||||
"ceding headline to deals/anchor chain",
|
||||
n_analogs,
|
||||
HEADLINE_LISTINGS_MIN_N,
|
||||
dkp_raw.get("count", 0) if dkp_raw else None,
|
||||
)
|
||||
median_ppm2 = 0.0
|
||||
q1_ppm2 = 0.0
|
||||
q3_ppm2 = 0.0
|
||||
median_price = 0
|
||||
range_low = 0
|
||||
range_high = 0
|
||||
n_analogs = 0
|
||||
cv = None
|
||||
else:
|
||||
logger.info(
|
||||
"headline sufficiency note #oblast-E: n=%d < %d listings, no usable "
|
||||
"ДКП corridor — keeping real median, flagged low-reliability "
|
||||
"(#oblast-F: never suppressed to zero without a fallback source)",
|
||||
n_analogs,
|
||||
HEADLINE_LISTINGS_MIN_N,
|
||||
)
|
||||
|
||||
# 4b. Repair coefficient — skipped when the headline was thin-suppressed
|
||||
# above (median_price is already 0; applying a coefficient would leave it
|
||||
# 0 but still emit a misleading "adjusted for repair state" note).
|
||||
# 4b. Repair coefficient — applies to any real (non-zero) median, INCLUDING
|
||||
# thin-but-kept (#oblast-F case ii) samples — a repair-state adjustment is
|
||||
# meaningful there. Skipped when the gate ceded the headline to deals
|
||||
# (gate_ceded_to_deals — median_price is already 0 above; applying a
|
||||
# coefficient would leave it 0 but still emit a misleading "adjusted for
|
||||
# repair state" note, same reasoning original #oblast-E used).
|
||||
repair_coef = _repair_coefficient(repair_state)
|
||||
repair_note = ""
|
||||
if listings_clean and not listings_headline_thin_n and repair_coef != 1.0:
|
||||
if listings_clean and not gate_ceded_to_deals and repair_coef != 1.0:
|
||||
median_price = int(median_price * repair_coef)
|
||||
range_low = int(range_low * repair_coef)
|
||||
range_high = int(range_high * repair_coef)
|
||||
|
|
@ -2725,19 +2796,20 @@ def _price_from_inputs(
|
|||
area_widened,
|
||||
listings=listings_clean,
|
||||
)
|
||||
# #oblast-E: honest override — _compute_confidence's generic "не найдено
|
||||
# аналогов" is FALSE here (we DID find listings_headline_thin_n of them,
|
||||
# just too few to trust). Stays the final explanation unless a later block
|
||||
# (anchor / #oblast-D deals-fallback) overwrites it with its OWN honest
|
||||
# reasoning — both of those already check truthy `explanation` and either
|
||||
# replace it (anchor) or append a construction-method clause that reads
|
||||
# this same thin-count (deals-fallback), so no contradiction either way.
|
||||
if listings_headline_thin_n:
|
||||
# #oblast-E/#oblast-F: honest low-reliability note — ONLY for case (ii) of
|
||||
# the gate above (real thin median kept, no usable deals corridor to cede
|
||||
# to). Case (i) (gate_ceded_to_deals) must NOT set this text — the
|
||||
# deals-headline-fallback block below writes its OWN "built from
|
||||
# Rosreestr deals" explanation; setting this first would leave a
|
||||
# contradictory "Оценка построена по N аналогам" sentence stapled in front
|
||||
# of it. Stays the final explanation unless a later block (anchor /
|
||||
# #oblast-D deals-fallback) overwrites it with its OWN honest reasoning.
|
||||
if listings_headline_thin_n and not gate_ceded_to_deals:
|
||||
confidence = "low"
|
||||
explanation = (
|
||||
f"Рядом найдено недостаточно объявлений ({listings_headline_thin_n} шт., "
|
||||
f"минимум для оценки по рынку — {HEADLINE_LISTINGS_MIN_N}) — медиана по "
|
||||
"такой маленькой выборке слишком чувствительна к случайным лотам."
|
||||
f"Оценка построена по {listings_headline_thin_n} "
|
||||
f"{_analog_word_dative(listings_headline_thin_n)} — выборка мала, "
|
||||
"точность снижена."
|
||||
)
|
||||
|
||||
# Tier note — информируем пользователя о качестве house-match
|
||||
|
|
@ -3316,10 +3388,16 @@ def _price_from_inputs(
|
|||
n_analogs = 0
|
||||
confidence = "low"
|
||||
cv = None
|
||||
# #oblast-E: differentiate "genuinely zero listings" (unchanged wording)
|
||||
# from "found some but below HEADLINE_LISTINGS_MIN_N, suppressed above" —
|
||||
# the latter must NOT claim "рядом нет объявлений" (false, contradicts the
|
||||
# thin-sufficiency explanation already set above this block).
|
||||
# #oblast-E (priority restored 2026-08-10): differentiate "genuinely
|
||||
# zero listings" from "found some but below HEADLINE_LISTINGS_MIN_N,
|
||||
# ceded to the deals corridor" (gate above, case i) — the latter must
|
||||
# NOT claim "рядом нет объявлений" (false — some WERE found, just not
|
||||
# trusted as headline on their own). `deals_headline_due_to_thin_
|
||||
# listings` (returned on PricingResult) tells estimate_quality() this
|
||||
# was the thin-cession path specifically, so it can (a) append the
|
||||
# "оценка по сделкам — мало объявлений рядом" relaxation label, (b)
|
||||
# cap reliability at 'low' — a real deals corridor is a real signal,
|
||||
# just not a listings-comp one.
|
||||
no_listings_clause = (
|
||||
f" Из {listings_headline_thin_n} найденных объявлений недостаточно для "
|
||||
"надёжной медианы —"
|
||||
|
|
@ -3331,9 +3409,11 @@ def _price_from_inputs(
|
|||
f"сделкам Росреестра ({dkp_raw['count']} шт. за {dkp_raw['period_months']} мес.),"
|
||||
" точность ориентировочная."
|
||||
)
|
||||
if listings_headline_thin_n:
|
||||
deals_headline_due_to_thin_listings = True
|
||||
logger.info(
|
||||
"deals_headline_fallback #oblast-D: dkp median=%d (n=%d) → headline"
|
||||
" (listings=0 [thin_suppressed=%d], anchor=None)",
|
||||
" (listings=0 [thin_ceded=%d], anchor=None)",
|
||||
int(median_ppm2),
|
||||
dkp_raw["count"],
|
||||
listings_headline_thin_n,
|
||||
|
|
@ -3469,6 +3549,7 @@ def _price_from_inputs(
|
|||
listings_clean=listings_clean,
|
||||
cv=cv,
|
||||
listings_headline_thin_n=listings_headline_thin_n,
|
||||
deals_headline_due_to_thin_listings=deals_headline_due_to_thin_listings,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -3730,6 +3811,9 @@ async def estimate_quality(
|
|||
house_type=target_house_type,
|
||||
total_floors=payload.total_floors,
|
||||
)
|
||||
# #oblast-F: True only when there WAS a cohort (year_built) filter to drop —
|
||||
# surfaced later as the "снят фильтр по году постройки" relaxation label.
|
||||
cohort_dropped = cohort_range is not None and len(listings_tier0) < MIN_ANALOGS_TIER_0
|
||||
area_widened = False
|
||||
|
||||
if len(listings) < 5:
|
||||
|
|
@ -3778,6 +3862,146 @@ async def estimate_quality(
|
|||
analog_tier = analog_tier_wa
|
||||
search_radius_m = fallback_radius_m
|
||||
|
||||
# ── #oblast-F: relaxation cascade (never-block estimate, product decision
|
||||
# 2026-08-10) ──────────────────────────────────────────────────────────
|
||||
# Product requirement: NEVER refuse an estimate outright. If the 4-tier
|
||||
# cascade above still leaves the sample thinner than HEADLINE_LISTINGS_MIN_N,
|
||||
# keep loosening search criteria — least → most distorting — until either
|
||||
# the sample clears the threshold or we run out of steps. Every step that
|
||||
# ACTUALLY grew the sample is recorded in `relaxations` (RU labels, surfaced
|
||||
# via AggregatedEstimate.relaxations + appended to confidence_explanation
|
||||
# below) so a low-reliability estimate honestly explains why it stretched
|
||||
# the search. Each step carries FORWARD the relaxations already applied by
|
||||
# earlier steps (cumulative widening), not just its own single criterion.
|
||||
relaxations: list[str] = []
|
||||
if cohort_dropped:
|
||||
relaxations.append("снят фильтр по году постройки")
|
||||
|
||||
cur_rooms_min: int | None = None
|
||||
cur_rooms_max: int | None = None
|
||||
cur_fresh_days = LISTINGS_FRESH_DAYS
|
||||
cur_allow_novostroyki = False
|
||||
cur_area_tolerance = 0.25 if area_widened else AREA_TOLERANCE
|
||||
|
||||
async def _try_relax(
|
||||
*,
|
||||
rooms_min: int | None,
|
||||
rooms_max: int | None,
|
||||
fresh_days: int,
|
||||
allow_novostroyki: bool,
|
||||
radius_m: int,
|
||||
area_tolerance: float,
|
||||
) -> tuple[list[dict[str, Any]], str] | None:
|
||||
"""Один шаг каскада #oblast-F. Возвращает (listings, tier) только если
|
||||
кандидат СТРОГО больше текущей выборки — иначе релаксация не засчитана
|
||||
(ничего реально не выиграла) и вызывающий её не применяет."""
|
||||
candidate, _, tier = await asyncio.to_thread(
|
||||
_fetch_analogs,
|
||||
db,
|
||||
lat=geo.lat,
|
||||
lon=geo.lon,
|
||||
rooms=payload.rooms,
|
||||
rooms_min=rooms_min,
|
||||
rooms_max=rooms_max,
|
||||
area=payload.area_m2,
|
||||
radius_m=radius_m,
|
||||
area_tolerance=area_tolerance,
|
||||
fresh_days=fresh_days,
|
||||
allow_novostroyki=allow_novostroyki,
|
||||
full_address=geo.full_address,
|
||||
target_house_id=target_house_id,
|
||||
year_built=target_year,
|
||||
house_type=target_house_type,
|
||||
total_floors=payload.total_floors,
|
||||
)
|
||||
if len(candidate) > len(listings):
|
||||
return candidate, tier
|
||||
return None
|
||||
|
||||
# (a) room-count adjacency — самое дешёвое искажение: студия↔1-комн для
|
||||
# rooms<=1 (live repro: Академика Парина 46/5, rooms=1 давал n=4, rooms=0
|
||||
# тем же адресом — n=34), иначе комнатность ±RELAX_ROOMS_ADJACENT_DELTA.
|
||||
if len(listings) < HEADLINE_LISTINGS_MIN_N:
|
||||
if payload.rooms <= 1:
|
||||
try_rooms_min, try_rooms_max, rooms_label = 0, 1, "учтены студии"
|
||||
else:
|
||||
try_rooms_min = payload.rooms - RELAX_ROOMS_ADJACENT_DELTA
|
||||
try_rooms_max = payload.rooms + RELAX_ROOMS_ADJACENT_DELTA
|
||||
rooms_label = "комнатность ±1"
|
||||
rooms_result = await _try_relax(
|
||||
rooms_min=try_rooms_min,
|
||||
rooms_max=try_rooms_max,
|
||||
fresh_days=cur_fresh_days,
|
||||
allow_novostroyki=cur_allow_novostroyki,
|
||||
radius_m=search_radius_m,
|
||||
area_tolerance=cur_area_tolerance,
|
||||
)
|
||||
if rooms_result is not None:
|
||||
listings, analog_tier = rooms_result
|
||||
cur_rooms_min, cur_rooms_max = try_rooms_min, try_rooms_max
|
||||
relaxations.append(rooms_label)
|
||||
|
||||
# (b) свежесть объявлений: LISTINGS_FRESH_DAYS (14) → LISTINGS_FRESH_DAYS_RELAXED (60).
|
||||
if len(listings) < HEADLINE_LISTINGS_MIN_N:
|
||||
fresh_result = await _try_relax(
|
||||
rooms_min=cur_rooms_min,
|
||||
rooms_max=cur_rooms_max,
|
||||
fresh_days=LISTINGS_FRESH_DAYS_RELAXED,
|
||||
allow_novostroyki=cur_allow_novostroyki,
|
||||
radius_m=search_radius_m,
|
||||
area_tolerance=cur_area_tolerance,
|
||||
)
|
||||
if fresh_result is not None:
|
||||
listings, analog_tier = fresh_result
|
||||
cur_fresh_days = LISTINGS_FRESH_DAYS_RELAXED
|
||||
relaxations.append("объявления за 60 дней")
|
||||
|
||||
# (c) снять guard listing_segment — допустить новостройки в comp-пул.
|
||||
if len(listings) < HEADLINE_LISTINGS_MIN_N:
|
||||
novo_result = await _try_relax(
|
||||
rooms_min=cur_rooms_min,
|
||||
rooms_max=cur_rooms_max,
|
||||
fresh_days=cur_fresh_days,
|
||||
allow_novostroyki=True,
|
||||
radius_m=search_radius_m,
|
||||
area_tolerance=cur_area_tolerance,
|
||||
)
|
||||
if novo_result is not None:
|
||||
listings, analog_tier = novo_result
|
||||
cur_allow_novostroyki = True
|
||||
relaxations.append("учтены новостройки")
|
||||
|
||||
# (d) радиус → max(текущий, RELAX_RADIUS_STEP1_M), затем → RELAX_RADIUS_STEP2_M.
|
||||
# Пропускается, когда пользователь явно зафиксировал radius_m — тот же
|
||||
# контракт, что и у существующего radius-fallback выше (#2044: сервер не
|
||||
# авто-расширяет поиск за пределы выбранного пользователем радиуса).
|
||||
if len(listings) < HEADLINE_LISTINGS_MIN_N and payload.radius_m is None:
|
||||
for relax_radius in (max(search_radius_m, RELAX_RADIUS_STEP1_M), RELAX_RADIUS_STEP2_M):
|
||||
if relax_radius <= search_radius_m:
|
||||
continue
|
||||
radius_result = await _try_relax(
|
||||
rooms_min=cur_rooms_min,
|
||||
rooms_max=cur_rooms_max,
|
||||
fresh_days=cur_fresh_days,
|
||||
allow_novostroyki=cur_allow_novostroyki,
|
||||
radius_m=relax_radius,
|
||||
area_tolerance=cur_area_tolerance,
|
||||
)
|
||||
if radius_result is not None:
|
||||
listings, analog_tier = radius_result
|
||||
search_radius_m = relax_radius
|
||||
fallback_used = True
|
||||
if len(listings) >= HEADLINE_LISTINGS_MIN_N:
|
||||
break
|
||||
|
||||
# Area/radius relaxations derived from FINAL state (covers both the
|
||||
# pre-existing Tier B/C radius/area widening above AND step (d) here) —
|
||||
# a single check avoids double-labelling the same underlying widening.
|
||||
if area_widened:
|
||||
relaxations.append("площадь ±25%")
|
||||
if search_radius_m > base_radius_m:
|
||||
relaxations.append(f"радиус расширен до {search_radius_m} м")
|
||||
|
||||
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
|
||||
# #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы
|
||||
# corridor_high был доступен для Tier C-гейта и soft-клампа headline.
|
||||
|
|
@ -3990,7 +4214,41 @@ async def estimate_quality(
|
|||
ratio_basis = pr.ratio_basis
|
||||
listings_clean = pr.listings_clean
|
||||
cv = pr.cv
|
||||
listings_headline_thin_n = pr.listings_headline_thin_n
|
||||
|
||||
# #oblast-E (priority restored 2026-08-10): headline ceded to the ДКП deals
|
||||
# corridor because the base listings sample was thin — a real signal (real
|
||||
# Rosreestr deals), just not a listings-comp one. Recorded as its own
|
||||
# relaxation label (distinct from the #oblast-F cascade labels above, which
|
||||
# describe attempts to grow the LISTINGS sample — this describes switching
|
||||
# sources entirely).
|
||||
if pr.deals_headline_due_to_thin_listings:
|
||||
relaxations.append("оценка по сделкам — мало объявлений рядом")
|
||||
|
||||
# #oblast-F: reliability tier derived from the FINAL n_analogs (post anchor/
|
||||
# deals-fallback override above) — independent of `confidence` (older
|
||||
# unique-address/IQR metric, see AggregatedEstimate docstring). If the
|
||||
# #oblast-F cascade had to relax anything to get here, an otherwise-"ok"
|
||||
# sample is downgraded to "low" — the raw count looks fine, but it only
|
||||
# exists because we widened the search past the user's exact criteria.
|
||||
if n_analogs >= 8:
|
||||
reliability: Literal["ok", "low", "very_low"] = "ok"
|
||||
elif n_analogs >= 3:
|
||||
reliability = "low"
|
||||
else:
|
||||
reliability = "very_low"
|
||||
if pr.deals_headline_due_to_thin_listings:
|
||||
# #oblast-E: n_analogs is 0 here (listings-comp count, honestly zero —
|
||||
# the headline came from deals instead), which would otherwise bucket
|
||||
# to 'very_low'. Pin to 'low' instead: a 54-deal Rosreestr corridor is
|
||||
# a real, meaningful signal — "не выше low" (product spec), not
|
||||
# "почти нет сигнала" (what 'very_low' would imply here).
|
||||
reliability = "low"
|
||||
elif relaxations and reliability == "ok":
|
||||
reliability = "low"
|
||||
if relaxations:
|
||||
explanation = (explanation or "") + (
|
||||
" Применены послабления подбора: " + ", ".join(relaxations) + "."
|
||||
)
|
||||
|
||||
# 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
|
||||
# Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
|
||||
|
|
@ -4026,14 +4284,6 @@ async def estimate_quality(
|
|||
# иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому
|
||||
# набору (или = None при пустом listings_clean, хотя у комплов данные есть).
|
||||
metadata_lots = display_pool
|
||||
elif listings_headline_thin_n:
|
||||
# #oblast-E: headline was suppressed (thin radius sample, no anchor to
|
||||
# take over) — do NOT surface those same listings as display cards
|
||||
# either, else `analogs` would show N cards while n_analogs==0 (broken
|
||||
# invariant, same dishonesty this gate exists to remove). Degrades to
|
||||
# the exact same empty-display state as "genuinely zero listings".
|
||||
analogs_lots = []
|
||||
metadata_lots = []
|
||||
else:
|
||||
# display-consistency fix: только ЦЕНОВЫЕ листинги — та же популяция, что
|
||||
# дала n_analogs = len(prices_ppm2) в radius-ветке _price_from_inputs.
|
||||
|
|
@ -4355,6 +4605,11 @@ async def estimate_quality(
|
|||
cv=cv,
|
||||
source_counts=source_counts,
|
||||
created_at=now,
|
||||
# #oblast-F (never-block relaxation cascade) — применённые ослабления
|
||||
# подбора + производная надёжность выборки (см. reliability computation
|
||||
# above, независимо от `confidence`).
|
||||
relaxations=relaxations,
|
||||
reliability=reliability,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -4760,11 +5015,27 @@ def _extract_short_addr(full_address: str | None) -> str | None:
|
|||
|
||||
# Ищет keyword типа улицы (ул./улица/пр./проспект/...) в адресе.
|
||||
# Работает для FORWARD и REVERSE форматов Nominatim.
|
||||
# #pdf-honesty/#oblast-E-follow-up (live-prod fix 2026-08-10): точка после
|
||||
# сокращений (ул., пр., пер., ш., наб., пл., мкр.) сделана ОПЦИОНАЛЬНОЙ
|
||||
# (`\.?`) — DaData (основной источник адресов, дом-уровень геокодинга) отдаёт
|
||||
# формат БЕЗ точки: "ул Академика Парина", а не "ул. Академика Парина". Старый
|
||||
# regex требовал точку строго → keyword не матчился НИ НА ОДНОМ DaData-адресе
|
||||
# → street-deals/sales-vs-listings блоки молчали (WARNING "could not extract
|
||||
# street") на КАЖДОМ запросе с DaData-геокодингом, не только на репро-адресе.
|
||||
# Порядок альтернатив принципиален: `ул\.?` идёт ПЕРЕД полным словом `улица` —
|
||||
# но это безопасно за счёт backtracking Python `re` (NFA, не POSIX longest-
|
||||
# match): если `ул\.?` матчит только "ул" из "улица" и последующий `\s+`
|
||||
# после этого не находит пробел (следующий символ — "и"), движок
|
||||
# откатывается и пробует СЛЕДУЮЩУЮ альтернативу — "улица" — которая матчит
|
||||
# полностью. Проверено на "ул. X" / "ул X" / "улица X" — все три дают
|
||||
# идентичный результат (см. test_street_deals_endpoint.py). Бывшая отдельная
|
||||
# bare-альтернатива "мкр" убрана как ставшая избыточной — "мкр\.?" уже
|
||||
# покрывает оба варианта (с точкой и без).
|
||||
_STREET_KW_RE = re.compile(
|
||||
r"(?<![А-Яа-яёЁa-zA-Z])"
|
||||
r"(?:ул\.|улица|пр\.|пр-т|проспект|пер\.|переулок|"
|
||||
r"б-р|бульвар|ш\.|шоссе|наб\.|набережная|проезд|тракт|"
|
||||
r"пл\.|площадь|мкр\.|мкр|микрорайон)"
|
||||
r"(?:ул\.?|улица|пр\.?|пр-т|проспект|пер\.?|переулок|"
|
||||
r"б-р|бульвар|ш\.?|шоссе|наб\.?|набережная|проезд|тракт|"
|
||||
r"пл\.?|площадь|мкр\.?|микрорайон)"
|
||||
r"\s+",
|
||||
flags=re.IGNORECASE | re.UNICODE,
|
||||
)
|
||||
|
|
@ -4938,7 +5209,10 @@ _ANALOG_SELECT_COLS = """
|
|||
"""
|
||||
|
||||
_COMMON_WHERE = """
|
||||
AND rooms = :rooms
|
||||
-- #oblast-F (a): rooms_min/rooms_max — обычно оба = целевой rooms (exact-match
|
||||
-- byte-identical поведение). estimate_quality() расширяет диапазон (студия↔1-
|
||||
-- комн / ±1 комната) ТОЛЬКО когда базовая выборка тоньше HEADLINE_LISTINGS_MIN_N.
|
||||
AND rooms BETWEEN :rooms_min AND :rooms_max
|
||||
AND area_m2 BETWEEN :area_min AND :area_max
|
||||
AND is_active = true
|
||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||
|
|
@ -4956,8 +5230,13 @@ _COMMON_WHERE = """
|
|||
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
||||
-- Исключаем новостройки из comp-пула вторички: девелоперский прайс искажает
|
||||
-- медиану ₽/м². NULL сегмент пропускаем (rosreestr/avito/yandex без сегмента —
|
||||
-- это вторичка или неклассифицированный объект).
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
-- это вторичка или неклассифицированный объект). #oblast-F (c): allow_novostroyki
|
||||
-- пробрасывается как последняя-по-очереди relaxation-ступень (estimate_quality) —
|
||||
-- дефолт False сохраняет канон-guard byte-identical.
|
||||
AND (
|
||||
CAST(:allow_novostroyki AS boolean) IS TRUE
|
||||
OR (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
)
|
||||
-- #2012 is_apartments hard-filter (флаг estimate_is_apartments_filter_enabled,
|
||||
-- default OFF pending backtest). Флаг выключен ⇒ CAST(... ) IS NOT TRUE ⇒
|
||||
-- условие прозрачно (byte-identical старому поведению). Включён ⇒ исключает
|
||||
|
|
@ -5012,6 +5291,14 @@ def _fetch_analogs(
|
|||
cohort_year_min: int | None = None, # NEW: lower bound year_built inclusive
|
||||
cohort_year_max: int | None = None, # NEW: upper bound year_built inclusive
|
||||
target_house_id: int | None = None, # #6: canonical house for same-building Tier S
|
||||
# #oblast-F (never-block relaxation cascade) — все три опциональны, дефолты
|
||||
# byte-identical старому поведению (exact rooms match / 14 дней / без
|
||||
# новостроек). estimate_quality() передаёт неполные (widened) значения ТОЛЬКО
|
||||
# когда базовая выборка тоньше HEADLINE_LISTINGS_MIN_N — см. module docstring.
|
||||
rooms_min: int | None = None, # #oblast-F (a): None → эффективно = rooms
|
||||
rooms_max: int | None = None, # #oblast-F (a): None → эффективно = rooms
|
||||
fresh_days: int = LISTINGS_FRESH_DAYS, # #oblast-F (b): relaxed = LISTINGS_FRESH_DAYS_RELAXED
|
||||
allow_novostroyki: bool = False, # #oblast-F (c)
|
||||
) -> tuple[list[dict[str, Any]], bool, str]:
|
||||
"""SELECT аналогов — трёхуровневый house-match (S → H → W).
|
||||
|
||||
|
|
@ -5052,21 +5339,29 @@ def _fetch_analogs(
|
|||
"""
|
||||
area_min = area * (1 - area_tolerance)
|
||||
area_max = area * (1 + area_tolerance)
|
||||
# #oblast-F (a): None → эффективно exact-match (rooms_min=rooms_max=rooms),
|
||||
# byte-identical старому `rooms = :rooms`. Caller (estimate_quality) passes a
|
||||
# widened range only past HEADLINE_LISTINGS_MIN_N thinness.
|
||||
eff_rooms_min = rooms if rooms_min is None else rooms_min
|
||||
eff_rooms_max = rooms if rooms_max is None else rooms_max
|
||||
# #1871 P2: (source, source_id) dedup в radius-тирах. rn_dup-окно всегда в SQL
|
||||
# (безвредно без фильтра); статический фрагмент управляет только применением
|
||||
# `AND rn_dup = 1` в outer WHERE. Это SQL-литерал (static), НЕ data — psycopg3
|
||||
# bind-параметры не задействованы, инъекции нет.
|
||||
dup_filter = "AND rn_dup = 1"
|
||||
base_params: dict[str, Any] = {
|
||||
"rooms": rooms,
|
||||
"rooms_min": eff_rooms_min,
|
||||
"rooms_max": eff_rooms_max,
|
||||
"area_min": area_min,
|
||||
"area_max": area_max,
|
||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||
"fresh_days": fresh_days,
|
||||
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
|
||||
"cohort_year_min": cohort_year_min,
|
||||
"cohort_year_max": cohort_year_max,
|
||||
# #2012: is_apartments hard-filter — see _COMMON_WHERE comment above.
|
||||
"is_apartments_filter": settings.estimate_is_apartments_filter_enabled,
|
||||
# #oblast-F (c): allow_novostroyki — see _COMMON_WHERE comment above.
|
||||
"allow_novostroyki": allow_novostroyki,
|
||||
}
|
||||
|
||||
# ── Tier S (canonical): same building via house_id_fk ─────────────────────
|
||||
|
|
@ -5391,7 +5686,8 @@ def _fetch_analogs(
|
|||
FROM listings
|
||||
WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
|
||||
AND (geo_precision IS DISTINCT FROM 'city')
|
||||
AND rooms = :rooms
|
||||
-- #oblast-F (a): sync с _COMMON_WHERE — см. комментарий там же.
|
||||
AND rooms BETWEEN :rooms_min AND :rooms_max
|
||||
AND area_m2 BETWEEN :area_min AND :area_max
|
||||
AND is_active = true
|
||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||
|
|
@ -5408,7 +5704,11 @@ def _fetch_analogs(
|
|||
)
|
||||
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
||||
-- Tier W: исключаем новостройки из comp-пула (sync с _COMMON_WHERE).
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
-- #oblast-F (c): allow_novostroyki relaxation, sync с _COMMON_WHERE.
|
||||
AND (
|
||||
CAST(:allow_novostroyki AS boolean) IS TRUE
|
||||
OR (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
)
|
||||
-- #2012 is_apartments hard-filter, sync с _COMMON_WHERE (см. комментарий
|
||||
-- там же). Флаг выключен ⇒ прозрачно (byte-identical старому поведению).
|
||||
AND (
|
||||
|
|
@ -5450,16 +5750,18 @@ def _fetch_analogs(
|
|||
"lat": lat,
|
||||
"lon": lon,
|
||||
"radius": radius_m,
|
||||
"rooms": rooms,
|
||||
"rooms_min": eff_rooms_min,
|
||||
"rooms_max": eff_rooms_max,
|
||||
"area_min": area_min,
|
||||
"area_max": area_max,
|
||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||
"fresh_days": fresh_days,
|
||||
"target_year": year_built,
|
||||
"target_house_type": house_type,
|
||||
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
|
||||
"cohort_year_min": cohort_year_min, # NEW
|
||||
"cohort_year_max": cohort_year_max, # NEW
|
||||
"is_apartments_filter": settings.estimate_is_apartments_filter_enabled, # #2012
|
||||
"allow_novostroyki": allow_novostroyki, # #oblast-F (c)
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
@ -6570,4 +6872,7 @@ def _empty_estimate(
|
|||
# Адрес не геокодирован (DaData не отрабатывала) → точность неизвестна.
|
||||
address_precision=None,
|
||||
analog_tier=None, # нет данных при empty estimate
|
||||
# #oblast-F: n_analogs=0 здесь честно — поиск аналогов вообще не выполнялся
|
||||
# (geocode failed / no coords), а не просто "мало нашлось".
|
||||
reliability="very_low",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1243,11 +1243,71 @@ def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int
|
|||
return min(prices), max(prices)
|
||||
|
||||
|
||||
def _deals_sourced_thin_listings_note_html(estimate: AggregatedEstimate) -> str:
|
||||
"""#pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): honest
|
||||
footnote for the specific case n_analogs==0 (headline ceded to the ДКП deals
|
||||
corridor, estimator.py `deals_headline_due_to_thin_listings`) BUT
|
||||
estimate.analogs is non-empty (the thin listings that triggered the cession
|
||||
are still shown below as reference cards — never cleared, see estimator.py
|
||||
#1871 ghost-anchor guard). Same tone/plain-sentence style as the web
|
||||
LowConfidenceBanner for this scenario. Empty string (no-op) otherwise —
|
||||
covers both "healthy sample" and "genuinely zero, nothing to show" cases."""
|
||||
if estimate.n_analogs != 0 or not estimate.analogs:
|
||||
return ""
|
||||
return (
|
||||
f'<p style="margin:6pt 0 0 0;font-size:{_FS_SM};color:{_MUTED};line-height:1.35;">'
|
||||
"Оценка построена по зарегистрированным сделкам Росреестра — подходящих "
|
||||
"объявлений поблизости почти нет. Объявления ниже приведены справочно, "
|
||||
"для наглядности рынка.</p>"
|
||||
)
|
||||
|
||||
|
||||
def _reliability_note_html(estimate: AggregatedEstimate, n_shown: int) -> str:
|
||||
"""#pdf-honesty: surfaces `AggregatedEstimate.relaxations`/`reliability`
|
||||
(estimator.py #oblast-F cascade + #oblast-E deals-priority) — the web report
|
||||
already shows this (LowConfidenceBanner); the PDF stayed silent, a
|
||||
client-visible discrepancy between the two. Empty string (no-op) when
|
||||
reliability=='ok' and relaxations is empty — the common, unrelaxed case,
|
||||
byte-identical to the report before these fields existed."""
|
||||
if estimate.reliability == "ok" and not estimate.relaxations:
|
||||
return ""
|
||||
if estimate.relaxations:
|
||||
detail = "Подбор аналогов расширен: " + ", ".join(
|
||||
_html.escape(r) for r in estimate.relaxations
|
||||
)
|
||||
else:
|
||||
# relaxations пуст, но reliability всё же не 'ok' (напр. тонкая выборка,
|
||||
# которую каскад ослаблений не смог расширить, см. estimator.py
|
||||
# #oblast-F) — n_shown, не сырой n_analogs (та же #pdf-honesty логика,
|
||||
# что и в счётчике выше страницы).
|
||||
detail = f"Оценка построена по небольшой выборке ({n_shown} шт.)"
|
||||
return f"""
|
||||
<div style="margin-top:10pt;padding:9pt 12pt;border-left:3pt solid {_WARN};
|
||||
background:{_ACCENT_2_SOFT};font-size:{_FS_SM};color:{_INK};line-height:1.35;">
|
||||
<span style="font-weight:700;color:{_WARN};">Точность оценки снижена.</span>
|
||||
{detail} — данные ниже приведены с этой оговоркой.
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
# ── Page 2: Listings (market) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
|
||||
n_total = estimate.n_analogs
|
||||
# #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): raw
|
||||
# estimate.n_analogs is the count of listings that drove the HEADLINE math —
|
||||
# it is deliberately 0 when the headline was ceded to the ДКП deals corridor
|
||||
# (estimator.py `deals_headline_due_to_thin_listings`), even though the thin
|
||||
# listings that triggered that cession are still shown below as display cards
|
||||
# (estimate.analogs — never cleared, see estimator.py #1871 ghost-anchor
|
||||
# guard comment). Printing raw n_analogs there read as "0 шт." above a
|
||||
# non-empty examples table — a client-visible contradiction. n_analogs is
|
||||
# normally >= len(analogs) (analogs is a top-10-capped SUBSET of what
|
||||
# n_analogs counts, see AnalogLot/AggregatedEstimate docstring) — max() is a
|
||||
# no-op in that common case (count stays the honest FULL n_analogs) and only
|
||||
# changes anything in this one pathological case, where it falls back to
|
||||
# "how many are actually shown" instead of the dishonest zero.
|
||||
n_total = max(estimate.n_analogs, len(estimate.analogs))
|
||||
# #1531: убрана строка-дубль «(с учётом ремонта)». Estimator НЕ фильтрует
|
||||
# аналоги по repair_state (coverage listings.repair_state ~2%, см. estimator.py:160),
|
||||
# а лишь применяет ценовой коэффициент к медиане/диапазону — поэтому отдельного
|
||||
|
|
@ -1306,6 +1366,10 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
|||
examples_rows = _examples_rows(top5)
|
||||
|
||||
heading_html = _section_heading("02", "РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
|
||||
# #pdf-honesty — see helper docstrings above. Both no-op ("") in the common
|
||||
# (unrelaxed, non-deals-sourced) case — byte-identical page in that case.
|
||||
deals_sourced_note = _deals_sourced_thin_listings_note_html(estimate)
|
||||
reliability_note = _reliability_note_html(estimate, n_total)
|
||||
|
||||
return f"""
|
||||
<div style="page-break-after:always;">
|
||||
|
|
@ -1320,6 +1384,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
|||
<tr><td style="padding:4pt 0;">Количество объявлений по аналогичным объектам</td>
|
||||
<td class="bold" style="text-align:right;">{_mono(f"{n_total} шт.")}</td></tr>
|
||||
</table>
|
||||
{deals_sourced_note}
|
||||
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
|
||||
<span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div>
|
||||
<div style="margin-top:6pt;overflow-wrap:anywhere;">{sources_html}</div>
|
||||
|
|
@ -1344,6 +1409,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
|||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{reliability_note}
|
||||
|
||||
<p style="margin:8pt 0 4pt 0;font-size:{_FS_MD};font-weight:700;">
|
||||
Диапазон цен в объявлениях</p>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,55 @@
|
|||
"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02).
|
||||
"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02, priority
|
||||
RESTORED 2026-08-10) + #oblast-F — never-block relaxation cascade (product
|
||||
decision, 2026-08-10).
|
||||
|
||||
Live-prod repro that motivated this gate: Серов 2к/45м², n=3 scraped listings →
|
||||
headline 42 391 ₽/м² (−36% vs the city ДКП corridor, 54 126 ₽/м²); a neighbouring
|
||||
street in the same town swung ±66% on 1-2 different random listings. Каменск-
|
||||
Уральский returned a LITERAL 0 ₽ for a room/area combo with no local ДКП match
|
||||
either, with no honest refusal surfaced. Первоуральск (0 listings) already fell
|
||||
back to the (pre-existing) ДКП deals-headline fallback correctly — this gate
|
||||
routes the THIN (1..HEADLINE_LISTINGS_MIN_N-1 listings) case into that SAME,
|
||||
already-tested path instead of trusting a 1-4-lot median as the headline.
|
||||
History:
|
||||
1. #oblast-E (2026-08-02) SUPPRESSED a thin (1..HEADLINE_LISTINGS_MIN_N-1)
|
||||
listings sample to a literal zero, forcing the anchor/#oblast-D-deals-
|
||||
fallback/insufficient_data chain to take over — motivated by a live
|
||||
Серов repro (n=3 → 42 391 ₽/м², −36% vs the town's ДКП corridor of
|
||||
54 126 ₽/м²).
|
||||
2. #oblast-F (2026-08-10, first pass) reversed that suppression WHOLESALE —
|
||||
a thin sample always kept its own median, even when a much more reliable
|
||||
deals corridor was available. That accidentally REOPENED the exact Серов
|
||||
bug #oblast-E existed to close.
|
||||
3. #oblast-E priority RESTORED (2026-08-10, same day, product correction):
|
||||
"никогда не блокировать вывод" ≠ "предпочитать шумную медиану по 3
|
||||
объявлениям надёжному коридору по 54 сделкам". Final 3-way rule, in
|
||||
`_price_from_inputs`'s gate:
|
||||
- n_analogs >= HEADLINE_LISTINGS_MIN_N → listings median (unaffected).
|
||||
- 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND a usable ДКП corridor
|
||||
exists (count >= DEALS_HEADLINE_FALLBACK_MIN_N, median_ppm2 > 0) →
|
||||
listings aggregate suppressed to zero, headline ceded to the
|
||||
#oblast-D deals-headline-fallback chain (original #oblast-E
|
||||
behaviour, restored). `PricingResult.deals_headline_due_to_thin_
|
||||
listings=True` — estimate_quality() adds relaxation label "оценка по
|
||||
сделкам — мало объявлений рядом" and caps reliability at 'low'.
|
||||
Listings display cards are NOT hidden (unlike original #oblast-E) —
|
||||
`listings_clean` stays intact and estimate_quality() still surfaces
|
||||
them as context even though they no longer drive n_analogs/median.
|
||||
- 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND no usable ДКП corridor →
|
||||
#oblast-F: keep the real thin median (never refuse outright).
|
||||
Real refusal ("недостаточно данных") now happens ONLY at genuine n=0
|
||||
(no listings AND no usable anchor/deals) — the never-block requirement
|
||||
with an honest, priority-ordered source selection.
|
||||
|
||||
`estimate_quality()` tries to grow a thin sample FIRST via the #oblast-F
|
||||
relaxation cascade (room-adjacency / freshness / novostroyki / radius, see
|
||||
estimator.py module docstring) BEFORE `_price_from_inputs` (tested here in
|
||||
Layer 1) ever runs the 3-way gate above — `listings` here is whatever that
|
||||
cascade could find.
|
||||
|
||||
Two layers:
|
||||
1. `_price_from_inputs` unit tests (no DB, no estimate_quality overhead) —
|
||||
boundary behaviour of the gate itself.
|
||||
boundary behaviour of the gate itself: the 3-way rule, low-reliability
|
||||
wording, listings_clean/listings_headline_thin_n/deals_headline_due_to_
|
||||
thin_listings bookkeeping.
|
||||
2. `estimate_quality` integration tests — proves the money-path invariants
|
||||
that matter to a caller: literal 0 never leaks as a "confident" price,
|
||||
display `analogs` cards never outnumber what `n_analogs` claims, and the
|
||||
explanation text describes what actually happened (not a stock "аналогов
|
||||
не найдено" when some WERE found, just too few).
|
||||
that matter to a caller: thin+usable-deals routes to the deals corridor
|
||||
(Серов repro), thin+no-deals keeps its own median, display `analogs`
|
||||
cards are shown either way, and the #oblast-F room-adjacency relaxation
|
||||
(studio↔1-комн) actually grows a thin sample and is reported via
|
||||
`AggregatedEstimate.relaxations` / `reliability`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -103,15 +136,14 @@ def test_threshold_is_five_not_lower() -> None:
|
|||
assert HEADLINE_LISTINGS_MIN_N == 5
|
||||
|
||||
|
||||
def test_four_listings_below_threshold_suppressed_no_fallback() -> None:
|
||||
"""n=4 (< 5), no ДКП signal → headline suppressed to the honest zero state,
|
||||
NOT the naive median of 4 listings."""
|
||||
def test_four_listings_below_threshold_kept_not_suppressed() -> None:
|
||||
"""#oblast-F: n=4 (< 5) → the REAL 4-listing median is kept (product decision
|
||||
2026-08-10 — never zero out a thin-but-real sample), just flagged low."""
|
||||
pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0, 230_000.0]))
|
||||
assert pr.median_ppm2 == 0.0
|
||||
assert pr.median_price == 0
|
||||
assert pr.n_analogs == 0
|
||||
assert pr.range_low == 0
|
||||
assert pr.range_high == 0
|
||||
assert pr.median_ppm2 == 215_000.0
|
||||
assert pr.n_analogs == 4
|
||||
assert pr.median_price == round(215_000.0 * 45.0)
|
||||
assert pr.confidence == "low"
|
||||
|
||||
|
||||
def test_five_listings_at_threshold_not_suppressed() -> None:
|
||||
|
|
@ -122,18 +154,21 @@ def test_five_listings_at_threshold_not_suppressed() -> None:
|
|||
assert pr.median_price == round(210_000.0 * 45.0)
|
||||
|
||||
|
||||
def test_one_listing_below_threshold_suppressed() -> None:
|
||||
"""n=1 — the sharpest form of the Серов bug (a single random lot deciding
|
||||
the whole headline) — must be suppressed exactly like n=4."""
|
||||
def test_one_listing_below_threshold_kept_not_suppressed() -> None:
|
||||
"""#oblast-F: n=1 — the sharpest thin case — still keeps its own (single-lot)
|
||||
median rather than being zeroed; confidence stays 'low'."""
|
||||
pr = _call(listings=_lots([200_000.0]))
|
||||
assert pr.median_ppm2 == 0.0
|
||||
assert pr.n_analogs == 0
|
||||
assert pr.median_ppm2 == 200_000.0
|
||||
assert pr.n_analogs == 1
|
||||
assert pr.confidence == "low"
|
||||
|
||||
|
||||
def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None:
|
||||
"""n=3 listings (thin) + a usable ДКП corridor → headline comes from the
|
||||
deal corridor median, NOT the 3-listing median (live Серов repro: 3
|
||||
listings gave 42 391 vs the honest ДКП-based ~54 126)."""
|
||||
"""#oblast-E priority RESTORED (2026-08-10 product correction): a thin
|
||||
(n=3) listings sample must NOT outrank a usable ДКП deals corridor — this
|
||||
is the exact live Серов repro #oblast-E exists for (3 noisy listings gave
|
||||
42 391 ₽/м², the honest 54-deal corridor gives 65 957 ₽/м²). Headline
|
||||
comes from the deal corridor median, NOT the 3-listing median."""
|
||||
dkp_raw = {
|
||||
"count": 54,
|
||||
"low_ppm2": 44_000,
|
||||
|
|
@ -151,12 +186,23 @@ def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None:
|
|||
)
|
||||
assert pr.n_analogs == 0, "honest: 0 scraped-listing analogs back this headline"
|
||||
assert pr.confidence == "low"
|
||||
assert pr.deals_headline_due_to_thin_listings is True
|
||||
assert pr.listings_clean, "listings_clean must stay intact — display cards still show them"
|
||||
# #4: explanation must not falsely claim "рядом нет объявлений" (some WERE
|
||||
# found, just ceded priority to the more reliable deals corridor) and must
|
||||
# NOT also carry the separate "Оценка построена по N аналогам" thin-kept
|
||||
# wording (that phrasing is reserved for the no-usable-corridor branch).
|
||||
assert pr.explanation is not None
|
||||
assert "рядом нет актуальных объявлений" not in pr.explanation.lower()
|
||||
assert "сделкам росреестра" in pr.explanation.lower()
|
||||
assert "оценка построена по 3" not in pr.explanation.lower()
|
||||
|
||||
|
||||
def test_thin_sample_with_insufficient_deals_stays_zero() -> None:
|
||||
def test_thin_sample_with_thin_deals_also_uses_real_listings_median() -> None:
|
||||
"""n=3 listings (thin) + a ДКП corridor that is ITSELF too thin
|
||||
(< DEALS_HEADLINE_FALLBACK_MIN_N) → neither source is trusted; honest zero,
|
||||
not a fabricated number from either side."""
|
||||
(< DEALS_HEADLINE_FALLBACK_MIN_N) → the corridor is NOT usable, so
|
||||
#oblast-F's never-block rule applies: the real listings median is kept
|
||||
rather than refusing (neither source alone would justify a hard zero)."""
|
||||
dkp_raw = {
|
||||
"count": 1,
|
||||
"low_ppm2": 40_000,
|
||||
|
|
@ -165,49 +211,51 @@ def test_thin_sample_with_insufficient_deals_stays_zero() -> None:
|
|||
"period_months": 12,
|
||||
}
|
||||
pr = _call(listings=_lots([42_391.0, 26_818.0, 75_058.0]), dkp_raw=dkp_raw)
|
||||
assert pr.median_ppm2 == 0.0
|
||||
assert pr.median_price == 0
|
||||
assert pr.n_analogs == 0
|
||||
assert pr.median_ppm2 == 42_391.0
|
||||
assert pr.n_analogs == 3
|
||||
assert pr.deals_headline_due_to_thin_listings is False
|
||||
|
||||
|
||||
def test_thin_sample_explanation_is_honest_about_count() -> None:
|
||||
"""The explanation for a thin-but-nonzero sample must say HOW MANY listings
|
||||
were found (not the generic 'ничего не найдено' text used for a genuine
|
||||
zero-listing case) — #4 in the task: explanation must match reality."""
|
||||
def test_thin_sample_explanation_is_honest_about_low_accuracy() -> None:
|
||||
"""#4 (task spec): the explanation for a thin-but-real sample must read as
|
||||
"small sample, lower accuracy" — NOT the old refusal-flavoured "минимум для
|
||||
оценки по рынку" copy, and NOT the generic zero-analogs text."""
|
||||
pr = _call(listings=_lots([200_000.0, 210_000.0])) # n=2
|
||||
assert pr.explanation is not None
|
||||
assert "2" in pr.explanation
|
||||
assert "недостаточно" in pr.explanation.lower()
|
||||
# Must NOT reuse the "nothing found at all" copy — 2 listings WERE found.
|
||||
assert "выборка мала" in pr.explanation.lower()
|
||||
assert "точность снижена" in pr.explanation.lower()
|
||||
assert "минимум для оценки по рынку" not in pr.explanation.lower()
|
||||
assert "не найдено аналогов" not in pr.explanation.lower()
|
||||
|
||||
|
||||
def test_thin_sample_deals_fallback_explanation_does_not_claim_zero_listings() -> None:
|
||||
"""#4: once the ДКП fallback fires for a thin (not zero) sample, the
|
||||
explanation must not falsely claim 'рядом нет объявлений' — some WERE
|
||||
found, just not enough to trust."""
|
||||
def test_zero_listings_with_sufficient_deals_still_uses_deals_headline() -> None:
|
||||
"""Control: the #oblast-D deals-headline-fallback path is UNCHANGED for
|
||||
GENUINELY zero listings (n=0) — #oblast-F only affects the 1..N-1 thin
|
||||
case, not the true-zero case, which still needs a fallback source."""
|
||||
dkp_raw = {
|
||||
"count": 20,
|
||||
"low_ppm2": 40_000,
|
||||
"median_ppm2": 60_000,
|
||||
"high_ppm2": 80_000,
|
||||
"count": 54,
|
||||
"low_ppm2": 44_000,
|
||||
"median_ppm2": 65_957,
|
||||
"high_ppm2": 89_000,
|
||||
"period_months": 12,
|
||||
}
|
||||
pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0]), dkp_raw=dkp_raw)
|
||||
pr = _call(listings=[], dkp_raw=dkp_raw)
|
||||
assert pr.median_ppm2 == 65_957.0
|
||||
assert pr.n_analogs == 0
|
||||
assert pr.confidence == "low"
|
||||
assert pr.explanation is not None
|
||||
assert "рядом нет актуальных объявлений" not in pr.explanation.lower()
|
||||
assert "рядом нет актуальных объявлений" in pr.explanation.lower()
|
||||
assert "сделкам росреестра" in pr.explanation.lower()
|
||||
|
||||
|
||||
def test_thin_sample_listings_clean_preserved_for_anchor_ghost_guard() -> None:
|
||||
"""Regression guard: the gate must suppress the AGGREGATE (median/n_analogs)
|
||||
without clearing `listings_clean` itself — the same-building anchor's own
|
||||
ghost-anchor guard (#1871) reads `listings_clean` truthiness to tell
|
||||
"genuinely zero nearby listings" from "some nearby, just too few to trust
|
||||
as headline", and conflating the two was caught regressing
|
||||
test_estimator_split_corridor_1871.py during this change."""
|
||||
def test_thin_sample_listings_clean_preserved_and_thin_n_still_tracked() -> None:
|
||||
"""listings_clean stays intact (unchanged invariant — same-building anchor's
|
||||
ghost-anchor guard #1871 depends on it) AND, post-#oblast-F, n_analogs is
|
||||
the REAL count (not zeroed) while listings_headline_thin_n still marks the
|
||||
sample as thin for the low-reliability note upstream."""
|
||||
pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0]))
|
||||
assert pr.n_analogs == 0
|
||||
assert pr.n_analogs == 3
|
||||
assert len(pr.listings_clean) == 3
|
||||
assert pr.listings_headline_thin_n == 3
|
||||
|
||||
|
|
@ -219,6 +267,42 @@ def test_sufficient_sample_listings_headline_thin_n_is_zero() -> None:
|
|||
assert pr.listings_headline_thin_n == 0
|
||||
|
||||
|
||||
def test_repair_coefficient_now_applies_to_thin_sample() -> None:
|
||||
"""#oblast-F: pre-#oblast-F, the repair-state coefficient was skipped for a
|
||||
thin sample because the headline was already zeroed (applying it would be a
|
||||
no-op). Now that the real median is kept, the coefficient must apply."""
|
||||
pr_no_repair = _call(listings=_lots([200_000.0, 210_000.0])) # n=2, thin
|
||||
pr = _price_from_inputs(
|
||||
listings=_lots([200_000.0, 210_000.0]),
|
||||
area_m2=45.0,
|
||||
rooms=2,
|
||||
repair_state="excellent",
|
||||
floor=5,
|
||||
total_floors=9,
|
||||
target_year=None,
|
||||
analog_tier="W",
|
||||
fallback_used=False,
|
||||
area_widened=False,
|
||||
anchor_comps=[],
|
||||
anchor_tier_fetched=None,
|
||||
dkp_raw=None,
|
||||
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=_geo(),
|
||||
dadata_qc_geo=None,
|
||||
)
|
||||
assert (
|
||||
pr.median_price != pr_no_repair.median_price
|
||||
), "repair coefficient must be applied even for a thin (#oblast-E-flagged) sample"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Layer 2 — `estimate_quality` integration tests (full stub-patched I/O path)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -261,24 +345,31 @@ def _serov_payload() -> Any:
|
|||
|
||||
def _run_estimate(
|
||||
*,
|
||||
analogs: list[dict[str, Any]],
|
||||
analogs: list[dict[str, Any]] | None = None,
|
||||
dkp_raw: dict[str, Any] | None,
|
||||
fetch_analogs_side_effect: Any = None,
|
||||
payload: Any = None,
|
||||
geo: GeocodeResult | None = None,
|
||||
) -> Any:
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
payload = _serov_payload()
|
||||
payload = payload or _serov_payload()
|
||||
geo = geo or _geo()
|
||||
|
||||
fetch_analogs_kwargs: dict[str, Any] = (
|
||||
{"side_effect": fetch_analogs_side_effect}
|
||||
if fetch_analogs_side_effect is not None
|
||||
else {"return_value": (list(analogs or []), False, "W")}
|
||||
)
|
||||
|
||||
async def _run() -> Any:
|
||||
with (
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_geo())),
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch(
|
||||
"app.services.estimator._fetch_analogs",
|
||||
return_value=(list(analogs), False, "W"),
|
||||
),
|
||||
patch("app.services.estimator._fetch_analogs", **fetch_analogs_kwargs),
|
||||
patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch(
|
||||
|
|
@ -301,40 +392,47 @@ def _run_estimate(
|
|||
return anyio.run(_run)
|
||||
|
||||
|
||||
def test_e2e_thin_no_deals_never_leaks_literal_zero_as_confident_price() -> None:
|
||||
"""Каменск-Уральский-style repro: thin listings, no usable ДКП corridor —
|
||||
median_price_rub must be 0 AND insufficient_data must be True TOGETHER
|
||||
(the AggregatedEstimate.insufficient_data computed_field invariant that
|
||||
stops a literal 0 ₽ reaching the user as a confident number)."""
|
||||
def test_e2e_thin_sample_no_relaxation_help_keeps_real_median() -> None:
|
||||
"""#oblast-F: 2 thin listings, no ДКП, and the mocked `_fetch_analogs` always
|
||||
returns the SAME 2 listings regardless of relaxation params (none of them
|
||||
help) — median_price_rub must be the REAL non-zero 2-listing median,
|
||||
insufficient_data False, n_analogs=2, confidence='low', reliability
|
||||
'very_low' (n<3), relaxations empty (nothing actually helped)."""
|
||||
analogs = [
|
||||
_make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"),
|
||||
_make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"),
|
||||
]
|
||||
est = _run_estimate(analogs=analogs, dkp_raw=None)
|
||||
assert est.median_price_rub == 0
|
||||
assert est.insufficient_data is True
|
||||
assert est.n_analogs == 0
|
||||
assert est.median_price_rub == round(205_000.0 * 45.0)
|
||||
assert est.insufficient_data is False
|
||||
assert est.n_analogs == 2
|
||||
assert est.confidence == "low"
|
||||
assert est.relaxations == []
|
||||
assert est.reliability == "very_low"
|
||||
|
||||
|
||||
def test_e2e_thin_sample_display_cards_never_outnumber_n_analogs() -> None:
|
||||
"""The 2 thin listings must NOT be surfaced as `analogs` display cards while
|
||||
n_analogs reports 0 — that would be the same dishonesty (confident-looking
|
||||
UI) this whole gate exists to remove."""
|
||||
def test_e2e_thin_sample_display_cards_match_n_analogs() -> None:
|
||||
"""#oblast-F: display `analogs` cards are NO LONGER suppressed for a thin
|
||||
sample — they must match n_analogs exactly (both = 2), never hidden."""
|
||||
analogs = [
|
||||
_make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"),
|
||||
_make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"),
|
||||
]
|
||||
est = _run_estimate(analogs=analogs, dkp_raw=None)
|
||||
assert est.n_analogs == 0
|
||||
assert est.analogs == []
|
||||
assert est.n_analogs == 2
|
||||
assert len(est.analogs) == 2
|
||||
|
||||
|
||||
def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None:
|
||||
"""Live Серов repro (n=3 scraped listings, wide ДКП corridor available):
|
||||
headline must come from the deal corridor, not the noisy 3-listing median,
|
||||
and the estimate must be honestly non-'insufficient' (a real number, low
|
||||
confidence, deals-sourced)."""
|
||||
"""Live Серов repro (n=3 scraped listings, wide ДКП corridor available) —
|
||||
#oblast-E priority RESTORED: headline must come from the deal corridor,
|
||||
not the noisy 3-listing median. Also proves the #4 task-spec requirements
|
||||
layered on top of the restored priority: the estimate is honestly non-
|
||||
'insufficient' (a real number, low confidence), reliability is capped at
|
||||
'low' (not 'very_low' — a 54-deal corridor is real signal), the
|
||||
relaxation label names the source switch, AND the 3 thin listings are
|
||||
still shown as display cards (not discarded) even though they no longer
|
||||
drive n_analogs/median."""
|
||||
analogs = [
|
||||
_make_listing(price_per_m2=42_391.0, address="ул. Льва Толстого, 8А"),
|
||||
_make_listing(price_per_m2=26_818.0, address="ул. Кирова, 4"),
|
||||
|
|
@ -354,12 +452,15 @@ def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None:
|
|||
assert est.confidence == "low"
|
||||
assert est.confidence_explanation is not None
|
||||
assert "сделкам росреестра" in est.confidence_explanation.lower()
|
||||
assert est.reliability == "low", "a 54-deal corridor is real signal, not 'very_low'"
|
||||
assert "оценка по сделкам — мало объявлений рядом" in est.relaxations
|
||||
assert len(est.analogs) == 3, "thin listings must still surface as display cards"
|
||||
|
||||
|
||||
def test_e2e_sufficient_five_analogs_unaffected_control() -> None:
|
||||
"""Control (mirrors the Екатеринбург prod check in the PR): a sample that
|
||||
clears the threshold is priced exactly as before — headline is the real
|
||||
listings median, all 5 analogs counted."""
|
||||
listings median, all 5 analogs counted, no relaxations needed."""
|
||||
analogs = [
|
||||
_make_listing(price_per_m2=195_000.0, address="ул. Ленина, 5"),
|
||||
_make_listing(price_per_m2=205_000.0, address="ул. Ленина, 7"),
|
||||
|
|
@ -371,3 +472,78 @@ def test_e2e_sufficient_five_analogs_unaffected_control() -> None:
|
|||
assert est.median_price_per_m2 == 210_000
|
||||
assert est.n_analogs == 5
|
||||
assert est.insufficient_data is False
|
||||
assert est.relaxations == []
|
||||
assert est.reliability == "low" # n=5 falls in the 3..7 bucket
|
||||
|
||||
|
||||
def test_e2e_rooms_relaxation_includes_studios_when_thin() -> None:
|
||||
"""#oblast-F step (a) — the exact scenario from the task spec: rooms=1 thin
|
||||
sample (studio-adjacent building, live prod repro Академика Парина 46/5) →
|
||||
cascade retries with rooms IN (0,1) and finds a trustworthy sample there.
|
||||
Asserts: studios pulled in, `relaxations` names it, real non-zero median,
|
||||
reliability downgraded to 'low' (thin base sample)."""
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
|
||||
exact_rooms1 = [
|
||||
_make_listing(price_per_m2=150_000.0, address="ул. Парина, 1", area_m2=23.0),
|
||||
_make_listing(price_per_m2=155_000.0, address="ул. Парина, 2", area_m2=23.0),
|
||||
]
|
||||
studio_pool = [
|
||||
*exact_rooms1,
|
||||
_make_listing(price_per_m2=140_000.0, address="ул. Парина, 3", area_m2=20.0),
|
||||
_make_listing(price_per_m2=145_000.0, address="ул. Парина, 4", area_m2=21.0),
|
||||
_make_listing(price_per_m2=148_000.0, address="ул. Парина, 5", area_m2=22.0),
|
||||
]
|
||||
|
||||
def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]:
|
||||
if kwargs.get("rooms_min") == 0 and kwargs.get("rooms_max") == 1:
|
||||
return list(studio_pool), False, "W"
|
||||
return list(exact_rooms1), False, "W"
|
||||
|
||||
geo = GeocodeResult(
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
full_address="Свердловская обл., Екатеринбург, ул. Парина, 46/5",
|
||||
provider="nominatim",
|
||||
)
|
||||
payload = TradeInEstimateInput(
|
||||
address="ЕКБ, ул. Парина, 46/5",
|
||||
area_m2=23.1,
|
||||
rooms=1,
|
||||
)
|
||||
|
||||
est = _run_estimate(
|
||||
dkp_raw=None,
|
||||
fetch_analogs_side_effect=_fetch_analogs_stub,
|
||||
payload=payload,
|
||||
geo=geo,
|
||||
)
|
||||
|
||||
assert "учтены студии" in est.relaxations
|
||||
assert est.median_price_rub > 0
|
||||
assert est.reliability == "low"
|
||||
assert est.n_analogs == 5
|
||||
|
||||
|
||||
def test_e2e_radius_relaxation_respects_explicit_user_radius() -> None:
|
||||
"""#oblast-F step (d) contract: when the user explicitly picked radius_m
|
||||
(#2044), the cascade must NOT auto-expand past it — mirrors the existing
|
||||
radius-fallback contract above (no auto-expansion beyond user's choice)."""
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
|
||||
thin = [
|
||||
_make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"),
|
||||
_make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"),
|
||||
]
|
||||
payload = TradeInEstimateInput(
|
||||
address="Серов, ул. Ленина, 5",
|
||||
area_m2=45.0,
|
||||
rooms=2,
|
||||
floor=5,
|
||||
total_floors=9,
|
||||
city_hint="Серов",
|
||||
radius_m=1500,
|
||||
)
|
||||
est = _run_estimate(analogs=thin, dkp_raw=None, payload=payload)
|
||||
assert not any("радиус расширен" in r for r in est.relaxations)
|
||||
assert est.search_radius_m == 1500
|
||||
|
|
|
|||
|
|
@ -423,6 +423,86 @@ def test_build_listings_page_none_year_built_no_crash() -> None:
|
|||
assert "РЫНОК КВАРТИР" in html
|
||||
|
||||
|
||||
# ── #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10) ────────
|
||||
# n_analogs==0 (headline ceded to the ДКП deals corridor, estimator.py
|
||||
# `deals_headline_due_to_thin_listings`) with a non-empty `analogs` display list
|
||||
# (thin listings kept as reference cards) used to print "0 шт." above a
|
||||
# non-empty examples table — a client-visible contradiction that leaked into
|
||||
# the PDF handed to clients. See _build_listings_page / _deals_sourced_thin_
|
||||
# listings_note_html / _reliability_note_html.
|
||||
|
||||
|
||||
def test_listings_page_zero_analogs_shown_cards_no_false_zero_count() -> None:
|
||||
"""The exact bug: n_analogs=0 + 3 shown analogs must NOT print '0 шт.' —
|
||||
falls back to the actually-shown population (3) and adds an honest
|
||||
deals-sourced footnote."""
|
||||
analogs = [
|
||||
_analog(address="ул. Льва Толстого, 8А"),
|
||||
_analog(address="ул. Кирова, 4"),
|
||||
_analog(address="ул. Льва Толстого, 34"),
|
||||
]
|
||||
est = _estimate(n_analogs=0, analogs=analogs, sources_used=["avito"])
|
||||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||||
assert "0 шт." not in html
|
||||
assert "3 шт." in html
|
||||
assert "Оценка построена по зарегистрированным сделкам Росреестра" in html
|
||||
assert "почти нет" in html
|
||||
|
||||
|
||||
def test_listings_page_zero_analogs_empty_cards_stays_honest_zero() -> None:
|
||||
"""Control: genuinely zero listings (no cards to show either) — '0 шт.' is
|
||||
honest here, and the deals-sourced footnote (which explains a MISMATCH)
|
||||
must NOT appear since there is nothing to reconcile."""
|
||||
est = _estimate(n_analogs=0, analogs=[])
|
||||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||||
assert "0 шт." in html
|
||||
assert "Оценка построена по зарегистрированным сделкам Росреестра" not in html
|
||||
|
||||
|
||||
def test_listings_page_healthy_sample_keeps_full_n_analogs_not_capped_len() -> None:
|
||||
"""Control/regression guard for the max() choice: a healthy sample where
|
||||
n_analogs (15) EXCEEDS the capped display list (10, AggregatedEstimate's
|
||||
own top-10 cap) must keep printing the full honest count (15 шт.), NOT
|
||||
silently understate it to len(analogs) (10 шт.)."""
|
||||
analogs = [_analog(address=f"ул. Тест, {i}") for i in range(10)]
|
||||
est = _estimate(n_analogs=15, analogs=analogs, sources_used=["avito"])
|
||||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||||
assert "15 шт." in html
|
||||
assert "10 шт." not in html
|
||||
|
||||
|
||||
def test_listings_page_relaxations_warning_shown_with_labels() -> None:
|
||||
"""relaxations non-empty → warning block present, names the labels, and
|
||||
reliability != 'ok' — mirrors what the web LowConfidenceBanner already
|
||||
shows (see AggregatedEstimate docstring)."""
|
||||
est = _estimate(relaxations=["учтены студии", "радиус расширен до 3000 м"], reliability="low")
|
||||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||||
assert "Точность оценки снижена." in html
|
||||
assert "учтены студии" in html
|
||||
assert "радиус расширен до 3000 м" in html
|
||||
|
||||
|
||||
def test_listings_page_reliability_downgraded_no_relaxations_fallback_text() -> None:
|
||||
"""reliability != 'ok' but relaxations is empty (cascade couldn't grow a
|
||||
thin sample, estimator.py #oblast-F) → warning block still shown, with a
|
||||
fallback sentence (not an empty label list)."""
|
||||
est = _estimate(n_analogs=2, reliability="very_low", relaxations=[])
|
||||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||||
assert "Точность оценки снижена." in html
|
||||
assert "небольшой выборке" in html
|
||||
|
||||
|
||||
def test_listings_page_no_warning_block_when_ok_and_no_relaxations() -> None:
|
||||
"""Control: the common/unrelaxed case (reliability='ok' default, no
|
||||
relaxations) — no warning block at all, byte-identical to the report
|
||||
before these fields existed."""
|
||||
est = _estimate()
|
||||
assert est.reliability == "ok"
|
||||
assert est.relaxations == []
|
||||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||||
assert "Точность оценки снижена." not in html
|
||||
|
||||
|
||||
def test_build_deals_page_none_year_built_no_crash() -> None:
|
||||
snap = dict(_SNAPSHOT)
|
||||
snap["year_built"] = None
|
||||
|
|
|
|||
|
|
@ -107,6 +107,64 @@ def test_extract_street_name_parametrized(address: str | None, expected: str | N
|
|||
assert extract_street_name(address) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address,expected",
|
||||
[
|
||||
# Live-prod repro (2026-08-10): DaData format — abbreviations WITHOUT a
|
||||
# trailing dot ("ул Академика Парина", not "ул. ..."), plus a leading
|
||||
# postal index + admin parts ("620105, Свердловская обл, г
|
||||
# Екатеринбург, Академический р-н, ..."). Old `_STREET_KW_RE` required
|
||||
# the dot → keyword never matched on ANY DaData address → street-deals
|
||||
# / sales-vs-listings endpoints silently returned empty for every
|
||||
# DaData-geocoded request, not just this one.
|
||||
(
|
||||
"620105, Свердловская обл, г Екатеринбург, Академический р-н, "
|
||||
"ул Академика Парина, д 46/5",
|
||||
"Академика Парина",
|
||||
),
|
||||
# Same address, WITH the dot — must give the identical result (dot
|
||||
# optional, not dot-forbidden).
|
||||
(
|
||||
"620105, Свердловская обл, г Екатеринбург, Академический р-н, "
|
||||
"ул. Академика Парина, д 46/5",
|
||||
"Академика Парина",
|
||||
),
|
||||
# Same address, full word "улица" — the alternation-order/backtracking
|
||||
# concern: "ул\\.?" must NOT eat the "ул" prefix of "улица" and leave
|
||||
# "ица ..." behind.
|
||||
(
|
||||
"620105, Свердловская обл, г Екатеринбург, Академический р-н, "
|
||||
"улица Академика Парина, д 46/5",
|
||||
"Академика Парина",
|
||||
),
|
||||
# Without the leading postal index — same admin prefix otherwise.
|
||||
(
|
||||
"Свердловская обл, г Екатеринбург, Академический р-н, ул Академика Парина, д 46/5",
|
||||
"Академика Парина",
|
||||
),
|
||||
# Bare street+house, no admin prefix at all.
|
||||
("ул Академика Парина, д 46/5", "Академика Парина"),
|
||||
# Other dot-optional abbreviations from _STREET_KW_RE (пр/пер/ш/наб/пл/мкр).
|
||||
("г Екатеринбург, пр Ленина, 5", "Ленина"),
|
||||
("г Екатеринбург, пер Красный, 4", "Красный"),
|
||||
("г Екатеринбург, наб Реки Исеть, 1", "Реки Исеть"),
|
||||
# "ул. X" / "ул X" / "улица X" must all agree (no dot-optional regression).
|
||||
("Екатеринбург, ул. Малышева, 1", "Малышева"),
|
||||
("Екатеринбург, ул Малышева, 1", "Малышева"),
|
||||
("Екатеринбург, улица Малышева, 1", "Малышева"),
|
||||
],
|
||||
)
|
||||
def test_extract_street_name_dadata_no_dot_abbreviations(
|
||||
address: str | None, expected: str | None
|
||||
) -> None:
|
||||
"""#pdf-honesty/street-deals live-prod fix (2026-08-10): DaData addresses
|
||||
use dot-less abbreviations ("ул", "пр", "пер", "ш", "наб", "пл", "мкр")
|
||||
— _STREET_KW_RE must match them exactly like the dotted forms."""
|
||||
from app.services.estimator import extract_street_name
|
||||
|
||||
assert extract_street_name(address) == expected
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import TopNav from "@/components/trade-in/v2/TopNav";
|
|||
import HeroBar from "@/components/trade-in/v2/HeroBar";
|
||||
import ParamsPanel from "@/components/trade-in/v2/ParamsPanel";
|
||||
import ResultPanel from "@/components/trade-in/v2/ResultPanel";
|
||||
import { LowConfidenceBanner } from "@/components/trade-in/v2/LowConfidenceBanner";
|
||||
import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary";
|
||||
import { LeadForm } from "@/components/trade-in/v2/LeadForm";
|
||||
import { Footer } from "@/components/trade-in/v2/Footer";
|
||||
|
|
@ -584,6 +585,18 @@ export default function TradeInV2Page() {
|
|||
// — no hydration drift, same reason the PDF control is gated behind `mounted`.
|
||||
const hasEstimate = mounted && estimate != null && !insufficient;
|
||||
|
||||
// fix (never-block estimate) — reliability/relaxations are optional on the
|
||||
// wire (old/cached estimates predate the backend fields), default to the
|
||||
// "nothing to disclose" values so a stale response never fabricates a
|
||||
// warning. LowConfidenceBanner mounts above the result whenever the sample
|
||||
// was thin (reliability !== "ok") or the backend had to relax the search to
|
||||
// produce a price at all (relaxations.length > 0) — never on insufficient
|
||||
// (no price at all — that stays InsufficientPanel, no banner to layer over).
|
||||
const reliability = estimate?.reliability ?? "ok";
|
||||
const relaxations = estimate?.relaxations ?? [];
|
||||
const showLowConfidenceBanner =
|
||||
!insufficient && (reliability !== "ok" || relaxations.length > 0);
|
||||
|
||||
// ── Mapped presentation data (memoised so nav/drawer toggles don't recompute
|
||||
// geometry). ──────────────────────────────────────────────────────────
|
||||
const report = useMemo(
|
||||
|
|
@ -772,12 +785,31 @@ export default function TradeInV2Page() {
|
|||
/>
|
||||
);
|
||||
} else if (estimate && !insufficient && resultPanelData) {
|
||||
// Banner is a sibling ABOVE ResultPanel, not a change to ResultPanel
|
||||
// itself — the wrapper only replaces the direct grid child; ResultPanel's
|
||||
// own markup/props are untouched from before this fix.
|
||||
middleContent = (
|
||||
<ResultPanel
|
||||
data={resultPanelData}
|
||||
onNavigate={setNav}
|
||||
regionRef={resultRegionRef}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{showLowConfidenceBanner && (
|
||||
<LowConfidenceBanner
|
||||
nAnalogs={estimate.n_analogs}
|
||||
reliability={reliability}
|
||||
relaxations={relaxations}
|
||||
/>
|
||||
)}
|
||||
<ResultPanel
|
||||
data={resultPanelData}
|
||||
onNavigate={setNav}
|
||||
regionRef={resultRegionRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (estimate && insufficient) {
|
||||
middleContent = <InsufficientPanel />;
|
||||
|
|
|
|||
|
|
@ -185,6 +185,15 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
const [enrichRepairState, setEnrichRepairState] = useState<string>("");
|
||||
// Фото первого аналога с картинкой — вместо пустого серого плейсхолдера.
|
||||
const heroPhoto = estimate.analogs.find((a) => a.photo_url)?.photo_url ?? null;
|
||||
// fix (v1 stale-tail) — n_analogs=0 больше не значит «аналогов нет»: бэкенд
|
||||
// может посчитать headline по зарегистрированным сделкам ДКП, но всё равно
|
||||
// отдать тонкую выборку объявлений в estimate.analogs (её же показывает
|
||||
// ListingsCard ниже на этой странице) — «0 аналогов» рядом с видимыми
|
||||
// карточками было бы прямым противоречием. Тон — как у v2 LowConfidenceBanner.
|
||||
const analogsCaption =
|
||||
estimate.n_analogs > 0 || estimate.analogs.length === 0
|
||||
? `${estimate.n_analogs} аналогов`
|
||||
: "оценка построена по зарегистрированным сделкам";
|
||||
// Расчёт ширины для price bar (50% = середина): медиана внутри min/max
|
||||
const span = hi - lo;
|
||||
const medianPctRaw = span > 0 ? ((m - lo) / span) * 100 : 50;
|
||||
|
|
@ -292,7 +301,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
{heroPhoto
|
||||
? `фото аналога${estimate.sources_used[0] ? ` · ${sourceLabel(estimate.sources_used[0])}` : ""}`
|
||||
: estimate.sources_used.length > 0
|
||||
? `${sourceLabel(estimate.sources_used[0])} · ${estimate.n_analogs} аналогов`
|
||||
? `${sourceLabel(estimate.sources_used[0])} · ${analogsCaption}`
|
||||
: "Нет фото"}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -118,7 +118,12 @@ export function ListingsCard({ estimate, estimateId }: Props) {
|
|||
<div className="count-cell">
|
||||
<div className="label">Объявлений по аналогам</div>
|
||||
<div className="value">
|
||||
<span data-tnum>{estimate.n_analogs}</span>
|
||||
{/* fix (v1 stale-tail) — n_analogs=0 больше не значит "объявлений
|
||||
нет": бэкенд может посчитать headline по сделкам ДКП, но
|
||||
всё равно отдать тонкую выборку объявлений в analogs (тот же
|
||||
массив рендерит таблица ниже, см. `lots`). Показываем реальную
|
||||
отображаемую популяцию, а не сырой n_analogs, когда он 0. */}
|
||||
<span data-tnum>{estimate.n_analogs > 0 ? estimate.n_analogs : lots.length}</span>
|
||||
<span className="unit">шт</span>
|
||||
</div>
|
||||
<div className="sub">из {estimate.sources_used.length} источников</div>
|
||||
|
|
@ -271,8 +276,21 @@ export function ListingsCard({ estimate, estimateId }: Props) {
|
|||
|
||||
<div className="table-foot">
|
||||
<span>
|
||||
Показано <b style={{ color: "var(--fg)" }}>{lots.length}</b> из {estimate.n_analogs}{" "}
|
||||
объявлений · отсортировано по расстоянию
|
||||
{/* fix (v1 stale-tail) — see count-strip comment above: n_analogs=0
|
||||
with a non-empty lots[] is the deals-fallback branch, not "0
|
||||
analogs shown". Drop the false "из 0" denominator and disclose
|
||||
the deals basis instead (same tone as v2 LowConfidenceBanner). */}
|
||||
{estimate.n_analogs > 0 ? (
|
||||
<>
|
||||
Показано <b style={{ color: "var(--fg)" }}>{lots.length}</b> из{" "}
|
||||
{estimate.n_analogs} объявлений · отсортировано по расстоянию
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Показано <b style={{ color: "var(--fg)" }}>{lots.length}</b> объявлений ·
|
||||
оценка построена по зарегистрированным сделкам · отсортировано по расстоянию
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
"use client";
|
||||
|
||||
// LowConfidenceBanner — fix (never-block estimate). Renders ABOVE the result
|
||||
// block (ResultPanel) whenever the backend flags the analog sample as thin
|
||||
// (`reliability !== "ok"`) or had to relax the search just to produce a
|
||||
// price at all (`relaxations.length > 0`). It never blocks the estimate —
|
||||
// v2/page.tsx's `insufficient` gate (InsufficientPanel) still fires only
|
||||
// when there is truly no price (`insufficient_data`, median_price_rub <= 0).
|
||||
//
|
||||
// Root incident this fixes: a 23.1 m² studio in Екатеринбург got
|
||||
// median_price_rub=0 purely because studios were being folded into 1-room
|
||||
// analogs (see ParamsPanel's initRoomsLabel/rooms=0 fix) and the UI walled
|
||||
// the whole estimate behind "недостаточно данных". Product call: always show
|
||||
// the number, with an honest, visible caveat instead of a hard block.
|
||||
|
||||
import { tokens } from "./tokens";
|
||||
import { pluralRu } from "./mappers";
|
||||
import type { ReliabilityLevel } from "@/types/trade-in";
|
||||
|
||||
interface LowConfidenceBannerProps {
|
||||
// Caller (v2/page.tsx) resolves the optional backend fields to concrete
|
||||
// values (reliability ?? "ok", relaxations ?? []) and decides whether to
|
||||
// mount this component at all — kept required here so an omitted prop is a
|
||||
// TS error, not a silent fallback (same contract as ResultPanel/ObjectSummary).
|
||||
nAnalogs: number;
|
||||
reliability: ReliabilityLevel;
|
||||
relaxations: string[];
|
||||
}
|
||||
|
||||
// Same one-off "danger" tint pairing already used elsewhere in v2
|
||||
// (AnalyticsView's sell-time tier tiles: rgba fill + soft hex border, no
|
||||
// direct token equivalent) — kept identical here instead of inventing a new
|
||||
// hex; the actual label colour is the real tokens.danger semantic token.
|
||||
const bannerBg = "rgba(214,90,90,.08)";
|
||||
const bannerBorder = "1px solid #e6c3c3";
|
||||
|
||||
export function LowConfidenceBanner({
|
||||
nAnalogs,
|
||||
reliability,
|
||||
relaxations,
|
||||
}: LowConfidenceBannerProps) {
|
||||
const title =
|
||||
reliability === "very_low"
|
||||
? "Данные ограничены — оценка ориентировочная"
|
||||
: "Мало аналогов — точность снижена";
|
||||
|
||||
// The backend's deals-cession label duplicates the prose we already render
|
||||
// in the nAnalogs === 0 branch — drop it there so the caveat is stated once.
|
||||
const visibleRelaxations =
|
||||
nAnalogs > 0
|
||||
? relaxations
|
||||
: relaxations.filter((r) => !r.startsWith("оценка по сделкам"));
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
background: bannerBg,
|
||||
border: bannerBorder,
|
||||
borderRadius: 8,
|
||||
padding: "12px 16px",
|
||||
fontFamily: tokens.font.sans,
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.4,
|
||||
color: tokens.danger,
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{/* Body text stays on the high-contrast ink token (not the danger
|
||||
token) — tokens.danger (#cd6868) over this pale tint fails AA for
|
||||
body copy, ink2 is the codebase's established accessible-contrast
|
||||
choice (see tokens.ts comment block). */}
|
||||
<div style={{ fontSize: 12, lineHeight: 1.5, color: tokens.ink2 }}>
|
||||
{/* n_analogs === 0 with a price on screen is NOT an empty result: it is
|
||||
the deals-corridor headline (backend cedes the headline to ДКП when
|
||||
the listings sample is thin). Saying «найдено 0 аналогов» there
|
||||
would contradict both the shown price and the listing cards below,
|
||||
which are still rendered from the thin sample. */}
|
||||
{nAnalogs > 0 ? (
|
||||
<>
|
||||
Найдено {nAnalogs}{" "}
|
||||
{pluralRu(nAnalogs, ["аналог", "аналога", "аналогов"])} — оценка
|
||||
может быть неточной.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Оценка построена по зарегистрированным сделкам — подходящих
|
||||
объявлений рядом почти нет.
|
||||
</>
|
||||
)}
|
||||
{visibleRelaxations.length > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
Для расчёта расширили параметры поиска:{" "}
|
||||
{visibleRelaxations.join(", ")}.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -580,11 +580,15 @@ interface ParamsPanelProps {
|
|||
searchRadiusM?: number | null;
|
||||
}
|
||||
|
||||
// rooms number -> dropdown label. The design has no «Студия» option, so studio
|
||||
// (0) and 1-room both map to "1"; >=5 collapses to "5+". null -> design default.
|
||||
// rooms number -> dropdown label. fix (never-block estimate) — «Студия»
|
||||
// (rooms=0) is its own option, no longer collapsed into "1" (that collapse
|
||||
// sent rooms:1 on submit for real studios — root cause of a prod incident
|
||||
// where a 23.1 m² studio got a false "недостаточно данных"). >=5 still
|
||||
// collapses to "5+". null -> design default.
|
||||
function initRoomsLabel(rooms: number | null | undefined): string {
|
||||
if (rooms == null) return "2";
|
||||
if (rooms >= 5) return "5+";
|
||||
if (rooms === 0) return "Студия";
|
||||
if (rooms <= 1) return "1";
|
||||
return String(rooms);
|
||||
}
|
||||
|
|
@ -919,7 +923,7 @@ export default function ParamsPanel({
|
|||
onSubmit?.({
|
||||
address: trimmedAddress,
|
||||
area_m2: areaNum,
|
||||
rooms: rooms === "5+" ? 5 : Number(rooms),
|
||||
rooms: rooms === "5+" ? 5 : rooms === "Студия" ? 0 : Number(rooms),
|
||||
floor: floor.trim() ? Number(floor) : null,
|
||||
total_floors: totalFloors.trim() ? Number(totalFloors) : null,
|
||||
year_built: year.trim() ? Number(year) : undefined,
|
||||
|
|
|
|||
|
|
@ -419,10 +419,22 @@ export function SourcesMap({ estimate }: Props) {
|
|||
{/* Fix #1 — analogPoints is the top-10 display sample (only what the
|
||||
backend returns coords for); estimate.n_analogs is the true total
|
||||
used in the calc. "N из M" mirrors the deals-table "Показано N из
|
||||
M" pattern so this never contradicts the market KPI band above. */}
|
||||
M" pattern so this never contradicts the market KPI band above.
|
||||
fix (v2 stale-tail) — n_analogs=0 with analogPoints non-empty is
|
||||
the deals-fallback branch (headline built from ДКП сделки, thin
|
||||
listing sample still plotted) — "N из 0" would read as a lie.
|
||||
Drop the denominator and disclose the basis instead; kept short
|
||||
(map caption, not a paragraph) but same tone as elsewhere in
|
||||
this fix (HeroSummary/ListingsCard/LowConfidenceBanner). */}
|
||||
Объявлений: <b style={{ color: tokens.ink2 }}>{analogPoints.length}</b>
|
||||
{" из "}
|
||||
{estimate.n_analogs}
|
||||
{estimate.n_analogs > 0 || analogPoints.length === 0 ? (
|
||||
<>
|
||||
{" из "}
|
||||
{estimate.n_analogs}
|
||||
</>
|
||||
) : (
|
||||
" · по сделкам ДКП"
|
||||
)}
|
||||
{dealPoints.length > 0 && (
|
||||
<>
|
||||
{" · "}сделок: <b style={{ color: tokens.ink2 }}>{dealPoints.length}</b>
|
||||
|
|
|
|||
|
|
@ -2105,7 +2105,12 @@ export function mapSources(
|
|||
|
||||
const marketAds: MarketAds = {
|
||||
kpi: {
|
||||
count: e != null ? String(e.n_analogs) : "—",
|
||||
// fix (v2 stale-tail) — n_analogs=0 with adRows non-empty is the
|
||||
// deals-fallback branch (headline built from ДКП сделки, thin listing
|
||||
// sample still shown in the table right below this KPI tile) — a bare
|
||||
// "0" here would directly contradict visible rows. Fall back to the
|
||||
// actual displayed population (same fix as ListingsCard's count-strip).
|
||||
count: e != null ? String(e.n_analogs > 0 ? e.n_analogs : e.analogs.length) : "—",
|
||||
median: e != null ? fmtMln(e.median_price_rub) : "—",
|
||||
ppm:
|
||||
e != null && Number.isFinite(e.median_price_per_m2)
|
||||
|
|
@ -2156,9 +2161,15 @@ export function mapSources(
|
|||
"возможных выбросов исключено",
|
||||
])} из расчёта разброса`
|
||||
: "";
|
||||
// fix (v2 stale-tail) — n_analogs=0 with a non-empty adRows[] is the
|
||||
// deals-fallback branch, not "0 analogs shown" (see marketAds.kpi.count
|
||||
// above). Drop the false "из 0" denominator and disclose the deals basis
|
||||
// instead, same tone as HeroSummary/ListingsCard/LowConfidenceBanner.
|
||||
const adsFootnote =
|
||||
e != null
|
||||
? `Показано ${adRows.length} из ${e.n_analogs} объявлений${outlierNote}`
|
||||
? e.n_analogs > 0
|
||||
? `Показано ${adRows.length} из ${e.n_analogs} объявлений${outlierNote}`
|
||||
: `Показано ${adRows.length} объявлений · оценка построена по зарегистрированным сделкам${outlierNote}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@ import type { DropdownOptions } from "./types";
|
|||
// ---- INPUTS / DROPDOWNS ---------------------------------------------------
|
||||
|
||||
export const dropdownOptions: DropdownOptions = {
|
||||
rooms: ["1", "2", "3", "4", "5+"],
|
||||
// fix (never-block estimate) — «Студия» первым пунктом, маппится на rooms=0
|
||||
// (см. initRoomsLabel / handleSubmit в ParamsPanel.tsx). Раньше студии
|
||||
// схлопывались в «1» → на сабмите уходил rooms:1 вместо rooms:0 — корневая
|
||||
// причина прод-инцидента с 23.1 м² студией в ЕКБ (median=0 → ложная
|
||||
// «недостаточно данных»).
|
||||
rooms: ["Студия", "1", "2", "3", "4", "5+"],
|
||||
houseType: [
|
||||
"Не указано",
|
||||
"Панельный",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,13 @@ export function asRepairState(v: string | null | undefined): RepairState | undef
|
|||
|
||||
export type ConfidenceLevel = "low" | "medium" | "high";
|
||||
|
||||
// fix (never-block estimate) — сигнал бэкенда о качестве выборки помимо
|
||||
// insufficient_data (которое теперь true ТОЛЬКО когда цены реально нет,
|
||||
// median_price_rub <= 0). "low"/"very_low" → UI показывает LowConfidenceBanner
|
||||
// НАД оценкой вместо блокировки. Optional: старый бэкенд/кешированные оценки
|
||||
// поле не отдают → UI фолбэк на "ok" (см. LowConfidenceBanner.tsx).
|
||||
export type ReliabilityLevel = "ok" | "low" | "very_low";
|
||||
|
||||
// Точность гео-привязки адреса (из DaData qc_geo): house=0, street=1, approximate≥2.
|
||||
export type AddressPrecision = "house" | "street" | "approximate";
|
||||
|
||||
|
|
@ -168,6 +175,14 @@ export interface AggregatedEstimate {
|
|||
confidence_explanation: string | null;
|
||||
n_analogs: number;
|
||||
insufficient_data: boolean; // backend #697: true когда median_price_rub <= 0 (нет данных)
|
||||
// fix (never-block estimate) — оценка теперь показывается всегда, пока цена
|
||||
// посчитана (insufficient_data=false), даже при n_analogs=0 (фолбэк по
|
||||
// сделкам ДКП). relaxations/reliability — как именно бэкенд ослабил поиск,
|
||||
// чтобы всё-таки посчитать цену; UI рендерит их в LowConfidenceBanner НАД
|
||||
// оценкой вместо блокирующей панели «недостаточно данных». Оба optional +
|
||||
// с дефолтами при чтении ([] / "ok") — старый бэкенд их не отдаёт.
|
||||
relaxations?: string[]; // готовые RU-подписи, напр. ["учтены студии", "радиус расширен до 3000 м"]
|
||||
reliability?: ReliabilityLevel;
|
||||
period_months: number; // 24
|
||||
analogs: AnalogLot[]; // top 5-10
|
||||
actual_deals: AnalogLot[]; // last 12 mo
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue