lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
"""Таймеры сессии.
|
|
|
|
|
|
|
|
|
|
|
|
**Правило: таймеры останавливаются событиями, а не таймаутами.** Иначе метрика
|
|
|
|
|
|
времени опроса становится недоказуемой, а вся оценка держится на том, что её
|
|
|
|
|
|
можно предъявить и проверить (docs/arch/CONTRACT.md).
|
|
|
|
|
|
|
|
|
|
|
|
Связь «событие → таймер» объявлена таблицей, а не разбросана по обработчикам:
|
|
|
|
|
|
так видно целиком, что чем запускается, и норматив нельзя потерять по дороге.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import time
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
|
|
|
|
|
|
from app.domain.timers import NORMATIVES, TimerCode, TimerSnapshot, state_for
|
|
|
|
|
|
|
|
|
|
|
|
#: Какое событие какой таймер запускает.
|
lct-12: детерминированная оценка по ГОСТ, таксономия, радар
Каждая метрика — факт против норматива со ссылкой: «94 с при ≤ 75 с,
ГОСТ Р 22.7.03-2021», а не балл. По недобытому факту — отдельная отметка
E1 с эталонным вопросом: в разборе нужен конкретный вопрос, не процент.
Радар — проекция тех же метрик без пересчёта весов, коммуникация без
судьи не рисуется нулём.
Таймер, не остановленный событием, — провал, а не зачёт: время
недоказуемо, операция не завершена. Метрика, которую нечем посчитать
(полнота опроса без модели эмбеддингов), видна как «не посчитано» —
молча выброшенная выглядела бы пройденной.
Найдено противоречие: таймеры опроса (75 с) и оповещения ДДС (60 с)
стартовали на ответе и останавливались передачей в ДДС — один отрезок,
два лимита. Опрос за законные 70 с давал E3, а на экране курсанта
краснел таймер посреди нормального разговора. События «опрос закончен»
в контракте нет, поэтому dds_notify снят с учёта, вопрос записан в
CONTRACT.md.
KIO проверяет присваивание: card.dds = "03" клало в карточку сырую
строку вместо кода ДДС, и падала уже оценка, далеко от места ошибки.
2026-09-17 14:06:16 +03:00
|
|
|
|
#:
|
|
|
|
|
|
#: `dds_notify` (≤ 60 с) не запускается ничем, и это сознательно. Стартуй он
|
|
|
|
|
|
#: на ответе, как опрос, оба таймера мерили бы один отрезок с разными лимитами:
|
|
|
|
|
|
#: курсант, опросивший за законные 70 секунд, получал бы E3 «ДДС не оповещена
|
|
|
|
|
|
#: за 60 с», а на экране краснел бы таймер посреди нормального разговора.
|
|
|
|
|
|
#: Норматив, судя по порядку операций, отсчитывается от конца опроса — а события
|
|
|
|
|
|
#: «опрос закончен» в контракте нет. Вопрос к людям: docs/arch/CONTRACT.md.
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
STARTS: dict[str, tuple[TimerCode, ...]] = {
|
|
|
|
|
|
"call.incoming": (TimerCode.ANSWER,),
|
lct-12: детерминированная оценка по ГОСТ, таксономия, радар
Каждая метрика — факт против норматива со ссылкой: «94 с при ≤ 75 с,
ГОСТ Р 22.7.03-2021», а не балл. По недобытому факту — отдельная отметка
E1 с эталонным вопросом: в разборе нужен конкретный вопрос, не процент.
Радар — проекция тех же метрик без пересчёта весов, коммуникация без
судьи не рисуется нулём.
Таймер, не остановленный событием, — провал, а не зачёт: время
недоказуемо, операция не завершена. Метрика, которую нечем посчитать
(полнота опроса без модели эмбеддингов), видна как «не посчитано» —
молча выброшенная выглядела бы пройденной.
Найдено противоречие: таймеры опроса (75 с) и оповещения ДДС (60 с)
стартовали на ответе и останавливались передачей в ДДС — один отрезок,
два лимита. Опрос за законные 70 с давал E3, а на экране курсанта
краснел таймер посреди нормального разговора. События «опрос закончен»
в контракте нет, поэтому dds_notify снят с учёта, вопрос записан в
CONTRACT.md.
KIO проверяет присваивание: card.dds = "03" клало в карточку сырую
строку вместо кода ДДС, и падала уже оценка, далеко от места ошибки.
2026-09-17 14:06:16 +03:00
|
|
|
|
"call.answer": (TimerCode.INTERVIEW,),
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
"dds.dispatch": (TimerCode.DDS_ACK, TimerCode.CLOSE),
|
2026-09-26 18:12:27 +03:00
|
|
|
|
"dds.open": (TimerCode.DDS_WORK,),
|
|
|
|
|
|
"card.start": (TimerCode.CARD_FILL,),
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
"card.received": (TimerCode.ZONE_CHECK,),
|
2026-09-17 21:38:43 +03:00
|
|
|
|
"call.dropped": (TimerCode.CALLBACK,),
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
"callback.dial": (TimerCode.CALLBACK,),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#: Какое событие какой таймер останавливает.
|
|
|
|
|
|
STOPS: dict[str, tuple[TimerCode, ...]] = {
|
|
|
|
|
|
"call.answer": (TimerCode.ANSWER,),
|
lct-12: детерминированная оценка по ГОСТ, таксономия, радар
Каждая метрика — факт против норматива со ссылкой: «94 с при ≤ 75 с,
ГОСТ Р 22.7.03-2021», а не балл. По недобытому факту — отдельная отметка
E1 с эталонным вопросом: в разборе нужен конкретный вопрос, не процент.
Радар — проекция тех же метрик без пересчёта весов, коммуникация без
судьи не рисуется нулём.
Таймер, не остановленный событием, — провал, а не зачёт: время
недоказуемо, операция не завершена. Метрика, которую нечем посчитать
(полнота опроса без модели эмбеддингов), видна как «не посчитано» —
молча выброшенная выглядела бы пройденной.
Найдено противоречие: таймеры опроса (75 с) и оповещения ДДС (60 с)
стартовали на ответе и останавливались передачей в ДДС — один отрезок,
два лимита. Опрос за законные 70 с давал E3, а на экране курсанта
краснел таймер посреди нормального разговора. События «опрос закончен»
в контракте нет, поэтому dds_notify снят с учёта, вопрос записан в
CONTRACT.md.
KIO проверяет присваивание: card.dds = "03" клало в карточку сырую
строку вместо кода ДДС, и падала уже оценка, далеко от места ошибки.
2026-09-17 14:06:16 +03:00
|
|
|
|
"dds.dispatch": (TimerCode.INTERVIEW,),
|
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта.
Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка
этого не видела — наоборот, награждала за полноту заполнения.
- Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта
и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система
должна отличить осознанное решение от брошенного вызова.
- Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака).
- Там, где карточка не заводится, метрики карточки не считаются вовсе.
Полнота опроса считается: передать вызов не значит не опрашивать.
- call.resolve останавливает норматив опроса, как и передача карточки.
Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля
ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1
целиком: три вызова, три классификатора, третий — из другого региона.
Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему.
В классификаторе скорая добавляется к массовой драке признаком «пострадавшие»,
но взять его было неоткуда. Теперь модификаторы берутся из карточки —
victims_count, life_threat, evacuation_needed, fire.gasified, — и список
оповещения пересобирается при правке любого из этих полей.
161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
|
|
|
|
# Справка и передача в другой регион заканчивают опрос так же, как передача
|
|
|
|
|
|
# карточки: норматив опроса не должен тикать после решения (lct-36).
|
|
|
|
|
|
"call.resolve": (TimerCode.INTERVIEW,),
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
"card.ack": (TimerCode.DDS_ACK,),
|
2026-09-26 18:12:27 +03:00
|
|
|
|
"card.submit": (TimerCode.CARD_FILL,),
|
|
|
|
|
|
"card.end": (TimerCode.CARD_FILL,),
|
|
|
|
|
|
"dds.complete": (TimerCode.DDS_WORK,),
|
|
|
|
|
|
"dds.finish": (TimerCode.DDS_WORK,),
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
"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
|