From e82761964d092e1b2d23a2a87c7da82cd7afd11e Mon Sep 17 00:00:00 2001
From: Light1YT
Date: Sat, 27 Jun 2026 17:42:27 +0500
Subject: [PATCH] fix(report): human RU microcopy in Site Finder Section 6
(#1963)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.../services/forecasting/confidence_engine.py | 47 ++++++--
backend/app/services/forecasting/scenarios.py | 7 +-
.../forecasting/test_confidence_engine.py | 33 +++++-
.../analysis/[cad]/ptica/ptica.module.css | 43 +++++++
.../analysis/ForecastConfidenceBlock.tsx | 20 +++-
.../analysis/ForecastScoringBlock.tsx | 57 +++++++--
.../site-finder/analysis/ScenariosBlock.tsx | 2 +-
.../site-finder/analysis/Section6Forecast.tsx | 108 +++++++++++++++---
.../forecast-helpers-stripRefs.test.ts | 45 ++++++++
.../site-finder/analysis/forecast-helpers.ts | 52 ++++++++-
.../ptica/scenarios/ConfidencePanel.tsx | 36 ++++--
.../ptica/scenarios/ScenarioCards.tsx | 4 +
.../ptica/scenarios/ScoringTransparency.tsx | 52 ++++++++-
13 files changed, 448 insertions(+), 58 deletions(-)
create mode 100644 frontend/src/components/site-finder/analysis/__tests__/forecast-helpers-stripRefs.test.ts
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 = {
deal_count: "Объём сделок",
analog_count: "Аналоги (ЖК)",
- domrf_coverage: "Покрытие ДОМ.РФ",
+ domrf_coverage: "Данные по будущим проектам",
history_months: "Глубина истории",
component: "Компонент",
+ // #1963: раньше рендерились сырым ключом — даём человеческие имена.
+ confounded_window: "Окно ряда пересекает шок-период",
+ advisory_cap: "Советующий режим",
};
+// #1963: weakest-link — итог тянет вниз самый слабый фактор (а не среднее).
+const WEAKEST_LINK_NOTE =
+ "Итоговый уровень задаёт самый слабый фактор (правило «слабого звена»): " +
+ "мы скорее завысим недоверие, чем недооценим риск.";
+
function isConfidenceFactor(v: unknown): v is ConfidenceFactor {
if (typeof v !== "object" || v === null) return false;
const o = v as Record;
@@ -103,6 +111,16 @@ export function ForecastConfidenceBlock({ confidence }: Props) {
{confidence.rationale}
)}
+
+ {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). */}
@@ -198,18 +212,24 @@ function ScoreBar({ score }: { score: ProductScore }) {
fill={BAR_FILL[variant]}
ariaLabel={`${label}: ${valueStr}`}
/>
- {score.reason && (
-
- {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({
}}
>
- Сценарная дифференциация недоступна
+ Почему один сценарий, а не три
{scenarios.scenarios_collapse_reason && (
+ {/* Легенда индекса дефицита (#1963) — что значит −1 / 0 / +1 + трактовка. */}
+
+
{!hasAny && (
)}
- {/* Траектория индекса дефицита (chart-then-table, выше 6.1) */}
- {report.scenarios.scenarios_collapsed &&
- report.scenarios.scenarios_collapse_reason && (
-
- {report.scenarios.scenarios_collapse_reason}
-
- )}
+ {/* Траектория индекса дефицита (chart-then-table, выше 6.1). Причину
+ схлопывания сценариев показываем в 6.2 под заголовком «Почему один
+ сценарий, а не три» (#1963) — здесь дубль убран. */}
{/* 6.1 Прогноз по горизонтам */}
@@ -462,6 +457,91 @@ function ForecastReady({
);
}
+// ── DeficitLegend — что значит шкала −1…+1 + связь с MOI (#1963) ─────────────
+//
+// Плоская строка-легенда под KPI: финдиректор видит «−0.42» / «116.6 мес» без
+// объяснения. Расшифровываем три точки шкалы простым языком + actionable-трактовку
+// и привязываем MOI к индексу. Только токены, без хардкод-цвета.
+
+function DeficitLegend() {
+ const items: { sign: string; color: string; text: string }[] = [
+ {
+ sign: "−1",
+ color: "var(--danger)",
+ text: "затоварка: предложения больше спроса — выходить осторожно, держать темп продаж",
+ },
+ {
+ sign: "0",
+ color: "var(--fg-secondary)",
+ text: "баланс: спрос и предложение сопоставимы",
+ },
+ {
+ sign: "+1",
+ color: "var(--success)",
+ text: "острый дефицит: спрос недозакрыт — окно для выхода, возможна премия к цене",
+ },
+ ];
+ return (
+
+
+ Как читать индекс дефицита (шкала −1…+1)
+
+
+ {items.map((it) => (
+
+
+ {it.sign}
+
+
+ {it.text}
+
+
+ ))}
+
+
+ «Мес. предложения» (MOI) — за сколько месяцев район распродаст текущее
+ предложение при нынешнем темпе продаж: чем больше месяцев, тем сильнее
+ затоварка и ниже индекс.
+
+
+ );
+}
+
// ── SubBlock wrapper (6.1 / 6.2 / 6.3) ───────────────────────────────────────
function SubBlock({
diff --git a/frontend/src/components/site-finder/analysis/__tests__/forecast-helpers-stripRefs.test.ts b/frontend/src/components/site-finder/analysis/__tests__/forecast-helpers-stripRefs.test.ts
new file mode 100644
index 00000000..4512c3fe
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/__tests__/forecast-helpers-stripRefs.test.ts
@@ -0,0 +1,45 @@
+/**
+ * Tests for `stripSectionRefs` — issue #1963 (report microcopy).
+ *
+ * The §13 score `reason` strings carry internal section refs («§10.4», «§9.6»)
+ * that read as jargon to a CFO. The 6.5 transparency block keeps the human text
+ * inline and moves the refs into a tooltip-only «детали» string. This helper does
+ * the split: clean prose out, refs collected, no orphan punctuation left behind.
+ */
+import { describe, expect, it } from "vitest";
+
+import { stripSectionRefs } from "../forecast-helpers";
+
+describe("stripSectionRefs — #1963", () => {
+ it("removes a trailing parenthetical section ref", () => {
+ const { clean, refs } = stripSectionRefs(
+ "Коммерческий сигнал §10.4 недоступен (нет данных по нежилому).",
+ );
+ expect(clean).toBe("Коммерческий сигнал недоступен (нет данных по нежилому).");
+ expect(refs).toBe("§10.4");
+ });
+
+ it("collects multiple refs and cleans orphan empty parens", () => {
+ const { clean, refs } = stripSectionRefs(
+ "Прокси платежа ~124 тыс.₽/мес (субсид. ставка, §7.9 — доступность относительная).",
+ );
+ expect(refs).toBe("§7.9");
+ expect(clean).not.toContain("§");
+ // No double spaces or dangling «(,» left over.
+ expect(clean).not.toMatch(/\s{2,}/);
+ expect(clean).not.toContain("( ");
+ });
+
+ it("returns null refs when there are none and leaves text intact", () => {
+ const reason = "Темп поглощения ~511 ед./мес по локации.";
+ const { clean, refs } = stripSectionRefs(reason);
+ expect(refs).toBeNull();
+ expect(clean).toBe(reason);
+ });
+
+ it("handles a bare § ref with no surrounding parens", () => {
+ const { clean, refs } = stripSectionRefs("Чувствительность не определена §9.6.");
+ expect(refs).toBe("§9.6");
+ expect(clean).toBe("Чувствительность не определена.");
+ });
+});
diff --git a/frontend/src/components/site-finder/analysis/forecast-helpers.ts b/frontend/src/components/site-finder/analysis/forecast-helpers.ts
index f865184f..5ba46171 100644
--- a/frontend/src/components/site-finder/analysis/forecast-helpers.ts
+++ b/frontend/src/components/site-finder/analysis/forecast-helpers.ts
@@ -168,7 +168,8 @@ export function scoreBarWidthPct(value: number): number {
export const PRODUCT_SCORE_RU: Record
= {
market_fit: "Соответствие рынку",
demand: "Спрос",
- supply_risk: "Риск избытка предложения",
+ // #1963: «Риск …» сбивал (скор инвертирован, выше=лучше) → «Запас по предложению».
+ supply_risk: "Запас по предложению",
future_competition: "Будущая конкуренция",
price_feasibility: "Доступность цены",
infra_fit: "Инфраструктура",
@@ -193,6 +194,55 @@ export const SPECIAL_INDEX_RU: Record = {
cost_of_error: "Цена ошибки",
};
+/**
+ * #1963 — однострочное «что это + куда лучше» для 6 спец-индексов. Раньше индексы
+ * показывались только сырым именем + числом без объяснения, что значит «выше» —
+ * а направление у них РАЗНОЕ (часть растёт к лучшему, часть к худшему). Здесь
+ * проговариваем смысл и желаемую сторону. Источник правды до появления поля от
+ * бэкенда — этот map (graceful: незнакомый ключ → пусто, строка просто без подписи).
+ */
+export const SPECIAL_INDEX_DESC: Record = {
+ product_void:
+ "Доля незакрытых ниш (форматов с дефицитом) — выше лучше: больше места для нового продукта.",
+ cost_of_error:
+ "Цена ошибки выхода = риск затоварки × средний чек лота — ниже лучше: дешевле ошибиться.",
+ launch_window:
+ "Лучший момент для старта по горизонтам прогноза — раньше окно, тем лучше.",
+ cannibalization:
+ "Насколько проект отъедает спрос у соседних ЖК того же класса — ниже лучше.",
+ artificial_demand:
+ "Доля спроса, держащегося на льготной ипотеке (уязвима к смене ставки) — ниже лучше.",
+ competitor_strength:
+ "Сила топ-конкурентов рядом — ниже лучше: слабее конкуренты, проще выйти.",
+};
+
+/**
+ * #1963 — вынести §-ссылки («§10.4», «§9.6», «§16») из основного reason в отдельную
+ * деталь/тултип. Возвращает {clean, refs}: `clean` — текст без §-маркеров и пустых
+ * скобок, `refs` — извлечённые ссылки (через запятую) для tooltip. PURE.
+ */
+export function stripSectionRefs(reason: string): {
+ clean: string;
+ refs: string | null;
+} {
+ // Match «§9.6» / «§10.4» / «§13.6a» but NOT the sentence-final dot after it.
+ // Trailing letter only when the ref ends at a separator/EOL (не «съедает» букву
+ // следующего слова, если § приклеена без пробела).
+ const SECTION_RE = /§\s*\d+(?:\.\d+)*(?:[a-zа-я](?=$|[\s.,;)]))?/gi;
+ const refs = reason.match(SECTION_RE) ?? [];
+ let clean = reason.replace(SECTION_RE, "");
+ // Подчистить осиротевшие скобки/запятые/двойные пробелы после выреза ссылки.
+ clean = clean
+ .replace(/\(\s*[,;]?\s*\)/g, "")
+ .replace(/,\s*\)/g, ")")
+ .replace(/\(\s+/g, "(")
+ .replace(/\s+\)/g, ")")
+ .replace(/\s{2,}/g, " ")
+ .replace(/\s+([.,;)])/g, "$1")
+ .trim();
+ return { clean, refs: refs.length > 0 ? refs.join(", ") : null };
+}
+
// ── Future supply (§9.3 — 6.6 evidence panel) ──────────────────────────────────
/**
diff --git a/frontend/src/components/site-finder/ptica/scenarios/ConfidencePanel.tsx b/frontend/src/components/site-finder/ptica/scenarios/ConfidencePanel.tsx
index 690f113b..6bbac0a3 100644
--- a/frontend/src/components/site-finder/ptica/scenarios/ConfidencePanel.tsx
+++ b/frontend/src/components/site-finder/ptica/scenarios/ConfidencePanel.tsx
@@ -18,11 +18,19 @@ interface Props {
const FACTOR_RU: Record = {
deal_count: "Объём данных по сделкам",
analog_count: "Аналоги (ЖК)",
- domrf_coverage: "Покрытие ДОМ.РФ",
+ domrf_coverage: "Данные по будущим проектам",
history_months: "Глубина истории",
component: "Компонент",
+ // #1963: раньше рендерились сырым ключом.
+ confounded_window: "Окно ряда пересекает шок-период",
+ advisory_cap: "Советующий режим",
};
+// #1963: weakest-link rule — итог тянет вниз самый слабый фактор.
+const WEAKEST_LINK_NOTE =
+ "Итоговый уровень задаёт самый слабый фактор (правило «слабого звена»): " +
+ "скорее завысим недоверие, чем недооценим риск.";
+
const LEVEL_RANK: Record = {
high: 0,
medium: 1,
@@ -72,15 +80,27 @@ export function ConfidencePanel({ confidence }: Props) {
)}
+ {/* #1963 — почему именно такой уровень: rationale из payload + правило. */}
+ {confidence.rationale && (
+ {confidence.rationale}
+ )}
+ {WEAKEST_LINK_NOTE}
+
{drivers.length > 0 ? (
drivers.map(([key, factor]) => (
-
-
{factorLabel(key, factor)}
-
- {CONFIDENCE_RU[factor.level]}
-
+
+
+
+ {factorLabel(key, factor)}
+
+
+ {CONFIDENCE_RU[factor.level]}
+
+
+ {/* #1963 — показываем note фактора (раньше скрыт). */}
+ {factor.note &&
{factor.note}
}
))
) : (
diff --git a/frontend/src/components/site-finder/ptica/scenarios/ScenarioCards.tsx b/frontend/src/components/site-finder/ptica/scenarios/ScenarioCards.tsx
index db86f0c1..0f0f3528 100644
--- a/frontend/src/components/site-finder/ptica/scenarios/ScenarioCards.tsx
+++ b/frontend/src/components/site-finder/ptica/scenarios/ScenarioCards.tsx
@@ -163,6 +163,10 @@ export function ScenarioCards({
/>
{scenarios.scenarios_collapse_reason && (
+ {/* #1963 — заголовок-вопрос вместо немой плашки с причиной. */}
+
+ Почему один сценарий, а не три
+
{scenarios.scenarios_collapse_reason}
)}
diff --git a/frontend/src/components/site-finder/ptica/scenarios/ScoringTransparency.tsx b/frontend/src/components/site-finder/ptica/scenarios/ScoringTransparency.tsx
index bd2628a8..68f1c86c 100644
--- a/frontend/src/components/site-finder/ptica/scenarios/ScoringTransparency.tsx
+++ b/frontend/src/components/site-finder/ptica/scenarios/ScoringTransparency.tsx
@@ -29,7 +29,8 @@ const SCORE_WEAK_THRESHOLD = 0.45;
const PRODUCT_SCORE_RU: Record
= {
market_fit: "Соответствие рынку",
demand: "Спрос",
- supply_risk: "Риск избытка предложения",
+ // #1963: «Риск …» сбивал (скор инвертирован, выше=лучше) → «Запас по предложению».
+ supply_risk: "Запас по предложению",
future_competition: "Будущая конкуренция",
price_feasibility: "Доступность цены",
infra_fit: "Инфраструктура",
@@ -39,6 +40,31 @@ const PRODUCT_SCORE_RU: Record = {
confidence: "Надёжность данных",
};
+// #1963 — порог баланса итогового скора (0.5 = баланс).
+const OVERALL_BALANCE = 0.5;
+
+/**
+ * #1963 — §-ссылки («§10.4», «§9.6») из основного reason в tooltip-детали.
+ * Возвращает {clean, refs}. Зеркало analysis/forecast-helpers.stripSectionRefs.
+ */
+function stripSectionRefs(reason: string): {
+ clean: string;
+ refs: string | null;
+} {
+ const SECTION_RE = /§\s*\d+(?:\.\d+)*(?:[a-zа-я](?=$|[\s.,;)]))?/gi;
+ const refs = reason.match(SECTION_RE) ?? [];
+ const clean = reason
+ .replace(SECTION_RE, "")
+ .replace(/\(\s*[,;]?\s*\)/g, "")
+ .replace(/,\s*\)/g, ")")
+ .replace(/\(\s+/g, "(")
+ .replace(/\s+\)/g, ")")
+ .replace(/\s{2,}/g, " ")
+ .replace(/\s+([.,;)])/g, "$1")
+ .trim();
+ return { clean, refs: refs.length > 0 ? refs.join(", ") : null };
+}
+
interface Direction {
text: string;
toneClass: string;
@@ -70,21 +96,36 @@ export function ScoringTransparency({ scoring }: Props) {
6.5 · прозрачность скоринга
+ {/* #1963 — общий вердикт по итоговому скору относительно баланса 0.5. */}
+ {scoring.overall != null && (
+
+ {scoring.overall < OVERALL_BALANCE
+ ? `Итог ${fmtNum(scoring.overall, 2)} ниже баланса 0.5 — рынок для этого продукта скорее неблагоприятный.`
+ : `Итог ${fmtNum(scoring.overall, 2)} на уровне баланса 0.5 или выше — рынок для этого продукта скорее благоприятный.`}
+
+ )}
| Фактор прогноза |
- Вес |
+ {/* #1963 — «Вес» вводил в заблуждение: это значение скора, не вес. */}
+ Оценка |
Направление |
{scores.map((s) => {
const dir = scoreDirection(s.value);
+ // #1963 — показываем причину прямо в строке (раньше только tooltip);
+ // §-ссылки уводим в title-«детали».
+ const { clean, refs } = s.reason
+ ? stripSectionRefs(s.reason)
+ : { clean: "", refs: null };
return (
- |
+ |
{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.
>