feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)

ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.

- domain/statuses.py: девять статусов реагирования с автоматом переходов,
  семь статусов карточки, обязательные комментарии к отказам. Формулировки
  из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
  отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
  от профильного происшествия) проверяется по списку оповещения из ЕКП —
  без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
  журнал отметок, статус карточки красным в трёх случаях из семи.

Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.

Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.

148 тестов зелёных (23 новых), make typecheck чистый.
This commit is contained in:
Ivan Gerasimov 2026-09-20 08:23:55 +03:00
commit d4f0d0a0f8
14 changed files with 953 additions and 9 deletions

View file

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

View file

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