gendesign/backend/app/schemas/recommend.py
lekss361 7a23aa9edc fix: heartbeat-based zombie autoclean (10min) + Phase A heartbeats; ship recommend velocity calculator
Scraper resume:
- /admin/scrape/queue autoclean уменьшен с 60min started_at до 10min
  COALESCE(heartbeat_at, started_at). Не режет валидные long sweeps.
- Phase A пишет heartbeat после каждого objStatus fetch (3min Phase A
  больше не рискует быть отмеченной как zombie при новом 10min threshold).

Recommend (Уровень 1 калькулятор):
- POST /api/v1/analytics/recommend/mix с velocity baseline (sale_graph),
  price elasticity (regr_slope/r2 на sale_graph, fallback -1.5),
  inverse mode (target_months → required price_factor), liquidity score
  24mo, headline.
- /analytics/recommend: RecommendVelocityPanel (price slider 0.85..1.15
  + 4 KPI Чек/Срок/Темп/Ликвидность + методология эластичности),
  RecommendLiquidityChart (cumulative 0..36 mo с пунктиром на 24).
- BucketsTable: +колонки Темп и Срок, цены/выручка масштабируются по
  priceFactor live.
- Все слайдеры и target_months считаются клиентски — никаких round-trip.

Fix: SQLAlchemy не парсит :cls::text — заменено на CAST(:cls AS TEXT).
2026-04-28 22:54:03 +03:00

55 lines
1.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=36)
# Velocity / pricing scenario knobs (live-tuned client-side; backend just
# ships base coefficients so frontend can recompute without round-trips).
price_factor: float = Field(default=1.0, ge=0.5, le=2.0)
target_months: int | None = Field(default=None, ge=3, le=120)
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
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
class RecommendMixOutput(BaseModel):
scope: dict[str, Any]
buckets: list[RecommendBucket]
summary: dict[str, Any]
comparables: list[RecommendComparable]