feat(tradein): бейдж точности адреса qc_geo на карточке (accuracy #1) (#617)
All checks were successful
Deploy Trade-In / build-frontend (push) Successful in 1m42s
Deploy Trade-In / deploy (push) Successful in 36s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-backend (push) Successful in 47s

This commit is contained in:
Light1YT 2026-05-28 10:37:54 +00:00
commit 7c715c9249
7 changed files with 136 additions and 2 deletions

View file

@ -75,7 +75,9 @@ def get_estimate(
analogs, actual_deals, sources_used, data_freshness_minutes, analogs, actual_deals, sources_used, data_freshness_minutes,
expires_at, address, lat, lon, expires_at, address, lat, lon,
area_m2, rooms, floor, total_floors, area_m2, rooms, floor, total_floors,
year_built, house_type, repair_state, has_balcony year_built, house_type, repair_state, has_balcony,
canonical_address, house_cadnum, house_fias_id,
dadata_qc_geo, dadata_metro
FROM trade_in_estimates FROM trade_in_estimates
WHERE id = CAST(:id AS uuid) WHERE id = CAST(:id AS uuid)
AND expires_at > NOW() AND expires_at > NOW()
@ -87,12 +89,17 @@ def get_estimate(
if row is None: if row is None:
raise HTTPException(status_code=404, detail="estimate not found or expired") raise HTTPException(status_code=404, detail="estimate not found or expired")
from app.services.estimator import _qc_geo_to_precision
analogs = [AnalogLot(**a) for a in (row.analogs or [])] analogs = [AnalogLot(**a) for a in (row.analogs or [])]
actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])] actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])]
# ВАЖНО: возвращаем ПОЛНЫЙ набор полей. Раньше эндпоинт отдавал огрызок # ВАЖНО: возвращаем ПОЛНЫЙ набор полей. Раньше эндпоинт отдавал огрызок
# без sources_used / confidence_explanation / координат — и при открытии # без sources_used / confidence_explanation / координат — и при открытии
# оценки по ссылке (?id=) карточка источников пустела до «0/7». # оценки по ссылке (?id=) карточка источников пустела до «0/7».
# DaData-обогащёнка (canonical/cadnum/fias/precision/metro) тоже
# rehydrate'ится из row — иначе бейдж точности + метро не показывались
# на shared-link reopen / в PDF.
return AggregatedEstimate( return AggregatedEstimate(
estimate_id=row.id, estimate_id=row.id,
median_price_rub=row.median_price, median_price_rub=row.median_price,
@ -119,6 +126,11 @@ def get_estimate(
house_type=row.house_type, house_type=row.house_type,
repair_state=row.repair_state, repair_state=row.repair_state,
has_balcony=row.has_balcony, has_balcony=row.has_balcony,
canonical_address=row.canonical_address,
house_cadnum=row.house_cadnum,
house_fias_id=row.house_fias_id,
address_precision=_qc_geo_to_precision(row.dadata_qc_geo),
metro_nearest=(row.dadata_metro or []),
) )
@ -142,7 +154,9 @@ def estimate_pdf(
analogs, actual_deals, sources_used, data_freshness_minutes, analogs, actual_deals, sources_used, data_freshness_minutes,
expires_at, expires_at,
address, lat, lon, area_m2, rooms, floor, total_floors, address, lat, lon, area_m2, rooms, floor, total_floors,
year_built, house_type, repair_state, has_balcony year_built, house_type, repair_state, has_balcony,
canonical_address, house_cadnum, house_fias_id,
dadata_qc_geo, dadata_metro
FROM trade_in_estimates FROM trade_in_estimates
WHERE id = CAST(:id AS uuid) WHERE id = CAST(:id AS uuid)
""" """
@ -156,6 +170,8 @@ def estimate_pdf(
if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC): if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC):
raise HTTPException(status_code=410, detail="estimate expired (24h TTL)") raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")
from app.services.estimator import _qc_geo_to_precision
analogs = [AnalogLot(**a) for a in (row.analogs or [])] analogs = [AnalogLot(**a) for a in (row.analogs or [])]
actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])] actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])]
@ -177,6 +193,11 @@ def estimate_pdf(
target_lon=row.lon, target_lon=row.lon,
sources_used=row.sources_used or [], sources_used=row.sources_used or [],
data_freshness_minutes=row.data_freshness_minutes, data_freshness_minutes=row.data_freshness_minutes,
canonical_address=row.canonical_address,
house_cadnum=row.house_cadnum,
house_fias_id=row.house_fias_id,
address_precision=_qc_geo_to_precision(row.dadata_qc_geo),
metro_nearest=(row.dadata_metro or []),
) )
input_snapshot = { input_snapshot = {
"address": row.address, "address": row.address,

View file

@ -104,6 +104,11 @@ class AggregatedEstimate(BaseModel):
house_cadnum: str | None = None house_cadnum: str | None = None
house_fias_id: str | None = None house_fias_id: str | None = None
metro_nearest: list[dict] = Field(default_factory=list) metro_nearest: list[dict] = Field(default_factory=list)
# address_precision — точность гео-привязки адреса (из DaData qc_geo):
# «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы),
# «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).
# None если DaData не отрабатывала (адрес не геокодирован) / credentials не заданы.
address_precision: Literal["house", "street", "approximate"] | None = None
# ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку # ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку
# при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ── # при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ──
area_m2: float | None = None area_m2: float | None = None

View file

@ -1071,9 +1071,21 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
house_cadnum=dadata.house_cadnum if dadata else None, house_cadnum=dadata.house_cadnum if dadata else None,
house_fias_id=dadata.house_fias_id if dadata else None, house_fias_id=dadata.house_fias_id if dadata else None,
metro_nearest=(dadata.metro if dadata and dadata.metro else []), metro_nearest=(dadata.metro if dadata and dadata.metro else []),
address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None),
) )
def _qc_geo_to_precision(qc_geo: int | None) -> str | None:
# DaData qc_geo: 0=exact(house), 1=street, 2=settlement, 3=city, 4=region, 5=unknown
if qc_geo is None:
return None
if qc_geo == 0:
return "house"
if qc_geo == 1:
return "street"
return "approximate"
def _estimate_days_on_market( def _estimate_days_on_market(
listings: list[dict[str, Any]], deals: list[dict[str, Any]] listings: list[dict[str, Any]], deals: list[dict[str, Any]]
) -> int | None: ) -> int | None:
@ -1953,4 +1965,6 @@ def _empty_estimate(
actual_deals=[], actual_deals=[],
expires_at=expires_at, expires_at=expires_at,
cian_valuation=None, cian_valuation=None,
# Адрес не геокодирован (DaData не отрабатывала) → точность неизвестна.
address_precision=None,
) )

View file

@ -0,0 +1,43 @@
"""Unit tests for `app.services.estimator._qc_geo_to_precision`.
Pure-function mapping DaData qc_geo address-precision indicator:
- 0 "house" (дом распознан точно)
- 1 "street" (до улицы)
- 2/3/4/5 "approximate" (населённый пункт / город / регион / не распознан)
- None None (DaData не отрабатывала)
Чистый unit-тест: без БД и сети.
"""
from __future__ import annotations
import os
import sys
from unittest.mock import MagicMock
import pytest
# DATABASE_URL required by config before any app import (см. test_dadata.py).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# WeasyPrint stub — not installed in CI без GTK (consistent с test_dadata.py).
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
from app.services.estimator import _qc_geo_to_precision # noqa: E402
@pytest.mark.parametrize(
("qc_geo", "expected"),
[
(0, "house"),
(1, "street"),
(2, "approximate"),
(3, "approximate"),
(4, "approximate"),
(5, "approximate"),
(None, None),
],
)
def test_qc_geo_to_precision(qc_geo: int | None, expected: str | None) -> None:
assert _qc_geo_to_precision(qc_geo) == expected

View file

@ -59,6 +59,16 @@ function isMockAddress(estimate: AggregatedEstimate): boolean {
export function HeroSummary({ estimate, input, onResubmit, isResubmitting = false }: Props) { export function HeroSummary({ estimate, input, onResubmit, isResubmitting = false }: Props) {
const cv = calcCv(estimate); const cv = calcCv(estimate);
const conf = CONF_LABELS[estimate.confidence] ?? CONF_LABELS.low; const conf = CONF_LABELS[estimate.confidence] ?? CONF_LABELS.low;
// Бейдж точности гео-привязки адреса (DaData qc_geo). null/undefined → ничего не рисуем.
const precisionBadge =
estimate.address_precision === "house"
? { text: "адрес: до дома", className: "precision-badge precision-house" }
: estimate.address_precision === "street"
? { text: "адрес: до улицы", className: "precision-badge precision-street" }
: estimate.address_precision === "approximate"
? { text: "адрес: приблизительно", className: "precision-badge precision-approx" }
: null;
const m = estimate.median_price_rub; const m = estimate.median_price_rub;
const lo = estimate.range_low_rub; const lo = estimate.range_low_rub;
const hi = estimate.range_high_rub; const hi = estimate.range_high_rub;
@ -134,6 +144,11 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
</div> </div>
<div style={{ marginTop: 4 }}> <div style={{ marginTop: 4 }}>
Достоверность · <b style={{ color: conf.color }}>{conf.txt}</b> · CV {cv.toFixed(1)}% Достоверность · <b style={{ color: conf.color }}>{conf.txt}</b> · CV {cv.toFixed(1)}%
{precisionBadge && (
<span className={precisionBadge.className} title="Точность определения адреса">
{precisionBadge.text}
</span>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -1714,3 +1714,31 @@
background: var(--bg-card-alt); background: var(--bg-card-alt);
color: var(--fg-tertiary); color: var(--fg-tertiary);
} }
/* ── Address precision badge (DaData qc_geo) ─────────────────────────────── */
.precision-badge {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
vertical-align: middle;
}
.precision-house {
/* qc_geo=0 — дом определён точно (high confidence) */
background: var(--success-soft);
color: var(--success);
}
.precision-street {
/* qc_geo=1 — до улицы (neutral, как tier-t1) */
background: var(--bg-card-alt);
color: var(--fg-tertiary);
}
.precision-approx {
/* qc_geo≥2 — приблизительно. Amber-токены под «Адрес распознан неточно» disclaimer */
background: var(--accent-2-soft);
color: var(--accent-2);
}

View file

@ -12,6 +12,9 @@ export type RepairState = "needs_repair" | "standard" | "good" | "excellent";
export type ConfidenceLevel = "low" | "medium" | "high"; export type ConfidenceLevel = "low" | "medium" | "high";
// Точность гео-привязки адреса (из DaData qc_geo): house=0, street=1, approximate≥2.
export type AddressPrecision = "house" | "street" | "approximate";
export interface TradeInEstimateInput { export interface TradeInEstimateInput {
address: string; // min 3, max 500 address: string; // min 3, max 500
area_m2: number; // 10 < x < 500 area_m2: number; // 10 < x < 500
@ -78,6 +81,11 @@ export interface AggregatedEstimate {
sources_used: string[]; // ['avito', 'cian', 'rosreestr'] sources_used: string[]; // ['avito', 'cian', 'rosreestr']
data_freshness_minutes: number | null; // «обновлено N минут назад» data_freshness_minutes: number | null; // «обновлено N минут назад»
est_days_on_market: number | null; // прогноз срока продажи est_days_on_market: number | null; // прогноз срока продажи
// address_precision — точность гео-привязки адреса (из DaData qc_geo):
// «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы),
// «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).
// null если DaData не отрабатывала (адрес не геокодирован) / credentials не заданы.
address_precision?: AddressPrecision | null;
// ── Параметры оценённой квартиры (для восстановления карточки по ?id=) ── // ── Параметры оценённой квартиры (для восстановления карточки по ?id=) ──
area_m2: number | null; area_m2: number | null;
rooms: number | null; rooms: number | null;