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