gendesign/backend/app/services/forecasting/report_assembler.py
Light1YT 01a74ade7a
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m21s
Deploy / build-worker (push) Successful in 2m17s
Deploy / deploy (push) Successful in 1m12s
feat(forecasting): add months-of-inventory (MOI) to §9.8 demand-supply forecast
deficit_index pins to -1.0 for every ЕКБ segment (12mo demand flow vs multi-year
supply stock → log-ratio clamps) → zero discriminating power, though the oversupply
is partly real. Add MOI (gross competing supply / demand_per_mo), the real-estate
absorption standard, as an additive non-saturating companion that DISCRIMINATES
(Уралмаш 42mo … Чермет 109mo) where deficit cannot. deficit_index math kept exactly
as-is (honest absolute: -1 = genuinely oversupplied); docstrings clarify -1 is common
and MOI is the discriminating companion (no recalibration). _gross_supply extract-method
(single source of truth; _project_supply behavior byte-identical, code-review-verified).
Surface MOI in §22 future_market (passthrough) + exec_summary key_numbers/verdict.
Guards: no demand → None, no supply → 0. Prod: MOI varies 42→109mo, deficit stays -1.
Discrimination test pins MOI separating two segments both at deficit -1. Refs #952.
2026-06-05 07:36:59 +05:00

942 lines
48 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 сборщик итогового советующего отчёта Site Finder v2 — ЧИСТОЕ маппирование.
#988 (955-A2, Site Finder v2 / «GG-форсайт» ТЗ §13), EPIC 11 «Отчёт». Это **СБОРЩИК**
(assembler): он НАПОЛНЯЕТ структурный контейнер `SiteFinderReport` (#987) уже-
посчитанными advisory-выводами форсайт-стека (#980…#986 / §9.1-9.3) + dict'ом
`analyze` (вывод эндпоинта analyze_parcel) и считает отчётную уверенность через
`compute_report_confidence` (#990). Своей §9.x-математики НЕ пересобирает и в БД НЕ
ходит — берёт уже-готовые входы (их считает будущий вызывающий: #961 API / #957 чат)
и РАСКЛАДЫВАЕТ их по восьми типизированным секциям §13. Поэтому он ЮНИТ-ТЕСТИРУЕТСЯ
БЕЗ БД. Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM (шаблонные RU-строки, не генерация).
ADVISORY (зеркало всего стека #980…#986 / #987 / #990): отчёт лишь СОБИРАЕТ advisory-
выводы — `advisory` ВСЕГДА True, прокидывается во все под-вызовы (в т.ч. cap-потолок
#990 'medium'). Не основание для инвест-решения.
ВОСЕМЬ СЕКЦИЙ §13 — ИЗ ЧЕГО собирается каждая:
1. exec_summary — СИНТЕЗ заголовка/вердикта/ключевых чисел из сильнейших сигналов
(deficit_index, рекомендованный класс/mix, overall-скор, уверенность). Шаблонные
RU-строки (НЕ LLM).
2. market_now — из `analyze` dict (абсорбция/цены/конкуренты/POI) + `market_metrics`
(§9.2) + `supply_layers` (§9.3). Defensive `.get()` (analyze loosely-typed).
3. future_market — `forecasts` (per-горизонт #952), `future_supply` (§9.3), будущие
конкуренты, сводка `scenarios` (#984).
4. product_tz — `recommendation_overlay` (#983: класс §10.2 / mix / commercial §10.4
/ USP §10.5 + §16-причины).
5. scenarios — `scenarios` (три ScenarioForecast #984, by_scenario).
6. scoring — `product_scores` (#985, 10 скоров) + `special_indices` (#986, 6) +
overall.
7. confidence — `compute_report_confidence(...)` (#990) из извлечённых сигналов
качества данных (deal_count/analog_count/domrf_coverage/history_months/confounded +
component-confidences). ReportConfidenceResult.as_dict() ложится в слот.
8. meta — cad_num/district/segment/horizons/advisory/schema_version.
GRACEFUL (дух всего стека): любой вход None → его секция частично заполнена / пуста —
отчёт ВАЛИДЕН как частичный (#987: все поля Optional). НИКОГДА не бросает: каждый вход
нормализуется через `_as_dict_or` (dataclass с `.as_dict()` ИЛИ уже-dict ИЛИ None),
чтобы вызывающий мог передать как объекты, так и их `.as_dict()`-словари.
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from typing import Any
from app.services.forecasting.confidence_engine import (
Confidence,
compute_report_confidence,
)
from app.services.forecasting.report import (
ReportConfidence,
ReportConfidenceLevel,
ReportExecSummary,
ReportFutureMarket,
ReportMarketNow,
ReportMeta,
ReportProductTz,
ReportScenarios,
ReportScoring,
SiteFinderReport,
)
logger = logging.getLogger(__name__)
# ── Named-константы ───────────────────────────────────────────────────────────
# Горизонты прогноза по умолчанию (мес) — зеркало #952/#984 _DEFAULT_HORIZONS.
_DEFAULT_HORIZONS: tuple[int, ...] = (6, 12, 18, 24)
# Весь отчёт советующий (зеркало advisory-cap всего стека) — прокидывается в #990 cap.
_ADVISORY: bool = True
# Ранг уверенности (для выбора «худшего» компонента при синтезе exec_summary).
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
# RU-метки уровня уверенности для exec_summary-вердикта.
_LEVEL_RU: dict[ReportConfidenceLevel, str] = {
"high": "высокая",
"medium": "средняя",
"low": "низкая",
}
# Порог deficit_index, выше которого сигнал «строить» считаем сильным (зеркало духа
# special_indices._VOID_THRESHOLD: умеренно-сильный дефицит на лог-шкале #980 [1,+1]).
_STRONG_DEFICIT_THRESHOLD: float = 0.25
# Сегментный горизонт по умолчанию для извлечения сигналов из forecasts (мес). Берём
# 12 — типовой средне-срочный продуктовый горизонт (зеркало #982/#983/#986 default).
_PRIMARY_HORIZON_MONTHS: int = 12
def _as_dict_or(x: Any) -> dict[str, Any] | None:
"""Нормализовать вход в JSON-safe dict: dataclass-с-`as_dict()` / dict / None. PURE.
Вызывающий (#961 API / #957 чат) может передать КАК объект под-сервиса (например
`MarketMetrics`), ТАК и его уже-посчитанный `.as_dict()`-словарь. Приводим к единой
форме (плоский JSON-safe dict):
• None → None (graceful — секция останется пустой).
• объект с методом `as_dict` → `x.as_dict()` (под-сервис сам отдаёт JSON-safe вид).
• уже-dict → как есть.
Любой иной тип (мусор) → None (НЕ бросаем — отчёт валиден как частичный).
Args:
x: dataclass-инстанс под-сервиса, его `.as_dict()`-словарь, или None.
Returns:
JSON-safe dict или None.
"""
if x is None:
return None
as_dict = getattr(x, "as_dict", None)
if callable(as_dict):
result = as_dict()
return result if isinstance(result, dict) else None
if isinstance(x, dict):
return x
return None
def _as_dict_list(items: Sequence[Any] | None) -> list[dict[str, Any]]:
"""Нормализовать последовательность входов в список JSON-safe dict'ов. PURE.
Каждый элемент через `_as_dict_or`; None-элементы (нормализовавшиеся в None)
отбрасываем. None/пустой вход → [] (graceful). Сохраняет порядок.
"""
if not items:
return []
out: list[dict[str, Any]] = []
for item in items:
normalized = _as_dict_or(item)
if normalized is not None:
out.append(normalized)
return out
# ──────────────────────────────────────────────────────────────────────────────
# Извлечение сигналов качества данных для §15-уверенности (#990). Каждый pure,
# graceful: вход None/тонкий → None (#990 трактует None как «нет сигнала»).
# ──────────────────────────────────────────────────────────────────────────────
def _deal_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None) -> int | None:
"""Число сделок (продаж) за окно — для deal_count #990. PURE.
Предпочитаем `market_metrics.n_sold` (§9.2 — прямой счётчик проданных лотов выборки);
fallback на `analyze.market_pulse.competitors_total` НЕ берём (это число ЖК, не
сделок — пошло бы в analog_count). Нет сигнала → None (#990 → тянет в low).
"""
if market_metrics is not None:
n_sold = market_metrics.get("n_sold")
if isinstance(n_sold, int):
return n_sold
return None
def _analog_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None) -> int | None:
"""Число ЖК-аналогов в выборке — для analog_count #990. PURE.
Предпочитаем `market_metrics.obj_count` (§9.2 — число отдельных ЖК выборки, ТОЧНО
то, что #990 ждёт). Fallback — число конкурентов из `analyze` (market_pulse
competitors_total ИЛИ длина списка competitors): это окружение участка, тоже мера
«сколько объектов-аналогов рядом». Нет сигнала → None.
"""
if market_metrics is not None:
obj_count = market_metrics.get("obj_count")
if isinstance(obj_count, int):
return obj_count
pulse = analyze.get("market_pulse")
if isinstance(pulse, dict):
total = pulse.get("competitors_total")
if isinstance(total, int):
return total
competitors = analyze.get("competitors")
if isinstance(competitors, list):
return len(competitors)
return None
def _domrf_coverage(analyze: dict[str, Any], supply_layers: dict[str, Any] | None) -> float | None:
"""Покрытие domrf↔objective ∈ [0,1] — для domrf_coverage #990. PURE.
Главный sparse-риск проекта (~2.5%). Источники по приоритету (единица ЯВНАЯ
per-branch — НЕ угадываем по величине, иначе настоящий sub-1% процент типа 0.8%
спутался бы с долей 0.8 = 80% и инфлировал бы confidence в exactly near-zero кейсе,
который §15 призван флагать):
• `supply_layers.domrf_coverage` — уже ДОЛЯ ∈ [0,1] (0.025) → берём как есть.
• `analyze.market_data_coverage_pct` — всегда ПРОЦЕНТ (2.5 == 2.5%) → /100 → доля.
Нет сигнала → None (#990 → тянет в low: слой §9.3 недооценён).
"""
if supply_layers is not None:
coverage = supply_layers.get("domrf_coverage")
if isinstance(coverage, (int, float)) and not isinstance(coverage, bool):
return _clamp_fraction(float(coverage))
pct = analyze.get("market_data_coverage_pct")
if isinstance(pct, (int, float)) and not isinstance(pct, bool):
return _clamp_fraction(float(pct) / 100.0)
return None
def _clamp_fraction(value: float) -> float:
"""Зажать долю покрытия в [0,1] (защита от грязных данных). PURE.
Единица приводится у источника (`_domrf_coverage`: percent-ветка делит на 100,
fraction-ветка — как есть) — здесь только clamp, БЕЗ догадок про percent-vs-fraction.
"""
return max(0.0, min(1.0, value))
def _history_months(
market_metrics: dict[str, Any] | None, forecasts: Sequence[dict[str, Any]]
) -> int | None:
"""Глубина ряда (мес) — для history_months #990. PURE.
`market_metrics.window_months` (§9.2 окно наблюдения продаж) — единственный явный
сигнал глубины истории в доступных входах. Нет → None (#990 → короткий ряд тянет
вниз). forecasts оставлены в сигнатуре для будущего расширения (на сейчас ряд
глубины в них не выносится).
"""
if market_metrics is not None:
window = market_metrics.get("window_months")
if isinstance(window, int) and window > 0:
return window
return None
def _confounded(forecasts: Sequence[dict[str, Any]]) -> bool:
"""Пересекает ли окно прогноза шок-период — для confounded #990. PURE.
Любой per-горизонт forecast несёт флаг `confounded`/`is_confounded_window` (PR2)?
Если хоть один True → отчётное окно считаем confounded (оценки смещены, #990 →
НИКОГДА не 'high'). Нет флага нигде → False (чистое окно).
"""
for f in forecasts:
if f.get("confounded") is True or f.get("is_confounded_window") is True:
return True
return False
def _component_confidences(
market_metrics: dict[str, Any] | None,
future_supply: dict[str, Any] | None,
forecasts: Sequence[dict[str, Any]],
product_scores: dict[str, Any] | None,
special_indices: dict[str, Any] | None,
) -> list[Confidence]:
"""Собрать per-service confidence вкладывающих под-сервисов — для #990. PURE.
#990 свернёт их weakest-link (MIN) вместе с сырыми счётчиками. Берём confidence-
метку каждого доступного под-вывода (market_metrics §9.2 / future_supply §9.3 /
каждый per-горизонт forecast #952 / product_scores #985 / special_indices #986).
Только whitelisted 'high|medium|low' (мусор/None отбрасываем).
"""
out: list[Confidence] = []
for source in (market_metrics, future_supply, product_scores, special_indices):
conf = _confidence_of(source)
if conf is not None:
out.append(conf)
for f in forecasts:
conf = _confidence_of(f)
if conf is not None:
out.append(conf)
return out
def _confidence_of(source: dict[str, Any] | None) -> Confidence | None:
"""Достать whitelisted confidence-метку из под-вывода (или None). PURE."""
if not isinstance(source, dict):
return None
conf = source.get("confidence")
if conf in _CONFIDENCE_RANK:
return conf # type: ignore[return-value] # сужено проверкой членства
return None
# ──────────────────────────────────────────────────────────────────────────────
# Извлечение опорных чисел для exec_summary-синтеза. Каждый pure, graceful → None.
# ──────────────────────────────────────────────────────────────────────────────
def _primary_deficit_index(forecasts: Sequence[dict[str, Any]]) -> float | None:
"""deficit_index основного горизонта (или первого доступного). PURE.
Предпочитаем _PRIMARY_HORIZON_MONTHS (12 мес — продуктовый горизонт); если его нет —
первый forecast с не-None deficit_index. Нет → None.
"""
primary = next(
(f for f in forecasts if f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS),
None,
)
if primary is not None and primary.get("deficit_index") is not None:
di = primary["deficit_index"]
return float(di) if isinstance(di, (int, float)) and not isinstance(di, bool) else None
for f in forecasts:
di = f.get("deficit_index")
if isinstance(di, (int, float)) and not isinstance(di, bool):
return float(di)
return None
def _primary_months_of_inventory(forecasts: Sequence[dict[str, Any]]) -> float | None:
"""months_of_inventory основного горизонта (или первого доступного). PURE.
Зеркало `_primary_deficit_index`, но для MOI (#952 ДИСКРИМИНИРУЮЩИЙ companion к
дефициту). Предпочитаем _PRIMARY_HORIZON_MONTHS (12 мес); иначе первый forecast
с не-None months_of_inventory. Нет → None.
"""
primary = next(
(f for f in forecasts if f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS),
None,
)
if primary is not None and primary.get("months_of_inventory") is not None:
moi = primary["months_of_inventory"]
return float(moi) if isinstance(moi, (int, float)) and not isinstance(moi, bool) else None
for f in forecasts:
moi = f.get("months_of_inventory")
if isinstance(moi, (int, float)) and not isinstance(moi, bool):
return float(moi)
return None
def _recommended_class(product_tz: dict[str, Any] | None) -> str | None:
"""Рекомендованный класс из product_tz-вывода (overlay class_reco / obj_class). PURE."""
if not isinstance(product_tz, dict):
return None
obj_class = product_tz.get("obj_class")
if isinstance(obj_class, str) and obj_class:
return obj_class
class_reco = product_tz.get("class_reco")
if isinstance(class_reco, dict):
reco_class = class_reco.get("obj_class")
if isinstance(reco_class, str) and reco_class:
return reco_class
return None
def _overall_score(product_scores: dict[str, Any] | None) -> float | None:
"""overall-скор из карты #985 (ProductScoreCard.as_dict()['overall']). PURE."""
if not isinstance(product_scores, dict):
return None
overall = product_scores.get("overall")
if isinstance(overall, (int, float)) and not isinstance(overall, bool):
return float(overall)
return None
# ──────────────────────────────────────────────────────────────────────────────
# Per-section builders — каждый PURE, graceful (входы None → пустая/частичная секция).
# ──────────────────────────────────────────────────────────────────────────────
def _build_market_now(
analyze: dict[str, Any],
market_metrics: dict[str, Any] | None,
supply_layers: dict[str, Any] | None,
) -> ReportMarketNow:
"""§13.2 market_now — текущий рынок из analyze + §9.2/§9.3. PURE, defensive.
`market_metrics` (§9.2) / `supply_layers` (§9.3) кладём как есть (их as_dict уже
JSON-safe). `competitors` берём из analyze (defensive: список dict'ов конкурентов).
`summary` синтезируем из доступных абсорбции/цены/числа конкурентов. analyze loosely-
typed → всё через `.get()`.
"""
competitors = analyze.get("competitors")
competitor_list = (
[c for c in competitors if isinstance(c, dict)] if isinstance(competitors, list) else []
)
summary = _market_now_summary(analyze, market_metrics)
return ReportMarketNow(
market_metrics=market_metrics,
competitors=competitor_list,
supply_layers=supply_layers,
summary=summary,
)
def _market_now_summary(
analyze: dict[str, Any], market_metrics: dict[str, Any] | None
) -> str | None:
"""Короткий RU-текст про текущий рынок из доступных сигналов. PURE, шаблонный.
Собирает фразу из абсорбции (§9.2 unit_velocity), средней цены (analyze) и числа
конкурентов. Ни одного сигнала → None (graceful — секция без summary валидна).
"""
parts: list[str] = []
if market_metrics is not None:
velocity = market_metrics.get("unit_velocity")
if isinstance(velocity, (int, float)) and not isinstance(velocity, bool):
parts.append(f"абсорбция ~{round(float(velocity), 1)} ед./мес")
avg_price = analyze.get("market_avg_price_per_m2")
if isinstance(avg_price, (int, float)) and not isinstance(avg_price, bool):
parts.append(f"средняя цена ~{round(float(avg_price)):,} ₽/м²".replace(",", " "))
n_comp = _analog_count(analyze, market_metrics)
if n_comp is not None:
parts.append(f"{n_comp} ЖК-конкурентов рядом")
if not parts:
return None
return "Текущий рынок: " + ", ".join(parts) + "."
def _build_future_market(
forecasts: Sequence[dict[str, Any]],
future_supply: dict[str, Any] | None,
future_competitors: list[dict[str, Any]],
scenarios_summary: dict[str, Any] | None,
) -> ReportFutureMarket:
"""§13.3 future_market — прогноз из forecasts/future_supply/scenarios. PURE.
`forecasts_by_horizon` — список per-горизонт #952; `future_supply` (§9.3) как есть;
`future_competitors` — будущие конкуренты (из forecasts или явный аргумент);
`scenarios_summary` — сводка #984. `summary` синтезируем из дефицита основного
горизонта. Всё пустое → пустая секция (graceful).
"""
summary = _future_market_summary(forecasts)
return ReportFutureMarket(
forecasts_by_horizon=list(forecasts),
future_supply=future_supply,
future_competitors=future_competitors,
scenarios_summary=scenarios_summary,
summary=summary,
)
def _future_market_summary(forecasts: Sequence[dict[str, Any]]) -> str | None:
"""Короткий RU-текст про будущий рынок (дефицит/затоварка осн. горизонта). PURE.
Индекс дефицита — АБСОЛЮТНЫЙ вердикт; months_of_inventory (если есть) —
ДИСКРИМИНИРУЮЩИЙ headline «≈N мес конкурирующего предложения» (различает
затоваренные сегменты, где дефицит у всех липнет к 1).
"""
di = _primary_deficit_index(forecasts)
if di is None:
return None
if di >= _STRONG_DEFICIT_THRESHOLD:
verdict = "выраженный дефицит предложения (сигнал «строить»)"
elif di > 0:
verdict = "умеренный дефицит предложения"
elif di == 0:
verdict = "баланс спроса и предложения"
else:
verdict = "затоварка (предложения больше спроса)"
moi = _primary_months_of_inventory(forecasts)
moi_part = f", ≈{round(moi, 1)} мес конкурирующего предложения" if moi is not None else ""
return f"Прогноз: {verdict}, индекс дефицита {_signed(di)}{moi_part} на горизонте."
def _build_product_tz(product_tz: dict[str, Any] | None) -> ReportProductTz:
"""§13.4 product_tz — рекомендация продукта из overlay #983. PURE, defensive.
Маппит overlay-форму (#983 build_forecast_overlay as-dict): класс §10.2 (class_reco
/ obj_class), квартирография (mix / ranked_segments), commercial §10.4, USP §10.5,
§16-причины (reasons / class_reco.reason). overlay None → пустая секция (graceful).
"""
if not isinstance(product_tz, dict):
return ReportProductTz()
obj_class = _recommended_class(product_tz)
mix = _extract_mix(product_tz)
commercial = (
product_tz.get("commercial") if isinstance(product_tz.get("commercial"), dict) else None
)
usp = _extract_list(product_tz, "usp")
reasons = _extract_reasons(product_tz)
summary = _product_tz_summary(obj_class, usp)
return ReportProductTz(
obj_class=obj_class,
mix=mix,
commercial=commercial,
usp=usp,
reasons=reasons,
summary=summary,
)
def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]:
"""Квартирография — явный `mix` ИЛИ ranked_segments overlay (формат+дефицит). PURE.
Overlay #983 не несёт готового `mix`, но его `ranked_segments` (DESC по дефициту) —
продуктовый ответ «какой формат строить». Берём `mix` если задан, иначе проецируем
ranked_segments в компактные {bucket, obj_class, deficit_index}. Нет ни того, ни
другого → [].
"""
explicit = product_tz.get("mix")
if isinstance(explicit, list):
return [m for m in explicit if isinstance(m, dict)]
ranked = product_tz.get("ranked_segments")
if isinstance(ranked, list):
return [
{
"bucket": seg.get("bucket"),
"obj_class": seg.get("obj_class"),
"deficit_index": seg.get("deficit_index"),
}
for seg in ranked
if isinstance(seg, dict)
]
return []
def _extract_list(source: dict[str, Any], key: str) -> list[dict[str, Any]]:
"""Достать список dict'ов по ключу (мусор/None → []). PURE."""
value = source.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
return []
def _extract_reasons(product_tz: dict[str, Any]) -> list[dict[str, Any]]:
"""§16-причины — явные `reasons` ИЛИ §16-reason из class_reco/usp overlay. PURE.
overlay #983 кладёт §16-reason ВНУТРЬ class_reco / каждого usp / ranked_segment.
Если явного `reasons` нет — поднимаем reason из class_reco (рекомендации класса) на
уровень секции (главная §16-причина продукта). Нет → [].
"""
explicit = product_tz.get("reasons")
if isinstance(explicit, list):
return [r for r in explicit if isinstance(r, dict)]
class_reco = product_tz.get("class_reco")
if isinstance(class_reco, dict):
reason = class_reco.get("reason")
if isinstance(reason, dict):
return [reason]
return []
def _product_tz_summary(obj_class: str | None, usp: list[dict[str, Any]]) -> str | None:
"""Короткий RU-текст про рекомендованный продукт. PURE, шаблонный."""
if obj_class is None and not usp:
return None
parts: list[str] = []
if obj_class is not None:
parts.append(f"рекомендован класс «{obj_class}»")
if usp:
parts.append(f"{len(usp)} USP-ниш по дефициту форматов")
return "Продукт: " + ", ".join(parts) + "."
def _build_scenarios(scenarios: Sequence[dict[str, Any]] | None) -> ReportScenarios:
"""§13.5 scenarios — карта {scenario: сводка} из трёх ScenarioForecast #984. PURE.
`scenarios` — список из трёх по `as_dict()` (conservative/base/aggressive). Свора-
чиваем в `by_scenario` (карта по имени сценария) — стабильно для экспортёров/чата.
None/пусто → пустая секция (graceful).
"""
if not scenarios:
return ReportScenarios()
by_scenario: dict[str, Any] = {}
for sc in scenarios:
if not isinstance(sc, dict):
continue
name = sc.get("scenario")
if isinstance(name, str) and name:
by_scenario[name] = sc
if not by_scenario:
return ReportScenarios()
summary = _scenarios_summary(by_scenario)
return ReportScenarios(by_scenario=by_scenario, summary=summary)
def _scenarios_summary(by_scenario: dict[str, Any]) -> str | None:
"""Короткий RU-текст про разброс сценариев. PURE, шаблонный."""
names = [n for n in ("conservative", "base", "aggressive") if n in by_scenario]
if not names:
return None
return f"Сценарии ({len(by_scenario)}): {', '.join(names)} — разброс по конверту ставки."
def _build_scoring(
product_scores: dict[str, Any] | None, special_indices: dict[str, Any] | None
) -> ReportScoring:
"""§13.6 scoring — 10 продуктовых скоров #985 + 6 спец-индексов #986 + overall. PURE.
`product_scores` (#985) / `special_indices` (#986) кладём как есть; `overall`
дублируем из карты #985 (удобство экспортёров). Всё None → пустая секция (graceful).
"""
return ReportScoring(
product_scores=product_scores,
special_indices=special_indices,
overall=_overall_score(product_scores),
)
def _build_confidence(
*,
analyze: dict[str, Any],
market_metrics: dict[str, Any] | None,
supply_layers: dict[str, Any] | None,
future_supply: dict[str, Any] | None,
forecasts: Sequence[dict[str, Any]],
product_scores: dict[str, Any] | None,
special_indices: dict[str, Any] | None,
advisory: bool,
) -> ReportConfidence:
"""§13.7 confidence — отчётная уверенность через #990 из извлечённых сигналов. PURE.
Кормит `compute_report_confidence` (#990) сырыми счётчиками качества данных
(deal_count/analog_count/domrf_coverage/history_months/confounded) + per-service
confidence вкладывающих под-сервисов. Результат (level/rationale/factors) ложится в
слот ReportConfidence. Все сигналы тонкие → #990 честно вернёт 'low' + «недостаточно
данных» (НИКОГДА не бросает).
"""
result = compute_report_confidence(
component_confidences=_component_confidences(
market_metrics, future_supply, forecasts, product_scores, special_indices
),
deal_count=_deal_count(analyze, market_metrics),
analog_count=_analog_count(analyze, market_metrics),
domrf_coverage=_domrf_coverage(analyze, supply_layers),
history_months=_history_months(market_metrics, forecasts),
confounded=_confounded(forecasts),
advisory=advisory,
)
payload = result.as_dict()
return ReportConfidence(
level=payload["level"],
rationale=payload["rationale"],
factors=payload["factors"],
)
def _build_exec_summary(
*,
forecasts: Sequence[dict[str, Any]],
product_tz: dict[str, Any] | None,
product_scores: dict[str, Any] | None,
confidence: ReportConfidence,
) -> ReportExecSummary:
"""§13.1 exec_summary — СИНТЕЗ заголовка/вердикта/чисел из сильнейших сигналов. PURE.
ДЕТЕРМИНИРОВАННЫЙ шаблонный синтез (НЕ LLM):
• headline — одна фраза «что строить здесь» из рекомендованного класса +
силы дефицита (или честный «данных недостаточно»).
• verdict — абзац: дефицит осн. горизонта (АБСОЛЮТ) + months_of_inventory
(ДИСКРИМИНИРУЮЩИЙ headline «≈N мес конкурирующего предложения») + overall-скор
+ уровень уверенности.
• key_numbers — плоские опорные числа (deficit_index / months_of_inventory /
overall_score / confidence).
• overall_confidence — уровень из §15-секции (#990).
Любого сигнала нет → соответствующая часть опускается; всё пусто → честный headline
«недостаточно данных» (секция всё равно валидна).
"""
di = _primary_deficit_index(forecasts)
moi = _primary_months_of_inventory(forecasts)
obj_class = _recommended_class(product_tz)
overall = _overall_score(product_scores)
level: ReportConfidenceLevel | None = confidence.level
headline = _exec_headline(obj_class, di)
verdict = _exec_verdict(di, moi, overall, level)
key_numbers = _exec_key_numbers(di, moi, overall, level)
return ReportExecSummary(
headline=headline,
verdict=verdict,
key_numbers=key_numbers,
overall_confidence=level,
)
def _exec_headline(obj_class: str | None, deficit_index: float | None) -> str:
"""Одна фраза-вердикт «что строить здесь». PURE, шаблонный.
Класс задан + сильный дефицит → «Строить <класс>: рынок недонасыщен». Класс задан,
дефицита нет/слаб → «Рассмотреть <класс>». Нет класса, но дефицит → сигнал по
дефициту. Ничего → честный «данных недостаточно».
"""
strong = deficit_index is not None and deficit_index >= _STRONG_DEFICIT_THRESHOLD
if obj_class is not None:
if strong:
return f"Строить «{obj_class}»: рынок недонасыщен на целевом горизонте."
return f"Рассмотреть «{obj_class}» как целевой класс продукта."
if deficit_index is not None:
if strong:
return "Локация недонасыщена — есть сигнал «строить» (класс не уточнён)."
if deficit_index < 0:
return "Локация затоварена — выход рискован (класс не уточнён)."
return "Рынок локации сбалансирован (класс не уточнён)."
return "Данных недостаточно для однозначного вердикта по продукту."
def _exec_verdict(
deficit_index: float | None,
months_of_inventory: float | None,
overall: float | None,
level: ReportConfidenceLevel | None,
) -> str | None:
"""Развёрнутый абзац-резюме из дефицита, MOI, overall-скора и уверенности. PURE.
Шаблонная RU-фраза; ни одного сигнала → None (graceful — вердикт опционален).
months_of_inventory — ДИСКРИМИНИРУЮЩИЙ headline (интерпретируемо «≈N мес
конкурирующего предложения»), рядом с АБСОЛЮТНЫМ индексом дефицита.
"""
parts: list[str] = []
if deficit_index is not None:
parts.append(f"индекс дефицита {_signed(deficit_index)} на целевом горизонте")
if months_of_inventory is not None:
parts.append(f"{round(months_of_inventory, 1)} мес конкурирующего предложения")
if overall is not None:
parts.append(f"итоговый продуктовый скор {round(overall, 2)}")
if level is not None:
parts.append(f"уверенность {_LEVEL_RU.get(level, level)}")
if not parts:
return None
return (
"Сводный советующий вердикт: "
+ "; ".join(parts)
+ ". Оценка advisory — не основание для инвест-решения."
)
def _exec_key_numbers(
deficit_index: float | None,
months_of_inventory: float | None,
overall: float | None,
level: ReportConfidenceLevel | None,
) -> dict[str, Any]:
"""Плоские опорные числа exec_summary (JSON-safe). PURE.
Кладём только заданные сигналы (None опускаем) — частичный набор валиден.
months_of_inventory — ДИСКРИМИНИРУЮЩИЙ headline рядом с АБСОЛЮТНЫМ дефицитом.
"""
numbers: dict[str, Any] = {}
if deficit_index is not None:
numbers["deficit_index"] = round(deficit_index, 3)
if months_of_inventory is not None:
numbers["months_of_inventory"] = round(months_of_inventory, 1)
if overall is not None:
numbers["overall_score"] = round(overall, 3)
if level is not None:
numbers["confidence"] = level
return numbers
def _build_meta(
*,
cad_num: str | None,
district: str | None,
segment: dict[str, Any] | None,
horizons: Sequence[int],
advisory: bool,
) -> ReportMeta:
"""§13.8 meta — контекст отчёта. PURE.
`generated_advisory`/`schema_version` берут дефолты ReportMeta (#987: ВСЕГДА True /
_SCHEMA_VERSION). `generated_at` НЕ ставим (PURE-маппер не дёргает текущее время —
детерминизм тестов; вызывающий/экспортёр при желании проставит).
"""
return ReportMeta(
cad_num=cad_num,
district=district,
segment=dict(segment) if segment else {},
horizons=list(horizons),
generated_advisory=advisory,
)
# ──────────────────────────────────────────────────────────────────────────────
# Микро-хелперы форматирования (RU-микрокопия). PURE.
# ──────────────────────────────────────────────────────────────────────────────
def _signed(value: float) -> str:
"""'+0.34' / '0.18' — знак для RU-микрокопии индекса (минус типографский). PURE."""
return f"+{value:.2f}" if value >= 0 else f"{abs(value):.2f}"
# ──────────────────────────────────────────────────────────────────────────────
# Публичный API — PURE-сборка отчёта (все входы уже посчитаны вызывающим, без БД).
# ──────────────────────────────────────────────────────────────────────────────
def assemble_report(
analyze: dict[str, Any],
*,
market_metrics: Any = None,
supply_layers: Any = None,
forecasts: Sequence[Any] | None = None,
future_supply: Any = None,
scenarios: Sequence[Any] | None = None,
recommendation_overlay: Any = None,
product_scores: Any = None,
special_indices: Any = None,
segment: dict[str, Any] | None = None,
cad_num: str | None = None,
district: str | None = None,
horizons: Sequence[int] = _DEFAULT_HORIZONS,
advisory: bool = _ADVISORY,
) -> SiteFinderReport:
"""§13 собрать `SiteFinderReport` (#987) из analyze + advisory-выводов форсайт-стека.
ЧИСТОЕ МАППИРОВАНИЕ (assembler #988): НЕ ходит в БД, НЕ зовёт §9.x-сервисы — берёт
уже-посчитанные входы (их считает вызывающий: #961 API / #957 чат) и РАСКЛАДЫВАЕТ
их по восьми типизированным секциям §13, попутно считая отчётную уверенность через
`compute_report_confidence` (#990). ДЕТЕРМИНИРОВАННО, БЕЗ LLM (шаблонные RU-строки).
ЮНИТ-ТЕСТИРУЕТСЯ БЕЗ БД.
Входы принимаются КАК dataclass-инстансы под-сервисов, ТАК и их `.as_dict()`-словари
(нормализуются через `_as_dict_or`) — вызывающий волен передать любое.
Маппинг секций:
• market_now ← analyze (конкуренты/цены/абсорбция) + market_metrics + supply_layers.
• future_market ← forecasts (per-горизонт) + future_supply + будущие конкуренты +
сводка scenarios.
• product_tz ← recommendation_overlay (класс/mix/commercial/USP + §16-причины).
• scenarios ← scenarios (три by_scenario).
• scoring ← product_scores (10) + special_indices (6) + overall.
• confidence ← compute_report_confidence(...) (#990) из извлечённых сигналов
качества данных + per-service confidence.
• exec_summary ← СИНТЕЗ из сильнейших сигналов (дефицит/класс/overall/уверенность).
• meta ← cad_num/district/segment/horizons/advisory/schema_version.
GRACEFUL: любой вход None → его секция частична/пуста — отчёт валиден как частичный
(#987). НИКОГДА не бросает. `advisory` прокидывается всюду (в т.ч. cap-потолок #990).
Args:
analyze: dict вывода analyze_parcel (loosely-typed; читаем defensive `.get()`).
market_metrics: §9.2 MarketMetrics (или as_dict / None).
supply_layers: §9.3 supply-слои (или as_dict / None).
forecasts: per-горизонт DemandSupplyForecast #952 (или as_dict-список / None).
future_supply: §9.3 FutureSupplyPressure (или as_dict / None).
scenarios: три ScenarioForecast #984 (или as_dict-список / None).
recommendation_overlay: §10 overlay #983 (или as_dict / None).
product_scores: §14.2 ProductScoreCard #985 (или as_dict / None).
special_indices: §25 SpecialIndices #986 (или as_dict / None).
segment: целевой сегмент (оси §9.x) для meta.
cad_num: кадастровый номер участка для meta.
district: район для meta.
horizons: горизонты прогноза (мес) для meta (по умолчанию _DEFAULT_HORIZONS).
advisory: советующий-флаг (по умолчанию True; прокидывается в #990 cap).
Returns:
SiteFinderReport — все восемь секций заполнены настолько, насколько хватило
входов (частичный отчёт валиден); `advisory` True; `as_dict()` JSON-сериализуем.
"""
# ── Нормализация входов (dataclass | as_dict | None → JSON-safe dict | None) ──
market_metrics_d = _as_dict_or(market_metrics)
supply_layers_d = _as_dict_or(supply_layers)
future_supply_d = _as_dict_or(future_supply)
overlay_d = _as_dict_or(recommendation_overlay)
product_scores_d = _as_dict_or(product_scores)
special_indices_d = _as_dict_or(special_indices)
forecasts_l = _as_dict_list(forecasts)
scenarios_l = _as_dict_list(scenarios)
# Будущие конкуренты — из forecasts (§9.7 top-N на горизонте, если они там есть).
future_competitors = _future_competitors(forecasts_l)
scenarios_summary = _scenarios_overall_summary(scenarios_l)
# ── Секции (каждая graceful: None-входы → частичная/пустая) ─────────────────
market_now = _build_market_now(analyze, market_metrics_d, supply_layers_d)
future_market = _build_future_market(
forecasts_l, future_supply_d, future_competitors, scenarios_summary
)
product_tz = _build_product_tz(overlay_d)
scenarios_section = _build_scenarios(scenarios_l)
scoring = _build_scoring(product_scores_d, special_indices_d)
confidence = _build_confidence(
analyze=analyze,
market_metrics=market_metrics_d,
supply_layers=supply_layers_d,
future_supply=future_supply_d,
forecasts=forecasts_l,
product_scores=product_scores_d,
special_indices=special_indices_d,
advisory=advisory,
)
exec_summary = _build_exec_summary(
forecasts=forecasts_l,
product_tz=overlay_d,
product_scores=product_scores_d,
confidence=confidence,
)
meta = _build_meta(
cad_num=cad_num,
district=district,
segment=segment,
horizons=horizons,
advisory=advisory,
)
logger.info(
"assemble_report: cad_num=%s district=%s horizons=%s confidence=%s "
"forecasts=%d scenarios=%d advisory=%s",
cad_num,
district,
list(horizons),
confidence.level,
len(forecasts_l),
len(scenarios_l),
advisory,
)
return SiteFinderReport(
exec_summary=exec_summary,
market_now=market_now,
future_market=future_market,
product_tz=product_tz,
scenarios=scenarios_section,
scoring=scoring,
confidence=confidence,
meta=meta,
advisory=advisory,
)
def _future_competitors(forecasts: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
"""Будущие конкуренты основного горизонта из forecasts (§9.7 top-N). PURE.
Берём `future_competitors` основного горизонта (_PRIMARY_HORIZON_MONTHS) или первого
forecast, где список не пуст. Нет → [] (graceful).
"""
primary = next(
(f for f in forecasts if f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS),
None,
)
if primary is not None:
comps = primary.get("future_competitors")
if isinstance(comps, list) and comps:
return [c for c in comps if isinstance(c, dict)]
for f in forecasts:
comps = f.get("future_competitors")
if isinstance(comps, list) and comps:
return [c for c in comps if isinstance(c, dict)]
return []
def _scenarios_overall_summary(scenarios: Sequence[dict[str, Any]]) -> dict[str, Any] | None:
"""Компактная сводка сценариев для future_market (дефицит осн. горизонта). PURE.
Из каждого ScenarioForecast (#984) достаём deficit_index основного горизонта →
{scenario: deficit_index}. Так future_market.scenarios_summary несёт разброс
дефицита по конверту ставки, не дублируя полную секцию scenarios. Нет → None.
"""
summary: dict[str, Any] = {}
for sc in scenarios:
name = sc.get("scenario")
sc_forecasts = sc.get("forecasts")
if not isinstance(name, str) or not isinstance(sc_forecasts, list):
continue
di = _primary_deficit_index([f for f in sc_forecasts if isinstance(f, dict)])
if di is not None:
summary[name] = round(di, 3)
return summary or None