diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py index c22ddb6..ea7e2d1 100644 --- a/backend/app/api/http/sessions.py +++ b/backend/app/api/http/sessions.py @@ -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 diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py index 2d0f0d4..fc74cb0 100644 --- a/backend/app/api/ws/call.py +++ b/backend/app/api/ws/call.py @@ -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"): diff --git a/backend/app/api/ws/control.py b/backend/app/api/ws/control.py index eb1ab26..367c3d7 100644 --- a/backend/app/api/ws/control.py +++ b/backend/app/api/ws/control.py @@ -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({ diff --git a/backend/app/api/ws/station.py b/backend/app/api/ws/station.py index 8481c09..3a93b81 100644 --- a/backend/app/api/ws/station.py +++ b/backend/app/api/ws/station.py @@ -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) diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py index 495708e..3b6a6f1 100644 --- a/backend/app/domain/events.py +++ b/backend/app/domain/events.py @@ -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): diff --git a/backend/app/domain/statuses.py b/backend/app/domain/statuses.py index 2f5be73..133ea46 100644 --- a/backend/app/domain/statuses.py +++ b/backend/app/domain/statuses.py @@ -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 diff --git a/backend/app/scoring/report.py b/backend/app/scoring/report.py index 3f7a9aa..764dfd9 100644 --- a/backend/app/scoring/report.py +++ b/backend/app/scoring/report.py @@ -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, ) diff --git a/backend/app/session/hub.py b/backend/app/session/hub.py index 056c616..c894660 100644 --- a/backend/app/session/hub.py +++ b/backend/app/session/hub.py @@ -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()) diff --git a/backend/app/session/pg_store.py b/backend/app/session/pg_store.py index b35e33c..24b1e0f 100644 --- a/backend/app/session/pg_store.py +++ b/backend/app/session/pg_store.py @@ -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) diff --git a/backend/app/session/state.py b/backend/app/session/state.py index 166e611..aed1ea7 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -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, ) diff --git a/backend/app/session/store.py b/backend/app/session/store.py index ea053b1..e1770fa 100644 --- a/backend/app/session/store.py +++ b/backend/app/session/store.py @@ -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: diff --git a/backend/app/session/timers.py b/backend/app/session/timers.py index d3338e6..c8a4835 100644 --- a/backend/app/session/timers.py +++ b/backend/app/session/timers.py @@ -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]: """Только запущенные таймеры: показывать нули по нормативам, до которых занятие ещё не дошло, значит пугать курсанта зря.""" diff --git a/backend/tests/test_call_privacy.py b/backend/tests/test_call_privacy.py index 4729d0e..b542c99 100644 --- a/backend/tests/test_call_privacy.py +++ b/backend/tests/test_call_privacy.py @@ -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) state = SimpleNamespace( ended=False, + paused=False, exercise=Exercise.CARD, dispatched_card=None, caller=Caller(), diff --git a/backend/tests/test_checkpoint_model.py b/backend/tests/test_checkpoint_model.py index 6ec7a19..4dd821a 100644 --- a/backend/tests/test_checkpoint_model.py +++ b/backend/tests/test_checkpoint_model.py @@ -57,6 +57,7 @@ def timers() -> SessionTimers: 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, 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="передано в другой регион", processed_station_commands=[str(uuid4())], text_revealed_facts={"f_address": "улица Ленина, 14"}, + paused=True, + paused_at=AT, + total_paused_ms=15_000, ) diff --git a/backend/tests/test_session_pause.py b/backend/tests/test_session_pause.py new file mode 100644 index 0000000..39e8f13 --- /dev/null +++ b/backend/tests/test_session_pause.py @@ -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 diff --git a/frontend/src/features/call/useCall.ts b/frontend/src/features/call/useCall.ts index dac8097..01f01dc 100644 --- a/frontend/src/features/call/useCall.ts +++ b/frontend/src/features/call/useCall.ts @@ -50,6 +50,7 @@ export function useCall(sessionId: string | null) { const [scoreReady, setScoreReady] = useState(false); const { report, error: reportError } = useSessionReport(sessionId, scoreReady); const [error, setError] = useState(null); + const [paused, setPaused] = useState(false); const channel = useRef | null>(null); const capture = useRef(null); @@ -153,6 +154,9 @@ export function useCall(sessionId: string | null) { case "score.ready": setScoreReady(true); break; + case "session.paused": + setPaused(event.paused); + break; case "error": setError(event.message); break; @@ -253,7 +257,7 @@ export function useCall(sessionId: string | null) { return { 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, answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment, sendText, }; diff --git a/frontend/src/features/instructor/useControl.ts b/frontend/src/features/instructor/useControl.ts index 02b16d3..64f8efc 100644 --- a/frontend/src/features/instructor/useControl.ts +++ b/frontend/src/features/instructor/useControl.ts @@ -63,7 +63,9 @@ export function useControl(sessionId: string | null) { ); 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" }), []); - return { status, openSessionId, start, note, inject, stop, playReference }; + return { status, openSessionId, start, note, inject, stop, pause, resume, playReference }; } diff --git a/frontend/src/features/observe/useObserve.ts b/frontend/src/features/observe/useObserve.ts index 7505865..697dad0 100644 --- a/frontend/src/features/observe/useObserve.ts +++ b/frontend/src/features/observe/useObserve.ts @@ -29,6 +29,7 @@ export interface ObservedSession { hintsUsed: number; notes: Map; ended: boolean; + paused: boolean; report: SessionReport | null; error: string | null; } @@ -45,6 +46,7 @@ export function useObserve(sessionId: string | null): ObservedSession { const [hintsUsed, setHintsUsed] = useState(0); const [notes, setNotes] = useState>(new Map()); const [ended, setEnded] = useState(false); + const [paused, setPaused] = useState(false); // Сессия, для которой отчёт уже стоит просить. Привязка к id, а не флаг: // при переключении истории старый флаг не должен дёрнуть запрос новой сессии. const [reportFor, setReportFor] = useState(null); @@ -65,6 +67,7 @@ export function useObserve(sessionId: string | null): ObservedSession { setHintsUsed(0); setNotes(new Map()); setEnded(false); + setPaused(false); setError(null); if (!sessionId) return; let active = true; @@ -88,6 +91,7 @@ export function useObserve(sessionId: string | null): ObservedSession { setTimers(event.timers); setHintsUsed(event.hints_used ?? 0); setEnded(event.ended ?? false); + setPaused(event.paused ?? false); if (event.ended) loadReport(); break; case "kio.state": @@ -112,6 +116,9 @@ export function useObserve(sessionId: string | null): ObservedSession { setEnded(true); loadReport(); break; + case "session.paused": + setPaused(event.paused); + break; case "error": setError(event.message); if (event.code === "session_not_found") loadReport(); @@ -127,5 +134,5 @@ export function useObserve(sessionId: string | null): ObservedSession { return () => { active = false; channel.close(); }; }, [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 }; } diff --git a/frontend/src/pages/dds/Dds.tsx b/frontend/src/pages/dds/Dds.tsx index 90d23df..b84a14a 100644 --- a/frontend/src/pages/dds/Dds.tsx +++ b/frontend/src/pages/dds/Dds.tsx @@ -233,6 +233,7 @@ export function Dds() { const [checkText, setCheckText] = useState(""); const [error, setError] = useState(""); const [ended, setEnded] = useState(false); + const [paused, setPaused] = useState(false); // Разбор просим только после `score.ready`; новая карточка снимает флаг. const [scoreReady, setScoreReady] = useState(false); const { report, loading: reportLoading, error: reportError, refetch: refetchReport } = @@ -252,6 +253,7 @@ export function Dds() { if (event.type === "station.state") { const snapshot = event.snapshot as DdsSnapshot; setStation(snapshot); + setPaused(snapshot.paused ?? false); setZone(snapshot.zone_decision ?? null); if (snapshot.card_id == null && (snapshot.pending_cards_count ?? 0) > 0) { activeCardId.current = null; @@ -298,6 +300,7 @@ export function Dds() { } 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 === "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 === "phone.report") { setLastReport(event); @@ -368,6 +371,7 @@ export function Dds() { АРМ {role}: {isConnected ? "на связи" : status} {error &&
Действие не принято: {error}
} + {paused && !ended &&
Пауза, ждите преподавателя
} {ended &&
Занятие завершено. Оценка появится после расчёта.
} {(ended || scoreReady) &&

Итог упражнения

diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx index 3c1f435..0dc44c0 100644 --- a/frontend/src/pages/instructor/Instructor.tsx +++ b/frontend/src/pages/instructor/Instructor.tsx @@ -695,7 +695,15 @@ export function Instructor() { {sessionId && session.title && (

{session.ended ? "занятие завершено" : ( - + <> + {" "} + {session.paused ? ( + + ) : ( + + )} + {session.paused && Занятие на паузе — время не идёт} + )}

)} diff --git a/frontend/src/pages/trainee/Call.tsx b/frontend/src/pages/trainee/Call.tsx index 8b68057..a1a9c2c 100644 --- a/frontend/src/pages/trainee/Call.tsx +++ b/frontend/src/pages/trainee/Call.tsx @@ -126,6 +126,7 @@ export function Call() { placeholder="Ваша реплика…" onChange={(event) => setTextTurn(event.target.value)} /> + {call.paused &&

Пауза, ждите преподавателя

} {call.error &&

{call.error}

}
@@ -190,6 +191,7 @@ export function Call() { : "Ожидайте входящего вызова"}
+ {call.paused &&

Пауза, ждите преподавателя

} {call.error &&

{call.error}

}
diff --git a/frontend/src/shared/types/generated.ts b/frontend/src/shared/types/generated.ts index 7735a2b..f5b64e5 100644 --- a/frontend/src/shared/types/generated.ts +++ b/frontend/src/shared/types/generated.ts @@ -585,6 +585,17 @@ export interface SessionEnded { /** Режим сессии. Меняет доступность подсказок и протоколирование, но не поведение звонящего (docs/product/MODES.md). */ 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 { session_id: string; @@ -611,6 +622,11 @@ export interface SessionReport { score_final: number; overridden_by?: 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; hints_used?: number; ended?: boolean; + paused?: boolean; } export interface SessionStop { @@ -667,6 +684,7 @@ export interface StationSnapshot { pending_cards_count?: number; next_arrival_in_seconds?: number | null; max_waiting_cards?: number; + paused?: boolean; } /** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */ @@ -775,15 +793,15 @@ export interface ZoneDecision { // Союзы по каналам — смотри 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 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; @@ -835,6 +853,9 @@ export type EventName = | "score.ready" | "self_assessment.submit" | "session.ended" + | "session.pause" + | "session.paused" + | "session.resume" | "session.snapshot" | "session.stop" | "station.finish" diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 11f3c1e..857e334 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -349,6 +349,7 @@ button:disabled { opacity: 0.5; cursor: default; } .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-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 > 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; }