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

99
backend/tests/test_ekp.py Normal file
View file

@ -0,0 +1,99 @@
"""Справочник ЕКП и карточка, которая из него считается.
Проверяются пункты приёмки lct-32: признаки дают код, список оповещения
производен и не выбирается руками, сценарий размечается признаками.
"""
import pytest
from app.domain import ekp
from app.domain.kio import KIO, derive_incident
def test_reference_loads_whole_book():
"""Импорт не должен незаметно потерять часть строк: их 1283, и это число
сверяется с книгой заказчика."""
reference = ekp.reference()
assert reference.version == "046.24"
assert len(reference.incidents) == 1283
assert len(reference.groups) == 23
assert all(incident.type for incident in reference.incidents), "код без итогового типа"
def test_signs_give_the_code_from_the_book():
found = ekp.by_signs(["жилой дом", "подъезд", "открытое пламя"])
assert found is not None
assert found.code == "1050701"
assert found.type == "пожар: подъезд"
def test_signs_are_case_insensitive():
"""Методист пишет «Жилой дом», книга — «жилой дом»."""
assert ekp.by_signs(["Жилой дом", "Подъезд", "Открытое пламя"]) is not None
def test_next_level_narrows_by_previous():
first = ekp.signs_at_level(1)
assert "жилой дом" in first
second = ekp.signs_at_level(2, ["жилой дом"])
assert "балкон" in second
assert "мусор" not in second, "«мусор» относится к улице, а не к жилому дому"
def test_service_rows_hidden_from_operator_are_not_offered():
"""Часть строк книги существует ради интеграции и оператору не показывается."""
assert all(ekp.HIDDEN_SIGN not in sign.lower() for sign in ekp.signs_at_level(1))
def test_notify_list_is_not_a_single_service():
"""Главное расхождение с прежней моделью: происшествие почти всегда
поднимает больше одной службы (docs/spec/DATASET.md)."""
services = ekp.notify_list("1050701")
assert "Служба 101" in services
assert len(services) > 1
def test_modifier_adds_services_and_never_removes():
base = ekp.notify_list("1050701")
with_gas = ekp.notify_list("1050701", ["gas"])
assert set(base) < set(with_gas), "модификатор только добавляет службы"
assert "МОСГАЗ" in with_gas
def test_unknown_code_is_empty_not_an_error():
"""Сценарий без признаков остаётся рабочим: занятие не должно падать
из-за неразмеченной карточки."""
assert ekp.notify_list("0000000") == []
assert ekp.incident("0000000") is None
def test_card_derives_code_and_notify_from_signs():
card = derive_incident(KIO(signs=["жилой дом", "балкон", "открытое пламя"]))
assert card.incident_code == "1050201"
assert "Служба 101" in card.notify
def test_operator_cannot_edit_the_notify_list():
"""В боевом АРМ службу можно добавить, но не удалить, и список целиком
оператор не переписывает — он производен от признаков."""
from app.domain.kio import apply_patch
card = derive_incident(KIO(signs=["жилой дом", "балкон", "открытое пламя"]))
patched = apply_patch(card, {"notify": [], "incident_code": "9999999"})
assert patched.notify == card.notify
assert patched.incident_code == card.incident_code
def test_changing_signs_recalculates_the_card():
from app.domain.kio import apply_patch
card = derive_incident(KIO(signs=["жилой дом", "балкон", "открытое пламя"]))
moved = apply_patch(card, {"signs": ["жилой дом", "квартира", "открытое пламя"]})
assert moved.incident_code != card.incident_code
assert moved.notify, "после смены признаков список оповещения пересобирается"
@pytest.mark.parametrize("level", [1, 2, 3])
def test_every_level_offers_something(level):
prefix = ["жилой дом", "подъезд"][: level - 1]
assert ekp.signs_at_level(level, prefix), f"уровень {level} пуст"

View file

@ -10,7 +10,7 @@ from pathlib import Path
import pytest
from app.domain.events import CallEndReason
from app.domain.kio import KIO
from app.domain.kio import KIO, derive_incident
from app.domain.taxonomy import Competency, ErrorCode
from app.scenarios.loader import load_file
from app.scoring.competency import radar
@ -36,14 +36,18 @@ def timeline(answer_s: float = 5, interview_s: float = 60) -> SessionTimers:
def good_card() -> KIO:
return KIO(
"""Карточка, заполненная верно. Признаки — то, что курсант проставляет сам;
код ЕКП и список оповещения из них выводит домен, а не тест."""
card = KIO(
address="ул. Ленина, д. 14, кв. 47",
floor="5",
incident_type="fire",
dds="01",
victims_count=2,
description="горит балкон",
signs=["жилой дом", "балкон", "открытое пламя"],
)
return derive_incident(card)
def test_perfect_call_passes_everything(scenario):
@ -110,12 +114,23 @@ def test_each_missing_fact_is_its_own_e1_with_the_reference_question(scenario):
assert completeness.fact == "добыто 3 из 5 обязательных фактов"
def test_wrong_dds_is_e2(scenario):
card = good_card()
card.dds = "03"
def test_wrong_signs_are_e2(scenario):
"""Оператор ошибся признаком — и происшествие уехало не в ту службу.
Службу он не выбирает, поэтому и ошибка именно в признаках."""
card = wrong_routing_card()
result = evaluate(scenario=scenario, kio=card, timers=timeline(), revealed_facts=ALL_FACTS)
finding = next(f for f in result.findings if f.code is ErrorCode.E2)
assert "03" in finding.summary and "01" in finding.summary
assert "квартира" in finding.summary, finding.summary
assert "балкон" in finding.summary, "в отметке не видно верного признака"
def test_notify_list_is_derived_not_chosen(scenario):
"""Список оповещения считается по ЕКП: 01 в нём есть, а выбирать его
оператор не может."""
card = good_card()
assert card.incident_code == scenario.ground_truth.incident_code
assert "Служба 101" in card.notify
assert len(card.notify) > 1, "происшествие почти всегда поднимает больше одной службы"
def test_address_abbreviations_are_not_punished(scenario):
@ -159,13 +174,23 @@ def test_dropped_call_without_callback_fails(scenario):
assert not callback.passed and callback.fact == "обратного дозвона не было"
def test_radar_is_a_projection_of_the_same_metrics(scenario):
def wrong_routing_card() -> KIO:
"""Тот же вызов, но признак второго уровня выбран неверно: «квартира»
вместо «балкона» — другой код ЕКП и другой список оповещения."""
card = good_card()
card.dds = "03"
card.signs = ["жилой дом", "квартира", "открытое пламя"]
return derive_incident(card)
def test_radar_is_a_projection_of_the_same_metrics(scenario):
card = wrong_routing_card()
result = evaluate(scenario=scenario, kio=card, timers=timeline(interview_s=94), revealed_facts=ALL_FACTS)
values = {score.competency: score.value for score in radar(result.metrics)}
assert values["routing"] == 0.5, "из двух метрик маршрутизации провалена одна"
# Маршрутизацию меряет одна метрика — признаки. Выбор службы из неё убран:
# список оповещения производен, и штрафовать за него отдельно значит
# наказать дважды за одну ошибку (docs/spec/DATASET.md).
assert values["routing"] == 0.0, "единственная метрика маршрутизации провалена"
assert values["card"] == 1.0
assert Competency.COMMUNICATION.value not in values, "без судьи коммуникация не оценивалась, а не провалена"