"""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 # Per-bucket эластичность спроса по цене (#1204) — recommend_mix считает её # отдельно на каждую квартирографию (см. analytics_queries.py:2592-2606), фронт # скейлит velocity_per_month как `velocity * price_factor^elasticity` при движении # слайдера цены (RecommendVelocityPanel.tsx:68). Без этих полей в схеме # response_model молча их отбрасывал → фронт всегда падал на глобальную # эластичность, расходясь с серверным months_to_sellout и UI-таблицей сегментов. elasticity: float | None = None elasticity_r2: float | None = None elasticity_n: int | None = None elasticity_source: str | None = None # Источник базового темпа продаж (analytics_queries → 'hedonic'|'deals'|'fallback'). velocity_source: str | 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` (demand−supply) 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]