diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py index 05c4279..9965d1d 100644 --- a/backend/app/domain/events.py +++ b/backend/app/domain/events.py @@ -54,6 +54,7 @@ class LessonCriteria(BaseModel): 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_enabled: bool = False 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 diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py index 2b3f3fd..84485c2 100644 --- a/backend/app/scoring/dispatcher.py +++ b/backend/app/scoring/dispatcher.py @@ -200,6 +200,7 @@ def evaluate_dispatcher( expected_decision_reason: str | None = None, phone_reports: list[PhoneReportRecord] | None = None, report_reaction_ms: int = REPORT_REACTION_MS, + report_reaction_enabled: bool = False, pause_intervals: Sequence[tuple[datetime, datetime]] | None = None, ) -> list[Finding]: """Отметки по работе диспетчера. Пустой список — работа без нарушений. @@ -392,9 +393,10 @@ def evaluate_dispatcher( ) # D6 — статус по докладу бригады отмечен позже лимита или не отмечен. - reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals) - if reaction is not None and reaction[1] is not None: - findings.append(reaction[1]) + if report_reaction_enabled: + reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals) + if reaction is not None and reaction[1] is not None: + findings.append(reaction[1]) return findings @@ -407,6 +409,7 @@ def dispatcher_metrics( *, services: list[str] | None = None, report_reaction_ms: int = REPORT_REACTION_MS, + report_reaction_enabled: bool = False, pause_intervals: Sequence[tuple[datetime, datetime]] | None = None, ) -> list[Metric]: """Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму. @@ -441,9 +444,10 @@ def dispatcher_metrics( # Метрика есть только там, где бригада докладывала: без докладов # реагировать не на что, и отсутствие звонков не штраф. reports = [report for report in card.phone_reports if report.service == service] - reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals) - if reaction is not None: - metrics.append(reaction[0]) + if report_reaction_enabled: + reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals) + 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 cc03471..13a97c2 100644 --- a/backend/app/scoring/export.py +++ b/backend/app/scoring/export.py @@ -55,7 +55,8 @@ def to_csv(report: SessionReport) -> bytes: row("Занятие", "", "Попытка", report.attempt) row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds) row("Критерии", "", "Лимит реакции на доклад бригады, с", - report.criteria.dds_report_reaction_limit_seconds) + report.criteria.dds_report_reaction_limit_seconds + if report.criteria.dds_report_reaction_enabled else "не применяется") row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors) row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет") row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные") @@ -172,7 +173,9 @@ 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} с"), + ("Лимит реакции на доклад бригады", + f"{report.criteria.dds_report_reaction_limit_seconds} с" + if report.criteria.dds_report_reaction_enabled else "не применяется"), ("Допустимо ошибок", report.criteria.allowed_errors), ("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"), ("Переопределения весов", report.criteria.score_weights or "стандартные"), diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py index 9f07d04..e8c5efc 100644 --- a/backend/app/session/finish.py +++ b/backend/app/session/finish.py @@ -61,11 +61,13 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord: expected_decision_reason=dds_decision.reason, phone_reports=card.phone_reports, report_reaction_ms=reaction_ms, + report_reaction_enabled=state.criteria.dds_report_reaction_enabled, pause_intervals=state.pause_intervals, ) metrics = dispatcher_metrics( card, decision_limit_ms, dds_decision.expected, dds_decision.reason, services=services, report_reaction_ms=reaction_ms, + report_reaction_enabled=state.criteria.dds_report_reaction_enabled, pause_intervals=state.pause_intervals, ) weighted = GostResult(metrics=metrics, findings=findings) diff --git a/backend/app/session/score.py b/backend/app/session/score.py index 873671e..35fe772 100644 --- a/backend/app/session/score.py +++ b/backend/app/session/score.py @@ -234,11 +234,13 @@ async def score_session( expected_decision_reason=scenario.dds_decision.reason, phone_reports=live_card.phone_reports, report_reaction_ms=reaction_ms, + report_reaction_enabled=snapshot.criteria.dds_report_reaction_enabled, pause_intervals=snapshot.pause_intervals, )) result.metrics.extend(dispatcher_metrics( live_card, decision_limit_ms, scenario.dds_decision.expected, scenario.dds_decision.reason, services=services, report_reaction_ms=reaction_ms, + report_reaction_enabled=snapshot.criteria.dds_report_reaction_enabled, pause_intervals=snapshot.pause_intervals, )) # Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой diff --git a/backend/tests/test_dds_exercise.py b/backend/tests/test_dds_exercise.py index 70a4e49..68462c0 100644 --- a/backend/tests/test_dds_exercise.py +++ b/backend/tests/test_dds_exercise.py @@ -455,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_enabled": False, "dds_report_reaction_limit_seconds": 20, "allowed_errors": 50, "require_correct_grammar": False, @@ -749,7 +750,7 @@ def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client) def test_complete_dds_workflow_with_training_calls_and_status_updates(client): - session_id, control = start(client) + session_id, control = start(client, criteria={"dds_report_reaction_enabled": True}) try: state = hub.get(session_id) with client.websocket_connect(f"/ws/station/{session_id}") as station: @@ -835,6 +836,7 @@ def send_status(station, service, status): 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_enabled": True, "dds_report_reaction_limit_seconds": 20, "score_weights": {"dds_report_reaction": 3.5}, }) @@ -876,6 +878,7 @@ def test_dds_report_reaction_goes_from_criteria_to_report(client, reaction, pass report = client.get(f"/api/sessions/{session_id}/report").json() assert report["criteria"]["dds_report_reaction_limit_seconds"] == 20 + assert report["criteria"]["dds_report_reaction_enabled"] is True assert [item for item in report["metrics"] if item["key"] == "dds_report_reaction"] == metrics if reaction == "slow": @@ -889,7 +892,7 @@ def test_dds_report_reaction_goes_from_criteria_to_report(client, reaction, pass def test_refusal_after_crew_report_does_not_lower_score_without_finding(client): """Бригада доложила о прибытии, но работы не проводились: отказ с причиной — терминальный исход, неотмеченное «Прибытие» не штрафуется.""" - session_id, control = start(client) + session_id, control = start(client, criteria={"dds_report_reaction_enabled": True}) try: state = hub.get(session_id) with client.websocket_connect(f"/ws/station/{session_id}") as station: diff --git a/backend/tests/test_dispatcher_scoring.py b/backend/tests/test_dispatcher_scoring.py index d6a1d7c..d985463 100644 --- a/backend/tests/test_dispatcher_scoring.py +++ b/backend/tests/test_dispatcher_scoring.py @@ -324,10 +324,12 @@ def reaction(status_log, reports, limit_ms=45_000, pause_intervals=None): 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, + report_reaction_enabled=True, pause_intervals=pause_intervals, ) metric = next((m for m in dispatcher_metrics(card, 30_000, services=SERVICES, report_reaction_ms=limit_ms, + report_reaction_enabled=True, pause_intervals=pause_intervals) if m.key == "dds_report_reaction"), None) return [f for f in findings if f.metric_key == "dds_report_reaction"], metric diff --git a/backend/tests/test_report_export.py b/backend/tests/test_report_export.py index 0ee91f4..1c56ea8 100644 --- a/backend/tests/test_report_export.py +++ b/backend/tests/test_report_export.py @@ -68,11 +68,21 @@ 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 ["Критерии", "", "Лимит реакции на доклад бригады, с", "не применяется", ""] in rows assert _cell(" =cmd") == "' =cmd" assert _cell("\tОбычный текст") == "'\tОбычный текст" +def test_csv_shows_enabled_report_reaction_limit(): + report = sample_report() + report.criteria.dds_report_reaction_enabled = True + report.criteria.dds_report_reaction_limit_seconds = 20 + + rows = list(csv.reader(io.StringIO(to_csv(report).decode("utf-8-sig")))) + + assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "20", ""] in rows + + def test_pdf_supports_cyrillic_and_spans_pages(tmp_path): data = to_pdf(sample_report(long=True)) assert data.startswith(b"%PDF-") @@ -91,6 +101,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 e572a22..326080a 100644 --- a/backend/tests/test_session_checkpoint.py +++ b/backend/tests/test_session_checkpoint.py @@ -88,6 +88,7 @@ def test_active_dds_session_round_trips_without_losing_work(): def test_report_reaction_keeps_times_and_limit_after_restore(): before = dds_state() + before.criteria.dds_report_reaction_enabled = True before.criteria.dds_report_reaction_limit_seconds = 20 card = before.desk.active service = before.card_services(card)[0] @@ -102,6 +103,7 @@ def test_report_reaction_keeps_times_and_limit_after_restore(): restored = load_state(dump_state(before), now_utc()) assert restored.criteria.dds_report_reaction_limit_seconds == 20 + assert restored.criteria.dds_report_reaction_enabled assert restored.desk.active.phone_reports == card.phone_reports def reaction(state): @@ -118,6 +120,31 @@ def test_report_reaction_keeps_times_and_limit_after_restore(): assert reaction(restored) == reaction(before), "оценка после восстановления та же" +def test_report_reaction_default_off_keeps_status_completeness_d6(): + state = dds_state() + card = state.desk.active + service = state.card_services(card)[0] + card.set_service_status(service, ServiceStatus.RESPONDING, + "Основание: доклад.\nСведения: бригада выехала.") + without_report = state.model_copy(deep=True) + card.phone_reports.append(PhoneReportRecord( + service=service, crew=card.crew_selected, phase="dispatched", + text="Бригада выехала", at=card.status_log[-1].at - timedelta(seconds=70), + )) + + result = score_dds_card(state, card) + baseline = score_dds_card(without_report, without_report.desk.active) + + assert state.criteria.dds_report_reaction_enabled is False + assert result.score_auto == baseline.score_auto + assert [(item.code, item.metric_key, item.fact) for item in result.findings] == [ + (item.code, item.metric_key, item.fact) for item in baseline.findings + ] + assert not any(item.key == "dds_report_reaction" for item in result.metrics) + assert not any(item.metric_key == "dds_report_reaction" for item in result.findings) + assert any(item.code is ErrorCode.D6 for item in result.findings) + + def completed_card() -> DdsCardRecord: return DdsCardRecord( card_id=uuid4(), diff --git a/backend/tests/test_session_pause.py b/backend/tests/test_session_pause.py index 4f662ba..88f89a0 100644 --- a/backend/tests/test_session_pause.py +++ b/backend/tests/test_session_pause.py @@ -628,6 +628,7 @@ def test_repeated_pause_and_resume_are_idempotent(clock, monkeypatch): def test_report_reaction_excludes_pause_after_control_and_checkpoint(clock, monkeypatch): base = datetime(2026, 9, 27, 10, tzinfo=UTC) state, card = _dds_state_with_active_card(clock) + state.criteria.dds_report_reaction_enabled = True service = state.card_services(card)[0] monkeypatch.setattr("app.session.dds.now_utc", lambda: base) card.set_service_status(service, ServiceStatus.ACCEPTED, "Карточка принята") diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx index cd2fd92..69f6eb7 100644 --- a/frontend/src/features/debrief/Debrief.tsx +++ b/frontend/src/features/debrief/Debrief.tsx @@ -83,7 +83,9 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?: {` ${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.criteria.dds_report_reaction_enabled + ? ` не позже ${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 94102ab..f1cc62d 100644 --- a/frontend/src/features/instructor/ScoreWeightsEditor.tsx +++ b/frontend/src/features/instructor/ScoreWeightsEditor.tsx @@ -47,10 +47,12 @@ const REVIEW_METRICS: MetricOption[] = [ export function ScoreWeightsEditor({ exercise, + reportReactionEnabled, values, onChange, }: { exercise: LessonExercise; + reportReactionEnabled: boolean; values: Record; onChange: (weights: Record) => void; }) { @@ -63,6 +65,11 @@ export function ScoreWeightsEditor({ ? [["Карточка КИО", CARD_METRICS], ["Реагирование ДДС", DDS_METRICS]] : [["Опрос 112", CALL_METRICS], ["Реагирование ДДС", DDS_METRICS]]; groups.push(["Разбор", REVIEW_METRICS]); + if (!reportReactionEnabled) { + for (const group of groups) { + group[1] = group[1].filter(({ key }) => key !== "dds_report_reaction"); + } + } function setWeight(key: string, raw: string) { const next = { ...values }; diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx index b362a7f..65bb74a 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 [ddsReportReactionEnabled, setDdsReportReactionEnabled] = useState(false); const [ddsReportReactionLimit, setDdsReportReactionLimit] = useState(45); const [allowedErrors, setAllowedErrors] = useState(0); const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true); @@ -292,6 +293,7 @@ export function Instructor() { && !(exercise === "dds" && wholeTicket) ? visibleScenarios.filter((item) => item.outcome === "card").map((item) => item.id) : undefined; + const reportReactionEnabled = (exercise === "dds" || exercise === "card_dds") && ddsReportReactionEnabled; const sent = control.start(sessionId, { scenarioId: primaryScenarioId, mode, @@ -308,15 +310,18 @@ export function Instructor() { decision_time_limit_seconds: decisionTimeLimit, card_fill_time_limit_seconds: cardFillTimeLimit, dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit, + dds_report_reaction_enabled: reportReactionEnabled, dds_report_reaction_limit_seconds: ddsReportReactionLimit, allowed_errors: allowedErrors, require_correct_grammar: requireCorrectGrammar, - score_weights: lessonScoreWeights, + score_weights: reportReactionEnabled + ? lessonScoreWeights + : Object.fromEntries(Object.entries(lessonScoreWeights).filter(([key]) => key !== "dds_report_reaction")), }, }); if (sent) toStart.current = null; }, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data, - exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios, + exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionEnabled, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios, ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data, randomScenario, visibleScenarios]); @@ -664,13 +669,20 @@ export function Instructor() { onCommit={setDdsCardWorkTimeLimit} /> По умолчанию 180 секунд; не заменяет 30-секундное первичное решение. } - {(exercise === "dds" || exercise === "card_dds") && } + {(exercise === "dds" || exercise === "card_dds") && <> + + {ddsReportReactionEnabled && } + }