gendesign/backend/app/schemas/recommend.py
Light1YT 4648e1aee7
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m32s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m56s
Deploy / deploy (push) Successful in 1m9s
fix(schemas): expose per-bucket elasticity + velocity_source в RecommendBucket (#1204)
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
2026-06-13 05:14:05 +00:00

149 lines
7.8 KiB
Python
Raw Permalink 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
# 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` (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]