4b0d56ff0e
Этап 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
106 lines
4.8 KiB
Python
106 lines
4.8 KiB
Python
"""Парсер JSON от LLM и валидатор draft (порт частей `documentGenService.js`)."""
|
|
from __future__ import annotations
|
|
|
|
import json as _json
|
|
import re
|
|
from typing import Any
|
|
|
|
from .llm_client import LlmError
|
|
|
|
|
|
_FENCE_RE = re.compile(r'^```(?:json)?\s*([\s\S]*?)```$', re.MULTILINE)
|
|
|
|
|
|
def parse_json_from_llm_text(text: str) -> Any:
|
|
if not isinstance(text, str) or not text.strip():
|
|
raise LlmError('Пустой ответ модели.', code='llm_empty')
|
|
t = text.strip()
|
|
if m := _FENCE_RE.match(t):
|
|
t = m.group(1).strip()
|
|
try:
|
|
return _json.loads(t)
|
|
except _json.JSONDecodeError:
|
|
raise LlmError('Ответ модели не является корректным JSON.', code='llm_json_parse')
|
|
|
|
|
|
def validate_and_normalize_draft(o: Any) -> dict:
|
|
if not isinstance(o, dict):
|
|
raise LlmError('JSON не содержит объекта с данными.', code='llm_shape')
|
|
title = str(o.get('title') or '').strip()
|
|
if not title:
|
|
raise LlmError('В ответе нет поля title.', code='llm_shape')
|
|
desc = o.get('description')
|
|
description = str(desc).strip() if desc and str(desc).strip() else None
|
|
|
|
raw_qs = o.get('questions')
|
|
if not isinstance(raw_qs, list) or not raw_qs:
|
|
raise LlmError('В ответе нет вопросов (questions).', code='llm_shape')
|
|
if len(raw_qs) > 40:
|
|
raise LlmError('Слишком много вопросов в ответе (макс. 40).', code='llm_shape')
|
|
|
|
questions = []
|
|
for i, q in enumerate(raw_qs):
|
|
if not isinstance(q, dict):
|
|
raise LlmError(f'Вопрос {i + 1}: неверный формат.', code='llm_shape')
|
|
text = str(q.get('text') or '').strip()
|
|
if not text:
|
|
raise LlmError(f'Вопрос {i + 1}: пустой текст.', code='llm_shape')
|
|
has_multi = bool(q.get('hasMultipleAnswers'))
|
|
raw_opts = q.get('options')
|
|
if not isinstance(raw_opts, list) or len(raw_opts) < 2:
|
|
raise LlmError(f'Вопрос {i + 1}: нужны минимум 2 варианта ответа.', code='llm_shape')
|
|
if len(raw_opts) > 12:
|
|
raise LlmError(f'Вопрос {i + 1}: слишком много вариантов (макс. 12).', code='llm_shape')
|
|
|
|
options = []
|
|
for j, op in enumerate(raw_opts):
|
|
if not isinstance(op, dict):
|
|
raise LlmError(f'Вопрос {i + 1}, вариант {j + 1}: неверный формат.', code='llm_shape')
|
|
options.append(
|
|
{
|
|
'text': (str(op.get('text') or '').strip() or f'Вариант {j + 1}'),
|
|
'isCorrect': bool(op.get('isCorrect')),
|
|
}
|
|
)
|
|
correct_n = sum(1 for x in options if x['isCorrect'])
|
|
if correct_n == 0:
|
|
raise LlmError(
|
|
f'Вопрос {i + 1}: отметьте минимум один правильный вариант.',
|
|
code='llm_shape',
|
|
)
|
|
if not has_multi and correct_n > 1:
|
|
raise LlmError(
|
|
f'Вопрос {i + 1}: с одним правильным ответом должен быть один вариант '
|
|
f'isCorrect, либо укажите hasMultipleAnswers: true.',
|
|
code='llm_shape',
|
|
)
|
|
questions.append({'text': text, 'hasMultipleAnswers': has_multi, 'options': options})
|
|
|
|
return {'title': title, 'description': description, 'questions': questions}
|
|
|
|
|
|
def assert_draft_matches_shape(o: dict, shape: list[dict]) -> None:
|
|
"""Проверяет, что число вопросов и вариантов = ровно как в shape."""
|
|
qs = o.get('questions') if isinstance(o, dict) else None
|
|
if not isinstance(qs, list):
|
|
raise LlmError('В ответе нет questions.', code='llm_shape')
|
|
if len(qs) != len(shape):
|
|
raise LlmError(
|
|
f'Ожидалось вопросов: {len(shape)}, в ответе: {len(qs)}.',
|
|
code='llm_shape',
|
|
)
|
|
for i, (q, sh) in enumerate(zip(qs, shape)):
|
|
opts = q.get('options') if isinstance(q, dict) else None
|
|
if not isinstance(opts, list):
|
|
raise LlmError(f'Вопрос {i + 1}: нет options.', code='llm_shape')
|
|
if len(opts) != sh['optionsCount']:
|
|
raise LlmError(
|
|
f'Вопрос {i + 1}: ожидалось вариантов {sh["optionsCount"]}, в ответе: {len(opts)}.',
|
|
code='llm_shape',
|
|
)
|
|
if bool(q.get('hasMultipleAnswers')) != sh['hasMultipleAnswers']:
|
|
raise LlmError(
|
|
f'Вопрос {i + 1}: hasMultipleAnswers должен быть {sh["hasMultipleAnswers"]}.',
|
|
code='llm_shape',
|
|
)
|