Implement DDS exercise, customer UI and local demo

This commit is contained in:
andreysk0304 2026-09-21 19:56:13 +03:00
commit 0526b11f91
46 changed files with 2504 additions and 253 deletions

View file

@ -8,6 +8,7 @@ UV ?= uv
# молча ждёт блокировку. Один раз поставить оба набора:
# cd backend && uv sync --extra dev --extra voice
COMPOSE ?= docker compose
DEMO_PORT ?= 8112
.DEFAULT_GOAL := help
@ -83,4 +84,7 @@ pregen: ## Офлайн-таблицы реплик: make pregen [s=<сцена
demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт
./scripts/demo.sh
.PHONY: help dev offline down back front types users users-docker backup test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo
demo-lite: ## Локальная демонстрация карточки/ДДС без Docker, БД, голоса и внешней сети
cd backend && UV_CACHE_DIR=/tmp/lct-uv-demo-cache OFFLINE=true VOICE_ENABLED=false DEV_AUTH_BYPASS=true DEMO_NO_DB=true $(UV) run --offline --no-sync uvicorn app.main:app --host 127.0.0.1 --port $(DEMO_PORT) --workers 1
.PHONY: help dev offline down back front types users users-docker backup test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo demo-lite

View file

@ -15,7 +15,10 @@
протокола авторизации в канале нет.
"""
import asyncio
import logging
import secrets
import weakref
from uuid import UUID
from argon2 import PasswordHasher
@ -31,8 +34,58 @@ from app.domain.roles import Role
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
DEMO_TRAINEE_ID = UUID("00000000-0000-4000-8000-000000000112")
_hasher = PasswordHasher()
# Cookie is signed, but carries a role snapshot. A changed account must not
# keep its old privileges for the full 12-hour cookie lifetime. Compose runs
# one worker; the process marker also invalidates all cookies after restart.
_INSTANCE = secrets.token_urlsafe(32)
_generations: dict[str, int] = {}
_active_sockets: dict[str, weakref.WeakKeyDictionary] = {}
async def _close_revoked(ws: WebSocket) -> None:
try:
await ws.close(code=1008, reason="Учётная запись изменена: войдите снова")
except (RuntimeError, OSError):
# The peer may already have disconnected; revocation still stands.
pass
def invalidate_login(login: str) -> None:
"""Revoke previously issued cookies after account/role/password changes."""
_generations[login] = _generations.get(login, 0) + 1
for ws, loop in list(_active_sockets.get(login, {}).items()):
try:
if not loop.is_closed():
loop.call_soon_threadsafe(lambda socket=ws: asyncio.create_task(_close_revoked(socket)))
except RuntimeError:
pass # loop closed between the check and scheduling
def _session_principal(session: dict) -> "Principal | None":
data = session.get("principal")
if not isinstance(data, dict):
return None
login = data.get("login")
if not isinstance(login, str):
return None
if session.get("auth_instance") != _INSTANCE:
return None
if session.get("auth_generation") != _generations.get(login, 0):
return None
try:
return Principal.model_validate(data)
except Exception: # malformed signed cookie: reject, do not 500
log.warning("неверный формат principal в cookie")
return None
def _issue_session(request: Request, who: "Principal") -> None:
request.session["principal"] = who.model_dump(mode="json")
request.session["auth_instance"] = _INSTANCE
request.session["auth_generation"] = _generations.get(who.login, 0)
def hash_password(password: str) -> str:
@ -50,8 +103,7 @@ def verify_password(password_hash: str, password: str) -> bool:
class Principal(BaseModel):
"""Кто действует. Хранится в cookie целиком: ходить в базу за ролью
на каждый запрос незачем, а меняется она правкой учётной записи."""
"""Кто действует. Cookie stores a snapshot, revoked on account edits."""
login: str
full_name: str
@ -69,9 +121,23 @@ class LoginIn(BaseModel):
password: str
def _demo_local(request: Request) -> None:
# При ошибочном bind 0.0.0.0 удалённый клиент всё равно не получит cookie.
if request.client is None or request.client.host not in {"127.0.0.1", "::1", "localhost", "testclient"}:
raise HTTPException(status_code=403, detail="demo_local_only")
def _demo_principal(role: Role) -> Principal:
return Principal(
login="demo-trainee" if role is Role.TRAINEE else "demo-instructor",
full_name="Демо-курсант" if role is Role.TRAINEE else "Демо-преподаватель",
role=role,
trainee_id=DEMO_TRAINEE_ID if role is Role.TRAINEE else None,
)
def current(request: Request) -> Principal | None:
data = request.session.get("principal")
return Principal.model_validate(data) if data else None
return _session_principal(request.session)
def principal_of(websocket: WebSocket) -> Principal | None:
@ -80,8 +146,10 @@ def principal_of(websocket: WebSocket) -> Principal | None:
`WebSocket.session` доступен, потому что `SessionMiddleware` стоит до
роутера: значит роль известна до входа в цикл приёма сообщений.
"""
data = websocket.session.get("principal") if "session" in websocket.scope else None
return Principal.model_validate(data) if data else None
who = _session_principal(websocket.session) if "session" in websocket.scope else None
if who is not None:
_active_sockets.setdefault(who.login, weakref.WeakKeyDictionary())[websocket] = asyncio.get_running_loop()
return who
def require(request: Request, *roles: Role) -> Principal:
@ -99,6 +167,8 @@ async def audit(
) -> None:
"""Запись в журнал. Аудит не должен ронять действие: если база недоступна,
занятие продолжается, а пропуск виден в логе."""
if get_settings().demo_no_db:
return # в явном demo-режиме запись и долговременный аудит недоступны
try:
async with get_sessionmaker()() as db:
db.add(
@ -113,6 +183,16 @@ async def audit(
@router.post("/login")
async def login(payload: LoginIn, request: Request) -> dict:
if get_settings().demo_no_db:
_demo_local(request)
demo_roles = {"demo-instructor": Role.INSTRUCTOR, "demo-trainee": Role.TRAINEE}
role = demo_roles.get(payload.login)
if role is None or not secrets.compare_digest(payload.password, "demo"):
raise HTTPException(status_code=401, detail="bad_credentials")
who = _demo_principal(role)
_issue_session(request, who)
return who.model_dump(mode="json")
async with get_sessionmaker()() as db:
user = await db.scalar(select(User).where(User.login == payload.login))
@ -130,7 +210,7 @@ async def login(payload: LoginIn, request: Request) -> dict:
service=user.service,
trainee_id=user.trainee_id,
)
request.session["principal"] = who.model_dump(mode="json")
_issue_session(request, who)
await audit(who.login, who.role.value, "login")
return who.model_dump(mode="json")
@ -153,7 +233,7 @@ async def me(request: Request) -> dict:
@router.post("/dev-token")
async def dev_token(request: Request) -> dict:
async def dev_token(request: Request, role: Role = Role.INSTRUCTOR) -> dict:
"""Вход без пароля для наших же инструментов.
`make lesson` и тесты открывают сокеты напрямую и после включения ролей
@ -161,8 +241,17 @@ async def dev_token(request: Request) -> dict:
умолчанию выключенным: в рабочем стенде она отвечает 404, а не 401 —
выключенной функции не должно быть видно вовсе.
"""
if not get_settings().dev_auth_bypass:
settings = get_settings()
if not settings.dev_auth_bypass:
raise HTTPException(status_code=404, detail="not_found")
who = Principal(login="dev", full_name="Разработка", role=Role.INSTRUCTOR)
request.session["principal"] = who.model_dump(mode="json")
if role is not Role.INSTRUCTOR and not settings.demo_no_db:
raise HTTPException(status_code=404, detail="not_found")
if settings.demo_no_db:
_demo_local(request)
if role not in {Role.INSTRUCTOR, Role.TRAINEE}:
raise HTTPException(status_code=403, detail="demo_role_forbidden")
who = _demo_principal(role) if settings.demo_no_db else Principal(
login="dev", full_name="Разработка", role=Role.INSTRUCTOR
)
_issue_session(request, who)
return who.model_dump(mode="json")

View file

@ -10,6 +10,7 @@
"""
import logging
import re
from datetime import datetime
from uuid import UUID
@ -18,9 +19,10 @@ from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.concurrency import run_in_threadpool
from app.admin import backup as backup_service
from app.api.auth import audit, hash_password, require
from app.api.auth import audit, hash_password, invalidate_login, require
from app.config import get_settings
from app.db.base import get_session
from app.db.models import AuditLog, Session as SessionRow, Trainee, User
@ -122,6 +124,13 @@ async def patch_user(
changed: list[str] = []
if body.role is not None:
if user.login == who.login and body.role is not Role.ADMIN:
raise HTTPException(status_code=409, detail="cannot_demote_yourself")
if body.role is Role.TRAINEE and user.trainee_id is None:
trainee = Trainee(name=user.full_name)
db.add(trainee)
await db.flush()
user.trainee_id = trainee.id
user.role = body.role.value
changed.append(f"роль {body.role.value}")
if body.service is not None:
@ -137,7 +146,10 @@ async def patch_user(
user.password_hash = hash_password(body.password)
changed.append("пароль сброшен")
if not changed:
return _out(user)
await db.commit()
invalidate_login(user.login)
await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed))
return _out(user)
@ -162,7 +174,7 @@ async def audit_log(
"""Журнал действий. Администратор его читает, но не правит: точки удаления
или изменения записи здесь нет — ТЗ требует хранения, а не управления."""
require(request, Role.ADMIN)
query = select(AuditLog).order_by(AuditLog.at.desc()).limit(min(limit, 1000))
query = select(AuditLog).order_by(AuditLog.at.desc()).limit(max(1, min(limit, 1000)))
if action:
query = query.where(AuditLog.action == action)
if actor:
@ -261,7 +273,19 @@ class BackupOut(BaseModel):
@router.get("/backups", response_model=list[BackupOut])
async def backups(request: Request) -> list[BackupOut]:
require(request, Role.ADMIN)
return [BackupOut(**item) for item in backup_service.listing()]
return [BackupOut(**item) for item in await run_in_threadpool(backup_service.listing)]
def _safe_backup_error(exc: backup_service.BackupError) -> str:
"""Never echo DATABASE_URL/its password from backup diagnostics to HTTP."""
message = str(exc)
dsn = get_settings().database_url
if dsn:
message = message.replace(dsn, "[DATABASE_URL скрыт]")
match = re.search(r"://[^:]+:([^@]+)@", dsn)
if match and match.group(1):
message = message.replace(match.group(1), "[пароль скрыт]")
return message
@router.post("/backups", response_model=BackupOut, status_code=201)
@ -270,8 +294,11 @@ async def make_backup(request: Request) -> BackupOut:
кнопка нужна перед занятием, расписание — чтобы о нём не вспоминали."""
who = require(request, Role.ADMIN)
try:
created = backup_service.create()
# pg_dump may run for two minutes; never block the event loop for it.
created = await run_in_threadpool(backup_service.create)
except backup_service.BackupError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
detail = _safe_backup_error(exc)
await audit(who.login, who.role.value, "backup.failed", detail=detail)
raise HTTPException(status_code=503, detail=detail) from exc
await audit(who.login, who.role.value, "backup.create", created["name"])
return BackupOut(**created)

View file

@ -0,0 +1,125 @@
"""Сводка ошибок и рекомендаций учебной группы для преподавателя."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
from app.db.base import get_session
from app.db.models import Group, Score, Session, Trainee
from app.domain.roles import Role
from app.scoring.group import ScoredAttempt, summarize
router = APIRouter(prefix="/api/groups", tags=["groups"])
class GroupOut(BaseModel):
id: UUID
name: str
class GroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
class GroupErrorOut(BaseModel):
code: str
title: str
affected_trainees: int
occurrences: int
rate_percent: float
recommendation: str
class GroupAnalyticsOut(BaseModel):
group: GroupOut
enrolled_trainees: int
active_trainees: int
scored_attempts: int
average_score: float | None
errors: list[GroupErrorOut]
@router.get("", response_model=list[GroupOut])
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[GroupOut]:
require(request, Role.INSTRUCTOR, Role.ADMIN)
groups = await db.scalars(select(Group).order_by(Group.name))
return [GroupOut(id=group.id, name=group.name) for group in groups]
@router.post("", response_model=GroupOut, status_code=201)
async def create(
body: GroupCreate, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
name = body.name.strip()
if not name:
raise HTTPException(status_code=422, detail="group_name_required")
group = Group(name=name)
db.add(group)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="group_exists") from exc
await audit(who.login, who.role.value, "group.create", str(group.id), group.name)
return GroupOut(id=group.id, name=group.name)
@router.put("/{group_id}/trainees/{trainee_id}", response_model=GroupOut)
async def assign_trainee(
group_id: UUID, trainee_id: UUID, request: Request,
db: AsyncSession = Depends(get_session),
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None:
raise HTTPException(status_code=404, detail="group_not_found")
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
trainee.group_id = group_id
await db.commit()
await audit(who.login, who.role.value, "group.assign", str(group.id), str(trainee_id))
return GroupOut(id=group.id, name=group.name)
@router.get("/{group_id}/analytics", response_model=GroupAnalyticsOut)
async def analytics(
group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupAnalyticsOut:
require(request, Role.INSTRUCTOR, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None:
raise HTTPException(status_code=404, detail="group_not_found")
enrolled = await db.scalar(
select(func.count()).select_from(Trainee).where(Trainee.group_id == group_id)
)
rows = await db.execute(
select(Session.trainee_id, Score.score_final, Score.report)
.join(Score, Score.session_id == Session.id)
.join(Trainee, Trainee.id == Session.trainee_id, isouter=True)
.where(
or_(
Session.group_id == group_id,
and_(Session.group_id.is_(None), Trainee.group_id == group_id),
),
Session.ended_at.is_not(None),
)
)
attempts = [
ScoredAttempt(
trainee_id=trainee_id,
score=score,
codes=(report or {}).get("summary", {}).get("codes", {}),
)
for trainee_id, score, report in rows
]
return GroupAnalyticsOut(
group=GroupOut(id=group.id, name=group.name),
**summarize(attempts, int(enrolled or 0)),
)

View file

@ -8,7 +8,7 @@ from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
@ -117,9 +117,8 @@ async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut
class ScoreOverride(BaseModel):
"""Коррекция оценки преподавателем. Автооценка сохраняется рядом."""
score_final: float
score_final: float = Field(ge=0, le=100)
comment: str = ""
author: str = "преподаватель"
def _live(session_id: UUID):
@ -188,9 +187,13 @@ async def override(session_id: UUID, body: ScoreOverride, request: Request) -> S
state.score = {
**state.score,
"score_final": body.score_final,
"overridden_by": body.author,
"overridden_by": who.login,
"override_comment": body.comment,
}
if hub.journal:
await hub.journal.score_override(
session_id, body.score_final, who.login, body.comment
)
await audit(
who.login, who.role.value, "score.override", str(session_id),
f"{state.score.get('score_auto')} → {body.score_final}: {body.comment}",

View file

@ -13,9 +13,10 @@ from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import require
from app.api.auth import DEMO_TRAINEE_ID, require
from app.config import get_settings
from app.domain.roles import Role
from app.db.base import get_session
from app.db.base import get_session, get_sessionmaker
from app.db.models import Group, Score, Session, Trainee
router = APIRouter(prefix="/api/trainees", tags=["trainees"])
@ -61,14 +62,17 @@ class ProfileOut(BaseModel):
@router.get("", response_model=list[TraineeOut])
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[TraineeOut]:
async def listing(request: Request) -> list[TraineeOut]:
"""Список курсантов — преподавателю и администратору: обучающемуся он
не нужен, а чужие фамилии из него видны."""
require(request, Role.INSTRUCTOR, Role.ADMIN)
rows = await db.execute(
select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True)
)
return [TraineeOut(id=trainee.id, name=trainee.name, group=group) for trainee, group in rows]
if get_settings().demo_no_db:
return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант")]
async with get_sessionmaker()() as db:
rows = await db.execute(
select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True)
)
return [TraineeOut(id=trainee.id, name=trainee.name, group=group) for trainee, group in rows]
@router.get("/{trainee_id}/profile", response_model=ProfileOut)

View file

@ -9,7 +9,7 @@
"""
import logging
import re
import math
from uuid import UUID, uuid4
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
@ -44,7 +44,7 @@ from app.api.auth import audit, principal_of
from app.domain.roles import Role
from app.session.hub import hub
from app.session.state import SessionState, now_utc
from app.domain.kio import KIO, ResponseStatus
from app.session.dds import prepare_card
from app.voice.models import get_voice_models
from app.voice.pipeline import FILLERS, prefetch
@ -84,6 +84,23 @@ async def _start(session_id: UUID, event, who=None) -> None:
)
return
scenario_ids = event.scenario_ids or [event.scenario_id]
if event.exercise is Exercise.DDS:
if not scenario_ids or scenario_ids[0] != event.scenario_id or len(scenario_ids) > 10:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID,
message="Очередь ДДС должна начинаться с scenario_id и содержать не более 10 карточек",
))
return
scenarios = [store.get(scenario_id) for scenario_id in scenario_ids]
if any(item is None for item in scenarios):
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID, message="В очереди ДДС есть неизвестный сценарий",
))
return
else:
scenarios = []
attempt = 1
recorded_trainee_id = event.trainee_id
if hub.journal:
@ -114,29 +131,8 @@ async def _start(session_id: UUID, event, who=None) -> None:
state.persona = PersonaState(state.scenario.persona)
state.caller = build_caller(scenario.id)
elif event.exercise is Exercise.DDS:
# В ДДС поступает уже оформленная учебная карточка. Содержимое берётся
# из утверждённого сценария, а не из действий несуществующего оператора.
truth = scenario.ground_truth
address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "")
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
service = truth.dds.value if truth.dds else None
fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"}
state.kio = KIO(
registered_at=now_utc(), caller_number="+7 (495) 000-00-00",
address=truth.address or address_fact or None,
floor=floor.group(1) if floor else None,
incident_type=truth.incident_type, incident_code=truth.incident_code,
dds=truth.dds, signs=list(scenario.signs),
notify=list(truth.notify) or ([fallback[service]] if service in fallback else []),
victims_count=truth.victims,
description="; ".join(fact.value for fact in scenario.facts[:3]) or scenario.first_line,
)
if service:
state.dispatch(service)
else:
state.kio.response_status = ResponseStatus.TRANSFERRED
state.dispatched_card = state.kio.model_copy(deep=True)
state.dispatched_at = now_utc()
state.dds_scenarios = [item.model_copy(deep=True) for item in scenarios]
prepare_card(state, state.dds_scenarios[0])
state.started_at = state.dispatched_at
else:
state.started_at = now_utc()
@ -151,8 +147,6 @@ async def _start(session_id: UUID, event, who=None) -> None:
if models is not None:
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
state.on_event("call.incoming")
elif event.exercise is Exercise.DDS:
state.on_event("dds.dispatch")
hub.start_ticker(session_id)
if who is not None:
# Запуск занятия меняет чужой результат — значит попадает в журнал
@ -265,15 +259,39 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
if state is not None:
hub.to_observers(session_id, ReferenceStarted(scenario_id=state.scenario_id))
case "score.override":
if event.session_id != session_id:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.FORBIDDEN,
message="Оценка относится к другому занятию",
))
continue
state = hub.get(session_id)
if state is not None and state.score is not None:
try:
verdict = float(event.verdict)
except ValueError:
verdict = float("nan")
if not math.isfinite(verdict) or not 0 <= verdict <= 100:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message="Оценка должна быть числом от 0 до 100",
))
continue
# Автооценка остаётся рядом: видно, что скорректировано и кем.
state.score = {
**state.score,
"score_final": float(event.verdict) if event.verdict.replace(".", "", 1).isdigit() else state.score["score_auto"],
"overridden_by": "преподаватель",
"score_final": verdict,
"overridden_by": who.login,
"override_comment": event.comment,
}
if hub.journal:
await hub.journal.score_override(
session_id, verdict, who.login, event.comment
)
await audit(
who.login, who.role.value, "score.override", str(session_id),
f"{state.score.get('score_auto')} → {verdict}: {event.comment}",
)
hub.to_observers(session_id, ScoreReady(session_id=session_id))
case "director.inject":
state = hub.get(session_id)

View file

@ -25,6 +25,8 @@ from app.domain.roles import Role
from app.session.hub import hub
from app.session.state import now_utc
from app.session.finish import finish
from app.session.finish import score_current_dds
from app.session.dds import prepare_card
log = logging.getLogger(__name__)
router = APIRouter()
@ -50,6 +52,18 @@ def _error(session_id: UUID, message: str) -> None:
hub.to_station(session_id, ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=message))
async def _finish_dds(session_id: UUID, state) -> None:
state.ended_at = now_utc()
state.end_reason = CallEndReason.COMPLETE
hub.stop_ticker(session_id)
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
if hub.journal:
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.COMPLETE.value)
await finish(session_id, state)
hub.to_station(session_id, ScoreReady(session_id=session_id))
async def _handle(session_id: UUID, state, event) -> None:
if state.ended:
_error(session_id, "Занятие уже завершено")
@ -104,6 +118,7 @@ async def _handle(session_id: UUID, state, event) -> None:
return
state.crew_selected = event.crew
state.crew_assignments[service] = event.crew
state.dds_log.append(("crew.select", now_utc(), event.crew))
case "phone.dial":
crew = state.crew_selected
service = state.crew_service(crew) if crew else None
@ -126,20 +141,37 @@ async def _handle(session_id: UUID, state, event) -> None:
text=REPORT_TEXT[phase], at=now_utc(),
)
state.phone_reports.append(report)
state.dds_log.append(("phone.dial", now_utc(), crew))
hub.to_station(session_id, PhoneReport(**report.model_dump()))
case "card.reply":
if state.exercise is not Exercise.DDS or not state.dispatched_card or (
event.card_id != state.dispatched_card.card_id
):
_error(session_id, "Ответ относится не к текущей карточке")
return
state.reply_text = event.text
state.reply_log.append((now_utc(), event.text))
case "card.next":
if state.exercise is not Exercise.DDS or not state.dispatched_card or (
event.card_id != state.dispatched_card.card_id
):
_error(session_id, "Следующая карточка недоступна: ID текущей не совпадает")
return
if any(item.card_id == event.card_id for item in state.dds_completed):
_error(session_id, "Эта карточка уже завершена")
return
state.dds_completed.append(score_current_dds(state))
if state.dds_card_index + 1 < len(state.dds_scenarios):
state.dds_card_index += 1
prepare_card(state, state.dds_scenarios[state.dds_card_index])
hub.to_station(session_id, state.card_received_event())
else:
await _finish_dds(session_id, state)
case "station.finish":
if state.exercise is not Exercise.DDS:
_error(session_id, "Операторское занятие завершается после звонка 112")
return
state.ended_at = now_utc()
state.end_reason = CallEndReason.COMPLETE
hub.stop_ticker(session_id)
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
if hub.journal:
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.COMPLETE.value)
await finish(session_id, state)
hub.to_station(session_id, ScoreReady(session_id=session_id))
await _finish_dds(session_id, state)
case "card.bounce":
# Карточка вернулась: в разборе это E6 с конкретной причиной.
state.bounced_fields = list(event.missing_fields)

View file

@ -13,6 +13,9 @@ class Settings(BaseSettings):
# Данные
database_url: str = "postgresql+asyncpg://lct:lct@localhost:5432/lct"
# Только локальная демонстрация: живые занятия и отчёты в памяти, без
# Postgres и без долговременного журнала. Не включать на учебном стенде.
demo_no_db: bool = False
# Голосовой контур
models_dir: str = "models"

View file

@ -2,6 +2,7 @@
from collections.abc import AsyncIterator
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
@ -45,5 +46,7 @@ def reset() -> None:
async def get_session() -> AsyncIterator[AsyncSession]:
"""Зависимость FastAPI: сессия на запрос, коммит явный."""
if get_settings().demo_no_db:
raise HTTPException(status_code=503, detail="database_disabled_demo")
async with get_sessionmaker()() as session:
yield session

View file

@ -75,7 +75,7 @@ async def ensure_session(
scenario_id=scenario_id,
mode=mode,
trainee_id=trainee.id if trainee else None,
group_id=group.id if group else None,
group_id=group.id if group else trainee.group_id if trainee else None,
session_id=session_id,
)

View file

@ -418,6 +418,7 @@ ServerToObserver = Annotated[
class ScenarioStart(BaseModel):
type: Literal["scenario.start"] = "scenario.start"
scenario_id: str
scenario_ids: list[str] | None = None
trainee: str
trainee_id: UUID | None = None
group_id: str | None = None
@ -483,6 +484,8 @@ class CardReceived(BaseModel):
card: KIO
from_operator: str
at: datetime
card_index: int = 1
card_total: int = 1
class CardAck(BaseModel):
@ -535,6 +538,17 @@ class StationFinish(BaseModel):
type: Literal["station.finish"] = "station.finish"
class CardReply(BaseModel):
type: Literal["card.reply"] = "card.reply"
card_id: UUID
text: str = Field(max_length=2000)
class CardNext(BaseModel):
type: Literal["card.next"] = "card.next"
card_id: UUID
class StationState(BaseModel):
"""Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше."""
@ -564,6 +578,7 @@ ServerToStation = Annotated[
StationToServer = Annotated[
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish
| CardReply | CardNext
| ZoneDecision | CrewDispatched | CrewArrived,
Field(discriminator="type"),
]
@ -616,6 +631,17 @@ class SelfAssessmentDiff(BaseModel):
overcautious: list[str] = []
class DdsCardReport(BaseModel):
card_id: UUID
scenario_id: str
score_auto: float
reply_text: str
metrics: list[Metric]
findings: list[Finding]
actions: list[dict[str, Any]] = []
duration_ms: int = 0
class SessionReport(BaseModel):
"""Единица истории: из отчётов складываются профиль, дельта попыток, аналитика."""
@ -626,6 +652,7 @@ class SessionReport(BaseModel):
transcript: list[TranscriptEntry]
findings: list[Finding]
metrics: list[Metric]
card_results: list[DdsCardReport] = []
competencies: list[CompetencyScore]
reference_questions: list[HintShown]
missed_checklist: list[str]

View file

@ -11,6 +11,7 @@
from datetime import datetime
from enum import StrEnum
from uuid import UUID
from pydantic import BaseModel
@ -134,6 +135,12 @@ class PhoneReportRecord(BaseModel):
at: datetime
class DdsCardSummary(BaseModel):
card_id: UUID
scenario_id: str
score_auto: float
class StatusError(ValueError):
"""Переход запрещён автоматом или нет обязательного комментария."""
@ -208,3 +215,8 @@ class StationSnapshot(BaseModel):
crew_options: list[str] = []
crew_selected: str | None = None
phone_reports: list[PhoneReportRecord] = []
card_id: UUID | None = None
card_index: int = 1
card_total: int = 1
reply_text: str = ""
completed_cards: list[DdsCardSummary] = []

View file

@ -13,6 +13,7 @@ from pathlib import Path
from app.api import auth
from app.api.http import admin as admin_api
from app.api.http import ekp as ekp_api
from app.api.http import groups as groups_api
from app.api.http import scenarios as scenarios_api
from app.api.http import sessions
from app.api.http import trainees
@ -41,6 +42,9 @@ logging.getLogger("app").setLevel(logging.INFO)
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
if settings.demo_no_db and not settings.dev_auth_bypass:
raise RuntimeError("DEMO_NO_DB требует DEV_AUTH_BYPASS=true для локального входа")
# Библиотека проверяется на старте целиком: сломанный сценарий, найденный
# посреди занятия, — сценарий, которого не должно случиться.
try:
@ -51,22 +55,27 @@ async def lifespan(app: FastAPI):
# Утверждённые преподавателем сценарии хранятся в БД и должны переживать
# перезапуск процесса. При недоступной БД остаётся базовая YAML-библиотека.
try:
# БД может ещё подниматься или отсутствовать в unit-тесте. Не задерживаем
# старт АРМ на минуту ради необязательной пользовательской библиотеки.
async with asyncio.timeout(1):
async with get_sessionmaker()() as db:
app.state.scenarios_loaded += await store.restore_published(db)
except (SQLAlchemyError, OSError, TimeoutError) as exc:
if not settings.demo_no_db:
try:
# БД может ещё подниматься или отсутствовать в unit-тесте. Не задерживаем
# старт АРМ на минуту ради необязательной пользовательской библиотеки.
async with asyncio.timeout(1):
async with get_sessionmaker()() as db:
app.state.scenarios_loaded += await store.restore_published(db)
except (SQLAlchemyError, OSError, TimeoutError) as exc:
logging.getLogger(__name__).warning(
"не удалось восстановить утверждённые сценарии из БД: %s", exc
)
else:
logging.getLogger(__name__).warning(
"не удалось восстановить утверждённые сценарии из БД: %s", exc
"DEMO_NO_DB: занятия и оценки живут только до перезапуска; журнал БД выключен"
)
# Журнал: всё, что не записано, для оценки не существует.
hub.journal = DbJournal(get_sessionmaker())
hub.journal = None if settings.demo_no_db else DbJournal(get_sessionmaker())
# Эмбеддинги для слот-автомата — грузятся один раз, до первого занятия.
app.state.embeddings_ready = get_embedder() is not None
app.state.embeddings_ready = not settings.demo_no_db and get_embedder() is not None
# Модели речи: ~5 секунд на старте стенда вместо паузы на первом звонке.
app.state.models_ready = get_voice_models() is not None
@ -92,6 +101,7 @@ app.include_router(auth.router)
app.include_router(sessions.router)
app.include_router(scenarios_api.router)
app.include_router(ekp_api.router)
app.include_router(groups_api.router)
app.include_router(admin_api.router)
app.include_router(trainees.router)
app.include_router(call_ws.router)
@ -110,4 +120,5 @@ async def health() -> dict:
"scenarios_loaded": getattr(app.state, "scenarios_loaded", 0),
"embeddings_ready": getattr(app.state, "embeddings_ready", False),
"offline": settings.offline,
"demo_no_db": settings.demo_no_db,
}

View file

@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
from app.domain.events import Mood
from app.scoring.taxonomy import METRIC_MAP
class Strict(BaseModel):
@ -157,6 +158,9 @@ class Scenario(Strict):
facts: list[Fact] = []
checklist: list[ChecklistItem] = []
required_fields: list[str] = Field(default_factory=list)
# Учебные веса задаёт преподаватель в утверждаемом сценарии. Ноль
# отключает метрику из знаменателя; значения методики предварительные.
score_weights: dict[str, float] = Field(default_factory=dict)
# Реплики оператора, которые вопросом не являются: «успокойтесь»,
# «оставайтесь на линии». Общий список — checklists/common.yaml,
# сценарий может дополнить своими.
@ -165,6 +169,15 @@ class Scenario(Strict):
era_glonass: EraGlonass | None = None
tree: Tree = Tree()
@model_validator(mode="after")
def valid_score_weights(self):
unknown = self.score_weights.keys() - METRIC_MAP.keys()
if unknown:
raise ValueError(f"неизвестные метрики score_weights: {', '.join(sorted(unknown))}")
if any(not 0 <= weight <= 10 for weight in self.score_weights.values()):
raise ValueError("score_weights: каждый вес должен быть от 0 до 10")
return self
@model_validator(mode="after")
def ticket_needs_position(self):
if (self.ticket is None) != (self.position is None):

View file

@ -0,0 +1,69 @@
"""Воспроизводимая аналитика учебной группы по сохранённым оценкам.
Рекомендации здесь основаны на кодах ошибок, а не выдаются за вывод ИИ.
Повторные попытки учитываются в среднем балле, но один курсант не может
увеличить долю группы повторением одной и той же ошибки.
"""
from collections import Counter, defaultdict
from dataclasses import dataclass
from uuid import UUID
from app.domain.taxonomy import ERRORS, ErrorCode
RECOMMENDATIONS: dict[str, str] = {
"E1": "Повторить опросную карту и обязательные уточняющие вопросы.",
"E2": "Отработать классификацию происшествия и маршрутизацию по ЕКП.",
"E3": "Провести тренировку по временным нормативам.",
"E4": "Разобрать управление разговором и коммуникацию в стрессе.",
"E5": "Потренировать полноту заполнения карточки 112.",
"E6": "Повторить проверку карточки перед завершением и передачей.",
"D1": "Отработать первичную отметку ДДС в течение 30 секунд.",
"D2": "Сверять статусы реагирования с фактическими докладами.",
"D3": "Разобрать профильность обращений и допустимость отказа.",
"D4": "Тренировать обоснование отказа в комментарии.",
"D5": "Тренировать полноту комментария: действие и передача информации.",
"D6": "Повторить цепочку статусов выезда, прибытия и работ.",
}
@dataclass(frozen=True)
class ScoredAttempt:
trainee_id: UUID | None
score: float
codes: dict[str, int]
def summarize(attempts: list[ScoredAttempt], enrolled: int) -> dict:
"""Сводка только по завершённым попыткам с оценкой."""
active = {item.trainee_id for item in attempts if item.trainee_id is not None}
affected: dict[str, set[UUID]] = defaultdict(set)
occurrences: Counter[str] = Counter()
for attempt in attempts:
for code, count in attempt.codes.items():
if code not in RECOMMENDATIONS or not isinstance(count, int) or count <= 0:
continue
occurrences[code] += count
if attempt.trainee_id is not None:
affected[code].add(attempt.trainee_id)
errors = []
for code in occurrences:
errors.append({
"code": code,
"title": ERRORS[ErrorCode(code)].title,
"affected_trainees": len(affected[code]),
"occurrences": occurrences[code],
"rate_percent": round(100 * len(affected[code]) / len(active), 1) if active else 0.0,
"recommendation": RECOMMENDATIONS[code],
})
errors.sort(key=lambda item: (-item["affected_trainees"], -item["occurrences"], item["code"]))
return {
"enrolled_trainees": enrolled,
"active_trainees": len(active),
"scored_attempts": len(attempts),
"average_score": round(sum(item.score for item in attempts) / len(attempts), 1)
if attempts else None,
"errors": errors,
}

View file

@ -9,6 +9,7 @@ from uuid import UUID
from app.domain.events import (
CompetencyScore,
DdsCardReport,
HintShown,
HintUsage,
InstructorNoteShown,
@ -68,23 +69,27 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
step.checklist_id
for step in reference.steps
if step.required and revealed is not None and step.fact_id not in revealed
] if state.exercise is not Exercise.CARD else []
] if state.exercise is Exercise.CALL else []
return SessionReport(
session_id=session_id,
scenario_id=scenario.id,
scenario_id=(state.dds_scenarios[0].id
if state.exercise is Exercise.DDS and state.dds_scenarios
else scenario.id),
mode=state.mode,
attempt=state.attempt,
transcript=list(state.transcript),
findings=[Finding.model_validate(item) for item in score.get("findings", [])],
metrics=[Metric.model_validate(item) for item in score.get("metrics", [])],
card_results=[DdsCardReport.model_validate(item)
for item in score.get("card_results", [])],
competencies=[CompetencyScore.model_validate(item) for item in score.get("competencies", [])],
# Эталонные вопросы по шагам: по каждому пропущенному пункту видно,
# какой вопрос был правильным.
reference_questions=[
HintShown(checklist_id=step.checklist_id, question=step.question)
for step in reference.steps
] if state.exercise is not Exercise.CARD else [],
] if state.exercise is Exercise.CALL else [],
missed_checklist=missed_checklist,
hints_used=[
HintUsage(checklist_id=checklist_id, question=questions.get(checklist_id, ""), at=at)

View file

@ -28,6 +28,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
"dds_contact": (ErrorCode.D6, Competency.COMMUNICATION),
"dds_progress": (ErrorCode.D6, Competency.CARD),
"dds_completion": (ErrorCode.D6, Competency.CARD),
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
}
#: Вес метрики в детерминированной оценке.

View file

@ -0,0 +1,10 @@
"""Пересчёт предварительной шкалы под утверждённый методистом сценарий."""
from app.scoring.gost import GostResult
def apply_weights(result: GostResult, overrides: dict[str, float]) -> None:
"""Меняет только веса метрик; факты, нормативы и ошибки неизменны."""
for metric in result.metrics:
if metric.key in overrides:
metric.weight = overrides[metric.key]

View file

@ -0,0 +1,50 @@
"""Выдача очередной учебной карточки ДДС в рамках одного занятия."""
import re
from app.domain.kio import KIO, ResponseStatus
from app.scenarios.schema import Scenario
from app.session.state import SessionState, now_utc
from app.session.timers import SessionTimers
def prepare_card(state: SessionState, scenario: Scenario) -> None:
"""Сменить активную карточку, сохранив общий ID занятия и прошлые результаты."""
state.scenario = scenario.model_copy(deep=True)
state.scenario_id = scenario.id
state.scenario_title = scenario.title
state.level = scenario.level.value
state.required_fields = list(scenario.required_fields)
truth = scenario.ground_truth
address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "")
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
service = truth.dds.value if truth.dds else None
fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"}
state.kio = KIO(
registered_at=now_utc(), caller_number="+7 (495) 000-00-00",
address=truth.address or address_fact or None,
floor=floor.group(1) if floor else None,
incident_type=truth.incident_type, incident_code=truth.incident_code,
dds=truth.dds, signs=list(scenario.signs),
notify=list(truth.notify) or ([fallback[service]] if service in fallback else []),
victims_count=truth.victims,
description="; ".join(fact.value for fact in scenario.facts[:3]) or scenario.first_line,
)
state.dispatched_card = None
if service:
state.dispatch(service)
else:
state.kio.response_status = ResponseStatus.TRANSFERRED
state.dispatched_card = state.kio.model_copy(deep=True)
state.dispatched_at = now_utc()
state.status_log.clear()
state.dds_log.clear()
state.bounced_fields.clear()
state.crew_selected = None
state.crew_assignments.clear()
state.phone_reports.clear()
state.reply_text = ""
state.reply_log.clear()
state.timers = SessionTimers()
state.on_event("dds.dispatch")

View file

@ -10,18 +10,82 @@
import logging
from uuid import UUID
from app.domain.events import Exercise, ScoreReady
from app.domain.events import Exercise, Metric, ScoreReady
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
from app.domain.timers import NORMATIVES, TimerCode
from app.scenarios import store
from app.scoring.competency import radar
from app.scoring.card import evaluate_card
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
from app.scoring.gost import GostResult, evaluate
from app.scoring.weights import apply_weights
from app.session.hub import hub
from app.session.state import DdsCardRecord, now_utc
log = logging.getLogger(__name__)
def score_current_dds(state) -> DdsCardRecord:
"""Оценить активную карточку отдельно, до выдачи следующей."""
number = state.dds_card_index + 1
findings = evaluate_dispatcher(
entries=state.status_log,
services=state.notified_services(),
deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms,
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
)
metrics = dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms)
reply = state.reply_text.strip()
if reply or len(state.dds_scenarios) > 1:
passed = len(reply) >= 20 and len(reply.split()) >= 3
metrics.append(Metric(
key="dds_reply", title="Свободный ответ по карточке",
fact=reply or "ответ отсутствует",
norm="содержательный текст не короче 20 символов и 3 слов",
ref="ТЗ: действия с карточками, ввод текста",
passed=passed, weight=1.5,
))
if not passed:
findings.append(Finding(
code=ErrorCode.D5, source=FindingSource.DISPATCHER,
summary="Свободный ответ отсутствует или слишком краток",
fact=reply or "ответ отсутствует",
norm="не менее 20 символов и 3 слов; грамматика отдельно не проверялась",
ref="ТЗ: действия с карточками, ввод текста",
competency=Competency.COMMUNICATION,
))
weighted = GostResult(metrics=metrics, findings=findings)
apply_weights(weighted, state.scenario.score_weights)
actions = [
{"type": "card.status", "service": mark.service, "status": mark.status.value,
"comment": mark.comment, "at": mark.at.isoformat()}
for mark in state.status_log
] + [
{"type": "phone.report", "service": report.service, "crew": report.crew,
"phase": report.phase, "text": report.text, "at": report.at.isoformat()}
for report in state.phone_reports
] + [
{"type": "card.reply", "text": text, "at": at.isoformat()}
for at, text in state.reply_log
] + [
{"type": kind, "text": text or "", "at": at.isoformat()}
for kind, at, text in state.dds_log
]
actions.sort(key=lambda item: item["at"])
return DdsCardRecord(
card_id=state.dispatched_card.card_id,
scenario_id=state.scenario_id,
reply_text=state.reply_text,
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}"})
for metric in weighted.metrics],
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}"})
for finding in weighted.findings],
actions=actions,
duration_ms=(max(0, int((now_utc() - state.dispatched_at).total_seconds() * 1000))
if state.dispatched_at else 0),
)
async def finish(session_id: UUID, state) -> None:
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
scenario = state.scenario or store.get(state.scenario_id)
@ -31,9 +95,22 @@ async def finish(session_id: UUID, state) -> None:
if state.exercise is Exercise.CARD:
result = evaluate_card(scenario, state.dispatched_card or state.kio)
elif state.exercise is Exercise.DDS:
# В готовой карточке не было входящего звонка, опроса и действий
# оператора 112. Ни одна E-метрика здесь не применима.
# В ДДС могут последовательно пройти несколько карточек. Уже закрытые
# сохранены отдельными результатами; активную оцениваем лишь однажды.
result = GostResult()
cards = list(state.dds_completed)
if state.dispatched_card and not any(
item.card_id == state.dispatched_card.card_id for item in cards
):
cards.append(score_current_dds(state))
state.dds_completed = cards
for card in cards:
result.metrics.extend(card.metrics)
result.findings.extend(card.findings)
result.unavailable.append(
"Грамматика свободного ответа: автоматическая проверка не настроена; "
"оценивались только наличие и минимальная полнота текста"
)
else:
result = evaluate(
scenario=scenario,
@ -49,7 +126,7 @@ async def finish(session_id: UUID, state) -> None:
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
# участвуют оба (docs/spec/DATASET.md#статусы-реагирования).
if state.dispatched_card is not None and state.exercise is not Exercise.CARD:
if state.dispatched_card is not None and state.exercise is Exercise.CALL:
dispatcher_findings = evaluate_dispatcher(
entries=state.status_log,
services=state.notified_services(),
@ -58,6 +135,8 @@ async def finish(session_id: UUID, state) -> None:
)
result.findings.extend(dispatcher_findings)
result.metrics.extend(dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms))
if state.exercise is not Exercise.DDS:
apply_weights(result, scenario.score_weights)
# Сводка числами: по ней считается дельта между попытками в профиле.
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
@ -82,6 +161,14 @@ async def finish(session_id: UUID, state) -> None:
"unavailable": result.unavailable,
# Статус карточки — готовая красная метка, понятная любому диспетчеру.
"card_status": state.station_snapshot().card.value,
"card_results": [
{"card_id": str(card.card_id), "scenario_id": card.scenario_id,
"score_auto": card.score_auto, "reply_text": card.reply_text,
"actions": card.actions, "duration_ms": card.duration_ms,
"metrics": [metric.model_dump(mode="json") for metric in card.metrics],
"findings": [finding.model_dump(mode="json") for finding in card.findings]}
for card in cards
] if state.exercise is Exercise.DDS else [],
}
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))

View file

@ -93,6 +93,24 @@ class DbJournal:
await self._write(lambda db: action(db))
async def score_override(
self, session_id: UUID, score_final: float, author: str, comment: str
) -> None:
"""Сохранить решение преподавателя рядом с неизменной автооценкой."""
async def action(db):
await db.execute(
update(Score)
.where(Score.session_id == session_id)
.values(
score_final=score_final,
overridden_by=author,
override_comment=comment,
)
)
await db.commit()
await self._write(lambda db: action(db))
async def session_started(self, session_id: UUID, at: datetime) -> None:
async def action(db):
await db.execute(update(Session).where(Session.id == session_id).values(started_at=at))

View file

@ -16,6 +16,7 @@ from app.domain.events import (
Mood,
SessionMode,
SessionSnapshot,
Metric,
Speaker,
TranscriptEntry,
)
@ -29,6 +30,7 @@ from app.domain.statuses import (
StationSnapshot,
StatusEntry,
PhoneReportRecord,
DdsCardSummary,
card_status,
check,
current,
@ -36,6 +38,7 @@ from app.domain.statuses import (
from app.domain.timers import NORMATIVES, TimerCode
from app.domain.kio import KIO, ResponseStatus, apply_patch
from app.session.timers import SessionTimers
from app.domain.taxonomy import Finding
def now_utc() -> datetime:
@ -43,6 +46,23 @@ def now_utc() -> datetime:
return datetime.now(timezone.utc)
@dataclass
class DdsCardRecord:
card_id: UUID
scenario_id: str
reply_text: str
metrics: list[Metric]
findings: list[Finding]
actions: list[dict[str, Any]]
duration_ms: int
@property
def score_auto(self) -> float:
total = sum(metric.weight for metric in self.metrics)
passed = sum(metric.weight for metric in self.metrics if metric.passed)
return round(100 * passed / total, 1) if total else 0.0
@dataclass
class SessionState:
session_id: UUID
@ -102,6 +122,11 @@ class SessionState:
crew_selected: str | None = None
crew_assignments: dict[str, str] = field(default_factory=dict)
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
dds_scenarios: list[Scenario] = field(default_factory=list)
dds_card_index: int = 0
dds_completed: list[DdsCardRecord] = field(default_factory=list)
reply_text: str = ""
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
resolved_outcome: str | None = None
resolve_comment: str = ""
@ -180,6 +205,14 @@ class SessionState:
crew_options=self.crew_options(),
crew_selected=self.crew_selected,
phone_reports=list(self.phone_reports),
card_id=self.dispatched_card.card_id if self.dispatched_card else None,
card_index=self.dds_card_index + 1,
card_total=len(self.dds_scenarios) or 1,
reply_text=self.reply_text,
completed_cards=[DdsCardSummary(card_id=item.card_id,
scenario_id=item.scenario_id,
score_auto=item.score_auto)
for item in self.dds_completed],
)
def card_received_event(self):
@ -191,6 +224,8 @@ class SessionState:
from_operator=("учебный сценарий" if self.exercise is Exercise.DDS
else self.trainee_name or "оператор 112"),
at=self.dispatched_at or now_utc(),
card_index=self.dds_card_index + 1,
card_total=len(self.dds_scenarios) or 1,
)
@property

View file

@ -0,0 +1,167 @@
"""Regressions for stale cookies and privileged admin operations."""
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
import app.api.auth as auth
from app.api.http import admin
from app.domain.roles import Role
from app.main import app
from app.session.hub import hub
@pytest.fixture
def client():
with TestClient(app) as test_client:
hub.journal = None
yield test_client
def test_account_change_revokes_http_and_new_websocket_handshakes(client):
assert client.post("/api/auth/dev-token").status_code == 200
assert client.get("/api/auth/me").status_code == 200
auth.invalidate_login("dev")
assert client.get("/api/auth/me").status_code == 401
with client.websocket_connect(f"/ws/control/{uuid4()}") as socket:
assert socket.receive_json()["code"] == "forbidden"
assert client.post("/api/auth/dev-token").status_code == 200
assert client.get("/api/auth/me").status_code == 200
def test_account_change_closes_an_existing_websocket(client):
assert client.post("/api/auth/dev-token").status_code == 200
with client.websocket_connect(f"/ws/control/{uuid4()}") as socket:
auth.invalidate_login("dev")
with pytest.raises(WebSocketDisconnect) as exc:
socket.receive_json()
assert exc.value.code == 1008
def test_cookie_from_previous_process_is_rejected(client, monkeypatch):
assert client.post("/api/auth/dev-token").status_code == 200
monkeypatch.setattr(auth, "_INSTANCE", "new-server-instance")
assert client.get("/api/auth/me").status_code == 401
class FakeDb:
def __init__(self, user):
self.user = user
self.commits = 0
self.added = []
async def get(self, _model, _id):
return self.user
def add(self, object_):
self.added.append(object_)
async def flush(self):
for object_ in self.added:
if getattr(object_, "id", None) is None:
object_.id = uuid4()
async def commit(self):
self.commits += 1
def fake_user(login="victim", role="instructor"):
return SimpleNamespace(
id=uuid4(), login=login, full_name="Проверка", role=role,
service=None, trainee_id=None, blocked=False,
password_hash="old", created_at=datetime.now(timezone.utc),
)
@pytest.mark.asyncio
async def test_admin_patch_revokes_cookie_after_commit(monkeypatch):
user = fake_user()
db = FakeDb(user)
calls = []
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
login="admin", full_name="Администратор", role=Role.ADMIN,
))
monkeypatch.setattr(admin, "invalidate_login", lambda login: calls.append((login, db.commits)))
async def no_audit(*_args, **_kwargs):
return None
monkeypatch.setattr(admin, "audit", no_audit)
await admin.patch_user(user.id, admin.UserPatch(blocked=True), object(), db)
assert user.blocked is True
assert calls == [("victim", 1)]
@pytest.mark.asyncio
async def test_admin_cannot_demote_self(monkeypatch):
user = fake_user(login="admin", role="admin")
db = FakeDb(user)
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
login="admin", full_name="Администратор", role=Role.ADMIN,
))
with pytest.raises(HTTPException) as exc:
await admin.patch_user(user.id, admin.UserPatch(role=Role.TRAINEE), object(), db)
assert exc.value.status_code == 409
assert db.commits == 0
@pytest.mark.asyncio
async def test_promotion_to_trainee_creates_profile(monkeypatch):
user = fake_user()
db = FakeDb(user)
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
login="admin", full_name="Администратор", role=Role.ADMIN,
))
async def no_audit(*_args, **_kwargs):
return None
monkeypatch.setattr(admin, "audit", no_audit)
await admin.patch_user(user.id, admin.UserPatch(role=Role.TRAINEE), object(), db)
assert user.role == "trainee"
assert user.trainee_id is not None
assert db.commits == 1
@pytest.mark.asyncio
async def test_backup_runs_off_event_loop_and_failure_is_audited(monkeypatch):
who = auth.Principal(login="admin", full_name="Администратор", role=Role.ADMIN)
monkeypatch.setattr(admin, "require", lambda _request, *_roles: who)
calls = []
async def fake_threadpool(fn):
calls.append(fn)
return fn()
async def fake_audit(*args, **kwargs):
calls.append((args, kwargs))
monkeypatch.setattr(admin, "run_in_threadpool", fake_threadpool)
monkeypatch.setattr(admin, "audit", fake_audit)
monkeypatch.setattr(admin.backup_service, "create", lambda: {
"name": "example.sql", "size_bytes": 1, "at": datetime.now(timezone.utc),
})
assert (await admin.make_backup(object())).name == "example.sql"
assert calls[0] is admin.backup_service.create
def broken():
raise admin.backup_service.BackupError("pg_dump failed")
monkeypatch.setattr(admin.backup_service, "create", broken)
with pytest.raises(HTTPException) as exc:
await admin.make_backup(object())
assert exc.value.status_code == 503
assert any(isinstance(item, tuple) and item[0][2] == "backup.failed" for item in calls)
def test_backup_error_redacts_database_credentials(monkeypatch):
dsn = "postgresql://user:supersecret@localhost:5432/example"
monkeypatch.setattr(admin, "get_settings", lambda: SimpleNamespace(database_url=dsn))
detail = admin._safe_backup_error(admin.backup_service.BackupError(f"bad DSN: {dsn}"))
assert "supersecret" not in detail
assert dsn not in detail

View file

@ -139,3 +139,124 @@ def test_complete_dds_workflow_scores_without_call_penalties(client):
assert all(metric["key"].startswith("dds_") for metric in score["metrics"])
finally:
control.__exit__(None, None, None)
def test_sequential_dds_cards_keep_separate_state_and_scores(client):
session_id = uuid4()
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
control = control_ctx.__enter__()
control.send_json({
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
"trainee": "Иванов", "mode": "training", "exercise": "dds",
})
wait_for(lambda: hub.get(session_id))
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
first = read_until(station, "card.received")
first_card_id = first["card"]["card_id"]
assert (first["card_index"], first["card_total"]) == (1, 2)
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
read_until(station, "station.state")
station.send_json({"type": "card.reply", "card_id": first_card_id,
"text": "Сообщение принято, дежурная бригада направлена на место."})
assert read_until(station, "station.state")["snapshot"]["reply_text"].startswith("Сообщение")
station.send_json({"type": "card.next", "card_id": first_card_id})
second = read_until(station, "card.received")
second_card_id = second["card"]["card_id"]
assert second_card_id != first_card_id
assert second["card"]["incident_type"] == "medical"
assert (second["card_index"], second["card_total"]) == (2, 2)
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["reply_text"] == ""
assert snapshot["statuses"].get(service) == "added" or service not in snapshot["statuses"]
assert len(snapshot["completed_cards"]) == 1
assert snapshot["completed_cards"][0]["card_id"] == first_card_id
station.send_json({"type": "card.reply", "card_id": first_card_id,
"text": "Запоздалый ответ к прошлой карточке"})
assert "не к текущей" in read_until(station, "error")["message"]
assert hub.get(session_id).reply_text == ""
station.send_json({"type": "card.next", "card_id": first_card_id})
assert "ID" in read_until(station, "error")["message"]
assert hub.get(session_id).dds_card_index == 1
with client.websocket_connect(f"/ws/station/{session_id}") as station:
assert read_until(station, "card.received")["card"]["card_id"] == second_card_id
assert read_until(station, "station.state")["snapshot"]["card_index"] == 2
station.send_json({"type": "card.reply", "card_id": second_card_id,
"text": "Сообщение принято, бригада направлена на место происшествия."})
read_until(station, "station.state")
station.send_json({"type": "card.next", "card_id": second_card_id})
read_until(station, "score.ready")
state = hub.get(session_id)
assert state.ended and len(state.dds_completed) == 2
assert len(state.score["card_results"]) == 2
assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2"
assert state.score["card_results"][1]["scenario_id"] == "t20-2-stroke"
assert {item["key"] for item in state.score["metrics"]} >= {"dds_reply", "dds_primary"}
assert all(item["code"].startswith("D") for item in state.score["findings"])
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["scenario_id"] == "fire-apartment-l2"
assert len(report["card_results"]) == 2
assert report["missed_checklist"] == [] and report["reference_questions"] == []
first_actions = report["card_results"][0]["actions"]
assert {item["type"] for item in first_actions} >= {"card.status", "card.reply"}
assert report["card_results"][0]["duration_ms"] >= 0
finally:
control_ctx.__exit__(None, None, None)
def test_instructor_can_end_multi_card_early_without_grading_future_cards(client):
session_id = uuid4()
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
control = control_ctx.__enter__()
control.send_json({
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
"trainee": "Иванов", "mode": "training", "exercise": "dds",
})
wait_for(lambda: hub.get(session_id))
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
read_until(station, "station.state")
control.send_json({"type": "session.stop"})
read_until(station, "score.ready")
state = hub.get(session_id)
assert state.ended
assert len(state.score["card_results"]) == 1
assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2"
finally:
control_ctx.__exit__(None, None, None)
def test_each_dds_card_uses_its_own_scenario_weights():
from pathlib import Path
from app.domain.events import Exercise, SessionMode
from app.scenarios.loader import load_file
from app.session.dds import prepare_card
from app.session.finish import score_current_dds
from app.session.state import SessionState
root = Path(__file__).resolve().parents[2] / "scenarios"
base = load_file(root / "fire-apartment-l2.yaml", root)
first = base.model_copy(deep=True)
second = base.model_copy(deep=True)
first.score_weights = {"dds_reply": 7.0}
second.score_weights = {"dds_reply": 2.0}
state = SessionState(
session_id=uuid4(), scenario_id=base.id, scenario_title=base.title,
level=base.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
dds_scenarios=[first, second],
)
prepare_card(state, first)
first_record = score_current_dds(state)
state.dds_card_index = 1
prepare_card(state, second)
second_record = score_current_dds(state)
assert next(item.weight for item in first_record.metrics if item.key == "dds_reply") == 7.0
assert next(item.weight for item in second_record.metrics if item.key == "dds_reply") == 2.0

View file

@ -0,0 +1,75 @@
"""Явный локальный демо-режим не зависит от Postgres."""
import time
from uuid import uuid4
from fastapi.testclient import TestClient
from app.api.auth import DEMO_TRAINEE_ID
from app.config import get_settings
from app.main import app
from app.session.hub import hub
def _wait_for(predicate, timeout=3):
until = time.monotonic() + timeout
while time.monotonic() < until:
value = predicate()
if value:
return value
time.sleep(0.02)
raise AssertionError("занятие не стартовало")
def _read_until(socket, wanted):
for _ in range(20):
event = socket.receive_json()
if event["type"] == wanted:
return event
raise AssertionError(f"не пришло событие {wanted}")
def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch):
monkeypatch.setenv("DEMO_NO_DB", "true")
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
monkeypatch.setenv("VOICE_ENABLED", "false")
get_settings.cache_clear()
try:
with TestClient(app) as client:
health = client.get("/api/health").json()
assert health["status"] == "ok" and health["demo_no_db"] is True
assert health["scenarios_loaded"] > 0
assert hub.journal is None
assert client.post("/api/auth/login", json={
"login": "demo-instructor", "password": "demo"
}).json()["role"] == "instructor"
assert client.post("/api/auth/login", json={
"login": "unknown", "password": "demo"
}).status_code == 401
assert client.post("/api/auth/dev-token?role=trainee").json()["trainee_id"] == str(DEMO_TRAINEE_ID)
assert client.post("/api/auth/dev-token").json()["role"] == "instructor"
assert client.get("/api/trainees").json() == [
{"id": str(DEMO_TRAINEE_ID), "name": "Демо-курсант", "group": None}
]
# БД-зависимые экраны получают быстрый и явный отказ, не ждут TCP timeout.
assert client.get("/api/sessions").json()["detail"] == "database_disabled_demo"
session_id = uuid4()
with client.websocket_connect(f"/ws/control/{session_id}") as control:
control.send_json({
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Демо-курсант", "trainee_id": str(DEMO_TRAINEE_ID),
"mode": "training", "exercise": "dds",
})
state = _wait_for(lambda: hub.get(session_id))
assert state.trainee_id == DEMO_TRAINEE_ID
who = client.post("/api/auth/login", json={
"login": "demo-trainee", "password": "demo"
}).json()
assert who["trainee_id"] == str(DEMO_TRAINEE_ID)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
assert _read_until(station, "card.received")["card"]["address"]
assert _read_until(station, "station.state")["snapshot"]["card_index"] == 1
finally:
get_settings.cache_clear()

View file

@ -0,0 +1,75 @@
"""Групповая сводка считает людей, а не число их повторных попыток."""
from uuid import uuid4
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from app.api import auth
from app.api.http import groups as group_api
from app.api.auth import Principal
from app.domain.roles import Role
from app.scoring.group import ScoredAttempt, summarize
def test_group_errors_count_distinct_trainees_and_give_actions():
first, second = uuid4(), uuid4()
result = summarize([
ScoredAttempt(first, 40.0, {"E1": 2, "D1": 1}),
ScoredAttempt(first, 60.0, {"E1": 1}),
ScoredAttempt(second, 80.0, {"E1": 1}),
], enrolled=3)
assert result["active_trainees"] == 2
assert result["scored_attempts"] == 3
assert result["average_score"] == 60.0
assert result["errors"][0]["code"] == "E1"
assert result["errors"][0]["affected_trainees"] == 2
assert result["errors"][0]["occurrences"] == 4
assert result["errors"][0]["rate_percent"] == 100.0
assert result["errors"][0]["recommendation"]
def test_group_without_scored_attempts_has_no_fake_recommendations():
result = summarize([], enrolled=5)
assert result == {
"enrolled_trainees": 5,
"active_trainees": 0,
"scored_attempts": 0,
"average_score": None,
"errors": [],
}
def test_unknown_codes_do_not_break_group_summary():
result = summarize([ScoredAttempt(uuid4(), 75.0, {"unknown": 2, "E5": 1})], enrolled=1)
assert [item["code"] for item in result["errors"]] == ["E5"]
@pytest.mark.asyncio
async def test_group_creation_requires_staff_and_returns_new_group(monkeypatch):
class FakeDb:
def add(self, group):
group.id = uuid4()
async def commit(self):
pass
async def no_audit(*args):
pass
monkeypatch.setattr(group_api, "audit", no_audit)
instructor = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
request = Request({"type": "http", "session": {}})
auth._issue_session(request, instructor)
created = await group_api.create(group_api.GroupCreate(name=" Группа 1 "), request, FakeDb())
assert created.name == "Группа 1"
assert created.id
trainee = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE)
forbidden = Request({"type": "http", "session": {}})
auth._issue_session(forbidden, trainee)
with pytest.raises(HTTPException) as exc:
await group_api.create(group_api.GroupCreate(name="Чужая"), forbidden, FakeDb())
assert exc.value.status_code == 403

View file

@ -0,0 +1,39 @@
"""Веса метрик настраиваются сценарием и не меняют факт проверки."""
from pathlib import Path
import pytest
from pydantic import ValidationError
from app.domain.events import Metric
from app.scenarios.loader import load_file
from app.scenarios.schema import Scenario
from app.scoring.gost import GostResult
from app.scoring.weights import apply_weights
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
def test_weight_override_changes_score_but_not_findings():
result = GostResult(metrics=[
Metric(key="address", title="Адрес", fact="пусто", norm="заполнен", passed=False, weight=2),
Metric(key="required_fields", title="Поля", fact="есть", norm="есть", passed=True, weight=2),
])
assert result.score == 50.0
apply_weights(result, {"address": 6.0})
assert result.score == 25.0
assert result.metrics[0].fact == "пусто"
def test_scenario_accepts_only_known_finite_weights():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
body = source.model_dump(mode="json")
body["score_weights"] = {"address": 3.0, "dds_ack": 0.0}
assert Scenario.model_validate(body).score_weights["address"] == 3.0
body["score_weights"] = {"typo": 3.0}
with pytest.raises(ValidationError, match="неизвестные метрики"):
Scenario.model_validate(body)
body["score_weights"] = {"address": 11.0}
with pytest.raises(ValidationError, match="от 0 до 10"):
Scenario.model_validate(body)

View file

@ -366,7 +366,34 @@ def test_instructor_correction_keeps_the_automatic_score(client):
assert corrected["score_final"] == 80.0
assert corrected["score_auto"] == auto, "автооценка должна сохраниться рядом"
assert corrected["overridden_by"] == "преподаватель"
assert corrected["overridden_by"] == "dev"
def test_ws_score_override_rejects_other_session_and_invalid_value(client):
with lesson(client) as (session_id, control):
state = hub.get(session_id)
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "call.hangup"})
wait_for(lambda: state.score is not None)
auto = state.score["score_auto"]
control.send_json({
"type": "score.override", "session_id": str(uuid4()),
"verdict": "90", "comment": "не тот номер",
})
control.send_json({
"type": "score.override", "session_id": str(session_id),
"verdict": "nan", "comment": "не число",
})
time.sleep(0.1)
assert state.score["score_auto"] == auto
assert "score_final" not in state.score
control.send_json({
"type": "score.override", "session_id": str(session_id),
"verdict": "85", "comment": "ручная проверка",
})
wait_for(lambda: state.score.get("score_final") == 85)
assert state.score["overridden_by"] == "dev"
def test_soft_directive_changes_how_the_caller_sounds(client):

View file

@ -5,8 +5,10 @@ import { Instructor } from "@/pages/instructor/Instructor";
import { Dds } from "@/pages/dds/Dds";
import { Admin } from "@/pages/admin/Admin";
import { Profile } from "@/pages/profile/Profile";
import { Groups } from "@/pages/groups/Groups";
import { Wall } from "@/pages/wall/Wall";
import { RequireAuth } from "@/features/auth/Login";
import { DEMO_MODE } from "@/features/auth/useAuth";
// Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md).
// session_id живёт в URL: /instructor?session=..., монитор открывают ссылкой.
@ -18,11 +20,12 @@ const guarded = (screen: string, element: JSX.Element) => (
);
export const router = createBrowserRouter([
{ path: "/", element: <Navigate to="/trainee" replace /> },
{ path: "/", element: <Navigate to={DEMO_MODE ? "/instructor" : "/trainee"} replace /> },
{ path: "/trainee", element: guarded("/trainee", <Call />) },
{ path: "/instructor", element: guarded("/instructor", <Instructor />) },
{ path: "/wall", element: guarded("/wall", <Wall />) },
{ path: "/profile", element: guarded("/profile", <Profile />) },
{ path: "/groups", element: guarded("/groups", <Groups />) },
{ path: "/dds", element: guarded("/dds", <Dds />) },
{ path: "/admin", element: guarded("/admin", <Admin />) },
]);

View file

@ -1,9 +1,32 @@
// Экран входа. Ставится перед всеми остальными: номер занятия перестал быть
// пропуском, по ссылке пускает роль, а не знание адреса (lct-23).
import { useState, type ReactNode } from "react";
import { useState, type MouseEvent, type ReactNode } from "react";
import { SCREENS, useLogin, useLogout, useMe } from "./useAuth";
import { DEMO_MODE, SCREENS, useDemoLogin, useLogin, useLogout, useMe } from "./useAuth";
function DemoLinks() {
const openScreen = (event: MouseEvent<HTMLAnchorElement>, path: string) => {
event.preventDefault();
const current = new URLSearchParams(window.location.search);
const session = current.get("session") || window.sessionStorage.getItem("demo_session");
if (session) window.sessionStorage.setItem("demo_session", session);
const target = new URL(path, window.location.origin);
if (session) target.searchParams.set("session", session);
if (path === "/dds") target.searchParams.set("role", current.get("role") || "dds_01");
window.location.assign(target.pathname + target.search);
};
return <nav className="demo-switcher" aria-label="Демо-переходы между АРМ">
<details><summary>Демо · экраны</summary>
<div>
<a href="/instructor" onClick={(event) => openScreen(event, "/instructor")}>Преподаватель</a>
<a href="/trainee" onClick={(event) => openScreen(event, "/trainee")}>Оператор 112</a>
<a href="/dds" onClick={(event) => openScreen(event, "/dds")}>ДДС</a>
</div>
</details>
</nav>;
}
export function RequireAuth({ screen, children }: { screen: string; children: ReactNode }) {
const me = useMe();
@ -14,7 +37,9 @@ export function RequireAuth({ screen, children }: { screen: string; children: Re
}
if (!me.data) return <Login />;
const allowed = SCREENS[me.data.role] ?? [];
const demoUser = DEMO_MODE && ["dev", "demo-instructor"].includes(me.data.login)
&& me.data.role === "instructor";
const allowed = [...(SCREENS[me.data.role] ?? []), ...(demoUser ? ["/trainee"] : [])];
if (!allowed.includes(screen)) {
return (
<main className="page">
@ -27,11 +52,12 @@ export function RequireAuth({ screen, children }: { screen: string; children: Re
</main>
);
}
return <>{children}</>;
return <>{children}{demoUser && <DemoLinks />}</>;
}
function Login() {
const login = useLogin();
const demoLogin = useDemoLogin();
const [name, setName] = useState("");
const [password, setPassword] = useState("");
@ -70,6 +96,17 @@ function Login() {
</button>
</p>
</form>
{DEMO_MODE && <div className="demo-login">
<p>Локальный деморежим: вход без учётной записи доступен только при включённом обходе авторизации на backend.</p>
<button type="button" disabled={demoLogin.isPending} onClick={() => demoLogin.mutate()}>
{demoLogin.isPending ? "Входим…" : "Войти в демо одним нажатием"}
</button>
{demoLogin.isError && <p className="warn" role="alert">
{demoLogin.error instanceof Error && demoLogin.error.message === "404"
? "Демо-вход выключен на сервере: запустите backend с DEV_AUTH_BYPASS=true."
: "Не удалось выполнить демо-вход. Проверьте подключение к локальному backend."}
</p>}
</div>}
{/* Причину отказа не уточняем: «нет такого логина» — это список
действующих учётных записей для того, кто подбирает. */}
{login.isError && <p className="warn">Логин или пароль неверны</p>}

View file

@ -5,6 +5,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Role } from "@/shared/types/generated";
// Vite подставляет значение при сборке. В обычном стенде демо-вход и его
// элементы интерфейса отсутствуют, даже если backend поддерживает endpoint.
export const DEMO_MODE = import.meta.env.VITE_DEMO_MODE === "true";
export interface Principal {
login: string;
full_name: string;
@ -47,6 +51,17 @@ export function useLogin() {
});
}
export function useDemoLogin() {
const cache = useQueryClient();
return useMutation({
mutationFn: () => {
if (!DEMO_MODE) throw new Error("demo_disabled");
return call<Principal>("/api/auth/dev-token", { method: "POST" });
},
onSuccess: (who) => cache.setQueryData(["me"], who),
});
}
export function useLogout() {
const cache = useQueryClient();
return useMutation({
@ -58,7 +73,7 @@ export function useLogout() {
/** Какие экраны открыты роли. Повторяет domain/roles.py: список короткий
* и меняется редко, а держать его на сервере ради двух строк — лишний запрос. */
export const SCREENS: Record<Role, string[]> = {
admin: ["/admin", "/profile"],
instructor: ["/instructor", "/wall", "/profile", "/dds"],
admin: ["/admin", "/profile", "/groups"],
instructor: ["/instructor", "/wall", "/profile", "/dds", "/groups"],
trainee: ["/trainee", "/dds", "/profile"],
};

View file

@ -11,6 +11,10 @@ export function StandStatus() {
}
if (!health.data) return null;
if (health.data.demo_no_db) {
return <div className="stand stand-warn">Локальная демка: карточки 112 и ДДС работают без БД и голоса; история занятий не сохраняется.</div>;
}
const troubles: string[] = [];
if (!health.data.models_ready) troubles.push("голосовой контур выключен: звонок будет без звука");
if (!health.data.embeddings_ready) troubles.push("нет модели эмбеддингов: подсказки идут по порядку чек-листа");

View file

@ -167,7 +167,7 @@ export function ScenarioEditor({
</div>
{draft && (
<>
<p className="ref">Черновик {draft.id}. Факты и эталон доступны только преподавателю до утверждения.</p>
<p className="ref">Черновик {draft.id}. Факты и эталон доступны только преподавателю до утверждения. Поле score_weights позволяет задать веса метрик (0–10), например {"{\"address\": 3, \"dds_ack\": 2}"}.</p>
<label className="scenario-editor-json">JSON тела сценария
<textarea value={bodyText} onChange={(event) => { setBodyText(event.target.value); setValidation(null); }} spellCheck={false} />
</label>

View file

@ -25,7 +25,7 @@ export function useControl(sessionId: string | null) {
}, [sessionId]);
const start = useCallback(
(scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string) => {
(scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string, scenarioIds?: string[]) => {
const message = {
type: "scenario.start",
scenario_id: scenarioId,
@ -34,6 +34,7 @@ export function useControl(sessionId: string | null) {
group_id: group || null,
trainee_id: traineeId || null,
exercise,
scenario_ids: scenarioIds?.length ? scenarioIds : undefined,
} as const;
channel.current?.send(message);
},

View file

@ -3,95 +3,124 @@
import { useSigns } from "@/shared/api/http";
import { type CardState, display } from "./merge";
import { type Field, visibleGroups } from "./fields";
import { type Field, type FieldGroup, visibleGroups } from "./fields";
interface Props {
state: CardState;
/** Обязательные поля приходят сценарием в `call.incoming`. */
required?: string[];
readOnly?: boolean;
referenceArm?: boolean;
onChange?: (path: string, value: unknown) => void;
}
export function KioCard({ state, required = [], readOnly = false, onChange }: Props) {
export function KioCard({ state, required = [], readOnly = false, referenceArm = false, onChange }: Props) {
const dds = display(state, "dds") as string | null;
const groups = visibleGroups(dds);
const section = (group: FieldGroup) => (
<section className={`kio-arm-section kio-arm-${group.area}`} key={group.title}>
{(!referenceArm || group.area !== "header") && <div className="kio-arm-section-title">{group.title}</div>}
<div className="kio-arm-fields">
{(referenceArm && group.area === "incident"
? [...group.fields].sort((a, b) => Number(b.path === "incident_type") - Number(a.path === "incident_type"))
: group.fields).map((field) => (
<Row
key={field.path}
field={field}
value={display(state, field.path)}
pending={field.path in state.pending}
required={required.includes(field.path)}
readOnly={readOnly || field.readOnly}
referenceArm={referenceArm}
onChange={onChange}
/>
))}
</div>
</section>
);
return (
<div className="kio">
{visibleGroups(dds).map((group) => (
<table className="grid" key={group.title}>
<thead>
<tr>
<th colSpan={2}>{group.title}</th>
</tr>
</thead>
<tbody>
{group.fields.map((field) => (
<Row
key={field.path}
field={field}
value={display(state, field.path)}
pending={field.path in state.pending}
required={required.includes(field.path)}
readOnly={readOnly || field.readOnly}
onChange={onChange}
/>
))}
</tbody>
</table>
))}
<div className={`kio-arm${referenceArm ? " kio-arm-ref" : ""}`}>
<div className="kio-arm-top">
{groups.filter((group) => group.title === "Звонящий").map(section)}
{groups.filter((group) => group.title === "Служебное").map(section)}
</div>
<div className="kio-arm-main">
<div className="kio-arm-left">{groups.filter((group) => group.area === "address" || group.area === "description").map(section)}</div>
<div className="kio-arm-right">{groups.filter((group) => group.area === "incident" || group.area === "dispatch" || group.area === "details").map(section)}</div>
</div>
</div>
);
}
function Row({
field, value, pending, required, readOnly, onChange,
field, value, pending, required, readOnly, referenceArm, onChange,
}: {
field: Field;
value: unknown;
pending: boolean;
required: boolean;
readOnly?: boolean;
referenceArm: boolean;
onChange?: (path: string, value: unknown) => void;
}) {
const empty = value === null || value === undefined || value === "";
const empty = value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
return (
<tr>
<th>
{field.label}
<div className={`kio-arm-row kio-arm-row-${field.path.replaceAll(".", "-")}${required && empty ? " kio-arm-missing" : ""}${pending ? " kio-arm-pending" : ""}`}>
<div className="kio-arm-label">
{referenceArm && field.path === "incident_type" ? "Что случилось?"
: referenceArm && field.path === "caller_number" ? "АОН"
: referenceArm && field.path === "caller_contact" ? "Предоставленный"
: referenceArm && field.path === "caller_name" ? "Фамилия и имя заявителя"
: field.label}
{required && <span title="обязательное поле" className="required"> ·</span>}
</th>
<td className={required && empty ? "warn" : pending ? "pending" : ""}>
</div>
<div className="kio-arm-value">
{readOnly ? (
<span>{formatValue(value)}</span>
<span className="kio-arm-readonly">{formatValue(value, field)}</span>
) : (
<Input field={field} value={value} onChange={onChange} />
<Input field={field} value={value} referenceArm={referenceArm} onChange={onChange} />
)}
</td>
</tr>
</div>
</div>
);
}
function Input({
field, value, onChange,
field, value, referenceArm, onChange,
}: {
field: Field;
value: unknown;
referenceArm: boolean;
onChange?: (path: string, value: unknown) => void;
}) {
const emit = (next: unknown) => onChange?.(field.path, next);
if (field.kind === "list") {
return <span>{formatValue(value)}</span>;
return <span className="kio-arm-readonly">{formatValue(value)}</span>;
}
if (field.kind === "bool") {
return (
<input type="checkbox" checked={value === true} onChange={(e) => emit(e.target.checked)} />
<div className="kio-arm-choice" role="group" aria-label={field.label}>
<button type="button" aria-pressed={value === true} className={value === true ? "kio-arm-selected" : ""} onClick={() => emit(true)}>Да</button>
<button type="button" aria-pressed={value === false} className={value === false ? "kio-arm-selected" : ""} onClick={() => emit(false)}>Нет</button>
</div>
);
}
if (field.kind === "select") {
if (referenceArm && field.path === "incident_type") {
return (
<div className="kio-arm-type-picker" role="group" aria-label="Что случилось?">
{field.options?.map((option) => (
<button type="button" key={option.value} aria-pressed={value === option.value}
className={value === option.value ? "kio-arm-selected" : ""}
onClick={() => emit(option.value)}>{option.label}</button>
))}
</div>
);
}
return (
<select value={(value as string) ?? ""} onChange={(e) => emit(e.target.value || null)}>
<select aria-label={field.label} value={(value as string) ?? ""} onChange={(e) => emit(e.target.value || null)}>
<option value="">—</option>
{field.options?.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
@ -102,10 +131,14 @@ function Input({
if (field.kind === "signs") {
return <SignsPicker value={(value as string[]) ?? []} onChange={(next) => emit(next)} />;
}
if (field.kind === "textarea") {
return <textarea aria-label={field.label} rows={5} value={(value as string) ?? ""} placeholder="Введите сведения со слов заявителя" onChange={(e) => emit(e.target.value)} />;
}
if (field.kind === "number") {
return (
<input
type="number"
aria-label={field.label}
min={0}
value={value === null || value === undefined ? "" : String(value)}
onChange={(e) => {
@ -120,7 +153,7 @@ function Input({
);
}
return (
<input type="text" value={(value as string) ?? ""} onChange={(e) => emit(e.target.value)} />
<input type="text" aria-label={field.label} value={(value as string) ?? ""} onChange={(e) => emit(e.target.value)} />
);
}
@ -152,6 +185,9 @@ function SignsPicker({
}}
/>
))}
{value.length > 0 && <div className="signs-selected" aria-label="Выбранные признаки">
{value.map((sign, index) => <span key={`${index}-${sign}`}>{sign}</span>)}
</div>}
</div>
);
}
@ -168,7 +204,7 @@ function SignSelect({
// Уровня может не быть: часть происшествий классифицируется двумя признаками.
if (!page.isLoading && values.length === 0) return null;
return (
<select value={chosen} onChange={(e) => onPick(e.target.value)} disabled={page.isLoading}>
<select aria-label={`Признак происшествия ${prefix.length + 1}`} value={chosen} onChange={(e) => onPick(e.target.value)} disabled={page.isLoading}>
<option value="">—</option>
{values.map((sign) => (
<option key={sign} value={sign}>{sign}</option>
@ -177,10 +213,11 @@ function SignSelect({
);
}
function formatValue(value: unknown): string {
function formatValue(value: unknown, field?: Field): string {
if (value === true) return "да";
if (value === false) return "нет";
if (Array.isArray(value)) return value.length ? value.join(", ") : "—";
if (value === null || value === undefined || value === "") return "—";
if (field?.options) return field.options.find((option) => option.value === value)?.label ?? String(value);
return String(value);
}

View file

@ -6,7 +6,7 @@
import type { DDSCode, IncidentType } from "@/shared/types/generated";
export type FieldKind = "text" | "number" | "bool" | "select" | "signs" | "list";
export type FieldKind = "text" | "textarea" | "number" | "bool" | "select" | "signs" | "list";
export interface Field {
/** Путь в карточке: «floor», «fire.floors» — так же, как в `kio.patch`. */
@ -21,6 +21,9 @@ export interface Field {
export interface FieldGroup {
title: string;
fields: Field[];
/** Рабочие зоны карточки 112: плотная шапка, адрес и описание слева,
* классификация и маршрутизация справа. */
area: "header" | "address" | "description" | "incident" | "dispatch" | "details";
/** Группа показывается только для этой службы. */
dds?: DDSCode;
}
@ -45,6 +48,7 @@ const DDS: { value: DDSCode; label: string }[] = [
export const GROUPS: FieldGroup[] = [
{
title: "Служебное",
area: "header",
fields: [
{ path: "registered_at", label: "Время регистрации", kind: "text", readOnly: true },
{ path: "response_status", label: "Статус реагирования", kind: "text", readOnly: true },
@ -52,6 +56,7 @@ export const GROUPS: FieldGroup[] = [
},
{
title: "Звонящий",
area: "header",
fields: [
{ path: "caller_number", label: "Номер телефона", kind: "text", readOnly: true },
{ path: "caller_name", label: "ФИО", kind: "text" },
@ -61,6 +66,7 @@ export const GROUPS: FieldGroup[] = [
},
{
title: "Место",
area: "address",
fields: [
{ path: "okato", label: "ОКАТО", kind: "text" },
{ path: "address", label: "Адрес", kind: "text" },
@ -71,15 +77,22 @@ export const GROUPS: FieldGroup[] = [
{ path: "intercom_code", label: "Код домофона", kind: "text" },
],
},
{
title: "Описание со слов заявителя",
area: "description",
fields: [
{ path: "description", label: "Описание", kind: "textarea" },
],
},
{
title: "Происшествие",
area: "incident",
fields: [
// Признаки по ЕКП — то, что оператор действительно проставляет.
// Код и список оповещения считает сервер (docs/spec/DATASET.md).
{ path: "signs", label: "Признаки происшествия", kind: "signs" },
{ path: "incident_code", label: "Код ЕКП", kind: "text", readOnly: true },
{ path: "incident_type", label: "Тип происшествия", kind: "select", options: INCIDENT },
{ path: "description", label: "Описание", kind: "text" },
{ path: "victims_count", label: "Пострадавших", kind: "number" },
{ path: "is_emergency", label: "Признак ЧС", kind: "bool" },
{ path: "life_threat", label: "Угроза жизни", kind: "bool" },
@ -88,6 +101,7 @@ export const GROUPS: FieldGroup[] = [
},
{
title: "Дежурно-диспетчерская служба",
area: "dispatch",
fields: [
// Список оповещения оператор не выбирает: он производен от признаков.
{ path: "notify", label: "Список оповещения", kind: "list", readOnly: true },
@ -98,6 +112,7 @@ export const GROUPS: FieldGroup[] = [
},
{
title: "Уточнения: пожар",
area: "details",
dds: "01",
fields: [
{ path: "fire.fire_nature", label: "Характер пожара", kind: "text" },
@ -110,6 +125,7 @@ export const GROUPS: FieldGroup[] = [
},
{
title: "Уточнения: полиция",
area: "details",
dds: "02",
fields: [
{ path: "police.offence_kind", label: "Вид правонарушения", kind: "text" },
@ -120,6 +136,7 @@ export const GROUPS: FieldGroup[] = [
},
{
title: "Уточнения: медицина",
area: "details",
dds: "03",
fields: [
{ path: "medical.reason", label: "Повод к вызову", kind: "text" },
@ -131,6 +148,7 @@ export const GROUPS: FieldGroup[] = [
},
{
title: "Уточнения: ЖКХ и прочие",
area: "details",
dds: "gkh",
fields: [
{ path: "utility.failure_kind", label: "Тип аварии", kind: "text" },

View file

@ -12,12 +12,17 @@ import { ALARMING, CARD_STATUS_LABELS, SERVICE_STATUS_LABELS } from "./statuses"
const ACK_LIMIT_S = 30;
type ReportPhase = "dispatched" | "arrived" | "working" | "completed";
interface PhoneReport { service: string; crew: string; phase: ReportPhase; text: string; at: string }
interface CompletedCard { card_id: string; scenario_id: string; score_auto: number }
interface DdsSnapshot extends StationSnapshot {
crew_options?: string[];
crew_selected?: string | null;
phone_reports?: PhoneReport[];
card_id?: string | null;
card_index?: number;
card_total?: number;
reply_text?: string;
completed_cards?: CompletedCard[];
}
type DdsCommand = { type: "crew.select"; crew: string } | { type: "phone.dial" } | { type: "station.finish" };
const REPORT_LABELS: Record<ReportPhase, string> = {
dispatched: "Доклад о выезде", arrived: "Доклад о прибытии",
working: "Доклад о ходе работ", completed: "Доклад о завершении работ",
@ -35,10 +40,19 @@ function time(value: string): string {
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString("ru-RU");
}
function serviceTitle(service: string): string {
return service.startsWith("Служба ") ? service : `Служба ${service}`;
}
export function Dds() {
const sessionId = sessionIdFromUrl();
const role = new URLSearchParams(location.search).get("role") ?? "dds_01";
const [status, setStatus] = useState<ChannelStatus>("connecting");
const [view, setView] = useState<"registry" | "card">("registry");
const [pane, setPane] = useState<"status" | "phone" | "reply" | "full" | "journal" | "more" | null>(null);
const [activeService, setActiveService] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [clockNow, setClockNow] = useState(() => new Date());
const [card, setCard] = useState<CardState | null>(null);
const [from, setFrom] = useState("");
const [receivedAt, setReceivedAt] = useState<number | null>(null);
@ -55,7 +69,10 @@ export function Dds() {
const [bounceComment, setBounceComment] = useState("");
const [bounced, setBounced] = useState(false);
const [zone, setZone] = useState<boolean | null>(null);
const [reply, setReply] = useState({ text: "", dirty: false });
const [advancePending, setAdvancePending] = useState(false);
const channel = useRef<ReturnType<typeof stationChannel> | null>(null);
const activeCardId = useRef<string | null>(null);
useEffect(() => {
if (!sessionId) return;
@ -84,20 +101,49 @@ export function Dds() {
const ch = stationChannel(sessionId, role, {
onStatus: (next) => { setStatus(next); if (next !== "open") setDialing(false); },
onEvent: (event) => {
if (event.type === "station.state") { setStation(event.snapshot as DdsSnapshot); setError(""); }
if (event.type === "station.state") {
const snapshot = event.snapshot as DdsSnapshot;
setStation(snapshot);
setActiveService((previous) => previous && snapshot.services.includes(previous)
? previous : snapshot.services[0] ?? null);
setReply((previous) => {
const saved = snapshot.reply_text ?? "";
if (previous.text === saved) return { text: saved, dirty: false };
return previous.dirty ? previous : { text: saved, dirty: false };
});
setError("");
}
else if (event.type === "card.received") {
setCard(applyState(emptyCard, event.card as unknown as Record<string, unknown>));
const nextId = String(event.card.card_id);
if (activeCardId.current !== nextId) {
activeCardId.current = nextId;
setStation(null);
setReply({ text: "", dirty: false });
setLastReport(null);
setDialing(false);
setMissing([]);
setBounceComment("");
setBounced(false);
setZone(null);
setReport(null);
setReportState("idle");
setView("registry");
setPane(null);
setActiveService(null);
}
setCard(applyState(emptyCard, { ...event.card }));
setFrom(event.from_operator);
// Норматив идёт с передачи карточки сервером, в том числе если
// диспетчер открыл АРМ уже после начала занятия.
const transmittedAt = new Date(event.at).getTime();
setReceivedAt(Number.isNaN(transmittedAt) ? Date.now() : transmittedAt);
setEnded(false);
} else if (event.type === "error") { setError(event.message); setDialing(false); }
else if (event.type === "session.ended") { setEnded(true); 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 === "score.ready") { void loadReport(); }
else if ((event as { type: string }).type === "phone.report") {
setLastReport(event as unknown as PhoneReport);
else if (event.type === "phone.report") {
setLastReport(event);
setDialing(false);
setError("");
}
@ -116,7 +162,12 @@ export function Dds() {
return () => clearInterval(timer);
}, [receivedAt, primaryRecorded]);
const send = (command: DdsCommand): boolean => channel.current?.send(command as StationToServer) ?? false;
useEffect(() => {
const timer = setInterval(() => setClockNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
const send = (command: StationToServer): boolean => channel.current?.send(command) ?? false;
if (!sessionId) return <main className="page"><h1>АРМ диспетчера ДДС</h1>
<p className="warn">Откройте ссылку занятия вида /dds?session=…&role=dds_01</p></main>;
@ -124,15 +175,29 @@ export function Dds() {
const selectedCrew = station?.crew_selected ?? "";
const latest = lastReport ?? reports.at(-1) ?? null;
const isConnected = status === "open";
const cardId = card ? String(readPath(card.server, "card_id") ?? "") : "";
const cardIndex = station?.card_index ?? 1;
const cardTotal = station?.card_total ?? 1;
const completedCards = station?.completed_cards ?? [];
const activeIsCompleted = completedCards.some((item) => item.card_id === cardId);
const query = search.trim().toLocaleLowerCase("ru-RU");
const visibleCompletedCards = completedCards.filter((item) =>
!query || `${item.card_id} ${item.scenario_id}`.toLocaleLowerCase("ru-RU").includes(query));
const showActive = card && !activeIsCompleted && (!query ||
[cardId, field(card, "address"), field(card, "description"), field(card, "incident_type")]
.some((value) => value.toLocaleLowerCase("ru-RU").includes(query)));
const crewAccepted = Boolean(station?.services.some((service) =>
selectedCrew.startsWith(`${service} —`) &&
["accepted", "responding", "arrived", "working"].includes(station.statuses[service])));
return <main className="page dds-workspace">
<header className="dds-header"><div><div className="dds-eyebrow">Учебный контур · АРМ системы-112</div>
<h1>Дежурно-диспетчерская служба <span>{role}</span></h1></div>
<div className={`dds-connection ${isConnected ? "state-ok" : "state-warn"}`}>
{isConnected ? "● Канал подключён" : `● ${status}`}</div></header>
<header className="dds-app-top">
<span className="dds-brand">112</span>
<span className="dds-app-name">ГБУ Система 112 · учебное АРМ ДДС</span>
<span className="dds-top-role">{role}</span>
<span className={`dds-connection ${isConnected ? "state-ok" : "state-warn"}`}>
{isConnected ? "● На связи" : `● ${status}`}</span>
</header>
{error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>}
{ended && <div className="dds-alert dds-done">Занятие завершено. Оценка появится после расчёта.</div>}
{(ended || reportState !== "idle") && <section className="dds-panel" aria-live="polite">
@ -142,6 +207,17 @@ export function Dds() {
{report.score_final !== report.score_auto && ` · автооценка ${report.score_auto.toFixed(0)}`}</p>
<p className="ref">Скачать отчёт: <a href={`/api/sessions/${sessionId}/report.csv`}>CSV</a>
{" · "}<a href={`/api/sessions/${sessionId}/report.pdf`}>PDF</a></p>
{Boolean(report.card_results?.length) && <>
<h3>Результаты по карточкам</h3>
{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>
<p><b>Ответ диспетчера:</b> {item.reply_text || "не внесён"}</p>
{item.metrics.filter((metric) => !metric.passed).map((metric) =>
<p key={metric.key} className="state-violated">{metric.title}: {metric.fact} · норматив {metric.norm}</p>)}
{item.findings.map((finding, findingIndex) =>
<p key={`${finding.code}-${findingIndex}`}>{finding.code}: {finding.summary}</p>)}
</details>)}
</>}
<h3>Не выполнены нормативы</h3>
<table className="dds-registry"><tbody>
{report.metrics.filter((metric) => !metric.passed).map((metric) => <tr key={metric.key}>
@ -172,20 +248,81 @@ export function Dds() {
</div>}
</section>}
<section className="dds-panel"><div className="dds-panel-title"><h2>Реестр обращений</h2><span>Входящие карточки</span></div>
<div className="dds-table-wrap"><table className="dds-registry"><thead><tr>
<th>№ карточки</th><th>Время</th><th>Адрес происшествия</th><th>Тип</th><th>Службы</th><th>Статус</th><th>Решение ДДС</th>
</tr></thead><tbody>{card ? <tr className="dds-selected">
<td>{sessionId.slice(0, 8).toUpperCase()}</td><td>{time(field(card, "registered_at"))}</td>
<td>{field(card, "address")}</td><td>{field(card, "incident_type")}</td>
<td>{station?.services.join(", ") || field(card, "notify")}</td>
<td>{station ? CARD_STATUS_LABELS[station.card] : "Получена"}</td>
<td>{station?.services.map((service) => `${service}: ${SERVICE_STATUS_LABELS[station.statuses[service]]}`).join("; ") || "ожидается"}</td>
</tr> : <tr><td colSpan={7} className="dds-empty">Ожидание готовой карточки от преподавателя или оператора 112…</td></tr>}</tbody></table></div>
{view === "registry" && <>
<section className="dds-search">
<div className="dds-search-main">
<h1>Поиск происшествий</h1>
<div className="dds-search-line"><label htmlFor="dds-search-input">Поиск по номеру, адресу или описанию</label>
<input id="dds-search-input" type="search" value={search} onChange={(event) => setSearch(event.target.value)}
placeholder="Введите номер или адрес" />
<button type="button" onClick={() => setSearch("")}>сбросить</button></div>
</div>
<div className="dds-clock"><small>{clockNow.toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long", year: "numeric" })}</small>
<strong>{clockNow.toLocaleTimeString("ru-RU")}</strong><span>Учебный контур</span></div>
</section>
<section className="dds-panel dds-register-panel"><div className="dds-panel-title"><h2>Список происшествий⌃</h2>
<span>Карточка {cardIndex} из {cardTotal} · обработано {completedCards.length}</span></div>
<div className="dds-table-wrap"><table className="dds-registry"><thead><tr>
<th>Связи</th><th>ЧС</th><th>Опер.</th><th>АРМ</th><th>Номер</th><th>Дата</th><th>Время</th><th>Тип происшествия</th><th>Постр.</th><th>Адрес</th><th>Статус службы</th>
</tr></thead><tbody>
{visibleCompletedCards.map((item) => <tr key={item.card_id}>
<td data-label="Связи">▾</td><td data-label="ЧС">—</td><td data-label="Опер.">0</td><td data-label="АРМ">ДДС</td>
<td data-label="Номер">{item.card_id.slice(0, 8).toUpperCase()}</td><td data-label="Дата">—</td><td data-label="Время">—</td>
<td data-label="Тип происшествия">Сценарий {item.scenario_id}</td><td data-label="Постр.">—</td><td data-label="Адрес">—</td>
<td data-label="Статус службы">Отработана · {item.score_auto.toFixed(0)} / 100</td>
</tr>)}
{showActive && card && <tr className="dds-selected" aria-current="true">
<td data-label="Связи"><button type="button" className="dds-row-open" onClick={() => setView("card")} aria-label="Открыть карточку">▾</button></td>
<td data-label="ЧС">{field(card, "is_emergency") === "true" ? "Да" : "Нет"}</td>
<td data-label="Опер.">0</td><td data-label="АРМ">ДДС</td>
<td data-label="Номер"><button type="button" className="dds-row-link" onClick={() => setView("card")}>{cardId.slice(0, 8).toUpperCase()}</button></td>
<td data-label="Дата">{receivedAt ? new Date(receivedAt).toLocaleDateString("ru-RU") : "—"}</td>
<td data-label="Время">{receivedAt ? new Date(receivedAt).toLocaleTimeString("ru-RU") : "—"}</td>
<td data-label="Тип происшествия">{field(card, "incident_code")} · {field(card, "incident_type")}
<small>Описание: {field(card, "description")}</small></td>
<td data-label="Постр.">{field(card, "victims_count")}</td>
<td data-label="Адрес">{field(card, "address")}</td>
<td data-label="Статус службы">{station?.services.map((service) => `${service}: ${SERVICE_STATUS_LABELS[station.statuses[service]]}`).join("; ") || "Добавлена"}</td>
</tr>}
{!showActive && !visibleCompletedCards.length && <tr><td colSpan={11} className="dds-empty">
{query ? "По запросу ничего не найдено." : "Ожидание готовой карточки от преподавателя или оператора 112…"}</td></tr>}
</tbody></table></div>
{cardTotal > cardIndex && <p className="dds-hint">Остальные {cardTotal - cardIndex} карточки поступят после завершения текущей.</p>}
</section>
</>}
{card && <div className="dds-main"><div className="dds-left">
<section className="dds-panel"><div className="dds-panel-title"><h2>Карточка обращения</h2>
{card && view === "card" && <div className="dds-case-layout">
<div className="dds-case-top"><button type="button" onClick={() => { setView("registry"); setPane(null); }}>← Список происшествий</button>
<span>Происшествие {cardId.slice(0, 8).toUpperCase()} · карточка {cardIndex} из {cardTotal}</span></div>
<div className="dds-call-strip">
<div className="dds-call-off"><span className="dds-call-icon">☎</span><strong>Отключение</strong><small>записи звонков · список SMS</small></div>
<div><span className="dds-call-icon">☎</span><small>АОН</small><strong>{field(card, "caller_number")}</strong></div>
<div><span className="dds-call-icon">☎</span><small>предоставленный</small><strong>{field(card, "caller_contact")}</strong></div>
<div><span className="dds-call-icon">☎</span><small>телефон на место</small><strong>{field(card, "caller_contact")}</strong></div>
<div className="dds-call-incident"><strong>Происшествие {cardId.slice(0, 8).toUpperCase()}</strong>
<small>Сохр. {receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "—"}</small>
<small>Опер., АРМ ДДС</small></div>
<div className="dds-case-top-buttons"><button type="button" onClick={() => setPane("full")}>просмотр</button>
<button type="button" onClick={() => setPane("more")}>дополнение</button></div>
</div>
<div className="dds-case-meta"><span>ФИО заявителя: <b>{field(card, "caller_name")}</b></span>
<span>Пострадавшие: {field(card, "victims_count")} &nbsp; ЧС: {field(card, "is_emergency") === "true" ? "да" : "нет"}</span>
<span>✎</span></div>
<div className="dds-case-summary">
<div className="dds-case-address"><strong>{field(card, "address")}</strong><span>{field(card, "street")} {field(card, "building")}</span>
<div><b>{receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "—"}</b> &nbsp; {field(card, "description")}</div></div>
<div className="dds-case-classification"><strong>Происшествие {field(card, "incident_code")}</strong>
<div><b>{field(card, "description")}</b></div>
<div>Класс: <b>{field(card, "incident_type")}</b></div>
<div>Список оповещения: <b>{station?.services.join(", ") || field(card, "notify")}</b></div></div>
</div>
<div className="dds-case-stage">
{pane && <div className={`dds-case-drawer dds-case-drawer-${pane}`}>
<header className="dds-drawer-title"><strong>{({ status: "Статусы службы", phone: "IP-телефон", reply: "Ответ диспетчера",
full: "Карточка информационного обмена", journal: "Журнал реагирования", more: "Дополнение" })[pane]}</strong>
<button type="button" onClick={() => setPane(null)} aria-label="Закрыть панель">×</button></header>
<div className="dds-main"><div className="dds-left">
{pane === "full" && <section className="dds-panel"><div className="dds-panel-title"><h2>Карточка обращения</h2>
<span>Источник: {from || "112"}</span></div>
<div className="dds-summary">
<div><small>Адрес</small><strong>{field(card, "address")}</strong></div>
@ -193,28 +330,39 @@ export function Dds() {
<div><small>Телефон</small><strong>{field(card, "caller_contact")}</strong></div>
<div><small>Происшествие</small><strong>{field(card, "description")}</strong></div>
</div><details className="dds-details"><summary>Полная карточка информационного обмена</summary><KioCard state={card} readOnly /></details>
</section>
<section className="dds-panel"><div className="dds-panel-title"><h2>Реагирование служб</h2>
</section>}
{pane === "status" && <section className="dds-panel"><div className="dds-panel-title"><h2>Реагирование служб</h2>
<span className={station && ALARMING.includes(station.card) ? "state-violated" : "state-ok"}>
{station ? CARD_STATUS_LABELS[station.card] : "—"}</span></div>
{!primaryRecorded && receivedAt !== null && <div className={`dds-deadline ${elapsed > ACK_LIMIT_S ? "state-violated" : "state-ok"}`}>
Первичное решение «Принята» / «Не принята»: {elapsed.toFixed(0)} из {ACK_LIMIT_S} секунд</div>}
{station && station.services.length > 0 ? <div className="dds-table-wrap"><table className="dds-registry dds-status-table">
<thead><tr><th>Служба</th><th>Текущий статус</th><th>Следующее действие</th></tr></thead><tbody>
{station.services.map((service) => <StatusRow key={service} service={service}
{station && station.services.length > 0 ? <>
<div className="dds-status-history"><strong>Служба {activeService ?? station.services[0]}</strong>
<button type="button" className="dds-status-close" onClick={() => setPane(null)} aria-label="Закрыть статусы">×</button>
{station.log.filter((entry) => entry.service === (activeService ?? station.services[0])).map((entry, index) =>
<div key={`${entry.at}-${index}`}><span>› {time(entry.at)} {SERVICE_STATUS_LABELS[entry.status]}</span>
<span>{entry.comment || "—"}</span></div>)}
{!station.log.some((entry) => entry.service === (activeService ?? station.services[0])) &&
<div><span>› Карточка получена службой</span><span>Ожидается первичное решение</span></div>}
</div>
{station.services.filter((service) => service === (activeService ?? station.services[0])).map((service) =>
<StatusRow key={`${cardId}:${service}`} service={service}
current={station.statuses[service]} available={station.available[service] ?? []}
disabled={!isConnected || ended} reports={reports.filter((report) => report.service === service)}
phoneExercise={from === "учебный сценарий"}
crewOptions={station.crew_options?.filter((crew) => crew.startsWith(`${service} —`)) ?? []}
selectedCrew={selectedCrew.startsWith(`${service} —`) ? selectedCrew : ""}
onCrew={(crew) => send({ type: "crew.select", crew })}
onSet={(next, comment) => channel.current?.send({ type: "card.status", service, status: next, comment })} />)}
</tbody></table></div> : <p className="dds-empty">Ожидаем состав служб по карточке.</p>}
</> : <p className="dds-empty">Ожидаем состав служб по карточке.</p>}
<p className="dds-hint">Сначала примите карточку или укажите причину отказа. Последующие статусы в занятии ДДС подтверждаются докладами по телефону.</p>
</section>
{station && station.log.length > 0 && <section className="dds-panel"><div className="dds-panel-title"><h2>Журнал реагирования</h2></div>
</section>}
{pane === "journal" && station && station.log.length > 0 && <section className="dds-panel"><div className="dds-panel-title"><h2>Журнал реагирования</h2></div>
<div className="dds-table-wrap"><table className="dds-registry"><thead><tr><th>Время</th><th>Служба</th><th>Статус</th><th>Комментарий</th></tr></thead>
<tbody>{station.log.map((entry, index) => <tr key={`${entry.at}-${index}`}><td>{time(entry.at)}</td><td>{entry.service}</td>
<td>{SERVICE_STATUS_LABELS[entry.status]}</td><td>{entry.comment || "—"}</td></tr>)}</tbody></table></div></section>}
</div><aside className="dds-right">
<section className="dds-panel dds-phone"><div className="dds-panel-title"><h2>IP-телефон</h2><span>Учебная имитация</span></div>
{pane === "phone" && <section className="dds-panel dds-phone"><div className="dds-panel-title"><h2>IP-телефон</h2><span>Учебная имитация</span></div>
<p className="dds-hint">Выберите бригаду вручную, свяжитесь со старшим группы и внесите его доклад в карточку.</p>
<label htmlFor="dds-crew">Дежурная бригада</label>
<select id="dds-crew" value={selectedCrew} disabled={!isConnected || ended || !station?.crew_options?.length}
@ -233,8 +381,21 @@ export function Dds() {
{reports.length > 0 && <details className="dds-details"><summary>История докладов ({reports.length})</summary>
<ol className="dds-reports">{reports.map((report, index) => <li key={`${report.at}-${index}`}><b>{REPORT_LABELS[report.phase]}</b> · {time(report.at)}<br />{report.text}</li>)}</ol>
</details>}
</section>
<section className="dds-panel dds-secondary"><div className="dds-panel-title"><h2>Дополнительные действия</h2></div>
</section>}
{pane === "reply" && <section className="dds-panel dds-reply">
<div className="dds-panel-title"><h2>Ответ и комментарий диспетчера</h2>
<span>{reply.dirty ? "Не сохранено" : "Сохранено"}</span></div>
<label htmlFor="dds-reply-text">Свободный текст по текущей карточке</label>
<textarea id="dds-reply-text" rows={7} value={reply.text}
disabled={!isConnected || ended || advancePending}
placeholder="Что сообщено старшему группы, какие сведения получены и почему принято решение"
onChange={(event) => setReply({ text: event.target.value, dirty: true })} />
<p className="dds-hint">Ответ сохраняется в журнале текущей карточки. Автооценка проверяет его наличие и минимальную полноту, но не грамотность или фактическую верность каждого предложения.</p>
<button type="button" disabled={!cardId || !reply.dirty || !isConnected || ended || advancePending}
onClick={() => { if (send({ type: "card.reply", card_id: cardId, text: reply.text })) setError(""); }}>
Сохранить ответ</button>
</section>}
{pane === "more" && <section className="dds-panel dds-secondary"><div className="dds-panel-title"><h2>Дополнительные действия</h2></div>
<div className="dds-zone">Зона ответственности: <button type="button" disabled={zone !== null || !isConnected || ended}
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: true }); setZone(true); }}>Наша</button>
<button type="button" disabled={zone !== null || !isConnected || ended}
@ -247,15 +408,42 @@ export function Dds() {
channel.current?.send({ type: "card.bounce", missing_fields: missing, comment: bounceComment }); setBounced(true);
}}>Вернуть оператору</button>
</details>
<button type="button" className="dds-finish" disabled={!isConnected || ended} onClick={() => send({ type: "station.finish" })}>Завершить упражнение ДДС</button>
</section>
</aside></div>}
<button type="button" className="dds-finish" disabled={!cardId || !isConnected || ended || advancePending}
onClick={() => {
if (reply.dirty && !send({ type: "card.reply", card_id: cardId, text: reply.text })) return;
if (send({ type: "card.next", card_id: cardId })) setAdvancePending(true);
}}>
{cardIndex < cardTotal ? "Завершить карточку и открыть следующую" : "Завершить карточку и занятие"}
</button>
{cardTotal > 1 && <button type="button" disabled={!isConnected || ended || advancePending}
onClick={() => { if (window.confirm("Завершить занятие досрочно? Остальные карточки не будут пройдены.")) send({ type: "station.finish" }); }}>
Досрочно завершить занятие</button>}
</section>}
</aside></div></div>}
</div>
<nav className="dds-service-dock" aria-label="Действия с карточкой">
<span className="dds-dock-label">Службы:</span>
{station?.services.map((service) => <button type="button" className={pane === "status" && activeService === service ? "active" : ""}
key={service} onClick={() => { setActiveService(service); setPane("status"); }}>
<b>{serviceTitle(service)}</b><small>{SERVICE_STATUS_LABELS[station.statuses[service]]}</small>
</button>)}
<button type="button" onClick={() => setPane("phone")}><b>☎ Связь</b><small>Бригада</small></button>
<button type="button" onClick={() => setPane("reply")}><b>✎ Ответ</b><small>{reply.dirty ? "Не сохранён" : "Комментарий"}</small></button>
<button type="button" onClick={() => setPane("journal")}><b>Журнал</b><small>Статусы</small></button>
<button type="button" disabled={!cardId || !isConnected || ended || advancePending}
onClick={() => {
if (reply.dirty && !send({ type: "card.reply", card_id: cardId, text: reply.text })) return;
if (send({ type: "card.next", card_id: cardId })) setAdvancePending(true);
}}><b>✓ Завершить</b><small>{cardIndex < cardTotal ? "Следующая карточка" : "Занятие"}</small></button>
</nav>
</div>}
</main>;
}
function StatusRow({ service, current, available, disabled, reports, phoneExercise, onSet }: {
function StatusRow({ service, current, available, disabled, reports, phoneExercise, crewOptions, selectedCrew, onCrew, onSet }: {
service: string; current: ServiceStatus; available: ServiceStatus[]; disabled: boolean;
reports: PhoneReport[]; phoneExercise: boolean; onSet: (status: ServiceStatus, comment: string) => void;
reports: PhoneReport[]; phoneExercise: boolean; crewOptions: string[]; selectedCrew: string;
onCrew: (crew: string) => void; onSet: (status: ServiceStatus, comment: string) => void;
}) {
const [chosen, setChosen] = useState<ServiceStatus | "">("");
const [comment, setComment] = useState("");
@ -265,17 +453,32 @@ function StatusRow({ service, current, available, disabled, reports, phoneExerci
};
const matchingReport = !chosen || !phoneExercise || !reportNeeded[chosen] ||
reports.some((report) => report.phase === reportNeeded[chosen]);
return <tr><td>{service}</td><td><strong>{SERVICE_STATUS_LABELS[current]}</strong></td><td>
{available.length ? <div className="dds-status-action">
if (!available.length) return <p className="dds-hint">{serviceTitle(service)}: {SERVICE_STATUS_LABELS[current]}. Дальнейшие статусы недоступны.</p>;
return <div className="dds-status-editor">
<label>Статус
<select value={chosen} disabled={disabled} aria-label={`Следующий статус ${service}`}
onChange={(event) => setChosen(event.target.value as ServiceStatus)}>
<option value="">Выберите статус</option>
<option value="">{SERVICE_STATUS_LABELS[current]} →</option>
{available.map((next) => <option key={next} value={next}>{SERVICE_STATUS_LABELS[next]}</option>)}
</select>
{needsComment && <input type="text" value={comment} placeholder="Причина отказа и кому передана информация"
onChange={(event) => setComment(event.target.value)} />}
{chosen && !matchingReport && <span className="state-warn">Сначала получите доклад по телефону.</span>}
<button type="button" disabled={disabled || !chosen || (needsComment && !comment.trim()) || !matchingReport}
onClick={() => { if (chosen) { onSet(chosen, comment.trim()); setChosen(""); setComment(""); } }}>Сохранить статус</button>
</div> : "Карточка закрыта для редактирования"}</td></tr>;
</label>
<label>Номер наряда
<select value={selectedCrew} disabled={disabled || !crewOptions.length} aria-label={`Наряд ${service}`}
onChange={(event) => { if (event.target.value) onCrew(event.target.value); }}>
<option value="">—</option>
{crewOptions.map((crew) => <option value={crew} key={crew}>{crew}</option>)}
</select>
</label>
<label>Комментарий
<input type="text" value={comment} disabled={disabled} aria-label={`Комментарий к статусу ${service}`}
placeholder={needsComment ? "Причина отказа и кому передана информация" : "Комментарий"}
onChange={(event) => setComment(event.target.value)} />
</label>
<button type="button" className="dds-status-save" aria-label="Сохранить статус"
disabled={disabled || !chosen || (needsComment && !comment.trim()) || !matchingReport}
onClick={() => { if (chosen) { onSet(chosen, comment.trim()); setChosen(""); setComment(""); } }}>✓</button>
<button type="button" className="dds-status-cancel" aria-label="Отменить изменение статуса"
onClick={() => { setChosen(""); setComment(""); }}>×</button>
{chosen && !matchingReport && <span className="dds-status-warning state-warn">Сначала получите доклад по телефону.</span>}
</div>;
}

View file

@ -0,0 +1,94 @@
import { useEffect, useState } from "react";
import { useAssignTrainee, useCreateGroup, useGroupAnalytics, useGroups, useTrainees } from "@/shared/api/http";
export function Groups() {
const groups = useGroups();
const trainees = useTrainees();
const create = useCreateGroup();
const assign = useAssignTrainee();
const [groupId, setGroupId] = useState<string | null>(null);
const [groupName, setGroupName] = useState("");
const [traineeId, setTraineeId] = useState("");
const report = useGroupAnalytics(groupId);
useEffect(() => {
if (!groupId && groups.data?.length) setGroupId(groups.data[0].id);
}, [groups.data, groupId]);
return (
<main className="page">
<h1>Аналитика учебной группы</h1>
<p><a href="/instructor">← к пульту преподавателя</a></p>
<form onSubmit={(event) => {
event.preventDefault();
create.mutate(groupName.trim(), { onSuccess: (group) => {
setGroupId(group.id);
setGroupName("");
} });
}}>
<label>Новая группа{" "}
<input value={groupName} maxLength={120} onChange={(event) => setGroupName(event.target.value)} />
</label>{" "}
<button type="submit" disabled={!groupName.trim() || create.isPending}>Создать</button>
{create.isError && <span className="warn"> Не удалось создать группу: возможно, имя занято.</span>}
</form>
<label>
Группа{" "}
<select value={groupId ?? ""} onChange={(event) => setGroupId(event.target.value || null)}>
<option value="">— выберите группу —</option>
{groups.data?.map((group) => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
</label>
{groupId && <form onSubmit={(event) => {
event.preventDefault();
if (traineeId) assign.mutate({ groupId, traineeId }, { onSuccess: () => setTraineeId("") });
}}>
<label>Добавить курсанта{" "}
<select value={traineeId} onChange={(event) => setTraineeId(event.target.value)}>
<option value="">— выберите курсанта —</option>
{trainees.data?.map((trainee) => (
<option key={trainee.id} value={trainee.id}>{trainee.name}{trainee.group ? ` · ${trainee.group}` : ""}</option>
))}
</select>
</label>{" "}
<button type="submit" disabled={!traineeId || assign.isPending}>Назначить</button>
{assign.isError && <span className="warn"> Не удалось назначить курсанта.</span>}
</form>}
{groups.isError && <p className="warn">Не удалось загрузить группы.</p>}
{groups.data?.length === 0 && <p>Учебные группы ещё не заведены.</p>}
{report.isError && <p className="warn">Не удалось загрузить аналитику.</p>}
{report.data && (
<>
<p>
Курсанты: {report.data.active_trainees} занимались из {report.data.enrolled_trainees} ·
оценённых попыток: {report.data.scored_attempts} ·
средний балл: {report.data.average_score?.toFixed(1) ?? "—"}
</p>
<p className="ref">
Доли ошибок считаются среди курсантов с оценёнными попытками; повторные попытки
одного человека не увеличивают долю. Рекомендации сформированы по правилам,
не нейросетью.
</p>
<table className="grid">
<thead><tr><th>Типичная ошибка</th><th>Курсанты</th><th>Рекомендация</th></tr></thead>
<tbody>
{report.data.errors.map((item) => (
<tr key={item.code}>
<th>{item.code} · {item.title}</th>
<td>{item.affected_trainees} · {item.rate_percent.toFixed(1)}%</td>
<td>{item.recommendation}</td>
</tr>
))}
{report.data.errors.length === 0 && (
<tr><td colSpan={3}>Пока нет ошибок в оценённых занятиях.</td></tr>
)}
</tbody>
</table>
</>
)}
</main>
);
}

View file

@ -28,6 +28,7 @@ const MODES: { value: SessionMode; label: string }[] = [
export function Instructor() {
const [sessionId, setSessionId] = useState<string | null>(sessionIdFromUrl());
const [scenarioId, setScenarioId] = useState("");
const [extraScenarioIds, setExtraScenarioIds] = useState<string[]>([]);
const [mode, setMode] = useState<SessionMode>("training");
const [exercise, setExercise] = useState<"dds" | "call" | "card">("dds");
const [trainee, setTrainee] = useState("");
@ -62,8 +63,11 @@ export function Instructor() {
if (!sessionId || control.status !== "open" || toStart.current !== sessionId) return;
toStart.current = null;
const selected = trainees.data?.find((item) => item.id === traineeId);
control.start(scenarioId, mode, selected?.name || trainee || "Демо-курсант", exercise, undefined, selected?.id);
}, [sessionId, control, scenarioId, mode, trainee, traineeId, trainees.data, exercise]);
const scenarioIds = exercise === "dds"
? [scenarioId, ...extraScenarioIds.filter((id) => id !== scenarioId)] : undefined;
control.start(scenarioId, mode, selected?.name || trainee || "Демо-курсант", exercise,
undefined, selected?.id, scenarioIds && scenarioIds.length > 1 ? scenarioIds : undefined);
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data, exercise]);
const links = sessionId
? {
@ -87,13 +91,27 @@ export function Instructor() {
<tr>
<th>Сценарий</th>
<td>
<select value={scenarioId} onChange={(event) => setScenarioId(event.target.value)}>
<select value={scenarioId} onChange={(event) => {
setScenarioId(event.target.value);
setExtraScenarioIds((previous) => previous.filter((id) => id !== event.target.value));
}}>
{scenarios.data?.map((scenario) => (
<option key={scenario.id} value={scenario.id}>
{scenario.title} · {scenario.level}
</option>
))}
</select>
{exercise === "dds" && <details className="scenario-queue">
<summary>Дополнительные карточки ({extraScenarioIds.length})</summary>
<p className="ref">Карточки поступят последовательно после завершения текущей.</p>
{scenarios.data?.filter((scenario) => scenario.id !== scenarioId).map((scenario) =>
<label key={scenario.id} className="mode-choice">
<input type="checkbox" checked={extraScenarioIds.includes(scenario.id)} onChange={() =>
setExtraScenarioIds((previous) => previous.includes(scenario.id)
? previous.filter((id) => id !== scenario.id) : [...previous, scenario.id])} />
{" "}{scenario.title} · {scenario.level}
</label>)}
</details>}
</td>
</tr>
<tr>
@ -219,7 +237,7 @@ export function Instructor() {
<section style={{ flex: "0 0 260px" }}>
<h2>История группы</h2>
<p><a href="/profile">профили курсантов</a></p>
<p><a href="/profile">профили курсантов</a> · <a href="/groups">аналитика группы</a></p>
<table className="grid">
<tbody>
{history.data?.slice(0, 15).map((item) => (

View file

@ -47,30 +47,36 @@ export function Call() {
}
if (call.briefing) {
return <main className="page">
const services = display(call.card, "notify");
const notifiedServices = Array.isArray(services) ? services.map(String) : [];
return <main className="page arm-page">
<ModeBanner mode={call.briefing.mode} />
<h1>АРМ оператора 112 · учебная карточка</h1>
<p className="ref">Текстовое упражнение: входящий голосовой вызов и микрофон не используются.</p>
<table className="grid"><tbody>
<tr><th>Канал</th><td className={call.status === "open" ? "state-ok" : "state-warn"}>{STATUS_LABEL[call.status]}</td></tr>
<tr><th>Сценарий</th><td>{call.briefing.scenario_id}</td></tr>
<tr><th>Состояние</th><td>{call.cardSubmitted ? "карточка отправлена на оценку" : "карточка заполняется"}</td></tr>
</tbody></table>
<section style={{ maxWidth: 900, marginTop: 16 }}>
<h2>Вводная о происшествии</h2>
<div style={{ background: "#fff", border: "1px solid var(--line)", padding: 16, whiteSpace: "pre-wrap", lineHeight: 1.5 }}>
{call.briefing.text}
</div>
<p className="ref">Извлеките сведения из вводной, заполните поля карточки и проверьте предлагаемый список оповещения.</p>
<h2>Карточка информационного обмена</h2>
<KioCard state={call.card} required={call.briefing.required_fields}
readOnly={call.cardSubmitted || call.status !== "open"} onChange={call.patchKio} />
{!call.cardSubmitted && <p><button type="button" className="start" disabled={call.status !== "open"}
onClick={call.submitCard}>Отправить карточку на проверку</button></p>}
{call.error && <p className="state-violated" role="alert">{call.error}</p>}
{call.cardSubmitted && !call.report && !call.reportError && <p>Карточка отправлена. Ожидаем итоговую оценку…</p>}
{call.reportError && <p className="state-violated" role="alert">{call.reportError}</p>}
<header className="arm-topbar arm-topbar-text">
<div className="arm-topbar-mark">112</div>
<div className="arm-topbar-title"><strong>Карточка информационного обмена</strong><span>Текстовое упражнение · сценарий {call.briefing.scenario_id}</span></div>
<div className="arm-topbar-state"><span className={call.status === "open" ? "state-ok" : "state-warn"}>● {call.status === "open" ? "АРМ подключён" : STATUS_LABEL[call.status]}</span><small>{call.cardSubmitted ? "Карточка отправлена" : "Карточка заполняется"}</small></div>
</header>
<section className="arm-briefing">
<div className="arm-briefing-label"><strong>Учебная вводная</strong><span>Текстовый режим · без входящего звонка</span></div>
<div className="arm-briefing-text">{call.briefing.text}</div>
</section>
<div className="arm-card-wrap">
<KioCard state={call.card} required={call.briefing.required_fields}
readOnly={call.cardSubmitted || call.status !== "open"} referenceArm onChange={call.patchKio} />
</div>
<footer className="arm-footer">
<span className="arm-footer-services">Службы:
{notifiedServices.length
? notifiedServices.map((service) => <b key={service}>{service}</b>)
: <em>появятся после выбора признаков</em>}
</span>
{!call.cardSubmitted && <button type="button" className="arm-primary" disabled={call.status !== "open"}
onClick={call.submitCard}>Сохранить и проверить</button>}
{call.cardSubmitted && <strong>Карточка отправлена</strong>}
</footer>
{call.error && <p className="state-violated arm-message" role="alert">{call.error}</p>}
{call.cardSubmitted && !call.report && !call.reportError && <p className="arm-message">Карточка отправлена. Ожидаем итоговую оценку…</p>}
{call.reportError && <p className="state-violated arm-message" role="alert">{call.reportError}</p>}
{call.report && <Debrief report={call.report} />}
</main>;
}
@ -80,59 +86,53 @@ export function Call() {
const autoRoute = Boolean(display(call.card, "incident_code")) && Array.isArray(notify) && notify.length > 0;
return (
<main className="page">
<main className="page arm-page">
<StandStatus />
<ModeBanner mode={call.incoming?.mode} />
<h1>АРМ оператора 112</h1>
<InterviewTimer timers={call.timers} />
<table className="grid">
<tbody>
<tr><th>Канал</th><td className={call.status === "open" ? "" : "warn"}>{STATUS_LABEL[call.status]}</td></tr>
<tr><th>Вызов</th><td>{call.incoming ? `${call.incoming.caller_number} · сценарий ${call.incoming.scenario_id} · режим ${call.incoming.mode}` : "ожидание"}</td></tr>
<tr><th>Гарнитура</th><td>{call.micOn ? "микрофон включён" : "выключен"}</td></tr>
<tr><th>Звонящий</th><td>{call.callerSpeaking ? "говорит" : "молчит"}</td></tr>
</tbody>
</table>
<header className="arm-topbar">
<div className="arm-topbar-mark">112</div>
<div className="arm-phone-cell"><small>Канал связи</small><strong className={call.status === "open" ? "state-ok" : "state-warn"}>{STATUS_LABEL[call.status]}</strong><span>{call.micOn ? "● Микрофон включён" : "Микрофон выключен"}</span></div>
<div className="arm-phone-cell arm-phone-main"><small>Входящий вызов</small><strong>{call.incoming?.caller_number || "Ожидание вызова"}</strong><span>{call.incoming ? `Сценарий ${call.incoming.scenario_id} · ${call.callerSpeaking ? "заявитель говорит" : "заявитель молчит"}` : "Карточка не назначена"}</span></div>
<div className="arm-topbar-title"><strong>АРМ оператора 112</strong><span>Карточка информационного обмена</span></div>
<div className="arm-topbar-clock"><InterviewTimer timers={call.timers} /></div>
</header>
<p>
{call.phase === "incoming" && !call.micOn && (
<button type="button" onClick={() => void call.answer()}>Ответить</button>
)}
{call.micOn && <button type="button" onClick={call.hangup}>Завершить вызов</button>}{" "}
{call.micOn && hintsAllowed(call.incoming?.mode) && (
<button type="button" onClick={call.hint}>Подсказка</button>
)}
</p>
<div className="arm-call-actions">
{call.phase === "incoming" && !call.micOn && <button type="button" className="arm-answer" onClick={() => void call.answer()}>☎ Ответить на вызов</button>}
{call.micOn && <span className="arm-live">● Разговор идёт</span>}
{call.micOn && hintsAllowed(call.incoming?.mode) && <button type="button" onClick={call.hint}>Подсказка</button>}
<span className="arm-call-actions-note">{call.phase === "ended" ? "Вызов завершён" : "Заполните адрес и признаки происшествия со слов заявителя"}</span>
</div>
{call.error && <p className="violated">{call.error}</p>}
{call.error && <p className="violated arm-message" role="alert">{call.error}</p>}
{call.phase === "ended" && !call.selfAssessed && call.checklist.length > 0 && (
<SelfAssessment checklist={call.checklist} onSubmit={call.submitSelfAssessment} />
)}
{call.selfAssessed && !call.report && <p>Самооценка принята, оценка считается…</p>}
{call.report && <Debrief report={call.report} />}
<div className="columns">
<section>
<h2>Карточка информационного обмена</h2>
<div className="arm-card-wrap">
<KioCard
state={call.card}
required={call.incoming?.required_fields ?? []}
readOnly={!call.micOn}
referenceArm
onChange={call.patchKio}
/>
<p>
</div>
<footer className="arm-footer">
<span>Службы · список оповещения рассчитывается по признакам</span>
<div className="arm-footer-actions">
<button
type="button"
className="arm-primary"
disabled={!call.micOn || (!service && !autoRoute)}
onClick={() => call.dispatch(service)}
>
Передать в ДДС
</button>
</p>
{call.micOn && <button type="button" onClick={call.hangup}>Завершить вызов</button>}
</div>
</footer>
<div className="arm-secondary">
{/* Не каждый вызов заканчивается карточкой: справка и чужой регион —
отдельные исходы, и курсант выбирает их сам (docs/spec/TICKETS.md). */}
<details>
<details className="arm-resolve">
<summary>Закрыть вызов без карточки</summary>
<p>
<input
@ -166,12 +166,10 @@ export function Call() {
</p>
{resolved && <p className="warn">Вызов закрыт: {RESOLVE_LABELS[resolved]}</p>}
</details>
</section>
<section>
<h2>Нормативы</h2>
<section className="arm-support">
<h2>Нормативы и разговор</h2>
<Timers timers={call.timers} />
<h2>Разговор</h2>
<table className="grid">
<table className="grid">
<tbody>
{call.lines.map((line, index) => (
<tr key={index}>
@ -184,6 +182,11 @@ export function Call() {
</table>
</section>
</div>
{call.phase === "ended" && !call.selfAssessed && call.checklist.length > 0 && (
<SelfAssessment checklist={call.checklist} onSubmit={call.submitSelfAssessment} />
)}
{call.selfAssessed && !call.report && <p className="arm-message">Самооценка принята, оценка считается…</p>}
{call.report && <Debrief report={call.report} />}
</main>
);
}

View file

@ -1,7 +1,7 @@
// HTTP-клиент и хуки. Типы ответов HTTP в generated.ts пока не попадают —
// там только события WS; здесь описаны руками по docs/arch/CONTRACT.md#http-api.
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Level, SessionMode } from "@/shared/types/generated";
@ -32,6 +32,7 @@ export interface Health {
embeddings_ready: boolean;
scenarios_loaded: number;
offline: boolean;
demo_no_db?: boolean;
}
export interface ScenarioSummary {
@ -125,6 +126,60 @@ export function useSigns(prefix: string[]) {
export const useTrainees = () =>
useQuery({ queryKey: ["trainees"], queryFn: () => request<TraineeInfo[]>("/api/trainees") });
export interface GroupInfo {
id: string;
name: string;
}
export interface GroupAnalytics {
group: GroupInfo;
enrolled_trainees: number;
active_trainees: number;
scored_attempts: number;
average_score: number | null;
errors: {
code: string;
title: string;
affected_trainees: number;
occurrences: number;
rate_percent: number;
recommendation: string;
}[];
}
export const useGroups = () =>
useQuery({ queryKey: ["groups"], queryFn: () => request<GroupInfo[]>("/api/groups") });
export function useCreateGroup() {
const cache = useQueryClient();
return useMutation({
mutationFn: (name: string) => request<GroupInfo>("/api/groups", {
method: "POST", body: JSON.stringify({ name }),
}),
onSuccess: () => void cache.invalidateQueries({ queryKey: ["groups"] }),
});
}
export function useAssignTrainee() {
const cache = useQueryClient();
return useMutation({
mutationFn: ({ groupId, traineeId }: { groupId: string; traineeId: string }) =>
request<GroupInfo>(`/api/groups/${groupId}/trainees/${traineeId}`, { method: "PUT" }),
onSuccess: () => {
void cache.invalidateQueries({ queryKey: ["trainees"] });
void cache.invalidateQueries({ queryKey: ["group-analytics"] });
},
});
}
export const useGroupAnalytics = (groupId: string | null) =>
useQuery({
queryKey: ["group-analytics", groupId],
queryFn: () => request<GroupAnalytics>(`/api/groups/${groupId}/analytics`),
enabled: Boolean(groupId),
refetchInterval: 15_000,
});
export const useProfile = (traineeId: string | null) =>
useQuery({
queryKey: ["profile", traineeId],

View file

@ -87,12 +87,25 @@ export interface CardBriefing {
card?: KIO | null;
}
export interface CardNext {
type: "card.next";
card_id: string;
}
/** Снимок КИО. После передачи не меняется — оператор не дописывает задним числом. */
export interface CardReceived {
type: "card.received";
card: KIO;
from_operator: string;
at: string;
card_index?: number;
card_total?: number;
}
export interface CardReply {
type: "card.reply";
card_id: string;
text: string;
}
/** Статус карточки целиком — считается из статусов служб, руками не ставится. */
@ -134,6 +147,23 @@ export interface CrewSelect {
/** Дежурно-диспетчерская служба, в которую уходит карточка. */
export type DDSCode = "01" | "02" | "03" | "04" | "gkh";
export interface DdsCardReport {
card_id: string;
scenario_id: string;
score_auto: number;
reply_text: string;
metrics: Array<Metric>;
findings: Array<Finding>;
actions?: Array<Record<string, unknown>>;
duration_ms?: number;
}
export interface DdsCardSummary {
card_id: string;
scenario_id: string;
score_auto: number;
}
/** Передача в ДДС. При маршрутизации по ЕКП служба уже есть в notify. */
export interface DdsDispatch {
type: "dds.dispatch";
@ -387,6 +417,7 @@ export interface ScenarioPublish {
export interface ScenarioStart {
type: "scenario.start";
scenario_id: string;
scenario_ids?: Array<string> | null;
trainee: string;
trainee_id?: string | null;
group_id?: string | null;
@ -455,6 +486,7 @@ export interface SessionReport {
transcript: Array<TranscriptEntry>;
findings: Array<Finding>;
metrics: Array<Metric>;
card_results?: Array<DdsCardReport>;
competencies: Array<CompetencyScore>;
reference_questions: Array<HintShown>;
missed_checklist: Array<string>;
@ -507,6 +539,11 @@ export interface StationSnapshot {
crew_options?: Array<string>;
crew_selected?: string | null;
phone_reports?: Array<PhoneReportRecord>;
card_id?: string | null;
card_index?: number;
card_total?: number;
reply_text?: string;
completed_cards?: Array<DdsCardSummary>;
}
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
@ -614,7 +651,7 @@ export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay
export type ServerToStation = CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish | ZoneDecision | CrewDispatched | CrewArrived;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish | CardReply | CardNext | ZoneDecision | CrewDispatched | CrewArrived;
export type EventName =
"bg.start"
@ -630,7 +667,9 @@ export type EventName =
| "card.ack"
| "card.bounce"
| "card.briefing"
| "card.next"
| "card.received"
| "card.reply"
| "card.status"
| "card.submit"
| "crew.arrived"

View file

@ -40,6 +40,16 @@ button:disabled { opacity: 0.5; cursor: default; }
.warn { color: var(--warn); }
.violated { color: var(--violated); }
/* Локальная демо-навигация существует только при VITE_DEMO_MODE=true. */
.demo-login { max-width: 480px; margin-top: 28px; padding: 12px 16px; border: 1px solid #b9bec6; background: #fff; }
.demo-login p { margin: 0 0 12px; }
.demo-login .warn { margin: 10px 0 0; }
.demo-switcher { position: fixed; right: 10px; bottom: 80px; z-index: 60; max-width: calc(100vw - 20px); border: 1px solid #708492; background: #f7fafc; box-shadow: 0 2px 12px #1d2b3240; color: #25343c; font-size: 12px; }
.demo-switcher summary { padding: 6px 10px; cursor: pointer; font-weight: 700; }
.demo-switcher div { display: grid; min-width: 140px; border-top: 1px solid #b9c8d0; }
.demo-switcher a { padding: 8px 10px; color: #154c71; text-decoration: none; }
.demo-switcher a:hover, .demo-switcher a:focus-visible { background: #d7ebf6; text-decoration: underline; }
/* Две колонки: карточка и разговор рядом, как на настоящем АРМ. */
.columns { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; }
.columns > section { flex: 1 1 420px; min-width: 0; }
@ -169,5 +179,315 @@ button:disabled { opacity: 0.5; cursor: default; }
.dds-secondary .bounce-fields { max-height: 200px; overflow-y: auto; font-size: 12px; }
.dds-secondary input[type="text"] { max-width: 100%; margin: 4px 0; }
.dds-finish { display: block; margin: 12px; }
.dds-reply > label { display: block; margin: 12px 12px 5px; font-size: 13px; font-weight: 600; }
.dds-reply textarea { box-sizing: border-box; display: block; width: calc(100% - 24px); margin: 0 12px; padding: 8px; border: 1px solid var(--line); font: inherit; resize: vertical; }
.dds-reply > button { margin: 0 12px 12px; }
.scenario-queue { margin-top: 8px; max-height: 220px; overflow-y: auto; }
.scenario-queue summary { cursor: pointer; font-size: 13px; color: #23577e; }
@media (max-width: 900px) { .dds-main { grid-template-columns: 1fr; } .dds-right { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } }
@media (max-width: 640px) { .dds-right, .dds-summary { display: block; } .dds-header { align-items: flex-start; flex-direction: column; } }
/* На телефоне действия остаются в одной колонке; таблица реестра читается
как список карточек, остальные служебные таблицы допускают прокрутку. */
@media (max-width: 700px) {
:root { font-size: 14px; }
.page { padding: 10px; max-width: 100%; overflow-x: auto; }
.columns { display: block; }
.columns > section { width: 100%; box-sizing: border-box; margin-bottom: 18px; }
.grid { min-width: 0; width: 100%; max-width: 100%; }
.grid th, .grid td { width: auto; overflow-wrap: anywhere; }
.kio .grid { table-layout: fixed; }
.kio .grid tbody th { width: 38%; }
.kio input[type="text"], .kio input[type="number"], .kio select { box-sizing: border-box; width: 100%; font-size: 16px; }
.kio .signs select { margin-bottom: 4px; }
input[type="text"], input[type="password"], select, textarea { max-width: 100%; font-size: 16px; box-sizing: border-box; }
button { min-height: 42px; white-space: normal; }
.interview-value { font-size: 32px; }
.mode-banner, .stand { margin: -10px -10px 10px; padding: 6px 10px; }
.dds-panel-title { align-items: flex-start; flex-wrap: wrap; }
.dds-register-panel .dds-registry thead { display: none; }
.dds-register-panel .dds-registry tr { display: block; border-bottom: 2px solid var(--line); }
.dds-register-panel .dds-registry td { display: flex; justify-content: space-between; gap: 12px; border-right: 0; padding: 6px 10px; overflow-wrap: anywhere; }
.dds-register-panel .dds-registry td::before { content: attr(data-label); flex: 0 0 38%; font-weight: 600; color: #56616c; }
.dds-register-panel .dds-registry td.dds-empty { display: block; }
.dds-register-panel .dds-registry td.dds-empty::before { content: none; }
.dds-status-action { display: grid; grid-template-columns: 1fr; }
.dds-status-action select, .dds-status-action input { min-width: 0; max-width: none; width: 100%; }
.dds-phone > select, .dds-dial, .dds-reply textarea { font-size: 16px; }
.bounce-fields { grid-template-columns: 1fr; }
}
/* АРМ 112: рабочая геометрия оригинальной карточки — серое поле, белые
плотные панели, синие выбранные признаки и оранжевая полоса действий. */
.arm-page { box-sizing: border-box; min-height: 100vh; padding: 8px; background: #d5dde2; color: #303943; font-family: Arial, "Segoe UI", sans-serif; font-size: 14px; }
.arm-page .mode-banner, .arm-page .stand { margin: -8px -8px 8px; padding: 5px 12px; font-size: 12px; }
.arm-topbar { display: flex; align-items: stretch; gap: 5px; min-height: 76px; margin-bottom: 8px; }
.arm-topbar > div { box-sizing: border-box; background: #f8f9fa; }
.arm-topbar-mark { display: grid; place-items: center; flex: 0 0 72px; color: #ed5931; font-size: 29px; font-weight: 800; letter-spacing: -3px; }
.arm-phone-cell { display: flex; flex: 1 1 210px; min-width: 0; flex-direction: column; justify-content: center; gap: 4px; padding: 8px 12px; border-left: 1px solid #d0d5d9; }
.arm-phone-cell small, .arm-topbar-title span, .arm-topbar-state small { color: #69727a; font-size: 12px; }
.arm-phone-cell strong { overflow: hidden; font-size: 19px; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; }
.arm-phone-cell span { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.arm-phone-main { flex: 1.3 1 250px; }
.arm-topbar-title { display: flex; flex: 0 1 285px; min-width: 190px; flex-direction: column; justify-content: center; gap: 4px; padding: 9px 12px; }
.arm-topbar-title strong { font-size: 16px; }
.arm-topbar-text { min-height: 56px; margin-bottom: 6px; }
.arm-topbar-text .arm-topbar-title { flex: 1 1 auto; }
.arm-topbar-clock { display: grid; flex: 0 0 132px; place-items: center; background: #252e35 !important; color: white; }
.arm-topbar-clock .interview { display: grid; gap: 0; margin: 0; text-align: center; }
.arm-topbar-clock .interview-value { font-size: 32px; line-height: 1; }
.arm-topbar-clock .interview-label, .arm-topbar-clock .interview-limit { color: white; font-size: 9px; }
.arm-topbar-state { display: flex; flex: 0 1 235px; flex-direction: column; justify-content: center; gap: 6px; padding: 10px 14px; }
.arm-topbar-state > span { font-weight: 700; }
.arm-call-actions { display: flex; align-items: center; gap: 8px; min-height: 54px; margin-bottom: 8px; padding: 6px 10px; background: #f8f9fa; }
.arm-call-actions button, .arm-footer button { min-height: 38px; border-color: #aab3b9; background: #fff; font-size: 14px; font-weight: 600; }
.arm-call-actions .arm-answer { border-color: #ed5931; color: #df4d29; }
.arm-live { color: #a51d1d; font-weight: 700; }
.arm-call-actions-note { margin-left: auto; color: #69727a; font-size: 12px; }
.arm-briefing { display: grid; grid-template-columns: 180px minmax(0, 1fr); max-height: 82px; margin-bottom: 8px; overflow: auto; background: #f8f9fa; }
.arm-briefing-label { display: flex; flex-direction: column; justify-content: center; gap: 5px; padding: 8px 12px; border-right: 1px solid #d1d6da; }
.arm-briefing-label span, .arm-briefing p { color: #69727a; font-size: 12px; }
.arm-briefing-text { min-height: 0; padding: 8px 12px; overflow: auto; white-space: pre-wrap; line-height: 1.4; }
.arm-briefing p { margin: 0; padding: 0 15px 10px; }
.arm-card-wrap { min-width: 0; }
.arm-footer { position: sticky; z-index: 4; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 70px; margin: 8px -8px 0; padding: 8px 16px; box-sizing: border-box; background: #f26137; color: white; }
.arm-footer > span { font-size: 13px; font-weight: 700; }
.arm-footer-services { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; }
.arm-footer-services b { padding: 7px 10px; border: 1px solid #fff; font-size: 12px; }
.arm-footer-services em { font-size: 12px; font-weight: 400; font-style: normal; }
.arm-footer-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.arm-footer button { padding: 8px 16px; border-color: white; background: transparent; color: white; }
.arm-footer .arm-primary { border-color: white; background: white; color: #e65a32; font-size: 16px; }
.arm-footer button:disabled { opacity: .55; }
.arm-message { margin: 8px 0; padding: 8px 12px; background: white; }
.arm-secondary { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(420px, 2fr); gap: 8px; margin-top: 8px; }
.arm-resolve, .arm-support { min-width: 0; padding: 12px; background: #f8f9fa; }
.arm-resolve summary { color: #235b85; cursor: pointer; font-weight: 700; }
.arm-resolve input { width: min(100%, 450px); padding: 7px; border: 1px solid #bac1c6; }
.arm-resolve button { margin: 2px 4px 2px 0; }
.arm-support h2 { margin: 0 0 8px; font-size: 14px; }
.arm-support .grid { width: 100%; min-width: 0; }
.arm-support .grid th { width: 110px; }
.kio-arm { min-width: 0; container: kio-card / inline-size; }
.kio-arm-top { display: grid; grid-template-columns: minmax(0, 3fr) minmax(240px, 1fr); gap: 8px; margin-bottom: 8px; }
.kio-arm-main { display: grid; grid-template-columns: minmax(340px, 45%) minmax(420px, 55%); gap: 8px; align-items: stretch; }
.arm-page .kio-arm-main { min-height: calc(100vh - 315px); }
.kio-arm-left, .kio-arm-right { display: flex; min-width: 0; flex-direction: column; gap: 8px; }
.kio-arm-section { min-width: 0; box-sizing: border-box; background: #f8f9fa; }
.kio-arm-section-title { padding: 8px 10px; border-bottom: 1px solid #d5d9dc; color: #67717a; font-size: 12px; }
.kio-arm-fields { padding: 4px 10px 12px; }
.kio-arm-row { min-width: 0; box-sizing: border-box; }
.kio-arm-label { overflow: hidden; margin: 6px 0 3px; color: #6b747d; font-size: 12px; text-overflow: ellipsis; }
.kio-arm-value { min-height: 28px; min-width: 0; }
.kio-arm input[type="text"], .kio-arm input[type="number"], .kio-arm select, .kio-arm textarea { box-sizing: border-box; display: block; width: 100%; min-height: 29px; padding: 5px 3px; border: 0; border-bottom: 1px solid #cbd1d5; border-radius: 0; background: transparent; color: #303943; font: inherit; outline-offset: -1px; }
.kio-arm input:focus, .kio-arm select:focus, .kio-arm textarea:focus { border-bottom-color: #1976b8; outline: 1px solid #1976b8; }
.kio-arm input::placeholder, .kio-arm textarea::placeholder { color: #88929a; }
.kio-arm textarea { min-height: 170px; resize: vertical; }
.kio-arm-readonly { display: block; min-height: 22px; padding: 5px 3px 2px; border-bottom: 1px solid #d2d7db; overflow-wrap: anywhere; }
.kio-arm-missing .kio-arm-label .required { color: #e45d32; }
.kio-arm-pending .kio-arm-value { background: #fff9d8; }
.kio-arm-top .kio-arm-section-title { padding: 6px 10px; }
.kio-arm-top .kio-arm-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 12px; padding-bottom: 8px; }
.kio-arm-ref .kio-arm-top .kio-arm-section { min-height: 62px; }
.kio-arm-ref .kio-arm-top .kio-arm-fields { grid-template-columns: repeat(4, minmax(0, 1fr)); padding: 7px 10px; }
.kio-arm-ref .kio-arm-top .kio-arm-label { margin: 0 0 2px; }
.kio-arm-ref .kio-arm-top .kio-arm-value { min-height: 25px; }
.kio-arm-top .kio-arm-section:last-child .kio-arm-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kio-arm-address { flex: 1 1 auto; min-height: 340px; }
.arm-page .kio-arm-address { flex: 0 0 57%; min-height: 0; }
.kio-arm-address .kio-arm-fields { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0 13px; }
.kio-arm-address .kio-arm-row-address { grid-column: 1 / -1; }
.kio-arm-address .kio-arm-row-street { grid-column: span 2; }
.kio-arm-description { flex: 0 0 auto; }
.arm-page .kio-arm-description { display: flex; flex: 1 1 auto; flex-direction: column; }
.kio-arm-description .kio-arm-section-title { border-bottom: 0; }
.kio-arm-description .kio-arm-fields { padding-top: 0; }
.arm-page .kio-arm-description .kio-arm-fields { display: flex; flex: 1; }
.arm-page .kio-arm-description .kio-arm-row, .arm-page .kio-arm-description .kio-arm-value { display: flex; flex: 1; min-width: 0; }
.arm-page .kio-arm-description textarea { flex: 1; min-height: 120px; }
.kio-arm-description .kio-arm-label { display: none; }
.kio-arm-incident { flex: 1 1 auto; min-height: 340px; }
.kio-arm-incident .kio-arm-section-title, .kio-arm-details .kio-arm-section-title { background: #303438; color: #fff; font-size: 13px; font-weight: 700; }
.kio-arm-incident .kio-arm-row, .kio-arm-dispatch .kio-arm-row, .kio-arm-details .kio-arm-row { display: grid; grid-template-columns: minmax(130px, 31%) minmax(0, 1fr); gap: 10px; align-items: start; min-height: 42px; }
.kio-arm-incident .kio-arm-label, .kio-arm-dispatch .kio-arm-label, .kio-arm-details .kio-arm-label { margin-top: 12px; }
.kio-arm-incident .kio-arm-row-signs { display: block; padding: 7px 0 12px; border-bottom: 1px solid #d6dade; }
.kio-arm-incident .kio-arm-row-signs .kio-arm-label { margin: 0 0 7px; font-size: 16px; }
.kio-arm-ref .kio-arm-incident .kio-arm-row-incident_type { display: block; min-height: 0; padding: 6px 0 10px; border-bottom: 1px solid #d6dade; }
.kio-arm-ref .kio-arm-incident .kio-arm-row-incident_type .kio-arm-label { margin: 2px 0 12px; font-size: 20px; font-weight: 400; }
.kio-arm-type-picker { display: flex; flex-wrap: wrap; gap: 7px; }
.kio-arm-type-picker button { padding: 8px 12px; border: 1px solid #b3bdc5; background: white; color: #303943; font-size: 13px; font-weight: 700; }
.kio-arm-type-picker .kio-arm-selected { border-color: #1876b4; background: #1876b4; color: white; }
.kio-arm-incident .signs { display: flex; flex-wrap: wrap; gap: 5px; }
.kio-arm-incident .signs select { flex: 1 1 180px; width: auto; max-width: 100%; padding: 7px 8px; border: 1px solid #acb5bc; background: white; }
.kio-arm-ref .signs-selected { display: flex; flex: 1 1 100%; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
.kio-arm-ref .signs-selected span { padding: 7px 10px; background: #1876b4; color: white; font-size: 12px; font-weight: 700; }
.kio-arm-choice { display: flex; flex-wrap: wrap; gap: 6px; padding: 5px 0; }
.kio-arm-choice button { min-width: 49px; padding: 6px 11px; border: 1px solid #aeb6bd; background: white; font-size: 13px; font-weight: 700; }
.kio-arm-choice .kio-arm-selected { border-color: #1979bb; background: #1979bb; color: white; }
.kio-arm-dispatch .kio-arm-fields { padding-top: 5px; }
.kio-arm-details .kio-arm-fields { padding-top: 8px; }
@media (max-width: 950px) {
.arm-topbar { flex-wrap: wrap; }
.arm-topbar-title { flex: 1 1 190px; }
.arm-topbar-clock { flex: 0 0 110px; }
.kio-arm-main { grid-template-columns: 1fr; }
.kio-arm-address, .kio-arm-incident { min-height: 0; }
.arm-page .kio-arm-main { min-height: 0; }
.arm-page .kio-arm-address { flex-basis: auto; }
}
@container kio-card (max-width: 800px) {
.kio-arm-top, .kio-arm-main { grid-template-columns: 1fr; }
.kio-arm-address, .kio-arm-incident { min-height: 0; }
}
@container kio-card (max-width: 480px) {
.kio-arm-top .kio-arm-fields, .kio-arm-address .kio-arm-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kio-arm-incident .kio-arm-row, .kio-arm-dispatch .kio-arm-row, .kio-arm-details .kio-arm-row { grid-template-columns: 110px minmax(0, 1fr); }
}
@media (max-width: 600px) {
.arm-page { padding: 6px; }
.arm-page .mode-banner, .arm-page .stand { margin: -6px -6px 6px; }
.arm-topbar-mark { flex-basis: 55px; }
.arm-topbar-clock { flex: 1 1 100%; min-height: 55px; }
.arm-topbar-clock .interview { display: flex; align-items: baseline; gap: 10px; }
.arm-call-actions, .arm-footer { flex-wrap: wrap; }
.arm-call-actions-note { flex: 1 1 100%; margin: 0; }
.arm-footer { margin-right: -6px; margin-left: -6px; }
.arm-briefing { grid-template-columns: 1fr; max-height: 160px; }
.arm-briefing-label { border-right: 0; border-bottom: 1px solid #d1d6da; }
.arm-secondary, .kio-arm-top { grid-template-columns: 1fr; }
.kio-arm-top .kio-arm-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kio-arm-address .kio-arm-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kio-arm-incident .kio-arm-row, .kio-arm-dispatch .kio-arm-row, .kio-arm-details .kio-arm-row { grid-template-columns: 115px minmax(0, 1fr); }
.kio-arm input[type="text"], .kio-arm input[type="number"], .kio-arm select, .kio-arm textarea { font-size: 16px; }
}
/* ДДС: повтор композиции предоставленного АРМ (msg480, кадры 2–13, 20).
Тёмный реестр и нижняя полоса служб — ориентиры боевого интерфейса. */
.dds-workspace { box-sizing: border-box; max-width: none; min-height: 100vh; padding: 0; background: #d9e0e5; font-family: Arial, "Segoe UI", sans-serif; font-size: 13px; }
.dds-app-top { box-sizing: border-box; display: flex; align-items: center; gap: 12px; min-height: 38px; padding: 4px 14px; background: #edf4fa; border-bottom: 1px solid #c7d3dc; color: #293740; }
.dds-brand { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; background: #bd2630; color: white; font-weight: 800; border-radius: 4px; }
.dds-app-name { flex: 1; font-weight: 600; }
.dds-top-role { color: #52616d; }
.dds-workspace .dds-connection { color: #52616d; font-size: 12px; }
.dds-search { display: grid; grid-template-columns: minmax(0, 1fr) 340px; min-height: 122px; background: #f7f9fa; }
.dds-search-main { padding: 14px 18px 10px; }
.dds-search-main h1 { margin: 0 0 8px; padding-bottom: 4px; border-bottom: 1px solid #252f35; font-size: 30px; line-height: 1.2; font-weight: 400; }
.dds-search-line { display: flex; align-items: center; gap: 8px; }
.dds-search-line label { font-size: 12px; color: #53616c; }
.dds-search-line input { flex: 1; min-width: 100px; max-width: 520px; padding: 5px 8px; border: 0; border-bottom: 1px solid #8b989f; background: transparent; font: inherit; }
.dds-search-line button { padding: 4px 10px; font-size: 12px; }
.dds-clock { display: grid; align-content: center; grid-template-columns: 1fr auto; gap: 3px 8px; padding: 10px 16px; background: #2d363b; color: white; }
.dds-clock small { font-size: 14px; font-weight: 600; }
.dds-clock strong { grid-row: span 2; font-size: 44px; line-height: 1; letter-spacing: -.04em; font-variant-numeric: tabular-nums; }
.dds-clock span { font-size: 11px; color: #b9c5ca; }
.dds-register-panel { box-sizing: border-box; min-height: calc(100vh - 160px); margin: 0; padding: 10px 17px 24px; border: 0; background: #7e8d96; color: #f5f8fa; }
.dds-register-panel .dds-panel-title { padding: 8px 8px 12px; border: 0; background: transparent; color: #fff; }
.dds-register-panel .dds-panel-title h2 { font-size: 18px; }
.dds-register-panel .dds-panel-title span { color: #dce5e8; }
.dds-register-panel .dds-registry { color: #fff; border-collapse: separate; border-spacing: 0 1px; font-size: 12px; }
.dds-register-panel .dds-registry th { padding: 4px 7px 9px; border: 0; background: transparent; color: #e5ecf0; font-weight: 400; }
.dds-register-panel .dds-registry td { padding: 7px; border-right: 1px solid #71808a; border-bottom: 0; background: #303b42; color: #eef5f8; }
.dds-register-panel .dds-registry tr.dds-selected td { background: #303b42; }
.dds-register-panel .dds-registry tr.dds-selected { box-shadow: none; }
.dds-register-panel .dds-registry td small { display: block; margin-top: 5px; padding-top: 5px; border-top: 1px solid #697881; color: #ccd6dc; }
.dds-register-panel .dds-registry td.dds-empty { background: transparent; color: #fff; }
.dds-row-open, .dds-row-link { min-height: 0; padding: 0 3px; border: 0; background: transparent; color: #fff; font: inherit; font-weight: 700; text-decoration: underline; }
.dds-row-open:hover, .dds-row-link:hover { color: #aedbf9; }
.dds-register-panel .dds-hint { color: #edf3f6; }
.dds-case-layout { display: flex; flex-direction: column; min-height: calc(100vh - 38px); background: #d7dfe4; }
.dds-case-top { display: flex; align-items: center; gap: 12px; min-height: 30px; padding: 3px 8px; background: #eaf1f6; color: #24323a; }
.dds-case-top button { min-height: 0; padding: 3px 8px; border: 0; background: transparent; color: #164d70; }
.dds-case-top span { font-size: 14px; }
.dds-call-strip { display: grid; grid-template-columns: .9fr 1.1fr 1.1fr 1.1fr 200px 100px; gap: 5px; padding: 7px; background: #d0d8dd; }
.dds-call-strip > div { box-sizing: border-box; min-width: 0; min-height: 64px; padding: 6px 8px; background: #f7f9fa; }
.dds-call-strip > div:not(.dds-case-top-buttons) { display: grid; align-content: start; gap: 3px; }
.dds-call-strip small { color: #56636c; font-size: 11px; }
.dds-call-strip strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dds-call-icon { float: left; color: #65737b; font-size: 18px; }
.dds-call-off { border-left: 4px solid #697984; }
.dds-case-top-buttons { display: grid; gap: 5px; padding: 0 !important; background: transparent !important; }
.dds-case-top-buttons button { min-height: 0; padding: 5px; background: #176b9d; color: #fff; border: 0; font-size: 11px; }
.dds-case-top-buttons button:last-child { background: #303a41; }
.dds-case-meta { display: grid; grid-template-columns: 1.2fr 1fr 65px; gap: 6px; padding: 2px 7px 6px; background: #d0d8dd; }
.dds-case-meta span { min-height: 29px; padding: 7px; background: #f7f9fa; }
.dds-case-summary { display: grid; grid-template-columns: .95fr 1.05fr; gap: 7px; padding: 0 7px; }
.dds-case-address, .dds-case-classification { min-width: 0; }
.dds-case-address > strong, .dds-case-address > span, .dds-case-address > div,
.dds-case-classification > strong, .dds-case-classification > div { display: block; box-sizing: border-box; min-height: 30px; margin-bottom: 5px; padding: 7px 9px; background: #f7f9fa; }
.dds-case-address > div { min-height: 330px; }
.dds-case-classification > strong { background: #2c3439; color: #fff; }
.dds-case-classification > div:first-of-type { font-weight: 600; }
.dds-case-stage { position: relative; flex: 1; min-height: 360px; }
.dds-case-drawer { position: absolute; bottom: 12px; left: 4%; z-index: 3; box-sizing: border-box; width: min(920px, 92%); max-height: min(65vh, 650px); overflow-y: auto; border: 1px solid #69899a; background: #f7f9fa; box-shadow: 0 8px 32px #1d2b3280; }
.dds-drawer-title { position: sticky; top: 0; z-index: 4; display: flex; align-items: center; justify-content: space-between; padding: 4px 9px; background: #1d6e9e; color: #fff; }
.dds-drawer-title button { min-height: 0; padding: 0 7px; border: 0; background: transparent; color: #fff; font-size: 22px; }
.dds-case-drawer .dds-main { display: block; margin: 0; padding: 8px; }
.dds-case-drawer .dds-panel { margin: 0; border: 0; }
.dds-case-drawer .dds-right { display: block; }
.dds-case-drawer .dds-panel-title { padding: 5px 8px; }
.dds-case-drawer .dds-panel-title h2 { font-size: 13px; }
.dds-case-drawer-status { bottom: 0; left: 8.5%; width: min(1050px, 86%); border: 0; box-shadow: 0 -4px 20px #1d2b3233; }
.dds-case-drawer-status > .dds-drawer-title { display: none; }
.dds-case-drawer-status .dds-main { padding: 0; }
.dds-case-drawer-status .dds-panel-title { display: none; }
.dds-case-drawer-status .dds-hint { margin: 0; padding: 2px 8px 4px; background: #fff; font-size: 10px; }
.dds-status-history { position: relative; max-height: 120px; overflow-y: auto; padding: 4px 30px 4px 10px; background: #1e6e9e; color: #fff; }
.dds-status-history > strong { display: block; margin-bottom: 6px; }
.dds-status-history > div { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; padding: 3px 0; border-bottom: 1px solid #5a9ac0; font-size: 11px; }
.dds-status-history .dds-status-close { position: absolute; top: 1px; right: 7px; min-height: 0; padding: 0 3px; border: 0; background: transparent; color: #fff; font-size: 19px; }
.dds-status-editor { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.3fr) 28px 28px; gap: 5px; align-items: end; padding: 3px 6px; background: #fff; }
.dds-status-editor label { display: grid; gap: 2px; min-width: 0; color: #5c6b75; font-size: 10px; }
.dds-status-editor select, .dds-status-editor input { box-sizing: border-box; width: 100%; min-width: 0; min-height: 25px; padding: 2px 4px; border: 0; border-bottom: 1px solid #8999a2; background: #fff; color: #26343d; font-size: 12px; }
.dds-status-editor button { min-width: 0; min-height: 25px; padding: 0; border: 1px solid #9bacb6; color: #25333b; font-size: 18px; }
.dds-status-warning { grid-column: 1 / -1; font-size: 11px; }
.dds-service-dock { display: flex; align-items: stretch; min-height: 64px; overflow-x: auto; background: #43525b; color: #fff; }
.dds-dock-label { display: flex; align-items: center; padding: 8px; border-right: 1px solid #85929b; font-size: 12px; }
.dds-service-dock button { display: grid; align-content: center; gap: 3px; flex: 0 0 auto; min-width: 115px; padding: 8px 11px; border: 0; border-right: 1px solid #87949b; background: #43525b; color: #fff; text-align: left; }
.dds-service-dock button:hover, .dds-service-dock button.active { background: #176b9d; }
.dds-service-dock button:disabled { color: #d0d7db; }
.dds-service-dock button small { color: #d8e5ea; font-size: 10px; }
@media (max-width: 900px) {
.dds-search { grid-template-columns: 1fr 220px; }
.dds-clock { grid-template-columns: 1fr; }
.dds-clock strong { grid-row: auto; font-size: 30px; }
.dds-call-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.dds-case-summary { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 700px) {
.dds-workspace { overflow-x: hidden; }
.dds-app-top { flex-wrap: wrap; }
.dds-app-name { flex-basis: calc(100% - 45px); }
.dds-search { display: block; }
.dds-search-main h1 { font-size: 24px; }
.dds-search-line { flex-wrap: wrap; }
.dds-search-line label { flex-basis: 100%; }
.dds-clock { display: flex; align-items: center; justify-content: space-between; min-height: 42px; }
.dds-clock small { font-size: 11px; }
.dds-clock strong { font-size: 22px; }
.dds-clock span { display: none; }
.dds-register-panel { min-height: calc(100vh - 190px); padding: 8px; }
.dds-register-panel .dds-registry tr { border-bottom: 2px solid #b1c2cb; }
.dds-register-panel .dds-registry td { background: #303b42; color: #fff; border-bottom: 1px solid #596b75; }
.dds-register-panel .dds-registry td::before { color: #d2dce1; }
.dds-case-top { flex-wrap: wrap; }
.dds-call-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dds-call-strip .dds-call-incident, .dds-case-top-buttons { grid-column: span 2; }
.dds-case-meta { grid-template-columns: 1fr 1fr; }
.dds-case-meta span:last-child { display: none; }
.dds-case-summary { display: block; }
.dds-case-address > div { min-height: 150px; }
.dds-case-stage { min-height: 0; }
.dds-case-drawer { position: static; width: auto; max-height: none; margin: 10px; overflow: visible; }
.dds-service-dock { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: visible; }
.dds-dock-label { grid-column: span 2; }
.dds-service-dock button { min-width: 0; }
.dds-case-drawer .dds-status-table, .dds-case-drawer .dds-registry { min-width: 500px; }
.dds-case-drawer-status { width: auto; }
.dds-status-editor { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dds-status-editor label { grid-column: 1 / -1; }
.dds-status-editor button { min-height: 38px; }
.dds-status-history > div { grid-template-columns: 1fr; gap: 2px; }
}

228
scripts/load_dds.py Normal file
View file

@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""Local-only WebSocket load probe for 20 DDS lessons / 100 clients.
The probe starts real DDS exercises, holds one station and N observers per
lesson, and times valid ``zone.decision`` -> ``station.state`` round trips.
It deliberately does not pretend to measure browser paint, voice or DB TPS.
"""
import argparse
import asyncio
import json
import math
import os
import statistics
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter
from contextlib import AsyncExitStack
from uuid import uuid4
import websockets
def percentile(values: list[float], percent: float) -> float | None:
if not values:
return None
ordered = sorted(values)
rank = math.ceil(percent / 100 * len(ordered)) - 1
return ordered[max(0, rank)]
def cookie(base: str, dev_token: bool) -> str:
endpoint = "/api/auth/dev-token" if dev_token else "/api/auth/login"
if dev_token:
data = b""
else:
login = os.getenv("LCT_LOAD_LOGIN")
password = os.getenv("LCT_LOAD_PASSWORD")
if not login or not password:
raise RuntimeError("set LCT_LOAD_LOGIN and LCT_LOAD_PASSWORD (or use --dev-token)")
data = json.dumps({"login": login, "password": password}).encode()
request = urllib.request.Request(
base + endpoint, data=data,
headers={"Content-Type": "application/json"}, method="POST",
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
session_cookie = response.headers.get("Set-Cookie", "").split(";", 1)[0]
if not session_cookie.startswith("lct_session="):
raise RuntimeError("login did not return lct_session cookie")
return session_cookie
except urllib.error.HTTPError as exc:
raise RuntimeError(f"auth HTTP {exc.code}; dev-token requires DEV_AUTH_BYPASS=true") from exc
async def read_type(ws, expected: str, timeout: float) -> dict:
deadline = time.monotonic() + timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(f"waited for {expected}")
message = json.loads(await asyncio.wait_for(ws.recv(), remaining))
if message.get("type") == "error":
raise RuntimeError(f"server error: {message}")
if message.get("type") == expected:
return message
async def observer(ws, stop: asyncio.Event, counts: Counter, errors: list[str]) -> None:
while not stop.is_set():
try:
message = json.loads(await asyncio.wait_for(ws.recv(), 2))
counts[message.get("type", "unknown")] += 1
if message.get("type") == "error":
errors.append(str(message))
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
raise
except Exception as exc: # failure must be reported, not swallowed
if not stop.is_set():
errors.append(f"observer: {type(exc).__name__}: {exc}")
return
async def main(args: argparse.Namespace) -> int:
parsed = urllib.parse.urlsplit(args.base_url)
if parsed.scheme != "http" or parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
raise ValueError("load probe only accepts a local http://localhost/127.0.0.1/::1 backend")
if args.clients < args.sessions or args.sessions < 1 or args.rounds < 1:
raise ValueError("require clients >= sessions >= 1 and rounds >= 1")
if args.clients % args.sessions:
raise ValueError("clients must be divisible by sessions")
if args.clients > 500 or args.sessions > 100:
raise ValueError("safety cap: at most 500 clients and 100 sessions")
base = args.base_url.rstrip("/")
ws_base = "ws" + base.removeprefix("http")
headers = {"Cookie": cookie(base, args.dev_token)}
ids = [str(uuid4()) for _ in range(args.sessions)]
errors: list[str] = []
counts: Counter = Counter()
times: list[float] = []
stop = asyncio.Event()
observer_tasks: list[asyncio.Task] = []
start = time.monotonic()
started_ids: set[str] = set()
try:
async with AsyncExitStack() as stack:
# Control connections are setup-only; they are not counted as clients.
async def start_lesson(index: int, session_id: str) -> None:
async with websockets.connect(
f"{ws_base}/ws/control/{session_id}", additional_headers=headers,
open_timeout=args.timeout, ping_interval=None,
) as ws:
await ws.send(json.dumps({
"type": "scenario.start", "scenario_id": args.scenario,
"trainee": f"load-{index:03d}", "mode": "training", "exercise": "dds",
}))
started_ids.add(session_id)
await asyncio.gather(*(start_lesson(i, sid) for i, sid in enumerate(ids)))
# Observe proves every lesson actually started, not just WS accepted.
async def connect_session(session_id: str):
deadline = time.monotonic() + args.timeout
while True:
station = await stack.enter_async_context(websockets.connect(
f"{ws_base}/ws/station/{session_id}", additional_headers=headers,
open_timeout=args.timeout, ping_interval=None,
))
try:
card = await read_type(station, "card.received", args.timeout)
break
except RuntimeError as exc:
if "session_not_found" not in str(exc) or time.monotonic() >= deadline:
raise
await asyncio.sleep(0.1)
if not card.get("card"):
raise RuntimeError(f"empty card in {session_id}")
await read_type(station, "station.state", args.timeout)
for _ in range(args.clients // args.sessions - 1):
ws = await stack.enter_async_context(websockets.connect(
f"{ws_base}/ws/observe/{session_id}", additional_headers=headers,
open_timeout=args.timeout, ping_interval=None,
))
await read_type(ws, "session.snapshot", args.timeout)
observer_tasks.append(asyncio.create_task(observer(ws, stop, counts, errors)))
return station
stations = await asyncio.gather(*(connect_session(sid) for sid in ids))
ready_at = time.monotonic()
# Each station serializes its own commands; all sessions run together.
async def exercise(station) -> None:
for round_number in range(args.rounds):
sent_at = time.monotonic()
await station.send(json.dumps({
"type": "zone.decision", "in_zone": round_number % 2 == 0,
}))
await read_type(station, "station.state", args.timeout)
times.append((time.monotonic() - sent_at) * 1000)
await asyncio.gather(*(exercise(ws) for ws in stations))
# Let fan-out queues flush, then record observer delivery count.
await asyncio.sleep(0.25)
load_seconds = time.monotonic() - ready_at
stop.set()
for task in observer_tasks:
task.cancel()
await asyncio.gather(*observer_tasks, return_exceptions=True)
result = {
"target": base, "scenario": args.scenario,
"sessions_started": len(ids), "clients_connected": args.clients,
"stations": len(stations), "observers": len(observer_tasks),
"rounds_per_session": args.rounds, "actions_completed": len(times),
"observer_state_events": counts["session.snapshot"],
"observer_state_expected": args.rounds * len(observer_tasks),
"round_trip_ms": {
"min": min(times), "median": statistics.median(times),
"p95": percentile(times, 95), "max": max(times),
},
"actions_per_second": len(times) / load_seconds,
"setup_seconds": ready_at - start, "load_seconds": load_seconds,
"errors": errors,
"pass_backend_event_2s": percentile(times, 95) <= 2000 and not errors
and counts["session.snapshot"] >= args.rounds * len(observer_tasks),
"scope": "backend WS event RTT and fan-out only; not browser UI, VoIP, DB TPS or 100 distinct accounts",
}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result["pass_backend_event_2s"] else 1
finally:
stop.set()
for task in observer_tasks:
task.cancel()
await asyncio.gather(*observer_tasks, return_exceptions=True)
# End only lessons opened by this invocation, including on failure.
async def end_lesson(session_id: str) -> None:
try:
async with websockets.connect(
f"{ws_base}/ws/control/{session_id}", additional_headers=headers,
open_timeout=args.timeout,
) as control:
await control.send('{"type":"session.stop"}')
await asyncio.sleep(0.05)
except Exception as exc:
print(f"cleanup warning {session_id}: {exc}", file=sys.stderr)
await asyncio.gather(*(end_lesson(sid) for sid in started_ids))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
parser.add_argument("--sessions", type=int, default=20)
parser.add_argument("--clients", type=int, default=100)
parser.add_argument("--rounds", type=int, default=10)
parser.add_argument("--scenario", default="fire-apartment-l2")
parser.add_argument("--timeout", type=float, default=10)
parser.add_argument("--dev-token", action="store_true", help="local dev stand only")
try:
sys.exit(asyncio.run(main(parser.parse_args())))
except Exception as exc: # network/protocol failures are setup errors, not measured failures
print(f"load probe failed: {exc}", file=sys.stderr)
sys.exit(2)

26
tests/test_load_dds.py Normal file
View file

@ -0,0 +1,26 @@
"""Small offline checks for the load probe's measurement and safety logic."""
import importlib.util
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "load_dds.py"
SPEC = importlib.util.spec_from_file_location("load_dds", SCRIPT)
load_dds = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(load_dds)
class PercentileTests(unittest.TestCase):
def test_empty(self):
self.assertIsNone(load_dds.percentile([], 95))
def test_nearest_rank_p95(self):
self.assertEqual(load_dds.percentile(list(range(1, 101)), 95), 95)
def test_single_sample(self):
self.assertEqual(load_dds.percentile([12.5], 95), 12.5)
if __name__ == "__main__":
unittest.main()