fix: пауза исключена из реакции на доклад
This commit is contained in:
parent
0d58878cbe
commit
6e7c4f4596
8 changed files with 97 additions and 16 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
))
|
||||
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
|
||||
# занятия. Веса первого сценария поверх них испортили бы остальные.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue