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 pathlib import Path
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from uuid import uuid4
|
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 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
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from app.scenarios import editor
|
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
|
|
|
|
|
|
|
|
|
|
_library: dict[str, Scenario] = {}
|
2026-09-24 01:10:49 +03:00
|
|
|
|
_demo_drafts: dict[str, ScenarioRow] = {}
|
|
|
|
|
|
_demo_archived: dict[str, Scenario] = {}
|
|
|
|
|
|
_demo_scenario_owners: dict[str, str] = {}
|
|
|
|
|
|
_demo_archived_owners: dict[str, str] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def reset_demo_drafts() -> None:
|
|
|
|
|
|
"""Черновики и архив demo-lite живут только в текущем процессе."""
|
|
|
|
|
|
_demo_drafts.clear()
|
|
|
|
|
|
_demo_archived.clear()
|
|
|
|
|
|
_demo_scenario_owners.clear()
|
|
|
|
|
|
_demo_archived_owners.clear()
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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())
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
def publish(scenario: Scenario) -> None:
|
|
|
|
|
|
"""Сделать утверждённую версию доступной запуску занятия немедленно."""
|
|
|
|
|
|
_library[scenario.id] = scenario
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
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
|
2026-09-24 01:10:49 +03:00
|
|
|
|
row.owner_login = 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
|
|
|
|
await db.commit()
|
|
|
|
|
|
return len(scenarios)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def restore_published(db: AsyncSession) -> int:
|
|
|
|
|
|
"""После старта вернуть утверждённые преподавателем сценарии из БД.
|
|
|
|
|
|
|
|
|
|
|
|
Вызвать после ``load_from_disk``. Базовую библиотеку не перезаписываем:
|
|
|
|
|
|
её источником остаются проверенные YAML из репозитория.
|
|
|
|
|
|
"""
|
2026-09-24 01:10:49 +03:00
|
|
|
|
rows = await db.scalars(select(ScenarioRow).where(
|
|
|
|
|
|
ScenarioRow.status.in_(["published", "archived"])
|
|
|
|
|
|
))
|
|
|
|
|
|
delta = 0
|
2026-09-21 17:40:54 +03:00
|
|
|
|
for row in rows:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if row.status == "archived":
|
|
|
|
|
|
if row.owner_login:
|
|
|
|
|
|
_demo_scenario_owners.pop(row.id, None)
|
|
|
|
|
|
_demo_archived_owners[row.id] = row.owner_login
|
|
|
|
|
|
if _library.pop(row.id, None) is not None:
|
|
|
|
|
|
delta -= 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
if row.owner_login:
|
|
|
|
|
|
_demo_scenario_owners[row.id] = row.owner_login
|
2026-09-21 17:40:54 +03:00
|
|
|
|
if row.id in _library:
|
|
|
|
|
|
continue
|
|
|
|
|
|
_library[row.id] = Scenario.model_validate(row.body)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
delta += 1
|
|
|
|
|
|
return delta
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def owned_scenario_ids(db: AsyncSession | None, owner_login: str) -> set[str]:
|
|
|
|
|
|
"""IDs the current instructor may edit/archive; base and legacy rows are read-only."""
|
|
|
|
|
|
if db is None:
|
|
|
|
|
|
return {
|
|
|
|
|
|
scenario_id for scenario_id, owner in _demo_scenario_owners.items()
|
|
|
|
|
|
if owner == owner_login and scenario_id in _library
|
|
|
|
|
|
}
|
|
|
|
|
|
rows = await db.scalars(select(ScenarioRow.id).where(
|
|
|
|
|
|
ScenarioRow.owner_login == owner_login,
|
|
|
|
|
|
ScenarioRow.status == "published",
|
|
|
|
|
|
))
|
|
|
|
|
|
values = set(rows)
|
|
|
|
|
|
# Test doubles and alternate SQLAlchemy result wrappers may return rows
|
|
|
|
|
|
# for a scalar selection; normalize them without leaking owner data.
|
|
|
|
|
|
return {value if isinstance(value, str) else value.id for value in values}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def archive(
|
|
|
|
|
|
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
|
|
|
|
|
|
) -> Scenario | None:
|
|
|
|
|
|
"""Скрыть опубликованный сценарий без удаления истории и внешних ключей."""
|
|
|
|
|
|
scenario = _library.get(scenario_id)
|
|
|
|
|
|
if scenario is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if db is None:
|
|
|
|
|
|
owner = _demo_scenario_owners.get(scenario_id)
|
|
|
|
|
|
if owner_login is not None and owner != owner_login:
|
|
|
|
|
|
return None
|
|
|
|
|
|
_demo_archived[scenario_id] = scenario
|
|
|
|
|
|
if owner is not None:
|
|
|
|
|
|
_demo_archived_owners[scenario_id] = owner
|
|
|
|
|
|
_demo_scenario_owners.pop(scenario_id, None)
|
|
|
|
|
|
else:
|
|
|
|
|
|
row = await db.get(ScenarioRow, scenario_id)
|
|
|
|
|
|
if owner_login is not None and (row is None or row.owner_login != owner_login):
|
|
|
|
|
|
return None
|
|
|
|
|
|
if row is None:
|
|
|
|
|
|
row = ScenarioRow(
|
|
|
|
|
|
id=scenario.id,
|
|
|
|
|
|
title=scenario.title,
|
|
|
|
|
|
incident_type=scenario.type.value,
|
|
|
|
|
|
level=scenario.level.value,
|
|
|
|
|
|
topics=scenario.topics,
|
|
|
|
|
|
modes=scenario.modes,
|
|
|
|
|
|
body=scenario.model_dump(mode="json"),
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(row)
|
|
|
|
|
|
row.status = "archived"
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
_library.pop(scenario_id, None)
|
|
|
|
|
|
return scenario
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def restore_archived(
|
|
|
|
|
|
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
|
|
|
|
|
|
) -> Scenario | None:
|
|
|
|
|
|
"""Вернуть мягко удалённый сценарий в библиотеку назначения."""
|
|
|
|
|
|
if db is None:
|
|
|
|
|
|
owner = _demo_archived_owners.get(scenario_id)
|
|
|
|
|
|
if owner_login is not None and owner != owner_login:
|
|
|
|
|
|
return None
|
|
|
|
|
|
scenario = _demo_archived.pop(scenario_id, None)
|
|
|
|
|
|
if scenario is not None and owner is not None:
|
|
|
|
|
|
_demo_scenario_owners[scenario_id] = owner
|
|
|
|
|
|
_demo_archived_owners.pop(scenario_id, None)
|
|
|
|
|
|
else:
|
|
|
|
|
|
row = await db.get(ScenarioRow, scenario_id)
|
|
|
|
|
|
if (row is None or row.status != "archived"
|
|
|
|
|
|
or owner_login is not None and row.owner_login != owner_login):
|
|
|
|
|
|
return None
|
|
|
|
|
|
# В опубликованном body уже лежат вычисленные поля ground_truth (ЕКП,
|
|
|
|
|
|
# ДДС, список оповещения). Редакторская validate предназначена для
|
|
|
|
|
|
# черновика до вычисления и закономерно запрещает такие поля.
|
|
|
|
|
|
scenario = Scenario.model_validate(row.body)
|
|
|
|
|
|
row.status = "published"
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
if scenario is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
publish(scenario)
|
|
|
|
|
|
return scenario
|
2026-09-21 17:40:54 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def create_draft(
|
2026-09-24 01:10:49 +03:00
|
|
|
|
db: AsyncSession | None, *, source: Scenario, title: str | None = None,
|
|
|
|
|
|
proposal: dict | None = None, full_proposal: dict | None = None,
|
|
|
|
|
|
owner_login: str | None = None,
|
2026-09-21 17:40:54 +03:00
|
|
|
|
) -> ScenarioRow:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if proposal is not None and full_proposal is not None:
|
|
|
|
|
|
raise ValueError("нельзя одновременно передать вариацию и полный сюжет")
|
|
|
|
|
|
prefix = "ai-full" if full_proposal is not None else "ai" if proposal is not None else "draft"
|
|
|
|
|
|
draft_id = f"{prefix}-{uuid4().hex}"
|
|
|
|
|
|
if full_proposal is not None:
|
|
|
|
|
|
from app.scenarios.generation import full_proposal_body
|
|
|
|
|
|
|
|
|
|
|
|
body = full_proposal_body(source, draft_id, full_proposal)
|
|
|
|
|
|
elif proposal is None:
|
|
|
|
|
|
body = editor.template_copy(source, draft_id, title)
|
|
|
|
|
|
else:
|
|
|
|
|
|
from app.scenarios.generation import proposal_body
|
|
|
|
|
|
|
|
|
|
|
|
body = proposal_body(source, draft_id, proposal)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
row = ScenarioRow(
|
|
|
|
|
|
id=draft_id,
|
|
|
|
|
|
title=body["title"],
|
|
|
|
|
|
incident_type=body["type"],
|
|
|
|
|
|
level=body["level"],
|
|
|
|
|
|
topics=body["topics"],
|
|
|
|
|
|
modes=body["modes"],
|
|
|
|
|
|
status="draft",
|
2026-09-24 01:10:49 +03:00
|
|
|
|
owner_login=owner_login,
|
2026-09-21 17:40:54 +03:00
|
|
|
|
body=body,
|
|
|
|
|
|
)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if db is None:
|
|
|
|
|
|
_demo_drafts[row.id] = row
|
|
|
|
|
|
else:
|
|
|
|
|
|
db.add(row)
|
|
|
|
|
|
await db.commit()
|
2026-09-21 17:40:54 +03:00
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
async def draft(
|
|
|
|
|
|
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
|
|
|
|
|
|
) -> ScenarioRow | None:
|
|
|
|
|
|
row = _demo_drafts.get(scenario_id) if db is None else await db.get(ScenarioRow, scenario_id)
|
|
|
|
|
|
if row is None or row.status != "draft":
|
|
|
|
|
|
return None
|
|
|
|
|
|
if owner_login is not None and row.owner_login != owner_login:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return row
|
2026-09-21 17:40:54 +03:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
async def update_draft(db: AsyncSession | None, row: ScenarioRow, patch: dict) -> ScenarioRow:
|
2026-09-21 17:40:54 +03:00
|
|
|
|
row.body = editor.merge_patch(row.body, patch)
|
|
|
|
|
|
row.title = str(row.body.get("title") or "")[:200]
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if db is not None:
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def revise_draft(db: AsyncSession | None, row: ScenarioRow, proposal: dict) -> ScenarioRow:
|
|
|
|
|
|
"""Заменить сюжетную версию того же черновика после комментария преподавателя."""
|
|
|
|
|
|
from app.scenarios.generation import proposal_body
|
|
|
|
|
|
|
|
|
|
|
|
source = editor.validate(row.body)
|
|
|
|
|
|
row.body = proposal_body(source, row.id, proposal)
|
|
|
|
|
|
row.title = row.body["title"]
|
|
|
|
|
|
row.incident_type = row.body["type"]
|
|
|
|
|
|
row.level = row.body["level"]
|
|
|
|
|
|
row.topics = row.body["topics"]
|
|
|
|
|
|
row.modes = row.body["modes"]
|
|
|
|
|
|
if db is not None:
|
|
|
|
|
|
await db.commit()
|
2026-09-21 17:40:54 +03:00
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
async def approve_draft(db: AsyncSession | None, row: ScenarioRow) -> Scenario:
|
2026-09-21 17:40:54 +03:00
|
|
|
|
scenario = editor.validate(row.body)
|
|
|
|
|
|
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 = scenario.model_dump(mode="json")
|
|
|
|
|
|
row.status = "published"
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if db is None:
|
|
|
|
|
|
_demo_drafts.pop(row.id, None)
|
|
|
|
|
|
if row.owner_login is not None:
|
|
|
|
|
|
_demo_scenario_owners[row.id] = row.owner_login
|
|
|
|
|
|
else:
|
|
|
|
|
|
await db.commit()
|
2026-09-21 17:40:54 +03:00
|
|
|
|
publish(scenario)
|
|
|
|
|
|
return scenario
|