/
sweetebin
/
hackaton_26
Обзор
Документация
Войти
/
sweetebin
/
hackaton_26
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
web-form-project/src/components/JournalView.jsx
514 строк
23 KB
sweetebin
Трассировка до первоисточника в UI + CLI-инструментарий решений для агента
28 июл 2026, 17:22
28 июл 2026, 17:22
b4de763
Код
Авторство
О чём код?
import { useEffect, useMemo, useState } from 'react'; import { Box, Chip, Collapse, IconButton, LinearProgress, Paper, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, ToggleButton, ToggleButtonGroup, Tooltip, Typography, } from '@mui/material'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import { getJournal } from '../api/client'; import SourceDialog from './SourceDialog'; const POLL_MS = 2500; const STAGE_LABELS = { extract: 'Извлечение JTBD', embed: 'Эмбеддинги', match: 'Кластеризация', score: 'Приоритизация', stories: 'User Stories', audit: 'Аудит бэклога', report: 'Итоговый список', }; const STAGE_HINTS = { extract: 'LLM читает каждое интервью и обращение и извлекает атомарные потребности по формуле «Когда…, я хочу…, чтобы…» с дословной цитатой.', embed: 'Каждый сигнал переводится в вектор (Qwen3-Embedding) для сравнения по смыслу.', match: 'Похожие сигналы из разных источников склеиваются в потребности; слишком крупные кластеры дробятся, явные дубли объединяются.', score: 'Приоритет каждой потребности: частота × эмоциональная окраска × охват сегментов.', stories: 'Для топ-потребностей LLM пишет User Story и проверяемые критерии приёмки.', audit: 'Каждая задача текущего бэклога переформулируется в JTBD и сверяется с потребностями: закрывает ли она хоть одну.', report: 'Сборка итогового списка 10–15 задач с трассировкой до цитат + семантическая карта.', }; const EMOTION_COLORS = ['default', 'info', 'warning', 'error']; const EMOTION_LABELS = ['нейтр.', 'недов.', 'боль', 'крит.']; const EMOTION_HINT = 'Эмоциональная окраска сигнала: 0 — нейтрально, 1 — лёгкое недовольство, 2 — выраженная боль, 3 — критическая (угроза ухода, деньги, доверие). Входит в формулу приоритета.'; function EmotionChip({ value }) { return ( <Tooltip title={EMOTION_HINT}> <Chip size="small" color={EMOTION_COLORS[value] || 'default'} label={`${value} · ${EMOTION_LABELS[value] || ''}`} variant={value >= 2 ? 'filled' : 'outlined'} /> </Tooltip> ); } function HeadCell({ children, hint, ...props }) { return ( <TableCell {...props}> {hint ? ( <Tooltip title={hint}> <Box component="span" sx={{ cursor: 'help', borderBottom: '1px dotted', borderColor: 'text.disabled' }}> {children} </Box> </Tooltip> ) : ( children )} </TableCell> ); } function SectionTitle({ children, hint }) { return ( <Box sx={{ mt: 4, mb: 1.5 }}> <Typography variant="h6">{children}</Typography> {hint && ( <Typography variant="body2" color="text.secondary"> {hint} </Typography> )} </Box> ); } function StagesPanel({ progress }) { const order = Object.keys(STAGE_LABELS); return ( <Stack direction="row" spacing={1} useFlexGap flexWrap="wrap"> {order.map((key) => { const s = progress?.[key]; const status = s?.status || 'idle'; const doneish = status.startsWith('done'); const running = status === 'running'; return ( <Tooltip key={key} title={STAGE_HINTS[key]}> <Paper variant="outlined" sx={{ p: 1.2, minWidth: 148, flexGrow: 1, cursor: 'help', borderColor: running ? 'primary.main' : 'divider', bgcolor: running ? 'rgba(42, 120, 214, 0.06)' : 'background.paper', }} > <Stack direction="row" spacing={0.8} alignItems="center"> {doneish && <CheckCircleIcon color="success" sx={{ fontSize: 18 }} />} <Typography variant="body2" sx={{ fontWeight: 600 }}> {STAGE_LABELS[key]} </Typography> </Stack> <Typography variant="caption" color="text.secondary"> {s ? `${s.done}/${s.total} ${s.message || ''}` : 'не запускалась'} </Typography> {running && <LinearProgress sx={{ mt: 0.5 }} />} </Paper> </Tooltip> ); })} </Stack> ); } // Кликабельный ref источника — открывает первичный документ целиком function SourceChip({ kind, refName, sourceId, onOpen }) { return ( <Tooltip title="Открыть первичный документ"> <Chip size="small" variant="outlined" clickable onClick={() => onOpen(sourceId)} label={`${kind === 'интервью' ? '🎙' : '🎫'} ${refName}`} /> </Tooltip> ); } function NeedRow({ need, onOpenSource }) { const [open, setOpen] = useState(false); return ( <> <TableRow hover sx={{ '& td': { borderBottom: open ? 'none' : undefined } }}> <TableCell padding="checkbox"> <Tooltip title="Показать сигналы, склеенные в эту потребность"> <IconButton size="small" onClick={() => setOpen(!open)}> {open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />} </IconButton> </Tooltip> </TableCell> <TableCell> <Typography variant="body2" sx={{ fontWeight: 600 }}> {need.name} </Typography> <Typography variant="caption" color="text.secondary"> {need.jtbd} </Typography> </TableCell> <TableCell align="right"> <Typography variant="body2" sx={{ fontWeight: 600 }}> {need.score} </Typography> </TableCell> <TableCell align="center"> <Tooltip title={`${need.freq} сигналов: ${need.fromInterviews} из интервью + ${need.fromTickets} из обращений`}> <span>{`${need.freq} = ${need.fromInterviews}и + ${need.fromTickets}о`}</span> </Tooltip> </TableCell> <TableCell align="center">{need.emotion}</TableCell> <TableCell align="center">{need.coverage}</TableCell> <TableCell> <Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap"> {need.confirmed && ( <Tooltip title="Потребность подтверждена и интервью, и обращениями в поддержку — самый сильный сигнал."> <Chip size="small" color="success" label="оба источника" /> </Tooltip> )} {need.contradictions && ( <Tooltip title={need.contradictions}> <Chip size="small" color="warning" variant="outlined" label="⚠ противоречие" /> </Tooltip> )} </Stack> </TableCell> </TableRow> <TableRow> <TableCell colSpan={7} sx={{ py: 0, borderBottom: open ? undefined : 'none' }}> <Collapse in={open} unmountOnExit> <Box sx={{ pl: 6, pb: 1.5 }}> {need.contradictions && ( <Typography variant="body2" color="warning.main" sx={{ mb: 1 }}> ⚠ {need.contradictions} </Typography> )} {need.signals.map((s) => ( <Stack key={s.id} direction="row" spacing={1} sx={{ mb: 0.5 }} alignItems="baseline"> <SourceChip kind={s.kind} refName={s.ref} sourceId={s.sourceId} onOpen={onOpenSource} /> <EmotionChip value={s.emotion} /> <Typography variant="body2" color="text.secondary"> «{s.quote}» </Typography> </Stack> ))} </Box> </Collapse> </TableCell> </TableRow> </> ); } const ACTION_HINTS = { new: 'new — потребность не закрыта текущим бэклогом, нужна новая задача', keep: 'keep — задача из бэклога хороша как есть, оставляем', modify: 'modify — задача из бэклога близка, но переформулирована под потребность', }; export default function JournalView({ projectId, phase }) { const [journal, setJournal] = useState(null); const [error, setError] = useState(null); const [sourceFilter, setSourceFilter] = useState('all'); const [openSourceId, setOpenSourceId] = useState(null); useEffect(() => { let timer = null; let cancelled = false; const load = () => getJournal(projectId) .then((j) => { if (cancelled) return; setJournal(j); if (phase === 'processing') timer = setTimeout(load, POLL_MS); }) .catch((e) => !cancelled && setError(e.message)); load(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [projectId, phase]); const allRows = journal?.extraction || []; const naCount = allRows.filter((r) => r.type === 'none').length; const extraction = useMemo(() => { if (sourceFilter === 'na') return allRows.filter((r) => r.type === 'none'); if (sourceFilter === 'all') return allRows; return allRows.filter((r) => r.sourceKind === sourceFilter); }, [allRows, sourceFilter]); if (error) return <Typography color="error">{error}</Typography>; if (!journal) return <LinearProgress sx={{ mt: 4 }} />; const verdictChip = (v) => v === 'confirmed' ? ( <Tooltip title="Реализация этой задачи закроет потребность из фидбека — задача оправдана данными."> <Chip size="small" color="success" label="подтверждена" /> </Tooltip> ) : ( <Tooltip title="Ни одна потребность из фидбека не подтверждает эту задачу — кандидат на пересмотр приоритета."> <Chip size="small" color="default" variant="outlined" label="orphan" /> </Tooltip> ); const statusStyle = (status) => ({ approved: { bgcolor: 'success.main', opacity: 0.9, '& td': { color: 'success.contrastText' } }, rejected: { opacity: 0.45, '& td': { textDecoration: 'line-through' } }, })[status] || {}; return ( <Box> <SectionTitle hint="Каждая стадия пишет прогресс в базу — здесь видно, что уже посчитано. Наведи на стадию, чтобы узнать, что она делает."> Стадии конвейера </SectionTitle> <StagesPanel progress={journal.progress} /> <SectionTitle hint={`Из каждого источника LLM извлекла атомарные сигналы Jobs-to-be-Done с дословной цитатой. Всего: ${allRows.length - naCount}${naCount ? `, пустых источников (N/A): ${naCount}` : ''}.`} > 1 · Извлечённые сигналы </SectionTitle> <ToggleButtonGroup size="small" exclusive value={sourceFilter} onChange={(_, v) => v && setSourceFilter(v)} sx={{ mb: 1 }} > <ToggleButton value="all">все ({allRows.length})</ToggleButton> <ToggleButton value="интервью"> 🎙 интервью ({allRows.filter((r) => r.sourceKind === 'интервью').length}) </ToggleButton> <ToggleButton value="обращение"> 🎫 обращения ({allRows.filter((r) => r.sourceKind === 'обращение').length}) </ToggleButton> {naCount > 0 && ( <ToggleButton value="na"> <Tooltip title="Источники, обработанные LLM, из которых честно нечего извлечь: пустые, спам, благодарности без сути. В кластеризацию не попадают."> <span>N/A ({naCount})</span> </Tooltip> </ToggleButton> )} </ToggleButtonGroup> <TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 460 }}> <Table size="small" stickyHeader> <TableHead> <TableRow> <TableCell>Источник</TableCell> <HeadCell hint="Потребность по формуле Jobs-to-be-Done: «Когда [ситуация], я хочу [мотивация], чтобы [результат]». Формулирует LLM, придумывать запрещено."> JTBD-сигнал </HeadCell> <HeadCell hint="Дословная цитата из источника — доказательство, что сигнал не выдуман."> Цитата </HeadCell> <HeadCell hint={EMOTION_HINT}>Эмоция</HeadCell> <HeadCell hint="Сегмент аудитории из контекста продукта, определённый по содержанию источника."> Сегмент </HeadCell> </TableRow> </TableHead> <TableBody> {extraction.map((r) => r.type === 'none' ? ( <TableRow key={r.signalId} sx={{ opacity: 0.6 }}> <TableCell sx={{ whiteSpace: 'nowrap' }}> <SourceChip kind={r.sourceKind} refName={r.sourceRef} sourceId={r.sourceId} onOpen={setOpenSourceId} /> </TableCell> <TableCell colSpan={4}> <Tooltip title="Источник обработан, но извлекаемых потребностей в нём нет (пустой, спам или благодарность без содержания). В кластеризацию не попадает."> <Chip size="small" variant="outlined" label="N/A — сигналов нет" /> </Tooltip> </TableCell> </TableRow> ) : ( <TableRow key={r.signalId} hover> <TableCell sx={{ whiteSpace: 'nowrap' }}> <SourceChip kind={r.sourceKind} refName={r.sourceRef} sourceId={r.sourceId} onOpen={setOpenSourceId} /> </TableCell> <TableCell sx={{ maxWidth: 420 }}>{r.jtbd}</TableCell> <TableCell sx={{ maxWidth: 340, color: 'text.secondary' }}> {r.quote && `«${r.quote}»`} </TableCell> <TableCell> <EmotionChip value={r.emotion} /> </TableCell> <TableCell>{r.segment}</TableCell> </TableRow> ) )} </TableBody> </Table> </TableContainer> <SectionTitle hint="Похожие сигналы из разных источников склеены в одну потребность (дедупликация). Раскрой строку — увидишь, какие цитаты склеились."> 2 · Потребности: склейка и приоритет </SectionTitle> <TableContainer component={Paper} variant="outlined"> <Table size="small"> <TableHead> <TableRow> <TableCell /> <TableCell>Потребность</TableCell> <HeadCell align="right" hint="Приоритет = частота × эмоция × охват сегментов, нормирован к 100 у лидера. Все множители видны в соседних колонках — никакой магии." > Score </HeadCell> <HeadCell align="center" hint="Сколько сигналов склеилось в потребность: «и» — из интервью, «о» — из обращений в поддержку."> Частота (и+о) </HeadCell> <HeadCell align="center" hint="Средняя эмоциональная окраска сигналов потребности (0–3)."> Эмоция </HeadCell> <HeadCell align="center" hint="Сколько разных сегментов аудитории упомянули эту потребность (1–4)."> Охват </HeadCell> <TableCell>Метки</TableCell> </TableRow> </TableHead> <TableBody> {journal.needs.map((n) => ( <NeedRow key={n.id} need={n} onOpenSource={setOpenSourceId} /> ))} </TableBody> </Table> </TableContainer> <SectionTitle hint="Каждая задача бэклога переформулирована в JTBD тем же LLM и сравнена с потребностями: confirmed — закрывает потребность из фидбека, orphan — не подтверждена ничем."> 3 · Аудит текущего бэклога </SectionTitle> <TableContainer component={Paper} variant="outlined"> <Table size="small"> <TableHead> <TableRow> <TableCell>Задача</TableCell> <HeadCell hint="Задача бэклога, переписанная как потребность пользователя — так её можно честно сравнивать с сигналами по смыслу, а не по словам."> JTBD-переформулировка </HeadCell> <TableCell>Вердикт</TableCell> <HeadCell hint="Потребность из анализа фидбека, которую закрывает задача (только для confirmed)."> Потребность </HeadCell> <HeadCell hint="Одно предложение от LLM-судьи: почему задача закрывает (или не закрывает) потребность."> Обоснование </HeadCell> </TableRow> </TableHead> <TableBody> {journal.audit.map((a) => ( <TableRow key={a.ref} hover> <TableCell sx={{ maxWidth: 220 }}> <Tooltip title="Открыть исходную задачу бэклога"> <Typography variant="body2" sx={{ fontWeight: 600, cursor: 'pointer', color: 'primary.main' }} onClick={() => setOpenSourceId(a.sourceId)} > {a.ref} </Typography> </Tooltip> <Typography variant="caption" color="text.secondary"> {a.title} </Typography> </TableCell> <TableCell sx={{ maxWidth: 320, color: 'text.secondary' }}> {a.reframedJtbd} </TableCell> <TableCell>{verdictChip(a.verdict)}</TableCell> <TableCell sx={{ maxWidth: 180 }}>{a.needName || '—'}</TableCell> <TableCell sx={{ maxWidth: 320, color: 'text.secondary' }}>{a.reason}</TableCell> </TableRow> ))} </TableBody> </Table> </TableContainer> <SectionTitle hint="Итог конвейера: зелёные — приняты человеком (уйдут в СберТрек), зачёркнутые — отклонены."> 4 · Итоговый список задач </SectionTitle> <TableContainer component={Paper} variant="outlined"> <Table size="small"> <TableHead> <TableRow> <TableCell>#</TableCell> <TableCell>Задача</TableCell> <HeadCell hint="new — новая задача из потребности; keep — задача бэклога оставлена как есть; modify — задача бэклога переформулирована. Рядом — ссылка на исходную задачу бэклога."> Действие </HeadCell> <HeadCell hint="Потребность, из которой родилась задача, — по ней можно дойти до конкретных цитат в разделе 2."> Из потребности </HeadCell> <TableCell>Статус</TableCell> </TableRow> </TableHead> <TableBody> {journal.finalTasks.map((t) => ( <TableRow key={t.id} hover sx={statusStyle(t.status)}> <TableCell>{t.rank}</TableCell> <TableCell sx={{ fontWeight: 600 }}>{t.title}</TableCell> <TableCell> <Tooltip title={ACTION_HINTS[t.action] || ''}> <Chip size="small" variant="outlined" label={t.action + (t.backlogRef ? ` · ${t.backlogRef}` : '')} /> </Tooltip> </TableCell> <TableCell>{t.needName || '—'}</TableCell> <TableCell> {t.status === 'approved' && '✅ принята'} {t.status === 'rejected' && 'отклонена'} {t.status === 'pending' && 'ждёт решения'} </TableCell> </TableRow> ))} </TableBody> </Table> </TableContainer> <SourceDialog projectId={projectId} sourceId={openSourceId} onClose={() => setOpenSourceId(null)} /> </Box> ); }