Complete training workflow and acceptance hardening

This commit is contained in:
andreysk0304 2026-09-26 18:12:27 +03:00 • committed by gglamer
commit 7237265833
243 changed files with 17014 additions and 1500 deletions

View file

@ -7,13 +7,23 @@
import asyncio
import contextlib
import logging
from contextvars import ContextVar, Token
from collections.abc import AsyncIterator, Iterator
from datetime import UTC, datetime
from typing import Protocol
from uuid import UUID
from pydantic import BaseModel
from app.domain.events import CardReceived, Exercise, StationState, TimerTick
from app.domain.events import (
CardReceived,
ErrorEvent,
ErrorKind,
Exercise,
StationState,
TimerTick,
)
from app.session.state import SessionState
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
@ -21,6 +31,15 @@ from app.session.state import SessionState
QUEUE_SIZE = 256
TICK_SECONDS = 1.0
LEASE_FENCED_MESSAGE = "Занятие передано другому backend-узлу; переподключитесь."
log = logging.getLogger(__name__)
def _current_task():
try:
return asyncio.current_task()
except RuntimeError: # synchronous tests and tooling have no running loop
return None
class Journal(Protocol):
@ -29,21 +48,26 @@ class Journal(Protocol):
async def start_lesson(
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
trainee_id: UUID | None = None,
) -> tuple[int, UUID | None, str | None]: ...
trainee_id: UUID | None = None, owner_login: str | None = None,
backend_node_id: str | None = None,
) -> tuple[int, UUID | None, str | None, int] | None: ...
async def utterance(self, session_id: UUID, entry) -> None: ...
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...
async def self_assessment(self, session_id: UUID, missed: list[str], comment: str, at) -> None: ...
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None: ...
async def self_assessment(
self, session_id: UUID, missed: list[str], comment: str, at
) -> bool: ...
async def score(self, session_id: UUID, score_auto: float, report: dict) -> bool: ...
async def score_snapshot(self, session_id: UUID, report: dict) -> None: ...
async def score_override(
self, session_id: UUID, score_final: float, author: str, comment: str,
) -> None: ...
) -> bool: ...
async def session_started(self, session_id: UUID, at) -> None: ...
async def session_ended(self, session_id: UUID, at, reason: str) -> None: ...
async def checkpoint(self, state: SessionState) -> 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 SessionHub:
@ -54,6 +78,9 @@ class SessionHub:
self._trainees: dict[UUID, set[asyncio.Queue]] = {}
self._stations: dict[UUID, set[asyncio.Queue]] = {}
self._tickers: dict[UUID, asyncio.Task] = {}
self._event_batch: ContextVar[dict | None] = ContextVar(
f"session-event-batch-{id(self)}", default=None
)
# ── реестр ──
@ -62,12 +89,47 @@ class SessionHub:
return state
def get(self, session_id: UUID) -> SessionState | None:
return self._sessions.get(session_id)
state = self._sessions.get(session_id)
return None if state is not None and state.lease_fenced else state
def is_lease_fenced(self, session_id: UUID) -> bool:
state = self._sessions.get(session_id)
return state is not None and state.lease_fenced
def active_sessions(self, owner_login: str) -> list[SessionState]:
"""Живые занятия только преподавателя-владельца для группового обзора."""
return [
state for state in self._sessions.values()
if not state.ended and not state.lease_fenced and state.owner_login == owner_login
]
def history(
self, *, owner_login: str | None = None, trainee_id: UUID | None = None,
mode: str | None = None, since: datetime | None = None, limit: int = 100,
) -> list[SessionState]:
"""Volatile session history for the explicit no-database demo mode."""
if since is not None and since.tzinfo is None:
since = since.replace(tzinfo=UTC)
states = [
state for state in self._sessions.values()
if not state.lease_fenced
and (owner_login is None or state.owner_login == owner_login)
and (trainee_id is None or state.trainee_id == trainee_id)
and (mode is None or state.mode.value == mode)
and (since is None or (state.started_at is not None and state.started_at >= since))
]
# Hub insertion order is creation order; completed lessons sort by
# their finish time, while unanswered calls retain their start time.
states.sort(
key=lambda state: state.ended_at or state.started_at or datetime.min.replace(tzinfo=UTC),
reverse=True,
)
return states[:max(0, limit)]
def has_active_scenario(self, scenario_id: str) -> bool:
"""Архивирование контента не должно менять уже идущее занятие."""
return any(
not state.ended and (
not state.ended and not state.lease_fenced and (
state.scenario_id == scenario_id
or any(item.id == scenario_id for item in state.dds_scenarios)
)
@ -80,9 +142,100 @@ class SessionHub:
async def checkpoint(self, session_id: UUID) -> None:
"""Зафиксировать подтверждённое состояние, если журнал доступен."""
state = self.get(session_id)
state = self._sessions.get(session_id)
if state is not None and state.lease_fenced:
raise RuntimeError(LEASE_FENCED_MESSAGE)
if state is not None and self.journal is not None:
await self.journal.checkpoint(state)
try:
await self.journal.checkpoint(state)
except Exception:
self._discard_event_batch(session_id)
await self.fence(state)
raise
self._flush_event_batch(session_id)
def begin_event_stream(self, session_id: UUID) -> Token:
"""Stage controller output until each explicit checkpoint in its loop."""
return self._event_batch.set({
"session_id": session_id, "events": [], "committed": False,
"persistent": True, "owner_task": _current_task(),
})
async def end_event_stream(self, token: Token) -> None:
batch = self._event_batch.get()
try:
if batch is not None and batch["events"]:
session_id = batch["session_id"]
batch["events"].clear()
state = self._sessions.get(session_id)
if self.journal is not None and state is not None and not state.lease_fenced:
await self.fence(state)
finally:
self._event_batch.reset(token)
@contextlib.asynccontextmanager
async def durable_transition(self, session_id: UUID):
"""Do not publish state-changing events until its checkpoint commits."""
batch = {
"session_id": session_id, "events": [], "committed": False,
"persistent": False, "owner_task": _current_task(),
}
token: Token = self._event_batch.set(batch)
try:
yield
if not batch["committed"]:
await self.checkpoint(session_id)
else:
self._flush_event_batch(session_id)
except Exception:
self._discard_event_batch(session_id)
state = self._sessions.get(session_id)
if self.journal is not None and state is not None and not state.lease_fenced:
await self.fence(state)
raise
finally:
self._event_batch.reset(token)
def _discard_event_batch(self, session_id: UUID) -> None:
batch = self._event_batch.get()
if batch is not None and batch["session_id"] == session_id:
batch["events"].clear()
def _flush_event_batch(self, session_id: UUID) -> None:
batch = self._event_batch.get()
if batch is None or batch["session_id"] != session_id:
return
pending, batch["events"] = batch["events"], []
batch["committed"] = not batch.get("persistent", False)
for registry, target_session_id, event in pending:
self._put(registry.get(target_session_id, set()), event)
def _send(self, registry: dict[UUID, set[asyncio.Queue]], session_id: UUID,
event: BaseModel) -> None:
batch = self._event_batch.get()
if isinstance(event, ErrorEvent):
self._put(registry.get(session_id, set()), event)
elif (batch is not None and batch["session_id"] == session_id
and batch["owner_task"] is _current_task()):
batch["events"].append((registry, session_id, event))
else:
self._put(registry.get(session_id, set()), event)
async def fence(self, state: SessionState) -> None:
"""Fail closed when durable ownership is lost or cannot be confirmed."""
if state.lease_fenced:
return
state.lease_fenced = True
self.stop_ticker(state.session_id)
if state.voice is not None:
try:
await state.voice.close()
except Exception as exc: # noqa: BLE001 — fencing must still close data channels
log.error("не удалось закрыть голос при fencing занятия %s (%s)",
state.session_id, type(exc).__name__)
event = ErrorEvent(code=ErrorKind.INTERNAL, message=LEASE_FENCED_MESSAGE)
self.broadcast(state.session_id, event)
self.to_station(state.session_id, event)
# ── подписки ──
@ -115,13 +268,16 @@ class SessionHub:
queues.discard(queue)
def to_observers(self, session_id: UUID, event: BaseModel) -> None:
self._put(self._observers.get(session_id, set()), event)
self._send(self._observers, session_id, event)
def to_trainee(self, session_id: UUID, event: BaseModel | bytes) -> None:
self._put(self._trainees.get(session_id, set()), event)
if isinstance(event, BaseModel):
self._send(self._trainees, session_id, event)
else:
self._put(self._trainees.get(session_id, set()), event)
def to_station(self, session_id: UUID, event: BaseModel) -> None:
self._put(self._stations.get(session_id, set()), event)
self._send(self._stations, session_id, event)
def broadcast(self, session_id: UUID, event: BaseModel) -> None:
self.to_trainee(session_id, event)
@ -160,7 +316,7 @@ class SessionHub:
state = self.get(session_id)
if state is None or state.ended:
return
if state.exercise is Exercise.DDS:
if state.exercise is Exercise.DDS or (state.handoff_to_dds and state.dds_scenarios):
from app.session.dds import deliver_due_cards
active_before = state.dds_active_card_id