lct-hack/backend/app/domain/kio.py
Ivan Gerasimov 7212251112 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 чистый.
2026-09-19 20:11:50 +03:00

196 lines
7.7 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.

"""Карточка информационного обмена (КИО).
Это не дизайн формы, это норматив: docs/spec/NORMATIVES.md#карточка-информационного-обмена-кио
Обязательность полей задаётся сценарием (`required_fields`), а не моделью:
в ложном вызове число пострадавших не требуется, в ЧС требуется.
"""
from datetime import datetime
from enum import StrEnum
from typing import Any
from uuid import UUID, uuid4
from pydantic import BaseModel, ConfigDict, Field
from app.domain.classifiers import DDSCode, IncidentType
class Coords(BaseModel):
"""Координаты одним представлением на весь контракт.
Именованные поля, а не кортеж: в `[55.75, 37.61]` невозможно увидеть,
где широта, а где долгота, и ошибка всплывёт на карте, а не в типах.
"""
lat: float
lon: float
class ResponseStatus(StrEnum):
REGISTERED = "registered"
TRANSFERRED = "transferred"
IN_PROGRESS = "in_progress"
CLOSED = "closed"
class FireDetails(BaseModel):
"""Уточняющие поля 01 — МЧС."""
fire_nature: str | None = None
object_kind: str | None = None
floors: int | None = None
gasified: bool | None = None
people_inside: bool | None = None
smoke_spread: str | None = None # куда идёт дым — пункт чек-листа 01
class PoliceDetails(BaseModel):
"""Уточняющие поля 02 — полиция."""
offence_kind: str | None = None
suspects: str | None = None
suspect_fled: bool | None = None # уехал ли нарушитель — пункт чек-листа 02
vehicle: str | None = None
class MedicalDetails(BaseModel):
"""Уточняющие поля 03 — медицина."""
reason: str | None = None
conscious: bool | None = None
breathing: bool | None = None
can_move: bool | None = None
age: int | None = None
class UtilityDetails(BaseModel):
"""Уточняющие поля 04 / ЖКХ."""
failure_kind: str | None = None
scale: str | None = None
threat_to_residents: bool | None = None
class KIO(BaseModel):
"""Полная карточка. Наблюдателям уходит целиком (`kio.state`),
курсанту — дельтой (`kio.patch`).
Присваивание проверяется: без этого `card.dds = "03"` кладёт в карточку
сырую строку вместо кода ДДС, и падает уже оценка, далеко от места ошибки.
"""
model_config = ConfigDict(validate_assignment=True)
# Служебное — заполняется системой
card_id: UUID = Field(default_factory=uuid4)
registered_at: datetime | None = None
response_status: ResponseStatus = ResponseStatus.REGISTERED
# Звонящий
caller_number: str | None = None
caller_name: str | None = None
caller_contact: str | None = None
language: str = "ru"
# Место
okato: str | None = None
address: str | None = None
street: str | None = None
building: str | None = None
entrance: str | None = None
floor: str | None = None
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
is_emergency: bool = False
life_threat: bool = False
evacuation_needed: bool = False
# ДДС. `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
# Уточняющие по службе
fire: FireDetails | None = None
police: PoliceDetails | None = None
medical: MedicalDetails | None = None
utility: UtilityDetails | None = None
#: Поля, которые курсант не редактирует: их проставляет система.
READ_ONLY_FIELDS: frozenset[str] = frozenset(
{"card_id", "registered_at", "caller_number", "response_status", "incident_code", "notify"}
)
def get_field(card: KIO, path: str) -> Any:
"""Значение поля по пути вида `floor` или `fire.floors`."""
value: Any = card
for part in path.split("."):
if value is None:
return None
value = getattr(value, part, None)
return value
def missing_fields(card: KIO, required: list[str]) -> list[str]:
"""Незаполненные обязательные поля — основание отметки E5.
Обязательность приходит из сценария, пустой список означает,
что сценарий требований к карточке не предъявляет.
"""
empty: list[str] = []
for path in required:
value = get_field(card, path)
if value is None or (isinstance(value, str) and not value.strip()):
empty.append(path)
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`. Служебные поля игнорируются.
Вложенные поля приходят плоским путём: {"fire.floors": 5}.
"""
data = card.model_dump()
for path, value in fields.items():
if path in READ_ONLY_FIELDS:
continue
head, _, tail = path.partition(".")
if not tail:
data[head] = value
continue
nested = data.get(head) or {}
nested[tail] = value
data[head] = nested
updated = KIO.model_validate(data)
return derive_incident(updated) if "signs" in fields else updated