feat(forecasting): §13 report assembler (#988, 955-A2) #1021
3 changed files with 1530 additions and 0 deletions
|
|
@ -93,6 +93,7 @@ from app.services.forecasting.report import (
|
||||||
ReportScoring,
|
ReportScoring,
|
||||||
SiteFinderReport,
|
SiteFinderReport,
|
||||||
)
|
)
|
||||||
|
from app.services.forecasting.report_assembler import assemble_report
|
||||||
from app.services.forecasting.sales_series import (
|
from app.services.forecasting.sales_series import (
|
||||||
SalesSeries,
|
SalesSeries,
|
||||||
SegmentSpec,
|
SegmentSpec,
|
||||||
|
|
@ -146,6 +147,7 @@ __all__ = [
|
||||||
"SpecialIndices",
|
"SpecialIndices",
|
||||||
"WhatToBuildRanking",
|
"WhatToBuildRanking",
|
||||||
"assemble_coefficient",
|
"assemble_coefficient",
|
||||||
|
"assemble_report",
|
||||||
"best_lag",
|
"best_lag",
|
||||||
"build_rate_envelopes",
|
"build_rate_envelopes",
|
||||||
"build_sales_series",
|
"build_sales_series",
|
||||||
|
|
|
||||||
900
backend/app/services/forecasting/report_assembler.py
Normal file
900
backend/app/services/forecasting/report_assembler.py
Normal file
|
|
@ -0,0 +1,900 @@
|
||||||
|
"""§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%). Источники по приоритету:
|
||||||
|
• `supply_layers.domrf_coverage` (если §9.3-слой его несёт явно).
|
||||||
|
• `analyze.market_data_coverage_pct` (доля конкурентов с ценой из objective, %) —
|
||||||
|
прокси покрытия objective-данными; делим на 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 _coverage_to_fraction(float(coverage))
|
||||||
|
pct = analyze.get("market_data_coverage_pct")
|
||||||
|
if isinstance(pct, (int, float)) and not isinstance(pct, bool):
|
||||||
|
return _coverage_to_fraction(float(pct))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _coverage_to_fraction(value: float) -> float:
|
||||||
|
"""Привести покрытие к доле ∈ [0,1]: значения >1 трактуем как проценты. PURE.
|
||||||
|
|
||||||
|
supply_layers может отдать долю (0.025), analyze — проценты (2.5). Эвристика: >1 →
|
||||||
|
делим на 100. Зажимаем в [0,1] (защита от грязных данных).
|
||||||
|
"""
|
||||||
|
fraction = value / 100.0 if value > 1.0 else value
|
||||||
|
return max(0.0, min(1.0, fraction))
|
||||||
|
|
||||||
|
|
||||||
|
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 _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."""
|
||||||
|
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 = "затоварка (предложения больше спроса)"
|
||||||
|
return f"Прогноз: {verdict}, индекс дефицита {_signed(di)} на горизонте."
|
||||||
|
|
||||||
|
|
||||||
|
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 — абзац: дефицит осн. горизонта + overall-скор + уровень уверенности.
|
||||||
|
• key_numbers — плоские опорные числа (deficit_index / overall_score / confidence).
|
||||||
|
• overall_confidence — уровень из §15-секции (#990).
|
||||||
|
|
||||||
|
Любого сигнала нет → соответствующая часть опускается; всё пусто → честный headline
|
||||||
|
«недостаточно данных» (секция всё равно валидна).
|
||||||
|
"""
|
||||||
|
di = _primary_deficit_index(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, overall, level)
|
||||||
|
key_numbers = _exec_key_numbers(di, 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,
|
||||||
|
overall: float | None,
|
||||||
|
level: ReportConfidenceLevel | None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Развёрнутый абзац-резюме из дефицита, overall-скора и уверенности. PURE.
|
||||||
|
|
||||||
|
Шаблонная RU-фраза; ни одного сигнала → None (graceful — вердикт опционален).
|
||||||
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
if deficit_index is not None:
|
||||||
|
parts.append(f"индекс дефицита {_signed(deficit_index)} на целевом горизонте")
|
||||||
|
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,
|
||||||
|
overall: float | None,
|
||||||
|
level: ReportConfidenceLevel | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Плоские опорные числа exec_summary (JSON-safe). PURE.
|
||||||
|
|
||||||
|
Кладём только заданные сигналы (None опускаем) — частичный набор валиден.
|
||||||
|
"""
|
||||||
|
numbers: dict[str, Any] = {}
|
||||||
|
if deficit_index is not None:
|
||||||
|
numbers["deficit_index"] = round(deficit_index, 3)
|
||||||
|
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
|
||||||
628
backend/tests/services/forecasting/test_report_assembler.py
Normal file
628
backend/tests/services/forecasting/test_report_assembler.py
Normal file
|
|
@ -0,0 +1,628 @@
|
||||||
|
"""Unit-тесты §13 сборщика отчёта (#988, 955-A2) — ЧИСТОЕ маппирование, БЕЗ БД.
|
||||||
|
|
||||||
|
Сборщик PURE (берёт уже-посчитанные входы, в БД не ходит, §9.x не пересобирает) →
|
||||||
|
тесты чистые, без фикстур БД:
|
||||||
|
• полный сбор (sample analyze dict + sample advisory dict'ы всех под-сервисов) →
|
||||||
|
SiteFinderReport со ВСЕМИ восемью секциями заполненными, `as_dict()` JSON-сериализуем,
|
||||||
|
confidence посчитана через #990 (структурная rationale присутствует), exec_summary
|
||||||
|
headline непустой, advisory True;
|
||||||
|
• частичный сбор (ТОЛЬКО analyze dict) → валидный частичный отчёт (market_now заполнен,
|
||||||
|
future-секции пусты, confidence 'low');
|
||||||
|
• вход КАК dataclass-инстанс, ТАК и как `.as_dict()`-словарь — оба нормализуются
|
||||||
|
(`_as_dict_or`), отчёт идентичен;
|
||||||
|
• извлечение сигналов качества данных для #990 (deal_count/analog_count/domrf_coverage/
|
||||||
|
history_months/confounded) + exec_summary-синтез;
|
||||||
|
• graceful: пустой analyze {} → отчёт всё равно валиден (8 секций, JSON-safe).
|
||||||
|
|
||||||
|
Детерминированно, без LLM, без сети. DATABASE_URL до импорта app-модулей (зеркало
|
||||||
|
соседних тестов — на случай side-effect'ов импорта пакета forecasting).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from app.services.forecasting.report import SiteFinderReport
|
||||||
|
from app.services.forecasting.report_assembler import (
|
||||||
|
_analog_count,
|
||||||
|
_as_dict_or,
|
||||||
|
_confounded,
|
||||||
|
_deal_count,
|
||||||
|
_domrf_coverage,
|
||||||
|
_history_months,
|
||||||
|
_primary_deficit_index,
|
||||||
|
assemble_report,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Восемь обязательных секций §13 — стабильный контракт `as_dict()`.
|
||||||
|
_SECTION_KEYS: tuple[str, ...] = (
|
||||||
|
"exec_summary",
|
||||||
|
"market_now",
|
||||||
|
"future_market",
|
||||||
|
"product_tz",
|
||||||
|
"scenarios",
|
||||||
|
"scoring",
|
||||||
|
"confidence",
|
||||||
|
"meta",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Sample-данные: форма `as_dict()` под-сервисов (плоские, JSON-safe). НЕ зовём живые
|
||||||
|
# сервисы (сборщик их не считает — берёт готовые входы).
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_analyze() -> dict[str, Any]:
|
||||||
|
"""Реалистичный (усечённый) dict вывода analyze_parcel — только релевантные ключи."""
|
||||||
|
return {
|
||||||
|
"cad_num": "66:41:0000000:1",
|
||||||
|
"district": {"district_name": "Верх-Исетский"},
|
||||||
|
"competitors": [
|
||||||
|
{"obj_id": 1, "comm_name": "ЖК Альфа", "distance_m": 320.0},
|
||||||
|
{"obj_id": 2, "comm_name": "ЖК Бета", "distance_m": 540.0},
|
||||||
|
],
|
||||||
|
"market_pulse": {
|
||||||
|
"competitors_total": 12,
|
||||||
|
"competitors_with_price": 9,
|
||||||
|
"coverage_pct": 75.0,
|
||||||
|
"market_avg_price_per_m2": 145000,
|
||||||
|
},
|
||||||
|
"market_avg_price_per_m2": 145000,
|
||||||
|
"market_data_coverage_pct": 75.0,
|
||||||
|
"poi_count": 18,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_market_metrics() -> dict[str, Any]:
|
||||||
|
"""Форма MarketMetrics.as_dict() (#950, §9.2) — несёт счётчики качества данных."""
|
||||||
|
return {
|
||||||
|
"district": "Верх-Исетский",
|
||||||
|
"obj_count": 7,
|
||||||
|
"n_lots": 240,
|
||||||
|
"n_sold": 88,
|
||||||
|
"n_available": 152,
|
||||||
|
"window_months": 18,
|
||||||
|
"premise_kind": "квартира",
|
||||||
|
"confidence": "medium",
|
||||||
|
"unit_velocity": 8.2,
|
||||||
|
"overstock_index": 0.21,
|
||||||
|
"absorption_rate": 0.05,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_supply_layers() -> dict[str, Any]:
|
||||||
|
"""Форма supply-слоёв (#950, §9.3) — несёт domrf_coverage."""
|
||||||
|
return {"open_units": 1200, "hidden_units": 800, "domrf_coverage": 0.55}
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_forecasts() -> list[dict[str, Any]]:
|
||||||
|
"""Per-горизонт DemandSupplyForecast.as_dict() (#952) — деривируем дефицит/конкурентов."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"horizon_months": 6,
|
||||||
|
"deficit_index": 0.21,
|
||||||
|
"confidence": "medium",
|
||||||
|
"future_competitors": [],
|
||||||
|
"confounded": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"horizon_months": 12,
|
||||||
|
"deficit_index": 0.34,
|
||||||
|
"confidence": "medium",
|
||||||
|
"future_competitors": [{"obj_id": 3, "comm_name": "ЖК Гамма", "relevance_weight": 0.6}],
|
||||||
|
"confounded": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"horizon_months": 18,
|
||||||
|
"deficit_index": 0.28,
|
||||||
|
"confidence": "low",
|
||||||
|
"future_competitors": [],
|
||||||
|
"confounded": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"horizon_months": 24,
|
||||||
|
"deficit_index": 0.19,
|
||||||
|
"confidence": "low",
|
||||||
|
"future_competitors": [],
|
||||||
|
"confounded": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_future_supply() -> dict[str, Any]:
|
||||||
|
"""Форма FutureSupplyPressure.as_dict() (#950, §9.3)."""
|
||||||
|
return {
|
||||||
|
"district": "Верх-Исетский",
|
||||||
|
"horizon_months": 12,
|
||||||
|
"confidence": "low",
|
||||||
|
"index": 0.42,
|
||||||
|
"breakdown": {"open_units": 1200, "hidden_units": 800},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_scenarios() -> list[dict[str, Any]]:
|
||||||
|
"""Три ScenarioForecast.as_dict() (#984) — conservative/base/aggressive."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"scenario": "conservative",
|
||||||
|
"advisory": True,
|
||||||
|
"rate_path": {12: 18.0},
|
||||||
|
"forecasts": [{"horizon_months": 12, "deficit_index": 0.18, "confidence": "medium"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "base",
|
||||||
|
"advisory": True,
|
||||||
|
"rate_path": {12: 16.0},
|
||||||
|
"forecasts": [{"horizon_months": 12, "deficit_index": 0.34, "confidence": "medium"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "aggressive",
|
||||||
|
"advisory": True,
|
||||||
|
"rate_path": {12: 13.0},
|
||||||
|
"forecasts": [{"horizon_months": 12, "deficit_index": 0.49, "confidence": "medium"}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_overlay() -> dict[str, Any]:
|
||||||
|
"""Форма build_forecast_overlay (#983) — класс §10.2 / ranked_segments / USP / commercial."""
|
||||||
|
return {
|
||||||
|
"horizon_months": 12,
|
||||||
|
"mode": "demand_supply",
|
||||||
|
"advisory": True,
|
||||||
|
"ranked_segments": [
|
||||||
|
{
|
||||||
|
"bucket": "1-Студия",
|
||||||
|
"obj_class": "комфорт",
|
||||||
|
"deficit_index": 0.34,
|
||||||
|
"confidence": "medium",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bucket": "2-1-к",
|
||||||
|
"obj_class": "комфорт",
|
||||||
|
"deficit_index": 0.22,
|
||||||
|
"confidence": "medium",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"warnings": [],
|
||||||
|
"class_reco": {
|
||||||
|
"obj_class": "комфорт",
|
||||||
|
"mean_deficit_index": 0.28,
|
||||||
|
"n_segments": 2,
|
||||||
|
"reason": {
|
||||||
|
"why": "Класс «комфорт»: сильнейший средний дефицит.",
|
||||||
|
"drivers": [{"factor": "deficit_index", "value": 0.28, "direction": "+"}],
|
||||||
|
"rejected": [],
|
||||||
|
"what_would_change": ["Рост ставки → спрос мягче."],
|
||||||
|
"confidence": "medium",
|
||||||
|
"advisory": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"usp": [
|
||||||
|
{
|
||||||
|
"segment": "1-Студия",
|
||||||
|
"obj_class": "комфорт",
|
||||||
|
"deficit_index": 0.34,
|
||||||
|
"usp_text": "Дефицит формата «1-Студия (комфорт)» — стройте его.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"commercial": {"available": False, "caveat": "коммерция: нет достаточных данных"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_product_scores() -> dict[str, Any]:
|
||||||
|
"""Форма ProductScoreCard.as_dict() (#985, 10 скоров + overall)."""
|
||||||
|
return {
|
||||||
|
"segment": {"obj_class": "комфорт", "room_bucket": "1-к 30-45"},
|
||||||
|
"horizon_months": 12,
|
||||||
|
"scores": {
|
||||||
|
"market_fit": {"key": "market_fit", "value": 0.67, "confidence": "medium"},
|
||||||
|
"demand": {"key": "demand", "value": 0.51, "confidence": "medium"},
|
||||||
|
},
|
||||||
|
"overall": 0.62,
|
||||||
|
"advisory": True,
|
||||||
|
"confidence": "medium",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_special_indices() -> dict[str, Any]:
|
||||||
|
"""Форма SpecialIndices.as_dict() (#986, 6 индексов)."""
|
||||||
|
return {
|
||||||
|
"segment": {"obj_class": "комфорт"},
|
||||||
|
"district": "Верх-Исетский",
|
||||||
|
"indices": {
|
||||||
|
"launch_window": {"key": "launch_window", "value": 0.6, "label": "12 мес"},
|
||||||
|
"product_void": {"key": "product_void", "value": 0.4, "label": "2 белых пятна"},
|
||||||
|
},
|
||||||
|
"advisory": True,
|
||||||
|
"confidence": "medium",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _full_assemble() -> SiteFinderReport:
|
||||||
|
"""Полный сбор отчёта из sample analyze dict + sample advisory dict'ов всех секций."""
|
||||||
|
return assemble_report(
|
||||||
|
_sample_analyze(),
|
||||||
|
market_metrics=_sample_market_metrics(),
|
||||||
|
supply_layers=_sample_supply_layers(),
|
||||||
|
forecasts=_sample_forecasts(),
|
||||||
|
future_supply=_sample_future_supply(),
|
||||||
|
scenarios=_sample_scenarios(),
|
||||||
|
recommendation_overlay=_sample_overlay(),
|
||||||
|
product_scores=_sample_product_scores(),
|
||||||
|
special_indices=_sample_special_indices(),
|
||||||
|
segment={"obj_class": "комфорт", "room_bucket": "1-к 30-45"},
|
||||||
|
cad_num="66:41:0000000:1",
|
||||||
|
district="Верх-Исетский",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Полный сбор: восемь секций + JSON-serializability + advisory ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestFullAssemble:
|
||||||
|
def test_returns_site_finder_report(self) -> None:
|
||||||
|
assert isinstance(_full_assemble(), SiteFinderReport)
|
||||||
|
|
||||||
|
def test_all_eight_sections_present_and_dicts(self) -> None:
|
||||||
|
payload = _full_assemble().as_dict()
|
||||||
|
for key in _SECTION_KEYS:
|
||||||
|
assert key in payload, f"отсутствует секция {key}"
|
||||||
|
assert isinstance(payload[key], dict)
|
||||||
|
|
||||||
|
def test_as_dict_is_json_serializable(self) -> None:
|
||||||
|
# Главный контракт #987: as_dict() проходит json.dumps без default= (ничего
|
||||||
|
# сырого/dataclass'ов в выдаче). Полный round-trip (json.loads == payload) на
|
||||||
|
# частичном/пустом отчёте ниже — здесь scenarios.rate_path несёт int-ключи
|
||||||
|
# (как ScenarioForecast.as_dict()), которые JSON приводит к строкам, поэтому
|
||||||
|
# строгое равенство неинформативно — важна именно сериализуемость.
|
||||||
|
payload = _full_assemble().as_dict()
|
||||||
|
dumped = json.dumps(payload, ensure_ascii=False)
|
||||||
|
assert isinstance(dumped, str)
|
||||||
|
assert json.loads(dumped) is not None
|
||||||
|
|
||||||
|
def test_advisory_true_everywhere(self) -> None:
|
||||||
|
payload = _full_assemble().as_dict()
|
||||||
|
assert payload["advisory"] is True
|
||||||
|
assert payload["meta"]["generated_advisory"] is True
|
||||||
|
|
||||||
|
def test_schema_version_present(self) -> None:
|
||||||
|
payload = _full_assemble().as_dict()
|
||||||
|
assert payload["schema_version"] == payload["meta"]["schema_version"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── market_now ← analyze + §9.2/§9.3 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarketNow:
|
||||||
|
def test_market_metrics_and_supply_passed_through(self) -> None:
|
||||||
|
market_now = _full_assemble().as_dict()["market_now"]
|
||||||
|
assert market_now["market_metrics"]["unit_velocity"] == 8.2
|
||||||
|
assert market_now["supply_layers"]["open_units"] == 1200
|
||||||
|
|
||||||
|
def test_competitors_from_analyze(self) -> None:
|
||||||
|
market_now = _full_assemble().as_dict()["market_now"]
|
||||||
|
assert len(market_now["competitors"]) == 2
|
||||||
|
assert market_now["competitors"][0]["comm_name"] == "ЖК Альфа"
|
||||||
|
|
||||||
|
def test_summary_synthesized(self) -> None:
|
||||||
|
market_now = _full_assemble().as_dict()["market_now"]
|
||||||
|
assert market_now["summary"] is not None
|
||||||
|
assert "абсорбция" in market_now["summary"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── future_market ← forecasts / future_supply / scenarios ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestFutureMarket:
|
||||||
|
def test_forecasts_by_horizon(self) -> None:
|
||||||
|
fm = _full_assemble().as_dict()["future_market"]
|
||||||
|
assert len(fm["forecasts_by_horizon"]) == 4
|
||||||
|
assert fm["forecasts_by_horizon"][1]["deficit_index"] == 0.34
|
||||||
|
|
||||||
|
def test_future_supply_passed_through(self) -> None:
|
||||||
|
fm = _full_assemble().as_dict()["future_market"]
|
||||||
|
assert fm["future_supply"]["index"] == 0.42
|
||||||
|
|
||||||
|
def test_future_competitors_from_primary_horizon(self) -> None:
|
||||||
|
fm = _full_assemble().as_dict()["future_market"]
|
||||||
|
# Будущие конкуренты подняты с горизонта 12 мес (там список непуст).
|
||||||
|
assert len(fm["future_competitors"]) == 1
|
||||||
|
assert fm["future_competitors"][0]["comm_name"] == "ЖК Гамма"
|
||||||
|
|
||||||
|
def test_scenarios_summary_deficit_spread(self) -> None:
|
||||||
|
fm = _full_assemble().as_dict()["future_market"]
|
||||||
|
assert fm["scenarios_summary"]["conservative"] == 0.18
|
||||||
|
assert fm["scenarios_summary"]["aggressive"] == 0.49
|
||||||
|
|
||||||
|
|
||||||
|
# ── product_tz ← overlay #983 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductTz:
|
||||||
|
def test_class_from_overlay(self) -> None:
|
||||||
|
pt = _full_assemble().as_dict()["product_tz"]
|
||||||
|
assert pt["obj_class"] == "комфорт"
|
||||||
|
|
||||||
|
def test_mix_from_ranked_segments(self) -> None:
|
||||||
|
pt = _full_assemble().as_dict()["product_tz"]
|
||||||
|
assert len(pt["mix"]) == 2
|
||||||
|
assert pt["mix"][0]["bucket"] == "1-Студия"
|
||||||
|
|
||||||
|
def test_usp_and_commercial(self) -> None:
|
||||||
|
pt = _full_assemble().as_dict()["product_tz"]
|
||||||
|
assert len(pt["usp"]) == 1
|
||||||
|
assert pt["commercial"]["available"] is False
|
||||||
|
|
||||||
|
def test_reasons_lifted_from_class_reco(self) -> None:
|
||||||
|
pt = _full_assemble().as_dict()["product_tz"]
|
||||||
|
assert len(pt["reasons"]) == 1
|
||||||
|
assert "комфорт" in pt["reasons"][0]["why"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── scenarios ← #984 by_scenario ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestScenarios:
|
||||||
|
def test_by_scenario_keyed(self) -> None:
|
||||||
|
sc = _full_assemble().as_dict()["scenarios"]
|
||||||
|
assert set(sc["by_scenario"].keys()) == {"conservative", "base", "aggressive"}
|
||||||
|
|
||||||
|
def test_summary_present(self) -> None:
|
||||||
|
sc = _full_assemble().as_dict()["scenarios"]
|
||||||
|
assert sc["summary"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ── scoring ← #985 + #986 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestScoring:
|
||||||
|
def test_product_scores_and_overall(self) -> None:
|
||||||
|
scoring = _full_assemble().as_dict()["scoring"]
|
||||||
|
assert scoring["product_scores"]["overall"] == 0.62
|
||||||
|
assert scoring["overall"] == 0.62
|
||||||
|
|
||||||
|
def test_special_indices_passed_through(self) -> None:
|
||||||
|
scoring = _full_assemble().as_dict()["scoring"]
|
||||||
|
assert scoring["special_indices"]["indices"]["launch_window"]["value"] == 0.6
|
||||||
|
|
||||||
|
|
||||||
|
# ── confidence ← compute_report_confidence (#990) ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfidence:
|
||||||
|
def test_level_computed(self) -> None:
|
||||||
|
conf = _full_assemble().as_dict()["confidence"]
|
||||||
|
assert conf["level"] in ("high", "medium", "low")
|
||||||
|
|
||||||
|
def test_advisory_never_high(self) -> None:
|
||||||
|
# advisory=True → #990 cap 'medium' (НИКОГДА 'high').
|
||||||
|
conf = _full_assemble().as_dict()["confidence"]
|
||||||
|
assert conf["level"] != "high"
|
||||||
|
|
||||||
|
def test_structural_rationale_present(self) -> None:
|
||||||
|
conf = _full_assemble().as_dict()["confidence"]
|
||||||
|
assert isinstance(conf["rationale"], str) and conf["rationale"]
|
||||||
|
|
||||||
|
def test_factors_carry_advisory_capped(self) -> None:
|
||||||
|
conf = _full_assemble().as_dict()["confidence"]
|
||||||
|
assert "advisory_capped" in conf["factors"]
|
||||||
|
|
||||||
|
def test_data_quality_factors_extracted(self) -> None:
|
||||||
|
# deal_count (n_sold=88) / analog_count (obj_count=7) / domrf_coverage (0.55)
|
||||||
|
# извлечены и попали в факторы #990.
|
||||||
|
conf = _full_assemble().as_dict()["confidence"]
|
||||||
|
factors = conf["factors"]
|
||||||
|
assert "deal_count" in factors
|
||||||
|
assert "analog_count" in factors
|
||||||
|
assert "domrf_coverage" in factors
|
||||||
|
|
||||||
|
|
||||||
|
# ── exec_summary — синтез ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecSummary:
|
||||||
|
def test_headline_non_empty(self) -> None:
|
||||||
|
es = _full_assemble().as_dict()["exec_summary"]
|
||||||
|
assert isinstance(es["headline"], str) and es["headline"]
|
||||||
|
|
||||||
|
def test_headline_mentions_class(self) -> None:
|
||||||
|
es = _full_assemble().as_dict()["exec_summary"]
|
||||||
|
assert "комфорт" in es["headline"]
|
||||||
|
|
||||||
|
def test_verdict_and_key_numbers(self) -> None:
|
||||||
|
es = _full_assemble().as_dict()["exec_summary"]
|
||||||
|
assert es["verdict"] is not None
|
||||||
|
assert es["key_numbers"]["deficit_index"] == 0.34
|
||||||
|
assert es["key_numbers"]["overall_score"] == 0.62
|
||||||
|
|
||||||
|
def test_overall_confidence_matches_section(self) -> None:
|
||||||
|
payload = _full_assemble().as_dict()
|
||||||
|
assert payload["exec_summary"]["overall_confidence"] == payload["confidence"]["level"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── meta ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestMeta:
|
||||||
|
def test_context_fields(self) -> None:
|
||||||
|
meta = _full_assemble().as_dict()["meta"]
|
||||||
|
assert meta["cad_num"] == "66:41:0000000:1"
|
||||||
|
assert meta["district"] == "Верх-Исетский"
|
||||||
|
assert meta["segment"]["obj_class"] == "комфорт"
|
||||||
|
assert meta["horizons"] == [6, 12, 18, 24]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Частичный сбор: ТОЛЬКО analyze dict → валидный частичный отчёт ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestPartialAssemble:
|
||||||
|
def _partial(self) -> SiteFinderReport:
|
||||||
|
return assemble_report(_sample_analyze(), cad_num="66:41:0000000:1")
|
||||||
|
|
||||||
|
def test_valid_partial_report(self) -> None:
|
||||||
|
report = self._partial()
|
||||||
|
assert isinstance(report, SiteFinderReport)
|
||||||
|
payload = report.as_dict()
|
||||||
|
# Все восемь секций присутствуют (контракт стабилен), JSON-safe.
|
||||||
|
for key in _SECTION_KEYS:
|
||||||
|
assert key in payload
|
||||||
|
assert json.loads(json.dumps(payload, ensure_ascii=False)) == payload
|
||||||
|
|
||||||
|
def test_market_now_filled_from_analyze(self) -> None:
|
||||||
|
payload = self._partial().as_dict()
|
||||||
|
# competitors из analyze есть; market_metrics/supply_layers None (не переданы).
|
||||||
|
assert len(payload["market_now"]["competitors"]) == 2
|
||||||
|
assert payload["market_now"]["market_metrics"] is None
|
||||||
|
assert payload["market_now"]["supply_layers"] is None
|
||||||
|
|
||||||
|
def test_future_sections_empty(self) -> None:
|
||||||
|
payload = self._partial().as_dict()
|
||||||
|
assert payload["future_market"]["forecasts_by_horizon"] == []
|
||||||
|
assert payload["future_market"]["future_supply"] is None
|
||||||
|
assert payload["scenarios"]["by_scenario"] == {}
|
||||||
|
assert payload["scoring"]["product_scores"] is None
|
||||||
|
assert payload["product_tz"]["obj_class"] is None
|
||||||
|
|
||||||
|
def test_confidence_capped_advisory_on_partial(self) -> None:
|
||||||
|
# Без forecasts/metrics #990 видит из analyze только analog_count (12 →
|
||||||
|
# high) + domrf_coverage (75% → 0.75 → high); None-сигналы (deal_count/
|
||||||
|
# history) #990 в факторы НЕ добавляет (None ≠ low-фактор). Weakest-link →
|
||||||
|
# high, но advisory-cap опускает до 'medium' (НИКОГДА не 'high').
|
||||||
|
payload = self._partial().as_dict()
|
||||||
|
assert payload["confidence"]["level"] == "medium"
|
||||||
|
assert payload["confidence"]["level"] != "high"
|
||||||
|
assert payload["confidence"]["rationale"]
|
||||||
|
|
||||||
|
def test_exec_summary_still_has_headline(self) -> None:
|
||||||
|
payload = self._partial().as_dict()
|
||||||
|
assert payload["exec_summary"]["headline"]
|
||||||
|
|
||||||
|
def test_advisory_true(self) -> None:
|
||||||
|
assert self._partial().as_dict()["advisory"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Graceful: пустой analyze {} → отчёт всё равно валиден ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestGracefulEmpty:
|
||||||
|
def test_empty_analyze_valid_report(self) -> None:
|
||||||
|
report = assemble_report({})
|
||||||
|
payload = report.as_dict()
|
||||||
|
for key in _SECTION_KEYS:
|
||||||
|
assert key in payload
|
||||||
|
assert json.loads(json.dumps(payload, ensure_ascii=False)) == payload
|
||||||
|
assert payload["advisory"] is True
|
||||||
|
# Всё тонко → confidence 'low', headline честный «недостаточно данных».
|
||||||
|
assert payload["confidence"]["level"] == "low"
|
||||||
|
assert payload["exec_summary"]["headline"]
|
||||||
|
|
||||||
|
def test_all_none_inputs(self) -> None:
|
||||||
|
# Явные None всех под-сервисов — не должно бросать, отчёт валиден.
|
||||||
|
report = assemble_report(
|
||||||
|
{},
|
||||||
|
market_metrics=None,
|
||||||
|
supply_layers=None,
|
||||||
|
forecasts=None,
|
||||||
|
future_supply=None,
|
||||||
|
scenarios=None,
|
||||||
|
recommendation_overlay=None,
|
||||||
|
product_scores=None,
|
||||||
|
special_indices=None,
|
||||||
|
)
|
||||||
|
assert isinstance(report, SiteFinderReport)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Вход КАК dataclass, ТАК и as_dict-словарь → нормализуется ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _FakeMetrics:
|
||||||
|
"""Минимальный stand-in под-сервиса с `as_dict()` (как реальный MarketMetrics)."""
|
||||||
|
|
||||||
|
unit_velocity: float
|
||||||
|
obj_count: int
|
||||||
|
n_sold: int
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"unit_velocity": self.unit_velocity,
|
||||||
|
"obj_count": self.obj_count,
|
||||||
|
"n_sold": self.n_sold,
|
||||||
|
"confidence": "medium",
|
||||||
|
"window_months": 18,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestInputNormalization:
|
||||||
|
def test_dataclass_instance_accepted(self) -> None:
|
||||||
|
# Передаём ОБЪЕКТ (не as_dict) — сборщик нормализует через _as_dict_or.
|
||||||
|
report = assemble_report(_sample_analyze(), market_metrics=_FakeMetrics(8.2, 7, 88))
|
||||||
|
market_now = report.as_dict()["market_now"]
|
||||||
|
assert market_now["market_metrics"]["unit_velocity"] == 8.2
|
||||||
|
|
||||||
|
def test_dataclass_and_dict_give_same_market_metrics(self) -> None:
|
||||||
|
from_obj = assemble_report(
|
||||||
|
_sample_analyze(), market_metrics=_FakeMetrics(8.2, 7, 88)
|
||||||
|
).as_dict()
|
||||||
|
from_dict = assemble_report(
|
||||||
|
_sample_analyze(), market_metrics=_FakeMetrics(8.2, 7, 88).as_dict()
|
||||||
|
).as_dict()
|
||||||
|
assert from_obj["market_now"]["market_metrics"] == from_dict["market_now"]["market_metrics"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pure-хелперы извлечения сигналов (юнит, без БД) ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignalExtractionHelpers:
|
||||||
|
def test_as_dict_or_normalizes(self) -> None:
|
||||||
|
assert _as_dict_or(None) is None
|
||||||
|
assert _as_dict_or({"a": 1}) == {"a": 1}
|
||||||
|
assert _as_dict_or(_FakeMetrics(1.0, 2, 3))["obj_count"] == 2
|
||||||
|
assert _as_dict_or(42) is None # мусор → None (graceful)
|
||||||
|
|
||||||
|
def test_deal_count_from_market_metrics(self) -> None:
|
||||||
|
assert _deal_count({}, {"n_sold": 88}) == 88
|
||||||
|
assert _deal_count({}, None) is None
|
||||||
|
|
||||||
|
def test_analog_count_prefers_obj_count(self) -> None:
|
||||||
|
assert _analog_count({"market_pulse": {"competitors_total": 12}}, {"obj_count": 7}) == 7
|
||||||
|
|
||||||
|
def test_analog_count_fallback_to_analyze(self) -> None:
|
||||||
|
assert _analog_count({"market_pulse": {"competitors_total": 12}}, None) == 12
|
||||||
|
assert _analog_count({"competitors": [{}, {}, {}]}, None) == 3
|
||||||
|
assert _analog_count({}, None) is None
|
||||||
|
|
||||||
|
def test_domrf_coverage_fraction_and_percent(self) -> None:
|
||||||
|
# supply_layers доля (0.55) → как есть; analyze проценты (75.0) → /100.
|
||||||
|
assert _domrf_coverage({}, {"domrf_coverage": 0.55}) == 0.55
|
||||||
|
assert _domrf_coverage({"market_data_coverage_pct": 75.0}, None) == 0.75
|
||||||
|
assert _domrf_coverage({}, None) is None
|
||||||
|
|
||||||
|
def test_history_months_from_window(self) -> None:
|
||||||
|
assert _history_months({"window_months": 18}, []) == 18
|
||||||
|
assert _history_months(None, []) is None
|
||||||
|
|
||||||
|
def test_confounded_any_horizon(self) -> None:
|
||||||
|
assert _confounded([{"confounded": False}, {"confounded": True}]) is True
|
||||||
|
assert _confounded([{"is_confounded_window": True}]) is True
|
||||||
|
assert _confounded([{"confounded": False}]) is False
|
||||||
|
assert _confounded([]) is False
|
||||||
|
|
||||||
|
def test_primary_deficit_prefers_12mo(self) -> None:
|
||||||
|
forecasts = [
|
||||||
|
{"horizon_months": 6, "deficit_index": 0.21},
|
||||||
|
{"horizon_months": 12, "deficit_index": 0.34},
|
||||||
|
]
|
||||||
|
assert _primary_deficit_index(forecasts) == 0.34
|
||||||
|
|
||||||
|
def test_primary_deficit_fallback_first(self) -> None:
|
||||||
|
forecasts = [{"horizon_months": 6, "deficit_index": 0.21}]
|
||||||
|
assert _primary_deficit_index(forecasts) == 0.21
|
||||||
|
assert _primary_deficit_index([]) is None
|
||||||
Loading…
Add table
Reference in a new issue