gendesign/backend/app/services/forecasting/report.py
Light1YT dcccebf2c2
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m17s
CI / openapi-codegen-check (pull_request) Successful in 2m11s
CI / backend-tests (pull_request) Successful in 10m19s
fix(forecasting): honest scenarios collapse when β rate-sensitivity gate fails (audit #1871 P1)
Когда demand_normalization honesty-gate отбивает β rate-sensitivity
(`sensitivity.confidence=='low'`, ЕКБ-регрессия не проходит n≥30/R²≥0.1/slope<0),
все 393 прод-отчёта получают coefficient=1.0 для всех трёх сценариев
conservative/base/aggressive. По дизайну backend честно деградирует, но фронт
рисует 3 разноцветные карточки/линии одинаковых чисел без caveat.

Backend (scenarios.py, report.py, report_assembler.py, orchestrator.py):
- compute_scenarios теперь возвращает (list, collapsed: bool, reason: str|None)
- _detect_collapsed(): math.isclose(rel_tol=1e-9, abs_tol=1e-6) сравнивает
  projected_demand_units И deficit_index на всех горизонтах между
  conservative и aggressive — расхождение в любой метрике на любом h → False
- _COLLAPSE_REASON_LOW_BETA — единственный источник истины каноничного
  русского предложения
- ReportScenarios получает поля scenarios_collapsed + scenarios_collapse_reason
- demand_normalization.py НЕ ТРОНУТ — поведение корректно по контракту

Backend экспортёры (report_pdf.py, excel.py):
- PDF: при collapse — тёмная плашка-параграф вместо таблицы трёх столбцов
- Excel: ОДНА строка «base» + caveat-ячейка вместо трёх идентичных столбцов

Frontend (forecast.ts, ScenariosBlock, ScenarioCards, ScenarioCompareTable,
ForecastChart, Section6Forecast, ptica.module.css):
- Тип ReportScenarios расширен двумя optional-полями
- ScenariosBlock (light): headline-bar + одна grid-карточка base + caveat
- ScenarioCards (ptica dark): sub-component ScenarioBaseCard, одна карточка
  + collapse-note, ScenarioCompareTable return null
- ForecastChart: effectiveScenarios filter — одна линия viz-1 «Базовый»
  вместо трёх перекрывающихся
- Section6Forecast: caveat выше графика, читается ТОЛЬКО из
  scenarios_collapse_reason (источник истины — backend constant)

Тесты: +18 новых (TestMetricsEqual×6, TestDetectCollapsed×7,
TestComputeScenariosCollapseDetection×4 + 1 проверка graceful + corner).
102 forecasting passed, 88 cross-module passed.

Refs #1871
2026-06-22 19:06:22 +05:00

346 lines
22 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).
COLLAPSE (§22 audit-issue #1871 P1): когда §9.6 rate_sensitivity gate не
пройден, §9.4 demand_normalization возвращает coefficient=1.0 для всех трёх
конвертов → conservative/aggressive идентичны base. Это by-design, но без явного
сигнала frontend/экспортёрам таблица «три одинаковых столбца» читается как баг.
Поля `scenarios_collapsed`/`scenarios_collapse_reason` пробрасывают вердикт
`compute_scenarios` (#984): True+RU-причина → плашка-объяснение вместо таблицы;
False+None → штатная таблица сценариев. Дефолты False/None — обратная
совместимость для частичных отчётов и прежних путей сборки. as_dict() отдаёт
оба ключа ВСЕГДА (стабильная схема для фронта).
"""
by_scenario: dict[str, Any] = field(default_factory=dict) # {scenario: сводка/as_dict}
summary: str | None = None # короткий RU-текст про разброс сценариев
scenarios_collapsed: bool = False # §22 P1: True ⇒ три сценария идентичны (failed β-gate)
scenarios_collapse_reason: str | None = None # RU-объяснение причины collapse (или None)
def as_dict(self) -> dict[str, Any]:
return {
"by_scenario": dict(self.by_scenario),
"summary": self.summary,
"scenarios_collapsed": self.scenarios_collapsed,
"scenarios_collapse_reason": self.scenarios_collapse_reason,
}
@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(),
}