feat: классификатор ЕКП — признаки вместо выбора службы (lct-32)

Оператор системы-112 службу не выбирает: он проставляет формализованные
признаки происшествия, комбинация признаков даёт код ЕКП, а коду соответствует
список оповещения, который система собирает сама (docs/spec/DATASET.md).
Прежняя модель с полем dds из пяти значений оценивала действие, которого
в боевой работе нет.

- scripts/import_ekp.py (make ekp): книга заказчика → app/domain/ekp.json,
  1283 кода, 61 служба, 23 группы. Разовый импорт, результат под гитом:
  читать xlsx в рантайме — лишняя зависимость и полсекунды на старте.
- domain/ekp.py: справочник с ленивой загрузкой, каскад значений признаков,
  список оповещения с модификаторами (нет доступа, угроза людям, пострадавшие,
  газификация и ещё десяток).
- КИО: поля signs, incident_code, notify. Последние два только на чтение
  и пересчитываются при каждой правке признаков; добавленная вручную служба
  не теряется, удалить службу нельзя — как в боевом АРМ.
- GET /api/ekp/signs отдаёт один уровень признаков, а не дерево на полмегабайта.
- Карточка на фронте: три каскадных селектора вместо выбора службы.
- Оценка: метрика incident_signs (E2, маршрутизация). Для размеченных сценариев
  dds_choice больше не считается — список оповещения производен от признаков,
  и штрафовать за него отдельно значит наказать дважды за одну ошибку.

Разбор книги оказался основной работой: имя службы лежит то в первой строке
заголовка, то во второй, то склеено с модификатором; «Классификатор МЧС» —
заголовок группы колонок, а не служба. Правила разбора в докстринге импорта.

113 тестов зелёных (14 новых в tests/test_ekp.py), make typecheck чистый.
This commit is contained in:
Ivan Gerasimov 2026-09-19 20:11:50 +03:00
commit 7212251112
16 changed files with 522 additions and 19 deletions

View file

@ -102,7 +102,10 @@ class KIO(BaseModel):
intercom_code: str | None = None # спрашивается чек-листом 03, без него скорая стоит у двери
coords: Coords | None = None
# Происшествие
# Происшествие. Оператор проставляет признаки, остальное считает система
# по ЕКП (domain/ekp.py) — службу он не выбирает.
signs: list[str] = Field(default_factory=list, max_length=3)
incident_code: str | None = Field(default=None, description="Код ЕКП по признакам")
incident_type: IncidentType | None = None
description: str | None = None
victims_count: int | None = None
@ -110,8 +113,12 @@ class KIO(BaseModel):
life_threat: bool = False
evacuation_needed: bool = False
# ДДС
dds: DDSCode | None = None
# ДДС. `notify` считается из признаков и правится только системой:
# в боевом АРМ оператор может добавить службу вручную, но не удалить.
notify: list[str] = Field(default_factory=list, description="Список оповещения")
dds: DDSCode | None = Field(
default=None, description="Устаревшее: одна служба. Заменено списком оповещения"
)
dispatch_order_at: datetime | None = None
arrival_at: datetime | None = None
@ -124,7 +131,7 @@ class KIO(BaseModel):
#: Поля, которые курсант не редактирует: их проставляет система.
READ_ONLY_FIELDS: frozenset[str] = frozenset(
{"card_id", "registered_at", "caller_number", "response_status"}
{"card_id", "registered_at", "caller_number", "response_status", "incident_code", "notify"}
)
@ -152,6 +159,23 @@ def missing_fields(card: KIO, required: list[str]) -> list[str]:
return empty
def derive_incident(card: KIO) -> KIO:
"""Пересчитать код происшествия и список оповещения по признакам.
Вызывается после каждой правки признаков: в боевом АРМ список оповещения
пересобирается сам, и это главное, чему учит карточка.
"""
from app.domain import ekp
found = ekp.by_signs(card.signs)
data = card.model_dump()
data["incident_code"] = found.code if found else None
# Добавленную вручную службу не теряем: удалить её оператор не может.
manual = [name for name in card.notify if name not in ekp.notify_list(data["incident_code"] or "")]
data["notify"] = (ekp.notify_list(found.code) if found else []) + manual
return KIO.model_validate(data)
def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
"""Применить дельту `kio.patch`. Служебные поля игнорируются.
@ -168,4 +192,5 @@ def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
nested = data.get(head) or {}
nested[tail] = value
data[head] = nested
return KIO.model_validate(data)
updated = KIO.model_validate(data)
return derive_incident(updated) if "signs" in fields else updated