lct-39: пауза не искажает оценку ДДС и не блокирует завершённое занятие

This commit is contained in:
kaifarikman 2026-09-27 17:18:27 +03:00
commit 2304d94877
5 changed files with 202 additions and 10 deletions

View file

@ -256,7 +256,9 @@ async def active(
),
dds_work_overdue_cards=sum(
(timer := card.timers.timers.get(TimerCode.DDS_WORK)) is not None
and timer.started_at is not None
# Пауза держит `started_at=None`, хотя таймер уже шёл: судить
# по нему одному спрятало бы уже случившееся нарушение (lct-39).
and (timer.started_at is not None or timer.paused)
and not timer.stopped
and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK]
for card in state.desk.cards.values()

View file

@ -31,6 +31,7 @@ from app.domain.events import (
PatchSource,
ScoreReady,
SessionMode,
SessionPaused,
StationState,
TimerTick,
TextTurnAccepted,
@ -398,6 +399,10 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
_who, state = entered
with hub.trainee(session_id) as queue:
if state.paused:
# Переподключение не ждёт следующего `session.pause`: баннер должен
# быть на экране сразу, раз сервер уже отклоняет команды (lct-39).
hub.to_trainee(session_id, SessionPaused(paused=True))
if state.exercise is Exercise.CARD:
from app.api.ws.control import card_briefing
@ -440,6 +445,10 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
# Бинарные кадры — аудио, текстовые — события. Направление определяется
# каналом, обёртки JSON вокруг звука нет (docs/arch/CONTRACT.md).
if message.get("bytes") is not None:
if state.paused:
# JSON-команды отказывает `_handle`; бинарные кадры сюда
# не заходят, поэтому пауза глушит звук здесь же (lct-39).
continue
if state.exercise is Exercise.CALL:
_on_audio(session_id, state, message["bytes"])
else:

View file

@ -22,7 +22,7 @@ from app.domain.statuses import (
)
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
from app.domain.events import Metric
from app.domain.timers import GOST_REF
from app.domain.timers import GOST_REF, TimerCode
#: Статусы, означающие, что реагирование действительно шло.
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
@ -282,7 +282,7 @@ def evaluate_dispatcher(
def dispatcher_metrics(
state,
card,
deadline_ms: int,
expected_decision: str = "accept",
expected_decision_reason: str | None = None,
@ -295,8 +295,11 @@ def dispatcher_metrics(
источник информации, но не обязательный шлюз статуса.
"""
metrics: list[Metric] = []
for service in (services if services is not None else state.managed_services()):
marks = [mark for mark in state.status_log if mark.service == service]
# Через таймер, а не `primary.at - dispatched_at`: календарная разница
# включила бы паузу преподавателя в норматив (lct-39).
elapsed = card.timers.measured_ms(TimerCode.DDS_ACK)
for service in (services if services is not None else card.managed_services()):
marks = [mark for mark in card.status_log if mark.service == service]
statuses = {mark.status for mark in marks}
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
@ -307,8 +310,6 @@ def dispatcher_metrics(
))
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
elapsed = (int((primary.at - state.dispatched_at).total_seconds() * 1000)
if primary and state.dispatched_at else None)
add("dds_primary", "решение по карточке", primary is not None,
primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0)
add("dds_ack", f"первичная отметка за {deadline_ms // 1000} с",
@ -326,7 +327,7 @@ def dispatcher_metrics(
expected_decision_reason or f"по эталону ожидается «{expected_label}»", 2.0)
if primary.status is ServiceStatus.DECLINED or expected_decision == "decline":
continue
crew = state.crew_assignments.get(service)
crew = card.crew_assignments.get(service)
add("dds_crew", "назначение бригады", bool(crew),
crew or "бригада не выбрана",
"необходимую бригаду выбирает ДДС вручную", 2.0)

View file

@ -454,7 +454,14 @@ class SessionState(PersistedSession):
return self.ended_at is not None
def end(self, reason: CallEndReason) -> datetime:
"""Закрыть занятие; возвращает момент окончания для журнала."""
"""Закрыть занятие; возвращает момент окончания для журнала.
Занятие могут завершить прямо на паузе (преподаватель нажал «Завершить»,
не возобновив): снять флаг и досчитать простой сейчас, иначе курсант
навсегда останется за баннером «Пауза, ждите преподавателя», а разбор
потеряет последний, ещё не закрытый интервал паузы (lct-39).
"""
self.resume()
self.ended_at = now_utc()
self.end_reason = reason
return self.ended_at