Replace dev-jargon with plain RU across the §22 forecast report (6.2/6.3/6.5 + deficit/затоварка legend). Source of truth = backend reason strings + the frontend RU maps; text/render-only, no scoring/forecast math touched. Backend (source of truth): - scenarios.py _COLLAPSE_REASON_LOW_BETA: «β rate-sensitivity не прошёл gate …» → «чувствительность к ставке не оценена на коротком ряде ЕКБ → один базовый сценарий вместо трёх». - confidence_engine.py _coverage_factor: drop «domrf↔objective» jargon, say it affects будущее предложение/конкуренцию. New _history_factor: «глубина истории N мес» + на что влияет + связь с 6.2 (короткий ряд → один сценарий). Frontend (both Section-6 families — live analysis page + ptica cockpit): - Deficit legend: −1 затоварка / 0 баланс / +1 острый дефицит + actionable трактовка; MOI tied to «сколько месяцев район распродаёт предложение». - 6.2 heading «Почему один сценарий, а не три» over the collapse reason. - 6.3 render confidence.rationale + weakest-link rule («скорее завысим недоверие, чем недооценим риск»); FACTOR_RU gains confounded_window/ advisory_cap; factor notes shown. - 6.5 «Вес»→«Оценка»; overall verdict vs 0.5; «Риск избытка предложения»→ «Запас по предложению»; §-refs moved from reason into tooltip; 6 special- index 1-line «что это + куда лучше» descriptions; 0.00-score reasons shown. Tests: confidence_engine (history/coverage notes), stripSectionRefs vitest. Refs #1963
567 lines
32 KiB
Python
567 lines
32 KiB
Python
"""§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 collections.abc import Sequence
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Literal
|
||
|
||
Confidence = Literal["high", "medium", "low"]
|
||
|
||
# Вход component_confidences может быть плоским уровнем (legacy) ИЛИ парой
|
||
# (имя-сервиса, уровень) — чтобы пронести ИМЯ сервиса сквозь стек (#1737).
|
||
ComponentConfidence = Confidence | tuple[str, Confidence]
|
||
|
||
# ── Порядок уверенности для 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
|
||
|
||
# ── RU-имена вкладывающих под-сервисов (#1737). Машинное имя сервиса (как его кормит
|
||
# сборщик #988) → человекочитаемое RU-имя для отчёта/UI. Неизвестный/пустой ключ →
|
||
# дефолт «Компонент» (graceful — не теряем фактор, просто без точечного имени). ──────
|
||
_SERVICE_RU: dict[str, str] = {
|
||
"market_metrics": "Рынок",
|
||
"future_supply": "Будущее предложение",
|
||
"product_scores": "Скоринг продукта",
|
||
"special_indices": "Спец-индексы",
|
||
"forecasts": "Прогноз спрос/предложение",
|
||
}
|
||
_SERVICE_RU_DEFAULT: str = "Компонент"
|
||
|
||
# ── Пороги счётчиков (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.
|
||
`label` — человекочитаемое RU-имя фактора (#1737: для component-факторов это ИМЯ
|
||
под-сервиса — «Рынок» / «Будущее предложение» …, чтобы UI не показывал сырой
|
||
«Компонент N»); пусто для счётчиков (у них своя RU-метка на стороне UI).
|
||
"""
|
||
|
||
name: str
|
||
value: Any # число/доля/флаг, обосновавшие level (или None)
|
||
level: Confidence
|
||
note: str # RU, с реальным числом
|
||
label: str = "" # RU-имя фактора (для component — имя сервиса, #1737); опционально
|
||
|
||
def as_dict(self) -> dict[str, Any]:
|
||
# `label` кладём ТОЛЬКО когда задан — схема обратно-совместима (старые
|
||
# потребители без поля `label` продолжают читать name/value/level/note).
|
||
out: dict[str, Any] = {
|
||
"name": self.name,
|
||
"value": self.value,
|
||
"level": self.level,
|
||
"note": self.note,
|
||
}
|
||
if self.label:
|
||
out["label"] = self.label
|
||
return out
|
||
|
||
|
||
@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}"
|
||
entry: dict[str, Any] = {"value": f.value, "level": f.level, "note": f.note}
|
||
# RU-имя фактора (для component — имя сервиса, #1737) кладём ТОЛЬКО когда
|
||
# задано — плоский dict остаётся обратно-совместимым по форме.
|
||
if f.label:
|
||
entry["label"] = f.label
|
||
factors[key] = entry
|
||
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 = (
|
||
"Доля будущих проектов с известными планировками и площадями неизвестна — "
|
||
"оценка будущего предложения и конкуренции менее надёжна"
|
||
)
|
||
else:
|
||
pct = round(float(coverage) * 100.0, 1)
|
||
note = (
|
||
f"Известные планировки и площади есть у {pct}% будущих проектов "
|
||
f"({_QUALITY_WORD[level]}) — от этого зависит точность прогноза "
|
||
"будущего предложения и конкуренции"
|
||
)
|
||
return ConfidenceFactor(name=_F_DOMRF_COVERAGE, value=coverage, level=level, note=note)
|
||
|
||
|
||
def _history_factor(history_months: int | None) -> ConfidenceFactor:
|
||
"""Глубина ряда (мес) → ConfidenceFactor с «на что влияет» + связью с 6.2. PURE.
|
||
|
||
Короткий ряд не даёт оценить тренды и чувствительность к ставке (§9.6) — а это
|
||
та же причина, по которой сценарии (6.2) схлопываются в один. Нота называет
|
||
реальное число месяцев И что от этого зависит. None → low (нет сигнала). PURE.
|
||
"""
|
||
level = _level_from_value(
|
||
history_months, high_at=_HISTORY_MONTHS_HIGH, low_below=_HISTORY_MONTHS_LOW
|
||
)
|
||
if history_months is None:
|
||
note = (
|
||
"Длина истории продаж неизвестна — тренды и чувствительность к ставке "
|
||
"оценить нельзя (см. сценарии в 6.2)"
|
||
)
|
||
else:
|
||
note = (
|
||
f"{int(history_months)} мес истории ({_QUALITY_WORD[level]}) — на коротком "
|
||
"ряде тренды и чувствительность спроса к ставке оцениваются хуже "
|
||
"(поэтому в 6.2 может остаться один сценарий вместо трёх)"
|
||
)
|
||
return ConfidenceFactor(
|
||
name=_F_HISTORY_MONTHS, value=history_months, 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, service: str | None = None) -> ConfidenceFactor:
|
||
"""Один вкладывающий per-service confidence → ConfidenceFactor. PURE.
|
||
|
||
Per-service уже свернул свои тонкие сигналы в high/medium/low; берём как факт,
|
||
нота поясняет вклад. value=None (число — внутри самого под-сервиса). `service` —
|
||
машинное имя под-сервиса (#1737): из него берём человекочитаемое RU-имя
|
||
(«Рынок» / «Будущее предложение» / «Скоринг продукта» / «Спец-индексы»…) в
|
||
`label` и НАЗЫВАЕМ сервис в `note` (вместо безличного «вкладывающий сервис: high»).
|
||
Неизвестное/None имя → дефолт «Компонент» (graceful — фактор не теряется).
|
||
"""
|
||
ru_name = _SERVICE_RU.get(service or "", _SERVICE_RU_DEFAULT)
|
||
return ConfidenceFactor(
|
||
name=_F_COMPONENT,
|
||
value=None,
|
||
level=level,
|
||
note=f"{ru_name}: {level}",
|
||
label=ru_name,
|
||
)
|
||
|
||
|
||
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) и НАЗЫВАЕТ их ноты (с реальными
|
||
числами) после «<Level> потому что …». Если уровень понижен именно 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-потолок (не данные) — это и есть главная причина.
|
||
# _F_ADVISORY_CAP-фактор уже проговорён в base — исключаем его ноту из «также»,
|
||
# иначе advisory-cap-сообщение дублируется (частый all-high случай).
|
||
other = [f.note for f in drag if f.name != _F_ADVISORY_CAP]
|
||
base = f"{label}: прогноз советующий (не провалидирован) — уровень ограничен «medium»"
|
||
if other:
|
||
base += "; также " + _join_notes(other)
|
||
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: Sequence[ComponentConfidence] | None = None,
|
||
deal_count: int | None = None,
|
||
deal_count_months: 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/[]→
|
||
нет вкладывающих компонентов. Каждый элемент — ЛИБО плоский уровень
|
||
'high|medium|low' (legacy), ЛИБО пара (имя-сервиса, уровень) (#1737:
|
||
имя сервиса проносится сквозь стек в RU-метку фактора).
|
||
deal_count: число сделок за окно (None → нет данных, тянет в low).
|
||
deal_count_months: окно наблюдения для deal_count (мес) — добавляет «за N мес»
|
||
в ноту фактора («7 сделок за 6 мес — мало»). None → нота без периода.
|
||
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:
|
||
_deal_suffix = f"за {deal_count_months} мес" if deal_count_months is not None else ""
|
||
factors.append(
|
||
_factor_from_count(
|
||
_F_DEAL_COUNT,
|
||
deal_count,
|
||
high_at=_DEAL_COUNT_HIGH,
|
||
low_below=_DEAL_COUNT_LOW,
|
||
unit="сделок",
|
||
suffix=_deal_suffix,
|
||
)
|
||
)
|
||
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(_history_factor(history_months))
|
||
# Шок-окно учитываем ТОЛЬКО когда оно есть (True): чистое окно не должно
|
||
# искусственно тянуть к 'high', если других сигналов нет (см. graceful ниже).
|
||
if confounded:
|
||
factors.append(_confounded_factor(True))
|
||
|
||
# ── 2. Вкладывающие per-service confidence → факторы ──────────────────────
|
||
# Элемент — ЛИБО плоский уровень (legacy), ЛИБО пара (имя-сервиса, уровень)
|
||
# (#1737: проносим имя сервиса в RU-метку фактора). Мусор/неизвестный уровень
|
||
# отбрасываем (whitelist), как и раньше.
|
||
for c in component_confidences or []:
|
||
if isinstance(c, tuple):
|
||
service, level = c
|
||
else:
|
||
service, level = None, c
|
||
if level in _CONFIDENCE_RANK:
|
||
factors.append(_component_factor(level, service))
|
||
|
||
# ── 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,
|
||
)
|