refactor: запись хода занятия — один commit SessionStore на операцию вместо журнала и checkpoint

Правка балла с пульта и из отчёта идёт одной доменной операцией: раньше WS-путь не обновлял full_report живой сессии.
This commit is contained in:
gglamer 2026-09-26 22:48:24 +00:00
commit 47cb85ee02
30 changed files with 1406 additions and 1161 deletions

View file

@ -20,7 +20,7 @@ from app.api.auth import add_audit_entry, audit, audit_required, require
from app.config import get_settings
from app.db import repo
from app.db.base import get_session
from app.db.models import AuditLog, Group, Score, Session, Trainee
from app.db.models import Group, Score, Session, Trainee
from app.domain.events import Exercise, SessionMode, SessionReport
from app.domain.roles import Role
from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current
@ -28,8 +28,9 @@ from app.domain.timers import TimerCode
from app.scoring.export import to_csv, to_pdf
from app.scoring.report import build as build_report
from app.session.checkpoint import load_state
from app.session.finish import scoring_scenario
from app.session.finish import override_score, scoring_scenario
from app.session.hub import hub
from app.session.store import ScoreOverridden, apply_score_override
from app.voice.recording import recording_path
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
@ -424,14 +425,14 @@ async def _report_data(
raise HTTPException(status_code=409, detail="self_assessment_required")
if state.score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
if hub.journal is not None and isinstance(db, AsyncSession):
if hub.store.persistent and isinstance(db, AsyncSession):
persisted_session = await db.scalar(
select(Session.id).where(Session.id == session_id)
)
if (persisted_session is not None and await db.scalar(
select(Score.session_id).where(Score.session_id == session_id)
) is None):
# Live state is populated just before the journal transaction commits.
# Live state is populated just before the store commit finishes.
# Do not expose a report that looks ready but cannot yet be corrected
# or retrieved after restart.
raise HTTPException(status_code=409, detail="score_not_ready")
@ -547,85 +548,47 @@ async def override(
session_id: UUID,
body: ScoreOverride,
request: Request,
db: AsyncSession = Depends(get_session),
db: AsyncSession | None = Depends(optional_session),
) -> SessionReport:
"""Тренажёр готовит материал, преподаватель имеет последнее слово.
Администратору сюда нельзя: ТЗ запрещает ему вмешиваться в оценки прямо.
Правка идёт тем же путём, что команда `score.override` с пульта.
"""
who = require(request, Role.INSTRUCTOR)
session = await repo.get_session(db, session_id)
if session is None:
raise HTTPException(status_code=404, detail="session_not_found")
if session.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
correction = ScoreOverridden(
score_final=body.score_final, author=who.login, role=who.role.value,
comment=body.comment,
)
state = hub.get(session_id)
live_ready = state is not None and state.score is not None
scenario = scoring_scenario(state) if live_ready else None
if live_ready and scenario is None:
raise HTTPException(status_code=409, detail="scenario_not_found")
if state is not None:
if state.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
if state.score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
scenario = scoring_scenario(state)
if scenario is None:
raise HTTPException(status_code=409, detail="scenario_not_found")
async with hub.operation(session_id):
override_score(state, correction)
return build_report(session_id, state, scenario)
# Занятия нет в памяти узла: правка из истории, по архивному разбору.
if db is None:
raise HTTPException(status_code=404, detail="session_not_found")
session = await repo.get_session(db, session_id)
if session is None or session.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
score = await db.scalar(select(Score).where(Score.session_id == session_id))
if score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
archived = (score.report or {}).get("full_report")
if not live_ready and archived is None:
if archived is None:
raise HTTPException(status_code=409, detail="report_not_archived")
# Persist score and audit entry in the same request transaction: the API
# must not report success if either durable record failed to commit.
score.score_final = body.score_final
score.overridden_by = who.login
score.override_comment = body.comment
report_payload = dict(score.report or {})
if archived is not None:
archived_payload = dict(archived)
archived_payload.update({
"score_auto": score.score_auto,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
})
report_payload["full_report"] = archived_payload
score.report = report_payload
db.add(AuditLog(
actor=who.login,
role=who.role.value,
action="score.override",
object_id=str(session_id),
# The actual reason remains attached to the instructor-facing score
# report. The durable security audit needs the change and actor, not
# a second indefinite copy of free-text that may contain personal data.
detail=(f"{score.score_auto} → {body.score_final}; "
f"comment_chars={len(body.comment)}"),
))
await db.commit()
if live_ready:
assert state is not None and state.score is not None and scenario is not None
state.score = {
**state.score,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
}
state.score["full_report"] = {
**state.score.get("full_report", {}),
"score_auto": score.score_auto,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
}
result = build_report(session_id, state, scenario)
else:
# Historical/archived sessions no longer have a live object in the hub.
# Keep score correction available from the instructor's report history.
result = SessionReport.model_validate(archived).model_copy(update={
"score_auto": score.score_auto,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
})
return result
report = apply_score_override(dict(score.report), correction)
await db.rollback() # чтение закончено; запись — одной транзакцией хранилища
await hub.store.commit_archived(session_id, [correction])
return SessionReport.model_validate(report["full_report"])
@router.get("", response_model=list[SessionOut])

View file

@ -47,6 +47,13 @@ from app.session.dds import prepare_handoff_queue
from app.session.finish import finish, refresh_archived_report, release_score
from app.session.hub import LEASE_FENCED_MESSAGE, hub
from app.session.state import now_utc
from app.session.store import (
HintRecorded,
LessonEnded,
LessonStarted,
SelfAssessed,
UtteranceAppended,
)
from app.voice.models import TTS_RATE, get_voice_models
from app.voice.pipeline import VoiceSession
from app.voice.recording import start_recording
@ -213,8 +220,7 @@ async def _handle(session_id: UUID, state, event) -> None:
accepted = TextTurnAccepted(text=event.text, at=operator_entry.at)
hub.to_trainee(session_id, accepted)
hub.to_observers(session_id, TranscriptAppend(entry=operator_entry))
if hub.journal:
await hub.journal.utterance(session_id, operator_entry)
hub.record(session_id, UtteranceAppended(operator_entry))
try:
slots = state.slots if state.slots is not None else _TextSlotView(state)
line = await state.caller.reply(turn, state.persona, slots)
@ -232,8 +238,7 @@ async def _handle(session_id: UUID, state, event) -> None:
at=caller_entry.at, mood=line.mood, source=line.source,
))
hub.to_observers(session_id, TranscriptAppend(entry=caller_entry))
if hub.journal:
await hub.journal.utterance(session_id, caller_entry)
hub.record(session_id, UtteranceAppended(caller_entry))
case "card.submit":
state.on_event("card.submit")
state.kio.registered_at = state.started_at or now_utc()
@ -260,18 +265,14 @@ async def _handle(session_id: UUID, state, event) -> None:
hub.to_observers(session_id, state.snapshot())
else:
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
if hub.journal:
await hub.journal.session_ended(
session_id, state.ended_at, CallEndReason.COMPLETE.value
)
hub.record(session_id, LessonEnded(state.ended_at, CallEndReason.COMPLETE.value))
await finish(session_id, state)
case "call.answer":
first_answer = state.started_at is None
if first_answer:
state.on_event("call.answer")
state.started_at = now_utc()
if hub.journal:
await hub.journal.session_started(session_id, state.started_at)
hub.record(session_id, LessonStarted(state.started_at))
hub.to_trainee(session_id, CallStarted(started_at=state.started_at))
hub.to_observers(session_id, state.snapshot())
if state.recorder is None:
@ -313,8 +314,7 @@ async def _handle(session_id: UUID, state, event) -> None:
state.hints_log.append((checklist_id, now_utc()))
shown = HintShown(checklist_id=checklist_id, question=question)
hub.broadcast(session_id, shown)
if hub.journal:
await hub.journal.hint(session_id, checklist_id, question, now_utc())
hub.record(session_id, HintRecorded(checklist_id, question, now_utc()))
case "dds.dispatch":
if event.service is None and not (state.kio.incident_code or state.kio.notify):
@ -354,14 +354,7 @@ async def _handle(session_id: UUID, state, event) -> None:
state.on_event("callback.dial")
case "self_assessment.submit":
if hub.journal and not await hub.journal.self_assessment(
session_id, event.missed, event.comment, now_utc()
):
hub.to_trainee(session_id, ErrorEvent(
code=ErrorKind.INTERNAL,
message="Не удалось сохранить самооценку и аудит; итог пока не выдан.",
))
return
hub.record(session_id, SelfAssessed(list(event.missed), event.comment, now_utc()))
state.self_assessed = True
state.self_assessment = {"missed": event.missed, "comment": event.comment}
await refresh_archived_report(session_id, state)
@ -376,14 +369,9 @@ async def _handle(session_id: UUID, state, event) -> None:
hub.stop_ticker(session_id)
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.HANGUP))
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.HANGUP))
if hub.journal:
await hub.journal.session_ended(
session_id, state.ended_at, CallEndReason.HANGUP.value
)
hub.record(session_id, LessonEnded(state.ended_at, CallEndReason.HANGUP.value))
await finish(session_id, state)
await hub.checkpoint(session_id)
def _start_voice(session_id: UUID, state, *, initial_statement: bool = True) -> None:
"""Голос включается, когда курсант снял трубку: звонящий сразу кричит первую реплику."""
@ -403,7 +391,7 @@ def _start_voice(session_id: UUID, state, *, initial_statement: bool = True) ->
send_event=lambda event: hub.to_trainee(session_id, event),
send_observer=lambda event: hub.to_observers(session_id, event),
send_audio=send_audio,
journal=hub.journal,
persist=lambda entry: hub.commit(session_id, UtteranceAppended(entry)),
)
if scenario.background:
event = BgStart(loop=scenario.background.loop, gain_db=scenario.background.gain_db)
@ -540,7 +528,7 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=str(payload)[:200]),
)
continue
async with hub.durable_transition(session_id):
async with hub.operation(session_id):
await _handle(session_id, state, event)
except WebSocketDisconnect:
return

View file

@ -17,7 +17,7 @@ from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.api.auth import audit, principal_of, websocket_origin_allowed
from app.api.auth import principal_of, websocket_origin_allowed
from app.config import get_settings
from app.db.base import get_sessionmaker
from app.db.repo import SessionNodeConflict
@ -49,7 +49,9 @@ from app.domain.timers import TimerCode
from app.scenarios import store
from app.session.dds import prepare_queue
from app.session.hub import LEASE_FENCED_MESSAGE, hub
from app.session.finish import finish, override_score
from app.session.state import SessionState, now_utc
from app.session.store import LessonEnded, LessonIdentity, LessonRequest, NoteAdded, ScoreOverridden
from app.voice.models import get_voice_models
from app.voice.pipeline import FILLERS, prefetch
@ -79,6 +81,70 @@ def card_briefing(state: SessionState) -> CardBriefing:
)
def _build_state(session_id: UUID, event, who, scenario, scenarios,
identity: LessonIdentity) -> SessionState:
"""Занятие собирается целиком и только потом регистрируется: иначе
наблюдатель, подключившийся в эту щель, увидит полусобранное состояние
без слот-автомата и звонящего. Снимок пишется в той же транзакции, что
и строка занятия."""
state = SessionState(
session_id=session_id,
scenario_id=scenario.id,
scenario_title=scenario.title,
level=scenario.level.value,
mode=event.mode,
owner_login=who.login if who is not None else None,
backend_fencing_epoch=identity.fencing_epoch,
exercise=event.exercise,
handoff_to_dds=event.handoff_to_dds,
scenario=scenario.model_copy(deep=True),
required_fields=scenario.required_fields,
trainee_name=event.trainee,
trainee_id=identity.trainee_id,
dds_service=identity.service or event.dds_service,
attempt=identity.attempt,
criteria=event.criteria,
)
state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
state.timers.limits[TimerCode.CARD_FILL] = event.criteria.card_fill_time_limit_seconds * 1000
state.timers.limits[TimerCode.DDS_WORK] = event.criteria.dds_card_work_time_limit_seconds * 1000
if event.exercise is Exercise.CALL:
embedder = get_embedder()
if embedder is not None:
state.slots = SlotMachine(state.scenario, embedder)
state.persona = PersonaState(state.scenario.persona)
state.caller = build_caller(
scenario.id,
use_pregenerated=scenario.tree.pregenerated,
)
elif event.exercise is Exercise.DDS:
prepare_queue(
state,
[item.model_copy(deep=True) for item in scenarios],
arrival_interval_seconds=event.dds_arrival_interval_seconds,
max_waiting=event.dds_max_waiting,
)
state.started_at = state.desk.active.dispatched_at if state.desk.active else None
else:
state.started_at = now_utc()
if event.exercise is Exercise.CARD:
embedder = get_embedder()
if embedder is not None:
state.slots = SlotMachine(state.scenario, embedder)
state.persona = PersonaState(state.scenario.persona)
state.caller = build_caller(
scenario.id, use_pregenerated=scenario.tree.pregenerated,
)
state.on_event("card.start")
if event.handoff_to_dds:
state.pending_dds_scenarios = [item.model_copy(deep=True) for item in scenarios[1:]]
state.desk.arrival_interval_seconds = event.dds_arrival_interval_seconds
state.desk.max_waiting = event.dds_max_waiting
if event.exercise is Exercise.CALL:
state.on_event("call.incoming")
return state
async def _start(session_id: UUID, event, who=None) -> None:
if event.handoff_to_dds and event.exercise is not Exercise.CARD:
hub.to_observers(session_id, ErrorEvent(
@ -89,7 +155,7 @@ async def _start(session_id: UUID, event, who=None) -> None:
scenario_ids = list(dict.fromkeys([
event.scenario_id, *(event.scenario_ids or []), *(event.random_scenario_ids or []),
]))
if get_settings().demo_no_db or hub.journal is None:
if get_settings().demo_no_db or not hub.store.persistent:
catalog = {
scenario_id: scenario for scenario_id in scenario_ids
if (scenario := store.get(scenario_id)) is not None
@ -207,96 +273,33 @@ async def _start(session_id: UUID, event, who=None) -> None:
else:
scenarios = []
attempt = 1
recorded_trainee_id = event.trainee_id
recorded_service = None
fencing_epoch = 0
if hub.journal:
try:
persisted = await hub.journal.start_lesson(
session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id,
owner_login=who.login if who is not None else None,
backend_node_id=get_settings().backend_node_id,
)
if persisted is None:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.INTERNAL,
message="Не удалось записать занятие и аудит; запуск отменён.",
))
return
attempt, recorded_trainee_id, recorded_service, fencing_epoch = persisted
except SessionNodeConflict:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.FORBIDDEN,
message="Сессия закреплена за другим backend-узлом; проверьте маршрутизацию proxy",
))
return
except PermissionError:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.FORBIDDEN,
message="Занятие создано другим преподавателем",
))
return
# Занятие собирается целиком и только потом регистрируется: иначе
# наблюдатель, подключившийся в эту щель, увидит полусобранное состояние
# без слот-автомата и звонящего.
state = SessionState(
session_id=session_id,
scenario_id=scenario.id,
scenario_title=scenario.title,
level=scenario.level.value,
mode=event.mode,
owner_login=who.login if who is not None else None,
backend_fencing_epoch=fencing_epoch,
exercise=event.exercise,
handoff_to_dds=event.handoff_to_dds,
scenario=scenario.model_copy(deep=True),
required_fields=scenario.required_fields,
trainee_name=event.trainee,
trainee_id=recorded_trainee_id,
dds_service=recorded_service or event.dds_service,
attempt=attempt,
criteria=event.criteria,
)
state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
state.timers.limits[TimerCode.CARD_FILL] = event.criteria.card_fill_time_limit_seconds * 1000
state.timers.limits[TimerCode.DDS_WORK] = event.criteria.dds_card_work_time_limit_seconds * 1000
if event.exercise is Exercise.CALL:
embedder = get_embedder()
if embedder is not None:
state.slots = SlotMachine(state.scenario, embedder)
state.persona = PersonaState(state.scenario.persona)
state.caller = build_caller(
scenario.id,
use_pregenerated=scenario.tree.pregenerated,
)
elif event.exercise is Exercise.DDS:
prepare_queue(
state,
[item.model_copy(deep=True) for item in scenarios],
arrival_interval_seconds=event.dds_arrival_interval_seconds,
max_waiting=event.dds_max_waiting,
)
state.started_at = state.desk.active.dispatched_at if state.desk.active else None
else:
state.started_at = now_utc()
if event.exercise is Exercise.CARD:
embedder = get_embedder()
if embedder is not None:
state.slots = SlotMachine(state.scenario, embedder)
state.persona = PersonaState(state.scenario.persona)
state.caller = build_caller(
scenario.id, use_pregenerated=scenario.tree.pregenerated,
)
state.on_event("card.start")
if event.handoff_to_dds:
state.pending_dds_scenarios = [item.model_copy(deep=True) for item in scenarios[1:]]
state.desk.arrival_interval_seconds = event.dds_arrival_interval_seconds
state.desk.max_waiting = event.dds_max_waiting
try:
state = await hub.store.open(LessonRequest(
session_id=session_id, scenario_id=scenario.id, mode=event.mode.value,
trainee_name=event.trainee, trainee_id=event.trainee_id,
owner_login=who.login if who is not None else None,
backend_node_id=get_settings().backend_node_id,
), lambda identity: _build_state(session_id, event, who, scenario, scenarios, identity))
except SessionNodeConflict:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.FORBIDDEN,
message="Сессия закреплена за другим backend-узлом; проверьте маршрутизацию proxy",
))
return
except PermissionError:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.FORBIDDEN,
message="Занятие создано другим преподавателем",
))
return
except Exception as exc: # noqa: BLE001 — без долговечной истории занятие не стартует
log.error("занятие %s не записано (%s)", session_id, type(exc).__name__)
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.INTERNAL,
message="Не удалось записать занятие и аудит; запуск отменён.",
))
return
hub.register(state)
if event.exercise is not Exercise.CALL and hub.journal and state.started_at is not None:
await hub.journal.session_started(session_id, state.started_at)
# Первая реплика и филлеры синтезируются, пока курсант не снял трубку:
# «Алло! Помогите!» должно прозвучать мгновенно (docs/arch/BACKEND.md).
@ -304,8 +307,6 @@ async def _start(session_id: UUID, event, who=None) -> None:
models = get_voice_models()
if models is not None:
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
state.on_event("call.incoming")
await hub.checkpoint(session_id)
hub.start_ticker(session_id)
if event.exercise is Exercise.CALL:
hub.to_trainee(
@ -340,7 +341,6 @@ async def _stop(session_id: UUID) -> None:
await state.voice.close()
hub.stop_ticker(session_id)
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
from app.session.finish import finish
if state.exercise in {Exercise.DDS, Exercise.CARD}:
if state.exercise is Exercise.DDS or state.handoff_to_dds and state.dispatched_card is not None:
@ -351,9 +351,97 @@ async def _stop(session_id: UUID) -> None:
hub.to_station(session_id, ScoreReady(session_id=session_id))
else:
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.INSTRUCTOR))
hub.record(session_id, LessonEnded(state.ended_at, CallEndReason.INSTRUCTOR.value))
await finish(session_id, state)
if hub.journal:
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value)
async def _command(session_id: UUID, event, who) -> None:
"""Команда пульта. Всё, кроме запуска, идёт внутри `hub.operation`."""
state = hub.get(session_id)
match event.type:
case "scenario.start":
await _start(session_id, event, who)
case "session.stop":
await _stop(session_id)
case "instructor_note.add":
if state is not None:
state.notes.append({
"type": "instructor_note.shown",
"transcript_ref": event.transcript_ref,
"text": event.text,
"author": "преподаватель",
})
hub.record(session_id, NoteAdded(event.transcript_ref, event.text, "преподаватель"))
hub.to_observers(
session_id,
InstructorNoteShown(
transcript_ref=event.transcript_ref,
text=event.text,
author="преподаватель",
),
)
case "reference.play":
if state is not None:
hub.to_observers(session_id, ReferenceStarted(scenario_id=state.scenario_id))
case "score.override":
if event.session_id != session_id:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.FORBIDDEN,
message="Оценка относится к другому занятию",
))
return
if state is None or state.score is None:
return
try:
verdict = float(event.verdict)
except ValueError:
verdict = float("nan")
if not math.isfinite(verdict) or not 0 <= verdict <= 100:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message="Оценка должна быть числом от 0 до 100",
))
return
override_score(state, ScoreOverridden(
score_final=verdict, author=who.login, role=who.role.value,
comment=event.comment,
))
case "director.inject":
if state is None:
return
if state.exercise is not Exercise.CALL:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message="Директивы звонящему доступны только в голосовом упражнении",
))
return
result = apply_directive(state, event.directive)
if result.needs_network:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.DIRECTIVE_NEEDS_NETWORK,
message="Свободный текст требует локально запущенной модели; без неё доступны кнопки",
))
return
state.directives.append(event.directive)
voice = state.voice
if result.drop_line and voice is not None:
# Обрыв рвёт звук на полуслове тем же механизмом, что
# перебивание, и запускает норматив обратного дозвона.
voice.barge_in()
if result.drop_line:
state.on_event("call.dropped")
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.DROPPED))
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.DROPPED))
elif result.say and voice is not None:
voice.speak(result.say, mood_of(state))
case _:
hub.to_observers(
session_id,
ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message=f"{event.type} ещё не реализовано",
),
)
async def _reject(ws: WebSocket, message: str) -> None:
@ -379,10 +467,9 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
if who is None or who.role not in (Role.INSTRUCTOR,):
await _reject(ws, "Недостаточно прав для этого экрана")
return
event_stream = hub.begin_event_stream(session_id)
# Чтение живёт дольше тика опроса: wait_for отменял бы его, а отмена после
# того, как receive уже забрал кадр, теряет команду или websocket.disconnect.
# Команда обрабатывается здесь, в задаче, открывшей event stream.
# Команда обрабатывается здесь, одной операцией хранилища.
read: asyncio.Future | None = None
try:
while True:
@ -422,119 +509,13 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
))
continue
match event.type:
case "scenario.start":
await _start(session_id, event, who)
case "session.stop":
await _stop(session_id)
case "instructor_note.add":
state = hub.get(session_id)
if state is not None:
state.notes.append({
"type": "instructor_note.shown",
"transcript_ref": event.transcript_ref,
"text": event.text,
"author": "преподаватель",
})
hub.to_observers(
session_id,
InstructorNoteShown(
transcript_ref=event.transcript_ref,
text=event.text,
author="преподаватель",
),
)
if hub.journal:
await hub.journal.note(
session_id, event.transcript_ref, event.text, "преподаватель"
)
case "reference.play":
state = hub.get(session_id)
if state is not None:
hub.to_observers(session_id, ReferenceStarted(scenario_id=state.scenario_id))
case "score.override":
if event.session_id != session_id:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.FORBIDDEN,
message="Оценка относится к другому занятию",
))
continue
state = hub.get(session_id)
if state is not None and state.score is not None:
try:
verdict = float(event.verdict)
except ValueError:
verdict = float("nan")
if not math.isfinite(verdict) or not 0 <= verdict <= 100:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message="Оценка должна быть числом от 0 до 100",
))
continue
if hub.journal is not None:
saved = await hub.journal.score_override(
session_id, verdict, who.login, event.comment
)
if not saved:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.INTERNAL,
message="Не удалось сохранить оценку и запись аудита; изменение отменено",
))
continue
else:
# Explicit in-memory demo mode has no Score table.
await audit(
who.login, who.role.value, "score.override", str(session_id),
f"{state.score.get('score_auto')} → {verdict}; "
f"comment_chars={len(event.comment)}",
)
# Автооценка остаётся рядом: видно, что скорректировано и кем.
state.score = {
**state.score,
"score_final": verdict,
"overridden_by": who.login,
"override_comment": event.comment,
}
hub.to_observers(session_id, ScoreReady(session_id=session_id))
case "director.inject":
state = hub.get(session_id)
if state is None:
continue
if state.exercise is not Exercise.CALL:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message="Директивы звонящему доступны только в голосовом упражнении",
))
continue
result = apply_directive(state, event.directive)
if result.needs_network:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.DIRECTIVE_NEEDS_NETWORK,
message="Свободный текст требует локально запущенной модели; без неё доступны кнопки",
))
continue
state.directives.append(event.directive)
voice = state.voice
if result.drop_line and voice is not None:
# Обрыв рвёт звук на полуслове тем же механизмом, что
# перебивание, и запускает норматив обратного дозвона.
voice.barge_in()
if result.drop_line:
state.on_event("call.dropped")
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.DROPPED))
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.DROPPED))
elif result.say and voice is not None:
voice.speak(result.say, mood_of(state))
case _:
hub.to_observers(
session_id,
ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message=f"{event.type} ещё не реализовано",
),
)
if event.type == "scenario.start":
# Запуск пишется своей транзакцией в `store.open`: до неё занятия нет.
await _command(session_id, event, who)
continue
try:
await hub.checkpoint(session_id)
async with hub.operation(session_id):
await _command(session_id, event, who)
except Exception:
if hub.is_lease_fenced(session_id):
await ws.send_text(ErrorEvent(
@ -551,4 +532,3 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
finally:
if read is not None:
read.cancel()
await hub.end_event_stream(event_stream)

View file

@ -8,7 +8,7 @@
в отчёте и становится сорванным выездом с конкретной причиной.
Правила пульта — в `DdsDesk.apply`; здесь разбор команды, отсев повторов,
durable transition и рассылка итога.
операция хранилища и рассылка итога.
"""
import asyncio
@ -32,6 +32,7 @@ from app.domain.events import (
from app.domain.roles import Role
from app.session.finish import finish
from app.session.hub import LEASE_FENCED_MESSAGE, hub
from app.session.store import LessonEnded
log = logging.getLogger(__name__)
router = APIRouter()
@ -48,8 +49,7 @@ async def _finish_dds(session_id: UUID, state) -> None:
hub.stop_ticker(session_id)
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
if hub.journal:
await hub.journal.session_ended(session_id, ended_at, CallEndReason.COMPLETE.value)
hub.record(session_id, LessonEnded(ended_at, CallEndReason.COMPLETE.value))
await finish(session_id, state)
hub.to_station(session_id, ScoreReady(session_id=session_id))
@ -65,7 +65,6 @@ async def _handle(session_id: UUID, state, event) -> None:
await _finish_dds(session_id, state)
if not outcome.changed:
return
await hub.checkpoint(session_id)
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
hub.to_observers(session_id, state.snapshot())
@ -156,18 +155,13 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
# Re-ack it without rerunning its business transition.
hub.to_station(session_id, CommandAck(command_id=command_id))
continue
async with hub.durable_transition(session_id):
async with hub.operation(session_id):
await _handle(session_id, state, event)
if command_id is not None:
# ID команды входит в тот же снимок, что и её переход:
# подтверждение уходит только после их общего commit.
state.processed_station_commands.append(str(command_id))
del state.processed_station_commands[:-512]
# Commit the state+dedupe ID before acknowledging. The
# transition context can have already flushed other
# events; an explicit checkpoint here makes the
# command/ACK boundary independent of that batch state.
await hub.checkpoint(session_id)
# The hub stages non-error events until the checkpoint
# transaction has committed, including this ack.
hub.to_station(session_id, CommandAck(command_id=command_id))
except WebSocketDisconnect:
return
@ -178,7 +172,7 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
"закрытие станционного WebSocket после fencing занятия %s",
session_id,
)
# `hub.checkpoint` broadcasts a structured fence event before
# `hub.operation` broadcasts a structured fence event before
# propagating the failed database write. Let the sender deliver
# that event and close with 1012 instead of an opaque 1006.
await asyncio.gather(sender, return_exceptions=True)

View file

@ -40,6 +40,7 @@ async def create_session(
owner_login: str | None = None,
backend_node_id: str | None = None,
before_commit: BeforeSessionCommit | None = None,
commit: bool = True,
) -> Session:
session = Session(
scenario_id=scenario_id,
@ -56,7 +57,10 @@ async def create_session(
if before_commit is not None:
await db.flush()
before_commit(db, session)
await db.commit()
if commit:
await db.commit()
else:
await db.flush()
return session
@ -72,6 +76,7 @@ async def ensure_session(
owner_login: str | None = None,
backend_node_id: str | None = None,
before_commit: BeforeSessionCommit | None = None,
commit: bool = True,
) -> Session:
"""Занятие, запущенное с пульта, должно иметь строку в журнале.
@ -98,8 +103,7 @@ async def ensure_session(
changed = True
if before_commit is not None:
before_commit(db, existing)
await db.commit()
elif changed:
if commit and (before_commit is not None or changed):
await db.commit()
return existing
@ -123,6 +127,7 @@ async def ensure_session(
owner_login=owner_login,
backend_node_id=backend_node_id,
before_commit=before_commit,
commit=commit,
)

View file

@ -28,7 +28,8 @@ from app.monitoring import install_diagnostics
from app.scenarios import store
from app.scenarios.loader import ScenarioError
from app.session.hub import hub
from app.session.journal import DbJournal, SessionLeaseLost
from app.session.pg_store import PostgresSessionStore
from app.session.store import MemorySessionStore, SessionLeaseLost
from app.voice.models import get_voice_models
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
@ -77,7 +78,7 @@ async def lifespan(app: FastAPI):
)
else:
logging.getLogger(__name__).warning(
"DEMO_NO_DB: занятия и оценки живут только до перезапуска; журнал БД выключен"
"DEMO_NO_DB: занятия и оценки живут только до перезапуска; хранилище в памяти"
)
if not settings.demo_no_db:
@ -89,16 +90,17 @@ async def lifespan(app: FastAPI):
"не удалось загрузить версии полномочий: %s", exc
)
# Журнал: всё, что не записано, для оценки не существует.
hub.journal = (
None if settings.demo_no_db
else DbJournal(get_sessionmaker(), node_id=settings.backend_node_id)
# Хранилище: всё, что не записано, для оценки не существует. Без БД —
# in-memory adapter, занятие живёт до перезапуска.
hub.store = (
MemorySessionStore() if settings.demo_no_db
else PostgresSessionStore(get_sessionmaker(), node_id=settings.backend_node_id)
)
app.state.sessions_restored = 0
if hub.journal is not None:
if hub.store.persistent:
try:
async with asyncio.timeout(3):
restored = await hub.journal.restore_active()
restored = await hub.store.restore_active()
for state in restored:
hub.register(state)
hub.start_ticker(state.session_id)
@ -113,18 +115,16 @@ async def lifespan(app: FastAPI):
)
lease_task = None
if hub.journal is not None and settings.backend_node_id:
if hub.store.persistent and settings.backend_node_id:
async def supervise_session_ownership() -> None:
while True:
await asyncio.sleep(5)
journal = hub.journal
if journal is None:
return
store = hub.store
for state in list(hub._sessions.values()):
if state.ended or state.lease_fenced:
continue
try:
await journal.renew(state.session_id)
await store.renew(state.session_id)
except SessionLeaseLost:
await hub.fence(state)
except (SQLAlchemyError, OSError, TimeoutError):
@ -139,7 +139,7 @@ async def lifespan(app: FastAPI):
)
await hub.fence(state)
try:
restored = await journal.claim_expired()
restored = await store.claim_expired()
for state in restored:
current = hub._sessions.get(state.session_id)
if current is not None and not current.lease_fenced:
@ -178,11 +178,11 @@ async def lifespan(app: FastAPI):
with suppress(asyncio.CancelledError):
await lease_task
if hub.journal is not None:
if hub.store.persistent:
for state in list(hub._sessions.values()):
if not state.ended:
if not state.ended and not state.lease_fenced:
try:
await hub.journal.checkpoint(state)
await hub.store.commit(state)
except Exception: # noqa: BLE001 — shutdown must release the process
logging.getLogger(__name__).exception(
"не удалось сохранить checkpoint %s при shutdown", state.session_id

View file

@ -12,7 +12,7 @@ import logging
import time
from uuid import UUID
from app.domain.events import ErrorKind, ErrorEvent, Exercise, Metric, ScoreReady
from app.domain.events import Exercise, Metric, ScoreReady
from app.domain.statuses import ServiceStatus, current
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
from app.domain.timers import TimerCode
@ -29,6 +29,7 @@ from app.scoring.timing import time_credit
from app.scoring.weights import apply_weights
from app.session.hub import hub
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
from app.session.store import ScoreArchived, ScoreCalculated, ScoreOverridden, apply_score_override
log = logging.getLogger(__name__)
@ -351,13 +352,9 @@ async def finish(session_id: UUID, state) -> None:
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))
if hub.journal and not await hub.journal.score(session_id, result.score, state.score):
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.INTERNAL,
message="Не удалось сохранить оценку и аудит; итог не выдан. Обратитесь к преподавателю.",
))
return
# Оценка и аудит уходят в commit операции, завершившей занятие; ScoreReady
# ждёт того же commit — без записи итог не выдаётся.
hub.record(session_id, ScoreCalculated(result.score, state.score))
hub.to_observers(session_id, ScoreReady(session_id=session_id))
await release_score(session_id, state)
@ -370,11 +367,21 @@ async def refresh_archived_report(session_id: UUID, state) -> None:
if scenario is None:
return
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
if hub.journal:
await hub.journal.score_snapshot(session_id, state.score)
hub.record(session_id, ScoreArchived(state.score))
async def release_score(session_id: UUID, state) -> None:
"""Отдать оценку курсанту, когда самооценка сдана."""
if state.score is not None and (state.self_assessed or state.exercise is Exercise.CARD):
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
def override_score(state, override: ScoreOverridden) -> None:
"""Правка балла преподавателем — одна операция для HTTP и WS.
Вызывается внутри `hub.operation`: строка `Score`, архивный разбор и аудит
пишутся тем же commit, что и снимок с исправленной оценкой.
"""
hub.record(state.session_id, override)
state.score = apply_score_override(state.score, override)
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))

View file

@ -11,7 +11,6 @@ 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
@ -24,6 +23,7 @@ from app.domain.events import (
TimerTick,
)
from app.session.state import SessionState
from app.session.store import MemorySessionStore, Record, SessionStore
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
#: очередь ограничена, переполнение роняет соединение, а не сессию.
@ -34,51 +34,30 @@ 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 _Operation:
"""Отложенная публикация одной операции: события ждут её commit."""
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, 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
) -> 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,
) -> 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]: ...
def __init__(self, session_id: UUID) -> None:
self.session_id = session_id
self.records: list[Record] = []
self.events: list[tuple[dict[UUID, set[asyncio.Queue]], BaseModel]] = []
self.open = True
#: Такт, которому нечего сохранять, не пишет снимок каждую секунду.
self.persist = True
class SessionHub:
def __init__(self, journal: Journal | None = None) -> None:
self.journal = journal
def __init__(self, store: SessionStore | None = None) -> None:
self.store: SessionStore = store if store is not None else MemorySessionStore()
self._sessions: dict[UUID, SessionState] = {}
self._observers: dict[UUID, set[asyncio.Queue]] = {}
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
# Задачи, порождённые внутри операции, наследуют контекст; после
# закрытия операции их события идут напрямую (`_Operation.open`).
self._operation: ContextVar[_Operation | None] = ContextVar(
f"session-operation-{id(self)}", default=None
)
# ── реестр ──
@ -139,84 +118,67 @@ class SessionHub:
self._sessions.pop(session_id, None)
self.stop_ticker(session_id)
async def checkpoint(self, session_id: UUID) -> None:
"""Зафиксировать подтверждённое состояние, если журнал доступен."""
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:
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)
# ── операция: один commit, потом события ──
@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)
async def operation(self, session_id: UUID) -> AsyncIterator[_Operation]:
"""Изменение занятия фиксируется одним `store.commit` на выходе.
События копятся до коммита. Исключение внутри операции или сбой
коммита отбрасывают их и закрывают занятие на узле (fail closed).
"""
op = _Operation(session_id)
token: Token = self._operation.set(op)
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)
yield op
state = self._sessions.get(session_id)
if self.journal is not None and state is not None and not state.lease_fenced:
if state is not None and op.persist:
if state.lease_fenced:
raise RuntimeError(LEASE_FENCED_MESSAGE)
op.persist = False # второй commit из обработчика отмены не нужен
await asyncio.shield(self.store.commit(state, op.records))
except asyncio.CancelledError:
# Задачу сокета отменили посреди операции — это не сбой хранилища.
# Сделанное фиксируется, события слать уже некому.
op.events.clear()
state = self._sessions.get(session_id)
if state is not None and op.persist and not state.lease_fenced:
try:
await asyncio.shield(self.store.commit(state, op.records))
except Exception:
await self.fence(state)
raise
except Exception:
op.events.clear()
state = self._sessions.get(session_id)
if state is not None and not state.lease_fenced:
await self.fence(state)
raise
finally:
self._event_batch.reset(token)
op.open = False
self._operation.reset(token)
for registry, event in op.events:
self._put(registry.get(session_id, set()), event)
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 record(self, session_id: UUID, record: Record) -> None:
"""Строка журнала уходит в commit текущей операции, не отдельной транзакцией."""
op = self._operation.get()
if op is None or not op.open or op.session_id != session_id:
raise RuntimeError(f"запись занятия {session_id} вне операции")
op.records.append(record)
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)
async def commit(self, session_id: UUID, *records: Record) -> None:
"""Операция из одних строк — реплика голосового контура."""
async with self.operation(session_id):
for record in records:
self.record(session_id, record)
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))
op = self._operation.get()
if (op is not None and op.open and op.session_id == session_id
and not isinstance(event, ErrorEvent)):
op.events.append((registry, event))
else:
self._put(registry.get(session_id, set()), event)
@ -316,12 +278,11 @@ class SessionHub:
if state is None or state.ended:
return
if state.dds_phase:
active_before = state.desk.active_id
delivered = state.desk.deliver_due()
if state.desk.active_id != active_before and state.desk.active_id:
self.to_station(session_id, state.card_received_event())
if delivered:
await self.checkpoint(session_id)
async with self.operation(session_id) as op:
active_before = state.desk.active_id
op.persist = bool(state.desk.deliver_due())
if state.desk.active_id != active_before and state.desk.active_id:
self.to_station(session_id, state.card_received_event())
# Keep the pending count and countdown live even while
# the active dispatcher card is being handled.
self.to_station(session_id, StationState(snapshot=state.station_snapshot()))

View file

@ -1,423 +0,0 @@
"""Запись хода занятия в БД.
Профиль курсанта, дельта попыток и аналитика группы строятся по журналу,
а не по памяти процесса: всё, что здесь не записано, для оценки не существует.
"""
import logging
from datetime import datetime, timedelta
from uuid import UUID
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.db import repo
from app.db.models import AuditLog, Score, SelfAssessment, Session, User, Utterance
from app.domain.events import Mood, Speaker, TranscriptEntry
from app.session.checkpoint import dump_state, load_state
from app.session.state import SessionState, now_utc
log = logging.getLogger(__name__)
LEASE_SECONDS = 15
class SessionLeaseLost(RuntimeError):
"""This process no longer owns the durable session generation."""
class DbJournal:
def __init__(self, sessionmaker: async_sessionmaker, node_id: str | None = None) -> None:
self._sessionmaker = sessionmaker
self._node_id = node_id
self._epochs: dict[UUID, int] = {}
async def _fence(self, db, session_id: UUID, expected_epoch: int | None = None) -> None:
"""Renew and fence this write in the same transaction as its mutation."""
if self._node_id is None:
return
epoch = expected_epoch if expected_epoch is not None else self._epochs.get(session_id)
if epoch is None:
raise SessionLeaseLost(f"session {session_id} has no local fencing epoch")
now = now_utc()
result = await db.execute(
update(Session)
.where(
Session.id == session_id,
Session.backend_node_id == self._node_id,
Session.backend_fencing_epoch == epoch,
)
.values(backend_lease_until=now + timedelta(seconds=LEASE_SECONDS))
.returning(Session.id)
)
if result.scalar_one_or_none() is None:
raise SessionLeaseLost(f"session {session_id} owner epoch {epoch} was fenced")
async def _write(
self, action, *args, _fence_session_id: UUID | None = None,
_fence_epoch: int | None = None, _raise_errors: bool = False, **kwargs
) -> None:
"""Ошибка записи не роняет занятие, но и не проглатывается молча:
занятие идёт дальше, в логе остаётся след."""
try:
async with self._sessionmaker() as db:
if _fence_session_id is not None:
await self._fence(db, _fence_session_id, _fence_epoch)
await action(db, *args, **kwargs)
except SessionLeaseLost:
raise
except Exception as exc: # noqa: BLE001 — журнал не должен ронять живую сессию
session_id = _fence_session_id or kwargs.get("session_id")
log.error("журнал: запись не удалась для сессии %s (%s)",
session_id, type(exc).__name__)
if _raise_errors:
raise
async def start_lesson(
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: 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:
"""Завести сессию в журнале и вернуть номер попытки и ID курсанта.
Строка сессии и событие аудита фиксируются вместе. При сбое транзакции
занятие не запускается без долговечной истории.
"""
try:
async with self._sessionmaker() as db:
node_id = backend_node_id or self._node_id
def audit_start(transaction, row):
if row.backend_fencing_epoch <= 0:
row.backend_fencing_epoch = 1
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
transaction.add(AuditLog(
actor=owner_login or "system",
role="instructor" if owner_login else "system",
action="lesson.start",
object_id=str(row.id),
detail=f"{scenario_id}, mode {mode}",
))
row = await repo.ensure_session(
db,
session_id=session_id,
scenario_id=scenario_id,
mode=mode,
trainee_name=trainee_name,
trainee_id=trainee_id,
owner_login=owner_login,
backend_node_id=node_id,
before_commit=audit_start,
)
epoch = getattr(row, "backend_fencing_epoch", 0) or 1
self._epochs[session_id] = epoch
service = None
if row.trainee_id is not None:
service = await db.scalar(
select(User.service)
.where(User.trainee_id == row.trainee_id, User.blocked.is_(False))
.limit(1)
)
return row.attempt, row.trainee_id, service, epoch
except PermissionError:
raise
except Exception as exc: # noqa: BLE001 — журнал не должен ронять живую сессию
log.error("журнал: не удалось завести сессию %s (%s)",
session_id, type(exc).__name__)
return None
async def checkpoint(self, state: SessionState) -> None:
"""Сохранить снимок после подтверждённого действия пользователя."""
async def action(db):
values = (
{"live_state": None, "checkpoint_at": None}
if state.ended
else {"live_state": dump_state(state), "checkpoint_at": now_utc()}
)
await db.execute(update(Session).where(Session.id == state.session_id).values(**values))
await db.commit()
if state.backend_fencing_epoch > 0:
self._epochs.setdefault(state.session_id, state.backend_fencing_epoch)
await self._write(
lambda db: action(db), _fence_session_id=state.session_id,
_fence_epoch=state.backend_fencing_epoch or None,
_raise_errors=True,
)
async def renew(self, session_id: UUID) -> None:
"""Refresh an owned session lease; concurrent takeover is row-serialized."""
async with self._sessionmaker() as db:
await self._fence(db, session_id)
await db.commit()
async def claim_expired(self, session_id: UUID | None = None) -> list[SessionState]:
"""Atomically fence and restore expired owners on this backend node."""
if self._node_id is None:
return []
now = now_utc()
conditions = [
Session.ended_at.is_(None),
Session.live_state.is_not(None),
Session.checkpoint_at.is_not(None),
(Session.backend_node_id.is_(None) | (Session.backend_node_id != self._node_id)),
(Session.backend_lease_until.is_(None) | (Session.backend_lease_until <= now)),
]
if session_id is not None:
conditions.append(Session.id == session_id)
async with self._sessionmaker() as db:
rows = (await db.scalars(
select(Session).where(*conditions).with_for_update(skip_locked=True).limit(100)
)).all()
for row in rows:
row.backend_node_id = self._node_id
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
row.backend_lease_until = now + timedelta(seconds=LEASE_SECONDS)
if rows:
await db.commit()
if not rows:
return []
return await self.restore_active(bump_owned_epoch=False)
async def restore_active(self, *, bump_owned_epoch: bool = True) -> list[SessionState]:
"""Восстановить только незавершённые сессии с валидным снимком."""
restored: list[SessionState] = []
async with self._sessionmaker() as db:
active_with_snapshot = (
Session.ended_at.is_(None),
Session.live_state.is_not(None),
Session.checkpoint_at.is_not(None),
)
if self._node_id is not None:
# Adopt legacy unassigned snapshots exactly once. Concurrent
# nodes lock disjoint rows; subsequent restores are owner-only.
unassigned = (await db.scalars(
select(Session)
.where(*active_with_snapshot, Session.backend_node_id.is_(None))
.with_for_update(skip_locked=True)
)).all()
for row in unassigned:
row.backend_node_id = self._node_id
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
if unassigned:
await db.commit()
# A restarted process with the same stable node ID is a new
# owner generation. Bump before exposing any restored state.
owned = (await db.scalars(
select(Session)
.where(*active_with_snapshot, Session.backend_node_id == self._node_id)
.with_for_update(skip_locked=True)
)).all()
if bump_owned_epoch:
for row in owned:
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
if owned:
await db.commit()
rows = (await db.scalars(
select(Session).where(
*active_with_snapshot,
Session.backend_node_id == self._node_id,
)
)).all()
else:
rows = (await db.scalars(
select(Session).where(*active_with_snapshot)
)).all()
for row in rows:
try:
state = load_state(row.live_state, row.checkpoint_at)
if state.session_id != row.id:
raise ValueError("ID снимка не совпадает с записью занятия")
state.owner_login = row.owner_login
state.backend_fencing_epoch = row.backend_fencing_epoch
self._epochs[row.id] = row.backend_fencing_epoch
# Реплики пишутся отдельно сразу после появления. Если
# процесс умер между репликой и общим снимком, отдельный
# журнал не даёт потерять последний фрагмент диалога.
utterances = (await db.scalars(
select(Utterance)
.where(Utterance.session_id == row.id)
.order_by(Utterance.at, Utterance.ref)
)).all()
if utterances:
state.transcript = [
TranscriptEntry(
ref=item.ref,
speaker=Speaker(item.speaker),
text=item.text,
at=item.at,
mood=Mood(item.mood) if item.mood else None,
)
for item in utterances
]
restored.append(state)
except Exception as exc: # noqa: BLE001 — один снимок не блокирует весь стенд
log.error("журнал: снимок занятия %s повреждён (%s)",
row.id, type(exc).__name__)
return restored
async def utterance(self, session_id: UUID, entry) -> None:
await self._write(
repo.append_utterance,
session_id=session_id,
ref=entry.ref,
speaker=entry.speaker.value,
text=entry.text,
at=entry.at,
mood=entry.mood.value if entry.mood else None,
_fence_session_id=session_id,
)
async def hint(self, session_id: UUID, checklist_id: str, question: str, at: datetime) -> None:
await self._write(
repo.record_hint, _fence_session_id=session_id, session_id=session_id,
checklist_id=checklist_id, question=question, at=at
)
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None:
await self._write(
repo.add_note, _fence_session_id=session_id, session_id=session_id,
transcript_ref=ref, text=text, author=author
)
async def self_assessment(
self, session_id: UUID, missed: list[str], comment: str, at: datetime
) -> bool:
"""Persist trainee reflection and its security audit together."""
try:
async with self._sessionmaker() as db:
session = await db.get(Session, session_id)
if session is None:
return False
actor = "system"
role = "system"
if session.trainee_id is not None:
login = await db.scalar(
select(User.login).where(User.trainee_id == session.trainee_id)
)
if login:
actor, role = login, "trainee"
else:
actor, role = f"trainee:{session.trainee_id}", "trainee"
db.add(SelfAssessment(
session_id=session_id, missed=missed, comment=comment, submitted_at=at
))
db.add(AuditLog(
actor=actor,
role=role,
action="self_assessment.submit",
object_id=str(session_id),
detail=f"missed_count={len(missed)}; comment_chars={len(comment)}",
))
await self._fence(db, session_id)
await db.commit()
return True
except SessionLeaseLost:
raise
except Exception as exc: # noqa: BLE001 — do not accept an unaudited reflection
log.error("самооценка и аудит сессии %s не сохранены (%s)",
session_id, type(exc).__name__)
return False
async def score(self, session_id: UUID, score_auto: float, report: dict) -> bool:
"""Persist the initial result and its audit event atomically."""
try:
async with self._sessionmaker() as db:
await self._fence(db, session_id)
db.add(Score(
session_id=session_id, score_auto=score_auto,
score_final=score_auto, report=report,
))
db.add(AuditLog(
actor="system", role="system", action="score.calculate",
object_id=str(session_id), detail=f"score_auto={score_auto}",
))
await db.commit()
return True
except SessionLeaseLost:
raise
except Exception as exc: # noqa: BLE001 — result is not complete until durable
log.error("итоговая оценка и аудит сессии %s не сохранены (%s)",
session_id, type(exc).__name__)
return False
async def score_override(
self, session_id: UUID, score_final: float, author: str, comment: str
) -> bool:
"""Persist a live correction and its security audit as one transaction."""
try:
async with self._sessionmaker() as db:
await self._fence(db, session_id)
score = await db.scalar(
select(Score)
.where(Score.session_id == session_id)
.with_for_update()
)
if score is None:
return False
score.score_final = score_final
score.overridden_by = author
score.override_comment = comment
report = dict(score.report or {})
archived = report.get("full_report")
if isinstance(archived, dict):
archived = dict(archived)
archived.update({
"score_auto": score.score_auto,
"score_final": score_final,
"overridden_by": author,
"override_comment": comment,
})
report["full_report"] = archived
score.report = report
db.add(AuditLog(
actor=author,
role="instructor",
action="score.override",
object_id=str(session_id),
detail=(f"{score.score_auto} → {score_final}; "
f"comment_chars={len(comment)}"),
))
await db.commit()
return True
except SessionLeaseLost:
raise
except Exception as exc: # noqa: BLE001 — do not confirm a correction without its audit
log.error("корректировка оценки и аудит сессии %s не сохранены (%s)",
session_id, type(exc).__name__)
return False
async def score_snapshot(self, session_id: UUID, report: dict) -> None:
"""Обновить полный архивный разбор после самооценки курсанта."""
async def action(db):
await db.execute(
update(Score).where(Score.session_id == session_id).values(report=report)
)
await db.commit()
await self._write(lambda db: action(db), _fence_session_id=session_id)
async def session_started(self, session_id: UUID, at: datetime) -> None:
async def action(db):
await db.execute(update(Session).where(Session.id == session_id).values(started_at=at))
await db.commit()
await self._write(lambda db: action(db), _fence_session_id=session_id)
async def session_ended(self, session_id: UUID, at: datetime, reason: str) -> None:
async def action(db):
await db.execute(
update(Session)
.where(Session.id == session_id)
.values(
ended_at=at,
end_reason=reason,
live_state=None,
checkpoint_at=None,
)
)
await db.commit()
await self._write(lambda db: action(db), _fence_session_id=session_id)

View file

@ -0,0 +1,362 @@
"""PostgreSQL adapter хранилища занятия.
Профиль курсанта, дельта попыток и аналитика группы строятся по этим строкам,
а не по памяти процесса: всё, что здесь не записано, для оценки не существует.
Каждый `commit` — одна транзакция: продление lease с проверкой `(node_id, epoch)`,
append-only строки операции и снимок `live_state`.
"""
import logging
from collections.abc import Callable, Sequence
from datetime import timedelta
from uuid import UUID
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.db import repo
from app.db.models import (
AuditLog,
HintUse,
InstructorNote,
Score,
SelfAssessment,
Session,
User,
Utterance,
)
from app.domain.events import Mood, Speaker, TranscriptEntry
from app.session.checkpoint import dump_state, load_state
from app.session.state import SessionState, now_utc
from app.session.store import (
HintRecorded,
LessonEnded,
LessonIdentity,
LessonRequest,
LessonStarted,
NoteAdded,
Record,
ScoreArchived,
ScoreCalculated,
ScoreOverridden,
SelfAssessed,
SessionLeaseLost,
UtteranceAppended,
apply_score_override,
)
log = logging.getLogger(__name__)
LEASE_SECONDS = 15
__all__ = ["LEASE_SECONDS", "PostgresSessionStore", "SessionLeaseLost"]
class PostgresSessionStore:
persistent = True
def __init__(self, sessionmaker: async_sessionmaker, node_id: str | None = None) -> None:
self._sessionmaker = sessionmaker
self._node_id = node_id
self._epochs: dict[UUID, int] = {}
async def _fence(self, db, session_id: UUID, expected_epoch: int | None = None) -> None:
"""Renew and fence this write in the same transaction as its mutation."""
if self._node_id is None:
return
epoch = expected_epoch if expected_epoch is not None else self._epochs.get(session_id)
if epoch is None:
raise SessionLeaseLost(f"session {session_id} has no local fencing epoch")
now = now_utc()
result = await db.execute(
update(Session)
.where(
Session.id == session_id,
Session.backend_node_id == self._node_id,
Session.backend_fencing_epoch == epoch,
)
.values(backend_lease_until=now + timedelta(seconds=LEASE_SECONDS))
.returning(Session.id)
)
if result.scalar_one_or_none() is None:
raise SessionLeaseLost(f"session {session_id} owner epoch {epoch} was fenced")
async def open(
self, request: LessonRequest, build: Callable[[LessonIdentity], SessionState],
) -> SessionState:
"""Строка занятия, аудит запуска и первый снимок — одна транзакция.
При сбое занятие не запускается без долговечной истории.
"""
async with self._sessionmaker() as db:
row = await repo.ensure_session(
db,
session_id=request.session_id,
scenario_id=request.scenario_id,
mode=request.mode,
trainee_name=request.trainee_name,
trainee_id=request.trainee_id,
owner_login=request.owner_login,
backend_node_id=request.backend_node_id or self._node_id,
commit=False,
)
if row.backend_fencing_epoch <= 0:
row.backend_fencing_epoch = 1
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
db.add(AuditLog(
actor=request.owner_login or "system",
role="instructor" if request.owner_login else "system",
action="lesson.start",
object_id=str(row.id),
detail=f"{request.scenario_id}, mode {request.mode}",
))
service = None
if row.trainee_id is not None:
service = await db.scalar(
select(User.service)
.where(User.trainee_id == row.trainee_id, User.blocked.is_(False))
.limit(1)
)
epoch = row.backend_fencing_epoch
state = build(LessonIdentity(
attempt=row.attempt, trainee_id=row.trainee_id,
service=service, fencing_epoch=epoch,
))
if state.started_at is not None:
row.started_at = state.started_at
row.live_state = dump_state(state)
row.checkpoint_at = now_utc()
await db.commit()
self._epochs[request.session_id] = epoch
return state
async def commit(self, state: SessionState, records: Sequence[Record] = ()) -> None:
session_id = state.session_id
if state.backend_fencing_epoch > 0:
self._epochs.setdefault(session_id, state.backend_fencing_epoch)
try:
async with self._sessionmaker() as db:
await self._fence(db, session_id, state.backend_fencing_epoch or None)
for record in records:
await self._apply(db, session_id, record)
values = (
{"live_state": None, "checkpoint_at": None}
if state.ended
else {"live_state": dump_state(state), "checkpoint_at": now_utc()}
)
await db.execute(
update(Session).where(Session.id == session_id).values(**values)
)
await db.commit()
except SessionLeaseLost:
raise
except Exception as exc:
# SQL-трасса несёт реплики и комментарии курсанта — в лог только тип.
log.error("хранилище: commit занятия %s не удался (%s)",
session_id, type(exc).__name__)
raise
async def commit_archived(self, session_id: UUID, records: Sequence[Record]) -> None:
"""Занятия нет в памяти узла, владеть нечем: без lease и снимка."""
try:
async with self._sessionmaker() as db:
for record in records:
await self._apply(db, session_id, record)
await db.commit()
except Exception as exc:
log.error("хранилище: запись архивного занятия %s не удалась (%s)",
session_id, type(exc).__name__)
raise
async def _apply(self, db: AsyncSession, session_id: UUID, record: Record) -> None:
match record:
case UtteranceAppended(entry=entry):
db.add(Utterance(
session_id=session_id, ref=entry.ref, speaker=entry.speaker.value,
text=entry.text, at=entry.at,
mood=entry.mood.value if entry.mood else None,
))
case HintRecorded():
db.add(HintUse(
session_id=session_id, checklist_id=record.checklist_id,
question=record.question, at=record.at,
))
case NoteAdded():
db.add(InstructorNote(
session_id=session_id, transcript_ref=record.transcript_ref,
text=record.text, author=record.author,
))
case SelfAssessed():
actor, role = await self._trainee_actor(db, session_id)
db.add(SelfAssessment(
session_id=session_id, missed=record.missed,
comment=record.comment, submitted_at=record.at,
))
db.add(AuditLog(
actor=actor, role=role, action="self_assessment.submit",
object_id=str(session_id),
detail=f"missed_count={len(record.missed)}; comment_chars={len(record.comment)}",
))
case LessonStarted(at=at):
await db.execute(
update(Session).where(Session.id == session_id).values(started_at=at)
)
case LessonEnded(at=at, reason=reason):
await db.execute(
update(Session).where(Session.id == session_id)
.values(ended_at=at, end_reason=reason)
)
case ScoreCalculated():
db.add(Score(
session_id=session_id, score_auto=record.score_auto,
score_final=record.score_auto, report=record.report,
))
db.add(AuditLog(
actor="system", role="system", action="score.calculate",
object_id=str(session_id), detail=f"score_auto={record.score_auto}",
))
case ScoreArchived():
await db.flush()
await db.execute(
update(Score).where(Score.session_id == session_id)
.values(report=record.report)
)
case ScoreOverridden():
await db.flush()
score = await db.scalar(
select(Score).where(Score.session_id == session_id).with_for_update()
)
if score is None:
raise LookupError(f"нет оценки занятия {session_id}")
score.score_final = record.score_final
score.overridden_by = record.author
score.override_comment = record.comment
score.report = apply_score_override(dict(score.report or {}), record)
db.add(AuditLog(
actor=record.author, role=record.role, action="score.override",
object_id=str(session_id),
# Обоснование остаётся в разборе; аудиту нужны изменение и автор,
# а не вторая бессрочная копия свободного текста.
detail=(f"{score.score_auto} → {record.score_final}; "
f"comment_chars={len(record.comment)}"),
))
await db.flush()
@staticmethod
async def _trainee_actor(db: AsyncSession, session_id: UUID) -> tuple[str, str]:
session = await db.get(Session, session_id)
if session is None or session.trainee_id is None:
return "system", "system"
login = await db.scalar(select(User.login).where(User.trainee_id == session.trainee_id))
return (login, "trainee") if login else (f"trainee:{session.trainee_id}", "trainee")
async def renew(self, session_id: UUID) -> None:
"""Refresh an owned session lease; concurrent takeover is row-serialized."""
async with self._sessionmaker() as db:
await self._fence(db, session_id)
await db.commit()
async def claim_expired(self, session_id: UUID | None = None) -> list[SessionState]:
"""Atomically fence and restore expired owners on this backend node."""
if self._node_id is None:
return []
now = now_utc()
conditions = [
Session.ended_at.is_(None),
Session.live_state.is_not(None),
Session.checkpoint_at.is_not(None),
(Session.backend_node_id.is_(None) | (Session.backend_node_id != self._node_id)),
(Session.backend_lease_until.is_(None) | (Session.backend_lease_until <= now)),
]
if session_id is not None:
conditions.append(Session.id == session_id)
async with self._sessionmaker() as db:
rows = (await db.scalars(
select(Session).where(*conditions).with_for_update(skip_locked=True).limit(100)
)).all()
for row in rows:
row.backend_node_id = self._node_id
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
row.backend_lease_until = now + timedelta(seconds=LEASE_SECONDS)
if rows:
await db.commit()
if not rows:
return []
return await self.restore_active(bump_owned_epoch=False)
async def restore_active(self, *, bump_owned_epoch: bool = True) -> list[SessionState]:
"""Восстановить только незавершённые сессии с валидным снимком."""
restored: list[SessionState] = []
async with self._sessionmaker() as db:
active_with_snapshot = (
Session.ended_at.is_(None),
Session.live_state.is_not(None),
Session.checkpoint_at.is_not(None),
)
if self._node_id is not None:
# Adopt legacy unassigned snapshots exactly once. Concurrent
# nodes lock disjoint rows; subsequent restores are owner-only.
unassigned = (await db.scalars(
select(Session)
.where(*active_with_snapshot, Session.backend_node_id.is_(None))
.with_for_update(skip_locked=True)
)).all()
for row in unassigned:
row.backend_node_id = self._node_id
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
if unassigned:
await db.commit()
# A restarted process with the same stable node ID is a new
# owner generation. Bump before exposing any restored state.
owned = (await db.scalars(
select(Session)
.where(*active_with_snapshot, Session.backend_node_id == self._node_id)
.with_for_update(skip_locked=True)
)).all()
if bump_owned_epoch:
for row in owned:
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
if owned:
await db.commit()
rows = (await db.scalars(
select(Session).where(
*active_with_snapshot,
Session.backend_node_id == self._node_id,
)
)).all()
else:
rows = (await db.scalars(
select(Session).where(*active_with_snapshot)
)).all()
for row in rows:
try:
state = load_state(row.live_state, row.checkpoint_at)
if state.session_id != row.id:
raise ValueError("ID снимка не совпадает с записью занятия")
state.owner_login = row.owner_login
state.backend_fencing_epoch = row.backend_fencing_epoch
self._epochs[row.id] = row.backend_fencing_epoch
# Стенограмма — по строкам реплик: снимок, записанный до
# перехода на единый commit, мог отстать от них.
utterances = (await db.scalars(
select(Utterance)
.where(Utterance.session_id == row.id)
.order_by(Utterance.at, Utterance.ref)
)).all()
if utterances:
state.transcript = [
TranscriptEntry(
ref=item.ref,
speaker=Speaker(item.speaker),
text=item.text,
at=item.at,
mood=Mood(item.mood) if item.mood else None,
)
for item in utterances
]
restored.append(state)
except Exception as exc: # noqa: BLE001 — один снимок не блокирует весь стенд
log.error("журнал: снимок занятия %s повреждён (%s)",
row.id, type(exc).__name__)
return restored

View file

@ -0,0 +1,215 @@
"""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 []

View file

@ -11,7 +11,7 @@ import asyncio
import hashlib
import logging
import time
from collections.abc import Callable
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from pathlib import Path
from uuid import UUID, uuid4
@ -68,7 +68,8 @@ class VoiceSession:
send_event: Callable[[object], None]
send_observer: Callable[[object], None]
send_audio: Callable[[bytes], None]
journal: object | None = None
#: Сохранить реплику одной операцией хранилища; без него реплика живёт в памяти.
persist: Callable[[object], Awaitable[None]] | None = None
timings: list[TurnTiming] = field(default_factory=list)
_vad: StreamingVad = field(init=False)
@ -149,8 +150,8 @@ class VoiceSession:
entry = self.state.append(Speaker.OPERATOR, text)
self.send_event(SttFinal(text=text, at=entry.at))
self.send_observer(TranscriptAppend(entry=entry))
if self.journal:
await self.journal.utterance(self.session_id, entry)
if self.persist is not None:
await self.persist(entry)
started = time.monotonic()
line = await self._caller_line(text)
@ -174,8 +175,8 @@ class VoiceSession:
entry = self.state.append(Speaker.CALLER, text, mood)
self.send_event(CallerUtterance(utterance_id=utterance_id, text=text, at=entry.at, mood=mood))
self.send_observer(TranscriptAppend(entry=entry))
if self.journal:
await self.journal.utterance(self.session_id, entry)
if self.persist is not None:
await self.persist(entry)
self.send_event(TtsBegin(utterance_id=utterance_id))
playback_ends = time.monotonic()