diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py index c22ddb6..d5f3b4d 100644 --- a/backend/app/api/http/sessions.py +++ b/backend/app/api/http/sessions.py @@ -8,6 +8,7 @@ import logging import time from collections.abc import AsyncIterator from datetime import UTC, datetime +from typing import Literal from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response @@ -23,7 +24,7 @@ from app.db.base import get_session from app.db.models import Group, Score, Session, Trainee from app.domain.events import Exercise, SessionMode, SessionReport from app.domain.roles import Role -from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current +from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current from app.domain.timers import TimerCode from app.scoring.export import to_csv, to_pdf from app.scoring.report import build as build_report @@ -32,6 +33,7 @@ from app.session.checkpoint import load_state from app.session.finish import override_score from app.session.score import scoring_scenario from app.session.hub import hub +from app.session.state import SessionState from app.session.store import ScoreOverridden, apply_score_override from app.voice.recording import recording_path @@ -86,6 +88,15 @@ class DdsHistoryOut(BaseModel): recipient_services: list[str] = [] +class Signal(BaseModel): + """Одна строка колонки сигналов реестра. Цвет несёт смысл только через + `severity`; текст обязателен и не заменяется цветом.""" + + kind: Literal["backlog", "refusals", "offline"] + severity: Literal["warn", "violated"] + text: str + + class ActiveSessionOut(BaseModel): session_id: UUID trainee_name: str | None @@ -101,6 +112,7 @@ class ActiveSessionOut(BaseModel): dds_work_overdue_cards: int dds_statuses: dict[str, str] dds_snapshot: StationSnapshot | None = None + signals: list[Signal] = [] def _out(session) -> SessionOut: @@ -184,6 +196,44 @@ async def dds_history( return result +def _signals(state: SessionState, queue: list[DdsQueueCard], now: datetime) -> list[Signal]: + """Сигналы реестра: очередь, повторные отказы, курсант не на связи. + + Не завязаны на паузу занятия (`session.pause`, если появится) — очередь, + отказы и присутствие сокета не читают таймеры. Присутствие — по наличию + подписчика в хабе; окно нужно, чтобы короткий разрыв соединения не сразу + считался потерей связи курсанта. + """ + settings = get_settings() + signals: list[Signal] = [] + if len(queue) >= settings.signal_backlog_threshold: + signals.append(Signal( + kind="backlog", severity="warn", + text=f"Очередь: {len(queue)} необработанных карточек", + )) + if state.consecutive_refusals >= settings.signal_refusals_threshold: + signals.append(Signal( + kind="refusals", severity="warn", + text=f"Подряд отказов: {state.consecutive_refusals}", + )) + connected = ( + hub.station_connected(state.session_id) if state.exercise is Exercise.DDS + else hub.trainee_connected(state.session_id) + ) + if connected: + state.socket_last_seen_at = now + offline_seconds = ( + (now - state.socket_last_seen_at).total_seconds() + if state.socket_last_seen_at is not None else 0 + ) + if offline_seconds >= settings.signal_offline_window_seconds: + signals.append(Signal( + kind="offline", severity="violated", + text=f"Курсант не на связи {int(offline_seconds)} с", + )) + return signals + + @router.get("/active", response_model=list[ActiveSessionOut]) async def active( request: Request, @@ -262,6 +312,7 @@ async def active( ), dds_statuses=latest_statuses, dds_snapshot=station, + signals=_signals(state, queue, now), )) return result diff --git a/backend/app/config.py b/backend/app/config.py index e318cf4..ff17222 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -120,6 +120,11 @@ class Settings(BaseSettings): def limit_ms(self, code: TimerCode) -> int: return self.timer_limits_ms.get(code, NORMATIVES[code].limit_ms) + # Пороги сигналов реестра живых занятий (пульт преподавателя). + signal_backlog_threshold: int = Field(default=4, ge=1) + signal_refusals_threshold: int = Field(default=2, ge=1) + signal_offline_window_seconds: int = Field(default=45, ge=1) + def validate_deployment_security(self) -> None: """Reject known development authentication defaults on a production app.""" if self.app_env != "production": diff --git a/backend/app/session/dds.py b/backend/app/session/dds.py index 67832de..48fc334 100644 --- a/backend/app/session/dds.py +++ b/backend/app/session/dds.py @@ -446,6 +446,7 @@ class DdsDesk(BaseModel): services[0], ServiceStatus.ACCEPTED, command.comment, author="диспетчер", ) + session.consecutive_refusals = 0 except StatusError: pass # статус уже стоит: повторное нажатие ничего не меняет case "card.status": @@ -462,6 +463,13 @@ class DdsDesk(BaseModel): # Первичный статус останавливает норматив 30 секунд. if command.status in PRIMARY: card.on_event("card.ack") + # Сигнал реестра "повторные отказы" считает подряд идущие + # отказы, а не сумму за занятие. + session.consecutive_refusals = ( + session.consecutive_refusals + 1 + if command.status is ServiceStatus.DECLINED + else 0 + ) if command.status in { ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED, }: diff --git a/backend/app/session/hub.py b/backend/app/session/hub.py index 056c616..3a2bc29 100644 --- a/backend/app/session/hub.py +++ b/backend/app/session/hub.py @@ -314,6 +314,12 @@ class SessionHub: def observer_count(self, session_id: UUID) -> int: return len(self._observers.get(session_id, set())) + def station_connected(self, session_id: UUID) -> bool: + return bool(self._stations.get(session_id)) + + def trainee_connected(self, session_id: UUID) -> bool: + return bool(self._trainees.get(session_id)) + # ── такт таймеров ── def start_ticker(self, session_id: UUID) -> None: diff --git a/backend/app/session/state.py b/backend/app/session/state.py index 166e611..38a1491 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -124,6 +124,9 @@ class PersistedSession(BaseModel): #: a lost WebSocket acknowledgement cannot apply an operation twice. processed_station_commands: list[str] = Field(default_factory=list) text_revealed_facts: dict[str, str] = Field(default_factory=dict) + #: Подряд идущих первичных отказов без принятой карточки между ними — + #: сигнал реестра преподавателя, не влияет на балл. + consecutive_refusals: int = 0 @field_validator("processed_station_commands") @classmethod @@ -170,6 +173,9 @@ class SessionState(PersistedSession): voice: object | None = Field(default=None, exclude=True) recorder: object | None = Field(default=None, exclude=True) recording_path: str | None = Field(default=None, exclude=True) + #: Когда реестр преподавателя последний раз видел подключённый сокет + #: курсанта (станция ДДС или звонок 112) — для сигнала "не на связи". + socket_last_seen_at: datetime | None = Field(default=None, exclude=True) def persisted(self) -> PersistedSession: """Сохраняемая часть без копирования — то, что уходит в снимок.""" diff --git a/backend/tests/test_checkpoint_model.py b/backend/tests/test_checkpoint_model.py index 6ec7a19..1402aad 100644 --- a/backend/tests/test_checkpoint_model.py +++ b/backend/tests/test_checkpoint_model.py @@ -161,6 +161,7 @@ def full_state() -> SessionState: resolve_comment="передано в другой регион", processed_station_commands=[str(uuid4())], text_revealed_facts={"f_address": "улица Ленина, 14"}, + consecutive_refusals=2, ) diff --git a/backend/tests/test_session_signals.py b/backend/tests/test_session_signals.py new file mode 100644 index 0000000..8b1d533 --- /dev/null +++ b/backend/tests/test_session_signals.py @@ -0,0 +1,152 @@ +"""Колонка сигналов реестра: очередь, повторные отказы, курсант не на связи.""" + +import time +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from app.api.http import sessions as sessions_http +from app.config import get_settings +from app.main import app +from app.session.hub import hub +from app.session.store import MemorySessionStore + +POOL = ["fire-apartment-l2", "t01-1-fire-container"] + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setenv("DEV_AUTH_BYPASS", "true") + get_settings.cache_clear() + + async def audit_override(*_args, **_kwargs): + return None + + async def optional_session_override(): + yield None + + monkeypatch.setattr(sessions_http, "audit_required", audit_override) + monkeypatch.setitem( + app.dependency_overrides, sessions_http.optional_session, optional_session_override + ) + try: + with TestClient(app) as test_client: + test_client.post("/api/auth/dev-token") + hub.store = MemorySessionStore() + yield test_client + finally: + get_settings.cache_clear() + + +def wait_for(predicate, timeout=3): + end = time.monotonic() + timeout + while time.monotonic() < end: + value = predicate() + if value: + return value + time.sleep(0.02) + raise AssertionError("состояние не обновилось") + + +def read_until(socket, wanted): + received = [] + for _ in range(20): + event = socket.receive_json() + received.append(event["type"]) + if event["type"] == wanted: + return event + raise AssertionError(f"событие {wanted} не пришло; получены: {received}") + + +def start_two_card_dds(client): + session_id = uuid4() + context = client.websocket_connect(f"/ws/control/{session_id}") + control = context.__enter__() + control.send_json({ + "type": "scenario.start", + "scenario_id": POOL[0], + "trainee": "Иванов", + "mode": "training", + "exercise": "dds", + "random_scenario_ids": POOL, + }) + wait_for(lambda: hub.get(session_id)) + return session_id, control + + +def row_for(client, session_id): + rows = client.get("/api/sessions/active").json() + return next(item for item in rows if item["session_id"] == str(session_id)) + + +def signal_kinds(row): + return {signal["kind"] for signal in row["signals"]} + + +def test_signal_backlog_when_queue_reaches_threshold(client, monkeypatch): + monkeypatch.setenv("SIGNAL_BACKLOG_THRESHOLD", "2") + get_settings.cache_clear() + session_id, control = start_two_card_dds(client) + try: + state = hub.get(session_id) + assert len(state.desk.cards) == 2, "оба билета должны прийти сразу без интервала" + row = row_for(client, session_id) + assert row["dds_open_cards"] == 2 + assert "backlog" in signal_kinds(row) + finally: + hub.stop_ticker(session_id) + control.__exit__(None, None, None) + + +def test_signal_refusals_after_two_consecutive_declines(client): + session_id, control = start_two_card_dds(client) + try: + state = hub.get(session_id) + first_card, second_card = state.desk.ordered() + with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station: + read_until(station, "station.state") + first_service = state.card_services(first_card)[0] + station.send_json({ + "type": "card.status", "service": first_service, "status": "declined", + "comment": "не наш адрес, передано в УК", + }) + read_until(station, "station.state") + + row = row_for(client, session_id) + assert "refusals" not in signal_kinds(row), "одного отказа недостаточно для сигнала" + + station.send_json({"type": "card.open", "card_id": str(second_card.card_id)}) + read_until(station, "station.state") + second_service = state.card_services(second_card)[0] + station.send_json({ + "type": "card.status", "service": second_service, "status": "declined", + "comment": "не наша территория, передано в ОМВД", + }) + read_until(station, "station.state") + + row = row_for(client, session_id) + assert state.consecutive_refusals == 2 + assert "refusals" in signal_kinds(row) + finally: + hub.stop_ticker(session_id) + control.__exit__(None, None, None) + + +def test_signal_offline_when_station_socket_is_closed_past_the_window(client): + session_id, control = start_two_card_dds(client) + try: + state = hub.get(session_id) + with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station: + read_until(station, "station.state") + row = row_for(client, session_id) + assert "offline" not in signal_kinds(row), "сокет подключён — сигнала быть не должно" + + window = get_settings().signal_offline_window_seconds + state.socket_last_seen_at = datetime.now(UTC) - timedelta(seconds=window + 1) + row = row_for(client, session_id) + assert "offline" in signal_kinds(row) + finally: + hub.stop_ticker(session_id) + control.__exit__(None, None, None) diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx index 3c1f435..d7dd1de 100644 --- a/frontend/src/pages/instructor/Instructor.tsx +++ b/frontend/src/pages/instructor/Instructor.tsx @@ -338,7 +338,7 @@ export function Instructor() { Список ваших активных сессий обновляется автоматически каждые 2 секунды.

- + {(activeSessions.data ?? []).map((item) => ( @@ -383,12 +383,17 @@ export function Instructor() { ? `Просрочено: первичная реакция ${item.dds_overdue_cards}; отработка карточки ${item.dds_work_overdue_cards}` : "Нормативы не нарушены"} + ))} - {!activeSessions.data?.length && } diff --git a/frontend/src/shared/api/http.ts b/frontend/src/shared/api/http.ts index 369e206..373e155 100644 --- a/frontend/src/shared/api/http.ts +++ b/frontend/src/shared/api/http.ts @@ -94,6 +94,12 @@ export interface SessionInfo { end_reason: string | null; } +export interface Signal { + kind: "backlog" | "refusals" | "offline"; + severity: "warn" | "violated"; + text: string; +} + export interface ActiveSessionInfo { session_id: string; trainee_name: string | null; @@ -109,6 +115,7 @@ export interface ActiveSessionInfo { dds_work_overdue_cards: number; dds_statuses: Record; dds_snapshot: StationSnapshot | null; + signals: Signal[]; } export const useActiveSessions = () =>
КурсантУпражнение · карточкаХодДДСЗадержки
КурсантУпражнение · карточкаХодДДСЗадержкиСигналы
+ {item.signals.length + ? item.signals.map((signal) => {signal.text}
) + : "Сигналов нет"} +
+ {!activeSessions.data?.length &&
{activeSessions.isLoading ? "Загружаем активные занятия…" : "Активных занятий пока нет."}