"""Port хранилища живого занятия. Операция курсанта или преподавателя фиксируется одним `commit`: проверка владения `(node_id, epoch)`, снимок состояния и append-only строки — в одной транзакции. Хаб публикует события только после успешного `commit`; сбой отбрасывает их и закрывает занятие на этом узле (docs/arch/SCALE-OUT.md). Adapter-ы: PostgreSQL (`app.session.pg_store`) и in-memory — для `DEMO_NO_DB` и юнит-тестов. """ from collections.abc import Callable, Sequence from dataclasses import dataclass from datetime import datetime from typing import Protocol from uuid import UUID from app.domain.events import TranscriptEntry from app.session.checkpoint import dump_state from app.session.state import SessionState class SessionLeaseLost(RuntimeError): """This process no longer owns the durable session generation.""" # ── append-only строки операции ── @dataclass(frozen=True) class UtteranceAppended: entry: TranscriptEntry @dataclass(frozen=True) class HintRecorded: checklist_id: str question: str at: datetime @dataclass(frozen=True) class NoteAdded: transcript_ref: str text: str author: str @dataclass(frozen=True) class SelfAssessed: missed: list[str] comment: str at: datetime @dataclass(frozen=True) class LessonStarted: at: datetime @dataclass(frozen=True) class LessonEnded: at: datetime reason: str @dataclass(frozen=True) class ScoreCalculated: score_auto: float report: dict @dataclass(frozen=True) class ScoreArchived: """Полный разбор дополнен после оценки — например, самооценкой курсанта.""" report: dict @dataclass(frozen=True) class ScoreOverridden: score_final: float author: str role: str comment: str Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted | LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden) def apply_score_override(report: dict, override: ScoreOverridden) -> dict: """Правка балла одинакова для живой сессии, строки `Score` и архива. Автооценка остаётся рядом: видно, что скорректировано и кем. Архивный `full_report` правится тем же образом, иначе PDF и история покажут старую оценку. """ fields = { "score_final": override.score_final, "overridden_by": override.author, "override_comment": override.comment, } updated = {**report, **fields} archived = report.get("full_report") if isinstance(archived, dict): updated["full_report"] = { **archived, "score_auto": report.get("score_auto", archived.get("score_auto")), **fields, } return updated @dataclass(frozen=True) class LessonIdentity: """То, что о занятии знает только хранилище: номер попытки, курсант, эпоха.""" attempt: int trainee_id: UUID | None service: str | None fencing_epoch: int @dataclass(frozen=True) class LessonRequest: session_id: UUID scenario_id: str mode: str trainee_name: str | None trainee_id: UUID | None owner_login: str | None backend_node_id: str | None class SessionStore(Protocol): #: Переживает ли занятие перезапуск процесса. Без этого каталог сценариев #: и отчёты берутся только из памяти. persistent: bool async def open( self, request: LessonRequest, build: Callable[[LessonIdentity], SessionState], ) -> SessionState: """Завести занятие, аудит запуска и первый снимок одной транзакцией.""" ... async def commit(self, state: SessionState, records: Sequence[Record] = ()) -> None: """Проверка владения, снимок и строки — одна транзакция. Ошибка — исключение.""" ... async def commit_archived(self, session_id: UUID, records: Sequence[Record]) -> None: """Строки к занятию, которого уже нет в памяти узла (правка балла из истории).""" ... async def restore_active(self) -> list[SessionState]: ... async def renew(self, session_id: UUID) -> None: ... async def claim_expired(self, session_id: UUID | None = None) -> list[SessionState]: ... class MemorySessionStore: """Занятие живёт до перезапуска процесса: `DEMO_NO_DB` и юнит-тесты. Хранит то же, что PostgreSQL, в словарях — тесты читают снимок, строки и оценку тем же путём, что и сквозной сценарий. """ persistent = False def __init__(self) -> None: self.commits: list[tuple[UUID, list[Record]]] = [] self.records: dict[UUID, list[Record]] = {} self.scores: dict[UUID, dict] = {} self._snapshots: dict[UUID, dict | None] = {} async def open( self, request: LessonRequest, build: Callable[[LessonIdentity], SessionState], ) -> SessionState: state = build(LessonIdentity( attempt=1, trainee_id=request.trainee_id, service=None, fencing_epoch=0, )) self.commits.append((request.session_id, [])) self._snapshots[request.session_id] = dump_state(state) return state async def commit(self, state: SessionState, records: Sequence[Record] = ()) -> None: records = list(records) self.commits.append((state.session_id, records)) self._apply(state.session_id, records) self._snapshots[state.session_id] = None if state.ended else dump_state(state) async def commit_archived(self, session_id: UUID, records: Sequence[Record]) -> None: records = list(records) self.commits.append((session_id, records)) self._apply(session_id, records) def _apply(self, session_id: UUID, records: list[Record]) -> None: for record in records: if isinstance(record, ScoreCalculated): self.scores[session_id] = dict(record.report) elif isinstance(record, ScoreArchived): self.scores[session_id] = dict(record.report) elif isinstance(record, ScoreOverridden): if session_id not in self.scores: raise LookupError(f"нет оценки занятия {session_id}") self.scores[session_id] = apply_score_override(self.scores[session_id], record) self.records.setdefault(session_id, []).extend(records) def snapshot(self, session_id: UUID) -> dict | None: return self._snapshots.get(session_id) async def restore_active(self) -> list[SessionState]: return [] async def renew(self, session_id: UUID) -> None: return None async def claim_expired(self, session_id: UUID | None = None) -> list[SessionState]: return []