diff --git a/DOC/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md b/DOC/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md new file mode 100644 index 0000000..cf0eab5 --- /dev/null +++ b/DOC/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md @@ -0,0 +1,140 @@ +# Предложение по редизайну страницы «Создание теста» + +**Ветка:** `dev-new-design-page-createtest` +**Затронутые файлы:** `frontend/src/components/TestForm/index.tsx`, `frontend/src/pages/TestCreate/index.tsx` (через общий компонент), частично `frontend/src/api/llm.ts` (расширение сигнатуры `generate`). +**Бэкенд:** менять не обязательно — у нас уже есть `POST /api/llm/generate` (см. `backend/app/api/llm.py`); нужно лишь принять параметры `questions_count` и `answers_count`. + +--- + +## 1. Цель + +Сделать форму создания теста читаемее: разбить «полотно» на три смысловые группы и чётко отделить редактируемое содержимое от служебных команд. Заодно — дать пользователю возможность сгенерировать тест сразу нужной формы, не нащёлкивая «+ вопрос» / «+ вариант» вручную. + +## 2. Текущее состояние (что есть) + +`TestForm/index.tsx` сейчас визуально устроен так: + +``` +┌─────────────────────────────────────────┐ +│ ← Назад Заголовок │ +├─────────────────────────────────────────┤ +│ Card «Основные настройки» │ +│ • название │ +│ • описание │ +│ • порог зачёта │ +│ • таймер │ +│ • разрешить возврат │ +├─────────────────────────────────────────┤ +│ [Сгенерировать с AI] [Проверить тест] │ ← вне карточек, между мета и вопросами +├─────────────────────────────────────────┤ +│ Card «Вопрос 1» │ +│ ... │ +│ Card «Вопрос N» │ +│ [+ Добавить вопрос] │ +├─────────────────────────────────────────┤ +│ [Создать тест] [Отмена] │ +└─────────────────────────────────────────┘ +``` + +Замечания: +- AI-кнопки висят «в воздухе» — не видно, к какой части формы они относятся. +- «Сгенерировать с AI» жёстко создаёт **7 вопросов** (см. `TestForm/index.tsx:123` — `llmApi.generate(title.trim(), 7)`), без выбора структуры. +- В fallback-сообщении ошибки упоминается «API ключ в настройках» (`TestForm/index.tsx:244`). + +## 3. Что меняем + +### 3.1. Три смысловых блока + +| Блок | Содержит | Визуально | +|------|----------|-----------| +| **Метаинформация** | название, описание, порог, таймер, навигация назад | `Card title="Метаинформация"` | +| **Содержание** | кнопка «Сгенерировать с AI», список вопросов, «+ Добавить вопрос» | `Card title="Содержание"` (вложенные карточки вопросов остаются) | +| **Команды** | «Создать тест», «Отмена», «Проверить тест» | блок `Space` снизу страницы | + +Кнопка **«Проверить тест»** логически — это команда над всем тестом, поэтому переезжает в нижнюю панель команд. Кнопка **«Сгенерировать с AI»** становится первым элементом блока «Содержание» — у неё там естественное место (она формирует именно содержание). + +### 3.2. Wireframe после редизайна + +``` +┌─────────────────────────────────────────┐ +│ ← Назад Создание теста │ +├─────────────────────────────────────────┤ +│ Card «Метаинформация» │ +│ • название │ +│ • описание │ +│ • порог зачёта │ +│ • таймер │ +│ • разрешить возврат │ +├─────────────────────────────────────────┤ +│ Card «Содержание» │ +│ ┌─ AI-генерация ────────────────────┐ │ +│ │ тема: [_________________] │ │ +│ │ вопросов: [7] вариантов: [3] │ │ +│ │ [🤖 Сгенерировать] │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ Card «Вопрос 1» ... │ +│ Card «Вопрос N» ... │ +│ [+ Добавить вопрос] │ +├─────────────────────────────────────────┤ +│ [Создать тест] [Проверить тест] [Отмена] │ +└─────────────────────────────────────────┘ +``` + +### 3.3. Форма AI-генерации с тремя полями + +Внутри блока «Содержание» — компактный мини-блок (не модалка) с тремя полями: + +| Поле | Тип | По умолчанию | Лимиты | +|------|-----|--------------|--------| +| Тема | `Input` | значение `title` из метаинформации (auto-fill) | непустая | +| Количество вопросов | `InputNumber` | 7 | 1…30 | +| Количество вариантов на вопрос | `InputNumber` | 3 | 2…8 | + +Кнопка **«Сгенерировать»** запускает текущий поток `handleGenerate` (модалка-превью → «Применить все вопросы»), но передаёт оба числа в API. Превью показывает то же, что и сейчас. + +Поведение существующих кнопок «Улучшить» и «Дистракторы» внутри карточки вопроса **не меняется** — они и так локальные. + +### 3.4. Уход от текста про API-ключи + +Единственное место в форме, где упоминается ключ, — fallback-сообщение об ошибке: + +```ts +// TestForm/index.tsx:244 +setReviewText('Не удалось получить рекомендации. Проверьте API ключ в настройках.') +``` + +Заменяем на нейтральное: + +```ts +setReviewText('Не удалось получить рекомендации. Попробуйте позже или обратитесь к администратору.') +``` + +Сама страница `Settings` остаётся как есть — это её прямое назначение, и её редактируют только администраторы. + +## 4. План работ (чек-лист для исполнителя) + +- [ ] **Backend** (опционально, если ещё не принимает параметры): расширить схему `LLMGenerateRequest` в `backend/app/schemas/llm.py` полями `questions_count: int = 7`, `answers_count: int = 3`; передать их в промпт сервиса `app/services/llm.py`. +- [ ] **API-клиент**: в `frontend/src/api/llm.ts` обновить сигнатуру `generate(topic, questionsCount, answersCount)`. +- [ ] **TestForm**: обернуть текущий блок «Основные настройки» в `Card title="Метаинформация"`. +- [ ] **TestForm**: создать новый `Card title="Содержание"`, в него поместить: + - мини-блок AI-генерации (3 поля + кнопка), + - текущий `Form.List` вопросов с кнопкой «+ Добавить вопрос». +- [ ] **TestForm**: убрать текущий ряд из двух AI-кнопок между мета и вопросами; «Проверить тест» перенести в нижнюю панель команд рядом с «Создать»/«Отмена». +- [ ] **TestForm**: добавить локальный стейт `aiQuestionsCount`, `aiAnswersCount`, инициализация: 7 / 3. +- [ ] **TestForm**: в `handleGenerate` передавать оба числа; при `Применить` создавать соответствующее число пустых ответов в каждом вопросе (если бэк прислал меньше — добить пустыми, если больше — обрезать). +- [ ] **TestForm**: заменить fallback-текст про API-ключ. +- [ ] Прогнать `docker compose up`, проверить вручную: создание с дефолтами, генерация на 5 вопросов × 4 ответа, проверка теста, отмена. +- [ ] Документ-шаг в `DOC/ШАГИ/ШАГ_<дата>_.md` по факту выполнения. + +## 5. Что **не** делаем в этой ветке + +- Не трогаем форму редактирования (`TestEdit`) — формально использует тот же `TestForm`, но эффект редизайна нужно отдельно проверить с уже заполненными вопросами и опциями версионирования. +- Не меняем поведение `Form.List` валидации (минимум 7 вопросов / 3 варианта) — это отдельная история. +- Не вводим drag-and-drop переупорядочивание вопросов. + +## 6. Открытые вопросы для согласования + +1. Нужно ли визуально подсвечивать мини-блок генерации (фон, бордер), чтобы он отличался от карточек вопросов? +2. После применения сгенерированных вопросов — нужно ли скрывать мини-блок, чтобы он не путал? +3. Лимиты «1…30 вопросов / 2…8 вариантов» — устраивают или нужны другие? diff --git a/backend/app/api/llm.py b/backend/app/api/llm.py index dd910de..f65dac9 100644 --- a/backend/app/api/llm.py +++ b/backend/app/api/llm.py @@ -16,6 +16,7 @@ class CheckResponse(BaseModel): class GenerateRequest(BaseModel): topic: str count: int = 7 + answers_count: int = 3 class GenerateResponse(BaseModel): @@ -73,7 +74,9 @@ async def check_connection(db: AsyncSession = Depends(get_db)): @router.post("/api/llm/generate", response_model=GenerateResponse) async def generate_questions(req: GenerateRequest, db: AsyncSession = Depends(get_db)): try: - questions = await llm_service.generate_questions(db, req.topic, req.count) + questions = await llm_service.generate_questions( + db, req.topic, req.count, req.answers_count + ) return {"questions": questions} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py index 89e2df9..ca5965d 100644 --- a/backend/app/services/llm.py +++ b/backend/app/services/llm.py @@ -33,7 +33,12 @@ async def check_connection(db: AsyncSession) -> str: return response.choices[0].message.content.strip() -async def generate_questions(db: AsyncSession, topic: str, count: int = 7) -> list[dict]: +async def generate_questions( + db: AsyncSession, + topic: str, + count: int = 7, + answers_count: int = 3, +) -> list[dict]: api_key = await _get_api_key(db) client = _client(api_key) @@ -54,7 +59,7 @@ async def generate_questions(db: AsyncSession, topic: str, count: int = 7) -> li }} Требования: -- Минимум 3 варианта ответа на каждый вопрос +- Ровно {answers_count} вариантов ответа на каждый вопрос - Ровно один правильный ответ на каждый вопрос - Вопросы должны проверять практические знания по теме - Варианты ответов должны быть правдоподобными""" diff --git a/frontend/src/api/llm.ts b/frontend/src/api/llm.ts index a83565f..224295b 100644 --- a/frontend/src/api/llm.ts +++ b/frontend/src/api/llm.ts @@ -9,9 +9,13 @@ const llmApi = { check: () => axios.post<{ ok: boolean; message: string }>('/api/llm/check').then((r) => r.data), - generate: (topic: string, count = 7) => + generate: (topic: string, count = 7, answersCount = 3) => axios - .post<{ questions: LLMQuestion[] }>('/api/llm/generate', { topic, count }) + .post<{ questions: LLMQuestion[] }>('/api/llm/generate', { + topic, + count, + answers_count: answersCount, + }) .then((r) => r.data), improve: (question: string, answers: string[]) => diff --git a/frontend/src/components/TestForm/index.tsx b/frontend/src/components/TestForm/index.tsx index 3493229..ee37675 100644 --- a/frontend/src/components/TestForm/index.tsx +++ b/frontend/src/components/TestForm/index.tsx @@ -15,12 +15,16 @@ import { Modal, Space, Switch, + Table, + Tag, Typography, notification, } from 'antd' +import type { ColumnsType } from 'antd/es/table' import { useState } from 'react' import llmApi, { LLMQuestion } from '../../api/llm' +import { TestListItem } from '../../api/tests' const { Title, Text, Paragraph } = Typography @@ -46,6 +50,11 @@ interface TestFormProps { onCancel: () => void onBack?: () => void backLabel?: string + versions?: TestListItem[] + currentVersionId?: number + onActivateVersion?: (id: number) => void + isActivating?: boolean + onOpenVersion?: (id: number) => void } export default function TestForm({ @@ -57,6 +66,11 @@ export default function TestForm({ onCancel, onBack, backLabel = 'Назад', + versions, + currentVersionId, + onActivateVersion, + isActivating, + onOpenVersion, }: TestFormProps) { const [form] = Form.useForm() const [notifApi, contextHolder] = notification.useNotification() @@ -65,6 +79,9 @@ export default function TestForm({ const [generateLoading, setGenerateLoading] = useState(false) const [previewOpen, setPreviewOpen] = useState(false) const [previewQuestions, setPreviewQuestions] = useState(null) + const [aiTopic, setAiTopic] = useState('') + const [aiQuestionsCount, setAiQuestionsCount] = useState(7) + const [aiAnswersCount, setAiAnswersCount] = useState(3) // Improve modal state const [improveState, setImproveState] = useState<{ @@ -114,13 +131,20 @@ export default function TestForm({ // ── AI: Генерация вопросов ────────────────────────────────────────────── const handleGenerate = async () => { - const title = form.getFieldValue('title') as string - if (!title?.trim()) return + const titleField = (form.getFieldValue('title') as string) || '' + const topic = (aiTopic.trim() || titleField.trim()) + if (!topic) { + notifApi.warning({ + message: 'Укажите тему', + description: 'Заполните поле «Тема» или название теста в метаинформации', + }) + return + } setGenerateLoading(true) setPreviewQuestions(null) setPreviewOpen(true) try { - const data = await llmApi.generate(title.trim(), 7) + const data = await llmApi.generate(topic, aiQuestionsCount, aiAnswersCount) setPreviewQuestions(data.questions) } catch { notifApi.error({ message: 'Ошибка AI', description: 'Не удалось сгенерировать вопросы' }) @@ -241,7 +265,7 @@ export default function TestForm({ const data = await llmApi.review(values.title, values.questions || []) setReviewText(data.review) } catch { - setReviewText('Не удалось получить рекомендации. Проверьте API ключ в настройках.') + setReviewText('Не удалось получить рекомендации. Попробуйте позже или обратитесь к администратору.') } finally { setReviewLoading(false) } @@ -306,8 +330,8 @@ export default function TestForm({ {heading}
- {/* ── Основные настройки ── */} - + {/* ── Метаинформация ── */} + - {/* ── AI: кнопки ── */} - prev.title !== cur.title} - > - {({ getFieldValue }) => ( -
+ {/* ── Версии теста ── */} + {versions && versions.length > 0 && ( + + + dataSource={versions} + rowKey="id" + size="small" + pagination={false} + rowClassName={(record) => + record.id === currentVersionId ? 'ant-table-row-selected' : '' + } + columns={ + [ + { + title: 'Версия', + dataIndex: 'version', + width: 80, + render: (v: number) => v{v}, + }, + { + title: 'Статус', + dataIndex: 'is_active', + width: 130, + render: (active: boolean) => + active ? ( + Активная + ) : ( + Неактивная + ), + }, + { + title: 'Дата', + dataIndex: 'created_at', + width: 120, + render: (d: string) => new Date(d).toLocaleDateString('ru-RU'), + }, + { + title: 'Вопросов', + dataIndex: 'questions_count', + width: 100, + align: 'center' as const, + }, + { + title: 'Порог', + dataIndex: 'passing_score', + width: 90, + align: 'center' as const, + render: (s: number) => `${s}%`, + }, + { + title: '', + key: 'action', + render: (_: unknown, record: TestListItem) => ( + + {onOpenVersion && record.id !== currentVersionId && ( + + )} + {onActivateVersion && + (record.id !== currentVersionId || !record.is_active) && ( + + )} + + ), + }, + ] as ColumnsType + } + /> + + )} + + {/* ── Содержание ── */} + + {/* AI мини-форма для генерации структуры теста */} +
+ Сгенерировать вопросы с AI + + Заполните поля и получите готовую структуру вопросов и вариантов — без ручного «+ вопрос» / «+ вариант». + + +
+ + Тема (если пусто — берётся из названия) + + setAiTopic(e.target.value)} + style={{ width: 320 }} + /> +
+
+ + Вопросов + + setAiQuestionsCount(Number(v) || 7)} + style={{ width: 100 }} + /> +
+
+ + Вариантов + + setAiAnswersCount(Number(v) || 3)} + style={{ width: 100 }} + /> +
- -
- )} - + +
- {/* ── Вопросы ── */} - )} - + +
+ {/* ── Команды ── */} + diff --git a/frontend/src/pages/TestEdit/index.tsx b/frontend/src/pages/TestEdit/index.tsx index 962e1af..cf573b9 100644 --- a/frontend/src/pages/TestEdit/index.tsx +++ b/frontend/src/pages/TestEdit/index.tsx @@ -30,7 +30,6 @@ export default function TestEdit() { const { data: versions = [] } = useQuery({ queryKey: ['tests', id, 'versions'], queryFn: () => testsApi.versions(Number(id)).then((r) => r.data), - enabled: !editMode, }) const { mutate: activateVersion, isPending: isActivating } = useMutation({ @@ -105,6 +104,11 @@ export default function TestEdit() { onCancel={() => setEditMode(false)} onBack={() => setEditMode(false)} backLabel="К просмотру теста" + versions={versions} + currentVersionId={test.id} + onActivateVersion={activateVersion} + isActivating={isActivating} + onOpenVersion={(vid) => navigate(`/tests/${vid}/edit`)} /> ) }