feat(forecasting): §13 report assembler (#988, 955-A2) (#1021)
All checks were successful
Deploy / build-backend (push) Successful in 1m22s
Deploy / build-worker (push) Successful in 2m36s
Deploy / deploy (push) Successful in 1m13s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped

This commit is contained in:
bot-backend 2026-06-03 08:51:23 +00:00
parent 47fe5a55eb
commit fc312fe606
3 changed files with 1530 additions and 0 deletions

View file

@ -93,6 +93,7 @@ from app.services.forecasting.report import (
ReportScoring,
SiteFinderReport,
)
from app.services.forecasting.report_assembler import assemble_report
from app.services.forecasting.sales_series import (
SalesSeries,
SegmentSpec,
@ -146,6 +147,7 @@ __all__ = [
"SpecialIndices",
"WhatToBuildRanking",
"assemble_coefficient",
"assemble_report",
"best_lag",
"build_rate_envelopes",
"build_sales_series",

View 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

View 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