Merge pull request 'fix(report): человеческий язык в Разделе 6 — дефицит/затоварка, 6.2/6.3/6.5 (#1963)' (#1988) from fix/report-microcopy-1963 into main
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled

This commit is contained in:
bot-backend 2026-06-27 12:59:28 +00:00
commit ef51bb8b62
13 changed files with 448 additions and 58 deletions

View file

@ -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:

View file

@ -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). Берём

View file

@ -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 ──────────────────────────────────────────────

View file

@ -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;

View file

@ -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 */}

View file

@ -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>
);
}

View file

@ -121,7 +121,7 @@ export function ScenariosBlock({
}}
>
<div style={{ fontSize: 14, fontWeight: 600 }}>
Сценарная дифференциация недоступна
Почему один сценарий, а не три
</div>
{scenarios.scenarios_collapse_reason && (
<div

View file

@ -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({

View file

@ -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("Чувствительность не определена.");
});
});

View file

@ -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) ──────────────────────────────────
/**

View file

@ -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>
))
) : (

View file

@ -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>
)}

View file

@ -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}>
Вес значение скора 01 (выше лучше для девелопера; риск-скоры
уже инвертированы). Направление относительно баланса 0.5.
Оценка значение скора 01 (выше лучше для девелопера; риск-скоры
уже инвертированы, поэтому «выше = лучше» и для них). Направление
относительно баланса 0.5.
</p>
</div>
</>