Some checks failed
CI / changes (push) Successful in 9s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Failing after 1m50s
CI / frontend-tests (pull_request) Failing after 1m48s
CI / openapi-codegen-check (pull_request) Failing after 2m39s
CI / openapi-codegen-check (push) Failing after 2m46s
CI / backend-tests (pull_request) Successful in 9m25s
CI / backend-tests (push) Successful in 9m30s
- #1736 MiniMap: проброс useConnectionPoints → точки подключения на карте analyze (были только в /legacy) - #1737 confidence: пронесено имя сервиса → RU-ярлык (Рынок/Будущее предложение/…) вместо «Компонент вкладывающий сервис» - #1738 pipeline: self-exclusion субъекта (ST_DWithin 80м) — проект не считает сам себя будущим конкурентом - #1739 PDF: snapshot_pdf обёрнут в try/except+logger.exception (причина 500 видна) + format=pdf в forecast export + font_url fallback - #1740 gate↔recommendation: при can_build_mkd=False — gate_caveat на обоих рекомендаторах (противоречие явное, не молчит) Verify: py_compile 5/5, tsc 0, ruff clean, pytest confidence/forecast 95 passed. Closes #1736 Closes #1737 Closes #1738 Closes #1739 Closes #1740
191 lines
6.6 KiB
TypeScript
191 lines
6.6 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* 6.3 Уровень уверенности — level pill + EXPLICIT drivers list from
|
||
* confidence.factors (DoD: «явный список drivers»).
|
||
*
|
||
* The factors map is mostly { note, level, value } entries, but the prod
|
||
* envelope also carries a stray boolean `advisory_capped` — narrowed out via a
|
||
* runtime type guard so we never render a non-factor.
|
||
*/
|
||
|
||
import { Badge } from "@/components/ui/Badge";
|
||
import type { ConfidenceFactor, ReportConfidence } from "@/types/forecast";
|
||
import { CONFIDENCE_RU, confidenceVariant } from "./forecast-helpers";
|
||
|
||
interface Props {
|
||
confidence: ReportConfidence;
|
||
}
|
||
|
||
// Human-readable labels for known factor keys; unknown keys fall back to the key.
|
||
const FACTOR_RU: Record<string, string> = {
|
||
deal_count: "Объём сделок",
|
||
analog_count: "Аналоги (ЖК)",
|
||
domrf_coverage: "Покрытие ДОМ.РФ",
|
||
history_months: "Глубина истории",
|
||
component: "Компонент",
|
||
};
|
||
|
||
function isConfidenceFactor(v: unknown): v is ConfidenceFactor {
|
||
if (typeof v !== "object" || v === null) return false;
|
||
const o = v as Record<string, unknown>;
|
||
return (
|
||
typeof o.note === "string" &&
|
||
(o.level === "high" || o.level === "medium" || o.level === "low")
|
||
);
|
||
}
|
||
|
||
function factorLabel(key: string, factor: ConfidenceFactor): string {
|
||
// #1737: backend проносит RU-имя сервиса в `label` для component-факторов
|
||
// («Рынок» / «Будущее предложение» / …) — предпочитаем его, чтобы не показывать
|
||
// безличный «Компонент N».
|
||
if (factor.label) return factor.label;
|
||
if (FACTOR_RU[key]) return FACTOR_RU[key];
|
||
// Fallback для старых конвертов без `label`: component / component_2..N → «Компонент N».
|
||
const m = key.match(/^component(?:_(\d+))?$/);
|
||
if (m) return m[1] ? `Компонент ${m[1]}` : "Компонент";
|
||
return key;
|
||
}
|
||
|
||
function formatFactorValue(value: number | null): string | null {
|
||
if (value == null) return null;
|
||
// Fractions in 0..1 read as percent (e.g. coverage); integers/large stay raw.
|
||
// Upper bound is inclusive so full coverage (1.0) renders as «100%», not «1».
|
||
if (value > 0 && value <= 1) return `${Math.round(value * 100)}%`;
|
||
return Math.round(value).toLocaleString("ru");
|
||
}
|
||
|
||
export function ForecastConfidenceBlock({ confidence }: Props) {
|
||
const level = confidence.level;
|
||
|
||
const drivers = Object.entries(confidence.factors)
|
||
.filter((entry): entry is [string, ConfidenceFactor] =>
|
||
isConfidenceFactor(entry[1]),
|
||
)
|
||
// Sort high → medium → low so the strongest evidence reads first.
|
||
.sort((a, b) => LEVEL_RANK[a[1].level] - LEVEL_RANK[b[1].level]);
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||
{/* Level pill + rationale */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||
<span
|
||
style={{
|
||
fontSize: 11,
|
||
fontWeight: 500,
|
||
letterSpacing: "0.04em",
|
||
textTransform: "uppercase",
|
||
color: "var(--fg-secondary)",
|
||
}}
|
||
>
|
||
Уровень уверенности
|
||
</span>
|
||
{level ? (
|
||
<Badge variant={confidenceVariant(level)} size="md">
|
||
{CONFIDENCE_RU[level]}
|
||
</Badge>
|
||
) : (
|
||
<span style={{ fontSize: 13, color: "var(--fg-tertiary)" }}>
|
||
не определена
|
||
</span>
|
||
)}
|
||
</div>
|
||
{confidence.rationale && (
|
||
<p
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 13,
|
||
lineHeight: 1.5,
|
||
color: "var(--fg-secondary)",
|
||
}}
|
||
>
|
||
{confidence.rationale}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Explicit drivers list */}
|
||
{drivers.length > 0 ? (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||
<span
|
||
style={{
|
||
fontSize: 11,
|
||
fontWeight: 500,
|
||
letterSpacing: "0.04em",
|
||
textTransform: "uppercase",
|
||
color: "var(--fg-secondary)",
|
||
}}
|
||
>
|
||
Факторы достоверности ({drivers.length})
|
||
</span>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||
{drivers.map(([key, factor]) => {
|
||
const valueStr = formatFactorValue(factor.value);
|
||
return (
|
||
<div
|
||
key={key}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: 8,
|
||
padding: "8px 12px",
|
||
background: "var(--bg-card-alt)",
|
||
border: "1px solid var(--border-soft)",
|
||
borderRadius: 8,
|
||
}}
|
||
>
|
||
<Badge variant={confidenceVariant(factor.level)} size="sm">
|
||
{CONFIDENCE_RU[factor.level]}
|
||
</Badge>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div
|
||
style={{
|
||
fontSize: 13,
|
||
fontWeight: 500,
|
||
color: "var(--fg-primary)",
|
||
}}
|
||
>
|
||
{factorLabel(key, factor)}
|
||
{valueStr != null && (
|
||
<span
|
||
style={{
|
||
marginLeft: 6,
|
||
fontWeight: 400,
|
||
color: "var(--fg-secondary)",
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
· {valueStr}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--fg-tertiary)",
|
||
marginTop: 2,
|
||
}}
|
||
>
|
||
{factor.note}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p style={{ fontSize: 13, color: "var(--fg-tertiary)", margin: 0 }}>
|
||
Факторы достоверности недоступны.
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const LEVEL_RANK: Record<ConfidenceFactor["level"], number> = {
|
||
high: 0,
|
||
medium: 1,
|
||
low: 2,
|
||
};
|