From d4f0d0a0f8ee711d64d98bb2d95c4ee28a3c9fd6 Mon Sep 17 00:00:00 2001 From: Ivan Gerasimov Date: Sun, 20 Sep 2026 08:23:55 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81?= =?UTF-8?q?=D1=8B=20=D1=80=D0=B5=D0=B0=D0=B3=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B0=20=D0=90=D0=A0=D0=9C=20=D0=94?= =?UTF-8?q?=D0=94=D0=A1=20=D0=B8=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=B8=D1=81=D0=BF=D0=B5=D1=82=D1=87=D0=B5=D1=80=D0=B0=20?= =?UTF-8?q?D1=E2=80=93D6=20(lct-33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ТЗ называет обучаемого оператором ДДС, а его работа в системе-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 чистый. --- 1.md | 0 backend/app/api/ws/call.py | 4 + backend/app/api/ws/station.py | 28 +++- backend/app/domain/events.py | 26 +++- backend/app/domain/statuses.py | 198 +++++++++++++++++++++++++ backend/app/domain/taxonomy.py | 50 +++++++ backend/app/scoring/dispatcher.py | 125 ++++++++++++++++ backend/app/session/finish.py | 16 +- backend/app/session/state.py | 48 ++++++ backend/tests/test_station.py | 88 +++++++++++ backend/tests/test_statuses.py | 175 ++++++++++++++++++++++ frontend/src/pages/dds/Dds.tsx | 119 ++++++++++++++- frontend/src/pages/dds/statuses.ts | 36 +++++ frontend/src/shared/types/generated.ts | 49 +++++- 14 files changed, 953 insertions(+), 9 deletions(-) create mode 100644 1.md create mode 100644 backend/app/domain/statuses.py create mode 100644 backend/app/scoring/dispatcher.py create mode 100644 backend/tests/test_statuses.py create mode 100644 frontend/src/pages/dds/statuses.ts diff --git a/1.md b/1.md new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py index 46cade5..aab37f9 100644 --- a/backend/app/api/ws/call.py +++ b/backend/app/api/ws/call.py @@ -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())) diff --git a/backend/app/api/ws/station.py b/backend/app/api/ws/station.py index de556d0..5b6e12c 100644 --- a/backend/app/api/ws/station.py +++ b/backend/app/api/ws/station.py @@ -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: diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py index 4afdb9d..6686a47 100644 --- a/backend/app/domain/events.py +++ b/backend/app/domain/events.py @@ -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"), ] diff --git a/backend/app/domain/statuses.py b/backend/app/domain/statuses.py new file mode 100644 index 0000000..2242d77 --- /dev/null +++ b/backend/app/domain/statuses.py @@ -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] diff --git a/backend/app/domain/taxonomy.py b/backend/app/domain/taxonomy.py index e545c06..a2be930 100644 --- a/backend/app/domain/taxonomy.py +++ b/backend/app/domain/taxonomy.py @@ -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, + ), } diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py new file mode 100644 index 0000000..258c405 --- /dev/null +++ b/backend/app/scoring/dispatcher.py @@ -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 diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py index eb9904a..0feca6a 100644 --- a/backend/app/session/finish.py +++ b/backend/app/session/finish.py @@ -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)) diff --git a/backend/app/session/state.py b/backend/app/session/state.py index 6b2bde3..6f8829b 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -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 diff --git a/backend/tests/test_station.py b/backend/tests/test_station.py index 8d8c12d..a0d869c 100644 --- a/backend/tests/test_station.py +++ b/backend/tests/test_station.py @@ -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) diff --git a/backend/tests/test_statuses.py b/backend/tests/test_statuses.py new file mode 100644 index 0000000..fa26aca --- /dev/null +++ b/backend/tests/test_statuses.py @@ -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 diff --git a/frontend/src/pages/dds/Dds.tsx b/frontend/src/pages/dds/Dds.tsx index b4250b2..c83ea1b 100644 --- a/frontend/src/pages/dds/Dds.tsx +++ b/frontend/src/pages/dds/Dds.tsx @@ -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(null); const [marks, setMarks] = useState([]); + const [station, setStation] = useState(null); const channel = useRef | 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)); setFrom(event.from_operator); @@ -146,12 +151,62 @@ export function Dds() { }}>Прибытие

+ {station && station.services.length > 0 && ( + <> +

Статусы реагирования

+

+ Статус карточки:{" "} + + {CARD_STATUS_LABELS[station.card]} + +

+ + + + + + {station.services.map((service) => ( + { + channel.current?.send({ type: "card.status", service, status, comment }); + mark(`${service}: ${SERVICE_STATUS_LABELS[status]}`); + }} + /> + ))} + +
СлужбаСтатусОтметить
+ + )} +

Карточка от оператора 112

{/* Снимок: после передачи он не меняется. */} )} + {station && station.log.length > 0 && ( + <> +

Журнал отметок

+ + + {station.log.map((entry, index) => ( + + + + + ))} + +
{entry.service} + {SERVICE_STATUS_LABELS[entry.status]} + {entry.comment && <> — {entry.comment}} +
+ + )} + {marks.length > 0 && ( <>

Отметки времени

@@ -163,3 +218,65 @@ export function Dds() { ); } + +/** Одна служба списка оповещения. + * + * Доступные статусы приходят с сервера: автомат переходов живёт там, и + * дублировать его на фронте нельзя — разъедется. Поле комментария появляется + * у отказов, потому что без комментария сервер такую отметку не примет. */ +function StatusRow({ + service, current, available, onSet, +}: { + service: string; + current: ServiceStatus; + available: ServiceStatus[]; + onSet: (status: ServiceStatus, comment: string) => void; +}) { + const [chosen, setChosen] = useState(""); + const [comment, setComment] = useState(""); + const needsComment = chosen === "declined" || chosen === "refused"; + + if (available.length === 0) { + return ( + + {service} + {SERVICE_STATUS_LABELS[current]} + карточка закрыта для редактирования + + ); + } + return ( + + {service} + {SERVICE_STATUS_LABELS[current]} + + {" "} + {needsComment && ( + setComment(event.target.value)} + /> + )}{" "} + + + + ); +} diff --git a/frontend/src/pages/dds/statuses.ts b/frontend/src/pages/dds/statuses.ts new file mode 100644 index 0000000..8dcc36b --- /dev/null +++ b/frontend/src/pages/dds/statuses.ts @@ -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 = { + added: "Добавлена", + received: "Получена службой", + accepted: "Принята", + declined: "Не принята", + responding: "Начало реагирования", + arrived: "Прибытие", + working: "Проведение работ", + completed: "Работы завершены", + refused: "Отказ от выполнения работ", +}; + +export const CARD_STATUS_LABELS: Record = { + registered: "Зарегистрирована", + processed: "Отработана", + checked: "Проверена", + not_notified: "Не оповещено", + refused: "Отказ", + not_completed: "Не завершено", + completed: "Завершена", +}; + +/** Три статуса, которые в боевой системе красные и попадают в раздел отдела + * контроля реагирования. Единственное место, где цвет несёт смысл. */ +export const ALARMING: CardStatus[] = ["not_notified", "refused", "not_completed"]; diff --git a/frontend/src/shared/types/generated.ts b/frontend/src/shared/types/generated.ts index 65ddbde..705041d 100644 --- a/frontend/src/shared/types/generated.ts +++ b/frontend/src/shared/types/generated.ts @@ -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; + statuses: Record; + available: Record>; + card: CardStatus; + log: Array; +} + +/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */ +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"