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

@ -10,6 +10,7 @@ from pathlib import Path
import yaml
from pydantic import ValidationError
from app.domain import ekp
from app.domain.classifiers import DDS_BY_INCIDENT
from app.scenarios.schema import ChecklistItem, Scenario
@ -53,6 +54,18 @@ def _derive_ground_truth(scenario: Scenario) -> Scenario:
scenario.ground_truth.incident_type = scenario.type
scenario.ground_truth.dds = DDS_BY_INCIDENT[scenario.type]
scenario.ground_truth.required_facts = required
# Признаки → код ЕКП → список оповещения. Сценарий без признаков остаётся
# рабочим: такие оцениваются по старым полям, пока их не разметят (lct-34).
if scenario.signs:
found = ekp.by_signs(scenario.signs)
if found is None:
raise ScenarioError(
f"{scenario.id}: признаки {scenario.signs} не найдены в классификаторе ЕКП "
f"версии {ekp.reference().version}"
)
scenario.ground_truth.incident_code = found.code
scenario.ground_truth.notify = ekp.notify_list(found.code)
return scenario
@ -71,7 +84,7 @@ def load_file(path: Path, root: Path) -> Scenario:
raw["not_questions"] = _common_not_questions(root) + list(raw.get("not_questions", []))
declared = raw.get("ground_truth") or {}
forbidden = {"incident_type", "dds", "required_facts"} & set(declared)
forbidden = {"incident_type", "dds", "required_facts", "incident_code", "notify"} & set(declared)
if forbidden:
raise ScenarioError(
f"{path.name}: {', '.join(sorted(forbidden))} в ground_truth выводится кодом "

View file

@ -96,6 +96,8 @@ class GroundTruth(Strict):
incident_type: IncidentType | None = None
dds: DDSCode | None = None
incident_code: str | None = None
notify: list[str] = []
required_facts: list[str] = []
address: str | None = None
victims: int | None = None
@ -114,6 +116,10 @@ class Scenario(Strict):
background: Background | None = None
first_line: str
# Признаки происшествия по ЕКП — то, что курсант обязан проставить в карточке.
# Из них загрузчик выводит код и список оповещения (domain/ekp.py).
signs: list[str] = Field(default_factory=list, max_length=3)
facts: list[Fact] = []
checklist: list[ChecklistItem] = []
required_fields: list[str] = Field(default_factory=list)