From 2a05f41b6509cb502ed332da1996af68bf3b1033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=82=D0=B8?= =?UTF-8?q?=D0=BD=20=D0=9B=D0=B5=D0=B1=D0=B5=D0=B4=D0=B8=D0=BD=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=B9?= Date: Mon, 27 Apr 2026 19:07:00 +0500 Subject: [PATCH 01/15] Redesign test editor: meta, content, AI shape, command bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split draft editor into AccSection Метаинформация and Содержание - AI generation: topic, question count (1–30), answers per question (2–8) - Move save and back-to-list to bottom command panel; remove AI from hero - Normalize generated options to requested count; sync ai topic on import draft - Add DOC/ШАГИ/ШАГ_2026-04-27_001.md and track design proposal doc Made-with: Cursor --- DOC/ШАГИ/ШАГ_2026-04-27_001.md | 20 + ...ИЗАЙН_СОЗДАНИЕ_ТЕСТА.md | 140 +++++ frontend/src/pages/TestDetail.jsx | 535 +++++++++++------- 3 files changed, 481 insertions(+), 214 deletions(-) create mode 100644 DOC/ШАГИ/ШАГ_2026-04-27_001.md create mode 100644 docs/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md diff --git a/DOC/ШАГИ/ШАГ_2026-04-27_001.md b/DOC/ШАГИ/ШАГ_2026-04-27_001.md new file mode 100644 index 0000000..072b144 --- /dev/null +++ b/DOC/ШАГИ/ШАГ_2026-04-27_001.md @@ -0,0 +1,20 @@ +# Шаг 2026-04-27 — редизайн формы редактора теста (ветка `dev-redisign`) + +## Сделано + +- Создана ветка `dev-redisign` от `dev` в репозитории `TestingWebApp`. +- Страница автора `frontend/src/pages/TestDetail.jsx` приведена к структуре из `docs/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md` (адаптация под существующий React/JSX, без Ant Design): + - блок **«Метаинформация»** — название, описание, порог зачёта; + - блок **«Содержание»** — мини-панель ИИ (тема, число вопросов 1…30, число вариантов 2…8, кнопка генерации) и список вопросов с локальными кнопками ИИ; + - панель **«Команды»** — «Сохранить черновик» (основная), «К списку»; строка статуса черновика под панелью. +- Кнопка **«Сгенерировать тест (ИИ)»** убрана из шапки; генерация строит `shape` из введённых чисел, тема — из поля «Тема» с запасным вариантом на «Название»; после ответа API варианты в каждом вопросе нормализуются к выбранному числу (добор/обрезка, минимум один верный). +- Копирование темы при загрузке редактора и при применении импорта/черновика LLM (`setAiGenTopic` при `applyGeneratedDraft`). + +## Бэкенд + +- Менять не требовалось: `POST .../ai/generate-test` уже принимает `shape` с `optionsCount` (см. `backend/src/services/aiEditorService.js`). + +## Проверка + +- `npm run lint` и `npm run build` в `TestingWebApp/frontend` — без ошибок. +- Ручной прогон `docker compose` по чек-листу из предложения — остаётся на стороне исполнителя. diff --git a/docs/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md b/docs/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md new file mode 100644 index 0000000..cf0eab5 --- /dev/null +++ b/docs/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md @@ -0,0 +1,140 @@ +# Предложение по редизайну страницы «Создание теста» + +**Ветка:** `dev-new-design-page-createtest` +**Затронутые файлы:** `frontend/src/components/TestForm/index.tsx`, `frontend/src/pages/TestCreate/index.tsx` (через общий компонент), частично `frontend/src/api/llm.ts` (расширение сигнатуры `generate`). +**Бэкенд:** менять не обязательно — у нас уже есть `POST /api/llm/generate` (см. `backend/app/api/llm.py`); нужно лишь принять параметры `questions_count` и `answers_count`. + +--- + +## 1. Цель + +Сделать форму создания теста читаемее: разбить «полотно» на три смысловые группы и чётко отделить редактируемое содержимое от служебных команд. Заодно — дать пользователю возможность сгенерировать тест сразу нужной формы, не нащёлкивая «+ вопрос» / «+ вариант» вручную. + +## 2. Текущее состояние (что есть) + +`TestForm/index.tsx` сейчас визуально устроен так: + +``` +┌─────────────────────────────────────────┐ +│ ← Назад Заголовок │ +├─────────────────────────────────────────┤ +│ Card «Основные настройки» │ +│ • название │ +│ • описание │ +│ • порог зачёта │ +│ • таймер │ +│ • разрешить возврат │ +├─────────────────────────────────────────┤ +│ [Сгенерировать с AI] [Проверить тест] │ ← вне карточек, между мета и вопросами +├─────────────────────────────────────────┤ +│ Card «Вопрос 1» │ +│ ... │ +│ Card «Вопрос N» │ +│ [+ Добавить вопрос] │ +├─────────────────────────────────────────┤ +│ [Создать тест] [Отмена] │ +└─────────────────────────────────────────┘ +``` + +Замечания: +- AI-кнопки висят «в воздухе» — не видно, к какой части формы они относятся. +- «Сгенерировать с AI» жёстко создаёт **7 вопросов** (см. `TestForm/index.tsx:123` — `llmApi.generate(title.trim(), 7)`), без выбора структуры. +- В fallback-сообщении ошибки упоминается «API ключ в настройках» (`TestForm/index.tsx:244`). + +## 3. Что меняем + +### 3.1. Три смысловых блока + +| Блок | Содержит | Визуально | +|------|----------|-----------| +| **Метаинформация** | название, описание, порог, таймер, навигация назад | `Card title="Метаинформация"` | +| **Содержание** | кнопка «Сгенерировать с AI», список вопросов, «+ Добавить вопрос» | `Card title="Содержание"` (вложенные карточки вопросов остаются) | +| **Команды** | «Создать тест», «Отмена», «Проверить тест» | блок `Space` снизу страницы | + +Кнопка **«Проверить тест»** логически — это команда над всем тестом, поэтому переезжает в нижнюю панель команд. Кнопка **«Сгенерировать с AI»** становится первым элементом блока «Содержание» — у неё там естественное место (она формирует именно содержание). + +### 3.2. Wireframe после редизайна + +``` +┌─────────────────────────────────────────┐ +│ ← Назад Создание теста │ +├─────────────────────────────────────────┤ +│ Card «Метаинформация» │ +│ • название │ +│ • описание │ +│ • порог зачёта │ +│ • таймер │ +│ • разрешить возврат │ +├─────────────────────────────────────────┤ +│ Card «Содержание» │ +│ ┌─ AI-генерация ────────────────────┐ │ +│ │ тема: [_________________] │ │ +│ │ вопросов: [7] вариантов: [3] │ │ +│ │ [🤖 Сгенерировать] │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ Card «Вопрос 1» ... │ +│ Card «Вопрос N» ... │ +│ [+ Добавить вопрос] │ +├─────────────────────────────────────────┤ +│ [Создать тест] [Проверить тест] [Отмена] │ +└─────────────────────────────────────────┘ +``` + +### 3.3. Форма AI-генерации с тремя полями + +Внутри блока «Содержание» — компактный мини-блок (не модалка) с тремя полями: + +| Поле | Тип | По умолчанию | Лимиты | +|------|-----|--------------|--------| +| Тема | `Input` | значение `title` из метаинформации (auto-fill) | непустая | +| Количество вопросов | `InputNumber` | 7 | 1…30 | +| Количество вариантов на вопрос | `InputNumber` | 3 | 2…8 | + +Кнопка **«Сгенерировать»** запускает текущий поток `handleGenerate` (модалка-превью → «Применить все вопросы»), но передаёт оба числа в API. Превью показывает то же, что и сейчас. + +Поведение существующих кнопок «Улучшить» и «Дистракторы» внутри карточки вопроса **не меняется** — они и так локальные. + +### 3.4. Уход от текста про API-ключи + +Единственное место в форме, где упоминается ключ, — fallback-сообщение об ошибке: + +```ts +// TestForm/index.tsx:244 +setReviewText('Не удалось получить рекомендации. Проверьте API ключ в настройках.') +``` + +Заменяем на нейтральное: + +```ts +setReviewText('Не удалось получить рекомендации. Попробуйте позже или обратитесь к администратору.') +``` + +Сама страница `Settings` остаётся как есть — это её прямое назначение, и её редактируют только администраторы. + +## 4. План работ (чек-лист для исполнителя) + +- [ ] **Backend** (опционально, если ещё не принимает параметры): расширить схему `LLMGenerateRequest` в `backend/app/schemas/llm.py` полями `questions_count: int = 7`, `answers_count: int = 3`; передать их в промпт сервиса `app/services/llm.py`. +- [ ] **API-клиент**: в `frontend/src/api/llm.ts` обновить сигнатуру `generate(topic, questionsCount, answersCount)`. +- [ ] **TestForm**: обернуть текущий блок «Основные настройки» в `Card title="Метаинформация"`. +- [ ] **TestForm**: создать новый `Card title="Содержание"`, в него поместить: + - мини-блок AI-генерации (3 поля + кнопка), + - текущий `Form.List` вопросов с кнопкой «+ Добавить вопрос». +- [ ] **TestForm**: убрать текущий ряд из двух AI-кнопок между мета и вопросами; «Проверить тест» перенести в нижнюю панель команд рядом с «Создать»/«Отмена». +- [ ] **TestForm**: добавить локальный стейт `aiQuestionsCount`, `aiAnswersCount`, инициализация: 7 / 3. +- [ ] **TestForm**: в `handleGenerate` передавать оба числа; при `Применить` создавать соответствующее число пустых ответов в каждом вопросе (если бэк прислал меньше — добить пустыми, если больше — обрезать). +- [ ] **TestForm**: заменить fallback-текст про API-ключ. +- [ ] Прогнать `docker compose up`, проверить вручную: создание с дефолтами, генерация на 5 вопросов × 4 ответа, проверка теста, отмена. +- [ ] Документ-шаг в `DOC/ШАГИ/ШАГ_<дата>_.md` по факту выполнения. + +## 5. Что **не** делаем в этой ветке + +- Не трогаем форму редактирования (`TestEdit`) — формально использует тот же `TestForm`, но эффект редизайна нужно отдельно проверить с уже заполненными вопросами и опциями версионирования. +- Не меняем поведение `Form.List` валидации (минимум 7 вопросов / 3 варианта) — это отдельная история. +- Не вводим drag-and-drop переупорядочивание вопросов. + +## 6. Открытые вопросы для согласования + +1. Нужно ли визуально подсвечивать мини-блок генерации (фон, бордер), чтобы он отличался от карточек вопросов? +2. После применения сгенерированных вопросов — нужно ли скрывать мини-блок, чтобы он не путал? +3. Лимиты «1…30 вопросов / 2…8 вариантов» — устраивают или нужны другие? diff --git a/frontend/src/pages/TestDetail.jsx b/frontend/src/pages/TestDetail.jsx index dbdeaa7..cb0ba00 100644 --- a/frontend/src/pages/TestDetail.jsx +++ b/frontend/src/pages/TestDetail.jsx @@ -85,6 +85,10 @@ export default function TestDetail() { const [importBusy, setImportBusy] = useState(false); const [aiTestBusy, setAiTestBusy] = useState(false); const [aiQBusy, setAiQBusy] = useState(null); + /** Параметры блока «Сгенерировать тест (ИИ)» (редизайн формы редактора) */ + const [aiGenTopic, setAiGenTopic] = useState(''); + const [aiQuestionsCount, setAiQuestionsCount] = useState(7); + const [aiAnswersCount, setAiAnswersCount] = useState(3); const [assignSearch, setAssignSearch] = useState(''); const [assignSearchApplied, setAssignSearchApplied] = useState(''); const [assignDept, setAssignDept] = useState('__all__'); @@ -119,6 +123,7 @@ export default function TestDetail() { setChain(c); if (ed?.test) { setDraftTitle(ed.test.title || ''); + setAiGenTopic((ed.test.title || '').trim()); setDraftDescription(ed.test.description || ''); const th = ed.test.passingThreshold; setDraftPassing( @@ -300,21 +305,41 @@ export default function TestDetail() { } } + function normalizeGeneratedQuestionOptions(q, targetCount) { + const n = Math.min(12, Math.max(2, targetCount)); + const raw = (q?.options || []).map((o) => ({ + key: newKey(), + text: (o.text || '').trim() || 'Вариант', + isCorrect: !!o.isCorrect, + })); + const out = raw.slice(0, n); + while (out.length < n) { + out.push({ key: newKey(), text: '', isCorrect: false }); + } + if (!out.some((o) => o.isCorrect)) { + out[0] = { ...out[0], isCorrect: true }; + } + return out; + } + async function runAiGenerateTest() { if (aiTestBusy || !id) { return; } + const nQ = Math.min(30, Math.max(1, Math.floor(Number(aiQuestionsCount)) || 7)); + const nA = Math.min(8, Math.max(2, Math.floor(Number(aiAnswersCount)) || 3)); + const topic = (aiGenTopic || draftTitle || '').trim() || 'Тест'; setDraftStatus(''); setAiTestBusy(true); try { - const shape = draftQuestions.map((q) => ({ - optionsCount: Math.max(2, Math.min(12, q.options?.length || 2)), - hasMultipleAnswers: q.hasMultipleAnswers, + const shape = Array.from({ length: nQ }, () => ({ + optionsCount: nA, + hasMultipleAnswers: false, })); const out = await api(`/api/tests/${id}/ai/generate-test`, { method: 'POST', body: JSON.stringify({ - testTitle: draftTitle, + testTitle: topic, testDescription: draftDescription, shape, }), @@ -330,11 +355,7 @@ export default function TestDetail() { key: newKey(), text: (q.text || '').trim() || 'Вопрос', hasMultipleAnswers: !!q.hasMultipleAnswers, - options: (q.options || []).map((o) => ({ - key: newKey(), - text: (o.text || '').trim() || 'Вариант', - isCorrect: !!o.isCorrect, - })), + options: normalizeGeneratedQuestionOptions(q, nA), })); if (qs.length) { setDraftQuestions(qs); @@ -449,6 +470,7 @@ export default function TestDetail() { return; } setDraftTitle((d.title || '').trim() || 'Без названия'); + setAiGenTopic((d.title || '').trim()); setDraftDescription( d.description != null && String(d.description).trim() ? String(d.description).trim() : '' ); @@ -615,21 +637,6 @@ export default function TestDetail() {

{formatTestAuthorLabel(user, test?.createdBy, test?.authorFullName)}

-
- -
- {test?.description && ( -

- {test.description} -

- )}

Обновлён: {fmtDt(test?.updatedAt || test?.createdAt)} {test?.chainActive === false && ( @@ -657,6 +664,296 @@ export default function TestDetail() { )} + +

+ + setDraftTitle(e.target.value)} + /> + + + +
+
+ +
+ + + + +
+
+ auto_awesome +

AI-помощник

+
+

+ Сгенерировать вопросы по текущей сетке (число вопросов и вариантов берётся из таблицы ниже). +

+
+ + + + + + +
+

+ Поддерживаются PDF, DOCX, TXT, MD (до 16 МБ). AI извлечёт текст и предложит черновик теста. +

+
+ + +
+
+

Вопросы (0)

+ +
+
    +
    + + +
    + +
    + + К каталогу + +
    +
    + + + + + + + +
    +
    +

    AI

    + +
    +
    +
    +
    +
    + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/flask_app/app/templates/tests/list.html b/flask_app/app/templates/tests/list.html new file mode 100644 index 0000000..8f5a709 --- /dev/null +++ b/flask_app/app/templates/tests/list.html @@ -0,0 +1,128 @@ +{% extends "base.html" %} +{% block title %}Тесты — каталог{% endblock %} + +{% block content %} +
    +
    +
    +

    Каталог тестов

    +

    Активные тесты, к которым у вас есть доступ.

    +
    + +
    + + {% if visible %} +
      + {% for t in visible %} +
    • +
      +

      {{ t.title }}

      + v{{ t.version }} +
      + {% if t.description %} +

      {{ t.description }}

      + {% endif %} +

      Автор: {{ t.author_full_name or '—' }}

      + +
    • + {% endfor %} +
    + {% else %} +

    Доступных тестов пока нет.

    + {% endif %} + + {% if hidden %} +
    + + Скрытые вами цепочки ({{ hidden|length }}) + +
      + {% for t in hidden %} +
    • + {{ t.title }} · v{{ t.version }} + Открыть +
    • + {% endfor %} +
    +
    + {% endif %} +
    + + +
    +
    +

    Новый тест

    +
    +
    + + +
    +
    + + +
    +
    +
    +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/flask_app/app/tests/__init__.py b/flask_app/app/tests/__init__.py new file mode 100644 index 0000000..6389bfe --- /dev/null +++ b/flask_app/app/tests/__init__.py @@ -0,0 +1,2 @@ +"""Blueprint `tests`: JSON API (`/api/tests/*`) и UI (`/tests`, `/tests//edit`).""" +from .routes import tests_bp # noqa: F401 diff --git a/flask_app/app/tests/routes.py b/flask_app/app/tests/routes.py new file mode 100644 index 0000000..f319cea --- /dev/null +++ b/flask_app/app/tests/routes.py @@ -0,0 +1,465 @@ +"""Маршруты тестов (E1.2). + +Покрытие Express → Flask: +- GET /api/tests/ — каталог + hidden by you +- POST /api/tests/ — создать тест (цепочку с версией 1) +- GET /api/tests//summary — краткая карточка +- GET /api/tests//versions — список версий + hasAttempts +- GET /api/tests//editor — контент редактора +- POST /api/tests//draft — saveTestDraft (fork если нужно) +- POST /api/tests//versions//activate +- PATCH /api/tests/ — chainActive +- POST /api/tests//ai/generate-test +- POST /api/tests//ai/generate-question + +UI-страницы: +- GET /tests — каталог +- GET /tests//edit — редактор (вызывает /api/tests/...) +""" +from __future__ import annotations + +import logging + +from flask import Blueprint, jsonify, render_template, request +from sqlalchemy import text + +from ..auth.decorators import current_user, login_required +from ..db import get_engine +from ..messages import RU +from ..services.ai_editor import ( + HttpError as AiHttpError, + check_test_quality, + generate_full_test_by_shape, + generate_or_rephrase_question, + generate_test_by_title, + improve_test_full, + parse_and_validate_shape, +) +from ..services.document_extract import ( + HttpError as DocExtractHttpError, + extract_text_from_file, +) +from ..services.document_gen import generation_for_import_document +from ..services.draft_validator import LlmError +from ..services.editor_content import HttpError as EditorHttpError, get_editor_content +from ..services.test_access import is_test_author, list_hidden_by_author, list_visible_tests +from ..services.test_chain import has_any_attempt_for_test +from ..services.test_draft import ( + HttpError as DraftHttpError, + create_test_with_version, + save_test_draft, +) + +log = logging.getLogger(__name__) + +tests_bp = Blueprint('tests', __name__) + + +# ─── helpers ───────────────────────────────────────────────────────── + +def _stringify_uuids(d: dict) -> dict: + """Преобразует UUID-поля в строки для безопасной JSON-сериализации.""" + out = {} + for k, v in d.items(): + if hasattr(v, 'hex') and not isinstance(v, (str, bytes)): + out[k] = str(v) + else: + out[k] = v + return out + + +def _check_test_author_or_404(test_id: str, user_id: str) -> dict: + """Загружает {id, created_by}; 404 если нет, 403 если не автор.""" + eng = get_engine() + with eng.connect() as conn: + row = conn.execute( + text('SELECT id, created_by FROM tests WHERE id = :id'), + {'id': test_id}, + ).mappings().first() + if not row: + from werkzeug.exceptions import NotFound + + raise NotFound(RU['notFound']) + if not is_test_author(row['created_by'], user_id): + from werkzeug.exceptions import Forbidden + + raise Forbidden('Доступ запрещён.') + return dict(row) + + +# ─── JSON API ──────────────────────────────────────────────────────── + +@tests_bp.route('/api/tests/', methods=['GET']) +@tests_bp.route('/api/tests', methods=['GET']) +@login_required +def api_list_tests(): + user = current_user() + visible = list_visible_tests(user.id) + hidden = list_hidden_by_author(user.id) + return jsonify( + tests=[_stringify_uuids(r) for r in visible], + hiddenByYou=[_stringify_uuids(r) for r in hidden], + ) + + +@tests_bp.route('/api/tests/', methods=['POST']) +@tests_bp.route('/api/tests', methods=['POST']) +@login_required +def api_create_test(): + user = current_user() + body = request.get_json(silent=True) or {} + title = body.get('title') + if not isinstance(title, str) or not title.strip(): + return jsonify(error='Укажите название.'), 400 + out = create_test_with_version(user.id, title=title.strip(), description=body.get('description')) + return jsonify(out), 201 + + +@tests_bp.route('/api/tests//summary', methods=['GET']) +@login_required +def api_test_summary(test_id): + user = current_user() + eng = get_engine() + with eng.connect() as conn: + row = conn.execute( + text( + """ + SELECT t.id, t.title, t.description, t.passing_threshold, t.is_active AS chain_active, + t.created_by, t.created_at, t.updated_at, + tv.id AS active_version_id, tv.version, + u.full_name AS author_full_name + FROM tests t + LEFT JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true + LEFT JOIN users u ON u.id = t.created_by + WHERE t.id = :id + """ + ), + {'id': test_id}, + ).mappings().first() + if not row: + return jsonify(error=RU['notFound']), 404 + + is_author = is_test_author(row['created_by'], user.id) + if not row['chain_active'] and not is_author: + return jsonify(error=RU['notFound']), 404 + + if not is_author: + from ..services.test_access import user_has_test_access + + acc = user_has_test_access(user.id, test_id) + if not acc.ok: + return jsonify(error=RU['notFound']), 404 + + return jsonify( + test={ + 'id': str(row['id']), + 'title': row['title'], + 'description': row['description'], + 'passingThreshold': row['passing_threshold'], + 'chainActive': row['chain_active'], + 'activeVersionId': str(row['active_version_id']) if row['active_version_id'] else None, + 'version': row['version'], + 'createdAt': row['created_at'].isoformat() if row['created_at'] else None, + 'updatedAt': row['updated_at'].isoformat() if row['updated_at'] else None, + 'createdBy': str(row['created_by']) if row['created_by'] else None, + 'authorFullName': row['author_full_name'], + }, + isAuthor=is_author, + hasActiveVersion=row['active_version_id'] is not None, + ) + + +@tests_bp.route('/api/tests//versions', methods=['GET']) +@login_required +def api_test_versions(test_id): + user = current_user() + eng = get_engine() + with eng.connect() as conn: + t = conn.execute( + text( + """ + SELECT t.id, t.title, t.created_by, t.is_active, t.created_at, t.updated_at, + t.description, u.full_name AS author_full_name + FROM tests t + INNER JOIN users u ON u.id = t.created_by + WHERE t.id = :id + """ + ), + {'id': test_id}, + ).mappings().first() + if not t: + return jsonify(error=RU['notFound']), 404 + if not is_test_author(t['created_by'], user.id): + return jsonify(error='Доступ запрещён.'), 403 + + rows = conn.execute( + text( + 'SELECT id, version, is_active, parent_id, created_at ' + 'FROM test_versions WHERE test_id = :id ORDER BY version' + ), + {'id': test_id}, + ).mappings().all() + has_attempts = has_any_attempt_for_test(conn, test_id) + + return jsonify( + test={ + 'id': str(t['id']), + 'title': t['title'], + 'description': t['description'], + 'chainActive': t['is_active'], + 'createdAt': t['created_at'].isoformat() if t['created_at'] else None, + 'updatedAt': t['updated_at'].isoformat() if t['updated_at'] else None, + 'createdBy': str(t['created_by']) if t['created_by'] else None, + 'authorFullName': t['author_full_name'], + }, + versions=[ + { + 'id': str(r['id']), + 'version': r['version'], + 'is_active': r['is_active'], + 'parent_id': str(r['parent_id']) if r['parent_id'] else None, + 'created_at': r['created_at'].isoformat() if r['created_at'] else None, + } + for r in rows + ], + hasAttempts=has_attempts, + ) + + +@tests_bp.route('/api/tests//editor', methods=['GET']) +@login_required +def api_test_editor(test_id): + user = current_user() + try: + out = get_editor_content(user.id, test_id) + except EditorHttpError as e: + return jsonify(error=e.message), e.status + return jsonify(out) + + +@tests_bp.route('/api/tests//draft', methods=['POST']) +@login_required +def api_save_draft(test_id): + user = current_user() + payload = request.get_json(silent=True) or {} + try: + out = save_test_draft(user.id, test_id, payload) + except DraftHttpError as e: + return jsonify(error=e.message), e.status + return jsonify(out) + + +@tests_bp.route('/api/tests//versions//activate', methods=['POST']) +@login_required +def api_activate_version(test_id, version_id): + user = current_user() + _check_test_author_or_404(test_id, user.id) + eng = get_engine() + with eng.begin() as conn: + v = conn.execute( + text('SELECT id FROM test_versions WHERE test_id = :t AND id = :v'), + {'t': test_id, 'v': version_id}, + ).first() + if not v: + return jsonify(error='Версия не найдена.'), 404 + conn.execute( + text('UPDATE test_versions SET is_active = false WHERE test_id = :t'), + {'t': test_id}, + ) + conn.execute( + text('UPDATE test_versions SET is_active = true WHERE id = :v'), + {'v': version_id}, + ) + return jsonify(ok=True, activeVersionId=str(version_id)) + + +@tests_bp.route('/api/tests/', methods=['PATCH']) +@login_required +def api_patch_test(test_id): + user = current_user() + body = request.get_json(silent=True) or {} + chain = body.get('chainActive', body.get('isActive')) + if not isinstance(chain, bool): + return jsonify(error='Передайте chainActive: true/false в теле запроса.'), 400 + _check_test_author_or_404(test_id, user.id) + eng = get_engine() + with eng.begin() as conn: + conn.execute( + text( + 'UPDATE tests SET is_active = :v, updated_at = CURRENT_TIMESTAMP WHERE id = :id' + ), + {'v': chain, 'id': test_id}, + ) + return jsonify(id=test_id, chainActive=chain) + + +# ─── AI ────────────────────────────────────────────────────────────── + +@tests_bp.route('/api/tests//ai/generate-test', methods=['POST']) +@login_required +def api_ai_generate_test(test_id): + user = current_user() + _check_test_author_or_404(test_id, user.id) + body = request.get_json(silent=True) or {} + try: + shape = parse_and_validate_shape(body.get('shape')) + draft = generate_full_test_by_shape( + body.get('testTitle') or '', + body.get('testDescription') or '', + shape, + ) + except (AiHttpError, LlmError) as e: + return _ai_error_response(e) + return jsonify(ok=True, draft=draft) + + +@tests_bp.route('/api/tests//ai/generate-question', methods=['POST']) +@login_required +def api_ai_generate_question(test_id): + user = current_user() + _check_test_author_or_404(test_id, user.id) + body = request.get_json(silent=True) or {} + try: + out = generate_or_rephrase_question( + body.get('testTitle') or '', + body.get('testDescription') or '', + body.get('questionText') or '', + body.get('optionsCount'), + bool(body.get('hasMultipleAnswers')), + ) + except (AiHttpError, LlmError) as e: + return _ai_error_response(e) + return jsonify(ok=True, **out) + + +# ─── AI v2 (E1.8) ──────────────────────────────────────────────────── + +def _ai_error_response(e): + """Единый JSON-формат ошибки для AI-эндпоинтов.""" + if isinstance(e, AiHttpError): + return jsonify(error=e.message), e.status + if isinstance(e, LlmError): + log.warning('LLM error: %s (%s)', e, e.code) + return ( + jsonify(error=str(e), code=e.code, settingsUrl='/settings'), + e.status or 502, + ) + raise e + + +@tests_bp.route('/api/tests//ai/generate-by-title', methods=['POST']) +@login_required +def api_ai_generate_by_title(test_id): + user = current_user() + _check_test_author_or_404(test_id, user.id) + body = request.get_json(silent=True) or {} + title = (body.get('testTitle') or '').strip() + if not title: + return jsonify(error='Заполните название теста.'), 400 + try: + draft = generate_test_by_title( + title, + body.get('testDescription') or '', + int(body.get('questionsCount') or 10), + int(body.get('optionsCount') or 4), + bool(body.get('hasMultipleAnswers')), + ) + except (AiHttpError, LlmError) as e: + return _ai_error_response(e) + return jsonify(ok=True, draft=draft) + + +@tests_bp.route('/api/tests//ai/check', methods=['POST']) +@login_required +def api_ai_check_test(test_id): + user = current_user() + _check_test_author_or_404(test_id, user.id) + body = request.get_json(silent=True) or {} + try: + review = check_test_quality( + body.get('testTitle') or '', + body.get('testDescription') or '', + body.get('questions') or [], + ) + except (AiHttpError, LlmError) as e: + return _ai_error_response(e) + return jsonify(ok=True, review=review) + + +@tests_bp.route('/api/tests//ai/improve', methods=['POST']) +@login_required +def api_ai_improve_test(test_id): + user = current_user() + _check_test_author_or_404(test_id, user.id) + body = request.get_json(silent=True) or {} + try: + out = improve_test_full( + body.get('testTitle') or '', + body.get('testDescription') or '', + body.get('questions') or [], + ) + except (AiHttpError, LlmError) as e: + return _ai_error_response(e) + return jsonify(ok=True, **out) + + +# ─── Импорт документа (E1.3) ──────────────────────────────────────── + +@tests_bp.route('/api/tests/import/document', methods=['POST']) +@login_required +def api_import_document(): + """PDF/DOCX/TXT/MD → извлечённый текст + AI-черновик (если задан LLM-ключ). + + Ограничения: размер файла — `MAX_CONTENT_LENGTH = 16 МБ` (см. фабрику). + """ + f = request.files.get('file') + if f is None or not f.filename: + return jsonify(error='Прикрепите файл к полю file.'), 400 + try: + extracted = extract_text_from_file(f.mimetype, f, f.filename) + except DocExtractHttpError as e: + return jsonify(error=e.message), e.status + except Exception: + log.exception('extract_text_from_file failed') + return jsonify(error='Не удалось разобрать файл.'), 500 + + generation = generation_for_import_document(extracted) + return jsonify( + received=True, + originalName=f.filename, + mime=f.mimetype, + size=len(extracted.encode('utf-8')), + extractedText=extracted, + textLength=len(extracted), + generation=generation, + ) + + +# ─── UI (Jinja) ────────────────────────────────────────────────────── + +@tests_bp.route('/tests', methods=['GET']) +@login_required +def tests_list_page(): + user = current_user() + visible = list_visible_tests(user.id) + hidden = list_hidden_by_author(user.id) + return render_template( + 'tests/list.html', + visible=[_stringify_uuids(r) for r in visible], + hidden=[_stringify_uuids(r) for r in hidden], + ) + + +@tests_bp.route('/tests//edit', methods=['GET']) +@login_required +def tests_editor_page(test_id): + user = current_user() + try: + content = get_editor_content(user.id, test_id) + except EditorHttpError as e: + if e.status == 404: + return render_template('404.html'), 404 + if e.status == 403: + return ('Доступ запрещён.', 403) + return render_template('500.html'), 500 + return render_template('tests/editor.html', content=content, test_id=test_id) diff --git a/flask_app/requirements.txt b/flask_app/requirements.txt index 2c46c11..3767101 100644 --- a/flask_app/requirements.txt +++ b/flask_app/requirements.txt @@ -1,3 +1,17 @@ Flask>=3.0.0,<4 python-dotenv>=1.0.0 waitress>=3.0.0 + +# Этап 1 (E1.0): тот же стек, что в HR_TG_Bot/tgFlaskForm. +# SQLAlchemy + psycopg2 драйвер для PostgreSQL. +SQLAlchemy>=2.0.0,<3 +psycopg2-binary>=2.9.0,<3 + +# Этап 1 (E1.1): авторизация. bcrypt — для локальных хешей в clinic_tests.users. +# Werkzeug-хеши (scrypt/pbkdf2) проверяет встроенный werkzeug.security. +bcrypt>=4.0.0,<5 + +# Этап 1 (E1.3): импорт документов (PDF/DOCX) → AI-черновик. +pypdf>=4.0.0,<6 +python-docx>=1.1.0,<2 + From 547840d6713cecf05182772e8940c1c7bacea201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=82=D0=B8?= =?UTF-8?q?=D0=BD=20=D0=9B=D0=B5=D0=B1=D0=B5=D0=B4=D0=B8=D0=BD=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=B9?= Date: Mon, 27 Apr 2026 23:50:38 +0500 Subject: [PATCH 12/15] docs(qa): tester guide for versioning and AI features Made-with: Cursor --- docs/QA-versioning-and-ai.md | 301 +++++++++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 docs/QA-versioning-and-ai.md diff --git a/docs/QA-versioning-and-ai.md b/docs/QA-versioning-and-ai.md new file mode 100644 index 0000000..a504b15 --- /dev/null +++ b/docs/QA-versioning-and-ai.md @@ -0,0 +1,301 @@ +# Инструкция для тестировщика: версионирование тестов и AI-функции + +> Контур: новый Flask на `:3108` (после E1.0–E1.3, E1.8). Старый Node — на `:3107`, +> для проверки этих задач **используйте только `:3108`**. + +Перед началом: + +1. Откройте `http://:3108/login` под учёткой автора (роль `manager` или + `admin`). Если сидов нет — заведите пользователя в `clinic_tests.users` + обычным способом. +2. Для AI-задач: откройте `http://:3108/settings` и убедитесь, что + статус ключа = «Задан» и кнопка **«Проверить подключение»** возвращает + зелёный **OK · provider/model · NN мс**. Если ключ не задан — AI-задачи + нужно прогнать в негативном сценарии (см. блок A.0). +3. Откройте `http://:3108/tests` — это каталог. + +--- + +## Часть 1. Версионирование при правке после попыток + +### Что должно работать + +| Правило | Поведение | +|---|---| +| Нет попыток | Автор правит тест **на месте**, номер версии не меняется. | +| Есть ≥ 1 попытка | Любое сохранение изменений **создаёт новую версию** (`version + 1`), старая становится неактивной, но **сохраняется в БД** и связана через `parent_id`. | +| Цепочка | Все версии связаны (parent → child), на странице «Версии» видны все. | +| Каталог | В списке видна **только активная** версия цепочки. | +| Переключение активной версии | Автор может вручную сделать активной любую версию — остальные автоматически становятся неактивными. | +| Деактивация цепочки | Тест можно скрыть целиком; данные не удаляются. | +| Корректность истории | Каждая попытка привязана к **той версии**, по которой её проходили — разбор ошибок остаётся корректным после правок. | + +### Сценарий 1.1. Правка теста до попыток (версия не растёт) + +1. В каталоге → **«Создать тест»**, заполните название и описание → создать. +2. В редакторе добавьте 2–3 вопроса по 3–4 варианта, сохраните. +3. Откройте этот же тест в редакторе ещё раз, измените: + - название; + - описание; + - текст одного вопроса; + - пометьте один вариант как правильный иначе; + - удалите/добавьте вариант. +4. **«Сохранить»**. + +**Ожидается:** +- Сообщение «Сохранено.» (без слов «создана новая версия»). +- В БД: `SELECT version FROM test_versions WHERE test_id = ''` → **одна** строка с `version = 1, is_active = true`. +- Эндпоинт `GET /api/tests//versions` → массив из 1 элемента, `hasAttempts: false`. + +### Сценарий 1.2. Появление первой попытки → форк новой версии + +> Прохождение теста (UI) пока не реализовано в Flask-контуре (запланировано +> в **E1.4**). Поэтому факт попытки имитируется напрямую в БД — это норма +> для текущего этапа. + +1. Возьмите `id` теста и `id` активной версии: + ```sql + SELECT t.id AS test_id, tv.id AS version_id + FROM tests t JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active + WHERE t.title = 'Ваш тест'; + ``` +2. Создайте «попытку» (минимальный INSERT, любой пользователь, любое + состояние; нам нужен только сам факт записи в `test_attempts`): + ```sql + INSERT INTO test_attempts (id, test_version_id, user_id, status, created_at) + VALUES (gen_random_uuid(), '', '', 'completed', now()); + ``` +3. В UI откройте редактор того же теста, **измените хотя бы один вопрос** + (текст / правильность варианта / число вариантов) и **«Сохранить»**. + +**Ожидается:** +- Сообщение «Сохранено (создана новая версия — есть попытки прохождения).» +- В БД: `SELECT version, is_active, parent_id FROM test_versions WHERE test_id=...` + → **две** строки: + - `version = 1, is_active = false, parent_id = NULL` (старая, не удалена); + - `version = 2, is_active = true, parent_id = ` (новая). +- Старая попытка по-прежнему ссылается на `version_id` из v1, и её ответы/вопросы остаются те же — разбор ошибок не «съехал». + +### Сценарий 1.3. Правка только метаданных после попыток (без форка) + +После сценария 1.2: + +1. В редакторе **не трогайте** вопросы и варианты. Поменяйте только название + или описание или проходной балл. Сохраните. + +**Ожидается:** +- Сообщение «Сохранено.» (без форка). +- `version` не вырос; метаданные обновились на активной версии. + +> Логика: форк делается только если после попыток меняется **содержание** +> (вопросы/варианты). Чисто косметические правки шапки версию не плодят. + +### Сценарий 1.4. Каталог показывает только активную версию + +1. После 1.2 откройте `/tests` под автором и под не-автором (если есть назначения). + +**Ожидается:** +- В каталоге — одна карточка теста с пометкой `v.2` (активная). Версия 1 в каталог не попадает, но видна автору на странице «Версии» теста. + +### Сценарий 1.5. Ручное переключение активной версии + +1. Получите id v1 и v2: `SELECT id, version, is_active FROM test_versions WHERE test_id=...` +2. Сделайте активной v1: + ```bash + curl -X POST -b cookies.txt \ + http://:3108/api/tests//versions//activate + ``` + (cookie сессии берёте из браузера или логином через `/api/auth/login`). + +**Ожидается:** +- Ответ `{ ok: true, activeVersionId: "" }`. +- В БД: `is_active = true` только у v1, у v2 — `false`. +- В каталоге карточка теста снова показывает `v.1`. + +### Сценарий 1.6. Деактивация цепочки целиком + +1. В редакторе снимите чекбокс **«Цепочка активна»** и сохраните. + +**Ожидается:** +- В каталоге `/tests` теста больше не видно (ни в visible, ни у не-авторов). +- У автора он появляется в блоке **«Скрытые вами»** (внизу каталога). +- В БД: `tests.is_active = false`, версии и попытки нетронуты. +- Включение чекбокса обратно возвращает тест в каталог. + +### Сценарий 1.7. Корректность истории по старым попыткам + +> Полноценный разбор пользовательских ответов появится в **E1.4** вместе +> с UI прохождения. Сейчас минимально проверяем, что данные старой версии +> не повреждены. + +1. После 1.2 в БД: + ```sql + SELECT q.text + FROM questions q + JOIN test_versions tv ON tv.id = q.test_version_id + WHERE tv.test_id = '' AND tv.version = 1; + ``` +2. **Ожидается:** видны вопросы **в том виде, в каком они были до правки** + (а не текущая версия v2). Эта же выборка должна совпадать с + `q.test_version_id` любой попытки, которую вы создали в 1.2. + +### Что фиксировать как баг + +- После правки **с попытками** в БД остался один `test_versions`-ряд (нет форка). +- После правки **без попыток** появилась `version = 2` (лишний форк). +- При активации одной версии другие не сбросились в `is_active = false`. +- Каталог показывает неактивную версию или скрытый тест. +- Сообщение в UI после сохранения не совпадает с реальным поведением (форк есть, текст «Сохранено.» без уточнения, или наоборот). + +--- + +## Часть 2. AI-функции (E1.2 + E1.8) + +### Заметка о ключе + +Изначально в ТЗ предполагалось хранить ключ в БД и вводить на `/settings`. +По согласованию ключ **общий** и хранится в `ENV` контейнера +(`DEEPSEEK_API_KEY` / `OPENAI_API_KEY`). Страница `/settings` остаётся, +но в ней — только статус и кнопка проверки подключения. Поле ввода ключа в UI **не нужно** (это не баг). + +### A.0. Негативный кейс — ключ не задан + +1. На сервере уберите `DEEPSEEK_API_KEY` из окружения и перезапустите контейнер. +2. Откройте `/settings`. + +**Ожидается:** +- Бейдж «Не задан» (красный). +- Блок «Как задать ключ» с примером `.env`. +- Кнопка **«Проверить подключение»** возвращает красный блок с текстом про незаданный ключ. +- В редакторе при нажатии **«Сгенерировать по сетке» / «по названию» / «Проверить тест» / «Улучшить тест» / «AI: вопрос»** появляется confirm: + «… Открыть Настройки?» → согласие открывает `/settings`. + +После проверки верните ключ и `docker compose ... up -d` — переходим к позитивным сценариям. + +### A.1. `/settings` → «Проверить подключение» + +1. На `/settings` нажмите **«Проверить подключение»**. + +**Ожидается:** +- В течение нескольких секунд — зелёный блок: `OK · / · мс` и сэмпл ответа. +- Provider/Model совпадают с ENV (`deepseek` + `deepseek-chat` по умолчанию). + +### A.2. «Сгенерировать тест по названию» (E1.8) + +1. Создайте новый пустой тест (никаких вопросов). +2. В редакторе нажмите **«Сгенерировать по названию»**. +3. На запрос «Сколько вопросов?» введите, например, `8`; «Сколько вариантов?» — `4`. +4. Дождитесь готовности → confirm «Применить как черновик?». + +**Ожидается:** +- Кнопка **активна только** когда поле «Название» заполнено. Если очистить название — нажатие даёт алерт «Сначала заполните название теста.» и фокус возвращается в название. +- Появляется ровно ~8 вопросов по ~4 варианта (модель может слегка отклониться по инструкции, это допустимо). +- Тексты — на русском, по теме названия. +- В каждом вопросе хотя бы один вариант помечен как правильный. +- Отказ в confirm не меняет редактор; согласие — заменяет. +- Сохранение работает, в БД появляется версия с этими вопросами. + +### A.3. «Сгенерировать тест по сетке» (E1.2 — было) + +1. Откройте тест, в котором уже руками настроены 5 вопросов, по 3 варианта в каждом, 2 из вопросов помечены как «Несколько правильных». +2. Нажмите **«Сгенерировать по сетке»**. + +**Ожидается:** +- Возвращается ровно 5 вопросов. +- В тех же позициях, что у вас стояли «Несколько правильных», — у новых вопросов несколько правильных вариантов. +- Число вариантов в каждом вопросе совпадает. +- При несовпадении сетки эндпоинт вернул бы 502 с кодом `llm_shape` (модель «не попала») — допустимая редкая ошибка, повторите. + +### A.4. «Проверить тест» (E1.8) + +1. Создайте тест с парой нарочно слабых мест: + - один вопрос с длинной мутной формулировкой; + - один вопрос, где все варианты слишком похожи или в качестве «дистракторов» — очевидная ерунда. +2. Нажмите **«Проверить тест»**. + +**Ожидается:** +- Открывается модалка «Проверка теста». +- Есть цветная плашка с одним из вердиктов: **Годен / Есть замечания / Серьёзные проблемы**, и краткое резюме (1–2 предложения). +- Ниже — список разделов («Чёткость формулировок», «Качество дистракторов», «Охват темы», «Сбалансированность сложности»). Разделы без замечаний пропускаются. +- В списке — конкретные пункты на русском, по делу. +- Закрытие модалки крестиком или кнопкой «Закрыть» работает. + +### A.5. «Улучшить тест» (E1.8) — массовое было → стало + +1. Возьмите тест из A.4 (с ≥ 3 вопросами). +2. Нажмите **«Улучшить тест»**. + +**Ожидается:** +- Открывается модалка с заголовком «Улучшение теста» и подсказкой «Отмечено N из M». +- Каждый изменённый вопрос — отдельная карточка: + - чекбокс «Вопрос #N» (по умолчанию **отмечен**); + - две колонки **Было** / **Стало**; + - изменённый текст в «Было» зачёркнут, в «Стало» — выделен; + - правильные варианты помечены ✓. +- Снимите галку с одного-двух вопросов и нажмите **«Применить выбранное»**. +- В редакторе **только** отмеченные вопросы заменены на улучшенные; остальные остались как были. +- Появляется надпись «Изменения применены. Не забудьте сохранить.» — нажмите **«Сохранить»** и проверьте версионирование (см. Часть 1). +- **Сетка не меняется**: число вопросов, число вариантов в каждом и значение «Несколько правильных» совпадают с исходными. Если модель «слетела» — эндпоинт возвращает 502 с `llm_shape` и UI показывает алерт; это не баг логики. + +### A.6. AI-кнопка на конкретном вопросе (E1.2) + +Сценарий «новый вопрос»: + +1. Добавьте вопрос, **поле текста оставьте пустым**, число вариантов = 4, «Несколько правильных» — выкл. +2. Нажмите **«AI: вопрос/переформулировать»** на этом вопросе. + +**Ожидается:** заполнен текст вопроса и все 4 варианта; ровно один помечен правильным; внизу — статус «AI: вопрос сгенерирован.» + +Сценарий «переформулировать»: + +1. Возьмите готовый вопрос с заполненным текстом. +2. Нажмите ту же кнопку. + +**Ожидается:** меняется **только текст** вопроса (вариант ответа и правильность не трогаются), статус «AI: формулировка обновлена.» + +### A.7. Импорт документа (E1.3) + +1. Подготовьте файл `sample.pdf` или `sample.docx` со связным текстом (1–3 страницы) на русском. +2. В редакторе → AI-панель → **«Импорт документа»** → выберите файл. + +**Ожидается:** +- Прогресс «Загружаем «sample.pdf»…». +- Confirm «Сгенерировано: «», вопросов: N. Применить как новый черновик?» → согласие заменяет вопросы, отказ ничего не меняет. +- При файле > 16 МБ — ошибка от Flask (413/500), это норма (лимит). +- При файле неподдерживаемого формата (`.xlsx`, `.png`) — алерт «Неподдерживаемый формат…». +- При **отсутствующем** ключе AI: вместо confirm — алерт с текстом «Автогенерация выключена…» и первыми 600 символами извлечённого текста; вопросы **не** заменяются. + +### A.8. Единая ошибка при отсутствии ключа + +1. Уберите ключ (как в A.0). Откройте редактор любого теста. +2. По очереди нажимайте **«Сгенерировать по сетке»**, **«по названию»**, **«Проверить тест»**, **«Улучшить тест»**, **«AI: вопрос»**. + +**Ожидается:** в каждом случае confirm с предложением открыть **/settings**, не молчаливый алерт. На бэке — JSON `{ error, code, settingsUrl: "/settings" }`, статус 502. + +### Что фиксировать как баг (AI) + +- Кнопка **«Сгенерировать по названию»** позволяет жать без названия и не показывает алерт. +- Модалка **«Проверить тест»** пуста или содержит англ. текст. +- В **«Улучшить тест»** меняется число вопросов / число вариантов / «Несколько правильных», т.е. сетка слетела, и UI всё равно применил. +- В **«AI: вопрос»** на пустом вопросе варианты не сгенерированы; на заполненном — варианты заменены без ведома пользователя. +- Любая AI-ошибка показывается без ссылки на `/settings`, когда ключ действительно отсутствует. +- Импорт документа подменяет вопросы **без confirm**. + +--- + +## Шпаргалка: что и где смотреть + +| Что | URL / SQL | +|---|---| +| Каталог | `/tests` | +| Редактор | `/tests/<id>/edit` | +| Версии теста | `GET /api/tests/<id>/versions` | +| Активность LLM | `/settings` + `POST /api/llm/ping` | +| `test_versions` | `SELECT id, version, is_active, parent_id, created_at FROM test_versions WHERE test_id = '<id>' ORDER BY version;` | +| Попытки | `SELECT id, test_version_id, status, created_at FROM test_attempts WHERE test_version_id IN (SELECT id FROM test_versions WHERE test_id='<id>');` | + +При баге прикладывайте: +- скрин редактора / модалки; +- ответ соответствующего эндпоинта (DevTools → Network → JSON); +- результат SQL из таблицы выше; +- `docker compose -f docker-compose.dev.yml logs --tail=200 testing-flask`. From 2d6d75fb3c936043e31c09a216dfb00bea406bbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=82=D0=B8?= =?UTF-8?q?=D0=BD=20=D0=9B=D0=B5=D0=B1=D0=B5=D0=B4=D0=B8=D0=BD=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=B9?= <lebedinsky.kd@gmail.com> Date: Mon, 27 Apr 2026 23:55:39 +0500 Subject: [PATCH 13/15] =?UTF-8?q?ui(mobile):=20=D0=BF=D0=BE=D0=BB=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D1=80=D0=B0=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B0=20=D0=B8=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Только layout/spacing/touch-targets, без изменения цветовой палитры и типографики. editor.html - Шапка теста: «Название» — отдельной строкой, large input; «Описание» ниже; «Проходной балл» — компактная пара label+input справа, inputmode="numeric". - AI-панель разбита на 3 группы с подзаголовками: «Создать вопросы» (По названию / По текущей сетке), «Улучшить существующее» (Проверить / Улучшить), «Импортировать» (загрузка файла). - Все основные кнопки — min-h-11, на мобиле во всю ширину/в гриде по 2. - Карточка вопроса: бейдж-номер, кнопки up/down/delete по 40×40, textarea и опции — на всю ширину с min-w-0 чтобы не было overflow. - Опции: чекбокс «Правильный» в 40×40 tap-target, input занимает flex, кнопка удаления 40×40. - Footer переведён на fixed bottom с safe-area-inset-bottom; контент получает pb-24, чтобы не уезжал под футер. - Модалка AI-результата теперь fullscreen на мобиле, sm:rounded-2xl на десктопе; шапка/тело/кнопки — отдельными зонами. list.html - Заголовок и кнопка «Создать тест» вертикально на мобиле, кнопка во всю ширину min-h-11. - Карточка теста — целиком кликабельная (`<a>` обёртка), grid-cols-1 по умолчанию, sm:2, lg:3. - Модалка создания — fullscreen на мобиле с крестиком в шапке, safe-area-inset-bottom в футере. base.html - Ссылки «Тесты» и «Настройки» теперь видны и на мобиле как иконки (40×40 tap-target), подписи появляются с sm: брейкпоинта. - Имя/роль пользователя — только с md+ (узкий мобильный экран). Made-with: Cursor --- flask_app/app/templates/base.html | 32 +-- flask_app/app/templates/tests/editor.html | 283 +++++++++++++--------- flask_app/app/templates/tests/list.html | 73 +++--- 3 files changed, 227 insertions(+), 161 deletions(-) diff --git a/flask_app/app/templates/base.html b/flask_app/app/templates/base.html index e3c40f0..483e698 100644 --- a/flask_app/app/templates/base.html +++ b/flask_app/app/templates/base.html @@ -57,39 +57,43 @@ <span class="material-symbols-outlined text-brand-600">quiz</span> <span>Тестирование</span> </a> - <nav class="flex items-center gap-2 text-sm"> + <nav class="flex items-center gap-1 sm:gap-2 text-sm"> {% if current_user %} <a href="{{ url_for('tests.tests_list_page') }}" - class="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-lg - text-ink-700 hover:bg-ink-100"> + class="inline-flex items-center justify-center gap-1 + min-w-10 min-h-10 px-2 sm:px-3 rounded-lg + text-ink-700 hover:bg-ink-100" + title="Каталог тестов" aria-label="Каталог тестов"> <span class="material-symbols-outlined text-base">list_alt</span> - Тесты + <span class="hidden sm:inline">Тесты</span> </a> <a href="{{ url_for('settings.settings_page') }}" - class="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-lg - text-ink-700 hover:bg-ink-100"> + class="inline-flex items-center justify-center gap-1 + min-w-10 min-h-10 px-2 sm:px-3 rounded-lg + text-ink-700 hover:bg-ink-100" + title="Настройки" aria-label="Настройки"> <span class="material-symbols-outlined text-base">settings</span> - Настройки + <span class="hidden sm:inline">Настройки</span> </a> - {% endif %} - {% if current_user %} - <span class="hidden sm:inline text-ink-500"> + <span class="hidden md:inline text-ink-500"> {{ current_user.full_name or current_user.login }} <span class="text-ink-300">·</span> <span class="text-brand-700">{{ current_user.role }}</span> </span> <form method="post" action="{{ url_for('auth.logout') }}" class="inline"> <button type="submit" - class="inline-flex items-center gap-1 px-2 py-1 rounded-lg - text-ink-700 hover:bg-ink-100 transition"> + class="inline-flex items-center justify-center gap-1 + min-w-10 min-h-10 px-2 sm:px-3 rounded-lg + text-ink-700 hover:bg-ink-100 transition" + title="Выйти" aria-label="Выйти"> <span class="material-symbols-outlined text-base">logout</span> <span class="hidden sm:inline">Выйти</span> </button> </form> {% else %} <a href="{{ url_for('auth.login_page') }}" - class="inline-flex items-center gap-1 px-2 py-1 rounded-lg - text-brand-700 hover:bg-brand-50 transition"> + class="inline-flex items-center gap-1 px-3 py-2 rounded-lg + text-brand-700 hover:bg-brand-50 transition min-h-10"> <span class="material-symbols-outlined text-base">login</span> Войти </a> diff --git a/flask_app/app/templates/tests/editor.html b/flask_app/app/templates/tests/editor.html index 301b726..ce84228 100644 --- a/flask_app/app/templates/tests/editor.html +++ b/flask_app/app/templates/tests/editor.html @@ -3,187 +3,236 @@ {% block content %} <div id="editor-root" + class="space-y-4 sm:space-y-5 pb-24" data-test-id="{{ test_id }}" data-initial='{{ content | tojson | safe }}'> - <!-- Шапка: название/описание/проходной балл --> - <section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-5"> - <div class="flex items-start justify-between gap-3 flex-wrap"> - <div class="flex-1 min-w-[260px]"> - <label class="block"> - <span class="text-xs font-medium text-ink-500 uppercase">Название</span> - <input id="test-title" type="text" maxlength="200" - class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 text-lg font-semibold - focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> - </label> - <label class="block mt-3"> - <span class="text-xs font-medium text-ink-500 uppercase">Описание</span> - <textarea id="test-description" rows="2" - class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 - focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"></textarea> - </label> - </div> - <div class="w-44"> - <label class="block"> - <span class="text-xs font-medium text-ink-500 uppercase">Проходной балл, %</span> - <input id="test-threshold" type="number" min="0" max="100" step="1" - class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 - focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> - </label> - </div> - </div> + + {# ── 1. Шапка теста ─────────────────────────────────────────── #} + <section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-4 sm:p-5"> + <h2 class="text-xs font-medium text-ink-500 uppercase tracking-wide">Тест</h2> + + <label class="mt-2 block"> + <span class="sr-only">Название</span> + <input id="test-title" type="text" maxlength="200" placeholder="Название теста" + class="w-full rounded-lg border border-ink-300 px-3 py-3 text-lg font-semibold + focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> + </label> + + <label class="mt-3 block"> + <span class="text-xs font-medium text-ink-500">Описание</span> + <textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)" + class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 + focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"></textarea> + </label> + + <label class="mt-3 flex items-center justify-between gap-3"> + <span class="text-xs font-medium text-ink-500">Проходной балл, %</span> + <input id="test-threshold" type="number" min="0" max="100" step="1" + inputmode="numeric" + class="w-24 text-right rounded-lg border border-ink-300 px-3 py-2 + focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> + </label> </section> - <!-- AI-панель --> - <section class="mt-4 rounded-2xl bg-brand-50/60 border border-brand-100 p-4"> + {# ── 2. AI-помощник ─────────────────────────────────────────── #} + <section class="rounded-2xl bg-brand-50/60 border border-brand-100 p-4 sm:p-5"> <div class="flex items-center gap-2"> <span class="material-symbols-outlined text-brand-600">auto_awesome</span> <h2 class="font-semibold text-brand-700">AI-помощник</h2> </div> - <p class="mt-1 text-sm text-ink-700"> - Сгенерировать вопросы по текущей сетке (число вопросов и вариантов берётся из таблицы ниже). - </p> - <div class="mt-3 flex flex-wrap gap-2 items-center"> - <button id="ai-generate-test" - class="inline-flex items-center gap-2 px-3 py-2 rounded-lg - bg-brand-600 hover:bg-brand-700 text-white text-sm"> - <span class="material-symbols-outlined text-base">stars</span> - Сгенерировать по сетке - </button> - <button id="ai-generate-by-title" - class="inline-flex items-center gap-2 px-3 py-2 rounded-lg - bg-white border border-brand-300/60 text-brand-700 hover:bg-brand-50 text-sm"> - <span class="material-symbols-outlined text-base">edit_note</span> - Сгенерировать по названию - </button> - <button id="ai-check" - class="inline-flex items-center gap-2 px-3 py-2 rounded-lg - bg-white border border-ink-300/60 hover:border-brand-300 text-sm"> - <span class="material-symbols-outlined text-base">fact_check</span> - Проверить тест - </button> - <button id="ai-improve" - class="inline-flex items-center gap-2 px-3 py-2 rounded-lg - bg-white border border-ink-300/60 hover:border-brand-300 text-sm"> - <span class="material-symbols-outlined text-base">tune</span> - Улучшить тест - </button> - <label class="inline-flex items-center gap-2 px-3 py-2 rounded-lg - bg-white border border-ink-300/60 hover:border-brand-300 text-sm cursor-pointer"> + + {# Группа A — генерация. Главные действия. На sm+ — в одну строку. #} + <div class="mt-3"> + <p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Создать вопросы</p> + <div class="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-2"> + <button id="ai-generate-by-title" + class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg + bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium min-h-11"> + <span class="material-symbols-outlined text-base">edit_note</span> + По названию + </button> + <button id="ai-generate-test" + class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg + bg-white border border-brand-300/60 text-brand-700 hover:bg-brand-50 + text-sm font-medium min-h-11"> + <span class="material-symbols-outlined text-base">stars</span> + По текущей сетке + </button> + </div> + </div> + + {# Группа B — анализ существующего. #} + <div class="mt-4"> + <p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Улучшить существующее</p> + <div class="mt-2 grid grid-cols-2 gap-2"> + <button id="ai-check" + class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg + bg-white border border-ink-300/60 hover:border-brand-300 + text-sm min-h-11"> + <span class="material-symbols-outlined text-base">fact_check</span> + Проверить + </button> + <button id="ai-improve" + class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg + bg-white border border-ink-300/60 hover:border-brand-300 + text-sm min-h-11"> + <span class="material-symbols-outlined text-base">tune</span> + Улучшить + </button> + </div> + </div> + + {# Группа C — импорт. #} + <div class="mt-4"> + <p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Импортировать</p> + <label class="mt-2 inline-flex w-full items-center justify-center gap-2 px-3 py-3 + rounded-lg bg-white border border-ink-300/60 hover:border-brand-300 + text-sm cursor-pointer min-h-11"> <span class="material-symbols-outlined text-base text-brand-600">upload_file</span> - <span>Импорт документа</span> + <span>Загрузить документ (PDF, DOCX, TXT, MD)</span> <input id="ai-import-file" type="file" accept=".pdf,.docx,.txt,.md" class="hidden" /> </label> - <span id="ai-status" class="text-sm text-ink-500"></span> + <p class="mt-1.5 text-xs text-ink-500"> + До 16 МБ. AI извлечёт текст и предложит черновик теста. + </p> </div> - <p class="mt-2 text-xs text-ink-500"> - Поддерживаются PDF, DOCX, TXT, MD (до 16 МБ). AI извлечёт текст и предложит черновик теста. - </p> + + <p id="ai-status" class="mt-3 text-sm text-ink-500 min-h-[1.25rem]"></p> </section> - <!-- Список вопросов --> - <section class="mt-4"> + {# ── 3. Вопросы ─────────────────────────────────────────────── #} + <section> <div class="flex items-center justify-between gap-2 px-1"> <h2 class="font-semibold">Вопросы (<span id="q-count">0</span>)</h2> <button id="add-question" - class="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg - bg-white border border-ink-300/60 hover:border-brand-300 text-sm"> + 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"> <span class="material-symbols-outlined text-base">add</span> - Добавить вопрос + <span class="hidden sm:inline">Добавить вопрос</span> + <span class="sm:hidden">Добавить</span> </button> </div> <ol id="questions" class="mt-3 space-y-3"></ol> </section> +</div> - <!-- Footer: сохранение / активность цепочки --> - <section class="sticky bottom-0 z-20 mt-6 -mx-4 px-4 py-3 - bg-white/90 backdrop-blur border-t border-ink-300/60 - flex items-center justify-between gap-2 flex-wrap"> - <label class="inline-flex items-center gap-2 text-sm"> +{# ── Sticky-footer: «Цепочка активна» + «Сохранить» ────────────── #} +<div class="fixed bottom-0 inset-x-0 z-30 bg-white/95 backdrop-blur border-t border-ink-300/60 + pb-[env(safe-area-inset-bottom)]"> + <div class="mx-auto max-w-6xl px-4 py-3 + flex items-center justify-between gap-3"> + <label class="inline-flex items-center gap-2 text-sm min-w-0"> <input id="chain-active" type="checkbox" class="rounded border-ink-300 text-brand-600 focus:ring-brand-500" /> - <span>Цепочка активна (виден в каталоге)</span> + <span class="truncate">Цепочка активна</span> </label> - <div class="flex items-center gap-2"> - <span id="save-status" class="text-sm text-ink-500"></span> + <div class="flex items-center gap-2 shrink-0"> <a href="{{ url_for('tests.tests_list_page') }}" - class="px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm">К каталогу</a> + class="hidden sm:inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm"> + К каталогу + </a> <button id="save-draft" - class="inline-flex items-center gap-2 px-4 py-2 rounded-lg - bg-brand-600 hover:bg-brand-700 text-white"> + class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg + bg-brand-600 hover:bg-brand-700 text-white font-medium min-h-11"> <span class="material-symbols-outlined text-base">save</span> Сохранить </button> </div> - </section> + </div> + <p id="save-status" class="mx-auto max-w-6xl px-4 pb-2 text-xs text-ink-500"></p> </div> -<!-- Шаблон вопроса --> +{# ── Шаблон вопроса ─────────────────────────────────────────────── #} <template id="tpl-question"> - <li class="rounded-xl bg-white border border-ink-300/60 p-4 q-item"> - <div class="flex items-start justify-between gap-2"> - <span class="text-xs uppercase tracking-wide text-ink-500 q-num">Вопрос #</span> - <div class="flex items-center gap-1"> - <button class="q-up p-1 rounded hover:bg-ink-100" title="Выше"> + <li class="rounded-xl bg-white border border-ink-300/60 p-3 sm:p-4 q-item"> + {# Шапка карточки вопроса: номер слева, кнопки справа. #} + <div class="flex items-center justify-between gap-2"> + <span class="inline-flex items-center px-2 py-0.5 rounded-md + bg-brand-50 text-brand-700 text-xs font-medium q-num">Вопрос #</span> + <div class="flex items-center gap-0.5"> + <button class="q-up p-2 rounded hover:bg-ink-100 min-w-10 min-h-10" + title="Выше" aria-label="Поднять выше"> <span class="material-symbols-outlined text-base">arrow_upward</span> </button> - <button class="q-down p-1 rounded hover:bg-ink-100" title="Ниже"> + <button class="q-down p-2 rounded hover:bg-ink-100 min-w-10 min-h-10" + title="Ниже" aria-label="Опустить ниже"> <span class="material-symbols-outlined text-base">arrow_downward</span> </button> - <button class="q-delete p-1 rounded hover:bg-red-50 text-red-600" title="Удалить"> + <button class="q-delete p-2 rounded hover:bg-red-50 text-red-600 min-w-10 min-h-10" + title="Удалить" aria-label="Удалить вопрос"> <span class="material-symbols-outlined text-base">delete</span> </button> </div> </div> - <textarea class="q-text mt-2 w-full rounded-lg border border-ink-300 px-3 py-2 + + <textarea class="q-text mt-3 w-full rounded-lg border border-ink-300 px-3 py-2 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" rows="2" placeholder="Формулировка вопроса"></textarea> - <div class="mt-2 flex items-center justify-between gap-2 flex-wrap text-sm"> - <label class="inline-flex items-center gap-2"> - <input type="checkbox" class="q-multi rounded border-ink-300 text-brand-600 focus:ring-brand-500" /> + {# Тип ответа + AI — две полные строки на мобиле, в строку на sm+. #} + <div class="mt-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-sm"> + <label class="inline-flex items-center gap-2 min-h-9"> + <input type="checkbox" + class="q-multi rounded border-ink-300 text-brand-600 focus:ring-brand-500" /> <span>Несколько правильных ответов</span> </label> - <button class="q-ai inline-flex items-center gap-1 px-2 py-1 rounded-lg - bg-brand-50 hover:bg-brand-100 text-brand-700"> + <button class="q-ai inline-flex items-center justify-center gap-1 px-2.5 py-2 rounded-lg + bg-brand-50 hover:bg-brand-100 text-brand-700 text-sm min-h-10"> <span class="material-symbols-outlined text-base">auto_awesome</span> AI: вопрос/переформулировать </button> </div> <ul class="q-options mt-3 space-y-2"></ul> - <div class="mt-2"> - <button class="q-add-option inline-flex items-center gap-1 text-sm text-brand-700 hover:underline"> - <span class="material-symbols-outlined text-base">add</span> Добавить вариант - </button> - </div> + <button class="q-add-option mt-2 inline-flex items-center gap-1 px-2 py-2 rounded + text-sm text-brand-700 hover:bg-brand-50 min-h-10"> + <span class="material-symbols-outlined text-base">add</span> + Добавить вариант + </button> </li> </template> -<!-- Модалка результата AI-проверки/улучшения --> -<dialog id="ai-modal" class="rounded-2xl p-0 max-w-3xl w-full backdrop:bg-black/40"> - <div class="p-5"> - <div class="flex items-center justify-between gap-3"> - <h3 id="ai-modal-title" class="text-lg font-semibold">AI</h3> - <button id="ai-modal-close" class="p-1 rounded hover:bg-ink-100"> - <span class="material-symbols-outlined">close</span> - </button> - </div> - <div id="ai-modal-body" class="mt-3 max-h-[70vh] overflow-y-auto"></div> - <div id="ai-modal-actions" class="mt-4 flex items-center justify-end gap-2"></div> - </div> -</dialog> - +{# ── Шаблон варианта ────────────────────────────────────────────── #} <template id="tpl-option"> <li class="flex items-center gap-2 opt-item"> - <input type="checkbox" class="opt-correct rounded border-ink-300 text-brand-600 focus:ring-brand-500" /> - <input type="text" class="opt-text flex-1 rounded-lg border border-ink-300 px-3 py-1.5 - focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" + {# Чекбокс «Правильный» — обёрнут в большой tap-target. #} + <label class="inline-flex items-center justify-center w-10 h-10 shrink-0 cursor-pointer + rounded hover:bg-ink-100" title="Правильный ответ"> + <input type="checkbox" + class="opt-correct w-5 h-5 rounded border-ink-300 text-brand-600 focus:ring-brand-500" /> + </label> + <input type="text" + class="opt-text flex-1 min-w-0 rounded-lg border border-ink-300 px-3 py-2 + focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" placeholder="Вариант ответа" /> - <button class="opt-delete p-1 rounded hover:bg-red-50 text-red-600" title="Удалить"> + <button class="opt-delete shrink-0 w-10 h-10 inline-flex items-center justify-center + rounded hover:bg-red-50 text-red-600" + title="Удалить" aria-label="Удалить вариант"> <span class="material-symbols-outlined text-base">close</span> </button> </li> </template> + +{# ── Модалка результата 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] + sm:rounded-2xl sm:m-auto bg-white backdrop:bg-black/50"> + <div class="flex flex-col h-full sm:max-h-[90vh]"> + <div class="flex items-center justify-between gap-3 px-4 sm:px-5 py-3 border-b border-ink-300/60"> + <h3 id="ai-modal-title" class="text-lg font-semibold truncate">AI</h3> + <button id="ai-modal-close" + class="p-2 rounded hover:bg-ink-100 min-w-10 min-h-10" + aria-label="Закрыть"> + <span class="material-symbols-outlined">close</span> + </button> + </div> + <div id="ai-modal-body" class="flex-1 overflow-y-auto px-4 sm:px-5 py-4"></div> + <div id="ai-modal-actions" + class="px-4 sm:px-5 py-3 border-t border-ink-300/60 + flex items-center justify-end gap-2 flex-wrap + pb-[max(env(safe-area-inset-bottom),0.75rem)]"></div> + </div> +</dialog> {% endblock %} {% block scripts %} diff --git a/flask_app/app/templates/tests/list.html b/flask_app/app/templates/tests/list.html index 8f5a709..1c4d124 100644 --- a/flask_app/app/templates/tests/list.html +++ b/flask_app/app/templates/tests/list.html @@ -2,39 +2,42 @@ {% block title %}Тесты — каталог{% endblock %} {% block content %} -<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6"> - <div class="flex items-center justify-between gap-4 flex-wrap"> +<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-4 sm:p-6"> + <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div> - <h1 class="text-2xl font-semibold">Каталог тестов</h1> + <h1 class="text-xl sm:text-2xl font-semibold">Каталог тестов</h1> <p class="mt-1 text-sm text-ink-500">Активные тесты, к которым у вас есть доступ.</p> </div> <button id="btn-create-test" - class="inline-flex items-center gap-2 px-4 py-2 rounded-lg - bg-brand-600 hover:bg-brand-700 text-white font-medium transition"> + class="inline-flex items-center justify-center gap-2 px-4 py-3 rounded-lg + bg-brand-600 hover:bg-brand-700 text-white font-medium transition + min-h-11 w-full sm:w-auto"> <span class="material-symbols-outlined text-base">add</span> Создать тест </button> </div> {% if visible %} - <ul class="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> + <ul class="mt-5 grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"> {% for t in visible %} - <li class="rounded-xl border border-ink-300/60 hover:border-brand-300 hover:shadow-sm transition p-4 bg-white"> - <div class="flex items-start justify-between gap-2"> - <h3 class="font-semibold text-ink-900 line-clamp-2">{{ t.title }}</h3> - <span class="text-xs text-ink-500 shrink-0">v{{ t.version }}</span> - </div> - {% if t.description %} - <p class="mt-1 text-sm text-ink-500 line-clamp-3">{{ t.description }}</p> - {% endif %} - <p class="mt-2 text-xs text-ink-500">Автор: {{ t.author_full_name or '—' }}</p> - <div class="mt-3 flex items-center gap-2"> - <a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" - class="inline-flex items-center gap-1 text-sm text-brand-700 hover:underline"> - <span class="material-symbols-outlined text-base">edit_note</span> - Открыть редактор - </a> - </div> + <li class="rounded-xl border border-ink-300/60 hover:border-brand-300 hover:shadow-sm transition bg-white"> + <a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" + class="block p-4 active:bg-ink-100/40"> + <div class="flex items-start justify-between gap-2"> + <h3 class="font-semibold text-ink-900 line-clamp-2 min-w-0">{{ t.title }}</h3> + <span class="text-xs text-ink-500 shrink-0 mt-0.5">v{{ t.version }}</span> + </div> + {% if t.description %} + <p class="mt-1 text-sm text-ink-500 line-clamp-3">{{ t.description }}</p> + {% endif %} + <div class="mt-3 flex items-center justify-between gap-2 text-xs text-ink-500"> + <span class="truncate">Автор: {{ t.author_full_name or '—' }}</span> + <span class="inline-flex items-center gap-1 text-brand-700"> + <span class="material-symbols-outlined text-sm">edit_note</span> + Открыть + </span> + </div> + </a> </li> {% endfor %} </ul> @@ -60,16 +63,23 @@ {% endif %} </section> -<dialog id="dlg-create" class="rounded-2xl p-0 backdrop:bg-ink-900/40 max-w-md w-full"> - <form method="dialog" class="bg-white rounded-2xl"> - <div class="p-5 border-b border-ink-300/60"> +<dialog id="dlg-create" + class="m-0 p-0 w-full h-full sm:h-auto sm:max-w-md sm:w-full sm:m-auto + sm:rounded-2xl bg-white backdrop:bg-ink-900/50"> + <form method="dialog" class="flex flex-col h-full sm:h-auto bg-white sm:rounded-2xl"> + <div class="px-4 sm:px-5 py-3 border-b border-ink-300/60 flex items-center justify-between"> <h2 class="text-lg font-semibold">Новый тест</h2> + <button type="button" id="dlg-cancel-x" + class="p-2 rounded hover:bg-ink-100 min-w-10 min-h-10" + aria-label="Закрыть"> + <span class="material-symbols-outlined">close</span> + </button> </div> - <div class="p-5 space-y-3"> + <div class="flex-1 overflow-y-auto px-4 sm:px-5 py-4 space-y-3"> <label class="block"> <span class="text-sm font-medium text-ink-700">Название</span> <input id="new-test-title" type="text" required maxlength="200" - class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 + class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-3 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> </label> <label class="block"> @@ -79,11 +89,13 @@ focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"></textarea> </label> </div> - <div class="p-4 border-t border-ink-300/60 flex justify-end gap-2 bg-ink-100/40 rounded-b-2xl"> + <div class="px-4 sm:px-5 py-3 border-t border-ink-300/60 flex justify-end gap-2 + bg-ink-100/40 sm:rounded-b-2xl + pb-[max(env(safe-area-inset-bottom),0.75rem)]"> <button type="button" id="dlg-cancel" - class="px-4 py-2 rounded-lg text-ink-700 hover:bg-ink-100">Отмена</button> + class="px-4 py-2.5 rounded-lg text-ink-700 hover:bg-ink-100 min-h-11">Отмена</button> <button type="button" id="dlg-submit" - class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white"> + class="px-4 py-2.5 rounded-lg bg-brand-600 hover:bg-brand-700 text-white min-h-11"> Создать </button> </div> @@ -106,6 +118,7 @@ setTimeout(() => titleEl.focus(), 50); }); document.getElementById('dlg-cancel').addEventListener('click', () => dlg.close()); + document.getElementById('dlg-cancel-x').addEventListener('click', () => dlg.close()); document.getElementById('dlg-submit').addEventListener('click', async () => { const title = titleEl.value.trim(); From 47d673496b9147951fb294d7ff999fbeaa6d99ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=82=D0=B8?= =?UTF-8?q?=D0=BD=20=D0=9B=D0=B5=D0=B1=D0=B5=D0=B4=D0=B8=D0=BD=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=B9?= <lebedinsky.kd@gmail.com> Date: Tue, 28 Apr 2026 00:09:11 +0500 Subject: [PATCH 14/15] =?UTF-8?q?docs(qa):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D1=82=D1=8C=20=D0=B8=D0=BD=D1=81=D1=82?= =?UTF-8?q?=D1=80=D1=83=D0=BA=D1=86=D0=B8=D1=8E=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D1=89=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=E2=80=94=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE?= =?UTF-8?q?=20UI,=20=D0=B1=D0=B5=D0=B7=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE?= =?UTF-8?q?=D0=BB=D0=B5=D0=B9=20=D0=B8=20SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- docs/QA-versioning-and-ai.md | 615 +++++++++++++++++++++-------------- 1 file changed, 363 insertions(+), 252 deletions(-) diff --git a/docs/QA-versioning-and-ai.md b/docs/QA-versioning-and-ai.md index a504b15..8bce2c5 100644 --- a/docs/QA-versioning-and-ai.md +++ b/docs/QA-versioning-and-ai.md @@ -1,301 +1,412 @@ -# Инструкция для тестировщика: версионирование тестов и AI-функции +# Инструкция для тестировщика: версионирование тестов и AI -> Контур: новый Flask на `:3108` (после E1.0–E1.3, E1.8). Старый Node — на `:3107`, -> для проверки этих задач **используйте только `:3108`**. +Сайт: **[https://edullm.pirogov.ai/](https://edullm.pirogov.ai/)** -Перед началом: +Учётка: войдите под автором (роль «менеджер» или «администратор»). Если +учётки нет — попросите её у разработчика, без неё тестировать нельзя. -1. Откройте `http://<host>:3108/login` под учёткой автора (роль `manager` или - `admin`). Если сидов нет — заведите пользователя в `clinic_tests.users` - обычным способом. -2. Для AI-задач: откройте `http://<host>:3108/settings` и убедитесь, что - статус ключа = «Задан» и кнопка **«Проверить подключение»** возвращает - зелёный **OK · provider/model · NN мс**. Если ключ не задан — AI-задачи - нужно прогнать в негативном сценарии (см. блок A.0). -3. Откройте `http://<host>:3108/tests` — это каталог. +> Всё, что описано ниже, проверяется **только через сайт**. Если в каком-то +> сценарии написано «недоступно сейчас» — это **не баг**, это значит, что +> функция UI ещё не сделана и появится позже. Просто пропускайте. --- -## Часть 1. Версионирование при правке после попыток - -### Что должно работать - -| Правило | Поведение | -|---|---| -| Нет попыток | Автор правит тест **на месте**, номер версии не меняется. | -| Есть ≥ 1 попытка | Любое сохранение изменений **создаёт новую версию** (`version + 1`), старая становится неактивной, но **сохраняется в БД** и связана через `parent_id`. | -| Цепочка | Все версии связаны (parent → child), на странице «Версии» видны все. | -| Каталог | В списке видна **только активная** версия цепочки. | -| Переключение активной версии | Автор может вручную сделать активной любую версию — остальные автоматически становятся неактивными. | -| Деактивация цепочки | Тест можно скрыть целиком; данные не удаляются. | -| Корректность истории | Каждая попытка привязана к **той версии**, по которой её проходили — разбор ошибок остаётся корректным после правок. | - -### Сценарий 1.1. Правка теста до попыток (версия не растёт) - -1. В каталоге → **«Создать тест»**, заполните название и описание → создать. -2. В редакторе добавьте 2–3 вопроса по 3–4 варианта, сохраните. -3. Откройте этот же тест в редакторе ещё раз, измените: - - название; - - описание; - - текст одного вопроса; - - пометьте один вариант как правильный иначе; - - удалите/добавьте вариант. -4. **«Сохранить»**. - -**Ожидается:** -- Сообщение «Сохранено.» (без слов «создана новая версия»). -- В БД: `SELECT version FROM test_versions WHERE test_id = '<id>'` → **одна** строка с `version = 1, is_active = true`. -- Эндпоинт `GET /api/tests/<id>/versions` → массив из 1 элемента, `hasAttempts: false`. - -### Сценарий 1.2. Появление первой попытки → форк новой версии - -> Прохождение теста (UI) пока не реализовано в Flask-контуре (запланировано -> в **E1.4**). Поэтому факт попытки имитируется напрямую в БД — это норма -> для текущего этапа. - -1. Возьмите `id` теста и `id` активной версии: - ```sql - SELECT t.id AS test_id, tv.id AS version_id - FROM tests t JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active - WHERE t.title = 'Ваш тест'; - ``` -2. Создайте «попытку» (минимальный INSERT, любой пользователь, любое - состояние; нам нужен только сам факт записи в `test_attempts`): - ```sql - INSERT INTO test_attempts (id, test_version_id, user_id, status, created_at) - VALUES (gen_random_uuid(), '<version_id>', '<any_user_id>', 'completed', now()); - ``` -3. В UI откройте редактор того же теста, **измените хотя бы один вопрос** - (текст / правильность варианта / число вариантов) и **«Сохранить»**. - -**Ожидается:** -- Сообщение «Сохранено (создана новая версия — есть попытки прохождения).» -- В БД: `SELECT version, is_active, parent_id FROM test_versions WHERE test_id=...` - → **две** строки: - - `version = 1, is_active = false, parent_id = NULL` (старая, не удалена); - - `version = 2, is_active = true, parent_id = <id версии 1>` (новая). -- Старая попытка по-прежнему ссылается на `version_id` из v1, и её ответы/вопросы остаются те же — разбор ошибок не «съехал». - -### Сценарий 1.3. Правка только метаданных после попыток (без форка) - -После сценария 1.2: - -1. В редакторе **не трогайте** вопросы и варианты. Поменяйте только название - или описание или проходной балл. Сохраните. - -**Ожидается:** -- Сообщение «Сохранено.» (без форка). -- `version` не вырос; метаданные обновились на активной версии. - -> Логика: форк делается только если после попыток меняется **содержание** -> (вопросы/варианты). Чисто косметические правки шапки версию не плодят. - -### Сценарий 1.4. Каталог показывает только активную версию - -1. После 1.2 откройте `/tests` под автором и под не-автором (если есть назначения). - -**Ожидается:** -- В каталоге — одна карточка теста с пометкой `v.2` (активная). Версия 1 в каталог не попадает, но видна автору на странице «Версии» теста. - -### Сценарий 1.5. Ручное переключение активной версии - -1. Получите id v1 и v2: `SELECT id, version, is_active FROM test_versions WHERE test_id=...` -2. Сделайте активной v1: - ```bash - curl -X POST -b cookies.txt \ - http://<host>:3108/api/tests/<test_id>/versions/<v1_id>/activate - ``` - (cookie сессии берёте из браузера или логином через `/api/auth/login`). - -**Ожидается:** -- Ответ `{ ok: true, activeVersionId: "<v1_id>" }`. -- В БД: `is_active = true` только у v1, у v2 — `false`. -- В каталоге карточка теста снова показывает `v.1`. - -### Сценарий 1.6. Деактивация цепочки целиком - -1. В редакторе снимите чекбокс **«Цепочка активна»** и сохраните. - -**Ожидается:** -- В каталоге `/tests` теста больше не видно (ни в visible, ни у не-авторов). -- У автора он появляется в блоке **«Скрытые вами»** (внизу каталога). -- В БД: `tests.is_active = false`, версии и попытки нетронуты. -- Включение чекбокса обратно возвращает тест в каталог. - -### Сценарий 1.7. Корректность истории по старым попыткам - -> Полноценный разбор пользовательских ответов появится в **E1.4** вместе -> с UI прохождения. Сейчас минимально проверяем, что данные старой версии -> не повреждены. - -1. После 1.2 в БД: - ```sql - SELECT q.text - FROM questions q - JOIN test_versions tv ON tv.id = q.test_version_id - WHERE tv.test_id = '<test_id>' AND tv.version = 1; - ``` -2. **Ожидается:** видны вопросы **в том виде, в каком они были до правки** - (а не текущая версия v2). Эта же выборка должна совпадать с - `q.test_version_id` любой попытки, которую вы создали в 1.2. - -### Что фиксировать как баг - -- После правки **с попытками** в БД остался один `test_versions`-ряд (нет форка). -- После правки **без попыток** появилась `version = 2` (лишний форк). -- При активации одной версии другие не сбросились в `is_active = false`. -- Каталог показывает неактивную версию или скрытый тест. -- Сообщение в UI после сохранения не совпадает с реальным поведением (форк есть, текст «Сохранено.» без уточнения, или наоборот). +## Часть 1. Версии теста — что меняется при правках + +### О чём вообще задача + +Когда автор правит тест, в системе важно не сломать историю прохождений +сотрудников. Поэтому правила такие: + +- Пока **никто не прошёл** тест — автор правит на месте, версия одна. +- Как только **хотя бы один сотрудник прошёл** тест — следующее сохранение + изменений создаёт **новую версию** (v2, v3, …), старая сохраняется. +- В каталоге всегда видна **только одна** активная версия. +- Автор может **скрыть** тест целиком (чекбокс «Цепочка активна»). +- Автор может **переключить** активную версию на другую из истории. + +> Сейчас на сайте нельзя пройти тест сотруднику и нельзя из UI открыть +> историю версий — это будет в следующих спринтах. Поэтому из шести +> правил тестировщик пока проверяет четыре, остальные помечены ниже. + +--- + +### 1.1. Создание нового теста + +**Что проверяем:** автор может создать тест с нуля. + +**Как проверять:** +1. Откройте [https://edullm.pirogov.ai/](https://edullm.pirogov.ai/) и войдите. +2. Нажмите в шапке иконку **«Тесты»** (список) → попадаете в каталог. +3. Нажмите кнопку **«Создать тест»**. +4. В появившемся окне заполните **Название** (например, «Тест A»), + при желании — Описание. +5. Нажмите **«Создать»**. + +**Что должно произойти:** +- Открывается экран редактора нового теста. +- В поле «Название» — то, что вы ввели. +- Список вопросов пуст, счётчик «Вопросы (0)». +- Внизу — кнопка **«Сохранить»** и чекбокс **«Цепочка активна»** (по + умолчанию включён). + +**Если что-то не так — баг:** +- Окно «Создать тест» не открывается. +- После «Создать» никуда не перенаправило. +- В поле «Название» в редакторе пусто, хотя ввели текст. +- Список тестов в каталоге не обновился (не появилась новая карточка). --- -## Часть 2. AI-функции (E1.2 + E1.8) +### 1.2. Правка теста до прохождений (версия не растёт) + +**Что проверяем:** пока никто не проходил тест, автор может править его +сколько угодно — это всё одна и та же версия. -### Заметка о ключе +**Как проверять:** +1. Откройте только что созданный «Тест A» (если уже не открыт): шапка → **«Тесты»** → нажмите на карточку теста. +2. Нажмите **«Добавить вопрос»** — появится карточка вопроса. +3. Введите текст вопроса. +4. Заполните 3–4 варианта ответа в поле «Вариант ответа», у одного из них поставьте чекбокс «Правильный» (квадратик слева от текста). +5. Добавьте ещё один-два вопроса тем же способом. +6. Нажмите **«Сохранить»** в нижней панели. -Изначально в ТЗ предполагалось хранить ключ в БД и вводить на `/settings`. -По согласованию ключ **общий** и хранится в `ENV` контейнера -(`DEEPSEEK_API_KEY` / `OPENAI_API_KEY`). Страница `/settings` остаётся, -но в ней — только статус и кнопка проверки подключения. Поле ввода ключа в UI **не нужно** (это не баг). +**Что должно произойти:** +- Под шапкой появляется надпись **«Сохранено.»** (без слов про новую версию). +- Если перезагрузить страницу — все вопросы и варианты на месте. -### A.0. Негативный кейс — ключ не задан +**Повторите правку:** +1. На том же экране **измените** текст одного вопроса, **добавьте** ещё один вариант к другому, **удалите** третий вопрос (кнопка «корзина» справа в карточке вопроса). +2. Нажмите **«Сохранить»**. -1. На сервере уберите `DEEPSEEK_API_KEY` из окружения и перезапустите контейнер. -2. Откройте `/settings`. +**Что должно произойти:** +- Снова надпись **«Сохранено.»**. +- Никаких слов «создана новая версия». +- Перезагрузка страницы — изменения сохранились. -**Ожидается:** -- Бейдж «Не задан» (красный). -- Блок «Как задать ключ» с примером `.env`. -- Кнопка **«Проверить подключение»** возвращает красный блок с текстом про незаданный ключ. -- В редакторе при нажатии **«Сгенерировать по сетке» / «по названию» / «Проверить тест» / «Улучшить тест» / «AI: вопрос»** появляется confirm: - «… Открыть Настройки?» → согласие открывает `/settings`. +**Если что-то не так — баг:** +- Появляется сообщение про «новую версию» (его быть не должно — попыток ещё нет). +- Изменения не сохранились после перезагрузки страницы. +- Сообщение «Сохранено.» не появилось вовсе. -После проверки верните ключ и `docker compose ... up -d` — переходим к позитивным сценариям. +--- + +### 1.3. Правка после прохождений (создаётся v2) -### A.1. `/settings` → «Проверить подключение» +> ⏳ **Сейчас недоступно для проверки.** +> +> На сайте пока нет страницы для прохождения теста сотрудником, поэтому +> тестировщик не может «накопить» попытки и проверить эту логику через UI. +> Сценарий вернётся в инструкцию вместе со следующим спринтом, когда +> появится страница прохождения. + +--- + +### 1.4. Каталог показывает только активные тесты + +**Что проверяем:** в каталоге нет неактивных/скрытых тестов. + +**Как проверять:** +1. На экране редактора любого теста снимите внизу чекбокс + **«Цепочка активна»** и нажмите **«Сохранить»**. +2. Перейдите в шапке на **«Тесты»** (каталог). + +**Что должно произойти:** +- Карточки этого теста в основном списке нет. +- Внизу страницы каталога есть раскрывающийся блок **«Скрытые вами цепочки (N)»**. Раскройте его — там видно ваш тест. +- Нажмите **«Открыть»** рядом с тестом — снова попадаете в редактор. +- Поставьте обратно галку **«Цепочка активна»** → **«Сохранить»**. +- Снова перейдите в **«Тесты»** — тест опять в основном списке каталога. + +**Если что-то не так — баг:** +- После снятия галки тест остаётся в обычном каталоге. +- Тест полностью пропал и его не видно нигде, даже в «Скрытых». +- После возврата галки тест не вернулся в основной каталог. + +--- + +### 1.5. Ручное переключение активной версии + +> ⏳ **Сейчас недоступно для проверки.** +> +> Страница с историей версий теста (где можно нажать «сделать активной») +> ещё не сделана. Появится в следующем спринте. + +--- + +### 1.6. История прохождений после правок + +> ⏳ **Сейчас недоступно для проверки** — см. 1.3 и 1.5. + +--- -1. На `/settings` нажмите **«Проверить подключение»**. +## Часть 2. AI-функции в редакторе теста -**Ожидается:** -- В течение нескольких секунд — зелёный блок: `OK · <provider> / <model> · <ms> мс` и сэмпл ответа. -- Provider/Model совпадают с ENV (`deepseek` + `deepseek-chat` по умолчанию). +### Как проверять, что AI вообще работает -### A.2. «Сгенерировать тест по названию» (E1.8) +**Что проверяем:** ключ к AI задан и сервис отвечает. -1. Создайте новый пустой тест (никаких вопросов). -2. В редакторе нажмите **«Сгенерировать по названию»**. -3. На запрос «Сколько вопросов?» введите, например, `8`; «Сколько вариантов?» — `4`. -4. Дождитесь готовности → confirm «Применить как черновик?». +**Как проверять:** +1. В шапке нажмите иконку **«Настройки»** (шестерёнка). +2. Посмотрите блок «Подключение к LLM»: + - **Статус ключа** — должно быть зелёное **«Задан»**. + - **Провайдер** и **Модель** — заполнены. +3. Нажмите кнопку **«Проверить подключение»**. -**Ожидается:** -- Кнопка **активна только** когда поле «Название» заполнено. Если очистить название — нажатие даёт алерт «Сначала заполните название теста.» и фокус возвращается в название. -- Появляется ровно ~8 вопросов по ~4 варианта (модель может слегка отклониться по инструкции, это допустимо). +**Что должно произойти:** +- В течение 1–10 секунд под кнопкой появляется **зелёный** блок + с текстом вида **«OK · deepseek / deepseek-chat · 1234 мс»**. + +**Если что-то не так:** +- Если статус **«Не задан»** (красный) — сообщите разработчику, не задан ключ. Тестировать AI-функции в этом режиме не нужно, кроме одного сценария ниже (2.7). +- Если кнопка отдала **красный** блок «Ошибка» при заданном ключе — это баг, прикладывайте текст ошибки к тикету. + +--- + +### 2.1. Сгенерировать тест по названию + +**Простыми словами:** автор пишет только тему, AI сам придумывает вопросы. + +**Как проверять:** +1. **«Тесты»** → **«Создать тест»** → название, например, **«Гигиена рук»**, описание можно оставить пустым → **«Создать»**. +2. В редакторе нажмите кнопку **«По названию»** (фиолетовая, в блоке «AI-помощник» → «Создать вопросы»). +3. На вопрос «Сколько вопросов сгенерировать?» введите, например, `8` → **OK**. +4. На вопрос «Сколько вариантов в каждом вопросе?» введите, например, `4` → **OK**. +5. Подождите 5–20 секунд. +6. Появится подтверждение «Готово: «…», вопросов — N. Применить как черновик? Текущие вопросы будут заменены». Нажмите **OK**. + +**Что должно произойти:** +- В списке появилось примерно 8 вопросов на тему гигиены рук, в каждом примерно 4 варианта ответа на русском языке. +- В каждом вопросе хотя бы один вариант помечен как «Правильный» (галка слева от текста варианта). +- Внизу можно нажать **«Сохранить»** — тест сохраняется. + +**Дополнительно (что блокировка названия работает):** +1. Создайте ещё один тест, в редакторе **очистите поле «Название»**. +2. Нажмите **«По названию»**. +3. Должен появиться алерт **«Сначала заполните название теста.»**, курсор перейдёт в поле «Название». Никакой генерации не происходит. + +**Если что-то не так — баг:** +- Кнопка **«По названию»** работает при пустом названии (без алерта). +- Сгенерированные вопросы — не на русском или не по теме названия. +- В вопросе нет ни одного правильного варианта. +- Подтверждение «Применить?» не появилось — вопросы заменились молча. +- Отказ в подтверждении (Cancel) всё равно заменил вопросы. + +--- + +### 2.2. Сгенерировать тест по сетке + +**Простыми словами:** автор сам задаёт «скелет» — сколько вопросов и +сколько вариантов; AI заполняет вопросы по этому скелету. + +**Как проверять:** +1. Создайте новый тест с названием **«Тест по сетке»**. +2. Нажмите **«Добавить вопрос»** пять раз — получится 5 пустых карточек. +3. В **третьей и пятой** карточках поставьте галку **«Несколько правильных ответов»**. +4. В каждом вопросе по умолчанию 0 вариантов — нажмите **«Добавить вариант»** в каждом вопросе по 3 раза, чтобы стало по 3 варианта. +5. Нажмите кнопку **«По текущей сетке»** в блоке «AI-помощник». + +**Что должно произойти:** +- В списке снова 5 вопросов (не больше, не меньше). +- В каждом — по 3 варианта. +- В третьем и пятом вопросах несколько вариантов помечены правильными; + в остальных — ровно один. - Тексты — на русском, по теме названия. -- В каждом вопросе хотя бы один вариант помечен как правильный. -- Отказ в confirm не меняет редактор; согласие — заменяет. -- Сохранение работает, в БД появляется версия с этими вопросами. -### A.3. «Сгенерировать тест по сетке» (E1.2 — было) +**Если что-то не так — баг:** +- Стало другое число вопросов или вариантов (не 5×3). +- В третьем/пятом вопросе только один правильный ответ, а в остальных — несколько. +- Пришла ошибка типа **«AI: ошибка»** без понятного объяснения. -1. Откройте тест, в котором уже руками настроены 5 вопросов, по 3 варианта в каждом, 2 из вопросов помечены как «Несколько правильных». -2. Нажмите **«Сгенерировать по сетке»**. +--- + +### 2.3. Проверить тест + +**Простыми словами:** AI читает весь тест и пишет, что в нём не так. + +**Как проверять:** +1. Откройте любой тест с 3+ вопросами (например, «Гигиена рук» из 2.1). +2. Желательно специально испортить пару вопросов: переписать + формулировку расплывчато («что-то про что-то»), сделать варианты + ответа очень похожими друг на друга или явно дурацкими. +3. Нажмите **«Сохранить»**. +4. Нажмите кнопку **«Проверить»** в блоке «AI-помощник» → «Улучшить существующее». + +**Что должно произойти:** +- Открывается окно «Проверка теста». +- Сверху — цветная плашка с одним из вердиктов: **«Годен»** (зелёный), + **«Есть замечания»** (жёлтый) или **«Серьёзные проблемы»** (красный) + + одно-два предложения резюме. +- Ниже — список разделов: «Чёткость формулировок», «Качество дистракторов», + «Охват темы», «Сбалансированность сложности». Под каждым — конкретные + пункты, что улучшить. +- Закрытие крестиком сверху или кнопкой «Закрыть» внизу — работает. +- В тесте при этом **ничего не меняется**, AI только советует. + +**Если что-то не так — баг:** +- Окно пустое или текст не на русском. +- Все вопросы AI признал хорошими, хотя вы их специально испортили. +- После закрытия окна в тесте появились/исчезли вопросы. -**Ожидается:** -- Возвращается ровно 5 вопросов. -- В тех же позициях, что у вас стояли «Несколько правильных», — у новых вопросов несколько правильных вариантов. -- Число вариантов в каждом вопросе совпадает. -- При несовпадении сетки эндпоинт вернул бы 502 с кодом `llm_shape` (модель «не попала») — допустимая редкая ошибка, повторите. +--- -### A.4. «Проверить тест» (E1.8) +### 2.4. Улучшить весь тест + +**Простыми словами:** AI предлагает улучшенные формулировки и варианты; +автор отмечает галками, что применить. + +**Как проверять:** +1. Откройте тест из 2.3 (с теми же намеренно слабыми вопросами). +2. Нажмите **«Сохранить»** на всякий случай. +3. Нажмите кнопку **«Улучшить»**. + +**Что должно произойти:** +- Открывается окно «Улучшение теста». +- Сверху подпись «Отметьте вопросы… N из M» (M — всего вопросов, N — где AI предложил изменения). +- Каждый изменённый вопрос — отдельный блок: + - Чекбокс **«Вопрос #N»** (по умолчанию **отмечен**). + - Слева — «Было» (старый текст и варианты, изменённые куски зачёркнуты). + - Справа — «Стало» (новые формулировки, выделены). + - Правильные варианты помечены галочкой **✓**. +- Внизу — две кнопки: **«Отмена»** и **«Применить выбранное»**. + +**Проверьте применение по выбору:** +1. Снимите галки у двух вопросов из списка. +2. Нажмите **«Применить выбранное»**. + +**Что должно произойти:** +- Окно закрывается. +- В редакторе **только** отмеченные вопросы заменены на улучшенные; + два вопроса, у которых вы сняли галки, остались в прежнем виде. +- Появляется подсказка «Изменения применены. Не забудьте сохранить.» +- После **«Сохранить»** — обычное «Сохранено.» + +**Если что-то не так — баг:** +- Поменялось **число** вопросов или вариантов в каких-то вопросах + (должно остаться как было). +- В вопросе изменилось значение «Несколько правильных ответов» (галка + переключилась сама). +- Изменились вопросы, у которых вы сняли галку. +- Кнопка «Отмена» всё равно применила изменения. +- В колонках «Было» и «Стало» одинаковый текст (нет смысла предлагать). -1. Создайте тест с парой нарочно слабых мест: - - один вопрос с длинной мутной формулировкой; - - один вопрос, где все варианты слишком похожи или в качестве «дистракторов» — очевидная ерунда. -2. Нажмите **«Проверить тест»**. +--- -**Ожидается:** -- Открывается модалка «Проверка теста». -- Есть цветная плашка с одним из вердиктов: **Годен / Есть замечания / Серьёзные проблемы**, и краткое резюме (1–2 предложения). -- Ниже — список разделов («Чёткость формулировок», «Качество дистракторов», «Охват темы», «Сбалансированность сложности»). Разделы без замечаний пропускаются. -- В списке — конкретные пункты на русском, по делу. -- Закрытие модалки крестиком или кнопкой «Закрыть» работает. +### 2.5. AI: вопрос/переформулировать -### A.5. «Улучшить тест» (E1.8) — массовое было → стало +**Простыми словами:** работа с одним вопросом. Если поле пустое — AI его +придумает; если уже заполнено — переформулирует красивее, варианты не трогает. -1. Возьмите тест из A.4 (с ≥ 3 вопросами). -2. Нажмите **«Улучшить тест»**. +**Как проверять (новый вопрос):** +1. В любом тесте нажмите **«Добавить вопрос»** — появилась пустая карточка. +2. **Не трогайте** поле «Формулировка вопроса». +3. Нажмите **«Добавить вариант»** 4 раза — должно стать 4 пустых варианта. +4. Нажмите кнопку **«AI: вопрос/переформулировать»** в этой карточке. -**Ожидается:** -- Открывается модалка с заголовком «Улучшение теста» и подсказкой «Отмечено N из M». -- Каждый изменённый вопрос — отдельная карточка: - - чекбокс «Вопрос #N» (по умолчанию **отмечен**); - - две колонки **Было** / **Стало**; - - изменённый текст в «Было» зачёркнут, в «Стало» — выделен; - - правильные варианты помечены ✓. -- Снимите галку с одного-двух вопросов и нажмите **«Применить выбранное»**. -- В редакторе **только** отмеченные вопросы заменены на улучшенные; остальные остались как были. -- Появляется надпись «Изменения применены. Не забудьте сохранить.» — нажмите **«Сохранить»** и проверьте версионирование (см. Часть 1). -- **Сетка не меняется**: число вопросов, число вариантов в каждом и значение «Несколько правильных» совпадают с исходными. Если модель «слетела» — эндпоинт возвращает 502 с `llm_shape` и UI показывает алерт; это не баг логики. +**Что должно произойти:** +- Поле «Формулировка вопроса» заполнено осмысленным текстом по теме теста. +- Все 4 варианта заполнены. +- Ровно один помечен как правильный. +- Внизу появляется строка статуса **«AI: вопрос сгенерирован.»** -### A.6. AI-кнопка на конкретном вопросе (E1.2) +**Как проверять (переформулировать существующий):** +1. Возьмите готовый вопрос с уже заполненной формулировкой. +2. Нажмите **«AI: вопрос/переформулировать»** в его карточке. -Сценарий «новый вопрос»: +**Что должно произойти:** +- Меняется **только текст вопроса** — варианты ответа остаются прежними, + правильные варианты те же. +- Статус **«AI: формулировка обновлена.»** -1. Добавьте вопрос, **поле текста оставьте пустым**, число вариантов = 4, «Несколько правильных» — выкл. -2. Нажмите **«AI: вопрос/переформулировать»** на этом вопросе. +**Если что-то не так — баг:** +- На пустом вопросе AI ничего не сгенерировал. +- На заполненном вопросе AI поменял варианты ответа или правильность — + должен трогать только формулировку. +- Получилось 0 или 1 правильных вариантов в новом вопросе (надо ровно 1 + для одиночного выбора). -**Ожидается:** заполнен текст вопроса и все 4 варианта; ровно один помечен правильным; внизу — статус «AI: вопрос сгенерирован.» +--- -Сценарий «переформулировать»: +### 2.6. Импорт документа -1. Возьмите готовый вопрос с заполненным текстом. -2. Нажмите ту же кнопку. +**Простыми словами:** автор загружает PDF/Word/текст со статьёй — +AI читает файл и сам предлагает черновик теста. -**Ожидается:** меняется **только текст** вопроса (вариант ответа и правильность не трогаются), статус «AI: формулировка обновлена.» +**Подготовьте файл:** возьмите PDF или DOCX на 1–3 страницы со связным +русским текстом (например, любую методичку или статью). Лимит — 16 МБ. -### A.7. Импорт документа (E1.3) +**Как проверять:** +1. В редакторе любого нового теста (можно пустого) → блок «AI-помощник» + → **«Импортировать»** → нажмите большую кнопку **«Загрузить документ (PDF, DOCX, TXT, MD)»** → выберите файл. +2. Подождите 5–30 секунд. -1. Подготовьте файл `sample.pdf` или `sample.docx` со связным текстом (1–3 страницы) на русском. -2. В редакторе → AI-панель → **«Импорт документа»** → выберите файл. +**Что должно произойти:** +- Появляется подтверждение «Сгенерировано: «…», вопросов: N. Применить как новый черновик? Текущие вопросы будут заменены». Нажмите **OK**. +- Тест заполнен вопросами по содержанию загруженного документа. +- Можно сохранить кнопкой **«Сохранить»**. -**Ожидается:** -- Прогресс «Загружаем «sample.pdf»…». -- Confirm «Сгенерировано: «<title>», вопросов: N. Применить как новый черновик?» → согласие заменяет вопросы, отказ ничего не меняет. -- При файле > 16 МБ — ошибка от Flask (413/500), это норма (лимит). -- При файле неподдерживаемого формата (`.xlsx`, `.png`) — алерт «Неподдерживаемый формат…». -- При **отсутствующем** ключе AI: вместо confirm — алерт с текстом «Автогенерация выключена…» и первыми 600 символами извлечённого текста; вопросы **не** заменяются. +**Дополнительно — отказ:** +1. Повторите загрузку, но в подтверждении нажмите **«Отмена»**. +2. Тест должен остаться **в прежнем виде**, ничего не подменилось. -### A.8. Единая ошибка при отсутствии ключа +**Дополнительно — большой файл:** +1. Возьмите файл больше 16 МБ. +2. Загрузите его. -1. Уберите ключ (как в A.0). Откройте редактор любого теста. -2. По очереди нажимайте **«Сгенерировать по сетке»**, **«по названию»**, **«Проверить тест»**, **«Улучшить тест»**, **«AI: вопрос»**. +**Что должно произойти:** ошибка о слишком большом файле; вопросы не +подменились. -**Ожидается:** в каждом случае confirm с предложением открыть **/settings**, не молчаливый алерт. На бэке — JSON `{ error, code, settingsUrl: "/settings" }`, статус 502. +**Дополнительно — неподдерживаемый тип:** +1. Возьмите файл `.xlsx` или картинку `.png`. +2. Попробуйте загрузить. -### Что фиксировать как баг (AI) +**Что должно произойти:** алерт «Неподдерживаемый формат…»; вопросы не +подменились. -- Кнопка **«Сгенерировать по названию»** позволяет жать без названия и не показывает алерт. -- Модалка **«Проверить тест»** пуста или содержит англ. текст. -- В **«Улучшить тест»** меняется число вопросов / число вариантов / «Несколько правильных», т.е. сетка слетела, и UI всё равно применил. -- В **«AI: вопрос»** на пустом вопросе варианты не сгенерированы; на заполненном — варианты заменены без ведома пользователя. -- Любая AI-ошибка показывается без ссылки на `/settings`, когда ключ действительно отсутствует. -- Импорт документа подменяет вопросы **без confirm**. +**Если что-то не так — баг:** +- Подтверждение не появилось — вопросы заменились молча. +- Отказ в подтверждении всё равно подменил вопросы. +- Большой файл не вызвал ошибку, а как-то «прошёл». +- Файл не на русском дал тест с непонятной кашей вместо вопросов. --- -## Шпаргалка: что и где смотреть - -| Что | URL / SQL | -|---|---| -| Каталог | `/tests` | -| Редактор | `/tests/<id>/edit` | -| Версии теста | `GET /api/tests/<id>/versions` | -| Активность LLM | `/settings` + `POST /api/llm/ping` | -| `test_versions` | `SELECT id, version, is_active, parent_id, created_at FROM test_versions WHERE test_id = '<id>' ORDER BY version;` | -| Попытки | `SELECT id, test_version_id, status, created_at FROM test_attempts WHERE test_version_id IN (SELECT id FROM test_versions WHERE test_id='<id>');` | - -При баге прикладывайте: -- скрин редактора / модалки; -- ответ соответствующего эндпоинта (DevTools → Network → JSON); -- результат SQL из таблицы выше; -- `docker compose -f docker-compose.dev.yml logs --tail=200 testing-flask`. +### 2.7. Поведение, если ключа AI нет + +> Этот сценарий обычно проверять не нужно — он сработает автоматически, если разработчик уберёт ключ. +> Но если в каталоге AI вообще не работает, то проверьте именно это, чтобы понять — это баг или просто ключ не настроен. + +**Как проверять:** +1. **«Настройки»** → если статус **«Не задан»** (красный) — это и есть тестируемая ситуация. +2. Откройте любой тест и нажмите по очереди: + - **«По названию»** + - **«По текущей сетке»** + - **«Проверить»** + - **«Улучшить»** + - **«AI: вопрос/переформулировать»** в любой карточке + - **«Загрузить документ»** + +**Что должно произойти:** в каждом случае появляется понятное сообщение, +что AI не настроен, **и предложение открыть «Настройки»**. После согласия +— открывается страница `/settings`. Никакого «AI: ошибка» без объяснений. + +**Если что-то не так — баг:** +- Сообщение об ошибке без слов «Настройки». +- Ссылка/кнопка «Открыть Настройки» не ведёт на нужную страницу. +- Сайт молча ничего не делает после нажатия AI-кнопки. + +--- + +## Памятка: общий алгоритм отчёта о баге + +Если что-то идёт не так, к тикету приложите: + +1. **URL страницы**, на которой воспроизвели баг (полностью, из адресной + строки). +2. **Шаги** — что именно нажимали по порядку (1-2-3). +3. **Что увидели** против **что ожидали увидеть** (по описанию выше). +4. **Скриншот** экрана с проблемой (для модалок — со всем содержимым окна). +5. Если ошибка — **точный текст** сообщения. +6. **Учётная запись**, под которой воспроизвели (логин, без пароля). + +Этого достаточно — лезть в консоль/код/базу не нужно и не надо. From c3bdb406d60ac5a7edc3a96ab7391183eabbb082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=82=D0=B8?= =?UTF-8?q?=D0=BD=20=D0=9B=D0=B5=D0=B1=D0=B5=D0=B4=D0=B8=D0=BD=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=B9?= <lebedinsky.kd@gmail.com> Date: Tue, 28 Apr 2026 00:11:10 +0500 Subject: [PATCH 15/15] =?UTF-8?q?docs(qa):=20=D1=83=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20=E2=80=94=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=B4=D0=BE=D0=B9=D0=B4=D1=91=D1=82?= =?UTF-8?q?=20=D0=BB=D1=8E=D0=B1=D0=B0=D1=8F=20=D1=83=D1=87=D1=91=D1=82?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- docs/QA-versioning-and-ai.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/QA-versioning-and-ai.md b/docs/QA-versioning-and-ai.md index 8bce2c5..fa16233 100644 --- a/docs/QA-versioning-and-ai.md +++ b/docs/QA-versioning-and-ai.md @@ -2,8 +2,10 @@ Сайт: **[https://edullm.pirogov.ai/](https://edullm.pirogov.ai/)** -Учётка: войдите под автором (роль «менеджер» или «администратор»). Если -учётки нет — попросите её у разработчика, без неё тестировать нельзя. +Учётка: подойдёт **любая** учётная запись на сайте — никаких особых ролей +не требуется. Любой залогиненный пользователь, который создаёт тест, +автоматически становится его автором и может его редактировать. Если +учётки нет — попросите её у разработчика. > Всё, что описано ниже, проверяется **только через сайт**. Если в каком-то > сценарии написано «недоступно сейчас» — это **не баг**, это значит, что