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

@ -10,6 +10,7 @@ import re
from dataclasses import dataclass, field
from app.domain import ekp
from app.domain.classifiers import OUTCOME_LABELS, Outcome
from app.domain.events import CallEndReason, Metric
from app.domain.kio import KIO, missing_fields
from app.domain.taxonomy import ERRORS, Competency, Finding, FindingSource
@ -112,6 +113,8 @@ def evaluate(
timers: SessionTimers,
revealed_facts: list[str] | None,
refined_facts: list[str] | None = None,
resolved_outcome: str | None = None,
dispatched: bool = False,
end_reason: CallEndReason | None = None,
bounced_fields: list[str] | None = None,
) -> GostResult:
@ -223,6 +226,26 @@ def evaluate(
finding=f"Карточка ушла в ДДС {actual}, верная — {truth.dds.value}",
)
# ── исход вызова, E2 ──
#
# Не каждый звонок заканчивается карточкой: «поругался с продавцом» — справка,
# вызов из другого субъекта передаётся туда (docs/spec/TICKETS.md). Курсант,
# заведший карточку на такой вызов, сделал лишнюю работу и занял расчёт.
expected_outcome = scenario.outcome
if expected_outcome is not Outcome.CARD:
actual = resolved_outcome or ("card" if dispatched else "ничего не решено")
build.add(
"outcome", "Исход вызова",
OUTCOME_LABELS.get(Outcome(actual), actual) if actual in set(Outcome) else actual,
OUTCOME_LABELS[expected_outcome],
passed=actual == expected_outcome.value,
ref="классификатор происшествий, группа 23 «Прочие происшествия»",
finding=(
f"Вызов закрыт как «{actual}», правильно — "
f"«{OUTCOME_LABELS[expected_outcome]}»"
),
)
# ── подтверждение места происшествия, E1 ──
#
# Адрес заявителя не всегда адрес происшествия: он называет тот, где стоит
@ -241,6 +264,11 @@ def evaluate(
)
# ── карточка, E5 ──
#
# Там, где карточка не заводится, пустые поля — не ошибка, а правильное
# поведение: штрафовать за них значит учить заполнять лишнее.
if expected_outcome is not Outcome.CARD:
return build.result
if truth.address:
written = kio.address or " ".join(filter(None, [kio.street, kio.building]))
expected = _normalize_address(truth.address)