diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py index 3cd3790..70ac045 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..ab81dd2 100644 --- a/backend/app/scoring/dispatcher.py +++ b/backend/app/scoring/dispatcher.py @@ -16,6 +16,7 @@ from app.domain.statuses import ( PRIMARY, REFUSAL_COMMENT_REQUIRED, SERVICE_STATUS_LABELS, + PhoneReportRecord, ServiceStatus, StatusEntry, current, @@ -27,6 +28,47 @@ from app.domain.timers import GOST_REF #: Статусы, означающие, что реагирование действительно шло. 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 + + +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 _finding(service: str, code: ErrorCode, summary: str, fact: str, norm: str | None = None) -> Finding: @@ -93,6 +135,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 +308,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 +327,32 @@ 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"})) + return findings @@ -290,6 +363,7 @@ def dispatcher_metrics( expected_decision_reason: str | None = None, *, services: list[str] | None = None, + report_reaction_ms: int = REPORT_REACTION_MS, ) -> list[Metric]: """Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму. @@ -355,4 +429,17 @@ 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 9ee879b..1eeafc7 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 "стандартные") diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py index 28f8dc8..be96cd1 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), } 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..b027fad 100644 --- a/backend/tests/test_dds_exercise.py +++ b/backend/tests/test_dds_exercise.py @@ -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,8 @@ 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 reaction and all(m["passed"] for m in reaction), "каждый доклад отмечен вовремя" finally: control.__exit__(None, None, None) diff --git a/backend/tests/test_dispatcher_scoring.py b/backend/tests/test_dispatcher_scoring.py index 8bc5c43..f6d562c 100644 --- a/backend/tests/test_dispatcher_scoring.py +++ b/backend/tests/test_dispatcher_scoring.py @@ -1,10 +1,10 @@ -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.statuses import PhoneReportRecord, ServiceStatus, StatusEntry from app.domain.taxonomy import ErrorCode from app.scenarios.loader import load_file from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher @@ -294,3 +294,95 @@ 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.fact == "о прибытии: 10 с" + + +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 "«Прибытие» отмечен через 70 с" in findings[0].fact + assert "о прибытии" in findings[0].fact, "факт ссылается на доклад" + assert findings[0].norm == "отметить статус по докладу не позже 45 с" + assert not metric.passed and metric.norm == "статус по каждому докладу не позже 45 с" + + +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.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, "статус раньше доклада — не нарушение" + + strict, _ = reaction(full_run(arrived_at=130), [crew_report("arrived", 100)], limit_ms=20_000) + assert "не позже 20 с" in strict[0].norm + + +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_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/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx index 8d69d3b..0b3230e 100644 --- a/frontend/src/features/debrief/Debrief.tsx +++ b/frontend/src/features/debrief/Debrief.tsx @@ -75,7 +75,8 @@ 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_card_work_time_limit_seconds ?? 180} с`}; статус по докладу бригады — + {` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с`}} {report.exercise === "card" && <>Норматив заполнения КИО — {` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}} {report.exercise && report.exercise !== "dds" && <> diff --git a/frontend/src/features/instructor/ScoreWeightsEditor.tsx b/frontend/src/features/instructor/ScoreWeightsEditor.tsx index 9f81065..9bced96 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 fa5184e..55608f2 100644 --- a/frontend/src/pages/instructor/Instructor.tsx +++ b/frontend/src/pages/instructor/Instructor.tsx @@ -86,6 +86,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>({}); @@ -300,6 +301,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, @@ -307,7 +309,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]); @@ -649,6 +651,13 @@ export function Instructor() { onCommit={setDdsCardWorkTimeLimit} /> По умолчанию 180 секунд; не заменяет 30-секундное первичное решение. } + {(exercise === "dds" || exercise === "card_dds") && }