Compare commits
1 commit
main
...
feat/forec
| Author | SHA1 | Date | |
|---|---|---|---|
| a04f2d98fc |
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-2", label: "6.2 Сценарии" },
|
||||||
{ id: "section-6-3", label: "6.3 Уверенность" },
|
{ id: "section-6-3", label: "6.3 Уверенность" },
|
||||||
{ id: "section-6-4", label: "6.4 Рекомендация по продукту" },
|
{ 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.2 Сценарии → ScenariosBlock
|
||||||
* 6.3 Уверенность → ForecastConfidenceBlock
|
* 6.3 Уверенность → ForecastConfidenceBlock
|
||||||
* 6.4 Рекомендация по продукту → ForecastProductTzBlock (что строить — §13.4)
|
* 6.4 Рекомендация по продукту → ForecastProductTzBlock (что строить — §13.4)
|
||||||
|
* 6.5 Прозрачность скоринга → ForecastScoringBlock (почему скор — §13.6)
|
||||||
* advisory disclaimer (footer)
|
* advisory disclaimer (footer)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -30,6 +31,7 @@ import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock";
|
||||||
import { ScenariosBlock } from "./ScenariosBlock";
|
import { ScenariosBlock } from "./ScenariosBlock";
|
||||||
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
|
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
|
||||||
import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
|
import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
|
||||||
|
import { ForecastScoringBlock } from "./ForecastScoringBlock";
|
||||||
import { ForecastExportButtons } from "./ForecastExportButtons";
|
import { ForecastExportButtons } from "./ForecastExportButtons";
|
||||||
import { CONFIDENCE_RU, deficitVariant, fmtNum } from "./forecast-helpers";
|
import { CONFIDENCE_RU, deficitVariant, fmtNum } from "./forecast-helpers";
|
||||||
|
|
||||||
|
|
@ -202,7 +204,18 @@ function ForecastReady({
|
||||||
(pt.commercial != null &&
|
(pt.commercial != null &&
|
||||||
(pt.commercial.available != null || !!pt.commercial.caveat)));
|
(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 «откуда / что значит»).
|
// Headline subtitle = future_market summary (one sentence «откуда / что значит»).
|
||||||
const subtitle = fm.summary ?? undefined;
|
const subtitle = fm.summary ?? undefined;
|
||||||
|
|
@ -340,6 +353,13 @@ function ForecastReady({
|
||||||
</SubBlock>
|
</SubBlock>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 6.5 Прозрачность скоринга — почему итоговый скор такой (§13.6) */}
|
||||||
|
{hasScoring && sc && (
|
||||||
|
<SubBlock id="section-6-5" title="6.5 Прозрачность скоринга">
|
||||||
|
<ForecastScoringBlock scoring={sc} />
|
||||||
|
</SubBlock>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Advisory disclaimer */}
|
{/* Advisory disclaimer */}
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,70 @@ export function confidenceLevelToValue(level: ConfidenceLevel): number {
|
||||||
return 0.25;
|
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 ─────────────────────────────────────────────────────────
|
// ── Number formatting ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,89 @@ export interface ReportProductTz {
|
||||||
summary: string | null;
|
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 ─────────────────────────────────────────────────────────────────────
|
// ── Meta ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ReportMeta {
|
export interface ReportMeta {
|
||||||
|
|
@ -250,6 +333,12 @@ export interface ForecastReport {
|
||||||
* or a thin assembly may omit the section entirely — guarded at render time.
|
* or a thin assembly may omit the section entirely — guarded at render time.
|
||||||
*/
|
*/
|
||||||
product_tz?: ReportProductTz;
|
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;
|
schema_version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue