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

@ -130,6 +130,15 @@ async def _handle(session_id: UUID, state, event) -> None:
hub.to_observers(session_id, KioState(kio=state.kio))
hub.to_observers(session_id, TimerTick(timers=state.timers.snapshot()))
case "call.resolve":
# Курсант закрывает вызов не карточкой. Отдельное действие, а не
# «положил трубку»: система должна отличить осознанное решение
# от брошенного вызова (docs/spec/TICKETS.md).
state.resolved_outcome = event.outcome.value
state.resolve_comment = event.comment
state.on_event("call.resolve")
hub.to_observers(session_id, state.snapshot())
case "callback.dial":
state.on_event("callback.dial")

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

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:

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)

View file

@ -14,6 +14,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
"checklist_completeness": (ErrorCode.E1, Competency.INTERVIEW),
"address_refined": (ErrorCode.E1, Competency.INTERVIEW),
"interview_time": (ErrorCode.E3, Competency.NORMS),
"outcome": (ErrorCode.E2, Competency.ROUTING),
"incident_signs": (ErrorCode.E2, Competency.ROUTING),
"incident_type": (ErrorCode.E2, Competency.ROUTING),
"dds_choice": (ErrorCode.E2, Competency.ROUTING),
@ -32,6 +33,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
METRIC_WEIGHTS: dict[str, float] = {
"checklist_completeness": 4.0,
"address_refined": 2.0,
"outcome": 3.0,
"incident_signs": 2.0,
"incident_type": 2.0,
"dds_choice": 2.0,

View file

@ -35,6 +35,8 @@ async def finish(session_id: UUID, state) -> None:
refined_facts=list(state.slots.refined) if state.slots else None,
end_reason=state.end_reason,
bounced_fields=state.bounced_fields,
resolved_outcome=state.resolved_outcome,
dispatched=state.dispatched_card is not None,
)
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии

View file

@ -93,6 +93,9 @@ class SessionState:
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
#: Отметки служб по списку оповещения — предмет второго режима занятия.
status_log: list[StatusEntry] = field(default_factory=list)
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
resolved_outcome: str | None = None
resolve_comment: str = ""
def on_event(self, event_type: str) -> None:
"""Единственная точка, где событие двигает таймеры."""

View file

@ -34,6 +34,9 @@ STARTS: dict[str, tuple[TimerCode, ...]] = {
STOPS: dict[str, tuple[TimerCode, ...]] = {
"call.answer": (TimerCode.ANSWER,),
"dds.dispatch": (TimerCode.INTERVIEW,),
# Справка и передача в другой регион заканчивают опрос так же, как передача
# карточки: норматив опроса не должен тикать после решения (lct-36).
"call.resolve": (TimerCode.INTERVIEW,),
"card.ack": (TimerCode.DDS_ACK,),
"zone.decision": (TimerCode.ZONE_CHECK,),
"crew.arrived": (TimerCode.CLOSE,),

View file

@ -97,3 +97,26 @@ def test_changing_signs_recalculates_the_card():
def test_every_level_offers_something(level):
prefix = ["жилой дом", "подъезд"][: level - 1]
assert ekp.signs_at_level(level, prefix), f"уровень {level} пуст"
def test_victims_bring_the_ambulance():
"""Модификатор приходит не из классификатора, а из карточки: пострадавшие
в поле `victims_count` поднимают скорую (docs/spec/DATASET.md)."""
from app.domain.kio import KIO, derive_incident
quiet = derive_incident(KIO(signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"]))
hurt = derive_incident(
KIO(signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"], victims_count=5)
)
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(
KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True))
)
assert "МОСГАЗ" in card.notify

View file

@ -0,0 +1,136 @@
"""Исход вызова: не каждый звонок заканчивается карточкой.
Пункты приёмки lct-36. Материал — билет 1 заказчика: третий вызов в нём
из Волгоградской области, и правильное действие — передать, а не заводить
карточку (docs/spec/TICKETS.md).
"""
from pathlib import Path
import pytest
from app.domain.classifiers import Outcome
from app.domain.kio import KIO
from app.scenarios.loader import load_file, load_library
from app.scoring.gost import evaluate
from app.session.timers import SessionTimers
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
@pytest.fixture(scope="module")
def other_region():
return load_file(LIBRARY / "tickets" / "t01-3-child-other-region.yaml", LIBRARY)
@pytest.fixture(scope="module")
def ordinary():
return load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
def timeline() -> SessionTimers:
timers = SessionTimers()
timers.on_event("call.incoming", now=0.0)
timers.on_event("call.answer", now=5.0)
return timers
def score(scenario, *, resolved=None, dispatched=False, kio=None):
return evaluate(
scenario=scenario,
kio=kio or KIO(),
timers=timeline(),
revealed_facts=[fact.id for fact in scenario.facts],
refined_facts=[fact.id for fact in scenario.facts if fact.refine_on],
resolved_outcome=resolved,
dispatched=dispatched,
)
def test_default_outcome_is_a_card(ordinary):
assert ordinary.outcome is Outcome.CARD
def test_transfer_is_declared_by_the_scenario(other_region):
assert other_region.outcome is Outcome.TRANSFER_REGION
def test_correct_transfer_passes(other_region):
result = score(other_region, resolved="transfer_region")
metric = next(m for m in result.metrics if m.key == "outcome")
assert metric.passed
def test_filing_a_card_instead_of_transferring_is_an_error(other_region):
"""Курсант завёл карточку и отправил наряд в другой субъект — это ошибка,
которую прежняя оценка не видела вовсе."""
result = score(other_region, dispatched=True)
metric = next(m for m in result.metrics if m.key == "outcome")
assert not metric.passed
assert any(finding.code.value == "E2" for finding in result.findings)
def test_doing_nothing_is_also_an_error(other_region):
result = score(other_region)
assert not next(m for m in result.metrics if m.key == "outcome").passed
def test_empty_card_is_not_punished_when_no_card_is_expected(other_region):
"""Штрафовать за пустые поля там, где карточка не заводится, значит учить
заполнять лишнее."""
result = score(other_region, resolved="transfer_region")
keys = {metric.key for metric in result.metrics}
assert "required_fields" not in keys
assert "address" not in keys
assert "victims_count" not in keys
def test_ordinary_call_has_no_outcome_metric(ordinary):
"""У обычного вызова исход не обсуждается — метрики просто нет,
а не «пройдена»."""
result = score(ordinary, dispatched=True)
assert "outcome" not in {metric.key for metric in result.metrics}
def test_interview_still_counts_for_a_transfer(other_region):
"""Передать вызов — не значит не опрашивать: адрес и состояние ребёнка
другому региону всё равно нужны."""
result = score(other_region, resolved="transfer_region")
assert "checklist_completeness" in {metric.key for metric in result.metrics}
# ── билет как единица занятия ──
def test_ticket_one_is_three_calls_of_different_services():
"""Билет собран так, чтобы за одно занятие курсант прошёл разные службы."""
ticket = [s for s in load_library(LIBRARY) if s.ticket == 1]
assert len(ticket) == 3
assert sorted(s.position for s in ticket) == [1, 2, 3]
assert len({s.type for s in ticket}) == 3
def test_ticket_and_position_go_together():
from pydantic import ValidationError
from app.scenarios.schema import Scenario
with pytest.raises(ValidationError):
Scenario.model_validate(
{
"id": "broken",
"title": "Билет без номера вызова",
"type": "fire",
"level": "L1",
"ticket": 4,
"persona": {"base": "calm"},
"first_line": "Алло",
}
)
def test_mass_fight_notifies_both_police_and_medics():
"""Тот случай, где выбор одной службы был бы неверен по построению."""
scenario = load_file(LIBRARY / "tickets" / "t01-2-mass-fight.yaml", LIBRARY)
assert "МВД" in scenario.ground_truth.notify
assert "СМП" in scenario.ground_truth.notify