diff --git a/backend/app/services/forecasting/__init__.py b/backend/app/services/forecasting/__init__.py index 135e3cc2..3dac08d8 100644 --- a/backend/app/services/forecasting/__init__.py +++ b/backend/app/services/forecasting/__init__.py @@ -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", diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py new file mode 100644 index 00000000..a255826e --- /dev/null +++ b/backend/app/services/forecasting/report_assembler.py @@ -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 diff --git a/backend/tests/services/forecasting/test_report_assembler.py b/backend/tests/services/forecasting/test_report_assembler.py new file mode 100644 index 00000000..b4fca208 --- /dev/null +++ b/backend/tests/services/forecasting/test_report_assembler.py @@ -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