From 5d1260c6fba024e1f205bf10363ca02613523ea3 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 3 Jun 2026 13:41:02 +0500 Subject: [PATCH] =?UTF-8?q?feat(forecasting):=20=C2=A715=20report-level=20?= =?UTF-8?q?confidence=20engine=20v2=20(#990,=20955-A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add confidence_engine.py — deterministic, advisory, DB-free aggregator folding per-component confidences + raw data-quality counts (deal/analog count, domrf↔objective coverage, history months, confounded window) into one High/Medium/Low via weakest-link MIN, capped at medium while advisory. Builds a structural RU rationale naming the dragging factors with real numbers ("Low потому что 7 сделок / 1 ЖК / покрытие 2.5%"). Fills the ReportConfidence slot of #987 SiteFinderReport. 33 unit tests. --- backend/app/services/forecasting/__init__.py | 8 + .../services/forecasting/confidence_engine.py | 488 ++++++++++++++++++ .../forecasting/test_confidence_engine.py | 357 +++++++++++++ 3 files changed, 853 insertions(+) create mode 100644 backend/app/services/forecasting/confidence_engine.py create mode 100644 backend/tests/services/forecasting/test_confidence_engine.py diff --git a/backend/app/services/forecasting/__init__.py b/backend/app/services/forecasting/__init__.py index 02fe86d2..135e3cc2 100644 --- a/backend/app/services/forecasting/__init__.py +++ b/backend/app/services/forecasting/__init__.py @@ -37,6 +37,11 @@ from app.services.forecasting.affordability import ( MortgageAffordabilityIndex, compute_affordability, ) +from app.services.forecasting.confidence_engine import ( + ConfidenceFactor, + ReportConfidenceResult, + compute_report_confidence, +) from app.services.forecasting.demand_normalization import ( DemandNormalization, compute_demand_normalization, @@ -114,6 +119,7 @@ from app.services.forecasting.what_to_build import ( ) __all__ = [ + "ConfidenceFactor", "DemandNormalization", "DemandSupplyForecast", "MacroCoefficient", @@ -124,6 +130,7 @@ __all__ = [ "RankedSegment", "RateSensitivity", "ReportConfidence", + "ReportConfidenceResult", "ReportExecSummary", "ReportFutureMarket", "ReportMarketNow", @@ -148,6 +155,7 @@ __all__ = [ "compute_demand_supply_forecast", "compute_macro_coefficient", "compute_rate_sensitivity", + "compute_report_confidence", "compute_scenarios", "compute_score_card", "compute_special_indices", diff --git a/backend/app/services/forecasting/confidence_engine.py b/backend/app/services/forecasting/confidence_engine.py new file mode 100644 index 00000000..6ae2a02e --- /dev/null +++ b/backend/app/services/forecasting/confidence_engine.py @@ -0,0 +1,488 @@ +"""§15 движок ОТЧЁТНОЙ уверенности v2 — weakest-link + СТРУКТУРНАЯ причина. + +#990 (955-A4, Site Finder v2 / «GG-форсайт» ТЗ §15), EPIC 11 «Отчёт». Это ЧИСТЫЙ +агрегатор уверенности: он сводит per-component confidence под-сервисов (#950/#952/ +#985/#986…) + СЫРЫЕ счётчики качества данных (число сделок, число ЖК-аналогов, +покрытие domrf↔objective, глубина истории, шок-окно) в ОДИН отчётный уровень +High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, ЧТО утянуло уровень вниз с +РЕАЛЬНЫМИ числами («Low потому что 7 сделок за 6 мес / только 1 ЖК-аналог / +покрытие domrf↔objective 2.5%»). Наполняет слот `ReportConfidence` отчёта #987. + +ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, СОВЕТУЮЩИЙ. Никакого SQL/сети/print/вычислений §9.x — +движок ЧИСТЫЙ: берёт уже-посчитанные входы (их кормит сборщик #988) и только +агрегирует + формулирует. Поэтому он ЮНИТ-ТЕСТИРУЕТСЯ БЕЗ БД. + +ЛОГИКА (ТЗ §15): + • Отчётная уверенность = WEAKEST-LINK (MIN ранга) вкладывающих факторов — самый + тонкий сигнал честно роняет общий уровень (зеркало future_supply._min_confidence + и product_scoring._min_confidence). Один 'low'-фактор → отчёт 'low'. + • Причина — СТРУКТУРНАЯ: называет КОНКРЕТНЫЙ фактор(ы)-виновник с его ЧИСЛОМ + (сделки / ЖК-аналоги / покрытие % / месяцы истории / шок-окно / advisory-cap), + формат «Low потому что N сделок … / только M ЖК … / покрытие X%». + • Весь форсайт-стек СОВЕТУЮЩИЙ (движок не провалидирован до бэктеста #951) → + advisory-cap: при advisory=True уровень НИКОГДА не 'high' (потолок 'medium'), + и причина это ПРОГОВАРИВАЕТ. Честность важнее оптимистичной метки. + +ПОРОГИ (align с per-service gate'ами, которые читает движок): + • deal_count — зеркало market_metrics._confidence (n_lots/n_sold) + §9.6 _MIN_OBS: + мало сделок → скоростные метрики статистически ненадёжны. + • analog_count (ЖК-аналоги, = market_metrics.obj_count) — high≥3 / medium≥2 / 1 → low + (точная копия _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2; «1 ЖК» — ТЗ §15-пример). + • domrf_coverage — главный риск проекта (domrf↔objective ~2.5%, см. market_metrics + docstring): низкое покрытие → скрытый/будущий слой §9.3 недооценён. + • history_months — зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) / _MIN_OBS=8: короткий + ряд → связь rate↔sales / тренды не установлены. + • confounded — шок-окно (is_confounded_window, PR2): ряд пересекает структурный + разрыв → оценки смещены (НИКОГДА не 'high'). + +GRACEFUL (дух всего форсайт-стека): все входы None/пусто → 'low' + «недостаточно +данных» (тонкий анализ всё равно даёт честный вердикт, не crash). `as_dict()` +JSON-safe и ложится в слот ReportConfidence (#987): {level, rationale, factors}. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + +Confidence = Literal["high", "medium", "low"] + +# ── Порядок уверенности для weakest-link MIN (хуже = ниже). Зеркало vocab +# future_supply._CONFIDENCE_RANK / product_scoring._CONFIDENCE_RANK. ─────────────── +_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2} +_RANK_TO_CONFIDENCE: dict[int, Confidence] = {0: "low", 1: "medium", 2: "high"} + +# ── Имена факторов (named — переименование не рассинхронит причину/тесты молча) ── +_F_DEAL_COUNT: str = "deal_count" +_F_ANALOG_COUNT: str = "analog_count" +_F_DOMRF_COVERAGE: str = "domrf_coverage" +_F_HISTORY_MONTHS: str = "history_months" +_F_CONFOUNDED_WINDOW: str = "confounded_window" +_F_ADVISORY_CAP: str = "advisory_cap" +_F_COMPONENT: str = "component" # один вкладывающий per-service confidence + +# ── Пороги счётчиков (align с per-service gate'ами, прочитанными в #949/#951) ────── + +# deal_count: число сделок (продаж) за окно. high — длинная плотная выборка, +# medium — рабочий минимум, low — статистически ненадёжно (зеркало духа +# market_metrics: n_sold>0 обязателен; §9.6 _MIN_OBS=8 — пол для регрессии). +_DEAL_COUNT_HIGH: int = 50 +_DEAL_COUNT_LOW: int = 15 + +# analog_count: число ЖК-аналогов (= market_metrics.obj_count). ТОЧНАЯ копия +# _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2: 1 ЖК → low (ТЗ §15-пример «только 1 ЖК»). +_ANALOG_COUNT_HIGH: int = 3 +_ANALOG_COUNT_LOW: int = 2 # < этого (т.е. ≤1 ЖК) → low + +# domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта ~2.5%). +# high — покрытие плотное; low — слой §9.3 (скрытое/будущее) недооценён. medium-порог +# созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6 (доверяем при покрытии большинства). +_DOMRF_COVERAGE_HIGH: float = 0.6 +_DOMRF_COVERAGE_LOW: float = 0.2 + +# history_months: глубина ряда (мес). Зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) / +# _MIN_OBS=8 (пол): короткий ряд → тренды/чувствительность не установлены. +_HISTORY_MONTHS_HIGH: int = 24 +_HISTORY_MONTHS_LOW: int = 12 + +# Потолок при advisory=True: весь стек советующий (не провалидирован) → НИКОГДА 'high'. +_ADVISORY_CEILING: Confidence = "medium" + + +@dataclass(frozen=True) +class ConfidenceFactor: + """Один вклад в отчётную уверенность — фактор-сигнал с числом, уровнем и RU-нотой. + + `name` — машинное имя (_F_*); `value` — сырое число/флаг, которым фактор оценён + (число сделок / ЖК / покрытие / месяцы / bool шок-окна — или None); `level` — + его собственный вклад high/medium/low; `note` — короткая RU-фраза с числом + («7 сделок за 6 мес — мало»), из которой собирается структурная причина §15. + """ + + name: str + value: Any # число/доля/флаг, обосновавшие level (или None) + level: Confidence + note: str # RU, с реальным числом + + def as_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "value": self.value, + "level": self.level, + "note": self.note, + } + + +@dataclass(frozen=True) +class ReportConfidenceResult: + """§15 результат движка отчётной уверенности — уровень + СТРУКТУРНАЯ причина. + + `level` — weakest-link MIN факторов, capped в 'medium' при advisory. `rationale` — + RU-предложение «Low потому что …», НАЗЫВАЮЩЕЕ тянущие вниз факторы с их числами. + `factors` — все учтённые ConfidenceFactor (explainability §16). `advisory_capped` — + True, если потолок advisory реально понизил уровень (был бы 'high'). `as_dict()` + ложится в слот ReportConfidence #987: {level, rationale, factors}. + """ + + level: Confidence + rationale: str + factors: list[ConfidenceFactor] = field(default_factory=list) + advisory_capped: bool = False + + def as_dict(self) -> dict[str, Any]: + """JSON-safe вид под слот ReportConfidence (#987): {level, rationale, factors}. + + `factors` — плоский dict {name: {value, level, note}} (стабильно, удобно + экспортёрам/чату; advisory_capped дублируем внутрь для полноты explainability). + Повторяющиеся имена (несколько `component`) суффиксуем `_2/_3…`, чтобы ни один + фактор не потерялся в плоском dict (ключи иначе бы перезаписали друг друга). + """ + factors: dict[str, Any] = {} + for f in self.factors: + key = f.name + if key in factors: + i = 2 + while f"{key}_{i}" in factors: + i += 1 + key = f"{key}_{i}" + factors[key] = {"value": f.value, "level": f.level, "note": f.note} + factors["advisory_capped"] = self.advisory_capped + return { + "level": self.level, + "rationale": self.rationale, + "factors": factors, + } + + +# ────────────────────────────────────────────────────────────────────────────── +# Pure-хелперы — без БД, полностью юнит-тестируемо. Каждый graceful: None/пусто → +# безопасная деградация ('low' / нейтраль), НИКОГДА не бросает. +# ────────────────────────────────────────────────────────────────────────────── + + +def _level_from_value(value: float | None, *, high_at: float, low_below: float) -> Confidence: + """Сырое число → high/medium/low по двум именованным порогам. PURE. + + value ≥ high_at → 'high'; value < low_below → 'low'; между → 'medium'. None → + 'low' (нет сигнала ≠ хороший сигнал). Монотонно (выше число → не хуже уровень). + """ + if value is None: + return "low" + if value >= high_at: + return "high" + if value < low_below: + return "low" + return "medium" + + +def _factor_from_count( + name: str, + count: float | None, + *, + high_at: float, + low_below: float, + unit: str, + suffix: str = "", +) -> ConfidenceFactor: + """Сырой счётчик → ConfidenceFactor (уровень по порогам + RU-нота с числом). PURE. + + Маппит `count` в high/medium/low через _level_from_value(high_at/low_below) и + собирает RU-ноту с РЕАЛЬНЫМ числом и оценкой («7 сделок за 6 мес — мало» / + «3 ЖК-аналога — достаточно»). `unit` — что считаем («сделок» / «ЖК-аналога»); + `suffix` — хвост контекста («за 6 мес»). count None → low + нота «нет данных». + + Args: + name: машинное имя фактора (_F_*). + count: сырое число (или None). + high_at: порог 'high' (count ≥ → high). + low_below: порог 'low' (count < → low). + unit: единица в RU-ноте (родительный падеж: «сделок», «ЖК-аналога»). + suffix: необязательный хвост ноты (например «за 6 мес»). + + Returns: + ConfidenceFactor с level/value/note. + """ + level = _level_from_value(count, high_at=high_at, low_below=low_below) + tail = f" {suffix}" if suffix else "" + if count is None: + note = f"нет данных о количестве ({unit})" + else: + shown = int(count) if float(count).is_integer() else round(float(count), 1) + note = f"{shown} {unit}{tail} — {_QUALITY_WORD[level]}" + return ConfidenceFactor(name=name, value=count, level=level, note=note) + + +# Оценочное слово на уровень (для RU-ноты фактора). Зеркало духа per-service фраз. +_QUALITY_WORD: dict[Confidence, str] = { + "high": "достаточно", + "medium": "умеренно", + "low": "мало", +} + + +def _coverage_factor(coverage: float | None) -> ConfidenceFactor: + """domrf↔objective покрытие ∈ [0,1] → ConfidenceFactor с % в ноте. PURE. + + Главный sparse-риск проекта (~2.5%). Нота показывает покрытие В ПРОЦЕНТАХ + (структурный §15-пример «покрытие domrf↔objective 2.5%»). None → low. + """ + level = _level_from_value(coverage, high_at=_DOMRF_COVERAGE_HIGH, low_below=_DOMRF_COVERAGE_LOW) + if coverage is None: + note = "покрытие domrf↔objective неизвестно" + else: + pct = round(float(coverage) * 100.0, 1) + note = f"покрытие domrf↔objective {pct}% — {_QUALITY_WORD[level]}" + return ConfidenceFactor(name=_F_DOMRF_COVERAGE, value=coverage, level=level, note=note) + + +def _confounded_factor(confounded: bool) -> ConfidenceFactor: + """Шок-окно → ConfidenceFactor. PURE. + + confounded=True (ряд пересекает структурный разрыв, PR2) → 'low' (оценки смещены, + НИКОГДА не 'high'). False → 'high' (окно чистое — фактор не тянет вниз). + """ + if confounded: + return ConfidenceFactor( + name=_F_CONFOUNDED_WINDOW, + value=True, + level="low", + note="окно ряда пересекает шок-период (оценки смещены)", + ) + return ConfidenceFactor( + name=_F_CONFOUNDED_WINDOW, + value=False, + level="high", + note="окно ряда вне шок-периодов", + ) + + +def _component_factor(level: Confidence) -> ConfidenceFactor: + """Один вкладывающий per-service confidence → ConfidenceFactor. PURE. + + Per-service уже свернул свои тонкие сигналы в high/medium/low; берём как факт, + нота поясняет вклад. value=None (число — внутри самого под-сервиса). + """ + return ConfidenceFactor( + name=_F_COMPONENT, + value=None, + level=level, + note=f"вкладывающий сервис: {level}", + ) + + +def _aggregate(factors: list[ConfidenceFactor]) -> Confidence: + """Weakest-link агрегат: MIN ранга факторов (худший тянет вниз). PURE. + + Зеркало future_supply._min_confidence / product_scoring._min_confidence: один + 'low'-фактор → общий 'low'. Пустой список → 'low' (нет ни одного сигнала). + """ + ranks = [_CONFIDENCE_RANK[f.level] for f in factors] + if not ranks: + return "low" + return _RANK_TO_CONFIDENCE[min(ranks)] + + +def _cap(level: Confidence, ceiling: Confidence) -> Confidence: + """Опустить level до потолка ceiling (MIN ранга). PURE. + + advisory-cap: при ceiling='medium' любое 'high' становится 'medium'. Зеркало + advisory-духа recommendation/product_scoring (confidence ≤ medium в advisory). + """ + return _RANK_TO_CONFIDENCE[min(_CONFIDENCE_RANK[level], _CONFIDENCE_RANK[ceiling])] + + +def _dragging_factors(factors: list[ConfidenceFactor], level: Confidence) -> list[ConfidenceFactor]: + """Факторы, чей уровень == итоговому (т.е. ИМЕННО они тянут вниз). PURE. + + Для 'low'-итога — все low-факторы; для 'medium' — все medium-факторы. Это + «виновники» структурной причины §15. Component-факторы (без своего числа) + отодвигаем в конец — приоритет адресным счётчикам с числами в ноте. + """ + same = [f for f in factors if f.level == level] + same.sort(key=lambda f: f.name == _F_COMPONENT) # named-счётчики раньше component + return same + + +def _build_rationale( + level: Confidence, + factors: list[ConfidenceFactor], + *, + advisory_capped: bool, +) -> str: + """Структурная §15-причина: «Low потому что <факторы с числами>». PURE. + + Берёт тянущие-вниз факторы (_dragging_factors) и НАЗЫВАЕТ их ноты (с реальными + числами) после « потому что …». Если уровень понижен именно advisory-cap'ом + (был бы 'high') — проговаривает, что движок советующий. Пустые/нейтральные факторы + → «недостаточно данных» (graceful). + + Args: + level: итоговый (уже capped) уровень. + factors: все учтённые факторы. + advisory_capped: True, если потолок advisory реально понизил уровень. + + Returns: + RU-предложение-причина. + """ + label = _LEVEL_LABEL[level] + drag = _dragging_factors(factors, level) + notes = [f.note for f in drag] + + if advisory_capped and level == _ADVISORY_CEILING: + # Уровень упёрся в advisory-потолок (не данные) — это и есть главная причина. + base = f"{label}: прогноз советующий (не провалидирован) — уровень ограничен «medium»" + if notes: + base += "; также " + _join_notes(notes) + return base + "." + + if not notes: + return f"{label}: недостаточно данных для оценки уверенности." + + return f"{label} потому что {_join_notes(notes)}." + + +# RU-метка уровня для причины (заглавная — начало предложения). +_LEVEL_LABEL: dict[Confidence, str] = {"high": "High", "medium": "Medium", "low": "Low"} + + +def _join_notes(notes: list[str], *, limit: int = 3) -> str: + """Склеить ноты факторов в RU-перечисление через « / » (не более limit). PURE. + + Ограничиваем limit (по умолчанию 3), чтобы причина оставалась фразой, а не + простынёй; хвост сворачиваем в «… и др.». Дубли убираем, порядок сохраняем. + """ + seen: list[str] = [] + for n in notes: + if n not in seen: + seen.append(n) + head = seen[:limit] + joined = " / ".join(head) + if len(seen) > limit: + # Без точки в маркере — её добавит вызывающий концом предложения (не «др..»). + joined += " и др" + return joined + + +# ────────────────────────────────────────────────────────────────────────────── +# Публичный API — PURE-сборка (входы уже посчитаны сборщиком #988, без БД). +# ────────────────────────────────────────────────────────────────────────────── + + +def compute_report_confidence( + *, + component_confidences: list[Confidence] | None = None, + deal_count: int | None = None, + analog_count: int | None = None, + domrf_coverage: float | None = None, + history_months: int | None = None, + confounded: bool = False, + advisory: bool = True, +) -> ReportConfidenceResult: + """§15 свести входы в ОДНУ отчётную уверенность + структурную причину. PURE. + + СОВЕТУЮЩИЙ агрегатор: НЕ ходит в БД и НЕ зовёт сервисы — берёт уже-посчитанные + счётчики качества данных + per-service confidence (их кормит сборщик #988), + строит факторы, агрегирует weakest-link (MIN), при advisory опускает потолок до + 'medium' и собирает причину «Low потому что …» с реальными числами виновников. + + Шаги: + 1. Из сырых счётчиков (deal_count / analog_count / domrf_coverage / + history_months / confounded) → ConfidenceFactor'ы с RU-нотами (с числами). + 2. Свернуть component_confidences под-сервисов в факторы (как есть). + 3. level = weakest-link MIN всех факторов (худший тянет вниз). + 4. advisory=True → cap level в 'medium' (НИКОГДА не 'high'); пометить + advisory_capped, если потолок реально понизил. + 5. Структурная §15-причина, называющая тянущие-вниз факторы с числами. + + Graceful: все входы None/пусто → 'low' + «недостаточно данных». Никогда не бросает. + + Args: + component_confidences: per-service confidence (#950/#952/#985/#986…), None/[]→ + нет вкладывающих компонентов. + deal_count: число сделок за окно (None → нет данных, тянет в low). + analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count). + domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта). + history_months: глубина ряда (мес). + confounded: True, если окно ряда пересекает шок-период (PR2). + advisory: весь стек советующий → cap 'medium' (по умолчанию True; почти всегда). + + Returns: + ReportConfidenceResult (level / rationale / factors / advisory_capped); + `as_dict()` ложится в слот ReportConfidence отчёта #987. + """ + factors: list[ConfidenceFactor] = [] + + # ── 1. Сырые счётчики качества данных → факторы (только заданные) ────────── + if deal_count is not None: + factors.append( + _factor_from_count( + _F_DEAL_COUNT, + deal_count, + high_at=_DEAL_COUNT_HIGH, + low_below=_DEAL_COUNT_LOW, + unit="сделок", + ) + ) + if analog_count is not None: + factors.append( + _factor_from_count( + _F_ANALOG_COUNT, + analog_count, + high_at=_ANALOG_COUNT_HIGH, + low_below=_ANALOG_COUNT_LOW, + unit="ЖК-аналога", + ) + ) + if domrf_coverage is not None: + factors.append(_coverage_factor(domrf_coverage)) + if history_months is not None: + factors.append( + _factor_from_count( + _F_HISTORY_MONTHS, + history_months, + high_at=_HISTORY_MONTHS_HIGH, + low_below=_HISTORY_MONTHS_LOW, + unit="мес истории", + ) + ) + # Шок-окно учитываем ТОЛЬКО когда оно есть (True): чистое окно не должно + # искусственно тянуть к 'high', если других сигналов нет (см. graceful ниже). + if confounded: + factors.append(_confounded_factor(True)) + + # ── 2. Вкладывающие per-service confidence → факторы ────────────────────── + for c in component_confidences or []: + if c in _CONFIDENCE_RANK: + factors.append(_component_factor(c)) + + # ── 3. weakest-link агрегат (худший тянет вниз) ─────────────────────────── + raw_level = _aggregate(factors) + + # ── 4. advisory-cap (НИКОГДА не 'high', пока стек советующий) ────────────── + if advisory: + capped_level = _cap(raw_level, _ADVISORY_CEILING) + advisory_capped = capped_level != raw_level + if advisory_capped: + # Зафиксировать причину-cap как явный фактор (explainability §16). + factors.append( + ConfidenceFactor( + name=_F_ADVISORY_CAP, + value=True, + level=_ADVISORY_CEILING, + note="прогноз советующий — уровень ограничен «medium»", + ) + ) + level = capped_level + else: + level = raw_level + advisory_capped = False + + # ── 5. структурная §15-причина ──────────────────────────────────────────── + rationale = _build_rationale(level, factors, advisory_capped=advisory_capped) + + return ReportConfidenceResult( + level=level, + rationale=rationale, + factors=factors, + advisory_capped=advisory_capped, + ) diff --git a/backend/tests/services/forecasting/test_confidence_engine.py b/backend/tests/services/forecasting/test_confidence_engine.py new file mode 100644 index 00000000..1a52bb60 --- /dev/null +++ b/backend/tests/services/forecasting/test_confidence_engine.py @@ -0,0 +1,357 @@ +"""Unit-тесты §15 движка отчётной уверенности (#990, 955-A4). + +Чистые тесты — БЕЗ БД (движок PURE: берёт уже-посчитанные входы, ничего не считает +из §9.x и не ходит в БД): + • _level_from_value / _factor_from_count — каждый порог-бэнд (high/medium/low) + + RU-нота с РЕАЛЬНЫМ числом и оценочным словом; None → low + «нет данных». + • _coverage_factor — покрытие domrf↔objective в % в ноте (структурный §15-пример). + • _aggregate — weakest-link MIN (один low → общий low); пустой → low. + • _cap — advisory-потолок опускает high → medium. + • _build_rationale — называет тянущий-вниз фактор с его числом, «Low потому что N…»; + advisory-cap проговаривается; пусто → «недостаточно данных». + • compute_report_confidence — смешанные факторы → правильный level + структурная + причина; advisory НИКОГДА не отдаёт high; all-None → low + «недостаточно данных»; + as_dict() ложится в слот ReportConfidence (#987) и JSON-сериализуем. + +Детерминированно, без LLM, без сети. DATABASE_URL до импорта app-модулей (зеркало +соседних тестов — на случай side-effect'ов импорта пакета forecasting). +""" + +from __future__ import annotations + +import json +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services.forecasting.confidence_engine import ( + _ANALOG_COUNT_HIGH, + _DEAL_COUNT_HIGH, + _DEAL_COUNT_LOW, + _HISTORY_MONTHS_HIGH, + ConfidenceFactor, + ReportConfidenceResult, + _aggregate, + _build_rationale, + _cap, + _coverage_factor, + _factor_from_count, + _level_from_value, + compute_report_confidence, +) + + +def _factor(name: str, level: str) -> ConfidenceFactor: + """Лёгкий фактор фиксированного уровня (для агрегатных/причинных тестов).""" + return ConfidenceFactor(name=name, value=None, level=level, note=f"{name}:{level}") # type: ignore[arg-type] + + +# ── _level_from_value — пороговые бэнды ──────────────────────────────────────── + + +class TestLevelFromValue: + def test_high_band(self) -> None: + assert _level_from_value(50, high_at=50, low_below=15) == "high" + assert _level_from_value(120, high_at=50, low_below=15) == "high" + + def test_medium_band(self) -> None: + # Между low_below (вкл.) и high_at (искл.) → medium. + assert _level_from_value(15, high_at=50, low_below=15) == "medium" + assert _level_from_value(30, high_at=50, low_below=15) == "medium" + + def test_low_band(self) -> None: + assert _level_from_value(14, high_at=50, low_below=15) == "low" + assert _level_from_value(0, high_at=50, low_below=15) == "low" + + def test_none_is_low(self) -> None: + # Нет сигнала ≠ хороший сигнал. + assert _level_from_value(None, high_at=50, low_below=15) == "low" + + +# ── _factor_from_count — бэнды + RU-нота с числом ────────────────────────────── + + +class TestFactorFromCount: + def test_low_count_note_has_number_and_word(self) -> None: + f = _factor_from_count( + "deal_count", + 7, + high_at=_DEAL_COUNT_HIGH, + low_below=_DEAL_COUNT_LOW, + unit="сделок", + suffix="за 6 мес", + ) + assert f.level == "low" + assert f.value == 7 + # Структурный §15-стиль: реальное число + контекст + оценка. + assert "7 сделок" in f.note + assert "за 6 мес" in f.note + assert "мало" in f.note + + def test_medium_count_band(self) -> None: + f = _factor_from_count( + "deal_count", 20, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок" + ) + assert f.level == "medium" + assert "20 сделок" in f.note + assert "умеренно" in f.note + + def test_high_count_band(self) -> None: + f = _factor_from_count( + "deal_count", 80, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок" + ) + assert f.level == "high" + assert "80 сделок" in f.note + assert "достаточно" in f.note + + def test_one_analog_is_low_with_number(self) -> None: + # ТЗ §15-пример «только 1 ЖК» — 1 аналог обязан давать low с числом в ноте. + f = _factor_from_count( + "analog_count", 1, high_at=_ANALOG_COUNT_HIGH, low_below=2, unit="ЖК-аналога" + ) + assert f.level == "low" + assert "1 ЖК-аналога" in f.note + + def test_none_count_low_no_data_note(self) -> None: + f = _factor_from_count( + "deal_count", None, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок" + ) + assert f.level == "low" + assert f.value is None + assert "нет данных" in f.note + + def test_float_count_rendered_compactly(self) -> None: + # Целое-как-float → без хвоста; дробное → 1 знак. + f_int = _factor_from_count( + "history_months", 12.0, high_at=_HISTORY_MONTHS_HIGH, low_below=12, unit="мес истории" + ) + assert "12 мес истории" in f_int.note # не "12.0" + f_frac = _factor_from_count( + "history_months", 12.5, high_at=_HISTORY_MONTHS_HIGH, low_below=12, unit="мес истории" + ) + assert "12.5 мес истории" in f_frac.note + + +# ── _coverage_factor — покрытие domrf↔objective в % ──────────────────────────── + + +class TestCoverageFactor: + def test_low_coverage_percent_in_note(self) -> None: + # Главный sparse-риск проекта: 2.5% покрытие → low, % в ноте (структурный §15). + f = _coverage_factor(0.025) + assert f.level == "low" + assert f.value == 0.025 + assert "2.5%" in f.note + assert "domrf↔objective" in f.note + + def test_high_coverage(self) -> None: + f = _coverage_factor(0.75) + assert f.level == "high" + assert "75.0%" in f.note + + def test_none_coverage_low(self) -> None: + f = _coverage_factor(None) + assert f.level == "low" + assert "неизвестно" in f.note + + +# ── _aggregate — weakest-link MIN ────────────────────────────────────────────── + + +class TestAggregate: + def test_one_low_drags_overall_to_low(self) -> None: + factors = [_factor("a", "high"), _factor("b", "medium"), _factor("c", "low")] + assert _aggregate(factors) == "low" + + def test_all_high_stays_high(self) -> None: + assert _aggregate([_factor("a", "high"), _factor("b", "high")]) == "high" + + def test_medium_floor_when_no_low(self) -> None: + assert _aggregate([_factor("a", "high"), _factor("b", "medium")]) == "medium" + + def test_empty_is_low(self) -> None: + # Нет ни одного сигнала → low (graceful). + assert _aggregate([]) == "low" + + +# ── _cap — advisory-потолок ──────────────────────────────────────────────────── + + +class TestCap: + def test_high_capped_to_medium(self) -> None: + assert _cap("high", "medium") == "medium" + + def test_low_unchanged_below_ceiling(self) -> None: + assert _cap("low", "medium") == "low" + + def test_medium_unchanged_at_ceiling(self) -> None: + assert _cap("medium", "medium") == "medium" + + +# ── _build_rationale — структурная §15-причина ───────────────────────────────── + + +class TestBuildRationale: + def test_low_names_dragging_factor_with_number(self) -> None: + # «Low потому что 7 сделок …» — называет виновника с его числом. + deal = _factor_from_count( + "deal_count", + 7, + high_at=_DEAL_COUNT_HIGH, + low_below=_DEAL_COUNT_LOW, + unit="сделок", + suffix="за 6 мес", + ) + good = _factor("history_months", "high") + text = _build_rationale("low", [deal, good], advisory_capped=False) + assert text.startswith("Low потому что") + assert "7 сделок" in text + # Не-тянущий (high) фактор НЕ называется. + assert "history_months" not in text + + def test_low_lists_multiple_dragging_factors(self) -> None: + deal = _factor_from_count( + "deal_count", 7, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок" + ) + analog = _factor_from_count( + "analog_count", 1, high_at=_ANALOG_COUNT_HIGH, low_below=2, unit="ЖК-аналога" + ) + cov = _coverage_factor(0.025) + text = _build_rationale("low", [deal, analog, cov], advisory_capped=False) + assert "7 сделок" in text + assert "1 ЖК-аналога" in text + assert "2.5%" in text + assert " / " in text # перечисление виновников + + def test_advisory_cap_spoken_in_rationale(self) -> None: + # Уровень упёрся в advisory-потолок (не данные) → причина это проговаривает. + text = _build_rationale("medium", [_factor("x", "medium")], advisory_capped=True) + assert "советующий" in text + assert "medium" in text + + def test_empty_factors_insufficient(self) -> None: + assert "недостаточно данных" in _build_rationale("low", [], advisory_capped=False) + + +# ── compute_report_confidence — интеграция (PURE, без БД) ────────────────────── + + +class TestComputeReportConfidence: + def test_mixed_factors_weakest_link_low(self) -> None: + # Сильная история, но 7 сделок и 1 ЖК → weakest-link low; причина структурная. + res = compute_report_confidence( + component_confidences=["medium", "high"], + deal_count=7, + analog_count=1, + domrf_coverage=0.4, + history_months=36, + confounded=False, + advisory=True, + ) + assert isinstance(res, ReportConfidenceResult) + assert res.level == "low" + assert res.rationale.startswith("Low потому что") + assert "7 сделок" in res.rationale + assert "1 ЖК-аналога" in res.rationale + + def test_advisory_never_high(self) -> None: + # Все сигналы отличные, но advisory=True → НИКОГДА не high (потолок medium). + res = compute_report_confidence( + component_confidences=["high", "high"], + deal_count=200, + analog_count=10, + domrf_coverage=0.9, + history_months=48, + confounded=False, + advisory=True, + ) + assert res.level == "medium" + assert res.advisory_capped is True + assert "советующий" in res.rationale + + def test_non_advisory_allows_high(self) -> None: + # advisory=False (гипотетически провалидированный стек) → high достижим. + res = compute_report_confidence( + component_confidences=["high"], + deal_count=200, + analog_count=10, + domrf_coverage=0.9, + history_months=48, + confounded=False, + advisory=False, + ) + assert res.level == "high" + assert res.advisory_capped is False + + def test_confounded_window_drags_down(self) -> None: + # Шок-окно → low даже при прочих хороших сигналах; названо в причине. + res = compute_report_confidence( + component_confidences=["high"], + deal_count=200, + analog_count=10, + domrf_coverage=0.9, + history_months=48, + confounded=True, + advisory=True, + ) + assert res.level == "low" + assert "шок" in res.rationale + + def test_all_none_is_low_insufficient(self) -> None: + # Граничный graceful: ни одного входа → low + «недостаточно данных». + res = compute_report_confidence() + assert res.level == "low" + assert "недостаточно данных" in res.rationale + assert res.factors == [] + assert res.advisory_capped is False + + def test_clean_inputs_medium_under_advisory(self) -> None: + # Рабочие (medium-бэнд) счётчики без low-факторов → medium под advisory-cap. + res = compute_report_confidence( + component_confidences=["medium"], + deal_count=20, + analog_count=2, + domrf_coverage=0.4, + history_months=18, + advisory=True, + ) + assert res.level == "medium" + # Здесь потолок не понижал (raw уже medium) → не помечаем capped. + assert res.advisory_capped is False + + def test_as_dict_fits_report_confidence_slot(self) -> None: + # as_dict() ложится в слот ReportConfidence (#987): {level, rationale, factors}. + res = compute_report_confidence(deal_count=7, analog_count=1, advisory=True) + d = res.as_dict() + assert set(d.keys()) == {"level", "rationale", "factors"} + assert d["level"] == "low" + assert isinstance(d["rationale"], str) + assert isinstance(d["factors"], dict) + # Факторы по именам + флаг advisory_capped внутри. + assert "deal_count" in d["factors"] + assert d["factors"]["deal_count"]["value"] == 7 + assert "advisory_capped" in d["factors"] + # Полностью JSON-сериализуем (контракт для экспортёров/чата). + assert json.loads(json.dumps(d, ensure_ascii=False)) == d + + def test_ignores_garbage_component_confidence(self) -> None: + # Мусорный component-уровень не учитывается (whitelist), не роняет искусственно. + res = compute_report_confidence( + component_confidences=["high", "bogus"], # type: ignore[list-item] + deal_count=200, + analog_count=10, + domrf_coverage=0.9, + history_months=48, + advisory=False, + ) + assert res.level == "high" + + def test_as_dict_keeps_all_component_factors(self) -> None: + # Несколько component-факторов не теряются в плоском dict (суффиксуются). + res = compute_report_confidence( + component_confidences=["high", "medium", "low"], advisory=False + ) + d = res.as_dict() + component_keys = [k for k in d["factors"] if k.startswith("component")] + assert len(component_keys) == 3 # ни один не перезаписан + assert json.loads(json.dumps(d, ensure_ascii=False)) == d