Merge branch 'feat/report-reaction'
lct-37: норматив реакции ДДС на доклад бригады
This commit is contained in:
commit
b66cf14e3f
15 changed files with 475 additions and 20 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 "Не принята"
|
||||
|
|
|
|||
|
|
@ -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 "стандартные"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
))
|
||||
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
|
||||
# занятия. Веса первого сценария поверх них испортили бы остальные.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 ? "входит" : "не входит"} в оценку
|
||||
</>}.
|
||||
|
|
|
|||
|
|
@ -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 }) => [
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue