From 492dd0aba66793e5508fb0df468cd35674cbf3b7 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Thu, 28 May 2026 15:37:33 +0500 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20=D0=B1=D0=B5=D0=B9=D0=B4=D0=B6?= =?UTF-8?q?=20=D1=82=D0=BE=D1=87=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D0=B0?= =?UTF-8?q?=D0=B4=D1=80=D0=B5=D1=81=D0=B0=20(qc=5Fgeo)=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D0=BE=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D0=BA=D0=B8=20(accuracy=20#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Честность к пользователю: показываем насколько точно определён адрес — «до дома» / «до улицы» / «приблизительно» — рядом с confidence. - schemas/trade_in.py: address_precision: Literal["house","street","approximate"]|None - estimator.py: _qc_geo_to_precision (qc_geo 0→house, 1→street, ≥2→approximate, None→None), set в основном return + _empty_estimate - api/v1/trade_in.py: get_estimate + estimate_pdf теперь rehydrate'ят DaData-поля (canonical_address, house_cadnum, house_fias_id, address_precision, metro_nearest) из row — раньше на ?id= reopen / в PDF вся Q1-обогащёнка пропадала (поймал code-reviewer). Колонки уже persisted, фикс read-path. - frontend: AddressPrecision type + precision-badge в HeroSummary (.card-meta), токены --success/--accent-2/--bg-card-alt, без новых токенов. Канон: tech-analyst (план) → backend-engineer + frontend-engineer → code-reviewer (✅ APPROVE, поймал reopen-path gap) → fold-in fix. Tests: 7 passed, tsc clean, ruff clean. match_house_readonly wiring отложен (fast-follow). --- tradein-mvp/backend/app/api/v1/trade_in.py | 25 ++++++++++- tradein-mvp/backend/app/schemas/trade_in.py | 5 +++ tradein-mvp/backend/app/services/estimator.py | 14 ++++++ .../services/test_estimator_precision.py | 43 +++++++++++++++++++ .../src/components/trade-in/HeroSummary.tsx | 15 +++++++ .../src/components/trade-in/trade-in.css | 28 ++++++++++++ tradein-mvp/frontend/src/types/trade-in.ts | 8 ++++ 7 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/tests/services/test_estimator_precision.py diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index d22f157e..3f8db648 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -75,7 +75,9 @@ def get_estimate( analogs, actual_deals, sources_used, data_freshness_minutes, expires_at, 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 WHERE id = CAST(:id AS uuid) AND expires_at > NOW() @@ -87,12 +89,17 @@ def get_estimate( if row is None: 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 [])] actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])] # ВАЖНО: возвращаем ПОЛНЫЙ набор полей. Раньше эндпоинт отдавал огрызок # без sources_used / confidence_explanation / координат — и при открытии # оценки по ссылке (?id=) карточка источников пустела до «0/7». + # DaData-обогащёнка (canonical/cadnum/fias/precision/metro) тоже + # rehydrate'ится из row — иначе бейдж точности + метро не показывались + # на shared-link reopen / в PDF. return AggregatedEstimate( estimate_id=row.id, median_price_rub=row.median_price, @@ -119,6 +126,11 @@ def get_estimate( house_type=row.house_type, repair_state=row.repair_state, 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, expires_at, 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 WHERE id = CAST(:id AS uuid) """ @@ -156,6 +170,8 @@ def estimate_pdf( if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC): 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 [])] actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])] @@ -177,6 +193,11 @@ def estimate_pdf( target_lon=row.lon, sources_used=row.sources_used or [], 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 = { "address": row.address, diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 9ae6a8ce..31340351 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -104,6 +104,11 @@ class AggregatedEstimate(BaseModel): house_cadnum: str | None = None house_fias_id: str | None = None 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=), когда формы-инпута уже нет ── area_m2: float | None = None diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 90f766cc..7cd48545 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1071,9 +1071,21 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg house_cadnum=dadata.house_cadnum 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 []), + 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( listings: list[dict[str, Any]], deals: list[dict[str, Any]] ) -> int | None: @@ -1953,4 +1965,6 @@ def _empty_estimate( actual_deals=[], expires_at=expires_at, cian_valuation=None, + # Адрес не геокодирован (DaData не отрабатывала) → точность неизвестна. + address_precision=None, ) diff --git a/tradein-mvp/backend/tests/services/test_estimator_precision.py b/tradein-mvp/backend/tests/services/test_estimator_precision.py new file mode 100644 index 00000000..09141443 --- /dev/null +++ b/tradein-mvp/backend/tests/services/test_estimator_precision.py @@ -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 diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index 812a6e06..ee72f119 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -59,6 +59,16 @@ function isMockAddress(estimate: AggregatedEstimate): boolean { export function HeroSummary({ estimate, input, onResubmit, isResubmitting = false }: Props) { const cv = calcCv(estimate); 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 lo = estimate.range_low_rub; const hi = estimate.range_high_rub; @@ -134,6 +144,11 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
Достоверность · {conf.txt} · CV {cv.toFixed(1)}% + {precisionBadge && ( + + {precisionBadge.text} + + )}
diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css index 0c3dbb0e..919fa364 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -1714,3 +1714,31 @@ background: var(--bg-card-alt); 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); +} diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 30190ce5..f4938197 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -12,6 +12,9 @@ export type RepairState = "needs_repair" | "standard" | "good" | "excellent"; export type ConfidenceLevel = "low" | "medium" | "high"; +// Точность гео-привязки адреса (из DaData qc_geo): house=0, street=1, approximate≥2. +export type AddressPrecision = "house" | "street" | "approximate"; + export interface TradeInEstimateInput { address: string; // min 3, max 500 area_m2: number; // 10 < x < 500 @@ -78,6 +81,11 @@ export interface AggregatedEstimate { sources_used: string[]; // ['avito', 'cian', 'rosreestr'] data_freshness_minutes: number | null; // «обновлено N минут назад» 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=) ── area_m2: number | null; rooms: number | null; -- 2.45.3