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 чистый.
This commit is contained in:
Ivan Gerasimov 2026-09-20 08:33:30 +03:00
commit b8dc39fd7d
22 changed files with 628 additions and 9 deletions

View file

@ -65,7 +65,12 @@ def _derive_ground_truth(scenario: Scenario) -> Scenario:
f"версии {ekp.reference().version}"
)
scenario.ground_truth.incident_code = found.code
scenario.ground_truth.notify = ekp.notify_list(found.code)
# Эталонный список оповещения — с учётом обстоятельств вызова: пятеро
# пострадавших поднимают скорую, и без них эталон был бы неполон.
keys: list[str] = []
if scenario.ground_truth.victims:
keys.append("casualties")
scenario.ground_truth.notify = ekp.notify_list(found.code, keys)
return scenario
@ -154,14 +159,32 @@ def load_file(path: Path, root: Path) -> Scenario:
return _derive_ground_truth(scenario)
#: Подкаталоги, которые сценариями не являются.
NOT_SCENARIOS = {"checklists", "pregenerated"}
def scenario_files(root: Path) -> list[Path]:
"""Файлы сценариев, включая подкаталоги вроде `tickets/`.
Библиотека перестала быть плоской, когда в неё поехали билеты заказчика:
тридцать два билета по три вызова — это девяносто шесть файлов, и держать
их в одном каталоге рядом с чек-листами нельзя (docs/spec/TICKETS.md).
"""
return sorted(
path
for path in root.rglob("*.yaml")
if not set(path.relative_to(root).parts[:-1]) & NOT_SCENARIOS
)
def load_library(root: Path) -> list[Scenario]:
"""Все сценарии каталога. Подкаталог `checklists/` — не сценарии."""
"""Все сценарии каталога и его подкаталогов."""
if not root.exists():
raise ScenarioError(f"каталог сценариев не найден: {root}")
scenarios: list[Scenario] = []
seen: dict[str, Path] = {}
for path in sorted(root.glob("*.yaml")):
for path in scenario_files(root):
scenario = load_file(path, root)
if scenario.id in seen:
raise ScenarioError(f"{path.name}: id {scenario.id} уже занят {seen[scenario.id].name}")

View file

@ -7,7 +7,7 @@
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.domain.classifiers import DDSCode, IncidentType, Level
from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
from app.domain.events import Mood
@ -145,6 +145,15 @@ class Scenario(Strict):
# Из них загрузчик выводит код и список оповещения (domain/ekp.py).
signs: list[str] = Field(default_factory=list, max_length=3)
# Билет — единица занятия у заказчика: три вызова подряд, разные службы
# (docs/spec/TICKETS.md). Преподаватель выбирает билет, а не сценарий.
ticket: int | None = None
position: int | None = Field(default=None, ge=1, le=3, description="Номер вызова в билете")
# Чем вызов заканчивается правильно. По умолчанию — карточка и выезд;
# справка и передача в другой регион разбираются в lct-36.
outcome: Outcome = Outcome.CARD
facts: list[Fact] = []
checklist: list[ChecklistItem] = []
required_fields: list[str] = Field(default_factory=list)
@ -156,6 +165,12 @@ class Scenario(Strict):
era_glonass: EraGlonass | None = None
tree: Tree = Tree()
@model_validator(mode="after")
def ticket_needs_position(self):
if (self.ticket is None) != (self.position is None):
raise ValueError("ticket и position задаются вместе: билет без номера вызова неполон")
return self
@model_validator(mode="after")
def era_only_for_era_type(self):
if self.era_glonass is not None and self.type is not IncidentType.ERA_GLONASS: