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:
parent
ed0b13f060
commit
7212251112
16 changed files with 522 additions and 19 deletions
165
backend/app/domain/ekp.py
Normal file
165
backend/app/domain/ekp.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"""Справочник ЕКП: признаки → итоговый тип → список оповещения.
|
||||
|
||||
Оператор системы-112 службу не выбирает. Он проставляет формализованные признаки
|
||||
происшествия, комбинация признаков даёт итоговый тип, а итоговому типу соответствует
|
||||
набор служб, который система формирует сама (docs/spec/DATASET.md).
|
||||
|
||||
Данные — `ekp.json`, собранный из книги заказчика.
|
||||
Грузится один раз и лениво: `make types` импортирует домен, и читать полмегабайта
|
||||
ради генерации TypeScript незачем.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
DATA = Path(__file__).parent / "ekp.json"
|
||||
|
||||
#: Часть строк книги существует ради интеграции с чужими системами, а оператору
|
||||
#: не показывается — книга помечает их прямо в первом признаке.
|
||||
HIDDEN_SIGN = "не отображается оператору 112"
|
||||
|
||||
#: Условия, меняющие состав списка оповещения. Ключи — из `ekp.json`,
|
||||
#: значения — то, как это называет оператор.
|
||||
MODIFIER_LABELS: dict[str, str] = {
|
||||
"no_access": "нет доступа",
|
||||
"life_threat": "угроза людям",
|
||||
"casualties": "есть пострадавшие или погибшие",
|
||||
"gas": "газифицированный объект",
|
||||
"evacuation": "требуется эвакуация",
|
||||
"medical": "нужна медицинская помощь",
|
||||
"offence": "признаки правонарушения",
|
||||
"road_block": "перекрыто движение",
|
||||
"construction": "стройка",
|
||||
"tunnel": "тоннель",
|
||||
"pedestrian": "пешеходный объект",
|
||||
"vehicle": "автомобильный объект",
|
||||
"mass": "более пяти человек",
|
||||
"comm_object": "объект связи",
|
||||
"listed_object": "объект из перечня",
|
||||
}
|
||||
|
||||
|
||||
class Service(BaseModel):
|
||||
"""Служба — участник информационного взаимодействия."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class IncidentGroup(BaseModel):
|
||||
"""Группа происшествий верхнего уровня: их 23, и это то, что преподаватель
|
||||
выбирает как тему занятия («категория событий» в ТЗ)."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
|
||||
|
||||
class Incident(BaseModel):
|
||||
"""Одна строка классификатора: комбинация признаков и её последствия."""
|
||||
|
||||
code: str = Field(description="Семизначный код ЕКП")
|
||||
group: int
|
||||
signs: list[str] = Field(description="Признаки 1–3, как их выбирает оператор")
|
||||
extra: str | None = Field(default=None, description="Дополнительные признаки")
|
||||
type: str = Field(description="Итоговый тип происшествия")
|
||||
ekp: str = Field(description="Тип по ЕКП-35")
|
||||
main: str | None = Field(default=None, description="Главная служба")
|
||||
notify: list[int] = Field(description="Индексы служб базового оповещения")
|
||||
notify_if: dict[str, list[int]] = Field(
|
||||
default_factory=dict, description="Службы, добавляемые модификатором"
|
||||
)
|
||||
|
||||
|
||||
class Reference(BaseModel):
|
||||
"""Справочник целиком. Версия — часть данных: классификатор обновляется,
|
||||
и в отчёте должно быть видно, по какой редакции считали."""
|
||||
|
||||
version: str
|
||||
source: str
|
||||
imported_at: str
|
||||
groups: list[IncidentGroup]
|
||||
services: list[Service]
|
||||
incidents: list[Incident]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def reference() -> Reference:
|
||||
return Reference.model_validate_json(DATA.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _by_code() -> dict[str, Incident]:
|
||||
return {incident.code: incident for incident in reference().incidents}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _by_signs() -> dict[tuple[str, ...], Incident]:
|
||||
"""Признаки → строка классификатора. Нормализуем регистр: методист пишет
|
||||
«Жилой дом», книга — «жилой дом», и это одно и то же."""
|
||||
table: dict[tuple[str, ...], Incident] = {}
|
||||
for incident in reference().incidents:
|
||||
table.setdefault(tuple(sign.lower() for sign in incident.signs), incident)
|
||||
return table
|
||||
|
||||
|
||||
def incident(code: str) -> Incident | None:
|
||||
return _by_code().get(code)
|
||||
|
||||
|
||||
def by_signs(signs: list[str]) -> Incident | None:
|
||||
"""Комбинация признаков → происшествие. Порядок признаков значим:
|
||||
в книге это три уровня, а не множество."""
|
||||
return _by_signs().get(tuple(sign.strip().lower() for sign in signs if sign.strip()))
|
||||
|
||||
|
||||
def groups() -> list[IncidentGroup]:
|
||||
return reference().groups
|
||||
|
||||
|
||||
def services() -> list[Service]:
|
||||
return reference().services
|
||||
|
||||
|
||||
def service_names(indexes: list[int]) -> list[str]:
|
||||
known = reference().services
|
||||
return [known[i].name for i in indexes if 0 <= i < len(known)]
|
||||
|
||||
|
||||
def notify_list(code: str, modifiers: list[str] | None = None) -> list[str]:
|
||||
"""Список оповещения: базовый плюс то, что добавили модификаторы.
|
||||
|
||||
Порядок — как в книге заказчика, и он не случаен: экстренные службы идут
|
||||
первыми, собирающие информацию — последними. Интерфейс показывает начало
|
||||
списка, поэтому сортировать его по-своему нельзя.
|
||||
"""
|
||||
found = incident(code)
|
||||
if found is None:
|
||||
return []
|
||||
indexes = list(found.notify)
|
||||
for modifier in modifiers or []:
|
||||
for index in found.notify_if.get(modifier, []):
|
||||
if index not in indexes:
|
||||
indexes.append(index)
|
||||
indexes.sort()
|
||||
return service_names(indexes)
|
||||
|
||||
|
||||
def signs_at_level(level: int, prefix: list[str] | None = None) -> list[str]:
|
||||
"""Значения признака уровня 1–3 при уже выбранных предыдущих.
|
||||
|
||||
Это источник выпадающих списков в карточке: выбрал «жилой дом» — во втором
|
||||
признаке остаются только те значения, что встречаются вместе с ним.
|
||||
"""
|
||||
chosen = [sign.strip().lower() for sign in (prefix or [])]
|
||||
values: list[str] = []
|
||||
for item in reference().incidents:
|
||||
if len(item.signs) < level or item.signs[0].lower() == HIDDEN_SIGN:
|
||||
continue
|
||||
if [sign.lower() for sign in item.signs[: len(chosen)]] != chosen:
|
||||
continue
|
||||
value = item.signs[level - 1]
|
||||
if value not in values:
|
||||
values.append(value)
|
||||
return values
|
||||
Loading…
Reference in a new issue