Merge branch 'feat/report-reaction'

lct-37: норматив реакции ДДС на доклад бригады
This commit is contained in:
GGlamer 2026-09-27 23:27:49 +03:00 • committed by GitHub
commit b66cf14e3f
15 changed files with 475 additions and 20 deletions

View file

@ -53,6 +53,8 @@ class LessonCriteria(BaseModel):
decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
card_fill_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
dds_card_work_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
#: Сколько секунд после доклада бригады даётся на соответствующий статус.
dds_report_reaction_limit_seconds: int = Field(default=45, ge=5, le=600)
allowed_errors: int = Field(default=0, ge=0, le=50)
require_correct_grammar: bool = True
score_weights: dict[str, float] = Field(default_factory=dict)

View file

@ -11,11 +11,14 @@
привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу.
"""
import math
from app.domain.statuses import (
COMMENT_REQUIRED,
PRIMARY,
REFUSAL_COMMENT_REQUIRED,
SERVICE_STATUS_LABELS,
PhoneReportRecord,
ServiceStatus,
StatusEntry,
current,
@ -23,10 +26,102 @@ from app.domain.statuses import (
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
from app.domain.events import Metric
from app.domain.timers import GOST_REF
from app.scoring.timing import TimeNorm, time_metric
MEMO_REF = "памятка «Работа на АРМ-112», раздел «Статусы реагирования»"
#: Статусы, означающие, что реагирование действительно шло.
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
#: Статус, который диспетчер отмечает по докладу бригады этой фазы.
REPORT_STATUS = {
"dispatched": ServiceStatus.RESPONDING,
"arrived": ServiceStatus.ARRIVED,
"working": ServiceStatus.WORKING,
"completed": ServiceStatus.COMPLETED,
}
REPORT_LABELS = {
"dispatched": "о выезде",
"arrived": "о прибытии",
"working": "о начале работ",
"completed": "о завершении работ",
}
#: Лимит реакции на доклад по умолчанию; занятие задаёт свой в критериях.
REPORT_REACTION_MS = 45_000
#: Реакция на доклад — норматив времени, как отработка карточки, но отметка
#: D6 из памятки: опоздавший статус — тот же неполный ход работ.
DDS_REPORT_REACTION = TimeNorm(
key="dds_report_reaction",
title="реакция на доклад бригады",
summary="статус по докладу бригады отмечен с нарушением норматива",
action="отметить статус по докладу",
unmeasured="не отмечен",
incomplete="не отмечен",
code=ErrorCode.D6,
source=FindingSource.DISPATCHER,
ref=MEMO_REF,
)
#: Терминальный отказ: работ не было, поэтому неотмеченный по докладу статус
#: не штрафуется ни отметкой, ни метрикой.
TERMINAL_REFUSAL = {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
def report_reactions(
marks: list[StatusEntry], reports: list[PhoneReportRecord],
) -> list[tuple[PhoneReportRecord, int | None]]:
"""Задержка статуса после каждого доклада бригады, мс; None — статус не отмечен.
Отсчёт — от серверного времени доклада. Статус, отмеченный раньше доклада,
задержки не имеет: диспетчер узнал о ходе работ по другому каналу.
"""
result: list[tuple[PhoneReportRecord, int | None]] = []
for report in reports:
status = REPORT_STATUS.get(report.phase)
if status is None:
continue
times = [mark.at for mark in marks if mark.status is status]
if any(at <= report.at for at in times):
result.append((report, 0))
continue
after = min(times, default=None)
result.append((
report,
int((after - report.at).total_seconds() * 1000) if after is not None else None,
))
return result
def report_reaction(
service: str, marks: list[StatusEntry], reports: list[PhoneReportRecord], limit_ms: int,
) -> tuple[Metric, Finding | None] | None:
"""Метрика и отметка D6 по самой поздней реакции службы на доклады бригады.
Одна функция для отметок и для балла: иначе метрика провалится там,
где отметки нет. None — реагировать было не на что.
"""
refused = current(marks, service) in TERMINAL_REFUSAL
reactions = [(report, delay) for report, delay in report_reactions(marks, reports)
if delay is not None or not refused]
if not reactions:
return None
report, delay = max(reactions, key=lambda item: math.inf if item[1] is None else item[1])
metric, finding = time_metric(DDS_REPORT_REACTION, delay, limit_ms, completed=True)
label = SERVICE_STATUS_LABELS[REPORT_STATUS[report.phase]]
detail = "отмечен не позже доклада" if delay == 0 else metric.fact
fact = (f"статус «{label}» по докладу бригады «{report.crew}» "
f"{REPORT_LABELS[report.phase]}: {detail}")
if len(reactions) > 1:
late = sum(item is None or item > limit_ms for _, item in reactions)
fact += f"; с нарушением {late} из {len(reactions)} докладов"
metric = metric.model_copy(update={
"title": f"{service}: {metric.title}", "fact": fact, "service": service,
})
if finding is not None:
finding = finding.model_copy(update={
"summary": f"{service}: {finding.summary}", "fact": fact, "service": service,
})
return metric, finding
def _finding(service: str, code: ErrorCode, summary: str, fact: str,
norm: str | None = None) -> Finding:
@ -37,7 +132,7 @@ def _finding(service: str, code: ErrorCode, summary: str, fact: str,
summary=summary,
fact=fact,
norm=norm,
ref="памятка «Работа на АРМ-112», раздел «Статусы реагирования»",
ref=MEMO_REF,
competency=(
Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD
),
@ -93,6 +188,8 @@ def evaluate_dispatcher(
reply_text: str = "",
expected_decision: str = "accept",
expected_decision_reason: str | None = None,
phone_reports: list[PhoneReportRecord] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS,
) -> list[Finding]:
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
@ -264,15 +361,18 @@ def evaluate_dispatcher(
# D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке
# это приводит к повторным звонкам и скрывает от других служб факт реакции.
# REFUSED — отдельный допустимый терминальный статус с обязательной причиной.
reports = [report for report in phone_reports or () if report.service == service]
missing_progress = [status for status in PROGRESS if status not in {m.status for m in marks}]
if (latest not in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
and (missing_progress or latest is not ServiceStatus.COMPLETED)):
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
reported_done = any(report.phase == "completed" for report in reports)
findings.append(
_finding(
service, ErrorCode.D6,
f"{service}: ход реагирования не доведён до конца",
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"),
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта")
+ ("; бригада доложила о завершении работ" if reported_done else ""),
norm=(
"отметить по факту начало реагирования, прибытие, проведение работ "
"и завершение; если работы не проводились — оформить отказ с причиной"
@ -280,6 +380,11 @@ def evaluate_dispatcher(
)
)
# D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
reaction = report_reaction(service, marks, reports, report_reaction_ms)
if reaction is not None and reaction[1] is not None:
findings.append(reaction[1])
return findings
@ -290,6 +395,7 @@ def dispatcher_metrics(
expected_decision_reason: str | None = None,
*,
services: list[str] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS,
) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
@ -319,6 +425,12 @@ def dispatcher_metrics(
f"≤ {deadline_ms // 1000} с")
if primary is None:
continue
# Метрика есть только там, где бригада докладывала: без докладов
# реагировать не на что, и отсутствие звонков не штраф.
reports = [report for report in getattr(state, "phone_reports", ()) if report.service == service]
reaction = report_reaction(service, marks, reports, report_reaction_ms)
if reaction is not None:
metrics.append(reaction[0])
expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept"
else ServiceStatus.DECLINED)
expected_label = "Принята" if expected_decision == "accept" else "Не принята"

View file

@ -54,6 +54,8 @@ def to_csv(report: SessionReport) -> bytes:
row("Занятие", "", "Режим", report.mode.value)
row("Занятие", "", "Попытка", report.attempt)
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
row("Критерии", "", "Лимит реакции на доклад бригады, с",
report.criteria.dds_report_reaction_limit_seconds)
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
@ -170,6 +172,7 @@ def to_pdf(report: SessionReport) -> bytes:
("Режим", report.mode.value),
("Попытка", report.attempt),
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
("Лимит реакции на доклад бригады", f"{report.criteria.dds_report_reaction_limit_seconds} с"),
("Допустимо ошибок", report.criteria.allowed_errors),
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
("Переопределения весов", report.criteria.score_weights or "стандартные"),

View file

@ -31,6 +31,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
"dds_progress": (ErrorCode.D6, Competency.CARD),
"dds_completion": (ErrorCode.D6, Competency.CARD),
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
"dds_report_reaction": (ErrorCode.D6, Competency.NORMS),
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
}
@ -64,4 +65,5 @@ METRIC_WEIGHTS: dict[str, float] = {
"description_grammar": 1.0,
"card_fill_time": 1.5,
"dds_work_time": 1.5,
"dds_report_reaction": 1.5,
}

View file

@ -23,8 +23,8 @@ def time_credit(elapsed_ms: int | None, limit_ms: int) -> float:
@dataclass(frozen=True)
class TimeNorm:
"""Формулировки норматива времени: карточка 112 и карточка ДДС различаются
только словами, расчёт и отметка E3 у них общие."""
"""Формулировки норматива времени: нормативы различаются словами и кодом
отметки, расчёт вклада и формат факта у них общие."""
key: str
title: str
@ -32,6 +32,10 @@ class TimeNorm:
action: str
unmeasured: str
incomplete: str
code: ErrorCode = ErrorCode.E3
source: FindingSource = FindingSource.TIMERS
competency: Competency = Competency.NORMS
ref: str = TIME_REF
CARD_FILL_TIME = TimeNorm(
@ -55,7 +59,7 @@ DDS_WORK_TIME = TimeNorm(
def time_metric(
norm: TimeNorm, elapsed_ms: int | None, limit_ms: int, *, completed: bool
) -> tuple[Metric, Finding | None]:
"""Метрика норматива и отметка E3, если он не выполнен.
"""Метрика норматива и отметка (по умолчанию E3), если он не выполнен.
Незавершённая работа не получает временного вклада: быстрое, но брошенное
заполнение не должно выглядеть скоростью.
@ -64,13 +68,20 @@ def time_metric(
fact = norm.unmeasured
else:
delta_ms = elapsed_ms - limit_ms
# Превышение до полусекунды при округлении до целых читалось бы
# как «45 с (+0 с)» при нормативе 45 с — тогда нужна десятая доля.
precise = 0 < delta_ms <= 500
def seconds(ms: int) -> str:
return f"{ms / 1000:.1f}" if precise else f"{round(ms / 1000)}"
if delta_ms > 0:
deviation = f"+{round(delta_ms / 1000)} с сверх норматива"
deviation = f"+{seconds(delta_ms)} с сверх норматива"
elif delta_ms < 0:
deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива"
else:
deviation = "точно в норматив"
fact = f"{round(elapsed_ms / 1000)} с ({deviation})"
fact = f"{seconds(elapsed_ms)} с ({deviation})"
if not completed:
fact = f"{norm.incomplete}; {fact}"
norm_text = f"{norm.action} за {round(limit_ms / 1000)} с"
@ -80,7 +91,7 @@ def time_metric(
title=norm.title,
fact=fact,
norm=norm_text,
ref=TIME_REF,
ref=norm.ref,
passed=passed,
weight=METRIC_WEIGHTS[norm.key],
credit=time_credit(elapsed_ms, limit_ms) if completed else 0.0,
@ -88,12 +99,12 @@ def time_metric(
if passed:
return metric, None
return metric, Finding(
code=ErrorCode.E3,
source=FindingSource.TIMERS,
code=norm.code,
source=norm.source,
summary=norm.summary,
fact=fact,
norm=norm_text,
ref=TIME_REF,
competency=Competency.NORMS,
ref=norm.ref,
competency=norm.competency,
metric_key=norm.key,
)

View file

@ -48,6 +48,7 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
services = state.card_services(card)
decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK]
dds_decision = card.scenario.dds_decision
reaction_ms = state.criteria.dds_report_reaction_limit_seconds * 1000
findings = evaluate_dispatcher(
entries=card.status_log,
services=services,
@ -57,10 +58,12 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
reply_text=card.reply_text,
expected_decision=dds_decision.expected,
expected_decision_reason=dds_decision.reason,
phone_reports=card.phone_reports,
report_reaction_ms=reaction_ms,
)
metrics = dispatcher_metrics(
card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
services=services,
services=services, report_reaction_ms=reaction_ms,
)
weighted = GostResult(metrics=metrics, findings=findings)
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]

View file

@ -219,6 +219,7 @@ async def score_session(
if live_card is not None:
decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK]
services = list(snapshot.call_card_services)
reaction_ms = snapshot.criteria.dds_report_reaction_limit_seconds * 1000
result.findings.extend(evaluate_dispatcher(
entries=live_card.status_log,
services=services,
@ -228,10 +229,12 @@ async def score_session(
reply_text=live_card.reply_text,
expected_decision=scenario.dds_decision.expected,
expected_decision_reason=scenario.dds_decision.reason,
phone_reports=live_card.phone_reports,
report_reaction_ms=reaction_ms,
))
result.metrics.extend(dispatcher_metrics(
live_card, decision_limit_ms, scenario.dds_decision.expected,
scenario.dds_decision.reason, services=services,
scenario.dds_decision.reason, services=services, report_reaction_ms=reaction_ms,
))
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
# занятия. Веса первого сценария поверх них испортили бы остальные.

View file

@ -2,7 +2,7 @@
import asyncio
import time
from datetime import datetime
from datetime import datetime, timedelta
from uuid import uuid4
import pytest
@ -396,6 +396,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
client,
criteria={
"decision_time_limit_seconds": 45,
"dds_report_reaction_limit_seconds": 20,
"allowed_errors": 50,
"require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5},
@ -454,6 +455,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
"decision_time_limit_seconds": 45,
"card_fill_time_limit_seconds": 180,
"dds_card_work_time_limit_seconds": 180,
"dds_report_reaction_limit_seconds": 20,
"allowed_errors": 50,
"require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5},
@ -797,6 +799,118 @@ def test_complete_dds_workflow_with_training_calls_and_status_updates(client):
]
assert all(report.crew == crew for report in state.desk.active.phone_reports)
assert not score["findings"]
reaction = [m for m in score["metrics"] if m["key"] == "dds_report_reaction"]
assert len(reaction) == 1 and reaction[0]["passed"], "каждый доклад отмечен вовремя"
assert "с нарушением 0 из 4 докладов" in reaction[0]["fact"]
finally:
control.__exit__(None, None, None)
STATUS_COMMENT = "Основание: доклад бригады.\nСведения: передано старшему бригады."
def accept_with_crew(station):
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
crew = next(option for option in snapshot["crew_options"]
if option.startswith(service + " — "))
station.send_json({"type": "card.status", "service": service, "status": "accepted",
"comment": STATUS_COMMENT})
read_until(station, "station.state")
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
return service
def send_status(station, service, status):
station.send_json({"type": "card.status", "service": service, "status": status,
"comment": STATUS_COMMENT})
read_until(station, "station.state")
@pytest.mark.parametrize(("reaction", "passed"), [
("fast", True), ("slow", False), ("missing", False), ("early", True),
])
def test_dds_report_reaction_goes_from_criteria_to_report(client, reaction, passed):
"""Лимит из `scenario.start` доходит до метрики, отметки D6 и отчёта."""
session_id, control = start(client, criteria={
"dds_report_reaction_limit_seconds": 20,
"score_weights": {"dds_report_reaction": 3.5},
})
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
service = accept_with_crew(station)
if reaction == "early":
send_status(station, service, "responding")
complete_phone_call(station, state, "dispatched")
read_until(station, "station.state")
if reaction in {"fast", "slow"}:
send_status(station, service, "responding")
if reaction == "slow":
# Доклад на 70 с раньше статуса: время доклада — серверное.
state.desk.active.phone_reports[0].at -= timedelta(seconds=70)
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
metrics = [item for item in score["metrics"] if item["key"] == "dds_report_reaction"]
assert len(metrics) == 1
metric = metrics[0]
assert metric["passed"] is passed
assert metric["weight"] == 3.5
assert metric["norm"] == "отметить статус по докладу за 20 с"
assert "«Начало реагирования» по докладу бригады" in metric["fact"]
findings = [item for item in score["findings"]
if item["metric_key"] == "dds_report_reaction"]
assert [item["code"] for item in findings] == ([] if passed else ["D6"])
assert all(item["competency"] == "norms" for item in findings)
if reaction == "slow":
assert "70 с (+50 с сверх норматива)" in metric["fact"]
assert findings[0]["fact"] == metric["fact"]
if reaction == "missing":
assert metric["fact"].endswith(": не отмечен") and metric["credit"] == 0.0
if reaction == "early":
assert metric["fact"].endswith("отмечен не позже доклада")
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["criteria"]["dds_report_reaction_limit_seconds"] == 20
assert [item for item in report["metrics"]
if item["key"] == "dds_report_reaction"] == metrics
if reaction == "slow":
csv_text = client.get(f"/api/sessions/{session_id}/report.csv").content.decode("utf-8-sig")
assert "Лимит реакции на доклад бригады" in csv_text
assert "70 с (+50 с сверх норматива)" in csv_text
finally:
control.__exit__(None, None, None)
def test_refusal_after_crew_report_does_not_lower_score_without_finding(client):
"""Бригада доложила о прибытии, но работы не проводились: отказ с причиной
— терминальный исход, неотмеченное «Прибытие» не штрафуется."""
session_id, control = start(client)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
service = accept_with_crew(station)
complete_phone_call(station, state, "dispatched")
read_until(station, "station.state")
send_status(station, service, "responding")
complete_phone_call(station, state, "arrived")
read_until(station, "station.state")
send_status(station, service, "refused")
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
assert [report.phase for report in state.desk.active.phone_reports] == [
"dispatched", "arrived",
]
metric = next(item for item in score["metrics"] if item["key"] == "dds_report_reaction")
assert metric["passed"], "прибытие при отказе не требуется"
assert "о выезде" in metric["fact"] and "докладов" not in metric["fact"]
assert not [item for item in score["findings"] if item["code"] == "D6"]
finally:
control.__exit__(None, None, None)

View file

@ -1,13 +1,14 @@
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from pydantic import ValidationError
from app.domain.statuses import ServiceStatus, StatusEntry
from app.domain.taxonomy import ErrorCode
from app.domain.statuses import PhoneReportRecord, ServiceStatus, StatusEntry
from app.domain.taxonomy import Competency, ErrorCode, FindingSource
from app.scenarios.loader import load_file
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
from app.scoring.timing import time_credit
from app.session.dds import DdsLiveCard, build_card
from app.session.timers import SessionTimers
@ -294,3 +295,158 @@ def test_reasoned_refusal_after_acceptance_is_a_valid_terminal_path():
deadline_ms=30_000, elapsed_ms=0,
)
assert ErrorCode.D6 not in [finding.code for finding in findings]
# ── Реакция на доклад бригады (lct-37) ──
T0 = datetime(2026, 9, 27, 10, 0, tzinfo=timezone.utc)
COMMENT = "Основание: доклад старшего.\nСведения: передано дежурному."
def at_second(seconds: int) -> datetime:
return T0 + timedelta(seconds=seconds)
def mark(status: ServiceStatus, seconds: int) -> StatusEntry:
return StatusEntry(service=SERVICES[0], status=status, at=at_second(seconds), comment=COMMENT)
def crew_report(phase: str, seconds: int) -> PhoneReportRecord:
return PhoneReportRecord(service=SERVICES[0], crew="Бригада 12", phase=phase,
text="доклад", at=at_second(seconds))
def reaction(status_log, reports, limit_ms=45_000):
card = live_card(status_log=status_log, crew_assignments={SERVICES[0]: "Бригада 12"},
dispatched_at=T0)
card.phone_reports = reports
findings = evaluate_dispatcher(
entries=status_log, services=SERVICES, crew_assignments=card.crew_assignments,
deadline_ms=30_000, elapsed_ms=5_000, phone_reports=reports,
report_reaction_ms=limit_ms,
)
metric = next((m for m in dispatcher_metrics(card, 30_000, services=SERVICES,
report_reaction_ms=limit_ms)
if m.key == "dds_report_reaction"), None)
return [f for f in findings if f.metric_key == "dds_report_reaction"], metric
def full_run(arrived_at: int) -> list[StatusEntry]:
return [mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20),
mark(ServiceStatus.ARRIVED, arrived_at), mark(ServiceStatus.WORKING, arrived_at + 10),
mark(ServiceStatus.COMPLETED, arrived_at + 20)]
def test_status_soon_after_crew_report_passes():
findings, metric = reaction(full_run(arrived_at=110), [crew_report("arrived", 100)])
assert findings == []
assert metric.passed and metric.weight == 1.5
assert metric.fact == (
"статус «Прибытие» по докладу бригады «Бригада 12» о прибытии: "
"10 с (на 35 с быстрее норматива)"
)
assert metric.credit == time_credit(10_000, 45_000)
assert metric.title == f"{SERVICES[0]}: реакция на доклад бригады"
def test_status_later_than_limit_after_crew_report_is_d6_with_fact_and_norm():
findings, metric = reaction(full_run(arrived_at=170), [crew_report("arrived", 100)])
assert [f.code for f in findings] == [ErrorCode.D6]
assert "«Прибытие»" in findings[0].fact and "70 с (+25 с сверх норматива)" in findings[0].fact
assert "о прибытии" in findings[0].fact, "факт ссылается на доклад"
assert findings[0].norm == metric.norm == "отметить статус по докладу за 45 с"
assert findings[0].competency is Competency.NORMS, "отметка и метрика в одной компетенции"
assert findings[0].source is FindingSource.DISPATCHER
assert findings[0].service == SERVICES[0]
assert not metric.passed and metric.fact == findings[0].fact
assert metric.credit == time_credit(70_000, 45_000)
def test_violation_by_a_fraction_of_a_second_is_not_shown_as_the_limit():
status_log = full_run(arrived_at=170)
status_log[2] = status_log[2].model_copy(
update={"at": at_second(100) + timedelta(milliseconds=45_400)},
)
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
assert not metric.passed
assert "45.4 с (+0.4 с сверх норматива)" in findings[0].fact
def test_missing_status_after_crew_report_is_d6():
status_log = [mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20)]
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
assert [f.code for f in findings] == [ErrorCode.D6]
assert "не отмечен" in findings[0].fact and "Бригада 12" in findings[0].fact
assert not metric.passed and metric.credit == 0.0
assert metric.fact == "статус «Прибытие» по докладу бригады «Бригада 12» о прибытии: не отмечен"
def test_metric_takes_the_worst_report_and_counts_violations():
reports = [crew_report("dispatched", 10), crew_report("arrived", 100),
crew_report("working", 175)]
findings, metric = reaction(full_run(arrived_at=170), reports)
assert len(findings) == 1, "одна отметка на службу, как у других нормативов"
assert "о прибытии: 70 с" in metric.fact
assert "с нарушением 1 из 3 докладов" in metric.fact
def test_status_set_before_the_report_and_custom_limit():
findings, metric = reaction(full_run(arrived_at=90), [crew_report("arrived", 100)])
assert findings == [] and metric.passed, "статус раньше доклада — не нарушение"
assert metric.fact.endswith("отмечен не позже доклада") and metric.credit == 1.0
strict, _ = reaction(full_run(arrived_at=130), [crew_report("arrived", 100)], limit_ms=20_000)
assert strict[0].norm == "отметить статус по докладу за 20 с"
def test_no_crew_reports_means_no_reaction_metric():
findings, metric = reaction(full_run(arrived_at=110), [])
assert findings == [] and metric is None
def test_refusal_after_crew_report_is_neither_d6_nor_failed_metric():
status_log = [
mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20),
StatusEntry(service=SERVICES[0], status=ServiceStatus.REFUSED, at=at_second(120),
comment="Основание: доклад старшего.\nСведения: бригаде отказано в доступе."),
]
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
assert findings == [], "работ не было — статус «Прибытие» отмечать не нужно"
assert metric is None, "метрика не снижает балл без отметки"
def test_refusal_keeps_late_reaction_on_reported_status():
status_log = [
mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 80),
StatusEntry(service=SERVICES[0], status=ServiceStatus.REFUSED, at=at_second(120),
comment="Основание: доклад старшего.\nСведения: бригаде отказано в доступе."),
]
findings, metric = reaction(status_log, [crew_report("dispatched", 10),
crew_report("arrived", 100)])
assert [f.code for f in findings] == [ErrorCode.D6]
assert not metric.passed and "о выезде: 70 с" in metric.fact
assert "докладов" not in metric.fact, "неотмеченное прибытие при отказе не считается"
def test_unfinished_work_after_completion_report_names_the_report():
status_log = full_run(arrived_at=110)[:-1]
findings = evaluate_dispatcher(
entries=status_log, services=SERVICES, crew_assignments={SERVICES[0]: "Бригада 12"},
deadline_ms=30_000, elapsed_ms=5_000, phone_reports=[crew_report("completed", 150)],
)
unfinished = next(f for f in findings if f.summary.endswith("не доведён до конца"))
assert "бригада доложила о завершении работ" in unfinished.fact

View file

@ -68,6 +68,7 @@ def test_csv_contains_sections_and_blocks_formula_injection():
assert any(row[0] == "Занятие" and row[3] == "'=1+1" for row in rows)
assert any(row[0] == "Ошибки" and row[3] == "'+HYPERLINK(\"x\")" for row in rows)
assert any(row[0] == "Самооценка" and row[3] == "'@SUM(1,2)" for row in rows)
assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "45", ""] in rows
assert _cell(" =cmd") == "' =cmd"
assert _cell("\tОбычный текст") == "'\tОбычный текст"
@ -89,6 +90,7 @@ def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
assert "Отчёт по учебному занятию" in extracted
assert "Адрес должен быть уточнён" in extracted
assert "Заявитель сообщает о дыме" in extracted
assert "Лимит реакции на доклад бригады" in extracted
def test_certificate_pdf_contains_saved_result(tmp_path):

View file

@ -12,12 +12,13 @@ from app.domain.events import (
SessionMode,
)
from app.domain.kio import KIO
from app.domain.statuses import PhoneCallPending, ServiceStatus
from app.domain.statuses import PhoneCallPending, PhoneReportRecord, ServiceStatus
from app.domain.taxonomy import ErrorCode, Finding, FindingSource
from app.domain.timers import TimerCode
from app.scenarios.loader import load_file
from app.session.checkpoint import dump_state, load_state
from app.session.dds import prepare_handoff_queue, prepare_queue
from app.session.finish import score_dds_card
from app.session.state import DdsCardRecord, SessionState, now_utc
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
@ -85,6 +86,38 @@ def test_active_dds_session_round_trips_without_losing_work():
assert timer.limit_ms == 45_000
def test_report_reaction_keeps_times_and_limit_after_restore():
before = dds_state()
before.criteria.dds_report_reaction_limit_seconds = 20
card = before.desk.active
service = before.card_services(card)[0]
card.phone_reports.append(PhoneReportRecord(
service=service, crew=card.crew_selected, phase="dispatched",
text="Бригада выехала", at=now_utc(),
))
card.set_service_status(service, ServiceStatus.RESPONDING,
"Основание: доклад.\nСведения: бригада выехала.")
card.phone_reports[0].at -= timedelta(seconds=70)
restored = load_state(dump_state(before), now_utc())
assert restored.criteria.dds_report_reaction_limit_seconds == 20
assert restored.desk.active.phone_reports == card.phone_reports
def reaction(state):
result = score_dds_card(state, state.desk.active)
metric = next(item for item in result.metrics if item.key == "dds_report_reaction")
findings = [item for item in result.findings if item.metric_key == "dds_report_reaction"]
return metric, findings
metric, findings = reaction(restored)
assert not metric.passed
assert metric.norm == "отметить статус по докладу за 20 с"
assert "70 с (+50 с сверх норматива)" in metric.fact
assert [item.code for item in findings] == [ErrorCode.D6]
assert reaction(restored) == reaction(before), "оценка после восстановления та же"
def completed_card() -> DdsCardRecord:
return DdsCardRecord(
card_id=uuid4(),

View file

@ -78,6 +78,9 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}</>}
{report.exercise === "card" && <>Норматив заполнения КИО —
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>}
{/* 112 → ДДС приходит как exercise "card": работу ДДС выдают карточки пульта. */}
{(report.exercise === "dds" || Boolean(report.card_results?.length)) && <>; статус по докладу бригады —
{` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с`}</>}
{report.exercise && report.exercise !== "dds" && <>
; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку
</>}.

View file

@ -32,6 +32,7 @@ const DDS_METRICS: MetricOption[] = [
{ key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." },
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
{ key: "dds_work_time", label: "Время отработки карточки", hint: "Соблюдение лимита полного цикла карточки." },
{ key: "dds_report_reaction", label: "Реакция на доклад бригады", hint: "Статус по докладу бригады не позже лимита занятия." },
];
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [

View file

@ -93,6 +93,7 @@ export function Instructor() {
const [decisionTimeLimit, setDecisionTimeLimit] = useState(30);
const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180);
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
const [ddsReportReactionLimit, setDdsReportReactionLimit] = useState(45);
const [allowedErrors, setAllowedErrors] = useState(0);
const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
const [lessonScoreWeights, setLessonScoreWeights] = useState<Record<string, number>>({});
@ -307,6 +308,7 @@ export function Instructor() {
decision_time_limit_seconds: decisionTimeLimit,
card_fill_time_limit_seconds: cardFillTimeLimit,
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
dds_report_reaction_limit_seconds: ddsReportReactionLimit,
allowed_errors: allowedErrors,
require_correct_grammar: requireCorrectGrammar,
score_weights: lessonScoreWeights,
@ -314,7 +316,7 @@ export function Instructor() {
});
if (sent) toStart.current = null;
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data,
randomScenario, visibleScenarios]);
@ -662,6 +664,13 @@ export function Instructor() {
onCommit={setDdsCardWorkTimeLimit} />
<small className="ref">По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.</small>
</label>}
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
Реакция на доклад бригады, секунд
<LessonNumberInput label="Лимит реакции на доклад бригады, секунд"
value={ddsReportReactionLimit} min={5} max={600} fallback={45}
onCommit={setDdsReportReactionLimit} />
<small className="ref">По умолчанию 45 секунд: статус по докладу бригады — не позже лимита.</small>
</label>}
<label className="lesson-criterion">
Допустимо нарушенных метрик
<LessonNumberInput label="Допустимое число ошибок" value={allowedErrors}

View file

@ -400,6 +400,7 @@ export interface LessonCriteria {
decision_time_limit_seconds?: number;
card_fill_time_limit_seconds?: number;
dds_card_work_time_limit_seconds?: number;
dds_report_reaction_limit_seconds?: number;
allowed_errors?: number;
require_correct_grammar?: boolean;
score_weights?: Record<string, number>;