fix(report): human RU microcopy in Site Finder Section 6 (#1963)
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
This commit is contained in:
parent
16f375782b
commit
e82761964d
13 changed files with 448 additions and 58 deletions
|
|
@ -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)
|
level = _level_from_value(coverage, high_at=_DOMRF_COVERAGE_HIGH, low_below=_DOMRF_COVERAGE_LOW)
|
||||||
if coverage is None:
|
if coverage is None:
|
||||||
note = "покрытие domrf↔objective неизвестно"
|
note = (
|
||||||
|
"Доля будущих проектов с известными планировками и площадями неизвестна — "
|
||||||
|
"оценка будущего предложения и конкуренции менее надёжна"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
pct = round(float(coverage) * 100.0, 1)
|
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)
|
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:
|
def _confounded_factor(confounded: bool) -> ConfidenceFactor:
|
||||||
"""Шок-окно → ConfidenceFactor. PURE.
|
"""Шок-окно → ConfidenceFactor. PURE.
|
||||||
|
|
||||||
|
|
@ -483,15 +516,7 @@ def compute_report_confidence(
|
||||||
if domrf_coverage is not None:
|
if domrf_coverage is not None:
|
||||||
factors.append(_coverage_factor(domrf_coverage))
|
factors.append(_coverage_factor(domrf_coverage))
|
||||||
if history_months is not None:
|
if history_months is not None:
|
||||||
factors.append(
|
factors.append(_history_factor(history_months))
|
||||||
_factor_from_count(
|
|
||||||
_F_HISTORY_MONTHS,
|
|
||||||
history_months,
|
|
||||||
high_at=_HISTORY_MONTHS_HIGH,
|
|
||||||
low_below=_HISTORY_MONTHS_LOW,
|
|
||||||
unit="мес истории",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Шок-окно учитываем ТОЛЬКО когда оно есть (True): чистое окно не должно
|
# Шок-окно учитываем ТОЛЬКО когда оно есть (True): чистое окно не должно
|
||||||
# искусственно тянуть к 'high', если других сигналов нет (см. graceful ниже).
|
# искусственно тянуть к 'high', если других сигналов нет (см. graceful ниже).
|
||||||
if confounded:
|
if confounded:
|
||||||
|
|
|
||||||
|
|
@ -96,9 +96,10 @@ _MIN_RATE_PCT: float = 0.0
|
||||||
# читается как баг. Текст уходит в отчёт через ReportScenarios.scenarios_collapse_reason
|
# читается как баг. Текст уходит в отчёт через ReportScenarios.scenarios_collapse_reason
|
||||||
# и далее в PDF/Excel-плашку вместо таблицы трёх одинаковых столбцов.
|
# и далее в PDF/Excel-плашку вместо таблицы трёх одинаковых столбцов.
|
||||||
_COLLAPSE_REASON_LOW_BETA: Final[str] = (
|
_COLLAPSE_REASON_LOW_BETA: Final[str] = (
|
||||||
"β rate-sensitivity не прошёл gate (n≥30 ∧ R²≥0.1 ∧ slope<0) на текущих "
|
"Чувствительность спроса к ключевой ставке не удалось оценить на коротком "
|
||||||
"данных ЕКБ — сценарная дифференциация conservative/aggressive восстановится "
|
"ряде по Екатеринбургу, поэтому консервативный и агрессивный сценарии "
|
||||||
"после расширения окна по ключевой ставке."
|
"совпали с базовым — показываем один базовый сценарий вместо трёх. Три "
|
||||||
|
"сценария вернутся, когда накопится более длинная история по ставке."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Допуски сравнения metric'ов сценариев при детекции collapse (math.isclose). Берём
|
# Допуски сравнения metric'ов сценариев при детекции collapse (math.isclose). Берём
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ from app.services.forecasting.confidence_engine import (
|
||||||
_cap,
|
_cap,
|
||||||
_coverage_factor,
|
_coverage_factor,
|
||||||
_factor_from_count,
|
_factor_from_count,
|
||||||
|
_history_factor,
|
||||||
_level_from_value,
|
_level_from_value,
|
||||||
compute_report_confidence,
|
compute_report_confidence,
|
||||||
)
|
)
|
||||||
|
|
@ -142,7 +143,9 @@ class TestCoverageFactor:
|
||||||
assert f.level == "low"
|
assert f.level == "low"
|
||||||
assert f.value == 0.025
|
assert f.value == 0.025
|
||||||
assert "2.5%" in f.note
|
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:
|
def test_high_coverage(self) -> None:
|
||||||
f = _coverage_factor(0.75)
|
f = _coverage_factor(0.75)
|
||||||
|
|
@ -152,7 +155,8 @@ class TestCoverageFactor:
|
||||||
def test_none_coverage_low(self) -> None:
|
def test_none_coverage_low(self) -> None:
|
||||||
f = _coverage_factor(None)
|
f = _coverage_factor(None)
|
||||||
assert f.level == "low"
|
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:
|
def test_sub_one_percent_fraction_stays_low_not_inflated(self) -> None:
|
||||||
# BUG #3 регрессия: 0.8% покрытия как доля = 0.008 → low (sparse-риск виден).
|
# BUG #3 регрессия: 0.8% покрытия как доля = 0.008 → low (sparse-риск виден).
|
||||||
|
|
@ -163,6 +167,31 @@ class TestCoverageFactor:
|
||||||
assert "0.8%" in f.note
|
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 ──────────────────────────────────────────────
|
# ── _aggregate — weakest-link MIN ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1049,6 +1049,13 @@
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
/* #1963 — заголовок над причиной схлопывания сценариев. */
|
||||||
|
.scenarioCollapsedTitle {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
.scenarioCard {
|
.scenarioCard {
|
||||||
border-top: 2px solid var(--accent-cyan);
|
border-top: 2px solid var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
@ -1193,6 +1200,13 @@
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
font-weight: 500;
|
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 {
|
.thNum {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
@ -1250,6 +1264,35 @@
|
||||||
color: var(--accent-red);
|
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 (recommended class) */
|
||||||
.tag {
|
.tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,19 @@ interface Props {
|
||||||
const FACTOR_RU: Record<string, string> = {
|
const FACTOR_RU: Record<string, string> = {
|
||||||
deal_count: "Объём сделок",
|
deal_count: "Объём сделок",
|
||||||
analog_count: "Аналоги (ЖК)",
|
analog_count: "Аналоги (ЖК)",
|
||||||
domrf_coverage: "Покрытие ДОМ.РФ",
|
domrf_coverage: "Данные по будущим проектам",
|
||||||
history_months: "Глубина истории",
|
history_months: "Глубина истории",
|
||||||
component: "Компонент",
|
component: "Компонент",
|
||||||
|
// #1963: раньше рендерились сырым ключом — даём человеческие имена.
|
||||||
|
confounded_window: "Окно ряда пересекает шок-период",
|
||||||
|
advisory_cap: "Советующий режим",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// #1963: weakest-link — итог тянет вниз самый слабый фактор (а не среднее).
|
||||||
|
const WEAKEST_LINK_NOTE =
|
||||||
|
"Итоговый уровень задаёт самый слабый фактор (правило «слабого звена»): " +
|
||||||
|
"мы скорее завысим недоверие, чем недооценим риск.";
|
||||||
|
|
||||||
function isConfidenceFactor(v: unknown): v is ConfidenceFactor {
|
function isConfidenceFactor(v: unknown): v is ConfidenceFactor {
|
||||||
if (typeof v !== "object" || v === null) return false;
|
if (typeof v !== "object" || v === null) return false;
|
||||||
const o = v as Record<string, unknown>;
|
const o = v as Record<string, unknown>;
|
||||||
|
|
@ -103,6 +111,16 @@ export function ForecastConfidenceBlock({ confidence }: Props) {
|
||||||
{confidence.rationale}
|
{confidence.rationale}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WEAKEST_LINK_NOTE}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Explicit drivers list */}
|
{/* Explicit drivers list */}
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,17 @@ import type {
|
||||||
import type { BadgeVariant } from "@/components/ui/Badge";
|
import type { BadgeVariant } from "@/components/ui/Badge";
|
||||||
import {
|
import {
|
||||||
PRODUCT_SCORE_RU,
|
PRODUCT_SCORE_RU,
|
||||||
|
SPECIAL_INDEX_DESC,
|
||||||
SPECIAL_INDEX_RU,
|
SPECIAL_INDEX_RU,
|
||||||
fmtNum,
|
fmtNum,
|
||||||
scoreBarWidthPct,
|
scoreBarWidthPct,
|
||||||
scoreVariant,
|
scoreVariant,
|
||||||
|
stripSectionRefs,
|
||||||
} from "./forecast-helpers";
|
} from "./forecast-helpers";
|
||||||
|
|
||||||
|
// #1963 — порог баланса для общего вердикта по итоговому скору (0.5 = баланс).
|
||||||
|
const OVERALL_BALANCE = 0.5;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** §13.6 section; the whole block is skipped when absent (see Section6Forecast). */
|
/** §13.6 section; the whole block is skipped when absent (see Section6Forecast). */
|
||||||
scoring: ReportScoring;
|
scoring: ReportScoring;
|
||||||
|
|
@ -121,6 +126,15 @@ export function ForecastScoringBlock({ scoring }: Props) {
|
||||||
: "Из чего складывается продуктовый скор (значения ∈ 0…1, выше — лучше)."}
|
: "Из чего складывается продуктовый скор (значения ∈ 0…1, выше — лучше)."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* #1963 — общий вердикт по итоговому скору относительно баланса 0.5. */}
|
||||||
|
{scoring.overall != null && (
|
||||||
|
<p style={{ ...HINT_STYLE, color: "var(--fg-secondary)" }}>
|
||||||
|
{scoring.overall < OVERALL_BALANCE
|
||||||
|
? `Итог ${fmtNum(scoring.overall, 2)} ниже баланса 0.5 — рынок для этого продукта скорее неблагоприятный.`
|
||||||
|
: `Итог ${fmtNum(scoring.overall, 2)} на уровне баланса 0.5 или выше — рынок для этого продукта скорее благоприятный.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Продуктовые скоры (#985) — quality gradient (higher = better). */}
|
{/* Продуктовые скоры (#985) — quality gradient (higher = better). */}
|
||||||
<ProductScoresGroup scores={scores} />
|
<ProductScoresGroup scores={scores} />
|
||||||
|
|
||||||
|
|
@ -198,18 +212,24 @@ function ScoreBar({ score }: { score: ProductScore }) {
|
||||||
fill={BAR_FILL[variant]}
|
fill={BAR_FILL[variant]}
|
||||||
ariaLabel={`${label}: ${valueStr}`}
|
ariaLabel={`${label}: ${valueStr}`}
|
||||||
/>
|
/>
|
||||||
{score.reason && (
|
{score.reason &&
|
||||||
<p
|
(() => {
|
||||||
style={{
|
// #1963 — §-ссылки уводим из основного текста в title-тултип «детали».
|
||||||
margin: 0,
|
const { clean, refs } = stripSectionRefs(score.reason);
|
||||||
fontSize: 12,
|
return (
|
||||||
lineHeight: 1.45,
|
<p
|
||||||
color: "var(--fg-tertiary)",
|
style={{
|
||||||
}}
|
margin: 0,
|
||||||
>
|
fontSize: 12,
|
||||||
{score.reason}
|
lineHeight: 1.45,
|
||||||
</p>
|
color: "var(--fg-tertiary)",
|
||||||
)}
|
}}
|
||||||
|
title={refs ? `Источник: ${refs}` : undefined}
|
||||||
|
>
|
||||||
|
{clean || score.reason}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -287,6 +307,19 @@ function IndexBar({ index }: { index: SpecialIndex }) {
|
||||||
fill={BAR_FILL[variant]}
|
fill={BAR_FILL[variant]}
|
||||||
ariaLabel={`${name}: ${valueStr}`}
|
ariaLabel={`${name}: ${valueStr}`}
|
||||||
/>
|
/>
|
||||||
|
{/* #1963 — однострочное «что это + куда лучше» (направление у индексов разное). */}
|
||||||
|
{SPECIAL_INDEX_DESC[index.key] && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SPECIAL_INDEX_DESC[index.key]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ export function ScenariosBlock({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 14, fontWeight: 600 }}>
|
<div style={{ fontSize: 14, fontWeight: 600 }}>
|
||||||
Сценарная дифференциация недоступна
|
Почему один сценарий, а не три
|
||||||
</div>
|
</div>
|
||||||
{scenarios.scenarios_collapse_reason && (
|
{scenarios.scenarios_collapse_reason && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -307,15 +307,17 @@ function ForecastReady({
|
||||||
? kpiColorForDeficit(kn.deficit_index)
|
? kpiColorForDeficit(kn.deficit_index)
|
||||||
: "neutral"
|
: "neutral"
|
||||||
}
|
}
|
||||||
|
caption="шкала от −1 до +1"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Мес. предложения"
|
label="Мес. предложения (MOI)"
|
||||||
value={
|
value={
|
||||||
kn.months_of_inventory != null
|
kn.months_of_inventory != null
|
||||||
? fmtNum(kn.months_of_inventory, 1)
|
? fmtNum(kn.months_of_inventory, 1)
|
||||||
: "—"
|
: "—"
|
||||||
}
|
}
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
caption="за сколько месяцев район распродаст текущее предложение"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Итоговый скор"
|
label="Итоговый скор"
|
||||||
|
|
@ -338,6 +340,9 @@ function ForecastReady({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Легенда индекса дефицита (#1963) — что значит −1 / 0 / +1 + трактовка. */}
|
||||||
|
<DeficitLegend />
|
||||||
|
|
||||||
{!hasAny && (
|
{!hasAny && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -354,19 +359,9 @@ function ForecastReady({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Траектория индекса дефицита (chart-then-table, выше 6.1) */}
|
{/* Траектория индекса дефицита (chart-then-table, выше 6.1). Причину
|
||||||
{report.scenarios.scenarios_collapsed &&
|
схлопывания сценариев показываем в 6.2 под заголовком «Почему один
|
||||||
report.scenarios.scenarios_collapse_reason && (
|
сценарий, а не три» (#1963) — здесь дубль убран. */}
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--fg-secondary)",
|
|
||||||
margin: "0 0 8px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{report.scenarios.scenarios_collapse_reason}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<ForecastChart report={report} selectedHorizon={selectedHorizon} />
|
<ForecastChart report={report} selectedHorizon={selectedHorizon} />
|
||||||
|
|
||||||
{/* 6.1 Прогноз по горизонтам */}
|
{/* 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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid var(--border-soft)",
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
padding: "12px 16px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Как читать индекс дефицита (шкала −1…+1)
|
||||||
|
</span>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{items.map((it) => (
|
||||||
|
<div
|
||||||
|
key={it.sign}
|
||||||
|
style={{ display: "flex", alignItems: "baseline", gap: 8 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: it.color,
|
||||||
|
minWidth: 24,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{it.sign}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{it.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 12, lineHeight: 1.5, color: "var(--fg-tertiary)" }}>
|
||||||
|
«Мес. предложения» (MOI) — за сколько месяцев район распродаст текущее
|
||||||
|
предложение при нынешнем темпе продаж: чем больше месяцев, тем сильнее
|
||||||
|
затоварка и ниже индекс.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── SubBlock wrapper (6.1 / 6.2 / 6.3) ───────────────────────────────────────
|
// ── SubBlock wrapper (6.1 / 6.2 / 6.3) ───────────────────────────────────────
|
||||||
|
|
||||||
function SubBlock({
|
function SubBlock({
|
||||||
|
|
|
||||||
|
|
@ -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("Чувствительность не определена.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -168,7 +168,8 @@ export function scoreBarWidthPct(value: number): number {
|
||||||
export const PRODUCT_SCORE_RU: Record<string, string> = {
|
export const PRODUCT_SCORE_RU: Record<string, string> = {
|
||||||
market_fit: "Соответствие рынку",
|
market_fit: "Соответствие рынку",
|
||||||
demand: "Спрос",
|
demand: "Спрос",
|
||||||
supply_risk: "Риск избытка предложения",
|
// #1963: «Риск …» сбивал (скор инвертирован, выше=лучше) → «Запас по предложению».
|
||||||
|
supply_risk: "Запас по предложению",
|
||||||
future_competition: "Будущая конкуренция",
|
future_competition: "Будущая конкуренция",
|
||||||
price_feasibility: "Доступность цены",
|
price_feasibility: "Доступность цены",
|
||||||
infra_fit: "Инфраструктура",
|
infra_fit: "Инфраструктура",
|
||||||
|
|
@ -193,6 +194,55 @@ export const SPECIAL_INDEX_RU: Record<string, string> = {
|
||||||
cost_of_error: "Цена ошибки",
|
cost_of_error: "Цена ошибки",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #1963 — однострочное «что это + куда лучше» для 6 спец-индексов. Раньше индексы
|
||||||
|
* показывались только сырым именем + числом без объяснения, что значит «выше» —
|
||||||
|
* а направление у них РАЗНОЕ (часть растёт к лучшему, часть к худшему). Здесь
|
||||||
|
* проговариваем смысл и желаемую сторону. Источник правды до появления поля от
|
||||||
|
* бэкенда — этот map (graceful: незнакомый ключ → пусто, строка просто без подписи).
|
||||||
|
*/
|
||||||
|
export const SPECIAL_INDEX_DESC: Record<string, string> = {
|
||||||
|
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) ──────────────────────────────────
|
// ── Future supply (§9.3 — 6.6 evidence panel) ──────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,19 @@ interface Props {
|
||||||
const FACTOR_RU: Record<string, string> = {
|
const FACTOR_RU: Record<string, string> = {
|
||||||
deal_count: "Объём данных по сделкам",
|
deal_count: "Объём данных по сделкам",
|
||||||
analog_count: "Аналоги (ЖК)",
|
analog_count: "Аналоги (ЖК)",
|
||||||
domrf_coverage: "Покрытие ДОМ.РФ",
|
domrf_coverage: "Данные по будущим проектам",
|
||||||
history_months: "Глубина истории",
|
history_months: "Глубина истории",
|
||||||
component: "Компонент",
|
component: "Компонент",
|
||||||
|
// #1963: раньше рендерились сырым ключом.
|
||||||
|
confounded_window: "Окно ряда пересекает шок-период",
|
||||||
|
advisory_cap: "Советующий режим",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// #1963: weakest-link rule — итог тянет вниз самый слабый фактор.
|
||||||
|
const WEAKEST_LINK_NOTE =
|
||||||
|
"Итоговый уровень задаёт самый слабый фактор (правило «слабого звена»): " +
|
||||||
|
"скорее завысим недоверие, чем недооценим риск.";
|
||||||
|
|
||||||
const LEVEL_RANK: Record<ConfidenceFactor["level"], number> = {
|
const LEVEL_RANK: Record<ConfidenceFactor["level"], number> = {
|
||||||
high: 0,
|
high: 0,
|
||||||
medium: 1,
|
medium: 1,
|
||||||
|
|
@ -72,15 +80,27 @@ export function ConfidencePanel({ confidence }: Props) {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* #1963 — почему именно такой уровень: rationale из payload + правило. */}
|
||||||
|
{confidence.rationale && (
|
||||||
|
<p className={styles.confRationale}>{confidence.rationale}</p>
|
||||||
|
)}
|
||||||
|
<p className={styles.confRule}>{WEAKEST_LINK_NOTE}</p>
|
||||||
|
|
||||||
{drivers.length > 0 ? (
|
{drivers.length > 0 ? (
|
||||||
drivers.map(([key, factor]) => (
|
drivers.map(([key, factor]) => (
|
||||||
<div key={key} className={styles.confRow}>
|
<div key={key} className={styles.confItem}>
|
||||||
<span className={styles.confName}>{factorLabel(key, factor)}</span>
|
<div className={styles.confItemHead}>
|
||||||
<span
|
<span className={styles.confName}>
|
||||||
className={`${styles.confPill} ${PILL_CLASS[confidencePill(factor.level)]}`}
|
{factorLabel(key, factor)}
|
||||||
>
|
</span>
|
||||||
{CONFIDENCE_RU[factor.level]}
|
<span
|
||||||
</span>
|
className={`${styles.confPill} ${PILL_CLASS[confidencePill(factor.level)]}`}
|
||||||
|
>
|
||||||
|
{CONFIDENCE_RU[factor.level]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* #1963 — показываем note фактора (раньше скрыт). */}
|
||||||
|
{factor.note && <p className={styles.confNote}>{factor.note}</p>}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,10 @@ export function ScenarioCards({
|
||||||
/>
|
/>
|
||||||
{scenarios.scenarios_collapse_reason && (
|
{scenarios.scenarios_collapse_reason && (
|
||||||
<div className={styles.scenarioCollapsedNote}>
|
<div className={styles.scenarioCollapsedNote}>
|
||||||
|
{/* #1963 — заголовок-вопрос вместо немой плашки с причиной. */}
|
||||||
|
<div className={styles.scenarioCollapsedTitle}>
|
||||||
|
Почему один сценарий, а не три
|
||||||
|
</div>
|
||||||
{scenarios.scenarios_collapse_reason}
|
{scenarios.scenarios_collapse_reason}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ const SCORE_WEAK_THRESHOLD = 0.45;
|
||||||
const PRODUCT_SCORE_RU: Record<string, string> = {
|
const PRODUCT_SCORE_RU: Record<string, string> = {
|
||||||
market_fit: "Соответствие рынку",
|
market_fit: "Соответствие рынку",
|
||||||
demand: "Спрос",
|
demand: "Спрос",
|
||||||
supply_risk: "Риск избытка предложения",
|
// #1963: «Риск …» сбивал (скор инвертирован, выше=лучше) → «Запас по предложению».
|
||||||
|
supply_risk: "Запас по предложению",
|
||||||
future_competition: "Будущая конкуренция",
|
future_competition: "Будущая конкуренция",
|
||||||
price_feasibility: "Доступность цены",
|
price_feasibility: "Доступность цены",
|
||||||
infra_fit: "Инфраструктура",
|
infra_fit: "Инфраструктура",
|
||||||
|
|
@ -39,6 +40,31 @@ const PRODUCT_SCORE_RU: Record<string, string> = {
|
||||||
confidence: "Надёжность данных",
|
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 {
|
interface Direction {
|
||||||
text: string;
|
text: string;
|
||||||
toneClass: string;
|
toneClass: string;
|
||||||
|
|
@ -70,21 +96,36 @@ export function ScoringTransparency({ scoring }: Props) {
|
||||||
6.5 · <b>прозрачность скоринга</b>
|
6.5 · <b>прозрачность скоринга</b>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
|
{/* #1963 — общий вердикт по итоговому скору относительно баланса 0.5. */}
|
||||||
|
{scoring.overall != null && (
|
||||||
|
<p className={styles.confRationale}>
|
||||||
|
{scoring.overall < OVERALL_BALANCE
|
||||||
|
? `Итог ${fmtNum(scoring.overall, 2)} ниже баланса 0.5 — рынок для этого продукта скорее неблагоприятный.`
|
||||||
|
: `Итог ${fmtNum(scoring.overall, 2)} на уровне баланса 0.5 или выше — рынок для этого продукта скорее благоприятный.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<table className={styles.dtable}>
|
<table className={styles.dtable}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Фактор прогноза</th>
|
<th>Фактор прогноза</th>
|
||||||
<th className={styles.thNum}>Вес</th>
|
{/* #1963 — «Вес» вводил в заблуждение: это значение скора, не вес. */}
|
||||||
|
<th className={styles.thNum}>Оценка</th>
|
||||||
<th>Направление</th>
|
<th>Направление</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{scores.map((s) => {
|
{scores.map((s) => {
|
||||||
const dir = scoreDirection(s.value);
|
const dir = scoreDirection(s.value);
|
||||||
|
// #1963 — показываем причину прямо в строке (раньше только tooltip);
|
||||||
|
// §-ссылки уводим в title-«детали».
|
||||||
|
const { clean, refs } = s.reason
|
||||||
|
? stripSectionRefs(s.reason)
|
||||||
|
: { clean: "", refs: null };
|
||||||
return (
|
return (
|
||||||
<tr key={s.key}>
|
<tr key={s.key}>
|
||||||
<td title={s.reason || undefined}>
|
<td title={refs ? `Источник: ${refs}` : undefined}>
|
||||||
{PRODUCT_SCORE_RU[s.key] ?? s.key}
|
{PRODUCT_SCORE_RU[s.key] ?? s.key}
|
||||||
|
{clean && <p className={styles.scoreReason}>{clean}</p>}
|
||||||
</td>
|
</td>
|
||||||
<td className={styles.tdNum}>
|
<td className={styles.tdNum}>
|
||||||
{s.value != null ? fmtNum(s.value, 2) : "нет данных"}
|
{s.value != null ? fmtNum(s.value, 2) : "нет данных"}
|
||||||
|
|
@ -96,8 +137,9 @@ export function ScoringTransparency({ scoring }: Props) {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p className={styles.panelHint}>
|
<p className={styles.panelHint}>
|
||||||
Вес — значение скора ∈ 0…1 (выше — лучше для девелопера; риск-скоры
|
Оценка — значение скора ∈ 0…1 (выше — лучше для девелопера; риск-скоры
|
||||||
уже инвертированы). Направление — относительно баланса 0.5.
|
уже инвертированы, поэтому «выше = лучше» и для них). Направление —
|
||||||
|
относительно баланса 0.5.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue