gendesign/backend/app/schemas/recommend.py
Light1YT bba165a5f2 feat(forecasting): §10.2/10.4/10.5 + §16 reasons on forecast-overlay (#983, 953-B)
Additively extend #982 build_forecast_overlay: class recommendation (§10.2,
strongest aggregate deficit per class), USP-from-deficits (§10.5, top-3),
degraded-honest commercial signal (§10.4, premise_kind=нежилое → available:False
+caveat when thin, never fabricates), and a §16 structural reason
(why/drivers/rejected/what_would_change/confidence/advisory) on every ranked
segment. §16 rejected-alternatives derived from the what_to_build ranking
runner-ups. Deterministic, no LLM, advisory, no new SQL. All #982 keys/behavior
preserved; schema extended additively (RecommendReason + optional fields).
31 new tests (86 total recommendation+velocity green).
2026-06-03 12:52:19 +05:00

137 lines
6.8 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 RecommendReason(BaseModel):
"""§16 структурная причина одного советующего пункта overlay (#983, 953-B). ADVISORY.
Объясняет, ПОЧЕМУ пункт попал в совет, ЧТО на него повлияло и ЧТО его изменило бы:
• `why` — RU-предложение (сегмент, deficit_index на горизонте, спрос/предложение).
• `drivers` — драйверы сигнала ([{factor, value, direction}]).
• `rejected` — отвергнутые альтернативы из РАНКИНГА (runner-up'ы / негатив-дефицит).
• `what_would_change` — рычаги §9.x, которые сдвинули бы индекс (ставка/предложение/
горизонт), полными RU-предложениями.
`advisory` ВСЕГДА True (наследует advisory-статус §9.x). Все поля схемы — для
explainability, НЕ основание для инвест-решения. Это «контракт-намёк»: overlay
пробрасывает причины как открытые dict'ы (см. RecommendForecastSegment.reason).
"""
why: str
drivers: list[dict[str, Any]] = []
rejected: list[dict[str, Any]] = []
what_would_change: list[str] = []
confidence: Literal["high", "medium", "low"]
advisory: bool
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). `reason` (#983, §16) —
структурная причина ячейки (RecommendReason-shaped dict) или None.
"""
bucket: str
obj_class: str | None = None
deficit_index: float
balance_units: float | None = None
confidence: Literal["high", "medium", "low"]
reason: dict[str, Any] | None = None
class RecommendForecastOverlay(BaseModel):
"""§9.7 СОВЕТУЮЩИЙ forecast-overlay поверх живого микса (#982, 953-A; #983, 953-B).
Кладётся ТОЛЬКО под scope["forecast"] при заданном horizon_months; живой микс
(4 поля RecommendMixOutput) НЕ затрагивается. `advisory` ВСЕГДА True (наследует
advisory-статус §9.x — не основание для инвест-решения). `mode`: demand_supply
(cad_num задан — supply/конкуренты учтены) или demand_only (cad_num=None — только
темп спроса; см. warnings).
#983 (953-B) — ADDITIVE-расширения поверх #982 (старые поля/поведение НЕ тронуты):
• `class_reco` — §10.2 рекомендация класса (сильнейший агрегатный дефицит) + §16
reason, либо None (нет ранжированных данных).
• `usp` — §10.5 USP-из-дефицитов: top-K самых недообеспеченных сегментов как
«дефицит формата X — стройте его» (пустой при отсутствии данных).
• `commercial` — §10.4 советующий коммерческий сигнал (доля коммерции) или
degraded-honest {available: False, caveat, advisory} при тонких данных, либо None.
"""
horizon_months: int
mode: Literal["demand_supply", "demand_only"]
advisory: bool
ranked_segments: list[RecommendForecastSegment]
warnings: list[str]
class_reco: dict[str, Any] | None = None
usp: list[dict[str, Any]] = []
commercial: dict[str, Any] | None = None
class RecommendMixOutput(BaseModel):
scope: dict[str, Any]
buckets: list[RecommendBucket]
summary: dict[str, Any]
comparables: list[RecommendComparable]