lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
This commit is contained in:
parent
59d5a6a26d
commit
6b2c96bab6
22 changed files with 1380 additions and 8 deletions
39
backend/alembic.ini
Normal file
39
backend/alembic.ini
Normal file
|
|
@ -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
|
||||
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/http/__init__.py
Normal file
0
backend/app/api/http/__init__.py
Normal file
47
backend/app/api/http/scenarios.py
Normal file
47
backend/app/api/http/scenarios.py
Normal file
|
|
@ -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
|
||||
93
backend/app/api/http/sessions.py
Normal file
93
backend/app/api/http/sessions.py
Normal file
|
|
@ -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]
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
36
backend/app/db/base.py
Normal file
36
backend/app/db/base.py
Normal file
|
|
@ -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
|
||||
54
backend/app/db/migrations/env.py
Normal file
54
backend/app/db/migrations/env.py
Normal file
|
|
@ -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())
|
||||
23
backend/app/db/migrations/script.py.mako
Normal file
23
backend/app/db/migrations/script.py.mako
Normal file
|
|
@ -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"}
|
||||
|
|
@ -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 ###
|
||||
208
backend/app/db/models.py
Normal file
208
backend/app/db/models.py
Normal file
|
|
@ -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())
|
||||
135
backend/app/db/repo.py
Normal file
135
backend/app/db/repo.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
0
backend/app/scenarios/__init__.py
Normal file
0
backend/app/scenarios/__init__.py
Normal file
124
backend/app/scenarios/loader.py
Normal file
124
backend/app/scenarios/loader.py
Normal file
|
|
@ -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
|
||||
125
backend/app/scenarios/schema.py
Normal file
125
backend/app/scenarios/schema.py
Normal file
|
|
@ -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}
|
||||
53
backend/app/scenarios/store.py
Normal file
53
backend/app/scenarios/store.py
Normal file
|
|
@ -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)
|
||||
30
backend/scripts/seed.py
Normal file
30
backend/scripts/seed.py
Normal file
|
|
@ -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())
|
||||
113
backend/tests/test_db.py
Normal file
113
backend/tests/test_db.py
Normal file
|
|
@ -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
|
||||
102
backend/tests/test_scenarios.py
Normal file
102
backend/tests/test_scenarios.py
Normal file
|
|
@ -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)
|
||||
Loading…
Reference in a new issue