Implement DDS exercise, customer UI and local demo
This commit is contained in:
parent
cec84ffcd0
commit
0526b11f91
46 changed files with 2504 additions and 253 deletions
6
Makefile
6
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
125
backend/app/api/http/groups.py
Normal file
125
backend/app/api/http/groups.py
Normal 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)),
|
||||
)
|
||||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
69
backend/app/scoring/group.py
Normal file
69
backend/app/scoring/group.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
#: Вес метрики в детерминированной оценке.
|
||||
|
|
|
|||
10
backend/app/scoring/weights.py
Normal file
10
backend/app/scoring/weights.py
Normal 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]
|
||||
50
backend/app/session/dds.py
Normal file
50
backend/app/session/dds.py
Normal 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")
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
167
backend/tests/test_auth_hardening.py
Normal file
167
backend/tests/test_auth_hardening.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
75
backend/tests/test_demo_no_db.py
Normal file
75
backend/tests/test_demo_no_db.py
Normal 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()
|
||||
75
backend/tests/test_group_analytics.py
Normal file
75
backend/tests/test_group_analytics.py
Normal 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
|
||||
39
backend/tests/test_score_weights.py
Normal file
39
backend/tests/test_score_weights.py
Normal 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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 />) },
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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("нет модели эмбеддингов: подсказки идут по порядку чек-листа");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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")} ЧС: {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> {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>;
|
||||
}
|
||||
|
|
|
|||
94
frontend/src/pages/groups/Groups.tsx
Normal file
94
frontend/src/pages/groups/Groups.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
228
scripts/load_dds.py
Normal 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
26
tests/test_load_dds.py
Normal 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()
|
||||
Loading…
Reference in a new issue