37 changed files with 4445 additions and 1297 deletions
Binary file not shown.
@ -0,0 +1,43 @@ |
|||||||
|
[alembic] |
||||||
|
script_location = alembic |
||||||
|
prepend_sys_path = . |
||||||
|
version_path_separator = os |
||||||
|
|
||||||
|
# URL задаётся динамически в env.py через get_database_url() |
||||||
|
sqlalchemy.url = |
||||||
|
|
||||||
|
[post_write_hooks] |
||||||
|
|
||||||
|
[loggers] |
||||||
|
keys = root,sqlalchemy,alembic |
||||||
|
|
||||||
|
[handlers] |
||||||
|
keys = console |
||||||
|
|
||||||
|
[formatters] |
||||||
|
keys = generic |
||||||
|
|
||||||
|
[logger_root] |
||||||
|
level = WARN |
||||||
|
handlers = console |
||||||
|
qualname = |
||||||
|
|
||||||
|
[logger_sqlalchemy] |
||||||
|
level = WARN |
||||||
|
handlers = |
||||||
|
qualname = sqlalchemy.engine |
||||||
|
|
||||||
|
[logger_alembic] |
||||||
|
level = INFO |
||||||
|
handlers = |
||||||
|
qualname = alembic |
||||||
|
|
||||||
|
[handler_console] |
||||||
|
class = StreamHandler |
||||||
|
args = (sys.stderr,) |
||||||
|
level = NOTSET |
||||||
|
formatter = generic |
||||||
|
|
||||||
|
[formatter_generic] |
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s |
||||||
|
datefmt = %H:%M:%S |
||||||
@ -0,0 +1,58 @@ |
|||||||
|
"""Alembic environment — подключает модели и настраивает миграции.""" |
||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
import os |
||||||
|
import sys |
||||||
|
from logging.config import fileConfig |
||||||
|
|
||||||
|
from alembic import context |
||||||
|
from sqlalchemy import engine_from_config, pool |
||||||
|
|
||||||
|
# Добавляем flask_app в sys.path, чтобы `from app...` работало. |
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) |
||||||
|
|
||||||
|
from app.db import get_database_url |
||||||
|
from app.models import Base # noqa: E402 — импорт после sys.path |
||||||
|
|
||||||
|
config = context.config |
||||||
|
config.set_main_option('sqlalchemy.url', get_database_url()) |
||||||
|
|
||||||
|
if config.config_file_name is not None: |
||||||
|
fileConfig(config.config_file_name) |
||||||
|
|
||||||
|
target_metadata = Base.metadata |
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None: |
||||||
|
url = config.get_main_option('sqlalchemy.url') |
||||||
|
context.configure( |
||||||
|
url=url, |
||||||
|
target_metadata=target_metadata, |
||||||
|
literal_binds=True, |
||||||
|
dialect_opts={"paramstyle": "named"}, |
||||||
|
compare_type=True, |
||||||
|
) |
||||||
|
with context.begin_transaction(): |
||||||
|
context.run_migrations() |
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None: |
||||||
|
connectable = engine_from_config( |
||||||
|
config.get_section(config.config_ini_section, {}), |
||||||
|
prefix='sqlalchemy.', |
||||||
|
poolclass=pool.NullPool, |
||||||
|
) |
||||||
|
with connectable.connect() as connection: |
||||||
|
context.configure( |
||||||
|
connection=connection, |
||||||
|
target_metadata=target_metadata, |
||||||
|
compare_type=True, |
||||||
|
) |
||||||
|
with context.begin_transaction(): |
||||||
|
context.run_migrations() |
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode(): |
||||||
|
run_migrations_offline() |
||||||
|
else: |
||||||
|
run_migrations_online() |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
"""${message} |
||||||
|
|
||||||
|
Revision ID: ${up_revision} |
||||||
|
Revises: ${down_revision | comma,n} |
||||||
|
Create Date: ${create_date} |
||||||
|
|
||||||
|
""" |
||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
from typing import Sequence, Union |
||||||
|
|
||||||
|
from alembic import op |
||||||
|
import sqlalchemy as sa |
||||||
|
${imports if imports else ""} |
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)} |
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)} |
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} |
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} |
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None: |
||||||
|
${upgrades if upgrades else "pass"} |
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None: |
||||||
|
${downgrades if downgrades else "pass"} |
||||||
@ -0,0 +1,30 @@ |
|||||||
|
"""Baseline — existing schema recognised by ORM. |
||||||
|
|
||||||
|
Revision ID: 0001_baseline |
||||||
|
Revises: |
||||||
|
Create Date: 2026-04-29 |
||||||
|
|
||||||
|
Эта ревизия не создаёт таблиц: схема существует. Она фиксирует текущее |
||||||
|
состояние как отправную точку для будущих автогенерированных миграций. |
||||||
|
|
||||||
|
Чтобы применить при первом запуске: |
||||||
|
alembic stamp 0001_baseline # если схема уже есть |
||||||
|
# или: |
||||||
|
alembic upgrade head # если схема пустая |
||||||
|
""" |
||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
from typing import Sequence, Union |
||||||
|
|
||||||
|
revision: str = "0001_baseline" |
||||||
|
down_revision: Union[str, None] = None |
||||||
|
branch_labels: Union[str, Sequence[str], None] = None |
||||||
|
depends_on: Union[str, Sequence[str], None] = None |
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None: |
||||||
|
pass # схема уже создана SQL-скриптами / Docker entrypoint |
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None: |
||||||
|
pass |
||||||
@ -0,0 +1,111 @@ |
|||||||
|
{ |
||||||
|
"generate_question_full": { |
||||||
|
"label": "Генерация вопроса (новый)", |
||||||
|
"description": "AI создаёт вопрос с вариантами ответа по теме теста.", |
||||||
|
"system": "Ты составитель тестов. Отвечай ТОЛЬКО JSON: {\"text\", \"hasMultipleAnswers\", \"options\": [{ \"text\", \"isCorrect\" }]}. Все на русском.", |
||||||
|
"user": "Тема теста: {topic}\n\nСформулируй ОДИН вопрос по этой теме с ровно {optionsCount} вариантами ответа. hasMultipleAnswers = {multiClause}", |
||||||
|
"user_hint_label": "Акцент / дополнение", |
||||||
|
"user_hint_placeholder": "Например: сделай акцент на противопожарных нормах", |
||||||
|
"vars": { |
||||||
|
"topic": "Название + описание теста", |
||||||
|
"optionsCount": "Количество вариантов ответа", |
||||||
|
"multiClause": "Пояснение о типе ответа" |
||||||
|
} |
||||||
|
}, |
||||||
|
"rephrase_question": { |
||||||
|
"label": "Улучшение вопроса", |
||||||
|
"description": "AI переформулирует черновик вопроса, сохраняя смысл.", |
||||||
|
"system": "Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {\"text\": string} — чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости от сложности исходного черновика, без вариантов ответа.", |
||||||
|
"user": "Тема теста: {topic}\n\nИсходный черновик вопроса (улучши формулировку, не меняй смысл без нужды):\n{questionText}", |
||||||
|
"user_hint_label": "Акцент / дополнение", |
||||||
|
"user_hint_placeholder": "Например: сделай формулировку короче и чётче", |
||||||
|
"vars": { |
||||||
|
"topic": "Название + описание теста", |
||||||
|
"questionText": "Исходный текст вопроса" |
||||||
|
} |
||||||
|
}, |
||||||
|
"generate_distractors": { |
||||||
|
"label": "Генерация дистракторов", |
||||||
|
"description": "AI заполняет пустые варианты ответа правдоподобными, но неверными.", |
||||||
|
"system": "Ты составитель учебных тестов. Отвечай ТОЛЬКО JSON: {\"options\": [{\"text\": string, \"isCorrect\": false}, ...]} — ровно {emptyCount} объекта в массиве. Все тексты на русском, без нумерации, без кавычек.", |
||||||
|
"user": "Тема теста: {topic}\n\nВопрос: {questionText}\n\nУже заполненные варианты:\n{filledOptions}\n\nПридумай ровно {emptyCount} правдоподобных, но НЕВЕРНЫХ дистракторов (isCorrect: false), которые не повторяют уже существующие варианты и выглядят похоже на реальные ответы.", |
||||||
|
"user_hint_label": "Акцент / дополнение", |
||||||
|
"user_hint_placeholder": "Например: дистракторы должны быть из той же категории", |
||||||
|
"vars": { |
||||||
|
"topic": "Название + описание теста", |
||||||
|
"questionText": "Текст вопроса", |
||||||
|
"filledOptions": "Список уже заполненных вариантов", |
||||||
|
"emptyCount": "Количество пустых слотов" |
||||||
|
} |
||||||
|
}, |
||||||
|
"generate_test_by_title": { |
||||||
|
"label": "Генерация теста по теме", |
||||||
|
"description": "AI генерирует весь тест (структуру + вопросы) по названию.", |
||||||
|
"system": "Ты опытный методист, составляешь учебные тесты. Отвечай ТОЛЬКО одним JSON-объектом на русском. Схема: {\"title\", \"description\", \"questions\": [{\"text\", \"hasMultipleAnswers\": boolean, \"options\": [{\"text\", \"isCorrect\"}]}]}. Минимум 2 варианта, отметь хотя бы один isCorrect: true.", |
||||||
|
"user": "Составь учебный тест по этой теме.\n\nНазвание теста: {title}\nОписание/контекст: {desc}\n\nПодсказка по сетке: примерно {nQ} вопросов, в каждом по {nOpt} вариантов ответа. Покрой ключевые подтемы. Дистракторы делай правдоподобными, не очевидно неверными. Текст — короткий, понятный.", |
||||||
|
"user_hint_label": "Акцент / дополнение", |
||||||
|
"user_hint_placeholder": "Например: сделай акцент на правилах безопасности, избегай теоретических вопросов", |
||||||
|
"vars": { |
||||||
|
"title": "Название теста", |
||||||
|
"desc": "Описание/контекст темы", |
||||||
|
"nQ": "Желаемое количество вопросов", |
||||||
|
"nOpt": "Желаемое количество вариантов" |
||||||
|
} |
||||||
|
}, |
||||||
|
"generate_test_from_doc": { |
||||||
|
"label": "Генерация теста из документа", |
||||||
|
"description": "AI создаёт тест на основе загруженного текста документа.", |
||||||
|
"system": "Ты помощник для составления тестов. Отвечай ТОЛЬКО одним JSON-объектом без пояснений. Схема: {\"title\": string, \"description\"?: string, \"questions\": array}. Каждый вопрос: {\"text\", \"hasMultipleAnswers\": boolean, \"options\": [{\"text\", \"isCorrect\": boolean}, ...]}. Минимум 2 варианта. Для одиночного выбора ровно один isCorrect: true. Текст и формулировки — на русском, по содержанию входного материала.", |
||||||
|
"user": "Составь тест с вопросами с одним или несколькими правильными ответами на основе текста:\n\n{documentText}", |
||||||
|
"user_hint_label": "На что сделать акцент", |
||||||
|
"user_hint_placeholder": "Например: сделай акцент на разделе 3, избегай вопросов про даты", |
||||||
|
"vars": { |
||||||
|
"documentText": "Извлечённый текст документа" |
||||||
|
} |
||||||
|
}, |
||||||
|
"check_test_quality": { |
||||||
|
"label": "Проверка качества теста", |
||||||
|
"description": "AI анализирует тест и выдаёт рекомендации по улучшению.", |
||||||
|
"system": "Ты ревьюер учебных тестов. Отвечай ТОЛЬКО JSON: {\"verdict\": \"ok\"|\"warn\"|\"bad\", \"summary\": string, \"sections\": [{\"title\": string, \"items\": [string, ...]}]}. Разделы рекомендаций: «Чёткость формулировок», «Качество дистракторов», «Охват темы», «Сбалансированность сложности». Пропусти раздел, если претензий нет. Вердикт: ok — годен; warn — есть замечания; bad — серьёзные проблемы. Все тексты — на русском, короткие и предметные.", |
||||||
|
"user": "Проверь качество теста и дай рекомендации:\n\n{testDump}", |
||||||
|
"user_hint_label": "Акцент / дополнение", |
||||||
|
"user_hint_placeholder": "Например: обрати особое внимание на дистракторы", |
||||||
|
"vars": { |
||||||
|
"testDump": "JSON-дамп теста (заголовок + вопросы)" |
||||||
|
} |
||||||
|
}, |
||||||
|
"improve_test_full": { |
||||||
|
"label": "Улучшение всего теста", |
||||||
|
"description": "AI предлагает улучшенные формулировки для всех вопросов и ответов.", |
||||||
|
"system": "Ты редактор учебных тестов. Получаешь массив вопросов и предлагаешь улучшения: чёткие формулировки, лучшие дистракторы, корректную разметку isCorrect. Сохраняй исходную сетку: число вопросов, число вариантов и значение hasMultipleAnswers НЕ меняй — иначе клиент отклонит ответ. Отвечай ТОЛЬКО JSON: {\"questions\": [{\"text\", \"hasMultipleAnswers\", \"options\": [{\"text\", \"isCorrect\"}]}, ...]}. Тексты — на русском, короткие.", |
||||||
|
"user": "Улучши тест без изменения сетки:\n\n{testDump}", |
||||||
|
"user_hint_label": "Акцент / дополнение", |
||||||
|
"user_hint_placeholder": "Например: сделай дистракторы правдоподобнее", |
||||||
|
"vars": { |
||||||
|
"testDump": "JSON-дамп теста (заголовок + вопросы)" |
||||||
|
} |
||||||
|
}, |
||||||
|
"question_hint": { |
||||||
|
"label": "Подсказка к вопросу", |
||||||
|
"description": "AI объясняет правильный ответ (показывается при прохождении теста).", |
||||||
|
"system": "Ты опытный преподаватель. Отвечай по-русски, кратко (2–4 предложения), без markdown и без вступлений. Объясни почему правильный вариант — правильный.", |
||||||
|
"user": "Вопрос: {questionText}\nВарианты: {allOptions}\nПравильный ответ: {correctOptions}\n\nДай краткое объяснение для подсказки во всплывающем окне.", |
||||||
|
"vars": { |
||||||
|
"questionText": "Текст вопроса", |
||||||
|
"allOptions": "Все варианты ответа", |
||||||
|
"correctOptions": "Правильные варианты ответа" |
||||||
|
} |
||||||
|
}, |
||||||
|
"explain_answer": { |
||||||
|
"label": "Объяснение ответа", |
||||||
|
"description": "AI объясняет результат ответа пользователя (после прохождения).", |
||||||
|
"system": "Ты опытный преподаватель. Отвечай по-русски, кратко (2–4 предложения). Объясни почему правильный ответ именно такой, без лишней воды и без markdown.", |
||||||
|
"user": "Вопрос: {questionText}\nПравильный ответ: {correctOptions}\nОтвет ученика ({verdict}): {selectedOptions}\n\nДай краткое объяснение для подсказки во всплывающем окне.", |
||||||
|
"vars": { |
||||||
|
"questionText": "Текст вопроса", |
||||||
|
"correctOptions": "Правильные варианты", |
||||||
|
"verdict": "Результат (верно/неверно)", |
||||||
|
"selectedOptions": "Выбранные пользователем варианты" |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,315 @@ |
|||||||
|
"""SQLAlchemy ORM-модели для БД clinic_tests. |
||||||
|
|
||||||
|
Таблицы: departments, users, tests, test_versions, questions, answer_options, |
||||||
|
test_assignments, test_assignment_targets, test_attempts, user_answers. |
||||||
|
|
||||||
|
Enum-типы (`user_role`, `target_type`, `attempt_status`) соответствуют PostgreSQL-перечислениям |
||||||
|
из 001_initial.sql — создаются через `create_constraint=False` (тип уже есть в БД). |
||||||
|
""" |
||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
import uuid |
||||||
|
from datetime import date, datetime |
||||||
|
from typing import List, Optional |
||||||
|
|
||||||
|
from sqlalchemy import ( |
||||||
|
ARRAY, |
||||||
|
Boolean, |
||||||
|
Date, |
||||||
|
DateTime, |
||||||
|
Enum, |
||||||
|
ForeignKey, |
||||||
|
Index, |
||||||
|
Integer, |
||||||
|
String, |
||||||
|
Text, |
||||||
|
UniqueConstraint, |
||||||
|
func, |
||||||
|
) |
||||||
|
from sqlalchemy.dialects.postgresql import UUID |
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship |
||||||
|
|
||||||
|
|
||||||
|
# ─── Base ──────────────────────────────────────────────────────────────────── |
||||||
|
|
||||||
|
class Base(DeclarativeBase): |
||||||
|
pass |
||||||
|
|
||||||
|
|
||||||
|
# ─── Enum types ─────────────────────────────────────────────────────────────── |
||||||
|
|
||||||
|
user_role_enum = Enum( |
||||||
|
"hr", "manager", "employee", |
||||||
|
name="user_role", |
||||||
|
create_constraint=False, # тип уже существует в БД |
||||||
|
) |
||||||
|
|
||||||
|
target_type_enum = Enum( |
||||||
|
"department", "user", |
||||||
|
name="target_type", |
||||||
|
create_constraint=False, |
||||||
|
) |
||||||
|
|
||||||
|
attempt_status_enum = Enum( |
||||||
|
"in_progress", "completed", "expired", |
||||||
|
name="attempt_status", |
||||||
|
create_constraint=False, |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
# ─── Models ─────────────────────────────────────────────────────────────────── |
||||||
|
|
||||||
|
class Department(Base): |
||||||
|
__tablename__ = "departments" |
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 |
||||||
|
) |
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False) |
||||||
|
created_at: Mapped[Optional[datetime]] = mapped_column( |
||||||
|
DateTime, server_default=func.now() |
||||||
|
) |
||||||
|
updated_at: Mapped[Optional[datetime]] = mapped_column( |
||||||
|
DateTime, server_default=func.now(), onupdate=func.now() |
||||||
|
) |
||||||
|
|
||||||
|
users: Mapped[List["User"]] = relationship(back_populates="department") |
||||||
|
|
||||||
|
|
||||||
|
class User(Base): |
||||||
|
__tablename__ = "users" |
||||||
|
__table_args__ = ( |
||||||
|
Index("idx_users_login", "login"), |
||||||
|
Index("idx_users_department", "department_id"), |
||||||
|
Index( |
||||||
|
"idx_users_staff_id", "staff_id", |
||||||
|
postgresql_where="staff_id IS NOT NULL", |
||||||
|
), |
||||||
|
) |
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 |
||||||
|
) |
||||||
|
login: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) |
||||||
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False) |
||||||
|
full_name: Mapped[str] = mapped_column(String(255), nullable=False) |
||||||
|
role: Mapped[str] = mapped_column(user_role_enum, nullable=False, server_default="employee") |
||||||
|
department_id: Mapped[Optional[uuid.UUID]] = mapped_column( |
||||||
|
UUID(as_uuid=True), ForeignKey("departments.id"), nullable=True |
||||||
|
) |
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, server_default="true") |
||||||
|
created_at: Mapped[Optional[datetime]] = mapped_column( |
||||||
|
DateTime, server_default=func.now() |
||||||
|
) |
||||||
|
updated_at: Mapped[Optional[datetime]] = mapped_column( |
||||||
|
DateTime, server_default=func.now(), onupdate=func.now() |
||||||
|
) |
||||||
|
staff_id: Mapped[Optional[int]] = mapped_column(Integer, unique=True, nullable=True) |
||||||
|
|
||||||
|
department: Mapped[Optional["Department"]] = relationship(back_populates="users") |
||||||
|
tests: Mapped[List["Test"]] = relationship(back_populates="author", foreign_keys="Test.created_by") |
||||||
|
attempts: Mapped[List["TestAttempt"]] = relationship(back_populates="user") |
||||||
|
|
||||||
|
|
||||||
|
class Test(Base): |
||||||
|
__tablename__ = "tests" |
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 |
||||||
|
) |
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False) |
||||||
|
description: Mapped[Optional[str]] = mapped_column(Text) |
||||||
|
passing_threshold: Mapped[Optional[int]] = mapped_column(Integer, server_default="70") |
||||||
|
time_limit: Mapped[Optional[int]] = mapped_column(Integer) |
||||||
|
allow_back: Mapped[bool] = mapped_column(Boolean, server_default="true") |
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, server_default="true") |
||||||
|
is_versioned: Mapped[bool] = mapped_column(Boolean, server_default="false") |
||||||
|
hints_enabled: Mapped[bool] = mapped_column(Boolean, server_default="false") |
||||||
|
result_mode: Mapped[str] = mapped_column(String(16), server_default="end") |
||||||
|
created_by: Mapped[Optional[uuid.UUID]] = mapped_column( |
||||||
|
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True |
||||||
|
) |
||||||
|
created_at: Mapped[Optional[datetime]] = mapped_column( |
||||||
|
DateTime, server_default=func.now() |
||||||
|
) |
||||||
|
updated_at: Mapped[Optional[datetime]] = mapped_column( |
||||||
|
DateTime, server_default=func.now(), onupdate=func.now() |
||||||
|
) |
||||||
|
|
||||||
|
author: Mapped[Optional["User"]] = relationship( |
||||||
|
back_populates="tests", foreign_keys=[created_by] |
||||||
|
) |
||||||
|
versions: Mapped[List["TestVersion"]] = relationship( |
||||||
|
back_populates="test", cascade="all, delete-orphan" |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
class TestVersion(Base): |
||||||
|
__tablename__ = "test_versions" |
||||||
|
__table_args__ = ( |
||||||
|
UniqueConstraint("test_id", "version", name="test_versions_test_id_version_key"), |
||||||
|
Index("idx_test_versions_parent_id", "parent_id"), |
||||||
|
Index( |
||||||
|
"uq_test_versions_one_active_per_test", "test_id", |
||||||
|
unique=True, |
||||||
|
postgresql_where="is_active = true", |
||||||
|
), |
||||||
|
) |
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 |
||||||
|
) |
||||||
|
test_id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), ForeignKey("tests.id", ondelete="CASCADE"), nullable=False |
||||||
|
) |
||||||
|
version: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1") |
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, server_default="false") |
||||||
|
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column( |
||||||
|
UUID(as_uuid=True), |
||||||
|
ForeignKey("test_versions.id", ondelete="RESTRICT"), |
||||||
|
nullable=True, |
||||||
|
) |
||||||
|
created_at: Mapped[Optional[datetime]] = mapped_column( |
||||||
|
DateTime, server_default=func.now() |
||||||
|
) |
||||||
|
|
||||||
|
test: Mapped["Test"] = relationship(back_populates="versions") |
||||||
|
questions: Mapped[List["Question"]] = relationship( |
||||||
|
back_populates="version", cascade="all, delete-orphan", order_by="Question.question_order" |
||||||
|
) |
||||||
|
attempts: Mapped[List["TestAttempt"]] = relationship(back_populates="test_version") |
||||||
|
assignments: Mapped[List["TestAssignment"]] = relationship(back_populates="test_version") |
||||||
|
parent: Mapped[Optional["TestVersion"]] = relationship( |
||||||
|
remote_side="TestVersion.id", foreign_keys=[parent_id] |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
class Question(Base): |
||||||
|
__tablename__ = "questions" |
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 |
||||||
|
) |
||||||
|
test_version_id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), ForeignKey("test_versions.id", ondelete="CASCADE"), nullable=False |
||||||
|
) |
||||||
|
text: Mapped[str] = mapped_column(Text, nullable=False) |
||||||
|
question_order: Mapped[int] = mapped_column(Integer, nullable=False) |
||||||
|
has_multiple_answers: Mapped[bool] = mapped_column(Boolean, server_default="false") |
||||||
|
ai_hint: Mapped[Optional[str]] = mapped_column(Text) |
||||||
|
|
||||||
|
version: Mapped["TestVersion"] = relationship(back_populates="questions") |
||||||
|
options: Mapped[List["AnswerOption"]] = relationship( |
||||||
|
back_populates="question", cascade="all, delete-orphan", order_by="AnswerOption.option_order" |
||||||
|
) |
||||||
|
user_answers: Mapped[List["UserAnswer"]] = relationship(back_populates="question") |
||||||
|
|
||||||
|
|
||||||
|
class AnswerOption(Base): |
||||||
|
__tablename__ = "answer_options" |
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 |
||||||
|
) |
||||||
|
question_id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), ForeignKey("questions.id", ondelete="CASCADE"), nullable=False |
||||||
|
) |
||||||
|
text: Mapped[str] = mapped_column(Text, nullable=False) |
||||||
|
is_correct: Mapped[bool] = mapped_column(Boolean, server_default="false") |
||||||
|
option_order: Mapped[int] = mapped_column(Integer, nullable=False) |
||||||
|
|
||||||
|
question: Mapped["Question"] = relationship(back_populates="options") |
||||||
|
|
||||||
|
|
||||||
|
class TestAssignment(Base): |
||||||
|
__tablename__ = "test_assignments" |
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 |
||||||
|
) |
||||||
|
test_version_id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), ForeignKey("test_versions.id", ondelete="CASCADE"), nullable=False |
||||||
|
) |
||||||
|
assigned_by: Mapped[Optional[uuid.UUID]] = mapped_column( |
||||||
|
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True |
||||||
|
) |
||||||
|
deadline: Mapped[Optional[date]] = mapped_column(Date) |
||||||
|
max_attempts: Mapped[Optional[int]] = mapped_column(Integer, server_default="1") |
||||||
|
created_at: Mapped[Optional[datetime]] = mapped_column( |
||||||
|
DateTime, server_default=func.now() |
||||||
|
) |
||||||
|
|
||||||
|
test_version: Mapped["TestVersion"] = relationship(back_populates="assignments") |
||||||
|
targets: Mapped[List["TestAssignmentTarget"]] = relationship( |
||||||
|
back_populates="assignment", cascade="all, delete-orphan" |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
class TestAssignmentTarget(Base): |
||||||
|
__tablename__ = "test_assignment_targets" |
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 |
||||||
|
) |
||||||
|
assignment_id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), ForeignKey("test_assignments.id", ondelete="CASCADE"), nullable=False |
||||||
|
) |
||||||
|
target_type: Mapped[str] = mapped_column(target_type_enum, nullable=False) |
||||||
|
target_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) |
||||||
|
|
||||||
|
assignment: Mapped["TestAssignment"] = relationship(back_populates="targets") |
||||||
|
|
||||||
|
|
||||||
|
class TestAttempt(Base): |
||||||
|
__tablename__ = "test_attempts" |
||||||
|
__table_args__ = ( |
||||||
|
UniqueConstraint( |
||||||
|
"test_version_id", "user_id", "attempt_number", |
||||||
|
name="test_attempts_test_version_id_user_id_attempt_number_key", |
||||||
|
), |
||||||
|
) |
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 |
||||||
|
) |
||||||
|
test_version_id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), ForeignKey("test_versions.id", ondelete="CASCADE"), nullable=False |
||||||
|
) |
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False |
||||||
|
) |
||||||
|
attempt_number: Mapped[int] = mapped_column(Integer, server_default="1") |
||||||
|
status: Mapped[str] = mapped_column(attempt_status_enum, server_default="in_progress") |
||||||
|
started_at: Mapped[Optional[datetime]] = mapped_column( |
||||||
|
DateTime, server_default=func.now() |
||||||
|
) |
||||||
|
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime) |
||||||
|
correct_count: Mapped[Optional[int]] = mapped_column(Integer) |
||||||
|
total_questions: Mapped[Optional[int]] = mapped_column(Integer) |
||||||
|
passed: Mapped[Optional[bool]] = mapped_column(Boolean) |
||||||
|
|
||||||
|
test_version: Mapped["TestVersion"] = relationship(back_populates="attempts") |
||||||
|
user: Mapped["User"] = relationship(back_populates="attempts") |
||||||
|
user_answers: Mapped[List["UserAnswer"]] = relationship( |
||||||
|
back_populates="attempt", cascade="all, delete-orphan" |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
class UserAnswer(Base): |
||||||
|
__tablename__ = "user_answers" |
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 |
||||||
|
) |
||||||
|
attempt_id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), ForeignKey("test_attempts.id", ondelete="CASCADE"), nullable=False |
||||||
|
) |
||||||
|
question_id: Mapped[uuid.UUID] = mapped_column( |
||||||
|
UUID(as_uuid=True), ForeignKey("questions.id", ondelete="CASCADE"), nullable=False |
||||||
|
) |
||||||
|
selected_options: Mapped[Optional[List[uuid.UUID]]] = mapped_column( |
||||||
|
ARRAY(UUID(as_uuid=True)), server_default="{}" |
||||||
|
) |
||||||
|
|
||||||
|
attempt: Mapped["TestAttempt"] = relationship(back_populates="user_answers") |
||||||
|
question: Mapped["Question"] = relationship(back_populates="user_answers") |
||||||
@ -0,0 +1,79 @@ |
|||||||
|
"""Хранилище редактируемых промптов для AI-функций. |
||||||
|
|
||||||
|
Промпты хранятся в app/data/prompts.json и загружаются при старте. |
||||||
|
Если файл не существует или повреждён — используются встроенные defaults. |
||||||
|
""" |
||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
import json |
||||||
|
import logging |
||||||
|
import os |
||||||
|
import threading |
||||||
|
from pathlib import Path |
||||||
|
from typing import Any |
||||||
|
|
||||||
|
_log = logging.getLogger(__name__) |
||||||
|
_lock = threading.Lock() |
||||||
|
|
||||||
|
_DATA_FILE = Path(__file__).parent.parent / 'data' / 'prompts.json' |
||||||
|
|
||||||
|
_store: dict[str, dict] | None = None |
||||||
|
|
||||||
|
|
||||||
|
def _load() -> dict[str, dict]: |
||||||
|
if _DATA_FILE.exists(): |
||||||
|
try: |
||||||
|
with _DATA_FILE.open('r', encoding='utf-8') as f: |
||||||
|
data = json.load(f) |
||||||
|
if isinstance(data, dict): |
||||||
|
return data |
||||||
|
except Exception as e: |
||||||
|
_log.warning('prompt_store: не удалось загрузить %s: %s', _DATA_FILE, e) |
||||||
|
return {} |
||||||
|
|
||||||
|
|
||||||
|
def _get_store() -> dict[str, dict]: |
||||||
|
global _store |
||||||
|
if _store is None: |
||||||
|
with _lock: |
||||||
|
if _store is None: |
||||||
|
_store = _load() |
||||||
|
return _store |
||||||
|
|
||||||
|
|
||||||
|
def get_all() -> dict[str, dict]: |
||||||
|
"""Вернуть все промпты (id → {label, description, system, user, vars}).""" |
||||||
|
return dict(_get_store()) |
||||||
|
|
||||||
|
|
||||||
|
def get_prompt(prompt_id: str) -> dict[str, Any] | None: |
||||||
|
return _get_store().get(prompt_id) |
||||||
|
|
||||||
|
|
||||||
|
def get_system(prompt_id: str, default: str = '') -> str: |
||||||
|
p = get_prompt(prompt_id) |
||||||
|
return p.get('system', default) if p else default |
||||||
|
|
||||||
|
|
||||||
|
def get_user(prompt_id: str, default: str = '') -> str: |
||||||
|
p = get_prompt(prompt_id) |
||||||
|
return p.get('user', default) if p else default |
||||||
|
|
||||||
|
|
||||||
|
def save_all(data: dict[str, dict]) -> None: |
||||||
|
"""Сохранить все промпты в JSON-файл и обновить кеш.""" |
||||||
|
global _store |
||||||
|
_DATA_FILE.parent.mkdir(parents=True, exist_ok=True) |
||||||
|
with _lock: |
||||||
|
with _DATA_FILE.open('w', encoding='utf-8') as f: |
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2) |
||||||
|
_store = data |
||||||
|
|
||||||
|
|
||||||
|
def save_prompt(prompt_id: str, system: str, user: str) -> None: |
||||||
|
"""Обновить system/user для одного промпта.""" |
||||||
|
store = dict(_get_store()) |
||||||
|
if prompt_id not in store: |
||||||
|
raise KeyError(f'Промпт {prompt_id!r} не найден.') |
||||||
|
store[prompt_id] = {**store[prompt_id], 'system': system, 'user': user} |
||||||
|
save_all(store) |
||||||
@ -1,22 +1,23 @@ |
|||||||
"""Утилиты по цепочке теста (попытки/версии).""" |
"""Утилиты по цепочке теста (попытки/версии).""" |
||||||
from __future__ import annotations |
from __future__ import annotations |
||||||
|
|
||||||
from sqlalchemy import text |
from sqlalchemy.orm import Session |
||||||
|
|
||||||
|
from ..models import TestAttempt, TestVersion |
||||||
|
|
||||||
def has_any_attempt_for_test(conn, test_id: str) -> bool: |
|
||||||
"""`conn` может быть Connection или Engine — обе поддерживают .execute().""" |
def has_any_attempt_for_test(session: Session, test_id) -> bool: |
||||||
row = conn.execute( |
"""Возвращает True, если для теста есть хотя бы одна попытка.""" |
||||||
text( |
import uuid as _uuid |
||||||
""" |
if not isinstance(test_id, _uuid.UUID): |
||||||
SELECT EXISTS ( |
try: |
||||||
SELECT 1 |
test_id = _uuid.UUID(str(test_id)) |
||||||
FROM test_attempts ta |
except (ValueError, AttributeError): |
||||||
INNER JOIN test_versions tv ON ta.test_version_id = tv.id |
return False |
||||||
WHERE tv.test_id = :test_id |
|
||||||
) AS has_any |
return session.query( |
||||||
""" |
session.query(TestAttempt) |
||||||
), |
.join(TestVersion, TestAttempt.test_version_id == TestVersion.id) |
||||||
{'test_id': test_id}, |
.filter(TestVersion.test_id == test_id) |
||||||
).first() |
.exists() |
||||||
return bool(row[0]) |
).scalar() |
||||||
|
|||||||
@ -0,0 +1,169 @@ |
|||||||
|
{% extends "base.html" %} |
||||||
|
{% block title %}Назначения — Тестирование персонала{% endblock %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div class="space-y-4 sm:space-y-5 pb-10"> |
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-3"> |
||||||
|
<h1 class="text-xl font-semibold text-ink-900">Назначения</h1> |
||||||
|
<a href="{{ url_for('main.index') }}" class="link-back text-sm">← Главная</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
{# Выбор теста #} |
||||||
|
<div class="cabinet-brick"> |
||||||
|
<h2 class="font-semibold text-sm text-ink-700 mb-3">Тест</h2> |
||||||
|
<select id="assign-test-select" class="form-input"> |
||||||
|
<option value="">— Выберите тест —</option> |
||||||
|
{% for t in tests %} |
||||||
|
<option value="{{ t.id }}">{{ t.title }}</option> |
||||||
|
{% endfor %} |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
|
||||||
|
{# Панель назначения (скрыта пока не выбран тест) #} |
||||||
|
<div id="assign-panel" class="cabinet-brick hidden"> |
||||||
|
<h2 class="font-semibold text-sm text-ink-700 mb-3">Кому выдать</h2> |
||||||
|
<p class="test-detail-hint">Список с учётом поиска и фильтров; можно отметить всех на экране.</p> |
||||||
|
<div class="assign-toolbar mt-3"> |
||||||
|
<input id="assign-search" class="form-input assign-toolbar__search" type="text" placeholder="Поиск: ФИО, логин" /> |
||||||
|
<select id="assign-dept" class="form-input"><option value="__all__">Все отделы</option></select> |
||||||
|
<select id="assign-clinic" class="form-input"> |
||||||
|
<option value="all">Все сотрудники</option> |
||||||
|
<option value="with">Уже есть в системе</option> |
||||||
|
<option value="without">Ещё не в системе</option> |
||||||
|
</select> |
||||||
|
<button id="assign-select-all" class="btn btn-ghost btn--sm" type="button">Выбрать всех</button> |
||||||
|
</div> |
||||||
|
<div id="assign-list" class="assign-list mt-3"></div> |
||||||
|
<div class="inline-actions mt-3"> |
||||||
|
<button id="assign-submit" class="btn btn-primary" type="button">Назначить выбранным</button> |
||||||
|
<span id="assign-status" class="muted"></span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block scripts %} |
||||||
|
<script> |
||||||
|
(function () { |
||||||
|
const $ = (s) => document.querySelector(s); |
||||||
|
const testSelect = $('#assign-test-select'); |
||||||
|
const assignPanel = $('#assign-panel'); |
||||||
|
const assignSearchEl = $('#assign-search'); |
||||||
|
const assignDeptEl = $('#assign-dept'); |
||||||
|
const assignClinicEl = $('#assign-clinic'); |
||||||
|
const assignListEl = $('#assign-list'); |
||||||
|
const assignSelectAllBtn = $('#assign-select-all'); |
||||||
|
const assignSubmitBtn = $('#assign-submit'); |
||||||
|
const assignStatusEl = $('#assign-status'); |
||||||
|
|
||||||
|
let currentTestId = null; |
||||||
|
let assignPeople = []; |
||||||
|
let assignSelected = new Set(); |
||||||
|
|
||||||
|
function escHtml(s) { |
||||||
|
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); |
||||||
|
} |
||||||
|
|
||||||
|
function renderAssignList() { |
||||||
|
assignListEl.innerHTML = ''; |
||||||
|
assignPeople.forEach((p) => { |
||||||
|
const row = document.createElement('label'); |
||||||
|
row.className = `assign-row${assignSelected.has(String(p.staffId)) ? ' assign-row--selected' : ''}`; |
||||||
|
row.innerHTML = ` |
||||||
|
<input type="checkbox" ${assignSelected.has(String(p.staffId)) ? 'checked' : ''} /> |
||||||
|
<span class="assign-row__text"> |
||||||
|
<span class="assign-row__fio">${escHtml(p.fio || '—')}</span> |
||||||
|
${p.webLogin ? `<span class="assign-row__login">${escHtml(p.webLogin)}</span>` : ''} |
||||||
|
<span class="assign-row__meta">${escHtml(p.department || '—')}</span> |
||||||
|
</span>`; |
||||||
|
const cb = row.querySelector('input'); |
||||||
|
cb.addEventListener('change', () => { |
||||||
|
const k = String(p.staffId); |
||||||
|
if (cb.checked) assignSelected.add(k); else assignSelected.delete(k); |
||||||
|
row.classList.toggle('assign-row--selected', cb.checked); |
||||||
|
}); |
||||||
|
assignListEl.appendChild(row); |
||||||
|
}); |
||||||
|
if (!assignPeople.length) assignListEl.innerHTML = '<p class="muted" style="padding:.75rem;">Никого не найдено.</p>'; |
||||||
|
} |
||||||
|
|
||||||
|
async function loadDirectory() { |
||||||
|
if (!currentTestId) return; |
||||||
|
assignStatusEl.textContent = 'Загружаем…'; |
||||||
|
try { |
||||||
|
const params = new URLSearchParams(); |
||||||
|
if (assignSearchEl.value.trim()) params.set('q', assignSearchEl.value.trim()); |
||||||
|
if (assignDeptEl.value && assignDeptEl.value !== '__all__') params.set('department', assignDeptEl.value); |
||||||
|
params.set('clinic', assignClinicEl.value || 'all'); |
||||||
|
const r = await fetch(`/api/auth/dev/assignment-directory?${params.toString()}`); |
||||||
|
const data = await r.json(); |
||||||
|
if (!r.ok) throw new Error(data.error || 'Не удалось загрузить сотрудников'); |
||||||
|
assignPeople = data.people || []; |
||||||
|
const depts = data.departments || []; |
||||||
|
if (assignDeptEl.options.length <= 1) { |
||||||
|
depts.forEach((d) => { |
||||||
|
const o = document.createElement('option'); |
||||||
|
o.value = d; o.textContent = d; |
||||||
|
assignDeptEl.appendChild(o); |
||||||
|
}); |
||||||
|
} |
||||||
|
assignSelected = new Set(); |
||||||
|
renderAssignList(); |
||||||
|
assignStatusEl.textContent = ''; |
||||||
|
} catch (e) { |
||||||
|
assignStatusEl.textContent = e.message || 'Ошибка загрузки'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
testSelect.addEventListener('change', () => { |
||||||
|
currentTestId = testSelect.value || null; |
||||||
|
if (currentTestId) { |
||||||
|
assignPanel.classList.remove('hidden'); |
||||||
|
// Сброс фильтров |
||||||
|
assignDeptEl.innerHTML = '<option value="__all__">Все отделы</option>'; |
||||||
|
assignSearchEl.value = ''; |
||||||
|
loadDirectory(); |
||||||
|
} else { |
||||||
|
assignPanel.classList.add('hidden'); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
let t = null; |
||||||
|
assignSearchEl.addEventListener('input', () => { clearTimeout(t); t = setTimeout(loadDirectory, 350); }); |
||||||
|
assignDeptEl.addEventListener('change', loadDirectory); |
||||||
|
assignClinicEl.addEventListener('change', loadDirectory); |
||||||
|
|
||||||
|
assignSelectAllBtn.addEventListener('click', () => { |
||||||
|
assignPeople.forEach((p) => assignSelected.add(String(p.staffId))); |
||||||
|
renderAssignList(); |
||||||
|
}); |
||||||
|
|
||||||
|
assignSubmitBtn.addEventListener('click', async () => { |
||||||
|
if (!currentTestId) return; |
||||||
|
const selectedRows = assignPeople.filter((p) => assignSelected.has(String(p.staffId))); |
||||||
|
if (!selectedRows.length) { assignStatusEl.textContent = 'Никто не выбран'; return; } |
||||||
|
assignStatusEl.textContent = 'Назначаем…'; |
||||||
|
try { |
||||||
|
const payload = selectedRows.map((p) => ({ |
||||||
|
staffId: p.staffId, |
||||||
|
webLogin: p.webLogin || null, |
||||||
|
fio: p.fio || null, |
||||||
|
department: p.department || null, |
||||||
|
})); |
||||||
|
const r = await fetch(`/api/tests/${currentTestId}/assign`, { |
||||||
|
method: 'POST', |
||||||
|
headers: { 'Content-Type': 'application/json' }, |
||||||
|
body: JSON.stringify({ targets: payload }), |
||||||
|
}); |
||||||
|
const data = await r.json(); |
||||||
|
if (!r.ok) throw new Error(data.error || 'Ошибка назначения'); |
||||||
|
assignStatusEl.textContent = `Назначено: ${data.count ?? selectedRows.length}`; |
||||||
|
} catch (e) { |
||||||
|
assignStatusEl.textContent = e.message || 'Ошибка назначения'; |
||||||
|
} |
||||||
|
}); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
{% endblock %} |
||||||
@ -1,45 +1,66 @@ |
|||||||
{% extends "base.html" %} |
{% extends "base.html" %} |
||||||
{% block title %}Тестирование — главная{% endblock %} |
{% block title %}Главная — Тестирование персонала{% endblock %} |
||||||
|
|
||||||
{% block content %} |
{% 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 mb-5">Тестирование персонала</h1> |
||||||
<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"> |
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> |
||||||
<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 href="{{ url_for('main.stats_page') }}" |
||||||
Каталог тестов |
class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-5 flex flex-col gap-3 |
||||||
|
hover:shadow-md hover:border-brand-300 transition-all group"> |
||||||
|
<div class="flex items-center gap-3"> |
||||||
|
<span class="material-symbols-outlined text-brand-500 text-2xl">bar_chart</span> |
||||||
|
<h2 class="font-semibold text-ink-900">Статистика</h2> |
||||||
|
</div> |
||||||
|
<p class="text-sm text-ink-500 flex-1">Прохождения по отделам, общая динамика и последняя активность.</p> |
||||||
|
<span class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 group-hover:underline"> |
||||||
|
Открыть <span class="material-symbols-outlined text-sm">arrow_forward</span> |
||||||
|
</span> |
||||||
</a> |
</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> |
<a href="{{ url_for('tests.tests_list_page') }}" |
||||||
Health-check |
class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-5 flex flex-col gap-3 |
||||||
|
hover:shadow-md hover:border-brand-300 transition-all group"> |
||||||
|
<div class="flex items-center gap-3"> |
||||||
|
<span class="material-symbols-outlined text-brand-500 text-2xl">list_alt</span> |
||||||
|
<h2 class="font-semibold text-ink-900">Тесты</h2> |
||||||
|
</div> |
||||||
|
<p class="text-sm text-ink-500 flex-1">Каталог тестов, создание, редактирование и прохождение.</p> |
||||||
|
<span class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 group-hover:underline"> |
||||||
|
Открыть <span class="material-symbols-outlined text-sm">arrow_forward</span> |
||||||
|
</span> |
||||||
</a> |
</a> |
||||||
|
|
||||||
|
{# Назначения #} |
||||||
|
<a href="{{ url_for('main.assignments_page') }}" |
||||||
|
class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-5 flex flex-col gap-3 |
||||||
|
hover:shadow-md hover:border-brand-300 transition-all group"> |
||||||
|
<div class="flex items-center gap-3"> |
||||||
|
<span class="material-symbols-outlined text-brand-500 text-2xl">assignment_ind</span> |
||||||
|
<h2 class="font-semibold text-ink-900">Назначения</h2> |
||||||
</div> |
</div> |
||||||
</section> |
<p class="text-sm text-ink-500 flex-1">Выдача тестов сотрудникам и отделам.</p> |
||||||
|
<span class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 group-hover:underline"> |
||||||
|
Открыть <span class="material-symbols-outlined text-sm">arrow_forward</span> |
||||||
|
</span> |
||||||
|
</a> |
||||||
|
|
||||||
<section class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 mt-4"> |
{# Настройки ИИ #} |
||||||
{% for title, descr, icon in [ |
<a href="{{ url_for('settings.prompts_page') }}" |
||||||
('Авторизация', 'E1.1 — логин по HR/локальному пользователю.', 'login'), |
class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-5 flex flex-col gap-3 |
||||||
('Тесты', 'E1.2 — каталог, фильтры, карточки.', 'list_alt'), |
hover:shadow-md hover:border-brand-300 transition-all group"> |
||||||
('Редактор', 'E1.3 — создание/правка теста, AI-помощник.', 'edit_note'), |
<div class="flex items-center gap-3"> |
||||||
('Назначения', 'E1.4 — назначить сотрудникам, отслеживать.', 'assignment'), |
<span class="material-symbols-outlined text-brand-500 text-2xl">psychology</span> |
||||||
('Прохождение', 'E1.5 — UI прохождения теста сотрудником.', 'fact_check'), |
<h2 class="font-semibold text-ink-900">Настройки ИИ</h2> |
||||||
('Импорт/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> |
</div> |
||||||
<p class="mt-1 text-sm text-ink-500">{{ descr }}</p> |
<p class="text-sm text-ink-500 flex-1">Редактор промптов — генерация вопросов, дистракторы, улучшение теста.</p> |
||||||
</article> |
<span class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 group-hover:underline"> |
||||||
{% endfor %} |
Открыть <span class="material-symbols-outlined text-sm">arrow_forward</span> |
||||||
</section> |
</span> |
||||||
|
</a> |
||||||
|
|
||||||
|
</div> |
||||||
{% endblock %} |
{% endblock %} |
||||||
|
|||||||
@ -0,0 +1,433 @@ |
|||||||
|
{% extends "base.html" %} |
||||||
|
{% block title %}Настройки ИИ — промпты{% endblock %} |
||||||
|
|
||||||
|
{% block head %} |
||||||
|
<style> |
||||||
|
.pe-wrap { |
||||||
|
border: 1px solid #d1d5db; |
||||||
|
border-radius: 0.625rem; |
||||||
|
background: #fff; |
||||||
|
transition: border-color 0.15s, box-shadow 0.15s; |
||||||
|
} |
||||||
|
.pe-wrap:focus-within { |
||||||
|
border-color: #00645b; |
||||||
|
box-shadow: 0 0 0 3px rgba(0,100,91,0.12); |
||||||
|
} |
||||||
|
.pe-field { |
||||||
|
min-height: 72px; |
||||||
|
padding: 10px 12px; |
||||||
|
font-family: 'Inter', system-ui, sans-serif; |
||||||
|
font-size: 13.5px; |
||||||
|
line-height: 1.7; |
||||||
|
white-space: pre-wrap; |
||||||
|
word-break: break-word; |
||||||
|
outline: none; |
||||||
|
cursor: text; |
||||||
|
} |
||||||
|
.pe-chip { |
||||||
|
display: inline-flex; |
||||||
|
align-items: center; |
||||||
|
padding: 1px 9px 1px 7px; |
||||||
|
height: 20px; |
||||||
|
border-radius: 99px; |
||||||
|
background: #d9efec; |
||||||
|
color: #00574f; |
||||||
|
font-size: 12px; |
||||||
|
font-weight: 600; |
||||||
|
border: 1px solid #9bd7d0; |
||||||
|
cursor: grab; |
||||||
|
user-select: none; |
||||||
|
vertical-align: middle; |
||||||
|
line-height: 1; |
||||||
|
white-space: nowrap; |
||||||
|
transition: background 0.12s, opacity 0.12s; |
||||||
|
} |
||||||
|
.pe-chip:hover { background: #bfe8e3; } |
||||||
|
.pe-chip.is-dragging { opacity: 0.3; cursor: grabbing; } |
||||||
|
.pe-caret { |
||||||
|
display: inline-block; |
||||||
|
width: 2px; |
||||||
|
height: 1.1em; |
||||||
|
background: #00645b; |
||||||
|
border-radius: 1px; |
||||||
|
vertical-align: middle; |
||||||
|
pointer-events: none; |
||||||
|
animation: pe-blink 0.7s steps(1) infinite; |
||||||
|
} |
||||||
|
@keyframes pe-blink { 50% { opacity: 0; } } |
||||||
|
.pc { |
||||||
|
border: 1px solid #e5e7eb; |
||||||
|
border-radius: 1rem; |
||||||
|
background: #fff; |
||||||
|
overflow: hidden; |
||||||
|
} |
||||||
|
.pc-head { |
||||||
|
display: flex; align-items: center; gap: 10px; |
||||||
|
padding: 13px 16px; |
||||||
|
cursor: pointer; |
||||||
|
user-select: none; |
||||||
|
background: #f9fafb; |
||||||
|
border-bottom: 1px solid transparent; |
||||||
|
} |
||||||
|
.pc.open .pc-head { border-bottom-color: #e5e7eb; } |
||||||
|
.pc-chevron { font-size: 18px; color: #6b7280; transition: transform 0.2s; } |
||||||
|
.pc.open .pc-chevron { transform: rotate(90deg); } |
||||||
|
.pc-body { display: none; padding: 16px; gap: 14px; flex-direction: column; } |
||||||
|
.pc.open .pc-body { display: flex; } |
||||||
|
.pe-label { |
||||||
|
display: block; |
||||||
|
font-size: 11px; font-weight: 700; letter-spacing: .06em; |
||||||
|
text-transform: uppercase; color: #9ca3af; |
||||||
|
margin-bottom: 5px; |
||||||
|
} |
||||||
|
.pe-palette { |
||||||
|
display: flex; flex-wrap: wrap; gap: 5px; |
||||||
|
padding: 7px 10px; |
||||||
|
background: #f3f8f9; |
||||||
|
border-top: 1px solid #e5e7eb; |
||||||
|
border-radius: 0 0 0.625rem 0.625rem; |
||||||
|
} |
||||||
|
.pe-palette-chip { |
||||||
|
display: inline-flex; align-items: center; gap: 3px; |
||||||
|
padding: 2px 10px; |
||||||
|
border-radius: 99px; |
||||||
|
background: #ecfdf5; |
||||||
|
color: #065f46; |
||||||
|
font-size: 12px; |
||||||
|
border: 1px dashed #6ee7b7; |
||||||
|
cursor: pointer; |
||||||
|
transition: background 0.1s; |
||||||
|
} |
||||||
|
.pe-palette-chip:hover { background: #d1fae5; } |
||||||
|
.pc-badge { font-size: 11px; padding: 2px 8px; border-radius: 99px; } |
||||||
|
.pc-badge--ok { background: #dcfce7; color: #166534; } |
||||||
|
.pc-badge--err { background: #fee2e2; color: #991b1b; } |
||||||
|
</style> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
{# Inject prompt data safely as JSON into JS scope #} |
||||||
|
<script> |
||||||
|
const PROMPTS_DATA = {{ prompts | tojson }}; |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-5 gap-3 flex-wrap"> |
||||||
|
<div class="flex items-center gap-2"> |
||||||
|
<span class="material-symbols-outlined text-brand-500 text-2xl">psychology</span> |
||||||
|
<h1 class="text-2xl font-semibold text-ink-900">Настройки ИИ</h1> |
||||||
|
</div> |
||||||
|
<a href="{{ url_for('main.index') }}" |
||||||
|
class="inline-flex items-center gap-1 text-sm text-ink-500 hover:text-ink-800 transition"> |
||||||
|
<span class="material-symbols-outlined text-base">arrow_back</span> |
||||||
|
Главная |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p class="text-sm text-ink-500 mb-5"> |
||||||
|
Переменные отображаются как |
||||||
|
<span class="pe-chip" style="cursor:default; pointer-events:none;">Название теста</span> |
||||||
|
— перетащите их в нужное место или нажмите «+ переменная» внизу редактора для вставки. |
||||||
|
</p> |
||||||
|
|
||||||
|
<div id="pc-list" class="flex flex-col gap-3"> |
||||||
|
{% for pid, p in prompts.items() %} |
||||||
|
<div class="pc" data-pid="{{ pid }}"> |
||||||
|
<div class="pc-head" onclick="this.closest('.pc').classList.toggle('open')"> |
||||||
|
<span class="material-symbols-outlined pc-chevron">chevron_right</span> |
||||||
|
<div class="flex-1 min-w-0"> |
||||||
|
<div class="text-sm font-semibold text-ink-900">{{ p.label }}</div> |
||||||
|
{% if p.description %} |
||||||
|
<div class="text-xs text-ink-400 mt-0.5">{{ p.description }}</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
<span class="pc-save-status pc-badge"></span> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="pc-body"> |
||||||
|
{# System #} |
||||||
|
<div> |
||||||
|
<span class="pe-label">System</span> |
||||||
|
<div class="pe-wrap"> |
||||||
|
<div class="pe-field" contenteditable="true" spellcheck="false" |
||||||
|
data-pid="{{ pid }}" data-field="system"></div> |
||||||
|
<div class="pe-palette" data-pid="{{ pid }}" data-field="system"></div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{# User #} |
||||||
|
<div> |
||||||
|
<span class="pe-label">User</span> |
||||||
|
<div class="pe-wrap"> |
||||||
|
<div class="pe-field" contenteditable="true" spellcheck="false" |
||||||
|
data-pid="{{ pid }}" data-field="user"></div> |
||||||
|
<div class="pe-palette" data-pid="{{ pid }}" data-field="user"></div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div class="flex items-center gap-3 pt-1"> |
||||||
|
<button type="button" |
||||||
|
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium transition" |
||||||
|
onclick="savePrompt(this.closest('.pc'))"> |
||||||
|
<span class="material-symbols-outlined text-base">save</span>Сохранить |
||||||
|
</button> |
||||||
|
<button type="button" |
||||||
|
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-white border border-ink-300 hover:bg-ink-100 text-ink-700 text-sm transition" |
||||||
|
onclick="resetPrompt(this.closest('.pc'))"> |
||||||
|
<span class="material-symbols-outlined text-base">restart_alt</span>Сбросить |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endfor %} |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block scripts %} |
||||||
|
<script> |
||||||
|
(() => { |
||||||
|
'use strict'; |
||||||
|
|
||||||
|
// ── DnD state ───────────────────────────────────────────────────────── |
||||||
|
let _drag = null; |
||||||
|
let _caret = null; |
||||||
|
|
||||||
|
// ── Chip ────────────────────────────────────────────────────────────── |
||||||
|
function makeChip(varName, label) { |
||||||
|
const s = document.createElement('span'); |
||||||
|
s.className = 'pe-chip'; |
||||||
|
s.setAttribute('contenteditable', 'false'); |
||||||
|
s.setAttribute('draggable', 'true'); |
||||||
|
s.dataset.var = varName; |
||||||
|
s.textContent = label; |
||||||
|
bindChipDnD(s); |
||||||
|
return s; |
||||||
|
} |
||||||
|
|
||||||
|
function bindChipDnD(chip) { |
||||||
|
chip.addEventListener('dragstart', e => { |
||||||
|
_drag = chip; |
||||||
|
e.dataTransfer.effectAllowed = 'move'; |
||||||
|
e.dataTransfer.setData('text/plain', ''); |
||||||
|
requestAnimationFrame(() => chip.classList.add('is-dragging')); |
||||||
|
}); |
||||||
|
chip.addEventListener('dragend', () => { |
||||||
|
chip.classList.remove('is-dragging'); |
||||||
|
removeCaret(); |
||||||
|
_drag = null; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// ── Render / serialize ──────────────────────────────────────────────── |
||||||
|
function renderText(el, text, vars) { |
||||||
|
el.innerHTML = ''; |
||||||
|
const parts = text.split(/(\{[a-zA-Z_]+\})/g); |
||||||
|
parts.forEach(part => { |
||||||
|
const m = part.match(/^\{([a-zA-Z_]+)\}$/); |
||||||
|
if (m && vars[m[1]] !== undefined) { |
||||||
|
el.appendChild(makeChip(m[1], vars[m[1]])); |
||||||
|
} else { |
||||||
|
el.appendChild(document.createTextNode(part)); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function serialize(el) { |
||||||
|
let out = ''; |
||||||
|
el.childNodes.forEach(n => { |
||||||
|
if (n.nodeType === Node.TEXT_NODE) out += n.textContent; |
||||||
|
else if (n.dataset && n.dataset.var) out += '{' + n.dataset.var + '}'; |
||||||
|
}); |
||||||
|
return out; |
||||||
|
} |
||||||
|
|
||||||
|
// ── Editor DnD ──────────────────────────────────────────────────────── |
||||||
|
function removeCaret() { |
||||||
|
if (_caret && _caret.parentNode) _caret.parentNode.removeChild(_caret); |
||||||
|
_caret = null; |
||||||
|
} |
||||||
|
|
||||||
|
function caretAt(x, y) { |
||||||
|
if (document.caretRangeFromPoint) { |
||||||
|
const r = document.caretRangeFromPoint(x, y); |
||||||
|
if (r) return [r.startContainer, r.startOffset]; |
||||||
|
} |
||||||
|
if (document.caretPositionFromPoint) { |
||||||
|
const p = document.caretPositionFromPoint(x, y); |
||||||
|
if (p) return [p.offsetNode, p.offset]; |
||||||
|
} |
||||||
|
return [null, 0]; |
||||||
|
} |
||||||
|
|
||||||
|
function bindEditorDrop(el) { |
||||||
|
el.addEventListener('dragover', e => { |
||||||
|
if (!_drag) return; |
||||||
|
e.preventDefault(); |
||||||
|
e.dataTransfer.dropEffect = 'move'; |
||||||
|
removeCaret(); |
||||||
|
const [node, off] = caretAt(e.clientX, e.clientY); |
||||||
|
if (!node) return; |
||||||
|
const c = document.createElement('span'); |
||||||
|
c.className = 'pe-caret'; |
||||||
|
_caret = c; |
||||||
|
try { |
||||||
|
const r = document.createRange(); |
||||||
|
r.setStart(node, Math.min(off, node.nodeType === Node.TEXT_NODE ? node.length : node.childNodes.length)); |
||||||
|
r.collapse(true); |
||||||
|
r.insertNode(c); |
||||||
|
} catch {} |
||||||
|
}); |
||||||
|
|
||||||
|
el.addEventListener('dragleave', e => { |
||||||
|
if (!el.contains(e.relatedTarget)) removeCaret(); |
||||||
|
}); |
||||||
|
|
||||||
|
el.addEventListener('drop', e => { |
||||||
|
e.preventDefault(); |
||||||
|
removeCaret(); |
||||||
|
if (!_drag) return; |
||||||
|
const [node, off] = caretAt(e.clientX, e.clientY); |
||||||
|
if (_drag.parentNode) _drag.parentNode.removeChild(_drag); |
||||||
|
if (node && el.contains(node)) { |
||||||
|
try { |
||||||
|
const r = document.createRange(); |
||||||
|
r.setStart(node, Math.min(off, node.nodeType === Node.TEXT_NODE ? node.length : node.childNodes.length)); |
||||||
|
r.collapse(true); |
||||||
|
r.insertNode(_drag); |
||||||
|
} catch { el.appendChild(_drag); } |
||||||
|
} else { |
||||||
|
el.appendChild(_drag); |
||||||
|
} |
||||||
|
bindChipDnD(_drag); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// ── Block deletion of chips ─────────────────────────────────────────── |
||||||
|
function bindEditorKeys(el) { |
||||||
|
el.addEventListener('keydown', e => { |
||||||
|
if (e.key !== 'Backspace' && e.key !== 'Delete') return; |
||||||
|
const sel = window.getSelection(); |
||||||
|
if (!sel || !sel.rangeCount) return; |
||||||
|
const range = sel.getRangeAt(0); |
||||||
|
if (!range.collapsed && range.cloneContents().querySelector('.pe-chip')) { |
||||||
|
e.preventDefault(); return; |
||||||
|
} |
||||||
|
if (range.collapsed) { |
||||||
|
const siblings = (container, offset, dir) => { |
||||||
|
if (container.nodeType === Node.TEXT_NODE) { |
||||||
|
if (dir === 'prev' && offset > 0) return null; |
||||||
|
if (dir === 'next' && offset < container.length) return null; |
||||||
|
const arr = Array.from(container.parentNode.childNodes); |
||||||
|
const i = arr.indexOf(container); |
||||||
|
return dir === 'prev' ? arr[i - 1] : arr[i + 1]; |
||||||
|
} |
||||||
|
return dir === 'prev' ? container.childNodes[offset - 1] : container.childNodes[offset]; |
||||||
|
}; |
||||||
|
const check = e.key === 'Backspace' |
||||||
|
? siblings(range.startContainer, range.startOffset, 'prev') |
||||||
|
: siblings(range.startContainer, range.startOffset, 'next'); |
||||||
|
if (check && check.classList && check.classList.contains('pe-chip')) { |
||||||
|
e.preventDefault(); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// ── Init from PROMPTS_DATA ──────────────────────────────────────────── |
||||||
|
function initEditor(el) { |
||||||
|
const pid = el.dataset.pid; |
||||||
|
const field = el.dataset.field; |
||||||
|
const prompt = PROMPTS_DATA[pid]; |
||||||
|
if (!prompt) return; |
||||||
|
const text = prompt[field] || ''; |
||||||
|
const vars = prompt.vars || {}; |
||||||
|
renderText(el, text, vars); |
||||||
|
bindEditorDrop(el); |
||||||
|
bindEditorKeys(el); |
||||||
|
} |
||||||
|
|
||||||
|
function initPalette(palette) { |
||||||
|
const pid = palette.dataset.pid; |
||||||
|
const field = palette.dataset.field; |
||||||
|
const vars = (PROMPTS_DATA[pid] || {}).vars || {}; |
||||||
|
palette.innerHTML = ''; |
||||||
|
Object.entries(vars).forEach(([varName, label]) => { |
||||||
|
const chip = document.createElement('span'); |
||||||
|
chip.className = 'pe-palette-chip'; |
||||||
|
chip.textContent = '+ ' + label; |
||||||
|
chip.title = 'вставить переменную «' + label + '»'; |
||||||
|
chip.addEventListener('click', () => insertVar(pid, field, varName, label)); |
||||||
|
palette.appendChild(chip); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function insertVar(pid, field, varName, label) { |
||||||
|
const el = document.querySelector(`.pe-field[data-pid="${pid}"][data-field="${field}"]`); |
||||||
|
if (!el) return; |
||||||
|
el.focus(); |
||||||
|
const sel = window.getSelection(); |
||||||
|
let range; |
||||||
|
if (sel && sel.rangeCount && el.contains(sel.getRangeAt(0).commonAncestorContainer)) { |
||||||
|
range = sel.getRangeAt(0); |
||||||
|
range.deleteContents(); |
||||||
|
} else { |
||||||
|
range = document.createRange(); |
||||||
|
range.selectNodeContents(el); |
||||||
|
range.collapse(false); |
||||||
|
} |
||||||
|
const chip = makeChip(varName, label); |
||||||
|
range.insertNode(chip); |
||||||
|
range.setStartAfter(chip); |
||||||
|
range.collapse(true); |
||||||
|
sel.removeAllRanges(); |
||||||
|
sel.addRange(range); |
||||||
|
} |
||||||
|
|
||||||
|
document.querySelectorAll('.pe-field').forEach(initEditor); |
||||||
|
document.querySelectorAll('.pe-palette').forEach(initPalette); |
||||||
|
|
||||||
|
// ── Save ────────────────────────────────────────────────────────────── |
||||||
|
window.savePrompt = async function(card) { |
||||||
|
const pid = card.dataset.pid; |
||||||
|
const sysEl = card.querySelector('.pe-field[data-field="system"]'); |
||||||
|
const usrEl = card.querySelector('.pe-field[data-field="user"]'); |
||||||
|
const badge = card.querySelector('.pc-save-status'); |
||||||
|
badge.textContent = ''; |
||||||
|
badge.className = 'pc-save-status pc-badge'; |
||||||
|
try { |
||||||
|
const r = await fetch('/api/ai/prompts/' + pid, { |
||||||
|
method: 'PUT', |
||||||
|
headers: { 'Content-Type': 'application/json' }, |
||||||
|
body: JSON.stringify({ system: serialize(sysEl), user: serialize(usrEl) }), |
||||||
|
}); |
||||||
|
const d = await r.json(); |
||||||
|
if (!r.ok) throw new Error(d.error || 'Ошибка'); |
||||||
|
// Update in-memory data |
||||||
|
PROMPTS_DATA[pid].system = serialize(sysEl); |
||||||
|
PROMPTS_DATA[pid].user = serialize(usrEl); |
||||||
|
badge.textContent = '✓ Сохранено'; |
||||||
|
badge.className = 'pc-save-status pc-badge pc-badge--ok'; |
||||||
|
setTimeout(() => { badge.textContent = ''; badge.className = 'pc-save-status pc-badge'; }, 3000); |
||||||
|
} catch (e) { |
||||||
|
badge.textContent = e.message; |
||||||
|
badge.className = 'pc-save-status pc-badge pc-badge--err'; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
// ── Reset ───────────────────────────────────────────────────────────── |
||||||
|
window.resetPrompt = async function(card) { |
||||||
|
if (!confirm('Сбросить к последней сохранённой версии?')) return; |
||||||
|
const pid = card.dataset.pid; |
||||||
|
try { |
||||||
|
const r = await fetch('/api/ai/prompts'); |
||||||
|
const d = await r.json(); |
||||||
|
const p = d.prompts?.[pid]; |
||||||
|
if (!p) return; |
||||||
|
PROMPTS_DATA[pid] = p; |
||||||
|
const sysEl = card.querySelector('.pe-field[data-field="system"]'); |
||||||
|
const usrEl = card.querySelector('.pe-field[data-field="user"]'); |
||||||
|
renderText(sysEl, p.system || '', p.vars || {}); |
||||||
|
renderText(usrEl, p.user || '', p.vars || {}); |
||||||
|
} catch (e) { alert(e.message); } |
||||||
|
}; |
||||||
|
|
||||||
|
})(); |
||||||
|
</script> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,103 @@ |
|||||||
|
{% extends "base.html" %} |
||||||
|
{% block title %}Статистика прохождений{% endblock %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div class="flex items-center justify-between mb-6 gap-3 flex-wrap"> |
||||||
|
<div class="flex items-center gap-2"> |
||||||
|
<span class="material-symbols-outlined text-brand-500 text-2xl">bar_chart</span> |
||||||
|
<h1 class="text-2xl font-semibold text-ink-900">Статистика</h1> |
||||||
|
</div> |
||||||
|
<a href="{{ url_for('main.index') }}" |
||||||
|
class="inline-flex items-center gap-1 text-sm text-ink-500 hover:text-ink-800 transition"> |
||||||
|
<span class="material-symbols-outlined text-base">arrow_back</span> |
||||||
|
Главная |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
{# Сводные метрики #} |
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mb-6"> |
||||||
|
{% for icon, label, value in [ |
||||||
|
('quiz', 'Тестов всего', stats.total_tests), |
||||||
|
('people', 'Пользователей', stats.total_users), |
||||||
|
('fact_check', 'Прохождений', stats.total_completed), |
||||||
|
('check_circle', 'Сдали', stats.total_passed), |
||||||
|
('percent', 'Успешность', stats.pass_rate | string + '\u2009%'), |
||||||
|
] %} |
||||||
|
<div class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-4 flex flex-col gap-1"> |
||||||
|
<span class="material-symbols-outlined text-brand-500 text-xl">{{ icon }}</span> |
||||||
|
<div class="text-2xl font-bold text-ink-900 leading-tight">{{ value }}</div> |
||||||
|
<div class="text-xs text-ink-500">{{ label }}</div> |
||||||
|
</div> |
||||||
|
{% endfor %} |
||||||
|
</div> |
||||||
|
|
||||||
|
{# По отделам + последние прохождения #} |
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> |
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white border border-ink-300/50 shadow-sm overflow-hidden"> |
||||||
|
<div class="px-4 py-3 border-b border-ink-100 flex items-center gap-2"> |
||||||
|
<span class="material-symbols-outlined text-brand-500 text-base">corporate_fare</span> |
||||||
|
<h2 class="font-semibold text-sm text-ink-900">По отделам</h2> |
||||||
|
</div> |
||||||
|
{% if stats.dept_stats %} |
||||||
|
<div class="overflow-x-auto"> |
||||||
|
<table class="w-full text-sm"> |
||||||
|
<thead> |
||||||
|
<tr class="border-b border-ink-100 text-xs text-ink-500 uppercase tracking-wide"> |
||||||
|
<th class="px-4 py-2 text-left font-medium">Отдел</th> |
||||||
|
<th class="px-4 py-2 text-right font-medium">Прошли</th> |
||||||
|
<th class="px-4 py-2 text-right font-medium">Сдали</th> |
||||||
|
<th class="px-4 py-2 text-right font-medium">%</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody class="divide-y divide-ink-100/60"> |
||||||
|
{% for d in stats.dept_stats %} |
||||||
|
<tr class="hover:bg-ink-100/40 transition-colors"> |
||||||
|
<td class="px-4 py-2.5 text-ink-800 max-w-[180px] truncate">{{ d.name }}</td> |
||||||
|
<td class="px-4 py-2.5 text-right text-ink-700">{{ d.total }}</td> |
||||||
|
<td class="px-4 py-2.5 text-right text-ink-700">{{ d.passed }}</td> |
||||||
|
<td class="px-4 py-2.5 text-right"> |
||||||
|
<span class="inline-block min-w-[38px] text-center rounded-full px-2 py-0.5 text-xs font-semibold |
||||||
|
{% if d.rate >= 80 %}bg-green-50 text-green-700 |
||||||
|
{% elif d.rate >= 50 %}bg-yellow-50 text-yellow-700 |
||||||
|
{% else %}bg-red-50 text-red-700{% endif %}"> |
||||||
|
{{ d.rate }}% |
||||||
|
</span> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<p class="px-4 py-8 text-sm text-ink-500 text-center">Прохождений ещё нет.</p> |
||||||
|
{% endif %} |
||||||
|
</section> |
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white border border-ink-300/50 shadow-sm overflow-hidden"> |
||||||
|
<div class="px-4 py-3 border-b border-ink-100 flex items-center gap-2"> |
||||||
|
<span class="material-symbols-outlined text-brand-500 text-base">history</span> |
||||||
|
<h2 class="font-semibold text-sm text-ink-900">Последние прохождения</h2> |
||||||
|
</div> |
||||||
|
{% if stats.recent %} |
||||||
|
<ul class="divide-y divide-ink-100/60"> |
||||||
|
{% for r in stats.recent %} |
||||||
|
<li class="px-4 py-2.5 flex items-start gap-3 hover:bg-ink-100/30 transition-colors"> |
||||||
|
<span class="material-symbols-outlined text-base mt-0.5 |
||||||
|
{% if r.passed == true %}text-green-500{% elif r.passed is none %}text-ink-300{% else %}text-red-400{% endif %}"> |
||||||
|
{% if r.passed == true %}check_circle{% elif r.passed is none %}radio_button_unchecked{% else %}cancel{% endif %} |
||||||
|
</span> |
||||||
|
<div class="flex-1 min-w-0"> |
||||||
|
<div class="text-sm text-ink-800 truncate font-medium">{{ r.test }}</div> |
||||||
|
<div class="text-xs text-ink-500 truncate">{{ r.user }} · {{ r.score }} · {{ r.at }}</div> |
||||||
|
</div> |
||||||
|
</li> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
{% else %} |
||||||
|
<p class="px-4 py-8 text-sm text-ink-500 text-center">Прохождений ещё нет.</p> |
||||||
|
{% endif %} |
||||||
|
</section> |
||||||
|
|
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
Loading…
Reference in new issue