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 чистый.
This commit is contained in:
Ivan Gerasimov 2026-09-20 08:23:55 +03:00
commit d4f0d0a0f8
14 changed files with 953 additions and 9 deletions

0
1.md Normal file
View file

View file

@ -13,6 +13,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.domain.events import (
StationState,
CallEnded,
CallEndReason,
CallStarted,
@ -123,6 +124,9 @@ async def _handle(session_id: UUID, state, event) -> None:
# Карточка замораживается снимком и уходит диспетчеру: оператор
# не должен иметь возможности дописать поле задним числом.
hub.to_station(session_id, state.card_received_event())
# Список оповещения диспетчер должен видеть сразу, а не после
# первого своего действия: отмечаться ему по нему же (lct-33).
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
hub.to_observers(session_id, KioState(kio=state.kio))
hub.to_observers(session_id, TimerTick(timers=state.timers.snapshot()))

View file

@ -15,7 +15,8 @@ from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.domain.events import ErrorEvent, ErrorKind, StationToServer
from app.domain.events import ErrorEvent, ErrorKind, StationState, StationToServer
from app.domain.statuses import PRIMARY, ServiceStatus, StatusError
from app.session.hub import hub
from app.session.state import now_utc
@ -28,8 +29,31 @@ _adapter = TypeAdapter(StationToServer)
async def _handle(session_id: UUID, state, event) -> None:
match event.type:
case "card.ack":
# Подтверждение приёма — это статус «Принята» у главной службы.
# Кнопка осталась ради живой цепочки 112 → ДДС (lct-20), где
# диспетчер один и выбирать службу не из чего.
state.on_event("card.ack")
state.dds_log.append(("card.ack", now_utc(), None))
services = state.notified_services()
if services:
try:
state.set_service_status(services[0], ServiceStatus.ACCEPTED)
except StatusError:
pass # статус уже стоит: повторное нажатие ничего не меняет
case "card.status":
try:
state.set_service_status(
event.service, event.status, event.comment, author="диспетчер"
)
except StatusError as exc:
hub.to_station(
session_id,
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=str(exc)),
)
return
# Первичный статус останавливает норматив 30 секунд.
if event.status in PRIMARY:
state.on_event("card.ack")
case "card.bounce":
# Карточка вернулась: в разборе это E6 с конкретной причиной.
state.bounced_fields = list(event.missing_fields)
@ -44,6 +68,7 @@ async def _handle(session_id: UUID, state, event) -> None:
state.on_event("crew.arrived")
state.kio = state.kio.model_copy(update={"arrival_at": event.at})
state.dds_log.append(("crew.arrived", now_utc(), None))
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
hub.to_observers(session_id, state.snapshot())
@ -72,6 +97,7 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
# диспетчер садится за АРМ, когда вызов уже идёт.
if state.dispatched_card is not None:
hub.to_station(session_id, state.card_received_event())
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
while True:
payload = await ws.receive_json()
try:

View file

@ -19,6 +19,7 @@ from pydantic import BaseModel, Field
from app.domain.classifiers import DDSCode, IncidentType, Level
from app.domain.kio import KIO, Coords
from app.domain.statuses import ServiceStatus, StationSnapshot
from app.domain.taxonomy import Finding
from app.domain.timers import TimerSnapshot
@ -453,6 +454,27 @@ class CardBounce(BaseModel):
comment: str = ""
class ServiceStatusSet(BaseModel):
"""Диспетчер ставит статус реагирования своей службе.
Последовательность жёсткая, комментарий к отказу обязателен — это не
валидация формы, а то, чему учит второй режим занятия
(docs/spec/DATASET.md#статусы-реагирования).
"""
type: Literal["card.status"] = "card.status"
service: str
status: ServiceStatus
comment: str = ""
class StationState(BaseModel):
"""Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше."""
type: Literal["station.state"] = "station.state"
snapshot: StationSnapshot
class ZoneDecision(BaseModel):
type: Literal["zone.decision"] = "zone.decision"
in_zone: bool
@ -469,12 +491,12 @@ class CrewArrived(BaseModel):
ServerToStation = Annotated[
CardReceived | TimerTick | SessionEnded | ErrorEvent,
CardReceived | StationState | TimerTick | SessionEnded | ErrorEvent,
Field(discriminator="type"),
]
StationToServer = Annotated[
CardAck | CardBounce | ZoneDecision | CrewDispatched | CrewArrived,
CardAck | CardBounce | ServiceStatusSet | ZoneDecision | CrewDispatched | CrewArrived,
Field(discriminator="type"),
]

View file

@ -0,0 +1,198 @@
"""Статусы реагирования служб и статусы карточки.
Взято из памятки ГБУ «Система 112» для дежурно-диспетчерских служб
(docs/spec/DATASET.md#статусы-реагирования). Это не наша модель работы, а её
модель: формулировки, порядок и обязательность комментариев — от заказчика,
и менять их по своему вкусу нельзя, иначе курсант выучит не то.
Главное свойство: статусы выбираются **последовательно**. Сначала доступны
только «Принята» и «Не принята»; всё остальное открывается после «Принята».
"""
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel
class ServiceStatus(StrEnum):
"""Статус реагирования одной службы из списка оповещения."""
ADDED = "added" # технический: карточка сохранена с оповещением
RECEIVED = "received" # технический: карточка открыта на АРМ
ACCEPTED = "accepted" # Принята
DECLINED = "declined" # Не принята
RESPONDING = "responding" # Начало реагирования
ARRIVED = "arrived" # Прибытие
WORKING = "working" # Проведение работ
COMPLETED = "completed" # Работы завершены
REFUSED = "refused" # Отказ от выполнения работ
SERVICE_STATUS_LABELS: dict[ServiceStatus, str] = {
ServiceStatus.ADDED: "Добавлена",
ServiceStatus.RECEIVED: "Получена службой",
ServiceStatus.ACCEPTED: "Принята",
ServiceStatus.DECLINED: "Не принята",
ServiceStatus.RESPONDING: "Начало реагирования",
ServiceStatus.ARRIVED: "Прибытие",
ServiceStatus.WORKING: "Проведение работ",
ServiceStatus.COMPLETED: "Работы завершены",
ServiceStatus.REFUSED: "Отказ от выполнения работ",
}
#: Автомат переходов. «Не принята» ведёт только к «Принята» — служба может
#: передумать, но не может отказаться дважды по-разному.
NEXT: dict[ServiceStatus, tuple[ServiceStatus, ...]] = {
ServiceStatus.ADDED: (ServiceStatus.ACCEPTED, ServiceStatus.DECLINED),
ServiceStatus.RECEIVED: (ServiceStatus.ACCEPTED, ServiceStatus.DECLINED),
ServiceStatus.DECLINED: (ServiceStatus.ACCEPTED,),
ServiceStatus.ACCEPTED: (
ServiceStatus.RESPONDING,
ServiceStatus.ARRIVED,
ServiceStatus.WORKING,
ServiceStatus.COMPLETED,
ServiceStatus.REFUSED,
),
ServiceStatus.RESPONDING: (
ServiceStatus.ARRIVED,
ServiceStatus.WORKING,
ServiceStatus.COMPLETED,
ServiceStatus.REFUSED,
),
ServiceStatus.ARRIVED: (
ServiceStatus.WORKING,
ServiceStatus.COMPLETED,
ServiceStatus.REFUSED,
),
ServiceStatus.WORKING: (ServiceStatus.COMPLETED, ServiceStatus.REFUSED),
# Закрывают карточку от правки: дальше только звонок в отдел контроля.
ServiceStatus.COMPLETED: (),
ServiceStatus.REFUSED: (),
}
#: Без комментария не сохраняются. Это не валидация формы, а предмет обучения:
#: половина нарушений в памятке — отказ без указания, куда передана информация.
COMMENT_REQUIRED: frozenset[ServiceStatus] = frozenset(
{ServiceStatus.DECLINED, ServiceStatus.REFUSED}
)
#: Первичные статусы: их ждут в норматив 30 секунд.
PRIMARY: frozenset[ServiceStatus] = frozenset(
{ServiceStatus.ACCEPTED, ServiceStatus.DECLINED}
)
TERMINAL: frozenset[ServiceStatus] = frozenset(
{ServiceStatus.COMPLETED, ServiceStatus.REFUSED}
)
class CardStatus(StrEnum):
"""Статус карточки целиком — считается из статусов служб, руками не ставится."""
REGISTERED = "registered" # Зарегистрирована
PROCESSED = "processed" # Отработана: оповещение завершено
CHECKED = "checked" # Проверена главным специалистом
NOT_NOTIFIED = "not_notified" # Не оповещено: служба промолчала в норматив
REFUSED = "refused" # Отказ
NOT_COMPLETED = "not_completed" # Не завершено: 48 часов без завершения работ
COMPLETED = "completed" # Завершена
CARD_STATUS_LABELS: dict[CardStatus, str] = {
CardStatus.REGISTERED: "Зарегистрирована",
CardStatus.PROCESSED: "Отработана",
CardStatus.CHECKED: "Проверена",
CardStatus.NOT_NOTIFIED: "Не оповещено",
CardStatus.REFUSED: "Отказ",
CardStatus.NOT_COMPLETED: "Не завершено",
CardStatus.COMPLETED: "Завершена",
}
#: Красные в интерфейсе и в разборе: именно они попадают в раздел отдела
#: контроля реагирования. Цвет несёт смысл только здесь (docs/arch/FRONTEND.md).
ALARMING: frozenset[CardStatus] = frozenset(
{CardStatus.NOT_NOTIFIED, CardStatus.REFUSED, CardStatus.NOT_COMPLETED}
)
class StatusEntry(BaseModel):
"""Одна отметка службы. Комментарий — часть отметки, а не примечание к ней."""
service: str
status: ServiceStatus
at: datetime
comment: str = ""
author: str = ""
class StatusError(ValueError):
"""Переход запрещён автоматом или нет обязательного комментария."""
def current(entries: list[StatusEntry], service: str) -> ServiceStatus:
"""Последний статус службы. Без отметок — «Добавлена»."""
for entry in reversed(entries):
if entry.service == service:
return entry.status
return ServiceStatus.ADDED
def check(entries: list[StatusEntry], service: str, status: ServiceStatus, comment: str) -> None:
"""Проверить отметку до записи. Бросает `StatusError` с текстом для курсанта."""
now = current(entries, service)
if status not in NEXT[now]:
allowed = ", ".join(SERVICE_STATUS_LABELS[item] for item in NEXT[now]) or "ничего"
raise StatusError(
f"после «{SERVICE_STATUS_LABELS[now]}» доступно: {allowed}"
+ ("" if NEXT[now] else " — карточка закрыта для редактирования")
)
if status in COMMENT_REQUIRED and not comment.strip():
raise StatusError(
f"«{SERVICE_STATUS_LABELS[status]}» требует комментария: причина и то, "
"куда передана информация"
)
def card_status(
entries: list[StatusEntry], services: list[str], notify_deadline_passed: bool = False
) -> CardStatus:
"""Статус карточки по статусам служб.
Упрощение против боевой системы, и оно намеренное: статусы «Проверена»
и «Не завершено» требуют главного специалиста и 48 часов — ни того, ни
другого в занятии на полтора часа нет. Остальные четыре считаются честно.
"""
if not services:
return CardStatus.COMPLETED
latest = {service: current(entries, service) for service in services}
silent = [
service for service, status in latest.items() if status not in PRIMARY | TERMINAL
]
if silent and notify_deadline_passed:
return CardStatus.NOT_NOTIFIED
if any(
status in {ServiceStatus.DECLINED, ServiceStatus.REFUSED} for status in latest.values()
):
return CardStatus.REFUSED
if all(status is ServiceStatus.COMPLETED for status in latest.values()):
return CardStatus.COMPLETED
if silent:
return CardStatus.REGISTERED
return CardStatus.PROCESSED
class StationSnapshot(BaseModel):
"""Что видит диспетчер: службы, их статусы и что доступно дальше.
Все поля обязательные, хотя списки и могут быть пустыми: снимок всегда
собирается целиком, а необязательное поле уезжает в TypeScript как
`| undefined` и заставляет фронт проверять то, чего не бывает.
"""
services: list[str]
statuses: dict[str, ServiceStatus]
available: dict[str, list[ServiceStatus]]
card: CardStatus
log: list[StatusEntry]

View file

@ -12,18 +12,32 @@ from pydantic import BaseModel
class ErrorCode(StrEnum):
"""E — ошибки приёма вызова, D — ошибки диспетчера ДДС.
Две роли, две таксономии. D1–D6 взяты из памятки заказчика дословно:
она перечисляет типовые нарушения с реальными примерами, и преподаватель
узнаёт их формулировки (docs/spec/DATASET.md).
"""
E1 = "E1"
E2 = "E2"
E3 = "E3"
E4 = "E4"
E5 = "E5"
E6 = "E6"
D1 = "D1"
D2 = "D2"
D3 = "D3"
D4 = "D4"
D5 = "D5"
D6 = "D6"
class FindingSource(StrEnum):
"""Кто выставил отметку. `judge` — единственный недетерминированный источник."""
SLOTS = "slots"
DISPATCHER = "dispatcher"
GROUND_TRUTH = "ground_truth"
TIMERS = "timers"
KIO = "kio"
@ -76,6 +90,42 @@ ERRORS: dict[ErrorCode, ErrorSpec] = {
detail="Карточка ушла в ДДС непригодной, снятие с контроля до подтверждения",
source=FindingSource.CHAIN,
),
ErrorCode.D1: ErrorSpec(
code=ErrorCode.D1,
title="Статус реагирования не проставлен",
detail="Карточка осталась без первичного статуса и ушла в «Не оповещено»",
source=FindingSource.DISPATCHER,
),
ErrorCode.D2: ErrorSpec(
code=ErrorCode.D2,
title="Статус не соответствует факту",
detail="«Принята» там, где работы проводиться не будут, и наоборот",
source=FindingSource.DISPATCHER,
),
ErrorCode.D3: ErrorSpec(
code=ErrorCode.D3,
title="Отказ от профильного происшествия",
detail="Служба есть в списке оповещения, значит происшествие её — отказ неправомерен",
source=FindingSource.DISPATCHER,
),
ErrorCode.D4: ErrorSpec(
code=ErrorCode.D4,
title="Нет комментария к отказу",
detail="«Не принята» и «Отказ от выполнения работ» без причины",
source=FindingSource.DISPATCHER,
),
ErrorCode.D5: ErrorSpec(
code=ErrorCode.D5,
title="Неполный комментарий",
detail="Не указано, куда передана информация и что сделал диспетчер",
source=FindingSource.JUDGE,
),
ErrorCode.D6: ErrorSpec(
code=ErrorCode.D6,
title="Нет статусов хода работ",
detail="Реагирование шло, но начало, прибытие и завершение не отмечены",
source=FindingSource.DISPATCHER,
),
}

View file

@ -0,0 +1,125 @@
"""Оценка работы диспетчера ДДС: статусы реагирования.
Вторая роль — второй набор ошибок. D1–D6 взяты из памятки заказчика, где они
перечислены поимённо и с реальными примерами (docs/spec/DATASET.md). Формулировки
не переписаны: преподаватель, который эту памятку читал, должен узнать их в разборе.
Детерминированно считаются D1, D2, D3, D4 и D6. D5 — полнота комментария — мягкий
критерий, его место у судьи (lct-13): «не принята: не обслуживаем» без указания,
куда передана информация, формально неотличимо от полного комментария.
"""
from app.domain.statuses import (
COMMENT_REQUIRED,
PRIMARY,
SERVICE_STATUS_LABELS,
ServiceStatus,
StatusEntry,
current,
)
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
#: Статусы, означающие, что реагирование действительно шло.
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
def _finding(code: ErrorCode, summary: str, fact: str, norm: str | None = None) -> Finding:
return Finding(
code=code,
source=FindingSource.DISPATCHER,
summary=summary,
fact=fact,
norm=norm,
ref="памятка «Работа на АРМ-112», раздел «Статусы реагирования»",
competency=Competency.CARD,
)
def evaluate_dispatcher(
*,
entries: list[StatusEntry],
services: list[str],
deadline_ms: int,
elapsed_ms: int | None,
) -> list[Finding]:
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
`services` — список оповещения карточки. Он же ответ на вопрос, профильно ли
происшествие: служба в списке — значит происшествие её, и отказ «не наша
компетенция» неправомерен.
"""
findings: list[Finding] = []
if not services:
return findings
for service in services:
marks = [entry for entry in entries if entry.service == service]
latest = current(entries, service)
# D1 — первичного статуса нет вовсе.
if not any(mark.status in PRIMARY for mark in marks):
findings.append(
_finding(
ErrorCode.D1,
f"{service}: статус реагирования не проставлен",
fact=(
f"прошло {elapsed_ms // 1000} с"
if elapsed_ms is not None
else "карточка осталась без отметки"
),
norm=f"первичный статус в течение {deadline_ms // 1000} с",
)
)
continue
for mark in marks:
# D4 — отказ без комментария.
if mark.status in COMMENT_REQUIRED and not mark.comment.strip():
findings.append(
_finding(
ErrorCode.D4,
f"{service}: «{SERVICE_STATUS_LABELS[mark.status]}» без комментария",
fact="причина отказа не указана",
norm="комментарий к отказу обязателен",
)
)
# D3 — отказ от профильного происшествия. Служба в списке оповещения
# по классификатору, значит происшествие входит в её компетенцию.
if latest is ServiceStatus.DECLINED:
findings.append(
_finding(
ErrorCode.D3,
f"{service}: отказ от происшествия, которое в её компетенции",
fact="служба есть в списке оповещения по ЕКП",
norm="отказываться от профильного происшествия нельзя",
)
)
# D2 — «Принята», но работ не было и отказа тоже: статус не отражает факт.
if latest is ServiceStatus.ACCEPTED:
findings.append(
_finding(
ErrorCode.D2,
f"{service}: «Принята», но о реагировании ничего не отмечено",
fact="после приёма статусов не было",
norm="статус должен соответствовать фактическому состоянию заявки",
)
)
# D6 — работы завершены, а ход работ не отмечен. В памятке это отдельный
# разбор: по такому происшествию идут повторные звонки, и другие службы
# не видят, что реагирование вообще началось.
if latest is ServiceStatus.COMPLETED and not any(
mark.status in PROGRESS for mark in marks
):
findings.append(
_finding(
ErrorCode.D6,
f"{service}: работы завершены без отметок хода",
fact="начало реагирования и прибытие не отмечены",
norm="статусы хода работ проставляются по факту",
)
)
return findings

View file

@ -11,9 +11,10 @@ import logging
from uuid import UUID
from app.domain.events import ScoreReady
from app.domain.timers import TimerCode
from app.domain.timers import NORMATIVES, TimerCode
from app.scenarios import store
from app.scoring.competency import radar
from app.scoring.dispatcher import evaluate_dispatcher
from app.scoring.gost import evaluate
from app.session.hub import hub
@ -35,6 +36,17 @@ async def finish(session_id: UUID, state) -> None:
end_reason=state.end_reason,
bounced_fields=state.bounced_fields,
)
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
# участвуют оба (docs/spec/DATASET.md#статусы-реагирования).
dispatcher_findings = evaluate_dispatcher(
entries=state.status_log,
services=state.notified_services(),
deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms,
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
)
result.findings.extend(dispatcher_findings)
# Сводка числами: по ней считается дельта между попытками в профиле.
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
required = scenario.ground_truth.required_facts
@ -56,6 +68,8 @@ async def finish(session_id: UUID, state) -> None:
"findings": [finding.model_dump(mode="json") for finding in result.findings],
"competencies": [item.model_dump() for item in radar(result.metrics)],
"unavailable": result.unavailable,
# Статус карточки — готовая красная метка, понятная любому диспетчеру.
"card_status": state.station_snapshot().card.value,
}
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))

View file

@ -22,6 +22,16 @@ from app.dialog.caller import TemplateCaller
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine
from app.scenarios.schema import Scenario
from app.domain.statuses import (
NEXT,
ServiceStatus,
StationSnapshot,
StatusEntry,
card_status,
check,
current,
)
from app.domain.timers import NORMATIVES, TimerCode
from app.domain.kio import KIO, apply_patch
from app.session.timers import SessionTimers
@ -81,6 +91,8 @@ class SessionState:
#: Поля, из-за которых диспетчер вернул карточку, — основание E6.
bounced_fields: list[str] = field(default_factory=list)
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
#: Отметки служб по списку оповещения — предмет второго режима занятия.
status_log: list[StatusEntry] = field(default_factory=list)
def on_event(self, event_type: str) -> None:
"""Единственная точка, где событие двигает таймеры."""
@ -109,6 +121,42 @@ class SessionState:
self.dispatched_at = now_utc()
return self.dispatched_card
def notified_services(self) -> list[str]:
"""Список оповещения переданной карточки. До передачи — пусто:
отмечаться диспетчеру пока не по чему."""
card = self.dispatched_card or self.kio
return list(card.notify)
def set_service_status(
self, service: str, status: ServiceStatus, comment: str = "", author: str = ""
) -> StatusEntry:
"""Отметка службы. Проверка перехода — до записи: недопустимый статус
не должен попасть в журнал даже отвергнутым."""
check(self.status_log, service, status, comment)
entry = StatusEntry(
service=service, status=status, at=now_utc(), comment=comment, author=author
)
self.status_log.append(entry)
return entry
def station_snapshot(self) -> StationSnapshot:
"""Что видит диспетчер: службы, их статусы и доступные переходы."""
services = self.notified_services()
statuses = {service: current(self.status_log, service) for service in services}
elapsed = self.timers.measured_ms(TimerCode.DDS_ACK)
limit = NORMATIVES[TimerCode.DDS_ACK].limit_ms
overdue = self.dispatched_at is not None and (
elapsed is None
and (now_utc() - self.dispatched_at).total_seconds() * 1000 > limit
)
return StationSnapshot(
services=services,
statuses=statuses,
available={service: list(NEXT[status]) for service, status in statuses.items()},
card=card_status(self.status_log, services, notify_deadline_passed=overdue),
log=list(self.status_log),
)
def card_received_event(self):
"""Снимок карточки для станции ДДС."""
from app.domain.events import CardReceived

View file

@ -124,3 +124,91 @@ def test_bounced_card_becomes_e6_with_the_reason(client):
assert "floor" in chain[0]["summary"] and "выезд сорван" in chain[0]["summary"]
finally:
control.__exit__(None, None, None)
# ── статусы реагирования (lct-33) ──
def dispatched(client):
"""Занятие, доведённое до передачи карточки в ДДС, и подключённая станция."""
session_id, control, _ = lesson(client)
station_ctx = client.websocket_connect(f"/ws/station/{session_id}?role=dds_01")
station = station_ctx.__enter__()
call_ctx = client.websocket_connect(f"/ws/call/{session_id}")
trainee = call_ctx.__enter__()
trainee.send_json({"type": "call.answer"})
trainee.send_json(
{"type": "kio.patch", "fields": {"signs": ["жилой дом", "балкон", "открытое пламя"]}}
)
wait_for(lambda: hub.get(session_id).kio.incident_code)
trainee.send_json({"type": "dds.dispatch", "service": "01"})
read_until(station, "card.received")
return session_id, station, (control, station_ctx, call_ctx)
def close_all(contexts):
for context in contexts:
context.__exit__(None, None, None)
def test_station_sees_the_notify_list_from_the_classifier(client):
"""Диспетчеру есть что отмечать только потому, что список оповещения
посчитан по ЕКП, а не выбран оператором (lct-32)."""
session_id, station, contexts = dispatched(client)
try:
state = read_until(station, "station.state")
assert "Служба 101" in state["snapshot"]["services"]
assert len(state["snapshot"]["services"]) > 1
assert state["snapshot"]["statuses"]["Служба 101"] == "added"
finally:
close_all(contexts)
def test_dispatcher_sets_a_status_and_the_card_follows(client):
session_id, station, contexts = dispatched(client)
try:
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": "Служба 101", "status": "accepted"})
state = read_until(station, "station.state")
assert state["snapshot"]["statuses"]["Служба 101"] == "accepted"
assert "responding" in state["snapshot"]["available"]["Служба 101"]
assert "accepted" not in state["snapshot"]["available"]["Служба 101"]
finally:
close_all(contexts)
def test_out_of_order_status_is_rejected_with_a_reason(client):
"""Автомат живёт на сервере: фронт может ошибиться, состояние — нет."""
session_id, station, contexts = dispatched(client)
try:
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": "Служба 101", "status": "arrived"})
error = read_until(station, "error")
assert "Принята" in error["message"]
assert hub.get(session_id).status_log == []
finally:
close_all(contexts)
def test_refusal_without_a_comment_is_rejected(client):
session_id, station, contexts = dispatched(client)
try:
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": "Служба 101", "status": "declined"})
error = read_until(station, "error")
assert "комментар" in error["message"]
finally:
close_all(contexts)
def test_ack_button_still_works_and_counts_as_accepted(client):
"""Кнопка подтверждения из живой цепочки (lct-20) осталась и означает
«Принята» у главной службы — иначе два экрана учили бы разному."""
session_id, station, contexts = dispatched(client)
try:
read_until(station, "station.state")
station.send_json({"type": "card.ack"})
state = read_until(station, "station.state")
assert state["snapshot"]["statuses"]["Служба 101"] == "accepted"
finally:
close_all(contexts)

View file

@ -0,0 +1,175 @@
"""Статусы реагирования на АРМ ДДС.
Пункты приёмки lct-33: автомат переходов, обязательный комментарий,
необратимость завершения, статус карточки и ошибки диспетчера D1–D6.
"""
from datetime import datetime, timedelta, timezone
import pytest
from app.domain.statuses import (
CardStatus,
ServiceStatus,
StatusEntry,
StatusError,
card_status,
check,
current,
)
from app.domain.taxonomy import ErrorCode
from app.scoring.dispatcher import evaluate_dispatcher
SERVICES = ["Служба 101", "МВД"]
NOW = datetime(2026, 9, 19, 12, 0, tzinfo=timezone.utc)
def mark(service: str, status: ServiceStatus, comment: str = "", minute: int = 0) -> StatusEntry:
return StatusEntry(
service=service, status=status, at=NOW + timedelta(minutes=minute), comment=comment
)
# ── автомат переходов ──
def test_only_primary_statuses_are_available_at_first():
check([], "Служба 101", ServiceStatus.ACCEPTED, "")
check([], "Служба 101", ServiceStatus.DECLINED, "не обслуживаем, передано в УК")
with pytest.raises(StatusError):
check([], "Служба 101", ServiceStatus.ARRIVED, "")
def test_accepted_opens_the_rest():
log = [mark("Служба 101", ServiceStatus.ACCEPTED)]
check(log, "Служба 101", ServiceStatus.RESPONDING, "")
check(log, "Служба 101", ServiceStatus.COMPLETED, "")
def test_declined_leads_only_back_to_accepted():
"""Служба может передумать, но не может отказаться дважды по-разному."""
log = [mark("Служба 101", ServiceStatus.DECLINED, "не наш адрес")]
check(log, "Служба 101", ServiceStatus.ACCEPTED, "")
with pytest.raises(StatusError):
check(log, "Служба 101", ServiceStatus.RESPONDING, "")
def test_completed_closes_the_card_for_editing():
log = [
mark("Служба 101", ServiceStatus.ACCEPTED),
mark("Служба 101", ServiceStatus.COMPLETED, minute=5),
]
with pytest.raises(StatusError) as exc:
check(log, "Служба 101", ServiceStatus.WORKING, "")
assert "закрыта" in str(exc.value)
def test_refusal_without_a_comment_is_rejected():
"""Не валидация формы, а предмет обучения: половина нарушений в памятке —
отказ без указания, куда передана информация."""
with pytest.raises(StatusError) as exc:
check([], "МВД", ServiceStatus.DECLINED, " ")
assert "комментар" in str(exc.value)
def test_statuses_are_tracked_per_service():
log = [mark("Служба 101", ServiceStatus.ACCEPTED)]
assert current(log, "Служба 101") is ServiceStatus.ACCEPTED
assert current(log, "МВД") is ServiceStatus.ADDED
# ── статус карточки ──
def test_silence_past_the_deadline_is_not_notified():
assert card_status([], SERVICES, notify_deadline_passed=True) is CardStatus.NOT_NOTIFIED
def test_silence_within_the_deadline_is_still_registered():
assert card_status([], SERVICES, notify_deadline_passed=False) is CardStatus.REGISTERED
def test_any_refusal_marks_the_whole_card():
log = [
mark("Служба 101", ServiceStatus.ACCEPTED),
mark("МВД", ServiceStatus.DECLINED, "не наша территория, передано в ОМВД"),
]
assert card_status(log, SERVICES) is CardStatus.REFUSED
def test_all_completed_closes_the_card():
log = []
for service in SERVICES:
log += [mark(service, ServiceStatus.ACCEPTED), mark(service, ServiceStatus.COMPLETED, minute=9)]
assert card_status(log, SERVICES) is CardStatus.COMPLETED
def test_alarming_statuses_are_the_three_from_the_memo():
from app.domain.statuses import ALARMING
assert ALARMING == {CardStatus.NOT_NOTIFIED, CardStatus.REFUSED, CardStatus.NOT_COMPLETED}
# ── ошибки диспетчера ──
def _codes(entries, services=SERVICES, elapsed=45_000):
findings = evaluate_dispatcher(
entries=entries, services=services, deadline_ms=30_000, elapsed_ms=elapsed
)
return [finding.code for finding in findings]
def test_no_status_is_d1():
assert ErrorCode.D1 in _codes([])
def test_refusal_without_comment_is_d4():
"""Автомат такую отметку не пропустит, но карточка может прийти из внешней
системы уже с пустым комментарием — разбор обязан это показать."""
log = [mark(service, ServiceStatus.DECLINED) for service in SERVICES]
assert ErrorCode.D4 in _codes(log)
def test_declining_a_profile_incident_is_d3():
log = [
mark("Служба 101", ServiceStatus.ACCEPTED),
mark("МВД", ServiceStatus.DECLINED, "не в нашей компетенции"),
]
codes = _codes(log)
assert ErrorCode.D3 in codes, "служба в списке оповещения — происшествие профильное"
def test_accepted_and_forgotten_is_d2():
log = [mark(service, ServiceStatus.ACCEPTED) for service in SERVICES]
assert ErrorCode.D2 in _codes(log)
def test_completed_without_progress_is_d6():
log = []
for service in SERVICES:
log += [mark(service, ServiceStatus.ACCEPTED), mark(service, ServiceStatus.COMPLETED, minute=9)]
assert ErrorCode.D6 in _codes(log)
def test_clean_work_has_no_findings():
log = []
for service in SERVICES:
log += [
mark(service, ServiceStatus.ACCEPTED),
mark(service, ServiceStatus.RESPONDING, minute=1),
mark(service, ServiceStatus.ARRIVED, minute=4),
mark(service, ServiceStatus.WORKING, minute=5),
mark(service, ServiceStatus.COMPLETED, minute=20),
]
assert _codes(log) == []
def test_every_finding_carries_its_reason():
"""Правило проекта: ни одной отметки без обоснования."""
findings = evaluate_dispatcher(
entries=[], services=SERVICES, deadline_ms=30_000, elapsed_ms=45_000
)
assert findings
for finding in findings:
assert finding.fact and finding.ref

View file

@ -11,9 +11,11 @@ import { GROUPS } from "@/features/kio-card/fields";
import { KioCard } from "@/features/kio-card/KioCard";
import { applyState, empty as emptyCard, type CardState } from "@/features/kio-card/merge";
import { stationChannel, type ChannelStatus } from "@/shared/api/ws";
import type { ServiceStatus, StationSnapshot } from "@/shared/types/generated";
import { SERVICE_STATUS_LABELS, CARD_STATUS_LABELS, ALARMING } from "./statuses";
import { sessionIdFromUrl } from "@/shared/api/session";
const ACK_LIMIT_S = 4; // норматив подтверждения получения карточки
const ACK_LIMIT_S = 30; // норматив подтверждения, ПП РФ № 1931 и памятка АРМ-112
const FIELDS = GROUPS.flatMap((group) =>
group.fields.filter((field) => !field.readOnly).map((field) => ({ path: field.path, label: field.label })),
@ -34,6 +36,7 @@ export function Dds() {
const [comment, setComment] = useState("");
const [zone, setZone] = useState<boolean | null>(null);
const [marks, setMarks] = useState<string[]>([]);
const [station, setStation] = useState<StationSnapshot | null>(null);
const channel = useRef<ReturnType<typeof stationChannel> | null>(null);
useEffect(() => {
@ -41,6 +44,8 @@ export function Dds() {
const ch = stationChannel(sessionId, role, {
onStatus: setStatus,
onEvent: (event) => {
if (event.type === "station.state") setStation(event.snapshot);
if (event.type === "error") mark(`отклонено: ${event.message}`);
if (event.type === "card.received") {
setCard(applyState(emptyCard, event.card as unknown as Record<string, unknown>));
setFrom(event.from_operator);
@ -146,12 +151,62 @@ export function Dds() {
}}>Прибытие</button>
</p>
{station && station.services.length > 0 && (
<>
<h2>Статусы реагирования</h2>
<p>
Статус карточки:{" "}
<b className={ALARMING.includes(station.card) ? "state-violated" : "state-ok"}>
{CARD_STATUS_LABELS[station.card]}
</b>
</p>
<table className="grid">
<thead>
<tr><th>Служба</th><th>Статус</th><th>Отметить</th></tr>
</thead>
<tbody>
{station.services.map((service) => (
<StatusRow
key={service}
service={service}
current={station.statuses[service]}
available={station.available[service] ?? []}
onSet={(status, comment) => {
channel.current?.send({ type: "card.status", service, status, comment });
mark(`${service}: ${SERVICE_STATUS_LABELS[status]}`);
}}
/>
))}
</tbody>
</table>
</>
)}
<h2>Карточка от оператора 112</h2>
{/* Снимок: после передачи он не меняется. */}
<KioCard state={card} readOnly />
</>
)}
{station && station.log.length > 0 && (
<>
<h2>Журнал отметок</h2>
<table className="grid">
<tbody>
{station.log.map((entry, index) => (
<tr key={index}>
<th>{entry.service}</th>
<td>
{SERVICE_STATUS_LABELS[entry.status]}
{entry.comment && <> — {entry.comment}</>}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
{marks.length > 0 && (
<>
<h2>Отметки времени</h2>
@ -163,3 +218,65 @@ export function Dds() {
</main>
);
}
/** Одна служба списка оповещения.
*
* Доступные статусы приходят с сервера: автомат переходов живёт там, и
* дублировать его на фронте нельзя — разъедется. Поле комментария появляется
* у отказов, потому что без комментария сервер такую отметку не примет. */
function StatusRow({
service, current, available, onSet,
}: {
service: string;
current: ServiceStatus;
available: ServiceStatus[];
onSet: (status: ServiceStatus, comment: string) => void;
}) {
const [chosen, setChosen] = useState<ServiceStatus | "">("");
const [comment, setComment] = useState("");
const needsComment = chosen === "declined" || chosen === "refused";
if (available.length === 0) {
return (
<tr>
<th>{service}</th>
<td>{SERVICE_STATUS_LABELS[current]}</td>
<td>карточка закрыта для редактирования</td>
</tr>
);
}
return (
<tr>
<th>{service}</th>
<td>{SERVICE_STATUS_LABELS[current]}</td>
<td>
<select value={chosen} onChange={(event) => setChosen(event.target.value as ServiceStatus)}>
<option value="">—</option>
{available.map((status) => (
<option key={status} value={status}>{SERVICE_STATUS_LABELS[status]}</option>
))}
</select>{" "}
{needsComment && (
<input
type="text"
value={comment}
placeholder="причина и куда передана информация"
onChange={(event) => setComment(event.target.value)}
/>
)}{" "}
<button
type="button"
disabled={!chosen || (needsComment && !comment.trim())}
onClick={() => {
if (!chosen) return;
onSet(chosen, comment);
setChosen("");
setComment("");
}}
>
Поставить
</button>
</td>
</tr>
);
}

View file

@ -0,0 +1,36 @@
// Подписи статусов реагирования и статусов карточки.
//
// Формулировки — из памятки ГБУ «Система 112» дословно (docs/spec/DATASET.md).
// Переводить их на «свой» язык нельзя: диспетчер узнаёт эти слова на своём
// боевом АРМ, и тренажёр должен говорить ими же.
//
// Сами значения приходят из домена через generated.ts — здесь только подписи,
// иначе список статусов разъедется с сервером.
import type { CardStatus, ServiceStatus } from "@/shared/types/generated";
export const SERVICE_STATUS_LABELS: Record<ServiceStatus, string> = {
added: "Добавлена",
received: "Получена службой",
accepted: "Принята",
declined: "Не принята",
responding: "Начало реагирования",
arrived: "Прибытие",
working: "Проведение работ",
completed: "Работы завершены",
refused: "Отказ от выполнения работ",
};
export const CARD_STATUS_LABELS: Record<CardStatus, string> = {
registered: "Зарегистрирована",
processed: "Отработана",
checked: "Проверена",
not_notified: "Не оповещено",
refused: "Отказ",
not_completed: "Не завершено",
completed: "Завершена",
};
/** Три статуса, которые в боевой системе красные и попадают в раздел отдела
* контроля реагирования. Единственное место, где цвет несёт смысл. */
export const ALARMING: CardStatus[] = ["not_notified", "refused", "not_completed"];

View file

@ -78,6 +78,9 @@ export interface CardReceived {
at: string;
}
/** Статус карточки целиком — считается из статусов служб, руками не ставится. */
export type CardStatus = "registered" | "processed" | "checked" | "not_notified" | "refused" | "not_completed" | "completed";
/** Шесть осей радара. Проекция уже посчитанных метрик, без пересчёта весов. */
export type Competency = "intake" | "interview" | "card" | "routing" | "norms" | "communication";
@ -130,7 +133,8 @@ export interface EraData {
impact_force: string;
}
export type ErrorCode = "E1" | "E2" | "E3" | "E4" | "E5" | "E6";
/** E — ошибки приёма вызова, D — ошибки диспетчера ДДС. Две роли, две таксономии. D1–D6 взяты из памятки заказчика дословно: она перечисляет типовые нарушения с реальными примерами, и преподаватель узнаёт их формулировки (docs/spec/DATASET.md). */
export type ErrorCode = "E1" | "E2" | "E3" | "E4" | "E5" | "E6" | "D1" | "D2" | "D3" | "D4" | "D5" | "D6";
export interface ErrorEvent {
type: "error";
@ -155,7 +159,7 @@ export interface Finding {
}
/** Кто выставил отметку. `judge` — единственный недетерминированный источник. */
export type FindingSource = "slots" | "ground_truth" | "timers" | "kio" | "chain" | "judge" | "instructor";
export type FindingSource = "slots" | "dispatcher" | "ground_truth" | "timers" | "kio" | "chain" | "judge" | "instructor";
/** Уточняющие поля 01 — МЧС. */
export interface FireDetails {
@ -358,6 +362,17 @@ export interface SelfAssessmentSubmit {
comment?: string;
}
/** Статус реагирования одной службы из списка оповещения. */
export type ServiceStatus = "added" | "received" | "accepted" | "declined" | "responding" | "arrived" | "working" | "completed" | "refused";
/** Диспетчер ставит статус реагирования своей службе. Последовательность жёсткая, комментарий к отказу обязателен — это не валидация формы, а то, чему учит второй режим занятия (docs/spec/DATASET.md#статусы-реагирования). */
export interface ServiceStatusSet {
type: "card.status";
service: string;
status: ServiceStatus;
comment?: string;
}
export interface SessionEnded {
type: "session.ended";
reason: CallEndReason;
@ -412,6 +427,30 @@ export interface SessionStop {
export type Speaker = "caller" | "operator";
/** Что видит диспетчер: службы, их статусы и что доступно дальше. Все поля обязательные, хотя списки и могут быть пустыми: снимок всегда собирается целиком, а необязательное поле уезжает в TypeScript как `| undefined` и заставляет фронт проверять то, чего не бывает. */
export interface StationSnapshot {
services: Array<string>;
statuses: Record<string, ServiceStatus>;
available: Record<string, Array<ServiceStatus>>;
card: CardStatus;
log: Array<StatusEntry>;
}
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
export interface StationState {
type: "station.state";
snapshot: StationSnapshot;
}
/** Одна отметка службы. Комментарий — часть отметки, а не примечание к ней. */
export interface StatusEntry {
service: string;
status: ServiceStatus;
at: string;
comment?: string;
author?: string;
}
export interface SttFinal {
type: "stt.final";
text: string;
@ -500,9 +539,9 @@ export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | T
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | ScenarioPublish | SessionStop;
export type ServerToStation = CardReceived | TimerTick | SessionEnded | ErrorEvent;
export type ServerToStation = CardReceived | StationState | TimerTick | SessionEnded | ErrorEvent;
export type StationToServer = CardAck | CardBounce | ZoneDecision | CrewDispatched | CrewArrived;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | ZoneDecision | CrewDispatched | CrewArrived;
export type EventName =
"bg.start"
@ -517,6 +556,7 @@ export type EventName =
| "card.ack"
| "card.bounce"
| "card.received"
| "card.status"
| "crew.arrived"
| "crew.dispatched"
| "dds.dispatch"
@ -540,6 +580,7 @@ export type EventName =
| "session.ended"
| "session.snapshot"
| "session.stop"
| "station.state"
| "stt.final"
| "stt.partial"
| "timer.tick"