From a04f2d98fc71338993598410e9d35bd9519160f1 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 7 Jun 2026 15:07:54 +0500 Subject: [PATCH] =?UTF-8?q?feat(forecast):=20render=20scoring=20transparen?= =?UTF-8?q?cy=20(=C2=A713.6)=20as=20in-app=20subsection=206.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../analysis/[cad]/AnalysisPageContent.tsx | 1 + .../analysis/ForecastScoringBlock.tsx | 330 ++++++++++++++++++ .../site-finder/analysis/Section6Forecast.tsx | 22 +- .../site-finder/analysis/forecast-helpers.ts | 64 ++++ frontend/src/types/forecast.ts | 89 +++++ 5 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/site-finder/analysis/ForecastScoringBlock.tsx diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index a43f12ec..c2ba1b1c 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -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 Прозрачность скоринга" }, ], }, ]; diff --git a/frontend/src/components/site-finder/analysis/ForecastScoringBlock.tsx b/frontend/src/components/site-finder/analysis/ForecastScoringBlock.tsx new file mode 100644 index 00000000..f9c98cbb --- /dev/null +++ b/frontend/src/components/site-finder/analysis/ForecastScoringBlock.tsx @@ -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 = { + 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 ( +
+ {/* Contextual sub-header — echoes overall WITHOUT duplicating the KPI card. */} +

+ {scoring.overall != null + ? `Из чего сложился итоговый продуктовый скор ${fmtNum(scoring.overall, 2)} (∈ 0…1, выше — лучше).` + : "Из чего складывается продуктовый скор (значения ∈ 0…1, выше — лучше)."} +

+ + {/* Продуктовые скоры (#985) — quality gradient (higher = better). */} + + + {/* Специальные индексы (#986) — neutral accent (signal strength). */} + +
+ ); +} + +// ── Продуктовые скоры (#985) ─────────────────────────────────────────────────── + +function ProductScoresGroup({ scores }: { scores: ProductScore[] }) { + if (scores.length === 0) return null; + + return ( +
+ Продуктовые скоры ({scores.length}) +
+ {scores.map((s) => ( + + ))} +
+

+ Длина и цвет полосы — значение скора ∈ 0…1: зелёный — сильный, жёлтый — + средний, красный — слабый. Риск-скоры уже инвертированы (выше всегда + лучше). +

+
+ ); +} + +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 ( +
+
+ + {label} + + + {valueStr} + +
+ + {score.reason && ( +

+ {score.reason} +

+ )} +
+ ); +} + +// ── Специальные индексы (#986) ───────────────────────────────────────────────── + +function SpecialIndicesGroup({ indices }: { indices: SpecialIndex[] }) { + if (indices.length === 0) return null; + + return ( +
+ Специальные индексы ({indices.length}) +
+ {indices.map((idx) => ( + + ))} +
+

+ Длина полосы — сила сигнала индекса ∈ 0…1 (нейтральный цвет: направление + зависит от индекса — часть растёт к лучшему, часть к худшему). +

+
+ ); +} + +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 ( +
+
+ + {name} + {index.label && ( + + · {index.label} + + )} + + + {valueStr} + +
+ +
+ ); +} + +// ── Shared bar track ─────────────────────────────────────────────────────────── + +function Track({ + widthPct, + fill, + ariaLabel, +}: { + widthPct: number; + fill: string; + ariaLabel: string; +}) { + return ( +
+
+
+ ); +} diff --git a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx index bc598f4a..eec05ee5 100644 --- a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx +++ b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx @@ -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({ )} + {/* 6.5 Прозрачность скоринга — почему итоговый скор такой (§13.6) */} + {hasScoring && sc && ( + + + + )} + {/* Advisory disclaimer */}

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 = { + 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 = { + launch_window: "Окно запуска", + product_void: "Белые пятна продукта", + cannibalization: "Каннибализация", + competitor_strength: "Сила конкурентов", + artificial_demand: "Искусственный спрос", + cost_of_error: "Цена ошибки", +}; + // ── Number formatting ───────────────────────────────────────────────────────── /** diff --git a/frontend/src/types/forecast.ts b/frontend/src/types/forecast.ts index d1550a69..7b31e00c 100644 --- a/frontend/src/types/forecast.ts +++ b/frontend/src/types/forecast.ts @@ -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; + 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; + /** Источник/способ; "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; + 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; } -- 2.45.3