feat(tradein): estimator deals tier classification (PR M / #564 Phase 3)
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.
This commit is contained in:
parent
63756c3c29
commit
8591efc42f
2 changed files with 24 additions and 4 deletions
|
|
@ -44,6 +44,12 @@ class AnalogLot(BaseModel):
|
||||||
source: str | None = None # 'avito' / 'cian' / 'domklik' / 'rosreestr'
|
source: str | None = None # 'avito' / 'cian' / 'domklik' / 'rosreestr'
|
||||||
source_url: str | None = None # ссылка на оригинальное объявление / сделку
|
source_url: str | None = None # ссылка на оригинальное объявление / сделку
|
||||||
distance_m: int | 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):
|
class CianChartPoint(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -575,9 +575,12 @@ def _save_yandex_history_items(
|
||||||
async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> AggregatedEstimate:
|
async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> AggregatedEstimate:
|
||||||
"""Главная функция — оценка квартиры по реальным данным.
|
"""Главная функция — оценка квартиры по реальным данным.
|
||||||
|
|
||||||
NOTE 2026-05-24: rosreestr_deals temporarily NOT included in actual_deals
|
PR M / #564 Phase 3: rosreestr_deals **included** в actual_deals output.
|
||||||
output (they contain ДДУ first-market data that skews secondary-market
|
Stale NOTE 2026-05-24 (про ДДУ contamination) устарел — importer
|
||||||
median). See Decision_TradeIn_DataQuality_8PR_Roadmap.
|
`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:
|
Returns:
|
||||||
AggregatedEstimate с estimate_id, медианой, диапазоном, аналогами.
|
AggregatedEstimate с estimate_id, медианой, диапазоном, аналогами.
|
||||||
|
|
@ -1582,6 +1585,7 @@ def _fetch_deals(
|
||||||
rooms, area_m2, floor, total_floors,
|
rooms, area_m2, floor, total_floors,
|
||||||
price_rub, price_per_m2,
|
price_rub, price_per_m2,
|
||||||
deal_date, days_on_market,
|
deal_date, days_on_market,
|
||||||
|
kadastr_num,
|
||||||
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m
|
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
|
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:
|
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(
|
return AnalogLot(
|
||||||
address=row.get("address") or "",
|
address=row.get("address") or "",
|
||||||
area_m2=float(row.get("area_m2") or 0),
|
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=row.get("source"),
|
||||||
source_url=None, # rosreestr сделки без публичной ссылки
|
source_url=None, # rosreestr сделки без публичной ссылки
|
||||||
distance_m=int(row["distance_m"]) if row.get("distance_m") is not None else None,
|
distance_m=int(row["distance_m"]) if row.get("distance_m") is not None else None,
|
||||||
|
tier=tier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue