Complete DDS training workflow and delivery package
This commit is contained in:
parent
68dd83c7c2
commit
4c4b91064f
229 changed files with 11969 additions and 1024 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
# Место
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue