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: сведения можно получить по любому рабочему каналу.
"""
import math
from app.domain.statuses import (
COMMENT_REQUIRED,
PRIMARY,
@ -24,6 +26,9 @@ 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)
@ -43,6 +48,22 @@ REPORT_LABELS = {
}
#: Лимит реакции на доклад по умолчанию; занятие задаёт свой в критериях.
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(
@ -70,6 +91,38 @@ def report_reactions(
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:
return Finding(
@ -79,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
),
@ -328,30 +381,9 @@ def evaluate_dispatcher(
)
# D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
# Код новый не вводится: это тот же неполный ход работ из памятки,
# только с измеримым временем реакции на доклад.
terminal = latest in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
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"}))
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
@ -393,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 "Не принята"
@ -429,17 +467,4 @@ def dispatcher_metrics(
notes_complete,
"; ".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

View file

@ -19,7 +19,7 @@ def _cell(value: object) -> str:
if value is None:
return ""
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")):
return "'" + result
return result
@ -98,7 +98,7 @@ def to_csv(report: SessionReport) -> bytes:
row("Сверка самооценки", "", label, ", ".join(values))
for number, item in enumerate(report.notes, 1):
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]:
@ -172,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

@ -60,4 +60,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,
)