fix(tradein/estimate): уверенность не может противоречить надёжности выборки (#2825)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m6s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 2m1s
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m6s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 2m1s
This commit is contained in:
parent
8423af5dd5
commit
0b84f0b661
2 changed files with 339 additions and 3 deletions
|
|
@ -4245,10 +4245,42 @@ async def estimate_quality(
|
|||
reliability = "low"
|
||||
elif relaxations and reliability == "ok":
|
||||
reliability = "low"
|
||||
|
||||
# #conf-cap (confidence↔reliability consistency, live-prod smoke 2026-08-11):
|
||||
# `confidence` (unique-address/IQR metric, _compute_confidence above) and
|
||||
# `reliability` (n_analogs-bucket + #oblast-F relaxation-cascade metric,
|
||||
# just computed) used to be fully independent — the cascade's room-
|
||||
# adjacency/freshness/novostroyki steps never set `fallback_used`, so
|
||||
# _compute_confidence stayed blind to them and kept scoring a wide,
|
||||
# tight-IQR (post-relaxation) sample as "high", while `reliability`
|
||||
# honestly read "low" from the SAME relaxations. Live prod repro: n=39,
|
||||
# confidence='high', reliability='low', 3 relaxations applied — client
|
||||
# saw a "высокая уверенность" badge next to a "точность снижена" banner
|
||||
# simultaneously. Fix applied at ONE point, after BOTH values are final
|
||||
# (NOT spread across the cascade steps above) — see
|
||||
# _cap_confidence_by_reliability docstring for the exact rule.
|
||||
original_confidence = confidence
|
||||
confidence = _cap_confidence_by_reliability(confidence, reliability)
|
||||
confidence_downgraded_by_reliability = confidence != original_confidence
|
||||
|
||||
if relaxations:
|
||||
explanation = (explanation or "") + (
|
||||
" Применены послабления подбора: " + ", ".join(relaxations) + "."
|
||||
)
|
||||
relaxations_detail = "Применены послабления подбора: " + ", ".join(relaxations) + "."
|
||||
if confidence_downgraded_by_reliability:
|
||||
# #conf-cap: explanation must LEAD with the accuracy-reduced summary
|
||||
# when confidence was actually pulled down by the cap above — the
|
||||
# old ordering opened with "Найдено 39 аналогов ... разброс ±6%"
|
||||
# (reads as a confident, unqualified headline) and only tacked the
|
||||
# relaxations sentence on at the very end, which itself contradicted
|
||||
# the (now-capped) confidence badge just as much as the raw scores did.
|
||||
explanation = (
|
||||
"Оценка построена с расширенными параметрами подбора — точность "
|
||||
"снижена. " + (explanation or "") + " " + relaxations_detail
|
||||
)
|
||||
else:
|
||||
# No visible confidence change (e.g. it was already 'low'/'medium'
|
||||
# pre-cap, or reliability=='ok' downgraded nothing) — keep the
|
||||
# existing tail-append shape, unaffected by #conf-cap.
|
||||
explanation = (explanation or "") + " " + relaxations_detail
|
||||
|
||||
# 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
|
||||
# Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
|
||||
|
|
@ -6587,6 +6619,47 @@ def _downgrade_confidence(confidence: str) -> str:
|
|||
return {"high": "medium", "medium": "low", "low": "low"}.get(confidence, confidence)
|
||||
|
||||
|
||||
_CONFIDENCE_ORDER: dict[str, int] = {"low": 0, "medium": 1, "high": 2}
|
||||
|
||||
|
||||
def _cap_confidence_by_reliability(confidence: str, reliability: str) -> str:
|
||||
"""#conf-cap (confidence↔reliability consistency, live-prod smoke 2026-08-11).
|
||||
|
||||
`confidence` (this function's sibling `_compute_confidence` — unique-
|
||||
address/IQR metric) and `reliability` (estimate_quality — n_analogs-bucket
|
||||
+ #oblast-F relaxation-cascade metric) used to be computed fully
|
||||
independently. The #oblast-F cascade's room-adjacency/freshness/
|
||||
novostroyki steps never set `fallback_used`, so `_compute_confidence`
|
||||
stayed blind to them and could keep scoring a post-relaxation sample
|
||||
(wide, tight-IQR) as "high" while `reliability` — which DOES see the
|
||||
relaxations — honestly read "low". Live prod repro: n_analogs=39,
|
||||
confidence='high', reliability='low', relaxations=[3 items] — a client
|
||||
could see a "высокая уверенность" badge next to a "точность снижена"
|
||||
banner on the SAME estimate.
|
||||
|
||||
Rule (applied ONCE, by the caller, after both values are final — NOT
|
||||
spread across the relaxation cascade):
|
||||
reliability == 'very_low' → confidence forced to 'low'.
|
||||
reliability == 'low' → confidence capped at 'medium' (high→medium;
|
||||
medium/low pass through unchanged).
|
||||
reliability == 'ok' → confidence untouched (unaffected — this is
|
||||
the common, unrelaxed case; byte-identical
|
||||
to pre-#conf-cap behaviour).
|
||||
|
||||
Never RAISES confidence — only caps/lowers it. Unknown confidence/
|
||||
reliability values pass through unchanged (defensive, mirrors
|
||||
_downgrade_confidence above).
|
||||
"""
|
||||
if reliability == "very_low":
|
||||
return "low"
|
||||
if reliability == "low":
|
||||
current = _CONFIDENCE_ORDER.get(confidence)
|
||||
cap = _CONFIDENCE_ORDER["medium"]
|
||||
if current is not None and current > cap:
|
||||
return "medium"
|
||||
return confidence
|
||||
|
||||
|
||||
def _compute_confidence(
|
||||
n_analogs: int,
|
||||
median_ppm2: float,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,263 @@
|
|||
"""#2827 — confidence↔reliability consistency (live-prod smoke, 2026-08-11).
|
||||
|
||||
`confidence` (`_compute_confidence` — unique-address/IQR metric) and
|
||||
`reliability` (`estimate_quality` — n_analogs-bucket + #oblast-F relaxation-
|
||||
cascade metric, see #2823) were computed fully independently. The cascade's
|
||||
room-adjacency/freshness/novostroyki steps never set `fallback_used`, so
|
||||
`_compute_confidence` stayed blind to them and could keep scoring a wide,
|
||||
tight-IQR (post-relaxation) sample as "high", while `reliability` — which DOES
|
||||
see the relaxations — honestly read "low".
|
||||
|
||||
Live prod repro (customer address, rooms=1, 23.1 m², radius=2000):
|
||||
n_analogs: 39
|
||||
confidence: high
|
||||
reliability: low
|
||||
relaxations: ['снят фильтр по году постройки', 'учтены студии', 'площадь ±25%']
|
||||
|
||||
A client could see a "высокая уверенность" badge next to a "точность снижена"
|
||||
banner on the SAME estimate — exactly the class of contradiction cleaned up
|
||||
from the PDF/counters earlier in this cycle (#2824-adjacent work), now leaking
|
||||
through the two headline confidence signals themselves.
|
||||
|
||||
Fix: `_cap_confidence_by_reliability` (estimator.py), applied ONCE in
|
||||
`estimate_quality` right after both `confidence` and `reliability` are final
|
||||
— NOT spread across the #oblast-F cascade steps. Rule:
|
||||
reliability == 'very_low' → confidence forced to 'low'
|
||||
reliability == 'low' → confidence capped at 'medium'
|
||||
reliability == 'ok' → confidence untouched (common case, unaffected)
|
||||
|
||||
Two layers:
|
||||
1. `_cap_confidence_by_reliability` direct unit tests — the 3 rules in
|
||||
isolation, no DB/estimate_quality overhead.
|
||||
2. `estimate_quality` integration tests — the prod repro shape (confidence
|
||||
downgraded + confidence_explanation restructured to LEAD with the
|
||||
accuracy-reduced summary) and the byte-identical-when-unrelaxed control.
|
||||
"""
|
||||
|
||||
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.estimator import _cap_confidence_by_reliability
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Layer 1 — `_cap_confidence_by_reliability` direct unit tests
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_very_low_reliability_forces_confidence_low() -> None:
|
||||
"""reliability == 'very_low' → confidence forced to 'low', regardless of
|
||||
what _compute_confidence originally scored."""
|
||||
for original in ("high", "medium", "low"):
|
||||
assert (
|
||||
_cap_confidence_by_reliability(original, "very_low") == "low"
|
||||
), f"original={original!r} must be forced to 'low' under very_low reliability"
|
||||
|
||||
|
||||
def test_low_reliability_caps_confidence_at_medium() -> None:
|
||||
"""reliability == 'low' → confidence capped at 'medium': 'high' is pulled
|
||||
down to 'medium'; 'medium'/'low' pass through unchanged (already <= cap)."""
|
||||
assert _cap_confidence_by_reliability("high", "low") == "medium"
|
||||
assert _cap_confidence_by_reliability("medium", "low") == "medium"
|
||||
assert _cap_confidence_by_reliability("low", "low") == "low"
|
||||
|
||||
|
||||
def test_ok_reliability_leaves_confidence_untouched() -> None:
|
||||
"""reliability == 'ok' — the common, unrelaxed case — must NOT change
|
||||
confidence at all (byte-identical to pre-#2827 behaviour)."""
|
||||
for original in ("high", "medium", "low"):
|
||||
assert _cap_confidence_by_reliability(original, "ok") == original
|
||||
|
||||
|
||||
def test_cap_never_raises_confidence() -> None:
|
||||
"""Sanity: the cap only lowers/holds — never raises 'low' to something
|
||||
higher under any reliability value."""
|
||||
for reliability in ("ok", "low", "very_low"):
|
||||
assert _cap_confidence_by_reliability("low", reliability) == "low"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Layer 2 — `estimate_quality` integration tests (full stub-patched I/O path)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _geo() -> GeocodeResult:
|
||||
return GeocodeResult(
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
full_address="Свердловская обл., Екатеринбург, ул. Академика Парина, 46/5",
|
||||
provider="nominatim",
|
||||
)
|
||||
|
||||
|
||||
def _make_listing(*, price_per_m2: float, address: str, area_m2: float = 23.1) -> dict[str, Any]:
|
||||
return {
|
||||
"source": "avito",
|
||||
"source_url": f"https://avito.ru/offer/{address}",
|
||||
"address": address,
|
||||
"lat": 56.838,
|
||||
"lon": 60.595,
|
||||
"rooms": 1,
|
||||
"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 _tight_price_pool(n: int, base_ppm2: float = 195_000.0) -> list[dict[str, Any]]:
|
||||
"""n listings, distinct addresses, tightly clustered price/m² (~±2%) —
|
||||
engineered to clear _compute_confidence's 'high' bar (unique_addr_count>=7
|
||||
AND iqr_pct<0.15) once assembled, exactly like the live prod repro (n=39,
|
||||
confidence='high' pre-fix)."""
|
||||
return [
|
||||
_make_listing(
|
||||
price_per_m2=base_ppm2 + (i - n / 2) * 200,
|
||||
address=f"ул. Академика Парина, {i + 1}",
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
def _payload_with_year() -> Any:
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
|
||||
return TradeInEstimateInput(
|
||||
address="ЕКБ, ул. Академика Парина, 46/5",
|
||||
area_m2=23.1,
|
||||
rooms=1,
|
||||
floor=5,
|
||||
total_floors=9,
|
||||
year_built=2010,
|
||||
city_hint="Екатеринбург",
|
||||
radius_m=2000,
|
||||
)
|
||||
|
||||
|
||||
def _run_estimate(
|
||||
*,
|
||||
payload: Any,
|
||||
fetch_analogs_side_effect: Any,
|
||||
dkp_raw: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
geo = _geo()
|
||||
|
||||
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",
|
||||
side_effect=fetch_analogs_side_effect,
|
||||
),
|
||||
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_prod_repro_high_confidence_low_reliability_gets_capped() -> None:
|
||||
"""Live prod repro shape: cohort (year_built) filter gets dropped by the
|
||||
Tier-0 cascade (empty cohort-call → fallback), landing on a 39-listing,
|
||||
tight-price, 39-unique-address pool — a healthy sample that
|
||||
`_compute_confidence` would score 'high' on its own. Because a relaxation
|
||||
WAS applied to get there, `reliability` reads 'low' — the cap must pull
|
||||
`confidence` down to 'medium' (not leave it at the contradictory 'high'),
|
||||
and `confidence_explanation` must LEAD with the accuracy-reduced summary."""
|
||||
pool = _tight_price_pool(39)
|
||||
|
||||
def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]:
|
||||
# Tier 0 (with cohort filter) → empty, forces the "drop cohort" fallback.
|
||||
if kwargs.get("cohort_year_min") is not None:
|
||||
return [], False, "W"
|
||||
# Every subsequent (no-cohort) call → the full healthy pool.
|
||||
return list(pool), False, "W"
|
||||
|
||||
est = _run_estimate(payload=_payload_with_year(), fetch_analogs_side_effect=_fetch_analogs_stub)
|
||||
|
||||
assert est.n_analogs == 39
|
||||
assert est.reliability == "low"
|
||||
assert "снят фильтр по году постройки" in est.relaxations
|
||||
assert est.confidence == "medium", (
|
||||
f"confidence={est.confidence!r} must be capped to 'medium' under reliability='low' "
|
||||
"(was 'high' pre-#2827 — the exact prod contradiction)"
|
||||
)
|
||||
explanation = est.confidence_explanation
|
||||
assert explanation is not None
|
||||
assert explanation.startswith(
|
||||
"Оценка построена с расширенными параметрами подбора — точность снижена."
|
||||
), f"explanation must LEAD with the accuracy-reduced summary, got: {explanation!r}"
|
||||
assert "Найдено 39 аналогов" in explanation, "original detail must be preserved"
|
||||
assert "Применены послабления подбора: снят фильтр по году постройки." in explanation
|
||||
|
||||
|
||||
def test_e2e_unrelaxed_estimate_is_byte_identical_control() -> None:
|
||||
"""#3 (task spec): no relaxations, reliability=='ok' → confidence and
|
||||
confidence_explanation must be UNCHANGED by #2827 — this is the common
|
||||
path most estimates take, and it must not regress."""
|
||||
pool = _tight_price_pool(39)
|
||||
|
||||
def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]:
|
||||
# No cohort filter this time (payload has no year_built) — Tier 0 is
|
||||
# skipped outright (cohort_range is None), so no relaxation fires.
|
||||
return list(pool), False, "W"
|
||||
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
|
||||
payload = TradeInEstimateInput(
|
||||
address="ЕКБ, ул. Академика Парина, 46/5",
|
||||
area_m2=23.1,
|
||||
rooms=1,
|
||||
floor=5,
|
||||
total_floors=9,
|
||||
city_hint="Екатеринбург",
|
||||
)
|
||||
est = _run_estimate(payload=payload, fetch_analogs_side_effect=_fetch_analogs_stub)
|
||||
|
||||
assert est.n_analogs == 39
|
||||
assert est.relaxations == []
|
||||
assert est.reliability == "ok"
|
||||
assert est.confidence == "high", "unrelaxed healthy sample must keep its real confidence"
|
||||
assert est.confidence_explanation is not None
|
||||
assert not est.confidence_explanation.startswith("Оценка построена с расширенными параметрами")
|
||||
assert "Применены послабления подбора" not in est.confidence_explanation
|
||||
assert est.confidence_explanation.startswith("Найдено 39 аналогов"), (
|
||||
"unrelaxed explanation must keep its original (pre-#2827) leading sentence, got: "
|
||||
f"{est.confidence_explanation!r}"
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue