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:
parent
d4f0d0a0f8
commit
b8dc39fd7d
22 changed files with 628 additions and 9 deletions
|
|
@ -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):
|
||||
"""Уровень сложности сценария."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue