feat(flask): E1.0–E1.3, E1.8 — миграция на Python/Flask + AI v2
Этап 1 миграции TestingWebApp на целевой стек (Python/Flask/Jinja),
БД остаётся clinic_tests.
E1.0 — База Flask-приложения: SQLAlchemy/psycopg2 пул, Flask sessions,
фабрика create_app, blueprint main с / и /health, base.html в стиле
кабинета HR (Tailwind CDN + Manrope + Material Symbols), 404/500.
E1.1 — Auth + /api/me: Flask sessions (signed cookie) вместо JWT,
bcrypt + Werkzeug, опц. HR_AUTH=1 с UPSERT в clinic_tests.users по
staff_id. UI /login, JSON /api/auth/{login,logout,me}, декораторы
@login_required / @require_role.
E1.2 — Тесты: список + редактор. 10 эндпоинтов, сервисы test_draft,
test_access, test_chain, ai_editor, llm_client, draft_validator,
editor_content. UI /tests (каталог + создание) и /tests/<id>/edit
(редактор с AI). Полный мобильный UX (аккордеоны/drag-n-drop) — в E1.7.
E1.3 — Импорт документов: pypdf + python-docx, эндпоинт
POST /api/tests/import/document, кнопка «Импорт документа» в
AI-панели редактора, лимит 16 МБ.
E1.8 — AI v2: страница /settings (статус ENV-ключа + ping),
ai/generate-by-title (без сетки), ai/check (рецензия), ai/improve
(массовое было→стало с чекбоксами). Унифицированный ответ AI-ошибок:
{ error, code, settingsUrl }.
Docker:
- docker-compose.dev.yml: добавлены DATABASE_URL, HR_AUTH/HR_DATABASE_URL,
DEEPSEEK_API_KEY/OPENAI_API_KEY/LLM_BASE_URL/LLM_MODEL и сеть postgres
для testing-flask.
Документация:
- docs/migration-final.md — двух-этапный план (Этап 1: унификация
стека внутри TestingWebApp; Этап 2: слияние с tgFlaskForm).
- docs/migration-final-inventory.md — карта 22 эндпоинтов Express.
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
/* Редактор теста: рабочий минимум.
|
||||
* Работает с эндпоинтами /api/tests/<id>/{draft, ai/generate-test, ai/generate-question}
|
||||
* и /api/tests/<id> (PATCH chainActive).
|
||||
*
|
||||
* Полная мобильная отполировка UX (4 аккордеона, fixed footer, drag-n-drop)
|
||||
* запланирована отдельным спринтом E1.7.
|
||||
*/
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const root = document.getElementById('editor-root');
|
||||
if (!root) return;
|
||||
|
||||
const TEST_ID = root.dataset.testId;
|
||||
const initial = JSON.parse(root.dataset.initial);
|
||||
|
||||
const $ = (sel, parent = document) => parent.querySelector(sel);
|
||||
const $$ = (sel, parent = document) => Array.from(parent.querySelectorAll(sel));
|
||||
|
||||
const titleEl = $('#test-title');
|
||||
const descEl = $('#test-description');
|
||||
const thresholdEl = $('#test-threshold');
|
||||
const questionsEl = $('#questions');
|
||||
const qCountEl = $('#q-count');
|
||||
const saveStatusEl = $('#save-status');
|
||||
const aiStatusEl = $('#ai-status');
|
||||
const chainActiveEl = $('#chain-active');
|
||||
|
||||
const tplQ = $('#tpl-question');
|
||||
const tplO = $('#tpl-option');
|
||||
|
||||
let chainActive = true;
|
||||
|
||||
// ─── render ─────────────────────────────────────────────────────────
|
||||
|
||||
function renderQuestion(q) {
|
||||
const node = tplQ.content.firstElementChild.cloneNode(true);
|
||||
node._q = { id: q.id || null };
|
||||
$('.q-text', node).value = q.text || '';
|
||||
$('.q-multi', node).checked = !!q.hasMultipleAnswers;
|
||||
|
||||
const optsEl = $('.q-options', node);
|
||||
(q.options || []).forEach((o) => optsEl.appendChild(renderOption(o)));
|
||||
|
||||
bindQuestionEvents(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
function renderOption(o) {
|
||||
const node = tplO.content.firstElementChild.cloneNode(true);
|
||||
$('.opt-text', node).value = o.text || '';
|
||||
$('.opt-correct', node).checked = !!o.isCorrect;
|
||||
$('.opt-delete', node).addEventListener('click', () => {
|
||||
node.remove();
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
function bindQuestionEvents(node) {
|
||||
$('.q-delete', node).addEventListener('click', () => {
|
||||
if (!confirm('Удалить вопрос?')) return;
|
||||
node.remove();
|
||||
renumber();
|
||||
});
|
||||
$('.q-up', node).addEventListener('click', () => {
|
||||
if (node.previousElementSibling) {
|
||||
node.parentNode.insertBefore(node, node.previousElementSibling);
|
||||
renumber();
|
||||
}
|
||||
});
|
||||
$('.q-down', node).addEventListener('click', () => {
|
||||
if (node.nextElementSibling) {
|
||||
node.parentNode.insertBefore(node.nextElementSibling, node);
|
||||
renumber();
|
||||
}
|
||||
});
|
||||
$('.q-add-option', node).addEventListener('click', () => {
|
||||
$('.q-options', node).appendChild(renderOption({ text: '', isCorrect: false }));
|
||||
});
|
||||
$('.q-ai', node).addEventListener('click', () => aiGenerateQuestion(node));
|
||||
}
|
||||
|
||||
function renumber() {
|
||||
$$('#questions .q-item').forEach((li, i) => {
|
||||
$('.q-num', li).textContent = `Вопрос #${i + 1}`;
|
||||
});
|
||||
qCountEl.textContent = $$('#questions .q-item').length;
|
||||
}
|
||||
|
||||
function loadInitial() {
|
||||
titleEl.value = initial.test.title || '';
|
||||
descEl.value = initial.test.description || '';
|
||||
thresholdEl.value =
|
||||
initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold);
|
||||
|
||||
questionsEl.innerHTML = '';
|
||||
(initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
renumber();
|
||||
}
|
||||
|
||||
// ─── collect ───────────────────────────────────────────────────────
|
||||
|
||||
function collectPayload() {
|
||||
const questions = $$('#questions .q-item').map((li, i) => ({
|
||||
text: $('.q-text', li).value.trim(),
|
||||
question_order: i + 1,
|
||||
hasMultipleAnswers: $('.q-multi', li).checked,
|
||||
options: $$('.opt-item', li).map((op, j) => ({
|
||||
text: $('.opt-text', op).value.trim(),
|
||||
isCorrect: $('.opt-correct', op).checked,
|
||||
option_order: j + 1,
|
||||
})),
|
||||
}));
|
||||
const payload = {
|
||||
title: titleEl.value.trim() || null,
|
||||
description: descEl.value.trim() || null,
|
||||
questions,
|
||||
};
|
||||
const t = thresholdEl.value;
|
||||
if (t !== '' && Number.isFinite(Number(t))) payload.passingThreshold = Number(t);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function collectShape() {
|
||||
return $$('#questions .q-item').map((li) => ({
|
||||
optionsCount: Math.max(2, $$('.opt-item', li).length || 4),
|
||||
hasMultipleAnswers: $('.q-multi', li).checked,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── actions ───────────────────────────────────────────────────────
|
||||
|
||||
$('#add-question').addEventListener('click', () => {
|
||||
questionsEl.appendChild(
|
||||
renderQuestion({
|
||||
text: '',
|
||||
hasMultipleAnswers: false,
|
||||
options: [
|
||||
{ text: '', isCorrect: true },
|
||||
{ text: '', isCorrect: false },
|
||||
{ text: '', isCorrect: false },
|
||||
{ text: '', isCorrect: false },
|
||||
],
|
||||
}),
|
||||
);
|
||||
renumber();
|
||||
});
|
||||
|
||||
$('#save-draft').addEventListener('click', async () => {
|
||||
saveStatusEl.textContent = 'Сохраняем…';
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/draft`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(collectPayload()),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось сохранить.');
|
||||
if (chainActiveEl.checked !== chainActive) {
|
||||
const r2 = await fetch(`/api/tests/${TEST_ID}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chainActive: chainActiveEl.checked }),
|
||||
});
|
||||
if (r2.ok) chainActive = chainActiveEl.checked;
|
||||
}
|
||||
saveStatusEl.textContent = data.forked
|
||||
? 'Сохранено (создана новая версия — есть попытки прохождения).'
|
||||
: 'Сохранено.';
|
||||
setTimeout(() => (saveStatusEl.textContent = ''), 4000);
|
||||
} catch (e) {
|
||||
saveStatusEl.textContent = '';
|
||||
alert(e.message || 'Не удалось сохранить.');
|
||||
}
|
||||
});
|
||||
|
||||
$('#ai-generate-test').addEventListener('click', async () => {
|
||||
const shape = collectShape();
|
||||
if (!shape.length) {
|
||||
alert('Сначала добавьте хотя бы один вопрос (его настройки определяют сетку).');
|
||||
return;
|
||||
}
|
||||
aiStatusEl.textContent = 'Генерируем…';
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
testTitle: titleEl.value,
|
||||
testDescription: descEl.value,
|
||||
shape,
|
||||
}),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
|
||||
const draft = data.draft;
|
||||
if (draft.title) titleEl.value = draft.title;
|
||||
if (draft.description) descEl.value = draft.description;
|
||||
questionsEl.innerHTML = '';
|
||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
renumber();
|
||||
aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`;
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
alert(e.message || 'AI: ошибка.');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── импорт документа (E1.3) ───────────────────────────────────
|
||||
|
||||
$('#ai-import-file').addEventListener('change', async (ev) => {
|
||||
const file = ev.target.files && ev.target.files[0];
|
||||
ev.target.value = '';
|
||||
if (!file) return;
|
||||
aiStatusEl.textContent = `Загружаем «${file.name}»…`;
|
||||
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();
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось импортировать.');
|
||||
const g = data.generation || {};
|
||||
if (!g.available) {
|
||||
aiStatusEl.textContent = '';
|
||||
const msg = g.message || 'AI недоступен.';
|
||||
const preview = (g.textPreview || data.extractedText || '').slice(0, 600);
|
||||
alert(msg + (preview ? '\n\nПервые 600 символов:\n' + preview : ''));
|
||||
return;
|
||||
}
|
||||
const ok = confirm(
|
||||
`${g.message}\n\nПрименить как новый черновик?\n` +
|
||||
`Текущие вопросы будут заменены.`,
|
||||
);
|
||||
if (!ok) {
|
||||
aiStatusEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
const draft = g.draft;
|
||||
if (draft.title) titleEl.value = draft.title;
|
||||
if (draft.description) descEl.value = draft.description;
|
||||
questionsEl.innerHTML = '';
|
||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
renumber();
|
||||
aiStatusEl.textContent = `Применено: ${draft.questions?.length || 0} вопросов.`;
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
alert(e.message || 'Не удалось импортировать.');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── AI v2 (E1.8): generate-by-title / check / improve ─────────
|
||||
|
||||
function aiAlert(data, fallback) {
|
||||
const msg = (data && data.error) || fallback || 'AI: ошибка.';
|
||||
if (data && data.settingsUrl) {
|
||||
if (confirm(msg + '\n\nОткрыть Настройки?')) {
|
||||
window.location.href = data.settingsUrl;
|
||||
}
|
||||
return;
|
||||
}
|
||||
alert(msg);
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const modal = $('#ai-modal');
|
||||
const modalTitle = $('#ai-modal-title');
|
||||
const modalBody = $('#ai-modal-body');
|
||||
const modalActions = $('#ai-modal-actions');
|
||||
$('#ai-modal-close').addEventListener('click', () => modal.close());
|
||||
|
||||
function openModal(title, bodyHtml, actions) {
|
||||
modalTitle.textContent = title;
|
||||
modalBody.innerHTML = bodyHtml;
|
||||
modalActions.innerHTML = '';
|
||||
(actions || []).forEach((a) => {
|
||||
const b = document.createElement('button');
|
||||
b.textContent = a.label;
|
||||
b.className = a.className
|
||||
|| 'px-3 py-2 rounded-lg bg-ink-100 hover:bg-ink-200 text-sm';
|
||||
b.addEventListener('click', () => a.onClick(b));
|
||||
modalActions.appendChild(b);
|
||||
});
|
||||
modal.showModal();
|
||||
}
|
||||
|
||||
$('#ai-generate-by-title').addEventListener('click', async () => {
|
||||
const title = titleEl.value.trim();
|
||||
if (!title) {
|
||||
alert('Сначала заполните название теста.');
|
||||
titleEl.focus();
|
||||
return;
|
||||
}
|
||||
const nQRaw = prompt('Сколько вопросов сгенерировать?', '10');
|
||||
if (nQRaw == null) return;
|
||||
const nQ = Math.max(3, Math.min(40, parseInt(nQRaw, 10) || 10));
|
||||
const nORaw = prompt('Сколько вариантов в каждом вопросе?', '4');
|
||||
if (nORaw == null) return;
|
||||
const nO = Math.max(2, Math.min(12, parseInt(nORaw, 10) || 4));
|
||||
aiStatusEl.textContent = 'Генерируем по названию…';
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-by-title`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
testTitle: title,
|
||||
testDescription: descEl.value,
|
||||
questionsCount: nQ,
|
||||
optionsCount: nO,
|
||||
}),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
aiStatusEl.textContent = '';
|
||||
return aiAlert(data);
|
||||
}
|
||||
const draft = data.draft;
|
||||
const ok = confirm(
|
||||
`Готово: «${draft.title}», вопросов — ${draft.questions.length}.\n` +
|
||||
'Применить как черновик? Текущие вопросы будут заменены.',
|
||||
);
|
||||
if (!ok) {
|
||||
aiStatusEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (draft.title) titleEl.value = draft.title;
|
||||
if (draft.description) descEl.value = draft.description;
|
||||
questionsEl.innerHTML = '';
|
||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
renumber();
|
||||
aiStatusEl.textContent = `Применено: ${draft.questions.length} вопросов.`;
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
aiAlert(null, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
$('#ai-check').addEventListener('click', async () => {
|
||||
const payload = collectPayload();
|
||||
if (!payload.questions.length) {
|
||||
alert('В тесте нет вопросов — нечего проверять.');
|
||||
return;
|
||||
}
|
||||
aiStatusEl.textContent = 'Анализируем…';
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
testTitle: titleEl.value,
|
||||
testDescription: descEl.value,
|
||||
questions: payload.questions,
|
||||
}),
|
||||
});
|
||||
const data = await r.json();
|
||||
aiStatusEl.textContent = '';
|
||||
if (!r.ok) return aiAlert(data);
|
||||
const rev = data.review || {};
|
||||
const verdict = rev.verdict || 'warn';
|
||||
const verdictMap = {
|
||||
ok: ['Годен', 'bg-green-50 text-green-800 border-green-200'],
|
||||
warn: ['Есть замечания', 'bg-yellow-50 text-yellow-800 border-yellow-200'],
|
||||
bad: ['Серьёзные проблемы', 'bg-red-50 text-red-800 border-red-200'],
|
||||
};
|
||||
const [verdictText, verdictCls] = verdictMap[verdict] || verdictMap.warn;
|
||||
let html = `<div class="rounded-lg border ${verdictCls} p-3 text-sm">
|
||||
<div class="font-semibold">${verdictText}</div>
|
||||
<div class="mt-1">${escHtml(rev.summary || '')}</div></div>`;
|
||||
if (Array.isArray(rev.sections) && rev.sections.length) {
|
||||
html += rev.sections.map((s) => `
|
||||
<div class="mt-4">
|
||||
<div class="font-semibold">${escHtml(s.title)}</div>
|
||||
<ul class="mt-1 list-disc pl-5 text-sm space-y-1">
|
||||
${s.items.map((it) => `<li>${escHtml(it)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>`).join('');
|
||||
} else {
|
||||
html += '<p class="mt-4 text-sm text-ink-500">Замечаний нет.</p>';
|
||||
}
|
||||
openModal('Проверка теста', html, [
|
||||
{ label: 'Закрыть', onClick: () => modal.close(),
|
||||
className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm' },
|
||||
]);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
aiAlert(null, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
$('#ai-improve').addEventListener('click', async () => {
|
||||
const payload = collectPayload();
|
||||
if (!payload.questions.length) {
|
||||
alert('В тесте нет вопросов — нечего улучшать.');
|
||||
return;
|
||||
}
|
||||
aiStatusEl.textContent = 'Улучшаем…';
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/improve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
testTitle: titleEl.value,
|
||||
testDescription: descEl.value,
|
||||
questions: payload.questions,
|
||||
}),
|
||||
});
|
||||
const data = await r.json();
|
||||
aiStatusEl.textContent = '';
|
||||
if (!r.ok) return aiAlert(data);
|
||||
const items = data.items || [];
|
||||
if (!items.length) {
|
||||
openModal('Улучшение теста', '<p>Нечего улучшать.</p>', [
|
||||
{ label: 'Закрыть', onClick: () => modal.close() },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
const changed = items.filter((i) => i.changed);
|
||||
if (!changed.length) {
|
||||
openModal('Улучшение теста', '<p>AI не предложил изменений.</p>', [
|
||||
{ label: 'Закрыть', onClick: () => modal.close() },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
let html = `<p class="text-sm text-ink-500 mb-3">
|
||||
Отметьте вопросы, к которым нужно применить улучшения. ${changed.length} из ${items.length}.</p>`;
|
||||
html += changed.map((it) => `
|
||||
<div class="rounded-xl border border-ink-300/60 p-3 mb-3" data-idx="${it.index}">
|
||||
<label class="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" class="apply-q rounded border-ink-300 text-brand-600 focus:ring-brand-500" checked />
|
||||
<span>Вопрос #${it.index + 1}</span>
|
||||
</label>
|
||||
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div class="text-xs uppercase text-ink-500">Было</div>
|
||||
<div class="mt-1 ${it.textChanged ? 'line-through text-ink-500' : ''}">
|
||||
${escHtml(it.original.text)}
|
||||
</div>
|
||||
<ul class="mt-1 list-disc pl-5">
|
||||
${it.original.options.map((o) =>
|
||||
`<li class="${it.optionsChanged ? 'text-ink-500' : ''}">
|
||||
${o.isCorrect ? '✓ ' : ''}${escHtml(o.text)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase text-brand-700">Стало</div>
|
||||
<div class="mt-1 ${it.textChanged ? 'font-medium' : ''}">
|
||||
${escHtml(it.suggested.text)}
|
||||
</div>
|
||||
<ul class="mt-1 list-disc pl-5">
|
||||
${it.suggested.options.map((o) =>
|
||||
`<li>${o.isCorrect ? '✓ ' : ''}${escHtml(o.text)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
openModal('Улучшение теста', html, [
|
||||
{ label: 'Отмена', onClick: () => modal.close() },
|
||||
{
|
||||
label: 'Применить выбранное',
|
||||
className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm',
|
||||
onClick: () => {
|
||||
const qs = $$('#questions .q-item');
|
||||
modalBody.querySelectorAll('[data-idx]').forEach((row) => {
|
||||
if (!$('.apply-q', row).checked) return;
|
||||
const idx = parseInt(row.dataset.idx, 10);
|
||||
const it = items.find((x) => x.index === idx);
|
||||
if (!it || !qs[idx]) return;
|
||||
const node = qs[idx];
|
||||
$('.q-text', node).value = it.suggested.text;
|
||||
$('.q-multi', node).checked = !!it.suggested.hasMultipleAnswers;
|
||||
const optsEl = $('.q-options', node);
|
||||
optsEl.innerHTML = '';
|
||||
it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o)));
|
||||
});
|
||||
modal.close();
|
||||
aiStatusEl.textContent = 'Изменения применены. Не забудьте сохранить.';
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 5000);
|
||||
},
|
||||
},
|
||||
]);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
aiAlert(null, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function aiGenerateQuestion(node) {
|
||||
const qText = $('.q-text', node).value.trim();
|
||||
const optsCount = Math.max(2, $$('.opt-item', node).length || 4);
|
||||
const multi = $('.q-multi', node).checked;
|
||||
aiStatusEl.textContent = 'AI: один вопрос…';
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-question`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
testTitle: titleEl.value,
|
||||
testDescription: descEl.value,
|
||||
questionText: qText,
|
||||
optionsCount: optsCount,
|
||||
hasMultipleAnswers: multi,
|
||||
}),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
|
||||
$('.q-text', node).value = data.text || '';
|
||||
if (data.mode === 'full' && Array.isArray(data.options)) {
|
||||
const optsEl = $('.q-options', node);
|
||||
optsEl.innerHTML = '';
|
||||
data.options.forEach((o) => optsEl.appendChild(renderOption(o)));
|
||||
$('.q-multi', node).checked = !!data.hasMultipleAnswers;
|
||||
}
|
||||
aiStatusEl.textContent = data.mode === 'full' ? 'AI: вопрос сгенерирован.' : 'AI: формулировка обновлена.';
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
alert(e.message || 'AI: ошибка.');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── chain active state (грузим summary, чтобы знать стартовое значение) ───
|
||||
|
||||
fetch(`/api/tests/${TEST_ID}/summary`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data && data.test && typeof data.test.chainActive === 'boolean') {
|
||||
chainActive = data.test.chainActive;
|
||||
chainActiveEl.checked = chainActive;
|
||||
} else {
|
||||
chainActiveEl.checked = true;
|
||||
chainActive = true;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
chainActiveEl.checked = true;
|
||||
});
|
||||
|
||||
loadInitial();
|
||||
})();
|
||||
Reference in New Issue
Block a user