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
10
Makefile
10
Makefile
|
|
@ -34,7 +34,13 @@ models: ## Скачать веса GigaAM (STT) и Silero (TTS) в backend/model
|
||||||
@echo "не реализовано — карточка tasks/lct-02-latency-baseline.md"; exit 1
|
@echo "не реализовано — карточка tasks/lct-02-latency-baseline.md"; exit 1
|
||||||
|
|
||||||
seed: ## Залить сценарии из /scenarios в БД
|
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: ## Текстовый диалог со звонящим без голоса
|
repl: ## Текстовый диалог со звонящим без голоса
|
||||||
@echo "не реализовано — карточка tasks/lct-07-caller-slots.md"; exit 1
|
@echo "не реализовано — карточка tasks/lct-07-caller-slots.md"; exit 1
|
||||||
|
|
@ -45,4 +51,4 @@ pregen: ## Дерево диалога и WAV первых реплик для
|
||||||
demo: ## Поднять всё в демо-режиме: офлайн, прогретые модели
|
demo: ## Поднять всё в демо-режиме: офлайн, прогретые модели
|
||||||
@echo "не реализовано — карточка tasks/lct-21-demo-readiness.md"; exit 1
|
@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
|
||||||
|
|
|
||||||
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 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.config import get_settings
|
||||||
|
from app.scenarios import store
|
||||||
|
from app.scenarios.loader import ScenarioError
|
||||||
|
|
||||||
|
|
||||||
|
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
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
|
app.state.models_ready = False
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Учебный симулятор занятия для системы 112", lifespan=lifespan)
|
app = FastAPI(title="Учебный симулятор занятия для системы 112", lifespan=lifespan)
|
||||||
|
app.include_router(sessions.router)
|
||||||
|
app.include_router(scenarios_api.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|
@ -24,5 +43,6 @@ async def health() -> dict:
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"models_ready": getattr(app.state, "models_ready", False),
|
"models_ready": getattr(app.state, "models_ready", False),
|
||||||
|
"scenarios_loaded": getattr(app.state, "scenarios_loaded", 0),
|
||||||
"offline": settings.offline,
|
"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)
|
||||||
|
|
@ -39,7 +39,6 @@ facts:
|
||||||
- id: f_gas
|
- id: f_gas
|
||||||
value: "плита газовая, вентиль в кухне, перекрыть не успел"
|
value: "плита газовая, вентиль в кухне, перекрыть не успел"
|
||||||
reveal_on: { question: q_gas }
|
reveal_on: { question: q_gas }
|
||||||
hidden: true
|
|
||||||
|
|
||||||
checklist:
|
checklist:
|
||||||
- { id: q_address, fact: f_address }
|
- { id: q_address, fact: f_address }
|
||||||
|
|
@ -57,10 +56,9 @@ required_fields:
|
||||||
- description
|
- description
|
||||||
|
|
||||||
ground_truth:
|
ground_truth:
|
||||||
# Выводится из фактов кодом при загрузке, здесь — для наглядности примера.
|
# incident_type, dds и required_facts выводит загрузчик — писать их здесь запрещено.
|
||||||
incident_type: fire
|
# Руками задаются только нормализованные ожидания, которые из текста факта
|
||||||
dds: "01"
|
# кодом не достать.
|
||||||
required_facts: [f_address, f_what_burns, f_people]
|
|
||||||
address: "улица Ленина, 14"
|
address: "улица Ленина, 14"
|
||||||
victims: 2
|
victims: 2
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue