feat(forecast): in-app scoring transparency (subsection 6.5) (#958) #1120
5 changed files with 505 additions and 1 deletions
|
|
@ -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 Прозрачность скоринга" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
Длина и цвет полосы — значение скора ∈ 0…1: зелёный — сильный, жёлтый —
|
||||
средний, красный — слабый. Риск-скоры уже инвертированы (выше всегда
|
||||
лучше).
|
||||
</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}>
|
||||
Длина полосы — сила сигнала индекса ∈ 0…1 (нейтральный цвет: направление
|
||||
зависит от индекса — часть растёт к лучшему, часть к худшему).
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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.45–0.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 ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue