Merge remote-tracking branch 'origin/main' into feat/cluster-auth-handshake

# Conflicts:
#	docs/arch/SCALE-OUT.md
#	scripts/test_cluster_failover.py
This commit is contained in:
gglamer 2026-09-27 20:48:17 +00:00
commit 240848a68d
58 changed files with 5789 additions and 152 deletions

View file

@ -48,3 +48,14 @@ async def signs(prefix: list[str] = Query(default=[]), group: int | None = None)
else None else None
), ),
} }
@router.get("/services")
async def services() -> dict:
"""Каталог служб для ручного добавления в карточку — как окно «Добавьте службы».
Каталог — справочник боевого АРМ, а не содержимое вызова: отдавать его
курсанту так же безопасно, как группы происшествий.
"""
data = ekp.catalog()
return {"source": data.source, "services": [item.model_dump() for item in data.services]}

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
@ -22,16 +23,29 @@ from app.db import repo
from app.db.base import get_session 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.taxonomy import (
ERRORS,
ErrorCode,
Finding,
FindingDecision,
FindingReview,
FindingSource,
)
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, TimerState
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
from app.scoring.review import has_finding
from app.scoring.taxonomy import METRIC_MAP
from app.session.access import can_access from app.session.access import can_access
from app.session.checkpoint import load_state from app.session.checkpoint import load_state
from app.session.finish import override_score from app.session.finish import change_findings, override_score
from app.session.store import FindingAdded, FindingReviewed
from app.session.timers import now_utc
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 +100,15 @@ class DdsHistoryOut(BaseModel):
recipient_services: list[str] = [] recipient_services: list[str] = []
class Signal(BaseModel):
"""Одна строка колонки сигналов реестра. Цвет несёт смысл только через
`severity`; текст обязателен и не заменяется цветом."""
kind: Literal["backlog", "refusals", "offline"]
severity: TimerState
text: str
class ActiveSessionOut(BaseModel): class ActiveSessionOut(BaseModel):
session_id: UUID session_id: UUID
trainee_name: str | None trainee_name: str | None
@ -100,7 +123,10 @@ class ActiveSessionOut(BaseModel):
dds_overdue_cards: int dds_overdue_cards: int
dds_work_overdue_cards: int dds_work_overdue_cards: int
dds_statuses: dict[str, str] dds_statuses: dict[str, str]
paused: bool = False
dds_snapshot: StationSnapshot | None = None dds_snapshot: StationSnapshot | None = None
signals: list[Signal] = []
presence_known: bool = True
def _out(session) -> SessionOut: def _out(session) -> SessionOut:
@ -184,6 +210,52 @@ async def dds_history(
return result return result
def _signals(
state: SessionState, queue: list[DdsQueueCard], now: datetime, *, live: bool
) -> list[Signal]:
"""Сигналы реестра: очередь, повторные отказы, курсант не на связи.
Не завязаны на паузу занятия (`session.pause`, если появится) — очередь,
отказы и присутствие сокета не читают таймеры. Присутствие — по наличию
подписчика в хабе этого узла; окно нужно, чтобы короткий разрыв
соединения не сразу считался потерей связи курсанта.
`live=False` — снимок чужого узла: его сокеты здесь не видны, поэтому
присутствие неизвестно. Чтение реестра не меняет состояние занятия.
"""
settings = get_settings()
signals: list[Signal] = []
# Необработанная — без первичного статуса: он останавливает норматив
# DDS_ACK. Принятая или отклонённая карточка висит в очереди до
# «Следующей», но реакции курсанта уже не ждёт.
pending = sum(not card.timer_stopped for card in queue)
if pending >= settings.signal_backlog_threshold:
signals.append(Signal(
kind="backlog", severity=TimerState.WARN,
text=f"Очередь: {pending} необработанных карточек",
))
if state.consecutive_refusals >= settings.signal_refusals_threshold:
signals.append(Signal(
kind="refusals", severity=TimerState.WARN,
text=f"Подряд отказов: {state.consecutive_refusals}",
))
if live:
connected = (
hub.station_connected(state.session_id) if state.dds_phase
else hub.trainee_connected(state.session_id)
)
offline_seconds = (
(now - (state.socket_last_seen_at or state.started_at)).total_seconds()
if (state.socket_last_seen_at or state.started_at) is not None else 0
)
if not connected and offline_seconds >= settings.signal_offline_window_seconds:
signals.append(Signal(
kind="offline", severity=TimerState.VIOLATED,
text=f"Курсант не на связи {int(offline_seconds)} с",
))
return sorted(signals, key=lambda signal: signal.severity is not TimerState.VIOLATED)
@router.get("/active", response_model=list[ActiveSessionOut]) @router.get("/active", response_model=list[ActiveSessionOut])
async def active( async def active(
request: Request, request: Request,
@ -197,6 +269,9 @@ async def active(
state.session_id: state state.session_id: state
for state in hub.active_sessions(who.login) for state in hub.active_sessions(who.login)
} }
#: Снимки без живой записи в хабе этого узла — чужой узел или сессия,
#: ожидающая восстановления. Её сокеты нельзя проверить этим процессом.
checkpoint_only: set[UUID] = set()
if db is not None: if db is not None:
rows = ( rows = (
await db.scalars( await db.scalars(
@ -225,11 +300,15 @@ async def active(
state.owner_login = row.owner_login state.owner_login = row.owner_login
if not state.ended: if not state.ended:
states[state.session_id] = state states[state.session_id] = state
checkpoint_only.add(state.session_id)
for state in states.values(): for state in states.values():
elapsed = (max(0, int((now - state.started_at).total_seconds())) paused_ms = state.total_paused_ms
if state.paused and state.paused_at is not None:
paused_ms += max(0, int((now - state.paused_at).total_seconds() * 1000))
elapsed = (max(0, int((now - state.started_at).total_seconds() - paused_ms / 1000))
if state.started_at else 0) if state.started_at else 0)
station = state.station_snapshot() if state.exercise is Exercise.DDS else None station = state.station_snapshot() if state.dds_phase else None
queue = station.queue_cards if station else [] queue = station.queue_cards if station else []
card = state.desk.active card = state.desk.active
status_log = card.status_log if card is not None else [] status_log = card.status_log if card is not None else []
@ -255,13 +334,18 @@ async def active(
), ),
dds_work_overdue_cards=sum( dds_work_overdue_cards=sum(
(timer := card.timers.timers.get(TimerCode.DDS_WORK)) is not None (timer := card.timers.timers.get(TimerCode.DDS_WORK)) is not None
and timer.started_at is not None # Пауза держит `started_at=None`, хотя таймер уже шёл: судить
# по нему одному спрятало бы уже случившееся нарушение (lct-39).
and (timer.started_at is not None or timer.paused)
and not timer.stopped and not timer.stopped
and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK] and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK]
for card in state.desk.cards.values() for card in state.desk.cards.values()
), ),
dds_statuses=latest_statuses, dds_statuses=latest_statuses,
paused=state.paused,
dds_snapshot=station, dds_snapshot=station,
signals=_signals(state, queue, now, live=state.session_id not in checkpoint_only),
presence_known=state.session_id not in checkpoint_only,
)) ))
return result return result
@ -584,6 +668,157 @@ async def override(
return SessionReport.model_validate(report["full_report"]) return SessionReport.model_validate(report["full_report"])
def _required_text(value: str) -> str:
cleaned = value.strip()
if not cleaned:
raise ValueError("поле обязательно")
return cleaned
class FindingReviewIn(BaseModel):
"""Решение по отметке: причина обязательна и остаётся в разборе."""
decision: FindingDecision
reason: str = Field(min_length=1, max_length=1000)
_reason = field_validator("reason")(_required_text)
class FindingIn(BaseModel):
"""Отметка преподавателя: код, факт и норма — то же обоснование, что у автоматической."""
code: ErrorCode
fact: str = Field(min_length=1, max_length=1000)
norm: str = Field(min_length=1, max_length=1000)
#: Номер карточки очереди ДДС (с 1); без него отметка относится к занятию.
card: int | None = Field(default=None, ge=1)
#: Ключ идемпотентности с клиента: повтор запроса не добавит отметку второй раз.
client_id: UUID | None = None
_fact = field_validator("fact")(_required_text)
_norm = field_validator("norm")(_required_text)
async def _change_findings(
session_id: UUID, who, db: AsyncSession | None, make,
) -> SessionReport:
"""Общий путь решений по отметкам — тот же, что у правки итога.
`make(who, score)` строит запись изменения по сохранённой оценке или
отвечает HTTP-ошибкой. Роль проверяет маршрут: администратору сюда нельзя,
как и к правке балла.
"""
state = hub.get(session_id)
if state is not None:
_require_access(who, state)
if state.score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
scenario = scoring_scenario(state)
if scenario is None:
raise HTTPException(status_code=409, detail="scenario_not_found")
change = make(who, state.score)
async with hub.operation(session_id):
if not _repeated(state.score, change):
change_findings(state, change)
return build_report(session_id, state, scenario)
if db is None:
raise HTTPException(status_code=404, detail="session_not_found")
session = await repo.get_session(db, session_id)
if session is None:
raise HTTPException(status_code=404, detail="session_not_found")
_require_access(who, session)
score = await db.scalar(select(Score).where(Score.session_id == session_id))
if score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
if (score.report or {}).get("full_report") is None:
raise HTTPException(status_code=409, detail="report_not_archived")
current = dict(score.report)
change = make(who, current)
await db.rollback() # чтение закончено; запись — одной транзакцией хранилища
if not _repeated(current, change):
await hub.store.commit_archived(session_id, [change])
# Ответ — по строке после commit: пересчёт шёл под блокировкой и мог учесть
# решение из соседней вкладки, которого не было в прочитанном выше отчёте.
score = await db.scalar(
select(Score).where(Score.session_id == session_id)
.execution_options(populate_existing=True)
)
report = SessionReport.model_validate(score.report["full_report"])
await db.rollback()
return report
def _repeated(report: dict, change: FindingReviewed | FindingAdded) -> bool:
return isinstance(change, FindingAdded) and has_finding(report, change.finding.client_id)
@router.post("/{session_id}/findings/{index}/review", response_model=SessionReport)
async def review_finding(
session_id: UUID,
index: int,
body: FindingReviewIn,
request: Request,
db: AsyncSession | None = Depends(optional_session),
) -> SessionReport:
"""Подтвердить или снять отметку; снятая не штрафует свою метрику."""
who = require(request, Role.INSTRUCTOR)
def make(who, score: dict) -> FindingReviewed:
if not 0 <= index < len(score.get("findings", [])):
raise HTTPException(status_code=404, detail="finding_not_found")
return FindingReviewed(index=index, role=who.role.value, review=FindingReview(
decision=body.decision, reason=body.reason, author=who.login, at=now_utc(),
))
return await _change_findings(session_id, who, db, make)
@router.post("/{session_id}/findings", response_model=SessionReport)
async def add_finding(
session_id: UUID,
body: FindingIn,
request: Request,
db: AsyncSession | None = Depends(optional_session),
) -> SessionReport:
"""Отметка преподавателя штрафует связанную метрику по той же методике."""
who = require(request, Role.INSTRUCTOR)
def make(who, score: dict) -> FindingAdded:
service = None
prefix = ""
exercise = (score.get("full_report") or {}).get("exercise")
# E1–E6 в связке 112 → ДДС — ошибки приёма вызова: их метрики у занятия,
# а не у карточки очереди. С номером карточки отметка не нашла бы свою
# метрику 112 и штрафовала бы отдельной метрикой преподавателя.
if (body.card is not None and body.code.value.startswith("E")
and exercise != Exercise.DDS.value):
raise HTTPException(status_code=422, detail="call_code_without_card")
if body.card is not None:
cards = score.get("card_results") or []
if body.card > len(cards):
raise HTTPException(status_code=422, detail="card_not_found")
service = cards[body.card - 1].get("managed_service")
prefix = f"Карточка {body.card}: " + (f"{service}: " if service else "")
competency = next((item for code, item in METRIC_MAP.values() if code is body.code), None)
return FindingAdded(role=who.role.value, finding=Finding(
code=body.code,
source=FindingSource.INSTRUCTOR,
summary=f"{prefix}{ERRORS[body.code].title}",
fact=body.fact,
norm=body.norm,
ref="отметка преподавателя на разборе",
competency=competency,
at=now_utc(),
card=body.card,
service=service,
author=who.login,
client_id=body.client_id,
))
return await _change_findings(session_id, who, db, make)
@router.get("", response_model=list[SessionOut]) @router.get("", response_model=list[SessionOut])
async def listing( async def listing(
request: Request, request: Request,

View file

@ -14,7 +14,7 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError from pydantic import TypeAdapter, ValidationError
from app.api.ws.session import pump, run_command, session_socket from app.api.ws.session import close_fenced, pump, run_command, session_socket
from app.domain.events import ( from app.domain.events import (
BgStart, BgStart,
CallEnded, CallEnded,
@ -31,6 +31,7 @@ from app.domain.events import (
PatchSource, PatchSource,
ScoreReady, ScoreReady,
SessionMode, SessionMode,
SessionPaused,
StationState, StationState,
TimerTick, TimerTick,
TextTurnAccepted, TextTurnAccepted,
@ -39,6 +40,7 @@ from app.domain.events import (
TraineeToServer, TraineeToServer,
) )
from app.dialog.slots import TurnResult from app.dialog.slots import TurnResult
from app.domain.kio import PatchRejected, get_field
from app.domain.roles import Role from app.domain.roles import Role
from app.scenarios import store from app.scenarios import store
from app.session.dds import prepare_handoff_queue from app.session.dds import prepare_handoff_queue
@ -191,6 +193,14 @@ async def _handle(session_id: UUID, state, event) -> None:
code=ErrorKind.UNSUPPORTED_EVENT, message="Занятие уже завершено", code=ErrorKind.UNSUPPORTED_EVENT, message="Занятие уже завершено",
)) ))
return return
if state.paused:
# Отказ временный, в отличие от отклонённого значения: `kio.patch`
# без `auto`-отката. useCall держит правку в pending и повторяет её
# после `session.paused: false` — откат стёр бы ввод курсанта.
hub.to_trainee(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT, message="Пауза, ждите преподавателя",
))
return
if (event.type == "text.turn" and state.exercise is not Exercise.CARD) or state.exercise is Exercise.DDS or ( if (event.type == "text.turn" and state.exercise is not Exercise.CARD) or state.exercise is Exercise.DDS or (
state.exercise is Exercise.CARD and event.type not in {"kio.patch", "card.submit", "text.turn"} state.exercise is Exercise.CARD and event.type not in {"kio.patch", "card.submit", "text.turn"}
) or (state.exercise is Exercise.CALL and event.type == "card.submit"): ) or (state.exercise is Exercise.CALL and event.type == "card.submit"):
@ -229,6 +239,9 @@ async def _handle(session_id: UUID, state, event) -> None:
code=ErrorKind.INTERNAL, message="Не удалось получить ответ заявителя. Попробуйте ещё раз.", code=ErrorKind.INTERNAL, message="Не удалось получить ответ заявителя. Попробуйте ещё раз.",
)) ))
return return
if state.paused:
# Ответ модели мог закончиться уже после команды преподавателя.
return
caller_entry = state.append(Speaker.CALLER, line.text, line.mood) caller_entry = state.append(Speaker.CALLER, line.text, line.mood)
hub.to_trainee(session_id, CallerUtterance( hub.to_trainee(session_id, CallerUtterance(
utterance_id=uuid4(), text=line.text, utterance_id=uuid4(), text=line.text,
@ -270,16 +283,37 @@ async def _handle(session_id: UUID, state, event) -> None:
case "kio.patch": case "kio.patch":
old_code, old_notify = state.kio.incident_code, list(state.kio.notify) old_code, old_notify = state.kio.incident_code, list(state.kio.notify)
state.patch_kio(event.fields) old_extra = list(state.kio.notify_extra)
try:
state.patch_kio(event.fields)
except (ValidationError, PatchRejected) as exc:
reason = exc.errors()[0].get("msg", "") if isinstance(exc, ValidationError) else str(exc)
# Недопустимое значение — отказ курсанту, а не сбой операции:
# сбой закрыл бы занятие на этом узле.
hub.to_trainee(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message=f"Поле карточки не принято: {reason}"[:200],
))
# Пакет отклонён целиком, а фронт держит его в pending до эха.
# Серверное значение с `auto` снимает pending — иначе на экране
# останутся правки, которых на сервере нет.
hub.to_trainee(session_id, KioPatchOut(
fields={path: get_field(state.kio, path) for path in event.fields},
source=PatchSource.AUTO,
))
return
hub.to_trainee(session_id, KioPatchOut( hub.to_trainee(session_id, KioPatchOut(
fields=event.fields, source=PatchSource.OPERATOR, fields=event.fields, source=PatchSource.OPERATOR,
)) ))
auto: dict = {}
if (state.kio.incident_code, state.kio.notify) != (old_code, old_notify): if (state.kio.incident_code, state.kio.notify) != (old_code, old_notify):
hub.to_trainee(session_id, KioPatchOut( auto |= {"incident_code": state.kio.incident_code, "notify": list(state.kio.notify)}
fields={"incident_code": state.kio.incident_code, # Добавки сервер чистит сам (повторы, службы из notify) — фронту
"notify": list(state.kio.notify)}, # нужно итоговое значение, а не эхо присланного.
source=PatchSource.AUTO, if state.kio.notify_extra != event.fields.get("notify_extra", old_extra):
)) auto["notify_extra"] = list(state.kio.notify_extra)
if auto:
hub.to_trainee(session_id, KioPatchOut(fields=auto, source=PatchSource.AUTO))
# Наблюдателю уходит карточка целиком: рассинхрон на внешнем мониторе # Наблюдателю уходит карточка целиком: рассинхрон на внешнем мониторе
# посреди занятия дороже лишних килобайт. # посреди занятия дороже лишних килобайт.
hub.to_observers(session_id, KioState(kio=state.kio)) hub.to_observers(session_id, KioState(kio=state.kio))
@ -390,9 +424,16 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
) )
if entered is None: if entered is None:
return return
_who, state = entered who, state = entered
with hub.trainee(session_id) as queue: async with hub.trainee_socket(
session_id, station=False, trainee=who.role is Role.TRAINEE,
) as queue:
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: if state.exercise is Exercise.CARD:
from app.api.ws.control import card_briefing from app.api.ws.control import card_briefing
@ -435,6 +476,10 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
# Бинарные кадры — аудио, текстовые — события. Направление определяется # Бинарные кадры — аудио, текстовые — события. Направление определяется
# каналом, обёртки JSON вокруг звука нет (docs/arch/CONTRACT.md). # каналом, обёртки JSON вокруг звука нет (docs/arch/CONTRACT.md).
if message.get("bytes") is not None: if message.get("bytes") is not None:
if state.paused:
# JSON-команды отказывает `_handle`; бинарные кадры сюда
# не заходят, поэтому пауза глушит звук здесь же (lct-39).
continue
if state.exercise is Exercise.CALL: if state.exercise is Exercise.CALL:
_on_audio(session_id, state, message["bytes"]) _on_audio(session_id, state, message["bytes"])
else: else:

View file

@ -41,6 +41,7 @@ from app.domain.events import (
ModeSet, ModeSet,
ReferenceStarted, ReferenceStarted,
SessionEnded, SessionEnded,
SessionPaused,
StationState, StationState,
) )
from app.domain.roles import Role from app.domain.roles import Role
@ -51,7 +52,14 @@ from app.session.access import can_access
from app.session.hub import hub from app.session.hub import hub
from app.session.finish import end_session, override_score from app.session.finish import end_session, override_score
from app.session.state import SessionState, now_utc from app.session.state import SessionState, now_utc
from app.session.store import LessonIdentity, LessonRequest, NoteAdded, ScoreOverridden from app.session.store import (
LessonIdentity,
LessonPaused,
LessonRequest,
LessonResumed,
NoteAdded,
ScoreOverridden,
)
from app.voice.models import get_voice_models from app.voice.models import get_voice_models
from app.voice.pipeline import FILLERS, prefetch from app.voice.pipeline import FILLERS, prefetch
@ -104,6 +112,7 @@ def _build_state(session_id: UUID, event, who, scenario, scenarios,
dds_service=identity.service or event.dds_service, dds_service=identity.service or event.dds_service,
attempt=identity.attempt, attempt=identity.attempt,
criteria=event.criteria, criteria=event.criteria,
socket_last_seen_at=now_utc(),
) )
state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000 state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
state.timers.limits[TimerCode.CARD_FILL] = event.criteria.card_fill_time_limit_seconds * 1000 state.timers.limits[TimerCode.CARD_FILL] = event.criteria.card_fill_time_limit_seconds * 1000
@ -344,6 +353,26 @@ async def _command(session_id: UUID, event, who) -> None:
await _start(session_id, event, who) await _start(session_id, event, who)
case "session.stop": case "session.stop":
await _stop(session_id) await _stop(session_id)
case "session.pause":
if state is None or state.ended or state.paused:
return
state.pause()
hub.record(session_id, LessonPaused(
at=state.paused_at, author=who.login, role=who.role.value,
))
hub.broadcast(session_id, SessionPaused(paused=True))
hub.to_station(session_id, SessionPaused(paused=True))
case "session.resume":
if state is None or state.ended or not state.paused:
return
paused_ms_before = state.total_paused_ms
state.resume()
hub.record(session_id, LessonResumed(
at=now_utc(), author=who.login, role=who.role.value,
paused_ms=state.total_paused_ms - paused_ms_before,
))
hub.broadcast(session_id, SessionPaused(paused=False))
hub.to_station(session_id, SessionPaused(paused=False))
case "instructor_note.add": case "instructor_note.add":
if state is not None: if state is not None:
state.notes.append({ state.notes.append({
@ -390,6 +419,14 @@ async def _command(session_id: UUID, event, who) -> None:
case "director.inject": case "director.inject":
if state is None: if state is None:
return return
if state.paused:
# Обрыв на паузе запустил бы норматив обратного дозвона, и простой
# ушёл бы в него; курсант же за баннером паузы не может ответить.
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message="Занятие на паузе",
))
return
if state.exercise is not Exercise.CALL: if state.exercise is not Exercise.CALL:
hub.to_observers(session_id, ErrorEvent( hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT, code=ErrorKind.UNSUPPORTED_EVENT,

View file

@ -17,7 +17,7 @@ from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError from pydantic import TypeAdapter, ValidationError
from app.api.ws.session import pump, run_command, session_socket from app.api.ws.session import close_fenced, pump, run_command, session_socket
from app.domain.events import ( from app.domain.events import (
CallEndReason, CallEndReason,
ErrorEvent, ErrorEvent,
@ -39,6 +39,9 @@ def _error(session_id: UUID, message: str) -> None:
async def _handle(session_id: UUID, state, event) -> None: async def _handle(session_id: UUID, state, event) -> None:
if state.paused:
_error(session_id, "Пауза, ждите преподавателя")
return
outcome = state.desk.apply(event, state) outcome = state.desk.apply(event, state)
if outcome.error is not None: if outcome.error is not None:
_error(session_id, outcome.error) _error(session_id, outcome.error)
@ -59,9 +62,14 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
entered = await session_socket(ws, session_id, (Role.INSTRUCTOR, Role.TRAINEE)) entered = await session_socket(ws, session_id, (Role.INSTRUCTOR, Role.TRAINEE))
if entered is None: if entered is None:
return return
_who, state = entered who, state = entered
with hub.station(session_id) as queue: async with hub.trainee_socket(
session_id, station=True, trainee=who.role is Role.TRAINEE,
) as queue:
if hub.is_lease_fenced(session_id):
await close_fenced(ws)
return
sender = asyncio.create_task(pump(ws, queue)) sender = asyncio.create_task(pump(ws, queue))
try: try:
# Карточка, переданная до подключения станции, не теряется: # Карточка, переданная до подключения станции, не теряется:

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

@ -15,6 +15,9 @@ from pathlib import Path
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
DATA = Path(__file__).parent / "ekp.json" DATA = Path(__file__).parent / "ekp.json"
#: Каталог окна «Добавьте службы» из «СЛУЖБЫ 112.docx»: 209 служб, в том числе
#: районные ДДС. Источник и лицензия — в шапке файла и в docs/delivery/THIRD-PARTY.md.
CATALOG = Path(__file__).parent / "services_catalog.json"
#: Часть строк книги существует ради интеграции с чужими системами, а оператору #: Часть строк книги существует ради интеграции с чужими системами, а оператору
#: не показывается — книга помечает их прямо в первом признаке. #: не показывается — книга помечает их прямо в первом признаке.
@ -89,6 +92,37 @@ def reference() -> Reference:
return Reference.model_validate_json(DATA.read_text(encoding="utf-8")) return Reference.model_validate_json(DATA.read_text(encoding="utf-8"))
class CatalogService(BaseModel):
"""Служба каталога боевого АРМ — то, что оператор добавляет в карточку вручную."""
id: str
name: str
organization: str | None = None
#: «ДДС», если это явно указано в названии в каталоге.
service_level: str | None = None
#: Скриншот и строка списка в «СЛУЖБЫ 112.docx».
source_reference: str
class Catalog(BaseModel):
source: str
transcription: str
license: str
services: list[CatalogService]
@lru_cache(maxsize=1)
def catalog() -> Catalog:
"""Каталог только расширяет выбор служб. Список оповещения по-прежнему
считается из признаков по классификатору, эталоны сценариев от него не зависят."""
return Catalog.model_validate_json(CATALOG.read_text(encoding="utf-8"))
@lru_cache(maxsize=1)
def catalog_names() -> frozenset[str]:
return frozenset(service.name for service in catalog().services)
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def _by_code() -> dict[str, Incident]: def _by_code() -> dict[str, Incident]:
return {incident.code: incident for incident in reference().incidents} return {incident.code: incident for incident in reference().incidents}

View file

@ -23,7 +23,7 @@ from app.domain.roles import Role
from app.domain.statuses import ServiceStatus, StationSnapshot from app.domain.statuses import ServiceStatus, StationSnapshot
from app.domain.taxonomy import Finding from app.domain.taxonomy import Finding
from app.domain.timers import TimerSnapshot from app.domain.timers import TimerSnapshot
from app.scoring.taxonomy import METRIC_MAP from app.scoring.taxonomy import INSTRUCTOR_METRIC, METRIC_MAP
class SessionMode(StrEnum): class SessionMode(StrEnum):
@ -53,6 +53,9 @@ class LessonCriteria(BaseModel):
decision_time_limit_seconds: int = Field(default=30, ge=5, le=300) decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
card_fill_time_limit_seconds: int = Field(default=180, ge=30, le=1800) card_fill_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
dds_card_work_time_limit_seconds: int = Field(default=180, ge=30, le=1800) dds_card_work_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
#: Сколько секунд после доклада бригады даётся на соответствующий статус.
dds_report_reaction_enabled: bool = False
dds_report_reaction_limit_seconds: int = Field(default=45, ge=5, le=600)
allowed_errors: int = Field(default=0, ge=0, le=50) allowed_errors: int = Field(default=0, ge=0, le=50)
require_correct_grammar: bool = True require_correct_grammar: bool = True
score_weights: dict[str, float] = Field(default_factory=dict) score_weights: dict[str, float] = Field(default_factory=dict)
@ -60,7 +63,7 @@ class LessonCriteria(BaseModel):
@field_validator("score_weights") @field_validator("score_weights")
@classmethod @classmethod
def validate_score_weights(cls, weights: dict[str, float]) -> dict[str, float]: def validate_score_weights(cls, weights: dict[str, float]) -> dict[str, float]:
unknown = weights.keys() - METRIC_MAP.keys() unknown = weights.keys() - METRIC_MAP.keys() - {INSTRUCTOR_METRIC}
if unknown: if unknown:
raise ValueError(f"неизвестные метрики весов: {', '.join(sorted(unknown))}") raise ValueError(f"неизвестные метрики весов: {', '.join(sorted(unknown))}")
if any(not 0 <= weight <= 10 for weight in weights.values()): if any(not 0 <= weight <= 10 for weight in weights.values()):
@ -274,6 +277,13 @@ class ErrorEvent(BaseModel):
message: str message: str
class SessionPaused(BaseModel):
"""Пульт остановил или возобновил время занятия (lct-39)."""
type: Literal["session.paused"] = "session.paused"
paused: bool
ServerToTrainee = Annotated[ ServerToTrainee = Annotated[
CallIncoming CallIncoming
| CardBriefing | CardBriefing
@ -293,6 +303,7 @@ ServerToTrainee = Annotated[
| TimerTick | TimerTick
| CallEnded | CallEnded
| ScoreReady | ScoreReady
| SessionPaused
| ErrorEvent, | ErrorEvent,
Field(discriminator="type"), Field(discriminator="type"),
] ]
@ -404,6 +415,8 @@ class SessionSnapshot(BaseModel):
timers: list[TimerSnapshot] timers: list[TimerSnapshot]
hints_used: int = 0 hints_used: int = 0
ended: bool = False ended: bool = False
#: Пульт остановил время занятия (lct-39).
paused: bool = False
class TranscriptAppend(BaseModel): class TranscriptAppend(BaseModel):
@ -458,6 +471,7 @@ ServerToObserver = Annotated[
| ScoreReady | ScoreReady
| InstructorNoteShown | InstructorNoteShown
| ReferenceStarted | ReferenceStarted
| SessionPaused
| ErrorEvent, | ErrorEvent,
Field(discriminator="type"), Field(discriminator="type"),
] ]
@ -518,13 +532,25 @@ class SessionStop(BaseModel):
type: Literal["session.stop"] = "session.stop" type: Literal["session.stop"] = "session.stop"
class SessionPause(BaseModel):
"""Остановить время занятия; карточку курсанта команда не меняет (lct-39)."""
type: Literal["session.pause"] = "session.pause"
class SessionResume(BaseModel):
type: Literal["session.resume"] = "session.resume"
InstructorToServer = Annotated[ InstructorToServer = Annotated[
ScenarioStart ScenarioStart
| DirectorInject | DirectorInject
| ReferencePlay | ReferencePlay
| InstructorNoteAdd | InstructorNoteAdd
| ScoreOverride | ScoreOverride
| SessionStop, | SessionStop
| SessionPause
| SessionResume,
Field(discriminator="type"), Field(discriminator="type"),
] ]
@ -665,7 +691,7 @@ class CrewArrived(BaseModel):
ServerToStation = Annotated[ ServerToStation = Annotated[
CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded
| ScoreReady | CommandAck | ErrorEvent, | ScoreReady | CommandAck | SessionPaused | ErrorEvent,
Field(discriminator="type"), Field(discriminator="type"),
] ]
@ -692,6 +718,12 @@ class Metric(BaseModel):
passed: bool passed: bool
weight: float = 1.0 weight: float = 1.0
credit: float | None = Field(default=None, ge=0, le=1) credit: float | None = Field(default=None, ge=0, le=1)
#: Номер карточки очереди ДДС (с 1) и служба — область метрики для разбора.
card: int | None = None
service: str | None = None
#: Автоматический вердикт, если его изменило решение преподавателя по отметке.
passed_auto: bool | None = None
credit_auto: float | None = Field(default=None, ge=0, le=1)
class CompetencyScore(BaseModel): class CompetencyScore(BaseModel):
@ -741,6 +773,8 @@ class DdsCardReport(BaseModel):
card_id: UUID card_id: UUID
scenario_id: str scenario_id: str
score_auto: float score_auto: float
#: Балл карточки после решений преподавателя по отметкам; None — решений не было.
score_reviewed: float | None = None
reply_text: str reply_text: str
metrics: list[Metric] metrics: list[Metric]
findings: list[Finding] findings: list[Finding]
@ -780,9 +814,13 @@ class SessionReport(BaseModel):
self_assessment_diff: SelfAssessmentDiff | None = None self_assessment_diff: SelfAssessmentDiff | None = None
notes: list[InstructorNoteShown] = [] notes: list[InstructorNoteShown] = []
score_auto: float score_auto: float
#: Балл после решений преподавателя по отметкам; None — решений не было.
score_reviewed: float | None = None
score_final: float score_final: float
overridden_by: str | None = None overridden_by: str | None = None
override_comment: str | None = None override_comment: str | None = None
#: Суммарная длительность пауз преподавателя — время объяснимо (lct-39).
total_paused_ms: int = 0
class Principal(BaseModel): class Principal(BaseModel):

View file

@ -112,12 +112,20 @@ class KIO(BaseModel):
description: str | None = None description: str | None = None
victims_count: int | None = None victims_count: int | None = None
is_emergency: bool = False is_emergency: bool = False
offence_signs: bool | None = None
life_threat: bool = False life_threat: bool = False
evacuation_needed: bool = False evacuation_needed: bool = False
# ДДС. `notify` считается из признаков и правится только системой: # ДДС. `notify` считается из признаков и правится только системой:
# в боевом АРМ оператор может добавить службу вручную, но не удалить. # в боевом АРМ оператор может добавить службу вручную, но не удалить.
notify: list[str] = Field(default_factory=list, description="Список оповещения") notify: list[str] = Field(default_factory=list, description="Список оповещения")
#: Службы, добавленные оператором вручную из каталога. Автоматический список
#: не заменяют и в оценку по эталону не входят. Каталог проверяется на входе
#: (apply_patch), а не здесь: иначе правка названия в каталоге сломала бы
#: загрузку сохранённых снимков занятий и сданных карточек.
notify_extra: list[str] = Field(
default_factory=list, max_length=20, description="Дополнительно оповестить"
)
dds: DDSCode | None = Field( dds: DDSCode | None = Field(
default=None, description="Устаревшее: одна служба. Заменено списком оповещения" default=None, description="Устаревшее: одна служба. Заменено списком оповещения"
) )
@ -146,8 +154,8 @@ EDITABLE_KIO_FIELDS: frozenset[str] = frozenset({
"caller_name", "caller_contact", "phone_on_scene", "language", "caller_name", "caller_contact", "phone_on_scene", "language",
"okato", "address", "street", "building", "entrance", "floor", "okato", "address", "street", "building", "entrance", "floor",
"intercom_code", "description", "incident_group", "signs", "intercom_code", "description", "incident_group", "signs",
"incident_type", "victims_count", "is_emergency", "life_threat", "incident_type", "victims_count", "is_emergency", "offence_signs", "life_threat",
"evacuation_needed", "dds", "evacuation_needed", "dds", "notify_extra",
"fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified", "fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified",
"fire.people_inside", "fire.smoke_spread", "fire.people_inside", "fire.smoke_spread",
"police.offence_kind", "police.suspects", "police.suspect_fled", "police.offence_kind", "police.suspects", "police.suspect_fled",
@ -186,7 +194,10 @@ def missing_fields(card: KIO, required: list[str]) -> list[str]:
#: те же «признаки», только не про тип происшествия, а про обстоятельства: #: те же «признаки», только не про тип происшествия, а про обстоятельства:
#: пострадавшие поднимают скорую, газификация — МОСГАЗ (domain/ekp.py). #: пострадавшие поднимают скорую, газификация — МОСГАЗ (domain/ekp.py).
MODIFIER_FIELDS: frozenset[str] = frozenset( MODIFIER_FIELDS: frozenset[str] = frozenset(
{"incident_group", "signs", "victims_count", "life_threat", "evacuation_needed", "fire.gasified"} {
"incident_group", "signs", "victims_count", "life_threat",
"evacuation_needed", "fire.gasified", "offence_signs", "police.offence_kind",
}
) )
@ -201,6 +212,12 @@ def modifiers(card: KIO) -> list[str]:
keys.append("evacuation") keys.append("evacuation")
if card.fire is not None and card.fire.gasified: if card.fire is not None and card.fire.gasified:
keys.append("gas") keys.append("gas")
if card.offence_signs is True or (
card.police is not None
and card.police.offence_kind is not None
and bool(card.police.offence_kind.strip())
):
keys.append("offence")
return keys return keys
@ -223,11 +240,40 @@ def derive_incident(card: KIO) -> KIO:
return KIO.model_validate(data) return KIO.model_validate(data)
class PatchRejected(ValueError):
"""Дельта `kio.patch` не принята целиком: карточка не изменилась."""
def _check_catalog(names: Any) -> None:
"""Только службы каталога: свободный текст в адресатах карточки
на АРМ ДДС выглядел бы как настоящая служба."""
from app.domain import ekp
if not isinstance(names, list):
return # тип и лимит проверит модель
known = ekp.catalog_names()
unknown = [name for name in names if name not in known]
if unknown:
raise PatchRejected(f"нет в каталоге служб: {', '.join(map(str, unknown[:3]))}")
def _without_auto_services(card: KIO) -> KIO:
"""Добавки без повторов и без служб, которые уже есть в списке оповещения:
иначе на АРМ ДДС одна служба окажется в двух строках."""
extra = [name for name in dict.fromkeys(card.notify_extra) if name not in card.notify]
if extra == card.notify_extra:
return card
return card.model_copy(update={"notify_extra": extra})
def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO: def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
"""Применить дельту `kio.patch`. Служебные поля игнорируются. """Применить дельту `kio.patch`. Служебные поля игнорируются.
Вложенные поля приходят плоским путём: {"fire.floors": 5}. Вложенные поля приходят плоским путём: {"fire.floors": 5}.
Недопустимое значение — `PatchRejected` или `ValidationError`.
""" """
if "notify_extra" in fields:
_check_catalog(fields["notify_extra"])
data = card.model_dump() data = card.model_dump()
for path, value in fields.items(): for path, value in fields.items():
if path in READ_ONLY_FIELDS: if path in READ_ONLY_FIELDS:
@ -243,4 +289,4 @@ def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
# Пересчитываем не только на смену признаков: пострадавшие поднимают скорую, # Пересчитываем не только на смену признаков: пострадавшие поднимают скорую,
# газификация — МОСГАЗ, и список оповещения обязан это отразить сразу. # газификация — МОСГАЗ, и список оповещения обязан это отразить сразу.
touched = MODIFIER_FIELDS & set(fields) touched = MODIFIER_FIELDS & set(fields)
return derive_incident(updated) if touched else updated return _without_auto_services(derive_incident(updated) if touched else updated)

File diff suppressed because it is too large Load diff

View file

@ -276,3 +276,5 @@ class StationSnapshot(BaseModel):
pending_cards_count: int = 0 pending_cards_count: int = 0
next_arrival_in_seconds: int | None = None next_arrival_in_seconds: int | None = None
max_waiting_cards: int = 3 max_waiting_cards: int = 3
#: Пульт на паузе: время не идёт, новая карточка не поступит (lct-39).
paused: bool = False

View file

@ -7,8 +7,10 @@
from datetime import datetime from datetime import datetime
from enum import StrEnum from enum import StrEnum
from typing import Any
from uuid import UUID
from pydantic import BaseModel from pydantic import BaseModel, model_validator
class ErrorCode(StrEnum): class ErrorCode(StrEnum):
@ -152,6 +154,22 @@ COMPETENCY_LABELS: dict[Competency, str] = {
} }
class FindingDecision(StrEnum):
"""Решение преподавателя по отметке на разборе."""
CONFIRMED = "confirmed"
DISMISSED = "dismissed"
class FindingReview(BaseModel):
"""Кто, когда и почему подтвердил или снял отметку. Сама отметка остаётся."""
decision: FindingDecision
reason: str
author: str
at: datetime
class Finding(BaseModel): class Finding(BaseModel):
"""Отметка в разборе. `fact` и `norm` — то самое обоснование.""" """Отметка в разборе. `fact` и `norm` — то самое обоснование."""
@ -164,3 +182,32 @@ class Finding(BaseModel):
competency: Competency | None = None competency: Competency | None = None
transcript_ref: str | None = None transcript_ref: str | None = None
at: datetime | None = None at: datetime | None = None
#: Метрика, чей провал объясняет отметка. Без неё связь с метриками идёт
#: по методике METRIC_MAP в пределах той же карточки и службы.
metric_key: str | None = None
#: Номер карточки очереди ДДС (с 1) и служба, к которым относится отметка.
card: int | None = None
service: str | None = None
#: Автор ручной отметки преподавателя (`source=instructor`).
author: str | None = None
#: Ключ идемпотентности ручной отметки: повтор запроса не добавляет её снова.
client_id: UUID | None = None
#: Все решения по отметке по порядку; действует последнее. Прежние не
#: затираются: «кто, когда, почему» нужно по каждой правке, а не по итогу.
reviews: list[FindingReview] = []
@model_validator(mode="before")
@classmethod
def _single_review(cls, data: Any) -> Any:
# Разборы, сохранённые до истории решений, хранили одно поле `review`.
if isinstance(data, dict) and "review" in data:
data = dict(data)
single = data.pop("review")
if single and not data.get("reviews"):
data["reviews"] = [single]
return data
@property
def review(self) -> FindingReview | None:
"""Действующее решение — последнее."""
return self.reviews[-1] if self.reviews else None

View file

@ -30,6 +30,7 @@ def evaluate_card(scenario: Scenario, kio: KIO) -> GostResult:
source=FindingSource.KIO if code.value == "E5" else FindingSource.GROUND_TRUTH, source=FindingSource.KIO if code.value == "E5" else FindingSource.GROUND_TRUTH,
summary=f"{title}: {fact}; требуется {norm}", summary=f"{title}: {fact}; требуется {norm}",
fact=fact, norm=norm, ref=ref, competency=competency, fact=fact, norm=norm, ref=ref, competency=competency,
metric_key=key,
)) ))
if truth.incident_code: if truth.incident_code:

View file

@ -11,31 +11,138 @@
привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу. привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу.
""" """
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,
PRIMARY, PRIMARY,
REFUSAL_COMMENT_REQUIRED, REFUSAL_COMMENT_REQUIRED,
SERVICE_STATUS_LABELS, SERVICE_STATUS_LABELS,
PhoneReportRecord,
ServiceStatus, ServiceStatus,
StatusEntry, StatusEntry,
current, current,
) )
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
from app.domain.events import Metric from app.domain.events import Metric
from app.domain.timers import GOST_REF from app.domain.timers import GOST_REF, TimerCode
from app.scoring.timing import TimeNorm, time_metric
MEMO_REF = "памятка «Работа на АРМ-112», раздел «Статусы реагирования»"
#: Статусы, означающие, что реагирование действительно шло. #: Статусы, означающие, что реагирование действительно шло.
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING) PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
#: Статус, который диспетчер отмечает по докладу бригады этой фазы.
REPORT_STATUS = {
"dispatched": ServiceStatus.RESPONDING,
"arrived": ServiceStatus.ARRIVED,
"working": ServiceStatus.WORKING,
"completed": ServiceStatus.COMPLETED,
}
REPORT_LABELS = {
"dispatched": "о выезде",
"arrived": "о прибытии",
"working": "о начале работ",
"completed": "о завершении работ",
}
#: Лимит реакции на доклад по умолчанию; занятие задаёт свой в критериях.
REPORT_REACTION_MS = 45_000
#: Реакция на доклад — норматив времени, как отработка карточки, но отметка
#: D6 из памятки: опоздавший статус — тот же неполный ход работ.
DDS_REPORT_REACTION = TimeNorm(
key="dds_report_reaction",
title="реакция на доклад бригады",
summary="статус по докладу бригады отмечен с нарушением норматива",
action="отметить статус по докладу",
unmeasured="не отмечен",
incomplete="не отмечен",
code=ErrorCode.D6,
source=FindingSource.DISPATCHER,
ref=MEMO_REF,
)
#: Терминальный отказ: работ не было, поэтому неотмеченный по докладу статус
#: не штрафуется ни отметкой, ни метрикой.
TERMINAL_REFUSAL = {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
def _finding(code: ErrorCode, summary: str, fact: str, norm: str | None = None) -> Finding:
def report_reactions(
marks: list[StatusEntry], reports: list[PhoneReportRecord],
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[tuple[PhoneReportRecord, int | None]]:
"""Задержка статуса после каждого доклада бригады, мс; None — статус не отмечен.
Отсчёт — от серверного времени доклада. Статус, отмеченный раньше доклада,
задержки не имеет: диспетчер узнал о ходе работ по другому каналу.
"""
result: list[tuple[PhoneReportRecord, int | None]] = []
for report in reports:
status = REPORT_STATUS.get(report.phase)
if status is None:
continue
times = [mark.at for mark in marks if mark.status is status]
if any(at <= report.at for at in times):
result.append((report, 0))
continue
after = min(times, default=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 по самой поздней реакции службы на доклады бригады.
Одна функция для отметок и для балла: иначе метрика провалится там,
где отметки нет. None — реагировать было не на что.
"""
refused = current(marks, service) in TERMINAL_REFUSAL
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
report, delay = max(reactions, key=lambda item: math.inf if item[1] is None else item[1])
metric, finding = time_metric(DDS_REPORT_REACTION, delay, limit_ms, completed=True)
label = SERVICE_STATUS_LABELS[REPORT_STATUS[report.phase]]
detail = "отмечен не позже доклада" if delay == 0 else metric.fact
fact = (f"статус «{label}» по докладу бригады «{report.crew}» "
f"{REPORT_LABELS[report.phase]}: {detail}")
if len(reactions) > 1:
late = sum(item is None or item > limit_ms for _, item in reactions)
fact += f"; с нарушением {late} из {len(reactions)} докладов"
metric = metric.model_copy(update={
"title": f"{service}: {metric.title}", "fact": fact, "service": service,
})
if finding is not None:
finding = finding.model_copy(update={
"summary": f"{service}: {finding.summary}", "fact": fact, "service": service,
})
return metric, finding
def _finding(service: str, code: ErrorCode, summary: str, fact: str,
norm: str | None = None) -> Finding:
return Finding( return Finding(
service=service,
code=code, code=code,
source=FindingSource.DISPATCHER, source=FindingSource.DISPATCHER,
summary=summary, summary=summary,
fact=fact, fact=fact,
norm=norm, norm=norm,
ref="памятка «Работа на АРМ-112», раздел «Статусы реагирования»", ref=MEMO_REF,
competency=( competency=(
Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD
), ),
@ -91,6 +198,10 @@ def evaluate_dispatcher(
reply_text: str = "", reply_text: str = "",
expected_decision: str = "accept", expected_decision: str = "accept",
expected_decision_reason: str | None = None, expected_decision_reason: str | None = None,
phone_reports: list[PhoneReportRecord] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS,
report_reaction_enabled: bool = False,
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[Finding]: ) -> list[Finding]:
"""Отметки по работе диспетчера. Пустой список — работа без нарушений. """Отметки по работе диспетчера. Пустой список — работа без нарушений.
@ -117,7 +228,7 @@ def evaluate_dispatcher(
] ]
if missing_comment_statuses: if missing_comment_statuses:
findings.append(_finding( findings.append(_finding(
ErrorCode.D5, service, ErrorCode.D5,
f"{service}: к статусу не добавлены основание и сведения", f"{service}: к статусу не добавлены основание и сведения",
fact="не заполнены комментарии: " + ", ".join(missing_comment_statuses), fact="не заполнены комментарии: " + ", ".join(missing_comment_statuses),
norm="к каждой ручной отметке добавить основание и содержание полученных сведений", norm="к каждой ручной отметке добавить основание и содержание полученных сведений",
@ -132,7 +243,7 @@ def evaluate_dispatcher(
] ]
if incomplete_comment_statuses: if incomplete_comment_statuses:
findings.append(_finding( findings.append(_finding(
ErrorCode.D5, service, ErrorCode.D5,
f"{service}: комментарии к статусам не разделяют основание и сведения", f"{service}: комментарии к статусам не разделяют основание и сведения",
fact="неполные комментарии: " + ", ".join(incomplete_comment_statuses), fact="неполные комментарии: " + ", ".join(incomplete_comment_statuses),
norm="в каждом комментарии заполнить отдельные поля «Основание» и «Сведения»", norm="в каждом комментарии заполнить отдельные поля «Основание» и «Сведения»",
@ -152,7 +263,7 @@ def evaluate_dispatcher(
] ]
if missing_recipients: if missing_recipients:
findings.append(_finding( findings.append(_finding(
ErrorCode.D5, service, ErrorCode.D5,
f"{service}: отдельный комментарий не указывает получателя сведений", f"{service}: отдельный комментарий не указывает получателя сведений",
fact="; ".join(missing_recipients)[:500], fact="; ".join(missing_recipients)[:500],
norm=( norm=(
@ -166,7 +277,7 @@ def evaluate_dispatcher(
if primary is None: if primary is None:
findings.append( findings.append(
_finding( _finding(
ErrorCode.D1, service, ErrorCode.D1,
f"{service}: статус реагирования не проставлен", f"{service}: статус реагирования не проставлен",
fact=( fact=(
f"прошло {elapsed_ms // 1000} с" f"прошло {elapsed_ms // 1000} с"
@ -181,7 +292,7 @@ def evaluate_dispatcher(
if elapsed_ms is None or elapsed_ms > deadline_ms: if elapsed_ms is None or elapsed_ms > deadline_ms:
findings.append( findings.append(
_finding( _finding(
ErrorCode.D1, service, ErrorCode.D1,
f"{service}: первичный статус проставлен с нарушением срока", f"{service}: первичный статус проставлен с нарушением срока",
fact=( fact=(
f"прошло {elapsed_ms // 1000} с" f"прошло {elapsed_ms // 1000} с"
@ -197,7 +308,7 @@ def evaluate_dispatcher(
if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip(): if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip():
findings.append( findings.append(
_finding( _finding(
ErrorCode.D4, service, ErrorCode.D4,
f"{service}: «{SERVICE_STATUS_LABELS[mark.status]}» без комментария", f"{service}: «{SERVICE_STATUS_LABELS[mark.status]}» без комментария",
fact="причина отказа не указана", fact="причина отказа не указана",
norm="комментарий к отказу обязателен", norm="комментарий к отказу обязателен",
@ -209,7 +320,7 @@ def evaluate_dispatcher(
if latest is ServiceStatus.DECLINED and expected_decision == "accept": if latest is ServiceStatus.DECLINED and expected_decision == "accept":
findings.append( findings.append(
_finding( _finding(
ErrorCode.D3, service, ErrorCode.D3,
f"{service}: отказ от происшествия, которое в её компетенции", f"{service}: отказ от происшествия, которое в её компетенции",
fact="служба есть в списке оповещения по ЕКП", fact="служба есть в списке оповещения по ЕКП",
norm="принять согласно эталону сценария", norm="принять согласно эталону сценария",
@ -218,7 +329,7 @@ def evaluate_dispatcher(
elif latest is ServiceStatus.ACCEPTED and expected_decision == "decline": elif latest is ServiceStatus.ACCEPTED and expected_decision == "decline":
findings.append( findings.append(
_finding( _finding(
ErrorCode.D2, service, ErrorCode.D2,
f"{service}: принято вопреки эталону сценария", f"{service}: принято вопреки эталону сценария",
fact="карточка принята службой", fact="карточка принята службой",
norm=expected_decision_reason or "отказать с указанной причиной", norm=expected_decision_reason or "отказать с указанной причиной",
@ -232,7 +343,7 @@ def evaluate_dispatcher(
if service not in crew_assignments: if service not in crew_assignments:
findings.append( findings.append(
_finding( _finding(
ErrorCode.D2, service, ErrorCode.D2,
f"{service}: «Принята» без назначения бригады и хода реагирования", f"{service}: «Принята» без назначения бригады и хода реагирования",
fact="бригада не выбрана, после приёма статусов не было", fact="бригада не выбрана, после приёма статусов не было",
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту", norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
@ -241,7 +352,7 @@ def evaluate_dispatcher(
else: else:
findings.append( findings.append(
_finding( _finding(
ErrorCode.D2, service, ErrorCode.D2,
f"{service}: «Принята», но о реагировании ничего не отмечено", f"{service}: «Принята», но о реагировании ничего не отмечено",
fact="после приёма статусов не было", fact="после приёма статусов не было",
norm="статус должен соответствовать фактическому состоянию заявки", norm="статус должен соответствовать фактическому состоянию заявки",
@ -252,7 +363,7 @@ def evaluate_dispatcher(
) and service not in crew_assignments: ) and service not in crew_assignments:
findings.append( findings.append(
_finding( _finding(
ErrorCode.D2, service, ErrorCode.D2,
f"{service}: ход реагирования без назначения бригады", f"{service}: ход реагирования без назначения бригады",
fact="статусы хода работ проставлены, бригада не выбрана", fact="статусы хода работ проставлены, бригада не выбрана",
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту", norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
@ -262,15 +373,18 @@ def evaluate_dispatcher(
# D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке # D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке
# это приводит к повторным звонкам и скрывает от других служб факт реакции. # это приводит к повторным звонкам и скрывает от других служб факт реакции.
# REFUSED — отдельный допустимый терминальный статус с обязательной причиной. # REFUSED — отдельный допустимый терминальный статус с обязательной причиной.
reports = [report for report in phone_reports or () if report.service == service]
missing_progress = [status for status in PROGRESS if status not in {m.status for m in marks}] missing_progress = [status for status in PROGRESS if status not in {m.status for m in marks}]
if (latest not in {ServiceStatus.DECLINED, ServiceStatus.REFUSED} if (latest not in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
and (missing_progress or latest is not ServiceStatus.COMPLETED)): and (missing_progress or latest is not ServiceStatus.COMPLETED)):
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress) missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
reported_done = any(report.phase == "completed" for report in reports)
findings.append( findings.append(
_finding( _finding(
ErrorCode.D6, service, ErrorCode.D6,
f"{service}: ход реагирования не доведён до конца", f"{service}: ход реагирования не доведён до конца",
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"), fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта")
+ ("; бригада доложила о завершении работ" if reported_done else ""),
norm=( norm=(
"отметить по факту начало реагирования, прибытие, проведение работ " "отметить по факту начало реагирования, прибытие, проведение работ "
"и завершение; если работы не проводились — оформить отказ с причиной" "и завершение; если работы не проводились — оформить отказ с причиной"
@ -278,16 +392,25 @@ def evaluate_dispatcher(
) )
) )
# D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
if report_reaction_enabled:
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])
return findings return findings
def dispatcher_metrics( def dispatcher_metrics(
state, card,
deadline_ms: int, deadline_ms: int,
expected_decision: str = "accept", expected_decision: str = "accept",
expected_decision_reason: str | None = None, expected_decision_reason: str | None = None,
*, *,
services: list[str] | None = None, services: list[str] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS,
report_reaction_enabled: bool = False,
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[Metric]: ) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму. """Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
@ -295,20 +418,21 @@ def dispatcher_metrics(
источник информации, но не обязательный шлюз статуса. источник информации, но не обязательный шлюз статуса.
""" """
metrics: list[Metric] = [] metrics: list[Metric] = []
for service in (services if services is not None else state.managed_services()): # Через таймер, а не `primary.at - dispatched_at`: календарная разница
marks = [mark for mark in state.status_log if mark.service == service] # включила бы паузу преподавателя в норматив (lct-39).
elapsed = card.timers.measured_ms(TimerCode.DDS_ACK)
for service in (services if services is not None else card.managed_services()):
marks = [mark for mark in card.status_log if mark.service == service]
statuses = {mark.status for mark in marks} statuses = {mark.status for mark in marks}
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0): def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
metrics.append(Metric( metrics.append(Metric(
key=key, title=f"{service}: {title}", fact=fact, norm=norm, key=key, title=f"{service}: {title}", fact=fact, norm=norm, service=service,
ref=GOST_REF if key == "dds_ack" else "памятка «Работа на АРМ-112», статусы реагирования", ref=GOST_REF if key == "dds_ack" else "памятка «Работа на АРМ-112», статусы реагирования",
passed=passed, weight=weight, passed=passed, weight=weight,
)) ))
primary = next((mark for mark in marks if mark.status in PRIMARY), None) primary = next((mark for mark in marks if mark.status in PRIMARY), None)
elapsed = (int((primary.at - state.dispatched_at).total_seconds() * 1000)
if primary and state.dispatched_at else None)
add("dds_primary", "решение по карточке", primary is not None, add("dds_primary", "решение по карточке", primary is not None,
primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0) primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0)
add("dds_ack", f"первичная отметка за {deadline_ms // 1000} с", add("dds_ack", f"первичная отметка за {deadline_ms // 1000} с",
@ -317,6 +441,13 @@ def dispatcher_metrics(
f"≤ {deadline_ms // 1000} с") f"≤ {deadline_ms // 1000} с")
if primary is None: if primary is None:
continue continue
# Метрика есть только там, где бригада докладывала: без докладов
# реагировать не на что, и отсутствие звонков не штраф.
reports = [report for report in card.phone_reports if report.service == service]
if report_reaction_enabled:
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" expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept"
else ServiceStatus.DECLINED) else ServiceStatus.DECLINED)
expected_label = "Принята" if expected_decision == "accept" else "Не принята" expected_label = "Принята" if expected_decision == "accept" else "Не принята"
@ -326,7 +457,7 @@ def dispatcher_metrics(
expected_decision_reason or f"по эталону ожидается «{expected_label}»", 2.0) expected_decision_reason or f"по эталону ожидается «{expected_label}»", 2.0)
if primary.status is ServiceStatus.DECLINED or expected_decision == "decline": if primary.status is ServiceStatus.DECLINED or expected_decision == "decline":
continue continue
crew = state.crew_assignments.get(service) crew = card.crew_assignments.get(service)
add("dds_crew", "назначение бригады", bool(crew), add("dds_crew", "назначение бригады", bool(crew),
crew or "бригада не выбрана", crew or "бригада не выбрана",
"необходимую бригаду выбирает ДДС вручную", 2.0) "необходимую бригаду выбирает ДДС вручную", 2.0)

View file

@ -11,6 +11,7 @@ from html import escape
from pathlib import Path from pathlib import Path
from app.domain.events import SessionReport from app.domain.events import SessionReport
from app.domain.taxonomy import Finding, FindingDecision, FindingSource
def _cell(value: object) -> str: def _cell(value: object) -> str:
@ -18,12 +19,27 @@ def _cell(value: object) -> str:
if value is None: if value is None:
return "" return ""
result = str(value) result = str(value)
probe = result.lstrip(" \t\r\n\ufeff\u200b") probe = result.lstrip(" \t\r\n\N{ZERO WIDTH NO-BREAK SPACE}\N{ZERO WIDTH SPACE}")
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")): if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
return "'" + result return "'" + result
return result return result
def _finding_origin(item: Finding) -> str:
if item.source is FindingSource.INSTRUCTOR:
return f"; добавлена преподавателем {item.author or ''}".rstrip()
return ""
def _finding_decision(item: Finding) -> str:
"""Решения по отметке рядом с ней, все по порядку: действует последнее."""
return "".join(
f"; {'снята' if review.decision is FindingDecision.DISMISSED else 'подтверждена'} "
f"преподавателем {review.author}: {review.reason}"
for review in item.reviews
)
def to_csv(report: SessionReport) -> bytes: def to_csv(report: SessionReport) -> bytes:
"""UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки.""" """UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки."""
output = io.StringIO(newline="") output = io.StringIO(newline="")
@ -38,12 +54,16 @@ def to_csv(report: SessionReport) -> bytes:
row("Занятие", "", "Режим", report.mode.value) row("Занятие", "", "Режим", report.mode.value)
row("Занятие", "", "Попытка", report.attempt) row("Занятие", "", "Попытка", report.attempt)
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds) row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
row("Критерии", "", "Лимит реакции на доклад бригады, с",
report.criteria.dds_report_reaction_limit_seconds
if report.criteria.dds_report_reaction_enabled else "не применяется")
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors) row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет") row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные") row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
row("Критерии", "", "Нарушенных метрик", report.failed_metrics) row("Критерии", "", "Нарушенных метрик", report.failed_metrics)
row("Критерии", "", "Зачёт", "да" if report.passed else "нет") row("Критерии", "", "Зачёт", "да" if report.passed else "нет")
row("Оценка", "", "Автоматическая", report.score_auto) row("Оценка", "", "Автоматическая", report.score_auto)
row("Оценка", "", "После разбора отметок", report.score_reviewed)
row("Оценка", "", "Итоговая", report.score_final) row("Оценка", "", "Итоговая", report.score_final)
row("Оценка", "", "Изменена преподавателем", report.overridden_by) row("Оценка", "", "Изменена преподавателем", report.overridden_by)
row("Оценка", "", "Причина изменения", report.override_comment) row("Оценка", "", "Причина изменения", report.override_comment)
@ -54,7 +74,9 @@ def to_csv(report: SessionReport) -> bytes:
f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; " f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; "
f"вес: {item.weight:g}{credit}; источник: {item.ref or ''}") f"вес: {item.weight:g}{credit}; источник: {item.ref or ''}")
for number, item in enumerate(report.findings, 1): for number, item in enumerate(report.findings, 1):
row("Ошибки", number, item.code.value, item.summary, f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}") row("Ошибки", number, item.code.value, item.summary,
f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}"
f"{_finding_origin(item)}{_finding_decision(item)}")
for number, item in enumerate(report.competencies, 1): for number, item in enumerate(report.competencies, 1):
row("Компетенции", number, item.competency, item.value) row("Компетенции", number, item.competency, item.value)
for number, item in enumerate(report.transcript, 1): for number, item in enumerate(report.transcript, 1):
@ -77,7 +99,7 @@ def to_csv(report: SessionReport) -> bytes:
row("Сверка самооценки", "", label, ", ".join(values)) row("Сверка самооценки", "", label, ", ".join(values))
for number, item in enumerate(report.notes, 1): for number, item in enumerate(report.notes, 1):
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author) row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
return ("\ufeff" + output.getvalue()).encode("utf-8") return ("\N{ZERO WIDTH NO-BREAK SPACE}" + output.getvalue()).encode("utf-8")
def _font_paths() -> tuple[Path, Path | None]: def _font_paths() -> tuple[Path, Path | None]:
@ -151,6 +173,9 @@ def to_pdf(report: SessionReport) -> bytes:
("Режим", report.mode.value), ("Режим", report.mode.value),
("Попытка", report.attempt), ("Попытка", report.attempt),
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"), ("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
("Лимит реакции на доклад бригады",
f"{report.criteria.dds_report_reaction_limit_seconds} с"
if report.criteria.dds_report_reaction_enabled else "не применяется"),
("Допустимо ошибок", report.criteria.allowed_errors), ("Допустимо ошибок", report.criteria.allowed_errors),
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"), ("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
("Переопределения весов", report.criteria.score_weights or "стандартные"), ("Переопределения весов", report.criteria.score_weights or "стандартные"),
@ -160,6 +185,9 @@ def to_pdf(report: SessionReport) -> bytes:
("Итоговая оценка", report.score_final), ("Итоговая оценка", report.score_final),
): ):
story.append(Paragraph(f"<b>{escape(label)}:</b> {escape(str(value))}", base)) story.append(Paragraph(f"<b>{escape(label)}:</b> {escape(str(value))}", base))
# Когда итог правили вручную, без этой строки не понять, какой балл дал разбор.
if report.score_reviewed is not None:
story.append(Paragraph(f"<b>После разбора отметок:</b> {report.score_reviewed}", base))
if report.overridden_by: if report.overridden_by:
story.append(Paragraph(f"<b>Оценка изменена:</b> {escape(report.overridden_by)}", base)) story.append(Paragraph(f"<b>Оценка изменена:</b> {escape(report.overridden_by)}", base))
if report.override_comment: if report.override_comment:
@ -183,7 +211,9 @@ def to_pdf(report: SessionReport) -> bytes:
story.append(p("Ошибок не выявлено", muted)) story.append(p("Ошибок не выявлено", muted))
for item in report.findings: for item in report.findings:
story.append(p(f"{item.code.value} - {item.summary}")) story.append(p(f"{item.code.value} - {item.summary}"))
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. Источник: {item.ref or 'не указан'}", muted)) story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. "
f"Источник: {item.ref or 'не указан'}"
f"{_finding_origin(item)}{_finding_decision(item)}", muted))
section("Компетенции") section("Компетенции")
for item in report.competencies: for item in report.competencies:

View file

@ -73,6 +73,7 @@ class _Builder:
norm=norm, norm=norm,
ref=ref, ref=ref,
competency=competency, competency=competency,
metric_key=key,
) )
) )
@ -174,6 +175,7 @@ def evaluate(
norm=f"эталонный вопрос: «{question}»" if question else "обязательный факт сценария", norm=f"эталонный вопрос: «{question}»" if question else "обязательный факт сценария",
ref="чек-лист сценария", ref="чек-лист сценария",
competency=Competency.INTERVIEW, competency=Competency.INTERVIEW,
metric_key="checklist_completeness",
) )
) )

View file

@ -106,9 +106,11 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
self_assessment_diff=difference, self_assessment_diff=difference,
notes=[InstructorNoteShown.model_validate(note) for note in state.notes], notes=[InstructorNoteShown.model_validate(note) for note in state.notes],
score_auto=score.get("score_auto", 0.0), score_auto=score.get("score_auto", 0.0),
score_reviewed=score.get("score_reviewed"),
score_final=score.get("score_final", score.get("score_auto", 0.0)), score_final=score.get("score_final", score.get("score_auto", 0.0)),
overridden_by=score.get("overridden_by"), overridden_by=score.get("overridden_by"),
override_comment=score.get("override_comment"), override_comment=score.get("override_comment"),
ai_coaching=(AICoaching.model_validate(score["ai_coaching"]) ai_coaching=(AICoaching.model_validate(score["ai_coaching"])
if score.get("ai_coaching") else None), if score.get("ai_coaching") else None),
total_paused_ms=state.total_paused_ms,
) )

View file

@ -0,0 +1,193 @@
"""Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку.
Баллы по-прежнему считает код. Преподаватель меняет входы — какие отметки
действуют, — а не формулу: балл пересчитывается теми же метриками и весами.
Связь «отметка → метрика» — методика `METRIC_MAP` в пределах карточки и службы;
если генератор знает метрику точно, она записана в `Finding.metric_key`.
Функции чистые и работают со словарём сохранённой оценки: живое занятие,
строка `Score` и архивный разбор меняются одним и тем же путём, как правка
итога в `apply_score_override`.
"""
from app.domain.events import Metric
from app.domain.taxonomy import Finding, FindingDecision, FindingReview, FindingSource
from app.scoring.competency import radar
from app.scoring.gost import GostResult
from app.scoring.taxonomy import INSTRUCTOR_METRIC, METRIC_MAP
__all__ = ["INSTRUCTOR_METRIC", "add_finding", "has_finding", "linked", "review_finding",
"reviewed_metrics"]
def _in_scope(finding: Finding, metric: Metric) -> bool:
if finding.card != metric.card:
return False
return finding.service is None or metric.service is None or finding.service == metric.service
def linked(finding: Finding, metric: Metric) -> bool:
"""Отметка объясняет провал этой метрики."""
if not _in_scope(finding, metric):
return False
if finding.metric_key is not None:
return finding.metric_key == metric.key
mapping = METRIC_MAP.get(metric.key)
return mapping is not None and mapping[0] is finding.code
def _dismissed(finding: Finding) -> bool:
return finding.review is not None and finding.review.decision is FindingDecision.DISMISSED
def _credit(metric: Metric) -> float:
return metric.credit if metric.credit is not None else float(metric.passed)
def reviewed_metrics(metrics: list[Metric], findings: list[Finding],
instructor_weight: float = 1.0) -> list[Metric]:
"""Метрики с учётом решений преподавателя.
Проваленная метрика засчитывается, только если все её автоматические
отметки сняты. Ручная отметка проваливает связанные метрики; если их нет,
появляется отдельная метрика преподавателя. Исходный вердикт остаётся в
`passed_auto`/`credit_auto`, повторный пересчёт начинается с него.
"""
base = [
metric.model_copy(update={
"passed": metric.passed_auto if metric.passed_auto is not None else metric.passed,
"credit": metric.credit_auto if metric.passed_auto is not None else metric.credit,
"passed_auto": None,
"credit_auto": None,
})
for metric in metrics
if metric.key != INSTRUCTOR_METRIC
]
result: list[Metric] = []
for metric in base:
links = [finding for finding in findings if linked(finding, metric)]
manual = [finding for finding in links
if finding.source is FindingSource.INSTRUCTOR and not _dismissed(finding)]
automatic = [finding for finding in links if finding.source is not FindingSource.INSTRUCTOR]
if manual and _credit(metric) > 0:
metric = metric.model_copy(update={
"passed": False, "credit": 0.0,
"passed_auto": metric.passed, "credit_auto": metric.credit,
})
elif (not manual and _credit(metric) < 1 and automatic
and all(_dismissed(finding) for finding in automatic)):
metric = metric.model_copy(update={
"passed": True, "credit": 1.0,
"passed_auto": metric.passed, "credit_auto": metric.credit,
})
result.append(metric)
for finding in findings:
if (finding.source is FindingSource.INSTRUCTOR and not _dismissed(finding)
and not any(linked(finding, metric) for metric in base)):
result.append(Metric(
key=INSTRUCTOR_METRIC,
title=f"Отметка преподавателя: {finding.summary}",
fact=finding.fact,
norm=finding.norm or "по решению преподавателя",
ref=finding.ref,
passed=False,
weight=instructor_weight,
card=finding.card,
service=finding.service,
))
return result
def _instructor_weight(report: dict) -> float:
"""Вес метрики преподавателя из весов занятия: веса настраивает преподаватель."""
criteria = (report.get("full_report") or {}).get("criteria") or {}
return float((criteria.get("score_weights") or {}).get(INSTRUCTOR_METRIC, 1.0))
def _codes(findings: list[Finding]) -> dict[str, int]:
"""Коды действующих отметок: по ним строятся частые ошибки группы и профиль."""
codes: dict[str, int] = {}
for finding in findings:
if not _dismissed(finding):
codes[finding.code.value] = codes.get(finding.code.value, 0) + 1
return codes
def _cards(cards: list[dict], metrics: list[Metric], findings: list[Finding]) -> list[dict]:
"""Карточки очереди ДДС с тем же разбором: их метрики — часть метрик занятия."""
updated = []
for number, card in enumerate(cards, 1):
own = [metric for metric in metrics if metric.card == number]
updated.append({
**card,
"metrics": [metric.model_dump(mode="json") for metric in own],
"findings": [finding.model_dump(mode="json")
for finding in findings if finding.card == number],
"score_reviewed": GostResult(metrics=own).score,
})
return updated
def _rescore(report: dict, findings: list[Finding]) -> dict:
metrics = reviewed_metrics(
[Metric.model_validate(item) for item in report.get("metrics", [])], findings,
_instructor_weight(report),
)
score = GostResult(metrics=metrics).score
fields = {
"findings": [finding.model_dump(mode="json") for finding in findings],
"metrics": [metric.model_dump(mode="json") for metric in metrics],
"competencies": [item.model_dump() for item in radar(metrics)],
"score_reviewed": score,
}
# Правка итога — последнее слово преподавателя; решение по отметке её не отменяет.
if not report.get("overridden_by"):
fields["score_final"] = score
updated = {**report, **fields}
if "summary" in report:
updated["summary"] = {**(report.get("summary") or {}), "codes": _codes(findings)}
if report.get("card_results"):
updated["card_results"] = _cards(report["card_results"], metrics, findings)
archived = report.get("full_report")
if isinstance(archived, dict):
failed = sum(not metric.passed and metric.weight > 0 for metric in metrics)
allowed = (archived.get("criteria") or {}).get("allowed_errors", 0)
updated["full_report"] = {
**archived, **fields,
"card_results": _cards(archived.get("card_results") or [], metrics, findings),
"failed_metrics": failed,
"passed": failed <= allowed,
"score_final": updated.get("score_final", archived.get("score_final")),
}
return updated
def _findings(report: dict) -> list[Finding]:
return [Finding.model_validate(item) for item in report.get("findings", [])]
def review_finding(report: dict, index: int, review: FindingReview) -> dict:
"""Подтвердить или снять отметку. Решение дописывается, действует последнее."""
findings = _findings(report)
if not 0 <= index < len(findings):
raise LookupError(f"нет отметки {index}")
findings[index] = findings[index].model_copy(
update={"reviews": [*findings[index].reviews, review]})
return _rescore(report, findings)
def has_finding(report: dict, client_id) -> bool:
"""Ручная отметка с этим ключом уже в разборе — повтор запроса."""
return client_id is not None and any(
finding.client_id == client_id for finding in _findings(report))
def add_finding(report: dict, finding: Finding) -> dict:
"""Добавить отметку преподавателя; она штрафует связанную метрику."""
if finding.source is not FindingSource.INSTRUCTOR:
raise ValueError("вручную добавляется только отметка преподавателя")
# Повтор (ретрай сети, вторая вкладка) не штрафует второй раз.
if has_finding(report, finding.client_id):
return report
return _rescore(report, [*_findings(report), finding])

View file

@ -31,10 +31,16 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
"dds_progress": (ErrorCode.D6, Competency.CARD), "dds_progress": (ErrorCode.D6, Competency.CARD),
"dds_completion": (ErrorCode.D6, Competency.CARD), "dds_completion": (ErrorCode.D6, Competency.CARD),
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION), "dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
"dds_report_reaction": (ErrorCode.D6, Competency.NORMS),
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION), "dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION), "description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
} }
#: Метрика ручной отметки, которой нечего провалить (у кода нет метрики в
#: METRIC_MAP или в карточке она не считалась). Кода у неё нет: код несёт сама
#: отметка. Вес задаёт преподаватель в весах занятия, по умолчанию 1.
INSTRUCTOR_METRIC = "instructor_finding"
#: Вес метрики в детерминированной оценке. #: Вес метрики в детерминированной оценке.
#: #:
#: **Предварительные значения, требуют утверждения методистом.** Без весов все #: **Предварительные значения, требуют утверждения методистом.** Без весов все
@ -59,4 +65,5 @@ METRIC_WEIGHTS: dict[str, float] = {
"description_grammar": 1.0, "description_grammar": 1.0,
"card_fill_time": 1.5, "card_fill_time": 1.5,
"dds_work_time": 1.5, "dds_work_time": 1.5,
"dds_report_reaction": 1.5,
} }

View file

@ -23,8 +23,8 @@ def time_credit(elapsed_ms: int | None, limit_ms: int) -> float:
@dataclass(frozen=True) @dataclass(frozen=True)
class TimeNorm: class TimeNorm:
"""Формулировки норматива времени: карточка 112 и карточка ДДС различаются """Формулировки норматива времени: нормативы различаются словами и кодом
только словами, расчёт и отметка E3 у них общие.""" отметки, расчёт вклада и формат факта у них общие."""
key: str key: str
title: str title: str
@ -32,6 +32,10 @@ class TimeNorm:
action: str action: str
unmeasured: str unmeasured: str
incomplete: str incomplete: str
code: ErrorCode = ErrorCode.E3
source: FindingSource = FindingSource.TIMERS
competency: Competency = Competency.NORMS
ref: str = TIME_REF
CARD_FILL_TIME = TimeNorm( CARD_FILL_TIME = TimeNorm(
@ -55,7 +59,7 @@ DDS_WORK_TIME = TimeNorm(
def time_metric( def time_metric(
norm: TimeNorm, elapsed_ms: int | None, limit_ms: int, *, completed: bool norm: TimeNorm, elapsed_ms: int | None, limit_ms: int, *, completed: bool
) -> tuple[Metric, Finding | None]: ) -> tuple[Metric, Finding | None]:
"""Метрика норматива и отметка E3, если он не выполнен. """Метрика норматива и отметка (по умолчанию E3), если он не выполнен.
Незавершённая работа не получает временного вклада: быстрое, но брошенное Незавершённая работа не получает временного вклада: быстрое, но брошенное
заполнение не должно выглядеть скоростью. заполнение не должно выглядеть скоростью.
@ -64,13 +68,20 @@ def time_metric(
fact = norm.unmeasured fact = norm.unmeasured
else: else:
delta_ms = elapsed_ms - limit_ms delta_ms = elapsed_ms - limit_ms
# Превышение до полусекунды при округлении до целых читалось бы
# как «45 с (+0 с)» при нормативе 45 с — тогда нужна десятая доля.
precise = 0 < delta_ms <= 500
def seconds(ms: int) -> str:
return f"{ms / 1000:.1f}" if precise else f"{round(ms / 1000)}"
if delta_ms > 0: if delta_ms > 0:
deviation = f"+{round(delta_ms / 1000)} с сверх норматива" deviation = f"+{seconds(delta_ms)} с сверх норматива"
elif delta_ms < 0: elif delta_ms < 0:
deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива" deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива"
else: else:
deviation = "точно в норматив" deviation = "точно в норматив"
fact = f"{round(elapsed_ms / 1000)} с ({deviation})" fact = f"{seconds(elapsed_ms)} с ({deviation})"
if not completed: if not completed:
fact = f"{norm.incomplete}; {fact}" fact = f"{norm.incomplete}; {fact}"
norm_text = f"{norm.action} за {round(limit_ms / 1000)} с" norm_text = f"{norm.action} за {round(limit_ms / 1000)} с"
@ -80,7 +91,7 @@ def time_metric(
title=norm.title, title=norm.title,
fact=fact, fact=fact,
norm=norm_text, norm=norm_text,
ref=TIME_REF, ref=norm.ref,
passed=passed, passed=passed,
weight=METRIC_WEIGHTS[norm.key], weight=METRIC_WEIGHTS[norm.key],
credit=time_credit(elapsed_ms, limit_ms) if completed else 0.0, credit=time_credit(elapsed_ms, limit_ms) if completed else 0.0,
@ -88,11 +99,12 @@ def time_metric(
if passed: if passed:
return metric, None return metric, None
return metric, Finding( return metric, Finding(
code=ErrorCode.E3, code=norm.code,
source=FindingSource.TIMERS, source=norm.source,
summary=norm.summary, summary=norm.summary,
fact=fact, fact=fact,
norm=norm_text, norm=norm_text,
ref=TIME_REF, ref=norm.ref,
competency=Competency.NORMS, competency=norm.competency,
metric_key=norm.key,
) )

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

@ -12,7 +12,7 @@ import logging
import time import time
from uuid import UUID from uuid import UUID
from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded, SessionPaused
from app.domain.statuses import ServiceStatus, current from app.domain.statuses import ServiceStatus, current
from app.domain.timers import TimerCode from app.domain.timers import TimerCode
from app.scoring.ai_coach import coach from app.scoring.ai_coach import coach
@ -26,10 +26,14 @@ from app.session.hub import hub
from app.session.score import ScoringSnapshot, score_session, scoring_scenario from app.session.score import ScoringSnapshot, score_session, scoring_scenario
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
from app.session.store import ( from app.session.store import (
FindingAdded,
FindingReviewed,
LessonEnded, LessonEnded,
LessonResumed,
ScoreArchived, ScoreArchived,
ScoreCalculated, ScoreCalculated,
ScoreOverridden, ScoreOverridden,
apply_finding_change,
apply_score_override, apply_score_override,
) )
@ -45,6 +49,7 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
services = state.card_services(card) services = state.card_services(card)
decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK] decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK]
dds_decision = card.scenario.dds_decision dds_decision = card.scenario.dds_decision
reaction_ms = state.criteria.dds_report_reaction_limit_seconds * 1000
findings = evaluate_dispatcher( findings = evaluate_dispatcher(
entries=card.status_log, entries=card.status_log,
services=services, services=services,
@ -54,10 +59,16 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
reply_text=card.reply_text, reply_text=card.reply_text,
expected_decision=dds_decision.expected, expected_decision=dds_decision.expected,
expected_decision_reason=dds_decision.reason, expected_decision_reason=dds_decision.reason,
phone_reports=card.phone_reports,
report_reaction_ms=reaction_ms,
report_reaction_enabled=state.criteria.dds_report_reaction_enabled,
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, services=services, report_reaction_ms=reaction_ms,
report_reaction_enabled=state.criteria.dds_report_reaction_enabled,
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]
@ -100,9 +111,11 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
card_id=card.card_id, card_id=card.card_id,
scenario_id=card.scenario.id, scenario_id=card.scenario.id,
reply_text=card.reply_text, reply_text=card.reply_text,
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}"}) metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}",
"card": number})
for metric in weighted.metrics], for metric in weighted.metrics],
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}"}) findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}",
"card": number})
for finding in weighted.findings], for finding in weighted.findings],
actions=actions, actions=actions,
duration_ms=max(0, int((now_utc() - card.dispatched_at).total_seconds() * 1000)), duration_ms=max(0, int((now_utc() - card.dispatched_at).total_seconds() * 1000)),
@ -181,7 +194,14 @@ async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
""" """
if state.ended: if state.ended:
return return
was_paused = state.paused
paused_ms_before = state.total_paused_ms
ended_at = state.end(reason) ended_at = state.end(reason)
if was_paused:
hub.record(session_id, LessonResumed(
at=ended_at, author="система", role="system",
paused_ms=state.total_paused_ms - paused_ms_before,
))
if state.exercise is Exercise.CARD and state.dispatched_card is None: if state.exercise is Exercise.CARD and state.dispatched_card is None:
state.on_event("card.end") state.on_event("card.end")
# Норматив отработки фиксируется событием, а не текущим значением часов. # Норматив отработки фиксируется событием, а не текущим значением часов.
@ -200,6 +220,11 @@ async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
hub.to_station(session_id, SessionEnded(reason=reason)) hub.to_station(session_id, SessionEnded(reason=reason))
else: else:
hub.to_trainee(session_id, CallEnded(reason=reason)) hub.to_trainee(session_id, CallEnded(reason=reason))
if was_paused:
# Снятие паузы — после сообщения о конце: иначе клиент дошлёт отложенные
# на паузе правки карточки в уже закрытое занятие.
hub.broadcast(session_id, SessionPaused(paused=False))
hub.to_station(session_id, SessionPaused(paused=False))
if state.score is None: if state.score is None:
return return
hub.to_observers(session_id, ScoreReady(session_id=session_id)) hub.to_observers(session_id, ScoreReady(session_id=session_id))
@ -234,3 +259,14 @@ def override_score(state, override: ScoreOverridden) -> None:
hub.record(state.session_id, override) hub.record(state.session_id, override)
state.score = apply_score_override(state.score, override) state.score = apply_score_override(state.score, override)
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id)) hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))
def change_findings(state, change: FindingReviewed | FindingAdded) -> None:
"""Решение преподавателя по отметке — тем же путём, что правка итога.
Вызывается внутри `hub.operation`: отметки, пересчитанный балл, архивный
разбор и строка аудита уходят одним commit.
"""
state.score = apply_finding_change(state.score, change)
hub.record(state.session_id, change)
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))

View file

@ -24,6 +24,7 @@ from app.domain.events import (
) )
from app.session.state import SessionState from app.session.state import SessionState
from app.session.store import MemorySessionStore, Record, SessionLeaseLost, SessionStore from app.session.store import MemorySessionStore, Record, SessionLeaseLost, SessionStore
from app.session.timers import now_utc
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие: #: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
#: очередь ограничена, переполнение роняет соединение, а не сессию. #: очередь ограничена, переполнение роняет соединение, а не сессию.
@ -53,6 +54,9 @@ class SessionHub:
self._observers: dict[UUID, set[asyncio.Queue]] = {} self._observers: dict[UUID, set[asyncio.Queue]] = {}
self._trainees: dict[UUID, set[asyncio.Queue]] = {} self._trainees: dict[UUID, set[asyncio.Queue]] = {}
self._stations: dict[UUID, set[asyncio.Queue]] = {} self._stations: dict[UUID, set[asyncio.Queue]] = {}
self._trainee_calls: dict[UUID, set[asyncio.Queue]] = {}
self._trainee_stations: dict[UUID, set[asyncio.Queue]] = {}
self._pending_adoptions: dict[UUID, SessionState] = {}
self._tickers: dict[UUID, asyncio.Task] = {} self._tickers: dict[UUID, asyncio.Task] = {}
# Задачи, порождённые внутри операции, наследуют контекст; после # Задачи, порождённые внутри операции, наследуют контекст; после
# закрытия операции их события идут напрямую (`_Operation.open`). # закрытия операции их события идут напрямую (`_Operation.open`).
@ -116,6 +120,7 @@ class SessionHub:
def drop(self, session_id: UUID) -> None: def drop(self, session_id: UUID) -> None:
self._sessions.pop(session_id, None) self._sessions.pop(session_id, None)
self._pending_adoptions.pop(session_id, None)
self.stop_ticker(session_id) self.stop_ticker(session_id)
def live_count(self) -> int: def live_count(self) -> int:
@ -135,9 +140,10 @@ class SessionHub:
if not self.store.persistent: if not self.store.persistent:
return 0 return 0
restored = await self.store.restore_active() restored = await self.store.restore_active()
adopted = 0
for state in restored: for state in restored:
self._adopt(state) adopted += await self._adopt(state)
return len(restored) return adopted
async def maintain_lease(self) -> None: async def maintain_lease(self) -> None:
"""Один оборот супервизора: продлить свои lease, подхватить просроченные чужие. """Один оборот супервизора: продлить свои lease, подхватить просроченные чужие.
@ -154,6 +160,18 @@ class SessionHub:
log.warning("продление lease занятия %s не удалось", state.session_id, log.warning("продление lease занятия %s не удалось", state.session_id,
exc_info=True) exc_info=True)
await self.fence(state) await self.fence(state)
for state in list(self._pending_adoptions.values()):
try:
await self.store.commit(state)
except SessionLeaseLost:
self._pending_adoptions.pop(state.session_id, None)
except Exception:
log.warning("повторное сохранение занятия %s после takeover не удалось",
state.session_id, exc_info=True)
else:
self._pending_adoptions.pop(state.session_id, None)
self.register(state)
self.start_ticker(state.session_id)
try: try:
claimed = await self.store.claim_expired() claimed = await self.store.claim_expired()
except Exception: # следующий оборот попробует снова except Exception: # следующий оборот попробует снова
@ -163,17 +181,32 @@ class SessionHub:
current = self._sessions.get(state.session_id) current = self._sessions.get(state.session_id)
if current is not None and not current.lease_fenced: if current is not None and not current.lease_fenced:
continue continue
self._adopt(state) await self._adopt(state)
async def supervise_lease(self, interval: float) -> None: async def supervise_lease(self, interval: float) -> None:
while True: while True:
await asyncio.sleep(interval) await asyncio.sleep(interval)
await self.maintain_lease() await self.maintain_lease()
def _adopt(self, state: SessionState) -> None: async def _adopt(self, state: SessionState) -> bool:
self.stop_ticker(state.session_id) self.stop_ticker(state.session_id)
if state.socket_connected_at_checkpoint:
# Разрыв произошёл при потере узла; старое время подключения не
# доказывает, что курсант отсутствовал всё это время.
state.socket_last_seen_at = now_utc()
state.socket_connected_at_checkpoint = False
try:
await self.store.commit(state)
except SessionLeaseLost:
return False
except Exception:
log.exception("не удалось сохранить присутствие после takeover %s", state.session_id)
self._pending_adoptions[state.session_id] = state
return False
self._pending_adoptions.pop(state.session_id, None)
self.register(state) self.register(state)
self.start_ticker(state.session_id) self.start_ticker(state.session_id)
return True
async def save_all(self) -> None: async def save_all(self) -> None:
"""Снимок живых занятий при остановке узла: следующий владелец продолжит с него.""" """Снимок живых занятий при остановке узла: следующий владелец продолжит с него."""
@ -285,6 +318,44 @@ class SessionHub:
def station(self, session_id: UUID): def station(self, session_id: UUID):
return self._subscribe(self._stations, session_id) return self._subscribe(self._stations, session_id)
async def _record_presence(self, session_id: UUID, *, station: bool) -> None:
state = self.get(session_id)
if state is None or state.ended or state.dds_phase != station:
return
async with self.operation(session_id):
state.socket_last_seen_at = now_utc()
present = self._trainee_stations if station else self._trainee_calls
state.socket_connected_at_checkpoint = bool(present.get(session_id))
@contextlib.asynccontextmanager
async def trainee_socket(
self, session_id: UUID, *, station: bool, trainee: bool,
) -> AsyncIterator[asyncio.Queue]:
"""Учесть только сокет курсанта; вещание преподавателю остаётся общим."""
registry = self._stations if station else self._trainees
presence = self._trainee_stations if station else self._trainee_calls
with self._subscribe(registry, session_id) as queue:
if trainee:
presence.setdefault(session_id, set()).add(queue)
try:
if trainee:
try:
await self._record_presence(session_id, station=station)
except Exception:
if not self.is_lease_fenced(session_id):
raise
yield queue
finally:
if trainee:
presence[session_id].discard(queue)
if not presence[session_id]:
presence.pop(session_id)
try:
await self._record_presence(session_id, station=station)
except Exception:
if not self.is_lease_fenced(session_id):
raise
# ── вещание ── # ── вещание ──
@staticmethod @staticmethod
@ -314,6 +385,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._trainee_stations.get(session_id))
def trainee_connected(self, session_id: UUID) -> bool:
return bool(self._trainee_calls.get(session_id))
# ── такт таймеров ── # ── такт таймеров ──
def start_ticker(self, session_id: UUID) -> None: def start_ticker(self, session_id: UUID) -> None:
@ -347,7 +424,7 @@ class SessionHub:
state = self.get(session_id) state = self.get(session_id)
if state is None or state.ended: if state is None or state.ended:
return return
if state.dds_phase: if state.dds_phase and not state.paused:
async with self.operation(session_id) as op: async with self.operation(session_id) as op:
active_before = state.desk.active_id active_before = state.desk.active_id
op.persist = bool(state.desk.deliver_due()) op.persist = bool(state.desk.deliver_due())

View file

@ -29,10 +29,14 @@ from app.domain.events import Mood, Speaker, TranscriptEntry
from app.session.checkpoint import dump_state, load_state from app.session.checkpoint import dump_state, load_state
from app.session.state import SessionState, now_utc from app.session.state import SessionState, now_utc
from app.session.store import ( from app.session.store import (
FindingAdded,
FindingReviewed,
HintRecorded, HintRecorded,
LessonEnded, LessonEnded,
LessonIdentity, LessonIdentity,
LessonPaused,
LessonRequest, LessonRequest,
LessonResumed,
LessonStarted, LessonStarted,
NoteAdded, NoteAdded,
Record, Record,
@ -42,8 +46,10 @@ from app.session.store import (
SelfAssessed, SelfAssessed,
SessionLeaseLost, SessionLeaseLost,
UtteranceAppended, UtteranceAppended,
apply_finding_change,
apply_score_override, apply_score_override,
) )
from app.scoring.review import has_finding
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
LEASE_SECONDS = 15 LEASE_SECONDS = 15
@ -196,6 +202,16 @@ class PostgresSessionStore:
object_id=str(session_id), object_id=str(session_id),
detail=f"missed_count={len(record.missed)}; comment_chars={len(record.comment)}", detail=f"missed_count={len(record.missed)}; comment_chars={len(record.comment)}",
)) ))
case LessonPaused(at=at, author=author, role=role):
db.add(AuditLog(
actor=author, role=role, action="session.pause",
object_id=str(session_id), detail=f"at={at.isoformat()}",
))
case LessonResumed(at=at, author=author, role=role, paused_ms=paused_ms):
db.add(AuditLog(
actor=author, role=role, action="session.resume",
object_id=str(session_id), detail=f"at={at.isoformat()}; paused_ms={paused_ms}",
))
case LessonStarted(at=at): case LessonStarted(at=at):
await db.execute( await db.execute(
update(Session).where(Session.id == session_id).values(started_at=at) update(Session).where(Session.id == session_id).values(started_at=at)
@ -239,6 +255,40 @@ class PostgresSessionStore:
detail=(f"{score.score_auto} → {record.score_final}; " detail=(f"{score.score_auto} → {record.score_final}; "
f"comment_chars={len(record.comment)}"), f"comment_chars={len(record.comment)}"),
)) ))
case FindingReviewed() | FindingAdded():
await db.flush()
score = await db.scalar(
select(Score).where(Score.session_id == session_id).with_for_update()
)
if score is None:
raise LookupError(f"нет оценки занятия {session_id}")
# Повтор ручной отметки под блокировкой: ни изменения, ни второй строки аудита.
if (isinstance(record, FindingAdded)
and has_finding(score.report or {}, record.finding.client_id)):
return
before = score.score_final
findings = (score.report or {}).get("findings", [])
score.report = apply_finding_change(dict(score.report or {}), record)
score.score_final = score.report.get("score_final", score.score_final)
if isinstance(record, FindingReviewed):
code = (findings[record.index].get("code", "?")
if 0 <= record.index < len(findings) else "?")
actor, action = record.review.author, "finding.review"
what = (f"#{record.index} {code} {record.review.decision.value}; "
f"причина: {record.review.reason}")
else:
finding = record.finding
actor, action = finding.author or "instructor", "finding.add"
where = f", карточка {finding.card}" if finding.card is not None else ""
what = (f"{finding.code.value}{where}; факт: {finding.fact}; "
f"норма: {finding.norm or ''}")
# Решение по отметке меняет чужой балл: аудит сам по себе должен
# ответить «кто, когда, что и почему», даже если разбор потом правили.
db.add(AuditLog(
actor=actor, role=record.role, action=action,
object_id=str(session_id),
detail=f"{what}; {before} → {score.score_final}",
))
await db.flush() await db.flush()
@staticmethod @staticmethod

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),
) )
@ -210,6 +213,7 @@ async def score_session(
norm="грамматически корректное описание", norm="грамматически корректное описание",
ref="критерий занятия; правила русского языка", ref="критерий занятия; правила русского языка",
competency=Competency.COMMUNICATION, competency=Competency.COMMUNICATION,
metric_key="description_grammar",
)) ))
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут # Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии # рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
@ -218,6 +222,7 @@ async def score_session(
if live_card is not None: if live_card is not None:
decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK] decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK]
services = list(snapshot.call_card_services) services = list(snapshot.call_card_services)
reaction_ms = snapshot.criteria.dds_report_reaction_limit_seconds * 1000
result.findings.extend(evaluate_dispatcher( result.findings.extend(evaluate_dispatcher(
entries=live_card.status_log, entries=live_card.status_log,
services=services, services=services,
@ -227,10 +232,16 @@ async def score_session(
reply_text=live_card.reply_text, reply_text=live_card.reply_text,
expected_decision=scenario.dds_decision.expected, expected_decision=scenario.dds_decision.expected,
expected_decision_reason=scenario.dds_decision.reason, expected_decision_reason=scenario.dds_decision.reason,
phone_reports=live_card.phone_reports,
report_reaction_ms=reaction_ms,
report_reaction_enabled=snapshot.criteria.dds_report_reaction_enabled,
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, scenario.dds_decision.reason, services=services, report_reaction_ms=reaction_ms,
report_reaction_enabled=snapshot.criteria.dds_report_reaction_enabled,
pause_intervals=snapshot.pause_intervals,
)) ))
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой # Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
# занятия. Веса первого сценария поверх них испортили бы остальные. # занятия. Веса первого сценария поверх них испортили бы остальные.

View file

@ -105,6 +105,14 @@ class PersistedSession(BaseModel):
started_at: datetime | None = None started_at: datetime | None = None
ended_at: datetime | None = None ended_at: datetime | None = None
end_reason: CallEndReason | None = None end_reason: CallEndReason | None = None
#: Пульт остановил время занятия; таймеры и очередь ДДС заморожены (lct-39).
paused: bool = False
#: Момент постановки на паузу — им же считается длительность простоя при снятии.
paused_at: datetime | None = None
#: Суммарная длительность пауз — для разбора, чтобы время было объяснимо.
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
@ -124,6 +132,15 @@ 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
#: Последнее подключение/отключение сокета курсанта или начало фазы ДДС.
#: Сохраняется вместе с занятием, чтобы чтение реестра не меняло состояние.
socket_last_seen_at: datetime | None = None
#: На момент последнего checkpoint курсант был подключён на текущем АРМ.
#: После аварии узла момент потери связи неизвестен: окно начинается при takeover.
socket_connected_at_checkpoint: bool = False
@field_validator("processed_station_commands") @field_validator("processed_station_commands")
@classmethod @classmethod
@ -181,6 +198,38 @@ class SessionState(PersistedSession):
"""Единственная точка, где событие двигает таймеры.""" """Единственная точка, где событие двигает таймеры."""
self.timers.on_event(event_type) self.timers.on_event(event_type)
def pause(self) -> None:
"""Заморозить таймеры занятия и карточек пульта; очередь не выдаёт следующую."""
if self.paused:
return
now = time.monotonic()
self.timers.pause(now)
for card in self.desk.cards.values():
card.timers.pause(now)
self.paused = True
self.paused_at = now_utc()
if self.voice is not None:
self.voice.pause()
def resume(self) -> None:
"""Снять паузу: таймеры продолжают с прежней отметки, очередь сдвигается на простой."""
if not self.paused:
return
now = time.monotonic()
self.timers.resume(now)
for card in self.desk.cards.values():
card.timers.resume(now)
if self.paused_at is not None:
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
self.paused_at = None
def append(self, speaker: Speaker, text: str, mood: Mood | None = None) -> TranscriptEntry: def append(self, speaker: Speaker, text: str, mood: Mood | None = None) -> TranscriptEntry:
entry = TranscriptEntry( entry = TranscriptEntry(
ref=f"u{len(self.transcript) + 1}", ref=f"u{len(self.transcript) + 1}",
@ -206,6 +255,9 @@ class SessionState(PersistedSession):
self.dispatched_at = now_utc() self.dispatched_at = now_utc()
if self.exercise is Exercise.CALL: if self.exercise is Exercise.CALL:
self._receive_call_card() self._receive_call_card()
if self.handoff_to_dds:
self.socket_last_seen_at = self.dispatched_at
self.socket_connected_at_checkpoint = False
return self.dispatched_card return self.dispatched_card
def _receive_call_card(self) -> None: def _receive_call_card(self) -> None:
@ -319,14 +371,15 @@ class SessionState(PersistedSession):
services = self._managed_services_for(recipients) services = self._managed_services_for(recipients)
log = list(card.status_log) if card is not None else [] log = list(card.status_log) if card is not None else []
statuses = {service: current(log, service) for service in services} statuses = {service: current(log, service) for service in services}
now_mono = time.monotonic()
overdue = False overdue = False
if card is not None: if card is not None:
elapsed = card.timers.measured_ms(TimerCode.DDS_ACK) # Через таймер, а не через `dispatched_at`: часы разошлись бы с
# паузой, которая держит `current_ms` на месте (lct-39).
ack_timer = card.timers.timers.get(TimerCode.DDS_ACK)
limit = card.timers.limits[TimerCode.DDS_ACK] limit = card.timers.limits[TimerCode.DDS_ACK]
overdue = elapsed is None and ( elapsed_ms = ack_timer.current_ms(now_mono) if ack_timer else 0
(now_utc() - card.dispatched_at).total_seconds() * 1000 > limit overdue = not (ack_timer.stopped if ack_timer else False) and elapsed_ms > limit
)
now_mono = time.monotonic()
queue_cards: list[DdsQueueCard] = [] queue_cards: list[DdsQueueCard] = []
for item in (desk.ordered() if self.dds_phase else []): for item in (desk.ordered() if self.dds_phase else []):
recipients_for_card = list(item.dispatched_card.notify) recipients_for_card = list(item.dispatched_card.notify)
@ -397,6 +450,7 @@ class SessionState(PersistedSession):
if desk.next_arrival_at is not None else None if desk.next_arrival_at is not None else None
), ),
max_waiting_cards=desk.max_waiting, max_waiting_cards=desk.max_waiting,
paused=self.paused,
) )
def card_received_event(self): def card_received_event(self):
@ -419,7 +473,14 @@ class SessionState(PersistedSession):
return self.ended_at is not None return self.ended_at is not None
def end(self, reason: CallEndReason) -> datetime: def end(self, reason: CallEndReason) -> datetime:
"""Закрыть занятие; возвращает момент окончания для журнала.""" """Закрыть занятие; возвращает момент окончания для журнала.
Занятие могут завершить прямо на паузе (преподаватель нажал «Завершить»,
не возобновив): снять флаг и досчитать простой сейчас, иначе курсант
навсегда останется за баннером «Пауза, ждите преподавателя», а разбор
потеряет последний, ещё не закрытый интервал паузы (lct-39).
"""
self.resume()
self.ended_at = now_utc() self.ended_at = now_utc()
self.end_reason = reason self.end_reason = reason
return self.ended_at return self.ended_at
@ -445,4 +506,5 @@ class SessionState(PersistedSession):
timers=self.shown_timers().snapshot(), timers=self.shown_timers().snapshot(),
hints_used=len(self.hints_shown), hints_used=len(self.hints_shown),
ended=self.ended, ended=self.ended,
paused=self.paused,
) )

View file

@ -16,6 +16,8 @@ from typing import Protocol
from uuid import UUID from uuid import UUID
from app.domain.events import TranscriptEntry from app.domain.events import TranscriptEntry
from app.domain.taxonomy import Finding, FindingReview
from app.scoring.review import add_finding, review_finding
from app.session.checkpoint import dump_state from app.session.checkpoint import dump_state
from app.session.state import SessionState from app.session.state import SessionState
@ -83,8 +85,47 @@ class ScoreOverridden:
comment: str comment: str
@dataclass(frozen=True)
class LessonPaused:
at: datetime
author: str
role: str
@dataclass(frozen=True)
class FindingReviewed:
"""Преподаватель подтвердил или снял отметку разбора."""
index: int
review: FindingReview
role: str
@dataclass(frozen=True)
class LessonResumed:
at: datetime
author: str
role: str
#: Длительность этой конкретной паузы — видно в аудите без пересчёта.
paused_ms: int
@dataclass(frozen=True)
class FindingAdded:
"""Преподаватель добавил свою отметку (`source=instructor`)."""
finding: Finding
role: str
Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted
| LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden) | LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden
| LessonPaused | LessonResumed | FindingReviewed | FindingAdded)
def apply_finding_change(report: dict, change: "FindingReviewed | FindingAdded") -> dict:
"""Решение по отметке одинаково для живой сессии, строки `Score` и архива."""
if isinstance(change, FindingReviewed):
return review_finding(report, change.index, change.review)
return add_finding(report, change.finding)
def apply_score_override(report: dict, override: ScoreOverridden) -> dict: def apply_score_override(report: dict, override: ScoreOverridden) -> dict:
@ -200,6 +241,10 @@ class MemorySessionStore:
if session_id not in self.scores: if session_id not in self.scores:
raise LookupError(f"нет оценки занятия {session_id}") raise LookupError(f"нет оценки занятия {session_id}")
self.scores[session_id] = apply_score_override(self.scores[session_id], record) self.scores[session_id] = apply_score_override(self.scores[session_id], record)
elif isinstance(record, FindingReviewed | FindingAdded):
if session_id not in self.scores:
raise LookupError(f"нет оценки занятия {session_id}")
self.scores[session_id] = apply_finding_change(self.scores[session_id], record)
self.records.setdefault(session_id, []).extend(records) self.records.setdefault(session_id, []).extend(records)
def snapshot(self, session_id: UUID) -> dict | None: def snapshot(self, session_id: UUID) -> dict | None:

View file

@ -83,6 +83,9 @@ class Timer(BaseModel):
elapsed_ms: int = 0 elapsed_ms: int = 0
attempt: int = 1 attempt: int = 1
stopped: bool = False stopped: bool = False
#: Пауза занятия — таймер заморожен, но не завершён: `start()` его не
#: считает новой попыткой, в отличие от `stopped` (lct-39).
paused: bool = False
@model_serializer(mode="wrap") @model_serializer(mode="wrap")
def _dump(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: def _dump(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
@ -100,13 +103,20 @@ class Timer(BaseModel):
data = dict(data) data = dict(data)
elapsed = max(0, int(data.get("elapsed_ms", 0))) elapsed = max(0, int(data.get("elapsed_ms", 0)))
stopped = bool(data.get("stopped")) stopped = bool(data.get("stopped"))
downtime = 0 if stopped else (info.context or {}).get("downtime_ms", 0) paused = bool(data.get("paused"))
# На паузе занятие не простаивало без присмотра — оно ждало
# преподавателя, и это время не досчитывается таймеру при перезапуске.
downtime = 0 if stopped or paused else (info.context or {}).get("downtime_ms", 0)
started = data.pop("started") started = data.pop("started")
data["started_at"] = time.monotonic() - (elapsed + downtime) / 1000 if started else None data["started_at"] = time.monotonic() - (elapsed + downtime) / 1000 if started else None
data["elapsed_ms"] = elapsed if stopped else 0 data["elapsed_ms"] = elapsed if (stopped or paused) else 0
return data return data
def start(self, now: float) -> None: def start(self, now: float) -> None:
if self.paused:
# Таймер уже идёт, просто заморожен: запуск с нуля потерял бы
# набранное время, а ход до `resume` посчитал бы паузу.
return
if self.stopped: if self.stopped:
# Повторный запуск после остановки — это новая попытка (обратный дозвон). # Повторный запуск после остановки — это новая попытка (обратный дозвон).
self.attempt += 1 self.attempt += 1
@ -125,6 +135,19 @@ class Timer(BaseModel):
return self.elapsed_ms return self.elapsed_ms
return int((now - self.started_at) * 1000) return int((now - self.started_at) * 1000)
def pause(self, now: float) -> None:
"""Зафиксировать прошедшее время и остановить ход часов до `resume`."""
if self.started_at is not None and not self.stopped:
self.elapsed_ms = self.current_ms(now)
self.started_at = None
self.paused = True
def resume(self, now: float) -> None:
"""Продолжить с той же отметки — простой в счёт не идёт."""
if self.paused:
self.started_at = now - self.elapsed_ms / 1000
self.paused = False
class SessionTimers(BaseModel): class SessionTimers(BaseModel):
"""Набор таймеров одной сессии. `limits` приходит из конфига — """Набор таймеров одной сессии. `limits` приходит из конфига —
@ -142,6 +165,16 @@ class SessionTimers(BaseModel):
for code in STOPS.get(event_type, ()): for code in STOPS.get(event_type, ()):
self.timers.setdefault(code, Timer(code=code)).stop(now) self.timers.setdefault(code, Timer(code=code)).stop(now)
def pause(self, now: float | None = None) -> None:
now = time.monotonic() if now is None else now
for timer in self.timers.values():
timer.pause(now)
def resume(self, now: float | None = None) -> None:
now = time.monotonic() if now is None else now
for timer in self.timers.values():
timer.resume(now)
def snapshot(self, now: float | None = None) -> list[TimerSnapshot]: def snapshot(self, now: float | None = None) -> list[TimerSnapshot]:
"""Только запущенные таймеры: показывать нули по нормативам, """Только запущенные таймеры: показывать нули по нормативам,
до которых занятие ещё не дошло, значит пугать курсанта зря.""" до которых занятие ещё не дошло, значит пугать курсанта зря."""

View file

@ -86,6 +86,8 @@ class VoiceSession:
# ── вход: кадры микрофона ── # ── вход: кадры микрофона ──
def feed(self, frame: bytes) -> None: def feed(self, frame: bytes) -> None:
if self.state.paused:
return
for event in self._vad.push(frame): for event in self._vad.push(frame):
if isinstance(event, SpeechStarted) and self.speaking: if isinstance(event, SpeechStarted) and self.speaking:
self.barge_in() self.barge_in()
@ -104,6 +106,8 @@ class VoiceSession:
первая фраза «Алло! Помогите!» — ровно та, которую оператор перебивает первая фраза «Алло! Помогите!» — ровно та, которую оператор перебивает
чаще всего, — не гасилась вовсе. чаще всего, — не гасилась вовсе.
""" """
if self.state.paused:
return asyncio.create_task(asyncio.sleep(0))
if self.speaking: if self.speaking:
self._reply.cancel() self._reply.cancel()
self._reply = asyncio.create_task(self.say(text, mood)) self._reply = asyncio.create_task(self.say(text, mood))
@ -119,6 +123,17 @@ class VoiceSession:
self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="barge_in")) self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="barge_in"))
log.info("сессия %s: перебивание", self.session_id) log.info("сессия %s: перебивание", self.session_id)
def pause(self) -> None:
"""Прервать текущую реплику и убрать речь, накопленную до паузы."""
if self.speaking:
self._reply.cancel()
if self._utterance_id is not None:
self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="director"))
self._utterance_id = None
while not self._queue.empty():
self._queue.get_nowait()
self._vad.reset()
async def close(self) -> None: async def close(self) -> None:
for task in (self._reply, self._worker): for task in (self._reply, self._worker):
if task is not None: if task is not None:
@ -129,6 +144,8 @@ class VoiceSession:
async def _work(self) -> None: async def _work(self) -> None:
while True: while True:
audio, ended_at = await self._queue.get() audio, ended_at = await self._queue.get()
if self.state.paused:
continue
# Новая фраза оператора, пока звонящий ещё говорит, — тоже перебивание. # Новая фраза оператора, пока звонящий ещё говорит, — тоже перебивание.
if self.speaking: if self.speaking:
self.barge_in() self.barge_in()
@ -143,6 +160,8 @@ class VoiceSession:
timing = TurnTiming() timing = TurnTiming()
started = time.monotonic() started = time.monotonic()
text = await self.models.transcribe(audio) text = await self.models.transcribe(audio)
if self.state.paused:
return
timing.stt_ms = (time.monotonic() - started) * 1000 timing.stt_ms = (time.monotonic() - started) * 1000
if not text: if not text:
return return
@ -155,6 +174,8 @@ class VoiceSession:
started = time.monotonic() started = time.monotonic()
line = await self._caller_line(text) line = await self._caller_line(text)
if self.state.paused:
return
timing.caller_ms = (time.monotonic() - started) * 1000 timing.caller_ms = (time.monotonic() - started) * 1000
await self.say(line.text, line.mood, ended_at=ended_at, timing=timing) await self.say(line.text, line.mood, ended_at=ended_at, timing=timing)
@ -171,6 +192,8 @@ class VoiceSession:
self, text: str, mood: Mood, *, ended_at: float | None = None, timing: TurnTiming | None = None self, text: str, mood: Mood, *, ended_at: float | None = None, timing: TurnTiming | None = None
) -> None: ) -> None:
"""Произнести реплику: событие с текстом, звук по предложениям, ожидание конца.""" """Произнести реплику: событие с текстом, звук по предложениям, ожидание конца."""
if self.state.paused:
return
self._utterance_id = utterance_id = uuid4() self._utterance_id = utterance_id = uuid4()
entry = self.state.append(Speaker.CALLER, text, mood) entry = self.state.append(Speaker.CALLER, text, mood)
self.send_event(CallerUtterance(utterance_id=utterance_id, text=text, at=entry.at, mood=mood)) self.send_event(CallerUtterance(utterance_id=utterance_id, text=text, at=entry.at, mood=mood))
@ -186,6 +209,8 @@ class VoiceSession:
pcm = await self._first_sentence(sentence, mood, ended_at, timing) pcm = await self._first_sentence(sentence, mood, ended_at, timing)
else: else:
pcm = await self.synthesize(sentence) pcm = await self.synthesize(sentence)
if self.state.paused:
return
if index == 0 and timing is not None: if index == 0 and timing is not None:
timing.tts_first_ms = (time.monotonic() - synth_started) * 1000 timing.tts_first_ms = (time.monotonic() - synth_started) * 1000
if not pcm: if not pcm:
@ -205,6 +230,7 @@ class VoiceSession:
# Ждём, пока курсант дослушает: перебивание в это время отменит задачу. # Ждём, пока курсант дослушает: перебивание в это время отменит задачу.
await asyncio.sleep(max(0.0, playback_ends - time.monotonic())) await asyncio.sleep(max(0.0, playback_ends - time.monotonic()))
self.send_event(TtsEnd(utterance_id=utterance_id)) self.send_event(TtsEnd(utterance_id=utterance_id))
self._utterance_id = None
async def _first_sentence( async def _first_sentence(
self, sentence: str, mood: Mood, ended_at: float, timing: TurnTiming | None self, sentence: str, mood: Mood, ended_at: float, timing: TurnTiming | None
@ -212,17 +238,25 @@ class VoiceSession:
"""Первое предложение с филлером: если к секунде после конца фразы звука """Первое предложение с филлером: если к секунде после конца фразы звука
ещё нет, звонящий «переспрашивает», а ответ встанет в очередь за ним.""" ещё нет, звонящий «переспрашивает», а ответ встанет в очередь за ним."""
synthesis = asyncio.ensure_future(self.synthesize(sentence)) synthesis = asyncio.ensure_future(self.synthesize(sentence))
remaining = FILLER_AFTER_S - (time.monotonic() - ended_at) try:
if remaining > 0: remaining = FILLER_AFTER_S - (time.monotonic() - ended_at)
done, _ = await asyncio.wait({synthesis}, timeout=remaining) if remaining > 0:
if done: done, _ = await asyncio.wait({synthesis}, timeout=remaining)
return synthesis.result() if done:
filler = await self.synthesize(FILLERS.get(mood, FILLERS[Mood.PANIC])) return synthesis.result()
if filler: filler = await self.synthesize(FILLERS.get(mood, FILLERS[Mood.PANIC]))
self.send_audio(filler) if filler and not self.state.paused:
if timing is not None: self.send_audio(filler)
timing.filler = True if timing is not None:
return await synthesis timing.filler = True
return await synthesis
finally:
if not synthesis.done():
synthesis.cancel()
try:
await synthesis
except asyncio.CancelledError:
pass
async def synthesize(self, text: str) -> bytes: async def synthesize(self, text: str) -> bytes:
return await cached_synthesize(self.models, text) return await cached_synthesize(self.models, text)

View file

@ -71,6 +71,17 @@ class StreamingVad:
def in_speech(self) -> bool: def in_speech(self) -> bool:
return self._in_speech return self._in_speech
def reset(self) -> None:
"""Не склеивать фрагменты речи до и после паузы занятия."""
self._state.fill(0)
self._context.fill(0)
self._pending = np.zeros(0, dtype=np.float32)
self._preroll.clear()
self._speech.clear()
self._voiced_ms = 0
self._silence_ms = 0
self._in_speech = False
def _probability(self, window: np.ndarray) -> float: def _probability(self, window: np.ndarray) -> float:
frame = np.concatenate([self._context, window])[None, :] frame = np.concatenate([self._context, window])[None, :]
output, self._state = self._session.run( output, self._state = self._session.run(

View file

@ -30,6 +30,7 @@ async def test_text_dialogue_provider_error_does_not_log_prompt_or_provider_body
monkeypatch.setattr(call.hub, "to_observers", lambda *_args: None) monkeypatch.setattr(call.hub, "to_observers", lambda *_args: None)
state = SimpleNamespace( state = SimpleNamespace(
ended=False, ended=False,
paused=False,
exercise=Exercise.CARD, exercise=Exercise.CARD,
dispatched_card=None, dispatched_card=None,
caller=Caller(), caller=Caller(),

View file

@ -57,6 +57,7 @@ def timers() -> SessionTimers:
TimerCode.DDS_ACK: Timer(code=TimerCode.DDS_ACK, started_at=MONO - 12.0, attempt=2), TimerCode.DDS_ACK: Timer(code=TimerCode.DDS_ACK, started_at=MONO - 12.0, attempt=2),
TimerCode.ANSWER: Timer(code=TimerCode.ANSWER, started_at=MONO - 7.0, TimerCode.ANSWER: Timer(code=TimerCode.ANSWER, started_at=MONO - 7.0,
elapsed_ms=7_000, attempt=3, stopped=True), elapsed_ms=7_000, attempt=3, stopped=True),
TimerCode.CARD_FILL: Timer(code=TimerCode.CARD_FILL, elapsed_ms=5_000, paused=True),
}) })
@ -159,8 +160,15 @@ def full_state() -> SessionState:
desk=desk, desk=desk,
resolved_outcome="справка", resolved_outcome="справка",
resolve_comment="передано в другой регион", resolve_comment="передано в другой регион",
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())], processed_station_commands=[str(uuid4())],
text_revealed_facts={"f_address": "улица Ленина, 14"}, text_revealed_facts={"f_address": "улица Ленина, 14"},
consecutive_refusals=2,
socket_last_seen_at=AT,
socket_connected_at_checkpoint=True,
) )

View file

@ -2,7 +2,7 @@
import asyncio import asyncio
import time import time
from datetime import datetime from datetime import datetime, timedelta
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@ -396,6 +396,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
client, client,
criteria={ criteria={
"decision_time_limit_seconds": 45, "decision_time_limit_seconds": 45,
"dds_report_reaction_limit_seconds": 20,
"allowed_errors": 50, "allowed_errors": 50,
"require_correct_grammar": False, "require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5}, "score_weights": {"dds_ack": 3.5},
@ -454,6 +455,8 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
"decision_time_limit_seconds": 45, "decision_time_limit_seconds": 45,
"card_fill_time_limit_seconds": 180, "card_fill_time_limit_seconds": 180,
"dds_card_work_time_limit_seconds": 180, "dds_card_work_time_limit_seconds": 180,
"dds_report_reaction_enabled": False,
"dds_report_reaction_limit_seconds": 20,
"allowed_errors": 50, "allowed_errors": 50,
"require_correct_grammar": False, "require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5}, "score_weights": {"dds_ack": 3.5},
@ -747,7 +750,7 @@ def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client)
def test_complete_dds_workflow_with_training_calls_and_status_updates(client): def test_complete_dds_workflow_with_training_calls_and_status_updates(client):
session_id, control = start(client) session_id, control = start(client, criteria={"dds_report_reaction_enabled": True})
try: try:
state = hub.get(session_id) state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station: with client.websocket_connect(f"/ws/station/{session_id}") as station:
@ -797,6 +800,120 @@ def test_complete_dds_workflow_with_training_calls_and_status_updates(client):
] ]
assert all(report.crew == crew for report in state.desk.active.phone_reports) assert all(report.crew == crew for report in state.desk.active.phone_reports)
assert not score["findings"] assert not score["findings"]
reaction = [m for m in score["metrics"] if m["key"] == "dds_report_reaction"]
assert len(reaction) == 1 and reaction[0]["passed"], "каждый доклад отмечен вовремя"
assert "с нарушением 0 из 4 докладов" in reaction[0]["fact"]
finally:
control.__exit__(None, None, None)
STATUS_COMMENT = "Основание: доклад бригады.\nСведения: передано старшему бригады."
def accept_with_crew(station):
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
crew = next(option for option in snapshot["crew_options"]
if option.startswith(service + " — "))
station.send_json({"type": "card.status", "service": service, "status": "accepted",
"comment": STATUS_COMMENT})
read_until(station, "station.state")
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
return service
def send_status(station, service, status):
station.send_json({"type": "card.status", "service": service, "status": status,
"comment": STATUS_COMMENT})
read_until(station, "station.state")
@pytest.mark.parametrize(("reaction", "passed"), [
("fast", True), ("slow", False), ("missing", False), ("early", True),
])
def test_dds_report_reaction_goes_from_criteria_to_report(client, reaction, passed):
"""Лимит из `scenario.start` доходит до метрики, отметки D6 и отчёта."""
session_id, control = start(client, criteria={
"dds_report_reaction_enabled": True,
"dds_report_reaction_limit_seconds": 20,
"score_weights": {"dds_report_reaction": 3.5},
})
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
service = accept_with_crew(station)
if reaction == "early":
send_status(station, service, "responding")
complete_phone_call(station, state, "dispatched")
read_until(station, "station.state")
if reaction in {"fast", "slow"}:
send_status(station, service, "responding")
if reaction == "slow":
# Доклад на 70 с раньше статуса: время доклада — серверное.
state.desk.active.phone_reports[0].at -= timedelta(seconds=70)
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
metrics = [item for item in score["metrics"] if item["key"] == "dds_report_reaction"]
assert len(metrics) == 1
metric = metrics[0]
assert metric["passed"] is passed
assert metric["weight"] == 3.5
assert metric["norm"] == "отметить статус по докладу за 20 с"
assert "«Начало реагирования» по докладу бригады" in metric["fact"]
findings = [item for item in score["findings"]
if item["metric_key"] == "dds_report_reaction"]
assert [item["code"] for item in findings] == ([] if passed else ["D6"])
assert all(item["competency"] == "norms" for item in findings)
if reaction == "slow":
assert "70 с (+50 с сверх норматива)" in metric["fact"]
assert findings[0]["fact"] == metric["fact"]
if reaction == "missing":
assert metric["fact"].endswith(": не отмечен") and metric["credit"] == 0.0
if reaction == "early":
assert metric["fact"].endswith("отмечен не позже доклада")
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["criteria"]["dds_report_reaction_limit_seconds"] == 20
assert report["criteria"]["dds_report_reaction_enabled"] is True
assert [item for item in report["metrics"]
if item["key"] == "dds_report_reaction"] == metrics
if reaction == "slow":
csv_text = client.get(f"/api/sessions/{session_id}/report.csv").content.decode("utf-8-sig")
assert "Лимит реакции на доклад бригады" in csv_text
assert "70 с (+50 с сверх норматива)" in csv_text
finally:
control.__exit__(None, None, None)
def test_refusal_after_crew_report_does_not_lower_score_without_finding(client):
"""Бригада доложила о прибытии, но работы не проводились: отказ с причиной
— терминальный исход, неотмеченное «Прибытие» не штрафуется."""
session_id, control = start(client, criteria={"dds_report_reaction_enabled": True})
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
service = accept_with_crew(station)
complete_phone_call(station, state, "dispatched")
read_until(station, "station.state")
send_status(station, service, "responding")
complete_phone_call(station, state, "arrived")
read_until(station, "station.state")
send_status(station, service, "refused")
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
assert [report.phase for report in state.desk.active.phone_reports] == [
"dispatched", "arrived",
]
metric = next(item for item in score["metrics"] if item["key"] == "dds_report_reaction")
assert metric["passed"], "прибытие при отказе не требуется"
assert "о выезде" in metric["fact"] and "докладов" not in metric["fact"]
assert not [item for item in score["findings"] if item["code"] == "D6"]
finally: finally:
control.__exit__(None, None, None) control.__exit__(None, None, None)

View file

@ -1,13 +1,14 @@
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from app.domain.statuses import ServiceStatus, StatusEntry from app.domain.statuses import PhoneReportRecord, ServiceStatus, StatusEntry
from app.domain.taxonomy import ErrorCode from app.domain.taxonomy import Competency, ErrorCode, FindingSource
from app.scenarios.loader import load_file from app.scenarios.loader import load_file
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
from app.scoring.timing import time_credit
from app.session.dds import DdsLiveCard, build_card from app.session.dds import DdsLiveCard, build_card
from app.session.timers import SessionTimers from app.session.timers import SessionTimers
@ -294,3 +295,177 @@ def test_reasoned_refusal_after_acceptance_is_a_valid_terminal_path():
deadline_ms=30_000, elapsed_ms=0, deadline_ms=30_000, elapsed_ms=0,
) )
assert ErrorCode.D6 not in [finding.code for finding in findings] assert ErrorCode.D6 not in [finding.code for finding in findings]
# ── Реакция на доклад бригады (lct-37) ──
T0 = datetime(2026, 9, 27, 10, 0, tzinfo=timezone.utc)
COMMENT = "Основание: доклад старшего.\nСведения: передано дежурному."
def at_second(seconds: int) -> datetime:
return T0 + timedelta(seconds=seconds)
def mark(status: ServiceStatus, seconds: int) -> StatusEntry:
return StatusEntry(service=SERVICES[0], status=status, at=at_second(seconds), comment=COMMENT)
def crew_report(phase: str, seconds: int) -> PhoneReportRecord:
return PhoneReportRecord(service=SERVICES[0], crew="Бригада 12", phase=phase,
text="доклад", at=at_second(seconds))
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
findings = evaluate_dispatcher(
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,
report_reaction_enabled=True,
pause_intervals=pause_intervals,
)
metric = next((m for m in dispatcher_metrics(card, 30_000, services=SERVICES,
report_reaction_ms=limit_ms,
report_reaction_enabled=True,
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
def full_run(arrived_at: int) -> list[StatusEntry]:
return [mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20),
mark(ServiceStatus.ARRIVED, arrived_at), mark(ServiceStatus.WORKING, arrived_at + 10),
mark(ServiceStatus.COMPLETED, arrived_at + 20)]
def test_status_soon_after_crew_report_passes():
findings, metric = reaction(full_run(arrived_at=110), [crew_report("arrived", 100)])
assert findings == []
assert metric.passed and metric.weight == 1.5
assert metric.fact == (
"статус «Прибытие» по докладу бригады «Бригада 12» о прибытии: "
"10 с (на 35 с быстрее норматива)"
)
assert metric.credit == time_credit(10_000, 45_000)
assert metric.title == f"{SERVICES[0]}: реакция на доклад бригады"
def test_status_later_than_limit_after_crew_report_is_d6_with_fact_and_norm():
findings, metric = reaction(full_run(arrived_at=170), [crew_report("arrived", 100)])
assert [f.code for f in findings] == [ErrorCode.D6]
assert "«Прибытие»" in findings[0].fact and "70 с (+25 с сверх норматива)" in findings[0].fact
assert "о прибытии" in findings[0].fact, "факт ссылается на доклад"
assert findings[0].norm == metric.norm == "отметить статус по докладу за 45 с"
assert findings[0].competency is Competency.NORMS, "отметка и метрика в одной компетенции"
assert findings[0].source is FindingSource.DISPATCHER
assert findings[0].service == SERVICES[0]
assert not metric.passed and metric.fact == findings[0].fact
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(
update={"at": at_second(100) + timedelta(milliseconds=45_400)},
)
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
assert not metric.passed
assert "45.4 с (+0.4 с сверх норматива)" in findings[0].fact
def test_missing_status_after_crew_report_is_d6():
status_log = [mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20)]
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
assert [f.code for f in findings] == [ErrorCode.D6]
assert "не отмечен" in findings[0].fact and "Бригада 12" in findings[0].fact
assert not metric.passed and metric.credit == 0.0
assert metric.fact == "статус «Прибытие» по докладу бригады «Бригада 12» о прибытии: не отмечен"
def test_metric_takes_the_worst_report_and_counts_violations():
reports = [crew_report("dispatched", 10), crew_report("arrived", 100),
crew_report("working", 175)]
findings, metric = reaction(full_run(arrived_at=170), reports)
assert len(findings) == 1, "одна отметка на службу, как у других нормативов"
assert "о прибытии: 70 с" in metric.fact
assert "с нарушением 1 из 3 докладов" in metric.fact
def test_status_set_before_the_report_and_custom_limit():
findings, metric = reaction(full_run(arrived_at=90), [crew_report("arrived", 100)])
assert findings == [] and metric.passed, "статус раньше доклада — не нарушение"
assert metric.fact.endswith("отмечен не позже доклада") and metric.credit == 1.0
strict, _ = reaction(full_run(arrived_at=130), [crew_report("arrived", 100)], limit_ms=20_000)
assert strict[0].norm == "отметить статус по докладу за 20 с"
def test_no_crew_reports_means_no_reaction_metric():
findings, metric = reaction(full_run(arrived_at=110), [])
assert findings == [] and metric is None
def test_refusal_after_crew_report_is_neither_d6_nor_failed_metric():
status_log = [
mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20),
StatusEntry(service=SERVICES[0], status=ServiceStatus.REFUSED, at=at_second(120),
comment="Основание: доклад старшего.\nСведения: бригаде отказано в доступе."),
]
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
assert findings == [], "работ не было — статус «Прибытие» отмечать не нужно"
assert metric is None, "метрика не снижает балл без отметки"
def test_refusal_keeps_late_reaction_on_reported_status():
status_log = [
mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 80),
StatusEntry(service=SERVICES[0], status=ServiceStatus.REFUSED, at=at_second(120),
comment="Основание: доклад старшего.\nСведения: бригаде отказано в доступе."),
]
findings, metric = reaction(status_log, [crew_report("dispatched", 10),
crew_report("arrived", 100)])
assert [f.code for f in findings] == [ErrorCode.D6]
assert not metric.passed and "о выезде: 70 с" in metric.fact
assert "докладов" not in metric.fact, "неотмеченное прибытие при отказе не считается"
def test_unfinished_work_after_completion_report_names_the_report():
status_log = full_run(arrived_at=110)[:-1]
findings = evaluate_dispatcher(
entries=status_log, services=SERVICES, crew_assignments={SERVICES[0]: "Бригада 12"},
deadline_ms=30_000, elapsed_ms=5_000, phone_reports=[crew_report("completed", 150)],
)
unfinished = next(f for f in findings if f.summary.endswith("не доведён до конца"))
assert "бригада доложила о завершении работ" in unfinished.fact

View file

@ -4,6 +4,8 @@
производен и не выбирается руками, сценарий размечается признаками. производен и не выбирается руками, сценарий размечается признаками.
""" """
import hashlib
import json
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -93,6 +95,31 @@ def test_all_customer_ticket_cards_are_complete_and_classified():
} }
def test_ticket_ekp_codes_and_notify_lists_match_current_baseline():
"""Изменение правил оповещения не должно незаметно переписать 96 эталонов.
При намеренной смене версии ЕКП сначала сверяют каждый изменившийся список
с источником и методистом, затем обновляют контрольную сумму.
"""
cards = [
scenario
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.ticket is not None
]
assert len(cards) == 96
manifest = {
scenario.id: {
"code": scenario.ground_truth.incident_code,
"notify": scenario.ground_truth.notify,
}
for scenario in cards
}
encoded = json.dumps(manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
assert hashlib.sha256(encoded.encode()).hexdigest() == (
"2e3d49cc1e4a47bf8813437207a4105799c5721cc54bddc795730267ebb44df4"
)
def test_ticket_two_preserves_moscow_and_does_not_invent_missing_victim_data(): def test_ticket_two_preserves_moscow_and_does_not_invent_missing_victim_data():
root = Path(__file__).resolve().parents[2] / "scenarios" root = Path(__file__).resolve().parents[2] / "scenarios"
cards = { cards = {
@ -382,6 +409,43 @@ def test_gasified_object_brings_mosgaz():
assert "МОСГАЗ" in card.notify assert "МОСГАЗ" in card.notify
def test_explicit_offence_signs_add_police_and_removal_recalculates_notify():
from app.domain.kio import apply_patch, modifiers
signs = ["на улице", "мусор", "открытое пламя"]
base = derive_incident(KIO(signs=signs))
assert base.incident_code == "1010101"
assert "МВД" not in base.notify
stated = apply_patch(base, {"offence_signs": True})
assert "offence" in modifiers(stated)
assert set(stated.notify) == set(base.notify) | {"МВД"}
denied = apply_patch(stated, {"offence_signs": False})
assert denied.notify == base.notify
unknown = apply_patch(stated, {"offence_signs": None})
assert unknown.notify == base.notify
def test_offence_kind_also_adds_police_until_both_sources_are_removed():
from app.domain.kio import apply_patch, modifiers
base = derive_incident(KIO(signs=["на улице", "мусор", "открытое пламя"]))
stated = apply_patch(base, {"police.offence_kind": "поджог"})
assert "offence" in modifiers(stated)
assert set(stated.notify) == set(base.notify) | {"МВД"}
denied_flag = apply_patch(stated, {"offence_signs": False})
assert "МВД" in denied_flag.notify, "явно указанный вид правонарушения остаётся источником"
with_both = apply_patch(stated, {"offence_signs": True})
assert modifiers(with_both).count("offence") == 1
without_kind = apply_patch(with_both, {"police.offence_kind": " "})
assert "МВД" in without_kind.notify
without_flag = apply_patch(without_kind, {"offence_signs": False})
assert without_flag.notify == base.notify
assert "offence" not in modifiers(without_flag)
def test_removed_modifier_recalculates_notify_without_stale_service(): def test_removed_modifier_recalculates_notify_without_stale_service():
from app.domain.kio import KIO, FireDetails, apply_patch, derive_incident from app.domain.kio import KIO, FireDetails, apply_patch, derive_incident

View file

@ -0,0 +1,644 @@
"""Разбор по отметкам (lct-35): преподаватель подтверждает, снимает и добавляет отметку.
Балл пересчитывается теми же метриками и весами: снятая отметка не штрафует
свою метрику, ручная — штрафует. Автоматическая оценка остаётся рядом.
"""
import asyncio
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app.api.auth import Principal
from app.api.http import sessions
from app.domain.events import Metric
from app.domain.roles import Role
from app.domain.taxonomy import (
ErrorCode,
Finding,
FindingDecision,
FindingReview,
FindingSource,
)
from app.main import app
from app.scoring.export import to_csv
from app.scoring.review import INSTRUCTOR_METRIC, add_finding, review_finding
from app.session import finish as finish_module
from app.session.hub import hub
from app.session.store import FindingAdded, FindingReviewed, MemorySessionStore
from tests.test_dds_desk_scores import dds_exercise_early_finish
AT = datetime(2026, 9, 27, 12, 0, tzinfo=UTC)
def review(decision: FindingDecision) -> FindingReview:
return FindingReview(decision=decision, reason="доклад пришёл по рации", author="teacher", at=AT)
def report_with(metrics: list[Metric], findings: list[Finding]) -> dict:
return {
"score_auto": 50.0,
"metrics": [metric.model_dump(mode="json") for metric in metrics],
"findings": [finding.model_dump(mode="json") for finding in findings],
"full_report": {"criteria": {"allowed_errors": 0}, "score_auto": 50.0,
"score_final": 50.0, "failed_metrics": 1, "passed": False},
}
def dds_metric(key: str, passed: bool, card: int = 1) -> Metric:
return Metric(key=key, title=key, fact="факт", norm="норма", passed=passed,
weight=1.0, card=card, service="01")
def dds_finding(code: ErrorCode, card: int = 1) -> Finding:
return Finding(code=code, source=FindingSource.DISPATCHER, summary=code.value,
fact="факт", norm="норма", card=card, service="01")
def test_dismissed_finding_stops_penalizing_its_metric_but_stays_in_report():
report = report_with(
[dds_metric("dds_primary", True), dds_metric("dds_progress", False)],
[dds_finding(ErrorCode.D6)],
)
updated = review_finding(report, 0, review(FindingDecision.DISMISSED))
assert updated["score_reviewed"] == 100.0
assert updated["score_final"] == 100.0
assert updated["score_auto"] == 50.0, "автооценка остаётся рядом"
progress = updated["metrics"][1]
assert (progress["passed"], progress["passed_auto"]) == (True, False)
finding = updated["findings"][0]
assert finding["reviews"][-1]["decision"] == "dismissed"
assert finding["reviews"][-1]["reason"] == "доклад пришёл по рации"
archived = updated["full_report"]
assert (archived["score_final"], archived["failed_metrics"], archived["passed"]) == (100.0, 0, True)
def test_confirmed_finding_and_other_card_keep_the_metric_failed():
report = report_with(
[dds_metric("dds_progress", False, card=1), dds_metric("dds_progress", False, card=2)],
[dds_finding(ErrorCode.D6, card=1), dds_finding(ErrorCode.D6, card=2)],
)
confirmed = review_finding(report, 0, review(FindingDecision.CONFIRMED))
assert confirmed["score_reviewed"] == 0.0
dismissed = review_finding(report, 0, review(FindingDecision.DISMISSED))
assert [metric["passed"] for metric in dismissed["metrics"]] == [True, False], (
"снятие отметки первой карточки не трогает вторую"
)
def test_metric_stays_failed_while_one_of_its_findings_is_active():
report = report_with(
[dds_metric("dds_reply", False)],
[dds_finding(ErrorCode.D5), dds_finding(ErrorCode.D5)],
)
updated = review_finding(report, 0, review(FindingDecision.DISMISSED))
assert updated["metrics"][0]["passed"] is False
again = review_finding(updated, 1, review(FindingDecision.DISMISSED))
assert again["metrics"][0]["passed"] is True
def test_instructor_finding_penalizes_linked_metric_and_unlinked_code_adds_one():
report = report_with([dds_metric("dds_progress", True), dds_metric("dds_primary", True)], [])
manual = Finding(code=ErrorCode.D6, source=FindingSource.INSTRUCTOR, summary="Карточка 1: D6",
fact="«Прибыла» отмечено до доклада", norm="по факту доклада",
card=1, service="01", author="teacher", at=AT)
updated = add_finding(report, manual)
assert updated["score_reviewed"] == 50.0
assert updated["metrics"][0]["passed"] is False
assert updated["metrics"][0]["passed_auto"] is True
assert updated["findings"][0]["source"] == "instructor"
# У D4 в методике нет метрики — ручная отметка всё равно должна штрафовать.
no_metric = manual.model_copy(update={"code": ErrorCode.D4})
extra = add_finding(report, no_metric)
assert [metric["key"] for metric in extra["metrics"]][-1] == INSTRUCTOR_METRIC
assert extra["score_reviewed"] == pytest.approx(66.7)
# Снятая ручная отметка не штрафует, и её метрика исчезает при пересчёте.
lifted = review_finding(extra, 0, review(FindingDecision.DISMISSED))
assert lifted["score_reviewed"] == 100.0
assert INSTRUCTOR_METRIC not in {metric["key"] for metric in lifted["metrics"]}
def test_score_override_stays_final_after_finding_review():
report = {**report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)]),
"score_final": 70.0, "overridden_by": "teacher"}
updated = review_finding(report, 0, review(FindingDecision.DISMISSED))
assert updated["score_reviewed"] == 100.0
assert updated["score_final"] == 70.0
def test_unknown_finding_index_is_refused():
with pytest.raises(LookupError):
review_finding(report_with([], []), 0, review(FindingDecision.DISMISSED))
# ── HTTP: живое занятие ДДС ──
@pytest.fixture
def lesson(monkeypatch):
"""Завершённое занятие ДДС преподавателя `dev` в памяти узла."""
class NoCoaching:
def model_dump(self, **_kwargs):
return {}
async def no_coach(_metrics):
return NoCoaching()
async def audit_in_memory(*_args, **_kwargs):
return None
async def no_database():
yield None
# Живое занятие в памяти: чтение отчёта не должно ходить в PostgreSQL.
monkeypatch.setattr(finish_module, "coach", no_coach)
monkeypatch.setattr(sessions, "audit_required", audit_in_memory)
monkeypatch.setitem(app.dependency_overrides, sessions.optional_session, no_database)
store = MemorySessionStore()
monkeypatch.setattr(hub, "store", store)
state = dds_exercise_early_finish()
state.owner_login = "dev"
with TestClient(app) as browser:
browser.post("/api/auth/dev-token")
hub.store = store
hub.register(state)
yield browser, state, store
def as_role(monkeypatch, role: Role, login: str = "dev") -> None:
def require(_request, *roles):
if roles and role not in roles:
raise HTTPException(status_code=403, detail="forbidden")
return Principal(login=login, full_name="Проверка", role=role,
trainee_id=uuid4() if role is Role.TRAINEE else None)
monkeypatch.setattr(sessions, "require", require)
def test_owner_dismisses_and_adds_finding_through_http(lesson):
browser, state, store = lesson
session_id = state.session_id
before = browser.get(f"/api/sessions/{session_id}/report").json()
target = next(index for index, finding in enumerate(before["findings"])
if finding["code"] == "D1")
dismissed = browser.post(
f"/api/sessions/{session_id}/findings/{target}/review",
json={"decision": "dismissed", "reason": "карточку выдали с опозданием"},
)
assert dismissed.status_code == 200, dismissed.text
body = dismissed.json()
assert body["findings"][target]["reviews"][-1]["decision"] == "dismissed"
assert body["findings"][target]["reviews"][-1]["author"] == "dev"
assert body["score_auto"] == before["score_auto"]
assert body["score_reviewed"] >= before["score_auto"]
added = browser.post(
f"/api/sessions/{session_id}/findings",
json={"code": "D5", "fact": "в комментарии нет адресата", "norm": "назвать адресата",
"card": 1},
)
assert added.status_code == 200, added.text
manual = added.json()["findings"][-1]
assert (manual["source"], manual["card"], manual["author"]) == ("instructor", 1, "dev")
assert manual["summary"].startswith("Карточка 1: ")
records = [record for _, batch in store.commits for record in batch]
assert [type(record) for record in records[-2:]] == [FindingReviewed, FindingAdded]
assert store.scores[session_id]["findings"][target]["reviews"][-1]["decision"] == "dismissed"
rows = to_csv(sessions.SessionReport.model_validate(added.json())).decode("utf-8-sig")
assert "карточку выдали с опозданием" in rows
assert "в комментарии нет адресата" in rows
def test_review_requires_reason_and_existing_card(lesson):
browser, state, _store = lesson
session_id = state.session_id
blank = browser.post(f"/api/sessions/{session_id}/findings/0/review",
json={"decision": "dismissed", "reason": " "})
assert blank.status_code == 422
missing = browser.post(f"/api/sessions/{session_id}/findings/999/review",
json={"decision": "confirmed", "reason": "проверено"})
assert missing.status_code == 404
card = browser.post(f"/api/sessions/{session_id}/findings",
json={"code": "D1", "fact": "факт", "norm": "норма", "card": 99})
assert card.status_code == 422
@pytest.mark.parametrize("role, login, status", [
(Role.TRAINEE, "trainee", 403),
(Role.INSTRUCTOR, "someone-else", 404),
])
def test_trainee_and_foreign_instructor_cannot_review(lesson, monkeypatch, role, login, status):
browser, state, store = lesson
commits = len(store.commits)
as_role(monkeypatch, role, login)
reviewed = browser.post(f"/api/sessions/{state.session_id}/findings/0/review",
json={"decision": "dismissed", "reason": "не согласен"})
added = browser.post(f"/api/sessions/{state.session_id}/findings",
json={"code": "D1", "fact": "факт", "norm": "норма"})
assert (reviewed.status_code, added.status_code) == (status, status)
assert len(store.commits) == commits, "отказ ничего не пишет"
def test_finding_review_is_audited_in_postgres(postgres_access):
"""Решение и аудит — одна транзакция: строка Score и AuditLog вместе."""
from sqlalchemy import select
from app.db.base import get_sessionmaker
from app.db.models import AuditLog, Score
from app.session.pg_store import PostgresSessionStore
from app.session.store import LessonRequest
store = PostgresSessionStore(get_sessionmaker())
session_id = uuid4()
report = report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)])
async def scenario():
from app.session.state import SessionState
from app.domain.events import Exercise, SessionMode
def build(_identity):
return SessionState(session_id=session_id, scenario_id="fire-apartment-l2",
scenario_title="Пожар", level="L2", mode=SessionMode.TRAINING,
exercise=Exercise.DDS)
await store.open(LessonRequest(
session_id=session_id, scenario_id="fire-apartment-l2", mode="training",
trainee_name=None, trainee_id=None, owner_login="teacher", backend_node_id=None,
), build)
from app.session.store import ScoreCalculated
await store.commit_archived(session_id, [ScoreCalculated(50.0, report)])
await store.commit_archived(session_id, [FindingReviewed(
index=0, role="instructor", review=review(FindingDecision.DISMISSED),
)])
async with get_sessionmaker()() as db:
score = await db.scalar(select(Score).where(Score.session_id == session_id))
audit = await db.scalar(select(AuditLog).where(
AuditLog.object_id == str(session_id), AuditLog.action == "finding.review",
))
return score, audit
score, audit = asyncio.run(scenario())
assert score.score_auto == 50.0
assert score.score_final == 100.0
assert score.report["findings"][0]["reviews"][-1]["decision"] == "dismissed"
assert audit is not None and audit.actor == "teacher"
assert "dismissed" in audit.detail and "50.0 → 100.0" in audit.detail
# ── История решений, коды, карточки, веса ──
def test_repeated_decision_keeps_history_and_last_one_applies():
report = report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)])
dismissed = review_finding(report, 0, review(FindingDecision.DISMISSED))
confirmed = review_finding(dismissed, 0, FindingReview(
decision=FindingDecision.CONFIRMED, reason="перепроверил запись", author="head", at=AT,
))
reviews = confirmed["findings"][0]["reviews"]
assert [(item["decision"], item["author"], item["reason"]) for item in reviews] == [
("dismissed", "teacher", "доклад пришёл по рации"),
("confirmed", "head", "перепроверил запись"),
]
assert confirmed["score_reviewed"] == 0.0, "действует последнее решение"
def test_report_saved_with_single_review_reads_as_history():
legacy = dds_finding(ErrorCode.D6).model_dump(mode="json")
legacy["review"] = review(FindingDecision.DISMISSED).model_dump(mode="json")
finding = Finding.model_validate(legacy)
assert [item.decision for item in finding.reviews] == [FindingDecision.DISMISSED]
assert finding.review.decision is FindingDecision.DISMISSED
def test_call_code_in_handoff_penalizes_call_metric_not_instructor_metric():
"""Связка 112 → ДДС: E1 без карточки проваливает метрику 112, а не добавляет свою."""
call = Metric(key="checklist_completeness", title="Полнота опроса", fact="все", norm="все",
passed=True, weight=1.0)
report = report_with([call, dds_metric("dds_progress", True)], [])
manual = Finding(code=ErrorCode.E1, source=FindingSource.INSTRUCTOR, summary="E1",
fact="не уточнён подъезд", norm="уточнить подъезд", author="teacher", at=AT)
updated = add_finding(report, manual)
assert [metric["passed"] for metric in updated["metrics"]] == [False, True]
assert INSTRUCTOR_METRIC not in {metric["key"] for metric in updated["metrics"]}
assert updated["score_reviewed"] == 50.0
def test_codes_and_card_scores_follow_the_review():
card = {"card_id": str(uuid4()), "scenario_id": "s", "score_auto": 50.0, "reply_text": "",
"metrics": [dds_metric("dds_primary", True).model_dump(mode="json"),
dds_metric("dds_progress", False).model_dump(mode="json")],
"findings": [dds_finding(ErrorCode.D6).model_dump(mode="json")]}
report = {**report_with([dds_metric("dds_primary", True), dds_metric("dds_progress", False)],
[dds_finding(ErrorCode.D6)]),
"summary": {"hints": 2, "codes": {"D6": 1}}, "card_results": [card]}
report["full_report"]["card_results"] = [card]
dismissed = review_finding(report, 0, review(FindingDecision.DISMISSED))
assert dismissed["summary"] == {"hints": 2, "codes": {}}, "снятая отметка не ошибка группы"
for cards in (dismissed["card_results"], dismissed["full_report"]["card_results"]):
assert cards[0]["score_auto"] == 50.0
assert cards[0]["score_reviewed"] == 100.0
assert cards[0]["metrics"][1]["passed"] is True
assert cards[0]["findings"][0]["reviews"][-1]["decision"] == "dismissed"
manual = Finding(code=ErrorCode.D4, source=FindingSource.INSTRUCTOR, summary="D4",
fact="отказ без причины", norm="причина обязательна", card=1,
author="teacher", at=AT)
added = add_finding(dismissed, manual)
assert added["summary"]["codes"] == {"D4": 1}
assert added["card_results"][0]["score_reviewed"] == pytest.approx(66.7)
def test_instructor_metric_weight_comes_from_lesson_weights():
report = report_with([dds_metric("dds_primary", True)], [])
report["full_report"]["criteria"]["score_weights"] = {INSTRUCTOR_METRIC: 3.0}
manual = Finding(code=ErrorCode.D4, source=FindingSource.INSTRUCTOR, summary="D4",
fact="отказ без причины", norm="причина обязательна", card=1,
author="teacher", at=AT)
updated = add_finding(report, manual)
assert updated["metrics"][-1]["weight"] == 3.0
assert updated["score_reviewed"] == 25.0
def test_lesson_criteria_accept_instructor_metric_weight():
from app.domain.events import LessonCriteria
assert LessonCriteria(score_weights={INSTRUCTOR_METRIC: 2.0}).score_weights == {
INSTRUCTOR_METRIC: 2.0}
def test_repeated_manual_finding_with_same_key_is_added_once():
report = report_with([dds_metric("dds_primary", True)], [])
manual = Finding(code=ErrorCode.D4, source=FindingSource.INSTRUCTOR, summary="D4",
fact="отказ без причины", norm="причина обязательна", card=1,
author="teacher", at=AT, client_id=uuid4())
once = add_finding(report, manual)
twice = add_finding(once, manual)
assert len(twice["findings"]) == 1
assert twice["score_reviewed"] == once["score_reviewed"] == 50.0
def test_pdf_shows_score_after_review(monkeypatch):
from reportlab import platypus
from app.scoring.export import to_pdf
from tests.test_report_export import sample_report
texts: list[str] = []
class Recorded(platypus.Paragraph):
def __init__(self, text, *args, **kwargs):
texts.append(text)
super().__init__(text, *args, **kwargs)
# Абзацы перехватываются до вёрстки: извлечь текст из PDF нечем без poppler.
monkeypatch.setattr(platypus, "Paragraph", Recorded)
assert to_pdf(sample_report().model_copy(update={"score_reviewed": 91.5})).startswith(b"%PDF-")
assert "<b>После разбора отметок:</b> 91.5" in texts
# ── HTTP: связка 112 → ДДС, идемпотентность, занятие из истории ──
def test_call_code_with_queue_card_is_refused_in_handoff(lesson):
browser, state, store = lesson
commits = len(store.commits)
state.score["full_report"]["exercise"] = "call"
refused = browser.post(f"/api/sessions/{state.session_id}/findings",
json={"code": "E1", "fact": "факт", "norm": "норма", "card": 1})
assert refused.status_code == 422
assert refused.json()["detail"] == "call_code_without_card"
assert len(store.commits) == commits
def test_repeated_add_request_does_not_add_second_finding(lesson):
browser, state, store = lesson
body = {"code": "D5", "fact": "нет адресата", "norm": "назвать адресата", "card": 1,
"client_id": str(uuid4())}
first = browser.post(f"/api/sessions/{state.session_id}/findings", json=body)
commits = len(store.commits)
second = browser.post(f"/api/sessions/{state.session_id}/findings", json=body)
assert first.status_code == second.status_code == 200
assert len(second.json()["findings"]) == len(first.json()["findings"])
assert second.json()["score_reviewed"] == first.json()["score_reviewed"]
assert not [record for _, batch in store.commits[commits:] for record in batch], (
"повтор ничего не пишет")
def test_archived_lesson_is_reviewed_through_http(monkeypatch):
"""Занятия нет в памяти узла: решение идёт через commit_archived, ответ — по строке после записи."""
from types import SimpleNamespace
from app.session.pg_store import PostgresSessionStore
from tests.test_report_export import sample_report
archived = sample_report().model_dump(mode="json")
archived["findings"] = [dds_finding(ErrorCode.D6).model_dump(mode="json")]
archived["metrics"] = [dds_metric("dds_progress", False).model_dump(mode="json")]
archived.update(score_final=0.0, overridden_by=None, override_comment=None, score_auto=0.0)
session_id = archived["session_id"]
session_row = SimpleNamespace(id=session_id, trainee_id=uuid4(), owner_login="dev")
score_row = SimpleNamespace(
session_id=session_id, score_auto=0.0, score_final=0.0,
report={"score_auto": 0.0, "summary": {"codes": {"D6": 1}},
"findings": archived["findings"], "metrics": archived["metrics"],
"full_report": archived},
)
class FakeDb:
def __init__(self):
self.added = []
self.commits = 0
async def get(self, _model, _key):
return session_row
async def scalar(self, _statement):
return score_row
def add(self, row):
self.added.append(row)
async def flush(self):
pass
async def rollback(self):
pass
async def commit(self):
self.commits += 1
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
db = FakeDb()
async def fake_session():
yield db
monkeypatch.setattr(hub, "store", PostgresSessionStore(lambda: db))
monkeypatch.setattr(hub, "get", lambda _session_id: None)
monkeypatch.setitem(app.dependency_overrides, sessions.optional_session, fake_session)
with TestClient(app) as browser:
browser.post("/api/auth/dev-token")
hub.store = PostgresSessionStore(lambda: db)
response = browser.post(f"/api/sessions/{session_id}/findings/0/review",
json={"decision": "dismissed", "reason": "доклад по рации"})
assert response.status_code == 200, response.text
body = response.json()
assert body["score_reviewed"] == 100.0 and body["score_final"] == 100.0
assert body["findings"][0]["reviews"][-1]["reason"] == "доклад по рации"
assert db.commits == 1, "решение и аудит — один commit"
assert score_row.score_final == 100.0
assert score_row.report["summary"]["codes"] == {}
audit = db.added[0]
assert audit.action == "finding.review" and audit.actor == "dev"
assert "D6 dismissed" in audit.detail and "доклад по рации" in audit.detail
def test_finding_review_is_not_saved_when_audit_fails(postgres_access):
"""Fail-closed: строка аудита не записалась — решения и нового балла тоже нет."""
from sqlalchemy import select
from app.db.base import get_sessionmaker
from app.db.models import AuditLog, Score
from app.domain.events import Exercise, SessionMode
from app.session.pg_store import PostgresSessionStore
from app.session.state import SessionState
from app.session.store import LessonRequest, ScoreCalculated
store = PostgresSessionStore(get_sessionmaker())
session_id = uuid4()
report = report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)])
# actor в audit_log — String(80): длинный логин роняет INSERT аудита
# на стороне PostgreSQL, а Score меняется в той же транзакции.
broken = FindingReview(decision=FindingDecision.DISMISSED, reason="доклад по рации",
author="x" * 200, at=AT)
async def scenario():
def build(_identity):
return SessionState(session_id=session_id, scenario_id="fire-apartment-l2",
scenario_title="Пожар", level="L2", mode=SessionMode.TRAINING,
exercise=Exercise.DDS)
await store.open(LessonRequest(
session_id=session_id, scenario_id="fire-apartment-l2", mode="training",
trainee_name=None, trainee_id=None, owner_login="teacher", backend_node_id=None,
), build)
await store.commit_archived(session_id, [ScoreCalculated(50.0, report)])
with pytest.raises(Exception):
await store.commit_archived(session_id, [FindingReviewed(
index=0, role="instructor", review=broken,
)])
async with get_sessionmaker()() as db:
score = await db.scalar(select(Score).where(Score.session_id == session_id))
audit = await db.scalar(select(AuditLog).where(
AuditLog.object_id == str(session_id), AuditLog.action == "finding.review",
))
return score, audit
score, audit = asyncio.run(scenario())
assert audit is None
assert score.score_final == 50.0
assert score.report["findings"][0].get("reviews", []) == []
assert "score_reviewed" not in score.report
def test_archived_lesson_review_through_http_in_postgres(postgres_access, monkeypatch):
"""Тот же маршрут на настоящей PostgreSQL: ответ перечитан после commit под блокировкой."""
from sqlalchemy import select
from app.db.base import get_sessionmaker
from app.db.models import AuditLog
from app.domain.events import Exercise, SessionMode
from app.session.pg_store import PostgresSessionStore
from app.session.state import SessionState
from app.session.store import LessonRequest, ScoreCalculated
from tests.test_report_export import sample_report
session_id = uuid4()
archived = sample_report().model_dump(mode="json")
archived.update(session_id=str(session_id), score_final=0.0, score_auto=0.0,
overridden_by=None, override_comment=None,
findings=[dds_finding(ErrorCode.D6).model_dump(mode="json")],
metrics=[dds_metric("dds_progress", False).model_dump(mode="json")])
report = {"score_auto": 0.0, "summary": {"codes": {"D6": 1}},
"findings": archived["findings"], "metrics": archived["metrics"],
"full_report": archived}
store = PostgresSessionStore(get_sessionmaker())
async def prepare():
def build(_identity):
return SessionState(session_id=session_id, scenario_id="fire-apartment-l2",
scenario_title="Пожар", level="L2", mode=SessionMode.TRAINING,
exercise=Exercise.DDS)
await store.open(LessonRequest(
session_id=session_id, scenario_id="fire-apartment-l2", mode="training",
trainee_name=None, trainee_id=None, owner_login="dev", backend_node_id=None,
), build)
await store.commit_archived(session_id, [ScoreCalculated(0.0, report)])
async def audit():
async with get_sessionmaker()() as db:
return await db.scalar(select(AuditLog).where(
AuditLog.object_id == str(session_id), AuditLog.action == "finding.review"))
monkeypatch.setattr(hub, "get", lambda _session_id: None)
# Пул соединений привязан к циклу событий: подготовка и проверка идут
# в цикле того же TestClient, что и запрос.
with TestClient(app) as browser:
browser.post("/api/auth/dev-token")
previous, hub.store = hub.store, store
try:
browser.portal.call(prepare)
response = browser.post(f"/api/sessions/{session_id}/findings/0/review",
json={"decision": "dismissed", "reason": "доклад по рации"})
row = browser.portal.call(audit)
finally:
hub.store = previous
assert response.status_code == 200, response.text
assert response.json()["score_final"] == 100.0
assert response.json()["findings"][0]["reviews"][-1]["reason"] == "доклад по рации"
assert row is not None and "доклад по рации" in row.detail

View file

@ -68,10 +68,21 @@ def test_csv_contains_sections_and_blocks_formula_injection():
assert any(row[0] == "Занятие" and row[3] == "'=1+1" for row in rows) assert any(row[0] == "Занятие" and row[3] == "'=1+1" for row in rows)
assert any(row[0] == "Ошибки" and row[3] == "'+HYPERLINK(\"x\")" for row in rows) assert any(row[0] == "Ошибки" and row[3] == "'+HYPERLINK(\"x\")" for row in rows)
assert any(row[0] == "Самооценка" and row[3] == "'@SUM(1,2)" for row in rows) assert any(row[0] == "Самооценка" and row[3] == "'@SUM(1,2)" for row in rows)
assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "не применяется", ""] in rows
assert _cell(" =cmd") == "' =cmd" assert _cell(" =cmd") == "' =cmd"
assert _cell("\tОбычный текст") == "'\tОбычный текст" assert _cell("\tОбычный текст") == "'\tОбычный текст"
def test_csv_shows_enabled_report_reaction_limit():
report = sample_report()
report.criteria.dds_report_reaction_enabled = True
report.criteria.dds_report_reaction_limit_seconds = 20
rows = list(csv.reader(io.StringIO(to_csv(report).decode("utf-8-sig"))))
assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "20", ""] in rows
def test_pdf_supports_cyrillic_and_spans_pages(tmp_path): def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
data = to_pdf(sample_report(long=True)) data = to_pdf(sample_report(long=True))
assert data.startswith(b"%PDF-") assert data.startswith(b"%PDF-")
@ -89,6 +100,8 @@ def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
assert "Отчёт по учебному занятию" in extracted assert "Отчёт по учебному занятию" in extracted
assert "Адрес должен быть уточнён" in extracted assert "Адрес должен быть уточнён" in extracted
assert "Заявитель сообщает о дыме" in extracted assert "Заявитель сообщает о дыме" in extracted
assert "Лимит реакции на доклад бригады" in extracted
assert "не применяется" in extracted
def test_certificate_pdf_contains_saved_result(tmp_path): def test_certificate_pdf_contains_saved_result(tmp_path):

View file

@ -12,13 +12,14 @@ from pathlib import Path
API_ROOT = Path(__file__).parents[1] / "app" / "api" API_ROOT = Path(__file__).parents[1] / "app" / "api"
# Public by design: credential entry/session bootstrap and the non-sensitive # Public by design: credential entry/session bootstrap and the non-sensitive
# incident classification dictionary. dev-token has its own fail-closed flag # incident classification dictionary and service catalog. dev-token has its own fail-closed flag
# and remains hidden in production. # and remains hidden in production.
PUBLIC_HTTP_ROUTES = { PUBLIC_HTTP_ROUTES = {
("auth.py", "post", "/login"), ("auth.py", "post", "/login"),
("auth.py", "post", "/dev-token"), ("auth.py", "post", "/dev-token"),
("http/ekp.py", "get", "/groups"), ("http/ekp.py", "get", "/groups"),
("http/ekp.py", "get", "/signs"), ("http/ekp.py", "get", "/signs"),
("http/ekp.py", "get", "/services"),
} }
# Routes that centralize ownership + authentication checks in a shared helper. # Routes that centralize ownership + authentication checks in a shared helper.
@ -83,6 +84,8 @@ HTTP_ROLE_GATE_POLICY = {
("http/sessions.py", "get", "/{session_id}/checklist"): ((),), ("http/sessions.py", "get", "/{session_id}/checklist"): ((),),
("http/sessions.py", "get", "/{session_id}/recording.wav"): (("INSTRUCTOR", "TRAINEE"),), ("http/sessions.py", "get", "/{session_id}/recording.wav"): (("INSTRUCTOR", "TRAINEE"),),
("http/sessions.py", "patch", "/{session_id}/report"): (("INSTRUCTOR",),), ("http/sessions.py", "patch", "/{session_id}/report"): (("INSTRUCTOR",),),
("http/sessions.py", "post", "/{session_id}/findings/{index}/review"): (("INSTRUCTOR",),),
("http/sessions.py", "post", "/{session_id}/findings"): (("INSTRUCTOR",),),
("http/sessions.py", "get", ""): ((),), ("http/sessions.py", "get", ""): ((),),
("http/trainees.py", "get", "/{trainee_id}/certificate.pdf"): ((),), ("http/trainees.py", "get", "/{trainee_id}/certificate.pdf"): ((),),
("http/trainees.py", "get", ""): (("ADMIN", "INSTRUCTOR"),), ("http/trainees.py", "get", ""): (("ADMIN", "INSTRUCTOR"),),

View file

@ -0,0 +1,167 @@
"""Каталог служб из «СЛУЖБЫ 112» (lct-38).
Каталог только расширяет ручной выбор служб в карточке. Список оповещения
по-прежнему считается по классификатору, эталоны сценариев от каталога не зависят.
"""
import asyncio
import json
from pathlib import Path
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from app.api.ws import call as call_ws
from app.domain import ekp
from app.domain.events import ErrorEvent, KioPatchIn, KioPatchOut, PatchSource, SessionMode
from app.domain.kio import EDITABLE_KIO_FIELDS, KIO, PatchRejected, apply_patch
from app.main import app
from app.scenarios.loader import load_file
from app.scoring.card import evaluate_card
from app.session.hub import hub
from app.session.state import SessionState
from app.session.store import MemorySessionStore
def test_catalog_keeps_all_209_services_with_attribution_and_source():
data = ekp.catalog()
assert len(data.services) == 209
assert len({item.name for item in data.services}) == 209
assert sum(item.service_level == "ДДС" for item in data.services) == 157
assert "ded466988f505082922aacac9fff32b777d1279a" in data.transcription
assert data.license.startswith("MIT")
assert all(item.source_reference.startswith("СЛУЖБЫ 112.docx#word/media/image")
for item in data.services)
@pytest.mark.parametrize("name", [
'Служба 101 (ГУ МЧС России по г.Москве, ГКУ "Пожарно спасательный центр" ОДС)',
'Служба 104 (АО "МОСГАЗ" Диспетчерское управление)',
"ЦЭМП",
'ЦОДД (ГКУ "Центр организации дорожного движения")',
'ЭВАЖД (ГБУ "Учреждение по эксплуатации высотных административных и жилых домов")',
])
def test_rows_checked_against_our_screenshots_are_verbatim(name):
assert name in ekp.catalog_names()
def test_classifier_services_and_notify_lists_are_untouched():
"""Индексы служб классификатора — основа списков оповещения 96 сценариев."""
raw = json.loads(ekp.DATA.read_text(encoding="utf-8"))
assert [item.name for item in ekp.services()] == [item["name"] for item in raw["services"]]
assert len(ekp.services()) == 61
def test_operator_adds_catalog_services_without_changing_the_notify_list():
assert "notify_extra" in EDITABLE_KIO_FIELDS
card = KIO(notify=["Служба 101"])
updated = apply_patch(card, {"notify_extra": ["ФСБ", "ЦЭМП", "ФСБ"], "notify": []})
assert updated.notify_extra == ["ФСБ", "ЦЭМП"]
assert updated.notify == ["Служба 101"], "автоматический список оператор не правит"
with pytest.raises(PatchRejected):
apply_patch(card, {"notify_extra": ["Служба, которой нет"]})
with pytest.raises(ValidationError):
apply_patch(card, {"notify_extra": [item.name for item in ekp.catalog().services[:21]]})
def test_service_already_in_notify_is_not_added_twice():
"""ЦЭМП, ФСБ, МОЭК, Метро, Мосгортранс есть и в каталоге, и в классификаторе."""
card = KIO(notify=["ЦЭМП"])
updated = apply_patch(card, {"notify_extra": ["ЦЭМП", "ФСБ"]})
assert updated.notify_extra == ["ФСБ"]
def test_saved_card_with_renamed_service_still_loads():
"""Каталог проверяется на входе kio.patch: правка названия в каталоге
не должна ломать восстановление снимков и сданных карточек."""
card = KIO.model_validate({"notify_extra": ["Прежнее название службы"]})
assert card.notify_extra == ["Прежнее название службы"]
def test_added_services_do_not_change_the_score_against_the_reference():
library = Path(__file__).resolve().parents[2] / "scenarios"
scenario = load_file(library / "fire-apartment-l2.yaml", library)
card = KIO(address="улица Ленина, 14", description="горит балкон")
plain = evaluate_card(scenario, card)
extended = evaluate_card(scenario, card.model_copy(update={"notify_extra": ["ФСБ"]}))
assert [(m.key, m.passed) for m in plain.metrics] == [(m.key, m.passed) for m in extended.metrics]
assert plain.score == extended.score
def test_catalog_is_served_like_the_classifier():
with TestClient(app) as client:
response = client.get("/api/ekp/services")
assert response.status_code == 200
body = response.json()
assert len(body["services"]) == 209
assert {"id", "name", "organization", "service_level", "source_reference"} <= body["services"][0].keys()
@pytest.fixture
def lesson(monkeypatch):
sent = []
monkeypatch.setattr(hub, "store", MemorySessionStore())
monkeypatch.setattr(hub, "to_trainee", lambda _session_id, event: sent.append(event))
monkeypatch.setattr(hub, "to_observers", lambda *_args: None)
state = SessionState(session_id=uuid4(), scenario_id="test", scenario_title="Тест",
level="L1", mode=SessionMode.TRAINING)
hub.register(state)
def patch(fields):
sent.clear()
async def run():
async with hub.operation(state.session_id):
await call_ws._handle(state.session_id, state, KioPatchIn(fields=fields))
asyncio.run(run())
return list(sent)
return state, patch
def test_unknown_service_in_kio_patch_is_refused_without_failing_the_lesson(lesson):
state, patch = lesson
sent = patch({"notify_extra": ["Служба, которой нет"]})
assert isinstance(sent[0], ErrorEvent) and "не принято" in sent[0].message
assert state.kio.notify_extra == []
sent = patch({"notify_extra": ["ФСБ"]})
assert state.kio.notify_extra == ["ФСБ"]
def test_refused_kio_patch_returns_server_values_to_drop_pending(lesson):
"""Пакет отклоняется целиком — фронт должен получить серверные значения
всех его полей с `auto`, иначе pending висит до следующего kio.state."""
state, patch = lesson
patch({"address": "улица Ленина, 14", "notify_extra": ["ФСБ"]})
sent = patch({"address": "Тверская, 1", "fire.floors": 5, "notify_extra": ["ФСБ", "Служба, которой нет"]})
assert isinstance(sent[0], ErrorEvent)
assert isinstance(sent[1], KioPatchOut) and sent[1].source is PatchSource.AUTO
assert sent[1].fields == {"address": "улица Ленина, 14", "fire.floors": None, "notify_extra": ["ФСБ"]}
assert state.kio.address == "улица Ленина, 14"
def test_cleaned_notify_extra_is_sent_back_as_auto(lesson):
state, patch = lesson
state.kio = state.kio.model_copy(update={"notify": ["ЦЭМП"]})
sent = patch({"notify_extra": ["ЦЭМП", "ФСБ", "ФСБ"]})
auto = [event for event in sent if isinstance(event, KioPatchOut) and event.source is PatchSource.AUTO]
assert auto and auto[-1].fields["notify_extra"] == ["ФСБ"]
assert state.kio.notify_extra == ["ФСБ"]

View file

@ -12,12 +12,13 @@ from app.domain.events import (
SessionMode, SessionMode,
) )
from app.domain.kio import KIO from app.domain.kio import KIO
from app.domain.statuses import PhoneCallPending, ServiceStatus from app.domain.statuses import PhoneCallPending, PhoneReportRecord, ServiceStatus
from app.domain.taxonomy import ErrorCode, Finding, FindingSource from app.domain.taxonomy import ErrorCode, Finding, FindingSource
from app.domain.timers import TimerCode from app.domain.timers import TimerCode
from app.scenarios.loader import load_file from app.scenarios.loader import load_file
from app.session.checkpoint import dump_state, load_state from app.session.checkpoint import dump_state, load_state
from app.session.dds import prepare_handoff_queue, prepare_queue from app.session.dds import prepare_handoff_queue, prepare_queue
from app.session.finish import score_dds_card
from app.session.state import DdsCardRecord, SessionState, now_utc from app.session.state import DdsCardRecord, SessionState, now_utc
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
@ -85,6 +86,65 @@ def test_active_dds_session_round_trips_without_losing_work():
assert timer.limit_ms == 45_000 assert timer.limit_ms == 45_000
def test_report_reaction_keeps_times_and_limit_after_restore():
before = dds_state()
before.criteria.dds_report_reaction_enabled = True
before.criteria.dds_report_reaction_limit_seconds = 20
card = before.desk.active
service = before.card_services(card)[0]
card.phone_reports.append(PhoneReportRecord(
service=service, crew=card.crew_selected, phase="dispatched",
text="Бригада выехала", at=now_utc(),
))
card.set_service_status(service, ServiceStatus.RESPONDING,
"Основание: доклад.\nСведения: бригада выехала.")
card.phone_reports[0].at -= timedelta(seconds=70)
restored = load_state(dump_state(before), now_utc())
assert restored.criteria.dds_report_reaction_limit_seconds == 20
assert restored.criteria.dds_report_reaction_enabled
assert restored.desk.active.phone_reports == card.phone_reports
def reaction(state):
result = score_dds_card(state, state.desk.active)
metric = next(item for item in result.metrics if item.key == "dds_report_reaction")
findings = [item for item in result.findings if item.metric_key == "dds_report_reaction"]
return metric, findings
metric, findings = reaction(restored)
assert not metric.passed
assert metric.norm == "отметить статус по докладу за 20 с"
assert "70 с (+50 с сверх норматива)" in metric.fact
assert [item.code for item in findings] == [ErrorCode.D6]
assert reaction(restored) == reaction(before), "оценка после восстановления та же"
def test_report_reaction_default_off_keeps_status_completeness_d6():
state = dds_state()
card = state.desk.active
service = state.card_services(card)[0]
card.set_service_status(service, ServiceStatus.RESPONDING,
"Основание: доклад.\nСведения: бригада выехала.")
without_report = state.model_copy(deep=True)
card.phone_reports.append(PhoneReportRecord(
service=service, crew=card.crew_selected, phase="dispatched",
text="Бригада выехала", at=card.status_log[-1].at - timedelta(seconds=70),
))
result = score_dds_card(state, card)
baseline = score_dds_card(without_report, without_report.desk.active)
assert state.criteria.dds_report_reaction_enabled is False
assert result.score_auto == baseline.score_auto
assert [(item.code, item.metric_key, item.fact) for item in result.findings] == [
(item.code, item.metric_key, item.fact) for item in baseline.findings
]
assert not any(item.key == "dds_report_reaction" for item in result.metrics)
assert not any(item.metric_key == "dds_report_reaction" for item in result.findings)
assert any(item.code is ErrorCode.D6 for item in result.findings)
def completed_card() -> DdsCardRecord: def completed_card() -> DdsCardRecord:
return DdsCardRecord( return DdsCardRecord(
card_id=uuid4(), card_id=uuid4(),

View file

@ -0,0 +1,686 @@
"""Пауза занятия с пульта преподавателя (lct-39).
Часы — управляемый монотонный счётчик: тест продвигает их сам, а не спит,
проверяя ровно то, что требует карточка — пауза не считается в норматив,
переживает чекпойнт и пишется в аудит.
"""
import asyncio
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.api.http import sessions as sessions_http
from app.api.ws import control as control_ws
from app.api.ws import call as call_ws
from app.api.ws import station as station_ws
from app.domain.classifiers import DDSCode
from app.domain.events import (
CallbackDial,
CallEnded,
CallEndReason,
CardAck,
DdsDispatch,
DirectorInject,
ErrorEvent,
Exercise,
HintRequest,
KioPatchIn,
LessonCriteria,
Principal,
SelfAssessmentSubmit,
SessionMode,
SessionPause,
SessionPaused,
SessionResume,
TtsCancel,
TtsEnd,
)
from app.domain.roles import Role
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, 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
from app.session.timers import Timer
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
TEACHER = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
def _dds_state_with_active_card(clock, criteria_ms: int = 30_000):
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
state = SessionState(
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
scenario=scenario, owner_login="teacher",
)
state.timers.limits[TimerCode.DDS_ACK] = criteria_ms
from app.session.dds import prepare_queue
prepare_queue(state, [scenario.model_copy(deep=True)])
return state, state.desk.active
class FakeClock:
"""Управляемая замена `time.monotonic`: тест продвигает время явно."""
def __init__(self, start: float = 1_000.0) -> None:
self.now = start
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
@pytest.fixture
def clock(monkeypatch):
fake = FakeClock()
monkeypatch.setattr(timers_module.time, "monotonic", fake)
return fake
def test_timer_pause_freezes_elapsed_until_resume(clock):
timer = Timer(code=TimerCode.DDS_ACK)
timer.start(clock.now)
clock.advance(10)
timer.pause(clock.now)
frozen = timer.current_ms(clock.now)
clock.advance(60) # долгая пауза — часы идут, таймер не должен
assert timer.current_ms(clock.now) == frozen == 10_000
timer.resume(clock.now)
clock.advance(15)
assert timer.current_ms(clock.now) == 25_000
def test_session_pause_excludes_wait_from_dds_ack_deadline(clock):
"""Карточка: 30 с норматива. Пауза 60 с внутри окна не должна дать D1 —
отметка приходит на 25-й секунде активного времени, а не 85-й реальной."""
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
state = SessionState(
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
scenario=scenario, criteria=LessonCriteria(decision_time_limit_seconds=30),
)
state.timers.limits[TimerCode.DDS_ACK] = 30_000
from app.session.dds import prepare_queue
prepare_queue(state, [scenario.model_copy(deep=True)])
card = state.desk.active
clock.advance(10)
state.pause()
clock.advance(60)
state.resume()
clock.advance(15)
card.on_event("card.ack")
measured = card.timers.measured_ms(TimerCode.DDS_ACK)
assert measured == 25_000
assert measured <= card.timers.limits[TimerCode.DDS_ACK]
def test_pause_freezes_queue_arrival_and_resume_shifts_it(clock, monkeypatch):
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
second = scenario.model_copy(deep=True)
state = SessionState(
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
scenario=scenario,
)
from app.session.dds import prepare_queue
prepare_queue(state, [scenario, second], arrival_interval_seconds=30, max_waiting=3)
due_before = state.desk.next_arrival_at
assert due_before is not None
base = now_utc()
monkeypatch.setattr("app.session.state.now_utc", lambda: base)
state.pause()
idle = timedelta(minutes=1)
monkeypatch.setattr("app.session.state.now_utc", lambda: base + idle)
state.resume()
# Следующая карточка не должна поступить раньше, чем простояла пауза.
assert state.desk.next_arrival_at == due_before + idle
assert state.desk.deliver_due(due_before + timedelta(seconds=1)) == 0
def test_checkpoint_round_trip_keeps_pause_without_adding_downtime(clock):
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
state = SessionState(
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
scenario=scenario,
)
from app.session.dds import prepare_queue
prepare_queue(state, [scenario.model_copy(deep=True)])
card = state.desk.active
clock.advance(5)
state.pause()
saved_at = now_utc()
payload = dump_state(state)
# Процесс лежал минуту — по часам сервера, не по monotonic этого теста.
restored_at = saved_at + timedelta(minutes=1)
restored = load_state(payload, restored_at)
assert restored.paused is True
restored_card = restored.desk.active
restored_timer = restored_card.timers.timers[TimerCode.DDS_ACK]
# Простоя в счёт не пошло: элапсед остался тем же, что был на паузе.
assert restored_timer.elapsed_ms == card.timers.timers[TimerCode.DDS_ACK].elapsed_ms
assert restored_timer.started_at is None
# После takeover пауза всё ещё действует; лишь явное возобновление
# продолжает норматив с пяти секунд до прерывания.
restored.resume()
clock.advance(10)
assert restored_timer.current_ms(clock.now) == 15_000
def test_pause_and_resume_are_recorded_for_audit(monkeypatch):
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
state = SessionState(
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
scenario=scenario, owner_login="teacher",
)
from app.session.dds import prepare_queue
prepare_queue(state, [scenario.model_copy(deep=True)])
store = MemorySessionStore()
monkeypatch.setitem(hub._sessions, state.session_id, state)
monkeypatch.setattr(hub, "store", store)
async def run():
async with hub.operation(state.session_id):
await control_ws._command(state.session_id, SessionPause(), TEACHER)
async with hub.operation(state.session_id):
await control_ws._command(state.session_id, SessionResume(), TEACHER)
asyncio.run(run())
records = [record for _sid, batch in store.commits for record in batch]
assert any(isinstance(record, LessonPaused) for record in records)
assert any(isinstance(record, LessonResumed) for record in records)
assert state.paused is False
def test_dispatcher_metric_dds_ack_excludes_pause_from_elapsed(clock):
"""Регрессия: `dispatcher_metrics` считала `primary.at - dispatched_at` —
календарную разницу, которую пауза не трогает. Метрика должна брать то
же замороженное время, что и таймер на экране."""
state, card = _dds_state_with_active_card(clock)
services = state.card_services(card)
service = services[0]
clock.advance(10)
state.pause()
clock.advance(60)
state.resume()
clock.advance(15)
card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято")
card.on_event("card.ack")
metric = next(m for m in dispatcher_metrics(card, 30_000, services=services) if m.key == "dds_ack")
assert metric.passed is True
assert "25.0" in metric.fact
def test_ending_session_while_paused_finalizes_pause_and_unblocks_report(clock, monkeypatch):
"""Регрессия: занятие, завершённое прямо на паузе, оставляло `paused=True`
навсегда — самооценка и отчёт отклонялись баннером «Пауза», а простой,
не закрытый явным `session.resume`, не попадал в `total_paused_ms`."""
from app.session import finish as finish_module
async def no_coach(_metrics):
class NoCoaching:
def model_dump(self, **_kwargs):
return {}
return NoCoaching()
monkeypatch.setattr(finish_module, "coach", no_coach)
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
state = SessionState(
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.CALL,
scenario=scenario, owner_login="teacher",
)
store = MemorySessionStore()
monkeypatch.setattr(hub, "store", store)
monkeypatch.setitem(hub._sessions, state.session_id, state)
base = now_utc()
monkeypatch.setattr("app.session.state.now_utc", lambda: base)
clock.advance(5)
state.pause()
clock.advance(30) # преподаватель завершает занятие, не возобновив его
idle = timedelta(seconds=45)
monkeypatch.setattr("app.session.state.now_utc", lambda: base + idle)
events = []
async def run():
with hub.trainee(state.session_id) as queue:
async with hub.operation(state.session_id):
await end_session(state.session_id, state, CallEndReason.INSTRUCTOR)
async with hub.operation(state.session_id):
await call_ws._handle(
state.session_id, state,
SelfAssessmentSubmit(missed=[], comment="Проверил"),
)
while not queue.empty():
events.append(queue.get_nowait())
asyncio.run(run())
assert state.ended
assert state.paused is False
assert state.total_paused_ms >= 30_000
assert state.self_assessed
assert state.score is not None
assert state.score["full_report"]["total_paused_ms"] >= 30_000
assert any(isinstance(event, SessionPaused) and not event.paused for event in events)
# Снятие паузы приходит после `call.ended`: иначе клиент дошлёт отложенные
# на паузе правки в уже закрытое занятие.
kinds = [type(event) for event in events]
assert kinds.index(CallEnded) < kinds.index(SessionPaused)
assert any(isinstance(record, LessonResumed) for _sid, batch in store.commits for record in batch)
def test_dds_work_overdue_survives_pause():
"""Регрессия: `dds_work_overdue_cards` требовал `started_at is not None`,
который пауза обнуляет — уже случившееся нарушение исчезало из реестра."""
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
from app.session.dds import build_card
from app.session.timers import SessionTimers
limits = {**SessionTimers().limits, TimerCode.DDS_WORK: 1_000}
card = build_card(scenario, 0, limits)
timer = card.timers.timers.setdefault(TimerCode.DDS_WORK, Timer(code=TimerCode.DDS_WORK))
timer.start(time.monotonic() - 5.0) # уже превысил норматив в 1 с
timer.pause(time.monotonic())
overdue = (
timer.started_at is not None or timer.paused
) and not timer.stopped and timer.current_ms(time.monotonic()) > limits[TimerCode.DDS_WORK]
assert overdue is True
def test_active_registry_keeps_existing_overdue_during_pause(http_client, clock):
app.dependency_overrides[sessions_http.optional_session] = lambda: None
state, card = _dds_state_with_active_card(clock)
state.owner_login = "dev"
timer = card.timers.timers.setdefault(TimerCode.DDS_WORK, Timer(code=TimerCode.DDS_WORK))
card.timers.limits[TimerCode.DDS_WORK] = 1_000
timer.start(clock.now)
clock.advance(5)
state.pause()
hub._sessions[state.session_id] = state
try:
response = http_client.get("/api/sessions/active")
assert response.status_code == 200
row = next(item for item in response.json() if item["session_id"] == str(state.session_id))
assert row["dds_work_overdue_cards"] == 1
assert row["paused"] is True
finally:
hub._sessions.pop(state.session_id, None)
app.dependency_overrides.pop(sessions_http.optional_session, None)
@pytest.fixture
def http_client(monkeypatch):
async def audit_in_memory(*_args, **_kwargs):
return None
monkeypatch.setattr(sessions_http, "audit_required", audit_in_memory)
with TestClient(app) as test_client:
test_client.post("/api/auth/dev-token")
hub.store = MemorySessionStore()
yield test_client
def _wait(predicate, timeout: float = 3.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
value = predicate()
if value:
return value
time.sleep(0.02)
raise AssertionError("не дождались")
def test_paused_socket_drops_audio_instead_of_feeding_voice(http_client):
"""Регрессия: пауза блокировала только JSON-команды — бинарные аудиокадры
/ws/call всё равно доходили до счётчика и голосового контура."""
session_id = uuid4()
with http_client.websocket_connect(f"/ws/control/{session_id}") as control:
control.send_json({
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Иванов И.И.", "mode": "training",
})
_wait(lambda: hub.get(session_id))
state = hub.get(session_id)
with http_client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_bytes(b"\x00\x00" * 320)
_wait(lambda: state.audio_frames == 1)
control.send_json({"type": "session.pause"})
_wait(lambda: state.paused)
trainee.send_bytes(b"\x00\x00" * 320)
trainee.send_bytes(b"\x00\x00" * 320)
time.sleep(0.1) # кадры паузы не должны просочиться в счётчик
assert state.audio_frames == 1
def test_reconnect_to_call_replays_paused_banner(http_client):
"""Регрессия: переподключение к /ws/call не сообщало о текущей паузе —
баннер появлялся только после следующего `session.pause`."""
session_id = uuid4()
with http_client.websocket_connect(f"/ws/control/{session_id}") as control:
control.send_json({
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Иванов И.И.", "mode": "training",
})
_wait(lambda: hub.get(session_id))
control.send_json({"type": "session.pause"})
_wait(lambda: hub.get(session_id).paused)
with http_client.websocket_connect(f"/ws/call/{session_id}") as trainee:
messages = [trainee.receive_json() for _ in range(2)]
assert any(
message["type"] == "session.paused" and message["paused"]
for message in messages
)
control.send_json({"type": "session.resume"})
_wait(lambda: not hub.get(session_id).paused)
with http_client.websocket_connect(f"/ws/call/{session_id}") as trainee:
message = trainee.receive_json()
assert message == {"type": "session.paused", "paused": False}
@pytest.mark.asyncio
async def test_pause_cancels_inflight_voice_and_clears_buffer(monkeypatch):
from app.voice import pipeline
class FakeVad:
def __init__(self, *_args, **_kwargs):
self.reset_count = 0
def reset(self):
self.reset_count += 1
def push(self, _frame):
return []
monkeypatch.setattr(pipeline, "StreamingVad", FakeVad)
state, _card = _dds_state_with_active_card(lambda: 1_000.0)
events = []
audio = []
started = asyncio.Event()
release = asyncio.Event()
class Models:
vad_path = None
voice = pipeline.VoiceSession(
state.session_id, state, Models(), events.append, lambda _event: None, audio.append,
)
state.voice = voice
async def slow_synthesis(_text):
started.set()
await release.wait()
return b"\x00\x00" * 2_400
monkeypatch.setattr(voice, "synthesize", slow_synthesis)
try:
speaking = voice.speak("Алло, помогите!", state.persona.mood if state.persona else pipeline.Mood.PANIC)
await asyncio.wait_for(started.wait(), timeout=1)
voice._queue.put_nowait((b"old", time.monotonic()))
state.pause()
release.set()
with pytest.raises(asyncio.CancelledError):
await speaking
await asyncio.sleep(0)
assert any(isinstance(event, TtsCancel) and event.reason == "director" for event in events)
assert not any(isinstance(event, TtsEnd) for event in events)
assert audio == []
assert voice._queue.empty()
assert voice._vad.reset_count == 1
finally:
await voice.close()
def _call_state():
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
return SessionState(
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.CALL,
scenario=scenario, owner_login="teacher",
)
def _drain(queue) -> list:
events = []
while not queue.empty():
events.append(queue.get_nowait())
return events
def test_timer_start_on_pause_keeps_frozen_elapsed(clock):
"""Повторное событие запуска на паузе не должно пустить часы с нуля."""
timer = Timer(code=TimerCode.CALLBACK)
timer.start(clock.now)
clock.advance(4)
timer.pause(clock.now)
timer.start(clock.now)
clock.advance(120)
assert timer.paused is True
assert timer.current_ms(clock.now) == 4_000
def test_director_inject_rejected_while_paused(clock, monkeypatch):
"""Регрессия: обрыв связи на паузе запускал норматив обратного дозвона,
и весь простой уходил в него; курсант получал `call.ended` под баннером."""
state = _call_state()
monkeypatch.setattr(hub, "store", MemorySessionStore())
monkeypatch.setitem(hub._sessions, state.session_id, state)
state.on_event("call.incoming")
state.on_event("call.answer")
state.pause()
async def run():
with hub.observer(state.session_id) as observer, hub.trainee(state.session_id) as trainee:
async with hub.operation(state.session_id):
await control_ws._command(
state.session_id, DirectorInject(directive="line_dropped"), TEACHER,
)
return _drain(observer), _drain(trainee)
observed, to_trainee = asyncio.run(run())
clock.advance(120)
assert any(isinstance(event, ErrorEvent) for event in observed)
assert to_trainee == []
assert state.directives == []
assert TimerCode.CALLBACK not in state.timers.timers
assert state.timers.timers[TimerCode.INTERVIEW].paused is True
@pytest.mark.parametrize("event", [
KioPatchIn(fields={"address": "ул. Ленина, 1"}),
HintRequest(),
DdsDispatch(service=DDSCode.FIRE),
CallbackDial(),
], ids=lambda event: event.type)
def test_call_channel_rejects_trainee_actions_while_paused(clock, monkeypatch, event):
"""Истина — сервер: кнопки на фронте выключены, но команда с паузы
не должна ни менять карточку, ни останавливать норматив опроса."""
state = _call_state()
monkeypatch.setattr(hub, "store", MemorySessionStore())
monkeypatch.setitem(hub._sessions, state.session_id, state)
state.on_event("call.incoming")
state.on_event("call.answer")
clock.advance(5)
state.pause()
kio_before = state.kio.model_dump()
async def run():
with hub.trainee(state.session_id) as queue:
async with hub.operation(state.session_id):
await call_ws._handle(state.session_id, state, event)
return _drain(queue)
events = asyncio.run(run())
assert [type(item) for item in events] == [ErrorEvent]
assert state.kio.model_dump() == kio_before
interview = state.timers.timers[TimerCode.INTERVIEW]
assert interview.paused and not interview.stopped
assert TimerCode.DDS_ACK not in state.timers.timers
assert TimerCode.CALLBACK not in state.timers.timers
assert state.hints_shown == []
def test_station_channel_rejects_card_ack_while_paused(clock, monkeypatch):
state, card = _dds_state_with_active_card(clock)
monkeypatch.setattr(hub, "store", MemorySessionStore())
monkeypatch.setitem(hub._sessions, state.session_id, state)
clock.advance(5)
state.pause()
card_before = card.model_dump()
async def run():
with hub.station(state.session_id) as queue:
async with hub.operation(state.session_id):
await station_ws._handle(state.session_id, state, CardAck(comment="Принято"))
return _drain(queue)
events = asyncio.run(run())
assert [type(item) for item in events] == [ErrorEvent]
assert card.model_dump() == card_before
ack = card.timers.timers[TimerCode.DDS_ACK]
assert ack.paused and not ack.stopped
assert ack.current_ms(clock.now) == 5_000
def test_repeated_pause_and_resume_are_idempotent(clock, monkeypatch):
state, card = _dds_state_with_active_card(clock)
store = MemorySessionStore()
monkeypatch.setattr(hub, "store", store)
monkeypatch.setitem(hub._sessions, state.session_id, state)
base = now_utc()
monkeypatch.setattr("app.session.state.now_utc", lambda: base)
async def command(event):
async with hub.operation(state.session_id):
await control_ws._command(state.session_id, event, TEACHER)
async def run():
with hub.station(state.session_id) as queue:
clock.advance(5)
await command(SessionPause())
clock.advance(30)
await command(SessionPause()) # второе нажатие не сдвигает начало паузы
monkeypatch.setattr("app.session.state.now_utc", lambda: base + timedelta(seconds=60))
await command(SessionResume())
await command(SessionResume())
return _drain(queue)
events = asyncio.run(run())
clock.advance(10)
records = [record for _sid, batch in store.commits for record in batch]
assert sum(isinstance(record, LessonPaused) 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 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)
state.criteria.dds_report_reaction_enabled = True
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()
monkeypatch.setattr(hub, "store", store)
monkeypatch.setitem(hub._sessions, state.session_id, state)
state.end(CallEndReason.INSTRUCTOR)
async def run():
with hub.station(state.session_id) as queue:
async with hub.operation(state.session_id):
await control_ws._command(state.session_id, SessionPause(), TEACHER)
return _drain(queue)
events = asyncio.run(run())
assert state.paused is False
assert state.paused_at is None
assert events == []
assert not any(isinstance(record, LessonPaused) for _sid, batch in store.commits for record in batch)

View file

@ -0,0 +1,517 @@
"""Колонка сигналов реестра: очередь, повторные отказы, курсант не на связи."""
import asyncio
import time
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.engine.result import IteratorResult, SimpleResultMetaData
from starlette.websockets import WebSocketDisconnect
from app.api.auth import Principal
from app.api.http import sessions as sessions_http
from app.api.ws import session as session_ws
from app.config import get_settings
from app.db.models import Session
from app.domain.events import Exercise, SessionMode
from app.domain.roles import Role
from app.main import app
from app.session.checkpoint import dump_state, load_state
from app.session import hub as hub_module
from app.session.hub import SessionHub, hub
from app.session.state import SessionState
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()
before = set(hub._sessions)
try:
yield test_client
finally:
for session_id in set(hub._sessions) - before:
hub.drop(session_id)
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, scenario_ids=POOL):
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": scenario_ids,
})
wait_for(lambda: hub.get(session_id))
return session_id, control
def start_card_handoff(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": "card", "handoff_to_dds": True,
"scenario_ids": POOL,
})
wait_for(lambda: hub.get(session_id))
return session_id, control
def start_call(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": "call",
})
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 assigned_trainee(state, monkeypatch):
state.trainee_id = uuid4()
who = Principal(login="курсант", full_name="Курсант", role=Role.TRAINEE,
trainee_id=state.trainee_id)
monkeypatch.setattr(session_ws, "principal_of", lambda _ws: who)
def registry_time(monkeypatch, since, seconds):
class Clock(datetime):
@classmethod
def now(cls, tz=None):
return since + timedelta(seconds=seconds)
monkeypatch.setattr(sessions_http, "datetime", Clock)
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_declined_card_leaves_backlog(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)
first_card = state.desk.ordered()[0]
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
read_until(station, "station.state")
station.send_json({
"type": "card.status", "service": state.card_services(first_card)[0],
"status": "declined", "comment": "не наш адрес, передано в УК",
})
read_until(station, "station.state")
row = row_for(client, session_id)
assert row["dds_open_cards"] == 2, "отклонённая карточка ждёт «Следующей» на пульте"
assert "backlog" not 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_acceptance_clears_refusal_streak(client):
session_id, control = start_two_card_dds(
client, [*POOL, "t20-2-stroke"],
)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "station.state")
for card in state.desk.ordered()[:2]:
station.send_json({"type": "card.open", "card_id": str(card.card_id)})
read_until(station, "station.state")
station.send_json({
"type": "card.status", "service": state.card_services(card)[0],
"status": "declined", "comment": "Не наша территория, передано дежурному",
})
read_until(station, "station.state")
assert state.consecutive_refusals == 2
third = state.desk.ordered()[2]
station.send_json({"type": "card.open", "card_id": str(third.card_id)})
read_until(station, "station.state")
station.send_json({
"type": "card.status", "service": state.card_services(third)[0],
"status": "accepted", "comment": "Карточка принята диспетчером",
})
read_until(station, "station.state")
assert state.consecutive_refusals == 0
assert "refusals" not in signal_kinds(row_for(client, session_id))
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_signal_offline_when_station_socket_is_closed_past_the_window(client, monkeypatch):
session_id, control = start_two_card_dds(client)
try:
state = hub.get(session_id)
assigned_trainee(state, monkeypatch)
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
read_until(station, "station.state")
assert hub.station_connected(session_id)
assert not hub.station_connected(session_id)
disconnected_at = state.socket_last_seen_at
assert disconnected_at is not None
window = get_settings().signal_offline_window_seconds
registry_time(monkeypatch, disconnected_at, window - 1)
assert "offline" not in signal_kinds(row_for(client, session_id))
registry_time(monkeypatch, disconnected_at, window + 1)
row = row_for(client, session_id)
assert "offline" in signal_kinds(row)
assert state.socket_last_seen_at == disconnected_at, "GET реестра не должен менять занятие"
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_signal_offline_without_first_connection(client, monkeypatch):
session_id, control = start_two_card_dds(client)
try:
state = hub.get(session_id)
started = state.socket_last_seen_at
assert started is not None
registry_time(monkeypatch, started, get_settings().signal_offline_window_seconds + 1)
assert "offline" in signal_kinds(row_for(client, session_id))
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_call_socket_disconnection_triggers_offline(client, monkeypatch):
session_id, control = start_call(client)
try:
state = hub.get(session_id)
assigned_trainee(state, monkeypatch)
with client.websocket_connect(f"/ws/call/{session_id}") as call:
read_until(call, "call.incoming")
assert hub.trainee_connected(session_id)
assert not hub.trainee_connected(session_id)
disconnected_at = state.socket_last_seen_at
registry_time(monkeypatch, disconnected_at,
get_settings().signal_offline_window_seconds + 1)
assert "offline" in signal_kinds(row_for(client, session_id))
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_teacher_socket_does_not_hide_offline(client, monkeypatch):
session_id, control = start_two_card_dds(client)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "station.state")
assert not hub.station_connected(session_id)
registry_time(monkeypatch, state.socket_last_seen_at,
get_settings().signal_offline_window_seconds + 1)
assert "offline" in signal_kinds(row_for(client, session_id))
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_last_of_two_trainee_sockets_starts_offline_window(client, monkeypatch):
session_id, control = start_two_card_dds(client)
try:
state = hub.get(session_id)
assigned_trainee(state, monkeypatch)
with client.websocket_connect(f"/ws/station/{session_id}") as first:
read_until(first, "station.state")
with client.websocket_connect(f"/ws/station/{session_id}") as second:
read_until(second, "station.state")
assert hub.station_connected(session_id)
registry_time(monkeypatch, state.socket_last_seen_at,
get_settings().signal_offline_window_seconds + 1)
assert "offline" not in signal_kinds(row_for(client, session_id))
disconnected_at = state.socket_last_seen_at
registry_time(monkeypatch, disconnected_at,
get_settings().signal_offline_window_seconds + 1)
assert "offline" in signal_kinds(row_for(client, session_id))
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_offline_window_survives_checkpoint_restore(client, monkeypatch):
session_id, control = start_two_card_dds(client)
try:
state = hub.get(session_id)
assigned_trainee(state, monkeypatch)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "station.state")
disconnected_at = state.socket_last_seen_at
restored = load_state(dump_state(state), datetime.now(UTC))
hub.drop(session_id)
asyncio.run(hub._adopt(restored))
assert restored.socket_last_seen_at == disconnected_at
registry_time(monkeypatch, disconnected_at,
get_settings().signal_offline_window_seconds + 1)
assert "offline" in signal_kinds(row_for(client, session_id))
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_takeover_starts_window_when_socket_was_open(client, monkeypatch):
session_id, control = start_two_card_dds(client)
try:
state = hub.get(session_id)
assigned_trainee(state, monkeypatch)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "station.state")
assert state.socket_connected_at_checkpoint
snapshot = dump_state(state)
old_time = state.socket_last_seen_at
takeover_at = old_time + timedelta(minutes=10)
monkeypatch.setattr(hub_module, "now_utc", lambda: takeover_at)
restored = load_state(snapshot, datetime.now(UTC))
class CapturingStore(MemorySessionStore):
def __init__(self):
self.saved = None
async def commit(self, state, _records=()):
self.saved = dump_state(state)
store = CapturingStore()
local_hub = SessionHub(store)
try:
assert asyncio.run(local_hub._adopt(restored))
assert restored.socket_last_seen_at == takeover_at
assert not restored.socket_connected_at_checkpoint
assert datetime.fromisoformat(
store.saved["socket_last_seen_at"].replace("Z", "+00:00")
) == takeover_at
assert store.saved["socket_connected_at_checkpoint"] is False
finally:
local_hub.stop_ticker(session_id)
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_card_handoff_uses_trainee_station_presence(client, monkeypatch):
session_id, control = start_card_handoff(client)
try:
state = hub.get(session_id)
assigned_trainee(state, monkeypatch)
with monkeypatch.context() as clock:
with client.websocket_connect(f"/ws/call/{session_id}") as call:
read_until(call, "card.briefing")
call.send_json({"type": "card.submit"})
read_until(call, "call.ended")
handoff_at = state.socket_last_seen_at
clock.setattr(hub_module, "now_utc", lambda: handoff_at + timedelta(minutes=1))
assert state.socket_last_seen_at == handoff_at, "старый call-сокет не сбрасывает окно ДДС"
assert state.exercise.value == "card" and state.dds_phase
assert state.desk.scenarios
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "station.state")
registry_time(monkeypatch, state.socket_last_seen_at,
get_settings().signal_offline_window_seconds + 1)
row = row_for(client, session_id)
assert row["dds_open_cards"] > 0
assert "offline" not in signal_kinds(row)
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_checkpoint_on_another_node_marks_presence_unknown(client, monkeypatch):
session_id, control = start_two_card_dds(client)
try:
state = hub.get(session_id)
snapshot = dump_state(state)
checkpoint_at = datetime.now(UTC)
hub.drop(session_id)
row = Session(id=session_id, owner_login="dev",
live_state=snapshot, checkpoint_at=checkpoint_at)
class CheckpointDB:
async def scalars(self, _query):
return IteratorResult(SimpleResultMetaData(["Session"]), iter([(row,)])).scalars()
monkeypatch.setattr(sessions_http, "require", lambda _request, _role:
Principal(login="dev", full_name="Преподаватель",
role=Role.INSTRUCTOR))
rows = asyncio.run(sessions_http.active(None, db=CheckpointDB()))
row = next(item for item in rows if item.session_id == session_id)
assert row.presence_known is False
assert "offline" not in {item.kind for item in row.signals}
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_failed_presence_commit_does_not_leave_connected_socket():
class BrokenStore(MemorySessionStore):
async def commit(self, _state, _records=()):
raise OSError("test commit failure")
local_hub = SessionHub(BrokenStore())
state = local_hub.register(SessionState(
session_id=uuid4(), scenario_id="test", scenario_title="Тест", level="L1",
mode=SessionMode.TRAINING, exercise=Exercise.DDS,
))
async def connect():
async with local_hub.trainee_socket(state.session_id, station=True, trainee=True):
assert local_hub.is_lease_fenced(state.session_id)
asyncio.run(connect())
assert not local_hub.station_connected(state.session_id)
def test_failed_presence_commit_closes_station_with_fencing_code(client, monkeypatch):
class BrokenStore(MemorySessionStore):
async def commit(self, _state, _records=()):
raise OSError("test commit failure")
session_id, control = start_two_card_dds(client)
try:
assigned_trainee(hub.get(session_id), monkeypatch)
hub.store = BrokenStore()
with client.websocket_connect(f"/ws/station/{session_id}") as station:
event = station.receive_json()
assert event["type"] == "error" and event["code"] == "internal"
with pytest.raises(WebSocketDisconnect) as closed:
station.receive_json()
assert closed.value.code == 1012
assert not hub.station_connected(session_id)
finally:
control.__exit__(None, None, None)
def test_takeover_retries_failed_presence_checkpoint():
class FlakyStore(MemorySessionStore):
def __init__(self):
self.commits = 0
async def commit(self, _state, _records=()):
self.commits += 1
if self.commits == 1:
raise OSError("temporary database outage")
store = FlakyStore()
local_hub = SessionHub(store)
state = SessionState(
session_id=uuid4(), scenario_id="test", scenario_title="Тест", level="L1",
mode=SessionMode.TRAINING, exercise=Exercise.DDS,
socket_last_seen_at=datetime.now(UTC) - timedelta(minutes=10),
socket_connected_at_checkpoint=True,
)
async def recover():
assert not await local_hub._adopt(state)
assert local_hub.get(state.session_id) is None
await local_hub.maintain_lease()
assert local_hub.get(state.session_id) is state
assert store.commits == 2
await local_hub.shutdown()
asyncio.run(recover())

View file

@ -74,6 +74,18 @@ socket.receive({ type: "kio.patch", fields: {
}, source: "operator" }); }, source: "operator" });
socket = reconnect(socket); socket = reconnect(socket);
assert.deepEqual(socket.sent, [], "matching checkpoint echo clears the KIO patch"); assert.deepEqual(socket.sent, [], "matching checkpoint echo clears the KIO patch");
const refused = { type: "kio.patch", fields: { address: "ул. Новая, 3", notify_extra: ["Нет в каталоге"] } };
call.send(refused);
socket.receive({ type: "kio.patch", fields: { address: patch.fields.address, notify_extra: [] }, source: "auto" });
socket.sent = [];
call.send({ type: "kio.patch", fields: { floor: "2" } });
assert.deepEqual(socket.sent.at(-1).fields, { floor: "2" },
"server values after a refused patch acknowledge it: refused fields are not merged into the next edit");
socket.receive({ type: "kio.patch", fields: { floor: "2" }, source: "operator" });
socket = reconnect(socket);
assert.deepEqual(socket.sent, [], "nothing left to replay after refusal and the next echo");
const refusedCard = applyPatch(edit(empty, "notify_extra", ["Нет в каталоге"]), { notify_extra: [] }, "auto");
assert.deepEqual(refusedCard.pending, {}, "server value after refusal drops the pending edit");
const edited = edit(empty, "coords", { latitude: 55.7, longitude: 37.6 }); const edited = edit(empty, "coords", { latitude: 55.7, longitude: 37.6 });
assert.deepEqual(applyPatch(edited, { coords: { longitude: 37.6, latitude: 55.7 } }, "operator").pending, {}); assert.deepEqual(applyPatch(edited, { coords: { longitude: 37.6, latitude: 55.7 } }, "operator").pending, {});
assert.deepEqual(applyState(edited, { coords: { longitude: 37.6, latitude: 55.7 } }).pending, {}); assert.deepEqual(applyState(edited, { coords: { longitude: 37.6, latitude: 55.7 } }).pending, {});

View file

@ -50,6 +50,7 @@ export function useCall(sessionId: string | null) {
const [scoreReady, setScoreReady] = useState(false); const [scoreReady, setScoreReady] = useState(false);
const { report, error: reportError } = useSessionReport(sessionId, scoreReady); const { report, error: reportError } = useSessionReport(sessionId, scoreReady);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [paused, setPaused] = useState(false);
const channel = useRef<ReturnType<typeof callChannel> | null>(null); const channel = useRef<ReturnType<typeof callChannel> | null>(null);
const capture = useRef<Capture | null>(null); const capture = useRef<Capture | null>(null);
@ -58,6 +59,8 @@ export function useCall(sessionId: string | null) {
const outbox = useRef<Record<string, unknown>>({}); const outbox = useRef<Record<string, unknown>>({});
const patchTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const patchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const textMode = useRef(false); const textMode = useRef(false);
const pausedRef = useRef(false);
const endedRef = useRef(false);
const append = useCallback((line: Line) => { const append = useCallback((line: Line) => {
setLines((prev) => { setLines((prev) => {
@ -68,22 +71,14 @@ export function useCall(sessionId: string | null) {
useEffect(() => { useEffect(() => {
if (!sessionId) return; if (!sessionId) return;
endedRef.current = false;
const ch = callChannel(sessionId, { const ch = callChannel(sessionId, {
onStatus: (next) => { onStatus: (next) => {
setStatus(next); setStatus(next);
if (next === "open") {
// Браузер не подтверждает доставку WebSocket-кадра. Всё, что ещё
// отмечено pending и не пришло эхом от сервера, повторяем после
// reconnect: kio.patch идемпотентен и не создаёт двойных действий.
queueMicrotask(() => setCard((current) => {
if (Object.keys(current.pending).length) {
channel.current?.send({ type: "kio.patch", fields: current.pending });
}
return current;
}));
}
}, },
onBinary: (frame) => audio.current?.playback.enqueue(frame), onBinary: (frame) => {
if (!pausedRef.current) audio.current?.playback.enqueue(frame);
},
onEvent: (event: ServerToTrainee) => { onEvent: (event: ServerToTrainee) => {
if (event.type === "text.turn.accepted") { if (event.type === "text.turn.accepted") {
append({ speaker: "operator", text: event.text }); append({ speaker: "operator", text: event.text });
@ -140,6 +135,7 @@ export function useCall(sessionId: string | null) {
setTimers(event.timers); setTimers(event.timers);
break; break;
case "call.ended": case "call.ended":
endedRef.current = true;
setPhase("ended"); setPhase("ended");
audio.current?.ambience.stop(); audio.current?.ambience.stop();
if (textMode.current) { setCardSubmitted(true); break; } if (textMode.current) { setCardSubmitted(true); break; }
@ -153,6 +149,33 @@ export function useCall(sessionId: string | null) {
case "score.ready": case "score.ready":
setScoreReady(true); setScoreReady(true);
break; break;
case "session.paused":
pausedRef.current = event.paused;
setPaused(event.paused);
if (event.paused) {
audio.current?.playback.flush();
setCallerSpeaking(false);
if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = null;
} else if (endedRef.current) {
// Занятие завершили прямо на паузе: сервер снимает её после
// `call.ended`, и отложенные правки уже не войдут в оценку.
outbox.current = {};
} else {
// При переподключении сервер первым присылает состояние паузы.
// Только после этого повторяем правки, ожидающие подтверждения.
queueMicrotask(() => setCard((current) => {
if (Object.keys(current.pending).length) {
channel.current?.send({ type: "kio.patch", fields: current.pending });
}
if (Object.keys(outbox.current).length) {
channel.current?.send({ type: "kio.patch", fields: outbox.current });
outbox.current = {};
}
return current;
}));
}
break;
case "error": case "error":
setError(event.message); setError(event.message);
break; break;
@ -170,13 +193,15 @@ export function useCall(sessionId: string | null) {
}, [sessionId, append]); }, [sessionId, append]);
const answer = useCallback(async () => { const answer = useCallback(async () => {
if (pausedRef.current) return;
try { try {
setError(null); setError(null);
// Контекст создаётся по нажатию: браузер не даёт играть звук без действия человека. // Контекст создаётся по нажатию: браузер не даёт играть звук без действия человека.
const context = new AudioContext({ sampleRate: CALLER_RATE }); const context = new AudioContext({ sampleRate: CALLER_RATE });
audio.current = { context, playback: new Playback(context), ambience: new Ambience(context) }; audio.current = { context, playback: new Playback(context), ambience: new Ambience(context) };
capture.current = await startCapture((frame) => { const startedCapture = await startCapture((frame) => {
if (pausedRef.current) return;
const playback = audio.current?.playback; const playback = audio.current?.playback;
// Перебивание: фронт гасит звук мгновенно, сервер подтвердит `tts.cancel`. // Перебивание: фронт гасит звук мгновенно, сервер подтвердит `tts.cancel`.
if (gate.current.push(frame) && playback?.speaking) { if (gate.current.push(frame) && playback?.speaking) {
@ -185,6 +210,13 @@ export function useCall(sessionId: string | null) {
} }
channel.current?.sendBinary(frame); channel.current?.sendBinary(frame);
}); });
if (pausedRef.current) {
await startedCapture.stop();
await context.close();
audio.current = null;
return;
}
capture.current = startedCapture;
setMicOn(true); setMicOn(true);
channel.current?.send({ type: "call.answer" }); channel.current?.send({ type: "call.answer" });
} catch (err) { } catch (err) {
@ -193,36 +225,45 @@ export function useCall(sessionId: string | null) {
}, []); }, []);
const hangup = useCallback(() => { const hangup = useCallback(() => {
if (pausedRef.current) return;
channel.current?.send({ type: "call.hangup" }); channel.current?.send({ type: "call.hangup" });
void capture.current?.stop(); void capture.current?.stop();
capture.current = null; capture.current = null;
setMicOn(false); setMicOn(false);
}, []); }, []);
const hint = useCallback(() => channel.current?.send({ type: "hint.request" }), []); const hint = useCallback(() => {
if (!pausedRef.current) channel.current?.send({ type: "hint.request" });
}, []);
const patchKio = useCallback((path: string, value: unknown) => { const patchKio = useCallback((path: string, value: unknown) => {
if (pausedRef.current) return;
setCard((prev) => edit(prev, path, value)); setCard((prev) => edit(prev, path, value));
outbox.current[path] = value; outbox.current[path] = value;
if (patchTimer.current) clearTimeout(patchTimer.current); if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = setTimeout(() => { patchTimer.current = setTimeout(() => {
const fields = outbox.current; const fields = outbox.current;
outbox.current = {}; outbox.current = {};
if (Object.keys(fields).length) channel.current?.send({ type: "kio.patch", fields }); if (pausedRef.current) {
outbox.current = { ...fields, ...outbox.current };
} else if (Object.keys(fields).length) channel.current?.send({ type: "kio.patch", fields });
}, PATCH_DEBOUNCE_MS); }, PATCH_DEBOUNCE_MS);
}, []); }, []);
const dispatch = useCallback((service?: DDSCode) => { const dispatch = useCallback((service?: DDSCode) => {
if (pausedRef.current) return;
channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" }); channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" });
}, []); }, []);
const sendText = useCallback((text: string) => { const sendText = useCallback((text: string) => {
if (pausedRef.current) return false;
const clean = text.trim(); const clean = text.trim();
if (!clean || clean.length > 1000) return false; if (!clean || clean.length > 1000) return false;
return Boolean(channel.current?.send({ type: "text.turn", text: clean })); return Boolean(channel.current?.send({ type: "text.turn", text: clean }));
}, []); }, []);
const submitCard = useCallback(() => { const submitCard = useCallback(() => {
if (pausedRef.current) return;
if (patchTimer.current) clearTimeout(patchTimer.current); if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = null; patchTimer.current = null;
const fields = outbox.current; const fields = outbox.current;
@ -243,17 +284,19 @@ export function useCall(sessionId: string | null) {
* Отдельное действие, а не «положил трубку»: система должна отличить * Отдельное действие, а не «положил трубку»: система должна отличить
* осознанное решение от брошенного вызова (docs/spec/TICKETS.md). */ * осознанное решение от брошенного вызова (docs/spec/TICKETS.md). */
const resolve = useCallback((outcome: Outcome, comment: string) => { const resolve = useCallback((outcome: Outcome, comment: string) => {
if (pausedRef.current) return;
channel.current?.send({ type: "call.resolve", outcome, comment }); channel.current?.send({ type: "call.resolve", outcome, comment });
}, []); }, []);
const submitSelfAssessment = useCallback((missed: string[], comment: string) => { const submitSelfAssessment = useCallback((missed: string[], comment: string) => {
if (pausedRef.current) return;
channel.current?.send({ type: "self_assessment.submit", missed, comment }); channel.current?.send({ type: "self_assessment.submit", missed, comment });
setSelfAssessed(true); setSelfAssessed(true);
}, []); }, []);
return { return {
resolve, resolve,
status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, card, status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, paused, card,
checklist, selfAssessed, scoreReady, report, reportError, checklist, selfAssessed, scoreReady, report, reportError,
answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment, sendText, answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment, sendText,
}; };

View file

@ -20,6 +20,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
const improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3); const improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3);
const metricTitles = new Map(report.metrics.map((metric) => [metric.key, metric.title])); const metricTitles = new Map(report.metrics.map((metric) => [metric.key, metric.title]));
const coaching = report.ai_coaching?.status === "ready" ? report.ai_coaching.recommendations ?? [] : []; const coaching = report.ai_coaching?.status === "ready" ? report.ai_coaching.recommendations ?? [] : [];
// Вверх: короткая пауза не должна выглядеть как «0 с», а без пауз строки нет.
const pausedSeconds = Math.ceil((report.total_paused_ms ?? 0) / 1000);
return ( return (
<section className={big ? "debrief debrief-big" : "debrief"}> <section className={big ? "debrief debrief-big" : "debrief"}>
@ -37,12 +39,14 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
<article className="debrief-stat"> <article className="debrief-stat">
<span>Автоматическая оценка</span><strong>{report.score_auto.toFixed(0)}<small> из 100</small></strong> <span>Автоматическая оценка</span><strong>{report.score_auto.toFixed(0)}<small> из 100</small></strong>
<small>{report.overridden_by ? "до корректировки преподавателя" : "расчёт по критериям занятия"}</small> <small>{report.overridden_by ? "до корректировки преподавателя" : "расчёт по критериям занятия"}</small>
{report.score_reviewed != null && <small>после разбора отметок: {report.score_reviewed.toFixed(0)}</small>}
</article> </article>
<article className="debrief-stat"> <article className="debrief-stat">
<span>Упражнение</span><strong>{report.exercise === "dds" ? "ДДС" : report.exercise === "card" ? "КИО" : report.exercise === "call" ? "Вызов 112" : "112 → ДДС"}</strong> <span>Упражнение</span><strong>{report.exercise === "dds" ? "ДДС" : report.exercise === "card" ? "КИО" : report.exercise === "call" ? "Вызов 112" : "112 → ДДС"}</strong>
<small>{report.attempt ? `Попытка ${report.attempt}` : "Завершённое упражнение"}</small> <small>{report.attempt ? `Попытка ${report.attempt}` : "Завершённое упражнение"}</small>
</article> </article>
</div> </div>
{pausedSeconds > 0 && <p className="ref">Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.</p>}
<details className="debrief-tech-details"> <details className="debrief-tech-details">
<summary>Служебные сведения</summary> <summary>Служебные сведения</summary>
<p>Идентификатор сценария: {report.scenario_id} · номер занятия: {report.session_id}</p> <p>Идентификатор сценария: {report.scenario_id} · номер занятия: {report.session_id}</p>
@ -77,6 +81,11 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}</>} {` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}</>}
{report.exercise === "card" && <>Норматив заполнения КИО — {report.exercise === "card" && <>Норматив заполнения КИО —
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>} {` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>}
{/* 112 → ДДС приходит как exercise "card": работу ДДС выдают карточки пульта. */}
{(report.exercise === "dds" || Boolean(report.card_results?.length)) && <>; статус по докладу бригады —
{report.criteria.dds_report_reaction_enabled
? ` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с`
: " не применяется"}</>}
{report.exercise && report.exercise !== "dds" && <> {report.exercise && report.exercise !== "dds" && <>
; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку ; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку
</>}. </>}.
@ -89,7 +98,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{Boolean(report.card_results?.length) && <section className="debrief-card-results"> {Boolean(report.card_results?.length) && <section className="debrief-card-results">
<h3>Карточки занятия ({report.card_results!.length})</h3> <h3>Карточки занятия ({report.card_results!.length})</h3>
{report.card_results!.map((card, index) => <details key={card.card_id}> {report.card_results!.map((card, index) => <details key={card.card_id}>
<summary>Карточка {index + 1}: {card.title || card.scenario_id} · {card.score_auto.toFixed(0)} / 100 <summary>Карточка {index + 1}: {card.title || card.scenario_id} · {(card.score_reviewed ?? card.score_auto).toFixed(0)} / 100
{card.score_reviewed != null && ` после разбора (автооценка ${card.score_auto.toFixed(0)})`}
{card.address ? ` · ${card.address}` : ""}</summary> {card.address ? ` · ${card.address}` : ""}</summary>
{card.managed_service && <p><strong>Ответственная служба:</strong> {card.managed_service}</p>} {card.managed_service && <p><strong>Ответственная служба:</strong> {card.managed_service}</p>}
<p><strong>Ответ диспетчера:</strong> {card.reply_text || "не внесён"}</p> <p><strong>Ответ диспетчера:</strong> {card.reply_text || "не внесён"}</p>
@ -144,6 +154,10 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
<td> <td>
{finding.summary} {finding.summary}
<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div> <div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div>
{finding.source === "instructor" && <div className="ref">отметка преподавателя {finding.author ?? ""}</div>}
{(finding.reviews ?? []).map((review, reviewIndex) => <div className="ref" key={reviewIndex}>
{review.decision === "dismissed" ? "снята" : "подтверждена"} преподавателем {review.author}: {review.reason}
</div>)}
</td> </td>
</tr> </tr>
))} ))}

View file

@ -0,0 +1,162 @@
// Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку.
//
// Балл пересчитывает сервер теми же метриками и весами (lct-35): здесь только
// решение и его причина. Причина обязательна — она остаётся в отчёте и в CSV/PDF.
import { useRef, useState } from "react";
import type { ErrorCode, FindingDecision, SessionReport } from "@/shared/types/generated";
const DDS_CODES: ErrorCode[] = ["D1", "D2", "D3", "D4", "D5", "D6"];
const CALL_CODES: ErrorCode[] = ["E1", "E2", "E3", "E4", "E5", "E6"];
async function send(url: string, body: unknown): Promise<SessionReport> {
const response = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await response.json().catch(() => null);
if (!response.ok) {
const detail = data && typeof data === "object" && "detail" in data ? data.detail : response.statusText;
throw new Error(typeof detail === "string" ? detail : "Не удалось сохранить решение.");
}
return data as SessionReport;
}
export function FindingReview({ report, onChange }: {
report: SessionReport;
onChange: (report: SessionReport) => void;
}) {
const cards = report.card_results ?? [];
const codes = report.exercise === "dds" ? DDS_CODES
: cards.length ? [...CALL_CODES, ...DDS_CODES] : CALL_CODES;
const [reasons, setReasons] = useState<Record<number, string>>({});
const [code, setCode] = useState<ErrorCode>(codes[0]);
// В ДДС все метрики принадлежат карточкам очереди. В связке 112 → ДДС
// E-коды относятся к занятию, а D-код без карточки — к живой карточке звонка.
const [card, setCard] = useState(report.exercise === "dds" && cards.length ? "1" : "");
const cardChoice = cards.length > 0 && (report.exercise === "dds" || code.startsWith("D"));
// Один ключ на отметку, пока она не сохранена: повтор после сбоя сети
// не добавит её второй раз.
const pendingId = useRef<string | null>(null);
const [fact, setFact] = useState("");
const [norm, setNorm] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const base = `/api/sessions/${report.session_id}/findings`;
async function run(action: () => Promise<SessionReport>) {
setBusy(true);
setError("");
try {
onChange(await action());
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Не удалось сохранить решение.");
} finally {
setBusy(false);
}
}
function decide(index: number, decision: FindingDecision) {
const reason = (reasons[index] ?? "").trim();
if (!reason) {
setError("Укажите причину решения по отметке.");
return;
}
void run(async () => {
const updated = await send(`${base}/${index}/review`, { decision, reason });
setReasons((current) => ({ ...current, [index]: "" }));
return updated;
});
}
function addFinding() {
if (!fact.trim() || !norm.trim()) {
setError("Для отметки нужны факт и норма.");
return;
}
pendingId.current ??= crypto.randomUUID();
const clientId = pendingId.current;
void run(async () => {
const updated = await send(base, {
code, fact: fact.trim(), norm: norm.trim(), card: cardChoice && card ? Number(card) : null,
client_id: clientId,
});
pendingId.current = null;
setFact("");
setNorm("");
return updated;
});
}
return (
<section className="debrief finding-review">
<h2>Разбор по отметкам</h2>
<p className="ref">
Снятая отметка не штрафует свою метрику, добавленная — штрафует. Балл пересчитывается
по тем же критериям и весам; автоматическая оценка ({report.score_auto.toFixed(1)}) сохраняется рядом.
{report.score_reviewed != null && ` После разбора: ${report.score_reviewed.toFixed(1)}.`}
</p>
<table className="grid">
<tbody>
{report.findings.map((finding, index) => (
<tr key={index}>
<th>{finding.code}</th>
<td>
{finding.summary}
<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div>
{(finding.reviews ?? []).map((review, reviewIndex, all) => <div key={reviewIndex}
className={reviewIndex < all.length - 1 || review.decision === "dismissed" ? "ref" : "state-violated"}>
{review.decision === "dismissed" ? "Снята" : "Подтверждена"} ({review.author},{" "}
{new Date(review.at).toLocaleString("ru-RU")}): {review.reason}
</div>)}
<div className="finding-review-actions">
<input aria-label={`Причина решения по отметке ${index + 1}`} maxLength={1000}
placeholder="Причина решения" value={reasons[index] ?? ""}
onChange={(event) => setReasons((current) => ({ ...current, [index]: event.target.value }))} />
<button type="button" disabled={busy} onClick={() => decide(index, "confirmed")}>Подтвердить</button>
<button type="button" disabled={busy} onClick={() => decide(index, "dismissed")}>Снять</button>
</div>
</td>
</tr>
))}
{report.findings.length === 0 && <tr><td colSpan={2}>Автоматических отметок нет.</td></tr>}
</tbody>
</table>
<h3>Добавить отметку</h3>
<div className="finding-review-form">
<label>Код
<select value={code} onChange={(event) => {
pendingId.current = null;
setCode(event.target.value as ErrorCode);
}}>
{codes.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
{cardChoice && <label>Карточка
<select value={card} onChange={(event) => { pendingId.current = null; setCard(event.target.value); }}>
{report.exercise !== "dds" && <option value="">занятие (без карточки)</option>}
{cards.map((item, index) => <option key={item.card_id} value={String(index + 1)}>
{index + 1}: {item.title || item.scenario_id}
</option>)}
</select>
</label>}
<label>Факт
<input maxLength={1000} value={fact} onChange={(event) => { pendingId.current = null; setFact(event.target.value); }}
placeholder="Что сделал курсант" />
</label>
<label>Норма
<input maxLength={1000} value={norm} onChange={(event) => { pendingId.current = null; setNorm(event.target.value); }}
placeholder="Как требовалось" />
</label>
<button type="button" disabled={busy || !fact.trim() || !norm.trim()} onClick={addFinding}>
{busy ? "Сохраняем…" : "Добавить отметку"}
</button>
</div>
{error && <p className="violated" role="alert">{error}</p>}
</section>
);
}

View file

@ -32,6 +32,7 @@ const DDS_METRICS: MetricOption[] = [
{ key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." }, { key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." },
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." }, { key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
{ key: "dds_work_time", label: "Время отработки карточки", hint: "Соблюдение лимита полного цикла карточки." }, { key: "dds_work_time", label: "Время отработки карточки", hint: "Соблюдение лимита полного цикла карточки." },
{ key: "dds_report_reaction", label: "Реакция на доклад бригады", hint: "Статус по докладу бригады не позже лимита занятия." },
]; ];
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [ const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
@ -39,13 +40,19 @@ const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
"required_fields", "description_grammar", "card_fill_time", "required_fields", "description_grammar", "card_fill_time",
].includes(key)); ].includes(key));
const CALL_METRICS = OPERATOR_METRICS.filter(({ key }) => key !== "card_fill_time"); const CALL_METRICS = OPERATOR_METRICS.filter(({ key }) => key !== "card_fill_time");
// Метрика появляется, только когда ручной отметке преподавателя нечего провалить.
const REVIEW_METRICS: MetricOption[] = [
{ key: "instructor_finding", label: "Отметка преподавателя", hint: "Ручная отметка на разборе без связанной метрики (например, D4)." },
];
export function ScoreWeightsEditor({ export function ScoreWeightsEditor({
exercise, exercise,
reportReactionEnabled,
values, values,
onChange, onChange,
}: { }: {
exercise: LessonExercise; exercise: LessonExercise;
reportReactionEnabled: boolean;
values: Record<string, number>; values: Record<string, number>;
onChange: (weights: Record<string, number>) => void; onChange: (weights: Record<string, number>) => void;
}) { }) {
@ -57,6 +64,12 @@ export function ScoreWeightsEditor({
: exercise === "card_dds" : exercise === "card_dds"
? [["Карточка КИО", CARD_METRICS], ["Реагирование ДДС", DDS_METRICS]] ? [["Карточка КИО", CARD_METRICS], ["Реагирование ДДС", DDS_METRICS]]
: [["Опрос 112", CALL_METRICS], ["Реагирование ДДС", DDS_METRICS]]; : [["Опрос 112", CALL_METRICS], ["Реагирование ДДС", DDS_METRICS]];
groups.push(["Разбор", REVIEW_METRICS]);
if (!reportReactionEnabled) {
for (const group of groups) {
group[1] = group[1].filter(({ key }) => key !== "dds_report_reaction");
}
}
function setWeight(key: string, raw: string) { function setWeight(key: string, raw: string) {
const next = { ...values }; const next = { ...values };

View file

@ -63,7 +63,9 @@ export function useControl(sessionId: string | null) {
); );
const stop = useCallback(() => channel.current?.socket.send({ type: "session.stop" }), []); const stop = useCallback(() => channel.current?.socket.send({ type: "session.stop" }), []);
const pause = useCallback(() => channel.current?.socket.send({ type: "session.pause" }), []);
const resume = useCallback(() => channel.current?.socket.send({ type: "session.resume" }), []);
const playReference = useCallback(() => channel.current?.socket.send({ type: "reference.play" }), []); const playReference = useCallback(() => channel.current?.socket.send({ type: "reference.play" }), []);
return { status, openSessionId, start, note, inject, stop, playReference }; return { status, openSessionId, start, note, inject, stop, pause, resume, playReference };
} }

View file

@ -1,7 +1,9 @@
// Карточка КИО. Один и тот же компонент рисуется у курсанта, на пульте, // Карточка КИО. Один и тот же компонент рисуется у курсанта, на пульте,
// на внешнем мониторе и на АРМ ДДС — там в режиме `readOnly`. // на внешнем мониторе и на АРМ ДДС — там в режиме `readOnly`.
import { useIncidentGroups, useSigns } from "@/shared/api/http"; import { useState } from "react";
import { useIncidentGroups, useServiceCatalog, useSigns } from "@/shared/api/http";
import { type CardState, display } from "./merge"; import { type CardState, display } from "./merge";
import { type Field, type FieldGroup, visibleGroups } from "./fields"; import { type Field, type FieldGroup, visibleGroups } from "./fields";
@ -17,6 +19,7 @@ interface Props {
export function KioCard({ state, required = [], readOnly = false, referenceArm = false, onChange }: Props) { export function KioCard({ state, required = [], readOnly = false, referenceArm = false, onChange }: Props) {
const dds = display(state, "dds") as string | null; const dds = display(state, "dds") as string | null;
const incidentGroup = display(state, "incident_group") as number | null; const incidentGroup = display(state, "incident_group") as number | null;
const notify = (display(state, "notify") as string[] | null) ?? [];
const groups = visibleGroups(dds); const groups = visibleGroups(dds);
const section = (group: FieldGroup) => ( const section = (group: FieldGroup) => (
<section className={`kio-arm-section kio-arm-${group.area}`} key={group.title}> <section className={`kio-arm-section kio-arm-${group.area}`} key={group.title}>
@ -36,6 +39,7 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm =
readOnly={readOnly || field.readOnly} readOnly={readOnly || field.readOnly}
referenceArm={referenceArm} referenceArm={referenceArm}
incidentGroup={incidentGroup} incidentGroup={incidentGroup}
notify={notify}
onChange={onChange} onChange={onChange}
/> />
))} ))}
@ -58,7 +62,7 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm =
} }
function Row({ function Row({
field, value, pending, required, readOnly, referenceArm, incidentGroup, onChange, field, value, pending, required, readOnly, referenceArm, incidentGroup, notify, onChange,
}: { }: {
field: Field; field: Field;
value: unknown; value: unknown;
@ -67,6 +71,7 @@ function Row({
readOnly?: boolean; readOnly?: boolean;
referenceArm: boolean; referenceArm: boolean;
incidentGroup: number | null; incidentGroup: number | null;
notify: string[];
onChange?: (path: string, value: unknown) => void; onChange?: (path: string, value: unknown) => void;
}) { }) {
const empty = value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0); const empty = value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
@ -85,7 +90,7 @@ function Row({
{readOnly ? ( {readOnly ? (
<span className="kio-arm-readonly">{formatValue(value, field)}</span> <span className="kio-arm-readonly">{formatValue(value, field)}</span>
) : ( ) : (
<Input field={field} value={value} referenceArm={referenceArm} incidentGroup={incidentGroup} onChange={onChange} /> <Input field={field} value={value} referenceArm={referenceArm} incidentGroup={incidentGroup} notify={notify} onChange={onChange} />
)} )}
</div> </div>
</div> </div>
@ -93,12 +98,13 @@ function Row({
} }
function Input({ function Input({
field, value, referenceArm, incidentGroup, onChange, field, value, referenceArm, incidentGroup, notify, onChange,
}: { }: {
field: Field; field: Field;
value: unknown; value: unknown;
referenceArm: boolean; referenceArm: boolean;
incidentGroup: number | null; incidentGroup: number | null;
notify: string[];
onChange?: (path: string, value: unknown) => void; onChange?: (path: string, value: unknown) => void;
}) { }) {
const emit = (next: unknown) => onChange?.(field.path, next); const emit = (next: unknown) => onChange?.(field.path, next);
@ -141,6 +147,9 @@ function Input({
</select> </select>
); );
} }
if (field.kind === "services") {
return <ServicesPicker value={(value as string[]) ?? []} notify={notify} onChange={(next) => emit(next)} />;
}
if (field.kind === "signs") { if (field.kind === "signs") {
return <SignsPicker value={(value as string[]) ?? []} group={incidentGroup} onChange={(next) => emit(next)} />; return <SignsPicker value={(value as string[]) ?? []} group={incidentGroup} onChange={(next) => emit(next)} />;
} }
@ -170,6 +179,46 @@ function Input({
); );
} }
/** Как `max_length` у `KIO.notify_extra`. */
const SERVICES_LIMIT = 20;
/** Ручное добавление служб из каталога — окно «Добавьте службы» боевого АРМ.
* Список оповещения по признакам остаётся как есть: здесь только добавки. */
function ServicesPicker({ value, notify, onChange }: {
value: string[]; notify: string[]; onChange: (next: string[]) => void;
}) {
const catalog = useServiceCatalog();
const [query, setQuery] = useState("");
const needle = query.trim().toLowerCase();
// Лимит и службы из списка оповещения — те же правила, что на сервере:
// лишняя служба отклонила бы весь пакет правок.
const full = value.length >= SERVICES_LIMIT;
const found = needle && !full
? (catalog.data?.services ?? [])
.filter((item) => !value.includes(item.name) && !notify.includes(item.name)
&& item.name.toLowerCase().includes(needle))
.slice(0, 8)
: [];
return (
<div className="kio-services">
{value.length > 0 && <div className="signs-selected" aria-label="Добавленные службы">
{value.map((name) => <span key={name}>
{name} <button type="button" aria-label={`Убрать ${name}`}
onClick={() => onChange(value.filter((item) => item !== name))}>×</button>
</span>)}
</div>}
<input type="search" aria-label="Поиск службы в каталоге"
placeholder={full ? `Не больше ${SERVICES_LIMIT} служб` : "Поиск службы…"} disabled={full}
value={query} onChange={(event) => setQuery(event.target.value)} />
{found.length > 0 && <ul className="kio-services-found">
{found.map((item) => <li key={item.id}>
<button type="button" onClick={() => { onChange([...value, item.name]); setQuery(""); }}>{item.name}</button>
</li>)}
</ul>}
</div>
);
}
function IncidentGroupPicker({ value, onChange }: { value: number | null; onChange: (next: number | null) => void }) { function IncidentGroupPicker({ value, onChange }: { value: number | null; onChange: (next: number | null) => void }) {
const page = useIncidentGroups(); const page = useIncidentGroups();
return <select aria-label="Категория происшествия" value={value ?? ""} return <select aria-label="Категория происшествия" value={value ?? ""}

View file

@ -6,7 +6,7 @@
import type { DDSCode, IncidentType } from "@/shared/types/generated"; import type { DDSCode, IncidentType } from "@/shared/types/generated";
export type FieldKind = "text" | "textarea" | "number" | "bool" | "select" | "signs" | "group" | "list"; export type FieldKind = "text" | "textarea" | "number" | "bool" | "select" | "signs" | "group" | "list" | "services";
export interface Field { export interface Field {
/** Путь в карточке: «floor», «fire.floors» — так же, как в `kio.patch`. */ /** Путь в карточке: «floor», «fire.floors» — так же, как в `kio.patch`. */
@ -97,6 +97,7 @@ export const GROUPS: FieldGroup[] = [
{ path: "incident_type", label: "Тип происшествия", kind: "select", options: INCIDENT }, { path: "incident_type", label: "Тип происшествия", kind: "select", options: INCIDENT },
{ path: "victims_count", label: "Пострадавших", kind: "number" }, { path: "victims_count", label: "Пострадавших", kind: "number" },
{ path: "is_emergency", label: "Признак ЧС", kind: "bool" }, { path: "is_emergency", label: "Признак ЧС", kind: "bool" },
{ path: "offence_signs", label: "Признаки правонарушения", kind: "bool" },
{ path: "life_threat", label: "Угроза жизни", kind: "bool" }, { path: "life_threat", label: "Угроза жизни", kind: "bool" },
{ path: "evacuation_needed", label: "Нужна эвакуация", kind: "bool" }, { path: "evacuation_needed", label: "Нужна эвакуация", kind: "bool" },
], ],
@ -107,6 +108,8 @@ export const GROUPS: FieldGroup[] = [
fields: [ fields: [
// Список оповещения оператор не выбирает: он производен от признаков. // Список оповещения оператор не выбирает: он производен от признаков.
{ path: "notify", label: "Список оповещения", kind: "list", readOnly: true }, { path: "notify", label: "Список оповещения", kind: "list", readOnly: true },
// Как «Службы: +» в боевом АРМ: добавить можно, убрать автоматическую — нет.
{ path: "notify_extra", label: "Дополнительно оповестить", kind: "services" },
{ path: "dds", label: "Служба (устаревшее)", kind: "select", options: DDS }, { path: "dds", label: "Служба (устаревшее)", kind: "select", options: DDS },
{ path: "dispatch_order_at", label: "Приказ на выезд", kind: "text", readOnly: true }, { path: "dispatch_order_at", label: "Приказ на выезд", kind: "text", readOnly: true },
{ path: "arrival_at", label: "Прибытие", kind: "text", readOnly: true }, { path: "arrival_at", label: "Прибытие", kind: "text", readOnly: true },

View file

@ -29,6 +29,7 @@ export interface ObservedSession {
hintsUsed: number; hintsUsed: number;
notes: Map<string, string>; notes: Map<string, string>;
ended: boolean; ended: boolean;
paused: boolean;
report: SessionReport | null; report: SessionReport | null;
error: string | null; error: string | null;
} }
@ -45,6 +46,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
const [hintsUsed, setHintsUsed] = useState(0); const [hintsUsed, setHintsUsed] = useState(0);
const [notes, setNotes] = useState<Map<string, string>>(new Map()); const [notes, setNotes] = useState<Map<string, string>>(new Map());
const [ended, setEnded] = useState(false); const [ended, setEnded] = useState(false);
const [paused, setPaused] = useState(false);
// Сессия, для которой отчёт уже стоит просить. Привязка к id, а не флаг: // Сессия, для которой отчёт уже стоит просить. Привязка к id, а не флаг:
// при переключении истории старый флаг не должен дёрнуть запрос новой сессии. // при переключении истории старый флаг не должен дёрнуть запрос новой сессии.
const [reportFor, setReportFor] = useState<string | null>(null); const [reportFor, setReportFor] = useState<string | null>(null);
@ -65,6 +67,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
setHintsUsed(0); setHintsUsed(0);
setNotes(new Map()); setNotes(new Map());
setEnded(false); setEnded(false);
setPaused(false);
setError(null); setError(null);
if (!sessionId) return; if (!sessionId) return;
let active = true; let active = true;
@ -88,6 +91,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
setTimers(event.timers); setTimers(event.timers);
setHintsUsed(event.hints_used ?? 0); setHintsUsed(event.hints_used ?? 0);
setEnded(event.ended ?? false); setEnded(event.ended ?? false);
setPaused(event.paused ?? false);
if (event.ended) loadReport(); if (event.ended) loadReport();
break; break;
case "kio.state": case "kio.state":
@ -112,6 +116,9 @@ export function useObserve(sessionId: string | null): ObservedSession {
setEnded(true); setEnded(true);
loadReport(); loadReport();
break; break;
case "session.paused":
setPaused(event.paused);
break;
case "error": case "error":
setError(event.message); setError(event.message);
if (event.code === "session_not_found") loadReport(); if (event.code === "session_not_found") loadReport();
@ -127,5 +134,5 @@ export function useObserve(sessionId: string | null): ObservedSession {
return () => { active = false; channel.close(); }; return () => { active = false; channel.close(); };
}, [sessionId, queries]); }, [sessionId, queries]);
return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, report, error }; return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, paused, report, error };
} }

View file

@ -47,6 +47,11 @@ function field(card: CardState, path: string): string {
const value = readPath(card.server, path); const value = readPath(card.server, path);
return value === null || value === undefined || value === "" ? "—" : String(value); return value === null || value === undefined || value === "" ? "—" : String(value);
} }
/** Список из карточки: `field()` не годится — `String([])` пустая строка, а не «—». */
function names(card: CardState, path: string): string[] {
const value = readPath(card.server, path);
return Array.isArray(value) ? value.map(String) : [];
}
function time(value: string): string { function time(value: string): string {
const parsed = new Date(value); const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString("ru-RU"); return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString("ru-RU");
@ -233,6 +238,7 @@ export function Dds() {
const [checkText, setCheckText] = useState(""); const [checkText, setCheckText] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [ended, setEnded] = useState(false); const [ended, setEnded] = useState(false);
const [paused, setPaused] = useState(false);
// Разбор просим только после `score.ready`; новая карточка снимает флаг. // Разбор просим только после `score.ready`; новая карточка снимает флаг.
const [scoreReady, setScoreReady] = useState(false); const [scoreReady, setScoreReady] = useState(false);
const { report, loading: reportLoading, error: reportError, refetch: refetchReport } = const { report, loading: reportLoading, error: reportError, refetch: refetchReport } =
@ -252,6 +258,7 @@ export function Dds() {
if (event.type === "station.state") { if (event.type === "station.state") {
const snapshot = event.snapshot as DdsSnapshot; const snapshot = event.snapshot as DdsSnapshot;
setStation(snapshot); setStation(snapshot);
setPaused(snapshot.paused ?? false);
setZone(snapshot.zone_decision ?? null); setZone(snapshot.zone_decision ?? null);
if (snapshot.card_id == null && (snapshot.pending_cards_count ?? 0) > 0) { if (snapshot.card_id == null && (snapshot.pending_cards_count ?? 0) > 0) {
activeCardId.current = null; activeCardId.current = null;
@ -298,6 +305,7 @@ export function Dds() {
} else if (event.type === "timer.tick") setTimers(event.timers); } else if (event.type === "timer.tick") setTimers(event.timers);
else if (event.type === "error") { setError(event.message); setDialing(false); setAdvancePending(false); } else if (event.type === "error") { setError(event.message); setDialing(false); setAdvancePending(false); }
else if (event.type === "session.ended") { setEnded(true); setDialing(false); setAdvancePending(false); setPane(null); setView("registry"); } else if (event.type === "session.ended") { setEnded(true); setDialing(false); setAdvancePending(false); setPane(null); setView("registry"); }
else if (event.type === "session.paused") setPaused(event.paused);
else if (event.type === "score.ready") setScoreReady(true); else if (event.type === "score.ready") setScoreReady(true);
else if (event.type === "phone.report") { else if (event.type === "phone.report") {
setLastReport(event); setLastReport(event);
@ -318,7 +326,7 @@ export function Dds() {
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
const send = (command: StationToServer): boolean => channel.current?.send(command) ?? false; const send = (command: StationToServer): boolean => !paused && (channel.current?.send(command) ?? false);
if (!sessionId) return <DdsHistoryOnly cards={ddsHistory.cards} error={ddsHistory.error} />; if (!sessionId) return <DdsHistoryOnly cards={ddsHistory.cards} error={ddsHistory.error} />;
const reports = station?.phone_reports ?? []; const reports = station?.phone_reports ?? [];
@ -328,6 +336,8 @@ export function Dds() {
const phoneCall = station?.phone_pending ?? null; const phoneCall = station?.phone_pending ?? null;
const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === selectedCrew); const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === selectedCrew);
const isConnected = status === "open"; const isConnected = status === "open";
// Вверх: короткая пауза не должна выглядеть как «0 с», а без пауз строки нет.
const pausedSeconds = Math.ceil((report?.total_paused_ms ?? 0) / 1000);
const cardId = card ? String(readPath(card.server, "card_id") ?? "") : ""; const cardId = card ? String(readPath(card.server, "card_id") ?? "") : "";
const cardIndex = station?.card_index ?? 1; const cardIndex = station?.card_index ?? 1;
const cardTotal = station?.card_total ?? 1; const cardTotal = station?.card_total ?? 1;
@ -368,18 +378,21 @@ export function Dds() {
АРМ {role}: {isConnected ? "на связи" : status} АРМ {role}: {isConnected ? "на связи" : status}
</span> </span>
{error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>} {error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>}
{paused && !ended && <div className="dds-alert dds-paused">Пауза, ждите преподавателя</div>}
{ended && <div className="dds-alert dds-done">Занятие завершено. Оценка появится после расчёта.</div>} {ended && <div className="dds-alert dds-done">Занятие завершено. Оценка появится после расчёта.</div>}
{(ended || scoreReady) && <section className="dds-panel" aria-live="polite"> {(ended || scoreReady) && <section className="dds-panel" aria-live="polite">
<div className="dds-panel-title"><h2>Итог упражнения</h2></div> <div className="dds-panel-title"><h2>Итог упражнения</h2></div>
{report ? <div style={{ padding: 12 }}> {report ? <div style={{ padding: 12 }}>
<p><strong>Итоговая оценка: {report.score_final.toFixed(0)} из 100</strong> <p><strong>Итоговая оценка: {report.score_final.toFixed(0)} из 100</strong>
{report.score_final !== report.score_auto && ` · автооценка ${report.score_auto.toFixed(0)}`}</p> {report.score_final !== report.score_auto && ` · автооценка ${report.score_auto.toFixed(0)}`}</p>
{pausedSeconds > 0 && <p className="ref">Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.</p>}
<p className="ref">Скачать отчёт: <a href={`/api/sessions/${sessionId}/report.csv`}>CSV</a> <p className="ref">Скачать отчёт: <a href={`/api/sessions/${sessionId}/report.csv`}>CSV</a>
{" · "}<a href={`/api/sessions/${sessionId}/report.pdf`}>PDF</a></p> {" · "}<a href={`/api/sessions/${sessionId}/report.pdf`}>PDF</a></p>
{Boolean(report.card_results?.length) && <> {Boolean(report.card_results?.length) && <>
<h3>Результаты по карточкам</h3> <h3>Результаты по карточкам</h3>
{report.card_results?.map((item, index) => <details className="dds-details" key={item.card_id}> {report.card_results?.map((item, index) => <details className="dds-details" key={item.card_id}>
<summary>Карточка {index + 1} · {item.scenario_id} · {item.score_auto.toFixed(0)} / 100</summary> <summary>Карточка {index + 1} · {item.scenario_id} · {(item.score_reviewed ?? item.score_auto).toFixed(0)} / 100
{item.score_reviewed != null && ` после разбора (автооценка ${item.score_auto.toFixed(0)})`}</summary>
<p><b>Ответ диспетчера:</b> {item.reply_text || "не внесён"}</p> <p><b>Ответ диспетчера:</b> {item.reply_text || "не внесён"}</p>
{item.metrics.filter((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)).map((metric) => {item.metrics.filter((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)).map((metric) =>
<p key={metric.key} className={metric.passed ? "ref" : "state-violated"}> <p key={metric.key} className={metric.passed ? "ref" : "state-violated"}>
@ -409,6 +422,7 @@ export function Dds() {
</div>} </div>}
</section>} </section>}
<fieldset className="dds-pause-controls" disabled={paused && !ended}>
{view === "registry" && <> {view === "registry" && <>
<section className="dds-search"> <section className="dds-search">
<div className="dds-search-main"> <div className="dds-search-main">
@ -548,6 +562,7 @@ export function Dds() {
<div><b>{field(card, "description")}</b></div> <div><b>{field(card, "description")}</b></div>
<div>Класс: <b>{field(card, "incident_type")}</b></div> <div>Класс: <b>{field(card, "incident_type")}</b></div>
<div>Получатели карточки: <b>{station?.recipient_services?.join(", ") || field(card, "notify")}</b></div> <div>Получатели карточки: <b>{station?.recipient_services?.join(", ") || field(card, "notify")}</b></div>
{names(card, "notify_extra").length > 0 && <div>Добавлены оператором: <b>{names(card, "notify_extra").join(", ")}</b></div>}
<div>Ваша ДДС: <b>{station?.managed_service || "служба не назначена"}</b></div></div> <div>Ваша ДДС: <b>{station?.managed_service || "служба не назначена"}</b></div></div>
</div> </div>
<div className="dds-case-stage"> <div className="dds-case-stage">
@ -703,6 +718,7 @@ export function Dds() {
</div> </div>
</nav> </nav>
</div>} </div>}
</fieldset>
</main>; </main>;
} }

View file

@ -7,6 +7,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Debrief } from "@/features/debrief/Debrief"; import { Debrief } from "@/features/debrief/Debrief";
import { FindingReview } from "@/features/debrief/FindingReview";
import { Director } from "@/features/instructor/Director"; import { Director } from "@/features/instructor/Director";
import { ScoreWeightsEditor } from "@/features/instructor/ScoreWeightsEditor"; import { ScoreWeightsEditor } from "@/features/instructor/ScoreWeightsEditor";
import { ScenarioEditor } from "@/features/instructor/ScenarioEditor"; import { ScenarioEditor } from "@/features/instructor/ScenarioEditor";
@ -22,6 +23,7 @@ import {
useActiveSessions, useIncidentGroups, useReviewScenarioSubmission, useScenarioSubmissions, useActiveSessions, useIncidentGroups, useReviewScenarioSubmission, useScenarioSubmissions,
useScenarios, useSessions, useTrainees, useScenarios, useSessions, useTrainees,
} from "@/shared/api/http"; } from "@/shared/api/http";
import type { Signal } from "@/shared/api/http";
import { sessionIdFromUrl } from "@/shared/api/session"; import { sessionIdFromUrl } from "@/shared/api/session";
import type { Level, SessionMode, SessionReport } from "@/shared/types/generated"; import type { Level, SessionMode, SessionReport } from "@/shared/types/generated";
@ -44,6 +46,12 @@ function modeLabel(mode: SessionMode): string {
return MODES.find((item) => item.value === mode)?.label ?? mode; return MODES.find((item) => item.value === mode)?.label ?? mode;
} }
function worstSignalSeverity(signals: Signal[]): "warn" | "violated" | null {
return signals.some((signal) => signal.severity === "violated")
? "violated"
: signals.length ? "warn" : null;
}
function sessionDateLabel(value: string | null): string | null { function sessionDateLabel(value: string | null): string | null {
if (!value) return null; if (!value) return null;
const date = new Date(value); const date = new Date(value);
@ -85,6 +93,8 @@ export function Instructor() {
const [decisionTimeLimit, setDecisionTimeLimit] = useState(30); const [decisionTimeLimit, setDecisionTimeLimit] = useState(30);
const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180); const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180);
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180); const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
const [ddsReportReactionEnabled, setDdsReportReactionEnabled] = useState(false);
const [ddsReportReactionLimit, setDdsReportReactionLimit] = useState(45);
const [allowedErrors, setAllowedErrors] = useState(0); const [allowedErrors, setAllowedErrors] = useState(0);
const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true); const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
const [lessonScoreWeights, setLessonScoreWeights] = useState<Record<string, number>>({}); const [lessonScoreWeights, setLessonScoreWeights] = useState<Record<string, number>>({});
@ -283,6 +293,7 @@ export function Instructor() {
&& !(exercise === "dds" && wholeTicket) && !(exercise === "dds" && wholeTicket)
? visibleScenarios.filter((item) => item.outcome === "card").map((item) => item.id) ? visibleScenarios.filter((item) => item.outcome === "card").map((item) => item.id)
: undefined; : undefined;
const reportReactionEnabled = (exercise === "dds" || exercise === "card_dds") && ddsReportReactionEnabled;
const sent = control.start(sessionId, { const sent = control.start(sessionId, {
scenarioId: primaryScenarioId, scenarioId: primaryScenarioId,
mode, mode,
@ -299,14 +310,18 @@ export function Instructor() {
decision_time_limit_seconds: decisionTimeLimit, decision_time_limit_seconds: decisionTimeLimit,
card_fill_time_limit_seconds: cardFillTimeLimit, card_fill_time_limit_seconds: cardFillTimeLimit,
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit, dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
dds_report_reaction_enabled: reportReactionEnabled,
dds_report_reaction_limit_seconds: ddsReportReactionLimit,
allowed_errors: allowedErrors, allowed_errors: allowedErrors,
require_correct_grammar: requireCorrectGrammar, require_correct_grammar: requireCorrectGrammar,
score_weights: lessonScoreWeights, score_weights: reportReactionEnabled
? lessonScoreWeights
: Object.fromEntries(Object.entries(lessonScoreWeights).filter(([key]) => key !== "dds_report_reaction")),
}, },
}); });
if (sent) toStart.current = null; if (sent) toStart.current = null;
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data, }, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios, exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionEnabled, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data, ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data,
randomScenario, visibleScenarios]); randomScenario, visibleScenarios]);
@ -338,7 +353,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 +398,18 @@ 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={worstSignalSeverity(item.signals) ? `state-${worstSignalSeverity(item.signals)}` : item.presence_known ? "state-ok" : undefined}>
{item.signals.length
? item.signals.map((signal) => <span key={signal.kind}>{signal.text}<br /></span>)
: item.presence_known ? "Сигналов нет" : null}
{!item.presence_known && <span>Связь курсанта не проверена</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>
@ -648,6 +669,20 @@ export function Instructor() {
onCommit={setDdsCardWorkTimeLimit} /> onCommit={setDdsCardWorkTimeLimit} />
<small className="ref">По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.</small> <small className="ref">По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.</small>
</label>} </label>}
{(exercise === "dds" || exercise === "card_dds") && <>
<label className="mode-choice">
<input type="checkbox" checked={ddsReportReactionEnabled}
onChange={(event) => setDdsReportReactionEnabled(event.target.checked)} />
Оценивать срок отметки статуса после доклада бригады
</label>
{ddsReportReactionEnabled && <label className="lesson-criterion">
Лимит реакции на доклад бригады, секунд
<LessonNumberInput label="Лимит реакции на доклад бригады, секунд"
value={ddsReportReactionLimit} min={5} max={600} fallback={45}
onCommit={setDdsReportReactionLimit} />
<small className="ref">Учебный лимит при включённом критерии; исходное значение — 45 секунд.</small>
</label>}
</>}
<label className="lesson-criterion"> <label className="lesson-criterion">
Допустимо нарушенных метрик Допустимо нарушенных метрик
<LessonNumberInput label="Допустимое число ошибок" value={allowedErrors} <LessonNumberInput label="Допустимое число ошибок" value={allowedErrors}
@ -659,6 +694,7 @@ export function Instructor() {
Грамматика карточки оператора 112 входит в оценку Грамматика карточки оператора 112 входит в оценку
</label>} </label>}
<ScoreWeightsEditor exercise={exercise === "card_dds" ? "card_dds" : exercise} <ScoreWeightsEditor exercise={exercise === "card_dds" ? "card_dds" : exercise}
reportReactionEnabled={(exercise === "dds" || exercise === "card_dds") && ddsReportReactionEnabled}
values={lessonScoreWeights} onChange={setLessonScoreWeights} /> values={lessonScoreWeights} onChange={setLessonScoreWeights} />
<small className="ref">Нормативы ГОСТ для приёма вызова не изменяются.</small> <small className="ref">Нормативы ГОСТ для приёма вызова не изменяются.</small>
</details> </details>
@ -695,12 +731,20 @@ export function Instructor() {
{sessionId && session.title && ( {sessionId && session.title && (
<p> <p>
{session.ended ? "занятие завершено" : ( {session.ended ? "занятие завершено" : (
<button type="button" onClick={control.stop}>Завершить занятие</button> <>
<button type="button" onClick={control.stop}>Завершить занятие</button>{" "}
{session.paused ? (
<button type="button" onClick={control.resume}>Возобновить</button>
) : (
<button type="button" onClick={control.pause}>Пауза</button>
)}
{session.paused && <small className="ref" role="status">Занятие на паузе — время не идёт</small>}
</>
)} )}
</p> </p>
)} )}
{sessionId && session.title && !session.ended && ( {sessionId && session.title && !session.ended && (
exercise === "call" && <Director onInject={control.inject} disabled={control.status !== "open"} error={session.error} /> exercise === "call" && <Director onInject={control.inject} disabled={control.status !== "open" || session.paused} error={session.error} />
)} )}
{links && startPendingId && ( {links && startPendingId && (
<p className="ref" role="status">Создаём занятие. Ссылки появятся автоматически после подключения.</p> <p className="ref" role="status">Создаём занятие. Ссылки появятся автоматически после подключения.</p>
@ -801,6 +845,8 @@ export function Instructor() {
{session.report && <> {session.report && <>
<Debrief report={overrideReport?.session_id === session.report.session_id <Debrief report={overrideReport?.session_id === session.report.session_id
? overrideReport : session.report} /> ? overrideReport : session.report} />
<FindingReview report={overrideReport?.session_id === session.report.session_id
? overrideReport : session.report} onChange={setOverrideReport} />
<section className="debrief debrief-score-override"> <section className="debrief debrief-score-override">
<h2>Ручная корректировка оценки</h2> <h2>Ручная корректировка оценки</h2>
<p className="ref">Автоматическая оценка: {session.report.score_auto.toFixed(1)} из 100. Меняйте её только при необходимости: причина сохранится в журнале вместе с вашим логином.</p> <p className="ref">Автоматическая оценка: {session.report.score_auto.toFixed(1)} из 100. Меняйте её только при необходимости: причина сохранится в журнале вместе с вашим логином.</p>

View file

@ -122,15 +122,16 @@ export function Call() {
event.preventDefault(); event.preventDefault();
if (call.sendText(textTurn)) setTextTurn(""); if (call.sendText(textTurn)) setTextTurn("");
}}> }}>
<input aria-label="Ваша реплика заявителю" value={textTurn} maxLength={1000} <input aria-label="Ваша реплика заявителю" value={textTurn} maxLength={1000} disabled={call.paused}
placeholder="Ваша реплика…" onChange={(event) => setTextTurn(event.target.value)} /> placeholder="Ваша реплика…" onChange={(event) => setTextTurn(event.target.value)} />
<button type="submit" disabled={call.status !== "open" || !textTurn.trim() || call.cardSubmitted}>Отправить</button> <button type="submit" disabled={call.paused || call.status !== "open" || !textTurn.trim() || call.cardSubmitted}>Отправить</button>
</form> </form>
{call.paused && <p role="status" className="state-warn">Пауза, ждите преподавателя</p>}
{call.error && <p role="alert" className="state-violated">{call.error}</p>} {call.error && <p role="alert" className="state-violated">{call.error}</p>}
</section> </section>
<div className="arm-card-wrap"> <div className="arm-card-wrap">
<KioCard state={call.card} required={call.briefing.required_fields} <KioCard state={call.card} required={call.briefing.required_fields}
readOnly={call.cardSubmitted} referenceArm onChange={call.patchKio} /> readOnly={call.paused || call.cardSubmitted} referenceArm onChange={call.patchKio} />
</div> </div>
</div> </div>
<section className="arm-support" aria-label="Норматив заполнения карточки"> <section className="arm-support" aria-label="Норматив заполнения карточки">
@ -142,7 +143,7 @@ export function Call() {
? notifiedServices.map((service) => <b key={service}>{service}</b>) ? notifiedServices.map((service) => <b key={service}>{service}</b>)
: <em>появятся после выбора признаков</em>} : <em>появятся после выбора признаков</em>}
</span> </span>
{!call.cardSubmitted && <button type="button" className="arm-primary" disabled={call.status !== "open"} {!call.cardSubmitted && <button type="button" className="arm-primary" disabled={call.paused || call.status !== "open"}
onClick={call.submitCard}>Сохранить и проверить</button>} onClick={call.submitCard}>Сохранить и проверить</button>}
{call.cardSubmitted && <strong>{call.briefing.handoff_to_dds ? "Карточка передана ДДС" : "Карточка отправлена"}</strong>} {call.cardSubmitted && <strong>{call.briefing.handoff_to_dds ? "Карточка передана ДДС" : "Карточка отправлена"}</strong>}
</footer> </footer>
@ -174,11 +175,11 @@ export function Call() {
</header> </header>
<div className="arm-call-actions"> <div className="arm-call-actions">
{(call.phase === "incoming" || call.phase === "talking") && !call.micOn && <button type="button" className="arm-answer" onClick={() => void call.answer()}> {(call.phase === "incoming" || call.phase === "talking") && !call.micOn && <button type="button" className="arm-answer" disabled={call.paused} onClick={() => void call.answer()}>
{call.phase === "talking" ? "☎ Продолжить разговор" : "☎ Ответить на вызов"} {call.phase === "talking" ? "☎ Продолжить разговор" : "☎ Ответить на вызов"}
</button>} </button>}
{call.micOn && <span className="arm-live">● Разговор идёт</span>} {call.micOn && <span className="arm-live">● Разговор идёт</span>}
{call.micOn && hintsAllowed(call.incoming?.mode) && <button type="button" onClick={call.hint}>Подсказка</button>} {call.micOn && hintsAllowed(call.incoming?.mode) && <button type="button" disabled={call.paused} onClick={call.hint}>Подсказка</button>}
<span className="arm-call-actions-note">{call.phase === "ended" <span className="arm-call-actions-note">{call.phase === "ended"
? "Вызов завершён" ? "Вызов завершён"
: !call.micOn && call.phase === "incoming" : !call.micOn && call.phase === "incoming"
@ -190,13 +191,14 @@ export function Call() {
: "Ожидайте входящего вызова"}</span> : "Ожидайте входящего вызова"}</span>
</div> </div>
{call.paused && <p className="state-warn arm-message" role="status">Пауза, ждите преподавателя</p>}
{call.error && <p className="violated arm-message" role="alert">{call.error}</p>} {call.error && <p className="violated arm-message" role="alert">{call.error}</p>}
<div className="arm-card-wrap"> <div className="arm-card-wrap">
<KioCard <KioCard
state={call.card} state={call.card}
required={call.incoming?.required_fields ?? []} required={call.incoming?.required_fields ?? []}
readOnly={!call.micOn} readOnly={call.paused || !call.micOn}
referenceArm referenceArm
onChange={call.patchKio} onChange={call.patchKio}
/> />
@ -207,12 +209,12 @@ export function Call() {
<button <button
type="button" type="button"
className="arm-primary" className="arm-primary"
disabled={!call.micOn || (!service && !autoRoute)} disabled={call.paused || !call.micOn || (!service && !autoRoute)}
onClick={() => call.dispatch(service)} onClick={() => call.dispatch(service)}
> >
Передать в ДДС Передать в ДДС
</button> </button>
{call.micOn && <button type="button" onClick={call.hangup}>Завершить вызов</button>} {call.micOn && <button type="button" disabled={call.paused} onClick={call.hangup}>Завершить вызов</button>}
</div> </div>
</footer> </footer>
<div className="arm-secondary"> <div className="arm-secondary">
@ -225,6 +227,7 @@ export function Call() {
type="text" type="text"
aria-label="Комментарий при закрытии вызова без карточки" aria-label="Комментарий при закрытии вызова без карточки"
value={resolveComment} value={resolveComment}
disabled={call.paused}
placeholder="что сказали заявителю, куда передали" placeholder="что сказали заявителю, куда передали"
onChange={(event) => setResolveComment(event.target.value)} onChange={(event) => setResolveComment(event.target.value)}
/> />
@ -232,7 +235,7 @@ export function Call() {
<p> <p>
<button <button
type="button" type="button"
disabled={!call.micOn || resolved !== null} disabled={call.paused || !call.micOn || resolved !== null}
onClick={() => { onClick={() => {
call.resolve("consultation", resolveComment); call.resolve("consultation", resolveComment);
setResolved("consultation"); setResolved("consultation");
@ -242,7 +245,7 @@ export function Call() {
</button>{" "} </button>{" "}
<button <button
type="button" type="button"
disabled={!call.micOn || resolved !== null} disabled={call.paused || !call.micOn || resolved !== null}
onClick={() => { onClick={() => {
call.resolve("transfer_region", resolveComment); call.resolve("transfer_region", resolveComment);
setResolved("transfer_region"); setResolved("transfer_region");

View file

@ -3,7 +3,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Level, SessionMode, SessionReport, StationSnapshot } from "@/shared/types/generated"; import type {
Level, SessionMode, SessionReport, StationSnapshot, TimerState,
} from "@/shared/types/generated";
import { reportErrorMessage, reportRetryDelay, shouldRetryReport } from "./report.mjs"; import { reportErrorMessage, reportRetryDelay, shouldRetryReport } from "./report.mjs";
@ -94,6 +96,12 @@ export interface SessionInfo {
end_reason: string | null; end_reason: string | null;
} }
export interface Signal {
kind: "backlog" | "refusals" | "offline";
severity: TimerState;
text: string;
}
export interface ActiveSessionInfo { export interface ActiveSessionInfo {
session_id: string; session_id: string;
trainee_name: string | null; trainee_name: string | null;
@ -109,6 +117,8 @@ 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[];
presence_known: boolean;
} }
export const useActiveSessions = () => export const useActiveSessions = () =>
@ -230,6 +240,17 @@ export const useIncidentGroups = () => useQuery({
queryFn: () => request<IncidentGroupsPage>("/api/ekp/groups"), queryFn: () => request<IncidentGroupsPage>("/api/ekp/groups"),
}); });
export interface ServiceCatalogPage {
source: string;
services: { id: string; name: string; organization: string | null; service_level: string | null; source_reference: string }[];
}
/** Каталог окна «Добавьте службы»: 209 служб, включая районные ДДС. */
export const useServiceCatalog = () => useQuery({
queryKey: ["ekp-services"], staleTime: Infinity,
queryFn: () => request<ServiceCatalogPage>("/api/ekp/services"),
});
export function useSigns(prefix: string[], group: number | null = null) { export function useSigns(prefix: string[], group: number | null = null) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (group !== null) params.set("group", String(group)); if (group !== null) params.set("group", String(group));

View file

@ -164,8 +164,17 @@ export const callChannel = (
...options, ...options,
queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null, queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
isAcknowledged: (incoming, pending) => { isAcknowledged: (incoming, pending) => {
if (pending.type !== "kio.patch" || incoming.type !== "kio.patch" if (pending.type !== "kio.patch" || incoming.type !== "kio.patch") return false;
|| incoming.source !== "operator") return false; // `auto` по всем полям команды с другим значением — сервер её переписал
// (так он отвечает и на отклонённый пакет), а merge.ts уже снял правку из
// pending. Повтор затёр бы серверную истину, а слияние с новыми правками
// отклоняло бы и их. Совпадающий `auto` ничего не доказывает: команда
// могла не дойти, повтор безвреден.
if (incoming.source === "auto") {
const keys = Object.keys(pending.fields);
return keys.every((key) => key in incoming.fields)
&& keys.some((key) => !sameJsonValue(pending.fields[key], incoming.fields[key]));
}
return Object.entries(pending.fields).every(([key, value]) => return Object.entries(pending.fields).every(([key, value]) =>
sameJsonValue(value, incoming.fields[key]), sameJsonValue(value, incoming.fields[key]),
); );

View file

@ -177,6 +177,7 @@ export interface DdsCardReport {
card_id: string; card_id: string;
scenario_id: string; scenario_id: string;
score_auto: number; score_auto: number;
score_reviewed?: number | null;
reply_text: string; reply_text: string;
metrics: Array<Metric>; metrics: Array<Metric>;
findings: Array<Finding>; findings: Array<Finding>;
@ -267,6 +268,23 @@ export interface Finding {
competency?: Competency | null; competency?: Competency | null;
transcript_ref?: string | null; transcript_ref?: string | null;
at?: string | null; at?: string | null;
metric_key?: string | null;
card?: number | null;
service?: string | null;
author?: string | null;
client_id?: string | null;
reviews?: Array<FindingReview>;
}
/** Решение преподавателя по отметке на разборе. */
export type FindingDecision = "confirmed" | "dismissed";
/** Кто, когда и почему подтвердил или снял отметку. Сама отметка остаётся. */
export interface FindingReview {
decision: FindingDecision;
reason: string;
author: string;
at: string;
} }
/** Источник объяснимой отметки; `judge` оставлен для старых отчётов. */ /** Источник объяснимой отметки; `judge` оставлен для старых отчётов. */
@ -342,10 +360,13 @@ export interface KIO {
description?: string | null; description?: string | null;
victims_count?: number | null; victims_count?: number | null;
is_emergency?: boolean; is_emergency?: boolean;
offence_signs?: boolean | null;
life_threat?: boolean; life_threat?: boolean;
evacuation_needed?: boolean; evacuation_needed?: boolean;
/** Список оповещения */ /** Список оповещения */
notify?: Array<string>; notify?: Array<string>;
/** Дополнительно оповестить */
notify_extra?: Array<string>;
/** Устаревшее: одна служба. Заменено списком оповещения */ /** Устаревшее: одна служба. Заменено списком оповещения */
dds?: DDSCode | null; dds?: DDSCode | null;
dispatch_order_at?: string | null; dispatch_order_at?: string | null;
@ -380,6 +401,8 @@ export interface LessonCriteria {
decision_time_limit_seconds?: number; decision_time_limit_seconds?: number;
card_fill_time_limit_seconds?: number; card_fill_time_limit_seconds?: number;
dds_card_work_time_limit_seconds?: number; dds_card_work_time_limit_seconds?: number;
dds_report_reaction_enabled?: boolean;
dds_report_reaction_limit_seconds?: number;
allowed_errors?: number; allowed_errors?: number;
require_correct_grammar?: boolean; require_correct_grammar?: boolean;
score_weights?: Record<string, number>; score_weights?: Record<string, number>;
@ -407,6 +430,10 @@ export interface Metric {
passed: boolean; passed: boolean;
weight?: number; weight?: number;
credit?: number | null; credit?: number | null;
card?: number | null;
service?: string | null;
passed_auto?: boolean | null;
credit_auto?: number | null;
} }
export interface ModeSet { export interface ModeSet {
@ -585,6 +612,17 @@ export interface SessionEnded {
/** Режим сессии. Меняет доступность подсказок и протоколирование, но не поведение звонящего (docs/product/MODES.md). */ /** Режим сессии. Меняет доступность подсказок и протоколирование, но не поведение звонящего (docs/product/MODES.md). */
export type SessionMode = "training" | "exam" | "self"; export type SessionMode = "training" | "exam" | "self";
/** Остановить время занятия; карточку курсанта команда не меняет (lct-39). */
export interface SessionPause {
type: "session.pause";
}
/** Пульт остановил или возобновил время занятия (lct-39). */
export interface SessionPaused {
type: "session.paused";
paused: boolean;
}
/** Единица истории: из отчётов складываются профиль, дельта попыток, аналитика. */ /** Единица истории: из отчётов складываются профиль, дельта попыток, аналитика. */
export interface SessionReport { export interface SessionReport {
session_id: string; session_id: string;
@ -608,9 +646,15 @@ export interface SessionReport {
self_assessment_diff?: SelfAssessmentDiff | null; self_assessment_diff?: SelfAssessmentDiff | null;
notes?: Array<InstructorNoteShown>; notes?: Array<InstructorNoteShown>;
score_auto: number; score_auto: number;
score_reviewed?: number | null;
score_final: number; score_final: number;
overridden_by?: string | null; overridden_by?: string | null;
override_comment?: string | null; override_comment?: string | null;
total_paused_ms?: number;
}
export interface SessionResume {
type: "session.resume";
} }
/** Обязательно при подключении: монитор в классе включают посреди занятия. */ /** Обязательно при подключении: монитор в классе включают посреди занятия. */
@ -631,6 +675,7 @@ export interface SessionSnapshot {
timers: Array<TimerSnapshot>; timers: Array<TimerSnapshot>;
hints_used?: number; hints_used?: number;
ended?: boolean; ended?: boolean;
paused?: boolean;
} }
export interface SessionStop { export interface SessionStop {
@ -667,6 +712,7 @@ export interface StationSnapshot {
pending_cards_count?: number; pending_cards_count?: number;
next_arrival_in_seconds?: number | null; next_arrival_in_seconds?: number | null;
max_waiting_cards?: number; max_waiting_cards?: number;
paused?: boolean;
} }
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */ /** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
@ -775,15 +821,15 @@ export interface ZoneDecision {
// Союзы по каналам — смотри docs/arch/CONTRACT.md#каналы // Союзы по каналам — смотри docs/arch/CONTRACT.md#каналы
export type ServerToTrainee = CallIncoming | CardBriefing | TextTurnAccepted | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | ErrorEvent; export type ServerToTrainee = CallIncoming | CardBriefing | TextTurnAccepted | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | SessionPaused | ErrorEvent;
export type TraineeToServer = CallAnswer | CardSubmit | TextTurn | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial; export type TraineeToServer = CallAnswer | CardSubmit | TextTurn | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial;
export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | ErrorEvent; export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | SessionPaused | ErrorEvent;
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | SessionStop; export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | SessionStop | SessionPause | SessionResume;
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | ErrorEvent; export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | SessionPaused | ErrorEvent;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived; export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
@ -835,6 +881,9 @@ export type EventName =
| "score.ready" | "score.ready"
| "self_assessment.submit" | "self_assessment.submit"
| "session.ended" | "session.ended"
| "session.pause"
| "session.paused"
| "session.resume"
| "session.snapshot" | "session.snapshot"
| "session.stop" | "session.stop"
| "station.finish" | "station.finish"

View file

@ -197,6 +197,9 @@ button:disabled { opacity: 0.5; cursor: default; }
.debrief-stat > strong { font-size: 24px; line-height: 1.15; overflow-wrap: anywhere; } .debrief-stat > strong { font-size: 24px; line-height: 1.15; overflow-wrap: anywhere; }
.debrief-stat > strong small { font-size: 14px; font-weight: 500; } .debrief-stat > strong small { font-size: 14px; font-weight: 500; }
.debrief-stat > small { color: #5a6068; font-size: 12px; } .debrief-stat > small { color: #5a6068; font-size: 12px; }
.finding-review-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.finding-review-actions input { flex: 1 1 200px; min-width: 0; }
.finding-review-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; align-items: end; }
.debrief-stat.is-passed { border-left-color: var(--ok); } .debrief-stat.is-passed { border-left-color: var(--ok); }
.debrief-stat.is-failed { border-left-color: var(--violated); } .debrief-stat.is-failed { border-left-color: var(--violated); }
.debrief-next-step { margin: 12px 0 18px; padding: 12px 15px; border: 1px solid var(--line); border-left: 4px solid #6b7780; border-radius: 5px; background: #fff; } .debrief-next-step { margin: 12px 0 18px; padding: 12px 15px; border: 1px solid var(--line); border-left: 4px solid #6b7780; border-radius: 5px; background: #fff; }
@ -316,6 +319,7 @@ button:disabled { opacity: 0.5; cursor: default; }
/* АРМ ДДС: реестр остаётся плотной таблицей, карточка и связь — рядом. */ /* АРМ ДДС: реестр остаётся плотной таблицей, карточка и связь — рядом. */
.dds-workspace { max-width: 1800px; margin: 0 auto; } .dds-workspace { max-width: 1800px; margin: 0 auto; }
.dds-pause-controls { display: contents; border: 0; margin: 0; padding: 0; min-width: 0; }
.dds-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 8px 0 16px; } .dds-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 8px 0 16px; }
.dds-header h1 { margin: 2px 0 0; } .dds-header h1 { margin: 2px 0 0; }
.dds-header h1 span { color: #576273; font-size: 15px; font-weight: 400; } .dds-header h1 span { color: #576273; font-size: 15px; font-weight: 400; }
@ -349,6 +353,7 @@ button:disabled { opacity: 0.5; cursor: default; }
.dds-status-action button { padding: 4px 8px; } .dds-status-action button { padding: 4px 8px; }
.dds-alert { padding: 10px 12px; margin: 0 0 12px; color: var(--violated); background: #fde8e8; border: 1px solid var(--violated); } .dds-alert { padding: 10px 12px; margin: 0 0 12px; color: var(--violated); background: #fde8e8; border: 1px solid var(--violated); }
.dds-done { color: var(--ok); background: #e7f4e9; border-color: var(--ok); } .dds-done { color: var(--ok); background: #e7f4e9; border-color: var(--ok); }
.dds-paused { color: var(--warn); background: #fdf3d8; border-color: var(--warn); }
.dds-phone > label { display: block; margin: 14px 12px 5px; font-size: 13px; font-weight: 600; } .dds-phone > label { display: block; margin: 14px 12px 5px; font-size: 13px; font-weight: 600; }
.dds-phone > select { display: block; width: calc(100% - 24px); margin: 0 12px; padding: 7px; box-sizing: border-box; } .dds-phone > select { display: block; width: calc(100% - 24px); margin: 0 12px; padding: 7px; box-sizing: border-box; }
.dds-dial { display: block; margin: 12px; width: calc(100% - 24px); text-align: center; background: #23577e; border-color: #23577e; color: white; font-weight: 600; } .dds-dial { display: block; margin: 12px; width: calc(100% - 24px); text-align: center; background: #23577e; border-color: #23577e; color: white; font-weight: 600; }
@ -783,6 +788,11 @@ button:disabled { opacity: 0.5; cursor: default; }
.kio-arm-incident .signs select { flex: 1 1 180px; width: auto; max-width: 100%; padding: 7px 8px; border: 1px solid #acb5bc; background: white; } .kio-arm-incident .signs select { flex: 1 1 180px; width: auto; max-width: 100%; padding: 7px 8px; border: 1px solid #acb5bc; background: white; }
.kio-arm-ref .signs-selected { display: flex; flex: 1 1 100%; flex-wrap: wrap; gap: 5px; margin-top: 5px; } .kio-arm-ref .signs-selected { display: flex; flex: 1 1 100%; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
.kio-arm-ref .signs-selected span { padding: 7px 10px; background: #1876b4; color: white; font-size: 12px; font-weight: 700; } .kio-arm-ref .signs-selected span { padding: 7px 10px; background: #1876b4; color: white; font-size: 12px; font-weight: 700; }
.kio-services { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.kio-services .signs-selected { display: flex; flex-wrap: wrap; gap: 5px; }
.kio-services .signs-selected button { border: 0; background: transparent; color: inherit; cursor: pointer; padding: 0 0 0 4px; }
.kio-services-found { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); max-height: 220px; overflow-y: auto; }
.kio-services-found button { width: 100%; text-align: left; border: 0; border-bottom: 1px solid var(--line); background: var(--panel); padding: 6px 8px; cursor: pointer; }
.kio-arm-choice { display: flex; flex-wrap: wrap; gap: 6px; padding: 5px 0; } .kio-arm-choice { display: flex; flex-wrap: wrap; gap: 6px; padding: 5px 0; }
.kio-arm-choice button { min-width: 49px; padding: 6px 11px; border: 1px solid #aeb6bd; background: white; font-size: 13px; font-weight: 700; } .kio-arm-choice button { min-width: 49px; padding: 6px 11px; border: 1px solid #aeb6bd; background: white; font-size: 13px; font-weight: 700; }
.kio-arm-choice .kio-arm-selected { border-color: #1979bb; background: #1979bb; color: white; } .kio-arm-choice .kio-arm-selected { border-color: #1979bb; background: #1979bb; color: white; }