feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)

Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта.
Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка
этого не видела — наоборот, награждала за полноту заполнения.

- Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта
  и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система
  должна отличить осознанное решение от брошенного вызова.
- Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака).
- Там, где карточка не заводится, метрики карточки не считаются вовсе.
  Полнота опроса считается: передать вызов не значит не опрашивать.
- call.resolve останавливает норматив опроса, как и передача карточки.

Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля
ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1
целиком: три вызова, три классификатора, третий — из другого региона.

Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему.
В классификаторе скорая добавляется к массовой драке признаком «пострадавшие»,
но взять его было неоткуда. Теперь модификаторы берутся из карточки —
victims_count, life_threat, evacuation_needed, fire.gasified, — и список
оповещения пересобирается при правке любого из этих полей.

161 тест зелёный (13 новых), make typecheck чистый.
This commit is contained in:
Ivan Gerasimov 2026-09-20 08:33:30 +03:00
commit b8dc39fd7d
22 changed files with 628 additions and 9 deletions

View file

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