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_work_overdue_cards: int
|
||||||
dds_statuses: dict[str, str]
|
dds_statuses: dict[str, str]
|
||||||
dds_snapshot: StationSnapshot | None = None
|
dds_snapshot: StationSnapshot | None = None
|
||||||
|
paused: bool = False
|
||||||
|
|
||||||
|
|
||||||
def _out(session) -> SessionOut:
|
def _out(session) -> SessionOut:
|
||||||
|
|
@ -262,6 +263,7 @@ async def active(
|
||||||
),
|
),
|
||||||
dds_statuses=latest_statuses,
|
dds_statuses=latest_statuses,
|
||||||
dds_snapshot=station,
|
dds_snapshot=station,
|
||||||
|
paused=state.paused,
|
||||||
))
|
))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,11 @@ async def _handle(session_id: UUID, state, event) -> None:
|
||||||
code=ErrorKind.UNSUPPORTED_EVENT, message="Занятие уже завершено",
|
code=ErrorKind.UNSUPPORTED_EVENT, message="Занятие уже завершено",
|
||||||
))
|
))
|
||||||
return
|
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 (
|
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"}
|
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"):
|
) or (state.exercise is Exercise.CALL and event.type == "card.submit"):
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ from app.domain.events import (
|
||||||
ModeSet,
|
ModeSet,
|
||||||
ReferenceStarted,
|
ReferenceStarted,
|
||||||
SessionEnded,
|
SessionEnded,
|
||||||
|
SessionPaused,
|
||||||
StationState,
|
StationState,
|
||||||
)
|
)
|
||||||
from app.domain.roles import Role
|
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.hub import hub
|
||||||
from app.session.finish import end_session, override_score
|
from app.session.finish import end_session, override_score
|
||||||
from app.session.state import SessionState, now_utc
|
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.models import get_voice_models
|
||||||
from app.voice.pipeline import FILLERS, prefetch
|
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)
|
await _start(session_id, event, who)
|
||||||
case "session.stop":
|
case "session.stop":
|
||||||
await _stop(session_id)
|
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":
|
case "instructor_note.add":
|
||||||
if state is not None:
|
if state is not None:
|
||||||
state.notes.append({
|
state.notes.append({
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ def _error(session_id: UUID, message: str) -> None:
|
||||||
|
|
||||||
|
|
||||||
async def _handle(session_id: UUID, state, event) -> None:
|
async def _handle(session_id: UUID, state, event) -> None:
|
||||||
|
if state.paused:
|
||||||
|
_error(session_id, "Пауза, ждите преподавателя")
|
||||||
|
return
|
||||||
outcome = state.desk.apply(event, state)
|
outcome = state.desk.apply(event, state)
|
||||||
if outcome.error is not None:
|
if outcome.error is not None:
|
||||||
_error(session_id, outcome.error)
|
_error(session_id, outcome.error)
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,13 @@ class ErrorEvent(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class SessionPaused(BaseModel):
|
||||||
|
"""Пульт остановил или возобновил время занятия (lct-39)."""
|
||||||
|
|
||||||
|
type: Literal["session.paused"] = "session.paused"
|
||||||
|
paused: bool
|
||||||
|
|
||||||
|
|
||||||
ServerToTrainee = Annotated[
|
ServerToTrainee = Annotated[
|
||||||
CallIncoming
|
CallIncoming
|
||||||
| CardBriefing
|
| CardBriefing
|
||||||
|
|
@ -293,6 +300,7 @@ ServerToTrainee = Annotated[
|
||||||
| TimerTick
|
| TimerTick
|
||||||
| CallEnded
|
| CallEnded
|
||||||
| ScoreReady
|
| ScoreReady
|
||||||
|
| SessionPaused
|
||||||
| ErrorEvent,
|
| ErrorEvent,
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|
@ -404,6 +412,8 @@ class SessionSnapshot(BaseModel):
|
||||||
timers: list[TimerSnapshot]
|
timers: list[TimerSnapshot]
|
||||||
hints_used: int = 0
|
hints_used: int = 0
|
||||||
ended: bool = False
|
ended: bool = False
|
||||||
|
#: Пульт остановил время занятия (lct-39).
|
||||||
|
paused: bool = False
|
||||||
|
|
||||||
|
|
||||||
class TranscriptAppend(BaseModel):
|
class TranscriptAppend(BaseModel):
|
||||||
|
|
@ -458,6 +468,7 @@ ServerToObserver = Annotated[
|
||||||
| ScoreReady
|
| ScoreReady
|
||||||
| InstructorNoteShown
|
| InstructorNoteShown
|
||||||
| ReferenceStarted
|
| ReferenceStarted
|
||||||
|
| SessionPaused
|
||||||
| ErrorEvent,
|
| ErrorEvent,
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|
@ -518,13 +529,25 @@ class SessionStop(BaseModel):
|
||||||
type: Literal["session.stop"] = "session.stop"
|
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[
|
InstructorToServer = Annotated[
|
||||||
ScenarioStart
|
ScenarioStart
|
||||||
| DirectorInject
|
| DirectorInject
|
||||||
| ReferencePlay
|
| ReferencePlay
|
||||||
| InstructorNoteAdd
|
| InstructorNoteAdd
|
||||||
| ScoreOverride
|
| ScoreOverride
|
||||||
| SessionStop,
|
| SessionStop
|
||||||
|
| SessionPause
|
||||||
|
| SessionResume,
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -665,7 +688,7 @@ class CrewArrived(BaseModel):
|
||||||
|
|
||||||
ServerToStation = Annotated[
|
ServerToStation = Annotated[
|
||||||
CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded
|
CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded
|
||||||
| ScoreReady | CommandAck | ErrorEvent,
|
| ScoreReady | CommandAck | SessionPaused | ErrorEvent,
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -783,6 +806,8 @@ class SessionReport(BaseModel):
|
||||||
score_final: float
|
score_final: float
|
||||||
overridden_by: str | None = None
|
overridden_by: str | None = None
|
||||||
override_comment: str | None = None
|
override_comment: str | None = None
|
||||||
|
#: Суммарная длительность пауз преподавателя — время объяснимо (lct-39).
|
||||||
|
total_paused_ms: int = 0
|
||||||
|
|
||||||
|
|
||||||
class Principal(BaseModel):
|
class Principal(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -276,3 +276,5 @@ class StationSnapshot(BaseModel):
|
||||||
pending_cards_count: int = 0
|
pending_cards_count: int = 0
|
||||||
next_arrival_in_seconds: int | None = None
|
next_arrival_in_seconds: int | None = None
|
||||||
max_waiting_cards: int = 3
|
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"),
|
override_comment=score.get("override_comment"),
|
||||||
ai_coaching=(AICoaching.model_validate(score["ai_coaching"])
|
ai_coaching=(AICoaching.model_validate(score["ai_coaching"])
|
||||||
if score.get("ai_coaching") else None),
|
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)
|
state = self.get(session_id)
|
||||||
if state is None or state.ended:
|
if state is None or state.ended:
|
||||||
return
|
return
|
||||||
if state.dds_phase:
|
if state.dds_phase and not state.paused:
|
||||||
async with self.operation(session_id) as op:
|
async with self.operation(session_id) as op:
|
||||||
active_before = state.desk.active_id
|
active_before = state.desk.active_id
|
||||||
op.persist = bool(state.desk.deliver_due())
|
op.persist = bool(state.desk.deliver_due())
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,9 @@ from app.session.store import (
|
||||||
HintRecorded,
|
HintRecorded,
|
||||||
LessonEnded,
|
LessonEnded,
|
||||||
LessonIdentity,
|
LessonIdentity,
|
||||||
|
LessonPaused,
|
||||||
LessonRequest,
|
LessonRequest,
|
||||||
|
LessonResumed,
|
||||||
LessonStarted,
|
LessonStarted,
|
||||||
NoteAdded,
|
NoteAdded,
|
||||||
Record,
|
Record,
|
||||||
|
|
@ -196,6 +198,16 @@ class PostgresSessionStore:
|
||||||
object_id=str(session_id),
|
object_id=str(session_id),
|
||||||
detail=f"missed_count={len(record.missed)}; comment_chars={len(record.comment)}",
|
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):
|
case LessonStarted(at=at):
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(Session).where(Session.id == session_id).values(started_at=at)
|
update(Session).where(Session.id == session_id).values(started_at=at)
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,12 @@ class PersistedSession(BaseModel):
|
||||||
started_at: datetime | None = None
|
started_at: datetime | None = None
|
||||||
ended_at: datetime | None = None
|
ended_at: datetime | None = None
|
||||||
end_reason: CallEndReason | None = None
|
end_reason: CallEndReason | None = None
|
||||||
|
#: Пульт остановил время занятия; таймеры и очередь ДДС заморожены (lct-39).
|
||||||
|
paused: bool = False
|
||||||
|
#: Момент постановки на паузу — им же считается длительность простоя при снятии.
|
||||||
|
paused_at: datetime | None = None
|
||||||
|
#: Суммарная длительность пауз — для разбора, чтобы время было объяснимо.
|
||||||
|
total_paused_ms: int = 0
|
||||||
#: Карточка оператора 112, замороженная при передаче в ДДС.
|
#: Карточка оператора 112, замороженная при передаче в ДДС.
|
||||||
dispatched_card: KIO | None = None
|
dispatched_card: KIO | None = None
|
||||||
dispatched_at: datetime | None = None
|
dispatched_at: datetime | None = None
|
||||||
|
|
@ -181,6 +187,33 @@ class SessionState(PersistedSession):
|
||||||
"""Единственная точка, где событие двигает таймеры."""
|
"""Единственная точка, где событие двигает таймеры."""
|
||||||
self.timers.on_event(event_type)
|
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:
|
def append(self, speaker: Speaker, text: str, mood: Mood | None = None) -> TranscriptEntry:
|
||||||
entry = TranscriptEntry(
|
entry = TranscriptEntry(
|
||||||
ref=f"u{len(self.transcript) + 1}",
|
ref=f"u{len(self.transcript) + 1}",
|
||||||
|
|
@ -319,14 +352,15 @@ class SessionState(PersistedSession):
|
||||||
services = self._managed_services_for(recipients)
|
services = self._managed_services_for(recipients)
|
||||||
log = list(card.status_log) if card is not None else []
|
log = list(card.status_log) if card is not None else []
|
||||||
statuses = {service: current(log, service) for service in services}
|
statuses = {service: current(log, service) for service in services}
|
||||||
|
now_mono = time.monotonic()
|
||||||
overdue = False
|
overdue = False
|
||||||
if card is not None:
|
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]
|
limit = card.timers.limits[TimerCode.DDS_ACK]
|
||||||
overdue = elapsed is None and (
|
elapsed_ms = ack_timer.current_ms(now_mono) if ack_timer else 0
|
||||||
(now_utc() - card.dispatched_at).total_seconds() * 1000 > limit
|
overdue = not (ack_timer.stopped if ack_timer else False) and elapsed_ms > limit
|
||||||
)
|
|
||||||
now_mono = time.monotonic()
|
|
||||||
queue_cards: list[DdsQueueCard] = []
|
queue_cards: list[DdsQueueCard] = []
|
||||||
for item in (desk.ordered() if self.dds_phase else []):
|
for item in (desk.ordered() if self.dds_phase else []):
|
||||||
recipients_for_card = list(item.dispatched_card.notify)
|
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
|
if desk.next_arrival_at is not None else None
|
||||||
),
|
),
|
||||||
max_waiting_cards=desk.max_waiting,
|
max_waiting_cards=desk.max_waiting,
|
||||||
|
paused=self.paused,
|
||||||
)
|
)
|
||||||
|
|
||||||
def card_received_event(self):
|
def card_received_event(self):
|
||||||
|
|
@ -445,4 +480,5 @@ class SessionState(PersistedSession):
|
||||||
timers=self.shown_timers().snapshot(),
|
timers=self.shown_timers().snapshot(),
|
||||||
hints_used=len(self.hints_shown),
|
hints_used=len(self.hints_shown),
|
||||||
ended=self.ended,
|
ended=self.ended,
|
||||||
|
paused=self.paused,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,25 @@ class ScoreOverridden:
|
||||||
comment: str
|
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
|
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:
|
def apply_score_override(report: dict, override: ScoreOverridden) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,9 @@ class Timer(BaseModel):
|
||||||
elapsed_ms: int = 0
|
elapsed_ms: int = 0
|
||||||
attempt: int = 1
|
attempt: int = 1
|
||||||
stopped: bool = False
|
stopped: bool = False
|
||||||
|
#: Пауза занятия — таймер заморожен, но не завершён: `start()` его не
|
||||||
|
#: считает новой попыткой, в отличие от `stopped` (lct-39).
|
||||||
|
paused: bool = False
|
||||||
|
|
||||||
@model_serializer(mode="wrap")
|
@model_serializer(mode="wrap")
|
||||||
def _dump(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
|
def _dump(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
|
||||||
|
|
@ -100,10 +103,13 @@ class Timer(BaseModel):
|
||||||
data = dict(data)
|
data = dict(data)
|
||||||
elapsed = max(0, int(data.get("elapsed_ms", 0)))
|
elapsed = max(0, int(data.get("elapsed_ms", 0)))
|
||||||
stopped = bool(data.get("stopped"))
|
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")
|
started = data.pop("started")
|
||||||
data["started_at"] = time.monotonic() - (elapsed + downtime) / 1000 if started else None
|
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
|
return data
|
||||||
|
|
||||||
def start(self, now: float) -> None:
|
def start(self, now: float) -> None:
|
||||||
|
|
@ -125,6 +131,19 @@ class Timer(BaseModel):
|
||||||
return self.elapsed_ms
|
return self.elapsed_ms
|
||||||
return int((now - self.started_at) * 1000)
|
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):
|
class SessionTimers(BaseModel):
|
||||||
"""Набор таймеров одной сессии. `limits` приходит из конфига —
|
"""Набор таймеров одной сессии. `limits` приходит из конфига —
|
||||||
|
|
@ -142,6 +161,16 @@ class SessionTimers(BaseModel):
|
||||||
for code in STOPS.get(event_type, ()):
|
for code in STOPS.get(event_type, ()):
|
||||||
self.timers.setdefault(code, Timer(code=code)).stop(now)
|
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]:
|
def snapshot(self, now: float | None = None) -> list[TimerSnapshot]:
|
||||||
"""Только запущенные таймеры: показывать нули по нормативам,
|
"""Только запущенные таймеры: показывать нули по нормативам,
|
||||||
до которых занятие ещё не дошло, значит пугать курсанта зря."""
|
до которых занятие ещё не дошло, значит пугать курсанта зря."""
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ async def test_text_dialogue_provider_error_does_not_log_prompt_or_provider_body
|
||||||
monkeypatch.setattr(call.hub, "to_observers", lambda *_args: None)
|
monkeypatch.setattr(call.hub, "to_observers", lambda *_args: None)
|
||||||
state = SimpleNamespace(
|
state = SimpleNamespace(
|
||||||
ended=False,
|
ended=False,
|
||||||
|
paused=False,
|
||||||
exercise=Exercise.CARD,
|
exercise=Exercise.CARD,
|
||||||
dispatched_card=None,
|
dispatched_card=None,
|
||||||
caller=Caller(),
|
caller=Caller(),
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ def timers() -> SessionTimers:
|
||||||
TimerCode.DDS_ACK: Timer(code=TimerCode.DDS_ACK, started_at=MONO - 12.0, attempt=2),
|
TimerCode.DDS_ACK: Timer(code=TimerCode.DDS_ACK, started_at=MONO - 12.0, attempt=2),
|
||||||
TimerCode.ANSWER: Timer(code=TimerCode.ANSWER, started_at=MONO - 7.0,
|
TimerCode.ANSWER: Timer(code=TimerCode.ANSWER, started_at=MONO - 7.0,
|
||||||
elapsed_ms=7_000, attempt=3, stopped=True),
|
elapsed_ms=7_000, attempt=3, stopped=True),
|
||||||
|
TimerCode.CARD_FILL: Timer(code=TimerCode.CARD_FILL, elapsed_ms=5_000, paused=True),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -161,6 +162,9 @@ def full_state() -> SessionState:
|
||||||
resolve_comment="передано в другой регион",
|
resolve_comment="передано в другой регион",
|
||||||
processed_station_commands=[str(uuid4())],
|
processed_station_commands=[str(uuid4())],
|
||||||
text_revealed_facts={"f_address": "улица Ленина, 14"},
|
text_revealed_facts={"f_address": "улица Ленина, 14"},
|
||||||
|
paused=True,
|
||||||
|
paused_at=AT,
|
||||||
|
total_paused_ms=15_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
176
backend/tests/test_session_pause.py
Normal file
176
backend/tests/test_session_pause.py
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
"""Пауза занятия с пульта преподавателя (lct-39).
|
||||||
|
|
||||||
|
Часы — управляемый монотонный счётчик: тест продвигает их сам, а не спит,
|
||||||
|
проверяя ровно то, что требует карточка — пауза не считается в норматив,
|
||||||
|
переживает чекпойнт и пишется в аудит.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.api.ws import control as control_ws
|
||||||
|
from app.domain.events import Exercise, LessonCriteria, SessionMode
|
||||||
|
from app.domain.timers import TimerCode
|
||||||
|
from app.scenarios.loader import load_file
|
||||||
|
from app.session import timers as timers_module
|
||||||
|
from app.session.checkpoint import dump_state, load_state
|
||||||
|
from app.session.hub import hub
|
||||||
|
from app.session.state import SessionState, now_utc
|
||||||
|
from app.session.store import LessonPaused, LessonResumed, MemorySessionStore
|
||||||
|
from app.session.timers import Timer
|
||||||
|
|
||||||
|
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
"""Управляемая замена `time.monotonic`: тест продвигает время явно."""
|
||||||
|
|
||||||
|
def __init__(self, start: float = 1_000.0) -> None:
|
||||||
|
self.now = start
|
||||||
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
def advance(self, seconds: float) -> None:
|
||||||
|
self.now += seconds
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clock(monkeypatch):
|
||||||
|
fake = FakeClock()
|
||||||
|
monkeypatch.setattr(timers_module.time, "monotonic", fake)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def test_timer_pause_freezes_elapsed_until_resume(clock):
|
||||||
|
timer = Timer(code=TimerCode.DDS_ACK)
|
||||||
|
timer.start(clock.now)
|
||||||
|
clock.advance(10)
|
||||||
|
|
||||||
|
timer.pause(clock.now)
|
||||||
|
frozen = timer.current_ms(clock.now)
|
||||||
|
clock.advance(60) # долгая пауза — часы идут, таймер не должен
|
||||||
|
|
||||||
|
assert timer.current_ms(clock.now) == frozen == 10_000
|
||||||
|
|
||||||
|
timer.resume(clock.now)
|
||||||
|
clock.advance(15)
|
||||||
|
|
||||||
|
assert timer.current_ms(clock.now) == 25_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_pause_excludes_wait_from_dds_ack_deadline(clock):
|
||||||
|
"""Карточка: 30 с норматива. Пауза 60 с внутри окна не должна дать D1 —
|
||||||
|
отметка приходит на 25-й секунде активного времени, а не 85-й реальной."""
|
||||||
|
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||||
|
state = SessionState(
|
||||||
|
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
||||||
|
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||||
|
scenario=scenario, criteria=LessonCriteria(decision_time_limit_seconds=30),
|
||||||
|
)
|
||||||
|
state.timers.limits[TimerCode.DDS_ACK] = 30_000
|
||||||
|
from app.session.dds import prepare_queue
|
||||||
|
prepare_queue(state, [scenario.model_copy(deep=True)])
|
||||||
|
card = state.desk.active
|
||||||
|
|
||||||
|
clock.advance(10)
|
||||||
|
state.pause()
|
||||||
|
clock.advance(60)
|
||||||
|
state.resume()
|
||||||
|
clock.advance(15)
|
||||||
|
card.on_event("card.ack")
|
||||||
|
|
||||||
|
measured = card.timers.measured_ms(TimerCode.DDS_ACK)
|
||||||
|
assert measured == 25_000
|
||||||
|
assert measured <= card.timers.limits[TimerCode.DDS_ACK]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_freezes_queue_arrival_and_resume_shifts_it(clock, monkeypatch):
|
||||||
|
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||||
|
second = scenario.model_copy(deep=True)
|
||||||
|
state = SessionState(
|
||||||
|
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
||||||
|
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||||
|
scenario=scenario,
|
||||||
|
)
|
||||||
|
from app.session.dds import prepare_queue
|
||||||
|
prepare_queue(state, [scenario, second], arrival_interval_seconds=30, max_waiting=3)
|
||||||
|
|
||||||
|
due_before = state.desk.next_arrival_at
|
||||||
|
assert due_before is not None
|
||||||
|
|
||||||
|
base = now_utc()
|
||||||
|
monkeypatch.setattr("app.session.state.now_utc", lambda: base)
|
||||||
|
state.pause()
|
||||||
|
idle = timedelta(minutes=1)
|
||||||
|
monkeypatch.setattr("app.session.state.now_utc", lambda: base + idle)
|
||||||
|
state.resume()
|
||||||
|
|
||||||
|
# Следующая карточка не должна поступить раньше, чем простояла пауза.
|
||||||
|
assert state.desk.next_arrival_at == due_before + idle
|
||||||
|
assert state.desk.deliver_due(due_before + timedelta(seconds=1)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkpoint_round_trip_keeps_pause_without_adding_downtime(clock):
|
||||||
|
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||||
|
state = SessionState(
|
||||||
|
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
||||||
|
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||||
|
scenario=scenario,
|
||||||
|
)
|
||||||
|
from app.session.dds import prepare_queue
|
||||||
|
prepare_queue(state, [scenario.model_copy(deep=True)])
|
||||||
|
card = state.desk.active
|
||||||
|
|
||||||
|
clock.advance(5)
|
||||||
|
state.pause()
|
||||||
|
saved_at = now_utc()
|
||||||
|
|
||||||
|
payload = dump_state(state)
|
||||||
|
# Процесс лежал минуту — по часам сервера, не по monotonic этого теста.
|
||||||
|
restored_at = saved_at + timedelta(minutes=1)
|
||||||
|
restored = load_state(payload, restored_at)
|
||||||
|
|
||||||
|
assert restored.paused is True
|
||||||
|
restored_card = restored.desk.active
|
||||||
|
restored_timer = restored_card.timers.timers[TimerCode.DDS_ACK]
|
||||||
|
# Простоя в счёт не пошло: элапсед остался тем же, что был на паузе.
|
||||||
|
assert restored_timer.elapsed_ms == card.timers.timers[TimerCode.DDS_ACK].elapsed_ms
|
||||||
|
assert restored_timer.started_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_and_resume_are_recorded_for_audit(monkeypatch):
|
||||||
|
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||||
|
state = SessionState(
|
||||||
|
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
||||||
|
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||||
|
scenario=scenario, owner_login="teacher",
|
||||||
|
)
|
||||||
|
from app.session.dds import prepare_queue
|
||||||
|
prepare_queue(state, [scenario.model_copy(deep=True)])
|
||||||
|
|
||||||
|
store = MemorySessionStore()
|
||||||
|
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
||||||
|
monkeypatch.setattr(hub, "store", store)
|
||||||
|
who = type("Who", (), {"login": "teacher", "role": type("R", (), {"value": "instructor"})()})()
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
async with hub.operation(state.session_id):
|
||||||
|
await control_ws._command(
|
||||||
|
state.session_id, type("E", (), {"type": "session.pause"})(), who,
|
||||||
|
)
|
||||||
|
async with hub.operation(state.session_id):
|
||||||
|
await control_ws._command(
|
||||||
|
state.session_id, type("E", (), {"type": "session.resume"})(), who,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
records = [record for _sid, batch in store.commits for record in batch]
|
||||||
|
assert any(isinstance(record, LessonPaused) for record in records)
|
||||||
|
assert any(isinstance(record, LessonResumed) for record in records)
|
||||||
|
assert state.paused is False
|
||||||
|
|
@ -50,6 +50,7 @@ export function useCall(sessionId: string | null) {
|
||||||
const [scoreReady, setScoreReady] = useState(false);
|
const [scoreReady, setScoreReady] = useState(false);
|
||||||
const { report, error: reportError } = useSessionReport(sessionId, scoreReady);
|
const { report, error: reportError } = useSessionReport(sessionId, scoreReady);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
|
||||||
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
|
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
|
||||||
const capture = useRef<Capture | null>(null);
|
const capture = useRef<Capture | null>(null);
|
||||||
|
|
@ -153,6 +154,9 @@ export function useCall(sessionId: string | null) {
|
||||||
case "score.ready":
|
case "score.ready":
|
||||||
setScoreReady(true);
|
setScoreReady(true);
|
||||||
break;
|
break;
|
||||||
|
case "session.paused":
|
||||||
|
setPaused(event.paused);
|
||||||
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
setError(event.message);
|
setError(event.message);
|
||||||
break;
|
break;
|
||||||
|
|
@ -253,7 +257,7 @@ export function useCall(sessionId: string | null) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resolve,
|
resolve,
|
||||||
status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, card,
|
status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, paused, card,
|
||||||
checklist, selfAssessed, scoreReady, report, reportError,
|
checklist, selfAssessed, scoreReady, report, reportError,
|
||||||
answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment, sendText,
|
answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment, sendText,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,9 @@ export function useControl(sessionId: string | null) {
|
||||||
);
|
);
|
||||||
|
|
||||||
const stop = useCallback(() => channel.current?.socket.send({ type: "session.stop" }), []);
|
const stop = useCallback(() => channel.current?.socket.send({ type: "session.stop" }), []);
|
||||||
|
const pause = useCallback(() => channel.current?.socket.send({ type: "session.pause" }), []);
|
||||||
|
const resume = useCallback(() => channel.current?.socket.send({ type: "session.resume" }), []);
|
||||||
const playReference = useCallback(() => channel.current?.socket.send({ type: "reference.play" }), []);
|
const playReference = useCallback(() => channel.current?.socket.send({ type: "reference.play" }), []);
|
||||||
|
|
||||||
return { status, openSessionId, start, note, inject, stop, playReference };
|
return { status, openSessionId, start, note, inject, stop, pause, resume, playReference };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export interface ObservedSession {
|
||||||
hintsUsed: number;
|
hintsUsed: number;
|
||||||
notes: Map<string, string>;
|
notes: Map<string, string>;
|
||||||
ended: boolean;
|
ended: boolean;
|
||||||
|
paused: boolean;
|
||||||
report: SessionReport | null;
|
report: SessionReport | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -45,6 +46,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
||||||
const [hintsUsed, setHintsUsed] = useState(0);
|
const [hintsUsed, setHintsUsed] = useState(0);
|
||||||
const [notes, setNotes] = useState<Map<string, string>>(new Map());
|
const [notes, setNotes] = useState<Map<string, string>>(new Map());
|
||||||
const [ended, setEnded] = useState(false);
|
const [ended, setEnded] = useState(false);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
// Сессия, для которой отчёт уже стоит просить. Привязка к id, а не флаг:
|
// Сессия, для которой отчёт уже стоит просить. Привязка к id, а не флаг:
|
||||||
// при переключении истории старый флаг не должен дёрнуть запрос новой сессии.
|
// при переключении истории старый флаг не должен дёрнуть запрос новой сессии.
|
||||||
const [reportFor, setReportFor] = useState<string | null>(null);
|
const [reportFor, setReportFor] = useState<string | null>(null);
|
||||||
|
|
@ -65,6 +67,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
||||||
setHintsUsed(0);
|
setHintsUsed(0);
|
||||||
setNotes(new Map());
|
setNotes(new Map());
|
||||||
setEnded(false);
|
setEnded(false);
|
||||||
|
setPaused(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
@ -88,6 +91,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
||||||
setTimers(event.timers);
|
setTimers(event.timers);
|
||||||
setHintsUsed(event.hints_used ?? 0);
|
setHintsUsed(event.hints_used ?? 0);
|
||||||
setEnded(event.ended ?? false);
|
setEnded(event.ended ?? false);
|
||||||
|
setPaused(event.paused ?? false);
|
||||||
if (event.ended) loadReport();
|
if (event.ended) loadReport();
|
||||||
break;
|
break;
|
||||||
case "kio.state":
|
case "kio.state":
|
||||||
|
|
@ -112,6 +116,9 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
||||||
setEnded(true);
|
setEnded(true);
|
||||||
loadReport();
|
loadReport();
|
||||||
break;
|
break;
|
||||||
|
case "session.paused":
|
||||||
|
setPaused(event.paused);
|
||||||
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
setError(event.message);
|
setError(event.message);
|
||||||
if (event.code === "session_not_found") loadReport();
|
if (event.code === "session_not_found") loadReport();
|
||||||
|
|
@ -127,5 +134,5 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
||||||
return () => { active = false; channel.close(); };
|
return () => { active = false; channel.close(); };
|
||||||
}, [sessionId, queries]);
|
}, [sessionId, queries]);
|
||||||
|
|
||||||
return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, report, error };
|
return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, paused, report, error };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,7 @@ export function Dds() {
|
||||||
const [checkText, setCheckText] = useState("");
|
const [checkText, setCheckText] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [ended, setEnded] = useState(false);
|
const [ended, setEnded] = useState(false);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
// Разбор просим только после `score.ready`; новая карточка снимает флаг.
|
// Разбор просим только после `score.ready`; новая карточка снимает флаг.
|
||||||
const [scoreReady, setScoreReady] = useState(false);
|
const [scoreReady, setScoreReady] = useState(false);
|
||||||
const { report, loading: reportLoading, error: reportError, refetch: refetchReport } =
|
const { report, loading: reportLoading, error: reportError, refetch: refetchReport } =
|
||||||
|
|
@ -252,6 +253,7 @@ export function Dds() {
|
||||||
if (event.type === "station.state") {
|
if (event.type === "station.state") {
|
||||||
const snapshot = event.snapshot as DdsSnapshot;
|
const snapshot = event.snapshot as DdsSnapshot;
|
||||||
setStation(snapshot);
|
setStation(snapshot);
|
||||||
|
setPaused(snapshot.paused ?? false);
|
||||||
setZone(snapshot.zone_decision ?? null);
|
setZone(snapshot.zone_decision ?? null);
|
||||||
if (snapshot.card_id == null && (snapshot.pending_cards_count ?? 0) > 0) {
|
if (snapshot.card_id == null && (snapshot.pending_cards_count ?? 0) > 0) {
|
||||||
activeCardId.current = null;
|
activeCardId.current = null;
|
||||||
|
|
@ -298,6 +300,7 @@ export function Dds() {
|
||||||
} else if (event.type === "timer.tick") setTimers(event.timers);
|
} else if (event.type === "timer.tick") setTimers(event.timers);
|
||||||
else if (event.type === "error") { setError(event.message); setDialing(false); setAdvancePending(false); }
|
else if (event.type === "error") { setError(event.message); setDialing(false); setAdvancePending(false); }
|
||||||
else if (event.type === "session.ended") { setEnded(true); setDialing(false); setAdvancePending(false); setPane(null); setView("registry"); }
|
else if (event.type === "session.ended") { setEnded(true); setDialing(false); setAdvancePending(false); setPane(null); setView("registry"); }
|
||||||
|
else if (event.type === "session.paused") setPaused(event.paused);
|
||||||
else if (event.type === "score.ready") setScoreReady(true);
|
else if (event.type === "score.ready") setScoreReady(true);
|
||||||
else if (event.type === "phone.report") {
|
else if (event.type === "phone.report") {
|
||||||
setLastReport(event);
|
setLastReport(event);
|
||||||
|
|
@ -368,6 +371,7 @@ export function Dds() {
|
||||||
АРМ {role}: {isConnected ? "на связи" : status}
|
АРМ {role}: {isConnected ? "на связи" : status}
|
||||||
</span>
|
</span>
|
||||||
{error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>}
|
{error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>}
|
||||||
|
{paused && !ended && <div className="dds-alert dds-paused">Пауза, ждите преподавателя</div>}
|
||||||
{ended && <div className="dds-alert dds-done">Занятие завершено. Оценка появится после расчёта.</div>}
|
{ended && <div className="dds-alert dds-done">Занятие завершено. Оценка появится после расчёта.</div>}
|
||||||
{(ended || scoreReady) && <section className="dds-panel" aria-live="polite">
|
{(ended || scoreReady) && <section className="dds-panel" aria-live="polite">
|
||||||
<div className="dds-panel-title"><h2>Итог упражнения</h2></div>
|
<div className="dds-panel-title"><h2>Итог упражнения</h2></div>
|
||||||
|
|
|
||||||
|
|
@ -695,7 +695,15 @@ export function Instructor() {
|
||||||
{sessionId && session.title && (
|
{sessionId && session.title && (
|
||||||
<p>
|
<p>
|
||||||
{session.ended ? "занятие завершено" : (
|
{session.ended ? "занятие завершено" : (
|
||||||
<button type="button" onClick={control.stop}>Завершить занятие</button>
|
<>
|
||||||
|
<button type="button" onClick={control.stop}>Завершить занятие</button>{" "}
|
||||||
|
{session.paused ? (
|
||||||
|
<button type="button" onClick={control.resume}>Возобновить</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={control.pause}>Пауза</button>
|
||||||
|
)}
|
||||||
|
{session.paused && <small className="ref" role="status">Занятие на паузе — время не идёт</small>}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,7 @@ export function Call() {
|
||||||
placeholder="Ваша реплика…" onChange={(event) => setTextTurn(event.target.value)} />
|
placeholder="Ваша реплика…" onChange={(event) => setTextTurn(event.target.value)} />
|
||||||
<button type="submit" disabled={call.status !== "open" || !textTurn.trim() || call.cardSubmitted}>Отправить</button>
|
<button type="submit" disabled={call.status !== "open" || !textTurn.trim() || call.cardSubmitted}>Отправить</button>
|
||||||
</form>
|
</form>
|
||||||
|
{call.paused && <p role="status" className="state-warn">Пауза, ждите преподавателя</p>}
|
||||||
{call.error && <p role="alert" className="state-violated">{call.error}</p>}
|
{call.error && <p role="alert" className="state-violated">{call.error}</p>}
|
||||||
</section>
|
</section>
|
||||||
<div className="arm-card-wrap">
|
<div className="arm-card-wrap">
|
||||||
|
|
@ -190,6 +191,7 @@ export function Call() {
|
||||||
: "Ожидайте входящего вызова"}</span>
|
: "Ожидайте входящего вызова"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{call.paused && <p className="state-warn arm-message" role="status">Пауза, ждите преподавателя</p>}
|
||||||
{call.error && <p className="violated arm-message" role="alert">{call.error}</p>}
|
{call.error && <p className="violated arm-message" role="alert">{call.error}</p>}
|
||||||
|
|
||||||
<div className="arm-card-wrap">
|
<div className="arm-card-wrap">
|
||||||
|
|
|
||||||
|
|
@ -585,6 +585,17 @@ export interface SessionEnded {
|
||||||
/** Режим сессии. Меняет доступность подсказок и протоколирование, но не поведение звонящего (docs/product/MODES.md). */
|
/** Режим сессии. Меняет доступность подсказок и протоколирование, но не поведение звонящего (docs/product/MODES.md). */
|
||||||
export type SessionMode = "training" | "exam" | "self";
|
export type SessionMode = "training" | "exam" | "self";
|
||||||
|
|
||||||
|
/** Остановить время занятия; карточку курсанта команда не меняет (lct-39). */
|
||||||
|
export interface SessionPause {
|
||||||
|
type: "session.pause";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пульт остановил или возобновил время занятия (lct-39). */
|
||||||
|
export interface SessionPaused {
|
||||||
|
type: "session.paused";
|
||||||
|
paused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** Единица истории: из отчётов складываются профиль, дельта попыток, аналитика. */
|
/** Единица истории: из отчётов складываются профиль, дельта попыток, аналитика. */
|
||||||
export interface SessionReport {
|
export interface SessionReport {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
|
|
@ -611,6 +622,11 @@ export interface SessionReport {
|
||||||
score_final: number;
|
score_final: number;
|
||||||
overridden_by?: string | null;
|
overridden_by?: string | null;
|
||||||
override_comment?: string | null;
|
override_comment?: string | null;
|
||||||
|
total_paused_ms?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionResume {
|
||||||
|
type: "session.resume";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Обязательно при подключении: монитор в классе включают посреди занятия. */
|
/** Обязательно при подключении: монитор в классе включают посреди занятия. */
|
||||||
|
|
@ -631,6 +647,7 @@ export interface SessionSnapshot {
|
||||||
timers: Array<TimerSnapshot>;
|
timers: Array<TimerSnapshot>;
|
||||||
hints_used?: number;
|
hints_used?: number;
|
||||||
ended?: boolean;
|
ended?: boolean;
|
||||||
|
paused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionStop {
|
export interface SessionStop {
|
||||||
|
|
@ -667,6 +684,7 @@ export interface StationSnapshot {
|
||||||
pending_cards_count?: number;
|
pending_cards_count?: number;
|
||||||
next_arrival_in_seconds?: number | null;
|
next_arrival_in_seconds?: number | null;
|
||||||
max_waiting_cards?: number;
|
max_waiting_cards?: number;
|
||||||
|
paused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
|
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
|
||||||
|
|
@ -775,15 +793,15 @@ export interface ZoneDecision {
|
||||||
|
|
||||||
// Союзы по каналам — смотри docs/arch/CONTRACT.md#каналы
|
// Союзы по каналам — смотри docs/arch/CONTRACT.md#каналы
|
||||||
|
|
||||||
export type ServerToTrainee = CallIncoming | CardBriefing | TextTurnAccepted | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | ErrorEvent;
|
export type ServerToTrainee = CallIncoming | CardBriefing | TextTurnAccepted | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | SessionPaused | ErrorEvent;
|
||||||
|
|
||||||
export type TraineeToServer = CallAnswer | CardSubmit | TextTurn | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial;
|
export type TraineeToServer = CallAnswer | CardSubmit | TextTurn | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial;
|
||||||
|
|
||||||
export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | ErrorEvent;
|
export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | SessionPaused | ErrorEvent;
|
||||||
|
|
||||||
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | SessionStop;
|
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | SessionStop | SessionPause | SessionResume;
|
||||||
|
|
||||||
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | ErrorEvent;
|
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | SessionPaused | ErrorEvent;
|
||||||
|
|
||||||
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
|
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
|
||||||
|
|
||||||
|
|
@ -835,6 +853,9 @@ export type EventName =
|
||||||
| "score.ready"
|
| "score.ready"
|
||||||
| "self_assessment.submit"
|
| "self_assessment.submit"
|
||||||
| "session.ended"
|
| "session.ended"
|
||||||
|
| "session.pause"
|
||||||
|
| "session.paused"
|
||||||
|
| "session.resume"
|
||||||
| "session.snapshot"
|
| "session.snapshot"
|
||||||
| "session.stop"
|
| "session.stop"
|
||||||
| "station.finish"
|
| "station.finish"
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,7 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||||
.dds-status-action button { padding: 4px 8px; }
|
.dds-status-action button { padding: 4px 8px; }
|
||||||
.dds-alert { padding: 10px 12px; margin: 0 0 12px; color: var(--violated); background: #fde8e8; border: 1px solid var(--violated); }
|
.dds-alert { padding: 10px 12px; margin: 0 0 12px; color: var(--violated); background: #fde8e8; border: 1px solid var(--violated); }
|
||||||
.dds-done { color: var(--ok); background: #e7f4e9; border-color: var(--ok); }
|
.dds-done { color: var(--ok); background: #e7f4e9; border-color: var(--ok); }
|
||||||
|
.dds-paused { color: var(--warn); background: #fdf3d8; border-color: var(--warn); }
|
||||||
.dds-phone > label { display: block; margin: 14px 12px 5px; font-size: 13px; font-weight: 600; }
|
.dds-phone > label { display: block; margin: 14px 12px 5px; font-size: 13px; font-weight: 600; }
|
||||||
.dds-phone > select { display: block; width: calc(100% - 24px); margin: 0 12px; padding: 7px; box-sizing: border-box; }
|
.dds-phone > select { display: block; width: calc(100% - 24px); margin: 0 12px; padding: 7px; box-sizing: border-box; }
|
||||||
.dds-dial { display: block; margin: 12px; width: calc(100% - 24px); text-align: center; background: #23577e; border-color: #23577e; color: white; font-weight: 600; }
|
.dds-dial { display: block; margin: 12px; width: calc(100% - 24px); text-align: center; background: #23577e; border-color: #23577e; color: white; font-weight: 600; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue