gendesign/frontend/src/components/site-finder/analysis/ForecastConfidenceBlock.tsx
Light1YT b1c0ea1268
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
fix(site-finder): 5 дефектов из боевого фидбека analyze (#1736 #1737 #1738 #1739 #1740)
- #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
2026-06-18 11:47:21 +05:00

191 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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,
};