lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
"""Таймеры сессии.
|
|
|
|
|
|
|
|
|
|
|
|
**Правило: таймеры останавливаются событиями, а не таймаутами.** Иначе метрика
|
|
|
|
|
|
времени опроса становится недоказуемой, а вся оценка держится на том, что её
|
|
|
|
|
|
можно предъявить и проверить (docs/arch/CONTRACT.md).
|
|
|
|
|
|
|
|
|
|
|
|
Связь «событие → таймер» объявлена таблицей, а не разбросана по обработчикам:
|
|
|
|
|
|
так видно целиком, что чем запускается, и норматив нельзя потерять по дороге.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import time
|
2026-09-26 21:38:09 +00:00
|
|
|
|
from datetime import UTC, datetime
|
2026-09-26 22:13:35 +00:00
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from pydantic import (
|
|
|
|
|
|
BaseModel,
|
|
|
|
|
|
Field,
|
|
|
|
|
|
SerializerFunctionWrapHandler,
|
|
|
|
|
|
ValidationInfo,
|
|
|
|
|
|
model_serializer,
|
|
|
|
|
|
model_validator,
|
|
|
|
|
|
)
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
|
|
|
|
|
|
from app.domain.timers import NORMATIVES, TimerCode, TimerSnapshot, state_for
|
|
|
|
|
|
|
2026-09-26 21:38:09 +00:00
|
|
|
|
|
|
|
|
|
|
def now_utc() -> datetime:
|
|
|
|
|
|
"""Часы серверные. Метрика, посчитанная по часам браузера, недоказуема."""
|
|
|
|
|
|
return datetime.now(UTC)
|
|
|
|
|
|
|
|
|
|
|
|
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
#: Какое событие какой таймер запускает.
|
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-27 23:38:10 +03:00
|
|
|
|
# Ответ заказчика (П.5): 30 с — от появления карточки в строке сообщений
|
|
|
|
|
|
# до её открытия, 3 мин — на открытие и первую запись.
|
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,),
|
2026-09-26 18:12:27 +03:00
|
|
|
|
"card.submit": (TimerCode.CARD_FILL,),
|
|
|
|
|
|
"card.end": (TimerCode.CARD_FILL,),
|
2026-09-27 23:38:10 +03:00
|
|
|
|
"dds.open": (TimerCode.DDS_ACK,),
|
|
|
|
|
|
# Первая запись — первый ручной статус с текстом. Дальше работы могут идти
|
|
|
|
|
|
# часы и дни: остальные статусы по времени не нормируются (П.5).
|
|
|
|
|
|
"dds.record": (TimerCode.DDS_WORK,),
|
2026-09-26 18:12:27 +03:00
|
|
|
|
"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,),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 22:13:35 +00:00
|
|
|
|
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`)."""
|
|
|
|
|
|
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
code: TimerCode
|
|
|
|
|
|
started_at: float | None = None
|
|
|
|
|
|
elapsed_ms: int = 0
|
|
|
|
|
|
attempt: int = 1
|
|
|
|
|
|
stopped: bool = False
|
2026-09-27 16:35:13 +03:00
|
|
|
|
#: Пауза занятия — таймер заморожен, но не завершён: `start()` его не
|
|
|
|
|
|
#: считает новой попыткой, в отличие от `stopped` (lct-39).
|
|
|
|
|
|
paused: bool = False
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
|
2026-09-26 22:13:35 +00:00
|
|
|
|
@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"))
|
2026-09-27 16:35:13 +03:00
|
|
|
|
paused = bool(data.get("paused"))
|
|
|
|
|
|
# На паузе занятие не простаивало без присмотра — оно ждало
|
|
|
|
|
|
# преподавателя, и это время не досчитывается таймеру при перезапуске.
|
|
|
|
|
|
downtime = 0 if stopped or paused else (info.context or {}).get("downtime_ms", 0)
|
2026-09-26 22:13:35 +00:00
|
|
|
|
started = data.pop("started")
|
|
|
|
|
|
data["started_at"] = time.monotonic() - (elapsed + downtime) / 1000 if started else None
|
2026-09-27 16:35:13 +03:00
|
|
|
|
data["elapsed_ms"] = elapsed if (stopped or paused) else 0
|
2026-09-26 22:13:35 +00:00
|
|
|
|
return data
|
|
|
|
|
|
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
def start(self, now: float) -> None:
|
2026-09-27 22:23:12 +03:00
|
|
|
|
if self.paused:
|
|
|
|
|
|
# Таймер уже идёт, просто заморожен: запуск с нуля потерял бы
|
|
|
|
|
|
# набранное время, а ход до `resume` посчитал бы паузу.
|
|
|
|
|
|
return
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-09-27 16:35:13 +03:00
|
|
|
|
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
|
|
|
|
|
|
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
|
2026-09-26 22:13:35 +00:00
|
|
|
|
class SessionTimers(BaseModel):
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
"""Набор таймеров одной сессии. `limits` приходит из конфига —
|
|
|
|
|
|
норматив меняется значением, а не правкой кода."""
|
|
|
|
|
|
|
2026-09-26 22:13:35 +00:00
|
|
|
|
limits: dict[TimerCode, int] = Field(
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
default_factory=lambda: {code: norm.limit_ms for code, norm in NORMATIVES.items()}
|
|
|
|
|
|
)
|
2026-09-26 22:13:35 +00:00
|
|
|
|
timers: dict[TimerCode, Timer] = Field(default_factory=dict)
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
|
|
|
|
|
|
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, ()):
|
2026-09-26 22:13:35 +00:00
|
|
|
|
self.timers.setdefault(code, Timer(code=code)).start(now)
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
for code in STOPS.get(event_type, ()):
|
2026-09-26 22:13:35 +00:00
|
|
|
|
self.timers.setdefault(code, Timer(code=code)).stop(now)
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
|
2026-09-27 16:35:13 +03:00
|
|
|
|
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)
|
|
|
|
|
|
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
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
|