/
sweetebin
/
hackaton_26
Обзор
Документация
Войти
/
sweetebin
/
hackaton_26
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
web-form-project/src/components/SourcesSetup.jsx
417 строк
17 KB
igor.saltykov
СберТрек: единая выгрузка без дропдауна типа; деление обращения/бэклог по диапазонам №№
28 июл 2026, 16:09
28 июл 2026, 16:09
6cf9ba4
Код
Авторство
О чём код?
import { useEffect, useRef, useState } from 'react'; import { Alert, Box, Button, Chip, CircularProgress, Paper, Stack, TextField, Tooltip, Typography, } from '@mui/material'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import DriveFolderUploadIcon from '@mui/icons-material/DriveFolderUpload'; import CloudSyncIcon from '@mui/icons-material/CloudSync'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import { deleteSource, sbertrackPull, sbertrackStatus, uploadSourceFiles, } from '../api/client'; const FILE_EXT = ['docx', 'txt', 'md', 'csv', 'xlsx']; const KIND_LABELS = { interview: 'Интервью', ticket: 'Обращения', backlog: 'Бэклог', context: 'Контекст продукта', }; const KIND_HINTS = { interview: 'Транскрипты интервью с пользователями — основной источник глубинных потребностей.', ticket: 'Обращения в поддержку — массовый сигнал; потребность, подтверждённая и интервью, и обращениями, весит больше.', backlog: 'Текущий бэклог продукта — нужен для аудита: какие задачи закрывают найденные потребности, какие «сироты».', context: 'Описание продукта и сегментов аудитории (xlsx) — по нему LLM привязывает сигналы к сегментам.', }; function extOf(name) { return name.split('.').pop().toLowerCase(); } function groupSources(sources) { const groups = {}; (sources || []).forEach((s) => { (groups[s.kind] = groups[s.kind] || []).push(s); }); return groups; } // Экран сборки пула данных: показывается, пока анализ ещё не запускался. // Источники: файлы/папка с диска и живая выгрузка из СберТрека через фасад MCP. export default function SourcesSetup({ project, onReload, onStart }) { const [stageErrors, setStageErrors] = useState([]); const [busy, setBusy] = useState(null); // 'files' | 'sbertrack' | 'start' | null const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [ticketsRange, setTicketsRange] = useState(''); const [backlogRange, setBacklogRange] = useState(''); // TQL по умолчанию — всё пространство проекта; фильтры (AND suit = "...") // нужны, только если обращения и бэклог надо разделить внутри пространства const [tql, setTql] = useState( project?.jiraProjectKey ? `space = "${project.jiraProjectKey}"` : '' ); const [stTickets, setStTickets] = useState(''); const [stBacklog, setStBacklog] = useState(''); const [stLimit, setStLimit] = useState('500'); const [stStatus, setStStatus] = useState(null); // null | {ok, user?, error?} | 'checking' const filesRef = useRef(null); const folderRef = useRef(null); // Сброс сообщений при обновлении списка источников const sources = project?.sources || []; const groups = groupSources(sources); const canStart = sources.some((s) => s.kind === 'interview' || s.kind === 'ticket'); useEffect(() => { setError(null); }, [sources.length]); // Ошибки прошлого (упавшего) запуска, чтобы не гадать, почему мы снова тут useEffect(() => { fetch('/api/progress') .then((r) => (r.ok ? r.json() : {})) .then((prog) => setStageErrors( Object.entries(prog || {}) .filter(([, s]) => s.status === 'error') .map(([stage, s]) => `${stage}: ${s.message || 'ошибка'}`) ) ) .catch(() => {}); }, []); const run = async (what, fn) => { setBusy(what); setError(null); setNotice(null); try { await fn(); } catch (e) { setError(e.message); } finally { setBusy(null); } }; const handleFiles = (fileList) => { const files = Array.from(fileList || []).filter((f) => FILE_EXT.includes(extOf(f.name))); if (!files.length) { setError(`Подходящих файлов не найдено. Форматы: ${FILE_EXT.map((e) => `.${e}`).join(', ')}`); return; } run('files', async () => { const { added } = await uploadSourceFiles(project.id, files, { ticketsRange: ticketsRange.trim(), backlogRange: backlogRange.trim(), }); setNotice( 'Добавлено: ' + Object.entries(added) .map(([k, v]) => `${(KIND_LABELS[k] || k).toLowerCase()} — ${v}`) .join(', ') ); onReload(); }); }; const handleSbertrackPull = () => run('sbertrack', async () => { const { added } = await sbertrackPull(project.id, { tql: tql.trim(), tickets_range: stTickets.trim(), backlog_range: stBacklog.trim(), limit: Number(stLimit) || 0, }); const parts = Object.entries(added || {}) .filter(([k, v]) => v > 0 && k !== 'skipped') .map(([k, v]) => `${(KIND_LABELS[k] || k).toLowerCase()} — ${v}`); const skipped = added?.skipped ? `, пропущено вне диапазонов: ${added.skipped}` : ''; setNotice(`Выгружено из СберТрека: ${parts.join(', ') || '0'}${skipped}`); onReload(); }); const handleCheck = async () => { setStStatus('checking'); try { setStStatus(await sbertrackStatus()); } catch (e) { setStStatus({ ok: false, error: e.message }); } }; const handleDelete = (sid) => run('files', async () => { await deleteSource(project.id, sid); onReload(); }); return ( <Box sx={{ maxWidth: 980, mx: 'auto' }}> <Typography variant="h6" sx={{ mb: 0.5 }}> Соберите пул данных </Typography> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> Для анализа нужны интервью и/или обращения в поддержку; бэклог — для аудита существующих задач, контекст продукта — для привязки сигналов к сегментам. Данные можно добавлять и после первого анализа. </Typography> {stageErrors?.length > 0 && ( <Alert severity="warning" sx={{ mb: 2 }}> Прошлый запуск анализа упал: {stageErrors.join('; ')}. Проверьте настройки LLM-бэкенда и запустите анализ ещё раз — уже обработанные источники пересчитываться не будут. </Alert> )} <Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, alignItems: 'stretch', }} > <Paper variant="outlined" sx={{ p: 2.5 }}> <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}> <UploadFileIcon color="primary" /> <Typography variant="subtitle1" fontWeight={600}> Файлы с диска </Typography> </Stack> <Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}> .docx / .txt / .md — интервью, .csv — выгрузка трекера, .xlsx — контекст продукта. Можно выбрать сразу папку с транскриптами. </Typography> <Stack direction="row" spacing={1.5} sx={{ mb: 2 }}> <Button variant="outlined" startIcon={<UploadFileIcon />} disabled={busy === 'files'} onClick={() => filesRef.current?.click()} > Выбрать файлы </Button> <Button variant="outlined" startIcon={<DriveFolderUploadIcon />} disabled={busy === 'files'} onClick={() => folderRef.current?.click()} > Выбрать папку </Button> {busy === 'files' && <CircularProgress size={24} sx={{ alignSelf: 'center' }} />} </Stack> <input ref={filesRef} type="file" hidden multiple accept={FILE_EXT.map((e) => `.${e}`).join(',')} onChange={(e) => { handleFiles(e.target.files); e.target.value = ''; }} /> <input ref={folderRef} type="file" hidden multiple webkitdirectory="" onChange={(e) => { handleFiles(e.target.files); e.target.value = ''; }} /> <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> Если csv содержит и обращения, и бэклог — задайте диапазоны номеров до загрузки: </Typography> <Stack direction="row" spacing={1.5}> <Tooltip title="Номера задач-обращений в csv, например 153-202. Пусто = весь csv считается обращениями."> <TextField size="small" label="Диапазон обращений" placeholder="153-202" value={ticketsRange} onChange={(e) => setTicketsRange(e.target.value)} /> </Tooltip> <Tooltip title="Номера задач текущего бэклога в csv, например 203-232."> <TextField size="small" label="Диапазон бэклога" placeholder="203-232" value={backlogRange} onChange={(e) => setBacklogRange(e.target.value)} /> </Tooltip> </Stack> </Paper> <Paper variant="outlined" sx={{ p: 2.5 }}> <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}> <CloudSyncIcon color="primary" /> <Typography variant="subtitle1" fontWeight={600} sx={{ flexGrow: 1 }}> СберТрек (живая выгрузка) </Typography> <Button size="small" onClick={handleCheck} disabled={stStatus === 'checking'}> {stStatus === 'checking' ? 'Проверяем…' : 'Проверить подключение'} </Button> </Stack> {stStatus && stStatus !== 'checking' && ( <Alert severity={stStatus.ok ? 'success' : 'error'} sx={{ mb: 1.5 }}> {stStatus.ok ? `Контур жив: ${stStatus.user || 'аноним'} · тулов MCP: ${stStatus.tools}` : `Нет связи: ${stStatus.error}`} </Alert> )} <Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}> Обращения и бэклог тянутся напрямую из СберТрека по TQL-запросу — через тот же MCP-контур, что у агента GigaCode. </Typography> <Stack spacing={1.5}> <Tooltip title='TQL-запрос трекера, например: space = "HD2026" AND suit = "Обращения". Скобки TQL не поддерживает.'> <TextField size="small" label="TQL-запрос" placeholder='space = "HD2026"' value={tql} onChange={(e) => setTql(e.target.value)} fullWidth /> </Tooltip> <Stack direction="row" spacing={1.5}> <Tooltip title="Ключи с этими номерами пойдут в обращения, напр. 153-202. Пустые диапазоны — вся выгрузка считается обращениями."> <TextField size="small" label="Обращения, №№" placeholder="153-202" value={stTickets} onChange={(e) => setStTickets(e.target.value)} sx={{ width: 150 }} /> </Tooltip> <Tooltip title="Ключи с этими номерами пойдут в бэклог, напр. 203-232."> <TextField size="small" label="Бэклог, №№" placeholder="203-232" value={stBacklog} onChange={(e) => setStBacklog(e.target.value)} sx={{ width: 150 }} /> </Tooltip> <Tooltip title="Максимум записей. 0 = выгрузить всё (лимит одного вызова MCP — 500, дальше выгрузка идёт окнами по дате создания)."> <TextField size="small" label="Лимит" value={stLimit} onChange={(e) => setStLimit(e.target.value.replace(/\D/g, ''))} sx={{ width: 110 }} /> </Tooltip> <Button variant="outlined" onClick={handleSbertrackPull} disabled={!tql.trim() || busy === 'sbertrack'} > {busy === 'sbertrack' ? 'Выгружаем…' : 'Выгрузить'} </Button> </Stack> </Stack> </Paper> </Box> {(error || notice) && ( <Alert severity={error ? 'error' : 'success'} sx={{ mt: 2 }} onClose={() => { setError(null); setNotice(null); }} > {error || notice} </Alert> )} <Paper variant="outlined" sx={{ p: 2.5, mt: 2 }}> <Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1.5 }}> Пул данных{sources.length ? ` · ${sources.length}` : ''} </Typography> {!sources.length && ( <Typography variant="body2" color="text.secondary"> Пока пусто — добавьте интервью или обращения выше. </Typography> )} {Object.entries(groups).map(([kind, items]) => ( <Box key={kind} sx={{ mb: 1.5 }}> <Tooltip title={KIND_HINTS[kind] || ''} placement="right"> <Typography variant="body2" fontWeight={600} sx={{ mb: 0.5, display: 'inline-block', cursor: 'help' }} > {KIND_LABELS[kind] || kind}: {items.length} </Typography> </Tooltip> <Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap" sx={{ maxHeight: 140, overflowY: 'auto' }} > {items.map((s) => ( <Tooltip key={s.id} title={s.title || ''}> <Chip size="small" variant="outlined" label={s.name} onDelete={() => handleDelete(s.id)} /> </Tooltip> ))} </Stack> </Box> ))} </Paper> <Stack direction="row" justifyContent="flex-end" sx={{ mt: 2.5 }}> <Tooltip title={ canStart ? 'Запустить конвейер: извлечение JTBD-сигналов → кластеризация → приоритизация → user stories → аудит бэклога → итоговый список.' : 'Нужен хотя бы один источник с обратной связью: интервью или обращения.' } > <span> <Button size="large" variant="contained" startIcon={<PlayArrowIcon />} disabled={!canStart || busy !== null} onClick={() => run('start', onStart)} > Запустить анализ </Button> </span> </Tooltip> </Stack> </Box> ); }