lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
"""Состояние живой сессии: карточка, транскрипт, таймеры, режим, попытка.
|
|
|
|
|
|
|
|
|
|
|
|
Живёт в памяти процесса — поэтому воркер uvicorn ровно один: с двумя
|
|
|
|
|
|
преподаватель подключился бы к другому процессу, чем курсант, и увидел
|
|
|
|
|
|
пустой экран (docs/arch/STACK.md).
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
import time
|
2026-09-26 21:38:09 +00:00
|
|
|
|
from datetime import datetime
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
from typing import Any
|
|
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
2026-09-26 22:13:35 +00:00
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.dialog.caller import TemplateCaller
|
|
|
|
|
|
from app.dialog.persona import PersonaState
|
|
|
|
|
|
from app.dialog.slots import SlotMachine
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
from app.domain.events import (
|
|
|
|
|
|
CallEndReason,
|
2026-09-21 17:40:54 +03:00
|
|
|
|
Exercise,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
LessonCriteria,
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
Mood,
|
|
|
|
|
|
SessionMode,
|
|
|
|
|
|
SessionSnapshot,
|
|
|
|
|
|
Speaker,
|
|
|
|
|
|
TranscriptEntry,
|
|
|
|
|
|
)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.domain.kio import KIO, ResponseStatus, apply_patch
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
from app.domain.statuses import (
|
|
|
|
|
|
NEXT,
|
2026-09-26 18:12:27 +03:00
|
|
|
|
CardStatus,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
DdsCardSummary,
|
|
|
|
|
|
DdsQueueCard,
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
ServiceStatus,
|
|
|
|
|
|
StationSnapshot,
|
|
|
|
|
|
card_status,
|
|
|
|
|
|
current,
|
|
|
|
|
|
)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.domain.timers import TimerCode
|
|
|
|
|
|
from app.scenarios.schema import Scenario
|
2026-09-26 21:38:09 +00:00
|
|
|
|
from app.session.dds import DdsCardRecord, DdsDesk, DdsLiveCard
|
|
|
|
|
|
from app.session.timers import SessionTimers, now_utc
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
|
2026-09-26 22:13:35 +00:00
|
|
|
|
__all__ = ["DdsCardRecord", "DdsLiveCard", "PersistedSession", "SessionState", "now_utc"]
|
|
|
|
|
|
|
|
|
|
|
|
#: Сколько последних команд станции помнит защита от повтора.
|
|
|
|
|
|
MAX_STATION_COMMANDS = 512
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
|
2026-09-26 22:13:35 +00:00
|
|
|
|
class PersistedSession(BaseModel):
|
|
|
|
|
|
"""Сохраняемая часть занятия — ровно то, что пишется в снимок.
|
|
|
|
|
|
|
|
|
|
|
|
Снимок — `model_dump` этой модели, загрузка — `model_validate`, поэтому
|
|
|
|
|
|
поле сессии нельзя завести, не решив, сохраняемое оно или живое: подкласс
|
|
|
|
|
|
обязан пометить каждое своё поле `Field(exclude=True)`, иначе класс не
|
|
|
|
|
|
создастся (см. `__pydantic_init_subclass__`).
|
|
|
|
|
|
"""
|
2026-09-24 01:10:49 +03:00
|
|
|
|
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
session_id: UUID
|
|
|
|
|
|
scenario_id: str
|
|
|
|
|
|
scenario_title: str
|
|
|
|
|
|
level: str
|
|
|
|
|
|
mode: SessionMode
|
2026-09-24 01:10:49 +03:00
|
|
|
|
#: Преподаватель, создавший занятие; чужой пульт не может им управлять.
|
|
|
|
|
|
owner_login: str | None = None
|
2026-09-26 18:12:27 +03:00
|
|
|
|
#: Monotonic DB ownership generation; stale processes may not persist writes.
|
|
|
|
|
|
backend_fencing_epoch: int = 0
|
2026-09-21 17:40:54 +03:00
|
|
|
|
exercise: Exercise = Exercise.CALL
|
2026-09-21 20:10:57 +03:00
|
|
|
|
#: После заполнения КИО занятие продолжится на АРМ ДДС, а не завершится.
|
|
|
|
|
|
handoff_to_dds: bool = False
|
2026-09-26 22:13:35 +00:00
|
|
|
|
required_fields: list[str] = Field(default_factory=list)
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
trainee_name: str | None = None
|
feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.
- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
call и station — обучающемуся и преподавателю. Отказ приходит событием
error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.
Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.
Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.
181 тест зелёный (14 новых), make typecheck чистый.
2026-09-20 09:01:05 +03:00
|
|
|
|
#: Чьё это занятие. Проставляется при запуске, когда курсант известен
|
|
|
|
|
|
#: по учётной записи: по нему разбор закрывается от чужих (lct-23).
|
|
|
|
|
|
trainee_id: UUID | None = None
|
2026-09-24 01:10:49 +03:00
|
|
|
|
#: Конкретная ДДС обучающегося. Она меняет статусы только своей службы;
|
|
|
|
|
|
#: остальные адресаты карточки показываются информационно.
|
|
|
|
|
|
dds_service: str | None = None
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
attempt: int = 1
|
2026-09-26 22:13:35 +00:00
|
|
|
|
criteria: LessonCriteria = Field(default_factory=LessonCriteria)
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
|
2026-09-26 22:13:35 +00:00
|
|
|
|
kio: KIO = Field(default_factory=KIO)
|
|
|
|
|
|
transcript: list[TranscriptEntry] = Field(default_factory=list)
|
|
|
|
|
|
timers: SessionTimers = Field(default_factory=SessionTimers)
|
|
|
|
|
|
hints_shown: list[str] = Field(default_factory=list)
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
#: Когда именно подсказывали — в разборе видно, какой пункт и на какой минуте.
|
2026-09-26 22:13:35 +00:00
|
|
|
|
hints_log: list[tuple[str, datetime]] = Field(default_factory=list)
|
|
|
|
|
|
notes: list[dict] = Field(default_factory=list)
|
|
|
|
|
|
directives: list[str] = Field(default_factory=list)
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
|
2026-09-17 21:38:43 +03:00
|
|
|
|
# Своя копия сценария на занятие: директивы преподавателя правят факты
|
|
|
|
|
|
# и эталон, и правка в одной группе не должна протекать в остальные.
|
|
|
|
|
|
scenario: Scenario | None = None
|
lct-07 (без LLM): слот-автомат, персона, звонящий на заготовках, make repl
Звонящий не выдаёт данные сам: автомат держит раскрытые факты явно и
отдаёт звонящему только их. Тест прогоняет длинную серию реплик и
проверяет, что ни один нераскрытый факт не прозвучал.
Главная находка — матчинг по порогу близости не работает. На
multilingual-e5-small настоящие вопросы дают 0.79–0.95, а «Оставайтесь
на линии» — до 0.89: диапазоны перекрываются, и любой порог либо выдаёт
адрес на «успокойтесь», либо не слышит «где вы находитесь?».
Решение — сравнение с ближайшим соседом: у пункта чек-листа несколько
формулировок (examples), рядом общий список не-вопросов
(checklists/common.yaml), и реплика засчитывается пункту, только если она
ближе к нему, чем к любому не-вопросу. На отложенных фразах: 19 из 20
вопросов, 0 из 8 ложных срабатываний.
Реплика режется только по границам предложений, со знаком: «?» для e5 —
сильный признак вопроса, без него «Куда ехать» уходит к не-вопросам.
Подсказка берёт неотработанный пункт из автомата. В живой сессии автомат
начнёт слышать оператора, когда голосовой контур передаст ему stt.final.
Ждёт ключа LLM: условие approach у скрытых фактов, звонящий своими
словами, dialog/llm.py с кэшем.
2026-09-17 13:57:43 +03:00
|
|
|
|
|
lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.
Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.
Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.
Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.
make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.
Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
2026-09-17 14:19:38 +03:00
|
|
|
|
# Аудио курсанта. До голосового контура (lct-06) кадры только считаются —
|
|
|
|
|
|
# этого достаточно, чтобы доказать, что звук доходит от микрофона до сервера.
|
|
|
|
|
|
audio_frames: int = 0
|
|
|
|
|
|
bad_frames: int = 0
|
|
|
|
|
|
|
2026-09-17 21:24:34 +03:00
|
|
|
|
# Самооценка сдана — до неё курсант не видит автооценку.
|
|
|
|
|
|
self_assessed: bool = False
|
|
|
|
|
|
self_assessment: dict | None = None
|
|
|
|
|
|
score: dict | None = None
|
|
|
|
|
|
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
started_at: datetime | None = None
|
|
|
|
|
|
ended_at: datetime | None = None
|
|
|
|
|
|
end_reason: CallEndReason | None = None
|
2026-09-26 21:38:09 +00:00
|
|
|
|
#: Карточка оператора 112, замороженная при передаче в ДДС.
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
dispatched_card: KIO | None = None
|
2026-09-17 21:45:47 +03:00
|
|
|
|
dispatched_at: datetime | None = None
|
|
|
|
|
|
#: Поля, из-за которых диспетчер вернул карточку, — основание E6.
|
2026-09-26 22:13:35 +00:00
|
|
|
|
bounced_fields: list[str] = Field(default_factory=list)
|
2026-09-26 21:38:09 +00:00
|
|
|
|
#: Готовые карточки связки 112→ДДС ждут, пока курсант не сдаст свою.
|
2026-09-26 22:13:35 +00:00
|
|
|
|
pending_dds_scenarios: list[Scenario] = Field(default_factory=list)
|
2026-09-26 21:38:09 +00:00
|
|
|
|
#: Исходная часть упражнения 112→ДДС сохраняется отдельно от карточек пульта.
|
2026-09-26 18:12:27 +03:00
|
|
|
|
operator_kio: KIO | None = None
|
|
|
|
|
|
operator_scenario: Scenario | None = None
|
2026-09-26 21:38:09 +00:00
|
|
|
|
#: Работа диспетчера живёт только здесь — в карточках пульта.
|
2026-09-26 22:13:35 +00:00
|
|
|
|
desk: DdsDesk = Field(default_factory=DdsDesk)
|
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).
|
|
|
|
|
|
resolved_outcome: str | None = None
|
|
|
|
|
|
resolve_comment: str = ""
|
2026-09-26 18:12:27 +03:00
|
|
|
|
#: Recently committed DDS command IDs; included in the durable checkpoint so
|
|
|
|
|
|
#: a lost WebSocket acknowledgement cannot apply an operation twice.
|
2026-09-26 22:13:35 +00:00
|
|
|
|
processed_station_commands: list[str] = Field(default_factory=list)
|
|
|
|
|
|
text_revealed_facts: dict[str, str] = Field(default_factory=dict)
|
2026-09-27 17:16:21 +03:00
|
|
|
|
#: Подряд идущих первичных отказов без принятой карточки между ними —
|
|
|
|
|
|
#: сигнал реестра преподавателя, не влияет на балл.
|
|
|
|
|
|
consecutive_refusals: int = 0
|
2026-09-27 17:22:10 +03:00
|
|
|
|
#: Когда реестр преподавателя последний раз видел подключённый сокет
|
|
|
|
|
|
#: курсанта (станция ДДС или звонок 112) — для сигнала "не на связи".
|
|
|
|
|
|
#: Персистится: без этого рестарт/failover обнуляет отсчёт окна
|
|
|
|
|
|
#: присутствия и сигнал никогда не срабатывает для восстановленных сессий.
|
|
|
|
|
|
socket_last_seen_at: datetime | None = None
|
2026-09-26 22:13:35 +00:00
|
|
|
|
|
|
|
|
|
|
@field_validator("processed_station_commands")
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def _recent_commands(cls, value: list[str]) -> list[str]:
|
|
|
|
|
|
return value[-MAX_STATION_COMMANDS:]
|
|
|
|
|
|
|
|
|
|
|
|
@field_serializer("processed_station_commands")
|
|
|
|
|
|
def _dump_recent_commands(self, value: list[str]) -> list[str]:
|
|
|
|
|
|
return value[-MAX_STATION_COMMANDS:]
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
|
|
|
|
|
|
super().__pydantic_init_subclass__(**kwargs)
|
|
|
|
|
|
undecided = [name for name, info in cls.model_fields.items()
|
|
|
|
|
|
if name not in PersistedSession.model_fields and not info.exclude]
|
|
|
|
|
|
if undecided:
|
|
|
|
|
|
raise TypeError(
|
|
|
|
|
|
f"{cls.__name__}: поля {undecided} — сохраняемое поле объявляется в "
|
|
|
|
|
|
"PersistedSession, живое — с Field(exclude=True)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SessionState(PersistedSession):
|
|
|
|
|
|
"""Живое занятие: сохраняемая часть плюс runtime-объекты процесса.
|
|
|
|
|
|
|
|
|
|
|
|
Живые поля в снимок не попадают и пересобираются при загрузке
|
|
|
|
|
|
(`checkpoint.rebuild_live`). Прогресс звонящего (`slots`, `persona`) пока
|
|
|
|
|
|
тоже живой: после failover звонящий начинает с чистого листа — голос P2,
|
|
|
|
|
|
а потеря редкая. Чтобы сохранять его, достаточно добавить в
|
|
|
|
|
|
`PersistedSession` поле `CallerProgress` и передать его в `rebuild_live`.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
|
|
|
|
|
|
|
|
|
|
#: Runtime-only: set when this process loses or cannot confirm DB ownership.
|
|
|
|
|
|
lease_fenced: bool = Field(default=False, exclude=True)
|
|
|
|
|
|
# Звонящий. Автомата нет, если не скачана модель эмбеддингов:
|
|
|
|
|
|
# занятие идёт, подсказки откатываются на порядок чек-листа.
|
|
|
|
|
|
slots: SlotMachine | None = Field(default=None, exclude=True)
|
|
|
|
|
|
persona: PersonaState | None = Field(default=None, exclude=True)
|
|
|
|
|
|
caller: TemplateCaller | None = Field(default=None, exclude=True)
|
|
|
|
|
|
# Голосовой контур звонка. Нет — если голос выключен или моделей нет:
|
|
|
|
|
|
# тогда кадры микрофона только считаются.
|
|
|
|
|
|
voice: object | None = Field(default=None, exclude=True)
|
|
|
|
|
|
recorder: object | None = Field(default=None, exclude=True)
|
|
|
|
|
|
recording_path: str | None = Field(default=None, exclude=True)
|
|
|
|
|
|
|
|
|
|
|
|
def persisted(self) -> PersistedSession:
|
|
|
|
|
|
"""Сохраняемая часть без копирования — то, что уходит в снимок."""
|
|
|
|
|
|
return PersistedSession.model_construct(
|
|
|
|
|
|
**{name: getattr(self, name) for name in PersistedSession.model_fields}
|
|
|
|
|
|
)
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
|
|
|
|
|
|
def on_event(self, event_type: str) -> None:
|
|
|
|
|
|
"""Единственная точка, где событие двигает таймеры."""
|
|
|
|
|
|
self.timers.on_event(event_type)
|
|
|
|
|
|
|
|
|
|
|
|
def append(self, speaker: Speaker, text: str, mood: Mood | None = None) -> TranscriptEntry:
|
|
|
|
|
|
entry = TranscriptEntry(
|
|
|
|
|
|
ref=f"u{len(self.transcript) + 1}",
|
|
|
|
|
|
speaker=speaker,
|
|
|
|
|
|
text=text,
|
|
|
|
|
|
at=now_utc(),
|
|
|
|
|
|
mood=mood,
|
|
|
|
|
|
)
|
|
|
|
|
|
self.transcript.append(entry)
|
|
|
|
|
|
return entry
|
|
|
|
|
|
|
|
|
|
|
|
def patch_kio(self, fields: dict[str, Any]) -> KIO:
|
|
|
|
|
|
self.kio = apply_patch(self.kio, fields)
|
|
|
|
|
|
return self.kio
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
def dispatch(self, service: str | None = None) -> KIO:
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
"""Карточка замораживается снимком: оператор не должен иметь
|
|
|
|
|
|
возможности дописать задним числом поле, которое забыл."""
|
2026-09-21 17:40:54 +03:00
|
|
|
|
if service is not None:
|
|
|
|
|
|
self.kio = apply_patch(self.kio, {"dds": service})
|
|
|
|
|
|
self.kio.response_status = ResponseStatus.TRANSFERRED
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
self.dispatched_card = self.kio.model_copy(deep=True)
|
2026-09-17 21:45:47 +03:00
|
|
|
|
self.dispatched_at = now_utc()
|
2026-09-26 21:38:09 +00:00
|
|
|
|
if self.exercise is Exercise.CALL:
|
|
|
|
|
|
self._receive_call_card()
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
return self.dispatched_card
|
|
|
|
|
|
|
2026-09-26 21:38:09 +00:00
|
|
|
|
def _receive_call_card(self) -> None:
|
|
|
|
|
|
"""Живой диспетчер упражнения 112 работает с переданной карточкой на пульте.
|
|
|
|
|
|
|
|
|
|
|
|
Таймеры общие с оператором: норматив приёма ДДС в этом упражнении
|
|
|
|
|
|
часть одной цепочки 112 → ДДС и оценивается вместе с ней. Повторная
|
|
|
|
|
|
передача обновляет содержимое карточки, не стирая работу диспетчера.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if self.scenario is None or self.dispatched_card is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
card = self.desk.cards.get(self.dispatched_card.card_id)
|
|
|
|
|
|
if card is None:
|
|
|
|
|
|
card = self.desk.add(DdsLiveCard(
|
|
|
|
|
|
original_index=0,
|
|
|
|
|
|
scenario=self.scenario,
|
|
|
|
|
|
kio=self.kio,
|
|
|
|
|
|
dispatched_card=self.dispatched_card,
|
|
|
|
|
|
dispatched_at=self.dispatched_at,
|
|
|
|
|
|
timers=self.timers,
|
|
|
|
|
|
))
|
|
|
|
|
|
else:
|
|
|
|
|
|
card.kio = self.kio
|
|
|
|
|
|
card.dispatched_card = self.dispatched_card
|
|
|
|
|
|
card.dispatched_at = self.dispatched_at
|
|
|
|
|
|
self.desk.open(card.card_id)
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def dds_phase(self) -> bool:
|
|
|
|
|
|
"""Экран курсанта — пульт с очередью: упражнение ДДС или вторая часть связки."""
|
|
|
|
|
|
return self.exercise is Exercise.DDS or (self.handoff_to_dds and bool(self.desk.scenarios))
|
|
|
|
|
|
|
|
|
|
|
|
def shown_card(self) -> DdsLiveCard | None:
|
|
|
|
|
|
"""Карточка, которую монитор показывает вместо КИО оператора."""
|
|
|
|
|
|
return self.desk.active if self.dds_phase else None
|
|
|
|
|
|
|
|
|
|
|
|
def shown_timers(self) -> SessionTimers:
|
|
|
|
|
|
card = self.shown_card()
|
|
|
|
|
|
return card.timers if card is not None else self.timers
|
|
|
|
|
|
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
def notified_services(self) -> list[str]:
|
|
|
|
|
|
"""Список оповещения переданной карточки. До передачи — пусто:
|
|
|
|
|
|
отмечаться диспетчеру пока не по чему."""
|
|
|
|
|
|
card = self.dispatched_card or self.kio
|
|
|
|
|
|
return list(card.notify)
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
def _managed_services_for(self, recipients: list[str]) -> list[str]:
|
|
|
|
|
|
"""Служба, от имени которой работает этот обучающийся.
|
|
|
|
|
|
|
|
|
|
|
|
Старые занятия и demo без профиля безопасно закрепляются за первым
|
|
|
|
|
|
адресатом. Обобщённая учебная служба «ДДС района» соответствует
|
|
|
|
|
|
территориальному органу, а не всем получателям карточки сразу.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not recipients:
|
|
|
|
|
|
return []
|
|
|
|
|
|
wanted = (self.dds_service or "").strip().casefold()
|
|
|
|
|
|
if wanted:
|
|
|
|
|
|
exact = next((item for item in recipients if item.casefold() == wanted), None)
|
|
|
|
|
|
if exact:
|
|
|
|
|
|
return [exact]
|
|
|
|
|
|
contained = next(
|
|
|
|
|
|
(item for item in recipients
|
|
|
|
|
|
if wanted in item.casefold() or item.casefold() in wanted),
|
|
|
|
|
|
None,
|
|
|
|
|
|
)
|
|
|
|
|
|
if contained:
|
|
|
|
|
|
return [contained]
|
|
|
|
|
|
if "район" in wanted:
|
|
|
|
|
|
district = next(
|
|
|
|
|
|
(item for item in recipients if "территориальн" in item.casefold()), None
|
|
|
|
|
|
)
|
|
|
|
|
|
if district:
|
|
|
|
|
|
return [district]
|
|
|
|
|
|
return []
|
|
|
|
|
|
return [recipients[0]]
|
|
|
|
|
|
|
|
|
|
|
|
def managed_services(self) -> list[str]:
|
2026-09-26 21:38:09 +00:00
|
|
|
|
"""Службы обучающегося: по активной карточке пульта, до неё — по КИО 112."""
|
|
|
|
|
|
card = self.desk.active
|
|
|
|
|
|
if card is not None:
|
|
|
|
|
|
return self.card_services(card)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
return self._managed_services_for(self.notified_services())
|
|
|
|
|
|
|
2026-09-26 21:38:09 +00:00
|
|
|
|
def card_services(self, card: DdsLiveCard) -> list[str]:
|
|
|
|
|
|
"""Службы карточки пульта, от имени которых работает обучающийся."""
|
|
|
|
|
|
return self._managed_services_for(list(card.dispatched_card.notify))
|
2026-09-21 17:40:54 +03:00
|
|
|
|
|
2026-09-26 21:38:09 +00:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _crew_options_for(services: list[str]) -> list[str]:
|
|
|
|
|
|
return [f"{service} — бригада №{number}" for service in services for number in (1, 2)]
|
|
|
|
|
|
|
|
|
|
|
|
def crew_options(self, card: DdsLiveCard) -> list[str]:
|
|
|
|
|
|
return self._crew_options_for(self.card_services(card))
|
|
|
|
|
|
|
|
|
|
|
|
def crew_service(self, card: DdsLiveCard, crew: str) -> str | None:
|
|
|
|
|
|
return next((service for service in self.card_services(card)
|
|
|
|
|
|
if crew in (f"{service} — бригада №1", f"{service} — бригада №2")), None)
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
|
|
|
|
|
|
def station_snapshot(self) -> StationSnapshot:
|
|
|
|
|
|
"""Что видит диспетчер: службы, их статусы и доступные переходы."""
|
2026-09-26 21:38:09 +00:00
|
|
|
|
desk = self.desk
|
|
|
|
|
|
card = desk.active
|
|
|
|
|
|
if card is not None:
|
|
|
|
|
|
recipients = list(card.dispatched_card.notify)
|
|
|
|
|
|
elif not desk.scenarios:
|
|
|
|
|
|
# Пульт ещё пуст (до передачи из 112) или упражнение без ДДС:
|
|
|
|
|
|
# показываем список оповещения карточки оператора.
|
|
|
|
|
|
recipients = self.notified_services()
|
|
|
|
|
|
else:
|
|
|
|
|
|
recipients = []
|
2026-09-24 01:10:49 +03:00
|
|
|
|
services = self._managed_services_for(recipients)
|
2026-09-26 21:38:09 +00:00
|
|
|
|
log = list(card.status_log) if card is not None else []
|
|
|
|
|
|
statuses = {service: current(log, service) for service in services}
|
|
|
|
|
|
overdue = False
|
|
|
|
|
|
if card is not None:
|
|
|
|
|
|
elapsed = card.timers.measured_ms(TimerCode.DDS_ACK)
|
|
|
|
|
|
limit = card.timers.limits[TimerCode.DDS_ACK]
|
|
|
|
|
|
overdue = elapsed is None and (
|
|
|
|
|
|
(now_utc() - card.dispatched_at).total_seconds() * 1000 > limit
|
|
|
|
|
|
)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
now_mono = time.monotonic()
|
|
|
|
|
|
queue_cards: list[DdsQueueCard] = []
|
2026-09-26 21:38:09 +00:00
|
|
|
|
for item in (desk.ordered() if self.dds_phase else []):
|
2026-09-24 01:10:49 +03:00
|
|
|
|
recipients_for_card = list(item.dispatched_card.notify)
|
|
|
|
|
|
managed = self._managed_services_for(recipients_for_card)
|
|
|
|
|
|
service = managed[0] if managed else None
|
|
|
|
|
|
timer = item.timers.timers.get(TimerCode.DDS_ACK)
|
|
|
|
|
|
elapsed_ms = timer.current_ms(now_mono) if timer else 0
|
|
|
|
|
|
stopped = timer.stopped if timer else False
|
|
|
|
|
|
limit_ms = item.timers.limits[TimerCode.DDS_ACK]
|
|
|
|
|
|
queue_cards.append(DdsQueueCard(
|
|
|
|
|
|
card_id=item.card_id,
|
|
|
|
|
|
scenario_id=item.scenario.id,
|
|
|
|
|
|
title=item.scenario.title,
|
|
|
|
|
|
address=item.dispatched_card.address,
|
|
|
|
|
|
description=item.dispatched_card.description,
|
|
|
|
|
|
incident_type=(item.dispatched_card.incident_type.value
|
|
|
|
|
|
if item.dispatched_card.incident_type else None),
|
|
|
|
|
|
victims_count=item.dispatched_card.victims_count,
|
|
|
|
|
|
received_at=item.dispatched_at,
|
|
|
|
|
|
managed_service=service,
|
|
|
|
|
|
service_status=(current(item.status_log, service)
|
|
|
|
|
|
if service else ServiceStatus.ADDED),
|
|
|
|
|
|
card_status=card_status(
|
|
|
|
|
|
item.status_log, managed,
|
|
|
|
|
|
notify_deadline_passed=not stopped and elapsed_ms > limit_ms,
|
|
|
|
|
|
),
|
|
|
|
|
|
elapsed_ms=elapsed_ms,
|
|
|
|
|
|
limit_ms=limit_ms,
|
|
|
|
|
|
timer_stopped=stopped,
|
2026-09-26 21:38:09 +00:00
|
|
|
|
active=item.card_id == desk.active_id,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
))
|
2026-09-26 21:38:09 +00:00
|
|
|
|
shows_card = card is not None or not desk.scenarios
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
return StationSnapshot(
|
|
|
|
|
|
services=services,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
recipient_services=recipients,
|
|
|
|
|
|
managed_service=services[0] if services else None,
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
statuses=statuses,
|
|
|
|
|
|
available={service: list(NEXT[status]) for service, status in statuses.items()},
|
2026-09-26 21:38:09 +00:00
|
|
|
|
card=(card_status(log, services, notify_deadline_passed=overdue)
|
|
|
|
|
|
if shows_card else CardStatus.REGISTERED),
|
|
|
|
|
|
log=log,
|
|
|
|
|
|
crew_options=(self._crew_options_for(services) if shows_card else []),
|
|
|
|
|
|
crew_selected=card.crew_selected if card is not None else None,
|
2026-09-26 18:12:27 +03:00
|
|
|
|
zone_decision=(
|
2026-09-26 21:38:09 +00:00
|
|
|
|
next((detail == "в зоне" for action, _at, detail in reversed(card.dds_log)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
if action == "zone.decision"), None)
|
2026-09-26 21:38:09 +00:00
|
|
|
|
if card is not None else None
|
2026-09-26 18:12:27 +03:00
|
|
|
|
),
|
2026-09-26 21:38:09 +00:00
|
|
|
|
phone_reports=list(card.phone_reports) if card is not None else [],
|
|
|
|
|
|
phone_lines=list(card.phone_lines) if card is not None else [],
|
|
|
|
|
|
phone_pending=card.phone_pending if card is not None else None,
|
|
|
|
|
|
card_id=card.card_id if card is not None else None,
|
|
|
|
|
|
card_index=(desk.card_index + 1 if card is not None
|
|
|
|
|
|
else min(desk.next_index + 1, len(desk.scenarios))),
|
|
|
|
|
|
card_total=len(desk.scenarios) or 1,
|
|
|
|
|
|
reply_text=card.reply_text if card is not None else "",
|
2026-09-21 19:56:13 +03:00
|
|
|
|
completed_cards=[DdsCardSummary(card_id=item.card_id,
|
|
|
|
|
|
scenario_id=item.scenario_id,
|
|
|
|
|
|
score_auto=item.score_auto)
|
2026-09-26 21:38:09 +00:00
|
|
|
|
for item in desk.completed],
|
2026-09-24 01:10:49 +03:00
|
|
|
|
queue_cards=queue_cards,
|
|
|
|
|
|
pending_cards_count=max(
|
2026-09-26 21:38:09 +00:00
|
|
|
|
0, len(desk.scenarios) - len(desk.completed)
|
|
|
|
|
|
- (1 if card is not None else 0),
|
2026-09-24 01:10:49 +03:00
|
|
|
|
),
|
|
|
|
|
|
next_arrival_in_seconds=(
|
2026-09-26 21:38:09 +00:00
|
|
|
|
max(0, int((desk.next_arrival_at - now_utc()).total_seconds() + 0.999))
|
|
|
|
|
|
if desk.next_arrival_at is not None else None
|
2026-09-24 01:10:49 +03:00
|
|
|
|
),
|
2026-09-26 21:38:09 +00:00
|
|
|
|
max_waiting_cards=desk.max_waiting,
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-17 21:45:47 +03:00
|
|
|
|
def card_received_event(self):
|
|
|
|
|
|
"""Снимок карточки для станции ДДС."""
|
|
|
|
|
|
from app.domain.events import CardReceived
|
|
|
|
|
|
|
2026-09-26 21:38:09 +00:00
|
|
|
|
card = self.desk.active
|
2026-09-17 21:45:47 +03:00
|
|
|
|
return CardReceived(
|
2026-09-26 21:38:09 +00:00
|
|
|
|
card=card.dispatched_card if card is not None else self.dispatched_card,
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from_operator=("учебный сценарий" if self.exercise is Exercise.DDS
|
2026-09-26 21:38:09 +00:00
|
|
|
|
or (self.handoff_to_dds and self.desk.card_index > 0)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
else self.trainee_name or "оператор 112"),
|
2026-09-26 21:38:09 +00:00
|
|
|
|
at=(card.dispatched_at if card is not None else self.dispatched_at) or now_utc(),
|
|
|
|
|
|
card_index=self.desk.card_index + 1,
|
|
|
|
|
|
card_total=len(self.desk.scenarios) or 1,
|
2026-09-17 21:45:47 +03:00
|
|
|
|
)
|
|
|
|
|
|
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
@property
|
|
|
|
|
|
def ended(self) -> bool:
|
|
|
|
|
|
return self.ended_at is not None
|
|
|
|
|
|
|
2026-09-26 22:01:27 +00:00
|
|
|
|
def end(self, reason: CallEndReason) -> datetime:
|
|
|
|
|
|
"""Закрыть занятие; возвращает момент окончания для журнала."""
|
|
|
|
|
|
self.ended_at = now_utc()
|
|
|
|
|
|
self.end_reason = reason
|
|
|
|
|
|
return self.ended_at
|
|
|
|
|
|
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
def snapshot(self) -> SessionSnapshot:
|
|
|
|
|
|
"""Полное состояние. Монитор в классе включают посреди занятия —
|
|
|
|
|
|
он обязан показать текущее, а не ждать следующего события."""
|
2026-09-26 21:38:09 +00:00
|
|
|
|
card = self.shown_card()
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
return SessionSnapshot(
|
|
|
|
|
|
session_id=self.session_id,
|
2026-09-26 21:38:09 +00:00
|
|
|
|
scenario_id=card.scenario.id if card is not None else self.scenario_id,
|
|
|
|
|
|
scenario_title=card.scenario.title if card is not None else self.scenario_title,
|
|
|
|
|
|
level=card.scenario.level.value if card is not None else self.level,
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
mode=self.mode,
|
2026-09-21 17:40:54 +03:00
|
|
|
|
exercise=self.exercise,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
criteria=self.criteria,
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
trainee_name=self.trainee_name,
|
|
|
|
|
|
started_at=self.started_at,
|
2026-09-26 21:38:09 +00:00
|
|
|
|
kio=card.kio if card is not None else self.kio,
|
|
|
|
|
|
required_fields=(list(card.scenario.required_fields) if card is not None
|
|
|
|
|
|
else self.required_fields),
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
transcript=list(self.transcript),
|
2026-09-26 21:38:09 +00:00
|
|
|
|
timers=self.shown_timers().snapshot(),
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
hints_used=len(self.hints_shown),
|
|
|
|
|
|
ended=self.ended,
|
|
|
|
|
|
)
|