fix: реакция на доклад бригады — норматив через time_metric, без штрафа при отказе от работ
This commit is contained in:
parent
a998b0c2f7
commit
83709b8b1e
5 changed files with 92 additions and 52 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 "стандартные"),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 ? "входит" : "не входит"} в оценку
|
||||||
</>}.
|
</>}.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue