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

View file

@ -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,

View file

@ -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<string, string> = {
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() {
Передать в ДДС
</button>
</p>
{/* Не каждый вызов заканчивается карточкой: справка и чужой регион —
отдельные исходы, и курсант выбирает их сам (docs/spec/TICKETS.md). */}
<details>
<summary>Закрыть вызов без карточки</summary>
<p>
<input
type="text"
value={resolveComment}
placeholder="что сказали заявителю, куда передали"
onChange={(event) => setResolveComment(event.target.value)}
/>
</p>
<p>
<button
type="button"
disabled={!call.micOn || resolved !== null}
onClick={() => {
call.resolve("consultation", resolveComment);
setResolved("consultation");
}}
>
Справка, выезд не нужен
</button>{" "}
<button
type="button"
disabled={!call.micOn || resolved !== null}
onClick={() => {
call.resolve("transfer_region", resolveComment);
setResolved("transfer_region");
}}
>
Передать в другой регион
</button>
</p>
{resolved && <p className="warn">Вызов закрыт: {RESOLVE_LABELS[resolved]}</p>}
</details>
</section>
<section>
<h2>Нормативы</h2>

View file

@ -40,6 +40,13 @@ export interface CallIncoming {
required_fields?: Array<string>;
}
/** Курсант решил, что карточка здесь не заводится. Не «завершить звонок», а именно «этот вызов закрывается иначе»: справкой или передачей в другой регион. Решение оценивается наравне с выбором признаков — это та же классификация, только на шаг раньше. */
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"

View file

@ -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: ["Как вас зовут?", "Ваш номер телефона?", "Кем вы приходитесь пострадавшему?"]

View file

@ -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: ["Как вас зовут?", "Ваш номер телефона?", "Вы это сами видели?"]

View file

@ -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: ["Как вас зовут?", "Ваш номер телефона?"]

View file

@ -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

View file

@ -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

View file

@ -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