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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
"""Библиотека сценариев по HTTP.
|
|
|
|
|
|
|
|
|
|
|
|
`GET /api/scenarios/{id}` **не отдаёт** `facts` и `ground_truth`: иначе курсант
|
|
|
|
|
|
откроет DevTools и прочитает адрес до того, как его спросит.
|
|
|
|
|
|
|
|
|
|
|
|
`checklist` скрыт по той же причине и даже более веской: чек-лист — это
|
|
|
|
|
|
содержимое подсказок. Отдать его целиком значит выдать в контрольном режиме
|
|
|
|
|
|
то, чего там не должно быть вовсе, и обойти выдачу по одному пункту
|
|
|
|
|
|
(docs/product/MODES.md#подсказка-по-запросу). Подсказки идут только событием
|
|
|
|
|
|
`hint.shown` из живой сессии, эталонные вопросы — только в разборе.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from typing import Any
|
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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
|
|
from app.api.auth import audit, require
|
2026-09-21 20:10:57 +03:00
|
|
|
|
from app.domain import ekp
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from app.db.base import get_session
|
|
|
|
|
|
from app.domain.roles import Role
|
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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
from app.scenarios import store
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from app.scenarios.editor import validate
|
|
|
|
|
|
from app.scenarios.loader import ScenarioError
|
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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
|
|
|
|
|
|
|
|
|
|
|
|
HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
class TemplateDraftIn(BaseModel):
|
|
|
|
|
|
source_id: str = Field(min_length=1)
|
|
|
|
|
|
title: str | None = Field(default=None, min_length=1, max_length=200)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _draft_out(row) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": row.id,
|
|
|
|
|
|
"status": row.status,
|
|
|
|
|
|
"generation": "template_copy",
|
|
|
|
|
|
"body": row.body,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/drafts/from-template", status_code=201)
|
|
|
|
|
|
async def create_template_draft(
|
|
|
|
|
|
body: TemplateDraftIn, request: Request, db: AsyncSession = Depends(get_session)
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
who = require(request, Role.INSTRUCTOR)
|
|
|
|
|
|
source = store.get(body.source_id)
|
|
|
|
|
|
if source is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="published_source_not_found")
|
|
|
|
|
|
row = await store.create_draft(db, source=source, title=body.title)
|
|
|
|
|
|
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
|
|
|
|
|
|
return _draft_out(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/drafts/{scenario_id}")
|
|
|
|
|
|
async def read_draft(
|
|
|
|
|
|
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
require(request, Role.INSTRUCTOR)
|
|
|
|
|
|
row = await store.draft(db, scenario_id)
|
|
|
|
|
|
if row is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="draft_not_found")
|
|
|
|
|
|
return _draft_out(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/drafts/{scenario_id}")
|
|
|
|
|
|
async def patch_draft(
|
|
|
|
|
|
scenario_id: str,
|
|
|
|
|
|
body: dict[str, Any],
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
db: AsyncSession = Depends(get_session),
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
who = require(request, Role.INSTRUCTOR)
|
|
|
|
|
|
row = await store.draft(db, scenario_id)
|
|
|
|
|
|
if row is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="draft_not_found")
|
|
|
|
|
|
try:
|
|
|
|
|
|
row = await store.update_draft(db, row, body)
|
|
|
|
|
|
except ScenarioError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
|
|
|
|
await audit(who.login, who.role.value, "scenario.draft.update", row.id)
|
|
|
|
|
|
return _draft_out(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/drafts/{scenario_id}/validate")
|
|
|
|
|
|
async def validate_draft(
|
|
|
|
|
|
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
require(request, Role.INSTRUCTOR)
|
|
|
|
|
|
row = await store.draft(db, scenario_id)
|
|
|
|
|
|
if row is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="draft_not_found")
|
|
|
|
|
|
try:
|
|
|
|
|
|
scenario = validate(row.body)
|
|
|
|
|
|
except ScenarioError as exc:
|
|
|
|
|
|
return {"valid": False, "errors": [str(exc)]}
|
|
|
|
|
|
return {
|
|
|
|
|
|
"valid": True,
|
|
|
|
|
|
"errors": [],
|
|
|
|
|
|
"ground_truth": scenario.ground_truth.model_dump(mode="json"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/drafts/{scenario_id}/approve")
|
|
|
|
|
|
async def approve_draft(
|
|
|
|
|
|
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
who = require(request, Role.INSTRUCTOR)
|
|
|
|
|
|
row = await store.draft(db, scenario_id)
|
|
|
|
|
|
if row is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="draft_not_found")
|
|
|
|
|
|
try:
|
|
|
|
|
|
scenario = await store.approve_draft(db, row)
|
|
|
|
|
|
except ScenarioError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
|
|
|
|
await audit(who.login, who.role.value, "scenario.approve", scenario.id)
|
|
|
|
|
|
return {"id": scenario.id, "status": "published", "title": scenario.title}
|
|
|
|
|
|
|
|
|
|
|
|
|
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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
@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,
|
2026-09-21 20:10:57 +03:00
|
|
|
|
"ticket": scenario.ticket,
|
|
|
|
|
|
"position": scenario.position,
|
|
|
|
|
|
"ekp_group": (ekp.incident(scenario.ground_truth.incident_code).group
|
|
|
|
|
|
if scenario.ground_truth.incident_code
|
|
|
|
|
|
and ekp.incident(scenario.ground_truth.incident_code) else None),
|
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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
}
|
|
|
|
|
|
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
|