lct-hack/backend/app/session/timers.py

203 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Таймеры сессии.
**Правило: таймеры останавливаются событиями, а не таймаутами.** Иначе метрика
времени опроса становится недоказуемой, а вся оценка держится на том, что её
можно предъявить и проверить (docs/arch/CONTRACT.md).
Связь «событие → таймер» объявлена таблицей, а не разбросана по обработчикам:
так видно целиком, что чем запускается, и норматив нельзя потерять по дороге.
"""
import time
from datetime import UTC, datetime
from typing import Any
from pydantic import (
BaseModel,
Field,
SerializerFunctionWrapHandler,
ValidationInfo,
model_serializer,
model_validator,
)
from app.domain.timers import NORMATIVES, TimerCode, TimerSnapshot, state_for
def now_utc() -> datetime:
"""Часы серверные. Метрика, посчитанная по часам браузера, недоказуема."""
return datetime.now(UTC)
#: Какое событие какой таймер запускает.
#:
#: `dds_notify` (≤ 60 с) не запускается ничем, и это сознательно. Стартуй он
#: на ответе, как опрос, оба таймера мерили бы один отрезок с разными лимитами:
#: курсант, опросивший за законные 70 секунд, получал бы E3 «ДДС не оповещена
#: за 60 с», а на экране краснел бы таймер посреди нормального разговора.
#: Норматив, судя по порядку операций, отсчитывается от конца опроса — а события
#: «опрос закончен» в контракте нет. Вопрос к людям: docs/arch/CONTRACT.md.
STARTS: dict[str, tuple[TimerCode, ...]] = {
"call.incoming": (TimerCode.ANSWER,),
"call.answer": (TimerCode.INTERVIEW,),
"dds.dispatch": (TimerCode.DDS_ACK, TimerCode.CLOSE),
"dds.open": (TimerCode.DDS_WORK,),
"card.start": (TimerCode.CARD_FILL,),
"card.received": (TimerCode.ZONE_CHECK,),
"call.dropped": (TimerCode.CALLBACK,),
"callback.dial": (TimerCode.CALLBACK,),
}
#: Какое событие какой таймер останавливает.
STOPS: dict[str, tuple[TimerCode, ...]] = {
"call.answer": (TimerCode.ANSWER,),
"dds.dispatch": (TimerCode.INTERVIEW,),
# Справка и передача в другой регион заканчивают опрос так же, как передача
# карточки: норматив опроса не должен тикать после решения (lct-36).
"call.resolve": (TimerCode.INTERVIEW,),
"card.ack": (TimerCode.DDS_ACK,),
"card.submit": (TimerCode.CARD_FILL,),
"card.end": (TimerCode.CARD_FILL,),
"dds.complete": (TimerCode.DDS_WORK,),
"dds.finish": (TimerCode.DDS_WORK,),
"zone.decision": (TimerCode.ZONE_CHECK,),
"crew.arrived": (TimerCode.CLOSE,),
"call.started": (TimerCode.CALLBACK,),
}
def downtime_ms(saved_at: datetime) -> int:
"""Сколько занятие пролежало в снимке: запущенный таймер считает и это время."""
if saved_at.tzinfo is None:
saved_at = saved_at.replace(tzinfo=UTC)
return max(0, int((now_utc() - saved_at).total_seconds() * 1000))
class Timer(BaseModel):
"""`started_at` — monotonic-отметка процесса, в другом процессе она ничего
не значит. Снимок хранит прошедшее время, а загрузка пересчитывает отметку
от своих часов (простой берётся из контекста `downtime_ms`)."""
code: TimerCode
started_at: float | None = None
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]:
data = handler(self)
data.pop("started_at")
data["elapsed_ms"] = self.current_ms(time.monotonic())
data["started"] = self.started_at is not None
return data
@model_validator(mode="before")
@classmethod
def _restore(cls, data: Any, info: ValidationInfo) -> Any:
if not isinstance(data, dict) or "started" not in data:
return data
data = dict(data)
elapsed = max(0, int(data.get("elapsed_ms", 0)))
stopped = bool(data.get("stopped"))
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 or paused) else 0
return data
def start(self, now: float) -> None:
if self.paused:
# Таймер уже идёт, просто заморожен: запуск с нуля потерял бы
# набранное время, а ход до `resume` посчитал бы паузу.
return
if self.stopped:
# Повторный запуск после остановки — это новая попытка (обратный дозвон).
self.attempt += 1
self.stopped = False
self.elapsed_ms = 0
if self.started_at is None:
self.started_at = now
def stop(self, now: float) -> None:
if self.started_at is not None and not self.stopped:
self.elapsed_ms = int((now - self.started_at) * 1000)
self.stopped = True
def current_ms(self, now: float) -> int:
if self.stopped or self.started_at is None:
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` приходит из конфига —
норматив меняется значением, а не правкой кода."""
limits: dict[TimerCode, int] = Field(
default_factory=lambda: {code: norm.limit_ms for code, norm in NORMATIVES.items()}
)
timers: dict[TimerCode, Timer] = Field(default_factory=dict)
def on_event(self, event_type: str, now: float | None = None) -> None:
now = time.monotonic() if now is None else now
for code in STARTS.get(event_type, ()):
self.timers.setdefault(code, Timer(code=code)).start(now)
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]:
"""Только запущенные таймеры: показывать нули по нормативам,
до которых занятие ещё не дошло, значит пугать курсанта зря."""
now = time.monotonic() if now is None else now
result: list[TimerSnapshot] = []
for code, timer in self.timers.items():
limit = self.limits[code]
elapsed = timer.current_ms(now)
result.append(
TimerSnapshot(
code=code,
elapsed_ms=elapsed,
limit_ms=limit,
state=state_for(elapsed, limit),
attempt=timer.attempt,
stopped=timer.stopped,
)
)
return result
def measured_ms(self, code: TimerCode) -> int | None:
"""Зафиксированное событием значение — то, что пойдёт в оценку."""
timer = self.timers.get(code)
if timer is None or not timer.stopped:
return None
return timer.elapsed_ms