diff --git a/flask_app/app/__init__.py b/flask_app/app/__init__.py index 5eb2f3d..d5103bd 100644 --- a/flask_app/app/__init__.py +++ b/flask_app/app/__init__.py @@ -11,6 +11,7 @@ import secrets from datetime import timedelta from flask import Flask, jsonify, render_template, request +from werkzeug.exceptions import RequestEntityTooLarge _ROLE_LABELS = { @@ -89,6 +90,12 @@ def create_app() -> Flask: 'format_role': _format_role, } + @app.errorhandler(RequestEntityTooLarge) + def _payload_too_large(_e): + if _is_api_path(): + return jsonify(error='Файл слишком большой (лимит загрузки на сервере).'), 413 + return ('Файл слишком большой.', 413, {'Content-Type': 'text/plain; charset=utf-8'}) + @app.errorhandler(404) def _not_found(_e): if _is_api_path(): diff --git a/flask_app/app/services/ai_editor.py b/flask_app/app/services/ai_editor.py index b1ab4a2..3e3f4ff 100644 --- a/flask_app/app/services/ai_editor.py +++ b/flask_app/app/services/ai_editor.py @@ -225,11 +225,64 @@ def check_test_quality(test_title: str, test_description: str, questions: list[d return {'verdict': verdict, 'summary': summary, 'sections': sections} -def improve_test_full(test_title: str, test_description: str, questions: list[dict]) -> dict: +def _merge_suggested_by_focus( + focus: str, + orig: dict, + sug: dict, +) -> dict: + """Сужает предложение модели под выбранную область правки.""" + orig_opts = [ + {'text': str(o.get('text', '')).strip(), 'isCorrect': bool(o.get('isCorrect'))} + for o in (orig.get('options') or []) + ] + sug_opts = sug.get('options') or [] + hm = bool(orig.get('hasMultipleAnswers')) + ot = str(orig.get('text', '')).strip() + + if focus == 'questions': + return { + 'text': str(sug.get('text', '')).strip(), + 'hasMultipleAnswers': hm, + 'options': [dict(o) for o in orig_opts], + } + if focus == 'distractors': + merged = [] + for j, oo in enumerate(orig_opts): + so = sug_opts[j] if j < len(sug_opts) else oo + if oo['isCorrect']: + merged.append(dict(oo)) + else: + merged.append( + {'text': str(so.get('text', '')).strip(), 'isCorrect': False} + ) + return {'text': ot, 'hasMultipleAnswers': hm, 'options': merged} + if focus == 'options': + merged = [] + for j, oo in enumerate(orig_opts): + so = sug_opts[j] if j < len(sug_opts) else oo + merged.append( + { + 'text': str(so.get('text', '')).strip(), + 'isCorrect': oo['isCorrect'], + } + ) + return {'text': ot, 'hasMultipleAnswers': hm, 'options': merged} + return { + 'text': str(sug.get('text', '')).strip(), + 'hasMultipleAnswers': bool(sug.get('hasMultipleAnswers')), + 'options': [ + {'text': str(o.get('text', '')).strip(), 'isCorrect': bool(o.get('isCorrect'))} + for o in sug_opts + ], + } + + +def improve_test_full( + test_title: str, test_description: str, questions: list[dict], focus: str = 'all' +) -> dict: """AI-улучшение всего теста. Возвращает 'было → стало' для каждого вопроса. - Для каждого вопроса: original + suggested, флаги textChanged/optionsChanged. - UI решает, что применить (чекбоксы). + focus: all | questions | distractors | options """ cfg = _require_cfg() title = (test_title or '').strip() or 'Тест' @@ -238,14 +291,43 @@ def improve_test_full(test_title: str, test_description: str, questions: list[di if not qs: raise HttpError(400, 'В тесте нет вопросов — нечего улучшать.') - system = ( - 'Ты редактор учебных тестов. Получаешь массив вопросов и предлагаешь ' - 'улучшения: чёткие формулировки, лучшие дистракторы, корректную разметку ' - 'isCorrect. Сохраняй исходную сетку: число вопросов, число вариантов и ' - 'значение hasMultipleAnswers НЕ меняй — иначе клиент отклонит ответ. ' - 'Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", ' - '"options": [{"text", "isCorrect"}]}, ...]}. Тексты — на русском, короткие.' - ) + focus = (focus or 'all').strip().lower() + if focus not in ('all', 'questions', 'distractors', 'options'): + focus = 'all' + + system_by_focus = { + 'all': ( + 'Ты редактор учебных тестов. Получаешь массив вопросов и предлагаешь ' + 'улучшения: чёткие формулировки, лучшие дистракторы, корректную разметку ' + 'isCorrect. Сохраняй исходную сетку: число вопросов, число вариантов и ' + 'значение hasMultipleAnswers НЕ меняй — иначе клиент отклонит ответ. ' + 'Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", ' + '"options": [{"text", "isCorrect"}]}, ...]}. Тексты — на русском, короткие.' + ), + 'questions': ( + 'Ты редактор учебных тестов. Улучши ТОЛЬКО формулировки вопросов (поле text). ' + 'В ответе для каждого вопроса верни options ДОСЛОВНО как во входе ' + '(те же тексты и те же isCorrect), без правок. ' + 'Сетку не меняй. Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", ' + '"options": [{"text", "isCorrect"}]}, ...]}.' + ), + 'distractors': ( + 'Ты редактор учебных тестов. Улучши ТОЛЬКО неверные варианты (дистракторы), ' + 'где isCorrect: false. Верные варианты (isCorrect: true) верни ДОСЛОВНО как во входе. ' + 'Текст вопроса (text) не меняй — верни как во входе. hasMultipleAnswers не меняй. ' + 'Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", ' + '"options": [{"text", "isCorrect"}]}, ...]}.' + ), + 'options': ( + 'Ты редактор учебных тестов. Улучши формулировки всех вариантов ответа; ' + 'какой вариант верный, не меняй — isCorrect копируй из входа для каждого индекса. ' + 'Текст вопроса не меняй — верни как во входе. hasMultipleAnswers не меняй. ' + 'Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", ' + '"options": [{"text", "isCorrect"}]}, ...]}.' + ), + } + system = system_by_focus[focus] + test_dump = { 'title': title, 'description': desc, @@ -280,7 +362,9 @@ def improve_test_full(test_title: str, test_description: str, questions: list[di draft = validate_and_normalize_draft( {'title': title, 'questions': parsed.get('questions') or []} ) - suggested_qs = draft['questions'] + raw_suggested = draft['questions'] + + suggested_qs = [_merge_suggested_by_focus(focus, o, s) for o, s in zip(qs, raw_suggested)] items = [] for i, (orig, sug) in enumerate(zip(qs, suggested_qs)): @@ -316,7 +400,53 @@ def improve_test_full(test_title: str, test_description: str, questions: list[di } ) - return {'items': items} + return {'items': items, 'focus': focus} + + +def improve_single_option_text( + test_title: str, + test_description: str, + question_text: str, + options: list[dict], + option_index: int, +) -> dict: + """Улучшает формулировку одного варианта ответа, не меняя роль (верный/неверный).""" + cfg = _require_cfg() + opts = options or [] + try: + idx = int(option_index) + except (TypeError, ValueError): + raise HttpError(400, 'Укажите целочисленный optionIndex.') + if idx < 0 or idx >= len(opts): + raise HttpError(400, 'Некорректный индекс варианта.') + + topic = (((test_title or '').strip() or 'Тест') + '. ' + (test_description or '').strip()).strip() + qt = (question_text or '').strip() + lines = [] + for j, o in enumerate(opts): + mark = ' ← улучшить этот вариант' if j == idx else '' + oc = 'верный' if o.get('isCorrect') else 'неверный' + lines.append(f'{j + 1}. ({oc}) {o.get("text", "")}{mark}') + + system = ( + 'Ты редактор учебных тестов. Отвечай ТОЛЬКО JSON: {"text": string} — ' + 'улучшенная формулировка ОДНОГО варианта ответа. Не меняй статус верности: ' + 'если вариант был верным, формулировка остаётся верным ответом по смыслу; ' + 'если неверным — остаётся неверным дистрактором. Без лишних слов.' + ) + user = ( + f'Тема теста: {topic}\n\n' + f'Вопрос: {qt or "—"}\n\n' + 'Варианты:\n' + + '\n'.join(lines) + + f'\n\nПерепиши только вариант №{idx + 1}, короче и яснее, на русском.' + ) + raw = chat_completion_text_content(cfg, system, user, 0.25) + parsed = parse_json_from_llm_text(raw) + text = str((parsed or {}).get('text') or '').strip() + if not text: + raise LlmError('Пустой text в ответе модели.', code='llm_shape') + return {'optionIndex': idx, 'text': text} def generate_question_hint( @@ -403,45 +533,80 @@ def generate_or_rephrase_question( topic = (((test_title or '').strip() or 'Тест') + '. ' + (test_description or '').strip()).strip() qt = (question_text or '').strip() + mode = (mode or '').strip() if mode else '' # ── Режим дистракторов: есть вопрос + часть вариантов пуста ───────────── if qt and mode == 'distractors' and existing_options: filled = [o for o in existing_options if (o.get('text') or '').strip()] - empty_count = len([o for o in existing_options if not (o.get('text') or '').strip()] ) - if empty_count > 0: - filled_lines = '\n'.join( - f'- {"✓" if o.get("isCorrect") else "✗"} {o["text"]}' - for o in filled - ) or '(нет)' - system = ( - 'Ты составитель учебных тестов. Отвечай ТОЛЬКО JSON: ' - f'{{"options": [{{"text": string, "isCorrect": false}}, ...]}} — ' - f'ровно {empty_count} объекта в массиве. ' - 'Все тексты на русском, без нумерации, без кавычек.' - ) - user = ( - f'Тема теста: {topic}\n\n' - f'Вопрос: {qt}\n\n' - f'Уже заполненные варианты:\n{filled_lines}\n\n' - f'Придумай ровно {empty_count} правдоподобных, но НЕВЕРНЫХ дистракторов ' - f'(isCorrect: false), которые не повторяют уже существующие варианты ' - f'и выглядят похоже на реальные ответы.' - ) - raw = chat_completion_text_content(cfg, system, user, 0.45) - parsed = parse_json_from_llm_text(raw) - opts = [] - if isinstance(parsed, dict): - opts = parsed.get('options') or [] - elif isinstance(parsed, list): - opts = parsed - opts = [ - {'text': str(o.get('text') or '').strip(), 'isCorrect': False} - for o in opts if (o.get('text') or '').strip() - ][:empty_count] - return {'mode': 'distractors', 'text': qt, 'options': opts} - - # ── Режим улучшения: вопрос есть → только переформулировать текст ──────── - if qt: + empty_count = len([o for o in existing_options if not (o.get('text') or '').strip()]) + if empty_count <= 0: + raise HttpError(400, 'Нет пустых полей для дистракторов — добавьте пустые варианты или выберите другой режим.') + filled_lines = '\n'.join( + f'- {"✓" if o.get("isCorrect") else "✗"} {o["text"]}' + for o in filled + ) or '(нет)' + system = ( + 'Ты составитель учебных тестов. Отвечай ТОЛЬКО JSON: ' + f'{{"options": [{{"text": string, "isCorrect": false}}, ...]}} — ' + f'ровно {empty_count} объекта в массиве. ' + 'Все тексты на русском, без нумерации, без кавычек.' + ) + user = ( + f'Тема теста: {topic}\n\n' + f'Вопрос: {qt}\n\n' + f'Уже заполненные варианты:\n{filled_lines}\n\n' + f'Придумай ровно {empty_count} правдоподобных, но НЕВЕРНЫХ дистракторов ' + f'(isCorrect: false), которые не повторяют уже существующие варианты ' + f'и выглядят похоже на реальные ответы.' + ) + raw = chat_completion_text_content(cfg, system, user, 0.45) + parsed = parse_json_from_llm_text(raw) + opts = [] + if isinstance(parsed, dict): + opts = parsed.get('options') or [] + elif isinstance(parsed, list): + opts = parsed + opts = [ + {'text': str(o.get('text') or '').strip(), 'isCorrect': False} + for o in opts if (o.get('text') or '').strip() + ][:empty_count] + return {'mode': 'distractors', 'text': qt, 'options': opts} + + # ── Улучшить только формулировки вариантов (без смены верности) ────────── + if qt and mode == 'improve_options' and existing_options: + n_opts = len(existing_options) + if n_opts < 2: + raise HttpError(400, 'Нужно минимум два варианта ответа.') + dump_opts = [ + {'text': str(o.get('text', '')), 'isCorrect': bool(o.get('isCorrect'))} + for o in existing_options + ] + system = ( + 'Ты редактор учебных тестов. Отвечай ТОЛЬКО JSON: ' + '{"options": [{"text": string, "isCorrect": boolean}, ...]}. ' + 'Число элементов и значение isCorrect на каждом индексе должны совпадать с входом; ' + 'улучши только формулировки text — короче и яснее, на русском.' + ) + user = ( + f'Тема теста: {topic}\n\n' + f'Вопрос:\n{qt}\n\n' + f'Варианты (улучши только тексты, isCorrect не меняй):\n{_json.dumps(dump_opts, ensure_ascii=False)}' + ) + raw = chat_completion_text_content(cfg, system, user, 0.3) + parsed = parse_json_from_llm_text(raw) + raw_list = parsed.get('options') if isinstance(parsed, dict) else None + if not isinstance(raw_list, list) or len(raw_list) != n_opts: + raise LlmError('Неверный формат: ожидается options той же длины.', code='llm_shape') + out_opts = [] + for i in range(n_opts): + ro = raw_list[i] if i < len(raw_list) else {} + orig_t = dump_opts[i]['text'].strip() + t = str((ro or {}).get('text') or '').strip() or orig_t + out_opts.append({'text': t, 'isCorrect': dump_opts[i]['isCorrect']}) + return {'mode': 'improve_options', 'text': qt, 'options': out_opts} + + # ── Только переформулировать текст вопроса ──────────────────────────────── + if qt and mode == 'rephrase': system = ( 'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — ' 'чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости ' @@ -458,6 +623,12 @@ def generate_or_rephrase_question( raise LlmError('Пустой text в ответе модели.', code='llm_shape') return {'mode': 'rephrase', 'text': text} + if qt: + raise HttpError( + 400, + 'Укажите режим: distractors, improve_options или rephrase — или очистите текст вопроса для полной генерации.', + ) + # ── Полная генерация: вопрос пуст ──────────────────────────────────────── system = ( 'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", ' diff --git a/flask_app/app/services/document_extract.py b/flask_app/app/services/document_extract.py index e3b58d6..00d9847 100644 --- a/flask_app/app/services/document_extract.py +++ b/flask_app/app/services/document_extract.py @@ -27,7 +27,9 @@ SUPPORTED_EXT = { def resolve_document_kind(mimetype: str | None, original_name: str | None = '') -> Optional[str]: - m = (mimetype or '').lower() + # Браузеры часто шлют «text/plain; charset=utf-8» — без отсечения параметров ключ не совпадает. + raw = (mimetype or '').strip() + m = raw.split(';', 1)[0].strip().lower() n = (original_name or '').lower() if m in SUPPORTED_MIME: return SUPPORTED_MIME[m] diff --git a/flask_app/app/services/test_attempt.py b/flask_app/app/services/test_attempt.py index 6ff94e0..3e431cb 100644 --- a/flask_app/app/services/test_attempt.py +++ b/flask_app/app/services/test_attempt.py @@ -446,6 +446,70 @@ def generate_missing_hints_for_test(session_or_eng, author_id: str, test_id: str return {'generated': generated, 'failed': failed, 'skipped': skipped, 'total': len(missing_qs)} +def generate_next_missing_hint_for_test(session_or_eng, author_id: str, test_id: str) -> dict: + """Генерирует одну недостающую подсказку (для отображения прогресса в UI).""" + from .ai_editor import generate_question_hint + + session = get_session() + tid = _to_uuid(test_id) + + test = session.get(Test, tid) + if not test: + raise HttpError(404, 'Тест не найден.') + if not is_test_author(test.created_by, author_id) and not is_test_edit_open(): + raise HttpError(403, 'Доступ запрещён.') + + active_version = ( + session.query(TestVersion) + .filter(TestVersion.test_id == tid, TestVersion.is_active.is_(True)) + .first() + ) + if not active_version: + return {'generated': 0, 'remaining': 0, 'done': True, 'totalMissing': 0} + + before = count_missing_hints(None, test_id).get('missing') or 0 + + missing_qs = ( + session.query(Question) + .options(selectinload(Question.options)) + .filter( + Question.test_version_id == active_version.id, + (Question.ai_hint == None) | (Question.ai_hint == ''), # noqa: E711 + ) + .order_by(Question.question_order) + .all() + ) + + for q in missing_qs: + if not (q.text or '').strip(): + continue + valid_opts = [o for o in q.options if (o.text or '').strip()] + if len(valid_opts) < 2: + 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: + q.ai_hint = hint + session.commit() + after = count_missing_hints(None, test_id).get('missing') or 0 + return { + 'generated': 1, + 'remaining': after, + 'done': after == 0, + 'totalMissing': before, + 'failed': 0, + } + + after = count_missing_hints(None, test_id).get('missing') or 0 + return { + 'generated': 0, + 'remaining': after, + 'done': after == 0, + 'totalMissing': before, + 'failed': 0, + } + + def check_question_for_attempt(session_or_eng, user_id: str, test_id: str, attempt_id: str, question_id: str, selected_option_ids: list[str]) -> dict: session = get_session() diff --git a/flask_app/app/static/css/app.css b/flask_app/app/static/css/app.css index c929164..46957fd 100644 --- a/flask_app/app/static/css/app.css +++ b/flask_app/app/static/css/app.css @@ -81,6 +81,11 @@ h3 { /* Кабинетный UI (класс body.ui-legacy на корне). */ /* ------------------------------------------------------------------ */ +/* Высота ряда шапки: лого 2rem или блок кнопки (.btn padding + текст) — для fixed-тостов под header */ +body.ui-legacy { + --cabinet-header-row-h: max(2rem, calc(0.9375rem * 1.38 + 1.1rem + 3px)); +} + body.ui-legacy .max-w-2xl { max-width: 42rem !important; } @@ -733,12 +738,60 @@ body.ui-legacy .test-detail-subsection__title { padding: 1rem; margin-bottom: 0; } -.editor-generation-panel__status { - margin-top: 0.75rem; - margin-bottom: 0; +/* Уведомления ИИ: справа под шапкой; зазор под шапкой = боковой отступ (0.65rem) */ +.editor-gen-toast { + --editor-toast-inset: max(0.65rem, env(safe-area-inset-right, 0px)); + position: fixed; + right: var(--editor-toast-inset); + left: auto; + /* Низ шапки: padding-top + ряд + padding-bottom + border; затем зазор 0.65rem как у боковых inset тоста */ + top: calc( + max(0.75rem, env(safe-area-inset-top, 0px)) + var(--cabinet-header-row-h) + 0.75rem + 1px + 0.65rem + ); + z-index: 10060; + box-sizing: border-box; + max-width: min(28rem, calc(100vw - 1.5rem)); + min-height: 2.85rem; + padding: 0.45rem 0.75rem; + border-radius: 0.5rem; + font-size: 0.9375rem; + line-height: 1.35; + font-weight: 500; + box-shadow: 0 3px 18px rgba(15, 23, 42, 0.16); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + transform: translateX(calc(100% + 1.25rem)); + opacity: 0; + pointer-events: none; + transition: + transform 0.4s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.35s ease; } -.editor-generation-panel__status:empty { - display: none; +.editor-gen-toast.editor-gen-toast--open { + transform: translateX(0); + opacity: 1; + pointer-events: auto; +} +.editor-gen-toast[hidden] { + display: none !important; +} +.editor-gen-toast[data-variant='info'] { + background: #ffffff; + border: 1px solid var(--ink-200, #e5e7eb); + color: var(--ink-800, #1f2937); +} +.editor-gen-toast[data-variant='ok'] { + background: #ecfdf5; + border: 1px solid #6ee7b7; + color: #065f46; +} +.editor-gen-toast[data-variant='err'] { + background: #fef2f2; + border: 1px solid #fca5a5; + color: #991b1b; } /* ─── Option row alignment ───────────────────────────────────────── */ @@ -751,10 +804,19 @@ body.ui-legacy .test-detail-subsection__title { .opt-text { line-height: 1.55; } -.opt-delete { +.opt-delete, +.opt-ai { margin-top: 0.2rem; } +.opt-text-wrap { + min-height: 2.5rem; +} +.opt-item--ai-busy .opt-correct { + opacity: 0.45; + pointer-events: none; +} + /* ─── Question AI overlay ────────────────────────────────────────── */ .q-ai-overlay { transition: opacity 0.15s; @@ -783,7 +845,7 @@ body.ui-legacy .test-detail-subsection__title { border-style: solid; border-color: var(--primary, #007168) !important; background-color: color-mix(in srgb, var(--primary, #007168) 6%, transparent) !important; - pointer-events: none; + /* не pointer-events:none — иначе нельзя снова открыть выбор файла тем же кликом по зоне */ } @keyframes spin { to { transform: rotate(360deg); } } diff --git a/flask_app/app/static/js/editor.js b/flask_app/app/static/js/editor.js index c94235d..fdf5d75 100644 --- a/flask_app/app/static/js/editor.js +++ b/flask_app/app/static/js/editor.js @@ -25,7 +25,10 @@ const questionsEl = $('#questions'); const qCountEl = $('#q-count'); const saveStatusEl = $('#save-status'); - const aiStatusEl = $('#ai-status'); + const aiKeepTitleEl = $('#ai-keep-title'); + const aiImproveFocusEl = $('#ai-improve-focus'); + const aiImportClearBtn = $('#ai-import-clear'); + const addQuestionAiBtn = $('#add-question-ai'); const chainActiveEl = { checked: true, _val: true }; // display-only — реальный toggle в блоке «Показ в каталоге» const chainActiveDisplay = $('#chain-active-display'); const aiTopicEl = $('#ai-topic'); @@ -34,7 +37,6 @@ const templateGlobalMultiEl = $('#template-global-multi'); const templateMinCorrectEl = $('#template-min-correct'); const templateMaxCorrectEl = $('#template-max-correct'); - const docProgressEl = $('#doc-progress'); const introUpdatedEl = $('#intro-updated'); const introForkBannerEl = $('#intro-fork-banner'); const versionsListEl = $('#versions-list'); @@ -167,6 +169,16 @@ if (qNode) updateOptionsCounter(qNode); scheduleDirtyCheck(); }); + const optAiBtn = $('.opt-ai', node); + if (optAiBtn && qNode) { + optAiBtn.addEventListener('click', async (ev) => { + ev.preventDefault(); + const opts = $$('.opt-item', qNode); + const idx = opts.indexOf(node); + if (idx < 0) return; + await improveSingleOption(qNode, idx); + }); + } return node; } @@ -476,6 +488,92 @@ .replace(/"/g, '"').replace(/'/g, '''); } + /** Тост справа под шапкой: выезд справа, variant ok/err/info, авто-уезд по durationMs. */ + function hideToastAnimated(el) { + if (!el) return; + clearTimeout(showAiToast._timer); + clearTimeout(showAiToast._fallbackHide); + el.classList.remove('editor-gen-toast--open'); + let done = false; + const finish = () => { + if (done) return; + done = true; + clearTimeout(showAiToast._fallbackHide); + el.removeEventListener('transitionend', onEnd); + el.hidden = true; + el.textContent = ''; + el.removeAttribute('data-variant'); + }; + const onEnd = (ev) => { + if (ev.propertyName !== 'transform') return; + finish(); + }; + el.addEventListener('transitionend', onEnd); + showAiToast._fallbackHide = setTimeout(finish, 500); + } + + function showAiToast(message, variant = 'info', durationMs = 4800) { + const el = document.getElementById('editor-gen-toast'); + if (!el) return; + clearTimeout(showAiToast._timer); + clearTimeout(showAiToast._fallbackHide); + if (!message) { + if (!el.hidden) hideToastAnimated(el); + return; + } + if (el.classList.contains('editor-gen-toast--open') && !el.hidden) { + el.textContent = message; + el.dataset.variant = variant; + if (durationMs != null && durationMs > 0) { + showAiToast._timer = setTimeout(() => hideToastAnimated(el), durationMs); + } + return; + } + el.textContent = message; + el.dataset.variant = variant; + el.hidden = false; + el.classList.remove('editor-gen-toast--open'); + void el.offsetWidth; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + el.classList.add('editor-gen-toast--open'); + }); + }); + if (durationMs != null && durationMs > 0) { + showAiToast._timer = setTimeout(() => hideToastAnimated(el), durationMs); + } + } + + function formatEtaSeconds(sec) { + const s = Math.min(7200, Math.max(5, Math.round(sec))); + if (s < 60) return `примерно ${s} с`; + const m = Math.ceil(s / 60); + if (m === 1) return 'примерно 1 мин'; + return `примерно ${m} мин`; + } + + function etaFullGenerateSeconds(nQ) { + return Math.min(900, Math.max(25, 20 + nQ * 12)); + } + + async function runHintsProgressFromServer(startMissing) { + const total = Math.max(0, Number(startMissing) || 0); + if (!total) return; + let iter = 0; + const maxIter = Math.min(500, Math.max(total * 3, total + 15)); + while (iter < maxIter) { + iter += 1; + const line = `Подсказки ИИ: шаг ${iter} из ~${total}`; + showAiToast(line, 'info', 0); + const r = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate-next`, { method: 'POST' }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.error || 'Не удалось сгенерировать подсказку.'); + if (d.done || d.remaining === 0) break; + if (d.generated) continue; + break; + } + } + // ─── collect ─────────────────────────────────────────────────────── function collectPayload() { @@ -668,26 +766,22 @@ + 'Сгенерировать недостающие подсказки через ИИ?', ); if (okGen) { - saveStatusEl.textContent = `Создаём ИИ-подсказки (${st.missing} из ${st.total})…`; - const gr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' }); - const gd = await gr.json().catch(() => ({})); - if (!gr.ok) { + try { + await runHintsProgressFromServer(st.missing); + if (saveMsg) saveMsg.textContent = `${msg} Подсказки обновлены через ИИ.`; + try { + await refreshHintsInForm(); + } catch (_) { + /* не блокируем успех сохранения */ + } + showAiToast('Подсказки сгенерированы.', 'ok'); + } catch (he) { saveStatusEl.textContent = ''; - alert(gd.error || 'Не удалось сгенерировать подсказки.'); - if (saveMsg) saveMsg.textContent = msg + ' (подсказки не созданы)'; + alert(he.message || 'Не удалось сгенерировать подсказки.'); + if (saveMsg) saveMsg.textContent = `${msg} (подсказки не созданы)`; if (saveModal) saveModal.showModal(); return; } - const skipped = Number(gd.skipped || 0); - const tail = gd.failed - ? ` Подсказки: ${gd.generated} создано, ${gd.failed} не удалось${skipped ? `, пропущено ${skipped}` : ''}.` - : ` Подсказки созданы (${gd.generated})${skipped ? `, пропущено ${skipped}` : ''}.`; - if (saveMsg) saveMsg.textContent = msg + tail; - try { - await refreshHintsInForm(); - } catch (_) { - /* не блокируем успех сохранения */ - } } else if (saveMsg) { saveMsg.textContent = msg; } @@ -716,14 +810,15 @@ alert('Укажите тему.'); return; } - // Предупреждение, если в тесте уже есть вопросы или заполненное название/описание + const keepTitle = !!(aiKeepTitleEl && aiKeepTitleEl.checked); const hasContent = questionsEl.children.length > 0 || titleEl.value.trim() || descEl.value.trim(); if (hasContent) { - const ok = confirm( - 'Полная генерация заменит текущее название, описание и все вопросы.\n\nПродолжить?' - ); + const warn = keepTitle + ? 'Полная генерация заменит описание и все вопросы. Название в редакторе не будет заменено, если включено «Не менять название».\n\nПродолжить?' + : 'Полная генерация заменит текущее название, описание и все вопросы.\n\nПродолжить?'; + const ok = confirm(warn); if (!ok) return; } const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7)); @@ -736,7 +831,11 @@ minCorrect: globalMulti ? globalRange.minCorrect : 1, maxCorrect: globalMulti ? globalRange.maxCorrect : 1, })); - aiStatusEl.textContent = 'Генерируем структуру и вопросы…'; + showAiToast( + `Генерация ${nQ} вопросов · ~${formatEtaSeconds(etaFullGenerateSeconds(nQ))}`, + 'info', + 0, + ); try { const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, { method: 'POST', @@ -750,44 +849,48 @@ const data = await r.json(); if (!r.ok) throw new Error(data.error || 'AI: ошибка.'); const draft = data.draft; - if (draft.title) { + if (draft.title && !keepTitle) { titleEl.value = draft.title; if (aiTopicEl) aiTopicEl.value = draft.title; + } else if (aiTopicEl) { + aiTopicEl.value = topic; } if (draft.description) descEl.value = draft.description; questionsEl.innerHTML = ''; (draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); 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 = 'Сохраняем черновик…'; + showAiToast('Сохраняем черновик перед подсказками…', 'info', 0); 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) { - try { - await refreshHintsInForm(); - } catch (_) { - // Не блокируем успех генерации вопросов. - } - const skipped = Number(hd.skipped || 0); - aiStatusEl.textContent = skipped - ? `Готово: вопросы + подсказки (${hd.generated}, пропущено ${skipped}).` - : `Готово: вопросы + подсказки (${hd.generated}).`; + const hs = await fetch(`/api/tests/${TEST_ID}/ai/hints/status`).then((x) => x.json()); + const miss = Number(hs.missing || 0); + if (miss > 0) { + await runHintsProgressFromServer(miss); } + try { + await refreshHintsInForm(); + } catch (_) { + /* не блокируем успех */ + } + showAiToast(`Готово: ${draft.questions?.length || 0} вопросов и подсказки`, 'ok'); } catch (_) { - // Оставляем базовый статус готовности вопросов. + showAiToast( + `Вопросы готовы (${draft.questions?.length || 0}); подсказки не созданы`, + 'err', + ); } + } else { + showAiToast(`Готово: ${draft.questions?.length || 0} вопросов`, 'ok'); } - setTimeout(() => (aiStatusEl.textContent = ''), 4000); } catch (e) { - aiStatusEl.textContent = ''; - alert(e.message || 'AI: ошибка.'); + showAiToast('', 'info'); + const msg = e.message || 'AI: ошибка.'; + showAiToast(msg, 'err', 7000); + alert(msg); } }); @@ -804,6 +907,8 @@ let _extractedText = ''; let _extractedFileName = ''; + /** Имена всех загруженных файлов (несколько выборов из разных папок склеиваются). */ + let _importFileNames = []; /** HTML карточки вопроса в модалке предпросмотра импорта (как в разборе: текст, подсказка, все варианты). */ function buildImportPreviewQuestionHtml(q, index) { @@ -858,31 +963,110 @@ importModal.showModal(); } - // Фаза 1: выбрать файл → извлечь текст, обновить метку дропзоны - async function handleImportFile(file) { - if (!file) return; - aiStatusEl.textContent = `Загружаем «${file.name}»…`; - importDropzone.classList.add('import-dropzone--loading'); + function clearImportState() { + _extractedText = ''; + _extractedFileName = ''; + _importFileNames = []; + if (importDropzoneLabel) importDropzoneLabel.textContent = 'Перетащите файлы сюда или нажмите'; + importDropzone?.classList.remove('import-dropzone--done', 'import-dropzone--loading'); + if (docUserHint) docUserHint.value = ''; + } + + if (aiImportClearBtn) { + aiImportClearBtn.addEventListener('click', () => { + clearImportState(); + showAiToast('Загрузка сброшена.', 'info'); + }); + } + + // Фаза 1: выбрать файл(ы) → извлечь текст, обновить метку дропзоны + async function handleImportFiles(fileList) { + const files = Array.from(fileList || []).filter(Boolean); + if (!files.length) return; + if (files.length > 5) { + showAiToast('Не более 5 файлов за раз.', 'err'); + return; + } + const allowed = ['.pdf', '.docx', '.txt', '.md']; + for (const file of files) { + const ext = ('.' + file.name.split('.').pop()).toLowerCase(); + if (!allowed.includes(ext)) { + showAiToast(`Формат «${ext}» не поддерживается.`, 'err'); + return; + } + } + const appendHint = _extractedText.trim() ? ' · добавляем к уже загруженным' : ''; + showAiToast( + files.length > 1 + ? `Загружаем ${files.length} файла…${appendHint}` + : `Загружаем «${files[0].name}»…${appendHint}`, + 'info', + 0, + ); + importDropzone?.classList.add('import-dropzone--loading'); try { const fd = new FormData(); - fd.append('file', file); - const r = await fetch('/api/tests/import/document', { method: 'POST', body: fd }); - const data = await r.json(); + files.forEach((f) => fd.append('files', f)); + const r = await fetch('/api/tests/import/document', { + method: 'POST', + body: fd, + credentials: 'same-origin', + }); + let data; + const raw = await r.text(); + try { + data = raw.trim() ? JSON.parse(raw) : {}; + } catch { + if (r.status === 413) throw new Error('Файл слишком большой для сервера или прокси.'); + if (r.status === 401) throw new Error('Сессия истекла — войдите снова и повторите загрузку.'); + throw new Error( + `Сервер вернул не JSON (код ${r.status}). Обновите страницу или войдите снова.`, + ); + } if (!r.ok) throw new Error(data.error || 'Не удалось загрузить файл.'); - _extractedText = data.extractedText || ''; - _extractedFileName = file.name; - aiStatusEl.textContent = `Файл загружен: «${file.name}» · ${data.textLength ?? 0} символов`; - if (importDropzoneLabel) importDropzoneLabel.textContent = `✓ ${file.name}`; - importDropzone.classList.add('import-dropzone--done'); + const batchText = (data.extractedText || '').trim(); + const names = Array.isArray(data.originalNames) ? data.originalNames : [data.originalName || '']; + const batchNames = names.filter(Boolean); + const hadExisting = !!_extractedText.trim(); + const batchLen = Number(data.textLength) || batchText.length; + + if (batchText) { + if (hadExisting) { + _extractedText = `${_extractedText.trimEnd()}\n\n---\n\n${batchText}`; + } else { + _extractedText = batchText; + } + batchNames.forEach((n) => _importFileNames.push(n)); + _extractedFileName = _importFileNames.join(', '); + if (importDropzoneLabel) { + const n = _importFileNames.length; + importDropzoneLabel.textContent = + n <= 1 + ? `✓ ${_importFileNames[0] || files[0]?.name || 'файл'} · можно добавить ещё` + : `✓ ${n} файлов · можно добавить ещё`; + } + importDropzone?.classList.add('import-dropzone--done'); + const totalLen = _extractedText.length; + if (hadExisting) { + showAiToast(`Добавлено · ${batchLen} симв. · всего ${totalLen}`, 'ok'); + } else { + showAiToast(`Загружено · ${totalLen} символов`, 'ok'); + } + } else if (hadExisting) { + showAiToast('Текст из этих файлов пуст — уже загруженное не меняли.', 'info', 4500); + } else { + _extractedFileName = ''; + showAiToast('Текст из файлов не извлечён.', 'info', 4500); + } } catch (e) { - aiStatusEl.textContent = ''; + showAiToast('', 'info'); openImportModal( 'Ошибка загрузки', `
${escHtml(e.message || 'Не удалось загрузить файл.')}
${escHtml(e.message || 'Не удалось сгенерировать тест.')}
Нечего улучшать.
', [ @@ -1297,21 +1492,71 @@ $('.q-multi', node).checked = !!it.suggested.hasMultipleAnswers; const optsEl = $('.q-options', node); optsEl.innerHTML = ''; - it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o))); + it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o, node))); }); + $$('#questions .q-item').forEach((node) => { + if (!node.classList.contains('q-removed')) syncOptionInputTypes(node); + }); + renumber(); modal.close(); scheduleDirtyCheck(); - aiStatusEl.textContent = 'Изменения применены. Не забудьте сохранить.'; - setTimeout(() => (aiStatusEl.textContent = ''), 5000); + showAiToast('Изменения применены — сохраните тест', 'ok'); }, }, ]); } catch (e) { - aiStatusEl.textContent = ''; + showAiToast('', 'info'); aiAlert(null, e.message); } }); + async function improveSingleOption(qNode, optIndex) { + const rows = $$('.opt-item', qNode); + const row = rows[optIndex]; + if (!row) return; + const overlay = $('.opt-ai-overlay', row); + const ta = $('.opt-text', row); + const aiBtn = $('.opt-ai', row); + + const setLocalBusy = (on) => { + if (overlay) overlay.classList.toggle('hidden', !on); + if (ta) ta.toggleAttribute('readonly', !!on); + if (aiBtn) aiBtn.disabled = !!on; + row.classList.toggle('opt-item--ai-busy', !!on); + }; + + setLocalBusy(true); + try { + const r = await fetch(`/api/tests/${TEST_ID}/ai/improve-option`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + testTitle: titleEl.value, + testDescription: descEl.value, + questionText: $('.q-text', qNode).value, + optionIndex: optIndex, + options: rows.map((op) => ({ + text: $('.opt-text', op).value.trim(), + isCorrect: $('.opt-correct', op).checked, + })), + }), + }); + const data = await r.json(); + if (!r.ok) { + setLocalBusy(false); + return aiAlert(data); + } + const t = $('.opt-text', row); + t.value = (data.text != null ? data.text : t.value) || ''; + autoResize(t); + scheduleDirtyCheck(); + setLocalBusy(false); + } catch (e) { + setLocalBusy(false); + aiAlert(null, e.message); + } + } + /** Перемешивает строки вариантов в DOM; чекбоксы «верный» остаются при своих полях. */ function shuffleQuestionOptionsDom(qNode) { const optsEl = $('.q-options', qNode); @@ -1325,6 +1570,44 @@ rows.forEach((el) => optsEl.appendChild(el)); } + function pickQuestionAiMode(canDistractors) { + const dlg = document.getElementById('dlg-q-ai-mode'); + const distBtn = document.getElementById('q-ai-mode-distractors'); + const qBtn = document.getElementById('q-ai-mode-question'); + const optBtn = document.getElementById('q-ai-mode-options'); + const cancelBtn = document.getElementById('q-ai-mode-cancel'); + if (!dlg || typeof dlg.showModal !== 'function') { + return Promise.resolve('rephrase'); + } + if (distBtn) { + distBtn.disabled = !canDistractors; + distBtn.style.opacity = canDistractors ? '' : '0.45'; + distBtn.title = canDistractors ? '' : 'Нет пустых полей для дистракторов'; + } + return new Promise((resolve) => { + let settled = false; + const safeResolve = (val) => { + if (settled) return; + settled = true; + resolve(val); + }; + const onClose = () => { + safeResolve(null); + }; + dlg.addEventListener('close', onClose, { once: true }); + const pick = (mode) => () => { + dlg.removeEventListener('close', onClose); + safeResolve(mode); + dlg.close(); + }; + distBtn?.addEventListener('click', pick('distractors'), { once: true }); + qBtn?.addEventListener('click', pick('rephrase'), { once: true }); + optBtn?.addEventListener('click', pick('improve_options'), { once: true }); + cancelBtn?.addEventListener('click', pick(null), { once: true }); + dlg.showModal(); + }); + } + async function aiGenerateQuestion(node) { const qTextEl = $('.q-text', node); const qText = qTextEl.value.trim(); @@ -1333,32 +1616,25 @@ const multi = $('.q-multi', node).checked; const overlay = $('.q-ai-overlay', node); - // Показываем оверлей - overlay?.classList.remove('hidden'); - node.style.pointerEvents = 'none'; - try { - // Собираем варианты с их состоянием const existingOptions = existingOpts.map((op) => ({ text: $('.opt-text', op).value.trim(), isCorrect: $('.opt-correct', op).checked, })); const emptySlots = existingOptions.filter((o) => !o.text).length; - const filledSlots = existingOptions.filter((o) => o.text).length; - // Выбираем режим: - // - нет текста вопроса → full - // - есть вопрос + есть пустые варианты (и хоть один заполнен) → distractors - // - есть вопрос, все варианты заполнены или вариантов нет → rephrase let requestMode; if (!qText) { requestMode = 'full'; - } else if (emptySlots > 0 && filledSlots > 0) { - requestMode = 'distractors'; } else { - requestMode = 'rephrase'; + const choice = await pickQuestionAiMode(emptySlots > 0); + if (!choice) return; + requestMode = choice; } + overlay?.classList.remove('hidden'); + node.style.pointerEvents = 'none'; + const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-question`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1375,20 +1651,17 @@ const data = await r.json(); if (!r.ok) throw new Error(data.error || 'AI: ошибка.'); - // Обновляем текст вопроса (кроме режима дистракторов — текст не меняем) - if (data.mode !== 'distractors') { + if (data.mode === 'rephrase' || data.mode === 'full') { qTextEl.value = data.text || qText; autoResize(qTextEl); } const optsEl = $('.q-options', node); if (data.mode === 'full' && Array.isArray(data.options) && data.options.length) { - // Полная замена вариантов optsEl.innerHTML = ''; data.options.forEach((o) => optsEl.appendChild(renderOption(o, node))); $('.q-multi', node).checked = !!data.hasMultipleAnswers; } else if (data.mode === 'distractors' && Array.isArray(data.options) && data.options.length) { - // Заполняем только пустые слоты let dIdx = 0; existingOpts.forEach((op) => { const t = $('.opt-text', op); @@ -1399,6 +1672,15 @@ } }); shuffleQuestionOptionsDom(node); + } else if (data.mode === 'improve_options' && Array.isArray(data.options) && data.options.length) { + const rows = $$('.opt-item', node); + data.options.forEach((o, i) => { + if (!rows[i]) return; + const ta = $('.opt-text', rows[i]); + ta.value = o.text != null ? o.text : ta.value; + $('.opt-correct', rows[i]).checked = !!o.isCorrect; + autoResize(ta); + }); } syncOptionInputTypes(node); @@ -1406,14 +1688,40 @@ updateAiButtonLabel(node); scheduleDirtyCheck(); } catch (e) { - aiStatusEl.textContent = ''; - alert(e.message || 'AI: ошибка.'); + const msg = e.message || 'AI: ошибка.'; + showAiToast(msg, 'err', 7000); + alert(msg); } finally { overlay?.classList.add('hidden'); node.style.pointerEvents = ''; } } + if (addQuestionAiBtn) { + addQuestionAiBtn.addEventListener('click', async () => { + const node = renderQuestion({ + text: '', + hasMultipleAnswers: false, + options: [ + { text: '', isCorrect: true }, + { text: '', isCorrect: false }, + { text: '', isCorrect: false }, + { text: '', isCorrect: false }, + ], + }); + questionsEl.appendChild(node); + renumber(); + scheduleDirtyCheck(); + try { + node.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } catch (_) { + /* ok */ + } + showAiToast('Добавлен блок — запускаем ИИ для одного вопроса…', 'info'); + await aiGenerateQuestion(node); + }); + } + // ─── chain active state (грузим summary, чтобы знать стартовое значение) ─── function updateChainActiveDisplay(active) { diff --git a/flask_app/app/templates/tests/editor.html b/flask_app/app/templates/tests/editor.html index 9cf2f2a..67c1433 100644 --- a/flask_app/app/templates/tests/editor.html +++ b/flask_app/app/templates/tests/editor.html @@ -7,6 +7,8 @@ data-test-id="{{ test_id }}" data-initial='{{ content | tojson | safe }}'> + +