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

837 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Схемы событий 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, field_validator
from app.domain.classifiers import DDSCode, Level, Outcome
from app.domain.kio import KIO, Coords
from app.domain.roles import Role
from app.domain.statuses import ServiceStatus, StationSnapshot
from app.domain.taxonomy import Finding
from app.domain.timers import TimerSnapshot
from app.scoring.taxonomy import METRIC_MAP
class SessionMode(StrEnum):
"""Режим сессии. Меняет доступность подсказок и протоколирование,
но не поведение звонящего (docs/product/MODES.md)."""
TRAINING = "training"
EXAM = "exam"
SELF = "self"
class Exercise(StrEnum):
CALL = "call"
DDS = "dds"
CARD = "card"
class LessonCriteria(BaseModel):
"""Настраиваемые преподавателем условия именно этого занятия.
Нормативы ГОСТ для приёма вызова сюда не входят. Для занятия меняются
учебные лимиты первичной реакции и полного цикла карточки ДДС, заполнения
КИО, порог успешности и веса метрик; веса сценария остаются базовыми,
настройки занятия могут их переопределить.
"""
decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
card_fill_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
dds_card_work_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
allowed_errors: int = Field(default=0, ge=0, le=50)
require_correct_grammar: bool = True
score_weights: dict[str, float] = Field(default_factory=dict)
@field_validator("score_weights")
@classmethod
def validate_score_weights(cls, weights: dict[str, float]) -> dict[str, float]:
unknown = weights.keys() - METRIC_MAP.keys()
if unknown:
raise ValueError(f"неизвестные метрики весов: {', '.join(sorted(unknown))}")
if any(not 0 <= weight <= 10 for weight in weights.values()):
raise ValueError("каждый вес метрики должен быть от 0 до 10")
return weights
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"
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"
#: Канал открыт не тем, кому он предназначен (lct-23). Фронт по этому коду
#: отправляет на вход, а не показывает «занятие не найдено».
FORBIDDEN = "forbidden"
INTERNAL = "internal"
class TranscriptEntry(BaseModel):
"""Реплика в ленте. `ref` — якорь для пометок преподавателя и отметок разбора."""
ref: str
speaker: Speaker
text: str
at: datetime
mood: Mood | None = None
# ─────────────────────────── сервер → курсант ───────────────────────────
class CallIncoming(BaseModel):
"""Обязательность полей приходит сценарием, а не моделью: без `required_fields`
АРМ не может подсветить незаполненное обязательное поле, и курсант узнаёт
о неполноте карточки только из разбора."""
type: Literal["call.incoming"] = "call.incoming"
scenario_id: str
caller_number: str
level: Level
mode: SessionMode
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 TextTurnAccepted(BaseModel):
type: Literal["text.turn.accepted"] = "text.turn.accepted"
text: str
at: datetime
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
source: Literal["local_llm", "scenario"] = "scenario"
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 CommandAck(BaseModel):
"""Durable confirmation for a station command, safe to replay by ID."""
type: Literal["command.ack"] = "command.ack"
command_id: UUID
class ErrorEvent(BaseModel):
type: Literal["error"] = "error"
code: ErrorKind
message: str
class SessionPaused(BaseModel):
"""Пульт остановил или возобновил время занятия (lct-39)."""
type: Literal["session.paused"] = "session.paused"
paused: bool
ServerToTrainee = Annotated[
CallIncoming
| CardBriefing
| TextTurnAccepted
| CallStarted
| SttPartial
| SttFinal
| CallerUtterance
| TtsBegin
| TtsEnd
| TtsCancel
| BgStart
| BgStop
| EraData
| KioPatchOut
| HintShown
| TimerTick
| CallEnded
| ScoreReady
| SessionPaused
| ErrorEvent,
Field(discriminator="type"),
]
# ─────────────────────────── курсант → сервер ───────────────────────────
class CallAnswer(BaseModel):
"""Снял гарнитуру. Останавливает норматив `answer`."""
type: Literal["call.answer"] = "call.answer"
class CardSubmit(BaseModel):
type: Literal["card.submit"] = "card.submit"
class TextTurn(BaseModel):
type: Literal["text.turn"] = "text.turn"
text: str = Field(min_length=1, max_length=1000)
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"
class CallResolve(BaseModel):
"""Курсант решил, что карточка здесь не заводится.
Не «завершить звонок», а именно «этот вызов закрывается иначе»: справкой
или передачей в другой регион. Решение оценивается наравне с выбором
признаков — это та же классификация, только на шаг раньше.
"""
type: Literal["call.resolve"] = "call.resolve"
outcome: Outcome
comment: str = ""
TraineeToServer = Annotated[
CallAnswer
| CardSubmit
| TextTurn
| KioPatchIn
| HintRequest
| SelfAssessmentSubmit
| DdsDispatch
| 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
criteria: LessonCriteria = Field(default_factory=LessonCriteria)
trainee_name: str | None = None
started_at: datetime | None = None
kio: KIO
required_fields: list[str] = []
transcript: list[TranscriptEntry]
timers: list[TimerSnapshot]
hints_used: int = 0
ended: bool = False
#: Пульт остановил время занятия (lct-39).
paused: 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
| SessionPaused
| ErrorEvent,
Field(discriminator="type"),
]
# ──────────────────── преподаватель → сервер (control) ────────────────────
class ScenarioStart(BaseModel):
type: Literal["scenario.start"] = "scenario.start"
scenario_id: str
scenario_ids: list[str] | None = None
random_scenario_ids: list[str] | None = None
# Pace defaults to simultaneous for older clients; the instructor UI
# explicitly sends its slower training default.
dds_arrival_interval_seconds: int = Field(default=0, ge=0, le=300)
dds_max_waiting: int = Field(default=3, ge=1, le=10)
trainee: str
trainee_id: UUID | None = None
# Служба обучающегося определяет, чьи статусы он ведёт. В рабочем режиме
# сервер заменяет это значение данными учётной записи; поле нужно demo без БД.
dds_service: str | None = None
group_id: str | None = None
mode: SessionMode
exercise: Exercise = Exercise.CALL
handoff_to_dds: bool = False
criteria: LessonCriteria = Field(default_factory=LessonCriteria)
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 SessionStop(BaseModel):
type: Literal["session.stop"] = "session.stop"
class SessionPause(BaseModel):
"""Остановить время занятия; карточку курсанта команда не меняет (lct-39)."""
type: Literal["session.pause"] = "session.pause"
class SessionResume(BaseModel):
type: Literal["session.resume"] = "session.resume"
InstructorToServer = Annotated[
ScenarioStart
| DirectorInject
| ReferencePlay
| InstructorNoteAdd
| ScoreOverride
| SessionStop
| SessionPause
| SessionResume,
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):
"""Останавливает норматив `dds_ack` (≤ 30 с)."""
type: Literal["card.ack"] = "card.ack"
comment: str = Field(min_length=1, max_length=1000)
class CardBounce(BaseModel):
"""Возврат на уточнение: неполнота КИО становится сорванным выездом."""
type: Literal["card.bounce"] = "card.bounce"
missing_fields: list[str]
comment: str = ""
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 PhoneBrief(BaseModel):
"""Что диспетчер передал старшему группы при первом исходящем звонке."""
type: Literal["phone.brief"] = "phone.brief"
address: str = Field(min_length=3, max_length=300)
incident: str = Field(min_length=3, max_length=500)
request: str = Field(default="", max_length=500)
class PhoneCheck(BaseModel):
"""Запрос диспетчера об обстановке после предыдущего доклада."""
type: Literal["phone.check"] = "phone.check"
text: str = Field(min_length=8, max_length=500)
class PhoneHangup(BaseModel):
type: Literal["phone.hangup"] = "phone.hangup"
class PhoneLine(BaseModel):
type: Literal["phone.line"] = "phone.line"
service: str
crew: str
speaker: Literal["dispatcher", "crew"]
text: str
at: datetime
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
class CardOpen(BaseModel):
"""Открыть одну из уже выданных карточек, не меняя её таймер."""
type: Literal["card.open"] = "card.open"
card_id: UUID
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 | PhoneLine | PhoneReport | TimerTick | SessionEnded
| ScoreReady | CommandAck | SessionPaused | ErrorEvent,
Field(discriminator="type"),
]
StationToServer = Annotated[
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck
| PhoneHangup | StationFinish
| CardReply | CardNext | CardOpen
| ZoneDecision | CrewDispatched | CrewArrived,
Field(discriminator="type"),
]
# ─────────────────────────── отчёт по HTTP ───────────────────────────
class Metric(BaseModel):
"""Факт против норматива со ссылкой; `credit` задаёт частичный вклад времени."""
key: str
title: str
fact: str
norm: str
ref: str | None = None
passed: bool
weight: float = 1.0
credit: float | None = Field(default=None, ge=0, le=1)
class CompetencyScore(BaseModel):
competency: str
value: float
class AIRecommendation(BaseModel):
metric_key: str
text: str = Field(min_length=12, max_length=240)
class AICoaching(BaseModel):
status: Literal["ready", "unavailable", "disabled", "not_needed"]
model: str | None = None
recommendations: list[AIRecommendation] = []
class HintUsage(BaseModel):
checklist_id: str
question: str
at: datetime
class SelfAssessment(BaseModel):
missed: list[str]
comment: str = ""
submitted_at: datetime
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
title: str | None = None
address: str | None = None
description: str | None = None
incident_type: str | None = None
victims_count: int | None = None
received_at: datetime | None = None
managed_service: str | None = None
recipient_services: list[str] = []
class SessionReport(BaseModel):
"""Единица истории: из отчётов складываются профиль, дельта попыток, аналитика."""
session_id: UUID
scenario_id: str
mode: SessionMode
exercise: Exercise = Exercise.CALL
attempt: int = 1
criteria: LessonCriteria
failed_metrics: int
passed: bool
transcript: list[TranscriptEntry]
findings: list[Finding]
metrics: list[Metric]
ai_coaching: AICoaching | None = None
card_results: list[DdsCardReport] = []
competencies: list[CompetencyScore]
reference_questions: list[HintShown]
missed_checklist: list[str]
hints_used: list[HintUsage]
self_assessment: SelfAssessment | None = None
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
#: Суммарная длительность пауз преподавателя — время объяснимо (lct-39).
total_paused_ms: int = 0
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."""
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