feat(flask): E1.0–E1.3, E1.8 — миграция на Python/Flask + AI v2
Этап 1 миграции TestingWebApp на целевой стек (Python/Flask/Jinja),
БД остаётся clinic_tests.
E1.0 — База Flask-приложения: SQLAlchemy/psycopg2 пул, Flask sessions,
фабрика create_app, blueprint main с / и /health, base.html в стиле
кабинета HR (Tailwind CDN + Manrope + Material Symbols), 404/500.
E1.1 — Auth + /api/me: Flask sessions (signed cookie) вместо JWT,
bcrypt + Werkzeug, опц. HR_AUTH=1 с UPSERT в clinic_tests.users по
staff_id. UI /login, JSON /api/auth/{login,logout,me}, декораторы
@login_required / @require_role.
E1.2 — Тесты: список + редактор. 10 эндпоинтов, сервисы test_draft,
test_access, test_chain, ai_editor, llm_client, draft_validator,
editor_content. UI /tests (каталог + создание) и /tests/<id>/edit
(редактор с AI). Полный мобильный UX (аккордеоны/drag-n-drop) — в E1.7.
E1.3 — Импорт документов: pypdf + python-docx, эндпоинт
POST /api/tests/import/document, кнопка «Импорт документа» в
AI-панели редактора, лимит 16 МБ.
E1.8 — AI v2: страница /settings (статус ENV-ключа + ping),
ai/generate-by-title (без сетки), ai/check (рецензия), ai/improve
(массовое было→стало с чекбоксами). Унифицированный ответ AI-ошибок:
{ error, code, settingsUrl }.
Docker:
- docker-compose.dev.yml: добавлены DATABASE_URL, HR_AUTH/HR_DATABASE_URL,
DEEPSEEK_API_KEY/OPENAI_API_KEY/LLM_BASE_URL/LLM_MODEL и сеть postgres
для testing-flask.
Документация:
- docs/migration-final.md — двух-этапный план (Этап 1: унификация
стека внутри TestingWebApp; Этап 2: слияние с tgFlaskForm).
- docs/migration-final-inventory.md — карта 22 эндпоинтов Express.
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
"""AI-генерация теста/вопроса в редакторе (порт `services/aiEditorService.js`)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .draft_validator import (
|
||||
assert_draft_matches_shape,
|
||||
parse_json_from_llm_text,
|
||||
validate_and_normalize_draft,
|
||||
)
|
||||
from .llm_client import LlmError, chat_completion_text_content, get_llm_config
|
||||
|
||||
|
||||
class HttpError(Exception):
|
||||
def __init__(self, status: int, message: str):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
def parse_and_validate_shape(s: Any) -> list[dict]:
|
||||
if not isinstance(s, list) or not s:
|
||||
raise HttpError(400, 'Передайте непустой массив shape: [{ optionsCount, hasMultipleAnswers }, ...].')
|
||||
if len(s) > 40:
|
||||
raise HttpError(400, 'Не более 40 вопросов за раз.')
|
||||
out = []
|
||||
for i, row in enumerate(s):
|
||||
if not isinstance(row, dict):
|
||||
raise HttpError(400, f'shape[{i}]: ожидается объект.')
|
||||
try:
|
||||
n = int(float(row.get('optionsCount')))
|
||||
except (TypeError, ValueError):
|
||||
raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.')
|
||||
if n < 2 or n > 12:
|
||||
raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.')
|
||||
out.append({'optionsCount': n, 'hasMultipleAnswers': bool(row.get('hasMultipleAnswers'))})
|
||||
return out
|
||||
|
||||
|
||||
def _require_cfg():
|
||||
cfg = get_llm_config()
|
||||
if cfg is None:
|
||||
raise HttpError(503, 'Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.')
|
||||
return cfg
|
||||
|
||||
|
||||
def generate_full_test_by_shape(test_title: str, test_description: str, shape: list[dict]) -> dict:
|
||||
cfg = _require_cfg()
|
||||
title = (test_title or '').strip() or 'Тест'
|
||||
desc = (test_description or '').strip()
|
||||
lines = []
|
||||
for i, sh in enumerate(shape):
|
||||
if sh['hasMultipleAnswers']:
|
||||
tail = 'несколько вариантов помечены как верные (hasMultipleAnswers: true).'
|
||||
else:
|
||||
tail = 'ровно один верный вариант (hasMultipleAnswers: false).'
|
||||
lines.append(f'Вопрос {i + 1}: ровно {sh["optionsCount"]} вариантов ответа; {tail}')
|
||||
|
||||
system = (
|
||||
'Ты составитель учебных тестов. Отвечай ТОЛЬКО одним JSON-объектом на русском. '
|
||||
'Схема: {"title": string, "description": string (может быть пустой строкой), '
|
||||
'"questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers", '
|
||||
'"options": [{ "text", "isCorrect" }]}.'
|
||||
)
|
||||
user = (
|
||||
'Составь тест по теме.\n\n'
|
||||
f'Название (можно уточнить, но смысл сохранить): {title}\n'
|
||||
f'Краткое описание / контекст темы: '
|
||||
f'{desc or "не указано; придумай согласованную тему с названием."}\n\n'
|
||||
f'Соблюди СТРОГО число вопросов и вариантов (не больше и не меньше):\n'
|
||||
+ '\n'.join(lines)
|
||||
+ '\n\nПравила: варианты — осмысленные, по теме; отметь isCorrect согласно '
|
||||
'hasMultipleAnswers; для одного правильного — ровна одна true.'
|
||||
)
|
||||
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.35)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
draft = validate_and_normalize_draft(parsed)
|
||||
assert_draft_matches_shape({'questions': draft['questions']}, shape)
|
||||
return {
|
||||
'title': draft['title'],
|
||||
'description': draft['description'],
|
||||
'questions': draft['questions'],
|
||||
}
|
||||
|
||||
|
||||
# ─── E1.8: AI v2 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_test_by_title(
|
||||
test_title: str,
|
||||
test_description: str = '',
|
||||
questions_count: int = 10,
|
||||
options_count: int = 4,
|
||||
has_multiple_answers: bool = False,
|
||||
) -> dict:
|
||||
"""Генерация теста ТОЛЬКО по названию: AI сам предлагает вопросы.
|
||||
|
||||
Сетка не задаётся жёстко: пользователю даётся подсказка о желаемом числе
|
||||
вопросов и вариантов, но мы валидируем мягко (не assert_draft_matches_shape).
|
||||
"""
|
||||
cfg = _require_cfg()
|
||||
title = (test_title or '').strip()
|
||||
if not title:
|
||||
raise HttpError(400, 'Укажите название теста.')
|
||||
desc = (test_description or '').strip()
|
||||
n_q = max(3, min(40, int(questions_count or 10)))
|
||||
n_opt = max(2, min(12, int(options_count or 4)))
|
||||
|
||||
system = (
|
||||
'Ты опытный методист, составляешь учебные тесты. Отвечай ТОЛЬКО одним '
|
||||
'JSON-объектом на русском. Схема: {"title", "description", "questions": ['
|
||||
'{"text", "hasMultipleAnswers": boolean, "options": [{"text", "isCorrect"}]}'
|
||||
']}. Минимум 2 варианта, отметь хотя бы один isCorrect: true.'
|
||||
)
|
||||
user = (
|
||||
'Составь учебный тест по этой теме.\n\n'
|
||||
f'Название теста: {title}\n'
|
||||
f'Описание/контекст: {desc or "не указано — определи по названию."}\n\n'
|
||||
f'Подсказка по сетке: примерно {n_q} вопросов, в каждом по {n_opt} вариантов '
|
||||
f'ответа; '
|
||||
f'тип ответа — {"несколько правильных" if has_multiple_answers else "один правильный"} '
|
||||
f'(но если по смыслу нужно отступить — отступи). '
|
||||
'Покрой ключевые подтемы. Дистракторы делай правдоподобными, не очевидно '
|
||||
'неверными. Текст — короткий, понятный.'
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.45)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
draft = validate_and_normalize_draft(parsed)
|
||||
return {
|
||||
'title': draft['title'],
|
||||
'description': draft['description'],
|
||||
'questions': draft['questions'],
|
||||
}
|
||||
|
||||
|
||||
def check_test_quality(test_title: str, test_description: str, questions: list[dict]) -> dict:
|
||||
"""AI-рецензия теста: общий вердикт + список рекомендаций по разделам."""
|
||||
cfg = _require_cfg()
|
||||
title = (test_title or '').strip() or 'Тест'
|
||||
desc = (test_description or '').strip()
|
||||
qs = questions or []
|
||||
if not qs:
|
||||
raise HttpError(400, 'В тесте нет вопросов — нечего проверять.')
|
||||
|
||||
system = (
|
||||
'Ты ревьюер учебных тестов. Отвечай ТОЛЬКО JSON: {"verdict": "ok"|"warn"|"bad", '
|
||||
'"summary": string (1-2 предложения), '
|
||||
'"sections": [{"title": string, "items": [string, ...]}]}. '
|
||||
'Разделы рекомендаций: «Чёткость формулировок», «Качество дистракторов», '
|
||||
'"Охват темы», «Сбалансированность сложности». Пропусти раздел, если '
|
||||
'претензий нет. Вердикт: ok — годен; warn — есть замечания; bad — серьёзные '
|
||||
'проблемы. Все тексты — на русском, короткие и предметные.'
|
||||
)
|
||||
test_dump = {
|
||||
'title': title,
|
||||
'description': desc,
|
||||
'questions': [
|
||||
{
|
||||
'text': q.get('text', ''),
|
||||
'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')),
|
||||
'options': [
|
||||
{'text': o.get('text', ''), 'isCorrect': bool(o.get('isCorrect'))}
|
||||
for o in (q.get('options') or [])
|
||||
],
|
||||
}
|
||||
for q in qs
|
||||
],
|
||||
}
|
||||
import json as _json
|
||||
|
||||
user = 'Проверь качество теста и дай рекомендации:\n\n' + _json.dumps(
|
||||
test_dump, ensure_ascii=False
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.25)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
raise LlmError('Неверный формат ответа модели.', code='llm_shape')
|
||||
verdict = str(parsed.get('verdict') or '').strip().lower()
|
||||
if verdict not in ('ok', 'warn', 'bad'):
|
||||
verdict = 'warn'
|
||||
summary = str(parsed.get('summary') or '').strip()
|
||||
raw_sections = parsed.get('sections') or []
|
||||
sections: list[dict] = []
|
||||
if isinstance(raw_sections, list):
|
||||
for s in raw_sections:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
t = str(s.get('title') or '').strip()
|
||||
items = s.get('items') or []
|
||||
if not t or not isinstance(items, list) or not items:
|
||||
continue
|
||||
clean_items = [str(x).strip() for x in items if str(x).strip()]
|
||||
if clean_items:
|
||||
sections.append({'title': t, 'items': clean_items})
|
||||
return {'verdict': verdict, 'summary': summary, 'sections': sections}
|
||||
|
||||
|
||||
def improve_test_full(test_title: str, test_description: str, questions: list[dict]) -> dict:
|
||||
"""AI-улучшение всего теста. Возвращает 'было → стало' для каждого вопроса.
|
||||
|
||||
Для каждого вопроса: original + suggested, флаги textChanged/optionsChanged.
|
||||
UI решает, что применить (чекбоксы).
|
||||
"""
|
||||
cfg = _require_cfg()
|
||||
title = (test_title or '').strip() or 'Тест'
|
||||
desc = (test_description or '').strip()
|
||||
qs = questions or []
|
||||
if not qs:
|
||||
raise HttpError(400, 'В тесте нет вопросов — нечего улучшать.')
|
||||
|
||||
system = (
|
||||
'Ты редактор учебных тестов. Получаешь массив вопросов и предлагаешь '
|
||||
'улучшения: чёткие формулировки, лучшие дистракторы, корректную разметку '
|
||||
'isCorrect. Сохраняй исходную сетку: число вопросов, число вариантов и '
|
||||
'значение hasMultipleAnswers НЕ меняй — иначе клиент отклонит ответ. '
|
||||
'Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", '
|
||||
'"options": [{"text", "isCorrect"}]}, ...]}. Тексты — на русском, короткие.'
|
||||
)
|
||||
test_dump = {
|
||||
'title': title,
|
||||
'description': desc,
|
||||
'questions': [
|
||||
{
|
||||
'text': q.get('text', ''),
|
||||
'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')),
|
||||
'options': [
|
||||
{'text': o.get('text', ''), 'isCorrect': bool(o.get('isCorrect'))}
|
||||
for o in (q.get('options') or [])
|
||||
],
|
||||
}
|
||||
for q in qs
|
||||
],
|
||||
}
|
||||
import json as _json
|
||||
|
||||
user = 'Улучши тест без изменения сетки:\n\n' + _json.dumps(
|
||||
test_dump, ensure_ascii=False
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.3)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
|
||||
shape = [
|
||||
{
|
||||
'optionsCount': len(q.get('options') or []),
|
||||
'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')),
|
||||
}
|
||||
for q in qs
|
||||
]
|
||||
assert_draft_matches_shape(parsed, shape)
|
||||
draft = validate_and_normalize_draft(
|
||||
{'title': title, 'questions': parsed.get('questions') or []}
|
||||
)
|
||||
suggested_qs = draft['questions']
|
||||
|
||||
items = []
|
||||
for i, (orig, sug) in enumerate(zip(qs, suggested_qs)):
|
||||
orig_opts = [
|
||||
{'text': str(o.get('text', '')).strip(), 'isCorrect': bool(o.get('isCorrect'))}
|
||||
for o in (orig.get('options') or [])
|
||||
]
|
||||
sug_opts = sug['options']
|
||||
text_changed = (str(orig.get('text', '')).strip() != sug['text'])
|
||||
options_changed = (
|
||||
len(orig_opts) != len(sug_opts)
|
||||
or any(
|
||||
a['text'] != b['text'] or a['isCorrect'] != b['isCorrect']
|
||||
for a, b in zip(orig_opts, sug_opts)
|
||||
)
|
||||
)
|
||||
items.append(
|
||||
{
|
||||
'index': i,
|
||||
'original': {
|
||||
'text': str(orig.get('text', '')).strip(),
|
||||
'hasMultipleAnswers': bool(orig.get('hasMultipleAnswers')),
|
||||
'options': orig_opts,
|
||||
},
|
||||
'suggested': {
|
||||
'text': sug['text'],
|
||||
'hasMultipleAnswers': sug['hasMultipleAnswers'],
|
||||
'options': sug_opts,
|
||||
},
|
||||
'textChanged': text_changed,
|
||||
'optionsChanged': options_changed,
|
||||
'changed': text_changed or options_changed,
|
||||
}
|
||||
)
|
||||
|
||||
return {'items': items}
|
||||
|
||||
|
||||
def generate_or_rephrase_question(
|
||||
test_title: str,
|
||||
test_description: str,
|
||||
question_text: str,
|
||||
options_count: Any,
|
||||
has_multiple_answers: bool,
|
||||
) -> dict:
|
||||
cfg = _require_cfg()
|
||||
try:
|
||||
n = int(float(options_count))
|
||||
except (TypeError, ValueError):
|
||||
raise HttpError(400, 'optionsCount: от 2 до 12.')
|
||||
if n < 2 or n > 12:
|
||||
raise HttpError(400, 'optionsCount: от 2 до 12.')
|
||||
|
||||
topic = (((test_title or '').strip() or 'Тест') + '. ' + (test_description or '').strip()).strip()
|
||||
qt = (question_text or '').strip()
|
||||
|
||||
if qt:
|
||||
system = (
|
||||
'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — '
|
||||
'чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости '
|
||||
'от сложности исходного черновика, без вариантов ответа.'
|
||||
)
|
||||
user = (
|
||||
f'Тема теста: {topic}\n\n'
|
||||
f'Исходный черновик вопроса (улучши формулировку, не меняй смысл без нужды):\n{qt}'
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.3)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
text = str((parsed or {}).get('text') or '').strip()
|
||||
if not text:
|
||||
raise LlmError('Пустой text в ответе модели.', code='llm_shape')
|
||||
return {'mode': 'rephrase', 'text': text}
|
||||
|
||||
system = (
|
||||
'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", '
|
||||
'"options": [{ "text", "isCorrect" }]}. Все на русском.'
|
||||
)
|
||||
multi_clause = (
|
||||
'true (несколько верных, минимум 2 isCorrect: true, остальные false).'
|
||||
if has_multiple_answers
|
||||
else 'false (ровно один isCorrect: true).'
|
||||
)
|
||||
user = (
|
||||
f'Тема теста: {topic}\n\n'
|
||||
f'Сформулируй ОДИН вопрос по этой теме с ровно {n} вариантами ответа. '
|
||||
f'hasMultipleAnswers = {multi_clause}'
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.35)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
shape = [{'optionsCount': n, 'hasMultipleAnswers': bool(has_multiple_answers)}]
|
||||
assert_draft_matches_shape({'questions': [parsed]}, shape)
|
||||
draft = validate_and_normalize_draft({'title': 'временно', 'questions': [parsed]})
|
||||
return {
|
||||
'mode': 'full',
|
||||
'text': draft['questions'][0]['text'],
|
||||
'hasMultipleAnswers': draft['questions'][0]['hasMultipleAnswers'],
|
||||
'options': draft['questions'][0]['options'],
|
||||
}
|
||||
Reference in New Issue
Block a user