gendesign/backend/app/services/forecasting/report.py
Light1YT 6ffa34eed6 feat(forecasting): §13 SiteFinderReport structural object (#987, 955-A1)
Add frozen, JSON-serializable SiteFinderReport container with 8 nested section
dataclasses (exec_summary, market_now, future_market, product_tz, scenarios,
scoring, confidence, meta). Pure container — no DB/compute/LLM; populated by
#988 assembler, consumed by #989 exporters + #957 chat. All fields
optional/defaulted (partial report valid); as_dict() JSON-safe (dates→iso);
advisory=True; schema_version "1.0". 14 unit tests.
2026-06-03 13:32:56 +05:00

332 lines
20 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.

"""§13 SiteFinderReport — структурный контейнер итогового советующего отчёта.
#987 (955-A1, Site Finder v2 / «GG-форсайт» ТЗ §13), EPIC 11 «Отчёт». Это ЧИСТЫЙ
СТРУКТУРНЫЙ объект — frozen, сериализуемый dataclass, фиксирующий ВОСЕМЬ секций §13
отчёта. Он НИЧЕГО не считает и не ходит в БД: его НАПОЛНЯЕТ сборщик #988 (assembler)
и ПОТРЕБЛЯЮТ экспортёры #989 (PDF/Excel/JSON) + чат #957. Здесь — ТОЛЬКО объект +
сериализация (НЕ сборщик, НЕ движок confidence #990, НЕ изменения экспортёров).
ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, СОВЕТУЮЩИЙ. Никакого SQL/сети/вычислений/print — это
зеркало advisory-духа всего форсайт-стека (#980…#986): сам отчёт лишь СОБИРАЕТ уже
посчитанные advisory-выводы, поэтому `advisory` ВСЕГДА True.
СТРУКТУРА (решение — вложенные frozen-dataclass'ы, по одному на секцию §13):
зеркалит house-style (каждый сосед — frozen dataclass + `as_dict()`), даёт типизированные
слоты (частичный отчёт всё равно типобезопасен) и явный JSON-safe `as_dict()`. КАЖДАЯ
секция МОЖЕТ ДЕРЖАТЬ выводы под-сервисов как ПЛОСКИЕ dict'ы (их собственные `as_dict()`
уже JSON-safe — `_round_or_none`/`.isoformat()` сделаны выше по стеку): отчёт НЕ
пере-деривирует внутренности под-объектов, а оборачивает их в типизированные конверты.
ВОСЕМЬ СЕКЦИЙ §13 (каждая — отдельный nested frozen dataclass):
1. exec_summary (ReportExecSummary) — вердикт-заголовок + ключевые числа + общая
уверенность («что строить здесь» одним абзацем).
2. market_now (ReportMarketNow) — текущий рынок: абсорбция/цены/конкуренты/
слои предложения (выводы #949/#950 — market_metrics/competitors/supply_layers).
3. future_market (ReportFutureMarket) — прогноз: спрос/предложение по горизонтам,
дефицит/затоварка, будущие конкуренты, сводка сценариев (#952/#954).
4. product_tz (ReportProductTz) — рекомендация продукта: класс, квартирография
(mix), коммерция, USP + §16-причины (#953 recommendation).
5. scenarios (ReportScenarios) — conservative/base/aggressive сводка (#954/#984).
6. scoring (ReportScoring) — 10 продуктовых скоров + 6 спец-индексов +
overall (#954/#985/#986 — product_scoring/special_indices).
7. confidence (ReportConfidence) — отчётная уверенность (High/Med/Low) + rationale.
ПЛЕЙСХОЛДЕР: настоящий §15-движок — #990; здесь ТИПИЗИРОВАННЫЙ слот (дефолт пуст).
8. meta (ReportMeta) — cad_num/district/segment/horizons,
generated_advisory=True, schema_version (named const _SCHEMA_VERSION).
GRACEFUL (дух всего форсайт-стека): КАЖДОЕ поле Optional/с дефолтом — частично
собранный (или вовсе пустой) отчёт ВАЛИДЕН: тонкий анализ всё равно даёт отчёт.
`as_dict()` всегда отдаёт ВОСЕМЬ секций (стабильный контракт для экспортёров/чата),
полностью JSON-сериализуемый (даты→iso, без утечки dataclass'ов/raw-объектов).
"""
from __future__ import annotations
import datetime as dt
from dataclasses import dataclass, field
from typing import Any, Literal
# Версия схемы отчёта — named const (бамп при несовместимом изменении формы `as_dict`).
# Экспортёры/чат/JSON-экспорт сверяются с ней. SemVer-строка ("major.minor").
_SCHEMA_VERSION: str = "1.0"
# Весь отчёт советующий (зеркало advisory-cap форсайт-стека #980…#986): он лишь
# СОБИРАЕТ уже-advisory выводы — не основание для инвест-решения.
_ADVISORY: bool = True
# Допустимые отчётные уровни уверенности (§15). Реальный движок — #990; здесь только
# типизированный слот, дефолт None (ещё не оценено).
ReportConfidenceLevel = Literal["high", "medium", "low"]
def _round_or_none(value: float | None, digits: int) -> float | None:
"""Округлить до digits знаков или вернуть None (зеркало соседей). PURE."""
return round(value, digits) if value is not None else None
def _iso_or_none(value: dt.date | dt.datetime | str | None) -> str | None:
"""Привести дату/время к ISO-строке для JSON (None passthrough). PURE.
`date`/`datetime` → `.isoformat()`; уже-строка → как есть (сборщик мог положить
готовую ISO-строку); None → None. Так `as_dict()` гарантированно JSON-safe без
утечки объектов `date` в выдачу.
"""
if value is None:
return None
if isinstance(value, (dt.date, dt.datetime)):
return value.isoformat()
return value
# ──────────────────────────────────────────────────────────────────────────────
# Секции §13 — вложенные frozen-dataclass'ы. Каждая держит выводы под-сервисов как
# ПЛОСКИЕ JSON-safe dict'ы (их as_dict() уже сериализуемы) + лёгкие скаляры-заголовки.
# Все поля Optional/с дефолтом → частичная/пустая секция валидна (graceful).
# ──────────────────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class ReportExecSummary:
"""§13.1 exec_summary — вердикт-заголовок + ключевые числа + общая уверенность.
«Что строить здесь» одним абзацем (`headline`/`verdict`), опорные числа в плоском
`key_numbers` (JSON-safe), `overall_confidence` — отчётный уровень (High/Med/Low).
Всё опционально: тонкий анализ → пустой exec_summary (graceful).
"""
headline: str | None = None # 1-фразовый вердикт «что строить здесь»
verdict: str | None = None # развёрнутый абзац-резюме
key_numbers: dict[str, Any] = field(default_factory=dict) # плоские опорные числа
overall_confidence: ReportConfidenceLevel | None = None # отчётная уверенность
def as_dict(self) -> dict[str, Any]:
return {
"headline": self.headline,
"verdict": self.verdict,
"key_numbers": dict(self.key_numbers),
"overall_confidence": self.overall_confidence,
}
@dataclass(frozen=True)
class ReportMarketNow:
"""§13.2 market_now — текущий рынок (выводы #949/#950).
Держит уже-посчитанные `as_dict()` под-сервисов как плоские JSON-safe dict'ы:
`market_metrics` (§9.2 абсорбция/цены), `competitors` (§9.1), `supply_layers`
(§9.3 слои предложения). Отчёт их НЕ пере-деривирует. Всё опционально (graceful).
"""
market_metrics: dict[str, Any] | None = None # MarketMetrics.as_dict() (#950)
competitors: list[dict[str, Any]] = field(default_factory=list) # Competitor.as_dict() (#949)
supply_layers: dict[str, Any] | None = None # supply_layers вывод (#950, §9.3)
summary: str | None = None # короткий RU-текст про текущий рынок
def as_dict(self) -> dict[str, Any]:
return {
"market_metrics": self.market_metrics,
"competitors": [dict(c) for c in self.competitors],
"supply_layers": self.supply_layers,
"summary": self.summary,
}
@dataclass(frozen=True)
class ReportFutureMarket:
"""§13.3 future_market — прогноз (выводы #952/#954).
`forecasts_by_horizon` — список DemandSupplyForecast.as_dict() (по горизонтам:
спрос/предложение, дефицит/затоварка). `future_competitors` — будущие конкуренты,
`future_supply` — давление будущего предложения (FutureSupplyPressure.as_dict()),
`scenarios_summary` — короткая сводка сценариев. Всё опционально (graceful).
"""
forecasts_by_horizon: list[dict[str, Any]] = field(default_factory=list) # #952 по горизонтам
future_supply: dict[str, Any] | None = None # FutureSupplyPressure.as_dict() (§9.3)
future_competitors: list[dict[str, Any]] = field(default_factory=list) # будущие конкуренты
scenarios_summary: dict[str, Any] | None = None # краткая сводка сценариев (#954)
summary: str | None = None # короткий RU-текст про будущий рынок
def as_dict(self) -> dict[str, Any]:
return {
"forecasts_by_horizon": [dict(f) for f in self.forecasts_by_horizon],
"future_supply": self.future_supply,
"future_competitors": [dict(c) for c in self.future_competitors],
"scenarios_summary": self.scenarios_summary,
"summary": self.summary,
}
@dataclass(frozen=True)
class ReportProductTz:
"""§13.4 product_tz — рекомендация продукта (выводы #953).
`obj_class` — рекомендованный класс, `mix` — квартирография (доли форматов),
`commercial` — коммерческий сигнал §10.4, `usp` — USP-из-дефицитов §10.5,
`reasons` — §16-причины (отвергнутые альтернативы / what-would-change). Держит
под-выводы #953 как плоские JSON-safe dict'ы. Всё опционально (graceful).
"""
obj_class: str | None = None # рекомендованный класс (§10.2)
mix: list[dict[str, Any]] = field(default_factory=list) # квартирография (доли форматов)
commercial: dict[str, Any] | None = None # коммерческий сигнал (§10.4)
usp: list[dict[str, Any]] = field(default_factory=list) # USP-из-дефицитов (§10.5)
reasons: list[dict[str, Any]] = field(default_factory=list) # §16-причины
summary: str | None = None # короткий RU-текст про рекомендованный продукт
def as_dict(self) -> dict[str, Any]:
return {
"obj_class": self.obj_class,
"mix": [dict(m) for m in self.mix],
"commercial": self.commercial,
"usp": [dict(u) for u in self.usp],
"reasons": [dict(r) for r in self.reasons],
"summary": self.summary,
}
@dataclass(frozen=True)
class ReportScenarios:
"""§13.5 scenarios — сводка conservative/base/aggressive (выводы #954/#984).
`by_scenario` — карта {scenario: ScenarioForecast.as_dict()} (или иной сводный
dict на сценарий). Держит advisory-сценарии #984 как плоские JSON-safe dict'ы.
Пустая карта → секция без сценариев (graceful). advisory наследуется (cap medium).
"""
by_scenario: dict[str, Any] = field(default_factory=dict) # {scenario: сводка/as_dict}
summary: str | None = None # короткий RU-текст про разброс сценариев
def as_dict(self) -> dict[str, Any]:
return {
"by_scenario": dict(self.by_scenario),
"summary": self.summary,
}
@dataclass(frozen=True)
class ReportScoring:
"""§13.6 scoring — 10 продуктовых скоров + 6 спец-индексов + overall (#954/#985/#986).
`product_scores` — ProductScoreCard.as_dict() (#985, 10 скоров + overall),
`special_indices` — SpecialIndices.as_dict() (#986, 6 индексов), `overall` —
итоговый продуктовый скор ∈ [0,1] (дубль из карты для удобства экспортёров).
Держит карты как плоские JSON-safe dict'ы. Всё опционально (graceful).
"""
product_scores: dict[str, Any] | None = None # ProductScoreCard.as_dict() (#985)
special_indices: dict[str, Any] | None = None # SpecialIndices.as_dict() (#986)
overall: float | None = None # итоговый продуктовый скор ∈ [0,1] (дубль, удобство)
def as_dict(self) -> dict[str, Any]:
return {
"product_scores": self.product_scores,
"special_indices": self.special_indices,
"overall": _round_or_none(self.overall, 3),
}
@dataclass(frozen=True)
class ReportConfidence:
"""§13.7 confidence — отчётная уверенность (High/Med/Low) + rationale. ПЛЕЙСХОЛДЕР.
Настоящий §15-движок уверенности — #990; здесь ТИПИЗИРОВАННЫЙ СЛОТ под него: `level`
(High/Med/Low) + RU `rationale` + плоский `factors` (что тянуло уверенность). Дефолт
пуст (уровень ещё не оценён) → секция валидна и до подключения #990 (graceful).
"""
level: ReportConfidenceLevel | None = None # High/Med/Low (None = ещё не оценено)
rationale: str | None = None # RU-обоснование уровня
factors: dict[str, Any] = field(default_factory=dict) # вклады/флаги уверенности (#990)
def as_dict(self) -> dict[str, Any]:
return {
"level": self.level,
"rationale": self.rationale,
"factors": dict(self.factors),
}
@dataclass(frozen=True)
class ReportMeta:
"""§13.8 meta — контекст отчёта (cad_num/district/segment/horizons) + флаги.
`generated_advisory` ВСЕГДА True (отчёт советующий), `schema_version` —
_SCHEMA_VERSION (контракт формы для экспортёров/чата), `generated_at` нормализуется
в ISO-строку в `as_dict()` (JSON-safe). Всё опционально (graceful).
"""
cad_num: str | None = None # кадастровый номер участка
district: str | None = None # район
segment: dict[str, Any] = field(default_factory=dict) # целевой сегмент (оси §9.x)
horizons: list[int] = field(default_factory=list) # горизонты прогноза (мес)
generated_at: dt.date | dt.datetime | str | None = None # момент сборки (#988)
generated_advisory: bool = _ADVISORY # ВСЕГДА True
schema_version: str = _SCHEMA_VERSION # версия формы as_dict()
def as_dict(self) -> dict[str, Any]:
return {
"cad_num": self.cad_num,
"district": self.district,
"segment": dict(self.segment),
"horizons": list(self.horizons),
"generated_at": _iso_or_none(self.generated_at),
"generated_advisory": self.generated_advisory,
"schema_version": self.schema_version,
}
# ──────────────────────────────────────────────────────────────────────────────
# Корневой контейнер §13 — собирает восемь секций. PURE-контейнер: НЕ считает, НЕ
# ходит в БД, НЕ зовёт сервисы (это #988 assembler). as_dict() — JSON-safe, всегда
# отдаёт восемь секций (стабильный контракт экспортёров #989 / чата #957).
# ──────────────────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class SiteFinderReport:
"""§13 итоговый советующий отчёт Site Finder v2 — frozen структурный контейнер.
ВОСЕМЬ секций (exec_summary / market_now / future_market / product_tz / scenarios /
scoring / confidence / meta), каждая — вложенный frozen-dataclass с дефолтом, так
что частично-собранный (или пустой) отчёт ВАЛИДЕН (graceful: тонкий анализ всё равно
даёт отчёт). `advisory` ВСЕГДА True — отчёт собирает advisory-выводы форсайт-стека,
НЕ основание для инвест-решения.
PURE-контейнер: НИКАКИХ БД/вычислений/сервис-вызовов — НАПОЛНЯЕТ его сборщик #988,
ПОТРЕБЛЯЮТ экспортёры #989 + чат #957. `as_dict()` полностью JSON-сериализуем
аты→iso, без утечки dataclass'ов) и всегда содержит ВОСЕМЬ секций + advisory +
schema_version — стабильный контракт для экспортёров/JSON-экспорта/чата.
"""
exec_summary: ReportExecSummary = field(default_factory=ReportExecSummary)
market_now: ReportMarketNow = field(default_factory=ReportMarketNow)
future_market: ReportFutureMarket = field(default_factory=ReportFutureMarket)
product_tz: ReportProductTz = field(default_factory=ReportProductTz)
scenarios: ReportScenarios = field(default_factory=ReportScenarios)
scoring: ReportScoring = field(default_factory=ReportScoring)
confidence: ReportConfidence = field(default_factory=ReportConfidence)
meta: ReportMeta = field(default_factory=ReportMeta)
advisory: bool = _ADVISORY # ВСЕГДА True
def as_dict(self) -> dict[str, Any]:
"""Полностью JSON-сериализуемое представление отчёта (стабильный контракт).
Всегда отдаёт ВОСЕМЬ секций + `advisory` + `schema_version` (дубль из meta на
верхнем уровне — экспортёрам/чату удобно читать версию без захода в meta).
Внутри секций — только JSON-native типы (даты→iso через `_iso_or_none`, числа
через `_round_or_none`, под-сервис-dict'ы уже JSON-safe). НИКАКОЙ утечки
dataclass'ов/raw-объектов.
"""
return {
"schema_version": self.meta.schema_version,
"advisory": self.advisory,
"exec_summary": self.exec_summary.as_dict(),
"market_now": self.market_now.as_dict(),
"future_market": self.future_market.as_dict(),
"product_tz": self.product_tz.as_dict(),
"scenarios": self.scenarios.as_dict(),
"scoring": self.scoring.as_dict(),
"confidence": self.confidence.as_dict(),
"meta": self.meta.as_dict(),
}