lct-39: пауза занятия с пульта, таймеры и очередь ДДС стоят на время простоя

This commit is contained in:
kaifarikman 2026-09-27 16:35:13 +03:00
commit b77d65c82e
23 changed files with 410 additions and 20 deletions

View file

@ -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

View file

@ -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"):

View file

@ -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({

View file

@ -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)

View file

@ -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):

View file

@ -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

View file

@ -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,
)

View file

@ -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())

View file

@ -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)

View file

@ -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,
)

View file

@ -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:

View file

@ -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]:
"""Только запущенные таймеры: показывать нули по нормативам,
до которых занятие ещё не дошло, значит пугать курсанта зря."""

View file

@ -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(),

View file

@ -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,
)

View 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