gendesign/backend/app/schemas/recommend.py
Light1YT 574ee43577 feat(forecasting): recommendation overlay module + schema/route (#982, 953-A)
Recreate the recommendation.py bridge module + RecommendMixInput
horizon_months/cad_num fields + RecommendForecast{Segment,Overlay} models +
endpoint passthrough + test_recommendation.py — completing the #982 opt-in
whose analytics_queries.recommend_mix wire-in landed in the prior commit.

build_forecast_overlay(db,*,district,cad_num,horizon_months,target_class,
market_buckets) matches the committed call. demand_supply via rank_segments
(cad_num) / demand_only pace-proxy (no cad_num, no fabricated supply, warning).
Pure room/class bridges drift-guarded vs _BUCKET_PRETTY; local §9.x imports
avoid the analytics_queries↔forecasting cycle. 55 tests green.
2026-06-03 12:40:03 +05:00

102 lines
4.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""IO contracts for the rule-based квартирография recommender.
POST /api/v1/analytics/recommend/mix
"""
from typing import Any, Literal
from pydantic import BaseModel, Field
ClassLiteral = Literal["Comfort", "Comfort+", "Business", "Elite"]
class RecommendMixInput(BaseModel):
district_name: str = Field(..., min_length=2, max_length=80)
area_total_m2: float | None = Field(default=None, ge=100, le=500_000)
target_class: ClassLiteral | None = None
months_window: int = Field(default=12, ge=3, le=24)
# Velocity / pricing scenario knobs (live-tuned client-side; backend just
# ships base coefficients so frontend can recompute without round-trips).
# 0.01..3.0 = -99%..+200% к рынку. min=0.01 (а не 0) чтобы избежать
# деления на 0 / pf^elasticity = ∞ при «бесплатной» цене.
price_factor: float = Field(default=1.0, ge=0.01, le=3.0)
target_months: int | None = Field(default=None, ge=3, le=120)
# #982 (953-A) forecast-overlay opt-in. Оба None по умолчанию → живой микс
# БАЙТ-в-БАЙТ как раньше (overlay не добавляется). horizon_months задан →
# advisory overlay под scope["forecast"]; cad_num задан → demand_supply-режим
# (с supply/конкурентами участка), иначе demand_only (только темп спроса).
horizon_months: int | None = Field(default=None, ge=3, le=24)
cad_num: str | None = Field(default=None, max_length=40)
class RecommendBucket(BaseModel):
bucket: str
share_pct: float
deal_count: int
area_avg_m2: float
area_median_m2: float
price_median_per_m2: float
price_p25_per_m2: float
price_p75_per_m2: float
units_planned: int | None = None
revenue_planned_rub: float | None = None
# Velocity baseline (units/month for THIS project allocated to this bucket
# at price_factor=1.0). Frontend scales by price_factor^elasticity for live
# what-if recompute.
velocity_per_month: float | None = None
months_to_sellout: float | None = None
# Success-driven mix flag (issue #25): bucket has top success_score in district
is_top_success: bool = False
class RecommendComparable(BaseModel):
obj_id: int
comm_name: str | None = None
dev_name: str | None = None
obj_class: str | None = None
flat_count: int | None = None
sold_perc: float | None = None
cad_quarter: str | None = None
lat: float | None = None
lon: float | None = None
buildings_n: int | None = None
class RecommendForecastSegment(BaseModel):
"""Одна ранжированная ячейка §9.7 forecast-overlay (#982). ADVISORY.
`deficit_index`: в demand_supply-режиме ∈ [1,+1] (>0 строить / <0 затоварка,
supply-based); в demand_only-режиме ∈ (0,1] = ПРОКСИ относительной силы спроса
(NOT supply-based — геометрии участка нет). `balance_units` (demandsupply)
None в demand_only (предложение неизмеримо без cad_num).
"""
bucket: str
obj_class: str | None = None
deficit_index: float
balance_units: float | None = None
confidence: Literal["high", "medium", "low"]
class RecommendForecastOverlay(BaseModel):
"""§9.7 СОВЕТУЮЩИЙ forecast-overlay поверх живого микса (#982, 953-A).
Кладётся ТОЛЬКО под scope["forecast"] при заданном horizon_months; живой микс
(4 поля RecommendMixOutput) НЕ затрагивается. `advisory` ВСЕГДА True (наследует
advisory-статус §9.x — не основание для инвест-решения). `mode`: demand_supply
(cad_num задан — supply/конкуренты учтены) или demand_only (cad_num=None — только
темп спроса; см. warnings).
"""
horizon_months: int
mode: Literal["demand_supply", "demand_only"]
advisory: bool
ranked_segments: list[RecommendForecastSegment]
warnings: list[str]
class RecommendMixOutput(BaseModel):
scope: dict[str, Any]
buckets: list[RecommendBucket]
summary: dict[str, Any]
comparables: list[RecommendComparable]