feat: сигналы реестра занятий: очередь, повторные отказы, курсант не на связи

This commit is contained in:
kaifarikman 2026-09-27 17:16:21 +03:00
commit 69b483f570
9 changed files with 244 additions and 3 deletions

View file

@ -8,6 +8,7 @@ import logging
import time import time
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Literal
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response 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.db.models import Group, Score, Session, Trainee
from app.domain.events import Exercise, SessionMode, SessionReport from app.domain.events import Exercise, SessionMode, SessionReport
from app.domain.roles import Role 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.domain.timers import TimerCode
from app.scoring.export import to_csv, to_pdf from app.scoring.export import to_csv, to_pdf
from app.scoring.report import build as build_report 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.finish import override_score
from app.session.score import scoring_scenario from app.session.score import scoring_scenario
from app.session.hub import hub from app.session.hub import hub
from app.session.state import SessionState
from app.session.store import ScoreOverridden, apply_score_override from app.session.store import ScoreOverridden, apply_score_override
from app.voice.recording import recording_path from app.voice.recording import recording_path
@ -86,6 +88,15 @@ class DdsHistoryOut(BaseModel):
recipient_services: list[str] = [] recipient_services: list[str] = []
class Signal(BaseModel):
"""Одна строка колонки сигналов реестра. Цвет несёт смысл только через
`severity`; текст обязателен и не заменяется цветом."""
kind: Literal["backlog", "refusals", "offline"]
severity: Literal["warn", "violated"]
text: str
class ActiveSessionOut(BaseModel): class ActiveSessionOut(BaseModel):
session_id: UUID session_id: UUID
trainee_name: str | None trainee_name: str | None
@ -101,6 +112,7 @@ class ActiveSessionOut(BaseModel):
dds_work_overdue_cards: int dds_work_overdue_cards: int
dds_statuses: dict[str, str] dds_statuses: dict[str, str]
dds_snapshot: StationSnapshot | None = None dds_snapshot: StationSnapshot | None = None
signals: list[Signal] = []
def _out(session) -> SessionOut: def _out(session) -> SessionOut:
@ -184,6 +196,44 @@ async def dds_history(
return result 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]) @router.get("/active", response_model=list[ActiveSessionOut])
async def active( async def active(
request: Request, request: Request,
@ -262,6 +312,7 @@ async def active(
), ),
dds_statuses=latest_statuses, dds_statuses=latest_statuses,
dds_snapshot=station, dds_snapshot=station,
signals=_signals(state, queue, now),
)) ))
return result return result

View file

@ -120,6 +120,11 @@ class Settings(BaseSettings):
def limit_ms(self, code: TimerCode) -> int: def limit_ms(self, code: TimerCode) -> int:
return self.timer_limits_ms.get(code, NORMATIVES[code].limit_ms) 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: def validate_deployment_security(self) -> None:
"""Reject known development authentication defaults on a production app.""" """Reject known development authentication defaults on a production app."""
if self.app_env != "production": if self.app_env != "production":

View file

@ -446,6 +446,7 @@ class DdsDesk(BaseModel):
services[0], ServiceStatus.ACCEPTED, command.comment, services[0], ServiceStatus.ACCEPTED, command.comment,
author="диспетчер", author="диспетчер",
) )
session.consecutive_refusals = 0
except StatusError: except StatusError:
pass # статус уже стоит: повторное нажатие ничего не меняет pass # статус уже стоит: повторное нажатие ничего не меняет
case "card.status": case "card.status":
@ -462,6 +463,13 @@ class DdsDesk(BaseModel):
# Первичный статус останавливает норматив 30 секунд. # Первичный статус останавливает норматив 30 секунд.
if command.status in PRIMARY: if command.status in PRIMARY:
card.on_event("card.ack") card.on_event("card.ack")
# Сигнал реестра "повторные отказы" считает подряд идущие
# отказы, а не сумму за занятие.
session.consecutive_refusals = (
session.consecutive_refusals + 1
if command.status is ServiceStatus.DECLINED
else 0
)
if command.status in { if command.status in {
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED, ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
}: }:

View file

@ -314,6 +314,12 @@ class SessionHub:
def observer_count(self, session_id: UUID) -> int: def observer_count(self, session_id: UUID) -> int:
return len(self._observers.get(session_id, set())) 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: def start_ticker(self, session_id: UUID) -> None:

View file

@ -124,6 +124,9 @@ class PersistedSession(BaseModel):
#: a lost WebSocket acknowledgement cannot apply an operation twice. #: a lost WebSocket acknowledgement cannot apply an operation twice.
processed_station_commands: list[str] = Field(default_factory=list) processed_station_commands: list[str] = Field(default_factory=list)
text_revealed_facts: dict[str, str] = Field(default_factory=dict) text_revealed_facts: dict[str, str] = Field(default_factory=dict)
#: Подряд идущих первичных отказов без принятой карточки между ними —
#: сигнал реестра преподавателя, не влияет на балл.
consecutive_refusals: int = 0
@field_validator("processed_station_commands") @field_validator("processed_station_commands")
@classmethod @classmethod
@ -170,6 +173,9 @@ class SessionState(PersistedSession):
voice: object | None = Field(default=None, exclude=True) voice: object | None = Field(default=None, exclude=True)
recorder: object | None = Field(default=None, exclude=True) recorder: object | None = Field(default=None, exclude=True)
recording_path: str | 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: def persisted(self) -> PersistedSession:
"""Сохраняемая часть без копирования — то, что уходит в снимок.""" """Сохраняемая часть без копирования — то, что уходит в снимок."""

View file

@ -161,6 +161,7 @@ def full_state() -> SessionState:
resolve_comment="передано в другой регион", resolve_comment="передано в другой регион",
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,
) )

View file

@ -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)

View file

@ -338,7 +338,7 @@ export function Instructor() {
Список ваших активных сессий обновляется автоматически каждые 2 секунды. Список ваших активных сессий обновляется автоматически каждые 2 секунды.
</p> </p>
<div className="dds-table-wrap"><table className="grid"> <div className="dds-table-wrap"><table className="grid">
<thead><tr><th>Курсант</th><th>Упражнение · карточка</th><th>Ход</th><th>ДДС</th><th>Задержки</th><th></th></tr></thead> <thead><tr><th>Курсант</th><th>Упражнение · карточка</th><th>Ход</th><th>ДДС</th><th>Задержки</th><th>Сигналы</th><th></th></tr></thead>
<tbody> <tbody>
{(activeSessions.data ?? []).map((item) => ( {(activeSessions.data ?? []).map((item) => (
<tr key={item.session_id}> <tr key={item.session_id}>
@ -383,12 +383,17 @@ export function Instructor() {
? `Просрочено: первичная реакция ${item.dds_overdue_cards}; отработка карточки ${item.dds_work_overdue_cards}` ? `Просрочено: первичная реакция ${item.dds_overdue_cards}; отработка карточки ${item.dds_work_overdue_cards}`
: "Нормативы не нарушены"} : "Нормативы не нарушены"}
</td> </td>
<td className={item.signals.length ? `state-${item.signals[0].severity}` : "state-ok"}>
{item.signals.length
? item.signals.map((signal) => <span key={signal.kind}>{signal.text}<br /></span>)
: "Сигналов нет"}
</td>
<td><button type="button" onClick={() => openActiveSession(item)}> <td><button type="button" onClick={() => openActiveSession(item)}>
{sessionId === item.session_id ? "Открыто" : "Наблюдать"} {sessionId === item.session_id ? "Открыто" : "Наблюдать"}
</button></td> </button></td>
</tr> </tr>
))} ))}
{!activeSessions.data?.length && <tr><td colSpan={6}> {!activeSessions.data?.length && <tr><td colSpan={7}>
{activeSessions.isLoading ? "Загружаем активные занятия…" : "Активных занятий пока нет."} {activeSessions.isLoading ? "Загружаем активные занятия…" : "Активных занятий пока нет."}
</td></tr>} </td></tr>}
</tbody> </tbody>

View file

@ -94,6 +94,12 @@ export interface SessionInfo {
end_reason: string | null; end_reason: string | null;
} }
export interface Signal {
kind: "backlog" | "refusals" | "offline";
severity: "warn" | "violated";
text: string;
}
export interface ActiveSessionInfo { export interface ActiveSessionInfo {
session_id: string; session_id: string;
trainee_name: string | null; trainee_name: string | null;
@ -109,6 +115,7 @@ export interface ActiveSessionInfo {
dds_work_overdue_cards: number; dds_work_overdue_cards: number;
dds_statuses: Record<string, string>; dds_statuses: Record<string, string>;
dds_snapshot: StationSnapshot | null; dds_snapshot: StationSnapshot | null;
signals: Signal[];
} }
export const useActiveSessions = () => export const useActiveSessions = () =>