Complete DDS training workflow and delivery package

This commit is contained in:
andreysk0304 2026-09-24 01:10:49 +03:00
commit 4c4b91064f
229 changed files with 11969 additions and 1024 deletions

View file

@ -15,14 +15,15 @@ from enum import StrEnum
from typing import Annotated, Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
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):
@ -40,6 +41,30 @@ class Exercise(StrEnum):
CARD = "card"
class LessonCriteria(BaseModel):
"""Настраиваемые преподавателем условия именно этого занятия.
Нормативы ГОСТ для приёма вызова сюда не входят. Для занятия меняются
учебный лимит решения, порог успешности и веса метрик; веса сценария
остаются базовыми, а настройки занятия могут их переопределить.
"""
decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
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"
@ -346,6 +371,7 @@ class SessionSnapshot(BaseModel):
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
@ -420,12 +446,20 @@ class ScenarioStart(BaseModel):
type: Literal["scenario.start"] = "scenario.start"
scenario_id: str
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):
@ -527,6 +561,35 @@ 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
@ -551,6 +614,13 @@ class CardNext(BaseModel):
card_id: UUID
class CardOpen(BaseModel):
"""Открыть одну из уже выданных карточек, не меняя её таймер."""
type: Literal["card.open"] = "card.open"
card_id: UUID
class StationState(BaseModel):
"""Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше."""
@ -574,13 +644,14 @@ class CrewArrived(BaseModel):
ServerToStation = Annotated[
CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent,
CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent,
Field(discriminator="type"),
]
StationToServer = Annotated[
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish
| CardReply | CardNext
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck
| PhoneHangup | StationFinish
| CardReply | CardNext | CardOpen
| ZoneDecision | CrewDispatched | CrewArrived,
Field(discriminator="type"),
]
@ -651,6 +722,9 @@ class SessionReport(BaseModel):
scenario_id: str
mode: SessionMode
attempt: int = 1
criteria: LessonCriteria
failed_metrics: int
passed: bool
transcript: list[TranscriptEntry]
findings: list[Finding]
metrics: list[Metric]

View file

@ -90,6 +90,7 @@ class KIO(BaseModel):
caller_number: str | None = None
caller_name: str | None = None
caller_contact: str | None = None
phone_on_scene: str | None = None
language: str = "ru"
# Место

View file

@ -23,7 +23,9 @@ ROLE_LABELS: dict[Role, str] = {
#: настройки, администратор не вмешивается в оценки, обучающийся не видит
#: чужих результатов.
SCREENS: dict[Role, tuple[str, ...]] = {
Role.ADMIN: ("/admin", "/profile"),
Role.INSTRUCTOR: ("/instructor", "/wall", "/profile", "/dds"),
Role.TRAINEE: ("/trainee", "/profile"),
Role.ADMIN: ("/admin", "/profile", "/groups", "/materials"),
Role.INSTRUCTOR: (
"/instructor", "/wall", "/profile", "/dds", "/phone", "/groups", "/materials",
),
Role.TRAINEE: ("/trainee", "/dds", "/phone", "/profile", "/materials"),
}

View file

@ -135,12 +135,46 @@ class PhoneReportRecord(BaseModel):
at: datetime
class PhoneLineRecord(BaseModel):
service: str
crew: str
speaker: str # dispatcher | crew
text: str
at: datetime
class PhoneCallPending(BaseModel):
service: str
crew: str
phase: str
class DdsCardSummary(BaseModel):
card_id: UUID
scenario_id: str
score_auto: float
class DdsQueueCard(BaseModel):
"""Строка общей очереди ДДС; таймер каждой карточки идёт независимо."""
card_id: UUID
scenario_id: str
title: str
address: str | None = None
description: str | None = None
incident_type: str | None = None
victims_count: int | None = None
received_at: datetime
managed_service: str | None = None
service_status: ServiceStatus
card_status: CardStatus
elapsed_ms: int
limit_ms: int
timer_stopped: bool
active: bool
class StatusError(ValueError):
"""Переход запрещён автоматом или нет обязательного комментария."""
@ -208,6 +242,8 @@ class StationSnapshot(BaseModel):
"""
services: list[str]
recipient_services: list[str] = []
managed_service: str | None = None
statuses: dict[str, ServiceStatus]
available: dict[str, list[ServiceStatus]]
card: CardStatus
@ -215,8 +251,14 @@ class StationSnapshot(BaseModel):
crew_options: list[str] = []
crew_selected: str | None = None
phone_reports: list[PhoneReportRecord] = []
phone_lines: list[PhoneLineRecord] = []
phone_pending: PhoneCallPending | None = None
card_id: UUID | None = None
card_index: int = 1
card_total: int = 1
reply_text: str = ""
completed_cards: list[DdsCardSummary] = []
queue_cards: list[DdsQueueCard] = []
pending_cards_count: int = 0
next_arrival_in_seconds: int | None = None
max_waiting_cards: int = 3