diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py index 78387ff..bb867be 100644 --- a/backend/app/api/ws/call.py +++ b/backend/app/api/ws/call.py @@ -432,6 +432,8 @@ async def call(ws: WebSocket, session_id: UUID) -> None: if hub.is_lease_fenced(session_id): await close_fenced(ws) return + # Переподключившийся клиент сначала узнаёт, разрешён ли ввод. + queue.put_nowait(SessionPaused(paused=state.paused)) if state.exercise is Exercise.CARD: from app.api.ws.control import card_briefing @@ -465,9 +467,6 @@ async def call(ws: WebSocket, session_id: UUID) -> None: if state.recorder is None: state.recorder = start_recording(session_id) _start_voice(session_id, state, initial_statement=False) - # Каждый новый канал получает серверное состояние, включая снятую паузу: - # клиент мог потерять событие resume во время переподключения. - queue.put_nowait(SessionPaused(paused=state.paused)) writer = asyncio.create_task(pump(ws, queue)) try: while True: diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py index 0342234..2b3f3fd 100644 --- a/backend/app/scoring/dispatcher.py +++ b/backend/app/scoring/dispatcher.py @@ -12,6 +12,8 @@ """ import math +from collections.abc import Sequence +from datetime import datetime from app.domain.statuses import ( COMMENT_REQUIRED, @@ -68,6 +70,7 @@ TERMINAL_REFUSAL = {ServiceStatus.DECLINED, ServiceStatus.REFUSED} def report_reactions( marks: list[StatusEntry], reports: list[PhoneReportRecord], + pause_intervals: Sequence[tuple[datetime, datetime]] | None = None, ) -> list[tuple[PhoneReportRecord, int | None]]: """Задержка статуса после каждого доклада бригады, мс; None — статус не отмечен. @@ -84,15 +87,22 @@ def report_reactions( 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, - )) + if after is None: + result.append((report, None)) + continue + elapsed_ms = int((after - report.at).total_seconds() * 1000) + for start, end in pause_intervals or (): + overlap_start = max(report.at, start) + overlap_end = min(after, end) + if overlap_end > overlap_start: + elapsed_ms -= int((overlap_end - overlap_start).total_seconds() * 1000) + result.append((report, max(0, elapsed_ms))) return result def report_reaction( service: str, marks: list[StatusEntry], reports: list[PhoneReportRecord], limit_ms: int, + pause_intervals: Sequence[tuple[datetime, datetime]] | None = None, ) -> tuple[Metric, Finding | None] | None: """Метрика и отметка D6 по самой поздней реакции службы на доклады бригады. @@ -100,7 +110,7 @@ def report_reaction( где отметки нет. None — реагировать было не на что. """ refused = current(marks, service) in TERMINAL_REFUSAL - reactions = [(report, delay) for report, delay in report_reactions(marks, reports) + reactions = [(report, delay) for report, delay in report_reactions(marks, reports, pause_intervals) if delay is not None or not refused] if not reactions: return None @@ -190,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, + pause_intervals: Sequence[tuple[datetime, datetime]] | None = None, ) -> list[Finding]: """Отметки по работе диспетчера. Пустой список — работа без нарушений. @@ -381,7 +392,7 @@ def evaluate_dispatcher( ) # D6 — статус по докладу бригады отмечен позже лимита или не отмечен. - reaction = report_reaction(service, marks, reports, report_reaction_ms) + 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]) @@ -396,6 +407,7 @@ def dispatcher_metrics( *, services: list[str] | None = None, report_reaction_ms: int = REPORT_REACTION_MS, + pause_intervals: Sequence[tuple[datetime, datetime]] | None = None, ) -> list[Metric]: """Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму. @@ -429,7 +441,7 @@ def dispatcher_metrics( # Метрика есть только там, где бригада докладывала: без докладов # реагировать не на что, и отсутствие звонков не штраф. reports = [report for report in card.phone_reports if report.service == service] - reaction = report_reaction(service, marks, reports, report_reaction_ms) + 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" diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py index c7a30d9..9f07d04 100644 --- a/backend/app/session/finish.py +++ b/backend/app/session/finish.py @@ -61,10 +61,12 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord: expected_decision_reason=dds_decision.reason, phone_reports=card.phone_reports, report_reaction_ms=reaction_ms, + 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, + pause_intervals=state.pause_intervals, ) 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 2ef6501..873671e 100644 --- a/backend/app/session/score.py +++ b/backend/app/session/score.py @@ -8,6 +8,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass +from datetime import datetime from typing import Any from app.domain.events import CallEndReason, Exercise, LessonCriteria, Metric @@ -68,6 +69,7 @@ class ScoringSnapshot: #: Карточка на пульте в упражнении по звонку: отметки D1–D6 рядом с E1–E6. call_card: DdsLiveCard | None call_card_services: tuple[str, ...] + pause_intervals: tuple[tuple[datetime, datetime], ...] @property def reports_cards(self) -> bool: @@ -112,6 +114,7 @@ class ScoringSnapshot: call_card=live_card.model_copy(deep=True) if live_card is not None else None, call_card_services=(tuple(state.card_services(live_card)) if live_card is not None else ()), + pause_intervals=tuple(state.pause_intervals), ) @@ -231,10 +234,12 @@ async def score_session( expected_decision_reason=scenario.dds_decision.reason, phone_reports=live_card.phone_reports, report_reaction_ms=reaction_ms, + 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, + pause_intervals=snapshot.pause_intervals, )) # Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой # занятия. Веса первого сценария поверх них испортили бы остальные. diff --git a/backend/app/session/state.py b/backend/app/session/state.py index 1f2c850..5c99a5c 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -111,6 +111,8 @@ class PersistedSession(BaseModel): paused_at: datetime | None = None #: Суммарная длительность пауз — для разбора, чтобы время было объяснимо. total_paused_ms: int = 0 + #: Закрытые интервалы пауз нужны для расчёта активного времени между докладом и статусом. + pause_intervals: list[tuple[datetime, datetime]] = Field(default_factory=list) #: Карточка оператора 112, замороженная при передаче в ДДС. dispatched_card: KIO | None = None dispatched_at: datetime | None = None @@ -218,8 +220,11 @@ class SessionState(PersistedSession): for card in self.desk.cards.values(): card.timers.resume(now) if self.paused_at is not None: - idle = now_utc() - self.paused_at + resumed_at = now_utc() + idle = resumed_at - self.paused_at self.total_paused_ms += max(0, int(idle.total_seconds() * 1000)) + if resumed_at > self.paused_at: + self.pause_intervals.append((self.paused_at, resumed_at)) if self.desk.next_arrival_at is not None: self.desk.next_arrival_at += idle self.paused = False diff --git a/backend/tests/test_checkpoint_model.py b/backend/tests/test_checkpoint_model.py index 90cead6..5032577 100644 --- a/backend/tests/test_checkpoint_model.py +++ b/backend/tests/test_checkpoint_model.py @@ -163,6 +163,7 @@ def full_state() -> SessionState: paused=True, paused_at=AT, total_paused_ms=15_000, + pause_intervals=[(AT - timedelta(seconds=20), AT - timedelta(seconds=5))], processed_station_commands=[str(uuid4())], text_revealed_facts={"f_address": "улица Ленина, 14"}, consecutive_refusals=2, diff --git a/backend/tests/test_dispatcher_scoring.py b/backend/tests/test_dispatcher_scoring.py index f4ba2df..d6a1d7c 100644 --- a/backend/tests/test_dispatcher_scoring.py +++ b/backend/tests/test_dispatcher_scoring.py @@ -316,7 +316,7 @@ def crew_report(phase: str, seconds: int) -> PhoneReportRecord: text="доклад", at=at_second(seconds)) -def reaction(status_log, reports, limit_ms=45_000): +def reaction(status_log, reports, limit_ms=45_000, pause_intervals=None): card = live_card(status_log=status_log, crew_assignments={SERVICES[0]: "Бригада 12"}, dispatched_at=T0) card.phone_reports = reports @@ -324,9 +324,11 @@ def reaction(status_log, reports, limit_ms=45_000): 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, + pause_intervals=pause_intervals, ) metric = next((m for m in dispatcher_metrics(card, 30_000, services=SERVICES, - report_reaction_ms=limit_ms) + report_reaction_ms=limit_ms, + 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 @@ -364,6 +366,21 @@ def test_status_later_than_limit_after_crew_report_is_d6_with_fact_and_norm(): assert metric.credit == time_credit(70_000, 45_000) +def test_pause_between_report_and_status_does_not_count_as_reaction_time(): + marks = full_run(arrived_at=180) + reports = [crew_report("arrived", 100)] + pause = [(at_second(110), at_second(170))] + + without_pause, late = reaction(marks, reports) + with_pause, active = reaction(marks, reports, pause_intervals=pause) + + assert [finding.code for finding in without_pause] == [ErrorCode.D6] + assert not late.passed + assert with_pause == [] + assert active.passed and active.credit == time_credit(20_000, 45_000) + assert "20 с" in active.fact + + 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( diff --git a/backend/tests/test_session_pause.py b/backend/tests/test_session_pause.py index cf522a4..4f662ba 100644 --- a/backend/tests/test_session_pause.py +++ b/backend/tests/test_session_pause.py @@ -7,7 +7,7 @@ import asyncio import time -from datetime import timedelta +from datetime import UTC, datetime, timedelta from pathlib import Path from uuid import uuid4 @@ -41,14 +41,14 @@ from app.domain.events import ( TtsEnd, ) from app.domain.roles import Role -from app.domain.statuses import ServiceStatus +from app.domain.statuses import PhoneReportRecord, ServiceStatus from app.domain.timers import TimerCode from app.main import app from app.scenarios.loader import load_file from app.scoring.dispatcher import dispatcher_metrics from app.session import timers as timers_module from app.session.checkpoint import dump_state, load_state -from app.session.finish import end_session +from app.session.finish import end_session, score_dds_card from app.session.hub import hub from app.session.state import SessionState, now_utc from app.session.store import LessonPaused, LessonResumed, MemorySessionStore @@ -621,9 +621,49 @@ def test_repeated_pause_and_resume_are_idempotent(clock, monkeypatch): assert sum(isinstance(record, LessonResumed) for record in records) == 1 assert [event.paused for event in events if isinstance(event, SessionPaused)] == [True, False] assert state.total_paused_ms == 60_000 + assert state.pause_intervals == [(base, base + timedelta(seconds=60))] assert card.timers.timers[TimerCode.DDS_ACK].current_ms(clock.now) == 15_000 +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) + service = state.card_services(card)[0] + monkeypatch.setattr("app.session.dds.now_utc", lambda: base) + card.set_service_status(service, ServiceStatus.ACCEPTED, "Карточка принята") + card.phone_reports.append(PhoneReportRecord( + service=service, crew="Бригада 12", phase="dispatched", + text="Бригада выехала", at=base, + )) + store = MemorySessionStore() + monkeypatch.setattr(hub, "store", store) + monkeypatch.setitem(hub._sessions, state.session_id, state) + + async def command(event): + async with hub.operation(state.session_id): + await control_ws._command(state.session_id, event, TEACHER) + + monkeypatch.setattr("app.session.state.now_utc", lambda: base + timedelta(seconds=10)) + asyncio.run(command(SessionPause())) + restored = load_state(dump_state(state), base + timedelta(seconds=70)) + assert restored.paused and restored.pause_intervals == [] + monkeypatch.setitem(hub._sessions, state.session_id, restored) + monkeypatch.setattr("app.session.state.now_utc", lambda: base + timedelta(seconds=70)) + asyncio.run(command(SessionResume())) + monkeypatch.setattr("app.session.dds.now_utc", lambda: base + timedelta(seconds=80)) + restored.desk.active.set_service_status( + service, ServiceStatus.RESPONDING, "Основание: доклад. Сведения: бригада выехала.", + ) + + result = score_dds_card(restored, restored.desk.active) + reaction = next(item for item in result.metrics if item.key == "dds_report_reaction") + assert restored.pause_intervals == [ + (base + timedelta(seconds=10), base + timedelta(seconds=70)), + ] + assert reaction.passed and "20 с" in reaction.fact + assert not any(item.metric_key == "dds_report_reaction" for item in result.findings) + + def test_pause_is_ignored_in_ended_session(clock, monkeypatch): state, _card = _dds_state_with_active_card(clock) store = MemorySessionStore()