Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
282
backend/tests/test_dispatcher_scoring.py
Normal file
282
backend/tests/test_dispatcher_scoring.py
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain.statuses import ServiceStatus, StatusEntry
|
||||
from app.domain.taxonomy import ErrorCode
|
||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||
|
||||
|
||||
def test_late_primary_status_has_d1_finding_even_when_status_exists():
|
||||
findings = evaluate_dispatcher(
|
||||
entries=[StatusEntry(
|
||||
service="Служба 101",
|
||||
status=ServiceStatus.ACCEPTED,
|
||||
at=datetime.now(timezone.utc),
|
||||
)],
|
||||
services=["Служба 101"],
|
||||
deadline_ms=30_000,
|
||||
elapsed_ms=31_000,
|
||||
)
|
||||
|
||||
d1 = next(finding for finding in findings if finding.code is ErrorCode.D1)
|
||||
assert "31 с" in d1.fact
|
||||
|
||||
|
||||
def test_primary_status_within_deadline_does_not_have_d1_finding():
|
||||
findings = evaluate_dispatcher(
|
||||
entries=[StatusEntry(
|
||||
service="Служба 101",
|
||||
status=ServiceStatus.ACCEPTED,
|
||||
at=datetime.now(timezone.utc),
|
||||
)],
|
||||
services=["Служба 101"],
|
||||
deadline_ms=30_000,
|
||||
elapsed_ms=30_000,
|
||||
)
|
||||
|
||||
assert ErrorCode.D1 not in [finding.code for finding in findings]
|
||||
|
||||
|
||||
def test_d5_flags_comment_without_recipient_but_not_a_named_crew():
|
||||
at = datetime.now(timezone.utc)
|
||||
base = StatusEntry(
|
||||
service="Служба 101",
|
||||
status=ServiceStatus.ACCEPTED,
|
||||
at=at,
|
||||
comment="Основание: не обслуживаем.\nСведения: информация передана.",
|
||||
)
|
||||
findings = evaluate_dispatcher(
|
||||
entries=[base],
|
||||
services=["Служба 101"],
|
||||
deadline_ms=30_000,
|
||||
elapsed_ms=1_000,
|
||||
)
|
||||
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
|
||||
assert "получателя" in d5.summary
|
||||
assert "передана" in d5.fact
|
||||
|
||||
complete = evaluate_dispatcher(
|
||||
entries=[base.model_copy(update={"comment": "Основание: принято.\nСведения: карточка передана бригаде 12."})],
|
||||
services=["Служба 101"],
|
||||
deadline_ms=30_000,
|
||||
elapsed_ms=1_000,
|
||||
)
|
||||
assert ErrorCode.D5 not in [finding.code for finding in complete]
|
||||
|
||||
|
||||
def test_recipient_in_one_status_comment_does_not_mask_another_status_comment():
|
||||
at = datetime.now(timezone.utc)
|
||||
entries = [
|
||||
StatusEntry(
|
||||
service="Служба 101", status=ServiceStatus.ACCEPTED,
|
||||
at=at, comment="Основание: карточка принята.\nСведения: переданы бригаде 12.",
|
||||
),
|
||||
StatusEntry(
|
||||
service="Служба 101", status=ServiceStatus.RESPONDING,
|
||||
at=at, comment="Основание: доклад.\nСведения: подтверждено начало движения.",
|
||||
),
|
||||
]
|
||||
|
||||
findings = evaluate_dispatcher(
|
||||
entries=entries,
|
||||
services=["Служба 101"],
|
||||
crew_assignments={"Служба 101": "Бригада 12"},
|
||||
deadline_ms=30_000,
|
||||
elapsed_ms=1_000,
|
||||
)
|
||||
|
||||
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
|
||||
assert "Начало реагирования" in d5.fact
|
||||
assert "Бригаде 12 переданы сведения" not in d5.fact
|
||||
|
||||
|
||||
def test_missing_manual_status_comment_has_a_d5_finding():
|
||||
findings = evaluate_dispatcher(
|
||||
entries=[StatusEntry(
|
||||
service="Служба 101",
|
||||
status=ServiceStatus.ACCEPTED,
|
||||
at=datetime.now(timezone.utc),
|
||||
)],
|
||||
services=["Служба 101"],
|
||||
deadline_ms=30_000,
|
||||
elapsed_ms=1_000,
|
||||
)
|
||||
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
|
||||
assert "не заполнены комментарии" in d5.fact
|
||||
|
||||
|
||||
def test_each_manual_status_comment_is_part_of_the_numeric_reply_metric():
|
||||
at = datetime.now(timezone.utc)
|
||||
state = SimpleNamespace(
|
||||
managed_services=lambda: ["Служба 101"],
|
||||
status_log=[
|
||||
StatusEntry(
|
||||
service="Служба 101", status=ServiceStatus.ACCEPTED,
|
||||
at=at, comment="Основание: карточка принята.\nСведения: принято в работу.",
|
||||
),
|
||||
StatusEntry(
|
||||
service="Служба 101", status=ServiceStatus.RESPONDING,
|
||||
at=at, comment="",
|
||||
),
|
||||
],
|
||||
crew_assignments={"Служба 101": "Бригада 1"},
|
||||
dispatched_at=at,
|
||||
)
|
||||
|
||||
reply = next(
|
||||
metric for metric in dispatcher_metrics(state, 30_000)
|
||||
if metric.key == "dds_reply"
|
||||
)
|
||||
|
||||
assert not reply.passed
|
||||
assert "к каждой ручной отметке" in reply.norm
|
||||
|
||||
findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=["Служба 101"],
|
||||
crew_assignments=state.crew_assignments,
|
||||
deadline_ms=30_000,
|
||||
elapsed_ms=0,
|
||||
)
|
||||
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
|
||||
assert "Начало реагирования" in d5.fact
|
||||
|
||||
|
||||
def test_d5_does_not_treat_address_house_number_as_recipient():
|
||||
entry = StatusEntry(
|
||||
service="Служба 101",
|
||||
status=ServiceStatus.ACCEPTED,
|
||||
at=datetime.now(timezone.utc),
|
||||
comment="Основание: доклад.\nСведения: в доме 101 проведён осмотр.",
|
||||
)
|
||||
findings = evaluate_dispatcher(
|
||||
entries=[entry],
|
||||
services=["Служба 101"],
|
||||
deadline_ms=30_000,
|
||||
elapsed_ms=1_000,
|
||||
)
|
||||
assert ErrorCode.D5 in [finding.code for finding in findings]
|
||||
|
||||
|
||||
def test_scenario_may_define_a_valid_decline_for_duplicate_or_territory():
|
||||
at = datetime.now(timezone.utc)
|
||||
entries = [StatusEntry(
|
||||
service="Служба 101", status=ServiceStatus.DECLINED, at=at,
|
||||
comment="Основание: дубль.\nСведения: передано в дежурную часть.",
|
||||
)]
|
||||
findings = evaluate_dispatcher(
|
||||
entries=entries, services=["Служба 101"], deadline_ms=30_000,
|
||||
elapsed_ms=1_000, expected_decision="decline",
|
||||
expected_decision_reason="дублирующая карточка",
|
||||
)
|
||||
assert ErrorCode.D3 not in [finding.code for finding in findings]
|
||||
state = SimpleNamespace(
|
||||
managed_services=lambda: ["Служба 101"], status_log=entries,
|
||||
crew_assignments={}, dispatched_at=at,
|
||||
)
|
||||
metrics = dispatcher_metrics(
|
||||
state, 30_000, expected_decision="decline",
|
||||
expected_decision_reason="дублирующая карточка",
|
||||
)
|
||||
decision = next(metric for metric in metrics if metric.key == "dds_decision")
|
||||
assert decision.passed
|
||||
assert "дублирующая карточка" in decision.norm
|
||||
|
||||
|
||||
def test_nonempty_unstructured_comment_fails_reply_metric_but_structured_passes():
|
||||
at = datetime.now(timezone.utc)
|
||||
state = SimpleNamespace(
|
||||
managed_services=lambda: ["Служба 101"],
|
||||
status_log=[StatusEntry(
|
||||
service="Служба 101", status=ServiceStatus.ACCEPTED,
|
||||
at=at, comment="бригада на связи",
|
||||
)],
|
||||
crew_assignments={"Служба 101": "Бригада 12"},
|
||||
dispatched_at=at,
|
||||
)
|
||||
reply = next(m for m in dispatcher_metrics(state, 30_000) if m.key == "dds_reply")
|
||||
assert not reply.passed
|
||||
findings = evaluate_dispatcher(
|
||||
entries=state.status_log, services=["Служба 101"],
|
||||
crew_assignments=state.crew_assignments,
|
||||
deadline_ms=30_000, elapsed_ms=0,
|
||||
)
|
||||
assert any("не разделяют основание и сведения" in f.summary for f in findings)
|
||||
|
||||
state.status_log[0] = state.status_log[0].model_copy(update={
|
||||
"comment": "Основание: доклад старшего.\nСведения: бригада на связи.",
|
||||
})
|
||||
reply = next(m for m in dispatcher_metrics(state, 30_000) if m.key == "dds_reply")
|
||||
assert reply.passed
|
||||
|
||||
def test_accepting_card_with_scenario_expected_decline_is_explained():
|
||||
findings = evaluate_dispatcher(
|
||||
entries=[StatusEntry(
|
||||
service="Служба 101", status=ServiceStatus.ACCEPTED,
|
||||
at=datetime.now(timezone.utc),
|
||||
)],
|
||||
services=["Служба 101"], deadline_ms=30_000, elapsed_ms=1_000,
|
||||
expected_decision="decline", expected_decision_reason="не наша территория",
|
||||
)
|
||||
decision_finding = next(
|
||||
finding for finding in findings
|
||||
if finding.code is ErrorCode.D2 and "вопреки эталону" in finding.summary
|
||||
)
|
||||
assert decision_finding.norm == "не наша территория"
|
||||
|
||||
|
||||
def test_scenario_decline_expectation_requires_an_explicit_reason():
|
||||
from app.scenarios.schema import DdsDecision
|
||||
|
||||
with pytest.raises(ValidationError, match="reason обязателен"):
|
||||
DdsDecision(expected="decline")
|
||||
assert DdsDecision(expected="decline", reason="дубль").expected == "decline"
|
||||
|
||||
|
||||
def test_incomplete_work_path_has_d6_for_failed_progress_metrics():
|
||||
at = datetime.now(timezone.utc)
|
||||
entries = [
|
||||
StatusEntry(service="Служба 101", status=ServiceStatus.ACCEPTED, at=at),
|
||||
StatusEntry(service="Служба 101", status=ServiceStatus.RESPONDING, at=at),
|
||||
]
|
||||
state = SimpleNamespace(
|
||||
managed_services=lambda: ["Служба 101"], status_log=entries,
|
||||
crew_assignments={"Служба 101": "Бригада 1"}, dispatched_at=at,
|
||||
)
|
||||
metrics = dispatcher_metrics(state, 30_000)
|
||||
assert not next(item for item in metrics if item.key == "dds_progress").passed
|
||||
assert not next(item for item in metrics if item.key == "dds_completion").passed
|
||||
findings = evaluate_dispatcher(
|
||||
entries=entries, services=["Служба 101"],
|
||||
crew_assignments={"Служба 101": "Бригада 1"},
|
||||
deadline_ms=30_000, elapsed_ms=0,
|
||||
)
|
||||
d6 = next(finding for finding in findings if finding.code is ErrorCode.D6)
|
||||
assert "Прибытие" in d6.fact and "Проведение работ" in d6.fact
|
||||
|
||||
|
||||
def test_reasoned_refusal_after_acceptance_is_a_valid_terminal_path():
|
||||
at = datetime.now(timezone.utc)
|
||||
entries = [
|
||||
StatusEntry(service="Служба 101", status=ServiceStatus.ACCEPTED, at=at),
|
||||
StatusEntry(
|
||||
service="Служба 101", status=ServiceStatus.REFUSED, at=at,
|
||||
comment="Бригаде переданы сведения, выезд не выполнялся по причине угрозы.",
|
||||
),
|
||||
]
|
||||
state = SimpleNamespace(
|
||||
managed_services=lambda: ["Служба 101"], status_log=entries,
|
||||
crew_assignments={"Служба 101": "Бригада 1"}, dispatched_at=at,
|
||||
)
|
||||
metrics = dispatcher_metrics(state, 30_000)
|
||||
assert next(item for item in metrics if item.key == "dds_progress").passed
|
||||
assert next(item for item in metrics if item.key == "dds_completion").passed
|
||||
findings = evaluate_dispatcher(
|
||||
entries=entries, services=["Служба 101"],
|
||||
crew_assignments={"Служба 101": "Бригада 1"},
|
||||
deadline_ms=30_000, elapsed_ms=0,
|
||||
)
|
||||
assert ErrorCode.D6 not in [finding.code for finding in findings]
|
||||
Loading…
Reference in a new issue