gendesign/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py
lekss361 9d9457f67d
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
fix(tradein/estimate): не блокировать оценку — расширять подбор и честно предупреждать (#2823)
2026-08-10 15:42:22 +00:00

549 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""#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).
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: 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: 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
import os
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import estimator
from app.services.estimator import HEADLINE_LISTINGS_MIN_N, _price_from_inputs
from app.services.geocoder import GeocodeResult
# ─────────────────────────────────────────────────────────────────────────────
# Layer 1 — `_price_from_inputs` direct unit tests
# ─────────────────────────────────────────────────────────────────────────────
def _geo() -> GeocodeResult:
return GeocodeResult(
lat=59.604,
lon=60.577,
full_address="Свердловская обл., Серов, ул. Ленина, 5",
provider="nominatim",
)
def _lot(ppm2: float, address: str = "ул. Ленина, 5", source: str = "avito") -> dict[str, Any]:
return {"price_per_m2": ppm2, "address": address, "source": source}
def _lots(prices: list[float]) -> list[dict[str, Any]]:
return [_lot(p, address=f"ул. Ленина, {i + 5}") for i, p in enumerate(prices)]
def _call(
*,
listings: list[dict[str, Any]],
area_m2: float = 45.0,
rooms: int | None = 2,
dkp_raw: dict[str, Any] | None = None,
anchor_comps: list[dict[str, Any]] | None = None,
anchor_tier_fetched: str | None = None,
) -> estimator.PricingResult:
def ratio_resolver(_appm2: float | None) -> tuple[float | None, str | None]:
return None, None
return _price_from_inputs(
listings=listings,
area_m2=area_m2,
rooms=rooms,
repair_state=None,
floor=5,
total_floors=9,
target_year=None,
analog_tier="W",
fallback_used=False,
area_widened=False,
anchor_comps=anchor_comps or [],
anchor_tier_fetched=anchor_tier_fetched,
dkp_raw=dkp_raw,
imv_anchor=None,
imv_eval=None,
yandex_val_present=False,
cian_val_present=False,
ratio_resolver=ratio_resolver,
quarter_index_lookup=lambda q: None,
quarter_indexes_lookup=lambda qs: {},
target_house_cadnum=None,
dadata_coarse=False,
geo=_geo(),
dadata_qc_geo=None,
)
def test_threshold_is_five_not_lower() -> None:
"""The chosen sufficiency floor — see estimator.py module docstring (#oblast-E)
for the data-driven justification (n=3 live-repro'd 36%, n=5 matches the
existing MIN_ANALOGS_TIER_0 "enough to trust" convention)."""
assert HEADLINE_LISTINGS_MIN_N == 5
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 == 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:
"""n=5 (== threshold) → the real listings median is trusted as the headline."""
pr = _call(listings=_lots([200_000.0, 205_000.0, 210_000.0, 215_000.0, 220_000.0]))
assert pr.median_ppm2 == 210_000.0
assert pr.n_analogs == 5
assert pr.median_price == round(210_000.0 * 45.0)
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 == 200_000.0
assert pr.n_analogs == 1
assert pr.confidence == "low"
def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None:
"""#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,
"median_ppm2": 65_957,
"high_ppm2": 89_000,
"period_months": 12,
}
pr = _call(
listings=_lots([42_391.0, 26_818.0, 75_058.0]),
dkp_raw=dkp_raw,
)
assert pr.median_ppm2 == 65_957.0, (
f"headline={pr.median_ppm2} must equal the ДКП corridor median, not the "
"noisy 3-listing median (42 391 area)"
)
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_thin_deals_also_uses_real_listings_median() -> None:
"""n=3 listings (thin) + a ДКП corridor that is ITSELF too thin
(< 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,
"median_ppm2": 65_957,
"high_ppm2": 80_000,
"period_months": 12,
}
pr = _call(listings=_lots([42_391.0, 26_818.0, 75_058.0]), dkp_raw=dkp_raw)
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_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()
assert "точность снижена" in pr.explanation.lower()
assert "минимум для оценки по рынку" not in pr.explanation.lower()
assert "не найдено аналогов" not in pr.explanation.lower()
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": 54,
"low_ppm2": 44_000,
"median_ppm2": 65_957,
"high_ppm2": 89_000,
"period_months": 12,
}
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 "рядом нет актуальных объявлений" in pr.explanation.lower()
assert "сделкам росреестра" in pr.explanation.lower()
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 == 3
assert len(pr.listings_clean) == 3
assert pr.listings_headline_thin_n == 3
def test_sufficient_sample_listings_headline_thin_n_is_zero() -> None:
"""Sanity/control: once n reaches the threshold, the thin-marker stays 0 —
downstream (estimate_quality) must not treat a healthy sample as thin."""
pr = _call(listings=_lots([200_000.0, 205_000.0, 210_000.0, 215_000.0, 220_000.0]))
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)
# ─────────────────────────────────────────────────────────────────────────────
def _make_listing(*, price_per_m2: float, address: str, area_m2: float = 45.0) -> dict[str, Any]:
return {
"source": "avito",
"source_url": f"https://avito.ru/offer/{address}",
"address": address,
"lat": 59.604,
"lon": 60.577,
"rooms": 2,
"area_m2": area_m2,
"floor": 5,
"total_floors": 9,
"price_rub": price_per_m2 * area_m2,
"price_per_m2": price_per_m2,
"listing_date": datetime(2026, 5, 1),
"days_on_market": 10,
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": 150.0,
"relevance_score": 0.1,
}
def _serov_payload() -> Any:
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="Серов, ул. Ленина, 5",
area_m2=45.0,
rooms=2,
floor=5,
total_floors=9,
city_hint="Серов",
)
def _run_estimate(
*,
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 = 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.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", **fetch_analogs_kwargs),
patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch(
"app.services.estimator._get_or_fetch_imv_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
):
return await estimate_quality(payload, db)
return anyio.run(_run)
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 == 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_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 == 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) —
#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"),
_make_listing(price_per_m2=75_058.0, address="ул. Льва Толстого, 34"),
]
dkp_raw = {
"count": 54,
"low_ppm2": 44_000,
"median_ppm2": 65_957,
"high_ppm2": 89_000,
"period_months": 12,
}
est = _run_estimate(analogs=analogs, dkp_raw=dkp_raw)
assert est.median_price_per_m2 == 65_957
assert est.insufficient_data is False
assert est.n_analogs == 0
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, no relaxations needed."""
analogs = [
_make_listing(price_per_m2=195_000.0, address="ул. Ленина, 5"),
_make_listing(price_per_m2=205_000.0, address="ул. Ленина, 7"),
_make_listing(price_per_m2=210_000.0, address="ул. Ленина, 9"),
_make_listing(price_per_m2=215_000.0, address="ул. Ленина, 11"),
_make_listing(price_per_m2=225_000.0, address="ул. Ленина, 13"),
]
est = _run_estimate(analogs=analogs, dkp_raw=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