lct-38: каталог 209 служб из «СЛУЖБЫ 112» для ручного добавления в карточку
This commit is contained in:
parent
b4420cea16
commit
3ce3e768e1
13 changed files with 1715 additions and 6 deletions
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
state.patch_kio(event.fields)
|
||||
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,
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
1470
backend/app/domain/services_catalog.json
Normal file
1470
backend/app/domain/services_catalog.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
|
|
|
|||
110
backend/tests/test_service_catalog.py
Normal file
110
backend/tests/test_service_catalog.py
Normal 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 == ["ФСБ"]
|
||||
Loading…
Reference in a new issue