From 8591efc42fecc0df25184bf6d0c0b1ed5f40186b Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 27 May 2026 17:36:46 +0500 Subject: [PATCH] feat(tradein): estimator deals tier classification (PR M / #564 Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove stale NOTE 2026-05-24 + add tier field на AnalogLot. - schemas/trade_in.py: AnalogLot добавлено поле `tier: str | None` ('T0_per_house' / 'T1_per_street' — для rosreestr deals) - services/estimator.py: * _deal_to_analog: compute tier via kadastr_num heuristic ('66:41:0204016:10' → T0; '66:41:...:0' OR NULL → T1) * estimate_quality docstring: replaced устаревший NOTE с corrected explanation что importer (PR-A 2026-05-24) уже фильтрует doc_type='ДКП' и deals идут в actual_deals output * _fetch_deals SQL: include kadastr_num в SELECT (для tier classify) Observation: open dataset Росреестра (current state) имеет kadastr_num=NULL для всех 49,791 ДКП-сделок → все классифицируются как T1_per_street. Поле зарезервировано на случай будущего ЕГРН direct feed. Tests: ruff clean, services/ pytest 19/19 pass. Closes #564 Phase 3. --- tradein-mvp/backend/app/schemas/trade_in.py | 6 +++++ tradein-mvp/backend/app/services/estimator.py | 22 +++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 8a600752..cba51c4e 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -44,6 +44,12 @@ class AnalogLot(BaseModel): source: str | None = None # 'avito' / 'cian' / 'domklik' / 'rosreestr' source_url: str | None = None # ссылка на оригинальное объявление / сделку distance_m: int | None = None # расстояние до целевой квартиры в метрах + # ── Confidence tier (PR M / #564 Phase 3) ── + # Только для rosreestr-сделок: T0_per_house (kadastr_num exact match), + # T1_per_street (street-level only). Open dataset Росреестра не имеет + # kadastr_num — все ДКП-сделки сейчас T1. Поле зарезервировано на случай + # будущего enrichment data feed (ЕГРН direct). + tier: str | None = None class CianChartPoint(BaseModel): diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index cc999f1e..613b467e 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -575,9 +575,12 @@ def _save_yandex_history_items( async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> AggregatedEstimate: """Главная функция — оценка квартиры по реальным данным. - NOTE 2026-05-24: rosreestr_deals temporarily NOT included in actual_deals - output (they contain ДДУ first-market data that skews secondary-market - median). See Decision_TradeIn_DataQuality_8PR_Roadmap. + PR M / #564 Phase 3: rosreestr_deals **included** в actual_deals output. + Stale NOTE 2026-05-24 (про ДДУ contamination) устарел — importer + `import-rosreestr.sh` после PR-A 2026-05-24 фильтрует doc_type='ДКП', + ДДУ первички исключены. Deals идут в `actual_deals` JSONB поле + AggregatedEstimate с tier classification (T0_per_house / T1_per_street) + — frontend может разделять confidence в UI. Returns: AggregatedEstimate с estimate_id, медианой, диапазоном, аналогами. @@ -1582,6 +1585,7 @@ def _fetch_deals( rooms, area_m2, floor, total_floors, price_rub, price_per_m2, deal_date, days_on_market, + kadastr_num, ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m FROM deals WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius) @@ -1736,7 +1740,16 @@ def _listing_to_analog(row: dict[str, Any]) -> AnalogLot: def _deal_to_analog(row: dict[str, Any]) -> AnalogLot: - """deals не имеют photo_url — упрощённо.""" + """deals не имеют photo_url — упрощённо. + + Tier classification (PR M / #564 Phase 3): + T0_per_house — kadastr_num exact match (НЕ доступно в open dataset Росреестра) + T1_per_street — street-level only (default для всех ДКП open dataset) + """ + kad = row.get("kadastr_num") + # Per-house tier требует kadastr_num типа "66:41:0204016:10" (с участком). + # Street-only patterns: "66:41:0000000:0" или NULL → T1. + tier = "T0_per_house" if kad and not kad.endswith(":0") else "T1_per_street" return AnalogLot( address=row.get("address") or "", area_m2=float(row.get("area_m2") or 0), @@ -1751,6 +1764,7 @@ def _deal_to_analog(row: dict[str, Any]) -> AnalogLot: source=row.get("source"), source_url=None, # rosreestr сделки без публичной ссылки distance_m=int(row["distance_m"]) if row.get("distance_m") is not None else None, + tier=tier, ) -- 2.45.3