Merge remote-tracking branch 'origin/main' into feat/decline-scenarios
# Conflicts: # docs/spec/GAP.md
This commit is contained in:
commit
b7445a60fe
66 changed files with 4250 additions and 333 deletions
|
|
@ -25,6 +25,7 @@ SIP_6002_PASSWORD=
|
||||||
SIP_6003_PASSWORD=
|
SIP_6003_PASSWORD=
|
||||||
SIP_6101_PASSWORD=
|
SIP_6101_PASSWORD=
|
||||||
SIP_6102_PASSWORD=
|
SIP_6102_PASSWORD=
|
||||||
|
SIP_6103_PASSWORD=
|
||||||
# IP/DNS сервера, который WebRTC-клиенты видят в локальной сети. При запуске
|
# IP/DNS сервера, который WebRTC-клиенты видят в локальной сети. При запуске
|
||||||
# только на той же машине оставьте 127.0.0.1; RTP-порты должны отображаться 1:1.
|
# только на той же машине оставьте 127.0.0.1; RTP-порты должны отображаться 1:1.
|
||||||
SIP_EXTERNAL_MEDIA_ADDRESS=127.0.0.1
|
SIP_EXTERNAL_MEDIA_ADDRESS=127.0.0.1
|
||||||
|
|
|
||||||
2
Makefile
2
Makefile
|
|
@ -47,7 +47,7 @@ sip: ## Собрать и поднять локальный Asterisk SIP/VoIP
|
||||||
offline-sip: ## Поднять SIP из уже собранного образа без сети
|
offline-sip: ## Поднять SIP из уже собранного образа без сети
|
||||||
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml up --pull never --no-build -d sip
|
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml up --pull never --no-build -d sip
|
||||||
|
|
||||||
sip-credentials: ## Показать локальные пароли софтфонов 6001–6003 и WebRTC 6101–6102
|
sip-credentials: ## Показать локальные пароли софтфонов 6001–6003 и WebRTC 6101–6103
|
||||||
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml exec -T sip cat /var/lib/lct-sip/credentials.env
|
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml exec -T sip cat /var/lib/lct-sip/credentials.env
|
||||||
|
|
||||||
sip-test: ## Проверить регистрацию, звонок, RTP и задержку (args="--rtp-host-offset 1000")
|
sip-test: ## Проверить регистрацию, звонок, RTP и задержку (args="--rtp-host-offset 1000")
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import VerifyMismatchError
|
from argon2.exceptions import VerifyMismatchError
|
||||||
from fastapi import APIRouter, HTTPException, Request, WebSocket
|
from fastapi import APIRouter, HTTPException, Request, WebSocket
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from starlette.websockets import WebSocketDisconnect
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
|
@ -56,6 +56,26 @@ _vanished: set[str] = set()
|
||||||
AUTH_GENERATION_SYNC_SECONDS = 1.0
|
AUTH_GENERATION_SYNC_SECONDS = 1.0
|
||||||
AUTH_GENERATION_MAX_AGE_SECONDS = 2.0
|
AUTH_GENERATION_MAX_AGE_SECONDS = 2.0
|
||||||
_generations_synced_at: float | None = None
|
_generations_synced_at: float | None = None
|
||||||
|
# Один lock на ещё неизвестный узлу логин: параллельные HTTP и WS входы
|
||||||
|
# разделяют один запрос к БД до очередной синхронизации (lct-42). Запись
|
||||||
|
# удаляется, когда lock больше никто не ждёт: иначе словарь растёт с каждым
|
||||||
|
# логином, который узел когда-либо проверял.
|
||||||
|
_lookup_locks: dict[str, "_LoginLookup"] = {}
|
||||||
|
# Логины, которых разовая проверка не нашла в users. Держатся до следующей
|
||||||
|
# сверки: повтор старой cookie не должен давать SELECT на каждый запрос.
|
||||||
|
_missing_logins: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
class _LoginLookup:
|
||||||
|
__slots__ = ("lock", "users")
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.lock = asyncio.Lock()
|
||||||
|
self.users = 0
|
||||||
|
|
||||||
|
|
||||||
|
class _AuthStateUnavailable(Exception):
|
||||||
|
"""Разовая проверка неизвестного узлу логина не смогла обратиться к БД."""
|
||||||
|
|
||||||
|
|
||||||
async def _close_revoked(ws: WebSocket) -> None:
|
async def _close_revoked(ws: WebSocket) -> None:
|
||||||
|
|
@ -98,6 +118,7 @@ def prime_generations(values: dict[str, int]) -> None:
|
||||||
global _generations_synced_at
|
global _generations_synced_at
|
||||||
_generations.clear()
|
_generations.clear()
|
||||||
_generations.update(values)
|
_generations.update(values)
|
||||||
|
_missing_logins.clear()
|
||||||
_generations_synced_at = time.monotonic()
|
_generations_synced_at = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -108,15 +129,38 @@ async def load_generations() -> None:
|
||||||
|
|
||||||
|
|
||||||
async def sync_generations() -> None:
|
async def sync_generations() -> None:
|
||||||
"""Refresh shared account epochs and close sockets revoked on peer nodes."""
|
"""Сверить версии полномочий и закрыть отозванные на другом узле сокеты."""
|
||||||
|
known_before_query = set(_generations)
|
||||||
async with get_sessionmaker()() as db:
|
async with get_sessionmaker()() as db:
|
||||||
rows = (await db.execute(select(User.login, User.auth_version))).all()
|
rows = (await db.execute(select(User.login, User.auth_version))).all()
|
||||||
current = {login: version for login, version in rows}
|
current = {login: version for login, version in rows}
|
||||||
|
# Версия узла выше БД, если отзыв не удалось записать (неудачный
|
||||||
|
# logout) или учётку пересоздали после удаления. Без записи в БД
|
||||||
|
# узлы расходятся навсегда: соседний узел выдаёт cookie со старой
|
||||||
|
# версией, а этот её отвергает. Поднимаем БД до версии узла — отзыв
|
||||||
|
# сохраняется, остальные узлы догоняют за одну сверку. При гонке со
|
||||||
|
# снимком БД уже выше, и UPDATE ничего не меняет.
|
||||||
|
ahead = {
|
||||||
|
login: _generations[login]
|
||||||
|
for login, version in current.items()
|
||||||
|
if login in _generations and _generations[login] > version
|
||||||
|
}
|
||||||
|
if ahead:
|
||||||
|
try:
|
||||||
|
for login, local in ahead.items():
|
||||||
|
await db.execute(_raise_auth_version(login, local))
|
||||||
|
await db.commit()
|
||||||
|
except Exception as exc: # noqa: BLE001 — повторим на следующей сверке
|
||||||
|
log.error("не удалось записать версию полномочий узла (%s)",
|
||||||
|
type(exc).__name__)
|
||||||
|
await db.rollback()
|
||||||
for login, version in current.items():
|
for login, version in current.items():
|
||||||
previous = _generations.get(login)
|
previous = _generations.get(login)
|
||||||
|
# Снимок БД мог устареть за время запроса; меньшая версия не должна
|
||||||
|
# отменять локальный отзыв, который уже поднял поколение.
|
||||||
if previous is None:
|
if previous is None:
|
||||||
_generations[login] = version
|
_generations[login] = version
|
||||||
elif previous != version:
|
elif version > previous:
|
||||||
invalidate_login(login, version)
|
invalidate_login(login, version)
|
||||||
# Account deletion is not exposed by the application. Still close active
|
# Account deletion is not exposed by the application. Still close active
|
||||||
# sockets if an operator removes one directly from the shared directory DB.
|
# sockets if an operator removes one directly from the shared directory DB.
|
||||||
|
|
@ -127,13 +171,88 @@ async def sync_generations() -> None:
|
||||||
# lookup fall back to 0 and re-accept cookies issued before the revocation.
|
# lookup fall back to 0 and re-accept cookies issued before the revocation.
|
||||||
synthetic = {"dev"} if get_settings().dev_auth_bypass else set()
|
synthetic = {"dev"} if get_settings().dev_auth_bypass else set()
|
||||||
_vanished.intersection_update(_generations.keys() - current.keys())
|
_vanished.intersection_update(_generations.keys() - current.keys())
|
||||||
for login in _generations.keys() - current.keys() - synthetic - _vanished:
|
# Разовая проверка могла найти учётку после снимка этого запроса.
|
||||||
|
# Её отсутствие в старом снимке не означает отзыв.
|
||||||
|
for login in known_before_query - current.keys() - synthetic - _vanished:
|
||||||
invalidate_login(login)
|
invalidate_login(login)
|
||||||
_vanished.add(login)
|
_vanished.add(login)
|
||||||
|
# Снимок только что прочитан: учётка, созданная до него, уже в кэше.
|
||||||
|
_missing_logins.clear()
|
||||||
global _generations_synced_at
|
global _generations_synced_at
|
||||||
_generations_synced_at = time.monotonic()
|
_generations_synced_at = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_auth_version(login: str, version: int):
|
||||||
|
# Только вверх: параллельная запись в БД могла уже поднять версию выше.
|
||||||
|
return (
|
||||||
|
update(User)
|
||||||
|
.where(User.login == login, User.auth_version < version)
|
||||||
|
.values(auth_version=version)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_unknown_login(login: str) -> int | None:
|
||||||
|
"""Проверить неизвестный узлу логин сразу, не дожидаясь опроса БД.
|
||||||
|
|
||||||
|
Отозванный логин остаётся в `_generations` (см. `_vanished`), поэтому
|
||||||
|
отсутствие в кэше означает, что этот узел ещё не видел учётку.
|
||||||
|
Возвращает версию или None, если строки в `users` нет. При недоступной
|
||||||
|
или зависшей БД вызывает `_AuthStateUnavailable`, чтобы вход был закрыт
|
||||||
|
с 503/1013.
|
||||||
|
"""
|
||||||
|
if login in _missing_logins:
|
||||||
|
return None
|
||||||
|
entry = _lookup_locks.get(login)
|
||||||
|
if entry is None:
|
||||||
|
entry = _lookup_locks[login] = _LoginLookup()
|
||||||
|
entry.users += 1
|
||||||
|
try:
|
||||||
|
# Тот же предел, что у сверки: при partition handshake не ждёт
|
||||||
|
# таймаута TCP, а очередь за lock не растягивает ожидание сверх него.
|
||||||
|
async with asyncio.timeout(AUTH_GENERATION_MAX_AGE_SECONDS):
|
||||||
|
async with entry.lock:
|
||||||
|
cached = _generations.get(login)
|
||||||
|
if cached is not None:
|
||||||
|
return cached # параллельный запрос уже получил версию
|
||||||
|
if login in _missing_logins:
|
||||||
|
return None
|
||||||
|
async with get_sessionmaker()() as db:
|
||||||
|
version = await db.scalar(
|
||||||
|
select(User.auth_version).where(User.login == login)
|
||||||
|
)
|
||||||
|
# Пока шёл SELECT, локальный отзыв или синхронизация могли
|
||||||
|
# записать новую версию. Старый ответ не должен вернуть
|
||||||
|
# отозванную cookie.
|
||||||
|
cached = _generations.get(login)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
if version is None:
|
||||||
|
_missing_logins.add(login)
|
||||||
|
return None
|
||||||
|
_generations[login] = version
|
||||||
|
log.warning(
|
||||||
|
"учётка %s найдена разовой проверкой до сверки узла",
|
||||||
|
login_log_marker(login),
|
||||||
|
)
|
||||||
|
return version
|
||||||
|
except Exception as exc: # noqa: BLE001 — ошибка или таймаут БД закрывают вход
|
||||||
|
log.error("разовая проверка полномочий не удалась (%s)", type(exc).__name__)
|
||||||
|
raise _AuthStateUnavailable from exc
|
||||||
|
finally:
|
||||||
|
entry.users -= 1
|
||||||
|
if entry.users == 0 and _lookup_locks.get(login) is entry:
|
||||||
|
del _lookup_locks[login]
|
||||||
|
|
||||||
|
|
||||||
|
def login_log_marker(login: str) -> str:
|
||||||
|
"""Метка логина для журнала без самого логина.
|
||||||
|
|
||||||
|
По ней кластерный смоук доказывает, что узел прошёл через разовую
|
||||||
|
проверку, а не увидел учётку обычной сверкой.
|
||||||
|
"""
|
||||||
|
return hashlib.sha256(f"lct-login:{login}".encode()).hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
async def watch_generations() -> None:
|
async def watch_generations() -> None:
|
||||||
"""Poll PostgreSQL once per node so remote logout/role changes close WS."""
|
"""Poll PostgreSQL once per node so remote logout/role changes close WS."""
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -151,6 +270,21 @@ async def watch_generations() -> None:
|
||||||
await asyncio.sleep(AUTH_GENERATION_SYNC_SECONDS)
|
await asyncio.sleep(AUTH_GENERATION_SYNC_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_auth_state_unavailable(scope, send) -> None:
|
||||||
|
if scope["type"] == "websocket":
|
||||||
|
await send({"type": "websocket.close", "code": 1013})
|
||||||
|
else:
|
||||||
|
await send({
|
||||||
|
"type": "http.response.start",
|
||||||
|
"status": 503,
|
||||||
|
"headers": [(b"content-type", b"application/json")],
|
||||||
|
})
|
||||||
|
await send({
|
||||||
|
"type": "http.response.body",
|
||||||
|
"body": b'{"detail":"auth_state_unavailable"}',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class AuthVersionMiddleware:
|
class AuthVersionMiddleware:
|
||||||
"""Check signed-cookie epochs against the fresh, DB-synchronized node cache."""
|
"""Check signed-cookie epochs against the fresh, DB-synchronized node cache."""
|
||||||
|
|
||||||
|
|
@ -186,22 +320,17 @@ class AuthVersionMiddleware:
|
||||||
synced_at is None
|
synced_at is None
|
||||||
or time.monotonic() - synced_at > AUTH_GENERATION_MAX_AGE_SECONDS
|
or time.monotonic() - synced_at > AUTH_GENERATION_MAX_AGE_SECONDS
|
||||||
):
|
):
|
||||||
if scope["type"] == "websocket":
|
await _send_auth_state_unavailable(scope, send)
|
||||||
await send({"type": "websocket.close", "code": 1013})
|
|
||||||
else:
|
|
||||||
await send({
|
|
||||||
"type": "http.response.start",
|
|
||||||
"status": 503,
|
|
||||||
"headers": [(b"content-type", b"application/json")],
|
|
||||||
})
|
|
||||||
await send({
|
|
||||||
"type": "http.response.body",
|
|
||||||
"body": b'{"detail":"auth_state_unavailable"}',
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
|
|
||||||
cookie_version = session.get("auth_generation")
|
cookie_version = session.get("auth_generation")
|
||||||
version = _generations.get(login)
|
version = _generations.get(login)
|
||||||
|
if version is None:
|
||||||
|
try:
|
||||||
|
version = await _resolve_unknown_login(login)
|
||||||
|
except _AuthStateUnavailable:
|
||||||
|
await _send_auth_state_unavailable(scope, send)
|
||||||
|
return
|
||||||
if version is None or cookie_version != version:
|
if version is None or cookie_version != version:
|
||||||
if session is not None:
|
if session is not None:
|
||||||
session.clear()
|
session.clear()
|
||||||
|
|
@ -536,7 +665,20 @@ async def login(payload: LoginIn, request: Request) -> dict:
|
||||||
await audit_required(user.login, user.role, "login.blocked")
|
await audit_required(user.login, user.role, "login.blocked")
|
||||||
raise HTTPException(status_code=403, detail="blocked")
|
raise HTTPException(status_code=403, detail="blocked")
|
||||||
|
|
||||||
if _generations.get(user.login) != user.auth_version:
|
local_version = _generations.get(user.login)
|
||||||
|
if local_version is not None and local_version > user.auth_version:
|
||||||
|
# Отзыв на этом узле не дошёл до БД (неудачный logout). Сброс к версии
|
||||||
|
# БД вернул бы силу cookie, выданной до выхода; выдача cookie с
|
||||||
|
# версией узла без записи в БД развела бы узлы. Поднимаем БД.
|
||||||
|
try:
|
||||||
|
async with get_sessionmaker()() as db:
|
||||||
|
await db.execute(_raise_auth_version(user.login, local_version))
|
||||||
|
await db.commit()
|
||||||
|
except Exception as exc: # noqa: BLE001 — cookie без записанной версии не выдаём
|
||||||
|
log.error("вход: версию полномочий не удалось записать (%s)",
|
||||||
|
type(exc).__name__)
|
||||||
|
raise HTTPException(status_code=503, detail="auth_state_unavailable") from exc
|
||||||
|
elif local_version != user.auth_version:
|
||||||
invalidate_login(user.login, user.auth_version)
|
invalidate_login(user.login, user.auth_version)
|
||||||
|
|
||||||
who = Principal(
|
who = Principal(
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,32 @@ 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, DdsQueueCard, StationSnapshot, current
|
from app.domain.statuses import (
|
||||||
|
SERVICE_STATUS_LABELS,
|
||||||
|
DdsQueueCard,
|
||||||
|
ServiceStatus,
|
||||||
|
StationSnapshot,
|
||||||
|
current,
|
||||||
|
)
|
||||||
from app.domain.timers import TimerCode, TimerState
|
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.state import SessionState
|
||||||
|
|
@ -111,6 +129,7 @@ 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] = []
|
signals: list[Signal] = []
|
||||||
presence_known: bool = True
|
presence_known: bool = True
|
||||||
|
|
@ -212,10 +231,12 @@ def _signals(
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
signals: list[Signal] = []
|
signals: list[Signal] = []
|
||||||
# Необработанная — без первичного статуса: он останавливает норматив
|
# Необработанная — без первичного статуса. Норматив DDS_ACK теперь
|
||||||
# DDS_ACK. Принятая или отклонённая карточка висит в очереди до
|
# останавливается открытием карточки, поэтому считаем по статусу службы:
|
||||||
# «Следующей», но реакции курсанта уже не ждёт.
|
# открытая, но не принятая и не отклонённая карточка реакции ещё ждёт.
|
||||||
pending = sum(not card.timer_stopped for card in queue)
|
pending = sum(
|
||||||
|
card.service_status in {ServiceStatus.ADDED, ServiceStatus.RECEIVED} for card in queue
|
||||||
|
)
|
||||||
if pending >= settings.signal_backlog_threshold:
|
if pending >= settings.signal_backlog_threshold:
|
||||||
signals.append(Signal(
|
signals.append(Signal(
|
||||||
kind="backlog", severity=TimerState.WARN,
|
kind="backlog", severity=TimerState.WARN,
|
||||||
|
|
@ -290,7 +311,10 @@ async def active(
|
||||||
checkpoint_only.add(state.session_id)
|
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.dds_phase 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 []
|
||||||
|
|
@ -318,12 +342,15 @@ 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),
|
signals=_signals(state, queue, now, live=state.session_id not in checkpoint_only),
|
||||||
presence_known=state.session_id not in checkpoint_only,
|
presence_known=state.session_id not in checkpoint_only,
|
||||||
|
|
@ -649,6 +676,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,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ from app.domain.events import (
|
||||||
PatchSource,
|
PatchSource,
|
||||||
ScoreReady,
|
ScoreReady,
|
||||||
SessionMode,
|
SessionMode,
|
||||||
|
SessionPaused,
|
||||||
StationState,
|
StationState,
|
||||||
TimerTick,
|
TimerTick,
|
||||||
TextTurnAccepted,
|
TextTurnAccepted,
|
||||||
|
|
@ -192,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"):
|
||||||
|
|
@ -230,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,
|
||||||
|
|
@ -420,6 +432,8 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
|
||||||
if hub.is_lease_fenced(session_id):
|
if hub.is_lease_fenced(session_id):
|
||||||
await close_fenced(ws)
|
await close_fenced(ws)
|
||||||
return
|
return
|
||||||
|
# Переподключившийся клиент сначала узнаёт, разрешён ли ввод.
|
||||||
|
queue.put_nowait(SessionPaused(paused=state.paused))
|
||||||
if state.exercise is Exercise.CARD:
|
if state.exercise is Exercise.CARD:
|
||||||
from app.api.ws.control import card_briefing
|
from app.api.ws.control import card_briefing
|
||||||
|
|
||||||
|
|
@ -462,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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -345,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({
|
||||||
|
|
@ -391,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,
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
Учебный исходящий звонок бригаде имитируется событиями JSON. Для целевого
|
Учебный исходящий звонок бригаде имитируется событиями JSON. Для целевого
|
||||||
локального прототипа не нужна внешняя телефония или аудиомодель.
|
локального прототипа не нужна внешняя телефония или аудиомодель.
|
||||||
|
|
||||||
Самая ценная механика цепочки — `card.bounce`: диспетчер видит, что не указан
|
`card.bounce` сервер отклоняет: ДДС не возвращает карточку в 112 при принятии
|
||||||
этаж, и отбивает карточку обратно. Неполнота КИО перестаёт быть процентом
|
и не правит её поля. Об ошибке, которую вскрыл доклад бригады с места,
|
||||||
в отчёте и становится сорванным выездом с конкретной причиной.
|
диспетчер сообщает в 112 по телефону (ответ заказчика, П.3).
|
||||||
|
|
||||||
Правила пульта — в `DdsDesk.apply`; здесь разбор команды и рассылка итога.
|
Правила пульта — в `DdsDesk.apply`; здесь разбор команды и рассылка итога.
|
||||||
Отсев повторов и операция хранилища — в `run_command`.
|
Отсев повторов и операция хранилища — в `run_command`.
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -153,7 +154,7 @@ 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", "notify_extra",
|
"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",
|
||||||
|
|
@ -193,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",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -208,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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,11 @@ NORMATIVES: dict[TimerCode, Normative] = {
|
||||||
TimerCode.DDS_ACK: Normative(
|
TimerCode.DDS_ACK: Normative(
|
||||||
# 30 секунд — ПП РФ № 1931 и памятка АРМ-112, а не ГОСТ: до получения
|
# 30 секунд — ПП РФ № 1931 и памятка АРМ-112, а не ГОСТ: до получения
|
||||||
# датасета здесь стояло 4 секунды, взятые по догадке (docs/spec/DATASET.md).
|
# датасета здесь стояло 4 секунды, взятые по догадке (docs/spec/DATASET.md).
|
||||||
|
# Отсчёт — от появления карточки до её открытия (ответ заказчика, П.5).
|
||||||
code=TimerCode.DDS_ACK,
|
code=TimerCode.DDS_ACK,
|
||||||
title="Подтверждение получения карточки",
|
title="Открытие карточки ДДС",
|
||||||
limit_ms=30_000,
|
limit_ms=30_000,
|
||||||
ref="ПП РФ № 1931",
|
ref="ПП РФ № 1931; ответ заказчика, П.5",
|
||||||
),
|
),
|
||||||
TimerCode.CARD_FILL: Normative(
|
TimerCode.CARD_FILL: Normative(
|
||||||
code=TimerCode.CARD_FILL,
|
code=TimerCode.CARD_FILL,
|
||||||
|
|
@ -72,9 +73,9 @@ NORMATIVES: dict[TimerCode, Normative] = {
|
||||||
),
|
),
|
||||||
TimerCode.DDS_WORK: Normative(
|
TimerCode.DDS_WORK: Normative(
|
||||||
code=TimerCode.DDS_WORK,
|
code=TimerCode.DDS_WORK,
|
||||||
title="Отработка карточки ДДС",
|
title="Первая запись по карточке ДДС",
|
||||||
limit_ms=180_000,
|
limit_ms=180_000,
|
||||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
ref="ТЗ задачи № 9; ответ заказчика, П.5",
|
||||||
),
|
),
|
||||||
TimerCode.ZONE_CHECK: Normative(
|
TimerCode.ZONE_CHECK: Normative(
|
||||||
code=TimerCode.ZONE_CHECK, title="Проверка зоны ответственности", limit_ms=30_000
|
code=TimerCode.ZONE_CHECK, title="Проверка зоны ответственности", limit_ms=30_000
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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=(
|
||||||
|
|
@ -161,43 +272,43 @@ def evaluate_dispatcher(
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
|
|
||||||
# D1 — первичного статуса нет либо он проставлен позже норматива.
|
# D1 — карточка открыта позже норматива или не открыта вовсе. 30 с идут
|
||||||
|
# от появления карточки в строке сообщений до её открытия (ответ
|
||||||
|
# заказчика, П.5); `elapsed_ms` — зафиксированное время открытия.
|
||||||
|
if elapsed_ms is None or elapsed_ms > deadline_ms:
|
||||||
|
findings.append(
|
||||||
|
_finding(
|
||||||
|
service, ErrorCode.D1,
|
||||||
|
(f"{service}: карточка открыта с нарушением срока"
|
||||||
|
if elapsed_ms is not None else f"{service}: карточка не открыта"),
|
||||||
|
fact=(
|
||||||
|
f"открыта через {elapsed_ms // 1000} с"
|
||||||
|
if elapsed_ms is not None
|
||||||
|
else "карточка не открыта до конца занятия"
|
||||||
|
),
|
||||||
|
norm=f"открыть карточку в течение {deadline_ms // 1000} с",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# D1 — первичного статуса нет: карточка ушла в «Не оповещено».
|
||||||
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)
|
||||||
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} с"
|
norm="проставить «Принята» или «Не принята» с комментарием",
|
||||||
if elapsed_ms is not None
|
|
||||||
else "карточка осталась без отметки"
|
|
||||||
),
|
|
||||||
norm=f"первичный статус в течение {deadline_ms // 1000} с",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if elapsed_ms is None or elapsed_ms > deadline_ms:
|
|
||||||
findings.append(
|
|
||||||
_finding(
|
|
||||||
ErrorCode.D1,
|
|
||||||
f"{service}: первичный статус проставлен с нарушением срока",
|
|
||||||
fact=(
|
|
||||||
f"прошло {elapsed_ms // 1000} с"
|
|
||||||
if elapsed_ms is not None
|
|
||||||
else "время первичной отметки не зафиксировано"
|
|
||||||
),
|
|
||||||
norm=f"первичный статус в течение {deadline_ms // 1000} с",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for mark in marks:
|
for mark in marks:
|
||||||
# D4 — отказ без комментария.
|
# D4 — отказ без комментария.
|
||||||
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,28 +418,36 @@ 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} с",
|
||||||
elapsed is not None and elapsed <= deadline_ms,
|
elapsed is not None and elapsed <= deadline_ms,
|
||||||
f"{elapsed / 1000:.1f} с" if elapsed is not None else "не отмечено",
|
f"{elapsed / 1000:.1f} с" if elapsed is not None else "карточка не открыта",
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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="")
|
||||||
|
|
@ -37,13 +53,17 @@ def to_csv(report: SessionReport) -> bytes:
|
||||||
row("Занятие", "", "Сценарий", report.scenario_id)
|
row("Занятие", "", "Сценарий", report.scenario_id)
|
||||||
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]:
|
||||||
|
|
@ -150,7 +172,10 @@ def to_pdf(report: SessionReport) -> bytes:
|
||||||
("Сценарий", report.scenario_id),
|
("Сценарий", report.scenario_id),
|
||||||
("Режим", 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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
193
backend/app/scoring/review.py
Normal file
193
backend/app/scoring/review.py
Normal 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])
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
@ -44,18 +48,18 @@ CARD_FILL_TIME = TimeNorm(
|
||||||
)
|
)
|
||||||
DDS_WORK_TIME = TimeNorm(
|
DDS_WORK_TIME = TimeNorm(
|
||||||
key="dds_work_time",
|
key="dds_work_time",
|
||||||
title="Отработка карточки ДДС",
|
title="Первая запись по карточке ДДС",
|
||||||
summary="ДДС: норматив времени отработки карточки не выполнен",
|
summary="ДДС: первая запись по карточке позже норматива",
|
||||||
action="завершить",
|
action="открыть карточку и добавить первую запись (статус и текст)",
|
||||||
unmeasured="время обработки не зафиксировано",
|
unmeasured="карточка не открыта",
|
||||||
incomplete="карточка не завершена",
|
incomplete="первая запись не добавлена",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -241,12 +241,23 @@ def _refused(message: str) -> DeskOutcome:
|
||||||
|
|
||||||
|
|
||||||
def _start_work_timer(card: DdsLiveCard) -> None:
|
def _start_work_timer(card: DdsLiveCard) -> None:
|
||||||
"""Три минуты на работу идут один раз — с открытия карточки."""
|
"""Открытие карточки: останавливает 30 с и запускает 3 мин — один раз.
|
||||||
|
|
||||||
|
Автоматическая активация первой карточки очереди открытием не считается:
|
||||||
|
карточка стоит в строке сообщений, пока диспетчер её не откроет. Первое
|
||||||
|
действие по карточке без явного открытия тоже доказывает, что она открыта.
|
||||||
|
"""
|
||||||
timer = card.timers.timers.get(TimerCode.DDS_WORK)
|
timer = card.timers.timers.get(TimerCode.DDS_WORK)
|
||||||
if timer is None or timer.started_at is None:
|
if timer is None or timer.started_at is None:
|
||||||
card.on_event("dds.open")
|
card.on_event("dds.open")
|
||||||
|
|
||||||
|
|
||||||
|
def _first_record(card: DdsLiveCard, comment: str) -> None:
|
||||||
|
"""Первая запись — ручной статус с текстом; она закрывает 3 минуты (П.5)."""
|
||||||
|
if comment.strip():
|
||||||
|
card.on_event("dds.record")
|
||||||
|
|
||||||
|
|
||||||
def _line(card: DdsLiveCard, speaker: str, text: str) -> PhoneLine | None:
|
def _line(card: DdsLiveCard, speaker: str, text: str) -> PhoneLine | None:
|
||||||
call = card.phone_pending
|
call = card.phone_pending
|
||||||
if call is None:
|
if call is None:
|
||||||
|
|
@ -436,17 +447,17 @@ class DdsDesk(BaseModel):
|
||||||
return _refused("Для подтверждения приёма добавьте комментарий с основанием")
|
return _refused("Для подтверждения приёма добавьте комментарий с основанием")
|
||||||
if any(action == "card.ack" for action, _at, _detail in card.dds_log):
|
if any(action == "card.ack" for action, _at, _detail in card.dds_log):
|
||||||
return DeskOutcome()
|
return DeskOutcome()
|
||||||
card.on_event("card.ack")
|
|
||||||
card.dds_log.append(("card.ack", now_utc(), None))
|
card.dds_log.append(("card.ack", now_utc(), None))
|
||||||
|
_start_work_timer(card)
|
||||||
services = session.card_services(card)
|
services = session.card_services(card)
|
||||||
if services:
|
if services:
|
||||||
_start_work_timer(card)
|
|
||||||
try:
|
try:
|
||||||
card.set_service_status(
|
card.set_service_status(
|
||||||
services[0], ServiceStatus.ACCEPTED, command.comment,
|
services[0], ServiceStatus.ACCEPTED, command.comment,
|
||||||
author="диспетчер",
|
author="диспетчер",
|
||||||
)
|
)
|
||||||
session.consecutive_refusals = 0
|
session.consecutive_refusals = 0
|
||||||
|
_first_record(card, command.comment)
|
||||||
except StatusError:
|
except StatusError:
|
||||||
pass # статус уже стоит: повторное нажатие ничего не меняет
|
pass # статус уже стоит: повторное нажатие ничего не меняет
|
||||||
case "card.status":
|
case "card.status":
|
||||||
|
|
@ -460,9 +471,8 @@ class DdsDesk(BaseModel):
|
||||||
card.set_service_status(
|
card.set_service_status(
|
||||||
command.service, command.status, command.comment, author="диспетчер"
|
command.service, command.status, command.comment, author="диспетчер"
|
||||||
)
|
)
|
||||||
# Первичный статус останавливает норматив 30 секунд.
|
_first_record(card, command.comment)
|
||||||
if command.status in PRIMARY:
|
if command.status in PRIMARY:
|
||||||
card.on_event("card.ack")
|
|
||||||
# Сигнал реестра "повторные отказы" считает подряд идущие
|
# Сигнал реестра "повторные отказы" считает подряд идущие
|
||||||
# отказы, а не сумму за занятие.
|
# отказы, а не сумму за занятие.
|
||||||
session.consecutive_refusals = (
|
session.consecutive_refusals = (
|
||||||
|
|
@ -470,10 +480,6 @@ class DdsDesk(BaseModel):
|
||||||
if command.status is ServiceStatus.DECLINED
|
if command.status is ServiceStatus.DECLINED
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
if command.status in {
|
|
||||||
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
|
|
||||||
}:
|
|
||||||
card.on_event("dds.complete")
|
|
||||||
case "crew.select":
|
case "crew.select":
|
||||||
if command.crew not in session.crew_options(card):
|
if command.crew not in session.crew_options(card):
|
||||||
return _refused("Выберите бригаду из списка доступных")
|
return _refused("Выберите бригаду из списка доступных")
|
||||||
|
|
@ -591,8 +597,13 @@ class DdsDesk(BaseModel):
|
||||||
self.finish_work()
|
self.finish_work()
|
||||||
outcome.finished = True
|
outcome.finished = True
|
||||||
case "card.bounce":
|
case "card.bounce":
|
||||||
|
# Возврата карточки на этапе принятия нет; об ошибке, которую
|
||||||
|
# вскрыл доклад бригады с места, ДДС сообщает в 112 по телефону
|
||||||
|
# (ответ заказчика, П.3).
|
||||||
return _refused(
|
return _refused(
|
||||||
"ДДС не проверяет заполнение карточки: замечания передаёт служба контроля 112"
|
"Карточку не возвращают в 112 при принятии и не правят на АРМ ДДС. "
|
||||||
|
"Если ошибка вскрылась по докладу бригады с места — сообщите о ней "
|
||||||
|
"в 112 по телефону"
|
||||||
)
|
)
|
||||||
case "zone.decision":
|
case "zone.decision":
|
||||||
previous = next(
|
previous = next(
|
||||||
|
|
|
||||||
|
|
@ -12,8 +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.timers import TimerCode
|
from app.domain.timers import TimerCode
|
||||||
from app.scoring.ai_coach import coach
|
from app.scoring.ai_coach import coach
|
||||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||||
|
|
@ -26,10 +25,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 +48,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 +58,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]
|
||||||
|
|
@ -65,16 +75,11 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
||||||
work_elapsed_ms = card.timers.measured_ms(TimerCode.DDS_WORK)
|
work_elapsed_ms = card.timers.measured_ms(TimerCode.DDS_WORK)
|
||||||
if work_elapsed_ms is None and work_timer is not None and work_timer.started_at is not None:
|
if work_elapsed_ms is None and work_timer is not None and work_timer.started_at is not None:
|
||||||
work_elapsed_ms = work_timer.current_ms(time.monotonic())
|
work_elapsed_ms = work_timer.current_ms(time.monotonic())
|
||||||
terminal = bool(services) and all(
|
# 3 минуты — на открытие и первую запись (статус с текстом), а не на
|
||||||
current(card.status_log, service) in {
|
# закрытие: работы по карточке могут идти часы и дни (ответ заказчика, П.5).
|
||||||
ServiceStatus.COMPLETED,
|
recorded = any(mark.comment.strip() for mark in card.status_log if mark.service in services)
|
||||||
ServiceStatus.DECLINED,
|
|
||||||
ServiceStatus.REFUSED,
|
|
||||||
}
|
|
||||||
for service in services
|
|
||||||
)
|
|
||||||
work_metric, work_finding = time_metric(
|
work_metric, work_finding = time_metric(
|
||||||
DDS_WORK_TIME, work_elapsed_ms, work_limit_ms, completed=terminal
|
DDS_WORK_TIME, work_elapsed_ms, work_limit_ms, completed=recorded
|
||||||
)
|
)
|
||||||
weighted.metrics.append(work_metric)
|
weighted.metrics.append(work_metric)
|
||||||
if work_finding is not None:
|
if work_finding is not None:
|
||||||
|
|
@ -100,9 +105,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 +188,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 +214,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 +253,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))
|
||||||
|
|
|
||||||
|
|
@ -424,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())
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
))
|
))
|
||||||
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
|
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
|
||||||
# занятия. Веса первого сценария поверх них испортили бы остальные.
|
# занятия. Веса первого сценария поверх них испортили бы остальные.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -190,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}",
|
||||||
|
|
@ -331,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)
|
||||||
|
|
@ -409,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):
|
||||||
|
|
@ -431,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
|
||||||
|
|
@ -457,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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ STARTS: dict[str, tuple[TimerCode, ...]] = {
|
||||||
"call.incoming": (TimerCode.ANSWER,),
|
"call.incoming": (TimerCode.ANSWER,),
|
||||||
"call.answer": (TimerCode.INTERVIEW,),
|
"call.answer": (TimerCode.INTERVIEW,),
|
||||||
"dds.dispatch": (TimerCode.DDS_ACK, TimerCode.CLOSE),
|
"dds.dispatch": (TimerCode.DDS_ACK, TimerCode.CLOSE),
|
||||||
|
# Ответ заказчика (П.5): 30 с — от появления карточки в строке сообщений
|
||||||
|
# до её открытия, 3 мин — на открытие и первую запись.
|
||||||
"dds.open": (TimerCode.DDS_WORK,),
|
"dds.open": (TimerCode.DDS_WORK,),
|
||||||
"card.start": (TimerCode.CARD_FILL,),
|
"card.start": (TimerCode.CARD_FILL,),
|
||||||
"card.received": (TimerCode.ZONE_CHECK,),
|
"card.received": (TimerCode.ZONE_CHECK,),
|
||||||
|
|
@ -55,10 +57,12 @@ STOPS: dict[str, tuple[TimerCode, ...]] = {
|
||||||
# Справка и передача в другой регион заканчивают опрос так же, как передача
|
# Справка и передача в другой регион заканчивают опрос так же, как передача
|
||||||
# карточки: норматив опроса не должен тикать после решения (lct-36).
|
# карточки: норматив опроса не должен тикать после решения (lct-36).
|
||||||
"call.resolve": (TimerCode.INTERVIEW,),
|
"call.resolve": (TimerCode.INTERVIEW,),
|
||||||
"card.ack": (TimerCode.DDS_ACK,),
|
|
||||||
"card.submit": (TimerCode.CARD_FILL,),
|
"card.submit": (TimerCode.CARD_FILL,),
|
||||||
"card.end": (TimerCode.CARD_FILL,),
|
"card.end": (TimerCode.CARD_FILL,),
|
||||||
"dds.complete": (TimerCode.DDS_WORK,),
|
"dds.open": (TimerCode.DDS_ACK,),
|
||||||
|
# Первая запись — первый ручной статус с текстом. Дальше работы могут идти
|
||||||
|
# часы и дни: остальные статусы по времени не нормируются (П.5).
|
||||||
|
"dds.record": (TimerCode.DDS_WORK,),
|
||||||
"dds.finish": (TimerCode.DDS_WORK,),
|
"dds.finish": (TimerCode.DDS_WORK,),
|
||||||
"zone.decision": (TimerCode.ZONE_CHECK,),
|
"zone.decision": (TimerCode.ZONE_CHECK,),
|
||||||
"crew.arrived": (TimerCode.CLOSE,),
|
"crew.arrived": (TimerCode.CLOSE,),
|
||||||
|
|
@ -83,6 +87,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 +107,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 +139,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 +169,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]:
|
||||||
"""Только запущенные таймеры: показывать нули по нормативам,
|
"""Только запущенные таймеры: показывать нули по нормативам,
|
||||||
до которых занятие ещё не дошло, значит пугать курсанта зря."""
|
до которых занятие ещё не дошло, значит пугать курсанта зря."""
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"dds_several_cards": {
|
"dds_several_cards": {
|
||||||
"score_auto": 53.3,
|
"score_auto": 65.3,
|
||||||
"card_status": "processed",
|
"card_status": "processed",
|
||||||
"metrics": [
|
"metrics": [
|
||||||
[
|
[
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_ack",
|
"dds_ack",
|
||||||
"Карточка 1: Служба 101: первичная отметка за 30 с",
|
"Карточка 1: Служба 101: открытие карточки за 30 с",
|
||||||
"0.0 с",
|
"0.0 с",
|
||||||
true,
|
true,
|
||||||
1.0,
|
1.0,
|
||||||
|
|
@ -61,11 +61,11 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_work_time",
|
"dds_work_time",
|
||||||
"Карточка 1: Отработка карточки ДДС",
|
"Карточка 1: Первая запись по карточке ДДС",
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
"0 с (на 180 с быстрее норматива)",
|
||||||
false,
|
true,
|
||||||
1.5,
|
1.5,
|
||||||
0.0
|
1.0
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_primary",
|
"dds_primary",
|
||||||
|
|
@ -77,7 +77,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_ack",
|
"dds_ack",
|
||||||
"Карточка 2: СМП: первичная отметка за 30 с",
|
"Карточка 2: СМП: открытие карточки за 30 с",
|
||||||
"0.0 с",
|
"0.0 с",
|
||||||
true,
|
true,
|
||||||
1.0,
|
1.0,
|
||||||
|
|
@ -125,11 +125,11 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_work_time",
|
"dds_work_time",
|
||||||
"Карточка 2: Отработка карточки ДДС",
|
"Карточка 2: Первая запись по карточке ДДС",
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
"0 с (на 180 с быстрее норматива)",
|
||||||
false,
|
true,
|
||||||
1.5,
|
1.5,
|
||||||
0.0
|
1.0
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_primary",
|
"dds_primary",
|
||||||
|
|
@ -141,7 +141,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_ack",
|
"dds_ack",
|
||||||
"Карточка 3: Служба 101: первичная отметка за 30 с",
|
"Карточка 3: Служба 101: открытие карточки за 30 с",
|
||||||
"0.0 с",
|
"0.0 с",
|
||||||
true,
|
true,
|
||||||
1.0,
|
1.0,
|
||||||
|
|
@ -189,11 +189,11 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_work_time",
|
"dds_work_time",
|
||||||
"Карточка 3: Отработка карточки ДДС",
|
"Карточка 3: Первая запись по карточке ДДС",
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
"0 с (на 180 с быстрее норматива)",
|
||||||
false,
|
true,
|
||||||
1.5,
|
1.5,
|
||||||
0.0
|
1.0
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"findings": [
|
"findings": [
|
||||||
|
|
@ -207,11 +207,6 @@
|
||||||
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
|
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
|
||||||
"не отмечены: Прибытие, Проведение работ"
|
"не отмечены: Прибытие, Проведение работ"
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"E3",
|
|
||||||
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
|
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"D2",
|
"D2",
|
||||||
"Карточка 2: СМП: «Принята» без назначения бригады и хода реагирования",
|
"Карточка 2: СМП: «Принята» без назначения бригады и хода реагирования",
|
||||||
|
|
@ -222,11 +217,6 @@
|
||||||
"Карточка 2: СМП: ход реагирования не доведён до конца",
|
"Карточка 2: СМП: ход реагирования не доведён до конца",
|
||||||
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
|
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"E3",
|
|
||||||
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
|
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"D5",
|
"D5",
|
||||||
"Карточка 3: Служба 101: комментарии к статусам не разделяют основание и сведения",
|
"Карточка 3: Служба 101: комментарии к статусам не разделяют основание и сведения",
|
||||||
|
|
@ -236,17 +226,12 @@
|
||||||
"D6",
|
"D6",
|
||||||
"Карточка 3: Служба 101: ход реагирования не доведён до конца",
|
"Карточка 3: Служба 101: ход реагирования не доведён до конца",
|
||||||
"не отмечены: Прибытие, Проведение работ"
|
"не отмечены: Прибытие, Проведение работ"
|
||||||
],
|
|
||||||
[
|
|
||||||
"E3",
|
|
||||||
"Карточка 3: ДДС: норматив времени отработки карточки не выполнен",
|
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"cards": [
|
"cards": [
|
||||||
[
|
[
|
||||||
"fire-apartment-l2",
|
"fire-apartment-l2",
|
||||||
56.0,
|
68.0,
|
||||||
"Принято, бригада направлена",
|
"Принято, бригада направлена",
|
||||||
[
|
[
|
||||||
"card.ack",
|
"card.ack",
|
||||||
|
|
@ -259,7 +244,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"t20-2-stroke",
|
"t20-2-stroke",
|
||||||
48.0,
|
60.0,
|
||||||
"",
|
"",
|
||||||
[
|
[
|
||||||
"card.ack",
|
"card.ack",
|
||||||
|
|
@ -268,7 +253,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"t01-1-fire-container",
|
"t01-1-fire-container",
|
||||||
56.0,
|
68.0,
|
||||||
"",
|
"",
|
||||||
[
|
[
|
||||||
"card.ack",
|
"card.ack",
|
||||||
|
|
@ -281,7 +266,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dds_early_finish": {
|
"dds_early_finish": {
|
||||||
"score_auto": 35.3,
|
"score_auto": 44.1,
|
||||||
"card_status": "processed",
|
"card_status": "processed",
|
||||||
"metrics": [
|
"metrics": [
|
||||||
[
|
[
|
||||||
|
|
@ -294,7 +279,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_ack",
|
"dds_ack",
|
||||||
"Карточка 1: СМП: первичная отметка за 30 с",
|
"Карточка 1: СМП: открытие карточки за 30 с",
|
||||||
"0.0 с",
|
"0.0 с",
|
||||||
true,
|
true,
|
||||||
1.0,
|
1.0,
|
||||||
|
|
@ -342,11 +327,11 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_work_time",
|
"dds_work_time",
|
||||||
"Карточка 1: Отработка карточки ДДС",
|
"Карточка 1: Первая запись по карточке ДДС",
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
"0 с (на 180 с быстрее норматива)",
|
||||||
false,
|
true,
|
||||||
1.5,
|
1.5,
|
||||||
0.0
|
1.0
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_primary",
|
"dds_primary",
|
||||||
|
|
@ -358,16 +343,16 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_ack",
|
"dds_ack",
|
||||||
"Карточка 2: Служба 101: первичная отметка за 30 с",
|
"Карточка 2: Служба 101: открытие карточки за 30 с",
|
||||||
"не отмечено",
|
"карточка не открыта",
|
||||||
false,
|
false,
|
||||||
1.0,
|
1.0,
|
||||||
null
|
null
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_work_time",
|
"dds_work_time",
|
||||||
"Карточка 2: Отработка карточки ДДС",
|
"Карточка 2: Первая запись по карточке ДДС",
|
||||||
"карточка не завершена; время обработки не зафиксировано",
|
"первая запись не добавлена; карточка не открыта",
|
||||||
false,
|
false,
|
||||||
1.5,
|
1.5,
|
||||||
0.0
|
0.0
|
||||||
|
|
@ -385,25 +370,25 @@
|
||||||
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
|
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"E3",
|
"D1",
|
||||||
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
|
"Карточка 2: Служба 101: карточка не открыта",
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
"карточка не открыта до конца занятия"
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"D1",
|
"D1",
|
||||||
"Карточка 2: Служба 101: статус реагирования не проставлен",
|
"Карточка 2: Служба 101: статус реагирования не проставлен",
|
||||||
"карточка осталась без отметки"
|
"карточка осталась без первичного статуса"
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"E3",
|
"E3",
|
||||||
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
|
"Карточка 2: ДДС: первая запись по карточке позже норматива",
|
||||||
"карточка не завершена; время обработки не зафиксировано"
|
"первая запись не добавлена; карточка не открыта"
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"cards": [
|
"cards": [
|
||||||
[
|
[
|
||||||
"t20-2-stroke",
|
"t20-2-stroke",
|
||||||
48.0,
|
60.0,
|
||||||
"",
|
"",
|
||||||
[
|
[
|
||||||
"card.ack",
|
"card.ack",
|
||||||
|
|
@ -419,7 +404,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"handoff_112_to_dds": {
|
"handoff_112_to_dds": {
|
||||||
"score_auto": 47.8,
|
"score_auto": 56.5,
|
||||||
"card_status": "processed",
|
"card_status": "processed",
|
||||||
"metrics": [
|
"metrics": [
|
||||||
[
|
[
|
||||||
|
|
@ -480,7 +465,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_ack",
|
"dds_ack",
|
||||||
"Карточка 1: Служба 101: первичная отметка за 30 с",
|
"Карточка 1: Служба 101: открытие карточки за 30 с",
|
||||||
"0.0 с",
|
"0.0 с",
|
||||||
true,
|
true,
|
||||||
1.0,
|
1.0,
|
||||||
|
|
@ -528,11 +513,11 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_work_time",
|
"dds_work_time",
|
||||||
"Карточка 1: Отработка карточки ДДС",
|
"Карточка 1: Первая запись по карточке ДДС",
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
"0 с (на 180 с быстрее норматива)",
|
||||||
false,
|
true,
|
||||||
1.5,
|
1.5,
|
||||||
0.0
|
1.0
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_primary",
|
"dds_primary",
|
||||||
|
|
@ -544,7 +529,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_ack",
|
"dds_ack",
|
||||||
"Карточка 2: Служба 101: первичная отметка за 30 с",
|
"Карточка 2: Служба 101: открытие карточки за 30 с",
|
||||||
"0.0 с",
|
"0.0 с",
|
||||||
true,
|
true,
|
||||||
1.0,
|
1.0,
|
||||||
|
|
@ -592,11 +577,11 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_work_time",
|
"dds_work_time",
|
||||||
"Карточка 2: Отработка карточки ДДС",
|
"Карточка 2: Первая запись по карточке ДДС",
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
"0 с (на 180 с быстрее норматива)",
|
||||||
false,
|
true,
|
||||||
1.5,
|
1.5,
|
||||||
0.0
|
1.0
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"findings": [
|
"findings": [
|
||||||
|
|
@ -630,11 +615,6 @@
|
||||||
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
|
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
|
||||||
"не отмечены: Прибытие, Проведение работ"
|
"не отмечены: Прибытие, Проведение работ"
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"E3",
|
|
||||||
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
|
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"D2",
|
"D2",
|
||||||
"Карточка 2: Служба 101: «Принята» без назначения бригады и хода реагирования",
|
"Карточка 2: Служба 101: «Принята» без назначения бригады и хода реагирования",
|
||||||
|
|
@ -644,17 +624,12 @@
|
||||||
"D6",
|
"D6",
|
||||||
"Карточка 2: Служба 101: ход реагирования не доведён до конца",
|
"Карточка 2: Служба 101: ход реагирования не доведён до конца",
|
||||||
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
|
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
|
||||||
],
|
|
||||||
[
|
|
||||||
"E3",
|
|
||||||
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
|
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"cards": [
|
"cards": [
|
||||||
[
|
[
|
||||||
"fire-apartment-l2",
|
"fire-apartment-l2",
|
||||||
56.0,
|
68.0,
|
||||||
"",
|
"",
|
||||||
[
|
[
|
||||||
"card.ack",
|
"card.ack",
|
||||||
|
|
@ -666,7 +641,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"t01-1-fire-container",
|
"t01-1-fire-container",
|
||||||
48.0,
|
60.0,
|
||||||
"",
|
"",
|
||||||
[
|
[
|
||||||
"card.ack",
|
"card.ack",
|
||||||
|
|
@ -745,7 +720,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_ack",
|
"dds_ack",
|
||||||
"Служба 101: первичная отметка за 30 с",
|
"Служба 101: открытие карточки за 30 с",
|
||||||
"0.0 с",
|
"0.0 с",
|
||||||
true,
|
true,
|
||||||
1.0,
|
1.0,
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,229 @@ def test_generation_sync_preserves_synthetic_dev_account(monkeypatch):
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stale_generation_snapshot_does_not_revoke_newly_resolved_login(monkeypatch):
|
||||||
|
snapshot_read = asyncio.Event()
|
||||||
|
release_snapshot = asyncio.Event()
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
def all(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def execute(self, _query):
|
||||||
|
snapshot_read.set()
|
||||||
|
await release_snapshot.wait()
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
async def scalar(self, _query):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
auth.prime_generations({})
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
sync = asyncio.create_task(auth.sync_generations())
|
||||||
|
await snapshot_read.wait()
|
||||||
|
assert await auth._resolve_unknown_login("just-created") == 0
|
||||||
|
release_snapshot.set()
|
||||||
|
await sync
|
||||||
|
assert auth._generations["just-created"] == 0
|
||||||
|
assert "just-created" not in auth._vanished
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stale_generation_snapshot_does_not_restore_revoked_cookie(monkeypatch):
|
||||||
|
snapshot_read = asyncio.Event()
|
||||||
|
release_snapshot = asyncio.Event()
|
||||||
|
|
||||||
|
raised = []
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
def all(self):
|
||||||
|
return [("revoked", 0)]
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def execute(self, query):
|
||||||
|
if query.is_update:
|
||||||
|
raised.append(query.compile().params)
|
||||||
|
return None
|
||||||
|
snapshot_read.set()
|
||||||
|
await release_snapshot.wait()
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
auth.prime_generations({"revoked": 0})
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
sync = asyncio.create_task(auth.sync_generations())
|
||||||
|
await snapshot_read.wait()
|
||||||
|
auth.invalidate_login("revoked", 1)
|
||||||
|
release_snapshot.set()
|
||||||
|
await sync
|
||||||
|
assert auth._generations["revoked"] == 1
|
||||||
|
# В БД версия уже 1 (отзыв записан до invalidate); UPDATE «только вверх»
|
||||||
|
# в реальной БД ничего не изменит, в снимке же он выглядит как отставание.
|
||||||
|
assert [params["auth_version"] for params in raised] == [1]
|
||||||
|
|
||||||
|
|
||||||
|
def _versioned_users_db(versions: dict[str, int]):
|
||||||
|
"""Поддельная users: сверка читает версии, UPDATE поднимает их только вверх."""
|
||||||
|
updates = []
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
def all(self):
|
||||||
|
return list(versions.items())
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def execute(self, query):
|
||||||
|
if query.is_update:
|
||||||
|
params = query.compile().params
|
||||||
|
login = next(v for k, v in params.items() if k.startswith("login"))
|
||||||
|
target = params["auth_version"]
|
||||||
|
updates.append((login, target))
|
||||||
|
if versions.get(login, target) < target:
|
||||||
|
versions[login] = target
|
||||||
|
return None
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
async def scalar(self, _query):
|
||||||
|
raise AssertionError("login is cached, no one-shot lookup expected")
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def rollback(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return updates, lambda: lambda: FakeSession()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_logout_revocation_is_written_to_db_so_nodes_converge(monkeypatch):
|
||||||
|
"""Неудачный logout поднял версию только на этом узле (B). Сверка не
|
||||||
|
откатывает отзыв, а записывает его в БД: соседний узел A догоняет, и вход
|
||||||
|
на A после этого выдаёт cookie, которую B принимает."""
|
||||||
|
login = "failed-logout-user"
|
||||||
|
versions = {login: 4}
|
||||||
|
updates, sessionmaker = _versioned_users_db(versions)
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", sessionmaker)
|
||||||
|
auth.prime_generations({login: 4})
|
||||||
|
try:
|
||||||
|
auth.invalidate_login(login) # ветка except в logout: БД недоступна
|
||||||
|
assert auth._generations[login] == 5
|
||||||
|
|
||||||
|
await auth.sync_generations()
|
||||||
|
assert updates == [(login, 5)]
|
||||||
|
assert versions[login] == 5, "revocation must reach the shared DB"
|
||||||
|
assert auth._generations[login] == 5, "sync must not roll back a local revocation"
|
||||||
|
|
||||||
|
# Вход на A берёт версию из БД, и теперь она совпадает с версией B.
|
||||||
|
assert versions[login] == auth._generations[login]
|
||||||
|
|
||||||
|
await auth.sync_generations()
|
||||||
|
assert updates == [(login, 5)], "converged nodes must not write again"
|
||||||
|
finally:
|
||||||
|
auth._generations.pop(login, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recreated_account_is_raised_to_the_node_revocation_version(monkeypatch):
|
||||||
|
"""Учётку удалили (узел отозвал её, поколение +1) и создали заново с 0.
|
||||||
|
Без записи в БД узел остался бы впереди навсегда."""
|
||||||
|
login = "recreated-user"
|
||||||
|
versions = {}
|
||||||
|
updates, sessionmaker = _versioned_users_db(versions)
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", sessionmaker)
|
||||||
|
auth.prime_generations({login: 2})
|
||||||
|
try:
|
||||||
|
await auth.sync_generations()
|
||||||
|
assert auth._generations[login] == 3
|
||||||
|
assert login in auth._vanished
|
||||||
|
|
||||||
|
versions[login] = 0 # оператор создал учётку заново
|
||||||
|
await auth.sync_generations()
|
||||||
|
assert updates == [(login, 3)]
|
||||||
|
assert versions[login] == 3
|
||||||
|
assert login not in auth._vanished
|
||||||
|
finally:
|
||||||
|
auth._generations.pop(login, None)
|
||||||
|
auth._vanished.discard(login)
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_raises_db_version_when_node_revocation_was_not_persisted(client, monkeypatch):
|
||||||
|
"""Вход на узле, где отзыв не дошёл до БД: cookie получает версию узла,
|
||||||
|
а БД поднимается до неё. Сброс к версии БД вернул бы силу старой cookie."""
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
user = SimpleNamespace(
|
||||||
|
login="ahead-login", auth_provider="local", password_hash="hash",
|
||||||
|
blocked=False, role="instructor", full_name="Преподаватель",
|
||||||
|
service=None, trainee_id=None, auth_version=1,
|
||||||
|
)
|
||||||
|
updates = []
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def scalar(self, _statement):
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def execute(self, query):
|
||||||
|
assert query.is_update
|
||||||
|
updates.append(query.compile().params["auth_version"])
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def audit_ok(*_args, **_kwargs):
|
||||||
|
return True
|
||||||
|
|
||||||
|
settings = get_settings().model_copy(update={"demo_no_db": False, "ldap_enabled": False})
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
monkeypatch.setattr(auth, "verify_password", lambda *_args: True)
|
||||||
|
monkeypatch.setattr(auth, "audit", audit_ok)
|
||||||
|
auth._generations[user.login] = 2 # неудачный logout на этом узле
|
||||||
|
try:
|
||||||
|
response = client.post("/api/auth/login", json={"login": user.login, "password": "x"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert updates == [2]
|
||||||
|
assert auth._generations[user.login] == 2, "local revocation must not be rolled back"
|
||||||
|
assert client.get("/api/auth/me").status_code == 200
|
||||||
|
finally:
|
||||||
|
auth._generations.pop(user.login, None)
|
||||||
|
|
||||||
|
|
||||||
def test_cross_origin_browser_websocket_is_rejected_before_handshake(client):
|
def test_cross_origin_browser_websocket_is_rejected_before_handshake(client):
|
||||||
assert client.post("/api/auth/dev-token").status_code == 200
|
assert client.post("/api/auth/dev-token").status_code == 200
|
||||||
with pytest.raises(WebSocketDisconnect) as exc:
|
with pytest.raises(WebSocketDisconnect) as exc:
|
||||||
|
|
@ -410,6 +633,401 @@ def test_auth_middleware_fails_closed_when_generation_cache_is_stale_but_allows_
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_resolves_and_caches_login_unknown_to_this_node(monkeypatch):
|
||||||
|
"""Cluster handshake (lct-42): a login another node just authenticated is
|
||||||
|
fetched via a single SELECT rather than being treated as revoked, and the
|
||||||
|
result is cached so a second request for it does not query again."""
|
||||||
|
login = "peer-node-fresh-login"
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def scalar(self, _query):
|
||||||
|
calls.append(1)
|
||||||
|
return 5
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class InnerApp:
|
||||||
|
def __init__(self):
|
||||||
|
self.called = 0
|
||||||
|
|
||||||
|
async def __call__(self, _scope, _receive, _send):
|
||||||
|
self.called += 1
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
auth.prime_generations({})
|
||||||
|
|
||||||
|
def make_scope():
|
||||||
|
return {
|
||||||
|
"type": "http", "path": "/api/admin/users",
|
||||||
|
"session": {
|
||||||
|
"principal": {"login": login},
|
||||||
|
"auth_instance": auth._INSTANCE,
|
||||||
|
"auth_generation": 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
return {"type": "http.request", "body": b"", "more_body": False}
|
||||||
|
|
||||||
|
async def send(_message):
|
||||||
|
return None
|
||||||
|
|
||||||
|
first = InnerApp()
|
||||||
|
await auth.AuthVersionMiddleware(first)(make_scope(), receive, send)
|
||||||
|
assert first.called == 1, "a fresh, valid epoch must reach the route"
|
||||||
|
assert auth._generations[login] == 5
|
||||||
|
assert calls == [1]
|
||||||
|
|
||||||
|
second = InnerApp()
|
||||||
|
await auth.AuthVersionMiddleware(second)(make_scope(), receive, send)
|
||||||
|
assert second.called == 1
|
||||||
|
assert calls == [1], "cached epoch must not trigger a second SELECT"
|
||||||
|
|
||||||
|
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
try:
|
||||||
|
asyncio.run(run())
|
||||||
|
finally:
|
||||||
|
auth._generations.pop(login, None)
|
||||||
|
auth._lookup_locks.pop(login, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_rejects_login_missing_from_users_via_one_shot_query(monkeypatch):
|
||||||
|
login = "peer-node-deleted-login"
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def scalar(self, _query):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class InnerApp:
|
||||||
|
def __init__(self):
|
||||||
|
self.called = False
|
||||||
|
|
||||||
|
async def __call__(self, _scope, _receive, _send):
|
||||||
|
self.called = True
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
auth.prime_generations({})
|
||||||
|
cookie_session = {
|
||||||
|
"principal": {"login": login},
|
||||||
|
"auth_instance": auth._INSTANCE,
|
||||||
|
"auth_generation": 3,
|
||||||
|
}
|
||||||
|
scope = {"type": "http", "path": "/api/admin/users", "session": cookie_session}
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
return {"type": "http.request", "body": b"", "more_body": False}
|
||||||
|
|
||||||
|
async def send(_message):
|
||||||
|
return None
|
||||||
|
|
||||||
|
protected = InnerApp()
|
||||||
|
await auth.AuthVersionMiddleware(protected)(scope, receive, send)
|
||||||
|
assert protected.called, "route still runs but the session was cleared below"
|
||||||
|
assert cookie_session == {}
|
||||||
|
assert login not in auth._generations
|
||||||
|
|
||||||
|
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
try:
|
||||||
|
asyncio.run(run())
|
||||||
|
finally:
|
||||||
|
auth._lookup_locks.pop(login, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_login_lookup_does_not_clobber_a_newer_local_revocation(monkeypatch):
|
||||||
|
"""A stale SELECT reply landing after a concurrent local revoke must not
|
||||||
|
resurrect the revoked cookie's epoch (regression: lock only deduplicates
|
||||||
|
concurrent lookups, it does not order a lookup against a write)."""
|
||||||
|
login = "peer-node-race-login"
|
||||||
|
started = asyncio.Event()
|
||||||
|
resume = asyncio.Event()
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def scalar(self, _query):
|
||||||
|
started.set()
|
||||||
|
await resume.wait()
|
||||||
|
return 0 # the epoch as it stood before the concurrent revoke below
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
auth.prime_generations({})
|
||||||
|
task = asyncio.ensure_future(auth._resolve_unknown_login(login))
|
||||||
|
await started.wait() # the SELECT is in flight, holding the login's lock
|
||||||
|
auth.invalidate_login(login, 1) # a local logout/edit races the reply
|
||||||
|
resume.set()
|
||||||
|
result = await task
|
||||||
|
assert result == 1, "the newer local revocation must win over the stale SELECT reply"
|
||||||
|
assert auth._generations[login] == 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
try:
|
||||||
|
asyncio.run(run())
|
||||||
|
finally:
|
||||||
|
auth._generations.pop(login, None)
|
||||||
|
auth._lookup_locks.pop(login, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_login_lookup_deduplicates_concurrent_requests_into_one_select(monkeypatch):
|
||||||
|
login = "peer-node-concurrent-login"
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def scalar(self, _query):
|
||||||
|
calls.append(1)
|
||||||
|
await asyncio.sleep(0.01) # widen the window for a racing second caller
|
||||||
|
return 9
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
auth.prime_generations({})
|
||||||
|
results = await asyncio.gather(
|
||||||
|
auth._resolve_unknown_login(login),
|
||||||
|
auth._resolve_unknown_login(login),
|
||||||
|
auth._resolve_unknown_login(login),
|
||||||
|
)
|
||||||
|
assert results == [9, 9, 9]
|
||||||
|
assert calls == [1], "concurrent lookups for one login must issue a single SELECT"
|
||||||
|
assert login not in auth._lookup_locks, "lock entry must not outlive its waiters"
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
try:
|
||||||
|
asyncio.run(run())
|
||||||
|
finally:
|
||||||
|
auth._generations.pop(login, None)
|
||||||
|
auth._lookup_locks.pop(login, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_login_lookup_fails_closed_when_database_is_unreachable(monkeypatch):
|
||||||
|
login = "peer-node-db-outage-login"
|
||||||
|
|
||||||
|
class BrokenSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
raise OSError("database unavailable")
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class InnerApp:
|
||||||
|
def __init__(self):
|
||||||
|
self.called = False
|
||||||
|
|
||||||
|
async def __call__(self, _scope, _receive, _send):
|
||||||
|
self.called = True
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
auth.prime_generations({})
|
||||||
|
cookie_session = {
|
||||||
|
"principal": {"login": login},
|
||||||
|
"auth_instance": auth._INSTANCE,
|
||||||
|
"auth_generation": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
return {"type": "http.request", "body": b"", "more_body": False}
|
||||||
|
|
||||||
|
http_messages = []
|
||||||
|
|
||||||
|
async def http_send(message):
|
||||||
|
http_messages.append(message)
|
||||||
|
|
||||||
|
http_app = InnerApp()
|
||||||
|
await auth.AuthVersionMiddleware(http_app)(
|
||||||
|
{"type": "http", "path": "/api/admin/users", "session": dict(cookie_session)},
|
||||||
|
receive, http_send,
|
||||||
|
)
|
||||||
|
assert not http_app.called
|
||||||
|
assert http_messages[0]["status"] == 503
|
||||||
|
|
||||||
|
ws_messages = []
|
||||||
|
|
||||||
|
async def ws_send(message):
|
||||||
|
ws_messages.append(message)
|
||||||
|
|
||||||
|
ws_app = InnerApp()
|
||||||
|
await auth.AuthVersionMiddleware(ws_app)(
|
||||||
|
{"type": "websocket", "path": "/ws/control/x", "session": dict(cookie_session)},
|
||||||
|
receive, ws_send,
|
||||||
|
)
|
||||||
|
assert not ws_app.called
|
||||||
|
assert ws_messages[0] == {"type": "websocket.close", "code": 1013}
|
||||||
|
|
||||||
|
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: BrokenSession())
|
||||||
|
try:
|
||||||
|
asyncio.run(run())
|
||||||
|
finally:
|
||||||
|
auth._lookup_locks.pop(login, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_login_lookup_times_out_instead_of_hanging_on_partition(monkeypatch):
|
||||||
|
"""При partition SELECT может висеть до таймаута TCP. Разовая проверка
|
||||||
|
ограничена тем же порогом, что сверка, и закрывает вход 503 / 1013 —
|
||||||
|
и для запроса, который ждёт lock за зависшим."""
|
||||||
|
login = "peer-node-hanging-db-login"
|
||||||
|
monkeypatch.setattr(auth, "AUTH_GENERATION_MAX_AGE_SECONDS", 0.3)
|
||||||
|
|
||||||
|
class HangingDb:
|
||||||
|
async def scalar(self, _query):
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return HangingDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class InnerApp:
|
||||||
|
called = False
|
||||||
|
|
||||||
|
async def __call__(self, _scope, _receive, _send):
|
||||||
|
self.called = True
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
auth.prime_generations({})
|
||||||
|
cookie_session = {
|
||||||
|
"principal": {"login": login},
|
||||||
|
"auth_instance": auth._INSTANCE,
|
||||||
|
"auth_generation": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
return {"type": "http.request", "body": b"", "more_body": False}
|
||||||
|
|
||||||
|
http_messages, ws_messages = [], []
|
||||||
|
|
||||||
|
async def http_send(message):
|
||||||
|
http_messages.append(message)
|
||||||
|
|
||||||
|
async def ws_send(message):
|
||||||
|
ws_messages.append(message)
|
||||||
|
|
||||||
|
http_app, ws_app = InnerApp(), InnerApp()
|
||||||
|
started = asyncio.get_running_loop().time()
|
||||||
|
await asyncio.wait_for(asyncio.gather(
|
||||||
|
auth.AuthVersionMiddleware(http_app)(
|
||||||
|
{"type": "http", "path": "/api/admin/users", "session": dict(cookie_session)},
|
||||||
|
receive, http_send,
|
||||||
|
),
|
||||||
|
auth.AuthVersionMiddleware(ws_app)(
|
||||||
|
{"type": "websocket", "path": "/ws/control/x", "session": dict(cookie_session)},
|
||||||
|
receive, ws_send,
|
||||||
|
),
|
||||||
|
), timeout=2)
|
||||||
|
elapsed = asyncio.get_running_loop().time() - started
|
||||||
|
assert elapsed < 1, "a queued handshake must not wait for a second timeout"
|
||||||
|
assert not http_app.called and not ws_app.called
|
||||||
|
assert http_messages[0]["status"] == 503
|
||||||
|
assert ws_messages[0] == {"type": "websocket.close", "code": 1013}
|
||||||
|
assert login not in auth._lookup_locks, "timed-out lookup must release its lock entry"
|
||||||
|
|
||||||
|
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_shot_lookup_logs_login_marker_not_login(caplog, monkeypatch):
|
||||||
|
"""Кластерный смоук ищет эту метку в журнале узла B: она доказывает, что
|
||||||
|
вход прошёл через разовую проверку, а не через обычную сверку."""
|
||||||
|
login = "peer-node-logged-login"
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def scalar(self, _query):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
auth.prime_generations({})
|
||||||
|
assert await auth._resolve_unknown_login(login) == 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
try:
|
||||||
|
with caplog.at_level("WARNING", logger="app.api.auth"):
|
||||||
|
asyncio.run(run())
|
||||||
|
finally:
|
||||||
|
auth._generations.pop(login, None)
|
||||||
|
assert auth.login_log_marker(login) in caplog.text
|
||||||
|
assert login not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_login_is_cached_until_next_sync_and_lock_entries_are_released(monkeypatch):
|
||||||
|
login = "peer-node-replayed-missing-login"
|
||||||
|
lookups = []
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
def all(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def scalar(self, _query):
|
||||||
|
lookups.append(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def execute(self, _query):
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
auth.prime_generations({})
|
||||||
|
assert await auth._resolve_unknown_login(login) is None
|
||||||
|
assert await auth._resolve_unknown_login(login) is None
|
||||||
|
assert lookups == [1], "a replayed cookie must not query users on every request"
|
||||||
|
assert auth._lookup_locks == {}
|
||||||
|
|
||||||
|
await auth.sync_generations()
|
||||||
|
assert await auth._resolve_unknown_login(login) is None
|
||||||
|
assert lookups == [1, 1], "the negative result lives only until the next sync"
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
try:
|
||||||
|
asyncio.run(run())
|
||||||
|
finally:
|
||||||
|
auth._missing_logins.discard(login)
|
||||||
|
|
||||||
|
|
||||||
def test_auth_middleware_uses_fresh_generation_cache_without_per_request_database_query(monkeypatch):
|
def test_auth_middleware_uses_fresh_generation_cache_without_per_request_database_query(monkeypatch):
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,6 +160,10 @@ 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,
|
consecutive_refusals=2,
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,8 @@ def test_bounce_is_refused_in_favour_of_112_control():
|
||||||
outcome = state.desk.apply(CardBounce(missing_fields=["floor"]), state)
|
outcome = state.desk.apply(CardBounce(missing_fields=["floor"]), state)
|
||||||
|
|
||||||
assert outcome.error == (
|
assert outcome.error == (
|
||||||
"ДДС не проверяет заполнение карточки: замечания передаёт служба контроля 112")
|
"Карточку не возвращают в 112 при принятии и не правят на АРМ ДДС. "
|
||||||
|
"Если ошибка вскрылась по докладу бригады с места — сообщите о ней в 112 по телефону")
|
||||||
|
|
||||||
|
|
||||||
def test_refusals_leave_desk_untouched():
|
def test_refusals_leave_desk_untouched():
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,9 @@ def fire():
|
||||||
|
|
||||||
def accept_with_crew(state: SessionState, card) -> None:
|
def accept_with_crew(state: SessionState, card) -> None:
|
||||||
service = state.card_services(card)[0]
|
service = state.card_services(card)[0]
|
||||||
|
card.on_event("dds.open") # карточка открыта: 30 с остановлены
|
||||||
card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу")
|
card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу")
|
||||||
card.on_event("card.ack")
|
card.on_event("dds.record")
|
||||||
card.crew_selected = state.crew_options(card)[0]
|
card.crew_selected = state.crew_options(card)[0]
|
||||||
card.crew_assignments[service] = card.crew_selected
|
card.crew_assignments[service] = card.crew_selected
|
||||||
card.dds_log.append(("crew.select", now_utc(), card.crew_selected))
|
card.dds_log.append(("crew.select", now_utc(), card.crew_selected))
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
|
@ -443,17 +444,21 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
|
||||||
reply_metric = next(item for item in score["metrics"] if item["key"] == "dds_reply")
|
reply_metric = next(item for item in score["metrics"] if item["key"] == "dds_reply")
|
||||||
assert reply_metric["passed"]
|
assert reply_metric["passed"]
|
||||||
assert not any(item["key"] == "dds_grammar" for item in score["metrics"])
|
assert not any(item["key"] == "dds_grammar" for item in score["metrics"])
|
||||||
|
# 3 минуты — до первой записи, а она сделана сразу: незавершённая
|
||||||
|
# работа по времени не штрафуется (ответ заказчика, П.5).
|
||||||
assert (
|
assert (
|
||||||
next(item for item in score["metrics"] if item["key"] == "dds_work_time")[
|
next(item for item in score["metrics"] if item["key"] == "dds_work_time")[
|
||||||
"passed"
|
"passed"
|
||||||
]
|
]
|
||||||
is False
|
is True
|
||||||
)
|
)
|
||||||
report = client.get(f"/api/sessions/{session_id}/report").json()
|
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||||
assert report["criteria"] == {
|
assert report["criteria"] == {
|
||||||
"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},
|
||||||
|
|
@ -500,9 +505,8 @@ def test_dds_statuses_do_not_require_phone_reports(client):
|
||||||
finding["code"] == "D2" and "бригады" in finding["summary"]
|
finding["code"] == "D2" and "бригады" in finding["summary"]
|
||||||
for finding in score["findings"]
|
for finding in score["findings"]
|
||||||
)
|
)
|
||||||
assert any(
|
assert not any(finding["code"] == "E3" for finding in score["findings"]), (
|
||||||
finding["code"] == "E3" and "времени отработки" in finding["summary"]
|
"первая запись сделана сразу: дальше работы по времени не нормируются"
|
||||||
for finding in score["findings"]
|
|
||||||
)
|
)
|
||||||
finding_codes = {finding["code"] for finding in score["findings"]}
|
finding_codes = {finding["code"] for finding in score["findings"]}
|
||||||
penalty_codes = {
|
penalty_codes = {
|
||||||
|
|
@ -703,22 +707,26 @@ def test_complete_dds_workflow_scores_100_with_assignment_without_call(client):
|
||||||
control.__exit__(None, None, None)
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client):
|
def test_late_first_record_has_e3_and_long_work_after_it_does_not(client):
|
||||||
|
"""П.5: 3 минуты — на открытие и первую запись; дальше работы идут часы."""
|
||||||
session_id, control = start(
|
session_id, control = start(
|
||||||
client, criteria={"dds_card_work_time_limit_seconds": 60}
|
client, criteria={"dds_card_work_time_limit_seconds": 60}
|
||||||
)
|
)
|
||||||
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:
|
||||||
read_until(station, "card.received")
|
received = read_until(station, "card.received")
|
||||||
snapshot = read_until(station, "station.state")["snapshot"]
|
snapshot = read_until(station, "station.state")["snapshot"]
|
||||||
service = snapshot["services"][0]
|
service = snapshot["services"][0]
|
||||||
|
station.send_json({"type": "card.open", "card_id": received["card"]["card_id"]})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
assert state.desk.active.timers.measured_ms(TimerCode.DDS_ACK) is not None
|
||||||
|
state.desk.active.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61
|
||||||
station.send_json(
|
station.send_json(
|
||||||
{"type": "card.status", "service": service, "status": "accepted",
|
{"type": "card.status", "service": service, "status": "accepted",
|
||||||
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
|
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
|
||||||
)
|
)
|
||||||
snapshot = read_until(station, "station.state")["snapshot"]
|
snapshot = read_until(station, "station.state")["snapshot"]
|
||||||
state.desk.active.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61
|
|
||||||
crew = next(
|
crew = next(
|
||||||
option
|
option
|
||||||
for option in snapshot["crew_options"]
|
for option in snapshot["crew_options"]
|
||||||
|
|
@ -740,14 +748,14 @@ def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client)
|
||||||
)
|
)
|
||||||
assert not metric["passed"]
|
assert not metric["passed"]
|
||||||
assert "61 с" in metric["fact"] and "+1 с" in metric["fact"]
|
assert "61 с" in metric["fact"] and "+1 с" in metric["fact"]
|
||||||
finding = next(item for item in score["findings"] if item["code"] == "E3")
|
findings = [item for item in score["findings"] if item["code"] == "E3"]
|
||||||
assert "времени отработки карточки" in finding["summary"]
|
assert len(findings) == 1 and "первая запись" in findings[0]["summary"]
|
||||||
finally:
|
finally:
|
||||||
control.__exit__(None, None, None)
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
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 +805,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)
|
||||||
|
|
||||||
|
|
@ -916,7 +1038,9 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
|
||||||
item["key"] == "dds_grammar"
|
item["key"] == "dds_grammar"
|
||||||
for item in state.score["metrics"]
|
for item in state.score["metrics"]
|
||||||
)
|
)
|
||||||
assert any(item["code"] == "E3" for item in state.score["findings"])
|
assert not any(item["code"] == "E3" for item in state.score["findings"]), (
|
||||||
|
"обе карточки получили первую запись сразу"
|
||||||
|
)
|
||||||
report = client.get(f"/api/sessions/{session_id}/report").json()
|
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||||
assert report["scenario_id"] == "fire-apartment-l2"
|
assert report["scenario_id"] == "fire-apartment-l2"
|
||||||
assert len(report["card_results"]) == 2
|
assert len(report["card_results"]) == 2
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
644
backend/tests/test_finding_review.py
Normal file
644
backend/tests/test_finding_review.py
Normal 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
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -84,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"),),
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
@ -143,8 +203,9 @@ def test_concurrent_dds_queue_round_trips_with_each_timer_and_status():
|
||||||
first_card = state.desk.active
|
first_card = state.desk.active
|
||||||
first_id = first_card.card_id
|
first_id = first_card.card_id
|
||||||
first_service = state.card_services(first_card)[0]
|
first_service = state.card_services(first_card)[0]
|
||||||
|
first_card.on_event("dds.open") # карточка открыта: 30 с остановлены
|
||||||
first_card.set_service_status(first_service, ServiceStatus.ACCEPTED, "Принято в работу")
|
first_card.set_service_status(first_service, ServiceStatus.ACCEPTED, "Принято в работу")
|
||||||
first_card.on_event("card.ack")
|
first_card.on_event("dds.record")
|
||||||
second_id = state.desk.ordered()[1].card_id
|
second_id = state.desk.ordered()[1].card_id
|
||||||
assert state.desk.open(second_id)
|
assert state.desk.open(second_id)
|
||||||
|
|
||||||
|
|
|
||||||
686
backend/tests/test_session_pause.py
Normal file
686
backend/tests/test_session_pause.py
Normal 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)
|
||||||
|
|
@ -123,7 +123,7 @@ def test_dds_cannot_return_card_for_112_quality_control(client):
|
||||||
station.send_json({"type": "card.bounce", "missing_fields": ["floor", "victims_count"],
|
station.send_json({"type": "card.bounce", "missing_fields": ["floor", "victims_count"],
|
||||||
"comment": "куда ехать без этажа"})
|
"comment": "куда ехать без этажа"})
|
||||||
error = read_until(station, "error")
|
error = read_until(station, "error")
|
||||||
assert "служба контроля 112" in error["message"]
|
assert "сообщите о ней в 112 по телефону" in error["message"]
|
||||||
assert state.bounced_fields == []
|
assert state.bounced_fields == []
|
||||||
trainee.send_json({"type": "call.hangup"})
|
trainee.send_json({"type": "call.hangup"})
|
||||||
wait_for(lambda: state.score is not None)
|
wait_for(lambda: state.score is not None)
|
||||||
|
|
|
||||||
|
|
@ -62,19 +62,21 @@ def test_card_fill_time_metric_texts(elapsed_ms, completed, fact, passed):
|
||||||
(605_000, True, "605 с (+425 с сверх норматива)", False),
|
(605_000, True, "605 с (+425 с сверх норматива)", False),
|
||||||
(25_000, True, "25 с (на 155 с быстрее норматива)", True),
|
(25_000, True, "25 с (на 155 с быстрее норматива)", True),
|
||||||
(180_000, True, "180 с (точно в норматив)", True),
|
(180_000, True, "180 с (точно в норматив)", True),
|
||||||
(None, False, "карточка не завершена; время обработки не зафиксировано", False),
|
(None, False, "первая запись не добавлена; карточка не открыта", False),
|
||||||
(25_000, False, "карточка не завершена; 25 с (на 155 с быстрее норматива)", False),
|
(25_000, False, "первая запись не добавлена; 25 с (на 155 с быстрее норматива)", False),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_dds_work_time_metric_texts(elapsed_ms, completed, fact, passed):
|
def test_dds_work_time_metric_texts(elapsed_ms, completed, fact, passed):
|
||||||
metric, finding = time_metric(DDS_WORK_TIME, elapsed_ms, 180_000, completed=completed)
|
metric, finding = time_metric(DDS_WORK_TIME, elapsed_ms, 180_000, completed=completed)
|
||||||
|
|
||||||
assert metric.key == "dds_work_time"
|
assert metric.key == "dds_work_time"
|
||||||
assert metric.title == "Отработка карточки ДДС"
|
assert metric.title == "Первая запись по карточке ДДС"
|
||||||
assert metric.fact == fact
|
assert metric.fact == fact
|
||||||
assert metric.norm == "завершить за 180 с"
|
assert metric.norm == "открыть карточку и добавить первую запись (статус и текст) за 180 с"
|
||||||
assert metric.passed is passed
|
assert metric.passed is passed
|
||||||
assert (finding is None) is passed
|
assert (finding is None) is passed
|
||||||
if finding is not None:
|
if finding is not None:
|
||||||
assert finding.summary == "ДДС: норматив времени отработки карточки не выполнен"
|
assert finding.summary == "ДДС: первая запись по карточке позже норматива"
|
||||||
assert (finding.fact, finding.norm) == (fact, "завершить за 180 с")
|
assert (finding.fact, finding.norm) == (
|
||||||
|
fact, "открыть карточку и добавить первую запись (статус и текст) за 180 с"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ services:
|
||||||
SIP_6003_PASSWORD: ${SIP_6003_PASSWORD:-}
|
SIP_6003_PASSWORD: ${SIP_6003_PASSWORD:-}
|
||||||
SIP_6101_PASSWORD: ${SIP_6101_PASSWORD:-}
|
SIP_6101_PASSWORD: ${SIP_6101_PASSWORD:-}
|
||||||
SIP_6102_PASSWORD: ${SIP_6102_PASSWORD:-}
|
SIP_6102_PASSWORD: ${SIP_6102_PASSWORD:-}
|
||||||
|
SIP_6103_PASSWORD: ${SIP_6103_PASSWORD:-}
|
||||||
SIP_EXTERNAL_MEDIA_ADDRESS: ${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1}
|
SIP_EXTERNAL_MEDIA_ADDRESS: ${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1}
|
||||||
SIP_RTP_START: ${SIP_RTP_START:-10000}
|
SIP_RTP_START: ${SIP_RTP_START:-10000}
|
||||||
SIP_RTP_END: ${SIP_RTP_END:-10099}
|
SIP_RTP_END: ${SIP_RTP_END:-10099}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -71,12 +75,17 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
||||||
{" · "}<a href={`/api/sessions/${report.session_id}/report.pdf`}>документ (PDF)</a>
|
{" · "}<a href={`/api/sessions/${report.session_id}/report.pdf`}>документ (PDF)</a>
|
||||||
</p>
|
</p>
|
||||||
<p className={report.passed ? "state-ok" : "state-violated"}>
|
<p className={report.passed ? "state-ok" : "state-violated"}>
|
||||||
{report.passed ? "Зачёт" : "Не зачтено"}: на разборе {report.failed_metrics} пунктов; допустимо пропустить {report.criteria.allowed_errors}. {report.exercise === "dds" && <>На первичное решение ДДС отводится
|
{report.passed ? "Зачёт" : "Не зачтено"}: на разборе {report.failed_metrics} пунктов; допустимо пропустить {report.criteria.allowed_errors}. {report.exercise === "dds" && <>На открытие карточки ДДС отводится
|
||||||
{` ${report.criteria.decision_time_limit_seconds} с`}</>}
|
{` ${report.criteria.decision_time_limit_seconds} с`}</>}
|
||||||
{report.exercise === "dds" && <>; норматив отработки карточки ДДС —
|
{report.exercise === "dds" && <>; на первую запись по карточке —
|
||||||
{` ${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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
162
frontend/src/features/debrief/FindingReview.tsx
Normal file
162
frontend/src/features/debrief/FindingReview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -25,13 +25,14 @@ const OPERATOR_METRICS: MetricOption[] = [
|
||||||
|
|
||||||
const DDS_METRICS: MetricOption[] = [
|
const DDS_METRICS: MetricOption[] = [
|
||||||
{ key: "dds_primary", label: "Первичное решение", hint: "Зафиксирован ли статус «Принята» или «Не принята»." },
|
{ key: "dds_primary", label: "Первичное решение", hint: "Зафиксирован ли статус «Принята» или «Не принята»." },
|
||||||
{ key: "dds_ack", label: "Срок первичного решения", hint: "Соблюдение установленного лимита реакции." },
|
{ key: "dds_ack", label: "Открытие карточки", hint: "Карточка открыта в пределах лимита после появления в строке сообщений." },
|
||||||
{ key: "dds_decision", label: "Решение по эталону", hint: "Соответствие принятию или обоснованному отказу." },
|
{ key: "dds_decision", label: "Решение по эталону", hint: "Соответствие принятию или обоснованному отказу." },
|
||||||
{ key: "dds_crew", label: "Назначение бригады", hint: "Выбор необходимой бригады ДДС." },
|
{ key: "dds_crew", label: "Назначение бригады", hint: "Выбор необходимой бригады ДДС." },
|
||||||
{ key: "dds_progress", label: "Ход реагирования", hint: "Статусы по фактическому ходу работ или отказу." },
|
{ key: "dds_progress", label: "Ход реагирования", hint: "Статусы по фактическому ходу работ или отказу." },
|
||||||
{ 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 };
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,19 @@ const LABEL: Record<SipStatus, string> = {
|
||||||
error: "ошибка",
|
error: "ошибка",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Абонент быстрого набора: пульт ДДС звонит бригаде и начальнику одной кнопкой. */
|
||||||
|
export interface SipContact {
|
||||||
|
label: string;
|
||||||
|
number: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function SipPhone({
|
export function SipPhone({
|
||||||
defaultExtension = "6101", defaultTarget = "6102", title = "Локальный SIP/WebRTC",
|
defaultExtension = "6101", defaultTarget = "6102", title = "Локальный SIP/WebRTC", contacts = [],
|
||||||
}: {
|
}: {
|
||||||
defaultExtension?: string;
|
defaultExtension?: string;
|
||||||
defaultTarget?: string;
|
defaultTarget?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
contacts?: SipContact[];
|
||||||
}) {
|
}) {
|
||||||
const phone = useSipPhone();
|
const phone = useSipPhone();
|
||||||
const [extension, setExtension] = useState(defaultExtension);
|
const [extension, setExtension] = useState(defaultExtension);
|
||||||
|
|
@ -46,6 +53,10 @@ export function SipPhone({
|
||||||
<label>Номер собеседника<input aria-label="Номер SIP собеседника" inputMode="numeric" value={target}
|
<label>Номер собеседника<input aria-label="Номер SIP собеседника" inputMode="numeric" value={target}
|
||||||
disabled={inCall} onChange={(event) => setTarget(event.target.value)} /></label>
|
disabled={inCall} onChange={(event) => setTarget(event.target.value)} /></label>
|
||||||
{phone.status === "registered" && <button type="button" onClick={() => phone.dial(target)}>Позвонить</button>}
|
{phone.status === "registered" && <button type="button" onClick={() => phone.dial(target)}>Позвонить</button>}
|
||||||
|
{phone.status === "registered" && contacts.map((contact) => <button type="button" key={contact.number}
|
||||||
|
onClick={() => { setTarget(contact.number); phone.dial(contact.number); }}>
|
||||||
|
Позвонить: {contact.label} · {contact.number}
|
||||||
|
</button>)}
|
||||||
{phone.status === "incoming" && <button type="button" className="sip-answer" onClick={phone.answer}>Ответить · {phone.peer}</button>}
|
{phone.status === "incoming" && <button type="button" className="sip-answer" onClick={phone.answer}>Ответить · {phone.peer}</button>}
|
||||||
{inCall && phone.status !== "incoming" && <span>{phone.peer || target}</span>}
|
{inCall && phone.status !== "incoming" && <span>{phone.peer || target}</span>}
|
||||||
{inCall && <button type="button" className="sip-hangup" onClick={phone.hangup}>Завершить</button>}
|
{inCall && <button type="button" className="sip-hangup" onClick={phone.hangup}>Завершить</button>}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ const TITLES: Record<string, string> = {
|
||||||
answer: "Ответ на звонок",
|
answer: "Ответ на звонок",
|
||||||
interview: "Опрос",
|
interview: "Опрос",
|
||||||
dds_notify: "Оповещение ДДС",
|
dds_notify: "Оповещение ДДС",
|
||||||
dds_ack: "Подтверждение ДДС",
|
dds_ack: "Открытие карточки ДДС",
|
||||||
dds_work: "Отработка карточки ДДС",
|
dds_work: "Первая запись ДДС",
|
||||||
card_fill: "Заполнение карточки КИО",
|
card_fill: "Заполнение карточки КИО",
|
||||||
zone_check: "Зона ответственности",
|
zone_check: "Зона ответственности",
|
||||||
callback: "Обратный дозвон",
|
callback: "Обратный дозвон",
|
||||||
|
|
|
||||||
|
|
@ -238,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 } =
|
||||||
|
|
@ -257,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;
|
||||||
|
|
@ -303,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);
|
||||||
|
|
@ -323,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 ?? [];
|
||||||
|
|
@ -333,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;
|
||||||
|
|
@ -373,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"}>
|
||||||
|
|
@ -414,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">
|
||||||
|
|
@ -489,7 +498,7 @@ export function Dds() {
|
||||||
<td data-label="Статус службы"><span className="dds-cell-value">
|
<td data-label="Статус службы"><span className="dds-cell-value">
|
||||||
<span>{item.managed_service || "ДДС"}: {SERVICE_STATUS_LABELS[item.service_status]}</span>
|
<span>{item.managed_service || "ДДС"}: {SERVICE_STATUS_LABELS[item.service_status]}</span>
|
||||||
<small className={!item.timer_stopped && item.elapsed_ms > item.limit_ms ? "state-violated" : ""}>
|
<small className={!item.timer_stopped && item.elapsed_ms > item.limit_ms ? "state-violated" : ""}>
|
||||||
{item.timer_stopped ? "решение за" : "до первичного решения"} {Math.ceil(item.elapsed_ms / 1000)} с
|
{item.timer_stopped ? "открыта за" : "не открыта"} {Math.ceil(item.elapsed_ms / 1000)} с
|
||||||
</small>
|
</small>
|
||||||
</span></td>
|
</span></td>
|
||||||
</tr>})}
|
</tr>})}
|
||||||
|
|
@ -604,9 +613,11 @@ export function Dds() {
|
||||||
</div><aside className="dds-right">
|
</div><aside className="dds-right">
|
||||||
{pane === "phone" && <section className="dds-panel dds-phone"><div className="dds-panel-title"><h2>IP-телефон</h2><span>SIP/WebRTC + учебный протокол</span></div>
|
{pane === "phone" && <section className="dds-panel dds-phone"><div className="dds-panel-title"><h2>IP-телефон</h2><span>SIP/WebRTC + учебный протокол</span></div>
|
||||||
<Suspense fallback={<p className="dds-hint">Загружаем IP-телефон…</p>}>
|
<Suspense fallback={<p className="dds-hint">Загружаем IP-телефон…</p>}>
|
||||||
<SipPhone defaultExtension="6101" defaultTarget="6102" title="Реальный локальный канал ДДС" />
|
<SipPhone defaultExtension="6101" defaultTarget="6102" title="Реальный локальный канал ДДС"
|
||||||
|
contacts={[{ label: "старший группы", number: "6102" },
|
||||||
|
{ label: "вышестоящий начальник", number: "6103" }]} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<p className="dds-hint">На втором рабочем месте откройте <a href="/phone" target="_blank" rel="noreferrer">телефон старшего группы</a> и зарегистрируйте 6102. Пароли 6101/6102 выдаёт локальный администратор командой <code>make sip-credentials</code>.</p>
|
<p className="dds-hint">На втором рабочем месте откройте <a href="/phone" target="_blank" rel="noreferrer">телефон старшего группы</a> (6102); преподаватель в роли вышестоящего начальника — <a href="/phone?role=superior" target="_blank" rel="noreferrer">телефон 6103</a>. Пароли 6101–6103 выдаёт локальный администратор командой <code>make sip-credentials</code>.</p>
|
||||||
<h3 className="dds-protocol-title">Учебный протокол доклада</h3>
|
<h3 className="dds-protocol-title">Учебный протокол доклада</h3>
|
||||||
<p className="dds-hint">Связь используется при необходимости. Разговор и учебный доклад сохраняются в журнале, но не блокируют изменение статуса.</p>
|
<p className="dds-hint">Связь используется при необходимости. Разговор и учебный доклад сохраняются в журнале, но не блокируют изменение статуса.</p>
|
||||||
<label htmlFor="dds-crew">Дежурная бригада</label>
|
<label htmlFor="dds-crew">Дежурная бригада</label>
|
||||||
|
|
@ -669,7 +680,7 @@ export function Dds() {
|
||||||
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: true }); }}>Наша</button>
|
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: true }); }}>Наша</button>
|
||||||
<button type="button" disabled={zone !== null || !isConnected || ended}
|
<button type="button" disabled={zone !== null || !isConnected || ended}
|
||||||
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: false }); }}>Не наша</button></div>
|
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: false }); }}>Не наша</button></div>
|
||||||
<p className="dds-hint">ДДС не исправляет и не возвращает заполненную карточку: её качество контролируют оператор и служба контроля 112.</p>
|
<p className="dds-hint">Карточку не правят на АРМ ДДС и не возвращают в 112 при принятии. Если бригада с места доложит о расхождении с карточкой — сообщите об ошибке в 112 по телефону.</p>
|
||||||
<button type="button" className="dds-finish" disabled={!cardId || !isConnected || ended || advancePending}
|
<button type="button" className="dds-finish" disabled={!cardId || !isConnected || ended || advancePending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (reply.dirty && !send({ type: "card.reply", card_id: cardId, text: reply.text })) return;
|
if (reply.dirty && !send({ type: "card.reply", card_id: cardId, text: reply.text })) return;
|
||||||
|
|
@ -709,6 +720,7 @@ export function Dds() {
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>}
|
</div>}
|
||||||
|
</fieldset>
|
||||||
</main>;
|
</main>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -92,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>>({});
|
||||||
|
|
@ -290,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,
|
||||||
|
|
@ -306,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]);
|
||||||
|
|
||||||
|
|
@ -364,9 +372,9 @@ export function Instructor() {
|
||||||
<strong>{card.active ? "Сейчас в работе: " : "Ожидает: "}{card.title}</strong>
|
<strong>{card.active ? "Сейчас в работе: " : "Ожидает: "}{card.title}</strong>
|
||||||
{card.address && <span>{card.address}</span>}
|
{card.address && <span>{card.address}</span>}
|
||||||
<small>{card.managed_service ? `${card.managed_service} · ` : ""}{SERVICE_STATUS_LABELS[card.service_status]} · {CARD_STATUS_LABELS[card.card_status]}</small>
|
<small>{card.managed_service ? `${card.managed_service} · ` : ""}{SERVICE_STATUS_LABELS[card.service_status]} · {CARD_STATUS_LABELS[card.card_status]}</small>
|
||||||
<small>{card.timer_stopped ? `Первичная реакция за ${Math.ceil(card.elapsed_ms / 1000)} с` :
|
<small>{card.timer_stopped ? `Открыта за ${Math.ceil(card.elapsed_ms / 1000)} с` :
|
||||||
card.elapsed_ms > card.limit_ms ? `Просрочена первичная реакция · ${Math.ceil((card.elapsed_ms - card.limit_ms) / 1000)} с` :
|
card.elapsed_ms > card.limit_ms ? `Просрочено открытие · ${Math.ceil((card.elapsed_ms - card.limit_ms) / 1000)} с` :
|
||||||
`На первичное решение осталось ${Math.ceil((card.limit_ms - card.elapsed_ms) / 1000)} с`}</small>
|
`До открытия осталось ${Math.ceil((card.limit_ms - card.elapsed_ms) / 1000)} с`}</small>
|
||||||
</article>)}
|
</article>)}
|
||||||
{item.dds_snapshot.pending_cards_count ? <p className="ref">Ещё поступят по сценарию: {item.dds_snapshot.pending_cards_count}</p> : null}
|
{item.dds_snapshot.pending_cards_count ? <p className="ref">Ещё поступят по сценарию: {item.dds_snapshot.pending_cards_count}</p> : null}
|
||||||
{item.dds_snapshot.log.length > 0 && <div className="active-session-log">
|
{item.dds_snapshot.log.length > 0 && <div className="active-session-log">
|
||||||
|
|
@ -387,7 +395,7 @@ export function Instructor() {
|
||||||
<td>{item.exercise === "dds" ? `Открытых карточек: ${item.dds_open_cards}` : "—"}</td>
|
<td>{item.exercise === "dds" ? `Открытых карточек: ${item.dds_open_cards}` : "—"}</td>
|
||||||
<td className={item.dds_overdue_cards ? "state-violated" : "state-ok"}>
|
<td className={item.dds_overdue_cards ? "state-violated" : "state-ok"}>
|
||||||
{item.dds_overdue_cards || item.dds_work_overdue_cards
|
{item.dds_overdue_cards || item.dds_work_overdue_cards
|
||||||
? `Просрочено: первичная реакция ${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}>
|
<td className={worstSignalSeverity(item.signals) ? `state-${worstSignalSeverity(item.signals)}` : item.presence_known ? "state-ok" : undefined}>
|
||||||
|
|
@ -623,9 +631,9 @@ export function Instructor() {
|
||||||
<summary>Настроить нормативы и критерии оценки</summary>
|
<summary>Настроить нормативы и критерии оценки</summary>
|
||||||
<p className="ref">
|
<p className="ref">
|
||||||
{exercise === "dds"
|
{exercise === "dds"
|
||||||
? `По умолчанию: решение за ${decisionTimeLimit} с, отработка карточки за ${ddsCardWorkTimeLimit} с; новые карточки поступают каждые ${ddsArrivalIntervalSeconds} с.`
|
? `По умолчанию: открытие карточки за ${decisionTimeLimit} с, первая запись за ${ddsCardWorkTimeLimit} с; новые карточки поступают каждые ${ddsArrivalIntervalSeconds} с.`
|
||||||
: exercise === "card_dds"
|
: exercise === "card_dds"
|
||||||
? `По умолчанию: заполнение КИО за ${cardFillTimeLimit} с, отработка карточки ДДС за ${ddsCardWorkTimeLimit} с.`
|
? `По умолчанию: заполнение КИО за ${cardFillTimeLimit} с, первая запись ДДС за ${ddsCardWorkTimeLimit} с.`
|
||||||
: exercise === "card"
|
: exercise === "card"
|
||||||
? `По умолчанию: заполнение КИО за ${cardFillTimeLimit} с.`
|
? `По умолчанию: заполнение КИО за ${cardFillTimeLimit} с.`
|
||||||
: "Для звонка действуют нормативы приёма вызова по ГОСТ."}
|
: "Для звонка действуют нормативы приёма вызова по ГОСТ."}
|
||||||
|
|
@ -644,8 +652,8 @@ export function Instructor() {
|
||||||
</label>
|
</label>
|
||||||
</>}
|
</>}
|
||||||
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
|
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
|
||||||
Решение ДДС, секунд
|
Открытие карточки ДДС, секунд
|
||||||
<LessonNumberInput label="Лимит решения ДДС, секунд" value={decisionTimeLimit}
|
<LessonNumberInput label="Лимит открытия карточки ДДС, секунд" value={decisionTimeLimit}
|
||||||
min={5} max={300} fallback={30} onCommit={setDecisionTimeLimit} />
|
min={5} max={300} fallback={30} onCommit={setDecisionTimeLimit} />
|
||||||
</label>}
|
</label>}
|
||||||
{(exercise === "card" || exercise === "card_dds") && <label className="lesson-criterion">
|
{(exercise === "card" || exercise === "card_dds") && <label className="lesson-criterion">
|
||||||
|
|
@ -655,12 +663,26 @@ export function Instructor() {
|
||||||
<small className="ref">По умолчанию 180 секунд; в отчёте будут факт и отклонение от норматива.</small>
|
<small className="ref">По умолчанию 180 секунд; в отчёте будут факт и отклонение от норматива.</small>
|
||||||
</label>}
|
</label>}
|
||||||
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
|
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
|
||||||
Норматив отработки карточки ДДС, секунд
|
Первая запись по карточке ДДС, секунд
|
||||||
<LessonNumberInput label="Норматив отработки карточки ДДС, секунд"
|
<LessonNumberInput label="Норматив первой записи по карточке ДДС, секунд"
|
||||||
value={ddsCardWorkTimeLimit} min={30} max={1800} fallback={180}
|
value={ddsCardWorkTimeLimit} min={30} max={1800} fallback={180}
|
||||||
onCommit={setDdsCardWorkTimeLimit} />
|
onCommit={setDdsCardWorkTimeLimit} />
|
||||||
<small className="ref">По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.</small>
|
<small className="ref">По умолчанию 180 секунд от открытия до первого статуса с текстом; дальше работы по времени не нормируются.</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}
|
||||||
|
|
@ -672,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>
|
||||||
|
|
@ -708,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>
|
||||||
|
|
@ -814,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>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,22 @@
|
||||||
import { SipPhone } from "@/features/sip/SipPhone";
|
import { SipPhone } from "@/features/sip/SipPhone";
|
||||||
|
|
||||||
|
// Второе рабочее место голосовой связи ДДС. Старший группы — по умолчанию;
|
||||||
|
// вышестоящего начальника (?role=superior) играет преподаватель: это
|
||||||
|
// обязательный маршрут по ответу заказчика (П.7).
|
||||||
|
const ROLES = {
|
||||||
|
crew: { title: "Телефон старшего группы", extension: "6102", heading: "IP-телефон старшего группы" },
|
||||||
|
superior: { title: "Телефон вышестоящего начальника", extension: "6103", heading: "IP-телефон вышестоящего начальника" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
export function Phone() {
|
export function Phone() {
|
||||||
|
const role = new URLSearchParams(window.location.search).get("role") === "superior" ? "superior" : "crew";
|
||||||
|
const { title, extension, heading } = ROLES[role];
|
||||||
return <main className="page phone-page">
|
return <main className="page phone-page">
|
||||||
<h1>IP-телефон старшего группы</h1>
|
<h1>{heading}</h1>
|
||||||
<p>Откройте этот экран на втором рабочем месте, зарегистрируйте номер 6102 и примите вызов ДДС 6101.</p>
|
<p>Откройте этот экран на втором рабочем месте, зарегистрируйте номер {extension} и примите вызов ДДС 6101.</p>
|
||||||
<SipPhone defaultExtension="6102" defaultTarget="6101" title="Телефон старшего группы" />
|
<SipPhone key={role} defaultExtension={extension} defaultTarget="6101" title={title} />
|
||||||
|
<p className="ref">{role === "crew"
|
||||||
|
? <>Роль вышестоящего начальника — <a href="/phone?role=superior">телефон 6103</a>.</>
|
||||||
|
: <>Роль старшего группы — <a href="/phone">телефон 6102</a>.</>}</p>
|
||||||
</main>;
|
</main>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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,6 +360,7 @@ 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;
|
||||||
/** Список оповещения */
|
/** Список оповещения */
|
||||||
|
|
@ -382,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>;
|
||||||
|
|
@ -409,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 {
|
||||||
|
|
@ -587,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;
|
||||||
|
|
@ -610,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";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Обязательно при подключении: монитор в классе включают посреди занятия. */
|
/** Обязательно при подключении: монитор в классе включают посреди занятия. */
|
||||||
|
|
@ -633,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 {
|
||||||
|
|
@ -669,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
|
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
|
||||||
|
|
@ -777,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;
|
||||||
|
|
||||||
|
|
@ -837,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"
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ def login(base: str, login_name: str, password: str) -> str:
|
||||||
return cookie
|
return cookie
|
||||||
|
|
||||||
|
|
||||||
async def send_control(ws_base: str, session_id: UUID, cookie: str, payload: dict) -> None:
|
async def send_control(ws_base: str, session_id: UUID, cookie: str, payload: dict) -> float:
|
||||||
async with websockets.connect(
|
async with websockets.connect(
|
||||||
f"{ws_base}/ws/control/{session_id}",
|
f"{ws_base}/ws/control/{session_id}",
|
||||||
additional_headers={"Cookie": cookie},
|
additional_headers={"Cookie": cookie},
|
||||||
|
|
@ -81,7 +81,9 @@ async def send_control(ws_base: str, session_id: UUID, cookie: str, payload: dic
|
||||||
ping_interval=None,
|
ping_interval=None,
|
||||||
) as socket:
|
) as socket:
|
||||||
await socket.send(json.dumps(payload, ensure_ascii=False))
|
await socket.send(json.dumps(payload, ensure_ascii=False))
|
||||||
|
sent_at = time.monotonic()
|
||||||
await asyncio.sleep(0.15)
|
await asyncio.sleep(0.15)
|
||||||
|
return sent_at
|
||||||
|
|
||||||
|
|
||||||
async def probe_ws(ws_base: str, path: str, cookie: str) -> dict:
|
async def probe_ws(ws_base: str, path: str, cookie: str) -> dict:
|
||||||
|
|
@ -238,7 +240,7 @@ async def main(args: argparse.Namespace) -> int:
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.pool import NullPool
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from app.api.auth import hash_password
|
from app.api.auth import hash_password, login_log_marker
|
||||||
from app.db.models import AuditLog, Session, Trainee, User
|
from app.db.models import AuditLog, Session, Trainee, User
|
||||||
|
|
||||||
database_url = (
|
database_url = (
|
||||||
|
|
@ -251,7 +253,7 @@ async def main(args: argparse.Namespace) -> int:
|
||||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
observer_engine = create_async_engine(database_url, poolclass=NullPool)
|
observer_engine = create_async_engine(database_url, poolclass=NullPool)
|
||||||
run_id = uuid4().hex[:12]
|
run_id = uuid4().hex[:12]
|
||||||
session_id = uuid4()
|
session_id = args.session_id or uuid4()
|
||||||
trainee = Trainee(name=f"Failover smoke {run_id}")
|
trainee = Trainee(name=f"Failover smoke {run_id}")
|
||||||
instructor_login = f"fo-i-{run_id}"
|
instructor_login = f"fo-i-{run_id}"
|
||||||
trainee_login = f"fo-t-{run_id}"
|
trainee_login = f"fo-t-{run_id}"
|
||||||
|
|
@ -263,6 +265,7 @@ async def main(args: argparse.Namespace) -> int:
|
||||||
fault_stopped = False
|
fault_stopped = False
|
||||||
result: dict = {"session_id": str(session_id), "checks": {}}
|
result: dict = {"session_id": str(session_id), "checks": {}}
|
||||||
backend = f"http://127.0.0.1:{args.backend_port}"
|
backend = f"http://127.0.0.1:{args.backend_port}"
|
||||||
|
result["login_node"] = "backend-a"
|
||||||
ws_base = args.frontend_url.replace("https://", "wss://").replace("http://", "ws://")
|
ws_base = args.frontend_url.replace("https://", "wss://").replace("http://", "ws://")
|
||||||
instructor_cookie = trainee_cookie = None
|
instructor_cookie = trainee_cookie = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -278,21 +281,32 @@ async def main(args: argparse.Namespace) -> int:
|
||||||
trainee_id=trainee.id, blocked=False),
|
trainee_id=trainee.id, blocked=False),
|
||||||
])
|
])
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
users_committed_at = time.monotonic()
|
||||||
|
|
||||||
instructor_cookie = login(backend, instructor_login, instructor_password)
|
instructor_cookie = login(backend, instructor_login, instructor_password)
|
||||||
trainee_cookie = login(backend, trainee_login, trainee_password)
|
trainee_cookie = login(backend, trainee_login, trainee_password)
|
||||||
# Учётки созданы прямо в БД, вход — на узле A, а сокет занятия Nginx
|
# Учётки созданы прямо в БД, вход — на узле A, а сокет занятия Nginx
|
||||||
# может отдать узлу B. B узнаёт новый логин при очередной сверке
|
# может отдать узлу B до его сверки поколений. Ждать сверку не нужно:
|
||||||
# поколений (раз в секунду); до неё middleware считает cookie отозванной.
|
# B проверяет неизвестный логин разовым SELECT (lct-42). Ожидание здесь
|
||||||
for port in (args.backend_port, args.backend_b_port):
|
# лишило бы доказательности проверку ниже: опрос B сам вызвал бы разовую
|
||||||
for cookie in (instructor_cookie, trainee_cookie):
|
# проверку, а за секунду B узнал бы логин сверкой.
|
||||||
if not await wait_auth_fresh(f"http://127.0.0.1:{port}", cookie):
|
control_sent_at = await send_control(ws_base, session_id, instructor_cookie, {
|
||||||
raise RuntimeError(f"backend :{port} не узнал новый логин за 10 с")
|
|
||||||
await send_control(ws_base, session_id, instructor_cookie, {
|
|
||||||
"type": "scenario.start", "scenario_id": args.scenario,
|
"type": "scenario.start", "scenario_id": args.scenario,
|
||||||
"trainee": trainee.name, "trainee_id": str(trainee.id),
|
"trainee": trainee.name, "trainee_id": str(trainee.id),
|
||||||
"mode": "training", "exercise": "dds",
|
"mode": "training", "exercise": "dds",
|
||||||
})
|
})
|
||||||
|
result["users_commit_to_control_seconds"] = round(control_sent_at - users_committed_at, 4)
|
||||||
|
if args.expect_initial_owner == "backend-b":
|
||||||
|
result["checks"]["control_sent_within_first_second"] = (
|
||||||
|
result["users_commit_to_control_seconds"] < 1
|
||||||
|
)
|
||||||
|
# Сверка B идёт раз в секунду в случайной фазе, и время само по себе
|
||||||
|
# не доказывает, что B не знал логин. Доказательство — запись B о
|
||||||
|
# разовой проверке именно этого логина.
|
||||||
|
result["checks"]["backend_b_resolved_login_one_shot"] = (
|
||||||
|
login_log_marker(instructor_login)
|
||||||
|
in compose(args, "logs", "--no-color", "backend-b")
|
||||||
|
)
|
||||||
|
|
||||||
async def session_row():
|
async def session_row():
|
||||||
async with factory() as db:
|
async with factory() as db:
|
||||||
|
|
@ -327,6 +341,12 @@ async def main(args: argparse.Namespace) -> int:
|
||||||
old_epoch = row.backend_fencing_epoch
|
old_epoch = row.backend_fencing_epoch
|
||||||
result["initial_owner"] = row.backend_node_id
|
result["initial_owner"] = row.backend_node_id
|
||||||
result["initial_epoch"] = old_epoch
|
result["initial_epoch"] = old_epoch
|
||||||
|
if args.expect_initial_owner and row.backend_node_id != args.expect_initial_owner:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"session routed to {row.backend_node_id}, expected {args.expect_initial_owner}"
|
||||||
|
)
|
||||||
|
if args.expect_initial_owner == "backend-b":
|
||||||
|
result["checks"]["login_a_initial_control_b"] = True
|
||||||
|
|
||||||
# Commit real DDS work before the fault. These actions must survive the
|
# Commit real DDS work before the fault. These actions must survive the
|
||||||
# checkpoint handoff and remain part of the final scored report.
|
# checkpoint handoff and remain part of the final scored report.
|
||||||
|
|
@ -735,4 +755,6 @@ if __name__ == "__main__":
|
||||||
parser.add_argument("--reconnect-attempts", type=int, default=5)
|
parser.add_argument("--reconnect-attempts", type=int, default=5)
|
||||||
parser.add_argument("--rest-timeout", type=float, default=60)
|
parser.add_argument("--rest-timeout", type=float, default=60)
|
||||||
parser.add_argument("--failure-mode", choices=("kill", "partition"), default="kill")
|
parser.add_argument("--failure-mode", choices=("kill", "partition"), default="kill")
|
||||||
|
parser.add_argument("--expect-initial-owner", choices=("backend-a", "backend-b"))
|
||||||
|
parser.add_argument("--session-id", type=UUID)
|
||||||
raise SystemExit(asyncio.run(main(parser.parse_args())))
|
raise SystemExit(asyncio.run(main(parser.parse_args())))
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ override_6002=${SIP_6002_PASSWORD:-}
|
||||||
override_6003=${SIP_6003_PASSWORD:-}
|
override_6003=${SIP_6003_PASSWORD:-}
|
||||||
override_6101=${SIP_6101_PASSWORD:-}
|
override_6101=${SIP_6101_PASSWORD:-}
|
||||||
override_6102=${SIP_6102_PASSWORD:-}
|
override_6102=${SIP_6102_PASSWORD:-}
|
||||||
|
override_6103=${SIP_6103_PASSWORD:-}
|
||||||
if [ -s "$credentials" ]; then
|
if [ -s "$credentials" ]; then
|
||||||
# Файл содержит только сгенерированные hex-значения без shell-конструкций.
|
# Файл содержит только сгенерированные hex-значения без shell-конструкций.
|
||||||
. "$credentials"
|
. "$credentials"
|
||||||
|
|
@ -19,6 +20,7 @@ SIP_6002_PASSWORD=${override_6002:-${SIP_6002_PASSWORD:-$(openssl rand -hex 16)}
|
||||||
SIP_6003_PASSWORD=${override_6003:-${SIP_6003_PASSWORD:-$(openssl rand -hex 16)}}
|
SIP_6003_PASSWORD=${override_6003:-${SIP_6003_PASSWORD:-$(openssl rand -hex 16)}}
|
||||||
SIP_6101_PASSWORD=${override_6101:-${SIP_6101_PASSWORD:-$(openssl rand -hex 16)}}
|
SIP_6101_PASSWORD=${override_6101:-${SIP_6101_PASSWORD:-$(openssl rand -hex 16)}}
|
||||||
SIP_6102_PASSWORD=${override_6102:-${SIP_6102_PASSWORD:-$(openssl rand -hex 16)}}
|
SIP_6102_PASSWORD=${override_6102:-${SIP_6102_PASSWORD:-$(openssl rand -hex 16)}}
|
||||||
|
SIP_6103_PASSWORD=${override_6103:-${SIP_6103_PASSWORD:-$(openssl rand -hex 16)}}
|
||||||
SIP_EXTERNAL_MEDIA_ADDRESS=${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1}
|
SIP_EXTERNAL_MEDIA_ADDRESS=${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1}
|
||||||
SIP_RTP_START=${SIP_RTP_START:-10000}
|
SIP_RTP_START=${SIP_RTP_START:-10000}
|
||||||
SIP_RTP_END=${SIP_RTP_END:-10099}
|
SIP_RTP_END=${SIP_RTP_END:-10099}
|
||||||
|
|
@ -36,12 +38,13 @@ SIP_6002_PASSWORD=$SIP_6002_PASSWORD
|
||||||
SIP_6003_PASSWORD=$SIP_6003_PASSWORD
|
SIP_6003_PASSWORD=$SIP_6003_PASSWORD
|
||||||
SIP_6101_PASSWORD=$SIP_6101_PASSWORD
|
SIP_6101_PASSWORD=$SIP_6101_PASSWORD
|
||||||
SIP_6102_PASSWORD=$SIP_6102_PASSWORD
|
SIP_6102_PASSWORD=$SIP_6102_PASSWORD
|
||||||
|
SIP_6103_PASSWORD=$SIP_6103_PASSWORD
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "$credentials"
|
chmod 600 "$credentials"
|
||||||
|
|
||||||
export SIP_6001_PASSWORD SIP_6002_PASSWORD SIP_6003_PASSWORD SIP_6101_PASSWORD SIP_6102_PASSWORD SIP_EXTERNAL_MEDIA_ADDRESS
|
export SIP_6001_PASSWORD SIP_6002_PASSWORD SIP_6003_PASSWORD SIP_6101_PASSWORD SIP_6102_PASSWORD SIP_6103_PASSWORD SIP_EXTERNAL_MEDIA_ADDRESS
|
||||||
export SIP_RTP_START SIP_RTP_END
|
export SIP_RTP_START SIP_RTP_END
|
||||||
envsubst '${SIP_6001_PASSWORD} ${SIP_6002_PASSWORD} ${SIP_6003_PASSWORD} ${SIP_6101_PASSWORD} ${SIP_6102_PASSWORD} ${SIP_EXTERNAL_MEDIA_ADDRESS}' \
|
envsubst '${SIP_6001_PASSWORD} ${SIP_6002_PASSWORD} ${SIP_6003_PASSWORD} ${SIP_6101_PASSWORD} ${SIP_6102_PASSWORD} ${SIP_6103_PASSWORD} ${SIP_EXTERNAL_MEDIA_ADDRESS}' \
|
||||||
< /opt/lct-sip/pjsip.conf.template > /etc/asterisk/pjsip.conf
|
< /opt/lct-sip/pjsip.conf.template > /etc/asterisk/pjsip.conf
|
||||||
chmod 640 /etc/asterisk/pjsip.conf
|
chmod 640 /etc/asterisk/pjsip.conf
|
||||||
chown root:asterisk /etc/asterisk/pjsip.conf
|
chown root:asterisk /etc/asterisk/pjsip.conf
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@ exten => _600[1-3],1,NoOp(LCT training call ${CALLERID(num)} -> ${EXTEN})
|
||||||
same => n,StopMixMonitor()
|
same => n,StopMixMonitor()
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
; 6101/6102 — браузерные WebRTC-абоненты. Тот же учебный контекст и запись,
|
; 6101–6103 — браузерные WebRTC-абоненты: ДДС, старший группы, вышестоящий
|
||||||
; но отдельные endpoints не ломают обычные UDP/TCP-софтфоны 6001–6003.
|
; начальник. Тот же учебный контекст и запись, но отдельные endpoints не ломают
|
||||||
exten => _610[1-2],1,NoOp(LCT WebRTC training call ${CALLERID(num)} -> ${EXTEN})
|
; обычные UDP/TCP-софтфоны 6001–6003.
|
||||||
|
exten => _610[1-3],1,NoOp(LCT WebRTC training call ${CALLERID(num)} -> ${EXTEN})
|
||||||
same => n,Set(RECORDING_FILE=${STRFTIME(${EPOCH},UTC,%Y%m%d-%H%M%S)}-${CALLERID(num)}-${EXTEN}-${UNIQUEID})
|
same => n,Set(RECORDING_FILE=${STRFTIME(${EPOCH},UTC,%Y%m%d-%H%M%S)}-${CALLERID(num)}-${EXTEN}-${UNIQUEID})
|
||||||
same => n,MixMonitor(/recordings/${RECORDING_FILE}.wav,b)
|
same => n,MixMonitor(/recordings/${RECORDING_FILE}.wav,b)
|
||||||
same => n,Dial(PJSIP/${EXTEN},30)
|
same => n,Dial(PJSIP/${EXTEN},30)
|
||||||
|
|
|
||||||
|
|
@ -157,3 +157,22 @@ type=aor
|
||||||
max_contacts=2
|
max_contacts=2
|
||||||
remove_existing=yes
|
remove_existing=yes
|
||||||
qualify_frequency=0
|
qualify_frequency=0
|
||||||
|
|
||||||
|
; Вышестоящий начальник ДДС: роль играет преподаватель со страницы
|
||||||
|
; /phone?role=superior. Обязательный голосовой маршрут по ответу заказчика (П.7).
|
||||||
|
[6103](webrtc-endpoint)
|
||||||
|
auth=6103-auth
|
||||||
|
aors=6103
|
||||||
|
callerid=Веб вышестоящий начальник <6103>
|
||||||
|
|
||||||
|
[6103-auth]
|
||||||
|
type=auth
|
||||||
|
auth_type=userpass
|
||||||
|
username=6103
|
||||||
|
password=${SIP_6103_PASSWORD}
|
||||||
|
|
||||||
|
[6103]
|
||||||
|
type=aor
|
||||||
|
max_contacts=2
|
||||||
|
remove_existing=yes
|
||||||
|
qualify_frequency=0
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue