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:
parent
842a333d16
commit
d4f0d0a0f8
14 changed files with 953 additions and 9 deletions
|
|
@ -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()))
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
||||
|
|
|
|||
198
backend/app/domain/statuses.py
Normal file
198
backend/app/domain/statuses.py
Normal 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]
|
||||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
125
backend/app/scoring/dispatcher.py
Normal file
125
backend/app/scoring/dispatcher.py
Normal 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
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
175
backend/tests/test_statuses.py
Normal file
175
backend/tests/test_statuses.py
Normal 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
|
||||
Loading…
Reference in a new issue