Browse Source

Миграция questions.ai_hint и подсказки в редакторе теста

- Alembic 0003: колонка ai_hint (TEXT NULL)
- API черновика: отдаём aiHint, сохраняем из payload
- Карточка вопроса: textarea подсказки для прохождения

Made-with: Cursor
dev
Константин Лебединский 2 weeks ago
parent
commit
09d996ead0
  1. 35
      flask_app/alembic/versions/0003_question_ai_hint.py
  2. 1
      flask_app/app/services/editor_content.py
  3. 7
      flask_app/app/services/test_draft.py
  4. 18
      flask_app/app/static/js/editor.js
  5. 12
      flask_app/app/templates/tests/editor.html

35
flask_app/alembic/versions/0003_question_ai_hint.py

@ -0,0 +1,35 @@
"""Добавление ai_hint в questions.
Revision ID: 0003_question_ai_hint
Revises: 0002_tests_hints_result_mode
Create Date: 2026-04-29
Колонка опциональная (TEXT NULL), как в ORM Question.ai_hint.
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
revision: str = "0003_question_ai_hint"
down_revision: Union[str, None] = "0002_tests_hints_result_mode"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS ai_hint TEXT NULL;
"""
)
def downgrade() -> None:
op.execute(
"""
ALTER TABLE questions DROP COLUMN IF EXISTS ai_hint;
"""
)

1
flask_app/app/services/editor_content.py

@ -51,6 +51,7 @@ def load_questions_for_version(session, test_version_id, *, include_correct: boo
'text': q.text, 'text': q.text,
'questionOrder': q.question_order, 'questionOrder': q.question_order,
'hasMultipleAnswers': bool(q.has_multiple_answers), 'hasMultipleAnswers': bool(q.has_multiple_answers),
'aiHint': (q.ai_hint or '').strip() or None,
'options': options, 'options': options,
}) })
return out return out

7
flask_app/app/services/test_draft.py

@ -122,12 +122,17 @@ def _replace_version_content(session: Session, version: TestVersion, payload: di
questions_payload = payload.get('questions') or [] questions_payload = payload.get('questions') or []
for i, qp in enumerate(questions_payload): for i, qp in enumerate(questions_payload):
q_text = (qp.get('text') or '').strip() q_text = (qp.get('text') or '').strip()
hint_in = qp.get('aiHint')
if hint_in is not None:
hint_val = (str(hint_in).strip() or None)
else:
hint_val = old_hints.get(q_text)
new_q = Question( new_q = Question(
test_version_id=version.id, test_version_id=version.id,
text=q_text, text=q_text,
question_order=qp.get('question_order') or (i + 1), question_order=qp.get('question_order') or (i + 1),
has_multiple_answers=bool(qp.get('hasMultipleAnswers')), has_multiple_answers=bool(qp.get('hasMultipleAnswers')),
ai_hint=old_hints.get(q_text), ai_hint=hint_val,
) )
session.add(new_q) session.add(new_q)
session.flush() session.flush()

18
flask_app/app/static/js/editor.js

@ -109,6 +109,14 @@
syncOptionInputTypes(node); syncOptionInputTypes(node);
updateOptionsCounter(node); updateOptionsCounter(node);
updateAiButtonLabel(node); updateAiButtonLabel(node);
const hintEl = $('.q-hint', node);
if (hintEl) {
hintEl.value = q.aiHint || '';
const rh = () => autoResize(hintEl);
hintEl.addEventListener('input', () => { rh(); scheduleDirtyCheck(); });
requestAnimationFrame(rh);
}
return node; return node;
} }
@ -207,6 +215,8 @@
const qTextEl = $('.q-text', node); const qTextEl = $('.q-text', node);
qTextEl.value = ''; qTextEl.value = '';
autoResize(qTextEl); autoResize(qTextEl);
const qh = $('.q-hint', node);
if (qh) { qh.value = ''; autoResize(qh); }
$$('.opt-text', node).forEach((t) => { t.value = ''; autoResize(t); }); $$('.opt-text', node).forEach((t) => { t.value = ''; autoResize(t); });
$$('.opt-correct', node).forEach((c) => { c.checked = false; }); $$('.opt-correct', node).forEach((c) => { c.checked = false; });
updateAiButtonLabel(node); updateAiButtonLabel(node);
@ -406,16 +416,20 @@
// ─── collect ─────────────────────────────────────────────────────── // ─── collect ───────────────────────────────────────────────────────
function collectPayload() { function collectPayload() {
const questions = $$('#questions .q-item:not(.q-removed)').map((li, i) => ({ const questions = $$('#questions .q-item:not(.q-removed)').map((li, i) => {
const hintVal = ($('.q-hint', li) && $('.q-hint', li).value.trim()) || '';
return {
text: $('.q-text', li).value.trim(), text: $('.q-text', li).value.trim(),
question_order: i + 1, question_order: i + 1,
hasMultipleAnswers: $('.q-multi', li).checked, hasMultipleAnswers: $('.q-multi', li).checked,
aiHint: hintVal || null,
options: $$('.opt-item', li).map((op, j) => ({ options: $$('.opt-item', li).map((op, j) => ({
text: $('.opt-text', op).value.trim(), text: $('.opt-text', op).value.trim(),
isCorrect: $('.opt-correct', op).checked, isCorrect: $('.opt-correct', op).checked,
option_order: j + 1, option_order: j + 1,
})), })),
})); };
});
const payload = { const payload = {
title: titleEl.value.trim() || null, title: titleEl.value.trim() || null,
description: descEl.value.trim() || null, description: descEl.value.trim() || null,

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

@ -346,6 +346,18 @@
</button> </button>
<span class="q-options-count text-xs text-ink-400"></span> <span class="q-options-count text-xs text-ink-400"></span>
</div> </div>
<div class="mt-4 pt-3 border-t border-ink-200/80 q-hint-block">
<label class="block">
<span class="text-xs text-ink-600 font-medium mb-1 block">Подсказка при прохождении</span>
<textarea class="q-hint form-input w-full rounded-lg border border-ink-300 px-3 py-2 text-sm
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
rows="2" maxlength="8000"
placeholder="Краткий текст подсказки (если в параметрах теста включены подсказки и режим «Сразу после ответа»)"
style="resize:none; overflow:hidden; font-family:inherit;"></textarea>
</label>
<p class="mt-1.5 text-xs text-ink-400 leading-snug">Необязательно. Показывается участнику во всплывающем окне при верном ответе.</p>
</div>
</li> </li>
</template> </template>

Loading…
Cancel
Save