// ════════════════════════════════════════════════════════════════ // CouncilLatest — последний прогон LLM Council // Источник: GET /api/council/latest (auth required) // Показывает: judge synthesis + переключатель между экспертами // ════════════════════════════════════════════════════════════════ function CouncilLatest() { const [data, setData] = React.useState(null); const [runs, setRuns] = React.useState([]); // история прогонов const [selTs, setSelTs] = React.useState(null); // выбранный прогон const [active, setActive] = React.useState('judge'); // judge | analyst | breadth | depth | breakthrough | adversarial | structured const [loading, setLoading] = React.useState(true); const [err, setErr] = React.useState(null); const apiGet = (p) => (window.SDH_API ? window.SDH_API.get(p) : fetch(p).then(x => { if (!x.ok) throw new Error('HTTP ' + x.status); return x.json(); })); React.useEffect(() => { let cancelled = false; async function load() { try { const [latest, list] = await Promise.all([ apiGet('/api/council/latest'), apiGet('/api/council/list').catch(() => []), ]); if (cancelled) return; setData(latest); setRuns(Array.isArray(list) ? list : []); setSelTs(latest.timestamp || null); setErr(null); } catch (e) { if (!cancelled) setErr(String(e.message || e)); } finally { if (!cancelled) setLoading(false); } } load(); return () => { cancelled = true; }; }, []); // Переключение прогона из истории async function selectRun(ts) { if (!ts || ts === selTs) return; setLoading(true); try { const d = await apiGet('/api/council/by-ts/' + ts); setData(d); setSelTs(ts); setActive('judge'); setErr(null); window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (e) { setErr(String(e.message || e)); } finally { setLoading(false); } } // Маппинг ролей в русские названия (без AI-провайдеров) // Префикс FULL_ означает прогон по полному корпусу (3000+), без префикса = sample (152) const ROLE_LABELS = { judge: { ru: 'Сводный разбор', desc: 'Финальный синтез по 6 секциям' }, analyst: { ru: 'Базовая аналитика', desc: 'Реестр трендов и кластеров' }, breadth: { ru: 'Широта', desc: 'Видит фокус-продукты портфеля' }, depth: { ru: 'Глубина', desc: 'Двусторонние тренды и сдвиги' }, breakthrough: { ru: 'Прорыв', desc: 'Неочевидные структурные паттерны' }, adversarial: { ru: 'Критика', desc: 'Контрвзгляд и проверка инсайтов' }, structured: { ru: 'Структура', desc: 'Кодифицированный обзор' }, extended: { ru: 'Заметки и беклог', desc: 'Расширенный разбор и план действий' }, }; // Нормализация role: FULL_analyst → analyst (префикс — это маркер scope, не отдельная роль) function cleanRole(role) { return (role || '').replace(/^FULL_/, ''); } function labelFor(role) { return ROLE_LABELS[cleanRole(role)] || { ru: cleanRole(role), desc: '' }; } // Простой markdown → HTML (без библиотек, для §1, ###, **bold**, списков) function mdToHtml(md) { if (!md) return ''; let html = md .replace(/&/g, '&').replace(//g, '>') // headings .replace(/^### (.+)$/gm, '
$1')
// numbered list (1. ...)
.replace(/^(\d+)\.\s+(.+)$/gm, '')
// single newline inside paragraph = break
.replace(/\n/g, '
');
return '
' + html + '
'; } if (loading) return (