Browse Source

testingwebapp fixes, weeek tasks 2948-2958

dev
Константин Лебединский 6 days ago
parent
commit
1ea83aa6b4
  1. 7
      flask_app/app/__init__.py
  2. 191
      flask_app/app/services/ai_editor.py
  3. 4
      flask_app/app/services/document_extract.py
  4. 64
      flask_app/app/services/test_attempt.py
  5. 76
      flask_app/app/static/css/app.css
  6. 552
      flask_app/app/static/js/editor.js
  7. 94
      flask_app/app/templates/tests/editor.html
  8. 85
      flask_app/app/templates/tests/list.html
  9. 68
      flask_app/app/tests/routes.py

7
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():

191
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 = (
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,12 +533,14 @@ 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:
if empty_count <= 0:
raise HttpError(400, 'Нет пустых полей для дистракторов — добавьте пустые варианты или выберите другой режим.')
filled_lines = '\n'.join(
f'- {"" if o.get("isCorrect") else ""} {o["text"]}'
for o in filled
@ -440,8 +572,41 @@ def generate_or_rephrase_question(
][:empty_count]
return {'mode': 'distractors', 'text': qt, 'options': opts}
# ── Режим улучшения: вопрос есть → только переформулировать текст ────────
if qt:
# ── Улучшить только формулировки вариантов (без смены верности) ──────────
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", '

4
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]

64
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()

76
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); } }

552
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, '&quot;').replace(/'/g, '&#39;');
}
/** Тост справа под шапкой: выезд справа, 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) {
saveStatusEl.textContent = '';
alert(gd.error || 'Не удалось сгенерировать подсказки.');
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 runHintsProgressFromServer(st.missing);
if (saveMsg) saveMsg.textContent = `${msg} Подсказки обновлены через ИИ.`;
try {
await refreshHintsInForm();
} catch (_) {
/* не блокируем успех сохранения */
}
showAiToast('Подсказки сгенерированы.', 'ok');
} catch (he) {
saveStatusEl.textContent = '';
alert(he.message || 'Не удалось сгенерировать подсказки.');
if (saveMsg) saveMsg.textContent = `${msg} (подсказки не созданы)`;
if (saveModal) saveModal.showModal();
return;
}
} 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) {
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 (_) {
// Не блокируем успех генерации вопросов.
}
const skipped = Number(hd.skipped || 0);
aiStatusEl.textContent = skipped
? `Готово: вопросы + подсказки (${hd.generated}, пропущено ${skipped}).`
: `Готово: вопросы + подсказки (${hd.generated}).`;
/* не блокируем успех */
}
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(
'Ошибка загрузки',
`<div class="import-modal-review"><p class="import-modal-review__alert import-modal-review__alert--error">${escHtml(e.message || 'Не удалось загрузить файл.')}</p></div>`,
[{ label: 'Закрыть', onClick: () => importModal.close() }],
);
} finally {
importDropzone.classList.remove('import-dropzone--loading');
importDropzone?.classList.remove('import-dropzone--loading');
}
}
@ -892,8 +1076,12 @@
const userHint = docUserHint ? docUserHint.value.trim() : '';
docGenerateBtn.disabled = true;
docGenerateBtn.textContent = 'Генерируем…';
aiStatusEl.textContent = 'Генерируем тест из документа…';
if (docProgressEl) docProgressEl.textContent = 'Шаг 1/3: подготовка шаблона…';
const nQDoc = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
showAiToast(
`Документ: шаг 1/3 · подготовка · ~${formatEtaSeconds(etaFullGenerateSeconds(nQDoc))}`,
'info',
0,
);
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));
@ -905,7 +1093,7 @@
minCorrect: globalMulti ? globalRange.minCorrect : 1,
maxCorrect: globalMulti ? globalRange.maxCorrect : 1,
}));
if (docProgressEl) docProgressEl.textContent = 'Шаг 2/3: генерация вопросов…';
showAiToast('Документ: шаг 2/3 · генерация вопросов…', 'info', 0);
const r = await fetch('/api/tests/generate-from-extracted', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -914,8 +1102,7 @@
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: подготовка к применению…';
showAiToast('Документ: шаг 3/3 · готово к предпросмотру', 'ok', 3200);
if (!g.available) {
openImportModal(
@ -953,22 +1140,15 @@
qs.forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
scheduleDirtyCheck();
aiStatusEl.textContent = `Применено: ${qs.length} вопросов.`;
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
// Сброс зоны загрузки
_extractedText = '';
_extractedFileName = '';
if (importDropzoneLabel) importDropzoneLabel.textContent = 'Перетащите файл сюда или нажмите';
importDropzone.classList.remove('import-dropzone--done');
if (docUserHint) docUserHint.value = '';
aiStatusEl.textContent = '';
showAiToast(`Применено: ${qs.length} вопросов`, 'ok');
clearImportState();
},
},
{ label: 'Отмена', onClick: () => importModal.close() },
],
);
} catch (e) {
aiStatusEl.textContent = '';
showAiToast(e.message || 'Ошибка генерации', 'err', 7000);
openImportModal(
'Ошибка генерации',
`<div class="import-modal-review"><p class="import-modal-review__alert import-modal-review__alert--error">${escHtml(e.message || 'Не удалось сгенерировать тест.')}</p></div>`,
@ -979,7 +1159,6 @@
docGenerateBtn.disabled = false;
docGenerateBtn.innerHTML = '<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_awesome</span> Сгенерировать из документа';
}
if (docProgressEl) setTimeout(() => { docProgressEl.textContent = ''; }, 2500);
}
}
@ -990,9 +1169,9 @@
if (fileInput) {
const onchange = async (ev) => {
fileInput.removeEventListener('change', onchange);
const f = ev.target.files && ev.target.files[0];
const picked = ev.target.files ? Array.from(ev.target.files) : [];
ev.target.value = '';
await handleImportFile(f);
await handleImportFiles(picked);
if (_extractedText) handleGenerateFromDoc();
};
fileInput.addEventListener('change', onchange);
@ -1004,9 +1183,9 @@
});
$('#ai-import-file').addEventListener('change', (ev) => {
const file = ev.target.files && ev.target.files[0];
const picked = ev.target.files ? Array.from(ev.target.files) : [];
ev.target.value = '';
handleImportFile(file);
handleImportFiles(picked);
});
// Drag-and-drop на зону загрузки
@ -1027,33 +1206,42 @@
importDropzone.addEventListener('drop', (e) => {
e.preventDefault();
importDropzone.classList.remove('import-dropzone--over');
const file = e.dataTransfer?.files?.[0];
if (!file) return;
const allowed = ['.pdf', '.docx', '.txt', '.md'];
const ext = ('.' + file.name.split('.').pop()).toLowerCase();
if (!allowed.includes(ext)) {
aiStatusEl.textContent = `Формат «${ext}» не поддерживается.`;
setTimeout(() => (aiStatusEl.textContent = ''), 3000);
const dropped = Array.from(e.dataTransfer?.files || []);
const batch = dropped.filter((f) => {
const ext = ('.' + f.name.split('.').pop()).toLowerCase();
return allowed.includes(ext);
}).slice(0, 5);
if (!batch.length) {
if (dropped.length) {
showAiToast('Поддерживаются только PDF, DOCX, TXT, MD.', 'err');
}
return;
}
handleImportFile(file);
handleImportFiles(batch);
});
}
// Drag-and-drop на всю страницу (когда перетаскивают извне браузера)
document.addEventListener('dragover', (e) => { e.preventDefault(); });
document.addEventListener('drop', (e) => {
if (importDropzone && importDropzone.contains(e.target)) return; // уже обработано
if (importDropzone && importDropzone.contains(e.target)) return;
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (!file) return;
const allowed = ['.pdf', '.docx', '.txt', '.md'];
const ext = ('.' + file.name.split('.').pop()).toLowerCase();
if (!allowed.includes(ext)) return;
// Подсвечиваем зону и обрабатываем
const dropped = Array.from(e.dataTransfer?.files || []);
const batch = dropped.filter((f) => {
const ext = ('.' + f.name.split('.').pop()).toLowerCase();
return allowed.includes(ext);
}).slice(0, 5);
if (!batch.length) {
if (dropped.length) {
showAiToast('Поддерживаются только PDF, DOCX, TXT, MD.', 'err');
}
return;
}
importDropzone?.classList.add('import-dropzone--over');
setTimeout(() => importDropzone?.classList.remove('import-dropzone--over'), 600);
handleImportFile(file);
handleImportFiles(batch);
});
// ─── AI v2 (E1.8): generate-by-title / check / improve ─────────
@ -1120,7 +1308,11 @@
const nORaw = prompt('Сколько вариантов в каждом вопросе?', '4');
if (nORaw == null) return;
const nO = Math.max(2, Math.min(12, parseInt(nORaw, 10) || 4));
aiStatusEl.textContent = 'Генерируем по названию…';
showAiToast(
`Генерация по названию · ~${formatEtaSeconds(etaFullGenerateSeconds(nQ))}`,
'info',
0,
);
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-by-title`, {
method: 'POST',
@ -1134,16 +1326,17 @@
});
const data = await r.json();
if (!r.ok) {
aiStatusEl.textContent = '';
showAiToast('', 'info');
return aiAlert(data);
}
const draft = data.draft;
showAiToast('Черновик готов — подтвердите в диалоге', 'ok');
const ok = confirm(
`Готово: «${draft.title}», вопросов — ${draft.questions.length}.\n` +
'Применить как черновик? Текущие вопросы будут заменены.',
);
if (!ok) {
aiStatusEl.textContent = '';
showAiToast('', 'info');
return;
}
if (draft.title) titleEl.value = draft.title;
@ -1152,10 +1345,9 @@
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
scheduleDirtyCheck();
aiStatusEl.textContent = `Применено: ${draft.questions.length} вопросов.`;
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
showAiToast(`Применено: ${draft.questions.length} вопросов`, 'ok');
} catch (e) {
aiStatusEl.textContent = '';
showAiToast('', 'info');
aiAlert(null, e.message);
}
});
@ -1167,7 +1359,7 @@
alert('В тесте нет вопросов — нечего проверять.');
return;
}
aiStatusEl.textContent = 'Анализируем…';
showAiToast('Проверка теста…', 'info', 0);
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/check`, {
method: 'POST',
@ -1179,8 +1371,9 @@
}),
});
const data = await r.json();
aiStatusEl.textContent = '';
showAiToast('', 'info');
if (!r.ok) return aiAlert(data);
showAiToast('Проверка готова — см. окно', 'ok');
const rev = data.review || {};
const verdict = rev.verdict || 'warn';
const verdictMap = {
@ -1208,7 +1401,7 @@
className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm' },
]);
} catch (e) {
aiStatusEl.textContent = '';
showAiToast('', 'info');
aiAlert(null, e.message);
}
});
@ -1220,7 +1413,7 @@
alert('В тесте нет вопросов — нечего улучшать.');
return;
}
aiStatusEl.textContent = 'Улучшаем…';
showAiToast('Подготовка улучшений…', 'info', 0);
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/improve`, {
method: 'POST',
@ -1229,11 +1422,13 @@
testTitle: titleEl.value,
testDescription: descEl.value,
questions: payload.questions,
focus: (aiImproveFocusEl && aiImproveFocusEl.value) || 'all',
}),
});
const data = await r.json();
aiStatusEl.textContent = '';
showAiToast('', 'info');
if (!r.ok) return aiAlert(data);
showAiToast('Выберите в окне, что применить', 'ok');
const items = data.items || [];
if (!items.length) {
openModal('Улучшение теста', '<p>Нечего улучшать.</p>', [
@ -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) {

94
flask_app/app/templates/tests/editor.html

@ -7,6 +7,8 @@
data-test-id="{{ test_id }}"
data-initial='{{ content | tojson | safe }}'>
<div id="editor-gen-toast" class="editor-gen-toast" role="status" aria-live="polite" hidden></div>
<section class="cabinet-brick cabinet-brick--hero hero-brick">
<a href="{{ url_for('tests.tests_list_page') }}" class="link-back">← К тестам</a>
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
@ -164,6 +166,10 @@
placeholder="Например: охрана труда на производстве"
style="resize:none; overflow:hidden; font-family:inherit;"></textarea>
</label>
<label class="mt-2 inline-flex items-start gap-2 text-sm text-ink-700 cursor-pointer select-none">
<input type="checkbox" id="ai-keep-title" class="mt-1 rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
<span>Не менять название теста в редакторе после генерации (оставить текущее)</span>
</label>
<div class="mt-2">
<button id="ai-generate-test"
class="btn btn-ghost" type="button" style="min-height:43px;">
@ -185,6 +191,17 @@
<p class="text-xs text-ink-500 leading-snug mb-3">
<span class="font-medium text-ink-600">Предложить улучшение</span> — ИИ предложит правки по каждому вопросу (было → стало); вы отметите, что применить к черновику.
</p>
<div class="flex flex-col sm:flex-row sm:flex-wrap sm:items-end gap-2">
<label class="flex flex-col gap-1 min-w-[12rem] flex-1">
<span class="text-xs font-medium text-ink-600">Область улучшения</span>
<select id="ai-improve-focus"
class="form-input text-sm py-2 rounded-lg border border-ink-300 bg-white">
<option value="all">Всё: вопросы и варианты</option>
<option value="questions">Только формулировки вопросов</option>
<option value="distractors">Только неверные варианты (дистракторы)</option>
<option value="options">Все варианты ответа (без смены верности)</option>
</select>
</label>
<div class="flex flex-wrap gap-2">
<button id="ai-check"
class="btn btn-ghost" type="button" style="min-height:43px;">
@ -198,11 +215,12 @@
</button>
</div>
</div>
</div>
{# ── Документ в вопросы ──────────────────────────────────── #}
<div class="question-editor-block test-detail-subsection test-detail-subsection--import">
<h3 class="test-detail-subsection__title">Документ в вопросы</h3>
<p class="text-xs text-ink-500 leading-snug mb-2">
<p class="text-xs text-ink-500 leading-snug mb-2 text-center">
<span class="font-medium text-ink-600">Сгенерировать из документа</span> — из файла извлекается текст; ИИ составляет вопросы по содержанию и шаблону из «Параметров» (число вопросов, вариантов, несколько верных и т.д.), с учётом поля «Пожелания», если оно заполнено. Перед заменой откроется предпросмотр: «Применить» подставит черновик вместо текущих вопросов; дальше сохраните тест на сервер — подсказки и версии ведут себя так же, как при генерации по теме.
</p>
<label id="ai-import-dropzone"
@ -210,10 +228,15 @@
px-4 py-5 rounded-xl bg-white border-2 border-dashed border-ink-300/70
hover:border-brand-400 hover:bg-brand-50/40 cursor-pointer transition-colors">
<span class="material-symbols-outlined text-2xl text-brand-400">upload_file</span>
<span id="ai-import-dropzone-label" class="text-sm font-medium text-ink-700">Перетащите файл сюда или нажмите</span>
<span class="text-xs text-ink-400">PDF, DOCX, TXT, MD · до 16 МБ</span>
<input id="ai-import-file" type="file" accept=".pdf,.docx,.txt,.md" class="hidden" />
<span id="ai-import-dropzone-label" class="text-sm font-medium text-ink-700 text-center block w-full">Перетащите файлы сюда или нажмите</span>
<span class="text-xs text-ink-400 text-center block w-full">PDF, DOCX, TXT, MD · до 5 за раз · до 16 МБ · повторный выбор добавляет к уже загруженным; полный сброс — «Сбросить загрузку».</span>
<input id="ai-import-file" type="file" accept=".pdf,.docx,.txt,.md" multiple class="hidden" />
</label>
<div class="mt-2 flex flex-wrap gap-2">
<button type="button" id="ai-import-clear" class="btn btn-ghost btn--sm text-sm" style="min-height:36px;">
Сбросить загрузку
</button>
</div>
<label class="block mt-3">
<span class="form-label">Пожелания по содержанию <span class="text-ink-400 font-normal">(необязательно)</span></span>
<textarea id="doc-user-hint" rows="1"
@ -226,7 +249,6 @@
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_awesome</span>
Сгенерировать из документа
</button>
<p id="doc-progress" class="mt-2 text-xs text-ink-500 min-h-[1rem]"></p>
</div>
{# ── Модалка результата импорта документа ─────────────────── #}
@ -238,7 +260,6 @@
</div>
</dialog>
<p id="ai-status" class="editor-generation-panel__status text-sm text-ink-500"></p>
</section>
</div>
@ -257,7 +278,7 @@
<h2 class="font-semibold text-ink-900">Вопросы (<span id="q-count-mirror">0</span>)</h2>
</div>
<ol id="questions" class="mt-3 space-y-4"></ol>
<div class="mt-3 flex justify-center">
<div class="mt-3 flex justify-center flex-wrap gap-2">
<button id="add-question"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg
bg-white border border-ink-300/60 hover:border-brand-300 text-sm min-h-10
@ -265,6 +286,14 @@
<span class="material-symbols-outlined text-base">add</span>
<span>Добавить вопрос</span>
</button>
<button id="add-question-ai" type="button"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg
bg-brand-50 border border-brand-200 hover:border-brand-400 text-sm min-h-10
btn btn-ghost question-editor__add-question-ai"
title="Добавляет пустой блок и вызывает ИИ для одного нового вопроса с вариантами">
<span class="material-symbols-outlined text-base">auto_awesome</span>
<span>Новый вопрос (ИИ)</span>
</button>
</div>
</section>
@ -396,11 +425,26 @@
<input type="checkbox"
class="opt-correct w-5 h-5 rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
</label>
<div class="opt-text-wrap relative flex-1 min-w-0 self-start">
<textarea rows="1"
class="opt-text flex-1 min-w-0 rounded-lg border border-ink-300 px-3 py-2
class="opt-text w-full rounded-lg border border-ink-300 px-3 py-2
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
placeholder="Вариант ответа"
style="resize:none; overflow:hidden; font-family:inherit; line-height:1.55;"></textarea>
<div class="opt-ai-overlay hidden absolute inset-0 rounded-lg z-[5]
bg-white/85 backdrop-blur-[2px] flex flex-col items-center justify-center gap-1
border border-ink-200/80 shadow-sm">
<span class="inline-block w-6 h-6 rounded-full
border-[3px] border-brand-200 border-t-brand-600 animate-spin"></span>
<span class="text-xs text-ink-600 font-medium px-2 text-center leading-snug">Улучшаю…</span>
</div>
</div>
<button type="button" class="opt-ai shrink-0 inline-flex items-center justify-center
rounded hover:bg-brand-50 text-brand-700 px-1.5 pt-1.5"
style="min-height:2.5rem; min-width:2.25rem;"
title="Улучшить только этот вариант (ИИ)" aria-label="Улучшить вариант ИИ">
<span class="material-symbols-outlined text-base">auto_fix_high</span>
</button>
<button class="opt-delete shrink-0 w-10 inline-flex items-center justify-center
rounded hover:bg-red-50 text-red-600 pt-1.5"
style="min-height:2.5rem;"
@ -428,6 +472,40 @@
</div>
</dialog>
{# ── Выбор режима ИИ для непустого вопроса (улучшить / дистракторы) ── #}
<dialog id="dlg-q-ai-mode" class="save-modal" style="max-width: 24rem; width: calc(100% - 2rem);">
<div class="save-modal__inner">
<h3 class="text-base font-semibold text-ink-900 mb-1">Что сделать с вопросом?</h3>
<p class="text-xs text-ink-500 mb-3">Выберите, что должен сделать ИИ.</p>
<div class="flex flex-col gap-2">
<button type="button" id="q-ai-mode-distractors"
class="px-3 py-2.5 rounded-lg border border-ink-200 text-left text-sm
hover:bg-brand-50 hover:border-brand-200 transition-colors">
<span class="font-medium text-ink-800">Добавить дистракторы</span>
<span class="block text-ink-500 text-xs mt-0.5">Заполнить пустые поля вариантов</span>
</button>
<button type="button" id="q-ai-mode-question"
class="px-3 py-2.5 rounded-lg border border-ink-200 text-left text-sm
hover:bg-brand-50 hover:border-brand-200 transition-colors">
<span class="font-medium text-ink-800">Улучшить только вопрос</span>
<span class="block text-ink-500 text-xs mt-0.5">Переформулировать текст вопроса</span>
</button>
<button type="button" id="q-ai-mode-options"
class="px-3 py-2.5 rounded-lg border border-ink-200 text-left text-sm
hover:bg-brand-50 hover:border-brand-200 transition-colors">
<span class="font-medium text-ink-800">Улучшить только варианты</span>
<span class="block text-ink-500 text-xs mt-0.5">Тексты ответов, без смены верности</span>
</button>
</div>
<div class="mt-4 flex justify-end">
<button type="button" id="q-ai-mode-cancel"
class="px-3 py-2 rounded-lg text-ink-600 hover:bg-ink-100 text-sm">
Отмена
</button>
</div>
</div>
</dialog>
{# ── Модалка результата AI-проверки/улучшения (fullscreen на мобиле) ── #}
<dialog id="ai-modal"
class="m-0 p-0 w-full h-full sm:h-auto sm:max-w-3xl sm:w-full sm:max-h-[90vh]

85
flask_app/app/templates/tests/list.html

@ -19,16 +19,37 @@
{% block content %}
<section class="legacy-list-shell">
<h1 class="font-headline legacy-list-title">Тесты</h1>
<div class="legacy-list-toolbar">
<button id="btn-create-test" class="btn btn-ghost" type="button">
<div class="legacy-list-toolbar legacy-list-toolbar--wrap flex flex-wrap items-center gap-3">
<div class="flex flex-col sm:flex-row flex-1 min-w-0 gap-2 sm:gap-3">
<label class="flex flex-col gap-1 flex-1 min-w-[12rem] max-w-md">
<span class="text-xs font-medium text-ink-600">Поиск по названию</span>
<input id="catalog-search" type="search" autocomplete="off" placeholder="Начните вводить…"
class="rounded-lg border border-ink-300 px-3 py-2 text-sm w-full
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
</label>
<label class="flex flex-col gap-1 w-full sm:w-52 shrink-0">
<span class="text-xs font-medium text-ink-600">Автор</span>
<select id="catalog-author"
class="rounded-lg border border-ink-300 px-3 py-2 text-sm w-full bg-white
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20">
<option value="">Все авторы</option>
</select>
</label>
</div>
<button id="btn-create-test" class="btn btn-ghost shrink-0" type="button">
Создать
</button>
</div>
<p id="catalog-filter-empty" class="text-sm text-ink-500 mt-2 hidden" role="status"></p>
{% if visible %}
<ul class="list-stack" aria-label="Тесты в общем списке">
{% for t in visible %}
<li class="list-row list-row--split">
<li class="list-row list-row--split list-row--catalog"
data-catalog-row
data-title-lower="{{ (t.title or '')|lower }}"
data-author-id="{{ t.created_by or '' }}"
data-author-name="{{ (t.author_full_name or '—')|e }}">
<div class="list-row__main">
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" class="list-row__link">
<span class="list-row__title">{{ t.title }}</span>
@ -55,7 +76,11 @@
<h2 class="font-headline legacy-list-subtitle">Скрытые вами из списка</h2>
<ul class="list-stack" aria-label="Скрытые тесты автора">
{% for t in hidden %}
<li class="list-row list-row--split list-row--hidden">
<li class="list-row list-row--split list-row--hidden list-row--catalog"
data-catalog-row
data-title-lower="{{ (t.title or '')|lower }}"
data-author-id="{{ t.created_by or '' }}"
data-author-name="{{ (t.author_full_name or '—')|e }}">
<div class="list-row__main">
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" class="list-row__link">
<span class="list-row__title">{{ t.title }}</span>
@ -124,6 +149,58 @@ class="m-0 p-0 w-full sm:w-full sm:max-w-md
const dlg = document.getElementById('dlg-create');
const titleEl = document.getElementById('new-test-title');
const descEl = document.getElementById('new-test-desc');
const catalogSearch = document.getElementById('catalog-search');
const catalogAuthor = document.getElementById('catalog-author');
const catalogEmpty = document.getElementById('catalog-filter-empty');
(function initCatalogFilter() {
if (!catalogSearch || !catalogAuthor) return;
const rows = Array.from(document.querySelectorAll('[data-catalog-row]'));
const byAuthor = new Map();
rows.forEach((row) => {
const id = (row.dataset.authorId || '').trim();
const name = (row.dataset.authorName || '').trim() || '—';
if (id && !byAuthor.has(id)) byAuthor.set(id, name);
});
const sorted = Array.from(byAuthor.entries()).sort((a, b) => a[1].localeCompare(b[1], 'ru'));
sorted.forEach(([id, name]) => {
const opt = document.createElement('option');
opt.value = id;
opt.textContent = name;
catalogAuthor.appendChild(opt);
});
function applyFilter() {
const q = (catalogSearch.value || '').trim().toLowerCase();
const author = (catalogAuthor.value || '').trim();
let visibleCount = 0;
rows.forEach((row) => {
const title = row.dataset.titleLower || '';
const aid = (row.dataset.authorId || '').trim();
const matchQ = !q || title.includes(q);
const matchA = !author || aid === author;
const show = matchQ && matchA;
row.style.display = show ? '' : 'none';
if (show) visibleCount += 1;
});
if (catalogEmpty) {
if (rows.length && visibleCount === 0) {
catalogEmpty.textContent = 'Ничего не найдено — измените запрос или фильтр.';
catalogEmpty.classList.remove('hidden');
} else {
catalogEmpty.textContent = '';
catalogEmpty.classList.add('hidden');
}
}
}
let t = null;
catalogSearch.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(applyFilter, 120);
});
catalogAuthor.addEventListener('change', applyFilter);
})();
document.getElementById('btn-create-test').addEventListener('click', () => {
titleEl.value = '';

68
flask_app/app/tests/routes.py

@ -35,6 +35,7 @@ from ..services.ai_editor import (
generate_full_test_by_shape,
generate_or_rephrase_question,
generate_test_by_title,
improve_single_option_text,
improve_test_full,
parse_and_validate_shape,
)
@ -50,6 +51,7 @@ from ..services.test_attempt import (
check_question_for_attempt,
count_missing_hints,
generate_missing_hints_for_test,
generate_next_missing_hint_for_test,
get_attempt_review_for_user,
get_play_content,
list_test_attempts_for_author,
@ -350,6 +352,17 @@ def api_test_hints_generate(test_id):
return jsonify(out)
@tests_bp.route('/api/tests/<test_id>/ai/hints/generate-next', methods=['POST'])
@login_required
def api_test_hints_generate_next(test_id):
user = current_user()
try:
out = generate_next_missing_hint_for_test(None, user.id, test_id)
except AttemptHttpError as e:
return jsonify(error=e.message), e.status
return jsonify(out)
@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/check', methods=['POST'])
@login_required
def api_attempt_check_question(test_id, attempt_id):
@ -511,6 +524,26 @@ def api_ai_improve_test(test_id):
body.get('testTitle') or '',
body.get('testDescription') or '',
body.get('questions') or [],
focus=body.get('focus') or 'all',
)
except (AiHttpError, LlmError) as e:
return _ai_error_response(e)
return jsonify(ok=True, **out)
@tests_bp.route('/api/tests/<test_id>/ai/improve-option', methods=['POST'])
@login_required
def api_ai_improve_option(test_id):
user = current_user()
_check_test_author_or_404(test_id, user.id)
body = request.get_json(silent=True) or {}
try:
out = improve_single_option_text(
body.get('testTitle') or '',
body.get('testDescription') or '',
body.get('questionText') or '',
body.get('options') or [],
body.get('optionIndex'),
)
except (AiHttpError, LlmError) as e:
return _ai_error_response(e)
@ -522,10 +555,21 @@ def api_ai_improve_test(test_id):
@tests_bp.route('/api/tests/import/document', methods=['POST'])
@login_required
def api_import_document():
"""Шаг 1: загрузить файл и извлечь текст. Генерация — отдельным запросом."""
"""Шаг 1: загрузить один или несколько файлов и извлечь текст. Генерация — отдельным запросом."""
files = request.files.getlist('files')
if not files:
f = request.files.get('file')
if f is None or not f.filename:
return jsonify(error='Прикрепите файл к полю file.'), 400
files = [f] if f is not None and getattr(f, 'filename', None) else []
files = [x for x in files if x is not None and getattr(x, 'filename', None)]
if not files:
return jsonify(error='Прикрепите файл(ы) к полю file или files.'), 400
if len(files) > 5:
return jsonify(error='Не более 5 файлов за один раз.'), 400
parts: list[str] = []
names: list[str] = []
mimes: list[str] = []
for f in files:
try:
extracted = extract_text_from_file(f.mimetype, f, f.filename)
except DocExtractHttpError as e:
@ -533,14 +577,22 @@ def api_import_document():
except Exception:
log.exception('extract_text_from_file failed')
return jsonify(error='Не удалось разобрать файл.'), 500
parts.append(extracted)
names.append(f.filename or '')
mimes.append(f.mimetype or '')
combined = '\n\n---\n\n'.join(parts)
primary = files[0]
return jsonify(
received=True,
originalName=f.filename,
mime=f.mimetype,
size=len(extracted.encode('utf-8')),
extractedText=extracted,
textLength=len(extracted),
originalName=primary.filename,
originalNames=names,
mime=primary.mimetype,
mimes=mimes,
size=len(combined.encode('utf-8')),
extractedText=combined,
textLength=len(combined),
fileCount=len(files),
)

Loading…
Cancel
Save