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.