Спринт 4: AI-помощник на базе DeepSeek

- Страница /settings: ввод и проверка API ключа DeepSeek
- POST /api/llm/generate — генерация вопросов по названию теста
- POST /api/llm/improve — улучшение формулировки вопроса + ответов (модал с галочками)
- POST /api/llm/distractors — генерация дистракторов
- POST /api/llm/review — рецензия теста + кнопка «Предложить вариант»
- POST /api/llm/improve_all — улучшение всего теста с постатейным сравнением
- Миграция 004: таблица settings (key-value)
- Шапка приложения с навигацией на /settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aleksey Razorvin
2026-03-21 15:11:49 +05:00
parent c1a38bfef8
commit 9a0b3ba92c
19 changed files with 1485 additions and 43 deletions
+614 -14
View File
@@ -1,4 +1,10 @@
import { ArrowLeftOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'
import {
ArrowLeftOutlined,
MinusCircleOutlined,
PlusOutlined,
RobotOutlined,
StarOutlined,
} from '@ant-design/icons'
import {
Button,
Card,
@@ -6,13 +12,17 @@ import {
Form,
Input,
InputNumber,
Modal,
Space,
Switch,
Typography,
notification,
} from 'antd'
import { useState } from 'react'
import llmApi, { LLMQuestion } from '../../api/llm'
const { Title } = Typography
const { Title, Text, Paragraph } = Typography
const EMPTY_ANSWER = { text: '', is_correct: false }
const EMPTY_QUESTION = { text: '', answers: [EMPTY_ANSWER, EMPTY_ANSWER, EMPTY_ANSWER] }
@@ -49,23 +59,247 @@ export default function TestForm({
backLabel = 'Назад',
}: TestFormProps) {
const [form] = Form.useForm<TestFormValues>()
const [notifApi, contextHolder] = notification.useNotification()
// Generate state
const [generateLoading, setGenerateLoading] = useState(false)
const [previewOpen, setPreviewOpen] = useState(false)
const [previewQuestions, setPreviewQuestions] = useState<LLMQuestion[] | null>(null)
// Improve modal state
const [improveState, setImproveState] = useState<{
open: boolean
qIndex: number | null
loading: boolean
original: { text: string; answers: { text: string; is_correct: boolean }[] } | null
improved: { question: string; answers: string[] } | null
applyQuestion: boolean
applyAnswers: boolean[]
}>({
open: false,
qIndex: null,
loading: false,
original: null,
improved: null,
applyQuestion: true,
applyAnswers: [],
})
// Distractors state: qIndex → loading
const [distractorsLoading, setDistractorsLoading] = useState<Record<number, boolean>>({})
// Review modal state
const [reviewOpen, setReviewOpen] = useState(false)
const [reviewLoading, setReviewLoading] = useState(false)
const [reviewText, setReviewText] = useState('')
// improve_all state (inside review modal)
const [improveAllLoading, setImproveAllLoading] = useState(false)
const [improveAllData, setImproveAllData] = useState<{
original: { text: string; answers: { text: string; is_correct: boolean }[] }[]
improved: { question: string; answers: string[] }[]
applyQuestions: boolean[]
applyAnswers: boolean[][]
} | null>(null)
const defaultValues: Partial<TestFormValues> = {
allow_navigation_back: true,
has_timer: false,
passing_score: 70,
questions: Array(7).fill(null).map(() => ({ ...EMPTY_QUESTION })),
questions: Array(7)
.fill(null)
.map(() => ({ ...EMPTY_QUESTION })),
...initialValues,
}
// ── AI: Генерация вопросов ──────────────────────────────────────────────
const handleGenerate = async () => {
const title = form.getFieldValue('title') as string
if (!title?.trim()) return
setGenerateLoading(true)
setPreviewQuestions(null)
setPreviewOpen(true)
try {
const data = await llmApi.generate(title.trim(), 7)
setPreviewQuestions(data.questions)
} catch {
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось сгенерировать вопросы' })
setPreviewOpen(false)
} finally {
setGenerateLoading(false)
}
}
const handleGenerateApply = () => {
if (!previewQuestions) return
form.setFieldValue('questions', previewQuestions)
setPreviewOpen(false)
setPreviewQuestions(null)
notifApi.success({ message: `Добавлено ${previewQuestions.length} вопросов` })
}
const handleGenerateClose = () => {
setPreviewOpen(false)
setPreviewQuestions(null)
}
// ── AI: Улучшить формулировку ──────────────────────────────────────────
const handleImprove = async (qIndex: number) => {
const questionText: string = form.getFieldValue(['questions', qIndex, 'text']) || ''
if (!questionText.trim()) {
notifApi.warning({ message: 'Введите текст вопроса перед улучшением' })
return
}
const answers: { text: string; is_correct: boolean }[] =
form.getFieldValue(['questions', qIndex, 'answers']) || []
setImproveState({
open: true,
qIndex,
loading: true,
original: { text: questionText, answers },
improved: null,
applyQuestion: true,
applyAnswers: answers.map(() => true),
})
try {
const answerTexts = answers.map((a) => a.text)
const data = await llmApi.improve(questionText, answerTexts)
setImproveState((prev) => ({
...prev,
loading: false,
improved: { question: data.improved_question, answers: data.improved_answers },
applyAnswers: data.improved_answers.map(() => true),
}))
} catch {
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось улучшить вопрос' })
setImproveState((prev) => ({ ...prev, open: false, loading: false }))
}
}
const handleImproveApply = () => {
const { qIndex, original, improved, applyQuestion, applyAnswers } = improveState
if (qIndex === null || !original || !improved) return
if (applyQuestion) {
form.setFieldValue(['questions', qIndex, 'text'], improved.question)
}
const updatedAnswers = original.answers.map((answer, i) => ({
...answer,
text: applyAnswers[i] && improved.answers[i] ? improved.answers[i] : answer.text,
}))
form.setFieldValue(['questions', qIndex, 'answers'], updatedAnswers)
setImproveState((prev) => ({ ...prev, open: false }))
notifApi.success({ message: 'Изменения применены' })
}
// ── AI: Добавить дистракторы ────────────────────────────────────────────
const handleDistractors = async (qIndex: number) => {
const questionText: string = form.getFieldValue(['questions', qIndex, 'text']) || ''
if (!questionText.trim()) {
notifApi.warning({ message: 'Введите текст вопроса перед генерацией дистракторов' })
return
}
const currentAnswers: { text: string; is_correct: boolean }[] =
form.getFieldValue(['questions', qIndex, 'answers']) || []
const answerTexts = currentAnswers.map((a) => a.text).filter(Boolean)
setDistractorsLoading((prev) => ({ ...prev, [qIndex]: true }))
try {
const data = await llmApi.distractors(questionText, answerTexts)
const newAnswers = [
...currentAnswers,
...data.distractors.map((d) => ({ text: d, is_correct: false })),
]
form.setFieldValue(['questions', qIndex, 'answers'], newAnswers)
notifApi.success({ message: `Добавлено ${data.distractors.length} дистракторов` })
} catch {
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось сгенерировать дистракторы' })
} finally {
setDistractorsLoading((prev) => ({ ...prev, [qIndex]: false }))
}
}
// ── AI: Проверить тест ─────────────────────────────────────────────────
const handleReview = async () => {
const values = form.getFieldsValue()
if (!values.title) {
notifApi.warning({ message: 'Введите название теста перед проверкой' })
return
}
setReviewLoading(true)
setReviewOpen(true)
setReviewText('')
setImproveAllData(null)
try {
const data = await llmApi.review(values.title, values.questions || [])
setReviewText(data.review)
} catch {
setReviewText('Не удалось получить рекомендации. Проверьте API ключ в настройках.')
} finally {
setReviewLoading(false)
}
}
const handleImproveAll = async () => {
const values = form.getFieldsValue()
const original: { text: string; answers: { text: string; is_correct: boolean }[] }[] =
(values.questions || []).map((q: { text: string; answers: { text: string; is_correct: boolean }[] }) => ({
text: q.text,
answers: q.answers || [],
}))
setImproveAllLoading(true)
try {
const data = await llmApi.improveAll(values.title, values.questions || [])
setImproveAllData({
original,
improved: data.questions,
applyQuestions: data.questions.map(() => true),
applyAnswers: data.questions.map((q) => q.answers.map(() => true)),
})
} catch {
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось сгенерировать улучшения' })
} finally {
setImproveAllLoading(false)
}
}
const handleImproveAllApply = () => {
if (!improveAllData) return
const questions: { text: string; answers: { text: string; is_correct: boolean }[] }[] =
form.getFieldValue('questions') || []
const updated = questions.map((q, qi) => ({
...q,
text:
improveAllData.applyQuestions[qi] && improveAllData.improved[qi]
? improveAllData.improved[qi].question
: q.text,
answers: q.answers.map((a, ai) => ({
...a,
text:
improveAllData.applyAnswers[qi]?.[ai] && improveAllData.improved[qi]?.answers[ai]
? improveAllData.improved[qi].answers[ai]
: a.text,
})),
}))
form.setFieldValue('questions', updated)
setReviewOpen(false)
setImproveAllData(null)
notifApi.success({ message: 'Изменения применены' })
}
return (
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}>
{contextHolder}
{onBack && (
<Button
icon={<ArrowLeftOutlined />}
onClick={onBack}
style={{ marginBottom: 16 }}
>
<Button icon={<ArrowLeftOutlined />} onClick={onBack} style={{ marginBottom: 16 }}>
{backLabel}
</Button>
)}
@@ -129,6 +363,31 @@ export default function TestForm({
</Form.Item>
</Card>
{/* ── AI: кнопки ── */}
<Form.Item
noStyle
shouldUpdate={(prev, cur) => prev.title !== cur.title}
>
{({ getFieldValue }) => (
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button
icon={<RobotOutlined />}
onClick={handleGenerate}
loading={generateLoading}
disabled={!getFieldValue('title')?.trim()}
>
Сгенерировать с AI
</Button>
<Button
icon={<StarOutlined />}
onClick={handleReview}
>
Проверить тест
</Button>
</div>
)}
</Form.Item>
{/* ── Вопросы ── */}
<Form.List
name="questions"
@@ -149,12 +408,32 @@ export default function TestForm({
key={key}
title={`Вопрос ${index + 1}`}
extra={
questionFields.length > 7 ? (
<MinusCircleOutlined
style={{ color: '#ff4d4f', fontSize: 16 }}
onClick={() => removeQuestion(qName)}
/>
) : null
<Space>
<Button
size="small"
icon={<RobotOutlined />}
loading={improveState.loading && improveState.qIndex === index}
onClick={() => handleImprove(index)}
title="Улучшить формулировку"
>
Улучшить
</Button>
<Button
size="small"
icon={<PlusOutlined />}
loading={distractorsLoading[index]}
onClick={() => handleDistractors(index)}
title="Добавить дистракторы"
>
Дистракторы
</Button>
{questionFields.length > 7 && (
<MinusCircleOutlined
style={{ color: '#ff4d4f', fontSize: 16 }}
onClick={() => removeQuestion(qName)}
/>
)}
</Space>
}
style={{ marginBottom: 16 }}
>
@@ -263,6 +542,327 @@ export default function TestForm({
</Space>
</Form.Item>
</Form>
{/* ── Modal: Превью сгенерированных вопросов ── */}
<Modal
title={<><RobotOutlined /> Сгенерированные вопросы</>}
open={previewOpen}
onCancel={handleGenerateClose}
footer={null}
width={640}
>
{generateLoading && (
<div style={{ textAlign: 'center', padding: 40 }}>
<RobotOutlined style={{ fontSize: 32, color: '#1677ff' }} spin />
<div style={{ marginTop: 12, color: '#666' }}>AI генерирует вопросы...</div>
</div>
)}
{!generateLoading && previewQuestions && (
<>
<Text type="secondary">
Сгенерировано {previewQuestions.length} вопросов. Нажмите «Применить», чтобы
заменить вопросы в форме.
</Text>
<div
style={{
maxHeight: 340,
overflowY: 'auto',
border: '1px solid #f0f0f0',
borderRadius: 8,
padding: 12,
marginTop: 12,
marginBottom: 16,
background: '#fafafa',
}}
>
{previewQuestions.map((q, i) => (
<div key={i} style={{ marginBottom: 12 }}>
<Text strong>
{i + 1}. {q.text}
</Text>
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
{q.answers.map((a, ai) => (
<li key={ai} style={{ color: a.is_correct ? '#52c41a' : undefined }}>
{a.text} {a.is_correct && '✓'}
</li>
))}
</ul>
</div>
))}
</div>
<Space>
<Button type="primary" onClick={handleGenerateApply}>
Применить все вопросы
</Button>
<Button onClick={handleGenerate}>Сгенерировать заново</Button>
<Button onClick={handleGenerateClose}>Отмена</Button>
</Space>
</>
)}
</Modal>
{/* ── Modal: Улучшение вопроса ── */}
<Modal
title={
<>
<RobotOutlined /> Улучшение вопроса{' '}
{improveState.qIndex !== null ? improveState.qIndex + 1 : ''}
</>
}
open={improveState.open}
onCancel={() => setImproveState((prev) => ({ ...prev, open: false }))}
width={680}
footer={
improveState.improved ? (
<Space>
<Button type="primary" onClick={handleImproveApply}>
Применить выбранные
</Button>
<Button onClick={() => setImproveState((prev) => ({ ...prev, open: false }))}>
Отмена
</Button>
</Space>
) : null
}
>
{improveState.loading && (
<div style={{ textAlign: 'center', padding: 40 }}>
<RobotOutlined style={{ fontSize: 32, color: '#1677ff' }} spin />
<div style={{ marginTop: 12, color: '#666' }}>AI улучшает формулировки...</div>
</div>
)}
{!improveState.loading && improveState.improved && improveState.original && (
<>
{/* Вопрос */}
<div style={{ marginBottom: 16 }}>
<Text strong>Текст вопроса</Text>
<div
style={{
display: 'flex',
gap: 12,
marginTop: 8,
padding: 12,
background: '#fafafa',
borderRadius: 8,
border: '1px solid #f0f0f0',
}}
>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 6 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Текущая
</Text>
<div>{improveState.original.text}</div>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
AI предлагает
</Text>
<div style={{ color: '#1677ff' }}>{improveState.improved.question}</div>
</div>
</div>
<Checkbox
checked={improveState.applyQuestion}
onChange={(e) =>
setImproveState((prev) => ({ ...prev, applyQuestion: e.target.checked }))
}
>
Применить
</Checkbox>
</div>
</div>
{/* Ответы */}
<Text strong>Варианты ответов</Text>
<div style={{ marginTop: 8 }}>
{improveState.original.answers.map((answer, i) => (
<div
key={i}
style={{
display: 'flex',
gap: 12,
marginBottom: 8,
padding: 12,
background: '#fafafa',
borderRadius: 8,
border: '1px solid #f0f0f0',
}}
>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 6 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Вариант {i + 1}{answer.is_correct ? ' (правильный ✓)' : ''}
</Text>
<div>{answer.text}</div>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
AI предлагает
</Text>
<div style={{ color: '#1677ff' }}>
{improveState.improved.answers[i] ?? answer.text}
</div>
</div>
</div>
<Checkbox
checked={improveState.applyAnswers[i]}
onChange={(e) => {
const next = [...improveState.applyAnswers]
next[i] = e.target.checked
setImproveState((prev) => ({ ...prev, applyAnswers: next }))
}}
>
Применить
</Checkbox>
</div>
))}
</div>
</>
)}
</Modal>
{/* ── Modal: Проверка теста + улучшение всего теста ── */}
<Modal
title={
improveAllData
? <><RobotOutlined /> Предложения по улучшению теста</>
: <><StarOutlined /> Рекомендации AI</>
}
open={reviewOpen}
onCancel={() => { setReviewOpen(false); setImproveAllData(null) }}
width={700}
footer={
improveAllData ? (
<Space>
<Button type="primary" onClick={handleImproveAllApply}>
Применить выбранные
</Button>
<Button onClick={() => setImproveAllData(null)}>
К рекомендациям
</Button>
<Button onClick={() => { setReviewOpen(false); setImproveAllData(null) }}>
Закрыть
</Button>
</Space>
) : (
<Space>
{!reviewLoading && reviewText && (
<Button
type="primary"
icon={<RobotOutlined />}
loading={improveAllLoading}
onClick={handleImproveAll}
>
Предложить вариант
</Button>
)}
<Button onClick={() => setReviewOpen(false)}>Закрыть</Button>
</Space>
)
}
>
{/* Режим 1: рекомендации */}
{!improveAllData && (
reviewLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<RobotOutlined style={{ fontSize: 32, color: '#1677ff' }} spin />
<div style={{ marginTop: 12, color: '#666' }}>AI анализирует тест...</div>
</div>
) : improveAllLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<RobotOutlined style={{ fontSize: 32, color: '#1677ff' }} spin />
<div style={{ marginTop: 12, color: '#666' }}>AI готовит улучшенный вариант...</div>
</div>
) : (
<Paragraph style={{ whiteSpace: 'pre-wrap' }}>{reviewText}</Paragraph>
)
)}
{/* Режим 2: сравнение старого и нового */}
{improveAllData && (
<div style={{ maxHeight: 520, overflowY: 'auto' }}>
{improveAllData.original.map((origQ, qi) => (
<div key={qi} style={{ marginBottom: 20 }}>
{/* Заголовок вопроса */}
<div
style={{
display: 'flex',
gap: 12,
padding: 12,
background: '#f0f5ff',
borderRadius: 8,
border: '1px solid #d6e4ff',
marginBottom: 6,
}}
>
<div style={{ flex: 1 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Вопрос {qi + 1} текущий
</Text>
<div style={{ marginBottom: 4 }}>{origQ.text}</div>
<Text type="secondary" style={{ fontSize: 12 }}>
AI предлагает
</Text>
<div style={{ color: '#1677ff' }}>
{improveAllData.improved[qi]?.question ?? origQ.text}
</div>
</div>
<Checkbox
checked={improveAllData.applyQuestions[qi]}
onChange={(e) => {
const next = [...improveAllData.applyQuestions]
next[qi] = e.target.checked
setImproveAllData((prev) => prev && { ...prev, applyQuestions: next })
}}
>
Применить
</Checkbox>
</div>
{/* Ответы */}
{origQ.answers.map((origA, ai) => (
<div
key={ai}
style={{
display: 'flex',
gap: 12,
padding: '8px 12px',
background: '#fafafa',
borderRadius: 6,
border: '1px solid #f0f0f0',
marginBottom: 4,
marginLeft: 16,
}}
>
<div style={{ flex: 1 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Вариант {ai + 1}{origA.is_correct ? ' (правильный ✓)' : ''}
</Text>
<div style={{ marginBottom: 2 }}>{origA.text}</div>
<Text type="secondary" style={{ fontSize: 12 }}>AI предлагает</Text>
<div style={{ color: '#1677ff' }}>
{improveAllData.improved[qi]?.answers[ai] ?? origA.text}
</div>
</div>
<Checkbox
checked={improveAllData.applyAnswers[qi]?.[ai]}
onChange={(e) => {
const next = improveAllData.applyAnswers.map((row) => [...row])
next[qi][ai] = e.target.checked
setImproveAllData((prev) => prev && { ...prev, applyAnswers: next })
}}
>
Применить
</Checkbox>
</div>
))}
</div>
))}
</div>
)}
</Modal>
</div>
)
}