diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py index ab81dd2..fc921a3 100644 --- a/backend/app/scoring/dispatcher.py +++ b/backend/app/scoring/dispatcher.py @@ -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 diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py index 1eeafc7..2243f4b 100644 --- a/backend/app/scoring/export.py +++ b/backend/app/scoring/export.py @@ -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 "стандартные"), diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py index be96cd1..43d069c 100644 --- a/backend/app/scoring/taxonomy.py +++ b/backend/app/scoring/taxonomy.py @@ -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, } diff --git a/backend/app/scoring/timing.py b/backend/app/scoring/timing.py index 5b9deb1..a2cd944 100644 --- a/backend/app/scoring/timing.py +++ b/backend/app/scoring/timing.py @@ -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, ) diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx index 0b3230e..fae3420 100644 --- a/frontend/src/features/debrief/Debrief.tsx +++ b/frontend/src/features/debrief/Debrief.tsx @@ -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.criteria.decision_time_limit_seconds} с`}} {report.exercise === "dds" && <>; норматив отработки карточки ДДС — - {` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}; статус по докладу бригады — - {` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с`}} + {` ${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 ? "входит" : "не входит"} в оценку }.