lct-39: пауза занятия с пульта, таймеры и очередь ДДС стоят на время простоя
This commit is contained in:
parent
b4420cea16
commit
b77d65c82e
23 changed files with 410 additions and 20 deletions
|
|
@ -101,6 +101,7 @@ class ActiveSessionOut(BaseModel):
|
|||
dds_work_overdue_cards: int
|
||||
dds_statuses: dict[str, str]
|
||||
dds_snapshot: StationSnapshot | None = None
|
||||
paused: bool = False
|
||||
|
||||
|
||||
def _out(session) -> SessionOut:
|
||||
|
|
@ -262,6 +263,7 @@ async def active(
|
|||
),
|
||||
dds_statuses=latest_statuses,
|
||||
dds_snapshot=station,
|
||||
paused=state.paused,
|
||||
))
|
||||
return result
|
||||
|
||||
|
|
|
|||
|
|
@ -191,6 +191,11 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
code=ErrorKind.UNSUPPORTED_EVENT, message="Занятие уже завершено",
|
||||
))
|
||||
return
|
||||
if state.paused:
|
||||
hub.to_trainee(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT, message="Пауза, ждите преподавателя",
|
||||
))
|
||||
return
|
||||
if (event.type == "text.turn" and state.exercise is not Exercise.CARD) or state.exercise is Exercise.DDS or (
|
||||
state.exercise is Exercise.CARD and event.type not in {"kio.patch", "card.submit", "text.turn"}
|
||||
) or (state.exercise is Exercise.CALL and event.type == "card.submit"):
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ from app.domain.events import (
|
|||
ModeSet,
|
||||
ReferenceStarted,
|
||||
SessionEnded,
|
||||
SessionPaused,
|
||||
StationState,
|
||||
)
|
||||
from app.domain.roles import Role
|
||||
|
|
@ -51,7 +52,14 @@ from app.session.access import can_access
|
|||
from app.session.hub import hub
|
||||
from app.session.finish import end_session, override_score
|
||||
from app.session.state import SessionState, now_utc
|
||||
from app.session.store import LessonIdentity, LessonRequest, NoteAdded, ScoreOverridden
|
||||
from app.session.store import (
|
||||
LessonIdentity,
|
||||
LessonPaused,
|
||||
LessonRequest,
|
||||
LessonResumed,
|
||||
NoteAdded,
|
||||
ScoreOverridden,
|
||||
)
|
||||
from app.voice.models import get_voice_models
|
||||
from app.voice.pipeline import FILLERS, prefetch
|
||||
|
||||
|
|
@ -344,6 +352,26 @@ async def _command(session_id: UUID, event, who) -> None:
|
|||
await _start(session_id, event, who)
|
||||
case "session.stop":
|
||||
await _stop(session_id)
|
||||
case "session.pause":
|
||||
if state is None or state.ended or state.paused:
|
||||
return
|
||||
state.pause()
|
||||
hub.record(session_id, LessonPaused(
|
||||
at=state.paused_at, author=who.login, role=who.role.value,
|
||||
))
|
||||
hub.broadcast(session_id, SessionPaused(paused=True))
|
||||
hub.to_station(session_id, SessionPaused(paused=True))
|
||||
case "session.resume":
|
||||
if state is None or state.ended or not state.paused:
|
||||
return
|
||||
paused_ms_before = state.total_paused_ms
|
||||
state.resume()
|
||||
hub.record(session_id, LessonResumed(
|
||||
at=now_utc(), author=who.login, role=who.role.value,
|
||||
paused_ms=state.total_paused_ms - paused_ms_before,
|
||||
))
|
||||
hub.broadcast(session_id, SessionPaused(paused=False))
|
||||
hub.to_station(session_id, SessionPaused(paused=False))
|
||||
case "instructor_note.add":
|
||||
if state is not None:
|
||||
state.notes.append({
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ def _error(session_id: UUID, message: str) -> None:
|
|||
|
||||
|
||||
async def _handle(session_id: UUID, state, event) -> None:
|
||||
if state.paused:
|
||||
_error(session_id, "Пауза, ждите преподавателя")
|
||||
return
|
||||
outcome = state.desk.apply(event, state)
|
||||
if outcome.error is not None:
|
||||
_error(session_id, outcome.error)
|
||||
|
|
|
|||
|
|
@ -274,6 +274,13 @@ class ErrorEvent(BaseModel):
|
|||
message: str
|
||||
|
||||
|
||||
class SessionPaused(BaseModel):
|
||||
"""Пульт остановил или возобновил время занятия (lct-39)."""
|
||||
|
||||
type: Literal["session.paused"] = "session.paused"
|
||||
paused: bool
|
||||
|
||||
|
||||
ServerToTrainee = Annotated[
|
||||
CallIncoming
|
||||
| CardBriefing
|
||||
|
|
@ -293,6 +300,7 @@ ServerToTrainee = Annotated[
|
|||
| TimerTick
|
||||
| CallEnded
|
||||
| ScoreReady
|
||||
| SessionPaused
|
||||
| ErrorEvent,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
|
@ -404,6 +412,8 @@ class SessionSnapshot(BaseModel):
|
|||
timers: list[TimerSnapshot]
|
||||
hints_used: int = 0
|
||||
ended: bool = False
|
||||
#: Пульт остановил время занятия (lct-39).
|
||||
paused: bool = False
|
||||
|
||||
|
||||
class TranscriptAppend(BaseModel):
|
||||
|
|
@ -458,6 +468,7 @@ ServerToObserver = Annotated[
|
|||
| ScoreReady
|
||||
| InstructorNoteShown
|
||||
| ReferenceStarted
|
||||
| SessionPaused
|
||||
| ErrorEvent,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
|
@ -518,13 +529,25 @@ class SessionStop(BaseModel):
|
|||
type: Literal["session.stop"] = "session.stop"
|
||||
|
||||
|
||||
class SessionPause(BaseModel):
|
||||
"""Остановить время занятия; карточку курсанта команда не меняет (lct-39)."""
|
||||
|
||||
type: Literal["session.pause"] = "session.pause"
|
||||
|
||||
|
||||
class SessionResume(BaseModel):
|
||||
type: Literal["session.resume"] = "session.resume"
|
||||
|
||||
|
||||
InstructorToServer = Annotated[
|
||||
ScenarioStart
|
||||
| DirectorInject
|
||||
| ReferencePlay
|
||||
| InstructorNoteAdd
|
||||
| ScoreOverride
|
||||
| SessionStop,
|
||||
| SessionStop
|
||||
| SessionPause
|
||||
| SessionResume,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
|
@ -665,7 +688,7 @@ class CrewArrived(BaseModel):
|
|||
|
||||
ServerToStation = Annotated[
|
||||
CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded
|
||||
| ScoreReady | CommandAck | ErrorEvent,
|
||||
| ScoreReady | CommandAck | SessionPaused | ErrorEvent,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
|
@ -783,6 +806,8 @@ class SessionReport(BaseModel):
|
|||
score_final: float
|
||||
overridden_by: str | None = None
|
||||
override_comment: str | None = None
|
||||
#: Суммарная длительность пауз преподавателя — время объяснимо (lct-39).
|
||||
total_paused_ms: int = 0
|
||||
|
||||
|
||||
class Principal(BaseModel):
|
||||
|
|
|
|||
|
|
@ -276,3 +276,5 @@ class StationSnapshot(BaseModel):
|
|||
pending_cards_count: int = 0
|
||||
next_arrival_in_seconds: int | None = None
|
||||
max_waiting_cards: int = 3
|
||||
#: Пульт на паузе: время не идёт, новая карточка не поступит (lct-39).
|
||||
paused: bool = False
|
||||
|
|
|
|||
|
|
@ -111,4 +111,5 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
|||
override_comment=score.get("override_comment"),
|
||||
ai_coaching=(AICoaching.model_validate(score["ai_coaching"])
|
||||
if score.get("ai_coaching") else None),
|
||||
total_paused_ms=state.total_paused_ms,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@ class SessionHub:
|
|||
state = self.get(session_id)
|
||||
if state is None or state.ended:
|
||||
return
|
||||
if state.dds_phase:
|
||||
if state.dds_phase and not state.paused:
|
||||
async with self.operation(session_id) as op:
|
||||
active_before = state.desk.active_id
|
||||
op.persist = bool(state.desk.deliver_due())
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ from app.session.store import (
|
|||
HintRecorded,
|
||||
LessonEnded,
|
||||
LessonIdentity,
|
||||
LessonPaused,
|
||||
LessonRequest,
|
||||
LessonResumed,
|
||||
LessonStarted,
|
||||
NoteAdded,
|
||||
Record,
|
||||
|
|
@ -196,6 +198,16 @@ class PostgresSessionStore:
|
|||
object_id=str(session_id),
|
||||
detail=f"missed_count={len(record.missed)}; comment_chars={len(record.comment)}",
|
||||
))
|
||||
case LessonPaused(at=at, author=author, role=role):
|
||||
db.add(AuditLog(
|
||||
actor=author, role=role, action="session.pause",
|
||||
object_id=str(session_id), detail=f"at={at.isoformat()}",
|
||||
))
|
||||
case LessonResumed(at=at, author=author, role=role, paused_ms=paused_ms):
|
||||
db.add(AuditLog(
|
||||
actor=author, role=role, action="session.resume",
|
||||
object_id=str(session_id), detail=f"at={at.isoformat()}; paused_ms={paused_ms}",
|
||||
))
|
||||
case LessonStarted(at=at):
|
||||
await db.execute(
|
||||
update(Session).where(Session.id == session_id).values(started_at=at)
|
||||
|
|
|
|||
|
|
@ -105,6 +105,12 @@ class PersistedSession(BaseModel):
|
|||
started_at: datetime | None = None
|
||||
ended_at: datetime | None = None
|
||||
end_reason: CallEndReason | None = None
|
||||
#: Пульт остановил время занятия; таймеры и очередь ДДС заморожены (lct-39).
|
||||
paused: bool = False
|
||||
#: Момент постановки на паузу — им же считается длительность простоя при снятии.
|
||||
paused_at: datetime | None = None
|
||||
#: Суммарная длительность пауз — для разбора, чтобы время было объяснимо.
|
||||
total_paused_ms: int = 0
|
||||
#: Карточка оператора 112, замороженная при передаче в ДДС.
|
||||
dispatched_card: KIO | None = None
|
||||
dispatched_at: datetime | None = None
|
||||
|
|
@ -181,6 +187,33 @@ class SessionState(PersistedSession):
|
|||
"""Единственная точка, где событие двигает таймеры."""
|
||||
self.timers.on_event(event_type)
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Заморозить таймеры занятия и карточек пульта; очередь не выдаёт следующую."""
|
||||
if self.paused:
|
||||
return
|
||||
now = time.monotonic()
|
||||
self.timers.pause(now)
|
||||
for card in self.desk.cards.values():
|
||||
card.timers.pause(now)
|
||||
self.paused = True
|
||||
self.paused_at = now_utc()
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Снять паузу: таймеры продолжают с прежней отметки, очередь сдвигается на простой."""
|
||||
if not self.paused:
|
||||
return
|
||||
now = time.monotonic()
|
||||
self.timers.resume(now)
|
||||
for card in self.desk.cards.values():
|
||||
card.timers.resume(now)
|
||||
if self.paused_at is not None:
|
||||
idle = now_utc() - self.paused_at
|
||||
self.total_paused_ms += max(0, int(idle.total_seconds() * 1000))
|
||||
if self.desk.next_arrival_at is not None:
|
||||
self.desk.next_arrival_at += idle
|
||||
self.paused = False
|
||||
self.paused_at = None
|
||||
|
||||
def append(self, speaker: Speaker, text: str, mood: Mood | None = None) -> TranscriptEntry:
|
||||
entry = TranscriptEntry(
|
||||
ref=f"u{len(self.transcript) + 1}",
|
||||
|
|
@ -319,14 +352,15 @@ class SessionState(PersistedSession):
|
|||
services = self._managed_services_for(recipients)
|
||||
log = list(card.status_log) if card is not None else []
|
||||
statuses = {service: current(log, service) for service in services}
|
||||
now_mono = time.monotonic()
|
||||
overdue = False
|
||||
if card is not None:
|
||||
elapsed = card.timers.measured_ms(TimerCode.DDS_ACK)
|
||||
# Через таймер, а не через `dispatched_at`: часы разошлись бы с
|
||||
# паузой, которая держит `current_ms` на месте (lct-39).
|
||||
ack_timer = card.timers.timers.get(TimerCode.DDS_ACK)
|
||||
limit = card.timers.limits[TimerCode.DDS_ACK]
|
||||
overdue = elapsed is None and (
|
||||
(now_utc() - card.dispatched_at).total_seconds() * 1000 > limit
|
||||
)
|
||||
now_mono = time.monotonic()
|
||||
elapsed_ms = ack_timer.current_ms(now_mono) if ack_timer else 0
|
||||
overdue = not (ack_timer.stopped if ack_timer else False) and elapsed_ms > limit
|
||||
queue_cards: list[DdsQueueCard] = []
|
||||
for item in (desk.ordered() if self.dds_phase else []):
|
||||
recipients_for_card = list(item.dispatched_card.notify)
|
||||
|
|
@ -397,6 +431,7 @@ class SessionState(PersistedSession):
|
|||
if desk.next_arrival_at is not None else None
|
||||
),
|
||||
max_waiting_cards=desk.max_waiting,
|
||||
paused=self.paused,
|
||||
)
|
||||
|
||||
def card_received_event(self):
|
||||
|
|
@ -445,4 +480,5 @@ class SessionState(PersistedSession):
|
|||
timers=self.shown_timers().snapshot(),
|
||||
hints_used=len(self.hints_shown),
|
||||
ended=self.ended,
|
||||
paused=self.paused,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -83,8 +83,25 @@ class ScoreOverridden:
|
|||
comment: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LessonPaused:
|
||||
at: datetime
|
||||
author: str
|
||||
role: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LessonResumed:
|
||||
at: datetime
|
||||
author: str
|
||||
role: str
|
||||
#: Длительность этой конкретной паузы — видно в аудите без пересчёта.
|
||||
paused_ms: int
|
||||
|
||||
|
||||
Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted
|
||||
| LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden)
|
||||
| LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden
|
||||
| LessonPaused | LessonResumed)
|
||||
|
||||
|
||||
def apply_score_override(report: dict, override: ScoreOverridden) -> dict:
|
||||
|
|
|
|||
|
|
@ -83,6 +83,9 @@ class Timer(BaseModel):
|
|||
elapsed_ms: int = 0
|
||||
attempt: int = 1
|
||||
stopped: bool = False
|
||||
#: Пауза занятия — таймер заморожен, но не завершён: `start()` его не
|
||||
#: считает новой попыткой, в отличие от `stopped` (lct-39).
|
||||
paused: bool = False
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _dump(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
|
||||
|
|
@ -100,10 +103,13 @@ class Timer(BaseModel):
|
|||
data = dict(data)
|
||||
elapsed = max(0, int(data.get("elapsed_ms", 0)))
|
||||
stopped = bool(data.get("stopped"))
|
||||
downtime = 0 if stopped else (info.context or {}).get("downtime_ms", 0)
|
||||
paused = bool(data.get("paused"))
|
||||
# На паузе занятие не простаивало без присмотра — оно ждало
|
||||
# преподавателя, и это время не досчитывается таймеру при перезапуске.
|
||||
downtime = 0 if stopped or paused else (info.context or {}).get("downtime_ms", 0)
|
||||
started = data.pop("started")
|
||||
data["started_at"] = time.monotonic() - (elapsed + downtime) / 1000 if started else None
|
||||
data["elapsed_ms"] = elapsed if stopped else 0
|
||||
data["elapsed_ms"] = elapsed if (stopped or paused) else 0
|
||||
return data
|
||||
|
||||
def start(self, now: float) -> None:
|
||||
|
|
@ -125,6 +131,19 @@ class Timer(BaseModel):
|
|||
return self.elapsed_ms
|
||||
return int((now - self.started_at) * 1000)
|
||||
|
||||
def pause(self, now: float) -> None:
|
||||
"""Зафиксировать прошедшее время и остановить ход часов до `resume`."""
|
||||
if self.started_at is not None and not self.stopped:
|
||||
self.elapsed_ms = self.current_ms(now)
|
||||
self.started_at = None
|
||||
self.paused = True
|
||||
|
||||
def resume(self, now: float) -> None:
|
||||
"""Продолжить с той же отметки — простой в счёт не идёт."""
|
||||
if self.paused:
|
||||
self.started_at = now - self.elapsed_ms / 1000
|
||||
self.paused = False
|
||||
|
||||
|
||||
class SessionTimers(BaseModel):
|
||||
"""Набор таймеров одной сессии. `limits` приходит из конфига —
|
||||
|
|
@ -142,6 +161,16 @@ class SessionTimers(BaseModel):
|
|||
for code in STOPS.get(event_type, ()):
|
||||
self.timers.setdefault(code, Timer(code=code)).stop(now)
|
||||
|
||||
def pause(self, now: float | None = None) -> None:
|
||||
now = time.monotonic() if now is None else now
|
||||
for timer in self.timers.values():
|
||||
timer.pause(now)
|
||||
|
||||
def resume(self, now: float | None = None) -> None:
|
||||
now = time.monotonic() if now is None else now
|
||||
for timer in self.timers.values():
|
||||
timer.resume(now)
|
||||
|
||||
def snapshot(self, now: float | None = None) -> list[TimerSnapshot]:
|
||||
"""Только запущенные таймеры: показывать нули по нормативам,
|
||||
до которых занятие ещё не дошло, значит пугать курсанта зря."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue