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.db import repo
|
||||
from app.db.base import get_session
|
||||
from app.db.models import AuditLog, Group, Score, Session, Trainee
|
||||
from app.db.models import Group, Score, Session, Trainee
|
||||
from app.domain.events import Exercise, SessionMode, SessionReport
|
||||
from app.domain.roles import Role
|
||||
from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current
|
||||
|
|
@ -28,8 +28,9 @@ from app.domain.timers import TimerCode
|
|||
from app.scoring.export import to_csv, to_pdf
|
||||
from app.scoring.report import build as build_report
|
||||
from app.session.checkpoint import load_state
|
||||
from app.session.finish import scoring_scenario
|
||||
from app.session.finish import override_score, scoring_scenario
|
||||
from app.session.hub import hub
|
||||
from app.session.store import ScoreOverridden, apply_score_override
|
||||
from app.voice.recording import recording_path
|
||||
|
||||
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
||||
|
|
@ -424,14 +425,14 @@ async def _report_data(
|
|||
raise HTTPException(status_code=409, detail="self_assessment_required")
|
||||
if state.score is None:
|
||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||
if hub.journal is not None and isinstance(db, AsyncSession):
|
||||
if hub.store.persistent and isinstance(db, AsyncSession):
|
||||
persisted_session = await db.scalar(
|
||||
select(Session.id).where(Session.id == session_id)
|
||||
)
|
||||
if (persisted_session is not None and await db.scalar(
|
||||
select(Score.session_id).where(Score.session_id == session_id)
|
||||
) is None):
|
||||
# Live state is populated just before the journal transaction commits.
|
||||
# Live state is populated just before the store commit finishes.
|
||||
# Do not expose a report that looks ready but cannot yet be corrected
|
||||
# or retrieved after restart.
|
||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||
|
|
@ -547,85 +548,47 @@ async def override(
|
|||
session_id: UUID,
|
||||
body: ScoreOverride,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
db: AsyncSession | None = Depends(optional_session),
|
||||
) -> SessionReport:
|
||||
"""Тренажёр готовит материал, преподаватель имеет последнее слово.
|
||||
|
||||
Администратору сюда нельзя: ТЗ запрещает ему вмешиваться в оценки прямо.
|
||||
Правка идёт тем же путём, что команда `score.override` с пульта.
|
||||
"""
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
session = await repo.get_session(db, session_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
if session.owner_login != who.login:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
correction = ScoreOverridden(
|
||||
score_final=body.score_final, author=who.login, role=who.role.value,
|
||||
comment=body.comment,
|
||||
)
|
||||
state = hub.get(session_id)
|
||||
live_ready = state is not None and state.score is not None
|
||||
scenario = scoring_scenario(state) if live_ready else None
|
||||
if live_ready and scenario is None:
|
||||
raise HTTPException(status_code=409, detail="scenario_not_found")
|
||||
if state is not None:
|
||||
if state.owner_login != who.login:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
if state.score is None:
|
||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||
scenario = scoring_scenario(state)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=409, detail="scenario_not_found")
|
||||
async with hub.operation(session_id):
|
||||
override_score(state, correction)
|
||||
return build_report(session_id, state, scenario)
|
||||
|
||||
# Занятия нет в памяти узла: правка из истории, по архивному разбору.
|
||||
if db is None:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
session = await repo.get_session(db, session_id)
|
||||
if session is None or session.owner_login != who.login:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
score = await db.scalar(select(Score).where(Score.session_id == session_id))
|
||||
if score is None:
|
||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||
archived = (score.report or {}).get("full_report")
|
||||
if not live_ready and archived is None:
|
||||
if archived is None:
|
||||
raise HTTPException(status_code=409, detail="report_not_archived")
|
||||
|
||||
# Persist score and audit entry in the same request transaction: the API
|
||||
# must not report success if either durable record failed to commit.
|
||||
score.score_final = body.score_final
|
||||
score.overridden_by = who.login
|
||||
score.override_comment = body.comment
|
||||
report_payload = dict(score.report or {})
|
||||
if archived is not None:
|
||||
archived_payload = dict(archived)
|
||||
archived_payload.update({
|
||||
"score_auto": score.score_auto,
|
||||
"score_final": body.score_final,
|
||||
"overridden_by": who.login,
|
||||
"override_comment": body.comment,
|
||||
})
|
||||
report_payload["full_report"] = archived_payload
|
||||
score.report = report_payload
|
||||
db.add(AuditLog(
|
||||
actor=who.login,
|
||||
role=who.role.value,
|
||||
action="score.override",
|
||||
object_id=str(session_id),
|
||||
# The actual reason remains attached to the instructor-facing score
|
||||
# report. The durable security audit needs the change and actor, not
|
||||
# a second indefinite copy of free-text that may contain personal data.
|
||||
detail=(f"{score.score_auto} → {body.score_final}; "
|
||||
f"comment_chars={len(body.comment)}"),
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
if live_ready:
|
||||
assert state is not None and state.score is not None and scenario is not None
|
||||
state.score = {
|
||||
**state.score,
|
||||
"score_final": body.score_final,
|
||||
"overridden_by": who.login,
|
||||
"override_comment": body.comment,
|
||||
}
|
||||
state.score["full_report"] = {
|
||||
**state.score.get("full_report", {}),
|
||||
"score_auto": score.score_auto,
|
||||
"score_final": body.score_final,
|
||||
"overridden_by": who.login,
|
||||
"override_comment": body.comment,
|
||||
}
|
||||
result = build_report(session_id, state, scenario)
|
||||
else:
|
||||
# Historical/archived sessions no longer have a live object in the hub.
|
||||
# Keep score correction available from the instructor's report history.
|
||||
result = SessionReport.model_validate(archived).model_copy(update={
|
||||
"score_auto": score.score_auto,
|
||||
"score_final": body.score_final,
|
||||
"overridden_by": who.login,
|
||||
"override_comment": body.comment,
|
||||
})
|
||||
return result
|
||||
report = apply_score_override(dict(score.report), correction)
|
||||
await db.rollback() # чтение закончено; запись — одной транзакцией хранилища
|
||||
await hub.store.commit_archived(session_id, [correction])
|
||||
return SessionReport.model_validate(report["full_report"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[SessionOut])
|
||||
|
|
|
|||
|
|
@ -47,6 +47,13 @@ from app.session.dds import prepare_handoff_queue
|
|||
from app.session.finish import finish, refresh_archived_report, release_score
|
||||
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
||||
from app.session.state import now_utc
|
||||
from app.session.store import (
|
||||
HintRecorded,
|
||||
LessonEnded,
|
||||
LessonStarted,
|
||||
SelfAssessed,
|
||||
UtteranceAppended,
|
||||
)
|
||||
from app.voice.models import TTS_RATE, get_voice_models
|
||||
from app.voice.pipeline import VoiceSession
|
||||
from app.voice.recording import start_recording
|
||||
|
|
@ -213,8 +220,7 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
accepted = TextTurnAccepted(text=event.text, at=operator_entry.at)
|
||||
hub.to_trainee(session_id, accepted)
|
||||
hub.to_observers(session_id, TranscriptAppend(entry=operator_entry))
|
||||
if hub.journal:
|
||||
await hub.journal.utterance(session_id, operator_entry)
|
||||
hub.record(session_id, UtteranceAppended(operator_entry))
|
||||
try:
|
||||
slots = state.slots if state.slots is not None else _TextSlotView(state)
|
||||
line = await state.caller.reply(turn, state.persona, slots)
|
||||
|
|
@ -232,8 +238,7 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
at=caller_entry.at, mood=line.mood, source=line.source,
|
||||
))
|
||||
hub.to_observers(session_id, TranscriptAppend(entry=caller_entry))
|
||||
if hub.journal:
|
||||
await hub.journal.utterance(session_id, caller_entry)
|
||||
hub.record(session_id, UtteranceAppended(caller_entry))
|
||||
case "card.submit":
|
||||
state.on_event("card.submit")
|
||||
state.kio.registered_at = state.started_at or now_utc()
|
||||
|
|
@ -260,18 +265,14 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
hub.to_observers(session_id, state.snapshot())
|
||||
else:
|
||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
if hub.journal:
|
||||
await hub.journal.session_ended(
|
||||
session_id, state.ended_at, CallEndReason.COMPLETE.value
|
||||
)
|
||||
hub.record(session_id, LessonEnded(state.ended_at, CallEndReason.COMPLETE.value))
|
||||
await finish(session_id, state)
|
||||
case "call.answer":
|
||||
first_answer = state.started_at is None
|
||||
if first_answer:
|
||||
state.on_event("call.answer")
|
||||
state.started_at = now_utc()
|
||||
if hub.journal:
|
||||
await hub.journal.session_started(session_id, state.started_at)
|
||||
hub.record(session_id, LessonStarted(state.started_at))
|
||||
hub.to_trainee(session_id, CallStarted(started_at=state.started_at))
|
||||
hub.to_observers(session_id, state.snapshot())
|
||||
if state.recorder is None:
|
||||
|
|
@ -313,8 +314,7 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
state.hints_log.append((checklist_id, now_utc()))
|
||||
shown = HintShown(checklist_id=checklist_id, question=question)
|
||||
hub.broadcast(session_id, shown)
|
||||
if hub.journal:
|
||||
await hub.journal.hint(session_id, checklist_id, question, now_utc())
|
||||
hub.record(session_id, HintRecorded(checklist_id, question, now_utc()))
|
||||
|
||||
case "dds.dispatch":
|
||||
if event.service is None and not (state.kio.incident_code or state.kio.notify):
|
||||
|
|
@ -354,14 +354,7 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
state.on_event("callback.dial")
|
||||
|
||||
case "self_assessment.submit":
|
||||
if hub.journal and not await hub.journal.self_assessment(
|
||||
session_id, event.missed, event.comment, now_utc()
|
||||
):
|
||||
hub.to_trainee(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL,
|
||||
message="Не удалось сохранить самооценку и аудит; итог пока не выдан.",
|
||||
))
|
||||
return
|
||||
hub.record(session_id, SelfAssessed(list(event.missed), event.comment, now_utc()))
|
||||
state.self_assessed = True
|
||||
state.self_assessment = {"missed": event.missed, "comment": event.comment}
|
||||
await refresh_archived_report(session_id, state)
|
||||
|
|
@ -376,14 +369,9 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
hub.stop_ticker(session_id)
|
||||
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.HANGUP))
|
||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.HANGUP))
|
||||
if hub.journal:
|
||||
await hub.journal.session_ended(
|
||||
session_id, state.ended_at, CallEndReason.HANGUP.value
|
||||
)
|
||||
hub.record(session_id, LessonEnded(state.ended_at, CallEndReason.HANGUP.value))
|
||||
await finish(session_id, state)
|
||||
|
||||
await hub.checkpoint(session_id)
|
||||
|
||||
|
||||
def _start_voice(session_id: UUID, state, *, initial_statement: bool = True) -> None:
|
||||
"""Голос включается, когда курсант снял трубку: звонящий сразу кричит первую реплику."""
|
||||
|
|
@ -403,7 +391,7 @@ def _start_voice(session_id: UUID, state, *, initial_statement: bool = True) ->
|
|||
send_event=lambda event: hub.to_trainee(session_id, event),
|
||||
send_observer=lambda event: hub.to_observers(session_id, event),
|
||||
send_audio=send_audio,
|
||||
journal=hub.journal,
|
||||
persist=lambda entry: hub.commit(session_id, UtteranceAppended(entry)),
|
||||
)
|
||||
if scenario.background:
|
||||
event = BgStart(loop=scenario.background.loop, gain_db=scenario.background.gain_db)
|
||||
|
|
@ -540,7 +528,7 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
|
|||
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=str(payload)[:200]),
|
||||
)
|
||||
continue
|
||||
async with hub.durable_transition(session_id):
|
||||
async with hub.operation(session_id):
|
||||
await _handle(session_id, state, event)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from uuid import UUID
|
|||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from app.api.auth import audit, principal_of, websocket_origin_allowed
|
||||
from app.api.auth import principal_of, websocket_origin_allowed
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_sessionmaker
|
||||
from app.db.repo import SessionNodeConflict
|
||||
|
|
@ -49,7 +49,9 @@ from app.domain.timers import TimerCode
|
|||
from app.scenarios import store
|
||||
from app.session.dds import prepare_queue
|
||||
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
||||
from app.session.finish import finish, override_score
|
||||
from app.session.state import SessionState, now_utc
|
||||
from app.session.store import LessonEnded, LessonIdentity, LessonRequest, NoteAdded, ScoreOverridden
|
||||
from app.voice.models import get_voice_models
|
||||
from app.voice.pipeline import FILLERS, prefetch
|
||||
|
||||
|
|
@ -79,6 +81,70 @@ def card_briefing(state: SessionState) -> CardBriefing:
|
|||
)
|
||||
|
||||
|
||||
def _build_state(session_id: UUID, event, who, scenario, scenarios,
|
||||
identity: LessonIdentity) -> SessionState:
|
||||
"""Занятие собирается целиком и только потом регистрируется: иначе
|
||||
наблюдатель, подключившийся в эту щель, увидит полусобранное состояние
|
||||
без слот-автомата и звонящего. Снимок пишется в той же транзакции, что
|
||||
и строка занятия."""
|
||||
state = SessionState(
|
||||
session_id=session_id,
|
||||
scenario_id=scenario.id,
|
||||
scenario_title=scenario.title,
|
||||
level=scenario.level.value,
|
||||
mode=event.mode,
|
||||
owner_login=who.login if who is not None else None,
|
||||
backend_fencing_epoch=identity.fencing_epoch,
|
||||
exercise=event.exercise,
|
||||
handoff_to_dds=event.handoff_to_dds,
|
||||
scenario=scenario.model_copy(deep=True),
|
||||
required_fields=scenario.required_fields,
|
||||
trainee_name=event.trainee,
|
||||
trainee_id=identity.trainee_id,
|
||||
dds_service=identity.service or event.dds_service,
|
||||
attempt=identity.attempt,
|
||||
criteria=event.criteria,
|
||||
)
|
||||
state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
|
||||
state.timers.limits[TimerCode.CARD_FILL] = event.criteria.card_fill_time_limit_seconds * 1000
|
||||
state.timers.limits[TimerCode.DDS_WORK] = event.criteria.dds_card_work_time_limit_seconds * 1000
|
||||
if event.exercise is Exercise.CALL:
|
||||
embedder = get_embedder()
|
||||
if embedder is not None:
|
||||
state.slots = SlotMachine(state.scenario, embedder)
|
||||
state.persona = PersonaState(state.scenario.persona)
|
||||
state.caller = build_caller(
|
||||
scenario.id,
|
||||
use_pregenerated=scenario.tree.pregenerated,
|
||||
)
|
||||
elif event.exercise is Exercise.DDS:
|
||||
prepare_queue(
|
||||
state,
|
||||
[item.model_copy(deep=True) for item in scenarios],
|
||||
arrival_interval_seconds=event.dds_arrival_interval_seconds,
|
||||
max_waiting=event.dds_max_waiting,
|
||||
)
|
||||
state.started_at = state.desk.active.dispatched_at if state.desk.active else None
|
||||
else:
|
||||
state.started_at = now_utc()
|
||||
if event.exercise is Exercise.CARD:
|
||||
embedder = get_embedder()
|
||||
if embedder is not None:
|
||||
state.slots = SlotMachine(state.scenario, embedder)
|
||||
state.persona = PersonaState(state.scenario.persona)
|
||||
state.caller = build_caller(
|
||||
scenario.id, use_pregenerated=scenario.tree.pregenerated,
|
||||
)
|
||||
state.on_event("card.start")
|
||||
if event.handoff_to_dds:
|
||||
state.pending_dds_scenarios = [item.model_copy(deep=True) for item in scenarios[1:]]
|
||||
state.desk.arrival_interval_seconds = event.dds_arrival_interval_seconds
|
||||
state.desk.max_waiting = event.dds_max_waiting
|
||||
if event.exercise is Exercise.CALL:
|
||||
state.on_event("call.incoming")
|
||||
return state
|
||||
|
||||
|
||||
async def _start(session_id: UUID, event, who=None) -> None:
|
||||
if event.handoff_to_dds and event.exercise is not Exercise.CARD:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
|
|
@ -89,7 +155,7 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
scenario_ids = list(dict.fromkeys([
|
||||
event.scenario_id, *(event.scenario_ids or []), *(event.random_scenario_ids or []),
|
||||
]))
|
||||
if get_settings().demo_no_db or hub.journal is None:
|
||||
if get_settings().demo_no_db or not hub.store.persistent:
|
||||
catalog = {
|
||||
scenario_id: scenario for scenario_id in scenario_ids
|
||||
if (scenario := store.get(scenario_id)) is not None
|
||||
|
|
@ -207,96 +273,33 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
else:
|
||||
scenarios = []
|
||||
|
||||
attempt = 1
|
||||
recorded_trainee_id = event.trainee_id
|
||||
recorded_service = None
|
||||
fencing_epoch = 0
|
||||
if hub.journal:
|
||||
try:
|
||||
persisted = await hub.journal.start_lesson(
|
||||
session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id,
|
||||
owner_login=who.login if who is not None else None,
|
||||
backend_node_id=get_settings().backend_node_id,
|
||||
)
|
||||
if persisted is None:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL,
|
||||
message="Не удалось записать занятие и аудит; запуск отменён.",
|
||||
))
|
||||
return
|
||||
attempt, recorded_trainee_id, recorded_service, fencing_epoch = persisted
|
||||
except SessionNodeConflict:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.FORBIDDEN,
|
||||
message="Сессия закреплена за другим backend-узлом; проверьте маршрутизацию proxy",
|
||||
))
|
||||
return
|
||||
except PermissionError:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.FORBIDDEN,
|
||||
message="Занятие создано другим преподавателем",
|
||||
))
|
||||
return
|
||||
|
||||
# Занятие собирается целиком и только потом регистрируется: иначе
|
||||
# наблюдатель, подключившийся в эту щель, увидит полусобранное состояние
|
||||
# без слот-автомата и звонящего.
|
||||
state = SessionState(
|
||||
session_id=session_id,
|
||||
scenario_id=scenario.id,
|
||||
scenario_title=scenario.title,
|
||||
level=scenario.level.value,
|
||||
mode=event.mode,
|
||||
owner_login=who.login if who is not None else None,
|
||||
backend_fencing_epoch=fencing_epoch,
|
||||
exercise=event.exercise,
|
||||
handoff_to_dds=event.handoff_to_dds,
|
||||
scenario=scenario.model_copy(deep=True),
|
||||
required_fields=scenario.required_fields,
|
||||
trainee_name=event.trainee,
|
||||
trainee_id=recorded_trainee_id,
|
||||
dds_service=recorded_service or event.dds_service,
|
||||
attempt=attempt,
|
||||
criteria=event.criteria,
|
||||
)
|
||||
state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
|
||||
state.timers.limits[TimerCode.CARD_FILL] = event.criteria.card_fill_time_limit_seconds * 1000
|
||||
state.timers.limits[TimerCode.DDS_WORK] = event.criteria.dds_card_work_time_limit_seconds * 1000
|
||||
if event.exercise is Exercise.CALL:
|
||||
embedder = get_embedder()
|
||||
if embedder is not None:
|
||||
state.slots = SlotMachine(state.scenario, embedder)
|
||||
state.persona = PersonaState(state.scenario.persona)
|
||||
state.caller = build_caller(
|
||||
scenario.id,
|
||||
use_pregenerated=scenario.tree.pregenerated,
|
||||
)
|
||||
elif event.exercise is Exercise.DDS:
|
||||
prepare_queue(
|
||||
state,
|
||||
[item.model_copy(deep=True) for item in scenarios],
|
||||
arrival_interval_seconds=event.dds_arrival_interval_seconds,
|
||||
max_waiting=event.dds_max_waiting,
|
||||
)
|
||||
state.started_at = state.desk.active.dispatched_at if state.desk.active else None
|
||||
else:
|
||||
state.started_at = now_utc()
|
||||
if event.exercise is Exercise.CARD:
|
||||
embedder = get_embedder()
|
||||
if embedder is not None:
|
||||
state.slots = SlotMachine(state.scenario, embedder)
|
||||
state.persona = PersonaState(state.scenario.persona)
|
||||
state.caller = build_caller(
|
||||
scenario.id, use_pregenerated=scenario.tree.pregenerated,
|
||||
)
|
||||
state.on_event("card.start")
|
||||
if event.handoff_to_dds:
|
||||
state.pending_dds_scenarios = [item.model_copy(deep=True) for item in scenarios[1:]]
|
||||
state.desk.arrival_interval_seconds = event.dds_arrival_interval_seconds
|
||||
state.desk.max_waiting = event.dds_max_waiting
|
||||
try:
|
||||
state = await hub.store.open(LessonRequest(
|
||||
session_id=session_id, scenario_id=scenario.id, mode=event.mode.value,
|
||||
trainee_name=event.trainee, trainee_id=event.trainee_id,
|
||||
owner_login=who.login if who is not None else None,
|
||||
backend_node_id=get_settings().backend_node_id,
|
||||
), lambda identity: _build_state(session_id, event, who, scenario, scenarios, identity))
|
||||
except SessionNodeConflict:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.FORBIDDEN,
|
||||
message="Сессия закреплена за другим backend-узлом; проверьте маршрутизацию proxy",
|
||||
))
|
||||
return
|
||||
except PermissionError:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.FORBIDDEN,
|
||||
message="Занятие создано другим преподавателем",
|
||||
))
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 — без долговечной истории занятие не стартует
|
||||
log.error("занятие %s не записано (%s)", session_id, type(exc).__name__)
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL,
|
||||
message="Не удалось записать занятие и аудит; запуск отменён.",
|
||||
))
|
||||
return
|
||||
hub.register(state)
|
||||
if event.exercise is not Exercise.CALL and hub.journal and state.started_at is not None:
|
||||
await hub.journal.session_started(session_id, state.started_at)
|
||||
|
||||
# Первая реплика и филлеры синтезируются, пока курсант не снял трубку:
|
||||
# «Алло! Помогите!» должно прозвучать мгновенно (docs/arch/BACKEND.md).
|
||||
|
|
@ -304,8 +307,6 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
models = get_voice_models()
|
||||
if models is not None:
|
||||
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
|
||||
state.on_event("call.incoming")
|
||||
await hub.checkpoint(session_id)
|
||||
hub.start_ticker(session_id)
|
||||
if event.exercise is Exercise.CALL:
|
||||
hub.to_trainee(
|
||||
|
|
@ -340,7 +341,6 @@ async def _stop(session_id: UUID) -> None:
|
|||
await state.voice.close()
|
||||
hub.stop_ticker(session_id)
|
||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
|
||||
from app.session.finish import finish
|
||||
|
||||
if state.exercise in {Exercise.DDS, Exercise.CARD}:
|
||||
if state.exercise is Exercise.DDS or state.handoff_to_dds and state.dispatched_card is not None:
|
||||
|
|
@ -351,9 +351,97 @@ async def _stop(session_id: UUID) -> None:
|
|||
hub.to_station(session_id, ScoreReady(session_id=session_id))
|
||||
else:
|
||||
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.INSTRUCTOR))
|
||||
hub.record(session_id, LessonEnded(state.ended_at, CallEndReason.INSTRUCTOR.value))
|
||||
await finish(session_id, state)
|
||||
if hub.journal:
|
||||
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value)
|
||||
|
||||
|
||||
async def _command(session_id: UUID, event, who) -> None:
|
||||
"""Команда пульта. Всё, кроме запуска, идёт внутри `hub.operation`."""
|
||||
state = hub.get(session_id)
|
||||
match event.type:
|
||||
case "scenario.start":
|
||||
await _start(session_id, event, who)
|
||||
case "session.stop":
|
||||
await _stop(session_id)
|
||||
case "instructor_note.add":
|
||||
if state is not None:
|
||||
state.notes.append({
|
||||
"type": "instructor_note.shown",
|
||||
"transcript_ref": event.transcript_ref,
|
||||
"text": event.text,
|
||||
"author": "преподаватель",
|
||||
})
|
||||
hub.record(session_id, NoteAdded(event.transcript_ref, event.text, "преподаватель"))
|
||||
hub.to_observers(
|
||||
session_id,
|
||||
InstructorNoteShown(
|
||||
transcript_ref=event.transcript_ref,
|
||||
text=event.text,
|
||||
author="преподаватель",
|
||||
),
|
||||
)
|
||||
case "reference.play":
|
||||
if state is not None:
|
||||
hub.to_observers(session_id, ReferenceStarted(scenario_id=state.scenario_id))
|
||||
case "score.override":
|
||||
if event.session_id != session_id:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.FORBIDDEN,
|
||||
message="Оценка относится к другому занятию",
|
||||
))
|
||||
return
|
||||
if state is None or state.score is None:
|
||||
return
|
||||
try:
|
||||
verdict = float(event.verdict)
|
||||
except ValueError:
|
||||
verdict = float("nan")
|
||||
if not math.isfinite(verdict) or not 0 <= verdict <= 100:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||
message="Оценка должна быть числом от 0 до 100",
|
||||
))
|
||||
return
|
||||
override_score(state, ScoreOverridden(
|
||||
score_final=verdict, author=who.login, role=who.role.value,
|
||||
comment=event.comment,
|
||||
))
|
||||
case "director.inject":
|
||||
if state is None:
|
||||
return
|
||||
if state.exercise is not Exercise.CALL:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||
message="Директивы звонящему доступны только в голосовом упражнении",
|
||||
))
|
||||
return
|
||||
result = apply_directive(state, event.directive)
|
||||
if result.needs_network:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.DIRECTIVE_NEEDS_NETWORK,
|
||||
message="Свободный текст требует локально запущенной модели; без неё доступны кнопки",
|
||||
))
|
||||
return
|
||||
state.directives.append(event.directive)
|
||||
voice = state.voice
|
||||
if result.drop_line and voice is not None:
|
||||
# Обрыв рвёт звук на полуслове тем же механизмом, что
|
||||
# перебивание, и запускает норматив обратного дозвона.
|
||||
voice.barge_in()
|
||||
if result.drop_line:
|
||||
state.on_event("call.dropped")
|
||||
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.DROPPED))
|
||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.DROPPED))
|
||||
elif result.say and voice is not None:
|
||||
voice.speak(result.say, mood_of(state))
|
||||
case _:
|
||||
hub.to_observers(
|
||||
session_id,
|
||||
ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||
message=f"{event.type} ещё не реализовано",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _reject(ws: WebSocket, message: str) -> None:
|
||||
|
|
@ -379,10 +467,9 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
if who is None or who.role not in (Role.INSTRUCTOR,):
|
||||
await _reject(ws, "Недостаточно прав для этого экрана")
|
||||
return
|
||||
event_stream = hub.begin_event_stream(session_id)
|
||||
# Чтение живёт дольше тика опроса: wait_for отменял бы его, а отмена после
|
||||
# того, как receive уже забрал кадр, теряет команду или websocket.disconnect.
|
||||
# Команда обрабатывается здесь, в задаче, открывшей event stream.
|
||||
# Команда обрабатывается здесь, одной операцией хранилища.
|
||||
read: asyncio.Future | None = None
|
||||
try:
|
||||
while True:
|
||||
|
|
@ -422,119 +509,13 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
))
|
||||
continue
|
||||
|
||||
match event.type:
|
||||
case "scenario.start":
|
||||
await _start(session_id, event, who)
|
||||
case "session.stop":
|
||||
await _stop(session_id)
|
||||
case "instructor_note.add":
|
||||
state = hub.get(session_id)
|
||||
if state is not None:
|
||||
state.notes.append({
|
||||
"type": "instructor_note.shown",
|
||||
"transcript_ref": event.transcript_ref,
|
||||
"text": event.text,
|
||||
"author": "преподаватель",
|
||||
})
|
||||
hub.to_observers(
|
||||
session_id,
|
||||
InstructorNoteShown(
|
||||
transcript_ref=event.transcript_ref,
|
||||
text=event.text,
|
||||
author="преподаватель",
|
||||
),
|
||||
)
|
||||
if hub.journal:
|
||||
await hub.journal.note(
|
||||
session_id, event.transcript_ref, event.text, "преподаватель"
|
||||
)
|
||||
case "reference.play":
|
||||
state = hub.get(session_id)
|
||||
if state is not None:
|
||||
hub.to_observers(session_id, ReferenceStarted(scenario_id=state.scenario_id))
|
||||
case "score.override":
|
||||
if event.session_id != session_id:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.FORBIDDEN,
|
||||
message="Оценка относится к другому занятию",
|
||||
))
|
||||
continue
|
||||
state = hub.get(session_id)
|
||||
if state is not None and state.score is not None:
|
||||
try:
|
||||
verdict = float(event.verdict)
|
||||
except ValueError:
|
||||
verdict = float("nan")
|
||||
if not math.isfinite(verdict) or not 0 <= verdict <= 100:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||
message="Оценка должна быть числом от 0 до 100",
|
||||
))
|
||||
continue
|
||||
if hub.journal is not None:
|
||||
saved = await hub.journal.score_override(
|
||||
session_id, verdict, who.login, event.comment
|
||||
)
|
||||
if not saved:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL,
|
||||
message="Не удалось сохранить оценку и запись аудита; изменение отменено",
|
||||
))
|
||||
continue
|
||||
else:
|
||||
# Explicit in-memory demo mode has no Score table.
|
||||
await audit(
|
||||
who.login, who.role.value, "score.override", str(session_id),
|
||||
f"{state.score.get('score_auto')} → {verdict}; "
|
||||
f"comment_chars={len(event.comment)}",
|
||||
)
|
||||
# Автооценка остаётся рядом: видно, что скорректировано и кем.
|
||||
state.score = {
|
||||
**state.score,
|
||||
"score_final": verdict,
|
||||
"overridden_by": who.login,
|
||||
"override_comment": event.comment,
|
||||
}
|
||||
hub.to_observers(session_id, ScoreReady(session_id=session_id))
|
||||
case "director.inject":
|
||||
state = hub.get(session_id)
|
||||
if state is None:
|
||||
continue
|
||||
if state.exercise is not Exercise.CALL:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||
message="Директивы звонящему доступны только в голосовом упражнении",
|
||||
))
|
||||
continue
|
||||
result = apply_directive(state, event.directive)
|
||||
if result.needs_network:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.DIRECTIVE_NEEDS_NETWORK,
|
||||
message="Свободный текст требует локально запущенной модели; без неё доступны кнопки",
|
||||
))
|
||||
continue
|
||||
state.directives.append(event.directive)
|
||||
voice = state.voice
|
||||
if result.drop_line and voice is not None:
|
||||
# Обрыв рвёт звук на полуслове тем же механизмом, что
|
||||
# перебивание, и запускает норматив обратного дозвона.
|
||||
voice.barge_in()
|
||||
if result.drop_line:
|
||||
state.on_event("call.dropped")
|
||||
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.DROPPED))
|
||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.DROPPED))
|
||||
elif result.say and voice is not None:
|
||||
voice.speak(result.say, mood_of(state))
|
||||
case _:
|
||||
hub.to_observers(
|
||||
session_id,
|
||||
ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||
message=f"{event.type} ещё не реализовано",
|
||||
),
|
||||
)
|
||||
if event.type == "scenario.start":
|
||||
# Запуск пишется своей транзакцией в `store.open`: до неё занятия нет.
|
||||
await _command(session_id, event, who)
|
||||
continue
|
||||
try:
|
||||
await hub.checkpoint(session_id)
|
||||
async with hub.operation(session_id):
|
||||
await _command(session_id, event, who)
|
||||
except Exception:
|
||||
if hub.is_lease_fenced(session_id):
|
||||
await ws.send_text(ErrorEvent(
|
||||
|
|
@ -551,4 +532,3 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
finally:
|
||||
if read is not None:
|
||||
read.cancel()
|
||||
await hub.end_event_stream(event_stream)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
в отчёте и становится сорванным выездом с конкретной причиной.
|
||||
|
||||
Правила пульта — в `DdsDesk.apply`; здесь разбор команды, отсев повторов,
|
||||
durable transition и рассылка итога.
|
||||
операция хранилища и рассылка итога.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
|
@ -32,6 +32,7 @@ from app.domain.events import (
|
|||
from app.domain.roles import Role
|
||||
from app.session.finish import finish
|
||||
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
||||
from app.session.store import LessonEnded
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
|
@ -48,8 +49,7 @@ async def _finish_dds(session_id: UUID, state) -> None:
|
|||
hub.stop_ticker(session_id)
|
||||
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
if hub.journal:
|
||||
await hub.journal.session_ended(session_id, ended_at, CallEndReason.COMPLETE.value)
|
||||
hub.record(session_id, LessonEnded(ended_at, CallEndReason.COMPLETE.value))
|
||||
await finish(session_id, state)
|
||||
hub.to_station(session_id, ScoreReady(session_id=session_id))
|
||||
|
||||
|
|
@ -65,7 +65,6 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
await _finish_dds(session_id, state)
|
||||
if not outcome.changed:
|
||||
return
|
||||
await hub.checkpoint(session_id)
|
||||
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
|
||||
hub.to_observers(session_id, state.snapshot())
|
||||
|
||||
|
|
@ -156,18 +155,13 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
|
|||
# Re-ack it without rerunning its business transition.
|
||||
hub.to_station(session_id, CommandAck(command_id=command_id))
|
||||
continue
|
||||
async with hub.durable_transition(session_id):
|
||||
async with hub.operation(session_id):
|
||||
await _handle(session_id, state, event)
|
||||
if command_id is not None:
|
||||
# ID команды входит в тот же снимок, что и её переход:
|
||||
# подтверждение уходит только после их общего commit.
|
||||
state.processed_station_commands.append(str(command_id))
|
||||
del state.processed_station_commands[:-512]
|
||||
# Commit the state+dedupe ID before acknowledging. The
|
||||
# transition context can have already flushed other
|
||||
# events; an explicit checkpoint here makes the
|
||||
# command/ACK boundary independent of that batch state.
|
||||
await hub.checkpoint(session_id)
|
||||
# The hub stages non-error events until the checkpoint
|
||||
# transaction has committed, including this ack.
|
||||
hub.to_station(session_id, CommandAck(command_id=command_id))
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
|
@ -178,7 +172,7 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
|
|||
"закрытие станционного WebSocket после fencing занятия %s",
|
||||
session_id,
|
||||
)
|
||||
# `hub.checkpoint` broadcasts a structured fence event before
|
||||
# `hub.operation` broadcasts a structured fence event before
|
||||
# propagating the failed database write. Let the sender deliver
|
||||
# that event and close with 1012 instead of an opaque 1006.
|
||||
await asyncio.gather(sender, return_exceptions=True)
|
||||
|
|
|
|||
Loading…
Reference in a new issue