Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
a04f2d98fc feat(forecast): render scoring transparency (§13.6) as in-app subsection 6.5
All checks were successful
CI / backend-tests (push) Has been skipped
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
Surface the product-score + special-index breakdown behind the overall KPI
as two token-bar groups in Section 6 (6.5), sidebar anchor section-6-5.
Product scores use a 0..1 quality gradient anchored on the documented 0.5
balance point (>=0.55 strong / 0.45-0.55 mid / <0.45 weak; risk scores are
pre-inverted so higher=better); special indices use a neutral accent
(value = signal strength, direction non-uniform). Null values render as
«нет данных», never a 0-bar that implies a real zero. Graceful on
absent/partial/202-pending.

- types/forecast.ts: ReportScoring + nested (dict-keyed scores/indices)
- ForecastScoringBlock.tsx: two bar groups, a11y, null-distinct rendering
- forecast-helpers.ts: scoreVariant/scoreBarWidthPct + RU label maps
- Section6Forecast + sidebar nav: wire 6.5

Part of #958.
2026-06-07 15:07:54 +05:00
5 changed files with 505 additions and 1 deletions

View file

@ -262,6 +262,7 @@ const NAV_ITEMS = [
{ id: "section-6-2", label: "6.2 Сценарии" },
{ id: "section-6-3", label: "6.3 Уверенность" },
{ id: "section-6-4", label: "6.4 Рекомендация по продукту" },
{ id: "section-6-5", label: "6.5 Прозрачность скоринга" },
],
},
];

View file

@ -0,0 +1,330 @@
"use client";
/**
* 6.5 Прозрачность скоринга §13.6 scoring (#985/#986).
*
* Explains WHY the overall score is what it is: the exec-summary KPI shows only
* `scoring.overall` (single number) this block surfaces the breakdown that
* drives it. Two labeled groups of pure-Tailwind/token width-% bars (mobile-
* first, NOT a pie):
*
* Продуктовые скоры (#985) 10 scores, each value [0,1] with «выше =
* лучше для девелопера» (risk scores pre-inverted at the source). Bar width =
* value·100, colour = quality (scoreVariant: success / warning / danger).
* Специальные индексы (#986) 6 indices, each value [0,1] = СИЛА сигнала,
* NOT uniformly good/bad (cost_of_error / cannibalization / competitor_
* strength / artificial_demand grow toward worse; launch_window /
* product_void toward better). Direction is not uniform NEUTRAL accent bar
* (we do not imply good/bad on an index whose valence depends on which one).
*
* GRACEFUL: returns null when scoring is absent/empty; product_scores empty
* that group is skipped; special_indices empty skipped; rows with value=null
* (thin/unavailable) render «нет данных» without a bar. Never crashes on a
* partial / 202-pending report. The `overall` KPI is NOT duplicated here (shown
* once in the exec-summary card) only a small contextual sub-header echoes it.
*/
import type {
ProductScore,
ProductScoreCard,
ReportScoring,
SpecialIndex,
SpecialIndicesCard,
} from "@/types/forecast";
import type { BadgeVariant } from "@/components/ui/Badge";
import {
PRODUCT_SCORE_RU,
SPECIAL_INDEX_RU,
fmtNum,
scoreBarWidthPct,
scoreVariant,
} from "./forecast-helpers";
interface Props {
/** §13.6 section; the whole block is skipped when absent (see Section6Forecast). */
scoring: ReportScoring;
}
// BadgeVariant → bar-fill token. Semantic colours encode score quality
// (success/warning/danger) — documented viz exception (bar = signal). `info`
// (--accent) is the neutral/directionless fill used for special indices.
const BAR_FILL: Record<BadgeVariant, string> = {
success: "var(--success)",
danger: "var(--danger)",
warning: "var(--warn)",
info: "var(--accent)",
neutral: "var(--border-strong)",
};
const SUBHEAD_STYLE: React.CSSProperties = {
fontSize: 11,
fontWeight: 500,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--fg-secondary)",
};
const HINT_STYLE: React.CSSProperties = {
margin: 0,
fontSize: 12,
lineHeight: 1.5,
color: "var(--fg-tertiary)",
};
// ── Predicates ─────────────────────────────────────────────────────────────────
function scoreRows(card: ProductScoreCard | null): ProductScore[] {
if (card == null) return [];
// Stable object insertion order = canonical _SCORE_KEYS order (assembler).
return Object.values(card.scores).filter(
(s): s is ProductScore => s != null && typeof s.key === "string",
);
}
function indexRows(card: SpecialIndicesCard | null): SpecialIndex[] {
if (card == null) return [];
return Object.values(card.indices).filter(
(i): i is SpecialIndex => i != null && typeof i.key === "string",
);
}
function isNonEmpty(scoring: ReportScoring): boolean {
return (
scoreRows(scoring.product_scores).length > 0 ||
indexRows(scoring.special_indices).length > 0
);
}
// ── ForecastScoringBlock ─────────────────────────────────────────────────────────
export function ForecastScoringBlock({ scoring }: Props) {
// Graceful: nothing meaningful to show → render nothing.
if (!isNonEmpty(scoring)) return null;
const scores = scoreRows(scoring.product_scores);
const indices = indexRows(scoring.special_indices);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Contextual sub-header — echoes overall WITHOUT duplicating the KPI card. */}
<p
style={{
margin: 0,
fontSize: 13,
lineHeight: 1.5,
color: "var(--fg-secondary)",
}}
>
{scoring.overall != null
? `Из чего сложился итоговый продуктовый скор ${fmtNum(scoring.overall, 2)} (∈ 0…1, выше — лучше).`
: "Из чего складывается продуктовый скор (значения ∈ 0…1, выше — лучше)."}
</p>
{/* Продуктовые скоры (#985) — quality gradient (higher = better). */}
<ProductScoresGroup scores={scores} />
{/* Специальные индексы (#986) — neutral accent (signal strength). */}
<SpecialIndicesGroup indices={indices} />
</div>
);
}
// ── Продуктовые скоры (#985) ───────────────────────────────────────────────────
function ProductScoresGroup({ scores }: { scores: ProductScore[] }) {
if (scores.length === 0) return null;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span style={SUBHEAD_STYLE}>Продуктовые скоры ({scores.length})</span>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{scores.map((s) => (
<ScoreBar key={s.key} score={s} />
))}
</div>
<p style={HINT_STYLE}>
Длина и цвет полосы значение скора 01: зелёный сильный, жёлтый
средний, красный слабый. Риск-скоры уже инвертированы (выше всегда
лучше).
</p>
</div>
);
}
function ScoreBar({ score }: { score: ProductScore }) {
const label = PRODUCT_SCORE_RU[score.key] ?? score.key;
const value = score.value;
const hasValue = value != null;
const widthPct = hasValue ? scoreBarWidthPct(value) : 0;
const variant: BadgeVariant = hasValue ? scoreVariant(value) : "neutral";
const valueStr = hasValue ? fmtNum(value, 2) : "нет данных";
return (
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 12,
}}
>
<span
style={{
fontSize: 13,
color: "var(--fg-primary)",
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{label}
</span>
<span
style={{
fontSize: 12,
color: hasValue ? "var(--fg-secondary)" : "var(--fg-tertiary)",
fontVariantNumeric: "tabular-nums",
flexShrink: 0,
}}
>
{valueStr}
</span>
</div>
<Track
widthPct={widthPct}
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>
)}
</div>
);
}
// ── Специальные индексы (#986) ─────────────────────────────────────────────────
function SpecialIndicesGroup({ indices }: { indices: SpecialIndex[] }) {
if (indices.length === 0) return null;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span style={SUBHEAD_STYLE}>Специальные индексы ({indices.length})</span>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{indices.map((idx) => (
<IndexBar key={idx.key} index={idx} />
))}
</div>
<p style={HINT_STYLE}>
Длина полосы сила сигнала индекса 01 (нейтральный цвет: направление
зависит от индекса часть растёт к лучшему, часть к худшему).
</p>
</div>
);
}
function IndexBar({ index }: { index: SpecialIndex }) {
const name = SPECIAL_INDEX_RU[index.key] ?? index.key;
const value = index.value;
const hasValue = value != null;
const widthPct = hasValue ? scoreBarWidthPct(value) : 0;
// Direction is not uniform across indices → neutral accent, no good/bad implied.
const variant: BadgeVariant = hasValue ? "info" : "neutral";
const valueStr = hasValue ? fmtNum(value, 2) : "нет данных";
return (
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 12,
}}
>
<span
style={{
fontSize: 13,
color: "var(--fg-primary)",
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{name}
{index.label && (
<span style={{ marginLeft: 6, color: "var(--fg-tertiary)" }}>
· {index.label}
</span>
)}
</span>
<span
style={{
fontSize: 12,
color: hasValue ? "var(--fg-secondary)" : "var(--fg-tertiary)",
fontVariantNumeric: "tabular-nums",
flexShrink: 0,
}}
>
{valueStr}
</span>
</div>
<Track
widthPct={widthPct}
fill={BAR_FILL[variant]}
ariaLabel={`${name}: ${valueStr}`}
/>
</div>
);
}
// ── Shared bar track ───────────────────────────────────────────────────────────
function Track({
widthPct,
fill,
ariaLabel,
}: {
widthPct: number;
fill: string;
ariaLabel: string;
}) {
return (
<div
style={{
position: "relative",
height: 8,
borderRadius: 6,
background: "var(--bg-card-alt)",
border: "1px solid var(--border-soft)",
overflow: "hidden",
}}
role="img"
aria-label={ariaLabel}
>
<div
style={{
position: "absolute",
insetBlock: 0,
insetInlineStart: 0,
width: `${widthPct}%`,
background: fill,
borderRadius: 6,
}}
/>
</div>
);
}

View file

@ -15,6 +15,7 @@
* 6.2 Сценарии ScenariosBlock
* 6.3 Уверенность ForecastConfidenceBlock
* 6.4 Рекомендация по продукту ForecastProductTzBlock (что строить §13.4)
* 6.5 Прозрачность скоринга ForecastScoringBlock (почему скор §13.6)
* advisory disclaimer (footer)
*/
@ -30,6 +31,7 @@ import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock";
import { ScenariosBlock } from "./ScenariosBlock";
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
import { ForecastScoringBlock } from "./ForecastScoringBlock";
import { ForecastExportButtons } from "./ForecastExportButtons";
import { CONFIDENCE_RU, deficitVariant, fmtNum } from "./forecast-helpers";
@ -202,7 +204,18 @@ function ForecastReady({
(pt.commercial != null &&
(pt.commercial.available != null || !!pt.commercial.caveat)));
const hasAny = hasHorizons || hasScenarios || hasConfidence || hasProductTz;
// 6.5 — прозрачность скоринга (§13.6). scoring is optional on the partial
// ForecastReport type; populated when product_scores / special_indices exist.
const sc = report.scoring;
const hasScoring =
sc != null &&
((sc.product_scores != null &&
Object.keys(sc.product_scores.scores).length > 0) ||
(sc.special_indices != null &&
Object.keys(sc.special_indices.indices).length > 0));
const hasAny =
hasHorizons || hasScenarios || hasConfidence || hasProductTz || hasScoring;
// Headline subtitle = future_market summary (one sentence «откуда / что значит»).
const subtitle = fm.summary ?? undefined;
@ -340,6 +353,13 @@ function ForecastReady({
</SubBlock>
)}
{/* 6.5 Прозрачность скоринга — почему итоговый скор такой (§13.6) */}
{hasScoring && sc && (
<SubBlock id="section-6-5" title="6.5 Прозрачность скоринга">
<ForecastScoringBlock scoring={sc} />
</SubBlock>
)}
{/* Advisory disclaimer */}
<p
style={{

View file

@ -61,6 +61,70 @@ export function confidenceLevelToValue(level: ConfidenceLevel): number {
return 0.25;
}
// ── Scoring (§13.6 — product scores #985 + special indices #986) ───────────────
/**
* Product-score semantics (HARD): every §14.2 score is [0,1] with «выше =
* лучше для девелопера» (risk scores supply_risk / future_competition /
* mortgage_sensitivity are pre-inverted at the source so score>0.5 =
* favourable). The backend anchors quality on 0.5 = баланс/midpoint
* (`product_scoring.py:127` `_MARKET_FIT_MIDPOINT = 0.5`; inverted-risk reasons
* flip favourable/unfavourable at risk<0.5 score>0.5), so the gradient is
* anchored on 0.5 too: a narrow ±0.05 neutral band straddles the balance point
* 0.55 success / 0.450.55 warning / <0.45 danger. This is NOT the deficit
* mapping (signed [1,1]) do not reuse deficitVariant here.
*/
export const SCORE_GOOD_THRESHOLD = 0.55;
export const SCORE_WEAK_THRESHOLD = 0.45;
export function scoreVariant(value: number): BadgeVariant {
if (value >= SCORE_GOOD_THRESHOLD) return "success";
if (value >= SCORE_WEAK_THRESHOLD) return "warning";
return "danger";
}
/**
* Bar width (%) for a 0..1 score on a 0..100 scale (direct, no magnitude trick
* the score is already unsigned 0..1). Clamped to [0, 100].
*/
export function scoreBarWidthPct(value: number): number {
return Math.max(0, Math.min(100, value * 100));
}
/**
* RU labels for the 10 §14.2 product-score keys (#985). The MD/PDF exporters
* render the raw English key these are the established in-app RU names, kept
* here so the block and any future reuse share one source of truth. Unknown
* keys fall back to the raw key (graceful).
*/
export const PRODUCT_SCORE_RU: Record<string, string> = {
market_fit: "Соответствие рынку",
demand: "Спрос",
supply_risk: "Риск избытка предложения",
future_competition: "Будущая конкуренция",
price_feasibility: "Доступность цены",
infra_fit: "Инфраструктура",
mortgage_sensitivity: "Чувствительность к ставке",
differentiation: "Дифференциация",
commercial: "Коммерция",
confidence: "Надёжность данных",
};
/**
* RU labels for the 6 §25 special-index keys (#986). The per-entry `label` field
* is a value-descriptor («6 мес» / «Комфорт»), not a metric name, so it is shown
* as supplementary context these are the metric names. Unknown keys fall back
* to the raw key (graceful).
*/
export const SPECIAL_INDEX_RU: Record<string, string> = {
launch_window: "Окно запуска",
product_void: "Белые пятна продукта",
cannibalization: "Каннибализация",
competitor_strength: "Сила конкурентов",
artificial_demand: "Искусственный спрос",
cost_of_error: "Цена ошибки",
};
// ── Number formatting ─────────────────────────────────────────────────────────
/**

View file

@ -223,6 +223,89 @@ export interface ReportProductTz {
summary: string | null;
}
// ── Scoring (§13.6 — прозрачность скоринга, #985/#986) ───────────────────────
/**
* One of the 10 §14.2 product scores (#985). `value` [0,1] (выше = лучше для
* девелопера риск-скоры supply_risk / future_competition / mortgage_sensitivity
* уже инвертированы у источника, так что бóльшее значение всегда «лучше»). `value`
* is null when backing data is thin (`source: "unavailable"`) never 0-as-stub.
*/
export interface ProductScore {
/** Canonical key (demand / market_fit / supply_risk / …). */
key: string;
/** ∈ [0,1] (выше = лучше) или null при тонких данных. */
value: number | null;
/** Короткая RU §16-причина значения скора. */
reason: string;
/** Backing-сервис (market_metrics / poi_score / unavailable / …). */
source: string;
confidence: ConfidenceLevel;
}
/**
* §14.2 product-score card (#985). `scores` is keyed by the canonical score key
* (10 entries по `_SCORE_KEYS`). `overall` дублирует `ReportScoring.overall`.
*/
export interface ProductScoreCard {
scores: Record<string, ProductScore>;
overall: number | null;
segment: ForecastSegment;
advisory: boolean;
confidence: ConfidenceLevel;
horizon_months: number;
}
/**
* One of the 6 §25 special indices (#986). `value` [0,1] = СИЛА сигнала (НЕ
* однозначно «хорошо/плохо» часть индексов риск-направленные: cost_of_error /
* cannibalization / competitor_strength / artificial_demand растут к худшему,
* launch_window / product_void к лучшему). null при тонких данных/сбое
* (`method: "unavailable"`). `label` человекочитаемый дескриптор значения
* (например «6 мес» для launch_window, «Комфорт» для cannibalization), НЕ имя
* метрики. `detail` опорные числа для explainability.
*/
export interface SpecialIndex {
/** Canonical key (launch_window / product_void / cost_of_error / …). */
key: string;
/** ∈ [0,1] = сила сигнала (направление зависит от индекса) или null. */
value: number | null;
/** Человекочитаемый дескриптор значения (горизонт / класс / метка), не имя. */
label: string | null;
confidence: ConfidenceLevel;
/** Опорные числа (explainability) — форма зависит от индекса. */
detail: Record<string, unknown>;
/** Источник/способ; "unavailable" если индекс не посчитан. */
method: string;
advisory: boolean;
}
/**
* §25 special-indices card (#986). `indices` is keyed by the canonical index key
* (6 entries по `_INDEX_KEYS`); недоступные присутствуют с value=null.
*/
export interface SpecialIndicesCard {
indices: Record<string, SpecialIndex>;
segment: ForecastSegment;
district: string | null;
advisory: boolean;
confidence: ConfidenceLevel;
}
/**
* §13.6 scoring прозрачность итогового скора (#985/#986). `overall` [0,1]
* дублируется в exec_summary KPI (карточка «Итоговый скор»), поэтому in-app блок
* 6.5 показывает РАЗБИВКУ (почему скор такой), а не повторяет overall как KPI.
*/
export interface ReportScoring {
/** §14.2 карта 10 продуктовых скоров (#985). */
product_scores: ProductScoreCard | null;
/** §25 карта 6 специальных индексов (#986). */
special_indices: SpecialIndicesCard | null;
/** Итоговый продуктовый скор ∈ [0,1] (дубль из карты для удобства). */
overall: number | null;
}
// ── Meta ─────────────────────────────────────────────────────────────────────
export interface ReportMeta {
@ -250,6 +333,12 @@ export interface ForecastReport {
* or a thin assembly may omit the section entirely guarded at render time.
*/
product_tz?: ReportProductTz;
/**
* §13.6 прозрачность скоринга (#985/#986). Optional: the report may be
* 202-pending or a thin assembly may omit the section entirely guarded at
* render time (see ForecastScoringBlock / Section6Forecast 6.5).
*/
scoring?: ReportScoring;
schema_version?: string;
}