lct-hack/backend/app/session/timers.py
gglamer 5a3761a830 refactor: пульт ДДС владеет живыми карточками, сессия не копирует их поля
Связка 112→ДДС больше не подменяет таймеры оператора таймерами первой
карточки ДДС, поэтому время заполнения карточки 112 снова попадает в оценку.
2026-09-26 21:38:09 +00:00

130 lines
6 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 dataclasses import dataclass, field
from datetime import UTC, datetime
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,),
}
@dataclass
class Timer:
code: TimerCode
started_at: float | None = None
elapsed_ms: int = 0
attempt: int = 1
stopped: bool = False
def start(self, now: float) -> None:
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)
@dataclass
class SessionTimers:
"""Набор таймеров одной сессии. `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)).start(now)
for code in STOPS.get(event_type, ()):
self.timers.setdefault(code, Timer(code)).stop(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