gendesign/backend/app/services/forecasting/orchestrator.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

423 lines
23 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.

"""§9.x → §22 оркестратор: посчитать форсайт-слои и собрать `SiteFinderReport`.
Step 3a проводки форсайта (955-A2). §22-сборщик `assemble_report` (#988) ЧИСТЫЙ — он
лишь РАСКЛАДЫВАЕТ уже-посчитанные advisory-входы по восьми секциям, в БД не ходит и
§9.x-сервисы не зовёт. ВЫЗЫВАЮЩИЙ (этот модуль) обязан посчитать §9.x-слои и скормить
их сборщику. Здесь — этот вызывающий: ОРКЕСТРАЦИОННЫЙ ШОВ, НЕ новая §9.x-математика.
Что делает `build_site_finder_report`:
1. Дефолт сегмента (audit Q1): если `obj_class` не задан — выводим МОДАЛЬНЫЙ (самый
частый) класс конкурентов из `analyze["competitors"]`. Нет конкурентов / нет
классов → obj_class=None (ЕКБ-wide all-class).
2. Считает §9.x-слои, КАЖДЫЙ через `_safe_call` (зеркало product_scoring._safe_call):
сбой/тонкие данные одного слоя → этот слой None, отчёт всё равно собирается
(graceful: тонкий/сбойный слой ДЕГРАДИРУЕТ отчёт, НЕ роняет его целиком). Сами
§9.x-модули уже graceful внутри — это belt-and-suspenders на оркестрационном шве.
3. Раскладывает слои в `assemble_report` (каждый в свой слот, с ПРАВИЛЬНОЙ —
ГЕТЕРОГЕННОЙ! — сигнатурой: forecasts/scenarios/special_indices берут `horizons=`,
score_card/future_supply берут `horizon_months=`, overlay берёт `target_class=`).
ADVISORY: весь форсайт-стек советующий (cap ≤ 'medium', #990) — отчёт лишь СОБИРАЕТ
advisory-выводы, не основание для инвест-решения. `advisory=True` прокидывается всюду.
ГЕТЕРОГЕННЫЕ сигнатуры §9.x (audit поймал ловушки — НЕ унифицированы):
• compute_market_metrics(db, *, district, premise_kind) — §9.2
• compute_all_layers(db, *, district, premise_kind) — §9.3 (список строк)
• compute_future_supply_pressure(db, *, district, horizon_months) — §9.3 давление
• compute_demand_supply_forecast(db, *, spec, district, cad_num, horizons) — §9.8
• compute_scenarios(db, *, spec, district, cad_num, horizons) — §11
• compute_score_card(db, *, spec, district, cad_num, horizon_months) — §14.2
• compute_special_indices(db, *, spec, district, cad_num, horizons) — §25
• build_forecast_overlay(db, *, district, cad_num, horizon_months, target_class) — §10
МАКРО-СВЕЖЕСТЬ (audit MEDIUM, отложено в 3b): domrf-месячный макро ~123 дня устаревает
(key_rate свежий). У §22-схемы v1.0 (frozen `SiteFinderReport`/`ReportMeta`) НЕТ чистого
слота под caveat без смены контракта (новое поле + bump schema_version) — что вне 3a.
Поэтому здесь только ЛОГИРУЕМ `macro_as_of` (последний наблюдаемый месяц макро), чтобы
сигнал не терялся; проводка его в тело отчёта — follow-up 3b (вместе с endpoint/Celery).
"""
from __future__ import annotations
import logging
from collections import Counter
from collections.abc import Sequence
from typing import Any
from sqlalchemy.orm import Session
from app.services.forecast_request_cache import forecast_cache
from app.services.forecasting.demand_supply_forecast import compute_demand_supply_forecast
from app.services.forecasting.macro_series import get_monthly_macro
from app.services.forecasting.product_scoring import compute_score_card
from app.services.forecasting.recommendation import build_forecast_overlay
from app.services.forecasting.report import SiteFinderReport
from app.services.forecasting.report_assembler import assemble_report
from app.services.forecasting.sales_series import SegmentSpec
from app.services.forecasting.scenarios import compute_scenarios
from app.services.forecasting.special_indices import compute_special_indices
from app.services.site_finder.future_supply import compute_future_supply_pressure
from app.services.site_finder.market_metrics import compute_market_metrics
from app.services.site_finder.supply_layers import SupplyLayerRow, compute_all_layers
logger = logging.getLogger(__name__)
# Горизонты прогноза по умолчанию (мес) — зеркало §9.x default grid (#952/#984/#986).
_DEFAULT_HORIZONS: tuple[int, ...] = (6, 12, 18, 24)
# Основной горизонт для single-горизонтных сервисов (score_card / overlay / future_supply).
# Берём 12 (типовой средне-срочный продуктовый горизонт) если он в сетке, иначе первый.
_PREFERRED_PRIMARY_HORIZON: int = 12
# Тип помещения — единый дефолт всего §9.x-стека (жилой сегмент ЕКБ).
_PREMISE_KIND: str = "квартира"
# Весь отчёт советующий (зеркало advisory-cap стека) — прокидывается в #990 cap.
_ADVISORY: bool = True
# Маппинг layer-номера → ключ агрегата supply_layers-сводки (#950, §9.3).
_LAYER_UNIT_KEY: dict[int, str] = {1: "open_units", 2: "hidden_units", 3: "future_units"}
def _modal_obj_class(competitors: Any) -> str | None:
"""Вывести МОДАЛЬНЫЙ (самый частый) класс конкурентов как дефолт сегмента. PURE.
audit Q1: user-дефолт сегмента = модальный класс конкуренции вокруг участка. Читаем
`obj_class` (или legacy-ключ `class`) каждого dict'а-конкурента, считаем Counter,
берём самый частый. Ничьи разрешаются детерминированно (по первому встреченному —
стабильный порядок Counter.most_common для равных частот).
Graceful: competitors не список / пуст / ни у одного нет класса → None
(ЕКБ-wide all-class сегмент — честно «класс не уточнён»).
Args:
competitors: `analyze["competitors"]` (loosely-typed: ожидаем список dict'ов).
Returns:
Модальный класс (str) или None.
"""
if not isinstance(competitors, list):
return None
classes: list[str] = []
for comp in competitors:
if not isinstance(comp, dict):
continue
value = comp.get("obj_class")
if not (isinstance(value, str) and value):
value = comp.get("class")
if isinstance(value, str) and value:
classes.append(value)
if not classes:
return None
# most_common(1) — детерминированно: при равенстве частот сохраняется порядок вставки.
return Counter(classes).most_common(1)[0][0]
def _primary_horizon(horizons: Sequence[int]) -> int:
"""Основной горизонт для single-горизонтных сервисов. PURE.
_PREFERRED_PRIMARY_HORIZON (12 мес) если он в сетке, иначе первый горизонт сетки.
Пустая сетка не ожидается (вызывающий даёт дефолт), но на всякий случай → 12.
"""
if _PREFERRED_PRIMARY_HORIZON in horizons:
return _PREFERRED_PRIMARY_HORIZON
return horizons[0] if horizons else _PREFERRED_PRIMARY_HORIZON
def _safe_call(label: str, fn: Any) -> Any:
"""Вызвать §9.x-сервис graceful: сбой → None + logger.exception (не crash отчёта).
Зеркало product_scoring._safe_call: любой §9.x-слой может бросить (тонкие данные / нет
геометрии cad_num / сбой SQL) — логируем и возвращаем None, чтобы соответствующая
секция отчёта осталась пустой/частичной, а остальной отчёт собрался. Намеренно ловим
широкий Exception (изоляция одного слоя от отчёта) с ОБЯЗАТЕЛЬНЫМ logger.exception —
НЕ молчаливое глотание. §9.x уже graceful внутри; это belt-and-suspenders на шве.
Args:
label: имя слоя для лога (диагностика какой §9.x-вызов деградировал).
fn: нулевой-аргумент thunk вокруг §9.x-вызова.
Returns:
Результат `fn()` или None при исключении.
"""
try:
return fn()
except Exception:
logger.exception("orchestrator: §9.x layer %s failed → section degraded", label)
return None
def _summarize_supply_layers(rows: Sequence[SupplyLayerRow] | None) -> dict[str, Any] | None:
"""Свернуть строки §9.3-слоёв (#950) в сводку-dict для слота `supply_layers`. PURE.
`compute_all_layers` отдаёт ПЛОСКИЙ список SupplyLayerRow (L1+L2+L3). Слот
`supply_layers` сборщика (#988 `_build_market_now`/`_domrf_coverage`) ждёт ОДИН dict,
не список — поэтому агрегируем: Σ units по слою (open/hidden/future_units) + сырой
список строк (`rows`) для прозрачности. domrf_coverage здесь НЕ выводим (нет дешёвого
продьюсера в сервисах) — сборщик возьмёт fallback из `analyze.market_data_coverage_pct`.
Graceful: None/пустой список → None (слот останется пустым — секция частична).
Args:
rows: вывод `compute_all_layers` (или None при сбое слоя).
Returns:
Сводка-dict (open_units/hidden_units/future_units/n_rows/rows) или None.
"""
if not rows:
return None
units_by_key: dict[str, int] = {"open_units": 0, "hidden_units": 0, "future_units": 0}
row_dicts: list[dict[str, Any]] = []
for row in rows:
row_dicts.append(row.as_dict())
key = _LAYER_UNIT_KEY.get(int(row.layer))
if key is not None and row.units_estimate is not None:
units_by_key[key] += int(row.units_estimate)
return {
"open_units": units_by_key["open_units"],
"hidden_units": units_by_key["hidden_units"],
"future_units": units_by_key["future_units"],
"n_rows": len(row_dicts),
"rows": row_dicts,
}
def _macro_as_of(macro: Any) -> str | None:
"""Последний наблюдаемый месяц макро-ряда (ISO) — для caveat-лога свежести. PURE.
`get_monthly_macro` отдаёт MonthlyMacro по ВОЗРАСТАНИЮ month по непрерывной сетке;
последний элемент — самый поздний месяц с (carry-forward) данными. Используем только
для ЛОГА свежести (audit MEDIUM) — проводка в тело отчёта отложена в 3b.
Graceful: пустой/None ряд → None.
"""
if not macro:
return None
last = macro[-1]
month = getattr(last, "month", None)
isoformat = getattr(month, "isoformat", None)
if callable(isoformat):
return isoformat()
return str(month) if month is not None else None
def _normalize_district(district: Any) -> str | None:
"""Привести `district` к str|None — защита от silent-degrade при dict-входе. PURE.
`analyze["district"]` в этой кодовой базе — `dict` вида
``{"district_name": "Верх-Исетский", "dist_to_center": 0.0, "median_price_per_m2": ...}``.
Штатный caller (`workers/tasks/forecast.py:123`) явно извлекает `district_name`:
`district = row.district or analyze["district"]["district_name"]`. Но новые callers
(тесты, расширения чата, ad-hoc эндпоинты) легко передают сырой dict без знания
этой конвенции — тогда внутри §9.x-слоёв `compute_market_metrics(district=<dict>)`
падает с `TypeError: unhashable type: 'dict'` в `forecast_request_cache.wrapper`,
`_safe_call` это проглатывает → секции `future_market.forecasts_by_horizon=[]` и
`scenarios.by_scenario={}` тихо остаются пустыми (silent degrade, не 500).
Здесь — defensive normalization на входе оркестратора: dict → `district_name`,
str → как есть, None → None, всё остальное → None (с warning'ом).
"""
if district is None or isinstance(district, str):
return district
if isinstance(district, dict):
name = district.get("district_name")
if isinstance(name, str) and name:
return name
logger.warning(
"build_site_finder_report: district передан как dict без district_name (%r) — None",
sorted(district.keys()),
)
return None
logger.warning(
"build_site_finder_report: district неподдерживаемого типа %s — None",
type(district).__name__,
)
return None
def build_site_finder_report(
db: Session,
*,
analyze: dict[str, Any],
cad_num: str,
district: str | None,
obj_class: str | None = None,
horizons: Sequence[int] = _DEFAULT_HORIZONS,
) -> SiteFinderReport:
"""Посчитать §9.x-слои и собрать §22 `SiteFinderReport` для участка (ОРКЕСТРАЦИЯ).
PERF (#1129): активируем per-отчёт кэш (`forecast_cache`) на ВСЮ сборку. §9.x-слои
массово пере-вызывают одни и те же горизонт/сегмент-инвариантные БД-загрузки с
совпадающими аргументами (профиль: get_competitors ×69, market_metrics ×124,
get_monthly_macro ×290 на один отчёт) — кэш считает каждую уникальную загрузку ОДИН
раз и переиспользует тот же (frozen, read-only) результат. Вывод ПОБАЙТОВО идентичен
несжатому пути (детерминизм §16/§26 сохранён); ускорение — чисто за счёт устранения
дублирующих roundtrip'ов, БЕЗ конкурентности. Кэш живёт ровно одну сборку (на выходе
из `with` отбрасывается — никакой кросс-отчётной утечки).
DEFENSIVE (#1130 follow-up): `district` нормализуется на входе — если caller передал
сырой dict из `analyze["district"]` (а не извлечённое `district_name`), достанем имя.
Подробности — `_normalize_district`. PURE-нормализация, без изменения остального флоу.
"""
district = _normalize_district(district)
with forecast_cache():
return _build_site_finder_report_impl(
db,
analyze=analyze,
cad_num=cad_num,
district=district,
obj_class=obj_class,
horizons=horizons,
)
def _build_site_finder_report_impl(
db: Session,
*,
analyze: dict[str, Any],
cad_num: str,
district: str | None,
obj_class: str | None = None,
horizons: Sequence[int] = _DEFAULT_HORIZONS,
) -> SiteFinderReport:
"""Реальная сборка §22-отчёта (тело #1056). Вызывается ВНУТРИ `forecast_cache()`.
ШОВ между §9.x-сервисами (которые ходят в БД) и ЧИСТЫМ §22-сборщиком (#988
`assemble_report`, который только раскладывает уже-посчитанные входы). НЕ новая
§9.x-математика — только проводка: считаем слои → раскладываем в сборщик.
Поток:
1. Дефолт сегмента: obj_class=None → модальный класс конкурентов из `analyze`
(audit Q1). Строим `spec = SegmentSpec(obj_class=…, district=…)`.
2. Считаем восемь §9.x-слоёв, КАЖДЫЙ через `_safe_call` (graceful: сбой слоя →
None → его секция деградирует, отчёт собирается). Сигнатуры ГЕТЕРОГЕННЫ
(см. module docstring): forecasts/scenarios/special_indices ← `horizons=`;
score_card/future_supply ← `horizon_months=`; overlay ← `target_class=`.
3. Раскладываем в `assemble_report` (каждый слой в свой слот; supply-строки
сворачиваем в сводку-dict; overlay идёт в `recommendation_overlay=`).
МАКРО-СВЕЖЕСТЬ (audit MEDIUM): логируем `macro_as_of`; проводка в отчёт — 3b
(нет чистого слота в схеме v1.0 без смены контракта). См. module docstring.
Args:
db: SQLAlchemy sync Session (read-only по факту — §9.x только читают).
analyze: dict вывода analyze_parcel (loosely-typed; читаем defensive `.get()`).
cad_num: кадастровый номер участка (вход §9.7 конкурентов + центроид POI).
district: район участка (None → ЕКБ-wide; §9.x резолвят админ-имя → микро).
obj_class: целевой класс; None → выводим модальный класс конкурентов (Q1).
horizons: горизонты прогноза (мес; по умолчанию _DEFAULT_HORIZONS).
Returns:
SiteFinderReport — восемь секций §22, заполнены настолько, насколько хватило
§9.x-слоёв (частичный отчёт валиден); advisory=True; `as_dict()` JSON-safe.
"""
horizon_list = list(horizons)
primary = _primary_horizon(horizon_list)
# ── 1. Дефолт сегмента: модальный класс конкурентов (audit Q1) ──────────────
resolved_class = (
obj_class if obj_class is not None else _modal_obj_class(analyze.get("competitors"))
)
spec = SegmentSpec(obj_class=resolved_class, district=district)
logger.info(
"build_site_finder_report: cad_num=%s district=%s obj_class=%s (given=%s) "
"horizons=%s primary=%d",
cad_num,
district,
resolved_class,
obj_class,
horizon_list,
primary,
)
# ── 2. §9.x-слои, каждый graceful через _safe_call (ГЕТЕРОГЕННЫЕ сигнатуры) ──
market_metrics = _safe_call(
"market_metrics",
lambda: compute_market_metrics(db, district=district, premise_kind=_PREMISE_KIND),
)
supply_rows = _safe_call(
"supply_layers",
lambda: compute_all_layers(db, district=district, premise_kind=_PREMISE_KIND),
)
supply_layers = _summarize_supply_layers(supply_rows)
future_supply = _safe_call(
"future_supply",
lambda: compute_future_supply_pressure(
db, district=district, horizon_months=primary, premise_kind=_PREMISE_KIND
),
)
forecasts = _safe_call(
"demand_supply_forecast",
lambda: compute_demand_supply_forecast(
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
),
)
# §22 audit-issue #1871 P1: compute_scenarios теперь возвращает кортеж
# `(scenarios, collapsed, collapse_reason)` — collapse-флаг идёт в payload отчёта,
# чтобы фронт показал ПОЧЕМУ три сценария идентичны (failed β-gate в §9.6).
# _safe_call оборачивает любой сбой → None → штатно деградируем (collapse=False).
scenarios_result = _safe_call(
"scenarios",
lambda: compute_scenarios(
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
),
)
if scenarios_result is None:
scenarios = None
scenarios_collapsed = False
scenarios_collapse_reason: str | None = None
else:
scenarios, scenarios_collapsed, scenarios_collapse_reason = scenarios_result
product_scores = _safe_call(
"score_card",
lambda: compute_score_card(
db, spec=spec, district=district, cad_num=cad_num, horizon_months=primary
),
)
special_indices = _safe_call(
"special_indices",
lambda: compute_special_indices(
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
),
)
recommendation_overlay = _safe_call(
"forecast_overlay",
lambda: build_forecast_overlay(
db,
district=district,
cad_num=cad_num,
horizon_months=primary,
target_class=resolved_class,
),
)
# ── Макро-свежесть (audit MEDIUM): только лог; проводка в отчёт — 3b ─────────
macro = _safe_call("monthly_macro", lambda: get_monthly_macro(db))
macro_as_of = _macro_as_of(macro)
if macro_as_of is not None:
logger.info(
"build_site_finder_report: macro_as_of=%s (domrf monthly may be stale; "
"surfacing into report deferred to 3b)",
macro_as_of,
)
# ── 3. Раскладка в ЧИСТЫЙ §22-сборщик (#988) — каждый слой в свой слот ───────
return assemble_report(
analyze,
market_metrics=market_metrics,
supply_layers=supply_layers,
forecasts=forecasts,
future_supply=future_supply,
scenarios=scenarios,
scenarios_collapsed=scenarios_collapsed,
scenarios_collapse_reason=scenarios_collapse_reason,
recommendation_overlay=recommendation_overlay,
product_scores=product_scores,
special_indices=special_indices,
segment=spec.as_dict(),
cad_num=cad_num,
district=district,
horizons=horizon_list,
advisory=_ADVISORY,
)