fix: реакция на доклад бригады — норматив через time_metric, без штрафа при отказе от работ

This commit is contained in:
GGlamer 2026-09-27 22:28:49 +03:00
commit 83709b8b1e
5 changed files with 92 additions and 52 deletions

View file

@ -11,6 +11,8 @@
привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу. привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу.
""" """
import math
from app.domain.statuses import ( from app.domain.statuses import (
COMMENT_REQUIRED, COMMENT_REQUIRED,
PRIMARY, PRIMARY,
@ -24,6 +26,9 @@ from app.domain.statuses import (
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
from app.domain.events import Metric from app.domain.events import Metric
from app.domain.timers import GOST_REF 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) PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
@ -43,6 +48,22 @@ REPORT_LABELS = {
} }
#: Лимит реакции на доклад по умолчанию; занятие задаёт свой в критериях. #: Лимит реакции на доклад по умолчанию; занятие задаёт свой в критериях.
REPORT_REACTION_MS = 45_000 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( def report_reactions(
@ -70,6 +91,38 @@ def report_reactions(
return result 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, def _finding(service: str, code: ErrorCode, summary: str, fact: str,
norm: str | None = None) -> Finding: norm: str | None = None) -> Finding:
return Finding( return Finding(
@ -79,7 +132,7 @@ def _finding(service: str, code: ErrorCode, summary: str, fact: str,
summary=summary, summary=summary,
fact=fact, fact=fact,
norm=norm, norm=norm,
ref="памятка «Работа на АРМ-112», раздел «Статусы реагирования»", ref=MEMO_REF,
competency=( competency=(
Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD
), ),
@ -328,30 +381,9 @@ def evaluate_dispatcher(
) )
# D6 — статус по докладу бригады отмечен позже лимита или не отмечен. # D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
# Код новый не вводится: это тот же неполный ход работ из памятки, reaction = report_reaction(service, marks, reports, report_reaction_ms)
# только с измеримым временем реакции на доклад. if reaction is not None and reaction[1] is not None:
terminal = latest in {ServiceStatus.DECLINED, ServiceStatus.REFUSED} findings.append(reaction[1])
limit_s = report_reaction_ms // 1000
for report, delay_ms in report_reactions(marks, reports):
label = SERVICE_STATUS_LABELS[REPORT_STATUS[report.phase]]
source = f"доклад бригады «{report.crew}» {REPORT_LABELS[report.phase]}"
if delay_ms is None and not terminal:
finding = _finding(
service, ErrorCode.D6,
f"{service}: по докладу бригады статус «{label}» не отмечен",
fact=f"{source} получен, статус «{label}» не отмечен",
norm=f"отметить статус по докладу не позже {limit_s} с",
)
elif delay_ms is not None and delay_ms > report_reaction_ms:
finding = _finding(
service, ErrorCode.D6,
f"{service}: статус «{label}» отмечен позже норматива после доклада",
fact=f"статус «{label}» отмечен через {round(delay_ms / 1000)} с; {source}",
norm=f"отметить статус по докладу не позже {limit_s} с",
)
else:
continue
findings.append(finding.model_copy(update={"metric_key": "dds_report_reaction"}))
return findings return findings
@ -393,6 +425,12 @@ def dispatcher_metrics(
f"≤ {deadline_ms // 1000} с") f"≤ {deadline_ms // 1000} с")
if primary is None: if primary is None:
continue 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" expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept"
else ServiceStatus.DECLINED) else ServiceStatus.DECLINED)
expected_label = "Принята" if expected_decision == "accept" else "Не принята" expected_label = "Принята" if expected_decision == "accept" else "Не принята"
@ -429,17 +467,4 @@ def dispatcher_metrics(
notes_complete, notes_complete,
"; ".join(notes) if notes else "комментарии к статусам не внесены", "; ".join(notes) if notes else "комментарии к статусам не внесены",
"к каждой ручной отметке добавить основание и содержание полученных сведений") "к каждой ручной отметке добавить основание и содержание полученных сведений")
# Метрика есть только там, где бригада докладывала: без докладов
# реагировать не на что, и отсутствие звонков не штраф.
reports = [report for report in getattr(state, "phone_reports", ()) if report.service == service]
reactions = report_reactions(marks, reports)
if reactions:
add("dds_report_reaction", "реакция на доклады бригады",
all(delay is not None and delay <= report_reaction_ms for _, delay in reactions),
"; ".join(
f"{REPORT_LABELS[report.phase]}: "
+ (f"{round(delay / 1000)} с" if delay is not None else "статус не отмечен")
for report, delay in reactions
),
f"статус по каждому докладу не позже {report_reaction_ms // 1000} с")
return metrics return metrics

View file

@ -19,7 +19,7 @@ def _cell(value: object) -> str:
if value is None: if value is None:
return "" return ""
result = str(value) result = str(value)
probe = result.lstrip(" \t\r\n\ufeff\u200b") probe = result.lstrip(" \t\r\n​")
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")): if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
return "'" + result return "'" + result
return result return result
@ -98,7 +98,7 @@ def to_csv(report: SessionReport) -> bytes:
row("Сверка самооценки", "", label, ", ".join(values)) row("Сверка самооценки", "", label, ", ".join(values))
for number, item in enumerate(report.notes, 1): for number, item in enumerate(report.notes, 1):
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author) row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
return ("\ufeff" + output.getvalue()).encode("utf-8") return ("" + output.getvalue()).encode("utf-8")
def _font_paths() -> tuple[Path, Path | None]: def _font_paths() -> tuple[Path, Path | None]:
@ -172,6 +172,7 @@ def to_pdf(report: SessionReport) -> bytes:
("Режим", report.mode.value), ("Режим", report.mode.value),
("Попытка", report.attempt), ("Попытка", report.attempt),
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"), ("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
("Лимит реакции на доклад бригады", f"{report.criteria.dds_report_reaction_limit_seconds} с"),
("Допустимо ошибок", report.criteria.allowed_errors), ("Допустимо ошибок", report.criteria.allowed_errors),
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"), ("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
("Переопределения весов", report.criteria.score_weights or "стандартные"), ("Переопределения весов", report.criteria.score_weights or "стандартные"),

View file

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

View file

@ -75,10 +75,12 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{report.passed ? "Зачёт" : "Не зачтено"}: на разборе {report.failed_metrics} пунктов; допустимо пропустить {report.criteria.allowed_errors}. {report.exercise === "dds" && <>На первичное решение ДДС отводится {report.passed ? "Зачёт" : "Не зачтено"}: на разборе {report.failed_metrics} пунктов; допустимо пропустить {report.criteria.allowed_errors}. {report.exercise === "dds" && <>На первичное решение ДДС отводится
{` ${report.criteria.decision_time_limit_seconds} с`}</>} {` ${report.criteria.decision_time_limit_seconds} с`}</>}
{report.exercise === "dds" && <>; норматив отработки карточки ДДС — {report.exercise === "dds" && <>; норматив отработки карточки ДДС —
{` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}; статус по докладу бригады — {` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}</>}
{` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с`}</>}
{report.exercise === "card" && <>Норматив заполнения КИО — {report.exercise === "card" && <>Норматив заполнения КИО —
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>} {` ${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.exercise && report.exercise !== "dds" && <>
; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку ; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку
</>}. </>}.