diff --git a/backend/app/services/forecasting/confidence_engine.py b/backend/app/services/forecasting/confidence_engine.py
index 82c2052f..4ef5b203 100644
--- a/backend/app/services/forecasting/confidence_engine.py
+++ b/backend/app/services/forecasting/confidence_engine.py
@@ -259,13 +259,46 @@ def _coverage_factor(coverage: float | None) -> ConfidenceFactor:
"""
level = _level_from_value(coverage, high_at=_DOMRF_COVERAGE_HIGH, low_below=_DOMRF_COVERAGE_LOW)
if coverage is None:
- note = "покрытие domrf↔objective неизвестно"
+ note = (
+ "Доля будущих проектов с известными планировками и площадями неизвестна — "
+ "оценка будущего предложения и конкуренции менее надёжна"
+ )
else:
pct = round(float(coverage) * 100.0, 1)
- note = f"покрытие domrf↔objective {pct}% — {_QUALITY_WORD[level]}"
+ 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.
@@ -483,15 +516,7 @@ def compute_report_confidence(
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="мес истории",
- )
- )
+ factors.append(_history_factor(history_months))
# Шок-окно учитываем ТОЛЬКО когда оно есть (True): чистое окно не должно
# искусственно тянуть к 'high', если других сигналов нет (см. graceful ниже).
if confounded:
diff --git a/backend/app/services/forecasting/scenarios.py b/backend/app/services/forecasting/scenarios.py
index 4b412076..e31f1ceb 100644
--- a/backend/app/services/forecasting/scenarios.py
+++ b/backend/app/services/forecasting/scenarios.py
@@ -96,9 +96,10 @@ _MIN_RATE_PCT: float = 0.0
# читается как баг. Текст уходит в отчёт через ReportScenarios.scenarios_collapse_reason
# и далее в PDF/Excel-плашку вместо таблицы трёх одинаковых столбцов.
_COLLAPSE_REASON_LOW_BETA: Final[str] = (
- "β rate-sensitivity не прошёл gate (n≥30 ∧ R²≥0.1 ∧ slope<0) на текущих "
- "данных ЕКБ — сценарная дифференциация conservative/aggressive восстановится "
- "после расширения окна по ключевой ставке."
+ "Чувствительность спроса к ключевой ставке не удалось оценить на коротком "
+ "ряде по Екатеринбургу, поэтому консервативный и агрессивный сценарии "
+ "совпали с базовым — показываем один базовый сценарий вместо трёх. Три "
+ "сценария вернутся, когда накопится более длинная история по ставке."
)
# Допуски сравнения metric'ов сценариев при детекции collapse (math.isclose). Берём
diff --git a/backend/tests/services/forecasting/test_confidence_engine.py b/backend/tests/services/forecasting/test_confidence_engine.py
index df6718b5..49c48a8d 100644
--- a/backend/tests/services/forecasting/test_confidence_engine.py
+++ b/backend/tests/services/forecasting/test_confidence_engine.py
@@ -36,6 +36,7 @@ from app.services.forecasting.confidence_engine import (
_cap,
_coverage_factor,
_factor_from_count,
+ _history_factor,
_level_from_value,
compute_report_confidence,
)
@@ -142,7 +143,9 @@ class TestCoverageFactor:
assert f.level == "low"
assert f.value == 0.025
assert "2.5%" in f.note
- assert "domrf↔objective" in f.note
+ # #1963: нота человеческая, без внутр.жаргона «domrf↔objective».
+ assert "domrf" not in f.note
+ assert "будущ" in f.note # говорит про будущее предложение/проекты
def test_high_coverage(self) -> None:
f = _coverage_factor(0.75)
@@ -152,7 +155,8 @@ class TestCoverageFactor:
def test_none_coverage_low(self) -> None:
f = _coverage_factor(None)
assert f.level == "low"
- assert "неизвестно" in f.note
+ assert "неизвестн" in f.note
+ assert "domrf" not in f.note
def test_sub_one_percent_fraction_stays_low_not_inflated(self) -> None:
# BUG #3 регрессия: 0.8% покрытия как доля = 0.008 → low (sparse-риск виден).
@@ -163,6 +167,31 @@ class TestCoverageFactor:
assert "0.8%" in f.note
+# ── _history_factor — глубина истории + «на что влияет» + связь с 6.2 (#1963) ──
+
+
+class TestHistoryFactor:
+ def test_short_history_low_with_impact_and_scenario_link(self) -> None:
+ f = _history_factor(6)
+ assert f.level == "low"
+ assert f.value == 6
+ assert "6 мес истории" in f.note
+ # Говорит на что влияет (ставка/тренды) и связывает с 6.2 (сценарии).
+ assert "ставк" in f.note
+ assert "6.2" in f.note
+
+ def test_long_history_high(self) -> None:
+ f = _history_factor(36)
+ assert f.level == "high"
+ assert "36 мес истории" in f.note
+
+ def test_none_history_low(self) -> None:
+ f = _history_factor(None)
+ assert f.level == "low"
+ assert f.value is None
+ assert "6.2" in f.note
+
+
# ── _aggregate — weakest-link MIN ──────────────────────────────────────────────
diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css
index 6633ceff..e4c70c5f 100644
--- a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css
+++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css
@@ -1049,6 +1049,13 @@
border-radius: var(--radius-sm);
background: var(--surface-2);
}
+/* #1963 — заголовок над причиной схлопывания сценариев. */
+.scenarioCollapsedTitle {
+ margin-bottom: 4px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text);
+}
.scenarioCard {
border-top: 2px solid var(--accent-cyan);
}
@@ -1193,6 +1200,13 @@
letter-spacing: 0.06em;
font-weight: 500;
}
+/* #1963 — reason под именем фактора в 6.5 (раньше только в title-тултипе). */
+.scoreReason {
+ margin: 2px 0 0;
+ font-size: 10px;
+ line-height: 1.45;
+ color: var(--text-soft);
+}
.thNum {
text-align: right;
}
@@ -1250,6 +1264,35 @@
color: var(--accent-red);
}
+/* #1963 — confidence rationale + weakest-link rule + per-factor note (6.3). */
+.confRationale {
+ margin: 6px 0 0;
+ font-size: 11px;
+ line-height: 1.5;
+ color: var(--text-muted);
+}
+.confRule {
+ margin: 6px 0 10px;
+ font-size: 10px;
+ line-height: 1.5;
+ color: var(--text-soft);
+}
+.confItem {
+ margin: 8px 0;
+}
+.confItemHead {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 11px;
+}
+.confNote {
+ margin: 3px 0 0;
+ font-size: 10px;
+ line-height: 1.5;
+ color: var(--text-soft);
+}
+
/* tag (recommended class) */
.tag {
display: inline-block;
diff --git a/frontend/src/components/site-finder/analysis/ForecastConfidenceBlock.tsx b/frontend/src/components/site-finder/analysis/ForecastConfidenceBlock.tsx
index d6131e74..e7cbf060 100644
--- a/frontend/src/components/site-finder/analysis/ForecastConfidenceBlock.tsx
+++ b/frontend/src/components/site-finder/analysis/ForecastConfidenceBlock.tsx
@@ -21,11 +21,19 @@ interface Props {
const FACTOR_RU: Record
+ {WEAKEST_LINK_NOTE} +
{/* Explicit drivers list */} diff --git a/frontend/src/components/site-finder/analysis/ForecastScoringBlock.tsx b/frontend/src/components/site-finder/analysis/ForecastScoringBlock.tsx index f9c98cbb..b7c0a0d8 100644 --- a/frontend/src/components/site-finder/analysis/ForecastScoringBlock.tsx +++ b/frontend/src/components/site-finder/analysis/ForecastScoringBlock.tsx @@ -35,12 +35,17 @@ import type { import type { BadgeVariant } from "@/components/ui/Badge"; import { PRODUCT_SCORE_RU, + SPECIAL_INDEX_DESC, SPECIAL_INDEX_RU, fmtNum, scoreBarWidthPct, scoreVariant, + stripSectionRefs, } from "./forecast-helpers"; +// #1963 — порог баланса для общего вердикта по итоговому скору (0.5 = баланс). +const OVERALL_BALANCE = 0.5; + interface Props { /** §13.6 section; the whole block is skipped when absent (see Section6Forecast). */ scoring: ReportScoring; @@ -121,6 +126,15 @@ export function ForecastScoringBlock({ scoring }: Props) { : "Из чего складывается продуктовый скор (значения ∈ 0…1, выше — лучше)."} + {/* #1963 — общий вердикт по итоговому скору относительно баланса 0.5. */} + {scoring.overall != null && ( ++ {scoring.overall < OVERALL_BALANCE + ? `Итог ${fmtNum(scoring.overall, 2)} ниже баланса 0.5 — рынок для этого продукта скорее неблагоприятный.` + : `Итог ${fmtNum(scoring.overall, 2)} на уровне баланса 0.5 или выше — рынок для этого продукта скорее благоприятный.`} +
+ )} + {/* Продуктовые скоры (#985) — quality gradient (higher = better). */}- {score.reason} -
- )} + {score.reason && + (() => { + // #1963 — §-ссылки уводим из основного текста в title-тултип «детали». + const { clean, refs } = stripSectionRefs(score.reason); + return ( ++ {clean || score.reason} +
+ ); + })()} ); } @@ -287,6 +307,19 @@ function IndexBar({ index }: { index: SpecialIndex }) { fill={BAR_FILL[variant]} ariaLabel={`${name}: ${valueStr}`} /> + {/* #1963 — однострочное «что это + куда лучше» (направление у индексов разное). */} + {SPECIAL_INDEX_DESC[index.key] && ( ++ {SPECIAL_INDEX_DESC[index.key]} +
+ )} ); } diff --git a/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx b/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx index 2fb6eacb..20a2980c 100644 --- a/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx +++ b/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx @@ -121,7 +121,7 @@ export function ScenariosBlock({ }} >- {report.scenarios.scenarios_collapse_reason} -
- )} + {/* Траектория индекса дефицита (chart-then-table, выше 6.1). Причину + схлопывания сценариев показываем в 6.2 под заголовком «Почему один + сценарий, а не три» (#1963) — здесь дубль убран. */}{confidence.rationale}
+ )} +{WEAKEST_LINK_NOTE}
+ {drivers.length > 0 ? ( drivers.map(([key, factor]) => ( -{factor.note}
}+ {scoring.overall < OVERALL_BALANCE + ? `Итог ${fmtNum(scoring.overall, 2)} ниже баланса 0.5 — рынок для этого продукта скорее неблагоприятный.` + : `Итог ${fmtNum(scoring.overall, 2)} на уровне баланса 0.5 или выше — рынок для этого продукта скорее благоприятный.`} +
+ )}| Фактор прогноза | -Вес | + {/* #1963 — «Вес» вводил в заблуждение: это значение скора, не вес. */} +Оценка | Направление |
|---|---|---|---|
| + |
{PRODUCT_SCORE_RU[s.key] ?? s.key}
+ {clean && {clean} } |
{s.value != null ? fmtNum(s.value, 2) : "нет данных"} @@ -96,8 +137,9 @@ export function ScoringTransparency({ scoring }: Props) { |
- Вес — значение скора ∈ 0…1 (выше — лучше для девелопера; риск-скоры - уже инвертированы). Направление — относительно баланса 0.5. + Оценка — значение скора ∈ 0…1 (выше — лучше для девелопера; риск-скоры + уже инвертированы, поэтому «выше = лучше» и для них). Направление — + относительно баланса 0.5.