"""Библиотека в памяти процесса и её заливка в БД. В памяти живёт то, что читает голосовой контур и оценка; в БД — то, из чего преподаватель выбирает сценарий и что переживает перезапуск. """ from collections.abc import Callable 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 import editor from app.scenarios.loader import load_library from app.scenarios.schema import Scenario _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] = {} BeforeCommit = Callable[[AsyncSession, ScenarioRow], None] 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 register_owned_scenario(scenario: Scenario, owner_login: str) -> None: """Обновить runtime-библиотеку после публикации модерируемого сценария.""" _library[scenario.id] = scenario _demo_scenario_owners[scenario.id] = owner_login 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 # Shipped YAML remains the canonical source for base cards, but a # published instructor-owned row may have changed on another process. already_loaded = row.id in _library if already_loaded and row.owner_login is None: continue _library[row.id] = Scenario.model_validate(row.body) if not already_loaded: delta += 1 return delta async def published_catalog( db: AsyncSession, scenario_ids: list[str], owner_login: str | None, ) -> tuple[dict[str, Scenario], set[str]]: """Resolve startable scenarios from shared DB, including a peer's cache misses. Process-local memory remains a fallback only for the shipped library. Any database row is authoritative: archived/pending rows never fall back to a stale in-memory copy, and instructor-owned rows stay private across nodes. """ ids = set(scenario_ids) if not ids: return {}, set() rows = await db.scalars(select(ScenarioRow).where(ScenarioRow.id.in_(ids))) by_id = {row.id: row for row in rows} scenarios: dict[str, Scenario] = {} hidden: set[str] = set() for scenario_id, row in by_id.items(): if row.owner_login and row.owner_login != owner_login: hidden.add(scenario_id) continue if row.status == "published": scenario = Scenario.model_validate(row.body) scenarios[scenario_id] = scenario # Refresh a stale process-local version from the authoritative row. _library[scenario_id] = scenario for scenario_id in ids - by_id.keys(): scenario = _library.get(scenario_id) # A process-local owner marker without a durable row is not publishable. if scenario is not None and scenario_id not in _demo_scenario_owners: scenarios[scenario_id] = scenario return scenarios, hidden 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 scenario_ids_owned_by_other(db: AsyncSession | None, owner_login: str) -> set[str]: """Hide another instructor's private scenarios from this instructor's bank.""" if db is None: return { scenario_id for scenario_id, owner in _demo_scenario_owners.items() if owner != owner_login } rows = await db.scalars(select(ScenarioRow.id).where( ScenarioRow.owner_login.is_not(None), ScenarioRow.owner_login != owner_login, ScenarioRow.status == "published", )) return {value if isinstance(value, str) else value.id for value in rows} async def archive( db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None, before_commit: BeforeCommit | 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" if before_commit is not None: before_commit(db, row) 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, before_commit: BeforeCommit | 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" if before_commit is not None: before_commit(db, row) 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, before_commit: BeforeCommit | 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) if before_commit is not None: before_commit(db, 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, *, before_commit: BeforeCommit | None = None, ) -> ScenarioRow: row.body = editor.merge_patch(row.body, patch) row.title = str(row.body.get("title") or "")[:200] row.manual_edit_pending = True row.grammar_check_hash = None if db is not None: if before_commit is not None: before_commit(db, row) await db.commit() return row async def revise_draft( db: AsyncSession | None, row: ScenarioRow, proposal: dict, *, before_commit: BeforeCommit | None = None, ) -> 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"] row.manual_edit_pending = False row.grammar_check_hash = None if db is not None: if before_commit is not None: before_commit(db, row) await db.commit() return row async def approve_draft( db: AsyncSession | None, row: ScenarioRow, *, before_commit: BeforeCommit | None = None, ) -> 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: if before_commit is not None: before_commit(db, row) await db.commit() publish(scenario) return scenario