// ════════════════════════════════════════════════════════════════ // 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

') .replace(/^## (.+)$/gm, '

$1

') .replace(/^# (.+)$/gm, '

$1

') // bold + italic .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1') // inline code .replace(/`([^`]+)`/g, '$1') // numbered list (1. ...) .replace(/^(\d+)\.\s+(.+)$/gm, '
  • $2
  • ') // bullets (* / - / •) .replace(/^[\*\-•]\s+(.+)$/gm, '
  • $1
  • ') // double newlines = paragraph .replace(/\n\n+/g, '

    ') // single newline inside paragraph = break .replace(/\n/g, '
    '); return '

    ' + html + '

    '; } if (loading) return (
    загружаю последний разбор…
    ); if (err) return (
    разбор не загрузился
    {err}
    возможно прогонов ещё не было. запустите scripts/corpus_council_full.py или scripts/corpus_council.py.
    ); const date = data.iso_date ? new Date(data.iso_date).toLocaleString('ru-RU', { day: '2-digit', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''; const activeText = active === 'judge' ? data.judge_synthesis : (data.experts.find(e => e.role === active)?.text || '(нет данных)'); const expertRoles = data.experts.map(e => e.role); const isFullScope = expertRoles.some(r => r.startsWith('FULL_')); return (
    {/* Заголовок */}
    экспертный разбор
    {runs.length > 1 && ( )}

    {selTs && runs[0] && selTs !== runs[0].timestamp ? 'Прогон совета' : 'Последний прогон совета'}

    дата {date} экспертов {data.experts.length} синтез {Math.round(data.judge_synthesis.length / 1024)} KB охват {isFullScope ? 'полный корпус (3000+)' : 'выборка 152'}
    {/* Tab nav */}
    {expertRoles.map(role => { const lbl = labelFor(role); return ( ); })}
    {/* Body */}
    {/* Footer */}
    совет собирается раз в неделю на выборке 152 стартапов и раз в месяц на полном корпусе. стоимость одного прогона: 5 до 40 рублей по курсу ЦБ.
    ); } window.CouncilLatest = CouncilLatest;