lct-38: каталог 209 служб из «СЛУЖБЫ 112» для ручного добавления в карточку

This commit is contained in:
Ivan Gerasimov 2026-09-27 16:52:11 +03:00
commit 3ce3e768e1
13 changed files with 1715 additions and 6 deletions

View file

@ -48,3 +48,14 @@ async def signs(prefix: list[str] = Query(default=[]), group: int | None = None)
else None
),
}
@router.get("/services")
async def services() -> dict:
"""Каталог служб для ручного добавления в карточку — как окно «Добавьте службы».
Каталог — справочник боевого АРМ, а не содержимое вызова: отдавать его
курсанту так же безопасно, как группы происшествий.
"""
data = ekp.catalog()
return {"source": data.source, "services": [item.model_dump() for item in data.services]}

View file

@ -270,7 +270,16 @@ async def _handle(session_id: UUID, state, event) -> None:
case "kio.patch":
old_code, old_notify = state.kio.incident_code, list(state.kio.notify)
try:
state.patch_kio(event.fields)
except ValidationError as exc:
# Недопустимое значение — отказ курсанту, а не сбой операции:
# сбой закрыл бы занятие на этом узле.
hub.to_trainee(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message=f"Поле карточки не принято: {exc.errors()[0].get('msg', '')}"[:200],
))
return
hub.to_trainee(session_id, KioPatchOut(
fields=event.fields, source=PatchSource.OPERATOR,
))

View file

@ -15,6 +15,9 @@ from pathlib import Path
from pydantic import BaseModel, Field
DATA = Path(__file__).parent / "ekp.json"
#: Каталог окна «Добавьте службы» из «СЛУЖБЫ 112.docx»: 209 служб, в том числе
#: районные ДДС. Источник и лицензия — в шапке файла и в docs/delivery/THIRD-PARTY.md.
CATALOG = Path(__file__).parent / "services_catalog.json"
#: Часть строк книги существует ради интеграции с чужими системами, а оператору
#: не показывается — книга помечает их прямо в первом признаке.
@ -89,6 +92,37 @@ def reference() -> Reference:
return Reference.model_validate_json(DATA.read_text(encoding="utf-8"))
class CatalogService(BaseModel):
"""Служба каталога боевого АРМ — то, что оператор добавляет в карточку вручную."""
id: str
name: str
organization: str | None = None
#: «ДДС», если это явно указано в названии в каталоге.
service_level: str | None = None
#: Скриншот и строка списка в «СЛУЖБЫ 112.docx».
source_reference: str
class Catalog(BaseModel):
source: str
transcription: str
license: str
services: list[CatalogService]
@lru_cache(maxsize=1)
def catalog() -> Catalog:
"""Каталог только расширяет выбор служб. Список оповещения по-прежнему
считается из признаков по классификатору, эталоны сценариев от него не зависят."""
return Catalog.model_validate_json(CATALOG.read_text(encoding="utf-8"))
@lru_cache(maxsize=1)
def catalog_names() -> frozenset[str]:
return frozenset(service.name for service in catalog().services)
@lru_cache(maxsize=1)
def _by_code() -> dict[str, Incident]:
return {incident.code: incident for incident in reference().incidents}

View file

@ -10,7 +10,7 @@ from enum import StrEnum
from typing import Any
from uuid import UUID, uuid4
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.domain.classifiers import DDSCode, IncidentType
@ -118,6 +118,11 @@ class KIO(BaseModel):
# ДДС. `notify` считается из признаков и правится только системой:
# в боевом АРМ оператор может добавить службу вручную, но не удалить.
notify: list[str] = Field(default_factory=list, description="Список оповещения")
#: Службы, добавленные оператором вручную из каталога. Автоматический список
#: не заменяют и в оценку по эталону не входят.
notify_extra: list[str] = Field(
default_factory=list, max_length=20, description="Дополнительно оповестить"
)
dds: DDSCode | None = Field(
default=None, description="Устаревшее: одна служба. Заменено списком оповещения"
)
@ -130,6 +135,19 @@ class KIO(BaseModel):
medical: MedicalDetails | None = None
utility: UtilityDetails | None = None
@field_validator("notify_extra")
@classmethod
def known_catalog_services(cls, names: list[str]) -> list[str]:
"""Только службы каталога, без повторов: свободный текст в адресатах
карточки на АРМ ДДС выглядел бы как настоящая служба."""
from app.domain import ekp
known = ekp.catalog_names()
unknown = [name for name in names if name not in known]
if unknown:
raise ValueError(f"нет в каталоге служб: {', '.join(unknown[:3])}")
return list(dict.fromkeys(names))
#: Поля, которые курсант не редактирует: их проставляет система.
READ_ONLY_FIELDS: frozenset[str] = frozenset(
@ -147,7 +165,7 @@ EDITABLE_KIO_FIELDS: frozenset[str] = frozenset({
"okato", "address", "street", "building", "entrance", "floor",
"intercom_code", "description", "incident_group", "signs",
"incident_type", "victims_count", "is_emergency", "life_threat",
"evacuation_needed", "dds",
"evacuation_needed", "dds", "notify_extra",
"fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified",
"fire.people_inside", "fire.smoke_spread",
"police.offence_kind", "police.suspects", "police.suspect_fled",

File diff suppressed because it is too large Load diff

View file

@ -12,13 +12,14 @@ from pathlib import Path
API_ROOT = Path(__file__).parents[1] / "app" / "api"
# Public by design: credential entry/session bootstrap and the non-sensitive
# incident classification dictionary. dev-token has its own fail-closed flag
# incident classification dictionary and service catalog. dev-token has its own fail-closed flag
# and remains hidden in production.
PUBLIC_HTTP_ROUTES = {
("auth.py", "post", "/login"),
("auth.py", "post", "/dev-token"),
("http/ekp.py", "get", "/groups"),
("http/ekp.py", "get", "/signs"),
("http/ekp.py", "get", "/services"),
}
# Routes that centralize ownership + authentication checks in a shared helper.

View file

@ -0,0 +1,110 @@
"""Каталог служб из «СЛУЖБЫ 112» (lct-38).
Каталог только расширяет ручной выбор служб в карточке. Список оповещения
по-прежнему считается по классификатору, эталоны сценариев от каталога не зависят.
"""
import asyncio
import json
from pathlib import Path
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from app.api.ws import call as call_ws
from app.domain import ekp
from app.domain.events import ErrorEvent, KioPatchIn, SessionMode
from app.domain.kio import EDITABLE_KIO_FIELDS, KIO, apply_patch
from app.main import app
from app.scenarios.loader import load_file
from app.scoring.card import evaluate_card
from app.session.hub import hub
from app.session.state import SessionState
from app.session.store import MemorySessionStore
def test_catalog_keeps_all_209_services_with_attribution_and_source():
data = ekp.catalog()
assert len(data.services) == 209
assert len({item.name for item in data.services}) == 209
assert sum(item.service_level == "ДДС" for item in data.services) == 157
assert "ded466988f505082922aacac9fff32b777d1279a" in data.transcription
assert data.license.startswith("MIT")
assert all(item.source_reference.startswith("СЛУЖБЫ 112.docx#word/media/image")
for item in data.services)
@pytest.mark.parametrize("name", [
'Служба 101 (ГУ МЧС России по г.Москве, ГКУ "Пожарно спасательный центр" ОДС)',
'Служба 104 (АО "МОСГАЗ" Диспетчерское управление)',
"ЦЭМП",
'ЦОДД (ГКУ "Центр организации дорожного движения")',
'ЭВАЖД (ГБУ "Учреждение по эксплуатации высотных административных и жилых домов")',
])
def test_rows_checked_against_our_screenshots_are_verbatim(name):
assert name in ekp.catalog_names()
def test_classifier_services_and_notify_lists_are_untouched():
"""Индексы служб классификатора — основа списков оповещения 96 сценариев."""
raw = json.loads(ekp.DATA.read_text(encoding="utf-8"))
assert [item.name for item in ekp.services()] == [item["name"] for item in raw["services"]]
assert len(ekp.services()) == 61
def test_operator_adds_catalog_services_without_changing_the_notify_list():
assert "notify_extra" in EDITABLE_KIO_FIELDS
card = KIO(notify=["Служба 101"])
updated = apply_patch(card, {"notify_extra": ["ФСБ", "ЦЭМП", "ФСБ"], "notify": []})
assert updated.notify_extra == ["ФСБ", "ЦЭМП"]
assert updated.notify == ["Служба 101"], "автоматический список оператор не правит"
with pytest.raises(ValidationError):
apply_patch(card, {"notify_extra": ["Служба, которой нет"]})
def test_added_services_do_not_change_the_score_against_the_reference():
library = Path(__file__).resolve().parents[2] / "scenarios"
scenario = load_file(library / "fire-apartment-l2.yaml", library)
card = KIO(address="улица Ленина, 14", description="горит балкон")
plain = evaluate_card(scenario, card)
extended = evaluate_card(scenario, card.model_copy(update={"notify_extra": ["ФСБ"]}))
assert [(m.key, m.passed) for m in plain.metrics] == [(m.key, m.passed) for m in extended.metrics]
assert plain.score == extended.score
def test_catalog_is_served_like_the_classifier():
with TestClient(app) as client:
response = client.get("/api/ekp/services")
assert response.status_code == 200
body = response.json()
assert len(body["services"]) == 209
assert {"id", "name", "organization", "service_level", "source_reference"} <= body["services"][0].keys()
def test_unknown_service_in_kio_patch_is_refused_without_failing_the_lesson(monkeypatch):
sent = []
monkeypatch.setattr(hub, "store", MemorySessionStore())
monkeypatch.setattr(hub, "to_trainee", lambda _session_id, event: sent.append(event))
monkeypatch.setattr(hub, "to_observers", lambda *_args: None)
state = SessionState(session_id=uuid4(), scenario_id="test", scenario_title="Тест",
level="L1", mode=SessionMode.TRAINING)
hub.register(state)
async def patch(fields):
async with hub.operation(state.session_id):
await call_ws._handle(state.session_id, state, KioPatchIn(fields=fields))
asyncio.run(patch({"notify_extra": ["Служба, которой нет"]}))
assert isinstance(sent[-1], ErrorEvent) and "не принято" in sent[-1].message
assert state.kio.notify_extra == []
asyncio.run(patch({"notify_extra": ["ФСБ"]}))
assert state.kio.notify_extra == ["ФСБ"]

View file

@ -1,7 +1,9 @@
// Карточка КИО. Один и тот же компонент рисуется у курсанта, на пульте,
// на внешнем мониторе и на АРМ ДДС — там в режиме `readOnly`.
import { useIncidentGroups, useSigns } from "@/shared/api/http";
import { useState } from "react";
import { useIncidentGroups, useServiceCatalog, useSigns } from "@/shared/api/http";
import { type CardState, display } from "./merge";
import { type Field, type FieldGroup, visibleGroups } from "./fields";
@ -141,6 +143,9 @@ function Input({
</select>
);
}
if (field.kind === "services") {
return <ServicesPicker value={(value as string[]) ?? []} onChange={(next) => emit(next)} />;
}
if (field.kind === "signs") {
return <SignsPicker value={(value as string[]) ?? []} group={incidentGroup} onChange={(next) => emit(next)} />;
}
@ -170,6 +175,36 @@ function Input({
);
}
/** Ручное добавление служб из каталога — окно «Добавьте службы» боевого АРМ.
* Список оповещения по признакам остаётся как есть: здесь только добавки. */
function ServicesPicker({ value, onChange }: { value: string[]; onChange: (next: string[]) => void }) {
const catalog = useServiceCatalog();
const [query, setQuery] = useState("");
const needle = query.trim().toLowerCase();
const found = needle
? (catalog.data?.services ?? [])
.filter((item) => !value.includes(item.name) && item.name.toLowerCase().includes(needle))
.slice(0, 8)
: [];
return (
<div className="kio-services">
{value.length > 0 && <div className="signs-selected" aria-label="Добавленные службы">
{value.map((name) => <span key={name}>
{name} <button type="button" aria-label={`Убрать ${name}`}
onClick={() => onChange(value.filter((item) => item !== name))}>×</button>
</span>)}
</div>}
<input type="search" aria-label="Поиск службы в каталоге" placeholder="Поиск службы…"
value={query} onChange={(event) => setQuery(event.target.value)} />
{found.length > 0 && <ul className="kio-services-found">
{found.map((item) => <li key={item.id}>
<button type="button" onClick={() => { onChange([...value, item.name]); setQuery(""); }}>{item.name}</button>
</li>)}
</ul>}
</div>
);
}
function IncidentGroupPicker({ value, onChange }: { value: number | null; onChange: (next: number | null) => void }) {
const page = useIncidentGroups();
return <select aria-label="Категория происшествия" value={value ?? ""}

View file

@ -6,7 +6,7 @@
import type { DDSCode, IncidentType } from "@/shared/types/generated";
export type FieldKind = "text" | "textarea" | "number" | "bool" | "select" | "signs" | "group" | "list";
export type FieldKind = "text" | "textarea" | "number" | "bool" | "select" | "signs" | "group" | "list" | "services";
export interface Field {
/** Путь в карточке: «floor», «fire.floors» — так же, как в `kio.patch`. */
@ -107,6 +107,8 @@ export const GROUPS: FieldGroup[] = [
fields: [
// Список оповещения оператор не выбирает: он производен от признаков.
{ path: "notify", label: "Список оповещения", kind: "list", readOnly: true },
// Как «Службы: +» в боевом АРМ: добавить можно, убрать автоматическую — нет.
{ path: "notify_extra", label: "Дополнительно оповестить", kind: "services" },
{ path: "dds", label: "Служба (устаревшее)", kind: "select", options: DDS },
{ path: "dispatch_order_at", label: "Приказ на выезд", kind: "text", readOnly: true },
{ path: "arrival_at", label: "Прибытие", kind: "text", readOnly: true },

View file

@ -548,6 +548,7 @@ export function Dds() {
<div><b>{field(card, "description")}</b></div>
<div>Класс: <b>{field(card, "incident_type")}</b></div>
<div>Получатели карточки: <b>{station?.recipient_services?.join(", ") || field(card, "notify")}</b></div>
{field(card, "notify_extra") !== "—" && <div>Добавлены оператором: <b>{field(card, "notify_extra")}</b></div>}
<div>Ваша ДДС: <b>{station?.managed_service || "служба не назначена"}</b></div></div>
</div>
<div className="dds-case-stage">

View file

@ -230,6 +230,17 @@ export const useIncidentGroups = () => useQuery({
queryFn: () => request<IncidentGroupsPage>("/api/ekp/groups"),
});
export interface ServiceCatalogPage {
source: string;
services: { id: string; name: string; organization: string | null; service_level: string | null; source_reference: string }[];
}
/** Каталог окна «Добавьте службы»: 209 служб, включая районные ДДС. */
export const useServiceCatalog = () => useQuery({
queryKey: ["ekp-services"], staleTime: Infinity,
queryFn: () => request<ServiceCatalogPage>("/api/ekp/services"),
});
export function useSigns(prefix: string[], group: number | null = null) {
const params = new URLSearchParams();
if (group !== null) params.set("group", String(group));

View file

@ -346,6 +346,8 @@ export interface KIO {
evacuation_needed?: boolean;
/** Список оповещения */
notify?: Array<string>;
/** Дополнительно оповестить */
notify_extra?: Array<string>;
/** Устаревшее: одна служба. Заменено списком оповещения */
dds?: DDSCode | null;
dispatch_order_at?: string | null;

View file

@ -783,6 +783,11 @@ button:disabled { opacity: 0.5; cursor: default; }
.kio-arm-incident .signs select { flex: 1 1 180px; width: auto; max-width: 100%; padding: 7px 8px; border: 1px solid #acb5bc; background: white; }
.kio-arm-ref .signs-selected { display: flex; flex: 1 1 100%; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
.kio-arm-ref .signs-selected span { padding: 7px 10px; background: #1876b4; color: white; font-size: 12px; font-weight: 700; }
.kio-services { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.kio-services .signs-selected { display: flex; flex-wrap: wrap; gap: 5px; }
.kio-services .signs-selected button { border: 0; background: transparent; color: inherit; cursor: pointer; padding: 0 0 0 4px; }
.kio-services-found { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); max-height: 220px; overflow-y: auto; }
.kio-services-found button { width: 100%; text-align: left; border: 0; border-bottom: 1px solid var(--line); background: var(--panel); padding: 6px 8px; cursor: pointer; }
.kio-arm-choice { display: flex; flex-wrap: wrap; gap: 6px; padding: 5px 0; }
.kio-arm-choice button { min-width: 49px; padding: 6px 11px; border: 1px solid #aeb6bd; background: white; font-size: 13px; font-weight: 700; }
.kio-arm-choice .kio-arm-selected { border-color: #1979bb; background: #1979bb; color: white; }