diff --git a/Makefile b/Makefile index 9cfc211..21cf451 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,13 @@ models: ## Скачать веса GigaAM (STT) и Silero (TTS) в backend/model @echo "не реализовано — карточка tasks/lct-02-latency-baseline.md"; exit 1 seed: ## Залить сценарии из /scenarios в БД - @echo "не реализовано — карточка tasks/lct-04-scenario-loader.md"; exit 1 + cd backend && $(UV) run python scripts/seed.py + +migrate: ## Накатить миграции + cd backend && $(UV) run alembic upgrade head + +revision: ## Создать миграцию: make revision m="что изменилось" + cd backend && $(UV) run alembic revision --autogenerate -m "$(m)" repl: ## Текстовый диалог со звонящим без голоса @echo "не реализовано — карточка tasks/lct-07-caller-slots.md"; exit 1 @@ -45,4 +51,4 @@ pregen: ## Дерево диалога и WAV первых реплик для demo: ## Поднять всё в демо-режиме: офлайн, прогретые модели @echo "не реализовано — карточка tasks/lct-21-demo-readiness.md"; exit 1 -.PHONY: help dev down back front types test typecheck models seed repl pregen demo +.PHONY: help dev down back front types test typecheck migrate revision models seed repl pregen demo diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..01bcc2e --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,39 @@ +[alembic] +script_location = app/db/migrations +prepend_sys_path = . +# Строка подключения берётся из настроек (app/config.py), не отсюда: +# в докере и на хосте она разная, а файл один. +sqlalchemy.url = + +[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 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/http/__init__.py b/backend/app/api/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/http/scenarios.py b/backend/app/api/http/scenarios.py new file mode 100644 index 0000000..e96b517 --- /dev/null +++ b/backend/app/api/http/scenarios.py @@ -0,0 +1,47 @@ +"""Библиотека сценариев по HTTP. + +`GET /api/scenarios/{id}` **не отдаёт** `facts` и `ground_truth`: иначе курсант +откроет DevTools и прочитает адрес до того, как его спросит. + +`checklist` скрыт по той же причине и даже более веской: чек-лист — это +содержимое подсказок. Отдать его целиком значит выдать в контрольном режиме +то, чего там не должно быть вовсе, и обойти выдачу по одному пункту +(docs/product/MODES.md#подсказка-по-запросу). Подсказки идут только событием +`hint.shown` из живой сессии, эталонные вопросы — только в разборе. +""" + +from fastapi import APIRouter, HTTPException + +from app.scenarios import store + +router = APIRouter(prefix="/api/scenarios", tags=["scenarios"]) + +HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"} + + +@router.get("") +async def listing() -> list[dict]: + return [ + { + "id": scenario.id, + "title": scenario.title, + "type": scenario.type.value, + "level": scenario.level.value, + "topics": scenario.topics, + "modes": scenario.modes, + "dds": scenario.ground_truth.dds.value if scenario.ground_truth.dds else None, + } + for scenario in store.all_scenarios() + ] + + +@router.get("/{scenario_id}") +async def read(scenario_id: str) -> dict: + scenario = store.get(scenario_id) + if scenario is None: + raise HTTPException(status_code=404, detail="scenario_not_found") + payload = scenario.model_dump(mode="json") + for key in HIDDEN_FROM_TRAINEE: + payload.pop(key, None) + payload["required_fields"] = scenario.required_fields + return payload diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py new file mode 100644 index 0000000..ad5c9fd --- /dev/null +++ b/backend/app/api/http/sessions.py @@ -0,0 +1,93 @@ +"""Сессии: создание, состояние, история. + +`group_id` и `mode` принимаются с первого дня — размечать накопленные сессии +задним числом не надо (docs/arch/CONTRACT.md#http-api). +""" + +from datetime import datetime +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import repo +from app.db.base import get_session +from app.domain.events import SessionMode + +router = APIRouter(prefix="/api/sessions", tags=["sessions"]) + + +class SessionCreate(BaseModel): + scenario_id: str + mode: SessionMode + trainee: str | None = None + group: str | None = None + + +class SessionOut(BaseModel): + session_id: UUID + scenario_id: str + mode: SessionMode + attempt: int + trainee_id: UUID | None = None + group_id: UUID | None = None + started_at: datetime | None = None + ended_at: datetime | None = None + end_reason: str | None = None + + +def _out(session) -> SessionOut: + return SessionOut( + session_id=session.id, + scenario_id=session.scenario_id, + mode=session.mode, + attempt=session.attempt, + trainee_id=session.trainee_id, + group_id=session.group_id, + started_at=session.started_at, + ended_at=session.ended_at, + end_reason=session.end_reason, + ) + + +@router.post("", response_model=SessionOut, status_code=201) +async def create(body: SessionCreate, db: AsyncSession = Depends(get_session)) -> SessionOut: + group = await repo.ensure_group(db, body.group) if body.group else None + trainee = await repo.ensure_trainee(db, body.trainee, group) if body.trainee else None + session = await repo.create_session( + db, + scenario_id=body.scenario_id, + mode=body.mode.value, + trainee_id=trainee.id if trainee else None, + group_id=group.id if group else None, + ) + return _out(session) + + +@router.get("/{session_id}", response_model=SessionOut) +async def read(session_id: UUID, db: AsyncSession = Depends(get_session)) -> SessionOut: + session = await repo.get_session(db, session_id) + if session is None: + raise HTTPException(status_code=404, detail="session_not_found") + return _out(session) + + +@router.get("", response_model=list[SessionOut]) +async def listing( + trainee: UUID | None = None, + group: UUID | None = None, + mode: SessionMode | None = None, + since: datetime | None = Query(default=None, alias="from"), + limit: int = 100, + db: AsyncSession = Depends(get_session), +) -> list[SessionOut]: + rows = await repo.history( + db, + trainee_id=trainee, + group_id=group, + mode=mode.value if mode else None, + since=since, + limit=limit, + ) + return [_out(row) for row in rows] diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..96d8264 --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,36 @@ +"""Подключение к Postgres. Асинхронный движок, одна фабрика сессий на процесс.""" + +from collections.abc import AsyncIterator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from app.config import get_settings + + +class Base(DeclarativeBase): + """Общая база моделей. Alembic сравнивает миграции с `Base.metadata`.""" + + +_engine = None +_sessionmaker: async_sessionmaker[AsyncSession] | None = None + + +def get_engine(): + global _engine + if _engine is None: + _engine = create_async_engine(get_settings().database_url, pool_pre_ping=True) + return _engine + + +def get_sessionmaker() -> async_sessionmaker[AsyncSession]: + global _sessionmaker + if _sessionmaker is None: + _sessionmaker = async_sessionmaker(get_engine(), expire_on_commit=False) + return _sessionmaker + + +async def get_session() -> AsyncIterator[AsyncSession]: + """Зависимость FastAPI: сессия на запрос, коммит явный.""" + async with get_sessionmaker()() as session: + yield session diff --git a/backend/app/db/migrations/env.py b/backend/app/db/migrations/env.py new file mode 100644 index 0000000..638d6dc --- /dev/null +++ b/backend/app/db/migrations/env.py @@ -0,0 +1,54 @@ +"""Alembic в асинхронном режиме. Строка подключения — из настроек приложения.""" + +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.ext.asyncio import async_engine_from_config +from sqlalchemy import pool + +from app.config import get_settings +from app.db.base import Base +from app.db import models # noqa: F401 — регистрирует таблицы в Base.metadata + +config = context.config +config.set_main_option("sqlalchemy.url", get_settings().database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=config.get_main_option("sqlalchemy.url"), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/backend/app/db/migrations/script.py.mako b/backend/app/db/migrations/script.py.mako new file mode 100644 index 0000000..aed88d8 --- /dev/null +++ b/backend/app/db/migrations/script.py.mako @@ -0,0 +1,23 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/app/db/migrations/versions/ff5de7dfe4ce_initial_schema.py b/backend/app/db/migrations/versions/ff5de7dfe4ce_initial_schema.py new file mode 100644 index 0000000..24b1181 --- /dev/null +++ b/backend/app/db/migrations/versions/ff5de7dfe4ce_initial_schema.py @@ -0,0 +1,166 @@ +"""первая схема: курсанты, группы, сессии, транскрипт, отметки, оценки + +Revision ID: ff5de7dfe4ce +Revises: +Create Date: 2026-09-15 19:51:15.671575 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = 'ff5de7dfe4ce' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('groups', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('llm_cache', + sa.Column('context_hash', sa.String(length=64), nullable=False), + sa.Column('model', sa.String(length=80), nullable=False), + sa.Column('prompt', sa.Text(), nullable=False), + sa.Column('response', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('context_hash') + ) + op.create_table('scenarios', + sa.Column('id', sa.String(length=80), nullable=False), + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('incident_type', sa.String(length=20), nullable=False), + sa.Column('level', sa.String(length=4), nullable=False), + sa.Column('topics', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('modes', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('body', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('trainees', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('group_id', sa.Uuid(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('sessions', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('scenario_id', sa.String(length=80), nullable=False), + sa.Column('trainee_id', sa.Uuid(), nullable=True), + sa.Column('group_id', sa.Uuid(), nullable=True), + sa.Column('mode', sa.String(length=16), nullable=False), + sa.Column('attempt', sa.Integer(), nullable=False), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('ended_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('end_reason', sa.String(length=16), nullable=True), + sa.Column('kio', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['scenario_id'], ['scenarios.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['trainee_id'], ['trainees.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_sessions_group_created', 'sessions', ['group_id', 'created_at'], unique=False) + op.create_index('ix_sessions_trainee_scenario', 'sessions', ['trainee_id', 'scenario_id'], unique=False) + op.create_table('findings', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('session_id', sa.Uuid(), nullable=False), + sa.Column('code', sa.String(length=4), nullable=False), + sa.Column('source', sa.String(length=16), nullable=False), + sa.Column('summary', sa.Text(), nullable=False), + sa.Column('fact', sa.Text(), nullable=False), + sa.Column('norm', sa.Text(), nullable=True), + sa.Column('ref', sa.String(length=80), nullable=True), + sa.Column('competency', sa.String(length=24), nullable=True), + sa.Column('transcript_ref', sa.String(length=40), nullable=True), + sa.Column('at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_findings_session_code', 'findings', ['session_id', 'code'], unique=False) + op.create_table('hint_uses', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('session_id', sa.Uuid(), nullable=False), + sa.Column('checklist_id', sa.String(length=60), nullable=False), + sa.Column('question', sa.Text(), nullable=False), + sa.Column('at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('instructor_notes', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('session_id', sa.Uuid(), nullable=False), + sa.Column('transcript_ref', sa.String(length=40), nullable=False), + sa.Column('text', sa.Text(), nullable=False), + sa.Column('author', sa.String(length=120), nullable=False), + sa.Column('at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('scores', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('session_id', sa.Uuid(), nullable=False), + sa.Column('score_auto', sa.Float(), nullable=False), + sa.Column('score_final', sa.Float(), nullable=False), + sa.Column('overridden_by', sa.String(length=120), nullable=True), + sa.Column('override_comment', sa.Text(), nullable=True), + sa.Column('report', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('session_id') + ) + op.create_table('self_assessments', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('session_id', sa.Uuid(), nullable=False), + sa.Column('missed', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('comment', sa.Text(), nullable=False), + sa.Column('submitted_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('session_id') + ) + op.create_table('utterances', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('session_id', sa.Uuid(), nullable=False), + sa.Column('ref', sa.String(length=40), nullable=False), + sa.Column('speaker', sa.String(length=10), nullable=False), + sa.Column('text', sa.Text(), nullable=False), + sa.Column('mood', sa.String(length=16), nullable=True), + sa.Column('at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('session_id', 'ref', name='uq_utterance_ref') + ) + op.create_index('ix_utterances_session', 'utterances', ['session_id', 'at'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_utterances_session', table_name='utterances') + op.drop_table('utterances') + op.drop_table('self_assessments') + op.drop_table('scores') + op.drop_table('instructor_notes') + op.drop_table('hint_uses') + op.drop_index('ix_findings_session_code', table_name='findings') + op.drop_table('findings') + op.drop_index('ix_sessions_trainee_scenario', table_name='sessions') + op.drop_index('ix_sessions_group_created', table_name='sessions') + op.drop_table('sessions') + op.drop_table('trainees') + op.drop_table('scenarios') + op.drop_table('llm_cache') + op.drop_table('groups') + # ### end Alembic commands ### diff --git a/backend/app/db/models.py b/backend/app/db/models.py new file mode 100644 index 0000000..4fb45ca --- /dev/null +++ b/backend/app/db/models.py @@ -0,0 +1,208 @@ +"""Таблицы. Профиль курсанта, дельта попыток и аналитика группы строятся +по этому журналу, а не по памяти процесса (docs/arch/BACKEND.md). + +Группы и связь `trainee → group` заложены здесь с первой миграции, даже пустыми: +размечать накопленные сессии задним числом — лишняя работа и лишняя миграция. +""" + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +def _uuid_pk() -> Mapped[UUID]: + return mapped_column(primary_key=True, default=uuid4) + + +class Group(Base): + """Учебная группа. Единица аналитики: «70% группы не уточняют этаж».""" + + __tablename__ = "groups" + + id: Mapped[UUID] = _uuid_pk() + name: Mapped[str] = mapped_column(String(120), unique=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + trainees: Mapped[list["Trainee"]] = relationship(back_populates="group") + + +class Trainee(Base): + __tablename__ = "trainees" + + id: Mapped[UUID] = _uuid_pk() + name: Mapped[str] = mapped_column(String(120)) + group_id: Mapped[UUID | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + group: Mapped["Group | None"] = relationship(back_populates="trainees") + sessions: Mapped[list["Session"]] = relationship(back_populates="trainee") + + +class Scenario(Base): + """Сценарий целиком лежит в `body`: библиотека — контент, а не схема. + Отдельными колонками вынесено только то, по чему идёт выборка в списке.""" + + __tablename__ = "scenarios" + + id: Mapped[str] = mapped_column(String(80), primary_key=True) + title: Mapped[str] = mapped_column(String(200)) + incident_type: Mapped[str] = mapped_column(String(20)) + level: Mapped[str] = mapped_column(String(4)) + topics: Mapped[list] = mapped_column(JSONB, default=list) + modes: Mapped[list] = mapped_column(JSONB, default=list) + status: Mapped[str] = mapped_column(String(16), default="published") + body: Mapped[dict] = mapped_column(JSONB) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class Session(Base): + """Одна попытка одного курсанта по одному сценарию. + + Номер попытки хранится здесь, а не отдельной таблицей: дельта попыток + считается запросом по (trainee_id, scenario_id, attempt). + """ + + __tablename__ = "sessions" + + id: Mapped[UUID] = _uuid_pk() + scenario_id: Mapped[str] = mapped_column(ForeignKey("scenarios.id", ondelete="RESTRICT")) + trainee_id: Mapped[UUID | None] = mapped_column(ForeignKey("trainees.id", ondelete="SET NULL")) + group_id: Mapped[UUID | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL")) + mode: Mapped[str] = mapped_column(String(16)) + attempt: Mapped[int] = mapped_column(default=1) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + end_reason: Mapped[str | None] = mapped_column(String(16)) + kio: Mapped[dict] = mapped_column(JSONB, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + trainee: Mapped["Trainee | None"] = relationship(back_populates="sessions") + utterances: Mapped[list["Utterance"]] = relationship( + back_populates="session", cascade="all, delete-orphan" + ) + + __table_args__ = ( + Index("ix_sessions_trainee_scenario", "trainee_id", "scenario_id"), + Index("ix_sessions_group_created", "group_id", "created_at"), + ) + + +class Utterance(Base): + """Реплика транскрипта. `ref` — якорь для пометок и отметок разбора.""" + + __tablename__ = "utterances" + + id: Mapped[UUID] = _uuid_pk() + session_id: Mapped[UUID] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE")) + ref: Mapped[str] = mapped_column(String(40)) + speaker: Mapped[str] = mapped_column(String(10)) + text: Mapped[str] = mapped_column(Text) + mood: Mapped[str | None] = mapped_column(String(16)) + at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + + session: Mapped["Session"] = relationship(back_populates="utterances") + + __table_args__ = ( + UniqueConstraint("session_id", "ref", name="uq_utterance_ref"), + Index("ix_utterances_session", "session_id", "at"), + ) + + +class Finding(Base): + """Отметка разбора. Без `fact` отметки не бывает: код без обоснования + не появляется ни в одном интерфейсе.""" + + __tablename__ = "findings" + + id: Mapped[UUID] = _uuid_pk() + session_id: Mapped[UUID] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE")) + code: Mapped[str] = mapped_column(String(4)) + source: Mapped[str] = mapped_column(String(16)) + summary: Mapped[str] = mapped_column(Text) + fact: Mapped[str] = mapped_column(Text) + norm: Mapped[str | None] = mapped_column(Text) + ref: Mapped[str | None] = mapped_column(String(80)) + competency: Mapped[str | None] = mapped_column(String(24)) + transcript_ref: Mapped[str | None] = mapped_column(String(40)) + at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + __table_args__ = (Index("ix_findings_session_code", "session_id", "code"),) + + +class HintUse(Base): + """Использованная подсказка: какой пункт и на какой минуте. + Штрафа нет — это информация преподавателю и материал разбора.""" + + __tablename__ = "hint_uses" + + id: Mapped[UUID] = _uuid_pk() + session_id: Mapped[UUID] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE")) + checklist_id: Mapped[str] = mapped_column(String(60)) + question: Mapped[str] = mapped_column(Text) + at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + + +class SelfAssessment(Base): + """Самооценка до показа автооценки. Одна на сессию.""" + + __tablename__ = "self_assessments" + + id: Mapped[UUID] = _uuid_pk() + session_id: Mapped[UUID] = mapped_column( + ForeignKey("sessions.id", ondelete="CASCADE"), unique=True + ) + missed: Mapped[list] = mapped_column(JSONB, default=list) + comment: Mapped[str] = mapped_column(Text, default="") + submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + + +class InstructorNote(Base): + """Пометка преподавателя к реплике. Видна курсанту в его истории.""" + + __tablename__ = "instructor_notes" + + id: Mapped[UUID] = _uuid_pk() + session_id: Mapped[UUID] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE")) + transcript_ref: Mapped[str] = mapped_column(String(40)) + text: Mapped[str] = mapped_column(Text) + author: Mapped[str] = mapped_column(String(120)) + at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class Score(Base): + """Оценка сессии. Автооценка сохраняется рядом с коррекцией: + видно, что скорректировано и кем.""" + + __tablename__ = "scores" + + id: Mapped[UUID] = _uuid_pk() + session_id: Mapped[UUID] = mapped_column( + ForeignKey("sessions.id", ondelete="CASCADE"), unique=True + ) + score_auto: Mapped[float] = mapped_column() + score_final: Mapped[float] = mapped_column() + overridden_by: Mapped[str | None] = mapped_column(String(120)) + override_comment: Mapped[str | None] = mapped_column(Text) + report: Mapped[dict] = mapped_column(JSONB, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class LlmCache(Base): + """Кэш ответов LLM по хешу контекста. Работает и онлайн — экономия + на повторах, — и как накопитель материала для офлайн-дерева.""" + + __tablename__ = "llm_cache" + + context_hash: Mapped[str] = mapped_column(String(64), primary_key=True) + model: Mapped[str] = mapped_column(String(80)) + prompt: Mapped[str] = mapped_column(Text) + response: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/db/repo.py b/backend/app/db/repo.py new file mode 100644 index 0000000..f2925e3 --- /dev/null +++ b/backend/app/db/repo.py @@ -0,0 +1,135 @@ +"""Доступ к журналу. Всё, что не записано сюда, для оценки не существует.""" + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import Group, HintUse, InstructorNote, Session, Trainee, Utterance + + +async def next_attempt(db: AsyncSession, trainee_id: UUID | None, scenario_id: str) -> int: + """Номер попытки по этому сценарию. Отдельной таблицы попыток нет: + дельта считается запросом по (trainee_id, scenario_id, attempt).""" + if trainee_id is None: + return 1 + done = await db.scalar( + select(func.count()) + .select_from(Session) + .where(Session.trainee_id == trainee_id, Session.scenario_id == scenario_id) + ) + return int(done or 0) + 1 + + +async def create_session( + db: AsyncSession, + *, + scenario_id: str, + mode: str, + trainee_id: UUID | None = None, + group_id: UUID | None = None, +) -> Session: + session = Session( + scenario_id=scenario_id, + mode=mode, + trainee_id=trainee_id, + group_id=group_id, + attempt=await next_attempt(db, trainee_id, scenario_id), + ) + db.add(session) + await db.commit() + return session + + +async def get_session(db: AsyncSession, session_id: UUID) -> Session | None: + return await db.get(Session, session_id) + + +async def append_utterance( + db: AsyncSession, + *, + session_id: UUID, + ref: str, + speaker: str, + text: str, + at: datetime, + mood: str | None = None, +) -> Utterance: + utterance = Utterance( + session_id=session_id, ref=ref, speaker=speaker, text=text, at=at, mood=mood + ) + db.add(utterance) + await db.commit() + return utterance + + +async def transcript(db: AsyncSession, session_id: UUID) -> list[Utterance]: + rows = await db.scalars( + select(Utterance).where(Utterance.session_id == session_id).order_by(Utterance.at) + ) + return list(rows) + + +async def record_hint( + db: AsyncSession, *, session_id: UUID, checklist_id: str, question: str, at: datetime +) -> HintUse: + """Каждое использование подсказки попадает в журнал: счёт подсказок — + материал разбора, а не вычитаемое из баллов.""" + hint = HintUse(session_id=session_id, checklist_id=checklist_id, question=question, at=at) + db.add(hint) + await db.commit() + return hint + + +async def add_note( + db: AsyncSession, *, session_id: UUID, transcript_ref: str, text: str, author: str +) -> InstructorNote: + note = InstructorNote( + session_id=session_id, transcript_ref=transcript_ref, text=text, author=author + ) + db.add(note) + await db.commit() + return note + + +async def history( + db: AsyncSession, + *, + trainee_id: UUID | None = None, + group_id: UUID | None = None, + mode: str | None = None, + since: datetime | None = None, + limit: int = 100, +) -> list[Session]: + """История с фильтрами: преподавателю нужно видеть прогресс группы + и самостоятельную работу курсантов.""" + query = select(Session).order_by(Session.created_at.desc()).limit(limit) + if trainee_id is not None: + query = query.where(Session.trainee_id == trainee_id) + if group_id is not None: + query = query.where(Session.group_id == group_id) + if mode is not None: + query = query.where(Session.mode == mode) + if since is not None: + query = query.where(Session.created_at >= since) + return list(await db.scalars(query)) + + +async def ensure_group(db: AsyncSession, name: str) -> Group: + group = await db.scalar(select(Group).where(Group.name == name)) + if group is None: + group = Group(name=name) + db.add(group) + await db.commit() + return group + + +async def ensure_trainee(db: AsyncSession, name: str, group: Group | None = None) -> Trainee: + query = select(Trainee).where(Trainee.name == name) + trainee = await db.scalar(query) + if trainee is None: + trainee = Trainee(name=name, group_id=group.id if group else None) + db.add(trainee) + await db.commit() + return trainee diff --git a/backend/app/main.py b/backend/app/main.py index 87f3b9a..79b321c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,17 +4,36 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from pathlib import Path + +from app.api.http import scenarios as scenarios_api +from app.api.http import sessions from app.config import get_settings +from app.scenarios import store +from app.scenarios.loader import ScenarioError + + +LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" @asynccontextmanager async def lifespan(app: FastAPI): - # Прогрев моделей и валидация сценариев — карточки lct-04 и lct-06. + # Библиотека проверяется на старте целиком: сломанный сценарий, найденный + # посреди занятия, — сценарий, которого не должно случиться. + try: + loaded = store.load_from_disk(LIBRARY) + except ScenarioError as exc: + raise RuntimeError(f"библиотека сценариев не прошла проверку: {exc}") from exc + app.state.scenarios_loaded = len(loaded) + + # Прогрев моделей — карточка lct-06. app.state.models_ready = False yield app = FastAPI(title="Учебный симулятор занятия для системы 112", lifespan=lifespan) +app.include_router(sessions.router) +app.include_router(scenarios_api.router) @app.get("/api/health") @@ -24,5 +43,6 @@ async def health() -> dict: return { "status": "ok", "models_ready": getattr(app.state, "models_ready", False), + "scenarios_loaded": getattr(app.state, "scenarios_loaded", 0), "offline": settings.offline, } diff --git a/backend/app/scenarios/__init__.py b/backend/app/scenarios/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/scenarios/loader.py b/backend/app/scenarios/loader.py new file mode 100644 index 0000000..7b56ba7 --- /dev/null +++ b/backend/app/scenarios/loader.py @@ -0,0 +1,124 @@ +"""Загрузчик библиотеки сценариев. + +Проверяет **все** YAML на старте приложения и падает с внятным сообщением +при первом же нарушении: сломанный сценарий, найденный посреди занятия, — +сценарий, которого не должно случиться (docs/arch/BACKEND.md). +""" + +from pathlib import Path + +import yaml +from pydantic import ValidationError + +from app.domain.classifiers import DDS_BY_INCIDENT +from app.scenarios.schema import ChecklistItem, Scenario + + +class ScenarioError(Exception): + """Ошибка библиотеки. Текст пишется для методиста, не для программиста.""" + + +def _read_yaml(path: Path) -> dict: + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise ScenarioError(f"{path.name}: битый YAML — {exc}") from exc + if not isinstance(data, dict): + raise ScenarioError(f"{path.name}: ожидался словарь верхнего уровня") + return data + + +def _merge_checklist(base: list[dict], local: list[dict]) -> list[ChecklistItem]: + """Общий чек-лист по классификатору плюс локальные дополнения. + + Наследуются все пункты базы, локальные перекрывают одноимённые и добавляют + свои. Пункт без `fact` допустим: «представьтесь» не добывает факт, + но остаётся частью эталонного опроса. + """ + merged: dict[str, dict] = {item["id"]: dict(item) for item in base} + for item in local: + merged.setdefault(item["id"], {}).update(item) + return [ChecklistItem.model_validate(item) for item in merged.values()] + + +def _derive_ground_truth(scenario: Scenario) -> Scenario: + """Эталон собирается кодом. Из YAML берутся только нормализованные + адрес и число пострадавших — остальное перезаписывается.""" + hidden = {fact.id for fact in scenario.facts if fact.hidden} + required = [ + item.fact + for item in scenario.checklist + if item.fact and item.fact not in hidden + ] + scenario.ground_truth.incident_type = scenario.type + scenario.ground_truth.dds = DDS_BY_INCIDENT[scenario.type] + scenario.ground_truth.required_facts = required + return scenario + + +def load_file(path: Path, root: Path) -> Scenario: + raw = _read_yaml(path) + + declared = raw.get("ground_truth") or {} + forbidden = {"incident_type", "dds", "required_facts"} & set(declared) + if forbidden: + raise ScenarioError( + f"{path.name}: {', '.join(sorted(forbidden))} в ground_truth выводится кодом " + "и руками не пишется — иначе факты и эталон разъедутся" + ) + + extends = raw.get("extends") + if extends: + base_path = root / extends + if not base_path.exists(): + raise ScenarioError(f"{path.name}: чек-лист {extends} не найден") + base = _read_yaml(base_path).get("checklist", []) + raw["checklist"] = [ + item.model_dump(exclude_none=True) + for item in _merge_checklist(base, raw.get("checklist", [])) + ] + + try: + scenario = Scenario.model_validate(raw) + except ValidationError as exc: + first = exc.errors()[0] + where = ".".join(str(part) for part in first["loc"]) + raise ScenarioError(f"{path.name}: {where} — {first['msg']}") from exc + + known = scenario.fact_ids() + for item in scenario.checklist: + if item.fact and item.fact not in known: + raise ScenarioError( + f"{path.name}: пункт {item.id} ссылается на факт {item.fact}, которого нет" + ) + if not item.question: + raise ScenarioError(f"{path.name}: у пункта {item.id} нет текста вопроса") + + for fact in scenario.facts: + question_id = fact.reveal_on.question if fact.reveal_on else None + if question_id and question_id not in {item.id for item in scenario.checklist}: + raise ScenarioError( + f"{path.name}: факт {fact.id} раскрывается вопросом {question_id}, " + "которого нет в чек-листе" + ) + + return _derive_ground_truth(scenario) + + +def load_library(root: Path) -> list[Scenario]: + """Все сценарии каталога. Подкаталог `checklists/` — не сценарии.""" + if not root.exists(): + raise ScenarioError(f"каталог сценариев не найден: {root}") + + scenarios: list[Scenario] = [] + seen: dict[str, Path] = {} + for path in sorted(root.glob("*.yaml")): + scenario = load_file(path, root) + if scenario.id in seen: + raise ScenarioError(f"{path.name}: id {scenario.id} уже занят {seen[scenario.id].name}") + seen[scenario.id] = path + scenarios.append(scenario) + + if not scenarios: + raise ScenarioError(f"в {root} нет ни одного сценария") + return scenarios diff --git a/backend/app/scenarios/schema.py b/backend/app/scenarios/schema.py new file mode 100644 index 0000000..778e433 --- /dev/null +++ b/backend/app/scenarios/schema.py @@ -0,0 +1,125 @@ +"""Схема сценария. Формат: docs/spec/SCENARIO-FORMAT.md + +Сценарий — контент, а не код: его пишет методист, и читается он глазами. +Поэтому схема строгая (`extra="forbid"`): опечатка в имени поля должна падать +на старте приложения, а не тихо игнорироваться и всплывать посреди занятия. +""" + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.domain.classifiers import DDSCode, IncidentType, Level +from app.domain.events import Mood + + +class Strict(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class ArcStage(Strict): + stage: str + mood: Mood + + +class Persona(Strict): + base: str + arc: list[ArcStage] = [] + + +class Background(Strict): + loop: str + gain_db: float = -18 + + +class RevealOn(Strict): + """Два вида условий: вопрос из чек-листа либо подход оператора.""" + + question: str | None = None + approach: str | None = None + + @model_validator(mode="after") + def exactly_one(self): + if bool(self.question) == bool(self.approach): + raise ValueError("reveal_on: ровно одно из `question` или `approach`") + return self + + +class Fact(Strict): + id: str + value: str + hidden: bool = False + reveal_on: RevealOn | None = None + + @model_validator(mode="after") + def hidden_needs_condition(self): + if self.hidden and (self.reveal_on is None or not self.reveal_on.approach): + raise ValueError( + f"факт {self.id}: hidden требует reveal_on.approach — " + "скрытый факт не раскрывается прямым вопросом" + ) + return self + + +class ChecklistItem(Strict): + id: str + question: str | None = None + fact: str | None = None + + +class EraGlonass(Strict): + vin: str + coords: dict + passengers: int + impact_force: str + + +class Tree(Strict): + pregenerated: bool = False + + +class GroundTruth(Strict): + """Выводится кодом. В YAML допускаются только нормализованные ожидания + (адрес и число пострадавших): вывести «улица Ленина, 14» из фразы + «улица Ленина, 14, квартира 47, 5-й этаж» кодом нельзя, а сверять оценку + с сырым текстом факта — значит штрафовать курсанта за правильный ответ. + + Всё остальное загрузчик проставляет сам и запрещает писать руками — + иначе генератор сценариев рассинхронизирует факты и эталон. + """ + + incident_type: IncidentType | None = None + dds: DDSCode | None = None + required_facts: list[str] = [] + address: str | None = None + victims: int | None = None + + +class Scenario(Strict): + id: str + title: str + type: IncidentType + level: Level + topics: list[str] = [] + modes: list[str] = ["training"] + extends: str | None = None + + persona: Persona + background: Background | None = None + first_line: str + + facts: list[Fact] = [] + checklist: list[ChecklistItem] = [] + required_fields: list[str] = Field(default_factory=list) + ground_truth: GroundTruth = GroundTruth() + era_glonass: EraGlonass | None = None + tree: Tree = Tree() + + @model_validator(mode="after") + def era_only_for_era_type(self): + if self.era_glonass is not None and self.type is not IncidentType.ERA_GLONASS: + raise ValueError("era_glonass задан, но type не era_glonass") + if self.type is IncidentType.ERA_GLONASS and self.era_glonass is None: + raise ValueError("type era_glonass требует блок era_glonass") + return self + + def fact_ids(self) -> set[str]: + return {fact.id for fact in self.facts} diff --git a/backend/app/scenarios/store.py b/backend/app/scenarios/store.py new file mode 100644 index 0000000..31a4d83 --- /dev/null +++ b/backend/app/scenarios/store.py @@ -0,0 +1,53 @@ +"""Библиотека в памяти процесса и её заливка в БД. + +В памяти живёт то, что читает голосовой контур и оценка; в БД — то, из чего +преподаватель выбирает сценарий и что переживает перезапуск. +""" + +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import Scenario as ScenarioRow +from app.scenarios.loader import load_library +from app.scenarios.schema import Scenario + +_library: dict[str, Scenario] = {} + + +def set_library(scenarios: list[Scenario]) -> None: + _library.clear() + _library.update({scenario.id: scenario for scenario in scenarios}) + + +def get(scenario_id: str) -> Scenario | None: + return _library.get(scenario_id) + + +def all_scenarios() -> list[Scenario]: + return list(_library.values()) + + +def load_from_disk(root: Path) -> list[Scenario]: + scenarios = load_library(root) + set_library(scenarios) + return scenarios + + +async def seed(db: AsyncSession, scenarios: list[Scenario]) -> int: + """Залить библиотеку в БД. Повторный запуск обновляет, а не дублирует.""" + for scenario in scenarios: + row = await db.scalar(select(ScenarioRow).where(ScenarioRow.id == scenario.id)) + payload = scenario.model_dump(mode="json") + if row is None: + row = ScenarioRow(id=scenario.id) + db.add(row) + row.title = scenario.title + row.incident_type = scenario.type.value + row.level = scenario.level.value + row.topics = scenario.topics + row.modes = scenario.modes + row.body = payload + await db.commit() + return len(scenarios) diff --git a/backend/scripts/seed.py b/backend/scripts/seed.py new file mode 100644 index 0000000..f844792 --- /dev/null +++ b/backend/scripts/seed.py @@ -0,0 +1,30 @@ +"""make seed: сценарии из /scenarios в БД.""" + +import asyncio +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.db.base import get_sessionmaker # noqa: E402 +from app.scenarios import store # noqa: E402 +from app.scenarios.loader import ScenarioError # noqa: E402 + +LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" + + +async def main() -> None: + try: + scenarios = store.load_from_disk(LIBRARY) + except ScenarioError as exc: + print(f"библиотека не прошла проверку: {exc}") + raise SystemExit(1) from exc + + async with get_sessionmaker()() as db: + count = await store.seed(db, scenarios) + print(f"залито сценариев: {count}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/tests/test_db.py b/backend/tests/test_db.py new file mode 100644 index 0000000..a85a75e --- /dev/null +++ b/backend/tests/test_db.py @@ -0,0 +1,113 @@ +"""Журнал сессий. Тесты идут против живой базы из `make dev`; +если её нет — пропускаются, чтобы `make test` оставался запускаемым везде. +""" + +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest +from sqlalchemy import delete, text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.config import get_settings +from app.db import repo +from app.db.models import Group, Scenario, Session, Trainee + + +@pytest.fixture +async def db(): + """Свой движок на каждый тест: глобальный в app.db.base кэшируется и + привязывается к первому событийному циклу, а pytest даёт новый на каждый тест.""" + engine = create_async_engine(get_settings().database_url, poolclass=None) + try: + async with engine.connect() as probe: + await probe.execute(text("select 1")) + except Exception as exc: # noqa: BLE001 — важен факт недоступности, не причина + await engine.dispose() + pytest.skip(f"Postgres недоступен ({type(exc).__name__}) — подними `make dev`") + + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +@pytest.fixture +async def scenario(db): + row = Scenario( + id=f"test-{uuid4().hex[:8]}", + title="Тестовый", + incident_type="fire", + level="L1", + topics=[], + modes=["training"], + body={}, + ) + db.add(row) + await db.commit() + yield row + await db.execute(delete(Session).where(Session.scenario_id == row.id)) + await db.execute(delete(Scenario).where(Scenario.id == row.id)) + await db.commit() + + +async def test_attempts_count_up(db, scenario): + """Дельта попыток — измеримый цикл: разбор → повтор → дельта.""" + group = await repo.ensure_group(db, f"группа-{uuid4().hex[:6]}") + trainee = await repo.ensure_trainee(db, f"курсант-{uuid4().hex[:6]}", group) + + first = await repo.create_session( + db, scenario_id=scenario.id, mode="training", trainee_id=trainee.id, group_id=group.id + ) + second = await repo.create_session( + db, scenario_id=scenario.id, mode="exam", trainee_id=trainee.id, group_id=group.id + ) + + assert (first.attempt, second.attempt) == (1, 2) + assert second.group_id == group.id, "группа размечается с первой миграции" + + await db.execute(delete(Trainee).where(Trainee.id == trainee.id)) + await db.execute(delete(Group).where(Group.id == group.id)) + await db.commit() + + +async def test_transcript_keeps_order_and_anchors(db, scenario): + session = await repo.create_session(db, scenario_id=scenario.id, mode="training") + at = datetime.now(timezone.utc) + + await repo.append_utterance( + db, session_id=session.id, ref="u1", speaker="caller", text="Алло! Горим!", at=at, mood="panic" + ) + await repo.append_utterance( + db, session_id=session.id, ref="u2", speaker="operator", text="Назовите адрес", at=at + ) + + rows = await repo.transcript(db, session.id) + assert [row.ref for row in rows] == ["u1", "u2"] + assert rows[0].mood == "panic" + + +async def test_history_filters_by_mode(db, scenario): + await repo.create_session(db, scenario_id=scenario.id, mode="training") + await repo.create_session(db, scenario_id=scenario.id, mode="exam") + + exams = await repo.history(db, mode="exam") + assert exams, "контрольные сессии не нашлись" + assert all(row.mode == "exam" for row in exams) + + +async def test_hints_are_logged(db, scenario): + """Счёт подсказок — материал разбора, а не вычитаемое из баллов, + но он обязан быть в журнале.""" + session = await repo.create_session(db, scenario_id=scenario.id, mode="training") + await repo.record_hint( + db, + session_id=session.id, + checklist_id="q_people", + question="Есть ли люди в помещении?", + at=datetime.now(timezone.utc), + ) + count = await db.scalar( + text("select count(*) from hint_uses where session_id = :sid").bindparams(sid=session.id) + ) + assert count == 1 diff --git a/backend/tests/test_scenarios.py b/backend/tests/test_scenarios.py new file mode 100644 index 0000000..95748b2 --- /dev/null +++ b/backend/tests/test_scenarios.py @@ -0,0 +1,102 @@ +"""Загрузчик библиотеки. Сломанный сценарий обязан падать на старте +с сообщением, понятным методисту, а не трассировкой pydantic. +""" + +from pathlib import Path + +import pytest + +from app.scenarios.loader import ScenarioError, load_file, load_library + +LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" + +VALID = """ +id: t-1 +title: "Проверочный" +type: fire +level: L1 +persona: { base: calm } +first_line: "Алло" +facts: + - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr } } +checklist: + - { id: q_addr, question: "Адрес?", fact: f_addr } +ground_truth: + address: "Ленина, 1" +""" + + +def write(tmp_path: Path, body: str, name: str = "t-1.yaml") -> Path: + path = tmp_path / name + path.write_text(body, encoding="utf-8") + return path + + +def test_library_loads(): + scenarios = load_library(LIBRARY) + assert scenarios, "библиотека пуста" + assert all(s.ground_truth.dds for s in scenarios), "ДДС не выведен" + + +def test_extends_inherits_whole_checklist(): + """Общий чек-лист по классификатору наследуется целиком, + локальные пункты дополняют его.""" + scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) + ids = [item.id for item in scenario.checklist] + assert "q_caller" in ids, "пункт из общего чек-листа потерялся" + assert all(item.question for item in scenario.checklist), "пункт без текста вопроса" + + +def test_ground_truth_is_derived_not_written(): + scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) + assert scenario.ground_truth.incident_type.value == "fire" + assert scenario.ground_truth.dds.value == "01" + assert scenario.ground_truth.required_facts, "обязательные факты не собраны" + + +def test_derived_fields_in_yaml_are_rejected(tmp_path): + """Иначе генератор сценариев разведёт факты и эталон, и курсанта + оштрафуют за правильный ответ.""" + path = write(tmp_path, VALID + ' dds: "03"\n') + with pytest.raises(ScenarioError, match="выводится кодом"): + load_file(path, tmp_path) + + +def test_broken_yaml_names_the_file(tmp_path): + path = write(tmp_path, "id: [не закрыт\n") + with pytest.raises(ScenarioError, match="битый YAML"): + load_file(path, tmp_path) + + +def test_hidden_fact_without_approach_is_rejected(tmp_path): + body = VALID.replace( + ' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr } }', + ' - { id: f_addr, value: "Ленина, 1", hidden: true, reveal_on: { question: q_addr } }', + ) + with pytest.raises(ScenarioError, match="hidden требует"): + load_file(write(tmp_path, body), tmp_path) + + +def test_checklist_pointing_at_missing_fact_is_rejected(tmp_path): + body = VALID.replace("fact: f_addr }", "fact: f_нет }") + with pytest.raises(ScenarioError, match="которого нет"): + load_file(write(tmp_path, body), tmp_path) + + +def test_typo_in_field_name_is_rejected(tmp_path): + """Схема строгая: опечатка должна падать на старте, а не игнорироваться.""" + body = VALID.replace("level: L1", "level: L1\nfirst_lines: 'опечатка'") + with pytest.raises(ScenarioError): + load_file(write(tmp_path, body), tmp_path) + + +def test_era_block_requires_era_type(tmp_path): + body = VALID + """ +era_glonass: + vin: "X" + coords: { lat: 1, lon: 2 } + passengers: 1 + impact_force: "сильный" +""" + with pytest.raises(ScenarioError, match="era_glonass"): + load_file(write(tmp_path, body), tmp_path) diff --git a/scenarios/fire-apartment-l2.yaml b/scenarios/fire-apartment-l2.yaml index 79c6780..93f2a2a 100644 --- a/scenarios/fire-apartment-l2.yaml +++ b/scenarios/fire-apartment-l2.yaml @@ -39,7 +39,6 @@ facts: - id: f_gas value: "плита газовая, вентиль в кухне, перекрыть не успел" reveal_on: { question: q_gas } - hidden: true checklist: - { id: q_address, fact: f_address } @@ -57,10 +56,9 @@ required_fields: - description ground_truth: - # Выводится из фактов кодом при загрузке, здесь — для наглядности примера. - incident_type: fire - dds: "01" - required_facts: [f_address, f_what_burns, f_people] + # incident_type, dds и required_facts выводит загрузчик — писать их здесь запрещено. + # Руками задаются только нормализованные ожидания, которые из текста факта + # кодом не достать. address: "улица Ленина, 14" victims: 2