lct-hack/backend/app/domain/taxonomy.py
Ivan Gerasimov d4f0d0a0f8 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

164 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Таксономия ошибок E1–E6 и компетенции.
Методика: docs/product/METHODOLOGY.md#таксономия-ошибок
Правило: каждая отметка несёт код и обоснование. Отметок без объяснения
не появляется ни в одном интерфейсе.
"""
from datetime import datetime
from enum import StrEnum
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"
CHAIN = "chain"
JUDGE = "judge"
INSTRUCTOR = "instructor"
class ErrorSpec(BaseModel):
code: ErrorCode
title: str
detail: str
source: FindingSource
ERRORS: dict[ErrorCode, ErrorSpec] = {
ErrorCode.E1: ErrorSpec(
code=ErrorCode.E1,
title="Пропущенный факт",
detail="Обязательный вопрос не прозвучал, факт не добыт",
source=FindingSource.SLOTS,
),
ErrorCode.E2: ErrorSpec(
code=ErrorCode.E2,
title="Неверная классификация или маршрутизация",
detail="Тип происшествия или ДДС определены неверно",
source=FindingSource.GROUND_TRUTH,
),
ErrorCode.E3: ErrorSpec(
code=ErrorCode.E3,
title="Нарушение временного норматива",
detail="Опрос дольше 75 с, ДДС не оповещена за 60 с и т. д.",
source=FindingSource.TIMERS,
),
ErrorCode.E4: ErrorSpec(
code=ErrorCode.E4,
title="Коммуникативная ошибка",
detail="Тон, эмпатия, управление диалогом, лишние вопросы, игнорирование паники",
source=FindingSource.JUDGE,
),
ErrorCode.E5: ErrorSpec(
code=ErrorCode.E5,
title="Неполнота карточки",
detail="Обязательные поля КИО пусты или заполнены неверно",
source=FindingSource.KIO,
),
ErrorCode.E6: ErrorSpec(
code=ErrorCode.E6,
title="Неверное завершение",
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,
),
}
class Competency(StrEnum):
"""Шесть осей радара. Проекция уже посчитанных метрик, без пересчёта весов."""
INTAKE = "intake"
INTERVIEW = "interview"
CARD = "card"
ROUTING = "routing"
NORMS = "norms"
COMMUNICATION = "communication"
COMPETENCY_LABELS: dict[Competency, str] = {
Competency.INTAKE: "Приём вызова",
Competency.INTERVIEW: "Опрос и добыча фактов",
Competency.CARD: "Заполнение КИО",
Competency.ROUTING: "Классификация и маршрутизация",
Competency.NORMS: "Соблюдение нормативов",
Competency.COMMUNICATION: "Коммуникация в стрессе",
}
class Finding(BaseModel):
"""Отметка в разборе. `fact` и `norm` — то самое обоснование."""
code: ErrorCode
source: FindingSource
summary: str
fact: str
norm: str | None = None
ref: str | None = None
competency: Competency | None = None
transcript_ref: str | None = None
at: datetime | None = None