fix(tradein/estimate): не блокировать оценку — расширять подбор и честно предупреждать (#2823)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m30s
Deploy Trade-In / test (push) Successful in 3m14s
Deploy Trade-In / build-backend (push) Successful in 7m24s
Deploy Trade-In / deploy (push) Successful in 1m25s

This commit is contained in:
lekss361 2026-08-10 15:42:22 +00:00
parent 74344f7b8b
commit 9d9457f67d
15 changed files with 1117 additions and 179 deletions

View file

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

View file

@ -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",
)

View file

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

View file

@ -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
(studio1-комн) 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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>
);
}

View file

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

View file

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

View file

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

View file

@ -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: [
"Не указано",
"Панельный",

View file

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