Implement local task 09 training workflows

This commit is contained in:
andreysk0304 2026-09-21 17:40:54 +03:00
commit cec84ffcd0
45 changed files with 2679 additions and 366 deletions

View file

@ -34,6 +34,12 @@ class SessionMode(StrEnum):
SELF = "self"
class Exercise(StrEnum):
CALL = "call"
DDS = "dds"
CARD = "card"
class Speaker(StrEnum):
CALLER = "caller"
OPERATOR = "operator"
@ -53,6 +59,7 @@ class CallEndReason(StrEnum):
HANGUP = "hangup"
DROPPED = "dropped"
INSTRUCTOR = "instructor"
COMPLETE = "complete"
class PatchSource(StrEnum):
@ -112,6 +119,17 @@ class CallIncoming(BaseModel):
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
class CallStarted(BaseModel):
type: Literal["call.started"] = "call.started"
started_at: datetime
@ -215,6 +233,7 @@ class ErrorEvent(BaseModel):
ServerToTrainee = Annotated[
CallIncoming
| CardBriefing
| CallStarted
| SttPartial
| SttFinal
@ -244,6 +263,10 @@ class CallAnswer(BaseModel):
type: Literal["call.answer"] = "call.answer"
class CardSubmit(BaseModel):
type: Literal["card.submit"] = "card.submit"
class KioPatchIn(BaseModel):
"""Правка карточки. Дебаунс 300 мс, шлётся только дельта."""
@ -266,10 +289,10 @@ class SelfAssessmentSubmit(BaseModel):
class DdsDispatch(BaseModel):
"""Передача в ДДС. Замораживает карточку снимком и останавливает опрос (`interview`)."""
"""Передача в ДДС. При маршрутизации по ЕКП служба уже есть в notify."""
type: Literal["dds.dispatch"] = "dds.dispatch"
service: DDSCode
service: DDSCode | None = None
class CallHangup(BaseModel):
@ -297,6 +320,7 @@ class CallResolve(BaseModel):
TraineeToServer = Annotated[
CallAnswer
| CardSubmit
| KioPatchIn
| HintRequest
| SelfAssessmentSubmit
@ -320,6 +344,7 @@ class SessionSnapshot(BaseModel):
scenario_title: str
level: Level
mode: SessionMode
exercise: Exercise = Exercise.CALL
trainee_name: str | None = None
started_at: datetime | None = None
kio: KIO
@ -394,8 +419,10 @@ class ScenarioStart(BaseModel):
type: Literal["scenario.start"] = "scenario.start"
scenario_id: str
trainee: str
trainee_id: UUID | None = None
group_id: str | None = None
mode: SessionMode
exercise: Exercise = Exercise.CALL
class DirectorInject(BaseModel):
@ -486,6 +513,28 @@ class ServiceStatusSet(BaseModel):
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 StationState(BaseModel):
"""Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше."""
@ -509,12 +558,13 @@ class CrewArrived(BaseModel):
ServerToStation = Annotated[
CardReceived | StationState | TimerTick | SessionEnded | ErrorEvent,
CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent,
Field(discriminator="type"),
]
StationToServer = Annotated[
CardAck | CardBounce | ServiceStatusSet | ZoneDecision | CrewDispatched | CrewArrived,
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish
| ZoneDecision | CrewDispatched | CrewArrived,
Field(discriminator="type"),
]

View file

@ -193,10 +193,10 @@ def derive_incident(card: KIO) -> KIO:
keys = modifiers(card)
data = card.model_dump()
data["incident_code"] = found.code if found else None
computed = ekp.notify_list(found.code, keys) if found else []
# Добавленную вручную службу не теряем: удалить её оператор не может.
manual = [name for name in card.notify if name not in ekp.notify_list(data["incident_code"] or "", keys)]
data["notify"] = computed + manual
# `notify` недоступен оператору для записи (READ_ONLY_FIELDS), поэтому
# хранить прошлый авторасчёт как «добавленные вручную службы» нельзя:
# при снятии признака газа МОСГАЗ должен исчезнуть из нового расчёта.
data["notify"] = ekp.notify_list(found.code, keys) if found else []
return KIO.model_validate(data)

View file

@ -126,6 +126,14 @@ class StatusEntry(BaseModel):
author: str = ""
class PhoneReportRecord(BaseModel):
service: str
crew: str
phase: str
text: str
at: datetime
class StatusError(ValueError):
"""Переход запрещён автоматом или нет обязательного комментария."""
@ -168,7 +176,8 @@ def card_status(
latest = {service: current(entries, service) for service in services}
silent = [
service for service, status in latest.items() if status not in PRIMARY | TERMINAL
service for service, status in latest.items()
if status in {ServiceStatus.ADDED, ServiceStatus.RECEIVED}
]
if silent and notify_deadline_passed:
return CardStatus.NOT_NOTIFIED
@ -196,3 +205,6 @@ class StationSnapshot(BaseModel):
available: dict[str, list[ServiceStatus]]
card: CardStatus
log: list[StatusEntry]
crew_options: list[str] = []
crew_selected: str | None = None
phone_reports: list[PhoneReportRecord] = []