diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py index 0d5f931..33dda61 100644 --- a/backend/app/domain/events.py +++ b/backend/app/domain/events.py @@ -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) diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py index 24e031a..fc921a3 100644 --- a/backend/app/scoring/dispatcher.py +++ b/backend/app/scoring/dispatcher.py @@ -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 "Не принята" diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py index 2070e61..cc03471 100644 --- a/backend/app/scoring/export.py +++ b/backend/app/scoring/export.py @@ -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 "стандартные"), diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py index 28f094f..55e750b 100644 --- a/backend/app/scoring/taxonomy.py +++ b/backend/app/scoring/taxonomy.py @@ -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, } 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/backend/app/session/finish.py b/backend/app/session/finish.py index 124e4f6..c729346 100644 --- a/backend/app/session/finish.py +++ b/backend/app/session/finish.py @@ -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] diff --git a/backend/app/session/score.py b/backend/app/session/score.py index 00e69a4..2ef6501 100644 --- a/backend/app/session/score.py +++ b/backend/app/session/score.py @@ -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, )) # Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой # занятия. Веса первого сценария поверх них испортили бы остальные. diff --git a/backend/tests/test_dds_exercise.py b/backend/tests/test_dds_exercise.py index 519c2f2..70a4e49 100644 --- a/backend/tests/test_dds_exercise.py +++ b/backend/tests/test_dds_exercise.py @@ -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) diff --git a/backend/tests/test_dispatcher_scoring.py b/backend/tests/test_dispatcher_scoring.py index 8bc5c43..f4ba2df 100644 --- a/backend/tests/test_dispatcher_scoring.py +++ b/backend/tests/test_dispatcher_scoring.py @@ -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 diff --git a/backend/tests/test_report_export.py b/backend/tests/test_report_export.py index 9a87484..0ee91f4 100644 --- a/backend/tests/test_report_export.py +++ b/backend/tests/test_report_export.py @@ -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): diff --git a/backend/tests/test_session_checkpoint.py b/backend/tests/test_session_checkpoint.py index d5842e8..e572a22 100644 --- a/backend/tests/test_session_checkpoint.py +++ b/backend/tests/test_session_checkpoint.py @@ -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(), diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx index 59b28df..7786baa 100644 --- a/frontend/src/features/debrief/Debrief.tsx +++ b/frontend/src/features/debrief/Debrief.tsx @@ -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 ? "входит" : "не входит"} в оценку }. diff --git a/frontend/src/features/instructor/ScoreWeightsEditor.tsx b/frontend/src/features/instructor/ScoreWeightsEditor.tsx index 8134184..94102ab 100644 --- a/frontend/src/features/instructor/ScoreWeightsEditor.tsx +++ b/frontend/src/features/instructor/ScoreWeightsEditor.tsx @@ -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 }) => [ diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx index 380a02a..309bb80 100644 --- a/frontend/src/pages/instructor/Instructor.tsx +++ b/frontend/src/pages/instructor/Instructor.tsx @@ -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>({}); @@ -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} /> По умолчанию 180 секунд; не заменяет 30-секундное первичное решение. } + {(exercise === "dds" || exercise === "card_dds") && }