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
|
||||
Loading…
Reference in a new issue