lct-hack/backend/app/scenarios/store.py
2026-09-24 01:10:49 +03:00

274 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Библиотека в памяти процесса и её заливка в БД.
В памяти живёт то, что читает голосовой контур и оценка; в БД — то, из чего
преподаватель выбирает сценарий и что переживает перезапуск.
"""
from pathlib import Path
from uuid import uuid4
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
from app.scenarios import editor
_library: dict[str, Scenario] = {}
_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()
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 publish(scenario: Scenario) -> None:
"""Сделать утверждённую версию доступной запуску занятия немедленно."""
_library[scenario.id] = scenario
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
row.owner_login = None
await db.commit()
return len(scenarios)
async def restore_published(db: AsyncSession) -> int:
"""После старта вернуть утверждённые преподавателем сценарии из БД.
Вызвать после ``load_from_disk``. Базовую библиотеку не перезаписываем:
её источником остаются проверенные YAML из репозитория.
"""
rows = await db.scalars(select(ScenarioRow).where(
ScenarioRow.status.in_(["published", "archived"])
))
delta = 0
for row in rows:
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
if row.id in _library:
continue
_library[row.id] = Scenario.model_validate(row.body)
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
async def create_draft(
db: AsyncSession | None, *, source: Scenario, title: str | None = None,
proposal: dict | None = None, full_proposal: dict | None = None,
owner_login: str | None = None,
) -> ScenarioRow:
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)
row = ScenarioRow(
id=draft_id,
title=body["title"],
incident_type=body["type"],
level=body["level"],
topics=body["topics"],
modes=body["modes"],
status="draft",
owner_login=owner_login,
body=body,
)
if db is None:
_demo_drafts[row.id] = row
else:
db.add(row)
await db.commit()
return row
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
async def update_draft(db: AsyncSession | None, row: ScenarioRow, patch: dict) -> ScenarioRow:
row.body = editor.merge_patch(row.body, patch)
row.title = str(row.body.get("title") or "")[:200]
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()
return row
async def approve_draft(db: AsyncSession | None, row: ScenarioRow) -> Scenario:
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"
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()
publish(scenario)
return scenario