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

@ -27,6 +27,28 @@ class IncidentType(StrEnum):
ERA_GLONASS = "era_glonass"
class Outcome(StrEnum):
"""Чем заканчивается вызов.
Весь продукт по умолчанию считает, что звонок → карточка → выезд. В билетах
заказчика это не всегда так: «поругался с продавцом „Мегафон“» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта
(docs/spec/TICKETS.md). Курсант, заведший карточку на такой вызов, ошибся,
и система обязана это увидеть.
"""
CARD = "card" # заполнить КИО и передать в ДДС
CONSULTATION = "consultation" # справка, карточка не заводится
TRANSFER_REGION = "transfer_region" # передать в систему-112 другого субъекта
OUTCOME_LABELS: dict[Outcome, str] = {
Outcome.CARD: "Карточка и передача в ДДС",
Outcome.CONSULTATION: "Справочно-консультационная помощь",
Outcome.TRANSFER_REGION: "Передача в систему-112 другого региона",
}
class Level(StrEnum):
"""Уровень сложности сценария."""

View file

@ -17,7 +17,7 @@ from uuid import UUID
from pydantic import BaseModel, Field
from app.domain.classifiers import DDSCode, IncidentType, Level
from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
from app.domain.kio import KIO, Coords
from app.domain.statuses import ServiceStatus, StationSnapshot
from app.domain.taxonomy import Finding
@ -278,12 +278,26 @@ class CallbackDial(BaseModel):
type: Literal["callback.dial"] = "callback.dial"
class CallResolve(BaseModel):
"""Курсант решил, что карточка здесь не заводится.
Не «завершить звонок», а именно «этот вызов закрывается иначе»: справкой
или передачей в другой регион. Решение оценивается наравне с выбором
признаков — это та же классификация, только на шаг раньше.
"""
type: Literal["call.resolve"] = "call.resolve"
outcome: Outcome
comment: str = ""
TraineeToServer = Annotated[
CallAnswer
| KioPatchIn
| HintRequest
| SelfAssessmentSubmit
| DdsDispatch
| CallResolve
| CallHangup
| CallbackDial,
Field(discriminator="type"),

View file

@ -159,6 +159,28 @@ def missing_fields(card: KIO, required: list[str]) -> list[str]:
return empty
#: Поля карточки, которые меняют состав списка оповещения. В боевом АРМ это
#: те же «признаки», только не про тип происшествия, а про обстоятельства:
#: пострадавшие поднимают скорую, газификация — МОСГАЗ (domain/ekp.py).
MODIFIER_FIELDS: frozenset[str] = frozenset(
{"signs", "victims_count", "life_threat", "evacuation_needed", "fire.gasified"}
)
def modifiers(card: KIO) -> list[str]:
"""Какие условия оповещения включены обстоятельствами вызова."""
keys: list[str] = []
if card.victims_count:
keys.append("casualties")
if card.life_threat:
keys.append("life_threat")
if card.evacuation_needed:
keys.append("evacuation")
if card.fire is not None and card.fire.gasified:
keys.append("gas")
return keys
def derive_incident(card: KIO) -> KIO:
"""Пересчитать код происшествия и список оповещения по признакам.
@ -168,11 +190,13 @@ def derive_incident(card: KIO) -> KIO:
from app.domain import ekp
found = ekp.by_signs(card.signs)
keys = modifiers(card)
data = card.model_dump()
data["incident_code"] = found.code if found else None
computed = ekp.notify_list(found.code, keys) if found else []
# Добавленную вручную службу не теряем: удалить её оператор не может.
manual = [name for name in card.notify if name not in ekp.notify_list(data["incident_code"] or "")]
data["notify"] = (ekp.notify_list(found.code) if found else []) + manual
manual = [name for name in card.notify if name not in ekp.notify_list(data["incident_code"] or "", keys)]
data["notify"] = computed + manual
return KIO.model_validate(data)
@ -193,4 +217,7 @@ def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
nested[tail] = value
data[head] = nested
updated = KIO.model_validate(data)
return derive_incident(updated) if "signs" in fields else updated
# Пересчитываем не только на смену признаков: пострадавшие поднимают скорую,
# газификация — МОСГАЗ, и список оповещения обязан это отразить сразу.
touched = MODIFIER_FIELDS & set(fields)
return derive_incident(updated) if touched else updated