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
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 → ДДС в одном занятии
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""Единственная точка, где событие двигает таймеры."""
|
||||
|
|
|
|||
|
|
@ -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,),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
136
backend/tests/test_outcomes.py
Normal file
136
backend/tests/test_outcomes.py
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
26
scenarios/checklists/medical.yaml
Normal file
26
scenarios/checklists/medical.yaml
Normal 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: ["Как вас зовут?", "Ваш номер телефона?", "Кем вы приходитесь пострадавшему?"]
|
||||
26
scenarios/checklists/police.yaml
Normal file
26
scenarios/checklists/police.yaml
Normal 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: ["Как вас зовут?", "Ваш номер телефона?", "Вы это сами видели?"]
|
||||
20
scenarios/checklists/utility.yaml
Normal file
20
scenarios/checklists/utility.yaml
Normal 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: ["Как вас зовут?", "Ваш номер телефона?"]
|
||||
53
scenarios/tickets/t01-1-fire-container.yaml
Normal file
53
scenarios/tickets/t01-1-fire-container.yaml
Normal 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
|
||||
58
scenarios/tickets/t01-2-mass-fight.yaml
Normal file
58
scenarios/tickets/t01-2-mass-fight.yaml
Normal 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
|
||||
64
scenarios/tickets/t01-3-child-other-region.yaml
Normal file
64
scenarios/tickets/t01-3-child-other-region.yaml
Normal 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
|
||||
Loading…
Reference in a new issue