From b8dc39fd7dab4bb75217ad331ed504f84245ba5b Mon Sep 17 00:00:00 2001
From: Ivan Gerasimov
Date: Sun, 20 Sep 2026 08:33:30 +0300
Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B8=D1=81=D1=85=D0=BE=D0=B4=20=D0=B2?=
=?UTF-8?q?=D1=8B=D0=B7=D0=BE=D0=B2=D0=B0=20=E2=80=94=20=D0=BD=D0=B5=20?=
=?UTF-8?q?=D0=BA=D0=B0=D0=B6=D0=B4=D1=8B=D0=B9=20=D0=B7=D0=B2=D0=BE=D0=BD?=
=?UTF-8?q?=D0=BE=D0=BA=20=D0=B7=D0=B0=D0=BA=D0=B0=D0=BD=D1=87=D0=B8=D0=B2?=
=?UTF-8?q?=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?=
=?UTF-8?q?=D1=87=D0=BA=D0=BE=D0=B9=20(lct-36)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-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 чистый.
---
backend/app/api/ws/call.py | 9 ++
backend/app/domain/classifiers.py | 22 +++
backend/app/domain/events.py | 16 ++-
backend/app/domain/kio.py | 33 ++++-
backend/app/scenarios/loader.py | 29 +++-
backend/app/scenarios/schema.py | 17 ++-
backend/app/scoring/gost.py | 28 ++++
backend/app/scoring/taxonomy.py | 2 +
backend/app/session/finish.py | 2 +
backend/app/session/state.py | 3 +
backend/app/session/timers.py | 3 +
backend/tests/test_ekp.py | 23 +++
backend/tests/test_outcomes.py | 136 ++++++++++++++++++
frontend/src/features/call/useCall.ts | 9 ++
frontend/src/pages/trainee/Call.tsx | 45 ++++++
frontend/src/shared/types/generated.ts | 13 +-
scenarios/checklists/medical.yaml | 26 ++++
scenarios/checklists/police.yaml | 26 ++++
scenarios/checklists/utility.yaml | 20 +++
scenarios/tickets/t01-1-fire-container.yaml | 53 +++++++
scenarios/tickets/t01-2-mass-fight.yaml | 58 ++++++++
.../tickets/t01-3-child-other-region.yaml | 64 +++++++++
22 files changed, 628 insertions(+), 9 deletions(-)
create mode 100644 backend/tests/test_outcomes.py
create mode 100644 scenarios/checklists/medical.yaml
create mode 100644 scenarios/checklists/police.yaml
create mode 100644 scenarios/checklists/utility.yaml
create mode 100644 scenarios/tickets/t01-1-fire-container.yaml
create mode 100644 scenarios/tickets/t01-2-mass-fight.yaml
create mode 100644 scenarios/tickets/t01-3-child-other-region.yaml
diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py
index aab37f9..bdd7487 100644
--- a/backend/app/api/ws/call.py
+++ b/backend/app/api/ws/call.py
@@ -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")
diff --git a/backend/app/domain/classifiers.py b/backend/app/domain/classifiers.py
index 8fdee65..a5edad9 100644
--- a/backend/app/domain/classifiers.py
+++ b/backend/app/domain/classifiers.py
@@ -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):
"""Уровень сложности сценария."""
diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py
index 6686a47..75f3c6f 100644
--- a/backend/app/domain/events.py
+++ b/backend/app/domain/events.py
@@ -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"),
diff --git a/backend/app/domain/kio.py b/backend/app/domain/kio.py
index 5f0809f..035bacd 100644
--- a/backend/app/domain/kio.py
+++ b/backend/app/domain/kio.py
@@ -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
diff --git a/backend/app/scenarios/loader.py b/backend/app/scenarios/loader.py
index da8f241..d11d9b8 100644
--- a/backend/app/scenarios/loader.py
+++ b/backend/app/scenarios/loader.py
@@ -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}")
diff --git a/backend/app/scenarios/schema.py b/backend/app/scenarios/schema.py
index 97bef07..99abffa 100644
--- a/backend/app/scenarios/schema.py
+++ b/backend/app/scenarios/schema.py
@@ -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:
diff --git a/backend/app/scoring/gost.py b/backend/app/scoring/gost.py
index 544a976..6b31561 100644
--- a/backend/app/scoring/gost.py
+++ b/backend/app/scoring/gost.py
@@ -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)
diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py
index cd45e9e..ead5fab 100644
--- a/backend/app/scoring/taxonomy.py
+++ b/backend/app/scoring/taxonomy.py
@@ -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,
diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py
index 0feca6a..a94975a 100644
--- a/backend/app/session/finish.py
+++ b/backend/app/session/finish.py
@@ -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 → ДДС в одном занятии
diff --git a/backend/app/session/state.py b/backend/app/session/state.py
index 6f8829b..5405632 100644
--- a/backend/app/session/state.py
+++ b/backend/app/session/state.py
@@ -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:
"""Единственная точка, где событие двигает таймеры."""
diff --git a/backend/app/session/timers.py b/backend/app/session/timers.py
index 6c4e85f..1b45710 100644
--- a/backend/app/session/timers.py
+++ b/backend/app/session/timers.py
@@ -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,),
diff --git a/backend/tests/test_ekp.py b/backend/tests/test_ekp.py
index feefa07..4da2e5c 100644
--- a/backend/tests/test_ekp.py
+++ b/backend/tests/test_ekp.py
@@ -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
diff --git a/backend/tests/test_outcomes.py b/backend/tests/test_outcomes.py
new file mode 100644
index 0000000..a1184b9
--- /dev/null
+++ b/backend/tests/test_outcomes.py
@@ -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
diff --git a/frontend/src/features/call/useCall.ts b/frontend/src/features/call/useCall.ts
index d58aca9..a0745b0 100644
--- a/frontend/src/features/call/useCall.ts
+++ b/frontend/src/features/call/useCall.ts
@@ -13,6 +13,7 @@ import { CALLER_RATE, Playback } from "@/shared/audio/playback";
import { type CardState, applyPatch, edit, empty as emptyCard } from "@/features/kio-card/merge";
import type { ChecklistItem } from "@/features/self-assessment/SelfAssessment";
import type {
+ Outcome,
CallIncoming,
DDSCode,
ServerToTrainee,
@@ -186,12 +187,20 @@ export function useCall(sessionId: string | null) {
channel.current?.send({ type: "dds.dispatch", service });
}, []);
+ /** Закрыть вызов не карточкой: справкой или передачей в другой регион.
+ * Отдельное действие, а не «положил трубку»: система должна отличить
+ * осознанное решение от брошенного вызова (docs/spec/TICKETS.md). */
+ const resolve = useCallback((outcome: Outcome, comment: string) => {
+ channel.current?.send({ type: "call.resolve", outcome, comment });
+ }, []);
+
const submitSelfAssessment = useCallback((missed: string[], comment: string) => {
channel.current?.send({ type: "self_assessment.submit", missed, comment });
setSelfAssessed(true);
}, []);
return {
+ resolve,
status, phase, incoming, lines, timers, callerSpeaking, micOn, error, card,
checklist, selfAssessed, scoreReady, report,
answer, hangup, hint, patchKio, dispatch, submitSelfAssessment,
diff --git a/frontend/src/pages/trainee/Call.tsx b/frontend/src/pages/trainee/Call.tsx
index 023021a..5b589f4 100644
--- a/frontend/src/pages/trainee/Call.tsx
+++ b/frontend/src/pages/trainee/Call.tsx
@@ -1,6 +1,8 @@
// АРМ курсанта: приём вызова и разговор голосом.
// Карточка КИО — lct-10, таймеры — lct-11.
+import { useState } from "react";
+
import { KioCard } from "@/features/kio-card/KioCard";
import { StandStatus } from "@/features/health/StandStatus";
import { ModeBanner, hintsAllowed } from "@/features/mode-banner/ModeBanner";
@@ -18,7 +20,14 @@ const STATUS_LABEL: Record = {
closed: "отключено",
};
+const RESOLVE_LABELS = {
+ consultation: "справочно-консультационная помощь",
+ transfer_region: "передан в систему-112 другого региона",
+} as const;
+
export function Call() {
+ const [resolveComment, setResolveComment] = useState("");
+ const [resolved, setResolved] = useState<"consultation" | "transfer_region" | null>(null);
const sessionId = sessionIdFromUrl();
const call = useCall(sessionId);
@@ -82,6 +91,42 @@ export function Call() {
Передать в ДДС
+ {/* Не каждый вызов заканчивается карточкой: справка и чужой регион —
+ отдельные исходы, и курсант выбирает их сам (docs/spec/TICKETS.md). */}
+
+ Закрыть вызов без карточки
+
+ setResolveComment(event.target.value)}
+ />
+
+
+ {" "}
+
+
+ {resolved &&
Вызов закрыт: {RESOLVE_LABELS[resolved]}
}
+
Нормативы
diff --git a/frontend/src/shared/types/generated.ts b/frontend/src/shared/types/generated.ts
index 705041d..117a19a 100644
--- a/frontend/src/shared/types/generated.ts
+++ b/frontend/src/shared/types/generated.ts
@@ -40,6 +40,13 @@ export interface CallIncoming {
required_fields?: Array;
}
+/** Курсант решил, что карточка здесь не заводится. Не «завершить звонок», а именно «этот вызов закрывается иначе»: справкой или передачей в другой регион. Решение оценивается наравне с выбором признаков — это та же классификация, только на шаг раньше. */
+export interface CallResolve {
+ type: "call.resolve";
+ outcome: Outcome;
+ comment?: string;
+}
+
export interface CallStarted {
type: "call.started";
started_at: string;
@@ -293,6 +300,9 @@ export interface ModeSet {
/** Состояние звонящего. Фон подачи, не факты. */
export type Mood = "calm" | "worried" | "panic" | "aggressive" | "confused";
+/** Чем заканчивается вызов. Весь продукт по умолчанию считает, что звонок → карточка → выезд. В билетах заказчика это не всегда так: «поругался с продавцом „Мегафон“» — справка, а вызов из Волгоградской области передаётся в систему-112 другого субъекта (docs/spec/TICKETS.md). Курсант, заведший карточку на такой вызов, ошибся, и система обязана это увидеть. */
+export type Outcome = "card" | "consultation" | "transfer_region";
+
/** `auto` — сервер распознал факт из речи, `operator` — эхо правки курсанта. */
export type PatchSource = "auto" | "operator";
@@ -533,7 +543,7 @@ export interface ZoneDecision {
export type ServerToTrainee = CallIncoming | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | ErrorEvent;
-export type TraineeToServer = CallAnswer | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallHangup | CallbackDial;
+export type TraineeToServer = CallAnswer | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial;
export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | ErrorEvent;
@@ -550,6 +560,7 @@ export type EventName =
| "call.ended"
| "call.hangup"
| "call.incoming"
+ | "call.resolve"
| "call.started"
| "callback.dial"
| "caller.utterance"
diff --git a/scenarios/checklists/medical.yaml b/scenarios/checklists/medical.yaml
new file mode 100644
index 0000000..09459f5
--- /dev/null
+++ b/scenarios/checklists/medical.yaml
@@ -0,0 +1,26 @@
+# Чек-лист по классификатору 03 — скорая помощь.
+# Методика: docs/product/METHODOLOGY.md#чек-листы-опроса
+#
+# `examples` — как спрашивают живые операторы, а не как написано в инструкции.
+# Чем их больше и чем они разнообразнее, тем точнее звонящий «понимает» вопрос.
+id: medical
+dds: "03"
+checklist:
+ - id: q_address
+ question: "Назовите адрес: улица, дом, квартира, этаж, код домофона?"
+ examples: ["Какой у вас адрес?", "Где вы находитесь?", "Куда ехать скорой?", "Код домофона какой?", "Номер квартиры?"]
+ - id: q_what_happened
+ question: "Что случилось с пострадавшим?"
+ examples: ["Что с человеком?", "Что произошло?", "На что жалуется?", "Как это случилось?"]
+ - id: q_who
+ question: "Кто пострадавший: имя, возраст, пол?"
+ examples: ["Сколько лет?", "Как зовут больного?", "Это мужчина или женщина?", "Возраст какой?"]
+ - id: q_conscious
+ question: "Человек в сознании, дышит?"
+ examples: ["В сознании?", "Дышит?", "Он отвечает вам?", "Реагирует на голос?"]
+ - id: q_can_move
+ question: "Может ли пострадавший передвигаться самостоятельно?"
+ examples: ["Он может идти?", "Ходит сам?", "Встать может?"]
+ - id: q_caller
+ question: "Представьтесь и оставьте контактный телефон"
+ examples: ["Как вас зовут?", "Ваш номер телефона?", "Кем вы приходитесь пострадавшему?"]
diff --git a/scenarios/checklists/police.yaml b/scenarios/checklists/police.yaml
new file mode 100644
index 0000000..892e66a
--- /dev/null
+++ b/scenarios/checklists/police.yaml
@@ -0,0 +1,26 @@
+# Чек-лист по классификатору 02 — полиция.
+# Методика: docs/product/METHODOLOGY.md#чек-листы-опроса
+id: police
+dds: "02"
+checklist:
+ - id: q_address
+ question: "Назовите адрес или ориентир, где это происходит?"
+ examples: ["Где это происходит?", "Какой адрес?", "Улица, дом?", "Куда наряду подъехать?"]
+ - id: q_what_happened
+ question: "Что именно происходит?"
+ examples: ["Что случилось?", "Что произошло?", "Опишите, что вы видите"]
+ - id: q_victims
+ question: "Есть ли пострадавшие, нужна ли скорая?"
+ examples: ["Пострадавшие есть?", "Скорая нужна?", "Кто-то ранен?", "Кровь есть?"]
+ - id: q_suspects
+ question: "Сколько нарушителей, как выглядят, есть ли оружие?"
+ examples: ["Сколько их?", "Как они выглядят?", "Оружие есть?", "Во что одеты?", "Чем дерутся?"]
+ - id: q_fled
+ question: "Нарушители на месте или скрылись, в какую сторону?"
+ examples: ["Они ещё там?", "Куда скрылись?", "В какую сторону уехали?", "Они на месте?"]
+ - id: q_vehicle
+ question: "Если есть транспорт: марка, цвет, номер?"
+ examples: ["Какая машина?", "Номер запомнили?", "Цвет машины?", "Марка автомобиля?"]
+ - id: q_caller
+ question: "Представьтесь и оставьте контактный телефон"
+ examples: ["Как вас зовут?", "Ваш номер телефона?", "Вы это сами видели?"]
diff --git a/scenarios/checklists/utility.yaml b/scenarios/checklists/utility.yaml
new file mode 100644
index 0000000..9484cd3
--- /dev/null
+++ b/scenarios/checklists/utility.yaml
@@ -0,0 +1,20 @@
+# Чек-лист по коммунальным авариям и городскому хозяйству.
+# Методика: docs/product/METHODOLOGY.md#чек-листы-опроса
+id: utility
+dds: gkh
+checklist:
+ - id: q_address
+ question: "Назовите адрес: улица, дом, подъезд?"
+ examples: ["Какой адрес?", "Где это?", "Улица, дом?", "Какой подъезд?"]
+ - id: q_what_happened
+ question: "Что именно произошло?"
+ examples: ["Что случилось?", "Что там с трубой?", "Опишите повреждение"]
+ - id: q_scale
+ question: "Каков масштаб: что затоплено, обесточено, перекрыто?"
+ examples: ["Сколько залило?", "Насколько всё серьёзно?", "Что перекрыто?", "Подъезд затоплен?"]
+ - id: q_threat
+ question: "Есть ли угроза людям, пострадавшие?"
+ examples: ["Люди пострадали?", "Есть угроза для жильцов?", "Кто-то пострадал?"]
+ - id: q_caller
+ question: "Представьтесь и оставьте контактный телефон"
+ examples: ["Как вас зовут?", "Ваш номер телефона?"]
diff --git a/scenarios/tickets/t01-1-fire-container.yaml b/scenarios/tickets/t01-1-fire-container.yaml
new file mode 100644
index 0000000..8b8ac21
--- /dev/null
+++ b/scenarios/tickets/t01-1-fire-container.yaml
@@ -0,0 +1,53 @@
+# Билет 1, вызов 1 (docs/spec/TICKETS.md). Ситуация и адрес — дословно из билета,
+# факты и чек-лист выведены из ситуации: чего в строке нет, того звонящий не знает.
+id: t01-1-fire-container
+title: "Возгорание мусорного контейнера"
+ticket: 1
+position: 1
+type: fire
+level: L2
+topics: [fires]
+modes: [training, exam, self]
+
+extends: checklists/fire.yaml
+signs: [на улице, мусор, открытое пламя]
+
+persona:
+ base: calm
+ arc:
+ - { stage: registration, mood: calm }
+ - { stage: interview, mood: calm }
+
+first_line: "Здравствуйте. Тут мусорный контейнер горит, у депо."
+
+facts:
+ - id: f_address
+ value: "Депо, около станции Москва-Пассажирская Киевская, длинное помещение недалеко от участкового пункта полиции"
+ refined: "Московская железная дорога, Киевская, 1 км, дом 2, строение 2"
+ reveal_on: { question: q_address }
+ refine_on: q_address_check
+ - id: f_what_burns
+ value: "горит мусорный контейнер, пламя открытое"
+ reveal_on: { question: q_what_burns }
+ - id: f_people
+ value: "пострадавших нет, рядом никого"
+ reveal_on: { question: q_people }
+ - id: f_caller
+ value: "Сидоров Иван Сергеевич, телефон 916-126-34-71"
+ reveal_on: { question: q_caller }
+
+checklist:
+ - { id: q_address, fact: f_address }
+ - { id: q_address_check }
+ - { id: q_what_burns, fact: f_what_burns }
+ - { id: q_people, fact: f_people }
+ - { id: q_caller, fact: f_caller }
+
+required_fields: [address, incident_type, description]
+
+ground_truth:
+ address: "Московская железная дорога, Киевская, 1 км, дом 2, строение 2"
+ victims: 0
+
+tree:
+ pregenerated: false
diff --git a/scenarios/tickets/t01-2-mass-fight.yaml b/scenarios/tickets/t01-2-mass-fight.yaml
new file mode 100644
index 0000000..85b32cb
--- /dev/null
+++ b/scenarios/tickets/t01-2-mass-fight.yaml
@@ -0,0 +1,58 @@
+# Билет 1, вызов 2 (docs/spec/TICKETS.md). Массовая драка с пострадавшими:
+# в списке оповещения и полиция, и скорая — это тот случай, где выбор одной
+# службы был бы неверен по построению.
+id: t01-2-mass-fight
+title: "Массовая драка у посольства, пятеро пострадавших"
+ticket: 1
+position: 2
+type: police
+level: L2
+topics: [police]
+modes: [training, exam, self]
+
+extends: checklists/police.yaml
+signs: [Драка, Улица общественное место, Массовая (от 10 человек)]
+
+persona:
+ base: worried
+ arc:
+ - { stage: registration, mood: worried }
+ - { stage: interview, mood: worried }
+
+first_line: "Алло! Тут человек пятнадцать дерутся, прутами машут! Скорее!"
+
+facts:
+ - id: f_address
+ value: "рядом с посольством Азербайджана, на тротуаре"
+ refined: "Леонтьевский переулок, дом 16, строение 1"
+ reveal_on: { question: q_address }
+ refine_on: q_address_check
+ - id: f_what_happened
+ value: "дерутся десять-пятнадцать человек"
+ reveal_on: { question: q_what_happened }
+ - id: f_victims
+ value: "пятеро пострадавших с разными травмами"
+ reveal_on: { question: q_victims }
+ - id: f_suspects
+ value: "дерутся палками и железными прутами, огнестрельного не видно"
+ reveal_on: { question: q_suspects }
+ - id: f_caller
+ value: "Иванов Пётр Иванович, телефон 916-123-98-78"
+ reveal_on: { question: q_caller }
+
+checklist:
+ - { id: q_address, fact: f_address }
+ - { id: q_address_check }
+ - { id: q_what_happened, fact: f_what_happened }
+ - { id: q_victims, fact: f_victims }
+ - { id: q_suspects, fact: f_suspects }
+ - { id: q_caller, fact: f_caller }
+
+required_fields: [address, incident_type, description, victims_count]
+
+ground_truth:
+ address: "Леонтьевский переулок, дом 16, строение 1"
+ victims: 5
+
+tree:
+ pregenerated: false
diff --git a/scenarios/tickets/t01-3-child-other-region.yaml b/scenarios/tickets/t01-3-child-other-region.yaml
new file mode 100644
index 0000000..7782767
--- /dev/null
+++ b/scenarios/tickets/t01-3-child-other-region.yaml
@@ -0,0 +1,64 @@
+# Билет 1, вызов 3 (docs/spec/TICKETS.md). Вызов не из Москвы: Волжский —
+# это Волгоградская область. Правильное действие — не заводить карточку,
+# а передать вызов в систему-112 другого субъекта. Проверяется тем, что
+# курсант вообще спросил про город.
+id: t01-3-child-other-region
+title: "Ребёнок упал с велосипеда, вызов из другого региона"
+ticket: 1
+position: 3
+type: medical
+level: L3
+topics: [medical, address]
+modes: [training, exam, self]
+
+extends: checklists/medical.yaml
+signs: [Травма]
+outcome: transfer_region
+
+persona:
+ base: worried
+ arc:
+ - { stage: registration, mood: worried }
+ - { stage: interview, mood: worried }
+
+first_line: "Здравствуйте, сын с велосипеда упал, рука и нога опухли!"
+
+facts:
+ # Мама называет улицу так, будто это Москва. Про область она скажет только
+ # если спросить: ей не приходит в голову, что попала не туда.
+ - id: f_address
+ value: "улица Карла Маркса, около Молсыркомбината"
+ refined: "Волгоградская область, город Волжский, улица Карла Маркса, около Волжского Молсыркомбината"
+ reveal_on: { question: q_address }
+ refine_on: q_address_check
+ - id: f_what_happened
+ value: "упал с велосипеда сам, отёк руки и ноги"
+ reveal_on: { question: q_what_happened }
+ - id: f_who
+ value: "Смирнов Илья, 11 лет, сын"
+ reveal_on: { question: q_who }
+ - id: f_conscious
+ value: "в сознании, плачет, дышит нормально"
+ reveal_on: { question: q_conscious }
+ - id: f_caller
+ value: "звонит мама, телефон 9163201283"
+ reveal_on: { question: q_caller }
+
+checklist:
+ - { id: q_address, fact: f_address }
+ - { id: q_address_check }
+ - { id: q_what_happened, fact: f_what_happened }
+ - { id: q_who, fact: f_who }
+ - { id: q_conscious, fact: f_conscious }
+ - { id: q_caller, fact: f_caller }
+
+# Карточка здесь не заводится, поэтому обязательных полей нет: штрафовать
+# за пустую карточку значит учить заполнять лишнее.
+required_fields: []
+
+ground_truth:
+ address: "Волгоградская область, город Волжский, улица Карла Маркса, около Волжского Молсыркомбината"
+ victims: 1
+
+tree:
+ pregenerated: false