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
|
|
|
|
"""Справочник ЕКП и карточка, которая из него считается.
|
|
|
|
|
|
|
|
|
|
|
|
Проверяются пункты приёмки lct-32: признаки дают код, список оповещения
|
|
|
|
|
|
производен и не выбирается руками, сценарий размечается признаками.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-09-27 23:30:59 +03:00
|
|
|
|
import hashlib
|
|
|
|
|
|
import json
|
2026-09-21 20:10:57 +03:00
|
|
|
|
from pathlib import Path
|
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
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
import pytest
|
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
|
|
|
|
from app.domain import ekp
|
|
|
|
|
|
from app.domain.kio import KIO, derive_incident
|
2026-09-21 20:10:57 +03:00
|
|
|
|
from app.scenarios.loader import load_library
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-26 18:12:27 +03:00
|
|
|
|
assert all(incident.type for incident in reference.incidents), (
|
|
|
|
|
|
"код без итогового типа"
|
|
|
|
|
|
)
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-21 20:10:57 +03:00
|
|
|
|
def test_all_customer_ticket_cards_are_complete_and_classified():
|
2026-09-26 18:12:27 +03:00
|
|
|
|
cards = [
|
|
|
|
|
|
scenario
|
|
|
|
|
|
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
|
|
|
|
|
if scenario.ticket is not None
|
|
|
|
|
|
]
|
2026-09-21 20:10:57 +03:00
|
|
|
|
assert len(cards) == 96
|
|
|
|
|
|
assert {scenario.ticket for scenario in cards} == set(range(1, 33))
|
|
|
|
|
|
for ticket in range(1, 33):
|
2026-09-26 18:12:27 +03:00
|
|
|
|
assert {
|
|
|
|
|
|
scenario.position for scenario in cards if scenario.ticket == ticket
|
|
|
|
|
|
} == {1, 2, 3}
|
|
|
|
|
|
assert all(scenario.facts and scenario.ground_truth.address for scenario in cards)
|
|
|
|
|
|
unclassified_ids = {
|
|
|
|
|
|
"t02-3-car-in-water", # источник не уточняет, был ли человек в воде
|
|
|
|
|
|
"t03-2-loud-music", # время, нужное для признака тишины, не дано
|
|
|
|
|
|
"t05-3-worker-in-pit", # падение в котлован не означает обрушение/коммуникации
|
|
|
|
|
|
"t22-3-suicide-sms", # намерение в СМС не подтверждает попытку/приготовление
|
|
|
|
|
|
"t26-3-death-care-home", # точный код для смерти в центре не подтверждён
|
|
|
|
|
|
"t32-1-carjacking", # срок угона для кода не дан
|
|
|
|
|
|
"t32-3-street-lights", # время суток для признака не дано
|
|
|
|
|
|
}
|
|
|
|
|
|
assert {scenario.id for scenario in cards if not scenario.signs} == unclassified_ids
|
|
|
|
|
|
assert all(
|
|
|
|
|
|
scenario.ground_truth.incident_code is None and not scenario.ground_truth.notify
|
|
|
|
|
|
for scenario in cards
|
|
|
|
|
|
if scenario.id in unclassified_ids
|
|
|
|
|
|
)
|
|
|
|
|
|
assert all(
|
|
|
|
|
|
scenario.signs
|
|
|
|
|
|
and scenario.ground_truth.incident_code
|
|
|
|
|
|
and ekp.incident(scenario.ground_truth.incident_code)
|
|
|
|
|
|
for scenario in cards
|
|
|
|
|
|
if scenario.id not in unclassified_ids
|
|
|
|
|
|
)
|
|
|
|
|
|
unknown_victim_counts = {
|
|
|
|
|
|
scenario.id for scenario in cards if scenario.ground_truth.victims is None
|
|
|
|
|
|
}
|
2026-09-24 01:10:49 +03:00
|
|
|
|
assert unknown_victim_counts == {
|
2026-09-26 18:12:27 +03:00
|
|
|
|
"t02-2-megafon-consultation",
|
|
|
|
|
|
"t02-3-car-in-water",
|
|
|
|
|
|
"t03-2-loud-music",
|
|
|
|
|
|
"t04-1-balcony-fire",
|
|
|
|
|
|
"t04-3-open-door",
|
|
|
|
|
|
"t12-3-men-on-bridge-rail",
|
|
|
|
|
|
"t07-3-lost-elderly",
|
|
|
|
|
|
"t08-1-mall-smoke",
|
|
|
|
|
|
"t14-1-grass-fire-azs",
|
|
|
|
|
|
"t17-1-fire-alarm",
|
|
|
|
|
|
"t12-1-restaurant-smoke",
|
|
|
|
|
|
"t30-3-gas-smell-house",
|
|
|
|
|
|
"t31-3-gas-pipe-whistle",
|
|
|
|
|
|
"t11-3-lost-in-forest",
|
|
|
|
|
|
"t16-1-smoke-column",
|
|
|
|
|
|
"t18-1-unknown-fire",
|
|
|
|
|
|
"t24-1-parking-quarrel",
|
|
|
|
|
|
"t25-1-drunk-at-stop",
|
|
|
|
|
|
"t23-3-lost-child",
|
|
|
|
|
|
"t27-1-suspicious-car",
|
|
|
|
|
|
"t28-1-stranger-at-door",
|
|
|
|
|
|
"t29-1-ticking-box",
|
|
|
|
|
|
"t29-3-threat-to-blow-up",
|
|
|
|
|
|
"t30-1-car-theft-yesterday",
|
|
|
|
|
|
"t31-1-car-theft-witnessed",
|
|
|
|
|
|
"t32-1-carjacking",
|
|
|
|
|
|
"t32-3-street-lights",
|
2026-09-24 01:10:49 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-27 23:30:59 +03:00
|
|
|
|
def test_ticket_ekp_codes_and_notify_lists_match_current_baseline():
|
|
|
|
|
|
"""Изменение правил оповещения не должно незаметно переписать 96 эталонов.
|
|
|
|
|
|
|
|
|
|
|
|
При намеренной смене версии ЕКП сначала сверяют каждый изменившийся список
|
|
|
|
|
|
с источником и методистом, затем обновляют контрольную сумму.
|
|
|
|
|
|
"""
|
|
|
|
|
|
cards = [
|
|
|
|
|
|
scenario
|
|
|
|
|
|
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
|
|
|
|
|
if scenario.ticket is not None
|
|
|
|
|
|
]
|
|
|
|
|
|
assert len(cards) == 96
|
|
|
|
|
|
manifest = {
|
|
|
|
|
|
scenario.id: {
|
|
|
|
|
|
"code": scenario.ground_truth.incident_code,
|
|
|
|
|
|
"notify": scenario.ground_truth.notify,
|
|
|
|
|
|
}
|
|
|
|
|
|
for scenario in cards
|
|
|
|
|
|
}
|
|
|
|
|
|
encoded = json.dumps(manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
|
|
|
|
assert hashlib.sha256(encoded.encode()).hexdigest() == (
|
|
|
|
|
|
"2e3d49cc1e4a47bf8813437207a4105799c5721cc54bddc795730267ebb44df4"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
def test_ticket_two_preserves_moscow_and_does_not_invent_missing_victim_data():
|
|
|
|
|
|
root = Path(__file__).resolve().parents[2] / "scenarios"
|
|
|
|
|
|
cards = {
|
|
|
|
|
|
scenario.id: scenario
|
|
|
|
|
|
for scenario in load_library(root)
|
|
|
|
|
|
if scenario.id
|
|
|
|
|
|
in {
|
|
|
|
|
|
"t02-1-smoke-chute",
|
|
|
|
|
|
"t02-2-megafon-consultation",
|
|
|
|
|
|
"t02-3-car-in-water",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
assert len(cards) == 3
|
|
|
|
|
|
for scenario in cards.values():
|
|
|
|
|
|
assert scenario.ground_truth.address.startswith("Москва,")
|
|
|
|
|
|
address_fact = next(fact for fact in scenario.facts if fact.id == "f_address")
|
|
|
|
|
|
assert address_fact.value.startswith("Москва,")
|
|
|
|
|
|
|
|
|
|
|
|
consultation = cards["t02-2-megafon-consultation"]
|
|
|
|
|
|
victim_fact = next(fact for fact in consultation.facts if fact.id == "f_victims")
|
|
|
|
|
|
assert victim_fact.value == "в исходном билете сведения о пострадавших не указаны"
|
|
|
|
|
|
assert consultation.ground_truth.victims is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ticket_14_refined_address_retains_azs_and_approach_landmarks():
|
|
|
|
|
|
scenario = next(
|
|
|
|
|
|
scenario
|
|
|
|
|
|
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
|
|
|
|
|
if scenario.id == "t14-1-grass-fire-azs"
|
|
|
|
|
|
)
|
|
|
|
|
|
address = next(fact for fact in scenario.facts if fact.id == "f_address")
|
|
|
|
|
|
for landmark in ("АЗС", "Роснефть", "не доезжая до Расторгуевского шоссе"):
|
|
|
|
|
|
assert landmark in address.value
|
|
|
|
|
|
assert landmark in address.refined
|
|
|
|
|
|
assert landmark in scenario.ground_truth.address
|
|
|
|
|
|
assert "25 км по столбам в сторону Москвы" in address.refined
|
|
|
|
|
|
assert "владение 2" in scenario.ground_truth.address
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ticket_05_02_does_not_invent_callers_age():
|
|
|
|
|
|
scenario = next(
|
|
|
|
|
|
scenario
|
|
|
|
|
|
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
|
|
|
|
|
if scenario.id == "t05-2-wrong-medicine"
|
|
|
|
|
|
)
|
|
|
|
|
|
who = next(fact for fact in scenario.facts if fact.id == "f_who")
|
|
|
|
|
|
assert who.value == "Иванова Ирина Петровна, дата рождения 10.03.1975"
|
|
|
|
|
|
assert "на вид" not in who.value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ticket_25_01_refined_address_keeps_stop_and_side_landmarks():
|
|
|
|
|
|
scenario = next(
|
|
|
|
|
|
scenario
|
|
|
|
|
|
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
|
|
|
|
|
if scenario.id == "t25-1-drunk-at-stop"
|
|
|
|
|
|
)
|
|
|
|
|
|
address = next(fact for fact in scenario.facts if fact.id == "f_address")
|
|
|
|
|
|
assert "62" in address.refined
|
|
|
|
|
|
assert "Штурвальная" in address.refined
|
|
|
|
|
|
assert "на стороне улицы Фабрициуса, дом 18" in address.refined
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ticket_address_code_is_not_guessed_to_be_an_intercom():
|
|
|
|
|
|
root = Path(__file__).resolve().parents[2] / "scenarios"
|
|
|
|
|
|
expected_codes = {
|
|
|
|
|
|
"t04-3-open-door": "45В",
|
|
|
|
|
|
"t05-2-wrong-medicine": "142",
|
|
|
|
|
|
"t17-1-fire-alarm": "2215",
|
|
|
|
|
|
"t18-2-husband-wont-wake": "5В",
|
|
|
|
|
|
"t22-1-flat-fight": "2В",
|
|
|
|
|
|
"t23-1-drunk-husband": "80В",
|
|
|
|
|
|
"t28-1-stranger-at-door": "100",
|
|
|
|
|
|
"t29-3-threat-to-blow-up": "67",
|
|
|
|
|
|
"t31-3-gas-pipe-whistle": "5В",
|
|
|
|
|
|
}
|
|
|
|
|
|
cards = {
|
|
|
|
|
|
scenario.id: scenario
|
|
|
|
|
|
for scenario in load_library(root)
|
|
|
|
|
|
if scenario.id in expected_codes
|
|
|
|
|
|
}
|
|
|
|
|
|
assert cards.keys() == expected_codes.keys()
|
|
|
|
|
|
for scenario_id, code in expected_codes.items():
|
|
|
|
|
|
address = cards[scenario_id].ground_truth.address
|
|
|
|
|
|
assert address.endswith(f"код {code}")
|
|
|
|
|
|
assert "домофон" not in address.casefold()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ticket_09_02_retains_the_source_motorway_designation_in_caller_facts():
|
|
|
|
|
|
scenario = next(
|
|
|
|
|
|
scenario
|
|
|
|
|
|
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
|
|
|
|
|
if scenario.id == "t09-2-labour"
|
|
|
|
|
|
)
|
|
|
|
|
|
address_fact = next(fact for fact in scenario.facts if fact.id == "f_address")
|
|
|
|
|
|
assert "Горьковского шоссе (М7)" in address_fact.value
|
|
|
|
|
|
assert "Горьковского шоссе (М7)" in scenario.ground_truth.address
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ticket_18_01_retains_both_source_motorway_designations_in_caller_facts():
|
|
|
|
|
|
scenario = next(
|
|
|
|
|
|
scenario
|
|
|
|
|
|
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
|
|
|
|
|
if scenario.id == "t18-1-unknown-fire"
|
|
|
|
|
|
)
|
|
|
|
|
|
address_fact = next(fact for fact in scenario.facts if fact.id == "f_address")
|
|
|
|
|
|
assert "М3" in address_fact.value
|
|
|
|
|
|
assert "М1" in address_fact.value
|
|
|
|
|
|
assert "М3" in scenario.ground_truth.address
|
|
|
|
|
|
assert "М1" in scenario.ground_truth.address
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ticket_8_2_preserves_both_highway_designations_from_source():
|
|
|
|
|
|
scenario = next(
|
|
|
|
|
|
item
|
|
|
|
|
|
for item in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
|
|
|
|
|
if item.id == "t08-2-unconscious-roadside"
|
|
|
|
|
|
)
|
|
|
|
|
|
address_fact = next(fact for fact in scenario.facts if fact.id == "f_address")
|
|
|
|
|
|
for designation in ("М3", "М1"):
|
|
|
|
|
|
assert designation in address_fact.value
|
|
|
|
|
|
assert designation in scenario.ground_truth.address
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ticket_five_preserves_source_observation_without_inventing_age():
|
|
|
|
|
|
cards = {
|
|
|
|
|
|
item.id: item
|
|
|
|
|
|
for item in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
|
|
|
|
|
if item.id in {"t05-1-window-fire-people", "t05-2-wrong-medicine"}
|
|
|
|
|
|
}
|
|
|
|
|
|
assert len(cards) == 2
|
|
|
|
|
|
fire_facts = {fact.id: fact.value for fact in cards["t05-1-window-fire-people"].facts}
|
|
|
|
|
|
assert fire_facts["f_observer"] == "заявитель наблюдает за пожаром с улицы"
|
|
|
|
|
|
medicine_facts = {fact.id: fact.value for fact in cards["t05-2-wrong-medicine"].facts}
|
|
|
|
|
|
assert medicine_facts["f_who"] == "Иванова Ирина Петровна, дата рождения 10.03.1975"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ticket_seven_preserves_ambiguous_address_code_verbatim():
|
|
|
|
|
|
scenario = next(
|
|
|
|
|
|
item
|
|
|
|
|
|
for item in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
|
|
|
|
|
if item.id == "t07-3-lost-elderly"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert scenario.ground_truth.address.endswith("код 5В")
|
|
|
|
|
|
assert "домофон" not in scenario.ground_truth.address
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
def test_unknown_ticket_victim_count_is_not_scored():
|
|
|
|
|
|
from app.scoring.card import evaluate_card
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
cards = [
|
|
|
|
|
|
scenario
|
|
|
|
|
|
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
|
|
|
|
|
if scenario.ticket is not None and scenario.ground_truth.victims is None
|
|
|
|
|
|
]
|
2026-09-24 01:10:49 +03:00
|
|
|
|
assert len(cards) == 27
|
2026-09-26 18:12:27 +03:00
|
|
|
|
assert all(
|
|
|
|
|
|
"victims_count"
|
|
|
|
|
|
not in {metric.key for metric in evaluate_card(scenario, KIO()).metrics}
|
|
|
|
|
|
for scenario in cards
|
|
|
|
|
|
)
|
2026-09-21 20:10:57 +03:00
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
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, "«мусор» относится к улице, а не к жилому дому"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-21 20:10:57 +03:00
|
|
|
|
def test_category_filters_all_three_incident_choices_and_card_derivation():
|
|
|
|
|
|
fire = ekp.by_signs(["жилой дом", "балкон", "открытое пламя"])
|
|
|
|
|
|
assert fire is not None
|
|
|
|
|
|
other_group = next(group.id for group in ekp.groups() if group.id != fire.group)
|
|
|
|
|
|
assert ekp.by_signs(fire.signs, fire.group) == fire
|
|
|
|
|
|
assert ekp.by_signs(fire.signs, other_group) is None
|
|
|
|
|
|
assert fire.signs[0] in ekp.signs_at_level(1, group=fire.group)
|
|
|
|
|
|
assert fire.signs[1] in ekp.signs_at_level(2, fire.signs[:1], group=fire.group)
|
|
|
|
|
|
assert fire.signs[2] in ekp.signs_at_level(3, fire.signs[:2], group=fire.group)
|
|
|
|
|
|
card = derive_incident(KIO(incident_group=fire.group, signs=fire.signs))
|
|
|
|
|
|
assert card.incident_code == fire.code
|
2026-09-26 18:12:27 +03:00
|
|
|
|
assert (
|
|
|
|
|
|
derive_incident(KIO(incident_group=other_group, signs=fire.signs)).incident_code
|
|
|
|
|
|
is None
|
|
|
|
|
|
)
|
2026-09-21 20:10:57 +03:00
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
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} пуст"
|
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта.
Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка
этого не видела — наоборот, награждала за полноту заполнения.
- Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта
и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система
должна отличить осознанное решение от брошенного вызова.
- Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака).
- Там, где карточка не заводится, метрики карточки не считаются вовсе.
Полнота опроса считается: передать вызов не значит не опрашивать.
- call.resolve останавливает норматив опроса, как и передача карточки.
Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля
ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1
целиком: три вызова, три классификатора, третий — из другого региона.
Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему.
В классификаторе скорая добавляется к массовой драке признаком «пострадавшие»,
но взять его было неоткуда. Теперь модификаторы берутся из карточки —
victims_count, life_threat, evacuation_needed, fire.gasified, — и список
оповещения пересобирается при правке любого из этих полей.
161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_victims_bring_the_ambulance():
|
|
|
|
|
|
"""Модификатор приходит не из классификатора, а из карточки: пострадавшие
|
|
|
|
|
|
в поле `victims_count` поднимают скорую (docs/spec/DATASET.md)."""
|
|
|
|
|
|
from app.domain.kio import KIO, derive_incident
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
quiet = derive_incident(
|
|
|
|
|
|
KIO(signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"])
|
|
|
|
|
|
)
|
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта.
Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка
этого не видела — наоборот, награждала за полноту заполнения.
- Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта
и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система
должна отличить осознанное решение от брошенного вызова.
- Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака).
- Там, где карточка не заводится, метрики карточки не считаются вовсе.
Полнота опроса считается: передать вызов не значит не опрашивать.
- call.resolve останавливает норматив опроса, как и передача карточки.
Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля
ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1
целиком: три вызова, три классификатора, третий — из другого региона.
Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему.
В классификаторе скорая добавляется к массовой драке признаком «пострадавшие»,
но взять его было неоткуда. Теперь модификаторы берутся из карточки —
victims_count, life_threat, evacuation_needed, fire.gasified, — и список
оповещения пересобирается при правке любого из этих полей.
161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
|
|
|
|
hurt = derive_incident(
|
2026-09-26 18:12:27 +03:00
|
|
|
|
KIO(
|
|
|
|
|
|
signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"],
|
|
|
|
|
|
victims_count=5,
|
|
|
|
|
|
)
|
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта.
Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка
этого не видела — наоборот, награждала за полноту заполнения.
- Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта
и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система
должна отличить осознанное решение от брошенного вызова.
- Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака).
- Там, где карточка не заводится, метрики карточки не считаются вовсе.
Полнота опроса считается: передать вызов не значит не опрашивать.
- call.resolve останавливает норматив опроса, как и передача карточки.
Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля
ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1
целиком: три вызова, три классификатора, третий — из другого региона.
Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему.
В классификаторе скорая добавляется к массовой драке признаком «пострадавшие»,
но взять его было неоткуда. Теперь модификаторы берутся из карточки —
victims_count, life_threat, evacuation_needed, fire.gasified, — и список
оповещения пересобирается при правке любого из этих полей.
161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
|
|
|
|
)
|
|
|
|
|
|
assert "СМП" not in quiet.notify
|
|
|
|
|
|
assert "СМП" in hurt.notify
|
|
|
|
|
|
assert set(quiet.notify) < set(hurt.notify), "модификатор только добавляет службы"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_gasified_object_brings_mosgaz():
|
|
|
|
|
|
from app.domain.kio import KIO, FireDetails, derive_incident
|
|
|
|
|
|
|
|
|
|
|
|
card = derive_incident(
|
2026-09-26 18:12:27 +03:00
|
|
|
|
KIO(
|
|
|
|
|
|
signs=["жилой дом", "балкон", "открытое пламя"],
|
|
|
|
|
|
fire=FireDetails(gasified=True),
|
|
|
|
|
|
)
|
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта.
Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка
этого не видела — наоборот, награждала за полноту заполнения.
- Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта
и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система
должна отличить осознанное решение от брошенного вызова.
- Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака).
- Там, где карточка не заводится, метрики карточки не считаются вовсе.
Полнота опроса считается: передать вызов не значит не опрашивать.
- call.resolve останавливает норматив опроса, как и передача карточки.
Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля
ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1
целиком: три вызова, три классификатора, третий — из другого региона.
Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему.
В классификаторе скорая добавляется к массовой драке признаком «пострадавшие»,
но взять его было неоткуда. Теперь модификаторы берутся из карточки —
victims_count, life_threat, evacuation_needed, fire.gasified, — и список
оповещения пересобирается при правке любого из этих полей.
161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
|
|
|
|
)
|
|
|
|
|
|
assert "МОСГАЗ" in card.notify
|
2026-09-21 17:40:54 +03:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-27 23:38:20 +03:00
|
|
|
|
def test_explicit_offence_signs_add_police_and_removal_recalculates_notify():
|
|
|
|
|
|
from app.domain.kio import apply_patch, modifiers
|
|
|
|
|
|
|
|
|
|
|
|
signs = ["на улице", "мусор", "открытое пламя"]
|
|
|
|
|
|
base = derive_incident(KIO(signs=signs))
|
|
|
|
|
|
assert base.incident_code == "1010101"
|
|
|
|
|
|
assert "МВД" not in base.notify
|
|
|
|
|
|
|
|
|
|
|
|
stated = apply_patch(base, {"offence_signs": True})
|
|
|
|
|
|
assert "offence" in modifiers(stated)
|
|
|
|
|
|
assert set(stated.notify) == set(base.notify) | {"МВД"}
|
|
|
|
|
|
|
|
|
|
|
|
denied = apply_patch(stated, {"offence_signs": False})
|
|
|
|
|
|
assert denied.notify == base.notify
|
|
|
|
|
|
unknown = apply_patch(stated, {"offence_signs": None})
|
|
|
|
|
|
assert unknown.notify == base.notify
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_offence_kind_also_adds_police_until_both_sources_are_removed():
|
|
|
|
|
|
from app.domain.kio import apply_patch, modifiers
|
|
|
|
|
|
|
|
|
|
|
|
base = derive_incident(KIO(signs=["на улице", "мусор", "открытое пламя"]))
|
|
|
|
|
|
stated = apply_patch(base, {"police.offence_kind": "поджог"})
|
|
|
|
|
|
assert "offence" in modifiers(stated)
|
|
|
|
|
|
assert set(stated.notify) == set(base.notify) | {"МВД"}
|
|
|
|
|
|
|
|
|
|
|
|
denied_flag = apply_patch(stated, {"offence_signs": False})
|
|
|
|
|
|
assert "МВД" in denied_flag.notify, "явно указанный вид правонарушения остаётся источником"
|
|
|
|
|
|
with_both = apply_patch(stated, {"offence_signs": True})
|
|
|
|
|
|
assert modifiers(with_both).count("offence") == 1
|
|
|
|
|
|
without_kind = apply_patch(with_both, {"police.offence_kind": " "})
|
|
|
|
|
|
assert "МВД" in without_kind.notify
|
|
|
|
|
|
without_flag = apply_patch(without_kind, {"offence_signs": False})
|
|
|
|
|
|
assert without_flag.notify == base.notify
|
|
|
|
|
|
assert "offence" not in modifiers(without_flag)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
def test_removed_modifier_recalculates_notify_without_stale_service():
|
|
|
|
|
|
from app.domain.kio import KIO, FireDetails, apply_patch, derive_incident
|
|
|
|
|
|
|
|
|
|
|
|
card = derive_incident(
|
2026-09-26 18:12:27 +03:00
|
|
|
|
KIO(
|
|
|
|
|
|
signs=["жилой дом", "балкон", "открытое пламя"],
|
|
|
|
|
|
fire=FireDetails(gasified=True),
|
|
|
|
|
|
)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
)
|
|
|
|
|
|
assert "МОСГАЗ" in card.notify
|
|
|
|
|
|
updated = apply_patch(card, {"fire.gasified": False})
|
|
|
|
|
|
assert "МОСГАЗ" not in updated.notify
|