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 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().""" |
||||
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]) |
||||
|
||||
def has_any_attempt_for_test(session: Session, test_id) -> bool: |
||||
"""Возвращает True, если для теста есть хотя бы одна попытка.""" |
||||
import uuid as _uuid |
||||
if not isinstance(test_id, _uuid.UUID): |
||||
try: |
||||
test_id = _uuid.UUID(str(test_id)) |
||||
except (ValueError, AttributeError): |
||||
return False |
||||
|
||||
return session.query( |
||||
session.query(TestAttempt) |
||||
.join(TestVersion, TestAttempt.test_version_id == TestVersion.id) |
||||
.filter(TestVersion.test_id == test_id) |
||||
.exists() |
||||
).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" %} |
||||
{% block title %}Тестирование — главная{% endblock %} |
||||
{% 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> |
||||
<h1 class="text-2xl font-semibold text-ink-900 mb-5">Тестирование персонала</h1> |
||||
|
||||
<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> |
||||
Каталог тестов |
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> |
||||
|
||||
{# Статистика #} |
||||
<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 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 href="{{ url_for('tests.tests_list_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">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 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> |
||||
</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 [ |
||||
('Авторизация', '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> |
||||
{# Настройки ИИ #} |
||||
<a href="{{ url_for('settings.prompts_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">psychology</span> |
||||
<h2 class="font-semibold text-ink-900">Настройки ИИ</h2> |
||||
</div> |
||||
<p class="mt-1 text-sm text-ink-500">{{ descr }}</p> |
||||
</article> |
||||
{% endfor %} |
||||
</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> |
||||
|
||||
</div> |
||||
{% 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