diff --git a/backend/app/services/forecasting/recommendation.py b/backend/app/services/forecasting/recommendation.py index fb06020b..1dbedad6 100644 --- a/backend/app/services/forecasting/recommendation.py +++ b/backend/app/services/forecasting/recommendation.py @@ -525,6 +525,9 @@ def _demand_supply_overlay( "obj_class": seg.segment.get("obj_class"), "deficit_index": seg.deficit_index, "balance_units": seg.balance_units, + # #1745: спрос на горизонт (квартир) — для таблицы прогноза по форматам. + # demand_only его не несёт (без геометрии supply/спрос-проекция неизмеримы). + "projected_demand_units": seg.projected_demand_units, "confidence": seg.confidence, } ) diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index cd45e8f1..f393a284 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -84,6 +84,10 @@ _LEVEL_RU: dict[ReportConfidenceLevel, str] = { # special_indices._VOID_THRESHOLD: умеренно-сильный дефицит на лог-шкале #980 [−1,+1]). _STRONG_DEFICIT_THRESHOLD: float = 0.25 +# Нейтральная зона |deficit_index|, внутри которой рынок считаем сбалансированным +# (#1745 «баланс»). Зеркало frontend forecast-helpers.DEFICIT_BALANCE_EPS = 0.05. +_SIGNAL_BALANCE_EPS: float = 0.05 + # Сегментный горизонт по умолчанию для извлечения сигналов из forecasts (мес). Берём # 12 — типовой средне-срочный продуктовый горизонт (зеркало #982/#983/#986 default). _PRIMARY_HORIZON_MONTHS: int = 12 @@ -479,12 +483,16 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]: Overlay #983 не несёт готового `mix`, но его `ranked_segments` (DESC по дефициту) — продуктовый ответ «какой формат строить». Берём `mix` если задан, иначе проецируем - ranked_segments в компактные {bucket, obj_class, deficit_index}. Нет ни того, ни - другого → []. + ranked_segments в компактные {bucket, obj_class, deficit_index, projected_demand_units, + signal} (#1745 — таблица прогноза по форматам). Нет ни того, ни другого → []. + + #1745 ADDITIVE: проброс `projected_demand_units` (прогноз спроса, квартир) и + деривированного `signal` («строить»/«баланс»/«избегать») из deficit_index. Старые + потребители mix читают bucket/obj_class/deficit_index — новые поля их не ломают. """ explicit = product_tz.get("mix") if isinstance(explicit, list): - return [m for m in explicit if isinstance(m, dict)] + return [_enrich_mix_entry(m) for m in explicit if isinstance(m, dict)] ranked = product_tz.get("ranked_segments") if isinstance(ranked, list): return [ @@ -492,6 +500,8 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]: "bucket": seg.get("bucket"), "obj_class": seg.get("obj_class"), "deficit_index": seg.get("deficit_index"), + "projected_demand_units": seg.get("projected_demand_units"), + "signal": _build_signal(seg.get("deficit_index")), } for seg in ranked if isinstance(seg, dict) @@ -499,6 +509,35 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]: return [] +def _enrich_mix_entry(entry: dict[str, Any]) -> dict[str, Any]: + """Дополнить явную mix-ячейку деривированным `signal` (#1745). PURE. + + Не перетирает уже заданные поля (явный `mix` мог нести свой `signal`); добавляет + `signal` из `deficit_index` только если его нет. Возвращает НОВЫЙ dict (не мутирует + вход — assembler-чистота). + """ + enriched = dict(entry) + if enriched.get("signal") is None: + enriched["signal"] = _build_signal(enriched.get("deficit_index")) + return enriched + + +def _build_signal(deficit_index: Any) -> str | None: + """Сигнал «строить»/«баланс»/«избегать» из deficit_index ∈ [−1,1] (#1745). PURE. + + Зеркало deficit-семантики §22 (frontend forecast-helpers.deficitWord): >+0.05 + недонасыщенность → «строить», <−0.05 затоварка → «избегать», иначе «баланс». + deficit_index None (тонкие данные) → None (фронт рисует «тонкие данные», НЕ 0/сигнал). + """ + if not isinstance(deficit_index, (int, float)) or isinstance(deficit_index, bool): + return None + if deficit_index > _SIGNAL_BALANCE_EPS: + return "строить" + if deficit_index < -_SIGNAL_BALANCE_EPS: + return "избегать" + return "баланс" + + def _extract_list(source: dict[str, Any], key: str) -> list[dict[str, Any]]: """Достать список dict'ов по ключу (мусор/None → []). PURE.""" value = source.get(key) diff --git a/backend/app/services/forecasting/what_to_build.py b/backend/app/services/forecasting/what_to_build.py index 761838cb..55fc99d0 100644 --- a/backend/app/services/forecasting/what_to_build.py +++ b/backend/app/services/forecasting/what_to_build.py @@ -99,12 +99,14 @@ class RankedSegment: deficit_index: float # не None в ранкинге (None-ячейки отброшены) balance_units: float | None # demand − supply (>0 дефицит / <0 затоварка) confidence: Confidence # ≤ 'medium' (#980 advisory-cap) + projected_demand_units: float | None = None # #1745: спрос на горизонт (квартир) def as_dict(self) -> dict[str, Any]: return { "segment": dict(self.segment), "deficit_index": _round_or_none(self.deficit_index, 3), "balance_units": _round_or_none(self.balance_units, 1), + "projected_demand_units": _round_or_none(self.projected_demand_units, 1), "confidence": self.confidence, } @@ -359,4 +361,5 @@ def _forecast_cell( deficit_index=forecast.deficit_index, balance_units=forecast.balance_units, confidence=forecast.confidence, + projected_demand_units=forecast.projected_demand_units, ) diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index f80f6349..5bfed1e0 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useQueryClient } from "@tanstack/react-query"; import { ChatPanel } from "@/components/site-finder/ChatPanel"; +import { GateVerdictBanner } from "@/components/site-finder/GateVerdictBanner"; import { HorizonSelector } from "@/components/site-finder/HorizonSelector"; import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo"; import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities"; @@ -160,22 +161,48 @@ export function AnalysisPageContent({ cad }: Props) { {/* ── Main scroll area ──────────────────────────────────────────── */}