lct-hack/backend/app/domain/events.py

695 lines
20 KiB
Python
Raw Normal View History

"""Схемы событий WebSocket — источник истины для make types.
Контракт целиком: docs/arch/CONTRACT.md
Каналы: /ws/call (курсант), /ws/station (ДДС), /ws/observe (монитор и пульт,
только приём), /ws/control (только преподаватель, только передача).
Правила, зашитые в схемы:
* курсанту уходят дельты, наблюдателям — состояние целиком;
* все `at` — серверное UTC, фронт время не считает;
* ошибки идут одним каналом `error`, без HTTP-кодов внутри WS.
"""
from datetime import datetime
from enum import StrEnum
from typing import Annotated, Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field
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
from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
lct-01: контракт закрыт — координаты, обязательные поля, коды ошибок Координаты были представлены дважды: кортежем в КИО и объектом в данных ЭРА-ГЛОНАСС. Теперь Coords {lat, lon} везде — в кортеже не видно, где широта, и ошибка всплывает на карте у диспетчера, а не в типах. required_fields едет в call.incoming и session.snapshot: обязательность полей задаёт сценарий, без списка АРМ не подсветит незаполненное поле, и курсант узнаёт о неполноте карточки только из разбора. Канал error получил коды (ErrorKind, восемь значений) — фронт разбирает код, а не текст сообщения. Имя не ErrorCode: оно занято таксономией E1–E6, два одинаковых имени дали бы коллизию в generated.ts. Добавлены поля, которые спрашивает чек-лист, а записать было некуда: куда идёт дым, уехал ли нарушитель, код домофона. Документы догнали код: mkh → gkh, payload transcript.append с якорем ref, причина director у tts.cancel, attempt и stopped у таймеров.
2026-09-15 19:48:56 +03:00
from app.domain.kio import KIO, Coords
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
from app.domain.roles import Role
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 ServiceStatus, StationSnapshot
from app.domain.taxonomy import Finding
from app.domain.timers import TimerSnapshot
class SessionMode(StrEnum):
"""Режим сессии. Меняет доступность подсказок и протоколирование,
но не поведение звонящего (docs/product/MODES.md)."""
TRAINING = "training"
EXAM = "exam"
SELF = "self"
class Exercise(StrEnum):
CALL = "call"
DDS = "dds"
CARD = "card"
class Speaker(StrEnum):
CALLER = "caller"
OPERATOR = "operator"
class Mood(StrEnum):
"""Состояние звонящего. Фон подачи, не факты."""
CALM = "calm"
WORRIED = "worried"
PANIC = "panic"
AGGRESSIVE = "aggressive"
CONFUSED = "confused"
class CallEndReason(StrEnum):
HANGUP = "hangup"
DROPPED = "dropped"
INSTRUCTOR = "instructor"
COMPLETE = "complete"
class PatchSource(StrEnum):
"""`auto` — сервер распознал факт из речи, `operator` — эхо правки курсанта."""
AUTO = "auto"
OPERATOR = "operator"
class DirectiveMode(StrEnum):
"""`immediate` — только для обрыва связи: рвёт TTS на полуслове."""
NEXT_TURN = "next_turn"
IMMEDIATE = "immediate"
lct-01: контракт закрыт — координаты, обязательные поля, коды ошибок Координаты были представлены дважды: кортежем в КИО и объектом в данных ЭРА-ГЛОНАСС. Теперь Coords {lat, lon} везде — в кортеже не видно, где широта, и ошибка всплывает на карте у диспетчера, а не в типах. required_fields едет в call.incoming и session.snapshot: обязательность полей задаёт сценарий, без списка АРМ не подсветит незаполненное поле, и курсант узнаёт о неполноте карточки только из разбора. Канал error получил коды (ErrorKind, восемь значений) — фронт разбирает код, а не текст сообщения. Имя не ErrorCode: оно занято таксономией E1–E6, два одинаковых имени дали бы коллизию в generated.ts. Добавлены поля, которые спрашивает чек-лист, а записать было некуда: куда идёт дым, уехал ли нарушитель, код домофона. Документы догнали код: mkh → gkh, payload transcript.append с якорем ref, причина director у tts.cancel, attempt и stopped у таймеров.
2026-09-15 19:48:56 +03:00
class ErrorKind(StrEnum):
"""Коды канала `error`. Фронт разбирает код, а не текст сообщения:
текст — для человека, код — для поведения интерфейса."""
SESSION_NOT_FOUND = "session_not_found"
CALL_NOT_STARTED = "call_not_started"
HINT_DENIED_IN_EXAM = "hint_denied_in_exam"
MODELS_WARMING_UP = "models_warming_up"
DIRECTIVE_NEEDS_NETWORK = "directive_needs_network"
SCENARIO_INVALID = "scenario_invalid"
UNSUPPORTED_EVENT = "unsupported_event"
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). Фронт по этому коду
#: отправляет на вход, а не показывает «занятие не найдено».
FORBIDDEN = "forbidden"
lct-01: контракт закрыт — координаты, обязательные поля, коды ошибок Координаты были представлены дважды: кортежем в КИО и объектом в данных ЭРА-ГЛОНАСС. Теперь Coords {lat, lon} везде — в кортеже не видно, где широта, и ошибка всплывает на карте у диспетчера, а не в типах. required_fields едет в call.incoming и session.snapshot: обязательность полей задаёт сценарий, без списка АРМ не подсветит незаполненное поле, и курсант узнаёт о неполноте карточки только из разбора. Канал error получил коды (ErrorKind, восемь значений) — фронт разбирает код, а не текст сообщения. Имя не ErrorCode: оно занято таксономией E1–E6, два одинаковых имени дали бы коллизию в generated.ts. Добавлены поля, которые спрашивает чек-лист, а записать было некуда: куда идёт дым, уехал ли нарушитель, код домофона. Документы догнали код: mkh → gkh, payload transcript.append с якорем ref, причина director у tts.cancel, attempt и stopped у таймеров.
2026-09-15 19:48:56 +03:00
INTERNAL = "internal"
class TranscriptEntry(BaseModel):
"""Реплика в ленте. `ref` — якорь для пометок преподавателя и отметок разбора."""
ref: str
speaker: Speaker
text: str
at: datetime
mood: Mood | None = None
# ─────────────────────────── сервер → курсант ───────────────────────────
class CallIncoming(BaseModel):
lct-01: контракт закрыт — координаты, обязательные поля, коды ошибок Координаты были представлены дважды: кортежем в КИО и объектом в данных ЭРА-ГЛОНАСС. Теперь Coords {lat, lon} везде — в кортеже не видно, где широта, и ошибка всплывает на карте у диспетчера, а не в типах. required_fields едет в call.incoming и session.snapshot: обязательность полей задаёт сценарий, без списка АРМ не подсветит незаполненное поле, и курсант узнаёт о неполноте карточки только из разбора. Канал error получил коды (ErrorKind, восемь значений) — фронт разбирает код, а не текст сообщения. Имя не ErrorCode: оно занято таксономией E1–E6, два одинаковых имени дали бы коллизию в generated.ts. Добавлены поля, которые спрашивает чек-лист, а записать было некуда: куда идёт дым, уехал ли нарушитель, код домофона. Документы догнали код: mkh → gkh, payload transcript.append с якорем ref, причина director у tts.cancel, attempt и stopped у таймеров.
2026-09-15 19:48:56 +03:00
"""Обязательность полей приходит сценарием, а не моделью: без `required_fields`
АРМ не может подсветить незаполненное обязательное поле, и курсант узнаёт
о неполноте карточки только из разбора."""
type: Literal["call.incoming"] = "call.incoming"
scenario_id: str
caller_number: str
level: Level
mode: SessionMode
lct-01: контракт закрыт — координаты, обязательные поля, коды ошибок Координаты были представлены дважды: кортежем в КИО и объектом в данных ЭРА-ГЛОНАСС. Теперь Coords {lat, lon} везде — в кортеже не видно, где широта, и ошибка всплывает на карте у диспетчера, а не в типах. required_fields едет в call.incoming и session.snapshot: обязательность полей задаёт сценарий, без списка АРМ не подсветит незаполненное поле, и курсант узнаёт о неполноте карточки только из разбора. Канал error получил коды (ErrorKind, восемь значений) — фронт разбирает код, а не текст сообщения. Имя не ErrorCode: оно занято таксономией E1–E6, два одинаковых имени дали бы коллизию в generated.ts. Добавлены поля, которые спрашивает чек-лист, а записать было некуда: куда идёт дым, уехал ли нарушитель, код домофона. Документы догнали код: mkh → gkh, payload transcript.append с якорем ref, причина director у tts.cancel, attempt и stopped у таймеров.
2026-09-15 19:48:56 +03:00
required_fields: list[str] = []
class CardBriefing(BaseModel):
"""Текстовая вводная упражнения 112; эталонные значения сюда не входят."""
type: Literal["card.briefing"] = "card.briefing"
scenario_id: str
mode: SessionMode
text: str
required_fields: list[str]
card: KIO | None = None
handoff_to_dds: bool = False
class CallStarted(BaseModel):
type: Literal["call.started"] = "call.started"
started_at: datetime
class SttPartial(BaseModel):
type: Literal["stt.partial"] = "stt.partial"
text: str
class SttFinal(BaseModel):
type: Literal["stt.final"] = "stt.final"
text: str
at: datetime
class CallerUtterance(BaseModel):
type: Literal["caller.utterance"] = "caller.utterance"
utterance_id: UUID
text: str
at: datetime
mood: Mood
class TtsBegin(BaseModel):
type: Literal["tts.begin"] = "tts.begin"
utterance_id: UUID
class TtsEnd(BaseModel):
type: Literal["tts.end"] = "tts.end"
utterance_id: UUID
class TtsCancel(BaseModel):
"""Оператор перебил. Фронт мгновенно чистит очередь воспроизведения."""
type: Literal["tts.cancel"] = "tts.cancel"
utterance_id: UUID
reason: Literal["barge_in", "director"] = "barge_in"
class BgStart(BaseModel):
"""Аудио-фон происшествия. Файл лежит в сборке фронта, трафика фон не создаёт."""
type: Literal["bg.start"] = "bg.start"
loop: str
gain_db: float
class BgStop(BaseModel):
type: Literal["bg.stop"] = "bg.stop"
class EraData(BaseModel):
"""Автоданные ЭРА-ГЛОНАСС до соединения с водителем."""
type: Literal["era.data"] = "era.data"
vin: str
coords: Coords
passengers: int
impact_force: str
class KioPatchOut(BaseModel):
"""Сервер распознал факт из речи либо шлёт эхо чужой правки."""
type: Literal["kio.patch"] = "kio.patch"
fields: dict[str, Any]
source: PatchSource
class HintShown(BaseModel):
type: Literal["hint.shown"] = "hint.shown"
checklist_id: str
question: str
class TimerTick(BaseModel):
"""Раз в секунду, не на каждое изменение: таймеров дюжина, UI рисует секунды."""
type: Literal["timer.tick"] = "timer.tick"
timers: list[TimerSnapshot]
class CallEnded(BaseModel):
type: Literal["call.ended"] = "call.ended"
reason: CallEndReason
class ScoreReady(BaseModel):
type: Literal["score.ready"] = "score.ready"
session_id: UUID
class ErrorEvent(BaseModel):
type: Literal["error"] = "error"
lct-01: контракт закрыт — координаты, обязательные поля, коды ошибок Координаты были представлены дважды: кортежем в КИО и объектом в данных ЭРА-ГЛОНАСС. Теперь Coords {lat, lon} везде — в кортеже не видно, где широта, и ошибка всплывает на карте у диспетчера, а не в типах. required_fields едет в call.incoming и session.snapshot: обязательность полей задаёт сценарий, без списка АРМ не подсветит незаполненное поле, и курсант узнаёт о неполноте карточки только из разбора. Канал error получил коды (ErrorKind, восемь значений) — фронт разбирает код, а не текст сообщения. Имя не ErrorCode: оно занято таксономией E1–E6, два одинаковых имени дали бы коллизию в generated.ts. Добавлены поля, которые спрашивает чек-лист, а записать было некуда: куда идёт дым, уехал ли нарушитель, код домофона. Документы догнали код: mkh → gkh, payload transcript.append с якорем ref, причина director у tts.cancel, attempt и stopped у таймеров.
2026-09-15 19:48:56 +03:00
code: ErrorKind
message: str
ServerToTrainee = Annotated[
CallIncoming
| CardBriefing
| CallStarted
| SttPartial
| SttFinal
| CallerUtterance
| TtsBegin
| TtsEnd
| TtsCancel
| BgStart
| BgStop
| EraData
| KioPatchOut
| HintShown
| TimerTick
| CallEnded
| ScoreReady
| ErrorEvent,
Field(discriminator="type"),
]
# ─────────────────────────── курсант → сервер ───────────────────────────
class CallAnswer(BaseModel):
"""Снял гарнитуру. Останавливает норматив `answer`."""
type: Literal["call.answer"] = "call.answer"
class CardSubmit(BaseModel):
type: Literal["card.submit"] = "card.submit"
class KioPatchIn(BaseModel):
"""Правка карточки. Дебаунс 300 мс, шлётся только дельта."""
type: Literal["kio.patch"] = "kio.patch"
fields: dict[str, Any]
class HintRequest(BaseModel):
"""В режиме `exam` сервер отвечает событием `error`."""
type: Literal["hint.request"] = "hint.request"
class SelfAssessmentSubmit(BaseModel):
"""Самооценка до показа автооценки."""
type: Literal["self_assessment.submit"] = "self_assessment.submit"
missed: list[str]
comment: str = ""
class DdsDispatch(BaseModel):
"""Передача в ДДС. При маршрутизации по ЕКП служба уже есть в notify."""
type: Literal["dds.dispatch"] = "dds.dispatch"
service: DDSCode | None = None
class CallHangup(BaseModel):
type: Literal["call.hangup"] = "call.hangup"
class CallbackDial(BaseModel):
"""Обратный дозвон после обрыва: 3 попытки по 10 с."""
type: Literal["callback.dial"] = "callback.dial"
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
class CallResolve(BaseModel):
"""Курсант решил, что карточка здесь не заводится.
Не «завершить звонок», а именно «этот вызов закрывается иначе»: справкой
или передачей в другой регион. Решение оценивается наравне с выбором
признаков — это та же классификация, только на шаг раньше.
"""
type: Literal["call.resolve"] = "call.resolve"
outcome: Outcome
comment: str = ""
TraineeToServer = Annotated[
CallAnswer
| CardSubmit
| KioPatchIn
| HintRequest
| SelfAssessmentSubmit
| DdsDispatch
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
| CallResolve
| CallHangup
| CallbackDial,
Field(discriminator="type"),
]
# ───────────────────────── сервер → наблюдателям ─────────────────────────
class SessionSnapshot(BaseModel):
"""Обязательно при подключении: монитор в классе включают посреди занятия."""
type: Literal["session.snapshot"] = "session.snapshot"
session_id: UUID
scenario_id: str
scenario_title: str
level: Level
mode: SessionMode
exercise: Exercise = Exercise.CALL
trainee_name: str | None = None
started_at: datetime | None = None
kio: KIO
lct-01: контракт закрыт — координаты, обязательные поля, коды ошибок Координаты были представлены дважды: кортежем в КИО и объектом в данных ЭРА-ГЛОНАСС. Теперь Coords {lat, lon} везде — в кортеже не видно, где широта, и ошибка всплывает на карте у диспетчера, а не в типах. required_fields едет в call.incoming и session.snapshot: обязательность полей задаёт сценарий, без списка АРМ не подсветит незаполненное поле, и курсант узнаёт о неполноте карточки только из разбора. Канал error получил коды (ErrorKind, восемь значений) — фронт разбирает код, а не текст сообщения. Имя не ErrorCode: оно занято таксономией E1–E6, два одинаковых имени дали бы коллизию в generated.ts. Добавлены поля, которые спрашивает чек-лист, а записать было некуда: куда идёт дым, уехал ли нарушитель, код домофона. Документы догнали код: mkh → gkh, payload transcript.append с якорем ref, причина director у tts.cancel, attempt и stopped у таймеров.
2026-09-15 19:48:56 +03:00
required_fields: list[str] = []
transcript: list[TranscriptEntry]
timers: list[TimerSnapshot]
hints_used: int = 0
ended: bool = False
class TranscriptAppend(BaseModel):
type: Literal["transcript.append"] = "transcript.append"
entry: TranscriptEntry
class KioState(BaseModel):
"""Полная карточка, не дельта: наблюдателю проще, рассинхрон дороже килобайт."""
type: Literal["kio.state"] = "kio.state"
kio: KIO
class ModeSet(BaseModel):
type: Literal["mode.set"] = "mode.set"
mode: SessionMode
class SessionEnded(BaseModel):
type: Literal["session.ended"] = "session.ended"
reason: CallEndReason
class InstructorNoteShown(BaseModel):
"""Пометка преподавателя видна всем, кому виден транскрипт."""
type: Literal["instructor_note.shown"] = "instructor_note.shown"
transcript_ref: str
text: str
author: str
class ReferenceStarted(BaseModel):
"""Автопроигрывание эталонного звонка на внешнем мониторе."""
type: Literal["reference.started"] = "reference.started"
scenario_id: str
ServerToObserver = Annotated[
SessionSnapshot
| TranscriptAppend
| KioState
| TimerTick
| ModeSet
| HintShown
| BgStart
| BgStop
| CallerUtterance
| SessionEnded
| ScoreReady
| InstructorNoteShown
| ReferenceStarted
| ErrorEvent,
Field(discriminator="type"),
]
# ──────────────────── преподаватель → сервер (control) ────────────────────
class ScenarioStart(BaseModel):
type: Literal["scenario.start"] = "scenario.start"
scenario_id: str
scenario_ids: list[str] | None = None
trainee: str
trainee_id: UUID | None = None
group_id: str | None = None
mode: SessionMode
exercise: Exercise = Exercise.CALL
handoff_to_dds: bool = False
class DirectorInject(BaseModel):
"""Директива звонящему. Карточку курсанта не трогает ни одна команда."""
type: Literal["director.inject"] = "director.inject"
directive: str
mode: DirectiveMode = DirectiveMode.NEXT_TURN
class ReferencePlay(BaseModel):
type: Literal["reference.play"] = "reference.play"
class InstructorNoteAdd(BaseModel):
type: Literal["instructor_note.add"] = "instructor_note.add"
transcript_ref: str
text: str
class ScoreOverride(BaseModel):
"""Коррекция оценки. Автооценка сохраняется рядом."""
type: Literal["score.override"] = "score.override"
session_id: UUID
verdict: str
comment: str
class ScenarioPublish(BaseModel):
type: Literal["scenario.publish"] = "scenario.publish"
scenario_id: str
class SessionStop(BaseModel):
type: Literal["session.stop"] = "session.stop"
InstructorToServer = Annotated[
ScenarioStart
| DirectorInject
| ReferencePlay
| InstructorNoteAdd
| ScoreOverride
| ScenarioPublish
| SessionStop,
Field(discriminator="type"),
]
# ───────────────────────────── станция ДДС ─────────────────────────────
class CardReceived(BaseModel):
"""Снимок КИО. После передачи не меняется — оператор не дописывает задним числом."""
type: Literal["card.received"] = "card.received"
card: KIO
from_operator: str
at: datetime
card_index: int = 1
card_total: int = 1
class CardAck(BaseModel):
docs: датасет заказчика — разбор, расшифровка билетов, ревизия карточек Получены первичные данные, названные в ТЗ: памятка по АРМ-112, классификатор происшествий на 1283 кода и 32 экзаменационных билета. Файлы в docs/spec/source/ под гитом: документ, на который ссылается норматив, должен приезжать вместе с кодом. Что изменилось по существу: - Норматив подтверждения карточки был 4 секунды со ссылкой на ГОСТ, где этого числа нет. Памятка и ТЗ независимо называют 30 секунд — исправлено в domain/timers.py с правильной ссылкой (ПП РФ № 1931), в контракте и в generated.ts. - Оператор в боевом АРМ-112 службу не выбирает: он проставляет признаки, а список оповещения из 5–11 служб считается по ЕКП. Наше поле dds моделирует не ту работу. - Сторона ДДС описана памяткой подробнее, чем реализована: девять статусов реагирования автоматом, семь статусов карточки, обязательные комментарии к отказам и таксономия ошибок с примерами. - 96 учебных вызовов из билетов расшифрованы слово в слово в docs/spec/TICKETS.md; сложность в них делается адресом и профильностью, а не сюжетом. Документы: DATASET.md, TICKETS.md, правки NORMATIVES.md (НПА, ЕКП, статусы), GAP.md (пункты 13–15), TZ.md (предположение «датасета не будет» не подтвердилось). Карточки: новые 32–36 (классификатор, статусы реагирования, билеты, уточнение адреса, непрофильные вызовы); в девятнадцати существующих — раздел «Датасет» с расхождениями, включая выполненные: lct-01 моделирует не ту карточку КИО, lct-12 сравнивает адрес так, что уточнивший его курсант получает расхождение с эталоном, lct-10 не содержит трёх блоков боевого АРМ.
2026-09-19 19:42:48 +03:00
"""Останавливает норматив `dds_ack` (≤ 30 с)."""
type: Literal["card.ack"] = "card.ack"
class CardBounce(BaseModel):
"""Возврат на уточнение: неполнота КИО становится сорванным выездом."""
type: Literal["card.bounce"] = "card.bounce"
missing_fields: list[str]
comment: str = ""
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
class ServiceStatusSet(BaseModel):
"""Диспетчер ставит статус реагирования своей службе.
Последовательность жёсткая, комментарий к отказу обязателен — это не
валидация формы, а то, чему учит второй режим занятия
(docs/spec/DATASET.md#статусы-реагирования).
"""
type: Literal["card.status"] = "card.status"
service: str
status: ServiceStatus
comment: str = ""
class CrewSelect(BaseModel):
type: Literal["crew.select"] = "crew.select"
crew: str
class PhoneDial(BaseModel):
type: Literal["phone.dial"] = "phone.dial"
class PhoneReport(BaseModel):
type: Literal["phone.report"] = "phone.report"
service: str
crew: str
phase: Literal["dispatched", "arrived", "working", "completed"]
text: str
at: datetime
class StationFinish(BaseModel):
type: Literal["station.finish"] = "station.finish"
class CardReply(BaseModel):
type: Literal["card.reply"] = "card.reply"
card_id: UUID
text: str = Field(max_length=2000)
class CardNext(BaseModel):
type: Literal["card.next"] = "card.next"
card_id: UUID
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
class StationState(BaseModel):
"""Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше."""
type: Literal["station.state"] = "station.state"
snapshot: StationSnapshot
class ZoneDecision(BaseModel):
type: Literal["zone.decision"] = "zone.decision"
in_zone: bool
class CrewDispatched(BaseModel):
type: Literal["crew.dispatched"] = "crew.dispatched"
at: datetime
class CrewArrived(BaseModel):
type: Literal["crew.arrived"] = "crew.arrived"
at: datetime
ServerToStation = Annotated[
CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent,
Field(discriminator="type"),
]
StationToServer = Annotated[
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish
| CardReply | CardNext
| ZoneDecision | CrewDispatched | CrewArrived,
Field(discriminator="type"),
]
# ─────────────────────────── отчёт по HTTP ───────────────────────────
class Metric(BaseModel):
"""Метрика оценки: факт против норматива со ссылкой. Не балл, а обоснование."""
key: str
title: str
fact: str
norm: str
ref: str | None = None
passed: bool
weight: float = 1.0
class CompetencyScore(BaseModel):
competency: str
value: float
class HintUsage(BaseModel):
checklist_id: str
question: str
at: datetime
class SelfAssessment(BaseModel):
missed: list[str]
comment: str = ""
submitted_at: datetime
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон Эталонный диалог собирается кодом из фактов и чек-листа: написанный руками, он разошёлся бы с фактами при первой же правке сценария, и курсанта оштрафовали бы за правильный ответ. Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый недобытый факт с эталонным вопросом, расхождение самооценки — что курсант заметил сам, чего не заметил, что отметил зря. Не заметил — самое ценное для разбора. Внешний монитор — не отдельное приложение, а другой режим отрисовки тех же событий: крупный таймер опроса, ход разговора, карточка, после оценки разбор на весь экран. Коррекция преподавателем сохраняет автооценку рядом: видно, что скорректировано и кем. Найдено: все метрики весили одинаково, и курсант, не задавший ни одного вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные веса (полнота опроса — 4) дают 74; окончательные утверждает методист, вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
class SelfAssessmentDiff(BaseModel):
"""Расхождение самооценки с автооценкой — отдельный материал для преподавателя.
Курсант, не заметивший, что пропустил вопрос о пострадавших, — более важный
случай, чем сама ошибка (docs/product/DEBRIEF.md).
"""
#: Пропустил и сам это заметил.
noticed: list[str] = []
#: Пропустил и не заметил — самое ценное для разбора.
unnoticed: list[str] = []
#: Отметил как пропущенное, хотя на деле спросил.
overcautious: list[str] = []
class DdsCardReport(BaseModel):
card_id: UUID
scenario_id: str
score_auto: float
reply_text: str
metrics: list[Metric]
findings: list[Finding]
actions: list[dict[str, Any]] = []
duration_ms: int = 0
class SessionReport(BaseModel):
"""Единица истории: из отчётов складываются профиль, дельта попыток, аналитика."""
session_id: UUID
scenario_id: str
mode: SessionMode
attempt: int = 1
transcript: list[TranscriptEntry]
findings: list[Finding]
metrics: list[Metric]
card_results: list[DdsCardReport] = []
competencies: list[CompetencyScore]
reference_questions: list[HintShown]
missed_checklist: list[str]
hints_used: list[HintUsage]
self_assessment: SelfAssessment | None = None
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон Эталонный диалог собирается кодом из фактов и чек-листа: написанный руками, он разошёлся бы с фактами при первой же правке сценария, и курсанта оштрафовали бы за правильный ответ. Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый недобытый факт с эталонным вопросом, расхождение самооценки — что курсант заметил сам, чего не заметил, что отметил зря. Не заметил — самое ценное для разбора. Внешний монитор — не отдельное приложение, а другой режим отрисовки тех же событий: крупный таймер опроса, ход разговора, карточка, после оценки разбор на весь экран. Коррекция преподавателем сохраняет автооценку рядом: видно, что скорректировано и кем. Найдено: все метрики весили одинаково, и курсант, не задавший ни одного вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные веса (полнота опроса — 4) дают 74; окончательные утверждает методист, вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
self_assessment_diff: SelfAssessmentDiff | None = None
notes: list[InstructorNoteShown] = []
score_auto: float
score_final: float
overridden_by: str | None = None
override_comment: 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
class Principal(BaseModel):
"""Кто вошёл. В событиях не участвует, но фронт разводит по роли экраны,
и список ролей должен приезжать из домена, а не переписываться руками
(lct-23). Полная модель — в `app/api/auth.py`.
"""
login: str
full_name: str
role: Role
service: str | None = None
trainee_id: UUID | None = None
class EventCatalog(BaseModel):
"""Единственное назначение — собрать все союзы в одну JSON Schema для make types."""
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
principal: Principal
server_to_trainee: ServerToTrainee
trainee_to_server: TraineeToServer
server_to_observer: ServerToObserver
instructor_to_server: InstructorToServer
server_to_station: ServerToStation
station_to_server: StationToServer
session_report: SessionReport