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 чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
"""Оценка работы диспетчера ДДС: статусы реагирования.
|
|
|
|
|
|
|
|
|
|
|
|
Вторая роль — второй набор ошибок. 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
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from app.domain.events import Metric
|
|
|
|
|
|
from app.domain.timers import GOST_REF
|
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 чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
|
|
|
|
|
|
#: Статусы, означающие, что реагирование действительно шло.
|
|
|
|
|
|
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
|
2026-09-21 17:40:54 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
|
|
|
|
|
|
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
Веса предварительные — до утверждения методистом. Телефон — возможный
|
|
|
|
|
|
источник информации, но не обязательный шлюз статуса.
|
2026-09-21 17:40:54 +03:00
|
|
|
|
"""
|
|
|
|
|
|
metrics: list[Metric] = []
|
2026-09-24 01:10:49 +03:00
|
|
|
|
for service in state.managed_services():
|
2026-09-21 17:40:54 +03:00
|
|
|
|
marks = [mark for mark in state.status_log if mark.service == service]
|
|
|
|
|
|
statuses = {mark.status for mark in marks}
|
|
|
|
|
|
|
|
|
|
|
|
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
|
|
|
|
|
|
metrics.append(Metric(
|
|
|
|
|
|
key=key, title=f"{service}: {title}", fact=fact, norm=norm,
|
|
|
|
|
|
ref=GOST_REF if key == "dds_ack" else "памятка «Работа на АРМ-112», статусы реагирования",
|
|
|
|
|
|
passed=passed, weight=weight,
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
|
|
|
|
|
|
elapsed = (int((primary.at - state.dispatched_at).total_seconds() * 1000)
|
|
|
|
|
|
if primary and state.dispatched_at else None)
|
|
|
|
|
|
add("dds_primary", "решение по карточке", primary is not None,
|
|
|
|
|
|
primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
add("dds_ack", f"первичная отметка за {deadline_ms // 1000} с",
|
2026-09-21 17:40:54 +03:00
|
|
|
|
elapsed is not None and elapsed <= deadline_ms,
|
|
|
|
|
|
f"{elapsed / 1000:.1f} с" if elapsed is not None else "не отмечено",
|
|
|
|
|
|
f"≤ {deadline_ms // 1000} с")
|
|
|
|
|
|
if primary is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
add("dds_decision", "профильное реагирование",
|
|
|
|
|
|
primary.status is ServiceStatus.ACCEPTED,
|
|
|
|
|
|
primary.status.value, "профильную заявку принять", 2.0)
|
|
|
|
|
|
if primary.status is ServiceStatus.DECLINED:
|
|
|
|
|
|
continue
|
|
|
|
|
|
expected = {
|
2026-09-24 01:10:49 +03:00
|
|
|
|
ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING,
|
2026-09-21 17:40:54 +03:00
|
|
|
|
}
|
2026-09-24 01:10:49 +03:00
|
|
|
|
add("dds_progress", "ведение хода реагирования", expected <= statuses,
|
2026-09-21 17:40:54 +03:00
|
|
|
|
", ".join(status.value for status in statuses) or "статусов нет",
|
2026-09-24 01:10:49 +03:00
|
|
|
|
"начало реагирования, прибытие и работы отмечены по полученной информации", 2.0)
|
|
|
|
|
|
add("dds_completion", "закрытие работ",
|
|
|
|
|
|
ServiceStatus.COMPLETED in statuses,
|
2026-09-21 17:40:54 +03:00
|
|
|
|
"завершено" if ServiceStatus.COMPLETED in statuses else "не завершено",
|
2026-09-24 01:10:49 +03:00
|
|
|
|
"по факту поставить статус «Работы завершены»")
|
2026-09-21 17:40:54 +03:00
|
|
|
|
return metrics
|