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:
@@ -1,8 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Фабрика Flask-приложения.
|
||||
|
||||
Этап 1:
|
||||
E1.0 — фундамент (БД-пул, sessions, base.html).
|
||||
E1.1 — авторизация (cookie-сессии Flask, bcrypt + Werkzeug, опц. HR_AUTH).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
|
||||
from flask import Flask, jsonify
|
||||
from flask import Flask, jsonify, render_template, request
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
@@ -13,17 +21,55 @@ def create_app() -> Flask:
|
||||
static_folder='static',
|
||||
static_url_path='/static',
|
||||
)
|
||||
|
||||
sk = (os.environ.get('SECRET_KEY') or '').strip()
|
||||
app.config['SECRET_KEY'] = sk or secrets.token_hex(32)
|
||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16 MB upload limit
|
||||
|
||||
@app.route('/health')
|
||||
def health():
|
||||
return jsonify(status='ok', service='testing-flask-app')
|
||||
app.config.update(
|
||||
SESSION_COOKIE_NAME='testing_session',
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE='Lax',
|
||||
SESSION_COOKIE_SECURE=(os.environ.get('FLASK_ENV') == 'production'),
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(days=7),
|
||||
)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
from flask import render_template
|
||||
from .blueprints.main import main_bp
|
||||
from .blueprints.settings import settings_bp
|
||||
from .auth import auth_bp
|
||||
from .tests import tests_bp
|
||||
|
||||
return render_template('index.html')
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(tests_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
|
||||
from .config import is_assignment_feature_enabled, is_dev_ui, is_hr_auth_enabled
|
||||
from .auth.decorators import current_user as _current_user
|
||||
|
||||
@app.context_processor
|
||||
def _inject_globals():
|
||||
return {
|
||||
'current_user': _current_user(),
|
||||
'hr_auth_enabled': is_hr_auth_enabled(),
|
||||
'dev_ui': is_dev_ui(),
|
||||
'assignment_ui': is_assignment_feature_enabled(),
|
||||
}
|
||||
|
||||
@app.errorhandler(404)
|
||||
def _not_found(_e):
|
||||
if _is_api_path():
|
||||
return jsonify(error='not_found'), 404
|
||||
return render_template('404.html'), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def _internal_error(_e):
|
||||
if _is_api_path():
|
||||
return jsonify(error='internal_error'), 500
|
||||
return render_template('500.html'), 500
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _is_api_path() -> bool:
|
||||
return request.path.startswith('/api/')
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Auth: логин/логаут/me — пара UI-страниц и JSON-API.
|
||||
|
||||
См. routes.py, services.py, decorators.py.
|
||||
"""
|
||||
from .routes import auth_bp # noqa: F401
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Декораторы доступа: подгружают пользователя из сессии.
|
||||
|
||||
`g.current_user` доступен в шаблонах как `current_user` (см. context_processor в `app/__init__.py`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from typing import Iterable
|
||||
|
||||
from flask import g, jsonify, redirect, request, session, url_for
|
||||
|
||||
from ..messages import RU
|
||||
from .services import AuthUser, load_user_by_id
|
||||
|
||||
|
||||
def _wants_json() -> bool:
|
||||
if request.path.startswith('/api/'):
|
||||
return True
|
||||
accept = request.headers.get('Accept', '')
|
||||
return 'application/json' in accept and 'text/html' not in accept
|
||||
|
||||
|
||||
def _load_current_user() -> AuthUser | None:
|
||||
if hasattr(g, 'current_user'):
|
||||
return g.current_user
|
||||
user_id = session.get('user_id')
|
||||
user = load_user_by_id(user_id) if user_id else None
|
||||
g.current_user = user
|
||||
return user
|
||||
|
||||
|
||||
def login_required(fn):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = _load_current_user()
|
||||
if user is None:
|
||||
if _wants_json():
|
||||
return jsonify(error=RU['authRequired']), 401
|
||||
return redirect(url_for('auth.login_page', next=request.full_path or '/'))
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def require_role(roles: str | Iterable[str]):
|
||||
allowed = {roles} if isinstance(roles, str) else set(roles)
|
||||
|
||||
def decorator(fn):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = _load_current_user()
|
||||
if user is None:
|
||||
if _wants_json():
|
||||
return jsonify(error=RU['authRequired']), 401
|
||||
return redirect(url_for('auth.login_page', next=request.full_path or '/'))
|
||||
if user.role not in allowed:
|
||||
if _wants_json():
|
||||
return jsonify(error=RU['insufficientPermissions']), 403
|
||||
return ('Доступ запрещён.', 403)
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def current_user() -> AuthUser | None:
|
||||
"""Хелпер для шаблонов и view-функций."""
|
||||
return _load_current_user()
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Маппинг роли HR → роль модуля тестов (порт `backend/src/utils/hrRoleMap.js`)."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def map_hr_role_to_app(hr_role: str | None) -> str:
|
||||
r = (hr_role or '').strip().lower()
|
||||
if not r:
|
||||
return 'employee'
|
||||
if r == 'admin' or 'hr' in r or 'дире' in r:
|
||||
return 'hr'
|
||||
if 'manager' in r or 'рук' in r or 'завед' in r:
|
||||
return 'manager'
|
||||
return 'employee'
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Маршруты auth: HTML (`/login`, `/logout`) и JSON (`/api/auth/*`)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
|
||||
from ..config import is_assignment_feature_enabled, is_dev_ui
|
||||
from ..messages import RU
|
||||
from .decorators import login_required, current_user
|
||||
from .services import AuthError, authenticate_credentials
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
def _safe_next(default: str = '/') -> str:
|
||||
"""Защита от open-redirect: разрешаем только относительные пути."""
|
||||
nxt = request.values.get('next') or default
|
||||
if not nxt.startswith('/') or nxt.startswith('//'):
|
||||
return default
|
||||
return nxt
|
||||
|
||||
|
||||
def _do_login(login: str, password: str):
|
||||
user = authenticate_credentials(login, password)
|
||||
session.clear()
|
||||
session['user_id'] = user.id
|
||||
session.permanent = True
|
||||
return user
|
||||
|
||||
|
||||
# ─── HTML ────────────────────────────────────────────────────────────
|
||||
|
||||
@auth_bp.route('/login', methods=['GET'])
|
||||
def login_page():
|
||||
if current_user() is not None:
|
||||
return redirect(_safe_next('/'))
|
||||
return render_template('auth/login.html', next=_safe_next('/'))
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['POST'])
|
||||
def login_submit():
|
||||
login = (request.form.get('login') or '').strip()
|
||||
password = request.form.get('password') or ''
|
||||
try:
|
||||
_do_login(login, password)
|
||||
except AuthError as e:
|
||||
flash(e.message, 'error')
|
||||
return render_template('auth/login.html', next=_safe_next('/'), login=login), e.status
|
||||
except Exception:
|
||||
log.exception('login_submit failed')
|
||||
flash(RU['loginFailed'], 'error')
|
||||
return render_template('auth/login.html', next=_safe_next('/'), login=login), 500
|
||||
return redirect(_safe_next('/'))
|
||||
|
||||
|
||||
@auth_bp.route('/logout', methods=['POST', 'GET'])
|
||||
def logout():
|
||||
session.clear()
|
||||
if request.method == 'GET':
|
||||
return redirect(url_for('auth.login_page'))
|
||||
return redirect(url_for('auth.login_page'))
|
||||
|
||||
|
||||
# ─── JSON API ────────────────────────────────────────────────────────
|
||||
|
||||
@auth_bp.route('/api/auth/login', methods=['POST'])
|
||||
def api_login():
|
||||
data = request.get_json(silent=True) or {}
|
||||
login = (data.get('login') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
try:
|
||||
user = _do_login(login, password)
|
||||
except AuthError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
except Exception:
|
||||
log.exception('api_login failed')
|
||||
return jsonify(error=RU['loginFailed']), 500
|
||||
return jsonify(user=user.to_public_dict())
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/logout', methods=['POST'])
|
||||
def api_logout():
|
||||
session.clear()
|
||||
return jsonify(message=RU['loggedOut'])
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/me', methods=['GET'])
|
||||
@login_required
|
||||
def api_me():
|
||||
user = current_user()
|
||||
return jsonify(
|
||||
user=user.to_public_dict() if user else None,
|
||||
devUi=is_dev_ui(),
|
||||
assignmentUi=is_assignment_feature_enabled(),
|
||||
)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Бизнес-логика логина — порт `backend/src/routes/auth.js` + `utils/auth.js`.
|
||||
|
||||
Поддерживает оба режима:
|
||||
- Локальный (по умолчанию): bcrypt в `clinic_tests.users.password_hash`.
|
||||
- HR_AUTH=1: пароль проверяется в `hr_bot_test.users` (Werkzeug scrypt/pbkdf2),
|
||||
затем находим запись `staff_members` по `web_login` и UPSERT-им в
|
||||
`clinic_tests.users` по `staff_id`.
|
||||
|
||||
Werkzeug-хеши проверяются через `werkzeug.security.check_password_hash`,
|
||||
bcrypt-хеши — через пакет `bcrypt`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import text
|
||||
from werkzeug.security import check_password_hash as _werkzeug_check
|
||||
|
||||
from ..config import (
|
||||
HR_MANAGED_PASSWORD_PLACEHOLDER,
|
||||
is_hr_auth_enabled,
|
||||
)
|
||||
from ..db import get_engine, get_hr_engine
|
||||
from ..messages import RU
|
||||
from .hr_role import map_hr_role_to_app
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthUser:
|
||||
id: str # UUID в виде строки
|
||||
login: str
|
||||
full_name: str | None
|
||||
role: str
|
||||
department_id: Optional[str]
|
||||
staff_id: Optional[int]
|
||||
|
||||
def to_public_dict(self) -> dict:
|
||||
out = {
|
||||
'id': str(self.id),
|
||||
'login': self.login,
|
||||
'fullName': self.full_name,
|
||||
'role': self.role,
|
||||
'departmentId': self.department_id,
|
||||
}
|
||||
out['staffId'] = self.staff_id
|
||||
return out
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
"""Ошибка авторизации с HTTP-кодом и сообщением для пользователя."""
|
||||
|
||||
def __init__(self, status: int, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
def _verify_password(plain: str, hashed: str | None) -> bool:
|
||||
"""Универсальная проверка пароля: bcrypt + Werkzeug (scrypt/pbkdf2)."""
|
||||
if not hashed:
|
||||
return False
|
||||
if hashed == HR_MANAGED_PASSWORD_PLACEHOLDER:
|
||||
return False
|
||||
if hashed.startswith('scrypt:') or hashed.startswith('pbkdf2:'):
|
||||
try:
|
||||
return _werkzeug_check(hashed, plain)
|
||||
except Exception:
|
||||
return False
|
||||
if hashed.startswith('$2'):
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode('utf-8'), hashed.encode('utf-8'))
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
return _werkzeug_check(hashed, plain)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def authenticate_credentials(login: str, password: str) -> AuthUser:
|
||||
"""Главная точка входа. Возвращает AuthUser или поднимает AuthError."""
|
||||
login = (login or '').strip()
|
||||
password = password or ''
|
||||
if not login or not password:
|
||||
raise AuthError(400, RU['loginAndPasswordRequired'])
|
||||
|
||||
if is_hr_auth_enabled():
|
||||
return _authenticate_via_hr(login, password)
|
||||
return _authenticate_local(login, password)
|
||||
|
||||
|
||||
# ─── локальный режим ────────────────────────────────────────────────
|
||||
|
||||
def _authenticate_local(login: str, password: str) -> AuthUser:
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
'SELECT id, login, password_hash, full_name, role, department_id, staff_id '
|
||||
'FROM users WHERE login = :login AND is_active = true'
|
||||
),
|
||||
{'login': login},
|
||||
).mappings().first()
|
||||
|
||||
if not row:
|
||||
raise AuthError(401, RU['invalidCredentials'])
|
||||
|
||||
if row['password_hash'] == HR_MANAGED_PASSWORD_PLACEHOLDER:
|
||||
raise AuthError(401, RU['useHrLogin'])
|
||||
|
||||
if not _verify_password(password, row['password_hash']):
|
||||
raise AuthError(401, RU['invalidCredentials'])
|
||||
|
||||
return AuthUser(
|
||||
id=str(row['id']),
|
||||
login=row['login'],
|
||||
full_name=row['full_name'],
|
||||
role=row['role'],
|
||||
department_id=row['department_id'],
|
||||
staff_id=row['staff_id'],
|
||||
)
|
||||
|
||||
|
||||
# ─── HR_AUTH=1 ──────────────────────────────────────────────────────
|
||||
|
||||
def _authenticate_via_hr(login: str, password: str) -> AuthUser:
|
||||
hr_eng = get_hr_engine()
|
||||
if hr_eng is None:
|
||||
raise AuthError(500, RU['hrDatabaseUrlMissing'])
|
||||
|
||||
with hr_eng.connect() as hr_conn:
|
||||
u = hr_conn.execute(
|
||||
text(
|
||||
'SELECT id, username, password_hash, role FROM users '
|
||||
'WHERE LOWER(TRIM(username)) = LOWER(TRIM(:login))'
|
||||
),
|
||||
{'login': login},
|
||||
).mappings().first()
|
||||
if not u or not u['password_hash']:
|
||||
raise AuthError(401, RU['invalidCredentials'])
|
||||
if not _verify_password(password, u['password_hash']):
|
||||
raise AuthError(401, RU['invalidCredentials'])
|
||||
|
||||
s = hr_conn.execute(
|
||||
text(
|
||||
'SELECT id, fio FROM staff_members '
|
||||
"WHERE LOWER(TRIM(COALESCE(web_login, ''))) = LOWER(TRIM(:login))"
|
||||
),
|
||||
{'login': login},
|
||||
).mappings().first()
|
||||
if not s:
|
||||
raise AuthError(403, RU['noStaffForLogin'])
|
||||
|
||||
staff_id = int(s['id'])
|
||||
fio = s['fio'] or login
|
||||
app_role = map_hr_role_to_app(u['role'])
|
||||
|
||||
eng = get_engine()
|
||||
with eng.begin() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (login, password_hash, full_name, role,
|
||||
department_id, is_active, staff_id)
|
||||
VALUES (:login, :ph, :fn, :role, NULL, true, :staff_id)
|
||||
ON CONFLICT (staff_id) DO UPDATE SET
|
||||
login = EXCLUDED.login,
|
||||
full_name = EXCLUDED.full_name,
|
||||
role = EXCLUDED.role,
|
||||
password_hash = EXCLUDED.password_hash
|
||||
RETURNING id, login, full_name, role, department_id, staff_id
|
||||
"""
|
||||
),
|
||||
{
|
||||
'login': login,
|
||||
'ph': HR_MANAGED_PASSWORD_PLACEHOLDER,
|
||||
'fn': fio,
|
||||
'role': app_role,
|
||||
'staff_id': staff_id,
|
||||
},
|
||||
).mappings().first()
|
||||
|
||||
return AuthUser(
|
||||
id=str(row['id']),
|
||||
login=row['login'],
|
||||
full_name=row['full_name'],
|
||||
role=row['role'],
|
||||
department_id=row['department_id'],
|
||||
staff_id=row['staff_id'],
|
||||
)
|
||||
|
||||
|
||||
def load_user_by_id(user_id: str) -> Optional[AuthUser]:
|
||||
"""Догружает пользователя из `clinic_tests.users` (используется при каждом запросе)."""
|
||||
if not user_id:
|
||||
return None
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
'SELECT id, login, full_name, role, department_id, staff_id '
|
||||
'FROM users WHERE id = :id AND is_active = true'
|
||||
),
|
||||
{'id': user_id},
|
||||
).mappings().first()
|
||||
if not row:
|
||||
return None
|
||||
return AuthUser(
|
||||
id=str(row['id']),
|
||||
login=row['login'],
|
||||
full_name=row['full_name'],
|
||||
role=row['role'],
|
||||
department_id=row['department_id'],
|
||||
staff_id=row['staff_id'],
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Главный blueprint — посадочная страница и health-чек.
|
||||
|
||||
В E1.0 здесь нет бизнес-логики; страницы и эндпоинты добавляются в следующих
|
||||
спринтах (E1.1 — auth, E1.2 — тесты, и т.д.).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, render_template
|
||||
|
||||
from .. import db as app_db
|
||||
from ..auth.decorators import login_required
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
@main_bp.route('/health')
|
||||
def health():
|
||||
"""Smoke-проверка приложения и подключений к БД (без авторизации)."""
|
||||
db_status = app_db.ping()
|
||||
overall = 'ok' if db_status.get('main') == 'ok' else 'degraded'
|
||||
return jsonify(
|
||||
status=overall,
|
||||
service='testing-flask-app',
|
||||
db=db_status,
|
||||
)
|
||||
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Страница настроек: статус LLM-ключа и проверка подключения (E1.8).
|
||||
|
||||
Ключ — общий, читается из ENV (`DEEPSEEK_API_KEY` / `OPENAI_API_KEY`). Здесь —
|
||||
только просмотр статуса и smoke-проверка. Изменение ключа — через `.env` и
|
||||
рестарт процесса.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, render_template
|
||||
|
||||
from ..auth.decorators import login_required
|
||||
from ..services.llm_client import get_llm_config, ping_llm
|
||||
|
||||
settings_bp = Blueprint('settings', __name__)
|
||||
|
||||
|
||||
@settings_bp.route('/settings', methods=['GET'])
|
||||
@login_required
|
||||
def settings_page():
|
||||
cfg = get_llm_config()
|
||||
return render_template(
|
||||
'settings.html',
|
||||
configured=cfg is not None,
|
||||
provider=cfg.provider if cfg else None,
|
||||
model=cfg.model if cfg else None,
|
||||
base_url=cfg.base_url if cfg else None,
|
||||
)
|
||||
|
||||
|
||||
@settings_bp.route('/api/llm/ping', methods=['POST', 'GET'])
|
||||
@login_required
|
||||
def api_llm_ping():
|
||||
return jsonify(ping_llm())
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Точечные настройки и feature-флаги (1:1 с Express-бэкендом)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
HR_MANAGED_PASSWORD_PLACEHOLDER = '$HR$MANAGED$NO_LOCAL$'
|
||||
"""Заглушка пароля для пользователей, попавших в clinic_tests через HR-апсёрт.
|
||||
При локальном входе compare всегда даёт False (см. authenticate_local)."""
|
||||
|
||||
|
||||
def _truthy(val: str | None) -> bool:
|
||||
return (val or '').strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
|
||||
def is_hr_auth_enabled() -> bool:
|
||||
"""`HR_AUTH=1` → логин через `hr_bot_test.users` (Werkzeug)."""
|
||||
return _truthy(os.environ.get('HR_AUTH'))
|
||||
|
||||
|
||||
def is_assignment_feature_enabled() -> bool:
|
||||
"""API/UI назначения тестов сотрудникам (см. backend/src/config/featureFlags.js)."""
|
||||
if (os.environ.get('FLASK_ENV') or '').lower() == 'development':
|
||||
return True
|
||||
if (os.environ.get('FLASK_DEBUG') or '').strip() == '1':
|
||||
return True
|
||||
raw = (os.environ.get('CLINIC_ASSIGNMENT_ENABLED') or '').strip().lower()
|
||||
if raw in ('1', 'true', 'yes'):
|
||||
return True
|
||||
if raw in ('0', 'false', 'no'):
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def is_dev_ui() -> bool:
|
||||
"""В Express это `NODE_ENV=development`. У нас — FLASK_ENV/FLASK_DEBUG."""
|
||||
if (os.environ.get('FLASK_ENV') or '').lower() == 'development':
|
||||
return True
|
||||
return (os.environ.get('FLASK_DEBUG') or '').strip() == '1'
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Подключение к PostgreSQL — тот же паттерн, что в HR_TG_Bot/tgFlaskForm/db/session.py.
|
||||
|
||||
В Этапе 1 работаем с БД `clinic_tests` (схема не меняется). Опционально доступна
|
||||
вторая БД `hr_bot_test` для HR-аутентификации (см. .env.example, флаг HR_AUTH).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import QueuePool
|
||||
|
||||
_lock = threading.Lock()
|
||||
_engine: Optional[Engine] = None
|
||||
_session_local: Optional[sessionmaker] = None
|
||||
_hr_engine: Optional[Engine] = None
|
||||
_hr_session_local: Optional[sessionmaker] = None
|
||||
|
||||
|
||||
def get_database_url() -> str:
|
||||
"""URL основной БД (`clinic_tests`).
|
||||
|
||||
Приоритет: DATABASE_URL → отдельные DB_*-переменные.
|
||||
"""
|
||||
if db_url := os.environ.get('DATABASE_URL'):
|
||||
return db_url.strip()
|
||||
|
||||
db_host = os.environ.get('DB_HOST', 'localhost')
|
||||
db_port = os.environ.get('DB_PORT', '5432')
|
||||
db_name = os.environ.get('DB_NAME', 'clinic_tests')
|
||||
db_user = os.environ.get('DB_USER', 'hr_bot_user')
|
||||
db_password = os.environ.get('DB_PASSWORD', 'hrbot123')
|
||||
return f'postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}'
|
||||
|
||||
|
||||
def get_hr_database_url() -> Optional[str]:
|
||||
"""URL БД HR (`hr_bot_test`) — только если включён HR_AUTH."""
|
||||
if not _hr_auth_enabled():
|
||||
return None
|
||||
if url := os.environ.get('HR_DATABASE_URL'):
|
||||
return url.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _hr_auth_enabled() -> bool:
|
||||
val = (os.environ.get('HR_AUTH') or '').strip().lower()
|
||||
return val in ('1', 'true', 'yes', 'on')
|
||||
|
||||
|
||||
def get_engine() -> Engine:
|
||||
"""Возвращает общий engine основной БД (singleton на процесс)."""
|
||||
global _engine
|
||||
if _engine is not None:
|
||||
return _engine
|
||||
with _lock:
|
||||
if _engine is not None:
|
||||
return _engine
|
||||
_engine = create_engine(
|
||||
get_database_url(),
|
||||
poolclass=QueuePool,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session():
|
||||
"""Создаёт новую ORM-сессию поверх общего engine."""
|
||||
global _session_local
|
||||
if _session_local is None:
|
||||
with _lock:
|
||||
if _session_local is None:
|
||||
_session_local = sessionmaker(bind=get_engine())
|
||||
return _session_local()
|
||||
|
||||
|
||||
def get_hr_engine() -> Optional[Engine]:
|
||||
"""Engine для HR-БД. Возвращает None, если HR_AUTH не включён."""
|
||||
if not _hr_auth_enabled():
|
||||
return None
|
||||
global _hr_engine
|
||||
if _hr_engine is not None:
|
||||
return _hr_engine
|
||||
url = get_hr_database_url()
|
||||
if not url:
|
||||
return None
|
||||
with _lock:
|
||||
if _hr_engine is not None:
|
||||
return _hr_engine
|
||||
_hr_engine = create_engine(
|
||||
url,
|
||||
poolclass=QueuePool,
|
||||
pool_size=3,
|
||||
max_overflow=5,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
return _hr_engine
|
||||
|
||||
|
||||
def get_hr_session():
|
||||
"""Сессия для HR-БД (или None при выключенном HR_AUTH)."""
|
||||
eng = get_hr_engine()
|
||||
if eng is None:
|
||||
return None
|
||||
global _hr_session_local
|
||||
if _hr_session_local is None:
|
||||
with _lock:
|
||||
if _hr_session_local is None:
|
||||
_hr_session_local = sessionmaker(bind=eng)
|
||||
return _hr_session_local()
|
||||
|
||||
|
||||
def ping() -> dict:
|
||||
"""Smoke-проверка подключения к БД (используется в /health)."""
|
||||
out: dict = {'main': 'unknown'}
|
||||
try:
|
||||
with get_engine().connect() as conn:
|
||||
conn.exec_driver_sql('SELECT 1')
|
||||
out['main'] = 'ok'
|
||||
except Exception as e:
|
||||
out['main'] = f'error: {type(e).__name__}: {e}'
|
||||
|
||||
if _hr_auth_enabled():
|
||||
out['hr'] = 'unknown'
|
||||
try:
|
||||
eng = get_hr_engine()
|
||||
if eng is None:
|
||||
out['hr'] = 'disabled (HR_DATABASE_URL not set)'
|
||||
else:
|
||||
with eng.connect() as conn:
|
||||
conn.exec_driver_sql('SELECT 1')
|
||||
out['hr'] = 'ok'
|
||||
except Exception as e:
|
||||
out['hr'] = f'error: {type(e).__name__}: {e}'
|
||||
|
||||
return out
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Русские сообщения API (порт `backend/src/messages/ru.js`)."""
|
||||
|
||||
RU = {
|
||||
'loginAndPasswordRequired': 'Укажите логин и пароль.',
|
||||
'invalidCredentials': 'Неверный логин или пароль.',
|
||||
'useHrLogin': 'Войдите через учётную запись кадровой системы (тот же логин, что в HR).',
|
||||
'hrDatabaseUrlMissing': 'База кадровой системы не настроена: задайте HR_DATABASE_URL.',
|
||||
'hrDatabaseNotConfigured': 'База кадровой системы не настроена.',
|
||||
'noStaffForLogin': (
|
||||
'К учётной записи не привязан сотрудник: в HR в карточке сотрудника '
|
||||
'должно совпадать поле веб-логина (web_login) с логином входа.'
|
||||
),
|
||||
'loggedOut': 'Вы вышли из системы.',
|
||||
'logoutFailed': 'Не удалось выйти. Повторите попытку.',
|
||||
'userDataFailed': 'Не удалось загрузить данные пользователя.',
|
||||
'loginFailed': 'Ошибка входа. Повторите попытку.',
|
||||
'authRequired': 'Требуется вход в систему.',
|
||||
'tokenInvalid': 'Сессия истекла или недействительна. Войдите снова.',
|
||||
'userNotFound': 'Пользователь не найден.',
|
||||
'authError': 'Ошибка проверки доступа.',
|
||||
'insufficientPermissions': 'Недостаточно прав.',
|
||||
'departmentAccessDenied': 'Нет доступа к этому подразделению.',
|
||||
'notFound': 'Не найдено.',
|
||||
'internal': 'Внутренняя ошибка сервера.',
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Извлечение текста из PDF/DOCX/TXT/MD (порт `services/documentExtractService.js`)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class HttpError(Exception):
|
||||
def __init__(self, status: int, message: str):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
SUPPORTED_MIME = {
|
||||
'application/pdf': 'pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
||||
'text/plain': 'text',
|
||||
'text/markdown': 'text',
|
||||
}
|
||||
SUPPORTED_EXT = {
|
||||
'.pdf': 'pdf',
|
||||
'.docx': 'docx',
|
||||
'.txt': 'text',
|
||||
'.md': 'text',
|
||||
}
|
||||
|
||||
|
||||
def resolve_document_kind(mimetype: str | None, original_name: str | None = '') -> Optional[str]:
|
||||
m = (mimetype or '').lower()
|
||||
n = (original_name or '').lower()
|
||||
if m in SUPPORTED_MIME:
|
||||
return SUPPORTED_MIME[m]
|
||||
for ext, kind in SUPPORTED_EXT.items():
|
||||
if n.endswith(ext):
|
||||
return kind
|
||||
return None
|
||||
|
||||
|
||||
def extract_text_from_buffer(kind: str, buf: bytes) -> str:
|
||||
if kind == 'text':
|
||||
try:
|
||||
return buf.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
return buf.decode('utf-8', errors='replace')
|
||||
|
||||
if kind == 'docx':
|
||||
try:
|
||||
from docx import Document
|
||||
except ImportError:
|
||||
raise HttpError(500, 'python-docx не установлен (см. requirements.txt).')
|
||||
doc = Document(BytesIO(buf))
|
||||
parts = []
|
||||
for p in doc.paragraphs:
|
||||
if p.text:
|
||||
parts.append(p.text)
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
if cell.text:
|
||||
parts.append(cell.text)
|
||||
return '\n'.join(parts).replace('\r\n', '\n').strip()
|
||||
|
||||
if kind == 'pdf':
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
except ImportError:
|
||||
raise HttpError(500, 'pypdf не установлен (см. requirements.txt).')
|
||||
reader = PdfReader(BytesIO(buf))
|
||||
parts = []
|
||||
for page in reader.pages:
|
||||
try:
|
||||
t = page.extract_text() or ''
|
||||
except Exception:
|
||||
t = ''
|
||||
if t:
|
||||
parts.append(t)
|
||||
return '\n'.join(parts).replace('\r\n', '\n').strip()
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
def extract_text_from_file(mimetype: str | None, file_storage, original_name: str | None) -> str:
|
||||
"""`file_storage` — werkzeug FileStorage. Читает целиком в память (≤16 МБ)."""
|
||||
kind = resolve_document_kind(mimetype, original_name)
|
||||
if not kind:
|
||||
raise HttpError(400, 'Неподдерживаемый формат. Допустимы: PDF, DOCX, TXT, MD.')
|
||||
buf = file_storage.read()
|
||||
return extract_text_from_buffer(kind, buf)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Генерация черновика теста из извлечённого текста (порт части `documentGenService.js`)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .draft_validator import (
|
||||
parse_json_from_llm_text,
|
||||
validate_and_normalize_draft,
|
||||
)
|
||||
from .llm_client import LlmError, chat_completion_text_content, get_llm_config
|
||||
|
||||
|
||||
MAX_EXTRACT_CHARS = 14000
|
||||
|
||||
|
||||
def generation_for_import_document(extracted_text: str) -> dict:
|
||||
text = (extracted_text or '').strip()
|
||||
if not text:
|
||||
return {
|
||||
'available': False,
|
||||
'message': 'Нет извлечённого текста — нечего передавать в модель.',
|
||||
}
|
||||
cfg = get_llm_config()
|
||||
if cfg is None:
|
||||
return {
|
||||
'available': False,
|
||||
'message': (
|
||||
'Автогенерация выключена: задайте DEEPSEEK_API_KEY или OPENAI_API_KEY '
|
||||
'в .env. Превью текста ниже — можно вставить вручную.'
|
||||
),
|
||||
'textPreview': text[:4000],
|
||||
}
|
||||
if len(text) > MAX_EXTRACT_CHARS:
|
||||
slice_ = text[:MAX_EXTRACT_CHARS] + '\n\n[…фрагмент обрезан для API]'
|
||||
else:
|
||||
slice_ = text
|
||||
try:
|
||||
system = (
|
||||
'Ты помощник для составления тестов. Отвечай ТОЛЬКО одним JSON-объектом '
|
||||
'без пояснений. Схема: {"title": string, "description"?: string, '
|
||||
'"questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers": boolean, '
|
||||
'"options": [{"text", "isCorrect": boolean}, ...]}. Минимум 2 варианта. '
|
||||
'Для одиночного выбора ровно один isCorrect: true. '
|
||||
'Текст и формулировки — на русском, по содержанию входного материала.'
|
||||
)
|
||||
user = (
|
||||
'Составь тест с вопросами с одним или несколькими правильными ответами '
|
||||
'на основе текста:\n\n' + slice_
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.25)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
draft = validate_and_normalize_draft(parsed)
|
||||
return {
|
||||
'available': True,
|
||||
'message': (
|
||||
f'Сгенерировано: «{draft["title"]}», вопросов: '
|
||||
f'{len(draft["questions"])}. Нажмите «Применить сгенерированный черновик».'
|
||||
),
|
||||
'draft': draft,
|
||||
}
|
||||
except LlmError as e:
|
||||
return {
|
||||
'available': False,
|
||||
'message': f'Генерация не удалась: {e}',
|
||||
'errorCode': e.code,
|
||||
'textPreview': text[:4000],
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'available': False,
|
||||
'message': f'Генерация не удалась: {e}',
|
||||
'errorCode': 'unknown',
|
||||
'textPreview': text[:4000],
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Парсер 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',
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Контент редактора: тест + активная версия + дерево вопросов с правильными вариантами.
|
||||
|
||||
Порт `getEditorContent` + `loadQuestionsForVersion` (только includeCorrect=true вариант)
|
||||
из `services/testAttemptService.js`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..db import get_engine
|
||||
from ..messages import RU
|
||||
from .test_access import is_test_author
|
||||
|
||||
|
||||
class HttpError(Exception):
|
||||
def __init__(self, status: int, message: str):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
def load_questions_for_version(conn, test_version_id, *, include_correct: bool) -> list[dict]:
|
||||
qrows = conn.execute(
|
||||
text(
|
||||
'SELECT id, text, question_order, has_multiple_answers '
|
||||
'FROM questions WHERE test_version_id = :v ORDER BY question_order'
|
||||
),
|
||||
{'v': test_version_id},
|
||||
).mappings().all()
|
||||
out = []
|
||||
for r in qrows:
|
||||
orows = conn.execute(
|
||||
text(
|
||||
'SELECT id, text, is_correct, option_order '
|
||||
'FROM answer_options WHERE question_id = :q ORDER BY option_order'
|
||||
),
|
||||
{'q': r['id']},
|
||||
).mappings().all()
|
||||
options = []
|
||||
for o in orows:
|
||||
base = {
|
||||
'id': str(o['id']),
|
||||
'text': o['text'],
|
||||
'optionOrder': o['option_order'],
|
||||
}
|
||||
if include_correct:
|
||||
base['isCorrect'] = bool(o['is_correct'])
|
||||
options.append(base)
|
||||
out.append(
|
||||
{
|
||||
'id': str(r['id']),
|
||||
'text': r['text'],
|
||||
'questionOrder': r['question_order'],
|
||||
'hasMultipleAnswers': bool(r['has_multiple_answers']),
|
||||
'options': options,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def get_editor_content(user_id: str, test_id: str) -> dict:
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
tr = conn.execute(
|
||||
text(
|
||||
'SELECT id, title, description, passing_threshold, created_by '
|
||||
'FROM tests WHERE id = :id'
|
||||
),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
if not tr:
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
if not is_test_author(tr['created_by'], user_id):
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
tv = conn.execute(
|
||||
text(
|
||||
'SELECT id FROM test_versions WHERE test_id = :id AND is_active = true LIMIT 1'
|
||||
),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
if not tv:
|
||||
raise HttpError(400, 'Нет активной версии теста.')
|
||||
version_id = tv['id']
|
||||
questions = load_questions_for_version(conn, version_id, include_correct=True)
|
||||
|
||||
return {
|
||||
'test': {
|
||||
'id': str(tr['id']),
|
||||
'title': tr['title'],
|
||||
'description': tr['description'],
|
||||
'passingThreshold': tr['passing_threshold'],
|
||||
},
|
||||
'activeVersionId': str(version_id),
|
||||
'questions': questions,
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"""OpenAI-совместимый клиент Chat Completions (порт `services/llmClient.js`)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import json as _json
|
||||
|
||||
|
||||
class LlmError(Exception):
|
||||
"""Ошибка работы с LLM API."""
|
||||
|
||||
def __init__(self, message: str, code: str = 'llm_error', status: int | None = None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmConfig:
|
||||
provider: str
|
||||
api_key: str
|
||||
base_url: str
|
||||
model: str
|
||||
|
||||
|
||||
def get_llm_config() -> Optional[LlmConfig]:
|
||||
if k := os.environ.get('DEEPSEEK_API_KEY'):
|
||||
return LlmConfig(
|
||||
provider='deepseek',
|
||||
api_key=k,
|
||||
base_url=(os.environ.get('LLM_BASE_URL') or 'https://api.deepseek.com/v1').rstrip('/'),
|
||||
model=os.environ.get('LLM_MODEL') or 'deepseek-chat',
|
||||
)
|
||||
if k := os.environ.get('OPENAI_API_KEY'):
|
||||
return LlmConfig(
|
||||
provider='openai',
|
||||
api_key=k,
|
||||
base_url=(os.environ.get('LLM_BASE_URL') or 'https://api.openai.com/v1').rstrip('/'),
|
||||
model=os.environ.get('LLM_MODEL') or 'gpt-4o-mini',
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def chat_completion_text_content(
|
||||
cfg: LlmConfig,
|
||||
system: str,
|
||||
user: str,
|
||||
temperature: float = 0.25,
|
||||
timeout: int = 120,
|
||||
) -> str:
|
||||
"""Возвращает `assistant.message.content` (строку)."""
|
||||
body: dict = {
|
||||
'model': cfg.model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': system},
|
||||
{'role': 'user', 'content': user},
|
||||
],
|
||||
'temperature': temperature,
|
||||
}
|
||||
if (os.environ.get('LLM_NO_JSON') or '').strip() != '1':
|
||||
body['response_format'] = {'type': 'json_object'}
|
||||
|
||||
req = urllib.request.Request(
|
||||
f'{cfg.base_url}/chat/completions',
|
||||
data=_json.dumps(body).encode('utf-8'),
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {cfg.api_key}',
|
||||
},
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = _json.loads(resp.read().decode('utf-8'))
|
||||
except urllib.error.HTTPError as e:
|
||||
text = ''
|
||||
try:
|
||||
text = e.read().decode('utf-8', errors='replace')
|
||||
except Exception:
|
||||
pass
|
||||
raise LlmError(
|
||||
f'LLM {e.code}: {(text or "").replace(chr(10), " ")[:280]}',
|
||||
code='llm_http',
|
||||
status=e.code,
|
||||
)
|
||||
except (urllib.error.URLError, TimeoutError) as e:
|
||||
msg = str(getattr(e, 'reason', '') or e)
|
||||
if 'timed out' in msg.lower():
|
||||
raise LlmError('Превышен таймаут ожидания ответа LLM (120 с).', code='llm_timeout')
|
||||
raise LlmError(f'Сбой сети при обращении к LLM: {msg}', code='llm_network')
|
||||
|
||||
try:
|
||||
content = data['choices'][0]['message']['content']
|
||||
except (KeyError, IndexError, TypeError):
|
||||
content = None
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
raise LlmError('Пустой content в ответе API.', code='llm_empty')
|
||||
return content
|
||||
|
||||
|
||||
def ping_llm(timeout: int = 30) -> dict:
|
||||
"""Smoke-проверка подключения к LLM. Не бросает исключений — всё в результате.
|
||||
|
||||
Возвращает: {'ok': bool, 'provider', 'model', 'error'?, 'latencyMs'?, 'sample'?}
|
||||
"""
|
||||
import time
|
||||
|
||||
cfg = get_llm_config()
|
||||
if cfg is None:
|
||||
return {
|
||||
'ok': False,
|
||||
'configured': False,
|
||||
'error': 'Ключ не задан. Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY в .env.',
|
||||
}
|
||||
started = time.monotonic()
|
||||
try:
|
||||
raw = chat_completion_text_content(
|
||||
cfg,
|
||||
'Отвечай ТОЛЬКО JSON: {"ok": true}.',
|
||||
'ping',
|
||||
temperature=0.0,
|
||||
timeout=timeout,
|
||||
)
|
||||
ms = int((time.monotonic() - started) * 1000)
|
||||
return {
|
||||
'ok': True,
|
||||
'configured': True,
|
||||
'provider': cfg.provider,
|
||||
'model': cfg.model,
|
||||
'latencyMs': ms,
|
||||
'sample': raw[:120],
|
||||
}
|
||||
except LlmError as e:
|
||||
ms = int((time.monotonic() - started) * 1000)
|
||||
return {
|
||||
'ok': False,
|
||||
'configured': True,
|
||||
'provider': cfg.provider,
|
||||
'model': cfg.model,
|
||||
'latencyMs': ms,
|
||||
'error': str(e),
|
||||
'code': e.code,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'ok': False,
|
||||
'configured': True,
|
||||
'provider': cfg.provider,
|
||||
'model': cfg.model,
|
||||
'error': f'{type(e).__name__}: {e}',
|
||||
'code': 'unknown',
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Кто видит тест: автор + назначенные пользователи (порт `services/testAccessService.js`)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..db import get_engine
|
||||
|
||||
|
||||
def is_test_author(created_by, user_id) -> bool:
|
||||
"""`tests.created_by` — UUID. Сравниваем по строковому представлению."""
|
||||
if created_by is None or user_id is None:
|
||||
return False
|
||||
return str(created_by) == str(user_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccessResult:
|
||||
ok: bool
|
||||
is_author: bool
|
||||
not_found: bool
|
||||
|
||||
|
||||
def user_has_test_access(user_id: str, test_id: str) -> AccessResult:
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
row = conn.execute(
|
||||
text('SELECT created_by FROM tests WHERE id = :id'),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
if not row:
|
||||
return AccessResult(ok=False, is_author=False, not_found=True)
|
||||
if is_test_author(row['created_by'], user_id):
|
||||
return AccessResult(ok=True, is_author=True, not_found=False)
|
||||
ar = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM test_assignments ta
|
||||
INNER JOIN test_versions tv_a ON tv_a.id = ta.test_version_id
|
||||
INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id
|
||||
WHERE tv_a.test_id = :test_id
|
||||
AND tat.target_type = 'user'
|
||||
AND tat.target_id = :user_id
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{'test_id': test_id, 'user_id': user_id},
|
||||
).first()
|
||||
return AccessResult(ok=ar is not None, is_author=False, not_found=False)
|
||||
|
||||
|
||||
def list_visible_tests(user_id: str) -> list[dict]:
|
||||
"""Каталог: только активная цепочка + (автор OR назначен)."""
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT DISTINCT t.id, t.title, t.description, t.is_active AS chain_active,
|
||||
t.created_at, t.updated_at,
|
||||
tv.id AS active_version_id, tv.version,
|
||||
t.created_by, u.full_name AS author_full_name
|
||||
FROM tests t
|
||||
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
|
||||
INNER JOIN users u ON u.id = t.created_by
|
||||
WHERE t.is_active = true
|
||||
AND (
|
||||
t.created_by = :uid
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM test_assignments ta
|
||||
INNER JOIN test_versions tv2 ON tv2.id = ta.test_version_id
|
||||
INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id
|
||||
WHERE tv2.test_id = t.id
|
||||
AND tat.target_type = 'user'
|
||||
AND tat.target_id = :uid
|
||||
)
|
||||
)
|
||||
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC
|
||||
"""
|
||||
),
|
||||
{'uid': user_id},
|
||||
).mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def list_hidden_by_author(user_id: str) -> list[dict]:
|
||||
"""Скрытые автором цепочки (`is_active = false`) — видны только автору."""
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT t.id, t.title, t.description, t.is_active AS chain_active,
|
||||
t.created_at, t.updated_at, tv.id AS active_version_id, tv.version,
|
||||
t.created_by, u.full_name AS author_full_name
|
||||
FROM tests t
|
||||
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
|
||||
INNER JOIN users u ON u.id = t.created_by
|
||||
WHERE t.is_active = false AND t.created_by = :uid
|
||||
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC
|
||||
"""
|
||||
),
|
||||
{'uid': user_id},
|
||||
).mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Утилиты по цепочке теста (попытки/версии)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
def has_any_attempt_for_test(conn, test_id: str) -> bool:
|
||||
"""`conn` может быть Connection или Engine — обе поддерживают .execute()."""
|
||||
row = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM test_attempts ta
|
||||
INNER JOIN test_versions tv ON ta.test_version_id = tv.id
|
||||
WHERE tv.test_id = :test_id
|
||||
) AS has_any
|
||||
"""
|
||||
),
|
||||
{'test_id': test_id},
|
||||
).first()
|
||||
return bool(row[0])
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Создание/правка теста, fork версии при наличии попыток (порт `testDraftService.js`)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..db import get_engine
|
||||
from ..messages import RU
|
||||
from .test_access import is_test_author
|
||||
from .test_chain import has_any_attempt_for_test
|
||||
|
||||
|
||||
class HttpError(Exception):
|
||||
def __init__(self, status: int, message: str):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
def create_test_with_version(author_id: str, *, title: str, description: str | None) -> dict:
|
||||
eng = get_engine()
|
||||
with eng.begin() as conn:
|
||||
t = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO tests (title, description, created_by, is_active, is_versioned)
|
||||
VALUES (:title, :desc, :uid, true, true) RETURNING id
|
||||
"""
|
||||
),
|
||||
{'title': title, 'desc': description or None, 'uid': author_id},
|
||||
).mappings().first()
|
||||
test_id = t['id']
|
||||
v = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO test_versions (test_id, version, is_active, parent_id)
|
||||
VALUES (:tid, 1, true, NULL) RETURNING id
|
||||
"""
|
||||
),
|
||||
{'tid': test_id},
|
||||
).mappings().first()
|
||||
return {'testId': str(test_id), 'versionId': str(v['id'])}
|
||||
|
||||
|
||||
def _get_active_version_row(conn, test_id: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
text(
|
||||
'SELECT * FROM test_versions WHERE test_id = :id AND is_active = true LIMIT 1'
|
||||
),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _copy_question_tree(conn, from_version_id, to_version_id) -> None:
|
||||
questions = conn.execute(
|
||||
text(
|
||||
'SELECT id, text, question_order, has_multiple_answers '
|
||||
'FROM questions WHERE test_version_id = :v ORDER BY question_order'
|
||||
),
|
||||
{'v': from_version_id},
|
||||
).mappings().all()
|
||||
for q in questions:
|
||||
new_q = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
|
||||
VALUES (:v, :text, :ord, :multi) RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
'v': to_version_id,
|
||||
'text': q['text'],
|
||||
'ord': q['question_order'],
|
||||
'multi': q['has_multiple_answers'],
|
||||
},
|
||||
).mappings().first()
|
||||
nqid = new_q['id']
|
||||
opts = conn.execute(
|
||||
text(
|
||||
'SELECT text, is_correct, option_order FROM answer_options '
|
||||
'WHERE question_id = :q ORDER BY option_order'
|
||||
),
|
||||
{'q': q['id']},
|
||||
).mappings().all()
|
||||
for o in opts:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO answer_options (question_id, text, is_correct, option_order)
|
||||
VALUES (:q, :text, :ic, :ord)
|
||||
"""
|
||||
),
|
||||
{'q': nqid, 'text': o['text'], 'ic': o['is_correct'], 'ord': o['option_order']},
|
||||
)
|
||||
|
||||
|
||||
def _replace_version_content(conn, test_version_id, payload: dict) -> None:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM answer_options WHERE question_id IN (
|
||||
SELECT id FROM questions WHERE test_version_id = :v
|
||||
)
|
||||
"""
|
||||
),
|
||||
{'v': test_version_id},
|
||||
)
|
||||
conn.execute(
|
||||
text('DELETE FROM questions WHERE test_version_id = :v'),
|
||||
{'v': test_version_id},
|
||||
)
|
||||
questions = payload.get('questions') or []
|
||||
for i, q in enumerate(questions):
|
||||
ins_q = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
|
||||
VALUES (:v, :text, :ord, :multi) RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
'v': test_version_id,
|
||||
'text': q.get('text'),
|
||||
'ord': q.get('question_order') or (i + 1),
|
||||
'multi': bool(q.get('hasMultipleAnswers')),
|
||||
},
|
||||
).mappings().first()
|
||||
qid = ins_q['id']
|
||||
opts = q.get('options') or []
|
||||
for j, o in enumerate(opts):
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO answer_options (question_id, text, is_correct, option_order)
|
||||
VALUES (:q, :text, :ic, :ord)
|
||||
"""
|
||||
),
|
||||
{
|
||||
'q': qid,
|
||||
'text': o.get('text'),
|
||||
'ic': bool(o.get('isCorrect')),
|
||||
'ord': o.get('option_order') or (j + 1),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _fork_new_version(conn, test_id: str) -> dict:
|
||||
av = _get_active_version_row(conn, test_id)
|
||||
if not av:
|
||||
raise HttpError(500, RU['internal']) # invariant: должна быть активная версия
|
||||
mx = conn.execute(
|
||||
text(
|
||||
'SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = :t'
|
||||
),
|
||||
{'t': test_id},
|
||||
).mappings().first()
|
||||
next_v = (mx['v'] or 0) + 1
|
||||
conn.execute(
|
||||
text('UPDATE test_versions SET is_active = false WHERE test_id = :t'),
|
||||
{'t': test_id},
|
||||
)
|
||||
nv = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO test_versions (test_id, version, is_active, parent_id)
|
||||
VALUES (:t, :ver, true, :parent) RETURNING *
|
||||
"""
|
||||
),
|
||||
{'t': test_id, 'ver': next_v, 'parent': av['id']},
|
||||
).mappings().first()
|
||||
_copy_question_tree(conn, av['id'], nv['id'])
|
||||
return dict(nv)
|
||||
|
||||
|
||||
def save_test_draft(author_id: str, test_id: str, payload: dict) -> dict:
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
eng = get_engine()
|
||||
with eng.begin() as conn:
|
||||
t = conn.execute(
|
||||
text('SELECT id, created_by FROM tests WHERE id = :id'),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
if not t:
|
||||
raise HttpError(404, RU['testNotFound'] if 'testNotFound' in RU else 'Тест не найден.')
|
||||
if not is_test_author(t['created_by'], author_id):
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
|
||||
if payload.get('title') is not None or payload.get('description') is not None:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE tests
|
||||
SET title = COALESCE(:title, title),
|
||||
description = COALESCE(:desc, description),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id
|
||||
"""
|
||||
),
|
||||
{
|
||||
'title': payload.get('title'),
|
||||
'desc': payload.get('description'),
|
||||
'id': test_id,
|
||||
},
|
||||
)
|
||||
if payload.get('passingThreshold') is not None:
|
||||
try:
|
||||
raw = float(payload['passingThreshold'])
|
||||
pt = max(0, min(100, round(raw)))
|
||||
conn.execute(
|
||||
text(
|
||||
'UPDATE tests SET passing_threshold = :pt, updated_at = CURRENT_TIMESTAMP WHERE id = :id'
|
||||
),
|
||||
{'pt': pt, 'id': test_id},
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
has_attempts = has_any_attempt_for_test(conn, test_id)
|
||||
version_row = _get_active_version_row(conn, test_id)
|
||||
if not version_row:
|
||||
raise HttpError(500, 'Нет активной версии теста.')
|
||||
|
||||
forked = False
|
||||
if has_attempts and 'questions' in payload and payload.get('questions') is not None:
|
||||
version_row = _fork_new_version(conn, test_id)
|
||||
forked = True
|
||||
|
||||
if payload.get('questions') is not None:
|
||||
_replace_version_content(conn, version_row['id'], payload)
|
||||
|
||||
return {'testId': test_id, 'versionId': str(version_row['id']), 'forked': forked}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* Точечные стили поверх Tailwind CDN.
|
||||
В E1.0 файл почти пустой — задаёт только сглаживание иконок и базовый focus-ring,
|
||||
чтобы кнопки/ссылки были консистентны со стилем кабинета HR. */
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings:
|
||||
'FILL' 0,
|
||||
'wght' 400,
|
||||
'GRAD' 0,
|
||||
'opsz' 20;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid #6366f1; /* brand-500 */
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
/* Редактор теста: рабочий минимум.
|
||||
* Работает с эндпоинтами /api/tests/<id>/{draft, ai/generate-test, ai/generate-question}
|
||||
* и /api/tests/<id> (PATCH chainActive).
|
||||
*
|
||||
* Полная мобильная отполировка UX (4 аккордеона, fixed footer, drag-n-drop)
|
||||
* запланирована отдельным спринтом E1.7.
|
||||
*/
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const root = document.getElementById('editor-root');
|
||||
if (!root) return;
|
||||
|
||||
const TEST_ID = root.dataset.testId;
|
||||
const initial = JSON.parse(root.dataset.initial);
|
||||
|
||||
const $ = (sel, parent = document) => parent.querySelector(sel);
|
||||
const $$ = (sel, parent = document) => Array.from(parent.querySelectorAll(sel));
|
||||
|
||||
const titleEl = $('#test-title');
|
||||
const descEl = $('#test-description');
|
||||
const thresholdEl = $('#test-threshold');
|
||||
const questionsEl = $('#questions');
|
||||
const qCountEl = $('#q-count');
|
||||
const saveStatusEl = $('#save-status');
|
||||
const aiStatusEl = $('#ai-status');
|
||||
const chainActiveEl = $('#chain-active');
|
||||
|
||||
const tplQ = $('#tpl-question');
|
||||
const tplO = $('#tpl-option');
|
||||
|
||||
let chainActive = true;
|
||||
|
||||
// ─── render ─────────────────────────────────────────────────────────
|
||||
|
||||
function renderQuestion(q) {
|
||||
const node = tplQ.content.firstElementChild.cloneNode(true);
|
||||
node._q = { id: q.id || null };
|
||||
$('.q-text', node).value = q.text || '';
|
||||
$('.q-multi', node).checked = !!q.hasMultipleAnswers;
|
||||
|
||||
const optsEl = $('.q-options', node);
|
||||
(q.options || []).forEach((o) => optsEl.appendChild(renderOption(o)));
|
||||
|
||||
bindQuestionEvents(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
function renderOption(o) {
|
||||
const node = tplO.content.firstElementChild.cloneNode(true);
|
||||
$('.opt-text', node).value = o.text || '';
|
||||
$('.opt-correct', node).checked = !!o.isCorrect;
|
||||
$('.opt-delete', node).addEventListener('click', () => {
|
||||
node.remove();
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
function bindQuestionEvents(node) {
|
||||
$('.q-delete', node).addEventListener('click', () => {
|
||||
if (!confirm('Удалить вопрос?')) return;
|
||||
node.remove();
|
||||
renumber();
|
||||
});
|
||||
$('.q-up', node).addEventListener('click', () => {
|
||||
if (node.previousElementSibling) {
|
||||
node.parentNode.insertBefore(node, node.previousElementSibling);
|
||||
renumber();
|
||||
}
|
||||
});
|
||||
$('.q-down', node).addEventListener('click', () => {
|
||||
if (node.nextElementSibling) {
|
||||
node.parentNode.insertBefore(node.nextElementSibling, node);
|
||||
renumber();
|
||||
}
|
||||
});
|
||||
$('.q-add-option', node).addEventListener('click', () => {
|
||||
$('.q-options', node).appendChild(renderOption({ text: '', isCorrect: false }));
|
||||
});
|
||||
$('.q-ai', node).addEventListener('click', () => aiGenerateQuestion(node));
|
||||
}
|
||||
|
||||
function renumber() {
|
||||
$$('#questions .q-item').forEach((li, i) => {
|
||||
$('.q-num', li).textContent = `Вопрос #${i + 1}`;
|
||||
});
|
||||
qCountEl.textContent = $$('#questions .q-item').length;
|
||||
}
|
||||
|
||||
function loadInitial() {
|
||||
titleEl.value = initial.test.title || '';
|
||||
descEl.value = initial.test.description || '';
|
||||
thresholdEl.value =
|
||||
initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold);
|
||||
|
||||
questionsEl.innerHTML = '';
|
||||
(initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
renumber();
|
||||
}
|
||||
|
||||
// ─── collect ───────────────────────────────────────────────────────
|
||||
|
||||
function collectPayload() {
|
||||
const questions = $$('#questions .q-item').map((li, i) => ({
|
||||
text: $('.q-text', li).value.trim(),
|
||||
question_order: i + 1,
|
||||
hasMultipleAnswers: $('.q-multi', li).checked,
|
||||
options: $$('.opt-item', li).map((op, j) => ({
|
||||
text: $('.opt-text', op).value.trim(),
|
||||
isCorrect: $('.opt-correct', op).checked,
|
||||
option_order: j + 1,
|
||||
})),
|
||||
}));
|
||||
const payload = {
|
||||
title: titleEl.value.trim() || null,
|
||||
description: descEl.value.trim() || null,
|
||||
questions,
|
||||
};
|
||||
const t = thresholdEl.value;
|
||||
if (t !== '' && Number.isFinite(Number(t))) payload.passingThreshold = Number(t);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function collectShape() {
|
||||
return $$('#questions .q-item').map((li) => ({
|
||||
optionsCount: Math.max(2, $$('.opt-item', li).length || 4),
|
||||
hasMultipleAnswers: $('.q-multi', li).checked,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── actions ───────────────────────────────────────────────────────
|
||||
|
||||
$('#add-question').addEventListener('click', () => {
|
||||
questionsEl.appendChild(
|
||||
renderQuestion({
|
||||
text: '',
|
||||
hasMultipleAnswers: false,
|
||||
options: [
|
||||
{ text: '', isCorrect: true },
|
||||
{ text: '', isCorrect: false },
|
||||
{ text: '', isCorrect: false },
|
||||
{ text: '', isCorrect: false },
|
||||
],
|
||||
}),
|
||||
);
|
||||
renumber();
|
||||
});
|
||||
|
||||
$('#save-draft').addEventListener('click', async () => {
|
||||
saveStatusEl.textContent = 'Сохраняем…';
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/draft`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(collectPayload()),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось сохранить.');
|
||||
if (chainActiveEl.checked !== chainActive) {
|
||||
const r2 = await fetch(`/api/tests/${TEST_ID}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chainActive: chainActiveEl.checked }),
|
||||
});
|
||||
if (r2.ok) chainActive = chainActiveEl.checked;
|
||||
}
|
||||
saveStatusEl.textContent = data.forked
|
||||
? 'Сохранено (создана новая версия — есть попытки прохождения).'
|
||||
: 'Сохранено.';
|
||||
setTimeout(() => (saveStatusEl.textContent = ''), 4000);
|
||||
} catch (e) {
|
||||
saveStatusEl.textContent = '';
|
||||
alert(e.message || 'Не удалось сохранить.');
|
||||
}
|
||||
});
|
||||
|
||||
$('#ai-generate-test').addEventListener('click', async () => {
|
||||
const shape = collectShape();
|
||||
if (!shape.length) {
|
||||
alert('Сначала добавьте хотя бы один вопрос (его настройки определяют сетку).');
|
||||
return;
|
||||
}
|
||||
aiStatusEl.textContent = 'Генерируем…';
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
testTitle: titleEl.value,
|
||||
testDescription: descEl.value,
|
||||
shape,
|
||||
}),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
|
||||
const draft = data.draft;
|
||||
if (draft.title) titleEl.value = draft.title;
|
||||
if (draft.description) descEl.value = draft.description;
|
||||
questionsEl.innerHTML = '';
|
||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
renumber();
|
||||
aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`;
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
alert(e.message || 'AI: ошибка.');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── импорт документа (E1.3) ───────────────────────────────────
|
||||
|
||||
$('#ai-import-file').addEventListener('change', async (ev) => {
|
||||
const file = ev.target.files && ev.target.files[0];
|
||||
ev.target.value = '';
|
||||
if (!file) return;
|
||||
aiStatusEl.textContent = `Загружаем «${file.name}»…`;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const r = await fetch('/api/tests/import/document', { method: 'POST', body: fd });
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось импортировать.');
|
||||
const g = data.generation || {};
|
||||
if (!g.available) {
|
||||
aiStatusEl.textContent = '';
|
||||
const msg = g.message || 'AI недоступен.';
|
||||
const preview = (g.textPreview || data.extractedText || '').slice(0, 600);
|
||||
alert(msg + (preview ? '\n\nПервые 600 символов:\n' + preview : ''));
|
||||
return;
|
||||
}
|
||||
const ok = confirm(
|
||||
`${g.message}\n\nПрименить как новый черновик?\n` +
|
||||
`Текущие вопросы будут заменены.`,
|
||||
);
|
||||
if (!ok) {
|
||||
aiStatusEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
const draft = g.draft;
|
||||
if (draft.title) titleEl.value = draft.title;
|
||||
if (draft.description) descEl.value = draft.description;
|
||||
questionsEl.innerHTML = '';
|
||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
renumber();
|
||||
aiStatusEl.textContent = `Применено: ${draft.questions?.length || 0} вопросов.`;
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
alert(e.message || 'Не удалось импортировать.');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── AI v2 (E1.8): generate-by-title / check / improve ─────────
|
||||
|
||||
function aiAlert(data, fallback) {
|
||||
const msg = (data && data.error) || fallback || 'AI: ошибка.';
|
||||
if (data && data.settingsUrl) {
|
||||
if (confirm(msg + '\n\nОткрыть Настройки?')) {
|
||||
window.location.href = data.settingsUrl;
|
||||
}
|
||||
return;
|
||||
}
|
||||
alert(msg);
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const modal = $('#ai-modal');
|
||||
const modalTitle = $('#ai-modal-title');
|
||||
const modalBody = $('#ai-modal-body');
|
||||
const modalActions = $('#ai-modal-actions');
|
||||
$('#ai-modal-close').addEventListener('click', () => modal.close());
|
||||
|
||||
function openModal(title, bodyHtml, actions) {
|
||||
modalTitle.textContent = title;
|
||||
modalBody.innerHTML = bodyHtml;
|
||||
modalActions.innerHTML = '';
|
||||
(actions || []).forEach((a) => {
|
||||
const b = document.createElement('button');
|
||||
b.textContent = a.label;
|
||||
b.className = a.className
|
||||
|| 'px-3 py-2 rounded-lg bg-ink-100 hover:bg-ink-200 text-sm';
|
||||
b.addEventListener('click', () => a.onClick(b));
|
||||
modalActions.appendChild(b);
|
||||
});
|
||||
modal.showModal();
|
||||
}
|
||||
|
||||
$('#ai-generate-by-title').addEventListener('click', async () => {
|
||||
const title = titleEl.value.trim();
|
||||
if (!title) {
|
||||
alert('Сначала заполните название теста.');
|
||||
titleEl.focus();
|
||||
return;
|
||||
}
|
||||
const nQRaw = prompt('Сколько вопросов сгенерировать?', '10');
|
||||
if (nQRaw == null) return;
|
||||
const nQ = Math.max(3, Math.min(40, parseInt(nQRaw, 10) || 10));
|
||||
const nORaw = prompt('Сколько вариантов в каждом вопросе?', '4');
|
||||
if (nORaw == null) return;
|
||||
const nO = Math.max(2, Math.min(12, parseInt(nORaw, 10) || 4));
|
||||
aiStatusEl.textContent = 'Генерируем по названию…';
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-by-title`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
testTitle: title,
|
||||
testDescription: descEl.value,
|
||||
questionsCount: nQ,
|
||||
optionsCount: nO,
|
||||
}),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
aiStatusEl.textContent = '';
|
||||
return aiAlert(data);
|
||||
}
|
||||
const draft = data.draft;
|
||||
const ok = confirm(
|
||||
`Готово: «${draft.title}», вопросов — ${draft.questions.length}.\n` +
|
||||
'Применить как черновик? Текущие вопросы будут заменены.',
|
||||
);
|
||||
if (!ok) {
|
||||
aiStatusEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (draft.title) titleEl.value = draft.title;
|
||||
if (draft.description) descEl.value = draft.description;
|
||||
questionsEl.innerHTML = '';
|
||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
renumber();
|
||||
aiStatusEl.textContent = `Применено: ${draft.questions.length} вопросов.`;
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
aiAlert(null, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
$('#ai-check').addEventListener('click', async () => {
|
||||
const payload = collectPayload();
|
||||
if (!payload.questions.length) {
|
||||
alert('В тесте нет вопросов — нечего проверять.');
|
||||
return;
|
||||
}
|
||||
aiStatusEl.textContent = 'Анализируем…';
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
testTitle: titleEl.value,
|
||||
testDescription: descEl.value,
|
||||
questions: payload.questions,
|
||||
}),
|
||||
});
|
||||
const data = await r.json();
|
||||
aiStatusEl.textContent = '';
|
||||
if (!r.ok) return aiAlert(data);
|
||||
const rev = data.review || {};
|
||||
const verdict = rev.verdict || 'warn';
|
||||
const verdictMap = {
|
||||
ok: ['Годен', 'bg-green-50 text-green-800 border-green-200'],
|
||||
warn: ['Есть замечания', 'bg-yellow-50 text-yellow-800 border-yellow-200'],
|
||||
bad: ['Серьёзные проблемы', 'bg-red-50 text-red-800 border-red-200'],
|
||||
};
|
||||
const [verdictText, verdictCls] = verdictMap[verdict] || verdictMap.warn;
|
||||
let html = `<div class="rounded-lg border ${verdictCls} p-3 text-sm">
|
||||
<div class="font-semibold">${verdictText}</div>
|
||||
<div class="mt-1">${escHtml(rev.summary || '')}</div></div>`;
|
||||
if (Array.isArray(rev.sections) && rev.sections.length) {
|
||||
html += rev.sections.map((s) => `
|
||||
<div class="mt-4">
|
||||
<div class="font-semibold">${escHtml(s.title)}</div>
|
||||
<ul class="mt-1 list-disc pl-5 text-sm space-y-1">
|
||||
${s.items.map((it) => `<li>${escHtml(it)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>`).join('');
|
||||
} else {
|
||||
html += '<p class="mt-4 text-sm text-ink-500">Замечаний нет.</p>';
|
||||
}
|
||||
openModal('Проверка теста', html, [
|
||||
{ label: 'Закрыть', onClick: () => modal.close(),
|
||||
className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm' },
|
||||
]);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
aiAlert(null, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
$('#ai-improve').addEventListener('click', async () => {
|
||||
const payload = collectPayload();
|
||||
if (!payload.questions.length) {
|
||||
alert('В тесте нет вопросов — нечего улучшать.');
|
||||
return;
|
||||
}
|
||||
aiStatusEl.textContent = 'Улучшаем…';
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/improve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
testTitle: titleEl.value,
|
||||
testDescription: descEl.value,
|
||||
questions: payload.questions,
|
||||
}),
|
||||
});
|
||||
const data = await r.json();
|
||||
aiStatusEl.textContent = '';
|
||||
if (!r.ok) return aiAlert(data);
|
||||
const items = data.items || [];
|
||||
if (!items.length) {
|
||||
openModal('Улучшение теста', '<p>Нечего улучшать.</p>', [
|
||||
{ label: 'Закрыть', onClick: () => modal.close() },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
const changed = items.filter((i) => i.changed);
|
||||
if (!changed.length) {
|
||||
openModal('Улучшение теста', '<p>AI не предложил изменений.</p>', [
|
||||
{ label: 'Закрыть', onClick: () => modal.close() },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
let html = `<p class="text-sm text-ink-500 mb-3">
|
||||
Отметьте вопросы, к которым нужно применить улучшения. ${changed.length} из ${items.length}.</p>`;
|
||||
html += changed.map((it) => `
|
||||
<div class="rounded-xl border border-ink-300/60 p-3 mb-3" data-idx="${it.index}">
|
||||
<label class="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" class="apply-q rounded border-ink-300 text-brand-600 focus:ring-brand-500" checked />
|
||||
<span>Вопрос #${it.index + 1}</span>
|
||||
</label>
|
||||
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div class="text-xs uppercase text-ink-500">Было</div>
|
||||
<div class="mt-1 ${it.textChanged ? 'line-through text-ink-500' : ''}">
|
||||
${escHtml(it.original.text)}
|
||||
</div>
|
||||
<ul class="mt-1 list-disc pl-5">
|
||||
${it.original.options.map((o) =>
|
||||
`<li class="${it.optionsChanged ? 'text-ink-500' : ''}">
|
||||
${o.isCorrect ? '✓ ' : ''}${escHtml(o.text)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase text-brand-700">Стало</div>
|
||||
<div class="mt-1 ${it.textChanged ? 'font-medium' : ''}">
|
||||
${escHtml(it.suggested.text)}
|
||||
</div>
|
||||
<ul class="mt-1 list-disc pl-5">
|
||||
${it.suggested.options.map((o) =>
|
||||
`<li>${o.isCorrect ? '✓ ' : ''}${escHtml(o.text)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
openModal('Улучшение теста', html, [
|
||||
{ label: 'Отмена', onClick: () => modal.close() },
|
||||
{
|
||||
label: 'Применить выбранное',
|
||||
className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm',
|
||||
onClick: () => {
|
||||
const qs = $$('#questions .q-item');
|
||||
modalBody.querySelectorAll('[data-idx]').forEach((row) => {
|
||||
if (!$('.apply-q', row).checked) return;
|
||||
const idx = parseInt(row.dataset.idx, 10);
|
||||
const it = items.find((x) => x.index === idx);
|
||||
if (!it || !qs[idx]) return;
|
||||
const node = qs[idx];
|
||||
$('.q-text', node).value = it.suggested.text;
|
||||
$('.q-multi', node).checked = !!it.suggested.hasMultipleAnswers;
|
||||
const optsEl = $('.q-options', node);
|
||||
optsEl.innerHTML = '';
|
||||
it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o)));
|
||||
});
|
||||
modal.close();
|
||||
aiStatusEl.textContent = 'Изменения применены. Не забудьте сохранить.';
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 5000);
|
||||
},
|
||||
},
|
||||
]);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
aiAlert(null, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function aiGenerateQuestion(node) {
|
||||
const qText = $('.q-text', node).value.trim();
|
||||
const optsCount = Math.max(2, $$('.opt-item', node).length || 4);
|
||||
const multi = $('.q-multi', node).checked;
|
||||
aiStatusEl.textContent = 'AI: один вопрос…';
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-question`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
testTitle: titleEl.value,
|
||||
testDescription: descEl.value,
|
||||
questionText: qText,
|
||||
optionsCount: optsCount,
|
||||
hasMultipleAnswers: multi,
|
||||
}),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
|
||||
$('.q-text', node).value = data.text || '';
|
||||
if (data.mode === 'full' && Array.isArray(data.options)) {
|
||||
const optsEl = $('.q-options', node);
|
||||
optsEl.innerHTML = '';
|
||||
data.options.forEach((o) => optsEl.appendChild(renderOption(o)));
|
||||
$('.q-multi', node).checked = !!data.hasMultipleAnswers;
|
||||
}
|
||||
aiStatusEl.textContent = data.mode === 'full' ? 'AI: вопрос сгенерирован.' : 'AI: формулировка обновлена.';
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
alert(e.message || 'AI: ошибка.');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── chain active state (грузим summary, чтобы знать стартовое значение) ───
|
||||
|
||||
fetch(`/api/tests/${TEST_ID}/summary`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data && data.test && typeof data.test.chainActive === 'boolean') {
|
||||
chainActive = data.test.chainActive;
|
||||
chainActiveEl.checked = chainActive;
|
||||
} else {
|
||||
chainActiveEl.checked = true;
|
||||
chainActive = true;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
chainActiveEl.checked = true;
|
||||
});
|
||||
|
||||
loadInitial();
|
||||
})();
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}404 — страница не найдена{% endblock %}
|
||||
{% block content %}
|
||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-8 text-center">
|
||||
<span class="material-symbols-outlined text-5xl text-brand-600">search_off</span>
|
||||
<h1 class="mt-2 text-xl font-semibold">Страница не найдена</h1>
|
||||
<p class="mt-1 text-ink-500">Проверьте адрес или вернитесь на <a class="text-brand-600 hover:underline" href="{{ url_for('main.index') }}">главную</a>.</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}500 — внутренняя ошибка{% endblock %}
|
||||
{% block content %}
|
||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-8 text-center">
|
||||
<span class="material-symbols-outlined text-5xl text-red-600">error</span>
|
||||
<h1 class="mt-2 text-xl font-semibold">Что-то пошло не так</h1>
|
||||
<p class="mt-1 text-ink-500">Попробуйте обновить страницу. Если ошибка повторяется — посмотрите логи сервера.</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Вход — Тестирование{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="mx-auto max-w-md mt-8">
|
||||
<div class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-brand-600">login</span>
|
||||
<h1 class="text-xl font-semibold">Вход в систему</h1>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-ink-500">
|
||||
Используйте логин и пароль.
|
||||
{% if hr_auth_enabled %}
|
||||
Учётка кадровой системы (HR).
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mt-4 space-y-2">
|
||||
{% for category, msg in messages %}
|
||||
<div class="px-3 py-2 rounded-lg text-sm
|
||||
{% if category == 'error' %}bg-red-50 text-red-700 border border-red-200
|
||||
{% else %}bg-brand-50 text-brand-700 border border-brand-100{% endif %}">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="post" action="{{ url_for('auth.login_submit') }}" class="mt-5 space-y-4" novalidate>
|
||||
<input type="hidden" name="next" value="{{ next or '/' }}">
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-ink-700">Логин</span>
|
||||
<input type="text" name="login" value="{{ login or '' }}" required autofocus autocomplete="username"
|
||||
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-ink-700">Пароль</span>
|
||||
<input type="password" name="password" required autocomplete="current-password"
|
||||
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
||||
</label>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full inline-flex items-center justify-center gap-2 rounded-lg
|
||||
bg-brand-600 hover:bg-brand-700 text-white font-medium px-4 py-2 transition">
|
||||
<span class="material-symbols-outlined text-base">login</span>
|
||||
Войти
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,111 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>{% block title %}Тестирование персонала{% endblock %}</title>
|
||||
|
||||
{# Tailwind CDN — на E1.0 этого достаточно. В Этапе 2/CI можно заменить на сборку. #}
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
// Минимальная палитра в стиле кабинета HR. Без зависимостей от HR-репо.
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Manrope', 'Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eef2ff',
|
||||
100: '#e0e7ff',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
700: '#4338ca',
|
||||
},
|
||||
ink: {
|
||||
900: '#0f172a',
|
||||
700: '#334155',
|
||||
500: '#64748b',
|
||||
300: '#cbd5e1',
|
||||
100: '#f1f5f9',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}" />
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="min-h-screen bg-ink-100 text-ink-900 font-sans antialiased">
|
||||
<header class="sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-ink-300/60">
|
||||
<div class="mx-auto max-w-6xl px-4 h-14 flex items-center justify-between">
|
||||
<a href="{{ url_for('main.index') }}" class="flex items-center gap-2 font-semibold text-ink-900">
|
||||
<span class="material-symbols-outlined text-brand-600">quiz</span>
|
||||
<span>Тестирование</span>
|
||||
</a>
|
||||
<nav class="flex items-center 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">
|
||||
<span class="material-symbols-outlined text-base">list_alt</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">
|
||||
<span class="material-symbols-outlined text-base">settings</span>
|
||||
Настройки
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user %}
|
||||
<span class="hidden sm: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">
|
||||
<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">
|
||||
<span class="material-symbols-outlined text-base">login</span>
|
||||
Войти
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-6xl px-4 py-6">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="mx-auto max-w-6xl px-4 py-8 text-xs text-ink-500">
|
||||
{% block footer %}testing-flask-app · Этап 1{% endblock %}
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Тестирование</title>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Тестирование — главная{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6">
|
||||
<h1 class="text-2xl font-semibold text-ink-900">Сервис тестирования персонала</h1>
|
||||
<p class="mt-2 text-ink-500">
|
||||
Этап 1 миграции: переход на единый стек (Flask + Jinja). Бизнес-функции
|
||||
переносятся последовательно — авторизация, каталог тестов, редактор,
|
||||
назначения, прохождение, импорт/AI.
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex flex-wrap gap-2 text-sm">
|
||||
<a href="{{ url_for('tests.tests_list_page') }}"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-brand-600 hover:bg-brand-700 text-white transition">
|
||||
<span class="material-symbols-outlined text-base">list_alt</span>
|
||||
Каталог тестов
|
||||
</a>
|
||||
<a href="{{ url_for('main.health') }}"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-brand-50 text-brand-700 hover:bg-brand-100 transition">
|
||||
<span class="material-symbols-outlined text-base">monitoring</span>
|
||||
Health-check
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 mt-4">
|
||||
{% for title, descr, icon in [
|
||||
('Авторизация', 'E1.1 — логин по HR/локальному пользователю.', 'login'),
|
||||
('Тесты', 'E1.2 — каталог, фильтры, карточки.', 'list_alt'),
|
||||
('Редактор', 'E1.3 — создание/правка теста, AI-помощник.', 'edit_note'),
|
||||
('Назначения', 'E1.4 — назначить сотрудникам, отслеживать.', 'assignment'),
|
||||
('Прохождение', 'E1.5 — UI прохождения теста сотрудником.', 'fact_check'),
|
||||
('Импорт/AI', 'E1.6 — генерация черновиков из документов.', 'auto_awesome'),
|
||||
] %}
|
||||
<article class="rounded-xl bg-white border border-ink-300/60 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-brand-600">{{ icon }}</span>
|
||||
<h3 class="font-semibold">{{ title }}</h3>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-ink-500">{{ descr }}</p>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Настройки — LLM{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6 max-w-2xl">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-brand-600">settings</span>
|
||||
<h1 class="text-2xl font-semibold">Настройки</h1>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-5 font-semibold">Подключение к LLM</h2>
|
||||
<p class="mt-1 text-sm text-ink-500">
|
||||
Ключ задаётся в <code class="px-1 py-0.5 rounded bg-ink-100">.env</code> сервера
|
||||
(общий, не на пользователя). Поддерживаются DeepSeek и OpenAI-совместимые API.
|
||||
После изменения <code class="px-1 py-0.5 rounded bg-ink-100">.env</code> нужен рестарт процесса.
|
||||
</p>
|
||||
|
||||
<dl class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
<dt class="text-ink-500">Статус ключа</dt>
|
||||
<dd>
|
||||
{% if configured %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-green-50 text-green-700 border border-green-200">
|
||||
<span class="material-symbols-outlined text-base">check_circle</span> Задан
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-red-50 text-red-700 border border-red-200">
|
||||
<span class="material-symbols-outlined text-base">error</span> Не задан
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt class="text-ink-500">Провайдер</dt>
|
||||
<dd>{{ provider or '—' }}</dd>
|
||||
<dt class="text-ink-500">Модель</dt>
|
||||
<dd>{{ model or '—' }}</dd>
|
||||
<dt class="text-ink-500">Base URL</dt>
|
||||
<dd class="break-all">{{ base_url or '—' }}</dd>
|
||||
</dl>
|
||||
|
||||
{% if not configured %}
|
||||
<div class="mt-5 rounded-lg bg-ink-100/60 border border-ink-300/60 p-4 text-sm">
|
||||
<p class="font-medium">Как задать ключ</p>
|
||||
<pre class="mt-2 text-xs whitespace-pre-wrap font-mono">DEEPSEEK_API_KEY=sk-...
|
||||
# либо
|
||||
OPENAI_API_KEY=sk-...
|
||||
# опционально:
|
||||
# LLM_BASE_URL=https://api.deepseek.com/v1
|
||||
# LLM_MODEL=deepseek-chat</pre>
|
||||
<p class="mt-2 text-ink-500">
|
||||
Файл: <code>flask_app/.env</code>. После сохранения — рестарт процесса.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-5 flex items-center gap-3">
|
||||
<button id="btn-ping"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg
|
||||
bg-brand-600 hover:bg-brand-700 text-white text-sm">
|
||||
<span class="material-symbols-outlined text-base">cable</span>
|
||||
Проверить подключение
|
||||
</button>
|
||||
<span id="ping-status" class="text-sm text-ink-500"></span>
|
||||
</div>
|
||||
|
||||
<div id="ping-result" class="mt-4 hidden text-sm rounded-lg p-3 border"></div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(() => {
|
||||
const btn = document.getElementById('btn-ping');
|
||||
const status = document.getElementById('ping-status');
|
||||
const result = document.getElementById('ping-result');
|
||||
btn.addEventListener('click', async () => {
|
||||
status.textContent = 'Запрос…';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch('/api/llm/ping', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
result.classList.remove('hidden', 'bg-green-50', 'border-green-200', 'text-green-800',
|
||||
'bg-red-50', 'border-red-200', 'text-red-800');
|
||||
if (d.ok) {
|
||||
result.classList.add('bg-green-50', 'border-green-200', 'text-green-800');
|
||||
result.innerHTML = `<b>OK</b> · ${d.provider} / ${d.model} · ${d.latencyMs} мс`
|
||||
+ (d.sample ? `<br><span class="text-xs opacity-80">Ответ: ${d.sample.replace(/</g,'<')}</span>` : '');
|
||||
} else {
|
||||
result.classList.add('bg-red-50', 'border-red-200', 'text-red-800');
|
||||
result.innerHTML = `<b>Ошибка</b> · ${d.code || ''}<br>${(d.error || '').replace(/</g,'<')}`;
|
||||
}
|
||||
} catch (e) {
|
||||
result.classList.remove('hidden');
|
||||
result.classList.add('bg-red-50', 'border-red-200', 'text-red-800');
|
||||
result.textContent = e.message || 'Сбой запроса.';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
status.textContent = '';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,191 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ content.test.title }} — редактор{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="editor-root"
|
||||
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>
|
||||
</section>
|
||||
|
||||
<!-- AI-панель -->
|
||||
<section class="mt-4 rounded-2xl bg-brand-50/60 border border-brand-100 p-4">
|
||||
<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">
|
||||
<span class="material-symbols-outlined text-base text-brand-600">upload_file</span>
|
||||
<span>Импорт документа</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>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-ink-500">
|
||||
Поддерживаются PDF, DOCX, TXT, MD (до 16 МБ). AI извлечёт текст и предложит черновик теста.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Список вопросов -->
|
||||
<section class="mt-4">
|
||||
<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">
|
||||
<span class="material-symbols-outlined text-base">add</span>
|
||||
Добавить вопрос
|
||||
</button>
|
||||
</div>
|
||||
<ol id="questions" class="mt-3 space-y-3"></ol>
|
||||
</section>
|
||||
|
||||
<!-- 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">
|
||||
<input id="chain-active" type="checkbox"
|
||||
class="rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
||||
<span>Цепочка активна (виден в каталоге)</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="save-status" class="text-sm text-ink-500"></span>
|
||||
<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>
|
||||
<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">
|
||||
<span class="material-symbols-outlined text-base">save</span>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</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="Выше">
|
||||
<span class="material-symbols-outlined text-base">arrow_upward</span>
|
||||
</button>
|
||||
<button class="q-down p-1 rounded hover:bg-ink-100" title="Ниже">
|
||||
<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="Удалить">
|
||||
<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
|
||||
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" />
|
||||
<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">
|
||||
<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>
|
||||
</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"
|
||||
placeholder="Вариант ответа" />
|
||||
<button class="opt-delete p-1 rounded hover:bg-red-50 text-red-600" title="Удалить">
|
||||
<span class="material-symbols-outlined text-base">close</span>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/editor.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,128 @@
|
||||
{% extends "base.html" %}
|
||||
{% 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">
|
||||
<div>
|
||||
<h1 class="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">
|
||||
<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">
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="mt-5 text-ink-500 text-sm">Доступных тестов пока нет.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if hidden %}
|
||||
<details class="mt-6 rounded-xl border border-ink-300/60 bg-ink-100/50 p-4">
|
||||
<summary class="cursor-pointer font-medium text-ink-700">
|
||||
Скрытые вами цепочки ({{ hidden|length }})
|
||||
</summary>
|
||||
<ul class="mt-3 space-y-2">
|
||||
{% for t in hidden %}
|
||||
<li class="flex items-center justify-between gap-2 text-sm">
|
||||
<span>{{ t.title }} <span class="text-ink-500">· v{{ t.version }}</span></span>
|
||||
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}"
|
||||
class="text-brand-700 hover:underline">Открыть</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% 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">
|
||||
<h2 class="text-lg font-semibold">Новый тест</h2>
|
||||
</div>
|
||||
<div class="p-5 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
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-ink-700">Описание (опц.)</span>
|
||||
<textarea id="new-test-desc" rows="3"
|
||||
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="p-4 border-t border-ink-300/60 flex justify-end gap-2 bg-ink-100/40 rounded-b-2xl">
|
||||
<button type="button" id="dlg-cancel"
|
||||
class="px-4 py-2 rounded-lg text-ink-700 hover:bg-ink-100">Отмена</button>
|
||||
<button type="button" id="dlg-submit"
|
||||
class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white">
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(() => {
|
||||
const dlg = document.getElementById('dlg-create');
|
||||
const titleEl = document.getElementById('new-test-title');
|
||||
const descEl = document.getElementById('new-test-desc');
|
||||
|
||||
document.getElementById('btn-create-test').addEventListener('click', () => {
|
||||
titleEl.value = '';
|
||||
descEl.value = '';
|
||||
if (typeof dlg.showModal === 'function') dlg.showModal();
|
||||
else dlg.setAttribute('open', 'open');
|
||||
setTimeout(() => titleEl.focus(), 50);
|
||||
});
|
||||
document.getElementById('dlg-cancel').addEventListener('click', () => dlg.close());
|
||||
|
||||
document.getElementById('dlg-submit').addEventListener('click', async () => {
|
||||
const title = titleEl.value.trim();
|
||||
if (!title) { titleEl.focus(); return; }
|
||||
try {
|
||||
const r = await fetch('/api/tests', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, description: descEl.value.trim() || null }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось создать тест.');
|
||||
window.location.href = `/tests/${data.testId}/edit`;
|
||||
} catch (e) {
|
||||
alert(e.message || 'Не удалось создать тест.');
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Blueprint `tests`: JSON API (`/api/tests/*`) и UI (`/tests`, `/tests/<id>/edit`)."""
|
||||
from .routes import tests_bp # noqa: F401
|
||||
@@ -0,0 +1,465 @@
|
||||
"""Маршруты тестов (E1.2).
|
||||
|
||||
Покрытие Express → Flask:
|
||||
- GET /api/tests/ — каталог + hidden by you
|
||||
- POST /api/tests/ — создать тест (цепочку с версией 1)
|
||||
- GET /api/tests/<id>/summary — краткая карточка
|
||||
- GET /api/tests/<id>/versions — список версий + hasAttempts
|
||||
- GET /api/tests/<id>/editor — контент редактора
|
||||
- POST /api/tests/<id>/draft — saveTestDraft (fork если нужно)
|
||||
- POST /api/tests/<id>/versions/<vid>/activate
|
||||
- PATCH /api/tests/<id> — chainActive
|
||||
- POST /api/tests/<id>/ai/generate-test
|
||||
- POST /api/tests/<id>/ai/generate-question
|
||||
|
||||
UI-страницы:
|
||||
- GET /tests — каталог
|
||||
- GET /tests/<id>/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/<test_id>/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/<test_id>/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/<test_id>/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/<test_id>/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/<test_id>/versions/<version_id>/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/<test_id>', 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/<test_id>/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/<test_id>/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/<test_id>/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/<test_id>/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/<test_id>/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/<test_id>/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)
|
||||
Reference in New Issue
Block a user