fix: пауза исключена из реакции на доклад

This commit is contained in:
kaifarikman 2026-09-27 23:33:46 +03:00
commit 6e7c4f4596
8 changed files with 97 additions and 16 deletions

View file

@ -432,6 +432,8 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
if hub.is_lease_fenced(session_id): if hub.is_lease_fenced(session_id):
await close_fenced(ws) await close_fenced(ws)
return return
# Переподключившийся клиент сначала узнаёт, разрешён ли ввод.
queue.put_nowait(SessionPaused(paused=state.paused))
if state.exercise is Exercise.CARD: if state.exercise is Exercise.CARD:
from app.api.ws.control import card_briefing 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: if state.recorder is None:
state.recorder = start_recording(session_id) state.recorder = start_recording(session_id)
_start_voice(session_id, state, initial_statement=False) _start_voice(session_id, state, initial_statement=False)
# Каждый новый канал получает серверное состояние, включая снятую паузу:
# клиент мог потерять событие resume во время переподключения.
queue.put_nowait(SessionPaused(paused=state.paused))
writer = asyncio.create_task(pump(ws, queue)) writer = asyncio.create_task(pump(ws, queue))
try: try:
while True: while True:

View file

@ -12,6 +12,8 @@
""" """
import math import math
from collections.abc import Sequence
from datetime import datetime
from app.domain.statuses import ( from app.domain.statuses import (
COMMENT_REQUIRED, COMMENT_REQUIRED,
@ -68,6 +70,7 @@ TERMINAL_REFUSAL = {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
def report_reactions( def report_reactions(
marks: list[StatusEntry], reports: list[PhoneReportRecord], marks: list[StatusEntry], reports: list[PhoneReportRecord],
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[tuple[PhoneReportRecord, int | None]]: ) -> list[tuple[PhoneReportRecord, int | None]]:
"""Задержка статуса после каждого доклада бригады, мс; None — статус не отмечен. """Задержка статуса после каждого доклада бригады, мс; None — статус не отмечен.
@ -84,15 +87,22 @@ def report_reactions(
result.append((report, 0)) result.append((report, 0))
continue continue
after = min(times, default=None) after = min(times, default=None)
result.append(( if after is None:
report, result.append((report, None))
int((after - report.at).total_seconds() * 1000) if after is not None else 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 return result
def report_reaction( def report_reaction(
service: str, marks: list[StatusEntry], reports: list[PhoneReportRecord], limit_ms: int, service: str, marks: list[StatusEntry], reports: list[PhoneReportRecord], limit_ms: int,
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> tuple[Metric, Finding | None] | None: ) -> tuple[Metric, Finding | None] | None:
"""Метрика и отметка D6 по самой поздней реакции службы на доклады бригады. """Метрика и отметка D6 по самой поздней реакции службы на доклады бригады.
@ -100,7 +110,7 @@ def report_reaction(
где отметки нет. None — реагировать было не на что. где отметки нет. None — реагировать было не на что.
""" """
refused = current(marks, service) in TERMINAL_REFUSAL 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 delay is not None or not refused]
if not reactions: if not reactions:
return None return None
@ -190,6 +200,7 @@ def evaluate_dispatcher(
expected_decision_reason: str | None = None, expected_decision_reason: str | None = None,
phone_reports: list[PhoneReportRecord] | None = None, phone_reports: list[PhoneReportRecord] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS, report_reaction_ms: int = REPORT_REACTION_MS,
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[Finding]: ) -> list[Finding]:
"""Отметки по работе диспетчера. Пустой список — работа без нарушений. """Отметки по работе диспетчера. Пустой список — работа без нарушений.
@ -381,7 +392,7 @@ def evaluate_dispatcher(
) )
# D6 — статус по докладу бригады отмечен позже лимита или не отмечен. # 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: if reaction is not None and reaction[1] is not None:
findings.append(reaction[1]) findings.append(reaction[1])
@ -396,6 +407,7 @@ def dispatcher_metrics(
*, *,
services: list[str] | None = None, services: list[str] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS, report_reaction_ms: int = REPORT_REACTION_MS,
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[Metric]: ) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму. """Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
@ -429,7 +441,7 @@ def dispatcher_metrics(
# Метрика есть только там, где бригада докладывала: без докладов # Метрика есть только там, где бригада докладывала: без докладов
# реагировать не на что, и отсутствие звонков не штраф. # реагировать не на что, и отсутствие звонков не штраф.
reports = [report for report in card.phone_reports if report.service == service] 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: if reaction is not None:
metrics.append(reaction[0]) metrics.append(reaction[0])
expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept" expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept"

View file

@ -61,10 +61,12 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
expected_decision_reason=dds_decision.reason, expected_decision_reason=dds_decision.reason,
phone_reports=card.phone_reports, phone_reports=card.phone_reports,
report_reaction_ms=reaction_ms, report_reaction_ms=reaction_ms,
pause_intervals=state.pause_intervals,
) )
metrics = dispatcher_metrics( metrics = dispatcher_metrics(
card, decision_limit_ms, dds_decision.expected, dds_decision.reason, card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
services=services, report_reaction_ms=reaction_ms, services=services, report_reaction_ms=reaction_ms,
pause_intervals=state.pause_intervals,
) )
weighted = GostResult(metrics=metrics, findings=findings) weighted = GostResult(metrics=metrics, findings=findings)
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK] work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]

View file

@ -8,6 +8,7 @@
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from typing import Any from typing import Any
from app.domain.events import CallEndReason, Exercise, LessonCriteria, Metric from app.domain.events import CallEndReason, Exercise, LessonCriteria, Metric
@ -68,6 +69,7 @@ class ScoringSnapshot:
#: Карточка на пульте в упражнении по звонку: отметки D1–D6 рядом с E1–E6. #: Карточка на пульте в упражнении по звонку: отметки D1–D6 рядом с E1–E6.
call_card: DdsLiveCard | None call_card: DdsLiveCard | None
call_card_services: tuple[str, ...] call_card_services: tuple[str, ...]
pause_intervals: tuple[tuple[datetime, datetime], ...]
@property @property
def reports_cards(self) -> bool: 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=live_card.model_copy(deep=True) if live_card is not None else None,
call_card_services=(tuple(state.card_services(live_card)) call_card_services=(tuple(state.card_services(live_card))
if live_card is not None else ()), 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, expected_decision_reason=scenario.dds_decision.reason,
phone_reports=live_card.phone_reports, phone_reports=live_card.phone_reports,
report_reaction_ms=reaction_ms, report_reaction_ms=reaction_ms,
pause_intervals=snapshot.pause_intervals,
)) ))
result.metrics.extend(dispatcher_metrics( result.metrics.extend(dispatcher_metrics(
live_card, decision_limit_ms, scenario.dds_decision.expected, live_card, decision_limit_ms, scenario.dds_decision.expected,
scenario.dds_decision.reason, services=services, report_reaction_ms=reaction_ms, scenario.dds_decision.reason, services=services, report_reaction_ms=reaction_ms,
pause_intervals=snapshot.pause_intervals,
)) ))
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой # Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
# занятия. Веса первого сценария поверх них испортили бы остальные. # занятия. Веса первого сценария поверх них испортили бы остальные.

View file

@ -111,6 +111,8 @@ class PersistedSession(BaseModel):
paused_at: datetime | None = None paused_at: datetime | None = None
#: Суммарная длительность пауз — для разбора, чтобы время было объяснимо. #: Суммарная длительность пауз — для разбора, чтобы время было объяснимо.
total_paused_ms: int = 0 total_paused_ms: int = 0
#: Закрытые интервалы пауз нужны для расчёта активного времени между докладом и статусом.
pause_intervals: list[tuple[datetime, datetime]] = Field(default_factory=list)
#: Карточка оператора 112, замороженная при передаче в ДДС. #: Карточка оператора 112, замороженная при передаче в ДДС.
dispatched_card: KIO | None = None dispatched_card: KIO | None = None
dispatched_at: datetime | None = None dispatched_at: datetime | None = None
@ -218,8 +220,11 @@ class SessionState(PersistedSession):
for card in self.desk.cards.values(): for card in self.desk.cards.values():
card.timers.resume(now) card.timers.resume(now)
if self.paused_at is not None: 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)) 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: if self.desk.next_arrival_at is not None:
self.desk.next_arrival_at += idle self.desk.next_arrival_at += idle
self.paused = False self.paused = False

View file

@ -163,6 +163,7 @@ def full_state() -> SessionState:
paused=True, paused=True,
paused_at=AT, paused_at=AT,
total_paused_ms=15_000, total_paused_ms=15_000,
pause_intervals=[(AT - timedelta(seconds=20), AT - timedelta(seconds=5))],
processed_station_commands=[str(uuid4())], processed_station_commands=[str(uuid4())],
text_revealed_facts={"f_address": "улица Ленина, 14"}, text_revealed_facts={"f_address": "улица Ленина, 14"},
consecutive_refusals=2, consecutive_refusals=2,

View file

@ -316,7 +316,7 @@ def crew_report(phase: str, seconds: int) -> PhoneReportRecord:
text="доклад", at=at_second(seconds)) 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"}, card = live_card(status_log=status_log, crew_assignments={SERVICES[0]: "Бригада 12"},
dispatched_at=T0) dispatched_at=T0)
card.phone_reports = reports 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, entries=status_log, services=SERVICES, crew_assignments=card.crew_assignments,
deadline_ms=30_000, elapsed_ms=5_000, phone_reports=reports, deadline_ms=30_000, elapsed_ms=5_000, phone_reports=reports,
report_reaction_ms=limit_ms, report_reaction_ms=limit_ms,
pause_intervals=pause_intervals,
) )
metric = next((m for m in dispatcher_metrics(card, 30_000, services=SERVICES, 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) if m.key == "dds_report_reaction"), None)
return [f for f in findings if f.metric_key == "dds_report_reaction"], metric 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) 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(): def test_violation_by_a_fraction_of_a_second_is_not_shown_as_the_limit():
status_log = full_run(arrived_at=170) status_log = full_run(arrived_at=170)
status_log[2] = status_log[2].model_copy( status_log[2] = status_log[2].model_copy(

View file

@ -7,7 +7,7 @@
import asyncio import asyncio
import time import time
from datetime import timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
@ -41,14 +41,14 @@ from app.domain.events import (
TtsEnd, TtsEnd,
) )
from app.domain.roles import Role 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.domain.timers import TimerCode
from app.main import app from app.main import app
from app.scenarios.loader import load_file from app.scenarios.loader import load_file
from app.scoring.dispatcher import dispatcher_metrics from app.scoring.dispatcher import dispatcher_metrics
from app.session import timers as timers_module from app.session import timers as timers_module
from app.session.checkpoint import dump_state, load_state 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.hub import hub
from app.session.state import SessionState, now_utc from app.session.state import SessionState, now_utc
from app.session.store import LessonPaused, LessonResumed, MemorySessionStore 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 sum(isinstance(record, LessonResumed) for record in records) == 1
assert [event.paused for event in events if isinstance(event, SessionPaused)] == [True, False] assert [event.paused for event in events if isinstance(event, SessionPaused)] == [True, False]
assert state.total_paused_ms == 60_000 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 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): def test_pause_is_ignored_in_ended_session(clock, monkeypatch):
state, _card = _dds_state_with_active_card(clock) state, _card = _dds_state_with_active_card(clock)
store = MemorySessionStore() store = MemorySessionStore()