refactor: запись хода занятия — один commit SessionStore на операцию вместо журнала и checkpoint
Правка балла с пульта и из отчёта идёт одной доменной операцией: раньше WS-путь не обновлял full_report живой сессии.
This commit is contained in:
parent
72c6a7cfa3
commit
47cb85ee02
30 changed files with 1406 additions and 1161 deletions
|
|
@ -20,7 +20,7 @@ from app.api.auth import add_audit_entry, audit, audit_required, require
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db import repo
|
from app.db import repo
|
||||||
from app.db.base import get_session
|
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.events import Exercise, SessionMode, SessionReport
|
||||||
from app.domain.roles import Role
|
from app.domain.roles import Role
|
||||||
from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current
|
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.export import to_csv, to_pdf
|
||||||
from app.scoring.report import build as build_report
|
from app.scoring.report import build as build_report
|
||||||
from app.session.checkpoint import load_state
|
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.hub import hub
|
||||||
|
from app.session.store import ScoreOverridden, apply_score_override
|
||||||
from app.voice.recording import recording_path
|
from app.voice.recording import recording_path
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
||||||
|
|
@ -424,14 +425,14 @@ async def _report_data(
|
||||||
raise HTTPException(status_code=409, detail="self_assessment_required")
|
raise HTTPException(status_code=409, detail="self_assessment_required")
|
||||||
if state.score is None:
|
if state.score is None:
|
||||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
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(
|
persisted_session = await db.scalar(
|
||||||
select(Session.id).where(Session.id == session_id)
|
select(Session.id).where(Session.id == session_id)
|
||||||
)
|
)
|
||||||
if (persisted_session is not None and await db.scalar(
|
if (persisted_session is not None and await db.scalar(
|
||||||
select(Score.session_id).where(Score.session_id == session_id)
|
select(Score.session_id).where(Score.session_id == session_id)
|
||||||
) is None):
|
) 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
|
# Do not expose a report that looks ready but cannot yet be corrected
|
||||||
# or retrieved after restart.
|
# or retrieved after restart.
|
||||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||||
|
|
@ -547,85 +548,47 @@ async def override(
|
||||||
session_id: UUID,
|
session_id: UUID,
|
||||||
body: ScoreOverride,
|
body: ScoreOverride,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_session),
|
db: AsyncSession | None = Depends(optional_session),
|
||||||
) -> SessionReport:
|
) -> SessionReport:
|
||||||
"""Тренажёр готовит материал, преподаватель имеет последнее слово.
|
"""Тренажёр готовит материал, преподаватель имеет последнее слово.
|
||||||
|
|
||||||
Администратору сюда нельзя: ТЗ запрещает ему вмешиваться в оценки прямо.
|
Администратору сюда нельзя: ТЗ запрещает ему вмешиваться в оценки прямо.
|
||||||
|
Правка идёт тем же путём, что команда `score.override` с пульта.
|
||||||
"""
|
"""
|
||||||
who = require(request, Role.INSTRUCTOR)
|
who = require(request, Role.INSTRUCTOR)
|
||||||
session = await repo.get_session(db, session_id)
|
correction = ScoreOverridden(
|
||||||
if session is None:
|
score_final=body.score_final, author=who.login, role=who.role.value,
|
||||||
raise HTTPException(status_code=404, detail="session_not_found")
|
comment=body.comment,
|
||||||
if session.owner_login != who.login:
|
)
|
||||||
raise HTTPException(status_code=404, detail="session_not_found")
|
|
||||||
state = hub.get(session_id)
|
state = hub.get(session_id)
|
||||||
live_ready = state is not None and state.score is not None
|
if state is not None:
|
||||||
scenario = scoring_scenario(state) if live_ready else None
|
if state.owner_login != who.login:
|
||||||
if live_ready and scenario is None:
|
raise HTTPException(status_code=404, detail="session_not_found")
|
||||||
raise HTTPException(status_code=409, detail="scenario_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))
|
score = await db.scalar(select(Score).where(Score.session_id == session_id))
|
||||||
if score is None:
|
if score is None:
|
||||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||||
archived = (score.report or {}).get("full_report")
|
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")
|
raise HTTPException(status_code=409, detail="report_not_archived")
|
||||||
|
report = apply_score_override(dict(score.report), correction)
|
||||||
# Persist score and audit entry in the same request transaction: the API
|
await db.rollback() # чтение закончено; запись — одной транзакцией хранилища
|
||||||
# must not report success if either durable record failed to commit.
|
await hub.store.commit_archived(session_id, [correction])
|
||||||
score.score_final = body.score_final
|
return SessionReport.model_validate(report["full_report"])
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[SessionOut])
|
@router.get("", response_model=list[SessionOut])
|
||||||
|
|
|
||||||
|
|
@ -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.finish import finish, refresh_archived_report, release_score
|
||||||
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
||||||
from app.session.state import now_utc
|
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.models import TTS_RATE, get_voice_models
|
||||||
from app.voice.pipeline import VoiceSession
|
from app.voice.pipeline import VoiceSession
|
||||||
from app.voice.recording import start_recording
|
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)
|
accepted = TextTurnAccepted(text=event.text, at=operator_entry.at)
|
||||||
hub.to_trainee(session_id, accepted)
|
hub.to_trainee(session_id, accepted)
|
||||||
hub.to_observers(session_id, TranscriptAppend(entry=operator_entry))
|
hub.to_observers(session_id, TranscriptAppend(entry=operator_entry))
|
||||||
if hub.journal:
|
hub.record(session_id, UtteranceAppended(operator_entry))
|
||||||
await hub.journal.utterance(session_id, operator_entry)
|
|
||||||
try:
|
try:
|
||||||
slots = state.slots if state.slots is not None else _TextSlotView(state)
|
slots = state.slots if state.slots is not None else _TextSlotView(state)
|
||||||
line = await state.caller.reply(turn, state.persona, slots)
|
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,
|
at=caller_entry.at, mood=line.mood, source=line.source,
|
||||||
))
|
))
|
||||||
hub.to_observers(session_id, TranscriptAppend(entry=caller_entry))
|
hub.to_observers(session_id, TranscriptAppend(entry=caller_entry))
|
||||||
if hub.journal:
|
hub.record(session_id, UtteranceAppended(caller_entry))
|
||||||
await hub.journal.utterance(session_id, caller_entry)
|
|
||||||
case "card.submit":
|
case "card.submit":
|
||||||
state.on_event("card.submit")
|
state.on_event("card.submit")
|
||||||
state.kio.registered_at = state.started_at or now_utc()
|
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())
|
hub.to_observers(session_id, state.snapshot())
|
||||||
else:
|
else:
|
||||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||||
if hub.journal:
|
hub.record(session_id, LessonEnded(state.ended_at, CallEndReason.COMPLETE.value))
|
||||||
await hub.journal.session_ended(
|
|
||||||
session_id, state.ended_at, CallEndReason.COMPLETE.value
|
|
||||||
)
|
|
||||||
await finish(session_id, state)
|
await finish(session_id, state)
|
||||||
case "call.answer":
|
case "call.answer":
|
||||||
first_answer = state.started_at is None
|
first_answer = state.started_at is None
|
||||||
if first_answer:
|
if first_answer:
|
||||||
state.on_event("call.answer")
|
state.on_event("call.answer")
|
||||||
state.started_at = now_utc()
|
state.started_at = now_utc()
|
||||||
if hub.journal:
|
hub.record(session_id, LessonStarted(state.started_at))
|
||||||
await hub.journal.session_started(session_id, state.started_at)
|
|
||||||
hub.to_trainee(session_id, CallStarted(started_at=state.started_at))
|
hub.to_trainee(session_id, CallStarted(started_at=state.started_at))
|
||||||
hub.to_observers(session_id, state.snapshot())
|
hub.to_observers(session_id, state.snapshot())
|
||||||
if state.recorder is None:
|
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()))
|
state.hints_log.append((checklist_id, now_utc()))
|
||||||
shown = HintShown(checklist_id=checklist_id, question=question)
|
shown = HintShown(checklist_id=checklist_id, question=question)
|
||||||
hub.broadcast(session_id, shown)
|
hub.broadcast(session_id, shown)
|
||||||
if hub.journal:
|
hub.record(session_id, HintRecorded(checklist_id, question, now_utc()))
|
||||||
await hub.journal.hint(session_id, checklist_id, question, now_utc())
|
|
||||||
|
|
||||||
case "dds.dispatch":
|
case "dds.dispatch":
|
||||||
if event.service is None and not (state.kio.incident_code or state.kio.notify):
|
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")
|
state.on_event("callback.dial")
|
||||||
|
|
||||||
case "self_assessment.submit":
|
case "self_assessment.submit":
|
||||||
if hub.journal and not await hub.journal.self_assessment(
|
hub.record(session_id, SelfAssessed(list(event.missed), event.comment, now_utc()))
|
||||||
session_id, event.missed, event.comment, now_utc()
|
|
||||||
):
|
|
||||||
hub.to_trainee(session_id, ErrorEvent(
|
|
||||||
code=ErrorKind.INTERNAL,
|
|
||||||
message="Не удалось сохранить самооценку и аудит; итог пока не выдан.",
|
|
||||||
))
|
|
||||||
return
|
|
||||||
state.self_assessed = True
|
state.self_assessed = True
|
||||||
state.self_assessment = {"missed": event.missed, "comment": event.comment}
|
state.self_assessment = {"missed": event.missed, "comment": event.comment}
|
||||||
await refresh_archived_report(session_id, state)
|
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.stop_ticker(session_id)
|
||||||
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.HANGUP))
|
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.HANGUP))
|
||||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.HANGUP))
|
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.HANGUP))
|
||||||
if hub.journal:
|
hub.record(session_id, LessonEnded(state.ended_at, CallEndReason.HANGUP.value))
|
||||||
await hub.journal.session_ended(
|
|
||||||
session_id, state.ended_at, CallEndReason.HANGUP.value
|
|
||||||
)
|
|
||||||
await finish(session_id, state)
|
await finish(session_id, state)
|
||||||
|
|
||||||
await hub.checkpoint(session_id)
|
|
||||||
|
|
||||||
|
|
||||||
def _start_voice(session_id: UUID, state, *, initial_statement: bool = True) -> None:
|
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_event=lambda event: hub.to_trainee(session_id, event),
|
||||||
send_observer=lambda event: hub.to_observers(session_id, event),
|
send_observer=lambda event: hub.to_observers(session_id, event),
|
||||||
send_audio=send_audio,
|
send_audio=send_audio,
|
||||||
journal=hub.journal,
|
persist=lambda entry: hub.commit(session_id, UtteranceAppended(entry)),
|
||||||
)
|
)
|
||||||
if scenario.background:
|
if scenario.background:
|
||||||
event = BgStart(loop=scenario.background.loop, gain_db=scenario.background.gain_db)
|
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]),
|
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=str(payload)[:200]),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
async with hub.durable_transition(session_id):
|
async with hub.operation(session_id):
|
||||||
await _handle(session_id, state, event)
|
await _handle(session_id, state, event)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from uuid import UUID
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
from pydantic import TypeAdapter, ValidationError
|
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.config import get_settings
|
||||||
from app.db.base import get_sessionmaker
|
from app.db.base import get_sessionmaker
|
||||||
from app.db.repo import SessionNodeConflict
|
from app.db.repo import SessionNodeConflict
|
||||||
|
|
@ -49,7 +49,9 @@ from app.domain.timers import TimerCode
|
||||||
from app.scenarios import store
|
from app.scenarios import store
|
||||||
from app.session.dds import prepare_queue
|
from app.session.dds import prepare_queue
|
||||||
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
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.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.models import get_voice_models
|
||||||
from app.voice.pipeline import FILLERS, prefetch
|
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:
|
async def _start(session_id: UUID, event, who=None) -> None:
|
||||||
if event.handoff_to_dds and event.exercise is not Exercise.CARD:
|
if event.handoff_to_dds and event.exercise is not Exercise.CARD:
|
||||||
hub.to_observers(session_id, ErrorEvent(
|
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([
|
scenario_ids = list(dict.fromkeys([
|
||||||
event.scenario_id, *(event.scenario_ids or []), *(event.random_scenario_ids or []),
|
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 = {
|
catalog = {
|
||||||
scenario_id: scenario for scenario_id in scenario_ids
|
scenario_id: scenario for scenario_id in scenario_ids
|
||||||
if (scenario := store.get(scenario_id)) is not None
|
if (scenario := store.get(scenario_id)) is not None
|
||||||
|
|
@ -207,96 +273,33 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
||||||
else:
|
else:
|
||||||
scenarios = []
|
scenarios = []
|
||||||
|
|
||||||
attempt = 1
|
try:
|
||||||
recorded_trainee_id = event.trainee_id
|
state = await hub.store.open(LessonRequest(
|
||||||
recorded_service = None
|
session_id=session_id, scenario_id=scenario.id, mode=event.mode.value,
|
||||||
fencing_epoch = 0
|
trainee_name=event.trainee, trainee_id=event.trainee_id,
|
||||||
if hub.journal:
|
owner_login=who.login if who is not None else None,
|
||||||
try:
|
backend_node_id=get_settings().backend_node_id,
|
||||||
persisted = await hub.journal.start_lesson(
|
), lambda identity: _build_state(session_id, event, who, scenario, scenarios, identity))
|
||||||
session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id,
|
except SessionNodeConflict:
|
||||||
owner_login=who.login if who is not None else None,
|
hub.to_observers(session_id, ErrorEvent(
|
||||||
backend_node_id=get_settings().backend_node_id,
|
code=ErrorKind.FORBIDDEN,
|
||||||
)
|
message="Сессия закреплена за другим backend-узлом; проверьте маршрутизацию proxy",
|
||||||
if persisted is None:
|
))
|
||||||
hub.to_observers(session_id, ErrorEvent(
|
return
|
||||||
code=ErrorKind.INTERNAL,
|
except PermissionError:
|
||||||
message="Не удалось записать занятие и аудит; запуск отменён.",
|
hub.to_observers(session_id, ErrorEvent(
|
||||||
))
|
code=ErrorKind.FORBIDDEN,
|
||||||
return
|
message="Занятие создано другим преподавателем",
|
||||||
attempt, recorded_trainee_id, recorded_service, fencing_epoch = persisted
|
))
|
||||||
except SessionNodeConflict:
|
return
|
||||||
hub.to_observers(session_id, ErrorEvent(
|
except Exception as exc: # noqa: BLE001 — без долговечной истории занятие не стартует
|
||||||
code=ErrorKind.FORBIDDEN,
|
log.error("занятие %s не записано (%s)", session_id, type(exc).__name__)
|
||||||
message="Сессия закреплена за другим backend-узлом; проверьте маршрутизацию proxy",
|
hub.to_observers(session_id, ErrorEvent(
|
||||||
))
|
code=ErrorKind.INTERNAL,
|
||||||
return
|
message="Не удалось записать занятие и аудит; запуск отменён.",
|
||||||
except PermissionError:
|
))
|
||||||
hub.to_observers(session_id, ErrorEvent(
|
return
|
||||||
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
|
|
||||||
hub.register(state)
|
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).
|
# «Алло! Помогите!» должно прозвучать мгновенно (docs/arch/BACKEND.md).
|
||||||
|
|
@ -304,8 +307,6 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
||||||
models = get_voice_models()
|
models = get_voice_models()
|
||||||
if models is not None:
|
if models is not None:
|
||||||
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
|
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)
|
hub.start_ticker(session_id)
|
||||||
if event.exercise is Exercise.CALL:
|
if event.exercise is Exercise.CALL:
|
||||||
hub.to_trainee(
|
hub.to_trainee(
|
||||||
|
|
@ -340,7 +341,6 @@ async def _stop(session_id: UUID) -> None:
|
||||||
await state.voice.close()
|
await state.voice.close()
|
||||||
hub.stop_ticker(session_id)
|
hub.stop_ticker(session_id)
|
||||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
|
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 in {Exercise.DDS, Exercise.CARD}:
|
||||||
if state.exercise is Exercise.DDS or state.handoff_to_dds and state.dispatched_card is not None:
|
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))
|
hub.to_station(session_id, ScoreReady(session_id=session_id))
|
||||||
else:
|
else:
|
||||||
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.INSTRUCTOR))
|
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)
|
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:
|
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,):
|
if who is None or who.role not in (Role.INSTRUCTOR,):
|
||||||
await _reject(ws, "Недостаточно прав для этого экрана")
|
await _reject(ws, "Недостаточно прав для этого экрана")
|
||||||
return
|
return
|
||||||
event_stream = hub.begin_event_stream(session_id)
|
|
||||||
# Чтение живёт дольше тика опроса: wait_for отменял бы его, а отмена после
|
# Чтение живёт дольше тика опроса: wait_for отменял бы его, а отмена после
|
||||||
# того, как receive уже забрал кадр, теряет команду или websocket.disconnect.
|
# того, как receive уже забрал кадр, теряет команду или websocket.disconnect.
|
||||||
# Команда обрабатывается здесь, в задаче, открывшей event stream.
|
# Команда обрабатывается здесь, одной операцией хранилища.
|
||||||
read: asyncio.Future | None = None
|
read: asyncio.Future | None = None
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -422,119 +509,13 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
||||||
))
|
))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
match event.type:
|
if event.type == "scenario.start":
|
||||||
case "scenario.start":
|
# Запуск пишется своей транзакцией в `store.open`: до неё занятия нет.
|
||||||
await _start(session_id, event, who)
|
await _command(session_id, event, who)
|
||||||
case "session.stop":
|
continue
|
||||||
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} ещё не реализовано",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
await hub.checkpoint(session_id)
|
async with hub.operation(session_id):
|
||||||
|
await _command(session_id, event, who)
|
||||||
except Exception:
|
except Exception:
|
||||||
if hub.is_lease_fenced(session_id):
|
if hub.is_lease_fenced(session_id):
|
||||||
await ws.send_text(ErrorEvent(
|
await ws.send_text(ErrorEvent(
|
||||||
|
|
@ -551,4 +532,3 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
||||||
finally:
|
finally:
|
||||||
if read is not None:
|
if read is not None:
|
||||||
read.cancel()
|
read.cancel()
|
||||||
await hub.end_event_stream(event_stream)
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
в отчёте и становится сорванным выездом с конкретной причиной.
|
в отчёте и становится сорванным выездом с конкретной причиной.
|
||||||
|
|
||||||
Правила пульта — в `DdsDesk.apply`; здесь разбор команды, отсев повторов,
|
Правила пульта — в `DdsDesk.apply`; здесь разбор команды, отсев повторов,
|
||||||
durable transition и рассылка итога.
|
операция хранилища и рассылка итога.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
@ -32,6 +32,7 @@ from app.domain.events import (
|
||||||
from app.domain.roles import Role
|
from app.domain.roles import Role
|
||||||
from app.session.finish import finish
|
from app.session.finish import finish
|
||||||
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
||||||
|
from app.session.store import LessonEnded
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -48,8 +49,7 @@ async def _finish_dds(session_id: UUID, state) -> None:
|
||||||
hub.stop_ticker(session_id)
|
hub.stop_ticker(session_id)
|
||||||
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||||
if hub.journal:
|
hub.record(session_id, LessonEnded(ended_at, CallEndReason.COMPLETE.value))
|
||||||
await hub.journal.session_ended(session_id, ended_at, CallEndReason.COMPLETE.value)
|
|
||||||
await finish(session_id, state)
|
await finish(session_id, state)
|
||||||
hub.to_station(session_id, ScoreReady(session_id=session_id))
|
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)
|
await _finish_dds(session_id, state)
|
||||||
if not outcome.changed:
|
if not outcome.changed:
|
||||||
return
|
return
|
||||||
await hub.checkpoint(session_id)
|
|
||||||
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
|
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
|
||||||
hub.to_observers(session_id, state.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.
|
# Re-ack it without rerunning its business transition.
|
||||||
hub.to_station(session_id, CommandAck(command_id=command_id))
|
hub.to_station(session_id, CommandAck(command_id=command_id))
|
||||||
continue
|
continue
|
||||||
async with hub.durable_transition(session_id):
|
async with hub.operation(session_id):
|
||||||
await _handle(session_id, state, event)
|
await _handle(session_id, state, event)
|
||||||
if command_id is not None:
|
if command_id is not None:
|
||||||
|
# ID команды входит в тот же снимок, что и её переход:
|
||||||
|
# подтверждение уходит только после их общего commit.
|
||||||
state.processed_station_commands.append(str(command_id))
|
state.processed_station_commands.append(str(command_id))
|
||||||
del state.processed_station_commands[:-512]
|
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))
|
hub.to_station(session_id, CommandAck(command_id=command_id))
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
return
|
return
|
||||||
|
|
@ -178,7 +172,7 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
|
||||||
"закрытие станционного WebSocket после fencing занятия %s",
|
"закрытие станционного WebSocket после fencing занятия %s",
|
||||||
session_id,
|
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
|
# propagating the failed database write. Let the sender deliver
|
||||||
# that event and close with 1012 instead of an opaque 1006.
|
# that event and close with 1012 instead of an opaque 1006.
|
||||||
await asyncio.gather(sender, return_exceptions=True)
|
await asyncio.gather(sender, return_exceptions=True)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ async def create_session(
|
||||||
owner_login: str | None = None,
|
owner_login: str | None = None,
|
||||||
backend_node_id: str | None = None,
|
backend_node_id: str | None = None,
|
||||||
before_commit: BeforeSessionCommit | None = None,
|
before_commit: BeforeSessionCommit | None = None,
|
||||||
|
commit: bool = True,
|
||||||
) -> Session:
|
) -> Session:
|
||||||
session = Session(
|
session = Session(
|
||||||
scenario_id=scenario_id,
|
scenario_id=scenario_id,
|
||||||
|
|
@ -56,7 +57,10 @@ async def create_session(
|
||||||
if before_commit is not None:
|
if before_commit is not None:
|
||||||
await db.flush()
|
await db.flush()
|
||||||
before_commit(db, session)
|
before_commit(db, session)
|
||||||
await db.commit()
|
if commit:
|
||||||
|
await db.commit()
|
||||||
|
else:
|
||||||
|
await db.flush()
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -72,6 +76,7 @@ async def ensure_session(
|
||||||
owner_login: str | None = None,
|
owner_login: str | None = None,
|
||||||
backend_node_id: str | None = None,
|
backend_node_id: str | None = None,
|
||||||
before_commit: BeforeSessionCommit | None = None,
|
before_commit: BeforeSessionCommit | None = None,
|
||||||
|
commit: bool = True,
|
||||||
) -> Session:
|
) -> Session:
|
||||||
"""Занятие, запущенное с пульта, должно иметь строку в журнале.
|
"""Занятие, запущенное с пульта, должно иметь строку в журнале.
|
||||||
|
|
||||||
|
|
@ -98,8 +103,7 @@ async def ensure_session(
|
||||||
changed = True
|
changed = True
|
||||||
if before_commit is not None:
|
if before_commit is not None:
|
||||||
before_commit(db, existing)
|
before_commit(db, existing)
|
||||||
await db.commit()
|
if commit and (before_commit is not None or changed):
|
||||||
elif changed:
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return existing
|
return existing
|
||||||
|
|
||||||
|
|
@ -123,6 +127,7 @@ async def ensure_session(
|
||||||
owner_login=owner_login,
|
owner_login=owner_login,
|
||||||
backend_node_id=backend_node_id,
|
backend_node_id=backend_node_id,
|
||||||
before_commit=before_commit,
|
before_commit=before_commit,
|
||||||
|
commit=commit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ from app.monitoring import install_diagnostics
|
||||||
from app.scenarios import store
|
from app.scenarios import store
|
||||||
from app.scenarios.loader import ScenarioError
|
from app.scenarios.loader import ScenarioError
|
||||||
from app.session.hub import hub
|
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
|
from app.voice.models import get_voice_models
|
||||||
|
|
||||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||||
|
|
@ -77,7 +78,7 @@ async def lifespan(app: FastAPI):
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logging.getLogger(__name__).warning(
|
logging.getLogger(__name__).warning(
|
||||||
"DEMO_NO_DB: занятия и оценки живут только до перезапуска; журнал БД выключен"
|
"DEMO_NO_DB: занятия и оценки живут только до перезапуска; хранилище в памяти"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not settings.demo_no_db:
|
if not settings.demo_no_db:
|
||||||
|
|
@ -89,16 +90,17 @@ async def lifespan(app: FastAPI):
|
||||||
"не удалось загрузить версии полномочий: %s", exc
|
"не удалось загрузить версии полномочий: %s", exc
|
||||||
)
|
)
|
||||||
|
|
||||||
# Журнал: всё, что не записано, для оценки не существует.
|
# Хранилище: всё, что не записано, для оценки не существует. Без БД —
|
||||||
hub.journal = (
|
# in-memory adapter, занятие живёт до перезапуска.
|
||||||
None if settings.demo_no_db
|
hub.store = (
|
||||||
else DbJournal(get_sessionmaker(), node_id=settings.backend_node_id)
|
MemorySessionStore() if settings.demo_no_db
|
||||||
|
else PostgresSessionStore(get_sessionmaker(), node_id=settings.backend_node_id)
|
||||||
)
|
)
|
||||||
app.state.sessions_restored = 0
|
app.state.sessions_restored = 0
|
||||||
if hub.journal is not None:
|
if hub.store.persistent:
|
||||||
try:
|
try:
|
||||||
async with asyncio.timeout(3):
|
async with asyncio.timeout(3):
|
||||||
restored = await hub.journal.restore_active()
|
restored = await hub.store.restore_active()
|
||||||
for state in restored:
|
for state in restored:
|
||||||
hub.register(state)
|
hub.register(state)
|
||||||
hub.start_ticker(state.session_id)
|
hub.start_ticker(state.session_id)
|
||||||
|
|
@ -113,18 +115,16 @@ async def lifespan(app: FastAPI):
|
||||||
)
|
)
|
||||||
|
|
||||||
lease_task = None
|
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:
|
async def supervise_session_ownership() -> None:
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
journal = hub.journal
|
store = hub.store
|
||||||
if journal is None:
|
|
||||||
return
|
|
||||||
for state in list(hub._sessions.values()):
|
for state in list(hub._sessions.values()):
|
||||||
if state.ended or state.lease_fenced:
|
if state.ended or state.lease_fenced:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
await journal.renew(state.session_id)
|
await store.renew(state.session_id)
|
||||||
except SessionLeaseLost:
|
except SessionLeaseLost:
|
||||||
await hub.fence(state)
|
await hub.fence(state)
|
||||||
except (SQLAlchemyError, OSError, TimeoutError):
|
except (SQLAlchemyError, OSError, TimeoutError):
|
||||||
|
|
@ -139,7 +139,7 @@ async def lifespan(app: FastAPI):
|
||||||
)
|
)
|
||||||
await hub.fence(state)
|
await hub.fence(state)
|
||||||
try:
|
try:
|
||||||
restored = await journal.claim_expired()
|
restored = await store.claim_expired()
|
||||||
for state in restored:
|
for state in restored:
|
||||||
current = hub._sessions.get(state.session_id)
|
current = hub._sessions.get(state.session_id)
|
||||||
if current is not None and not current.lease_fenced:
|
if current is not None and not current.lease_fenced:
|
||||||
|
|
@ -178,11 +178,11 @@ async def lifespan(app: FastAPI):
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await lease_task
|
await lease_task
|
||||||
|
|
||||||
if hub.journal is not None:
|
if hub.store.persistent:
|
||||||
for state in list(hub._sessions.values()):
|
for state in list(hub._sessions.values()):
|
||||||
if not state.ended:
|
if not state.ended and not state.lease_fenced:
|
||||||
try:
|
try:
|
||||||
await hub.journal.checkpoint(state)
|
await hub.store.commit(state)
|
||||||
except Exception: # noqa: BLE001 — shutdown must release the process
|
except Exception: # noqa: BLE001 — shutdown must release the process
|
||||||
logging.getLogger(__name__).exception(
|
logging.getLogger(__name__).exception(
|
||||||
"не удалось сохранить checkpoint %s при shutdown", state.session_id
|
"не удалось сохранить checkpoint %s при shutdown", state.session_id
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import logging
|
||||||
import time
|
import time
|
||||||
from uuid import UUID
|
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.statuses import ServiceStatus, current
|
||||||
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||||
from app.domain.timers import TimerCode
|
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.scoring.weights import apply_weights
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
|
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
|
||||||
|
from app.session.store import ScoreArchived, ScoreCalculated, ScoreOverridden, apply_score_override
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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")
|
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))
|
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):
|
# Оценка и аудит уходят в commit операции, завершившей занятие; ScoreReady
|
||||||
hub.to_observers(session_id, ErrorEvent(
|
# ждёт того же commit — без записи итог не выдаётся.
|
||||||
code=ErrorKind.INTERNAL,
|
hub.record(session_id, ScoreCalculated(result.score, state.score))
|
||||||
message="Не удалось сохранить оценку и аудит; итог не выдан. Обратитесь к преподавателю.",
|
|
||||||
))
|
|
||||||
return
|
|
||||||
|
|
||||||
hub.to_observers(session_id, ScoreReady(session_id=session_id))
|
hub.to_observers(session_id, ScoreReady(session_id=session_id))
|
||||||
await release_score(session_id, state)
|
await release_score(session_id, state)
|
||||||
|
|
||||||
|
|
@ -370,11 +367,21 @@ async def refresh_archived_report(session_id: UUID, state) -> None:
|
||||||
if scenario is None:
|
if scenario is None:
|
||||||
return
|
return
|
||||||
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
||||||
if hub.journal:
|
hub.record(session_id, ScoreArchived(state.score))
|
||||||
await hub.journal.score_snapshot(session_id, state.score)
|
|
||||||
|
|
||||||
|
|
||||||
async def release_score(session_id: UUID, state) -> None:
|
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):
|
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))
|
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))
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import logging
|
||||||
from contextvars import ContextVar, Token
|
from contextvars import ContextVar, Token
|
||||||
from collections.abc import AsyncIterator, Iterator
|
from collections.abc import AsyncIterator, Iterator
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Protocol
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -24,6 +23,7 @@ from app.domain.events import (
|
||||||
TimerTick,
|
TimerTick,
|
||||||
)
|
)
|
||||||
from app.session.state import SessionState
|
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__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _current_task():
|
class _Operation:
|
||||||
try:
|
"""Отложенная публикация одной операции: события ждут её commit."""
|
||||||
return asyncio.current_task()
|
|
||||||
except RuntimeError: # synchronous tests and tooling have no running loop
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
def __init__(self, session_id: UUID) -> None:
|
||||||
class Journal(Protocol):
|
self.session_id = session_id
|
||||||
"""Запись в БД. Вынесена за хаб: без базы занятие должно идти,
|
self.records: list[Record] = []
|
||||||
но молчать об ошибке записи нельзя."""
|
self.events: list[tuple[dict[UUID, set[asyncio.Queue]], BaseModel]] = []
|
||||||
|
self.open = True
|
||||||
async def start_lesson(
|
#: Такт, которому нечего сохранять, не пишет снимок каждую секунду.
|
||||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
|
self.persist = True
|
||||||
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]: ...
|
|
||||||
|
|
||||||
|
|
||||||
class SessionHub:
|
class SessionHub:
|
||||||
def __init__(self, journal: Journal | None = None) -> None:
|
def __init__(self, store: SessionStore | None = None) -> None:
|
||||||
self.journal = journal
|
self.store: SessionStore = store if store is not None else MemorySessionStore()
|
||||||
self._sessions: dict[UUID, SessionState] = {}
|
self._sessions: dict[UUID, SessionState] = {}
|
||||||
self._observers: dict[UUID, set[asyncio.Queue]] = {}
|
self._observers: dict[UUID, set[asyncio.Queue]] = {}
|
||||||
self._trainees: dict[UUID, set[asyncio.Queue]] = {}
|
self._trainees: dict[UUID, set[asyncio.Queue]] = {}
|
||||||
self._stations: dict[UUID, set[asyncio.Queue]] = {}
|
self._stations: dict[UUID, set[asyncio.Queue]] = {}
|
||||||
self._tickers: dict[UUID, asyncio.Task] = {}
|
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._sessions.pop(session_id, None)
|
||||||
self.stop_ticker(session_id)
|
self.stop_ticker(session_id)
|
||||||
|
|
||||||
async def checkpoint(self, session_id: UUID) -> None:
|
# ── операция: один commit, потом события ──
|
||||||
"""Зафиксировать подтверждённое состояние, если журнал доступен."""
|
|
||||||
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)
|
|
||||||
|
|
||||||
@contextlib.asynccontextmanager
|
@contextlib.asynccontextmanager
|
||||||
async def durable_transition(self, session_id: UUID):
|
async def operation(self, session_id: UUID) -> AsyncIterator[_Operation]:
|
||||||
"""Do not publish state-changing events until its checkpoint commits."""
|
"""Изменение занятия фиксируется одним `store.commit` на выходе.
|
||||||
batch = {
|
|
||||||
"session_id": session_id, "events": [], "committed": False,
|
События копятся до коммита. Исключение внутри операции или сбой
|
||||||
"persistent": False, "owner_task": _current_task(),
|
коммита отбрасывают их и закрывают занятие на узле (fail closed).
|
||||||
}
|
"""
|
||||||
token: Token = self._event_batch.set(batch)
|
op = _Operation(session_id)
|
||||||
|
token: Token = self._operation.set(op)
|
||||||
try:
|
try:
|
||||||
yield
|
yield op
|
||||||
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)
|
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)
|
await self.fence(state)
|
||||||
raise
|
raise
|
||||||
finally:
|
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:
|
def record(self, session_id: UUID, record: Record) -> None:
|
||||||
batch = self._event_batch.get()
|
"""Строка журнала уходит в commit текущей операции, не отдельной транзакцией."""
|
||||||
if batch is not None and batch["session_id"] == session_id:
|
op = self._operation.get()
|
||||||
batch["events"].clear()
|
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:
|
async def commit(self, session_id: UUID, *records: Record) -> None:
|
||||||
batch = self._event_batch.get()
|
"""Операция из одних строк — реплика голосового контура."""
|
||||||
if batch is None or batch["session_id"] != session_id:
|
async with self.operation(session_id):
|
||||||
return
|
for record in records:
|
||||||
pending, batch["events"] = batch["events"], []
|
self.record(session_id, record)
|
||||||
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,
|
def _send(self, registry: dict[UUID, set[asyncio.Queue]], session_id: UUID,
|
||||||
event: BaseModel) -> None:
|
event: BaseModel) -> None:
|
||||||
batch = self._event_batch.get()
|
op = self._operation.get()
|
||||||
if isinstance(event, ErrorEvent):
|
if (op is not None and op.open and op.session_id == session_id
|
||||||
self._put(registry.get(session_id, set()), event)
|
and not isinstance(event, ErrorEvent)):
|
||||||
elif (batch is not None and batch["session_id"] == session_id
|
op.events.append((registry, event))
|
||||||
and batch["owner_task"] is _current_task()):
|
|
||||||
batch["events"].append((registry, session_id, event))
|
|
||||||
else:
|
else:
|
||||||
self._put(registry.get(session_id, set()), event)
|
self._put(registry.get(session_id, set()), event)
|
||||||
|
|
||||||
|
|
@ -316,12 +278,11 @@ class SessionHub:
|
||||||
if state is None or state.ended:
|
if state is None or state.ended:
|
||||||
return
|
return
|
||||||
if state.dds_phase:
|
if state.dds_phase:
|
||||||
active_before = state.desk.active_id
|
async with self.operation(session_id) as op:
|
||||||
delivered = state.desk.deliver_due()
|
active_before = state.desk.active_id
|
||||||
if state.desk.active_id != active_before and state.desk.active_id:
|
op.persist = bool(state.desk.deliver_due())
|
||||||
self.to_station(session_id, state.card_received_event())
|
if state.desk.active_id != active_before and state.desk.active_id:
|
||||||
if delivered:
|
self.to_station(session_id, state.card_received_event())
|
||||||
await self.checkpoint(session_id)
|
|
||||||
# Keep the pending count and countdown live even while
|
# Keep the pending count and countdown live even while
|
||||||
# the active dispatcher card is being handled.
|
# the active dispatcher card is being handled.
|
||||||
self.to_station(session_id, StationState(snapshot=state.station_snapshot()))
|
self.to_station(session_id, StationState(snapshot=state.station_snapshot()))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
362
backend/app/session/pg_store.py
Normal file
362
backend/app/session/pg_store.py
Normal 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
|
||||||
215
backend/app/session/store.py
Normal file
215
backend/app/session/store.py
Normal 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 []
|
||||||
|
|
@ -11,7 +11,7 @@ import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
@ -68,7 +68,8 @@ class VoiceSession:
|
||||||
send_event: Callable[[object], None]
|
send_event: Callable[[object], None]
|
||||||
send_observer: Callable[[object], None]
|
send_observer: Callable[[object], None]
|
||||||
send_audio: Callable[[bytes], None]
|
send_audio: Callable[[bytes], None]
|
||||||
journal: object | None = None
|
#: Сохранить реплику одной операцией хранилища; без него реплика живёт в памяти.
|
||||||
|
persist: Callable[[object], Awaitable[None]] | None = None
|
||||||
|
|
||||||
timings: list[TurnTiming] = field(default_factory=list)
|
timings: list[TurnTiming] = field(default_factory=list)
|
||||||
_vad: StreamingVad = field(init=False)
|
_vad: StreamingVad = field(init=False)
|
||||||
|
|
@ -149,8 +150,8 @@ class VoiceSession:
|
||||||
entry = self.state.append(Speaker.OPERATOR, text)
|
entry = self.state.append(Speaker.OPERATOR, text)
|
||||||
self.send_event(SttFinal(text=text, at=entry.at))
|
self.send_event(SttFinal(text=text, at=entry.at))
|
||||||
self.send_observer(TranscriptAppend(entry=entry))
|
self.send_observer(TranscriptAppend(entry=entry))
|
||||||
if self.journal:
|
if self.persist is not None:
|
||||||
await self.journal.utterance(self.session_id, entry)
|
await self.persist(entry)
|
||||||
|
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
line = await self._caller_line(text)
|
line = await self._caller_line(text)
|
||||||
|
|
@ -174,8 +175,8 @@ class VoiceSession:
|
||||||
entry = self.state.append(Speaker.CALLER, text, mood)
|
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_event(CallerUtterance(utterance_id=utterance_id, text=text, at=entry.at, mood=mood))
|
||||||
self.send_observer(TranscriptAppend(entry=entry))
|
self.send_observer(TranscriptAppend(entry=entry))
|
||||||
if self.journal:
|
if self.persist is not None:
|
||||||
await self.journal.utterance(self.session_id, entry)
|
await self.persist(entry)
|
||||||
|
|
||||||
self.send_event(TtsBegin(utterance_id=utterance_id))
|
self.send_event(TtsBegin(utterance_id=utterance_id))
|
||||||
playback_ends = time.monotonic()
|
playback_ends = time.monotonic()
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,13 @@ from app.api.auth import Principal
|
||||||
from app.domain.roles import Role
|
from app.domain.roles import Role
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
|
from app.session.store import MemorySessionStore
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
with TestClient(app) as test_client:
|
with TestClient(app) as test_client:
|
||||||
hub.journal = None
|
hub.store = MemorySessionStore()
|
||||||
yield test_client
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,13 @@ from app.api.auth import hash_password, verify_password
|
||||||
from app.domain.roles import Role
|
from app.domain.roles import Role
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
|
from app.session.store import MemorySessionStore
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
with TestClient(app) as test_client:
|
with TestClient(app) as test_client:
|
||||||
hub.journal = None
|
hub.store = MemorySessionStore()
|
||||||
yield test_client
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from app.api.http import admin
|
||||||
from app.domain.roles import Role
|
from app.domain.roles import Role
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
|
from app.session.store import MemorySessionStore
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|
@ -92,7 +93,7 @@ def client():
|
||||||
# process' in-memory cache.
|
# process' in-memory cache.
|
||||||
auth.prime_generations({})
|
auth.prime_generations({})
|
||||||
with TestClient(app) as test_client:
|
with TestClient(app) as test_client:
|
||||||
hub.journal = None
|
hub.store = MemorySessionStore()
|
||||||
yield test_client
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import pytest
|
||||||
from app.api.ws import call
|
from app.api.ws import call
|
||||||
from app.dialog.slots import TurnResult
|
from app.dialog.slots import TurnResult
|
||||||
from app.domain.events import Exercise, TranscriptEntry
|
from app.domain.events import Exercise, TranscriptEntry
|
||||||
|
from app.session.store import MemorySessionStore
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -24,7 +25,7 @@ async def test_text_dialogue_provider_error_does_not_log_prompt_or_provider_body
|
||||||
def revealed_facts(self):
|
def revealed_facts(self):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
monkeypatch.setattr(call.hub, "journal", None)
|
monkeypatch.setattr(call.hub, "store", MemorySessionStore())
|
||||||
monkeypatch.setattr(call.hub, "to_trainee", lambda *_args: None)
|
monkeypatch.setattr(call.hub, "to_trainee", lambda *_args: None)
|
||||||
monkeypatch.setattr(call.hub, "to_observers", lambda *_args: None)
|
monkeypatch.setattr(call.hub, "to_observers", lambda *_args: None)
|
||||||
state = SimpleNamespace(
|
state = SimpleNamespace(
|
||||||
|
|
@ -41,7 +42,9 @@ async def test_text_dialogue_provider_error_does_not_log_prompt_or_provider_body
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
await call._handle(uuid4(), state, SimpleNamespace(type="text.turn", text="where is the incident"))
|
session_id = uuid4()
|
||||||
|
async with call.hub.operation(session_id):
|
||||||
|
await call._handle(session_id, state, SimpleNamespace(type="text.turn", text="where is the incident"))
|
||||||
|
|
||||||
assert secret not in caplog.text
|
assert secret not in caplog.text
|
||||||
assert "RuntimeError" in caplog.text
|
assert "RuntimeError" in caplog.text
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from app.api.ws.call import _text_turn
|
||||||
from app.scenarios import store
|
from app.scenarios import store
|
||||||
from app.scoring.grammar import basic_check
|
from app.scoring.grammar import basic_check
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
|
from app.session.store import MemorySessionStore
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -23,7 +24,7 @@ def client(monkeypatch):
|
||||||
monkeypatch.setattr(sessions_http, "audit_required", audit_in_memory)
|
monkeypatch.setattr(sessions_http, "audit_required", audit_in_memory)
|
||||||
with TestClient(app) as test_client:
|
with TestClient(app) as test_client:
|
||||||
test_client.post("/api/auth/dev-token")
|
test_client.post("/api/auth/dev-token")
|
||||||
hub.journal = None
|
hub.store = MemorySessionStore()
|
||||||
yield test_client
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -450,41 +451,25 @@ def test_handoff_report_and_checklist_follow_operator_scenario(client, monkeypat
|
||||||
|
|
||||||
state.self_assessed = True
|
state.self_assessed = True
|
||||||
state.self_assessment = {"missed": [checklist[0]["id"]], "comment": ""}
|
state.self_assessment = {"missed": [checklist[0]["id"]], "comment": ""}
|
||||||
asyncio.run(refresh_archived_report(session_id, state))
|
async def refresh():
|
||||||
|
async with hub.operation(session_id):
|
||||||
|
await refresh_archived_report(session_id, state)
|
||||||
|
|
||||||
|
asyncio.run(refresh())
|
||||||
assert state.score["full_report"]["scenario_id"] == "fire-apartment-l2"
|
assert state.score["full_report"]["scenario_id"] == "fire-apartment-l2"
|
||||||
assert state.score["full_report"]["metrics"] == state.score["metrics"]
|
assert state.score["full_report"]["metrics"] == state.score["metrics"]
|
||||||
|
|
||||||
session_row = SimpleNamespace(id=session_id, owner_login="teacher")
|
|
||||||
score_row = SimpleNamespace(
|
|
||||||
score_auto=state.score["score_auto"], score_final=state.score["score_auto"],
|
|
||||||
overridden_by=None, override_comment=None,
|
|
||||||
report={"full_report": state.score["full_report"]},
|
|
||||||
)
|
|
||||||
|
|
||||||
class FakeDb:
|
|
||||||
async def scalar(self, statement):
|
|
||||||
return score_row
|
|
||||||
|
|
||||||
def add(self, row):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def commit(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def fake_session(db, key):
|
|
||||||
return session_row
|
|
||||||
|
|
||||||
monkeypatch.setattr(sessions_http.repo, "get_session", fake_session)
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
sessions_http, "require",
|
sessions_http, "require",
|
||||||
lambda request, *roles: Principal(login="teacher", full_name="Преподаватель",
|
lambda request, *roles: Principal(login=state.owner_login, full_name="Преподаватель",
|
||||||
role=Role.INSTRUCTOR),
|
role=Role.INSTRUCTOR),
|
||||||
)
|
)
|
||||||
corrected = asyncio.run(sessions_http.override(
|
corrected = asyncio.run(sessions_http.override(
|
||||||
session_id,
|
session_id,
|
||||||
sessions_http.ScoreOverride(score_final=50, comment="проверено"),
|
sessions_http.ScoreOverride(score_final=50, comment="проверено"),
|
||||||
object(), FakeDb(),
|
object(), None,
|
||||||
))
|
))
|
||||||
assert corrected.scenario_id == "fire-apartment-l2"
|
assert corrected.scenario_id == "fire-apartment-l2"
|
||||||
|
assert state.score["full_report"]["score_final"] == 50
|
||||||
finally:
|
finally:
|
||||||
control.__exit__(None, None, None)
|
control.__exit__(None, None, None)
|
||||||
|
|
|
||||||
|
|
@ -27,18 +27,59 @@ from app.db.models import (
|
||||||
Score, SelfAssessment, Session, Trainee, User, Utterance,
|
Score, SelfAssessment, Session, Trainee, User, Utterance,
|
||||||
)
|
)
|
||||||
from app.db.repo import SessionNodeConflict, ensure_session
|
from app.db.repo import SessionNodeConflict, ensure_session
|
||||||
from app.domain.events import Exercise, SessionMode
|
from app.domain.events import Exercise, SessionMode, Speaker
|
||||||
from app.domain.roles import Role
|
from app.domain.roles import Role
|
||||||
from app.main import LIBRARY, app
|
from app.main import LIBRARY, app
|
||||||
from app.scenarios import store
|
from app.scenarios import store
|
||||||
from app.session.checkpoint import load_state
|
from app.session.checkpoint import load_state
|
||||||
from app.session.dds import build_card
|
from app.session.dds import build_card
|
||||||
from app.session.hub import hub
|
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 (
|
||||||
|
LessonRequest,
|
||||||
|
ScoreCalculated,
|
||||||
|
ScoreOverridden,
|
||||||
|
SelfAssessed,
|
||||||
|
SessionLeaseLost,
|
||||||
|
UtteranceAppended,
|
||||||
|
)
|
||||||
from app.session.state import SessionState
|
from app.session.state import SessionState
|
||||||
from app.session.timers import SessionTimers
|
from app.session.timers import SessionTimers
|
||||||
|
|
||||||
|
|
||||||
|
def _state(session_id, scenario, **fields) -> SessionState:
|
||||||
|
return SessionState(
|
||||||
|
session_id=session_id, scenario_id=scenario.id, scenario_title=scenario.title,
|
||||||
|
level=scenario.level, mode=SessionMode.TRAINING, **fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _open(store, scenario, session_id, trainee_name, trainee_id=None, owner_login=None):
|
||||||
|
"""Открыть занятие и вернуть его идентичность — как делает пульт."""
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def build(identity):
|
||||||
|
seen["identity"] = identity
|
||||||
|
return _state(
|
||||||
|
session_id, scenario, owner_login=owner_login, trainee_id=identity.trainee_id,
|
||||||
|
attempt=identity.attempt, backend_fencing_epoch=identity.fencing_epoch,
|
||||||
|
)
|
||||||
|
|
||||||
|
state = await store.open(LessonRequest(
|
||||||
|
session_id=session_id, scenario_id=scenario.id, mode="training",
|
||||||
|
trainee_name=trainee_name, trainee_id=trainee_id, owner_login=owner_login,
|
||||||
|
backend_node_id=None,
|
||||||
|
), build)
|
||||||
|
return state, seen["identity"]
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(text):
|
||||||
|
from app.domain.events import Speaker, TranscriptEntry
|
||||||
|
|
||||||
|
return TranscriptEntry(ref="u1", speaker=Speaker.CALLER, text=text,
|
||||||
|
at=datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def db():
|
async def db():
|
||||||
"""Свой движок на каждый тест: глобальный в app.db.base кэшируется и
|
"""Свой движок на каждый тест: глобальный в app.db.base кэшируется и
|
||||||
|
|
@ -340,16 +381,9 @@ async def test_station_command_ids_survive_a_real_postgres_checkpoint(db, scenar
|
||||||
db, scenario_id=scenario.id, mode="training", session_id=uuid4(),
|
db, scenario_id=scenario.id, mode="training", session_id=uuid4(),
|
||||||
)
|
)
|
||||||
command_id = str(uuid4())
|
command_id = str(uuid4())
|
||||||
state = SessionState(
|
state = _state(session.id, scenario, processed_station_commands=[command_id])
|
||||||
session_id=session.id,
|
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
||||||
scenario_id=scenario.id,
|
await pg.commit(state)
|
||||||
scenario_title=scenario.title,
|
|
||||||
level=scenario.level,
|
|
||||||
mode=SessionMode.TRAINING,
|
|
||||||
processed_station_commands=[command_id],
|
|
||||||
)
|
|
||||||
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False))
|
|
||||||
await journal.checkpoint(state)
|
|
||||||
|
|
||||||
observer_engine = create_async_engine(get_settings().database_url)
|
observer_engine = create_async_engine(get_settings().database_url)
|
||||||
try:
|
try:
|
||||||
|
|
@ -406,24 +440,22 @@ async def test_security_audit_older_than_six_months_remains_queryable(db, monkey
|
||||||
|
|
||||||
|
|
||||||
async def test_websocket_score_override_and_audit_commit_atomically(db, scenario):
|
async def test_websocket_score_override_and_audit_commit_atomically(db, scenario):
|
||||||
from app.session.journal import DbJournal
|
|
||||||
|
|
||||||
session = await repo.create_session(
|
session = await repo.create_session(
|
||||||
db, scenario_id=scenario.id, mode="training", owner_login="score-teacher"
|
db, scenario_id=scenario.id, mode="training", owner_login="score-teacher"
|
||||||
)
|
)
|
||||||
score = Score(
|
score = Score(
|
||||||
session_id=session.id, score_auto=70, score_final=70,
|
session_id=session.id, score_auto=70, score_final=70,
|
||||||
report={"full_report": {"score_auto": 70, "score_final": 70}},
|
report={"score_auto": 70, "full_report": {"score_auto": 70, "score_final": 70}},
|
||||||
)
|
)
|
||||||
db.add(score)
|
db.add(score)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
maker = async_sessionmaker(db.bind, expire_on_commit=False)
|
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
||||||
journal = DbJournal(maker)
|
state = _state(session.id, scenario, owner_login="score-teacher")
|
||||||
|
|
||||||
# Force the audit insert to fail after the score row is staged. PostgreSQL
|
# Force the audit insert to fail after the score row is staged. PostgreSQL
|
||||||
# must roll back both, and the websocket handler can then refuse the change.
|
# must roll back both, and the commit must fail the whole operation.
|
||||||
saved = await journal.score_override(session.id, 85, "x" * 81, "manual review")
|
with pytest.raises(Exception):
|
||||||
assert not saved
|
await pg.commit(state, [ScoreOverridden(85, "x" * 81, "instructor", "manual review")])
|
||||||
await db.refresh(score)
|
await db.refresh(score)
|
||||||
assert score.score_final == 70
|
assert score.score_final == 70
|
||||||
assert score.overridden_by is None
|
assert score.overridden_by is None
|
||||||
|
|
@ -433,14 +465,12 @@ async def test_websocket_score_override_and_audit_commit_atomically(db, scenario
|
||||||
)
|
)
|
||||||
) is None
|
) is None
|
||||||
|
|
||||||
saved = await journal.score_override(
|
await pg.commit(state, [ScoreOverridden(85, "score-teacher", "instructor", "manual review")])
|
||||||
session.id, 85, "score-teacher", "manual review"
|
|
||||||
)
|
|
||||||
assert saved
|
|
||||||
await db.refresh(score)
|
await db.refresh(score)
|
||||||
assert score.score_auto == 70
|
assert score.score_auto == 70
|
||||||
assert score.score_final == 85
|
assert score.score_final == 85
|
||||||
assert score.overridden_by == "score-teacher"
|
assert score.overridden_by == "score-teacher"
|
||||||
|
assert score.report["score_final"] == 85
|
||||||
assert score.report["full_report"]["score_final"] == 85
|
assert score.report["full_report"]["score_final"] == 85
|
||||||
audit_row = await db.scalar(
|
audit_row = await db.scalar(
|
||||||
select(AuditLog).where(
|
select(AuditLog).where(
|
||||||
|
|
@ -455,16 +485,16 @@ async def test_websocket_score_override_and_audit_commit_atomically(db, scenario
|
||||||
|
|
||||||
|
|
||||||
async def test_initial_result_and_calculation_audit_commit_atomically(db, scenario):
|
async def test_initial_result_and_calculation_audit_commit_atomically(db, scenario):
|
||||||
from app.session.journal import DbJournal
|
|
||||||
|
|
||||||
session = await repo.create_session(
|
session = await repo.create_session(
|
||||||
db, scenario_id=scenario.id, mode="training", owner_login="score-teacher"
|
db, scenario_id=scenario.id, mode="training", owner_login="score-teacher"
|
||||||
)
|
)
|
||||||
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False))
|
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
||||||
|
state = _state(session.id, scenario, owner_login="score-teacher")
|
||||||
|
|
||||||
# Invalid JSON makes PostgreSQL reject the score transaction; no orphaned
|
# Invalid JSON makes PostgreSQL reject the score transaction; no orphaned
|
||||||
# calculation-audit row may remain (or vice versa).
|
# calculation-audit row may remain (or vice versa).
|
||||||
assert not await journal.score(session.id, 71, {"bad": object()})
|
with pytest.raises(Exception):
|
||||||
|
await pg.commit(state, [ScoreCalculated(71, {"bad": object()})])
|
||||||
assert await db.scalar(
|
assert await db.scalar(
|
||||||
select(Score.id).where(Score.session_id == session.id)
|
select(Score.id).where(Score.session_id == session.id)
|
||||||
) is None
|
) is None
|
||||||
|
|
@ -472,7 +502,7 @@ async def test_initial_result_and_calculation_audit_commit_atomically(db, scenar
|
||||||
AuditLog.action == "score.calculate", AuditLog.object_id == str(session.id)
|
AuditLog.action == "score.calculate", AuditLog.object_id == str(session.id)
|
||||||
)) is None
|
)) is None
|
||||||
|
|
||||||
assert await journal.score(session.id, 71, {"full_report": {"score_auto": 71}})
|
await pg.commit(state, [ScoreCalculated(71, {"full_report": {"score_auto": 71}})])
|
||||||
score = await db.scalar(select(Score).where(Score.session_id == session.id))
|
score = await db.scalar(select(Score).where(Score.session_id == session.id))
|
||||||
audit_row = await db.scalar(select(AuditLog).where(
|
audit_row = await db.scalar(select(AuditLog).where(
|
||||||
AuditLog.action == "score.calculate", AuditLog.object_id == str(session.id)
|
AuditLog.action == "score.calculate", AuditLog.object_id == str(session.id)
|
||||||
|
|
@ -574,12 +604,9 @@ async def test_websocket_lesson_start_cannot_claim_foreign_group_trainee(db, sce
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
session_id = uuid4()
|
session_id = uuid4()
|
||||||
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False))
|
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
||||||
with pytest.raises(PermissionError, match="другой учебной групп"):
|
with pytest.raises(PermissionError, match="другой учебной групп"):
|
||||||
await journal.start_lesson(
|
await _open(pg, scenario, session_id, trainee.name, trainee.id, owner_login=teacher)
|
||||||
session_id, scenario.id, "training", trainee.name, trainee.id,
|
|
||||||
owner_login=teacher,
|
|
||||||
)
|
|
||||||
assert await db.get(Session, session_id) is None
|
assert await db.get(Session, session_id) is None
|
||||||
assert await db.scalar(select(AuditLog.id).where(
|
assert await db.scalar(select(AuditLog.id).where(
|
||||||
AuditLog.action == "lesson.start", AuditLog.object_id == str(session_id)
|
AuditLog.action == "lesson.start", AuditLog.object_id == str(session_id)
|
||||||
|
|
@ -592,14 +619,13 @@ async def test_websocket_lesson_start_cannot_claim_foreign_group_trainee(db, sce
|
||||||
|
|
||||||
async def test_websocket_lesson_start_audit_is_committed_with_session(db, scenario):
|
async def test_websocket_lesson_start_audit_is_committed_with_session(db, scenario):
|
||||||
session_id = uuid4()
|
session_id = uuid4()
|
||||||
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False))
|
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
||||||
result = await journal.start_lesson(
|
_, identity = await _open(pg, scenario, session_id, "Курсант", owner_login="lesson-teacher")
|
||||||
session_id, scenario.id, "training", "Курсант", owner_login="lesson-teacher"
|
assert identity.attempt == 1 and identity.trainee_id is not None
|
||||||
)
|
assert identity.service is None
|
||||||
assert result is not None and result[0] == 1 and result[1] is not None
|
assert identity.fencing_epoch == 1
|
||||||
assert result[2] is None
|
|
||||||
assert result[3] == 1
|
|
||||||
persisted = await db.get(Session, session_id)
|
persisted = await db.get(Session, session_id)
|
||||||
|
assert persisted.live_state is not None, "первый снимок пишется вместе со строкой занятия"
|
||||||
audit_row = await db.scalar(select(AuditLog).where(
|
audit_row = await db.scalar(select(AuditLog).where(
|
||||||
AuditLog.action == "lesson.start", AuditLog.object_id == str(session_id)
|
AuditLog.action == "lesson.start", AuditLog.object_id == str(session_id)
|
||||||
))
|
))
|
||||||
|
|
@ -609,25 +635,67 @@ async def test_websocket_lesson_start_audit_is_committed_with_session(db, scenar
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_store_commit_is_one_transaction_with_one_fencing_check(db, scenario):
|
||||||
|
"""Реплика, финиш и оценка — одна транзакция и один `(node_id, epoch)`-чек."""
|
||||||
|
from sqlalchemy import event
|
||||||
|
|
||||||
|
from app.session.store import LessonEnded
|
||||||
|
|
||||||
|
session_id = uuid4()
|
||||||
|
maker = async_sessionmaker(db.bind, expire_on_commit=False)
|
||||||
|
opened = []
|
||||||
|
|
||||||
|
def counting_maker():
|
||||||
|
opened.append(True)
|
||||||
|
return maker()
|
||||||
|
|
||||||
|
pg = PostgresSessionStore(counting_maker, node_id="node-a")
|
||||||
|
state, _ = await _open(pg, scenario, session_id, "Курсант", owner_login="lesson-teacher")
|
||||||
|
statements = []
|
||||||
|
|
||||||
|
def seen(_conn, _cursor, statement, *_args):
|
||||||
|
statements.append(statement)
|
||||||
|
|
||||||
|
event.listen(db.bind.sync_engine, "before_cursor_execute", seen)
|
||||||
|
opened.clear()
|
||||||
|
try:
|
||||||
|
entry = state.append(Speaker.CALLER, "адрес: Ленина, 14")
|
||||||
|
state.ended_at = datetime.now(timezone.utc)
|
||||||
|
await pg.commit(state, [
|
||||||
|
UtteranceAppended(entry),
|
||||||
|
LessonEnded(state.ended_at, "complete"),
|
||||||
|
ScoreCalculated(71, {"score_auto": 71}),
|
||||||
|
])
|
||||||
|
finally:
|
||||||
|
event.remove(db.bind.sync_engine, "before_cursor_execute", seen)
|
||||||
|
|
||||||
|
assert len(opened) == 1, "одна операция — одна транзакция"
|
||||||
|
fence_checks = [sql for sql in statements
|
||||||
|
if sql.startswith("UPDATE sessions SET backend_lease_until")]
|
||||||
|
assert len(fence_checks) == 1
|
||||||
|
row = await db.get(Session, session_id)
|
||||||
|
await db.refresh(row)
|
||||||
|
assert row.end_reason == "complete" and row.live_state is None
|
||||||
|
assert await db.scalar(select(Utterance.text).where(Utterance.session_id == session_id)) == (
|
||||||
|
"адрес: Ленина, 14"
|
||||||
|
)
|
||||||
|
await db.execute(delete(AuditLog).where(AuditLog.object_id == str(session_id)))
|
||||||
|
await db.execute(delete(Session).where(Session.id == session_id))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def test_old_backend_fencing_epoch_cannot_append_transcript(db, scenario):
|
async def test_old_backend_fencing_epoch_cannot_append_transcript(db, scenario):
|
||||||
session_id = uuid4()
|
session_id = uuid4()
|
||||||
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-a")
|
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-a")
|
||||||
started = await journal.start_lesson(
|
state, identity = await _open(pg, scenario, session_id, "Курсант", owner_login="lesson-teacher")
|
||||||
session_id, scenario.id, "training", "Курсант", owner_login="lesson-teacher"
|
|
||||||
)
|
|
||||||
assert started is not None
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(Session)
|
update(Session)
|
||||||
.where(Session.id == session_id)
|
.where(Session.id == session_id)
|
||||||
.values(backend_node_id="node-b", backend_fencing_epoch=started[3] + 1)
|
.values(backend_node_id="node-b", backend_fencing_epoch=identity.fencing_epoch + 1)
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
entry = SimpleNamespace(
|
|
||||||
ref="u1", speaker=SimpleNamespace(value="caller"), text="проверка fencing",
|
|
||||||
at=datetime.now(timezone.utc), mood=None,
|
|
||||||
)
|
|
||||||
with pytest.raises(SessionLeaseLost):
|
with pytest.raises(SessionLeaseLost):
|
||||||
await journal.utterance(session_id, entry)
|
await pg.commit(state, [UtteranceAppended(_entry("проверка fencing"))])
|
||||||
assert await db.scalar(select(Utterance.id).where(Utterance.session_id == session_id)) is None
|
assert await db.scalar(select(Utterance.id).where(Utterance.session_id == session_id)) is None
|
||||||
await db.execute(delete(AuditLog).where(AuditLog.object_id == str(session_id)))
|
await db.execute(delete(AuditLog).where(AuditLog.object_id == str(session_id)))
|
||||||
await db.execute(delete(Session).where(Session.id == session_id))
|
await db.execute(delete(Session).where(Session.id == session_id))
|
||||||
|
|
@ -636,44 +704,28 @@ async def test_old_backend_fencing_epoch_cannot_append_transcript(db, scenario):
|
||||||
|
|
||||||
async def test_expired_backend_lease_is_atomically_claimed_and_restored(db, scenario):
|
async def test_expired_backend_lease_is_atomically_claimed_and_restored(db, scenario):
|
||||||
session_id = uuid4()
|
session_id = uuid4()
|
||||||
old = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-a")
|
old = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-a")
|
||||||
initial = await old.start_lesson(
|
snapshot, identity = await _open(old, scenario, session_id, "Курсант", owner_login="lease-teacher")
|
||||||
session_id, scenario.id, "training", "Курсант", owner_login="lease-teacher"
|
snapshot.exercise = Exercise.DDS
|
||||||
)
|
await old.commit(snapshot)
|
||||||
assert initial is not None
|
|
||||||
snapshot = SessionState(
|
|
||||||
session_id=session_id,
|
|
||||||
scenario_id=scenario.id,
|
|
||||||
scenario_title=scenario.title,
|
|
||||||
level=scenario.level,
|
|
||||||
mode=SessionMode.TRAINING,
|
|
||||||
owner_login="lease-teacher",
|
|
||||||
exercise=Exercise.DDS,
|
|
||||||
backend_fencing_epoch=initial[3],
|
|
||||||
)
|
|
||||||
await old.checkpoint(snapshot)
|
|
||||||
row = await db.get(Session, session_id)
|
row = await db.get(Session, session_id)
|
||||||
assert row is not None
|
assert row is not None
|
||||||
row.backend_lease_until = datetime.now(timezone.utc) - timedelta(seconds=1)
|
row.backend_lease_until = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
new = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-b")
|
new = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-b")
|
||||||
restored = await new.claim_expired(session_id)
|
restored = await new.claim_expired(session_id)
|
||||||
assert len(restored) == 1
|
assert len(restored) == 1
|
||||||
assert restored[0].session_id == session_id
|
assert restored[0].session_id == session_id
|
||||||
assert restored[0].backend_fencing_epoch == initial[3] + 1
|
assert restored[0].backend_fencing_epoch == identity.fencing_epoch + 1
|
||||||
persisted = await db.get(Session, session_id)
|
persisted = await db.get(Session, session_id)
|
||||||
assert persisted is not None
|
assert persisted is not None
|
||||||
await db.refresh(persisted)
|
await db.refresh(persisted)
|
||||||
assert persisted.backend_node_id == "node-b"
|
assert persisted.backend_node_id == "node-b"
|
||||||
assert persisted.backend_fencing_epoch == initial[3] + 1
|
assert persisted.backend_fencing_epoch == identity.fencing_epoch + 1
|
||||||
|
|
||||||
entry = SimpleNamespace(
|
|
||||||
ref="u1", speaker=SimpleNamespace(value="caller"), text="stale owner",
|
|
||||||
at=datetime.now(timezone.utc), mood=None,
|
|
||||||
)
|
|
||||||
with pytest.raises(SessionLeaseLost):
|
with pytest.raises(SessionLeaseLost):
|
||||||
await old.utterance(session_id, entry)
|
await old.commit(snapshot, [UtteranceAppended(_entry("stale owner"))])
|
||||||
assert await db.scalar(select(Utterance.id).where(Utterance.session_id == session_id)) is None
|
assert await db.scalar(select(Utterance.id).where(Utterance.session_id == session_id)) is None
|
||||||
await db.execute(delete(AuditLog).where(AuditLog.object_id == str(session_id)))
|
await db.execute(delete(AuditLog).where(AuditLog.object_id == str(session_id)))
|
||||||
await db.execute(delete(Session).where(Session.id == session_id))
|
await db.execute(delete(Session).where(Session.id == session_id))
|
||||||
|
|
@ -681,23 +733,21 @@ async def test_expired_backend_lease_is_atomically_claimed_and_restored(db, scen
|
||||||
|
|
||||||
|
|
||||||
async def test_self_assessment_and_audit_commit_atomically(db, scenario):
|
async def test_self_assessment_and_audit_commit_atomically(db, scenario):
|
||||||
from app.session.journal import DbJournal
|
|
||||||
|
|
||||||
trainee = await repo.ensure_trainee(db, f"reflection-{uuid4().hex[:12]}")
|
trainee = await repo.ensure_trainee(db, f"reflection-{uuid4().hex[:12]}")
|
||||||
session = await repo.create_session(
|
session = await repo.create_session(
|
||||||
db, scenario_id=scenario.id, mode="training", trainee_id=trainee.id,
|
db, scenario_id=scenario.id, mode="training", trainee_id=trainee.id,
|
||||||
owner_login="reflection-teacher",
|
owner_login="reflection-teacher",
|
||||||
)
|
)
|
||||||
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False))
|
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
||||||
|
state = _state(session.id, scenario, trainee_id=trainee.id)
|
||||||
|
|
||||||
class InvalidText:
|
class InvalidText:
|
||||||
def __len__(self):
|
def __len__(self):
|
||||||
return 12
|
return 12
|
||||||
|
|
||||||
# Let the DB reject the staged assessment, and verify its audit rolls back too.
|
# Let the DB reject the staged assessment, and verify its audit rolls back too.
|
||||||
assert not await journal.self_assessment(
|
with pytest.raises(Exception):
|
||||||
session.id, ["q_address"], InvalidText(), datetime.now(timezone.utc)
|
await pg.commit(state, [SelfAssessed(["q_address"], InvalidText(), datetime.now(timezone.utc))])
|
||||||
)
|
|
||||||
assert await db.scalar(select(SelfAssessment.id).where(
|
assert await db.scalar(select(SelfAssessment.id).where(
|
||||||
SelfAssessment.session_id == session.id
|
SelfAssessment.session_id == session.id
|
||||||
)) is None
|
)) is None
|
||||||
|
|
@ -705,9 +755,9 @@ async def test_self_assessment_and_audit_commit_atomically(db, scenario):
|
||||||
AuditLog.action == "self_assessment.submit", AuditLog.object_id == str(session.id)
|
AuditLog.action == "self_assessment.submit", AuditLog.object_id == str(session.id)
|
||||||
)) is None
|
)) is None
|
||||||
|
|
||||||
assert await journal.self_assessment(
|
await pg.commit(state, [SelfAssessed(
|
||||||
session.id, ["q_address"], "адрес уточнил поздно", datetime.now(timezone.utc)
|
["q_address"], "адрес уточнил поздно", datetime.now(timezone.utc),
|
||||||
)
|
)])
|
||||||
assessment = await db.scalar(select(SelfAssessment).where(
|
assessment = await db.scalar(select(SelfAssessment).where(
|
||||||
SelfAssessment.session_id == session.id
|
SelfAssessment.session_id == session.id
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from app.session import timers as timers_module
|
||||||
from app.session.checkpoint import dump_state, load_state
|
from app.session.checkpoint import dump_state, load_state
|
||||||
from app.session.dds import prepare_handoff_queue, prepare_queue
|
from app.session.dds import prepare_handoff_queue, prepare_queue
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
|
from app.session.store import MemorySessionStore
|
||||||
from app.session.state import SessionState
|
from app.session.state import SessionState
|
||||||
from app.session.timers import now_utc
|
from app.session.timers import now_utc
|
||||||
|
|
||||||
|
|
@ -30,6 +31,12 @@ LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||||
_events = TypeAdapter(StationToServer)
|
_events = TypeAdapter(StationToServer)
|
||||||
|
|
||||||
|
|
||||||
|
async def finish_in_operation(state):
|
||||||
|
"""finish() пишет оценку в commit операции, завершившей занятие."""
|
||||||
|
async with hub.operation(state.session_id):
|
||||||
|
await finish_module.finish(state.session_id, state)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def offline_finish(monkeypatch):
|
def offline_finish(monkeypatch):
|
||||||
class NoCoaching:
|
class NoCoaching:
|
||||||
|
|
@ -40,7 +47,7 @@ def offline_finish(monkeypatch):
|
||||||
return NoCoaching()
|
return NoCoaching()
|
||||||
|
|
||||||
monkeypatch.setattr(finish_module, "coach", no_coach)
|
monkeypatch.setattr(finish_module, "coach", no_coach)
|
||||||
monkeypatch.setattr(hub, "journal", None)
|
monkeypatch.setattr(hub, "store", MemorySessionStore())
|
||||||
|
|
||||||
|
|
||||||
def fire():
|
def fire():
|
||||||
|
|
@ -150,7 +157,7 @@ def test_call_checkpoint_keeps_dispatcher_work_on_shared_timers():
|
||||||
assert snapshot.queue_cards == []
|
assert snapshot.queue_cards == []
|
||||||
|
|
||||||
state.ended_at = now_utc()
|
state.ended_at = now_utc()
|
||||||
asyncio.run(finish_module.finish(state.session_id, state))
|
asyncio.run(finish_in_operation(state))
|
||||||
|
|
||||||
crew = next(item for item in state.score["metrics"] if item["key"] == "dds_crew")
|
crew = next(item for item in state.score["metrics"] if item["key"] == "dds_crew")
|
||||||
assert crew["passed"] and crew["fact"] == card.crew_selected
|
assert crew["passed"] and crew["fact"] == card.crew_selected
|
||||||
|
|
@ -189,7 +196,7 @@ def test_finish_scores_every_card_without_switching_active(name):
|
||||||
unscored = {card.card_id for card in state.desk.cards.values()}
|
unscored = {card.card_id for card in state.desk.cards.values()}
|
||||||
|
|
||||||
state.ended_at = now_utc()
|
state.ended_at = now_utc()
|
||||||
asyncio.run(finish_module.finish(state.session_id, state))
|
asyncio.run(finish_in_operation(state))
|
||||||
|
|
||||||
assert state.desk.active is active
|
assert state.desk.active is active
|
||||||
assert {record.card_id for record in state.desk.completed} == unscored
|
assert {record.card_id for record in state.desk.completed} == unscored
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from app.scenarios.loader import load_file
|
||||||
from app.session import finish as finish_module
|
from app.session import finish as finish_module
|
||||||
from app.session.dds import prepare_handoff_queue, prepare_queue
|
from app.session.dds import prepare_handoff_queue, prepare_queue
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
|
from app.session.store import MemorySessionStore
|
||||||
from app.session.state import SessionState
|
from app.session.state import SessionState
|
||||||
|
|
||||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||||
|
|
@ -32,6 +33,12 @@ COMMENT = "Основание: доклад старшего.\nСведения:
|
||||||
_events = TypeAdapter(StationToServer)
|
_events = TypeAdapter(StationToServer)
|
||||||
|
|
||||||
|
|
||||||
|
async def finish_in_operation(state):
|
||||||
|
"""finish() пишет оценку в commit операции, завершившей занятие."""
|
||||||
|
async with hub.operation(state.session_id):
|
||||||
|
await finish_module.finish(state.session_id, state)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def offline_finish(monkeypatch):
|
def offline_finish(monkeypatch):
|
||||||
class NoCoaching:
|
class NoCoaching:
|
||||||
|
|
@ -42,7 +49,7 @@ def offline_finish(monkeypatch):
|
||||||
return NoCoaching()
|
return NoCoaching()
|
||||||
|
|
||||||
monkeypatch.setattr(finish_module, "coach", no_coach)
|
monkeypatch.setattr(finish_module, "coach", no_coach)
|
||||||
monkeypatch.setattr(hub, "journal", None)
|
monkeypatch.setattr(hub, "store", MemorySessionStore())
|
||||||
|
|
||||||
|
|
||||||
def scenario(name: str):
|
def scenario(name: str):
|
||||||
|
|
@ -51,7 +58,13 @@ def scenario(name: str):
|
||||||
|
|
||||||
def station(state: SessionState, **payload) -> None:
|
def station(state: SessionState, **payload) -> None:
|
||||||
hub.register(state)
|
hub.register(state)
|
||||||
asyncio.run(station_ws._handle(state.session_id, state, _events.validate_python(payload)))
|
event = _events.validate_python(payload)
|
||||||
|
|
||||||
|
async def command():
|
||||||
|
async with hub.operation(state.session_id):
|
||||||
|
await station_ws._handle(state.session_id, state, event)
|
||||||
|
|
||||||
|
asyncio.run(command())
|
||||||
|
|
||||||
|
|
||||||
def active_id(state: SessionState):
|
def active_id(state: SessionState):
|
||||||
|
|
@ -167,7 +180,7 @@ def call_with_live_dispatcher() -> SessionState:
|
||||||
state.dispatch()
|
state.dispatch()
|
||||||
work_card(state)
|
work_card(state)
|
||||||
state.ended_at = state.dispatched_at
|
state.ended_at = state.dispatched_at
|
||||||
asyncio.run(finish_module.finish(state.session_id, state))
|
asyncio.run(finish_in_operation(state))
|
||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from app.domain.timers import TimerCode
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.scenarios import store
|
from app.scenarios import store
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
|
from app.session.store import MemorySessionStore
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -42,11 +43,10 @@ def client(monkeypatch):
|
||||||
optional_session_override,
|
optional_session_override,
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(sessions_http, "audit_required", audit_override)
|
monkeypatch.setattr(sessions_http, "audit_required", audit_override)
|
||||||
monkeypatch.setattr("app.api.ws.control.audit", audit_override)
|
|
||||||
try:
|
try:
|
||||||
with TestClient(app) as test_client:
|
with TestClient(app) as test_client:
|
||||||
test_client.post("/api/auth/dev-token")
|
test_client.post("/api/auth/dev-token")
|
||||||
hub.journal = None
|
hub.store = MemorySessionStore()
|
||||||
yield test_client
|
yield test_client
|
||||||
finally:
|
finally:
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|
@ -995,3 +995,32 @@ def test_each_dds_card_uses_its_own_scenario_weights():
|
||||||
next(item.weight for item in second_record.metrics if item.key == "dds_primary")
|
next(item.weight for item in second_record.metrics if item.key == "dds_primary")
|
||||||
== 2.0
|
== 2.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dds_station_command_and_finish_are_one_store_commit_each(client):
|
||||||
|
"""Команда пульта ДДС и её подтверждение фиксируются одним commit."""
|
||||||
|
from app.session.store import LessonEnded, ScoreCalculated
|
||||||
|
|
||||||
|
session_id, control = start(client)
|
||||||
|
store = hub.store
|
||||||
|
try:
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||||
|
read_until(station, "card.received")
|
||||||
|
service = read_until(station, "station.state")["snapshot"]["services"][0]
|
||||||
|
before = len(store.commits)
|
||||||
|
command_id = str(uuid4())
|
||||||
|
station.send_json({
|
||||||
|
"type": "card.status", "service": service, "status": "accepted",
|
||||||
|
"comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята.",
|
||||||
|
"_command_id": command_id,
|
||||||
|
})
|
||||||
|
read_until(station, "command.ack")
|
||||||
|
assert len(store.commits) == before + 1
|
||||||
|
assert command_id in store.snapshot(session_id)["processed_station_commands"]
|
||||||
|
|
||||||
|
station.send_json({"type": "station.finish"})
|
||||||
|
read_until(station, "score.ready")
|
||||||
|
assert len(store.commits) == before + 2
|
||||||
|
assert [type(item) for item in store.commits[-1][1]] == [LessonEnded, ScoreCalculated]
|
||||||
|
finally:
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from app.api.auth import DEMO_TRAINEE_ID
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
|
from app.session.store import MemorySessionStore
|
||||||
|
|
||||||
|
|
||||||
def _wait_for(predicate, timeout=3):
|
def _wait_for(predicate, timeout=3):
|
||||||
|
|
@ -39,7 +40,7 @@ def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch)
|
||||||
health = client.get("/api/health").json()
|
health = client.get("/api/health").json()
|
||||||
assert health["status"] == "ok" and health["demo_no_db"] is True
|
assert health["status"] == "ok" and health["demo_no_db"] is True
|
||||||
assert health["scenarios_loaded"] > 0
|
assert health["scenarios_loaded"] > 0
|
||||||
assert hub.journal is None
|
assert isinstance(hub.store, MemorySessionStore)
|
||||||
|
|
||||||
assert client.post("/api/auth/login", json={
|
assert client.post("/api/auth/login", json={
|
||||||
"login": "demo-instructor", "password": "demo"
|
"login": "demo-instructor", "password": "demo"
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,18 @@ from app.scenarios.loader import load_file
|
||||||
from app.session import finish as finish_module
|
from app.session import finish as finish_module
|
||||||
from app.session.dds import prepare_handoff_queue, prepare_queue
|
from app.session.dds import prepare_handoff_queue, prepare_queue
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
|
from app.session.store import MemorySessionStore
|
||||||
from app.session.state import SessionState
|
from app.session.state import SessionState
|
||||||
|
|
||||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||||
|
|
||||||
|
|
||||||
|
async def finish_in_operation(state):
|
||||||
|
"""finish() пишет оценку в commit операции, завершившей занятие."""
|
||||||
|
async with hub.operation(state.session_id):
|
||||||
|
await finish_module.finish(state.session_id, state)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def offline_finish(monkeypatch):
|
def offline_finish(monkeypatch):
|
||||||
class NoCoaching:
|
class NoCoaching:
|
||||||
|
|
@ -27,7 +34,7 @@ def offline_finish(monkeypatch):
|
||||||
return NoCoaching()
|
return NoCoaching()
|
||||||
|
|
||||||
monkeypatch.setattr(finish_module, "coach", no_coach)
|
monkeypatch.setattr(finish_module, "coach", no_coach)
|
||||||
monkeypatch.setattr(hub, "journal", None)
|
monkeypatch.setattr(hub, "store", MemorySessionStore())
|
||||||
|
|
||||||
|
|
||||||
def accept_active_card(state: SessionState) -> None:
|
def accept_active_card(state: SessionState) -> None:
|
||||||
|
|
@ -59,7 +66,7 @@ def test_dds_finish_reports_status_of_active_card_not_last_scored():
|
||||||
accept_active_card(state)
|
accept_active_card(state)
|
||||||
active_status = state.station_snapshot().card
|
active_status = state.station_snapshot().card
|
||||||
|
|
||||||
asyncio.run(finish_module.finish(state.session_id, state))
|
asyncio.run(finish_in_operation(state))
|
||||||
|
|
||||||
assert_active_card_kept(state, active_id, active_status)
|
assert_active_card_kept(state, active_id, active_status)
|
||||||
|
|
||||||
|
|
@ -83,6 +90,6 @@ def test_handoff_finish_reports_status_of_active_card_not_last_scored():
|
||||||
accept_active_card(state)
|
accept_active_card(state)
|
||||||
active_status = state.station_snapshot().card
|
active_status = state.station_snapshot().card
|
||||||
|
|
||||||
asyncio.run(finish_module.finish(state.session_id, state))
|
asyncio.run(finish_in_operation(state))
|
||||||
|
|
||||||
assert_active_card_kept(state, active_id, active_status)
|
assert_active_card_kept(state, active_id, active_status)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ from types import SimpleNamespace
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from app.session.pg_store import PostgresSessionStore
|
||||||
|
from app.session.store import MemorySessionStore
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
@ -129,7 +131,7 @@ def client(monkeypatch):
|
||||||
with TestClient(app) as test_client:
|
with TestClient(app) as test_client:
|
||||||
# These endpoint tests exercise the in-memory live-report path. Durable
|
# These endpoint tests exercise the in-memory live-report path. Durable
|
||||||
# report readiness is covered by the isolated PostgreSQL integration suite.
|
# report readiness is covered by the isolated PostgreSQL integration suite.
|
||||||
monkeypatch.setattr(sessions.hub, "journal", None)
|
monkeypatch.setattr(sessions.hub, "store", MemorySessionStore())
|
||||||
test_client.post("/api/auth/dev-token")
|
test_client.post("/api/auth/dev-token")
|
||||||
yield test_client, state, report
|
yield test_client, state, report
|
||||||
|
|
||||||
|
|
@ -243,10 +245,23 @@ def test_archived_score_override_updates_json_exports_and_audit(monkeypatch):
|
||||||
def add(self, row):
|
def add(self, row):
|
||||||
self.added.append(row)
|
self.added.append(row)
|
||||||
|
|
||||||
|
async def flush(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def rollback(self):
|
||||||
|
pass
|
||||||
|
|
||||||
async def commit(self):
|
async def commit(self):
|
||||||
self.commits += 1
|
self.commits += 1
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
db = FakeDb()
|
db = FakeDb()
|
||||||
|
monkeypatch.setattr(sessions.hub, "store", PostgresSessionStore(lambda: db))
|
||||||
monkeypatch.setattr(sessions.hub, "get", lambda session_id: None)
|
monkeypatch.setattr(sessions.hub, "get", lambda session_id: None)
|
||||||
monkeypatch.setattr(sessions, "_live", lambda session_id: (_ for _ in ()).throw(
|
monkeypatch.setattr(sessions, "_live", lambda session_id: (_ for _ in ()).throw(
|
||||||
HTTPException(status_code=404, detail="session_not_found")
|
HTTPException(status_code=404, detail="session_not_found")
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,13 @@ from app.api.http import sessions
|
||||||
from app.api.ws import call as call_ws
|
from app.api.ws import call as call_ws
|
||||||
from app.api.ws import observe as observe_ws
|
from app.api.ws import observe as observe_ws
|
||||||
from app.api.ws import station as station_ws
|
from app.api.ws import station as station_ws
|
||||||
from app.domain.events import Exercise, SessionMode
|
from app.domain.events import Exercise, SessionMode, Speaker
|
||||||
from app.domain.roles import Role
|
from app.domain.roles import Role
|
||||||
from app.session.checkpoint import dump_state
|
from app.session.checkpoint import dump_state
|
||||||
from app.session.hub import SessionHub
|
from app.session.hub import SessionHub
|
||||||
from app.session.state import SessionState
|
from app.session.state import SessionState
|
||||||
from app.session.journal import DbJournal
|
from app.session.pg_store import PostgresSessionStore
|
||||||
|
from app.session.store import LessonRequest, UtteranceAppended
|
||||||
|
|
||||||
|
|
||||||
def request() -> Request:
|
def request() -> Request:
|
||||||
|
|
@ -28,7 +29,7 @@ def request() -> Request:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_journal_write_failure_does_not_log_user_text(caplog):
|
async def test_store_commit_failure_does_not_log_user_text(caplog):
|
||||||
private_text = "private caller address and medical detail"
|
private_text = "private caller address and medical detail"
|
||||||
|
|
||||||
class FakeDb:
|
class FakeDb:
|
||||||
|
|
@ -38,11 +39,16 @@ async def test_journal_write_failure_does_not_log_user_text(caplog):
|
||||||
async def __aexit__(self, *_args):
|
async def __aexit__(self, *_args):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def fail_write(_db, text):
|
def add(self, _row):
|
||||||
raise RuntimeError(text)
|
raise RuntimeError(private_text)
|
||||||
|
|
||||||
journal = DbJournal(lambda: FakeDb())
|
state = SessionState(
|
||||||
await journal._write(fail_write, private_text)
|
session_id=uuid4(), scenario_id="case", scenario_title="Сессия", level="L1",
|
||||||
|
mode=SessionMode.TRAINING,
|
||||||
|
)
|
||||||
|
entry = state.append(Speaker.CALLER, private_text)
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
await PostgresSessionStore(lambda: FakeDb()).commit(state, [UtteranceAppended(entry)])
|
||||||
assert private_text not in caplog.text
|
assert private_text not in caplog.text
|
||||||
assert "RuntimeError" in caplog.text
|
assert "RuntimeError" in caplog.text
|
||||||
|
|
||||||
|
|
@ -480,10 +486,13 @@ async def test_unassigned_existing_session_is_claimed_once():
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_journal_assigns_new_lesson_to_its_backend_node(monkeypatch):
|
async def test_store_opens_new_lesson_on_its_backend_node(monkeypatch):
|
||||||
seen = {}
|
seen = {}
|
||||||
trainee_id = uuid4()
|
trainee_id = uuid4()
|
||||||
row = SimpleNamespace(attempt=3, trainee_id=trainee_id)
|
row = SimpleNamespace(
|
||||||
|
id=uuid4(), attempt=3, trainee_id=trainee_id, backend_fencing_epoch=0,
|
||||||
|
backend_lease_until=None, started_at=None, live_state=None, checkpoint_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
async def ensure(_db, **kwargs):
|
async def ensure(_db, **kwargs):
|
||||||
seen.update(kwargs)
|
seen.update(kwargs)
|
||||||
|
|
@ -501,18 +510,38 @@ async def test_journal_assigns_new_lesson_to_its_backend_node(monkeypatch):
|
||||||
async def scalar(self, _query):
|
async def scalar(self, _query):
|
||||||
return "01"
|
return "01"
|
||||||
|
|
||||||
journal = DbJournal(lambda: FakeDb(), node_id="node-a")
|
def add(self, item):
|
||||||
result = await journal.start_lesson(
|
added.append(item)
|
||||||
uuid4(), "case", "training", "Курсант", trainee_id,
|
|
||||||
owner_login="teacher-a",
|
async def commit(self):
|
||||||
|
committed.append(True)
|
||||||
|
|
||||||
|
added, committed, identities = [], [], []
|
||||||
|
|
||||||
|
def build(identity):
|
||||||
|
identities.append(identity)
|
||||||
|
return SessionState(
|
||||||
|
session_id=row.id, scenario_id="case", scenario_title="Сессия", level="L1",
|
||||||
|
mode=SessionMode.TRAINING,
|
||||||
|
)
|
||||||
|
|
||||||
|
store = PostgresSessionStore(lambda: FakeDb(), node_id="node-a")
|
||||||
|
await store.open(LessonRequest(
|
||||||
|
session_id=row.id, scenario_id="case", mode="training", trainee_name="Курсант",
|
||||||
|
trainee_id=trainee_id, owner_login="teacher-a", backend_node_id=None,
|
||||||
|
), build)
|
||||||
|
identity = identities[0]
|
||||||
|
assert (identity.attempt, identity.trainee_id, identity.service, identity.fencing_epoch) == (
|
||||||
|
3, trainee_id, "01", 1,
|
||||||
)
|
)
|
||||||
assert result == (3, trainee_id, "01", 1)
|
|
||||||
assert seen["backend_node_id"] == "node-a"
|
assert seen["backend_node_id"] == "node-a"
|
||||||
assert callable(seen["before_commit"]), "session creation must carry its audit into commit"
|
assert seen["commit"] is False, "строка, аудит и снимок коммитятся вместе"
|
||||||
|
assert [item.action for item in added] == ["lesson.start"]
|
||||||
|
assert row.live_state is not None and committed == [True]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_journal_restores_checkpoint_owned_by_this_node():
|
async def test_store_restores_checkpoint_owned_by_this_node():
|
||||||
owner = "teacher-a"
|
owner = "teacher-a"
|
||||||
state = SessionState(
|
state = SessionState(
|
||||||
session_id=uuid4(),
|
session_id=uuid4(),
|
||||||
|
|
@ -562,8 +591,8 @@ async def test_journal_restores_checkpoint_owned_by_this_node():
|
||||||
return Rows([])
|
return Rows([])
|
||||||
return Rows([row])
|
return Rows([row])
|
||||||
|
|
||||||
journal = DbJournal(lambda: FakeDb(), node_id="node-a")
|
store = PostgresSessionStore(lambda: FakeDb(), node_id="node-a")
|
||||||
restored = await journal.restore_active()
|
restored = await store.restore_active()
|
||||||
assert len(restored) == 1
|
assert len(restored) == 1
|
||||||
assert restored[0].session_id == state.session_id
|
assert restored[0].session_id == state.session_id
|
||||||
assert restored[0].owner_login == owner
|
assert restored[0].owner_login == owner
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
"""Промежуточное состояние занятия переживает смену backend-процесса."""
|
"""Промежуточное состояние занятия переживает смену backend-процесса."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from app.domain.events import (
|
from app.domain.events import (
|
||||||
CallStarted,
|
|
||||||
CommandAck,
|
|
||||||
Exercise,
|
Exercise,
|
||||||
LessonCriteria,
|
LessonCriteria,
|
||||||
Metric,
|
Metric,
|
||||||
|
|
@ -21,7 +18,6 @@ from app.domain.timers import TimerCode
|
||||||
from app.scenarios.loader import load_file
|
from app.scenarios.loader import load_file
|
||||||
from app.session.checkpoint import dump_state, load_state
|
from app.session.checkpoint import dump_state, load_state
|
||||||
from app.session.dds import prepare_handoff_queue, prepare_queue
|
from app.session.dds import prepare_handoff_queue, prepare_queue
|
||||||
from app.session.hub import LEASE_FENCED_MESSAGE, SessionHub
|
|
||||||
from app.session.state import DdsCardRecord, SessionState, now_utc
|
from app.session.state import DdsCardRecord, SessionState, now_utc
|
||||||
|
|
||||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||||
|
|
@ -228,69 +224,3 @@ def test_mixed_handoff_checkpoint_preserves_operator_card_and_generated_queue():
|
||||||
assert restored.desk.scenarios[1].id == second.id
|
assert restored.desk.scenarios[1].id == second.id
|
||||||
assert len(restored.desk.cards) == 2
|
assert len(restored.desk.cards) == 2
|
||||||
assert restored.desk.active_id == state.desk.active_id
|
assert restored.desk.active_id == state.desk.active_id
|
||||||
|
|
||||||
|
|
||||||
def test_checkpoint_storage_failure_fences_and_notifies_all_data_channels():
|
|
||||||
class BrokenJournal:
|
|
||||||
async def checkpoint(self, _state):
|
|
||||||
raise OSError("simulated database partition")
|
|
||||||
|
|
||||||
local_hub = SessionHub(journal=BrokenJournal())
|
|
||||||
state = dds_state()
|
|
||||||
local_hub.register(state)
|
|
||||||
|
|
||||||
with local_hub.observer(state.session_id) as observers, \
|
|
||||||
local_hub.trainee(state.session_id) as trainee, \
|
|
||||||
local_hub.station(state.session_id) as station:
|
|
||||||
async def failing_transition():
|
|
||||||
async with local_hub.durable_transition(state.session_id):
|
|
||||||
local_hub.to_trainee(
|
|
||||||
state.session_id, CallStarted(started_at=now_utc())
|
|
||||||
)
|
|
||||||
assert trainee.empty(), "success event escaped before durable checkpoint"
|
|
||||||
|
|
||||||
with pytest.raises(OSError, match="partition"):
|
|
||||||
asyncio.run(failing_transition())
|
|
||||||
|
|
||||||
assert state.lease_fenced
|
|
||||||
assert local_hub.get(state.session_id) is None
|
|
||||||
for queue in (observers, trainee, station):
|
|
||||||
event = queue.get_nowait()
|
|
||||||
assert event.message == LEASE_FENCED_MESSAGE
|
|
||||||
assert queue.empty(), "uncommitted success event leaked during fencing"
|
|
||||||
|
|
||||||
|
|
||||||
def test_durable_transition_publishes_event_only_after_checkpoint_commit():
|
|
||||||
class CommitJournal:
|
|
||||||
committed = False
|
|
||||||
|
|
||||||
async def checkpoint(self, _state):
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
self.committed = True
|
|
||||||
|
|
||||||
journal = CommitJournal()
|
|
||||||
local_hub = SessionHub(journal=journal)
|
|
||||||
state = dds_state()
|
|
||||||
local_hub.register(state)
|
|
||||||
|
|
||||||
command_id = uuid4()
|
|
||||||
with local_hub.trainee(state.session_id) as trainee, \
|
|
||||||
local_hub.station(state.session_id) as station:
|
|
||||||
async def transition():
|
|
||||||
async with local_hub.durable_transition(state.session_id):
|
|
||||||
local_hub.to_trainee(
|
|
||||||
state.session_id, CallStarted(started_at=now_utc())
|
|
||||||
)
|
|
||||||
local_hub.to_station(
|
|
||||||
state.session_id, CommandAck(command_id=command_id)
|
|
||||||
)
|
|
||||||
assert trainee.empty()
|
|
||||||
assert station.empty()
|
|
||||||
assert journal.committed
|
|
||||||
|
|
||||||
asyncio.run(transition())
|
|
||||||
event = trainee.get_nowait()
|
|
||||||
assert isinstance(event, CallStarted)
|
|
||||||
ack = station.get_nowait()
|
|
||||||
assert isinstance(ack, CommandAck)
|
|
||||||
assert ack.command_id == command_id
|
|
||||||
|
|
|
||||||
70
backend/tests/test_session_store.py
Normal file
70
backend/tests/test_session_store.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""Одна операция занятия — один commit хранилища, события только после него."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domain.events import CallStarted, Speaker
|
||||||
|
from app.session.hub import LEASE_FENCED_MESSAGE, SessionHub
|
||||||
|
from app.session.state import now_utc
|
||||||
|
from app.session.store import MemorySessionStore, UtteranceAppended
|
||||||
|
from tests.test_session_checkpoint import dds_state
|
||||||
|
|
||||||
|
|
||||||
|
class FailingStore(MemorySessionStore):
|
||||||
|
async def commit(self, state, records=()):
|
||||||
|
raise OSError("simulated database partition")
|
||||||
|
|
||||||
|
|
||||||
|
def test_operation_commits_snapshot_and_records_once_then_publishes():
|
||||||
|
store = MemorySessionStore()
|
||||||
|
local_hub = SessionHub(store=store)
|
||||||
|
state = local_hub.register(dds_state())
|
||||||
|
sid = state.session_id
|
||||||
|
|
||||||
|
with local_hub.trainee(sid) as trainee:
|
||||||
|
async def operation():
|
||||||
|
async with local_hub.operation(sid):
|
||||||
|
entry = state.append(Speaker.OPERATOR, "Адрес?")
|
||||||
|
local_hub.record(sid, UtteranceAppended(entry))
|
||||||
|
local_hub.to_trainee(sid, CallStarted(started_at=now_utc()))
|
||||||
|
assert trainee.empty(), "событие ушло до коммита"
|
||||||
|
assert store.commits == []
|
||||||
|
|
||||||
|
asyncio.run(operation())
|
||||||
|
assert isinstance(trainee.get_nowait(), CallStarted)
|
||||||
|
|
||||||
|
assert len(store.commits) == 1
|
||||||
|
committed_id, records = store.commits[0]
|
||||||
|
assert committed_id == sid
|
||||||
|
assert [type(item) for item in records] == [UtteranceAppended]
|
||||||
|
assert store.snapshot(sid)["transcript"][0]["text"] == "Адрес?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_commit_drops_events_and_fences_session():
|
||||||
|
local_hub = SessionHub(store=FailingStore())
|
||||||
|
state = local_hub.register(dds_state())
|
||||||
|
sid = state.session_id
|
||||||
|
|
||||||
|
with local_hub.observer(sid) as observers, \
|
||||||
|
local_hub.trainee(sid) as trainee, \
|
||||||
|
local_hub.station(sid) as station:
|
||||||
|
async def operation():
|
||||||
|
async with local_hub.operation(sid):
|
||||||
|
local_hub.to_trainee(sid, CallStarted(started_at=now_utc()))
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="partition"):
|
||||||
|
asyncio.run(operation())
|
||||||
|
|
||||||
|
assert state.lease_fenced
|
||||||
|
assert local_hub.get(sid) is None
|
||||||
|
for queue in (observers, trainee, station):
|
||||||
|
assert queue.get_nowait().message == LEASE_FENCED_MESSAGE
|
||||||
|
assert queue.empty(), "событие несостоявшейся операции утекло"
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_outside_operation_is_rejected():
|
||||||
|
local_hub = SessionHub(store=MemorySessionStore())
|
||||||
|
state = local_hub.register(dds_state())
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
local_hub.record(state.session_id, UtteranceAppended(state.append(Speaker.OPERATOR, "x")))
|
||||||
|
|
@ -9,6 +9,7 @@ from fastapi.testclient import TestClient
|
||||||
from app.api.http import sessions as sessions_http
|
from app.api.http import sessions as sessions_http
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
|
from app.session.store import MemorySessionStore
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -21,7 +22,7 @@ def client(monkeypatch):
|
||||||
# Сокеты закрыты ролями (lct-23): тесты входят так же,
|
# Сокеты закрыты ролями (lct-23): тесты входят так же,
|
||||||
# как `make lesson`, — через dev-token за флагом.
|
# как `make lesson`, — через dev-token за флагом.
|
||||||
test_client.post("/api/auth/dev-token")
|
test_client.post("/api/auth/dev-token")
|
||||||
hub.journal = None
|
hub.store = MemorySessionStore()
|
||||||
yield test_client
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from app.api.http import sessions as sessions_http
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.scenarios import store
|
from app.scenarios import store
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
|
from app.session.store import MemorySessionStore
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -30,7 +31,7 @@ def client(monkeypatch):
|
||||||
# Сокеты закрыты ролями (lct-23): тесты входят так же,
|
# Сокеты закрыты ролями (lct-23): тесты входят так же,
|
||||||
# как `make lesson`, — через dev-token за флагом.
|
# как `make lesson`, — через dev-token за флагом.
|
||||||
test_client.post("/api/auth/dev-token")
|
test_client.post("/api/auth/dev-token")
|
||||||
hub.journal = None # тесты не пишут в БД: проверяется поведение каналов
|
hub.store = MemorySessionStore() # тесты не пишут в БД: in-memory adapter хранилища
|
||||||
yield test_client
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -469,46 +470,93 @@ def test_report_shows_missed_questions_and_self_assessment_gap(client):
|
||||||
assert report["hints_used"], "использованные подсказки попадают в разбор"
|
assert report["hints_used"], "использованные подсказки попадают в разбор"
|
||||||
|
|
||||||
|
|
||||||
def test_instructor_correction_keeps_the_automatic_score(client, postgres_access):
|
@pytest.mark.parametrize("path", ["http", "ws"])
|
||||||
from app.db.base import get_sessionmaker
|
def test_score_override_is_one_operation_for_http_and_ws(client, postgres_access, path):
|
||||||
from app.session.journal import DbJournal
|
"""Правка с пульта и из отчёта даёт одну и ту же строку Score и разбор.
|
||||||
|
|
||||||
# Unlike the websocket-only cases above, this regression exercises the
|
Раньше WS-путь не трогал `full_report` живой сессии: отчёт показывал
|
||||||
# durable HTTP correction endpoint against a real PostgreSQL score row.
|
старую оценку до перезагрузки.
|
||||||
journal = DbJournal(get_sessionmaker())
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.base import get_sessionmaker
|
||||||
|
from app.db.models import Score
|
||||||
|
from app.session.pg_store import PostgresSessionStore
|
||||||
|
|
||||||
|
comment = "связь рвалась не по вине курсанта"
|
||||||
|
hub.store = PostgresSessionStore(get_sessionmaker())
|
||||||
try:
|
try:
|
||||||
with lesson(client) as (session_id, _):
|
with lesson(client) as (session_id, control), \
|
||||||
|
client.websocket_connect(f"/ws/observe/{session_id}") as observer:
|
||||||
|
state = hub.get(session_id)
|
||||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||||
trainee.send_json({"type": "call.answer"})
|
trainee.send_json({"type": "call.answer"})
|
||||||
trainee.send_json({"type": "call.hangup"})
|
trainee.send_json({"type": "call.hangup"})
|
||||||
wait_for(lambda: hub.get(session_id).score is not None)
|
# score.ready уходит только после commit оценки.
|
||||||
# The websocket-only fixture deliberately disables journalling;
|
read_until(observer, "score.ready", limit=50)
|
||||||
# persist its computed result before testing the HTTP override.
|
|
||||||
state = hub.get(session_id)
|
|
||||||
client.portal.call(
|
|
||||||
journal.start_lesson,
|
|
||||||
session_id,
|
|
||||||
state.scenario_id,
|
|
||||||
state.mode.value,
|
|
||||||
state.trainee_name,
|
|
||||||
state.trainee_id,
|
|
||||||
"dev",
|
|
||||||
)
|
|
||||||
client.portal.call(journal.score, session_id, state.score["score_auto"], state.score)
|
|
||||||
|
|
||||||
auto = client.get(f"/api/sessions/{session_id}/report").json()["score_auto"]
|
auto = client.get(f"/api/sessions/{session_id}/report").json()["score_auto"]
|
||||||
response = client.patch(
|
|
||||||
f"/api/sessions/{session_id}/report",
|
|
||||||
json={"score_final": 80.0, "comment": "связь рвалась не по вине курсанта"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200, response.text
|
|
||||||
corrected = response.json()
|
|
||||||
finally:
|
|
||||||
hub.journal = None
|
|
||||||
|
|
||||||
assert corrected["score_final"] == 80.0
|
if path == "http":
|
||||||
assert corrected["score_auto"] == auto, "автооценка должна сохраниться рядом"
|
response = client.patch(
|
||||||
assert corrected["overridden_by"] == "dev"
|
f"/api/sessions/{session_id}/report",
|
||||||
|
json={"score_final": 80.0, "comment": comment},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
else:
|
||||||
|
control.send_json({
|
||||||
|
"type": "score.override", "session_id": str(session_id),
|
||||||
|
"verdict": "80", "comment": comment,
|
||||||
|
})
|
||||||
|
read_until(observer, "score.ready", limit=50)
|
||||||
|
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||||
|
|
||||||
|
async def score_row():
|
||||||
|
async with get_sessionmaker()() as db:
|
||||||
|
return await db.scalar(select(Score).where(Score.session_id == session_id))
|
||||||
|
|
||||||
|
row = client.portal.call(score_row)
|
||||||
|
finally:
|
||||||
|
hub.store = MemorySessionStore()
|
||||||
|
|
||||||
|
corrected = {"score_final": 80.0, "overridden_by": "dev", "override_comment": comment}
|
||||||
|
assert {key: report[key] for key in corrected} == corrected
|
||||||
|
assert report["score_auto"] == auto, "автооценка должна сохраниться рядом"
|
||||||
|
assert (row.score_auto, row.score_final, row.overridden_by, row.override_comment) == (
|
||||||
|
auto, 80.0, "dev", comment,
|
||||||
|
)
|
||||||
|
assert {key: row.report[key] for key in corrected} == corrected
|
||||||
|
assert {key: row.report["full_report"][key] for key in corrected} == corrected
|
||||||
|
assert row.report["full_report"]["score_auto"] == auto
|
||||||
|
assert {key: state.score["full_report"][key] for key in corrected} == corrected
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_lesson_operation_is_exactly_one_store_commit(client):
|
||||||
|
"""Раньше реплика, подсказка и финиш шли отдельной транзакцией журнала
|
||||||
|
и ещё одним checkpoint — между ними возможен частичный коммит."""
|
||||||
|
from app.session.store import HintRecorded, LessonEnded, LessonStarted, NoteAdded, ScoreCalculated
|
||||||
|
|
||||||
|
store = hub.store
|
||||||
|
with lesson(client) as (session_id, control):
|
||||||
|
assert len(store.commits) == 1, "запуск: строка занятия и снимок вместе"
|
||||||
|
|
||||||
|
def commits_after(action) -> list:
|
||||||
|
before = len(store.commits)
|
||||||
|
action()
|
||||||
|
wait_for(lambda: len(store.commits) > before)
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert len(store.commits) == before + 1
|
||||||
|
return [type(item) for item in store.commits[-1][1]]
|
||||||
|
|
||||||
|
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||||
|
assert commits_after(lambda: trainee.send_json({"type": "call.answer"})) == [LessonStarted]
|
||||||
|
assert commits_after(lambda: trainee.send_json({"type": "hint.request"})) == [HintRecorded]
|
||||||
|
assert commits_after(lambda: trainee.send_json({"type": "call.hangup"})) == [
|
||||||
|
LessonEnded, ScoreCalculated,
|
||||||
|
]
|
||||||
|
assert commits_after(lambda: control.send_json({
|
||||||
|
"type": "instructor_note.add", "transcript_ref": "u1", "text": "поздно спросил адрес",
|
||||||
|
})) == [NoteAdded]
|
||||||
|
assert all(committed_id == session_id for committed_id, _ in store.commits)
|
||||||
|
|
||||||
|
|
||||||
def test_ws_score_override_rejects_other_session_and_invalid_value(client):
|
def test_ws_score_override_rejects_other_session_and_invalid_value(client):
|
||||||
|
|
@ -538,13 +586,10 @@ def test_ws_score_override_rejects_other_session_and_invalid_value(client):
|
||||||
assert state.score["overridden_by"] == "dev"
|
assert state.score["overridden_by"] == "dev"
|
||||||
|
|
||||||
|
|
||||||
def test_ws_score_override_is_not_applied_when_atomic_persistence_fails(client):
|
def test_ws_score_override_is_not_confirmed_when_commit_fails(client):
|
||||||
class FailedJournal:
|
class FailedStore(MemorySessionStore):
|
||||||
async def score_override(self, *_args):
|
async def commit(self, state, records=()):
|
||||||
return False
|
raise OSError("simulated database partition")
|
||||||
|
|
||||||
async def checkpoint(self, *_args):
|
|
||||||
return None
|
|
||||||
|
|
||||||
with lesson(client) as (session_id, control):
|
with lesson(client) as (session_id, control):
|
||||||
state = hub.get(session_id)
|
state = hub.get(session_id)
|
||||||
|
|
@ -553,15 +598,18 @@ def test_ws_score_override_is_not_applied_when_atomic_persistence_fails(client):
|
||||||
trainee.send_json({"type": "call.hangup"})
|
trainee.send_json({"type": "call.hangup"})
|
||||||
wait_for(lambda: state.score is not None)
|
wait_for(lambda: state.score is not None)
|
||||||
|
|
||||||
auto = state.score["score_auto"]
|
with client.websocket_connect(f"/ws/observe/{session_id}") as observer:
|
||||||
hub.journal = FailedJournal()
|
observer.receive_json()
|
||||||
control.send_json({
|
hub.store = FailedStore()
|
||||||
"type": "score.override", "session_id": str(session_id),
|
control.send_json({
|
||||||
"verdict": "85", "comment": "manual review",
|
"type": "score.override", "session_id": str(session_id),
|
||||||
})
|
"verdict": "85", "comment": "manual review",
|
||||||
time.sleep(0.1)
|
})
|
||||||
assert state.score["score_auto"] == auto
|
message = read_until(observer, "error")
|
||||||
assert "score_final" not in state.score
|
|
||||||
|
assert "переподключитесь" in message["message"], "без записи правка не подтверждается"
|
||||||
|
assert state.lease_fenced
|
||||||
|
assert hub.get(session_id) is None
|
||||||
|
|
||||||
|
|
||||||
def test_soft_directive_changes_how_the_caller_sounds(client):
|
def test_soft_directive_changes_how_the_caller_sounds(client):
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from app.domain.roles import Role
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.scenarios import store
|
from app.scenarios import store
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
|
from app.session.store import MemorySessionStore
|
||||||
from app.session.state import SessionState
|
from app.session.state import SessionState
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -21,7 +22,7 @@ from app.session.state import SessionState
|
||||||
def client():
|
def client():
|
||||||
with TestClient(app) as test_client:
|
with TestClient(app) as test_client:
|
||||||
test_client.post("/api/auth/dev-token")
|
test_client.post("/api/auth/dev-token")
|
||||||
hub.journal = None
|
hub.store = MemorySessionStore()
|
||||||
yield test_client
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -153,8 +154,8 @@ def test_control_command_checkpoint_failure_returns_fencing_error(monkeypatch):
|
||||||
mode="training", owner_login="lease-owner",
|
mode="training", owner_login="lease-owner",
|
||||||
)
|
)
|
||||||
|
|
||||||
class BrokenJournal:
|
class BrokenStore(MemorySessionStore):
|
||||||
async def checkpoint(self, _state):
|
async def commit(self, _state, _records=()):
|
||||||
raise OSError("simulated database partition")
|
raise OSError("simulated database partition")
|
||||||
|
|
||||||
class OneCommandSocket:
|
class OneCommandSocket:
|
||||||
|
|
@ -176,8 +177,8 @@ def test_control_command_checkpoint_failure_returns_fencing_error(monkeypatch):
|
||||||
|
|
||||||
who = Principal(login="lease-owner", full_name="Преподаватель", role=Role.INSTRUCTOR)
|
who = Principal(login="lease-owner", full_name="Преподаватель", role=Role.INSTRUCTOR)
|
||||||
socket = OneCommandSocket()
|
socket = OneCommandSocket()
|
||||||
old_journal = hub.journal
|
old_store = hub.store
|
||||||
hub.journal = BrokenJournal()
|
hub.store = BrokenStore()
|
||||||
hub.register(state)
|
hub.register(state)
|
||||||
monkeypatch.setattr(control_module, "websocket_origin_allowed", lambda _ws: True)
|
monkeypatch.setattr(control_module, "websocket_origin_allowed", lambda _ws: True)
|
||||||
monkeypatch.setattr(control_module, "principal_of", lambda _ws: who)
|
monkeypatch.setattr(control_module, "principal_of", lambda _ws: who)
|
||||||
|
|
@ -193,7 +194,7 @@ def test_control_command_checkpoint_failure_returns_fencing_error(monkeypatch):
|
||||||
assert "переподключитесь" in event.message
|
assert "переподключитесь" in event.message
|
||||||
assert observer_queue.empty(), "uncommitted controller event leaked to observers"
|
assert observer_queue.empty(), "uncommitted controller event leaked to observers"
|
||||||
finally:
|
finally:
|
||||||
hub.journal = old_journal
|
hub.store = old_store
|
||||||
hub.drop(session_id)
|
hub.drop(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue