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)
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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). Берём
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -21,11 +21,19 @@ interface Props {
|
|||
const FACTOR_RU: Record<string, string> = {
|
||||
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<string, unknown>;
|
||||
|
|
@ -103,6 +111,16 @@ export function ForecastConfidenceBlock({ confidence }: Props) {
|
|||
{confidence.rationale}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
color: "var(--fg-tertiary)",
|
||||
}}
|
||||
>
|
||||
{WEAKEST_LINK_NOTE}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Explicit drivers list */}
|
||||
|
|
|
|||
|
|
@ -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, выше — лучше)."}
|
||||
</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). */}
|
||||
<ProductScoresGroup scores={scores} />
|
||||
|
||||
|
|
@ -198,18 +212,24 @@ function ScoreBar({ score }: { score: ProductScore }) {
|
|||
fill={BAR_FILL[variant]}
|
||||
ariaLabel={`${label}: ${valueStr}`}
|
||||
/>
|
||||
{score.reason && (
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.45,
|
||||
color: "var(--fg-tertiary)",
|
||||
}}
|
||||
>
|
||||
{score.reason}
|
||||
</p>
|
||||
)}
|
||||
{score.reason &&
|
||||
(() => {
|
||||
// #1963 — §-ссылки уводим из основного текста в title-тултип «детали».
|
||||
const { clean, refs } = stripSectionRefs(score.reason);
|
||||
return (
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.45,
|
||||
color: "var(--fg-tertiary)",
|
||||
}}
|
||||
title={refs ? `Источник: ${refs}` : undefined}
|
||||
>
|
||||
{clean || score.reason}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -287,6 +307,19 @@ function IndexBar({ index }: { index: SpecialIndex }) {
|
|||
fill={BAR_FILL[variant]}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ export function ScenariosBlock({
|
|||
}}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>
|
||||
Сценарная дифференциация недоступна
|
||||
Почему один сценарий, а не три
|
||||
</div>
|
||||
{scenarios.scenarios_collapse_reason && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -307,15 +307,17 @@ function ForecastReady({
|
|||
? kpiColorForDeficit(kn.deficit_index)
|
||||
: "neutral"
|
||||
}
|
||||
caption="шкала от −1 до +1"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Мес. предложения"
|
||||
label="Мес. предложения (MOI)"
|
||||
value={
|
||||
kn.months_of_inventory != null
|
||||
? fmtNum(kn.months_of_inventory, 1)
|
||||
: "—"
|
||||
}
|
||||
color="neutral"
|
||||
caption="за сколько месяцев район распродаст текущее предложение"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Итоговый скор"
|
||||
|
|
@ -338,6 +340,9 @@ function ForecastReady({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Легенда индекса дефицита (#1963) — что значит −1 / 0 / +1 + трактовка. */}
|
||||
<DeficitLegend />
|
||||
|
||||
{!hasAny && (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -354,19 +359,9 @@ function ForecastReady({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Траектория индекса дефицита (chart-then-table, выше 6.1) */}
|
||||
{report.scenarios.scenarios_collapsed &&
|
||||
report.scenarios.scenarios_collapse_reason && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--fg-secondary)",
|
||||
margin: "0 0 8px 0",
|
||||
}}
|
||||
>
|
||||
{report.scenarios.scenarios_collapse_reason}
|
||||
</p>
|
||||
)}
|
||||
{/* Траектория индекса дефицита (chart-then-table, выше 6.1). Причину
|
||||
схлопывания сценариев показываем в 6.2 под заголовком «Почему один
|
||||
сценарий, а не три» (#1963) — здесь дубль убран. */}
|
||||
<ForecastChart report={report} selectedHorizon={selectedHorizon} />
|
||||
|
||||
{/* 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) ───────────────────────────────────────
|
||||
|
||||
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> = {
|
||||
market_fit: "Соответствие рынку",
|
||||
demand: "Спрос",
|
||||
supply_risk: "Риск избытка предложения",
|
||||
// #1963: «Риск …» сбивал (скор инвертирован, выше=лучше) → «Запас по предложению».
|
||||
supply_risk: "Запас по предложению",
|
||||
future_competition: "Будущая конкуренция",
|
||||
price_feasibility: "Доступность цены",
|
||||
infra_fit: "Инфраструктура",
|
||||
|
|
@ -193,6 +194,55 @@ export const SPECIAL_INDEX_RU: Record<string, string> = {
|
|||
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) ──────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,11 +18,19 @@ interface Props {
|
|||
const FACTOR_RU: Record<string, string> = {
|
||||
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<ConfidenceFactor["level"], number> = {
|
||||
high: 0,
|
||||
medium: 1,
|
||||
|
|
@ -72,15 +80,27 @@ export function ConfidencePanel({ confidence }: Props) {
|
|||
</span>
|
||||
)}
|
||||
</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.map(([key, factor]) => (
|
||||
<div key={key} className={styles.confRow}>
|
||||
<span className={styles.confName}>{factorLabel(key, factor)}</span>
|
||||
<span
|
||||
className={`${styles.confPill} ${PILL_CLASS[confidencePill(factor.level)]}`}
|
||||
>
|
||||
{CONFIDENCE_RU[factor.level]}
|
||||
</span>
|
||||
<div key={key} className={styles.confItem}>
|
||||
<div className={styles.confItemHead}>
|
||||
<span className={styles.confName}>
|
||||
{factorLabel(key, factor)}
|
||||
</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>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -163,6 +163,10 @@ export function ScenarioCards({
|
|||
/>
|
||||
{scenarios.scenarios_collapse_reason && (
|
||||
<div className={styles.scenarioCollapsedNote}>
|
||||
{/* #1963 — заголовок-вопрос вместо немой плашки с причиной. */}
|
||||
<div className={styles.scenarioCollapsedTitle}>
|
||||
Почему один сценарий, а не три
|
||||
</div>
|
||||
{scenarios.scenarios_collapse_reason}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ const SCORE_WEAK_THRESHOLD = 0.45;
|
|||
const PRODUCT_SCORE_RU: Record<string, string> = {
|
||||
market_fit: "Соответствие рынку",
|
||||
demand: "Спрос",
|
||||
supply_risk: "Риск избытка предложения",
|
||||
// #1963: «Риск …» сбивал (скор инвертирован, выше=лучше) → «Запас по предложению».
|
||||
supply_risk: "Запас по предложению",
|
||||
future_competition: "Будущая конкуренция",
|
||||
price_feasibility: "Доступность цены",
|
||||
infra_fit: "Инфраструктура",
|
||||
|
|
@ -39,6 +40,31 @@ const PRODUCT_SCORE_RU: Record<string, string> = {
|
|||
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 · <b>прозрачность скоринга</b>
|
||||
</div>
|
||||
<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}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Фактор прогноза</th>
|
||||
<th className={styles.thNum}>Вес</th>
|
||||
{/* #1963 — «Вес» вводил в заблуждение: это значение скора, не вес. */}
|
||||
<th className={styles.thNum}>Оценка</th>
|
||||
<th>Направление</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scores.map((s) => {
|
||||
const dir = scoreDirection(s.value);
|
||||
// #1963 — показываем причину прямо в строке (раньше только tooltip);
|
||||
// §-ссылки уводим в title-«детали».
|
||||
const { clean, refs } = s.reason
|
||||
? stripSectionRefs(s.reason)
|
||||
: { clean: "", refs: null };
|
||||
return (
|
||||
<tr key={s.key}>
|
||||
<td title={s.reason || undefined}>
|
||||
<td title={refs ? `Источник: ${refs}` : undefined}>
|
||||
{PRODUCT_SCORE_RU[s.key] ?? s.key}
|
||||
{clean && <p className={styles.scoreReason}>{clean}</p>}
|
||||
</td>
|
||||
<td className={styles.tdNum}>
|
||||
{s.value != null ? fmtNum(s.value, 2) : "нет данных"}
|
||||
|
|
@ -96,8 +137,9 @@ export function ScoringTransparency({ scoring }: Props) {
|
|||
</tbody>
|
||||
</table>
|
||||
<p className={styles.panelHint}>
|
||||
Вес — значение скора ∈ 0…1 (выше — лучше для девелопера; риск-скоры
|
||||
уже инвертированы). Направление — относительно баланса 0.5.
|
||||
Оценка — значение скора ∈ 0…1 (выше — лучше для девелопера; риск-скоры
|
||||
уже инвертированы, поэтому «выше = лучше» и для них). Направление —
|
||||
относительно баланса 0.5.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue