diff --git a/DOC/ШАГИ/ШАГ_2026-04-27_001.md b/DOC/ШАГИ/ШАГ_2026-04-27_001.md new file mode 100644 index 0000000..072b144 --- /dev/null +++ b/DOC/ШАГИ/ШАГ_2026-04-27_001.md @@ -0,0 +1,20 @@ +# Шаг 2026-04-27 — редизайн формы редактора теста (ветка `dev-redisign`) + +## Сделано + +- Создана ветка `dev-redisign` от `dev` в репозитории `TestingWebApp`. +- Страница автора `frontend/src/pages/TestDetail.jsx` приведена к структуре из `docs/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md` (адаптация под существующий React/JSX, без Ant Design): + - блок **«Метаинформация»** — название, описание, порог зачёта; + - блок **«Содержание»** — мини-панель ИИ (тема, число вопросов 1…30, число вариантов 2…8, кнопка генерации) и список вопросов с локальными кнопками ИИ; + - панель **«Команды»** — «Сохранить черновик» (основная), «К списку»; строка статуса черновика под панелью. +- Кнопка **«Сгенерировать тест (ИИ)»** убрана из шапки; генерация строит `shape` из введённых чисел, тема — из поля «Тема» с запасным вариантом на «Название»; после ответа API варианты в каждом вопросе нормализуются к выбранному числу (добор/обрезка, минимум один верный). +- Копирование темы при загрузке редактора и при применении импорта/черновика LLM (`setAiGenTopic` при `applyGeneratedDraft`). + +## Бэкенд + +- Менять не требовалось: `POST .../ai/generate-test` уже принимает `shape` с `optionsCount` (см. `backend/src/services/aiEditorService.js`). + +## Проверка + +- `npm run lint` и `npm run build` в `TestingWebApp/frontend` — без ошибок. +- Ручной прогон `docker compose` по чек-листу из предложения — остаётся на стороне исполнителя. diff --git a/docs/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md b/docs/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md new file mode 100644 index 0000000..cf0eab5 --- /dev/null +++ b/docs/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.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/frontend/src/pages/TestDetail.jsx b/frontend/src/pages/TestDetail.jsx index dbdeaa7..cb0ba00 100644 --- a/frontend/src/pages/TestDetail.jsx +++ b/frontend/src/pages/TestDetail.jsx @@ -85,6 +85,10 @@ export default function TestDetail() { const [importBusy, setImportBusy] = useState(false); const [aiTestBusy, setAiTestBusy] = useState(false); const [aiQBusy, setAiQBusy] = useState(null); + /** Параметры блока «Сгенерировать тест (ИИ)» (редизайн формы редактора) */ + const [aiGenTopic, setAiGenTopic] = useState(''); + const [aiQuestionsCount, setAiQuestionsCount] = useState(7); + const [aiAnswersCount, setAiAnswersCount] = useState(3); const [assignSearch, setAssignSearch] = useState(''); const [assignSearchApplied, setAssignSearchApplied] = useState(''); const [assignDept, setAssignDept] = useState('__all__'); @@ -119,6 +123,7 @@ export default function TestDetail() { setChain(c); if (ed?.test) { setDraftTitle(ed.test.title || ''); + setAiGenTopic((ed.test.title || '').trim()); setDraftDescription(ed.test.description || ''); const th = ed.test.passingThreshold; setDraftPassing( @@ -300,21 +305,41 @@ export default function TestDetail() { } } + function normalizeGeneratedQuestionOptions(q, targetCount) { + const n = Math.min(12, Math.max(2, targetCount)); + const raw = (q?.options || []).map((o) => ({ + key: newKey(), + text: (o.text || '').trim() || 'Вариант', + isCorrect: !!o.isCorrect, + })); + const out = raw.slice(0, n); + while (out.length < n) { + out.push({ key: newKey(), text: '', isCorrect: false }); + } + if (!out.some((o) => o.isCorrect)) { + out[0] = { ...out[0], isCorrect: true }; + } + return out; + } + async function runAiGenerateTest() { if (aiTestBusy || !id) { return; } + const nQ = Math.min(30, Math.max(1, Math.floor(Number(aiQuestionsCount)) || 7)); + const nA = Math.min(8, Math.max(2, Math.floor(Number(aiAnswersCount)) || 3)); + const topic = (aiGenTopic || draftTitle || '').trim() || 'Тест'; setDraftStatus(''); setAiTestBusy(true); try { - const shape = draftQuestions.map((q) => ({ - optionsCount: Math.max(2, Math.min(12, q.options?.length || 2)), - hasMultipleAnswers: q.hasMultipleAnswers, + const shape = Array.from({ length: nQ }, () => ({ + optionsCount: nA, + hasMultipleAnswers: false, })); const out = await api(`/api/tests/${id}/ai/generate-test`, { method: 'POST', body: JSON.stringify({ - testTitle: draftTitle, + testTitle: topic, testDescription: draftDescription, shape, }), @@ -330,11 +355,7 @@ export default function TestDetail() { key: newKey(), text: (q.text || '').trim() || 'Вопрос', hasMultipleAnswers: !!q.hasMultipleAnswers, - options: (q.options || []).map((o) => ({ - key: newKey(), - text: (o.text || '').trim() || 'Вариант', - isCorrect: !!o.isCorrect, - })), + options: normalizeGeneratedQuestionOptions(q, nA), })); if (qs.length) { setDraftQuestions(qs); @@ -449,6 +470,7 @@ export default function TestDetail() { return; } setDraftTitle((d.title || '').trim() || 'Без названия'); + setAiGenTopic((d.title || '').trim()); setDraftDescription( d.description != null && String(d.description).trim() ? String(d.description).trim() : '' ); @@ -615,21 +637,6 @@ export default function TestDetail() {

{formatTestAuthorLabel(user, test?.createdBy, test?.authorFullName)}

-
- -
- {test?.description && ( -

- {test.description} -

- )}

Обновлён: {fmtDt(test?.updatedAt || test?.createdAt)} {test?.chainActive === false && ( @@ -657,6 +664,296 @@ export default function TestDetail() { )} + +

+ + setDraftTitle(e.target.value)} + /> + +