recommend_mix кладёт в bucket поля elasticity / elasticity_r2 / elasticity_n / elasticity_source / velocity_source (analytics_queries.py:2592-2606), но RecommendBucket их не объявляет и не имеет extra='allow' → Pydantic v2 молча выкидывает их из response. Фронт (RecommendVelocityPanel.tsx:68 `r.elasticity ?? elasticity`) при движении price-слайдера всегда падает на ГЛОБАЛЬНУЮ эластичность — live what-if расходится и с серверным months_to_sellout (тот считается per-bucket), и с UI-таблицей «Эластичность по сегментам». Patch: добавлены 5 опциональных полей в схему. Совместимость: фронт уже читает их с `?? globalFallback`, поэтому non-breaking. Producer/consumer- контракт восстановлен. 113/113 recommend-тестов зелёные. Closes #1204
149 lines
7.8 KiB
Python
149 lines
7.8 KiB
Python
"""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]
|