From db9851eeda777131c537246bab83ca9c8506c547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=82=D0=B8?= =?UTF-8?q?=D0=BD=20=D0=9B=D0=B5=D0=B1=D0=B5=D0=B4=D0=B8=D0=BD=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=B9?= Date: Thu, 30 Apr 2026 14:11:15 +0500 Subject: [PATCH] bugfix --- docs/bugfix/SPRINTS_BUGFIX_TESTS_NAV_AI.md | 220 +++++++++++++++++++++ flask_app/app/services/ai_editor.py | 30 ++- flask_app/app/services/document_gen.py | 19 +- flask_app/app/services/draft_validator.py | 62 ++++++ flask_app/app/services/test_attempt.py | 27 ++- flask_app/app/static/js/editor.js | 158 +++++++++++++-- flask_app/app/templates/tests/editor.html | 26 ++- flask_app/app/tests/routes.py | 9 +- 8 files changed, 527 insertions(+), 24 deletions(-) create mode 100644 docs/bugfix/SPRINTS_BUGFIX_TESTS_NAV_AI.md diff --git a/docs/bugfix/SPRINTS_BUGFIX_TESTS_NAV_AI.md b/docs/bugfix/SPRINTS_BUGFIX_TESTS_NAV_AI.md new file mode 100644 index 0000000..286368e --- /dev/null +++ b/docs/bugfix/SPRINTS_BUGFIX_TESTS_NAV_AI.md @@ -0,0 +1,220 @@ +# Спринты по bugfix-задачам (Тесты / Доступ / Генерация) + +Дата: 2026-04-30 +Контур: `flask_app` (UI + API + сервисы генерации) + +## Цели пакета + +1. Дать доступ всем авторизованным пользователям ко всем активным тестам (без назначений). +2. Привести поведение шаблона генерации теста к ожидаемому (кол-во вопросов/вариантов/правильных ответов). +3. Добавить массовые настройки «несколько вариантов ответа». +4. Улучшить поток работы с подсказками и прозрачность прогресса генерации. + +--- + +## Спринт 1 — Доступ всем авторизованным (без назначений) + +### Объём + +- Убрать зависимость прохождения теста от назначений (`TestAssignment*`). +- Разрешить доступ к активным тестам для любого авторизованного пользователя. +- Проверить, что каталог и старт попытки работают консистентно с новой политикой. + +### Задачи + +1. **Политика доступа** + - В `user_has_test_access` вернуть `ok=True` для любого активного теста, если тест существует и пользователь авторизован. + - Оставить проверки авторства только там, где они нужны для редактора/версий/админских действий. + +2. **Проверка API прохождения** + - `start_attempt`, `play`, `submit`, `review` не должны требовать назначения на пользователя. + - Ошибка «Доступ запрещён» не возникает для обычного авторизованного сотрудника при прохождении активного теста. + +### Критерии приёмки + +- Любой авторизованный пользователь может открыть и пройти любой активный тест, даже без назначения. +- При этом операции автора (редактор, версии, массовые действия) остаются ограничены автором. + +### Оценка + +- 0.5 дня. + +--- + +## Спринт 2 — Шаблон генерации: контракт и предсказуемость + +### Проблема + +Сейчас пользователь создаёт шаблон (например, 12×4), затем генерирует из документа и получает иной результат (например, 10×3). Ожидания и фактический контракт не совпадают. + +### Объём + +- Зафиксировать единый контракт: какие параметры шаблона обязательны для генератора. +- Принудительно соблюдать: + - количество вопросов, + - количество вариантов в вопросе, + - границы количества правильных ответов. +- Добавить валидацию и пост-проверку результата генерации. + +### Задачи + +1. **Формальный контракт шаблона** + - Явно определить обязательные поля shape: + - `questions_count` + - `options_per_question` + - `multiple_answers_default` + - `correct_answers_min/max` + - Хранить shape вместе с тестом/версией как источник истины. + +2. **Генерация по документу с shape** + - Передавать shape в генератор при `generate from document`. + - После генерации валидировать фактическую структуру. + - При расхождении: + - либо автоматически нормализовать (добить/сжать до нужного формата), + - либо показать понятную ошибку и не сохранять черновик. + +3. **Пользовательская обратная связь** + - На экране до запуска показывать «Будет сгенерировано: 12 вопросов, 4 варианта». + - После завершения показывать фактический итог и предупреждение, если пришлось авто-нормализовать. + +### Критерии приёмки + +- Для шаблона 12×4 итоговый тест всегда 12 вопросов по 4 варианта. +- Несоответствие не проходит «тихо»: либо авто-исправление с уведомлением, либо явная ошибка. + +### Оценка + +- 1.5–2.5 дня. + +--- + +## Спринт 3 — Массовый контроль «Несколько вариантов ответа» + +### Объём + +- Добавить глобальный чекбокс «Несколько вариантов ответа» (для всех вопросов). +- В шаблоне добавить диапазон правильных ответов: `от _ до _`. +- При отключении мультивыбора на конкретном вопросе нижняя граница «от» фиксируется в `1`. + +### Задачи + +1. **UI шаблона** + - Глобальный switch/checkbox: «Несколько вариантов ответа для всех вопросов». + - Поля диапазона: + - `Мин. правильных` (от), + - `Макс. правильных` (до), + - валидация `1 <= min <= max <= options_per_question`. + +2. **Применение к вопросам** + - При включении глобального флага обновлять все вопросы: + - `hasMultipleAnswers=true`. + - При выключении: + - `hasMultipleAnswers=false`, + - `minCorrect=1`, `maxCorrect=1`. + - На уровне отдельного вопроса разрешить override. + +3. **Правило «заморозки min=1»** + - Если на вопросе `hasMultipleAnswers=false`, то: + - `minCorrect` автоматически = `1`, + - поле `minCorrect` read-only/disabled. + +4. **Серверная валидация** + - API сохраняет/проверяет тот же инвариант. + - Невалидные комбинации отклоняются с понятным сообщением. + +### Критерии приёмки + +- Глобальный флаг влияет на все вопросы. +- Локальное отключение мультивыбора фиксирует `min=1`. +- Генератор и редактор работают в одинаковой логике, без рассинхрона. + +### Оценка + +- 1.5–2 дня. + +--- + +## Спринт 4 — Подсказки и прогресс генерации + +### Объём + +- В параметрах теста при включённых подсказках показать действие «Сгенерировать подсказки». +- Генерация подсказок работает только по заполненным вопросам. +- Если тест генерируется с нуля и подсказки уже включены — подсказки генерируются в том же пайплайне. +- Добавить прогресс по этапам генерации + локальные индикаторы загрузки в соответствующих блоках UI. + +### Задачи + +1. **Кнопка/ссылка «Сгенерировать подсказки»** + - Показ только при `hintsEnabled=true`. + - Показ количества: `N без подсказок`. + - Запуск только по вопросам, где есть текст + варианты. + +2. **Пайплайн генерации «с нуля»** + - Если `hintsEnabled=true`, после генерации вопросов автоматически запускать генерацию подсказок. + - Ошибки подсказок не должны ломать весь тест: частичный результат допустим с отчётом. + +3. **Прогресс и статусы** + - Этапы: + - подготовка документа, + - извлечение текста, + - генерация структуры, + - генерация вопросов, + - генерация подсказок, + - финализация. + - В UI показывать текущий этап и процент/счётчик. + - Спиннеры показывать только у активного блока (не глобально на всю форму). + +4. **Наблюдаемость** + - Логи этапов и длительности. + - В ответ API возвращать breakdown по шагам/ошибкам. + +### Критерии приёмки + +- Пользователь видит, что именно сейчас генерируется. +- Подсказки отдельно запускаются и генерируются только для валидных вопросов. +- При генерации «с нуля» с включёнными подсказками подсказки появляются автоматически. + +### Оценка + +- 2–3 дня. + +--- + +## Спринт 5 — Регрессия, UX-полировка, выпуск + +### Объём + +- Сквозное тестирование сценариев. +- Документация для пользователей и команды. +- Подготовка релиз-нота. + +### Тест-кейсы (минимум) + +1. Неавторизованный пользователь открывает приватные URL → редирект на логин. +2. Каталог тестов: есть переход «На главную». +3. Шаблон 12×4 + генерация из PDF → на выходе 12×4. +4. Глобальный мультивыбор + локальное выключение на 1 вопросе → `min=1` на этом вопросе. +5. Включены подсказки: + - кнопка «Сгенерировать подсказки» доступна, + - генерируются только по заполненным вопросам. +6. Генерация с нуля + подсказки включены → подсказки сгенерированы в том же запуске. +7. Прогресс этапов отображается корректно, загрузка локальная. + +### Оценка + +- 1–1.5 дня. + +--- + +## Приоритеты + +- **P0:** Спринт 1 (доступ всем авторизованным), критичный функциональный bugfix. +- **P1:** Спринт 2 (контракт шаблона), устранение основного функционального несоответствия. +- **P1:** Спринт 3 (массовый мультивыбор), важная продуктовая логика. +- **P2:** Спринт 4 (подсказки + прогресс), прозрачность и удобство. +- **P2:** Спринт 5 (регрессия + выпуск). + +## Суммарная оценка + +Ориентир: **6.5–10 рабочих дней** (в зависимости от объёма автотестов и глубины рефакторинга генератора). diff --git a/flask_app/app/services/ai_editor.py b/flask_app/app/services/ai_editor.py index 1254109..2abc957 100644 --- a/flask_app/app/services/ai_editor.py +++ b/flask_app/app/services/ai_editor.py @@ -33,7 +33,30 @@ def parse_and_validate_shape(s: Any) -> list[dict]: raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.') if n < 2 or n > 12: raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.') - out.append({'optionsCount': n, 'hasMultipleAnswers': bool(row.get('hasMultipleAnswers'))}) + has_multi = bool(row.get('hasMultipleAnswers')) + raw_min = row.get('minCorrect', 1) + raw_max = row.get('maxCorrect', n if has_multi else 1) + try: + min_c = int(float(raw_min)) + max_c = int(float(raw_max)) + except (TypeError, ValueError): + raise HttpError(400, f'shape[{i}]: minCorrect/maxCorrect должны быть числами.') + if not has_multi: + min_c = 1 + max_c = 1 + if min_c < 1 or max_c < 1 or min_c > max_c or max_c > n: + raise HttpError( + 400, + f'shape[{i}]: корректный диапазон правильных ответов — от 1 до {n}, min<=max.', + ) + out.append( + { + 'optionsCount': n, + 'hasMultipleAnswers': has_multi, + 'minCorrect': min_c, + 'maxCorrect': max_c, + } + ) return out @@ -51,7 +74,10 @@ def generate_full_test_by_shape(test_title: str, test_description: str, shape: l lines = [] for i, sh in enumerate(shape): if sh['hasMultipleAnswers']: - tail = 'несколько вариантов помечены как верные (hasMultipleAnswers: true).' + tail = ( + 'несколько вариантов помечены как верные (hasMultipleAnswers: true), ' + f'число правильных от {sh["minCorrect"]} до {sh["maxCorrect"]}.' + ) else: tail = 'ровно один верный вариант (hasMultipleAnswers: false).' lines.append(f'Вопрос {i + 1}: ровно {sh["optionsCount"]} вариантов ответа; {tail}') diff --git a/flask_app/app/services/document_gen.py b/flask_app/app/services/document_gen.py index 192a375..8d8e260 100644 --- a/flask_app/app/services/document_gen.py +++ b/flask_app/app/services/document_gen.py @@ -2,6 +2,7 @@ from __future__ import annotations from .draft_validator import ( + normalize_draft_to_shape, parse_json_from_llm_text, validate_and_normalize_draft, ) @@ -11,7 +12,7 @@ from .llm_client import LlmError, chat_completion_text_content, get_llm_config MAX_EXTRACT_CHARS = 14000 -def generation_for_import_document(extracted_text: str, user_hint: str = '') -> dict: +def generation_for_import_document(extracted_text: str, user_hint: str = '', shape: list[dict] | None = None) -> dict: text = (extracted_text or '').strip() if not text: return { @@ -42,13 +43,27 @@ def generation_for_import_document(extracted_text: str, user_hint: str = '') -> 'Текст и формулировки — на русском, по содержанию входного материала.' ) hint_block = f'\n\nДополнительные инструкции от автора теста:\n{user_hint.strip()}' if user_hint and user_hint.strip() else '' + shape_block = '' + if shape: + rows = [] + for i, sh in enumerate(shape): + if sh.get('hasMultipleAnswers'): + rows.append( + f'- Вопрос {i + 1}: ровно {sh["optionsCount"]} вариантов, ' + f'правильных от {sh.get("minCorrect", 1)} до {sh.get("maxCorrect", sh["optionsCount"])}.' + ) + else: + rows.append(f'- Вопрос {i + 1}: ровно {sh["optionsCount"]} вариантов, ровно 1 правильный.') + shape_block = '\n\nСтрого соблюди шаблон:\n' + '\n'.join(rows) user = ( 'Составь тест с вопросами с одним или несколькими правильными ответами ' - 'на основе текста:\n\n' + slice_ + hint_block + 'на основе текста:\n\n' + slice_ + hint_block + shape_block ) raw = chat_completion_text_content(cfg, system, user, 0.25) parsed = parse_json_from_llm_text(raw) draft = validate_and_normalize_draft(parsed) + if shape: + draft = normalize_draft_to_shape(draft, shape) return { 'available': True, 'message': ( diff --git a/flask_app/app/services/draft_validator.py b/flask_app/app/services/draft_validator.py index a8a6254..dd3b20a 100644 --- a/flask_app/app/services/draft_validator.py +++ b/flask_app/app/services/draft_validator.py @@ -103,3 +103,65 @@ def assert_draft_matches_shape(o: dict, shape: list[dict]) -> None: f'Вопрос {i + 1}: hasMultipleAnswers должен быть {sh["hasMultipleAnswers"]}.', code='llm_shape', ) + min_c = int(sh.get('minCorrect', 1)) + max_c = int(sh.get('maxCorrect', sh['optionsCount'])) + correct_n = sum(1 for op in opts if bool(op.get('isCorrect'))) + if correct_n < min_c or correct_n > max_c: + raise LlmError( + f'Вопрос {i + 1}: правильных ответов должно быть от {min_c} до {max_c}, в ответе: {correct_n}.', + code='llm_shape', + ) + + +def normalize_draft_to_shape(draft: dict, shape: list[dict]) -> dict: + """Приводит draft к shape: число вопросов/вариантов/мульти и диапазон correct.""" + qs = list((draft or {}).get('questions') or []) + out_qs = [] + + def _mk_option(i: int) -> dict: + return {'text': f'Вариант {i + 1}', 'isCorrect': False} + + for i, sh in enumerate(shape): + src = qs[i] if i < len(qs) and isinstance(qs[i], dict) else {} + text = str(src.get('text') or '').strip() or f'Вопрос {i + 1}' + has_multi = bool(sh.get('hasMultipleAnswers')) + min_c = int(sh.get('minCorrect', 1)) + max_c = int(sh.get('maxCorrect', sh['optionsCount'])) + if not has_multi: + min_c = 1 + max_c = 1 + + raw_opts = src.get('options') if isinstance(src.get('options'), list) else [] + opts = [] + for j in range(sh['optionsCount']): + if j < len(raw_opts) and isinstance(raw_opts[j], dict): + t = str(raw_opts[j].get('text') or '').strip() or f'Вариант {j + 1}' + opts.append({'text': t, 'isCorrect': bool(raw_opts[j].get('isCorrect'))}) + else: + opts.append(_mk_option(j)) + + true_idx = [idx for idx, op in enumerate(opts) if op['isCorrect']] + if not has_multi: + keep = true_idx[0] if true_idx else 0 + for idx, op in enumerate(opts): + op['isCorrect'] = (idx == keep) + else: + if len(true_idx) < min_c: + for idx in range(len(opts)): + if idx not in true_idx: + opts[idx]['isCorrect'] = True + true_idx.append(idx) + if len(true_idx) >= min_c: + break + if len(true_idx) > max_c: + keep = set(true_idx[:max_c]) + for idx, op in enumerate(opts): + op['isCorrect'] = idx in keep + + out_qs.append({'text': text, 'hasMultipleAnswers': has_multi, 'options': opts}) + + return { + 'title': str((draft or {}).get('title') or '').strip() or 'Тест', + 'description': (draft or {}).get('description'), + 'questions': out_qs, + } diff --git a/flask_app/app/services/test_attempt.py b/flask_app/app/services/test_attempt.py index 782a432..1a79e5b 100644 --- a/flask_app/app/services/test_attempt.py +++ b/flask_app/app/services/test_attempt.py @@ -364,9 +364,18 @@ def count_missing_hints(session_or_eng, test_id: str) -> dict: if not active_version: return {'total': 0, 'missing': 0} - all_qs = session.query(Question).filter(Question.test_version_id == active_version.id).all() - total = len(all_qs) - missing = sum(1 for q in all_qs if not q.ai_hint) + all_qs = session.query(Question).options(selectinload(Question.options)).filter( + Question.test_version_id == active_version.id + ).all() + filled_qs = [] + for q in all_qs: + if not (q.text or '').strip(): + continue + if len([o for o in q.options if (o.text or '').strip()]) < 2: + continue + filled_qs.append(q) + total = len(filled_qs) + missing = sum(1 for q in filled_qs if not q.ai_hint) return {'total': total, 'missing': missing} @@ -400,8 +409,16 @@ def generate_missing_hints_for_test(session_or_eng, author_id: str, test_id: str .all() ) - generated = failed = 0 + generated = failed = skipped = 0 for q in missing_qs: + # Подсказку строим только по заполненному вопросу (есть текст и >=2 непустых варианта). + if not (q.text or '').strip(): + skipped += 1 + continue + valid_opts = [o for o in q.options if (o.text or '').strip()] + if len(valid_opts) < 2: + skipped += 1 + continue opt_payload = [{'text': o.text, 'isCorrect': bool(o.is_correct)} for o in q.options] hint = generate_question_hint(question_text=q.text, options=opt_payload) if hint: @@ -410,7 +427,7 @@ def generate_missing_hints_for_test(session_or_eng, author_id: str, test_id: str else: failed += 1 session.commit() - return {'generated': generated, 'failed': failed, 'total': len(missing_qs)} + return {'generated': generated, 'failed': failed, 'skipped': skipped, 'total': len(missing_qs)} def check_question_for_attempt(session_or_eng, user_id: str, test_id: str, attempt_id: str, diff --git a/flask_app/app/static/js/editor.js b/flask_app/app/static/js/editor.js index 80eccb5..87f8e00 100644 --- a/flask_app/app/static/js/editor.js +++ b/flask_app/app/static/js/editor.js @@ -31,6 +31,13 @@ const aiTopicEl = $('#ai-topic'); const aiQCountEl = $('#ai-q-count'); const aiOCountEl = $('#ai-o-count'); + const templateGlobalMultiEl = $('#template-global-multi'); + const templateMinCorrectEl = $('#template-min-correct'); + const templateMaxCorrectEl = $('#template-max-correct'); + const hintsStatusEl = $('#hints-status'); + const hintsActionsEl = $('#test-hints-actions'); + const generateHintsBtn = $('#btn-generate-hints'); + const docProgressEl = $('#doc-progress'); const introUpdatedEl = $('#intro-updated'); const introForkBannerEl = $('#intro-fork-banner'); const versionsListEl = $('#versions-list'); @@ -56,6 +63,34 @@ let baselineDraftKey = ''; let dirtyCheckQueued = false; + function getTemplateCorrectRange(optionsCount, hasMultipleAnswers) { + const maxOpt = Math.max(2, Number(optionsCount || 2)); + const rawMin = Math.max(1, Number(templateMinCorrectEl?.value || 1) || 1); + const rawMax = Math.max(1, Number(templateMaxCorrectEl?.value || 1) || 1); + if (!hasMultipleAnswers) return { minCorrect: 1, maxCorrect: 1 }; + const minCorrect = Math.min(maxOpt, rawMin); + const maxCorrect = Math.max(minCorrect, Math.min(maxOpt, rawMax)); + return { minCorrect, maxCorrect }; + } + + function syncTemplateRangeUi() { + const hasMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked); + const maxOpt = Math.min(MAX_OPTIONS, Math.max(2, Number(aiOCountEl?.value || 3) || 3)); + const range = getTemplateCorrectRange(maxOpt, hasMulti); + if (templateMinCorrectEl) { + templateMinCorrectEl.min = '1'; + templateMinCorrectEl.max = String(maxOpt); + templateMinCorrectEl.value = String(range.minCorrect); + templateMinCorrectEl.disabled = !hasMulti; + } + if (templateMaxCorrectEl) { + templateMaxCorrectEl.min = '1'; + templateMaxCorrectEl.max = String(maxOpt); + templateMaxCorrectEl.value = String(range.maxCorrect); + templateMaxCorrectEl.disabled = !hasMulti; + } + } + function currentDraftKey() { return JSON.stringify(collectPayload()); } @@ -379,15 +414,22 @@ const mode = document.querySelector('input[name="result-mode"]:checked'); const isImmediate = mode && mode.value === 'immediate'; if (hintsRow) hintsRow.style.display = isImmediate ? '' : 'none'; + if (hintsActionsEl) hintsActionsEl.style.display = (isImmediate && hintsEl && hintsEl.checked) ? '' : 'none'; if (hintsEl && !isImmediate) hintsEl.checked = false; scheduleDirtyCheck(); }); }); if (hintsEl) { hintsEl.checked = !!initial.test.hintsEnabled; - hintsEl.addEventListener('change', scheduleDirtyCheck); + hintsEl.addEventListener('change', () => { + const mode = document.querySelector('input[name="result-mode"]:checked'); + const isImmediate = mode && mode.value === 'immediate'; + if (hintsActionsEl) hintsActionsEl.style.display = (isImmediate && hintsEl.checked) ? '' : 'none'; + scheduleDirtyCheck(); + }); } if (hintsRow) hintsRow.style.display = (initMode === 'immediate') ? '' : 'none'; + if (hintsActionsEl) hintsActionsEl.style.display = (initMode === 'immediate' && hintsEl && hintsEl.checked) ? '' : 'none'; questionsEl.innerHTML = ''; (initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); @@ -452,10 +494,29 @@ } function collectShape() { - return $$('#questions .q-item').map((li) => ({ - optionsCount: Math.max(2, $$('.opt-item', li).length || 4), - hasMultipleAnswers: $('.q-multi', li).checked, - })); + return $$('#questions .q-item').map((li) => { + const optionsCount = Math.max(2, $$('.opt-item', li).length || 4); + const hasMultipleAnswers = $('.q-multi', li).checked; + const range = getTemplateCorrectRange(optionsCount, hasMultipleAnswers); + return { + optionsCount, + hasMultipleAnswers, + minCorrect: hasMultipleAnswers ? range.minCorrect : 1, + maxCorrect: hasMultipleAnswers ? range.maxCorrect : 1, + }; + }); + } + + async function saveCurrentDraftQuietly() { + const r = await fetch(`/api/tests/${TEST_ID}/draft`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(collectPayload()), + }); + const data = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(data.error || 'Не удалось сохранить черновик перед генерацией подсказок.'); + resetBaselineDraft(); + return data; } // ─── actions ─────────────────────────────────────────────────────── @@ -520,9 +581,10 @@ if (saveModal) saveModal.showModal(); return; } + const skipped = Number(gd.skipped || 0); const tail = gd.failed - ? ` Подсказки: ${gd.generated} создано, ${gd.failed} не удалось.` - : ` Подсказки созданы (${gd.generated}).`; + ? ` Подсказки: ${gd.generated} создано, ${gd.failed} не удалось${skipped ? `, пропущено ${skipped}` : ''}.` + : ` Подсказки созданы (${gd.generated})${skipped ? `, пропущено ${skipped}` : ''}.`; if (saveMsg) saveMsg.textContent = msg + tail; } else { if (saveMsg) saveMsg.textContent = msg; @@ -561,11 +623,15 @@ } const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7)); const nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3)); + const globalMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked); + const globalRange = getTemplateCorrectRange(nO, globalMulti); const shape = Array.from({ length: nQ }, () => ({ optionsCount: nO, - hasMultipleAnswers: false, + hasMultipleAnswers: globalMulti, + minCorrect: globalMulti ? globalRange.minCorrect : 1, + maxCorrect: globalMulti ? globalRange.maxCorrect : 1, })); - aiStatusEl.textContent = 'Генерируем…'; + aiStatusEl.textContent = 'Генерируем структуру и вопросы…'; try { const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, { method: 'POST', @@ -589,6 +655,25 @@ renumber(); scheduleDirtyCheck(); aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`; + const hintsEl = document.getElementById('test-hints-enabled'); + const modeEl = document.querySelector('input[name="result-mode"]:checked'); + if (hintsEl && hintsEl.checked && modeEl && modeEl.value === 'immediate') { + aiStatusEl.textContent = 'Сохраняем черновик…'; + try { + await saveCurrentDraftQuietly(); + aiStatusEl.textContent = 'Генерируем вопросы… затем подсказки…'; + const hr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' }); + const hd = await hr.json().catch(() => ({})); + if (hr.ok) { + const skipped = Number(hd.skipped || 0); + aiStatusEl.textContent = skipped + ? `Готово: вопросы + подсказки (${hd.generated}, пропущено ${skipped}).` + : `Готово: вопросы + подсказки (${hd.generated}).`; + } + } catch (_) { + // Оставляем базовый статус готовности вопросов. + } + } setTimeout(() => (aiStatusEl.textContent = ''), 4000); } catch (e) { aiStatusEl.textContent = ''; @@ -662,16 +747,29 @@ docGenerateBtn.disabled = true; docGenerateBtn.textContent = 'Генерируем…'; aiStatusEl.textContent = 'Генерируем тест из документа…'; + if (docProgressEl) docProgressEl.textContent = 'Шаг 1/3: подготовка шаблона…'; try { + const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7)); + const nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3)); + const globalMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked); + const globalRange = getTemplateCorrectRange(nO, globalMulti); + const shape = Array.from({ length: nQ }, () => ({ + optionsCount: nO, + hasMultipleAnswers: globalMulti, + minCorrect: globalMulti ? globalRange.minCorrect : 1, + maxCorrect: globalMulti ? globalRange.maxCorrect : 1, + })); + if (docProgressEl) docProgressEl.textContent = 'Шаг 2/3: генерация вопросов…'; const r = await fetch('/api/tests/generate-from-extracted', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ extractedText: _extractedText, userHint }), + body: JSON.stringify({ extractedText: _extractedText, userHint, shape }), }); const data = await r.json(); if (!r.ok) throw new Error(data.error || 'Ошибка генерации.'); const g = data.generation || {}; aiStatusEl.textContent = ''; + if (docProgressEl) docProgressEl.textContent = 'Шаг 3/3: подготовка к применению…'; if (!g.available) { openImportModal( @@ -741,6 +839,7 @@ docGenerateBtn.disabled = false; docGenerateBtn.innerHTML = 'auto_awesome Сгенерировать из документа'; } + if (docProgressEl) setTimeout(() => { docProgressEl.textContent = ''; }, 2500); } } @@ -1393,6 +1492,8 @@ createTemplateBtn.addEventListener('click', () => { const qCount = Math.min(30, Math.max(1, parseInt($('#ai-q-count').value || '7', 10))); const oCount = Math.min(MAX_OPTIONS, Math.max(2, parseInt($('#ai-o-count').value || '3', 10))); + const globalMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked); + const range = getTemplateCorrectRange(oCount, globalMulti); const existing = $$('#questions .q-item').length; if (existing > 0) { const ok = confirm( @@ -1405,9 +1506,9 @@ for (let qi = 0; qi < qCount; qi++) { const opts = []; for (let oi = 0; oi < oCount; oi++) { - opts.push({ text: '', isCorrect: oi === 0 }); + opts.push({ text: '', isCorrect: oi < range.minCorrect }); } - questionsEl.appendChild(renderQuestion({ text: '', hasMultipleAnswers: false, options: opts })); + questionsEl.appendChild(renderQuestion({ text: '', hasMultipleAnswers: globalMulti, options: opts })); } renumber(); scheduleDirtyCheck(); @@ -1416,6 +1517,39 @@ }); } + async function generateHintsForCurrentTest() { + if (!generateHintsBtn) return; + generateHintsBtn.disabled = true; + if (hintsStatusEl) hintsStatusEl.textContent = 'Сохраняем текущие изменения…'; + try { + await saveCurrentDraftQuietly(); + if (hintsStatusEl) hintsStatusEl.textContent = 'Генерируем подсказки…'; + const r = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' }); + const data = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(data.error || 'Не удалось сгенерировать подсказки.'); + const skipped = Number(data.skipped || 0); + if (hintsStatusEl) { + hintsStatusEl.textContent = data.failed + ? `Создано ${data.generated}, ошибок ${data.failed}${skipped ? `, пропущено ${skipped}` : ''}.` + : `Подсказки созданы: ${data.generated}${skipped ? `, пропущено ${skipped}` : ''}.`; + } + } catch (e) { + if (hintsStatusEl) hintsStatusEl.textContent = e.message || 'Ошибка генерации подсказок.'; + } finally { + generateHintsBtn.disabled = false; + } + } + + if (generateHintsBtn) { + generateHintsBtn.addEventListener('click', generateHintsForCurrentTest); + } + + if (templateGlobalMultiEl) templateGlobalMultiEl.addEventListener('change', syncTemplateRangeUi); + if (templateMinCorrectEl) templateMinCorrectEl.addEventListener('input', syncTemplateRangeUi); + if (templateMaxCorrectEl) templateMaxCorrectEl.addEventListener('input', syncTemplateRangeUi); + if (aiOCountEl) aiOCountEl.addEventListener('input', syncTemplateRangeUi); + syncTemplateRangeUi(); + Promise.all([ fetch(`/api/tests/${TEST_ID}/versions`).then((r) => r.json()).catch(() => null), fetch(`/api/tests/${TEST_ID}/attempts`).then((r) => r.json()).catch(() => null), diff --git a/flask_app/app/templates/tests/editor.html b/flask_app/app/templates/tests/editor.html index 42f050c..3d061fe 100644 --- a/flask_app/app/templates/tests/editor.html +++ b/flask_app/app/templates/tests/editor.html @@ -83,7 +83,7 @@ @@ -94,6 +94,10 @@ +
Видимость в каталоге @@ -137,6 +141,23 @@ Создать шаблон
+
+ + + +
{# ── Заполнить через ИИ по теме ──────────────────────────── #} @@ -197,6 +218,7 @@ auto_awesome Сгенерировать из документа +

{# ── Модалка результата импорта документа ─────────────────── #} @@ -356,7 +378,7 @@ placeholder="Краткий текст подсказки (если в параметрах теста включены подсказки и режим «Сразу после ответа»)" style="resize:none; overflow:hidden; font-family:inherit;"> -

Необязательно. Показывается участнику во всплывающем окне при верном ответе.

+

Необязательно. Показывается участнику во всплывающем окне после ответа на вопрос.

diff --git a/flask_app/app/tests/routes.py b/flask_app/app/tests/routes.py index 24b2019..6030c23 100644 --- a/flask_app/app/tests/routes.py +++ b/flask_app/app/tests/routes.py @@ -545,9 +545,16 @@ def api_generate_from_extracted(): body = request.get_json(silent=True) or {} extracted = (body.get('extractedText') or '').strip() user_hint = (body.get('userHint') or '').strip() + shape_raw = body.get('shape') if not extracted: return jsonify(error='Нет текста для генерации.'), 400 - generation = generation_for_import_document(extracted, user_hint=user_hint) + shape = None + if shape_raw: + try: + shape = parse_and_validate_shape(shape_raw) + except (AiHttpError, LlmError) as e: + return _ai_error_response(e) + generation = generation_for_import_document(extracted, user_hint=user_hint, shape=shape) return jsonify(generation=generation)