diff --git a/.env.example b/.env.example
index ca65c76..a8fb3f7 100644
--- a/.env.example
+++ b/.env.example
@@ -1,15 +1,56 @@
# Скопировать в .env. Файл в .gitignore: ключи в репозиторий не едут.
+BIND_HOST=127.0.0.1
+POSTGRES_PORT=5432
+BACKEND_PORT=8000
+FRONTEND_PORT=5173
+TLS_PORT=5443
+TLS_CERT_DIR=./.local/tls
+SIP_PORT=5060
+SIPS_PORT=5061
+SIP_WS_PORT=8088
+RTP_PORT_START=10000
+RTP_PORT_END=10099
+SIP_TLS_DIR=./.local/sip-tls
+# Если пусто, Asterisk создаст стойкие случайные пароли в volume sipdata.
+SIP_6001_PASSWORD=
+SIP_6002_PASSWORD=
+SIP_6003_PASSWORD=
+SIP_6101_PASSWORD=
+SIP_6102_PASSWORD=
+# IP/DNS сервера, который WebRTC-клиенты видят в локальной сети. При запуске
+# только на той же машине оставьте 127.0.0.1; RTP-порты должны отображаться 1:1.
+SIP_EXTERNAL_MEDIA_ADDRESS=127.0.0.1
DATABASE_URL=postgresql+asyncpg://lct:lct@localhost:5432/lct
-# LLM: провайдер меняется значением, не кодом (docs/arch/STACK.md)
-LLM_PROVIDER=gigachat
+# Два локальных OpenAI-совместимых сервера (например, llama-server).
+# Адреса должны быть loopback: OFFLINE=true не допускает внешний API.
+LLM_PROVIDER=local
+LLM_BASE_URL=http://127.0.0.1:18080/v1
+# Адреса тех же процессов из backend-контейнера Docker Desktop (macOS/Windows).
+DOCKER_LLM_BASE_URL=http://host.docker.internal:18080/v1
LLM_API_KEY=
-LLM_MODEL_CALLER=
-LLM_MODEL_JUDGE=
+LLM_MODEL_CALLER=Qwen3-1.7B
+LLM_CONTROL_BASE_URL=http://127.0.0.1:18081/v1
+DOCKER_LLM_CONTROL_BASE_URL=http://host.docker.internal:18081/v1
+LLM_MODEL_CONTROL=Vikhr-1B
+GRAMMAR_LLM_ENABLED=true
+DIALOGUE_MODEL_MODE=dialogue
JUDGE_TEMPERATURE=0
# Голосовой контур
MODELS_DIR=models
-STT_MODEL=gigaam-v3-rnnt
+STT_MODEL=whisper-small
+WHISPER_MODEL_PATH=models/whisper-small
+WHISPER_SERVER_URL=http://127.0.0.1:18082
+VOICE_ENABLED=false
+RECORD_CALLS=true
+RECORDINGS_DIR=recordings
+BACKUP_INTERVAL_SECONDS=86400
+BACKUP_RETRY_SECONDS=300
+BACKUP_KEEP=14
ENDPOINTING_MS=600
-OFFLINE=false
+OFFLINE=true
+SECURE_COOKIES=false
+# Необязательно для Compose: если пусто, стойкий случайный ключ создаётся в
+# volume securitydata. На управляемом стенде можно задать свой 48+ байтный ключ.
+SESSION_SECRET=
diff --git a/.gitignore b/.gitignore
index 919f7a7..eb821bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,8 @@ backend/models/
# Резервные копии базы (make backup)
backend/backups/
+backend/recordings/
+recordings/
# Python
__pycache__/
@@ -22,9 +24,14 @@ frontend/dist/
.env.*
!.env.example
*.local
+.local/
# Python
.pytest_cache/
+# Temporary SQLite files created by local in-memory demo/test runs
+:memory:.ses
+backend/:memory:.ses
+
# Артефакты Windows рядом со скачанными файлами
*:Zone.Identifier
diff --git a/Makefile b/Makefile
index 544ce03..8a9d4ef 100644
--- a/Makefile
+++ b/Makefile
@@ -21,10 +21,34 @@ dev: ## Поднять стенд: postgres + backend --reload + frontend
offline: ## Поднять карточки/ДДС из заранее собранных локальных образов, без скачивания
$(COMPOSE) up --pull never --no-build
+tls: ## Поднять полный стенд по HTTPS/WSS с локальным сертификатом
+ $(COMPOSE) -f docker-compose.yml -f docker-compose.tls.yml up --build
+
+offline-tls: ## Поднять HTTPS/WSS из уже собранных образов без сети
+ $(COMPOSE) -f docker-compose.yml -f docker-compose.tls.yml up --pull never --no-build
+
+sip: ## Собрать и поднять локальный Asterisk SIP/VoIP
+ $(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml up --build -d sip
+
+offline-sip: ## Поднять SIP из уже собранного образа без сети
+ $(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml up --pull never --no-build -d sip
+
+sip-credentials: ## Показать локальные пароли софтфонов 6001–6003 и WebRTC 6101–6102
+ $(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml exec -T sip cat /var/lib/lct-sip/credentials.env
+
+sip-test: ## Проверить регистрацию, звонок, RTP и задержку (args="--rtp-host-offset 1000")
+ python3 scripts/smoke_sip.py --port $(or $(SIP_PORT),5060) $(args)
+
+webrtc-test: ## Проверить два браузерных SIP-клиента, аудио и запись Asterisk
+ python3 scripts/smoke_webrtc_browser.py \
+ --frontend-url https://127.0.0.1:$(or $(TLS_PORT),5443) \
+ --backend-url http://127.0.0.1:$(or $(BACKEND_PORT),8000) \
+ --database-url postgresql+asyncpg://lct:lct@127.0.0.1:$(or $(POSTGRES_PORT),5432)/lct $(args)
+
down: ## Погасить стенд
$(COMPOSE) down
-back: ## Бэкенд нативно, с голосовым контуром (порт 8000: сначала docker compose stop backend)
+back: ## Бэкенд нативно, без голоса по умолчанию (порт 8000: сначала docker compose stop backend)
cd backend && $(UV) run --extra voice uvicorn app.main:app --reload --port 8000 --workers 1
front: ## Только фронтенд, локально
@@ -42,9 +66,18 @@ test-voice: ## Тест голосового контура на настоящ
typecheck: ## Проверить фронтенд компилятором
npm --prefix frontend run typecheck
-models: ## Скачать модели в backend/models/: эмбеддинги, GigaAM, Silero VAD; Silero TTS — вручную
+models: ## Скачать эмбеддинги и Silero VAD; Silero TTS — вручную
cd backend && $(UV) run python scripts/models.py
+local-models: ## Скачать и проверить Qwen3 1.7B, Vikhr 1B, Whisper small (нужна сеть один раз)
+ cd backend && $(UV) run --offline --no-sync python scripts/download_local_models.py
+
+local-llm: ## Запустить Qwen3 на localhost:18080 (mode=both для Qwen3 и Vikhr)
+ cd backend && python3 scripts/local_llms.py --mode $(or $(mode),dialogue)
+
+local-stt: ## Запустить локальный whisper.cpp server на localhost:18082
+ cd backend && python3 scripts/local_stt.py
+
backup: ## Резервная копия базы в backend/backups/ (для cron: раз в сутки)
cd backend && $(UV) run python scripts/backup.py
@@ -63,7 +96,7 @@ lesson: ## Запустить занятие и напечатать ссылк
test-llm: ## Живые проверки LLM по backend/.env.test (медленно: рассуждающая модель)
cd backend && $(UV) run --extra dev pytest tests/test_llm.py -m llm -q -s
-llm-check: ## Один запрос к LLM: проверить ключ и адрес из backend/.env
+llm-check: ## Один запрос к активной локальной модели из backend/.env
cd backend && $(UV) run python scripts/llm_check.py
latency: ## Замер задержки голосового контура по этапам — запускать на демо-машине
@@ -81,10 +114,10 @@ repl: ## Текстовый диалог со звонящим без голос
pregen: ## Офлайн-таблицы реплик: make pregen [s=<сценарий>] [t=<билет>] [force=1]
cd backend && s="$(s)" t="$(t)" force="$(force)" $(UV) run python scripts/pregenerate.py
-demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт
+demo: ## Поднять основной стенд ДДС: база, готовые карточки, бэкенд и фронт
./scripts/demo.sh
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
+.PHONY: help dev offline tls offline-tls sip offline-sip sip-credentials sip-test webrtc-test down back front types users users-docker backup test test-voice typecheck test-llm lesson llm-check latency migrate revision models local-models local-llm local-stt seed repl pregen demo demo-lite
diff --git a/backend/Dockerfile b/backend/Dockerfile
index d30b636..bcda384 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -7,7 +7,7 @@ ENV PYTHONUNBUFFERED=1
# Экспорт PDF требует локальный TTF с кириллицей; базовый slim-образ его не
# гарантирует. Шрифт запекается в образ при подготовке офлайн-комплекта.
-RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core \
+RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Зависимости — из pyproject.toml, а не отдельным списком: ручной список разошёлся
diff --git a/backend/app/admin/backup.py b/backend/app/admin/backup.py
index c1c5c8f..7d75a14 100644
--- a/backend/app/admin/backup.py
+++ b/backend/app/admin/backup.py
@@ -7,13 +7,16 @@
честно говорим, чего не хватает.
"""
+import base64
import logging
import os
-import re
import shutil
import subprocess
+import tempfile
from datetime import datetime, timezone
+from getpass import getuser
from pathlib import Path
+from urllib.parse import unquote, urlsplit
from app.config import get_settings
@@ -23,51 +26,122 @@ DIR = Path(__file__).resolve().parents[2] / "backups"
#: Контейнер базы в docker compose проекта.
CONTAINER = os.environ.get("POSTGRES_CONTAINER", "lct-hack-postgres-1")
TIMEOUT_S = 120
+# pg_dump 17 добавляет эту настройку в plain SQL, а целевая PostgreSQL 16 из
+# Compose её ещё не знает. Остальной plain dump обратно совместим; удаляем
+# только служебный SET, не данные и не DDL.
+INCOMPATIBLE_SETTINGS = (b"SET transaction_timeout = 0;\n",)
class BackupError(RuntimeError):
"""Копию снять нечем или не удалось. Сообщение показывается администратору."""
+def _restrict_file_permissions(fd: int, path: Path, platform_name: str | None = None) -> None:
+ """Закрыть резервную копию от остальных локальных пользователей.
+
+ chmod(0o600) не задаёт ACL на Windows, поэтому там icacls удаляет
+ наследуемые разрешения и оставляет полный доступ владельцу процесса и
+ системной учётной записи. При ошибке копию не публикуем.
+ """
+ platform_name = platform_name or os.name
+ if platform_name == "nt":
+ username = getuser()
+ domain = os.environ.get("USERDOMAIN")
+ if domain and "\\" not in username:
+ username = f"{domain}\\{username}"
+ system_root = Path(os.environ.get("SystemRoot", "C:\\Windows"))
+ icacls = system_root / "System32" / "icacls.exe"
+ try:
+ result = subprocess.run(
+ [str(icacls), str(path), "/inheritance:r", "/grant:r",
+ f"{username}:(F)", "*S-1-5-18:(F)"],
+ capture_output=True,
+ text=True,
+ timeout=15,
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ raise BackupError("не удалось задать ACL резервной копии через icacls") from exc
+ if result.returncode != 0:
+ raise BackupError("Windows не применила закрытый ACL к резервной копии")
+ return
+ if hasattr(os, "fchmod"):
+ try:
+ os.fchmod(fd, 0o600)
+ except OSError as exc:
+ raise BackupError("не удалось ограничить права резервной копии") from exc
+ else:
+ try:
+ os.chmod(path, 0o600)
+ except OSError as exc:
+ raise BackupError("не удалось ограничить права резервной копии") from exc
+
+
def _dsn() -> tuple[str, str, str, str, str]:
"""Разобрать DATABASE_URL на части для pg_dump."""
url = get_settings().database_url
- match = re.match(r".*://([^:]+):([^@]+)@([^:/]+):(\d+)/(\w+)", url)
- if not match:
- raise BackupError(f"не разобрать DATABASE_URL: {url}")
- return match.groups() # user, password, host, port, database
+ try:
+ parsed = urlsplit(url)
+ user = unquote(parsed.username or "")
+ password = unquote(parsed.password or "")
+ host = parsed.hostname or ""
+ port = parsed.port
+ database = unquote(parsed.path.lstrip("/"))
+ except ValueError as exc:
+ # Не включать исходный URL в диагностику: он содержит пароль.
+ raise BackupError("не удалось разобрать DATABASE_URL") from exc
+ if not all((user, password, host, port, database)) or "/" in database:
+ raise BackupError("не удалось разобрать DATABASE_URL")
+ return user, password, host, str(port), database
def listing() -> list[dict]:
"""Готовые копии, новые первыми."""
- if not DIR.exists():
+ items = []
+ try:
+ with os.scandir(DIR) as entries:
+ for entry in entries:
+ if not entry.name.endswith(".sql") or not entry.is_file(follow_symlinks=False):
+ continue
+ stat = entry.stat(follow_symlinks=False)
+ items.append({
+ "name": entry.name,
+ "size_bytes": stat.st_size,
+ "at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
+ })
+ except FileNotFoundError:
return []
- items = [
- {
- "name": path.name,
- "size_bytes": path.stat().st_size,
- "at": datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc),
- }
- for path in DIR.glob("*.sql")
- ]
+ except OSError as exc:
+ raise BackupError(f"не удалось прочитать каталог резервных копий: {exc}") from exc
return sorted(items, key=lambda item: item["at"], reverse=True)
def create() -> dict:
"""Снять копию. Возвращает описание файла, как в `listing()`."""
user, password, host, port, database = _dsn()
- DIR.mkdir(parents=True, exist_ok=True)
- stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
+ try:
+ DIR.mkdir(parents=True, exist_ok=True)
+ except OSError as exc:
+ raise BackupError(f"недоступен каталог резервных копий: {exc}") from exc
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
target = DIR / f"lct-{stamp}.sql"
if shutil.which("pg_dump"):
command = ["pg_dump", "-h", host, "-p", port, "-U", user, "-d", database]
env = {**os.environ, "PGPASSWORD": password}
+ stdin_data = None
elif shutil.which("docker"):
# В контейнере база доступна как localhost, а не по хосту из DSN.
- command = ["docker", "exec", "-e", f"PGPASSWORD={password}", CONTAINER,
- "pg_dump", "-U", user, "-d", database]
+ # Пароль передаём через stdin, а не аргументом `docker exec -e`,
+ # поскольку аргументы процесса могут быть видны другим пользователям.
+ script = (
+ "IFS= read -r encoded_password || exit 1; "
+ 'PGPASSWORD="$(printf %s "$encoded_password" | base64 -d)" || exit 1; '
+ 'export PGPASSWORD; exec pg_dump -U "$1" -d "$2"'
+ )
+ command = ["docker", "exec", "-i", CONTAINER, "sh", "-c", script,
+ "lct-pg-dump", user, database]
env = dict(os.environ)
+ stdin_data = base64.b64encode(password.encode("utf-8")) + b"\n"
else:
raise BackupError(
"нечем снять копию: нет ни pg_dump, ни docker. "
@@ -75,7 +149,9 @@ def create() -> dict:
)
try:
- result = subprocess.run(command, capture_output=True, timeout=TIMEOUT_S, env=env)
+ result = subprocess.run(
+ command, capture_output=True, timeout=TIMEOUT_S, env=env, input=stdin_data
+ )
except subprocess.TimeoutExpired as exc:
raise BackupError(f"pg_dump не уложился в {TIMEOUT_S} с") from exc
except OSError as exc:
@@ -84,10 +160,38 @@ def create() -> dict:
if result.returncode != 0:
raise BackupError(result.stderr.decode(errors="replace")[:300] or "pg_dump завершился с ошибкой")
- target.write_bytes(result.stdout)
- log.info("резервная копия: %s, %d КБ", target.name, target.stat().st_size // 1024)
+ dump = result.stdout
+ for setting in INCOMPATIBLE_SETTINGS:
+ dump = dump.replace(setting, b"")
+ if not dump.startswith(b"--\n-- PostgreSQL database dump"):
+ raise BackupError("pg_dump вернул неожиданный или пустой формат")
+ # Публикуем только полностью записанный dump. При аварийном завершении
+ # процесса список копий не должен показывать обрезанный файл как готовый.
+ try:
+ fd, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=DIR)
+ except OSError as exc:
+ raise BackupError(f"не удалось создать временный файл копии: {exc}") from exc
+ temporary = Path(temporary_name)
+ try:
+ with os.fdopen(fd, "wb") as stream:
+ _restrict_file_permissions(stream.fileno(), temporary)
+ stream.write(dump)
+ stream.flush()
+ os.fsync(stream.fileno())
+ temporary.replace(target)
+ except Exception as exc:
+ try:
+ temporary.unlink(missing_ok=True)
+ except OSError:
+ log.exception("не удалось удалить временную копию %s", temporary.name)
+ if isinstance(exc, OSError):
+ raise BackupError(f"не удалось записать резервную копию: {exc}") from exc
+ raise
+ size_bytes = len(dump)
+ created_at = datetime.now(timezone.utc)
+ log.info("резервная копия: %s, %d КБ", target.name, size_bytes // 1024)
return {
"name": target.name,
- "size_bytes": target.stat().st_size,
- "at": datetime.fromtimestamp(target.stat().st_mtime, tz=timezone.utc),
+ "size_bytes": size_bytes,
+ "at": created_at,
}
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
index 0f8e2df..91240d1 100644
--- a/backend/app/api/auth.py
+++ b/backend/app/api/auth.py
@@ -16,6 +16,7 @@
"""
import asyncio
+import hashlib
import logging
import secrets
import weakref
@@ -24,7 +25,7 @@ from uuid import UUID
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import APIRouter, HTTPException, Request, WebSocket
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
from sqlalchemy import select
from app.config import get_settings
@@ -37,10 +38,12 @@ 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)
+# Cookie is signed and may safely survive a backend restart. Marker changes
+# only together with SESSION_SECRET; account edits are revoked independently
+# through the persistent auth_version loaded below.
+_INSTANCE = hashlib.sha256(
+ f"lct-auth-instance:{get_settings().session_secret}".encode()
+).hexdigest()
_generations: dict[str, int] = {}
_active_sockets: dict[str, weakref.WeakKeyDictionary] = {}
@@ -53,9 +56,21 @@ async def _close_revoked(ws: WebSocket) -> None:
pass
-def invalidate_login(login: str) -> None:
+def prime_generations(values: dict[str, int]) -> None:
+ """Загрузить версии полномочий из БД при старте нового процесса."""
+ _generations.clear()
+ _generations.update(values)
+
+
+async def load_generations() -> None:
+ async with get_sessionmaker()() as db:
+ rows = (await db.execute(select(User.login, User.auth_version))).all()
+ prime_generations({login: version for login, version in rows})
+
+
+def invalidate_login(login: str, version: int | None = None) -> None:
"""Revoke previously issued cookies after account/role/password changes."""
- _generations[login] = _generations.get(login, 0) + 1
+ _generations[login] = version if version is not None else _generations.get(login, 0) + 1
for ws, loop in list(_active_sockets.get(login, {}).items()):
try:
if not loop.is_closed():
@@ -117,8 +132,8 @@ class Principal(BaseModel):
class LoginIn(BaseModel):
- login: str
- password: str
+ login: str = Field(min_length=3, max_length=80)
+ password: str = Field(min_length=1, max_length=1024)
def _demo_local(request: Request) -> None:
@@ -199,10 +214,17 @@ async def login(payload: LoginIn, request: Request) -> dict:
# Одинаковый ответ на неизвестный логин и неверный пароль: иначе форма
# входа превращается в список действующих учётных записей.
if user is None or not verify_password(user.password_hash, payload.password):
+ # Не записываем пароль, IP либо факт существования учётной записи.
+ # Логин нужен администратору для расследования перебора; ограничиваем
+ # длину до размера поля AuditLog.actor.
+ await audit(payload.login[:80], "unknown", "login.failed")
raise HTTPException(status_code=401, detail="bad_credentials")
if user.blocked:
+ await audit(user.login, user.role, "login.blocked")
raise HTTPException(status_code=403, detail="blocked")
+ _generations[user.login] = user.auth_version
+
who = Principal(
login=user.login,
full_name=user.full_name,
diff --git a/backend/app/api/http/admin.py b/backend/app/api/http/admin.py
index e8be34c..4957799 100644
--- a/backend/app/api/http/admin.py
+++ b/backend/app/api/http/admin.py
@@ -11,10 +11,13 @@
import logging
import re
-from datetime import datetime
+from datetime import datetime, timedelta, timezone
from uuid import UUID
+from xml.etree import ElementTree as ET
-from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi import APIRouter, Depends, HTTPException, Request, Response
+from fastapi.encoders import jsonable_encoder
+from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
@@ -27,13 +30,68 @@ from app.config import get_settings
from app.db.base import get_session
from app.db.models import AuditLog, Session as SessionRow, Trainee, User
from app.domain import ekp
-from app.domain.roles import ROLE_LABELS, Role
+from app.domain.roles import ROLE_LABELS, SCREENS, Role
+from app.domain.timers import NORMATIVES
+from app.dialog.llm import is_loopback_url
+from app.monitoring import recent_events, sample_metrics
from app.session.hub import hub
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin", tags=["admin"])
+def _configuration_xml() -> bytes:
+ """Безопасный переносимый снимок конфигурации без паролей и ключей."""
+ settings = get_settings()
+ root = ET.Element("lctConfiguration", {"version": "1"})
+ ET.SubElement(root, "platform", {
+ "offline": str(settings.offline).lower(),
+ "voiceEnabled": str(settings.voice_enabled).lower(),
+ "secureCookies": str(settings.secure_cookies).lower(),
+ })
+ models = ET.SubElement(root, "localModels")
+ ET.SubElement(models, "dialogue", {"name": settings.llm_model_caller})
+ ET.SubElement(models, "russianControl", {
+ "name": settings.llm_model_control,
+ "grammarEnabled": str(settings.grammar_llm_enabled).lower(),
+ })
+ ET.SubElement(models, "speechToText", {
+ "name": settings.stt_model,
+ "enabled": str(settings.voice_enabled).lower(),
+ })
+ workstations = ET.SubElement(root, "workstations")
+ for role, screens in SCREENS.items():
+ workstation = ET.SubElement(workstations, "workstation", {
+ "role": role.value, "label": ROLE_LABELS[role],
+ })
+ for path in screens:
+ ET.SubElement(workstation, "screen", {"path": path})
+ timers = ET.SubElement(root, "timerLimits")
+ for code, normative in NORMATIVES.items():
+ ET.SubElement(timers, "timer", {
+ "code": code.value,
+ "milliseconds": str(settings.limit_ms(code)),
+ "defaultMilliseconds": str(normative.limit_ms),
+ })
+ reference = ekp.reference()
+ ET.SubElement(root, "ekp", {
+ "version": reference.version,
+ "incidents": str(len(reference.incidents)),
+ })
+ ET.indent(root, space=" ")
+ return ET.tostring(root, encoding="utf-8", xml_declaration=True)
+
+
+@router.get("/config.xml")
+async def configuration_xml(request: Request) -> Response:
+ require(request, Role.ADMIN)
+ return Response(
+ content=_configuration_xml(),
+ media_type="application/xml",
+ headers={"Content-Disposition": 'attachment; filename="lct-workstations.xml"'},
+ )
+
+
class UserOut(BaseModel):
id: UUID
login: str
@@ -47,7 +105,7 @@ class UserOut(BaseModel):
class UserCreate(BaseModel):
login: str = Field(min_length=3, max_length=80)
full_name: str = Field(min_length=1, max_length=120)
- password: str = Field(min_length=8, description="Короткий пароль не заводится")
+ password: str = Field(min_length=8, max_length=1024, description="Пароль должен быть от 8 до 1024 символов")
role: Role
service: str | None = None
@@ -59,7 +117,7 @@ class UserPatch(BaseModel):
role: Role | None = None
service: str | None = None
blocked: bool | None = None
- password: str | None = Field(default=None, min_length=8)
+ password: str | None = Field(default=None, min_length=8, max_length=1024)
def _out(user: User) -> UserOut:
@@ -148,8 +206,9 @@ async def patch_user(
if not changed:
return _out(user)
+ user.auth_version += 1
await db.commit()
- invalidate_login(user.login)
+ invalidate_login(user.login, user.auth_version)
await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed))
return _out(user)
@@ -169,12 +228,18 @@ async def audit_log(
action: str | None = None,
actor: str | None = None,
limit: int = 200,
+ offset: int = 0,
db: AsyncSession = Depends(get_session),
) -> list[AuditOut]:
"""Журнал действий. Администратор его читает, но не правит: точки удаления
или изменения записи здесь нет — ТЗ требует хранения, а не управления."""
require(request, Role.ADMIN)
- query = select(AuditLog).order_by(AuditLog.at.desc()).limit(max(1, min(limit, 1000)))
+ query = (
+ select(AuditLog)
+ .order_by(AuditLog.at.desc(), AuditLog.id.desc())
+ .limit(max(1, min(limit, 1001)))
+ .offset(max(0, min(offset, 10_000_000)))
+ )
if action:
query = query.where(AuditLog.action == action)
if actor:
@@ -195,6 +260,104 @@ class ServiceState(BaseModel):
detail: str
+class RuntimeMetrics(BaseModel):
+ at: datetime
+ uptime_seconds: float
+ cpu_percent: float
+ load_1m_percent: float | None
+ cpu_cores: int
+ rss_bytes: int | None
+ memory_total_bytes: int | None
+ memory_available_bytes: int | None
+ disk_total_bytes: int
+ disk_free_bytes: int
+ threads: int
+ active_sessions: int
+ restored_sessions: int
+ completed_sessions_24h: int
+
+
+class DiagnosticEvent(BaseModel):
+ at: datetime
+ level: str
+ source: str
+ message: str
+
+
+class FailureEvent(BaseModel):
+ at: datetime
+ actor: str
+ action: str
+ object_id: str | None
+ detail: str
+
+
+class DiagnosticReport(BaseModel):
+ generated_at: datetime
+ metrics: RuntimeMetrics
+ recent_system_events: list[DiagnosticEvent]
+ failed_actions_24h: list[FailureEvent]
+
+
+async def _runtime_metrics(db: AsyncSession) -> RuntimeMetrics:
+ from app.main import app
+
+ raw = sample_metrics()
+ since = datetime.now(timezone.utc) - timedelta(hours=24)
+ completed = await db.scalar(
+ select(func.count()).select_from(SessionRow).where(SessionRow.ended_at >= since)
+ )
+ return RuntimeMetrics(
+ **raw,
+ active_sessions=sum(not item.ended for item in hub._sessions.values()), # noqa: SLF001
+ restored_sessions=getattr(app.state, "sessions_restored", 0),
+ completed_sessions_24h=completed or 0,
+ )
+
+
+async def _diagnostic_report(db: AsyncSession) -> DiagnosticReport:
+ since = datetime.now(timezone.utc) - timedelta(hours=24)
+ rows = await db.scalars(
+ select(AuditLog)
+ .where(AuditLog.at >= since, AuditLog.action.like("%.failed"))
+ .order_by(AuditLog.at.desc())
+ .limit(200)
+ )
+ return DiagnosticReport(
+ generated_at=datetime.now(timezone.utc),
+ metrics=await _runtime_metrics(db),
+ recent_system_events=[DiagnosticEvent(**item) for item in recent_events(limit=100)],
+ failed_actions_24h=[
+ FailureEvent(
+ at=row.at, actor=row.actor, action=row.action,
+ object_id=row.object_id, detail=row.detail,
+ )
+ for row in rows
+ ],
+ )
+
+
+@router.get("/diagnostics", response_model=DiagnosticReport)
+async def diagnostics(
+ request: Request, db: AsyncSession = Depends(get_session)
+) -> DiagnosticReport:
+ """Live load plus a bounded, redacted incident report for the admin."""
+ require(request, Role.ADMIN)
+ return await _diagnostic_report(db)
+
+
+@router.get("/diagnostics.json")
+async def download_diagnostics(
+ request: Request, db: AsyncSession = Depends(get_session)
+) -> JSONResponse:
+ require(request, Role.ADMIN)
+ report = await _diagnostic_report(db)
+ return JSONResponse(
+ jsonable_encoder(report),
+ headers={"Content-Disposition": 'attachment; filename="lct-diagnostics.json"'},
+ )
+
+
@router.get("/status", response_model=list[ServiceState])
async def status(request: Request, db: AsyncSession = Depends(get_session)) -> list[ServiceState]:
"""Состояние компонентов стенда — то, что администратор смотрит до занятия,
@@ -220,6 +383,21 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
detail="распознавание и синтез готовы" if models_ready else "не загружены: занятие пойдёт без голоса",
)
)
+ metrics = sample_metrics()
+ disk_free = metrics["disk_free_bytes"]
+ disk_total = max(metrics["disk_total_bytes"], 1)
+ disk_ok = disk_free >= 1024 ** 3 and disk_free / disk_total >= 0.05
+ load = metrics["load_1m_percent"]
+ states.append(ServiceState(
+ name="Нагрузка backend",
+ ok=disk_ok and (load is None or load < 100),
+ detail=(
+ f"CPU процесса {metrics['cpu_percent']:.1f}%; "
+ + (f"нагрузка хоста {load:.1f}%; " if load is not None else "")
+ + f"RAM процесса {(metrics['rss_bytes'] or 0) / 1024 ** 2:.0f} МБ; "
+ + f"свободно на диске {disk_free / 1024 ** 3:.1f} ГБ"
+ ),
+ ))
states.append(
ServiceState(
name="Эмбеддинги",
@@ -228,11 +406,20 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
else "нет модели: подсказки идут по порядку чек-листа",
)
)
+ llm_configured = (
+ is_loopback_url(
+ settings.llm_base_url,
+ allow_docker_host=settings.allow_docker_host_models,
+ )
+ if settings.offline or settings.llm_provider == "local"
+ else bool(settings.llm_api_key and settings.llm_base_url)
+ )
states.append(
ServiceState(
name="Провайдер LLM",
- ok=bool(settings.llm_api_key and settings.llm_base_url),
- detail=settings.llm_base_url or "не настроен: звонящий читает офлайн-таблицу",
+ ok=llm_configured,
+ detail=(f"локальный адрес разрешён: {settings.llm_base_url}"
+ if llm_configured else "не настроен: звонящий читает офлайн-таблицу"),
)
)
@@ -248,9 +435,39 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
ServiceState(
name="Живых занятий",
ok=True,
- detail=str(len(hub._sessions)), # noqa: SLF001 — реестр в памяти процесса
+ detail=(
+ f"активно {sum(not item.ended for item in hub._sessions.values())}; "
+ f"восстановлено после запуска {getattr(app.state, 'sessions_restored', 0)}"
+ ), # noqa: SLF001 — реестр в памяти процесса
)
)
+ try:
+ copies = backup_service.listing()
+ except backup_service.BackupError as exc:
+ states.append(ServiceState(
+ name="Резервное копирование",
+ ok=False,
+ detail=f"ошибка чтения копий: {exc}",
+ ))
+ copies = []
+ if copies:
+ latest = copies[0]
+ age_seconds = max(0.0, (datetime.now(timezone.utc) - latest["at"]).total_seconds())
+ allowed_age = max(60, settings.backup_interval_seconds) + max(
+ 60, settings.backup_retry_seconds
+ )
+ states.append(ServiceState(
+ name="Резервное копирование",
+ ok=age_seconds <= allowed_age,
+ detail=(f"последняя копия {latest['name']}, "
+ f"{age_seconds / 3600:.1f} ч назад; хранится {len(copies)}"),
+ ))
+ else:
+ states.append(ServiceState(
+ name="Резервное копирование",
+ ok=False,
+ detail="успешных копий ещё нет",
+ ))
# Секрет сессии по умолчанию — не ошибка запуска, но на стенде это дыра,
# и увидеть её должен администратор, а не проверяющий.
default_secret = settings.session_secret.startswith("dev-secret")
@@ -273,7 +490,12 @@ 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 await run_in_threadpool(backup_service.listing)]
+ try:
+ copies = await run_in_threadpool(backup_service.listing)
+ except backup_service.BackupError as exc:
+ detail = _safe_backup_error(exc)
+ raise HTTPException(status_code=503, detail=detail) from exc
+ return [BackupOut(**item) for item in copies]
def _safe_backup_error(exc: backup_service.BackupError) -> str:
diff --git a/backend/app/api/http/groups.py b/backend/app/api/http/groups.py
index 55b8f07..7d0c662 100644
--- a/backend/app/api/http/groups.py
+++ b/backend/app/api/http/groups.py
@@ -10,9 +10,11 @@ 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.db.models import Group, Score, Session, Trainee, User
from app.domain.roles import Role
from app.scoring.group import ScoredAttempt, summarize
+from app.scoring.group_insight import InsightInvalid, generate_group_insight
+from app.dialog.llm import LlmUnavailable
router = APIRouter(prefix="/api/groups", tags=["groups"])
@@ -26,6 +28,10 @@ class GroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
+class GroupOwnerPatch(BaseModel):
+ owner_login: str | None
+
+
class GroupErrorOut(BaseModel):
code: str
title: str
@@ -44,62 +50,23 @@ class GroupAnalyticsOut(BaseModel):
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]
+class GroupInsightOut(BaseModel):
+ summary: str
+ priorities: list[str]
+ source: str = "local_qwen"
+ personal_data_sent: bool = False
-@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)
+async def _analytics(
+ group_id: UUID, db: AsyncSession, *, owner_login: str | None = None
) -> GroupAnalyticsOut:
- require(request, Role.INSTRUCTOR, Role.ADMIN)
group = await db.get(Group, group_id)
- if group is None:
+ if group is None or (owner_login is not None and group.owner_login != owner_login):
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(
+ attempts_query = (
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)
@@ -111,6 +78,9 @@ async def analytics(
Session.ended_at.is_not(None),
)
)
+ if owner_login is not None:
+ attempts_query = attempts_query.where(Session.owner_login == owner_login)
+ rows = await db.execute(attempts_query)
attempts = [
ScoredAttempt(
trainee_id=trainee_id,
@@ -123,3 +93,116 @@ async def analytics(
group=GroupOut(id=group.id, name=group.name),
**summarize(attempts, int(enrolled or 0)),
)
+
+
+@router.get("", response_model=list[GroupOut])
+async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[GroupOut]:
+ who = require(request, Role.INSTRUCTOR, Role.ADMIN)
+ query = select(Group).order_by(Group.name)
+ if who.role is Role.INSTRUCTOR:
+ query = query.where(Group.owner_login == who.login)
+ groups = await db.scalars(query)
+ 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, owner_login=who.login if who.role is Role.INSTRUCTOR else None)
+ 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.patch("/{group_id}/owner", response_model=GroupOut)
+async def transfer_ownership(
+ group_id: UUID,
+ body: GroupOwnerPatch,
+ request: Request,
+ db: AsyncSession = Depends(get_session),
+) -> GroupOut:
+ """Администратор безопасно закрепляет legacy-группу за преподавателем."""
+ who = require(request, Role.ADMIN)
+ group = await db.get(Group, group_id)
+ if group is None:
+ raise HTTPException(status_code=404, detail="group_not_found")
+ if body.owner_login is not None:
+ user = await db.scalar(select(User).where(User.login == body.owner_login))
+ if user is None or user.role != Role.INSTRUCTOR.value or user.blocked:
+ raise HTTPException(status_code=422, detail="active_instructor_required")
+ previous_owner = group.owner_login
+ group.owner_login = body.owner_login
+ await db.commit()
+ await audit(
+ who.login,
+ who.role.value,
+ "group.transfer",
+ str(group.id),
+ f"{previous_owner or 'admin'} -> {body.owner_login or 'admin'}",
+ )
+ 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 or (who.role is Role.INSTRUCTOR and group.owner_login != who.login):
+ 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")
+ if (
+ who.role is Role.INSTRUCTOR
+ and trainee.group_id is not None
+ and trainee.group_id != group_id
+ ):
+ current_group = await db.get(Group, trainee.group_id)
+ if current_group is None or current_group.owner_login != who.login:
+ raise HTTPException(status_code=409, detail="trainee_in_other_instructor_group")
+ 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:
+ who = require(request, Role.INSTRUCTOR, Role.ADMIN)
+ return await _analytics(
+ group_id, db, owner_login=who.login if who.role is Role.INSTRUCTOR else None
+ )
+
+
+@router.post("/{group_id}/analytics/insight", response_model=GroupInsightOut)
+async def ai_insight(
+ group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
+) -> GroupInsightOut:
+ """Сформировать по запросу преподавателя локальный обезличенный инсайт."""
+ who = require(request, Role.INSTRUCTOR)
+ data = await _analytics(group_id, db, owner_login=who.login)
+ if data.scored_attempts == 0:
+ raise HTTPException(status_code=409, detail="no_scored_attempts")
+ try:
+ insight = await generate_group_insight(data.model_dump(mode="json"))
+ except LlmUnavailable as exc:
+ raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
+ except InsightInvalid as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+ await audit(who.login, who.role.value, "group.ai_insight", str(group_id), "aggregated_only")
+ return GroupInsightOut(**insight)
diff --git a/backend/app/api/http/materials.py b/backend/app/api/http/materials.py
new file mode 100644
index 0000000..41b5c51
--- /dev/null
+++ b/backend/app/api/http/materials.py
@@ -0,0 +1,549 @@
+"""Учебные материалы, локальные вложения и назначения курсантам.
+
+Файлы хранятся в PostgreSQL и отдаются только как attachment: учебный контур
+не зависит от внешнего файлового сервиса и не исполняет загруженный HTML.
+"""
+
+import base64
+import binascii
+import hashlib
+from collections.abc import AsyncIterator
+from datetime import datetime, timezone
+from typing import Literal
+from urllib.parse import quote
+from uuid import UUID, uuid4
+
+from fastapi import APIRouter, Depends, HTTPException, Request, Response
+from pydantic import BaseModel, Field, model_validator
+from sqlalchemy import delete, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.auth import DEMO_TRAINEE_ID, audit, require
+from app.config import get_settings
+from app.db.base import get_session
+from app.db.models import Group, LearningMaterial, MaterialAssignment, Trainee
+from app.domain.events import Exercise, ScenarioStart, SessionMode
+from app.domain.roles import Role
+from app.scenarios import store
+from app.session.hub import hub
+
+router = APIRouter(prefix="/api/materials", tags=["materials"])
+MAX_FILE_BYTES = 5 * 1024 * 1024
+LEVELS = {"L1", "L2", "L3"}
+
+_DEMO_GUIDE_ID = UUID("00000000-0000-4000-8000-000000000901")
+_demo_materials: dict[UUID, LearningMaterial] = {}
+_demo_assignments: dict[tuple[UUID, UUID], dict] = {}
+
+
+def reset_demo_materials() -> None:
+ """Демонстрационный справочник воспроизводим после каждого старта."""
+ _demo_materials.clear()
+ _demo_assignments.clear()
+ now = datetime.now(timezone.utc)
+ _demo_materials[_DEMO_GUIDE_ID] = LearningMaterial(
+ id=_DEMO_GUIDE_ID,
+ title="Памятка диспетчера ДДС",
+ description="Короткий алгоритм работы с готовой карточкой происшествия.",
+ level="L1",
+ kind="text",
+ body=(
+ "1. Подтвердите получение карточки.\n"
+ "2. Проверьте зону ответственности и список оповещения.\n"
+ "3. Назначьте бригаду и передайте адрес, событие и задачу.\n"
+ "4. Фиксируйте выезд, прибытие, локализацию и завершение работ."
+ ),
+ scenario_id="t01-1-fire-container",
+ file_name=None,
+ media_type=None,
+ file_data=None,
+ file_sha256=None,
+ active=True,
+ created_by="system",
+ created_at=now,
+ updated_at=now,
+ )
+ _demo_assignments[(_DEMO_GUIDE_ID, DEMO_TRAINEE_ID)] = {
+ "assigned_by": "system", "assigned_at": now, "completed_at": None,
+ }
+
+
+async def material_session() -> AsyncIterator[AsyncSession | None]:
+ if get_settings().demo_no_db:
+ yield None
+ else:
+ async for db in get_session():
+ yield db
+
+
+class MaterialCreate(BaseModel):
+ title: str = Field(min_length=3, max_length=200)
+ description: str = Field(default="", max_length=2000)
+ level: Literal["L1", "L2", "L3"] = "L1"
+ kind: Literal["text", "file"] = "text"
+ body: str = Field(default="", max_length=30_000)
+ scenario_id: str | None = Field(default=None, max_length=80)
+ file_name: str | None = Field(default=None, max_length=240)
+ media_type: str | None = Field(default=None, max_length=120)
+ content_base64: str | None = None
+
+ @model_validator(mode="after")
+ def valid_content(self):
+ if self.kind == "text" and not self.body.strip():
+ raise ValueError("текст материала пуст")
+ if self.kind == "file" and (not self.file_name or not self.content_base64):
+ raise ValueError("для файла нужны имя и содержимое")
+ return self
+
+
+class MaterialPatch(BaseModel):
+ title: str | None = Field(default=None, min_length=3, max_length=200)
+ description: str | None = Field(default=None, max_length=2000)
+ level: Literal["L1", "L2", "L3"] | None = None
+ body: str | None = Field(default=None, max_length=30_000)
+ scenario_id: str | None = Field(default=None, max_length=80)
+ active: bool | None = None
+
+
+class MaterialOut(BaseModel):
+ id: UUID
+ title: str
+ description: str
+ level: str
+ kind: str
+ body: str
+ scenario_id: str | None
+ file_name: str | None
+ media_type: str | None
+ file_size: int
+ file_sha256: str | None
+ active: bool
+ created_by: str
+ created_at: datetime
+ assigned_at: datetime | None = None
+ completed_at: datetime | None = None
+ assignment_count: int = 0
+
+
+def _out(
+ row: LearningMaterial, *, assignment: MaterialAssignment | dict | None = None,
+ assignment_count: int = 0,
+) -> MaterialOut:
+ if isinstance(assignment, dict):
+ assigned_at = assignment.get("assigned_at")
+ completed_at = assignment.get("completed_at")
+ else:
+ assigned_at = assignment.assigned_at if assignment else None
+ completed_at = assignment.completed_at if assignment else None
+ return MaterialOut(
+ id=row.id,
+ title=row.title,
+ description=row.description,
+ level=row.level,
+ kind=row.kind,
+ body=row.body,
+ scenario_id=row.scenario_id,
+ file_name=row.file_name,
+ media_type=row.media_type,
+ file_size=len(row.file_data or b""),
+ file_sha256=row.file_sha256,
+ active=row.active,
+ created_by=row.created_by,
+ created_at=row.created_at,
+ assigned_at=assigned_at,
+ completed_at=completed_at,
+ assignment_count=assignment_count,
+ )
+
+
+def _decode_file(payload: MaterialCreate) -> bytes | None:
+ if payload.kind != "file":
+ return None
+ try:
+ data = base64.b64decode(payload.content_base64 or "", validate=True)
+ except (binascii.Error, ValueError) as exc:
+ raise HTTPException(status_code=422, detail="invalid_file_base64") from exc
+ if not data:
+ raise HTTPException(status_code=422, detail="empty_file")
+ if len(data) > MAX_FILE_BYTES:
+ raise HTTPException(status_code=413, detail="file_too_large_5mb")
+ return data
+
+
+def _validate_scenario(scenario_id: str | None) -> None:
+ if scenario_id and store.get(scenario_id) is None:
+ raise HTTPException(status_code=422, detail="scenario_not_found")
+
+
+def _safe_filename(value: str) -> str:
+ """Убрать и POSIX-, и Windows-путь; в БД остаётся только имя файла."""
+ return value.replace("\\", "/").rsplit("/", 1)[-1] or "resource.bin"
+
+
+@router.get("", response_model=list[MaterialOut])
+async def listing(
+ request: Request,
+ include_archived: bool = False,
+ db: AsyncSession | None = Depends(material_session),
+) -> list[MaterialOut]:
+ who = require(request)
+ if db is None:
+ if who.role is Role.TRAINEE:
+ if who.trainee_id is None:
+ raise HTTPException(status_code=403, detail="trainee_profile_required")
+ result = []
+ for material_id, row in _demo_materials.items():
+ assignment = _demo_assignments.get((material_id, who.trainee_id))
+ if assignment and row.active:
+ result.append(_out(row, assignment=assignment))
+ return result
+ return [
+ _out(row, assignment_count=sum(mid == row.id for mid, _ in _demo_assignments))
+ for row in _demo_materials.values()
+ if (include_archived or row.active)
+ and (who.role is not Role.INSTRUCTOR or row.created_by in {who.login, "system"})
+ ]
+
+ if who.role is Role.TRAINEE:
+ if who.trainee_id is None:
+ raise HTTPException(status_code=403, detail="trainee_profile_required")
+ rows = await db.execute(
+ select(LearningMaterial, MaterialAssignment)
+ .join(MaterialAssignment, MaterialAssignment.material_id == LearningMaterial.id)
+ .where(
+ MaterialAssignment.trainee_id == who.trainee_id,
+ LearningMaterial.active.is_(True),
+ )
+ .order_by(MaterialAssignment.assigned_at.desc())
+ )
+ return [_out(row, assignment=assignment) for row, assignment in rows]
+
+ require(request, Role.INSTRUCTOR, Role.ADMIN)
+ statement = (
+ select(LearningMaterial, func.count(MaterialAssignment.id))
+ .outerjoin(MaterialAssignment, MaterialAssignment.material_id == LearningMaterial.id)
+ .group_by(LearningMaterial.id)
+ .order_by(LearningMaterial.active.desc(), LearningMaterial.updated_at.desc())
+ )
+ if who.role is Role.INSTRUCTOR:
+ statement = statement.where(LearningMaterial.created_by == who.login)
+ if not include_archived:
+ statement = statement.where(LearningMaterial.active.is_(True))
+ rows = await db.execute(statement)
+ return [_out(row, assignment_count=count) for row, count in rows]
+
+
+@router.post("", response_model=MaterialOut, status_code=201)
+async def create(
+ payload: MaterialCreate,
+ request: Request,
+ db: AsyncSession | None = Depends(material_session),
+) -> MaterialOut:
+ who = require(request, Role.INSTRUCTOR)
+ _validate_scenario(payload.scenario_id)
+ data = _decode_file(payload)
+ now = datetime.now(timezone.utc)
+ row = LearningMaterial(
+ id=uuid4(),
+ title=payload.title.strip(),
+ description=payload.description.strip(),
+ level=payload.level,
+ kind=payload.kind,
+ body=payload.body.strip(),
+ scenario_id=payload.scenario_id,
+ file_name=_safe_filename(payload.file_name) if payload.file_name else None,
+ media_type=(payload.media_type or "application/octet-stream") if data else None,
+ file_data=data,
+ file_sha256=hashlib.sha256(data).hexdigest() if data else None,
+ active=True,
+ created_by=who.login,
+ created_at=now,
+ updated_at=now,
+ )
+ if db is None:
+ _demo_materials[row.id] = row
+ else:
+ db.add(row)
+ await db.commit()
+ await audit(who.login, who.role.value, "material.create", str(row.id), row.title)
+ return _out(row)
+
+
+async def _material(db: AsyncSession | None, material_id: UUID) -> LearningMaterial | None:
+ return _demo_materials.get(material_id) if db is None else await db.get(LearningMaterial, material_id)
+
+
+def _require_owner(row: LearningMaterial, login: str) -> None:
+ """Only the instructor who authored a resource may manage it."""
+ if row.created_by != login:
+ raise HTTPException(status_code=404, detail="material_not_found")
+
+
+@router.patch("/{material_id}", response_model=MaterialOut)
+async def update(
+ material_id: UUID,
+ payload: MaterialPatch,
+ request: Request,
+ db: AsyncSession | None = Depends(material_session),
+) -> MaterialOut:
+ who = require(request, Role.INSTRUCTOR)
+ row = await _material(db, material_id)
+ if row is None:
+ raise HTTPException(status_code=404, detail="material_not_found")
+ _require_owner(row, who.login)
+ patch = payload.model_dump(exclude_unset=True)
+ if "scenario_id" in patch:
+ patch["scenario_id"] = patch["scenario_id"] or None
+ _validate_scenario(patch["scenario_id"])
+ if row.kind == "text" and "body" in patch and not (patch["body"] or "").strip():
+ raise HTTPException(status_code=422, detail="empty_material_body")
+ for key, value in patch.items():
+ setattr(row, key, value.strip() if isinstance(value, str) else value)
+ row.updated_at = datetime.now(timezone.utc)
+ if db is not None:
+ await db.commit()
+ await audit(who.login, who.role.value, "material.update", str(row.id))
+ return _out(row)
+
+
+@router.delete("/{material_id}", response_model=MaterialOut)
+async def archive(
+ material_id: UUID,
+ request: Request,
+ db: AsyncSession | None = Depends(material_session),
+) -> MaterialOut:
+ who = require(request, Role.INSTRUCTOR)
+ row = await _material(db, material_id)
+ if row is None:
+ raise HTTPException(status_code=404, detail="material_not_found")
+ _require_owner(row, who.login)
+ row.active = False
+ row.updated_at = datetime.now(timezone.utc)
+ if db is not None:
+ await db.commit()
+ await audit(who.login, who.role.value, "material.archive", str(row.id))
+ return _out(row)
+
+
+@router.put("/{material_id}/assign/{trainee_id}", response_model=MaterialOut)
+async def assign(
+ material_id: UUID,
+ trainee_id: UUID,
+ request: Request,
+ db: AsyncSession | None = Depends(material_session),
+) -> MaterialOut:
+ who = require(request, Role.INSTRUCTOR)
+ row = await _material(db, material_id)
+ if row is None or not row.active:
+ raise HTTPException(status_code=404, detail="material_not_found")
+ _require_owner(row, who.login)
+ if db is None:
+ if trainee_id != DEMO_TRAINEE_ID:
+ raise HTTPException(status_code=404, detail="trainee_not_found")
+ assignment = _demo_assignments.setdefault(
+ (material_id, trainee_id),
+ {"assigned_by": who.login, "assigned_at": datetime.now(timezone.utc), "completed_at": None},
+ )
+ else:
+ trainee = await db.get(Trainee, trainee_id)
+ if trainee is None:
+ raise HTTPException(status_code=404, detail="trainee_not_found")
+ if trainee.group_id is not None:
+ group = await db.get(Group, trainee.group_id)
+ if group is None or group.owner_login != who.login:
+ raise HTTPException(status_code=404, detail="trainee_not_found")
+ assignment = await db.scalar(select(MaterialAssignment).where(
+ MaterialAssignment.material_id == material_id,
+ MaterialAssignment.trainee_id == trainee_id,
+ ))
+ if assignment is None:
+ assignment = MaterialAssignment(
+ material_id=material_id, trainee_id=trainee_id, assigned_by=who.login
+ )
+ db.add(assignment)
+ await db.commit()
+ await db.refresh(assignment)
+ await audit(who.login, who.role.value, "material.assign", str(row.id), str(trainee_id))
+ return _out(row, assignment=assignment)
+
+
+@router.put("/{material_id}/assign-group/{group_id}")
+async def assign_group(
+ material_id: UUID,
+ group_id: UUID,
+ request: Request,
+ db: AsyncSession | None = Depends(material_session),
+) -> dict:
+ who = require(request, Role.INSTRUCTOR)
+ row = await _material(db, material_id)
+ if row is None or not row.active:
+ raise HTTPException(status_code=404, detail="material_not_found")
+ _require_owner(row, who.login)
+ if db is None:
+ raise HTTPException(status_code=409, detail="groups_unavailable_in_demo")
+ group = await db.get(Group, group_id)
+ if group is None or group.owner_login != who.login:
+ raise HTTPException(status_code=404, detail="group_not_found")
+ trainee_ids = list(await db.scalars(select(Trainee.id).where(Trainee.group_id == group_id)))
+ existing = set(await db.scalars(select(MaterialAssignment.trainee_id).where(
+ MaterialAssignment.material_id == material_id,
+ MaterialAssignment.trainee_id.in_(trainee_ids),
+ ))) if trainee_ids else set()
+ for trainee_id in trainee_ids:
+ if trainee_id not in existing:
+ db.add(MaterialAssignment(
+ material_id=material_id, trainee_id=trainee_id, assigned_by=who.login
+ ))
+ await db.commit()
+ await audit(who.login, who.role.value, "material.assign_group", str(row.id), str(group_id))
+ return {"material_id": str(row.id), "assigned": len(trainee_ids)}
+
+
+@router.delete("/{material_id}/assign/{trainee_id}")
+async def unassign(
+ material_id: UUID,
+ trainee_id: UUID,
+ request: Request,
+ db: AsyncSession | None = Depends(material_session),
+) -> dict:
+ who = require(request, Role.INSTRUCTOR)
+ row = await _material(db, material_id)
+ if row is None:
+ raise HTTPException(status_code=404, detail="material_not_found")
+ _require_owner(row, who.login)
+ if db is None:
+ removed = _demo_assignments.pop((material_id, trainee_id), None) is not None
+ else:
+ assignment = await db.scalar(select(MaterialAssignment).where(
+ MaterialAssignment.material_id == material_id,
+ MaterialAssignment.trainee_id == trainee_id,
+ ))
+ if assignment is not None and assignment.assigned_by != who.login:
+ raise HTTPException(status_code=404, detail="assignment_not_found")
+ if assignment is not None:
+ await db.delete(assignment)
+ await db.commit()
+ removed = assignment is not None
+ await audit(who.login, who.role.value, "material.unassign", str(material_id), str(trainee_id))
+ return {"removed": removed}
+
+
+@router.post("/{material_id}/complete", response_model=MaterialOut)
+async def complete(
+ material_id: UUID,
+ request: Request,
+ db: AsyncSession | None = Depends(material_session),
+) -> MaterialOut:
+ who = require(request, Role.TRAINEE)
+ if who.trainee_id is None:
+ raise HTTPException(status_code=403, detail="trainee_profile_required")
+ row = await _material(db, material_id)
+ if row is None or not row.active:
+ raise HTTPException(status_code=404, detail="material_not_found")
+ if db is None:
+ assignment = _demo_assignments.get((material_id, who.trainee_id))
+ else:
+ assignment = await db.scalar(select(MaterialAssignment).where(
+ MaterialAssignment.material_id == material_id,
+ MaterialAssignment.trainee_id == who.trainee_id,
+ ))
+ if assignment is None:
+ raise HTTPException(status_code=403, detail="material_not_assigned")
+ completed_at = datetime.now(timezone.utc)
+ if isinstance(assignment, dict):
+ assignment["completed_at"] = completed_at
+ else:
+ assignment.completed_at = completed_at
+ await db.commit()
+ await audit(who.login, who.role.value, "material.complete", str(material_id))
+ return _out(row, assignment=assignment)
+
+
+@router.post("/{material_id}/start")
+async def start_assigned_practice(
+ material_id: UUID,
+ request: Request,
+ db: AsyncSession | None = Depends(material_session),
+) -> dict:
+ """Курсант сам выбирает назначенный модуль и запускает текстовую практику."""
+ who = require(request, Role.TRAINEE)
+ if who.trainee_id is None:
+ raise HTTPException(status_code=403, detail="trainee_profile_required")
+ row = await _material(db, material_id)
+ if row is None or not row.active:
+ raise HTTPException(status_code=404, detail="material_not_found")
+ if db is None:
+ assignment = _demo_assignments.get((material_id, who.trainee_id))
+ else:
+ assignment = await db.scalar(select(MaterialAssignment).where(
+ MaterialAssignment.material_id == material_id,
+ MaterialAssignment.trainee_id == who.trainee_id,
+ ))
+ if assignment is None:
+ raise HTTPException(status_code=403, detail="material_not_assigned")
+ if not row.scenario_id:
+ raise HTTPException(status_code=409, detail="material_has_no_practice")
+ scenario = store.get(row.scenario_id)
+ if scenario is None:
+ raise HTTPException(status_code=409, detail="scenario_not_found")
+ # Явное назначение преподавателя — и есть разрешение на самостоятельный
+ # модуль. Оно не открывает курсанту остальные сценарии библиотеки.
+
+ from app.api.ws.control import _start
+
+ session_id = uuid4()
+ await _start(session_id, ScenarioStart(
+ scenario_id=scenario.id,
+ trainee=who.full_name,
+ trainee_id=who.trainee_id,
+ mode=SessionMode.SELF,
+ exercise=Exercise.CARD,
+ ), who)
+ if hub.get(session_id) is None:
+ raise HTTPException(status_code=409, detail="practice_start_failed")
+ return {
+ "session_id": str(session_id),
+ "scenario_id": scenario.id,
+ "mode": SessionMode.SELF.value,
+ "exercise": Exercise.CARD.value,
+ "path": f"/trainee?session={session_id}",
+ }
+
+
+@router.get("/{material_id}/download")
+async def download(
+ material_id: UUID,
+ request: Request,
+ db: AsyncSession | None = Depends(material_session),
+) -> Response:
+ who = require(request)
+ row = await _material(db, material_id)
+ if row is None or not row.active or row.kind != "file" or row.file_data is None:
+ raise HTTPException(status_code=404, detail="file_not_found")
+ if who.role is Role.TRAINEE:
+ if who.trainee_id is None:
+ raise HTTPException(status_code=403, detail="trainee_profile_required")
+ if db is None:
+ allowed = (material_id, who.trainee_id) in _demo_assignments
+ else:
+ allowed = await db.scalar(select(MaterialAssignment.id).where(
+ MaterialAssignment.material_id == material_id,
+ MaterialAssignment.trainee_id == who.trainee_id,
+ )) is not None
+ if not allowed:
+ raise HTTPException(status_code=403, detail="material_not_assigned")
+ elif who.role is Role.INSTRUCTOR:
+ _require_owner(row, who.login)
+ elif who.role not in {Role.INSTRUCTOR, Role.ADMIN}:
+ raise HTTPException(status_code=403, detail="forbidden")
+ filename = _safe_filename(row.file_name or "resource.bin")
+ disposition = f"attachment; filename=resource; filename*=UTF-8''{quote(filename)}"
+ return Response(
+ content=row.file_data,
+ media_type=row.media_type or "application/octet-stream",
+ headers={
+ "Content-Disposition": disposition,
+ "X-Content-Type-Options": "nosniff",
+ "Content-Security-Policy": "default-src 'none'",
+ },
+ )
diff --git a/backend/app/api/http/scenarios.py b/backend/app/api/http/scenarios.py
index 0bb8a14..6af494f 100644
--- a/backend/app/api/http/scenarios.py
+++ b/backend/app/api/http/scenarios.py
@@ -10,6 +10,7 @@
`hint.shown` из живой сессии, эталонные вопросы — только в разборе.
"""
+from collections.abc import AsyncIterator
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -19,49 +20,126 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
from app.domain import ekp
from app.db.base import get_session
+from app.config import get_settings
from app.domain.roles import Role
from app.scenarios import store
from app.scenarios.editor import validate
+from app.scenarios.generation import GenerationError, generate, generate_from_description
+from app.dialog.llm import LlmUnavailable
from app.scenarios.loader import ScenarioError
+from app.session.hub import hub
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"}
+async def scenario_session() -> AsyncIterator[AsyncSession | None]:
+ """Только редактор в demo-lite использует временное хранилище без БД."""
+ if get_settings().demo_no_db:
+ yield None
+ else:
+ async for db in get_session():
+ yield db
+
+
class TemplateDraftIn(BaseModel):
source_id: str = Field(min_length=1)
title: str | None = Field(default=None, min_length=1, max_length=200)
+class GenerateDraftIn(BaseModel):
+ source_id: str = Field(min_length=1)
+ instruction: str = Field(min_length=10, max_length=1000)
+
+
+class GenerateFullDraftIn(BaseModel):
+ source_id: str = Field(min_length=1)
+ description: str = Field(min_length=20, max_length=1500)
+
+
+class ReviseDraftIn(BaseModel):
+ comment: str = Field(min_length=10, max_length=1000)
+
+
def _draft_out(row) -> dict:
+ if row.id.startswith("ai-full-"):
+ generation = "ai_full"
+ elif row.id.startswith("ai-"):
+ generation = "ai_variant"
+ else:
+ generation = "template_copy"
return {
"id": row.id,
"status": row.status,
- "generation": "template_copy",
+ "generation": generation,
"body": row.body,
}
@router.post("/drafts/from-template", status_code=201)
async def create_template_draft(
- body: TemplateDraftIn, request: Request, db: AsyncSession = Depends(get_session)
+ body: TemplateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
who = require(request, Role.INSTRUCTOR)
source = store.get(body.source_id)
if source is None:
raise HTTPException(status_code=404, detail="published_source_not_found")
- row = await store.create_draft(db, source=source, title=body.title)
+ row = await store.create_draft(db, source=source, title=body.title, owner_login=who.login)
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
return _draft_out(row)
+@router.post("/drafts/generate", status_code=201)
+async def create_ai_draft(
+ body: GenerateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_session)
+) -> dict:
+ who = require(request, Role.INSTRUCTOR)
+ source = store.get(body.source_id)
+ if source is None:
+ raise HTTPException(status_code=404, detail="published_source_not_found")
+ try:
+ proposal = await generate(source, body.instruction.strip(), require_fact_change=False)
+ row = await store.create_draft(db, source=source, proposal=proposal, owner_login=who.login)
+ except LlmUnavailable as exc:
+ raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
+ except GenerationError as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+ await audit(who.login, who.role.value, "scenario.draft.ai_generate", row.id,
+ f"source:{source.id}")
+ return _draft_out(row)
+
+
+@router.post("/drafts/generate-from-description", status_code=201)
+async def create_full_ai_draft(
+ body: GenerateFullDraftIn, request: Request,
+ db: AsyncSession | None = Depends(scenario_session),
+) -> dict:
+ """Новый сюжет и мягкий эталон внутри выбранного класса ЕКП."""
+ who = require(request, Role.INSTRUCTOR)
+ source = store.get(body.source_id)
+ if source is None:
+ raise HTTPException(status_code=404, detail="published_source_not_found")
+ try:
+ proposal = await generate_from_description(source, body.description.strip())
+ row = await store.create_draft(
+ db, source=source, full_proposal=proposal, owner_login=who.login
+ )
+ except LlmUnavailable as exc:
+ raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
+ except GenerationError as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+ await audit(who.login, who.role.value, "scenario.draft.ai_generate_full", row.id,
+ f"class_source:{source.id}")
+ return _draft_out(row)
+
+
@router.get("/drafts/{scenario_id}")
async def read_draft(
- scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
+ scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
- require(request, Role.INSTRUCTOR)
- row = await store.draft(db, scenario_id)
+ who = require(request, Role.INSTRUCTOR)
+ row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
return _draft_out(row)
@@ -72,10 +150,10 @@ async def patch_draft(
scenario_id: str,
body: dict[str, Any],
request: Request,
- db: AsyncSession = Depends(get_session),
+ db: AsyncSession | None = Depends(scenario_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
- row = await store.draft(db, scenario_id)
+ row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
@@ -86,12 +164,36 @@ async def patch_draft(
return _draft_out(row)
+@router.post("/drafts/{scenario_id}/revise")
+async def revise_ai_draft(
+ scenario_id: str,
+ body: ReviseDraftIn,
+ request: Request,
+ db: AsyncSession | None = Depends(scenario_session),
+) -> dict:
+ who = require(request, Role.INSTRUCTOR)
+ row = await store.draft(db, scenario_id, owner_login=who.login)
+ if row is None:
+ raise HTTPException(status_code=404, detail="draft_not_found")
+ try:
+ source = validate(row.body)
+ proposal = await generate(source, body.comment.strip(), require_fact_change=False)
+ row = await store.revise_draft(db, row, proposal)
+ except LlmUnavailable as exc:
+ raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
+ except (GenerationError, ScenarioError) as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+ await audit(who.login, who.role.value, "scenario.draft.ai_revise", row.id,
+ body.comment.strip()[:500])
+ return _draft_out(row)
+
+
@router.post("/drafts/{scenario_id}/validate")
async def validate_draft(
- scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
+ scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
- require(request, Role.INSTRUCTOR)
- row = await store.draft(db, scenario_id)
+ who = require(request, Role.INSTRUCTOR)
+ row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
@@ -107,10 +209,10 @@ async def validate_draft(
@router.post("/drafts/{scenario_id}/approve")
async def approve_draft(
- scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
+ scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
who = require(request, Role.INSTRUCTOR)
- row = await store.draft(db, scenario_id)
+ row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
@@ -122,7 +224,15 @@ async def approve_draft(
@router.get("")
-async def listing() -> list[dict]:
+async def listing(
+ request: Request, db: AsyncSession | None = Depends(scenario_session)
+) -> list[dict]:
+ who = require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
+ owned_ids = (
+ await store.owned_scenario_ids(db, who.login)
+ if who is not None and who.role is Role.INSTRUCTOR
+ else set()
+ )
return [
{
"id": scenario.id,
@@ -131,19 +241,57 @@ async def listing() -> list[dict]:
"level": scenario.level.value,
"topics": scenario.topics,
"modes": scenario.modes,
+ # Преподаватель должен видеть не только название карточки, но и
+ # зафиксированный путь классификатора. ИИ меняет сюжет внутри
+ # этого пути, а не незаметно подменяет код происшествия.
+ "signs": scenario.signs,
+ "incident_code": scenario.ground_truth.incident_code,
"dds": scenario.ground_truth.dds.value if scenario.ground_truth.dds else None,
"ticket": scenario.ticket,
"position": scenario.position,
"ekp_group": (ekp.incident(scenario.ground_truth.incident_code).group
if scenario.ground_truth.incident_code
and ekp.incident(scenario.ground_truth.incident_code) else None),
+ "can_manage": scenario.id in owned_ids,
}
for scenario in store.all_scenarios()
]
+@router.delete("/{scenario_id}")
+async def archive_scenario(
+ scenario_id: str, request: Request,
+ db: AsyncSession | None = Depends(scenario_session),
+) -> dict:
+ """Мягкое удаление: история занятий остаётся целой, сценарий можно вернуть."""
+ who = require(request, Role.INSTRUCTOR)
+ if hub.has_active_scenario(scenario_id):
+ raise HTTPException(status_code=409, detail="scenario_is_used_by_active_session")
+ scenario = await store.archive(db, scenario_id, owner_login=who.login)
+ if scenario is None:
+ raise HTTPException(status_code=404, detail="scenario_not_found")
+ await audit(who.login, who.role.value, "scenario.archive", scenario_id)
+ return {"id": scenario_id, "status": "archived", "title": scenario.title}
+
+
+@router.post("/{scenario_id}/restore")
+async def restore_scenario(
+ scenario_id: str, request: Request,
+ db: AsyncSession | None = Depends(scenario_session),
+) -> dict:
+ who = require(request, Role.INSTRUCTOR)
+ scenario = await store.restore_archived(db, scenario_id, owner_login=who.login)
+ if scenario is None:
+ raise HTTPException(status_code=404, detail="archived_scenario_not_found")
+ await audit(who.login, who.role.value, "scenario.restore", scenario_id)
+ return {"id": scenario_id, "status": "published", "title": scenario.title}
+
+
@router.get("/{scenario_id}")
-async def read(scenario_id: str) -> dict:
+async def read(scenario_id: str, request: Request) -> dict:
+ # Training content is local but not public: anonymous clients must not be
+ # able to enumerate cards or inspect even the trainee-safe scenario body.
+ require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
scenario = store.get(scenario_id)
if scenario is None:
raise HTTPException(status_code=404, detail="scenario_not_found")
diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py
index ffdd88a..5c73194 100644
--- a/backend/app/api/http/sessions.py
+++ b/backend/app/api/http/sessions.py
@@ -8,18 +8,23 @@ from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
-from pydantic import BaseModel, Field
+from fastapi.responses import FileResponse
+from pydantic import BaseModel, Field, field_validator
+from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
+from app.config import get_settings
from app.db import repo
from app.db.base import get_session
+from app.db.models import AuditLog, Score
from app.domain.events import Exercise, SessionMode, SessionReport
from app.scenarios import store
from app.scoring.report import build as build_report
from app.scoring.export import to_csv, to_pdf
from app.domain.roles import Role
from app.session.hub import hub
+from app.voice.recording import recording_path
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
@@ -59,8 +64,11 @@ def _out(session) -> SessionOut:
@router.post("", response_model=SessionOut, status_code=201)
async def create(body: SessionCreate, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
- require(request, Role.INSTRUCTOR)
- group = await repo.ensure_group(db, body.group) if body.group else None
+ who = require(request, Role.INSTRUCTOR)
+ try:
+ group = await repo.ensure_group(db, body.group, owner_login=who.login) if body.group else None
+ except PermissionError as exc:
+ raise HTTPException(status_code=404, detail="group_not_found") from exc
trainee = await repo.ensure_trainee(db, body.trainee, group) if body.trainee else None
session = await repo.create_session(
db,
@@ -68,6 +76,14 @@ async def create(body: SessionCreate, request: Request, db: AsyncSession = Depen
mode=body.mode.value,
trainee_id=trainee.id if trainee else None,
group_id=group.id if group else None,
+ owner_login=who.login,
+ )
+ await audit(
+ who.login,
+ who.role.value,
+ "session.create",
+ str(session.id),
+ f"scenario={session.scenario_id}; mode={session.mode}; attempt={session.attempt}",
)
return _out(session)
@@ -80,6 +96,8 @@ async def read(session_id: UUID, request: Request, db: AsyncSession = Depends(ge
raise HTTPException(status_code=404, detail="session_not_found")
if who.role is Role.TRAINEE and session.trainee_id != who.trainee_id:
raise HTTPException(status_code=403, detail="not_your_session")
+ if who.role is Role.INSTRUCTOR and session.owner_login != who.login:
+ raise HTTPException(status_code=404, detail="session_not_found")
return _out(session)
@@ -102,6 +120,8 @@ async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut
raise HTTPException(status_code=404, detail="session_not_found")
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
raise HTTPException(status_code=403, detail="not_your_session")
+ if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
+ raise HTTPException(status_code=404, detail="session_not_found")
if not state.ended:
raise HTTPException(status_code=409, detail="call_not_ended")
scenario = state.scenario or store.get(state.scenario_id)
@@ -118,7 +138,15 @@ class ScoreOverride(BaseModel):
"""Коррекция оценки преподавателем. Автооценка сохраняется рядом."""
score_final: float = Field(ge=0, le=100)
- comment: str = ""
+ comment: str = Field(min_length=1, max_length=2000)
+
+ @field_validator("comment")
+ @classmethod
+ def comment_must_not_be_blank(cls, comment: str) -> str:
+ cleaned = comment.strip()
+ if not cleaned:
+ raise ValueError("обоснование корректировки обязательно")
+ return cleaned
def _live(session_id: UUID):
@@ -131,8 +159,9 @@ def _live(session_id: UUID):
return state, scenario
-@router.get("/{session_id}/report", response_model=SessionReport)
-async def report(session_id: UUID, request: Request) -> SessionReport:
+async def _report_data(
+ session_id: UUID, request: Request, db: AsyncSession,
+) -> SessionReport:
"""Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки.
Обучающийся открывает только свой разбор: ТЗ запрещает доступ к чужим
@@ -140,20 +169,62 @@ async def report(session_id: UUID, request: Request) -> SessionReport:
занятия, а не по номеру в ссылке.
"""
who = require(request)
- state, scenario = _live(session_id)
- if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
+ try:
+ state, scenario = _live(session_id)
+ except HTTPException as exc:
+ if exc.status_code != 404:
+ raise
+ state = None
+ scenario = None
+ if state is not None and scenario is not None:
+ if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
+ raise HTTPException(status_code=404, detail="session_not_found")
+ if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
+ raise HTTPException(status_code=403, detail="not_your_session")
+ if who.role is Role.TRAINEE and state.exercise is Exercise.CALL and not state.self_assessed:
+ raise HTTPException(status_code=409, detail="self_assessment_required")
+ if state.score is None:
+ raise HTTPException(status_code=409, detail="score_not_ready")
+ return build_report(session_id, state, scenario)
+
+ session = await repo.get_session(db, session_id)
+ if session is None:
+ raise HTTPException(status_code=404, detail="session_not_found")
+ if who.role is Role.TRAINEE and session.trainee_id != who.trainee_id:
raise HTTPException(status_code=403, detail="not_your_session")
- if who.role is Role.TRAINEE and state.exercise is Exercise.CALL and not state.self_assessed:
- raise HTTPException(status_code=409, detail="self_assessment_required")
- if state.score is None:
+ if who.role is Role.INSTRUCTOR and session.owner_login != who.login:
+ raise HTTPException(status_code=404, detail="session_not_found")
+ score = await db.scalar(select(Score).where(Score.session_id == session_id))
+ if score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
- return build_report(session_id, state, scenario)
+ archived = (score.report or {}).get("full_report")
+ if archived is None:
+ raise HTTPException(status_code=409, detail="report_not_archived")
+ data = SessionReport.model_validate(archived)
+ if (who.role is Role.TRAINEE and data.reference_questions
+ and data.self_assessment is None):
+ raise HTTPException(status_code=409, detail="self_assessment_required")
+ return data.model_copy(update={
+ "score_auto": score.score_auto,
+ "score_final": score.score_final,
+ "overridden_by": score.overridden_by,
+ "override_comment": score.override_comment,
+ })
+
+
+@router.get("/{session_id}/report", response_model=SessionReport)
+async def report(
+ session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
+) -> SessionReport:
+ return await _report_data(session_id, request, db)
@router.get("/{session_id}/report.csv")
-async def report_csv(session_id: UUID, request: Request) -> Response:
+async def report_csv(
+ session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
+) -> Response:
"""Те же права и готовность оценки, что у JSON-разбора."""
- data = await report(session_id, request)
+ data = await _report_data(session_id, request, db)
return Response(
content=to_csv(data), media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.csv"'},
@@ -161,9 +232,11 @@ async def report_csv(session_id: UUID, request: Request) -> Response:
@router.get("/{session_id}/report.pdf")
-async def report_pdf(session_id: UUID, request: Request) -> Response:
+async def report_pdf(
+ session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
+) -> Response:
"""Печатный разбор; генерация полностью локальна."""
- data = await report(session_id, request)
+ data = await _report_data(session_id, request, db)
try:
content = to_pdf(data)
except RuntimeError as exc:
@@ -174,31 +247,120 @@ async def report_pdf(session_id: UUID, request: Request) -> Response:
)
+@router.get("/{session_id}/recording.wav")
+async def recording(session_id: UUID, request: Request, db: AsyncSession = Depends(get_session)):
+ """Запись учебного звонка: преподавателю либо владельцу занятия."""
+ who = require(request, Role.INSTRUCTOR, Role.TRAINEE)
+ if get_settings().demo_no_db:
+ state = hub.get(session_id)
+ if state is None:
+ raise HTTPException(status_code=404, detail="session_not_found")
+ owner_id = state.trainee_id
+ owner_login = state.owner_login
+ ended = state.ended
+ else:
+ row = await repo.get_session(db, session_id)
+ if row is None:
+ raise HTTPException(status_code=404, detail="session_not_found")
+ owner_id = row.trainee_id
+ owner_login = row.owner_login
+ ended = row.ended_at is not None
+ if who.role is Role.TRAINEE and (owner_id is None or owner_id != who.trainee_id):
+ raise HTTPException(status_code=403, detail="not_your_recording")
+ if who.role is Role.INSTRUCTOR and owner_login != who.login:
+ raise HTTPException(status_code=404, detail="recording_not_found")
+ if not ended:
+ raise HTTPException(status_code=409, detail="recording_not_ready")
+ path = recording_path(session_id)
+ if not path.is_file():
+ raise HTTPException(status_code=404, detail="recording_not_found")
+ return FileResponse(
+ path,
+ media_type="audio/wav",
+ filename=f"session-{session_id}-recording.wav",
+ )
+
+
@router.patch("/{session_id}/report", response_model=SessionReport)
-async def override(session_id: UUID, body: ScoreOverride, request: Request) -> SessionReport:
+async def override(
+ session_id: UUID,
+ body: ScoreOverride,
+ request: Request,
+ db: AsyncSession = Depends(get_session),
+) -> SessionReport:
"""Тренажёр готовит материал, преподаватель имеет последнее слово.
Администратору сюда нельзя: ТЗ запрещает ему вмешиваться в оценки прямо.
"""
who = require(request, Role.INSTRUCTOR)
- state, scenario = _live(session_id)
- if state.score is None:
+ session = await repo.get_session(db, session_id)
+ if session is None:
+ raise HTTPException(status_code=404, detail="session_not_found")
+ if session.owner_login != who.login:
+ raise HTTPException(status_code=404, detail="session_not_found")
+ state = hub.get(session_id)
+ live_ready = state is not None and state.score is not None
+ scenario = (state.scenario or store.get(state.scenario_id)) if live_ready else None
+ if live_ready and scenario is None:
+ raise HTTPException(status_code=409, detail="scenario_not_found")
+ score = await db.scalar(select(Score).where(Score.session_id == session_id))
+ if score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
- state.score = {
- **state.score,
- "score_final": body.score_final,
- "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}",
- )
- return build_report(session_id, state, scenario)
+ archived = (score.report or {}).get("full_report")
+ if not live_ready and archived is None:
+ raise HTTPException(status_code=409, detail="report_not_archived")
+
+ # Persist score and audit entry in the same request transaction: the API
+ # must not report success if either durable record failed to commit.
+ score.score_final = body.score_final
+ score.overridden_by = who.login
+ score.override_comment = body.comment
+ report_payload = dict(score.report or {})
+ if archived is not None:
+ archived_payload = dict(archived)
+ archived_payload.update({
+ "score_auto": score.score_auto,
+ "score_final": body.score_final,
+ "overridden_by": who.login,
+ "override_comment": body.comment,
+ })
+ report_payload["full_report"] = archived_payload
+ score.report = report_payload
+ db.add(AuditLog(
+ actor=who.login,
+ role=who.role.value,
+ action="score.override",
+ object_id=str(session_id),
+ detail=f"{score.score_auto} → {body.score_final}: {body.comment}"[:2000],
+ ))
+ await db.commit()
+
+ if live_ready:
+ assert state is not None and state.score is not None and scenario is not None
+ state.score = {
+ **state.score,
+ "score_final": body.score_final,
+ "overridden_by": who.login,
+ "override_comment": body.comment,
+ }
+ state.score["full_report"] = {
+ **state.score.get("full_report", {}),
+ "score_auto": score.score_auto,
+ "score_final": body.score_final,
+ "overridden_by": who.login,
+ "override_comment": body.comment,
+ }
+ result = build_report(session_id, state, scenario)
+ else:
+ # Historical/archived sessions no longer have a live object in the hub.
+ # Keep score correction available from the instructor's report history.
+ result = SessionReport.model_validate(archived).model_copy(update={
+ "score_auto": score.score_auto,
+ "score_final": body.score_final,
+ "overridden_by": who.login,
+ "override_comment": body.comment,
+ })
+ return result
@router.get("", response_model=list[SessionOut])
@@ -217,11 +379,13 @@ async def listing(
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
trainee = who.trainee_id
+ owner_login = who.login if who.role is Role.INSTRUCTOR else None
rows = await repo.history(
db,
trainee_id=trainee,
group_id=group,
mode=mode.value if mode else None,
+ owner_login=owner_login,
since=since,
limit=limit,
)
diff --git a/backend/app/api/http/trainees.py b/backend/app/api/http/trainees.py
index 882afa0..050cea8 100644
--- a/backend/app/api/http/trainees.py
+++ b/backend/app/api/http/trainees.py
@@ -8,24 +8,77 @@
from datetime import datetime
from uuid import UUID
-from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
-from sqlalchemy import select
+from sqlalchemy import exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
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, get_sessionmaker
-from app.db.models import Group, Score, Session, Trainee
+from app.db.models import Group, Score, Session, Trainee, User
+from app.scoring.export import certificate_pdf
+from app.voice.recording import recording_path
router = APIRouter(prefix="/api/trainees", tags=["trainees"])
+@router.get("/{trainee_id}/certificate.pdf")
+async def certificate(
+ trainee_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
+) -> Response:
+ who = require(request)
+ if who.role is Role.TRAINEE and who.trainee_id != trainee_id:
+ raise HTTPException(status_code=403, detail="not_your_certificate")
+ if who.role is Role.INSTRUCTOR:
+ owns_trainee = await db.scalar(
+ select(Session.id)
+ .where(Session.trainee_id == trainee_id, Session.owner_login == who.login)
+ .limit(1)
+ )
+ if owns_trainee is None:
+ raise HTTPException(status_code=404, detail="trainee_not_found")
+ trainee = await db.get(Trainee, trainee_id)
+ if trainee is None:
+ raise HTTPException(status_code=404, detail="trainee_not_found")
+ group = await db.get(Group, trainee.group_id) if trainee.group_id else None
+ if who.role is Role.INSTRUCTOR and group is not None and group.owner_login != who.login:
+ group = None
+ scores_query = (
+ select(func.count(Score.id), func.avg(Score.score_final), func.max(Score.created_at))
+ .join(Session, Session.id == Score.session_id)
+ .where(Session.trainee_id == trainee_id)
+ )
+ if who.role is Role.INSTRUCTOR:
+ scores_query = scores_query.where(Session.owner_login == who.login)
+ result = await db.execute(scores_query)
+ attempts, average_score, completed_at = result.one()
+ if not attempts:
+ raise HTTPException(status_code=409, detail="no_scored_attempts")
+ try:
+ content = certificate_pdf(
+ trainee_name=trainee.name,
+ trainee_id=trainee.id,
+ group_name=group.name if group else None,
+ attempts=int(attempts),
+ average_score=float(average_score),
+ issued_at=completed_at.date().isoformat(),
+ )
+ except RuntimeError as exc:
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
+ return Response(
+ content=content,
+ media_type="application/pdf",
+ headers={"Content-Disposition": f'attachment; filename="trainee-{trainee_id}-certificate.pdf"'},
+ )
+
+
class TraineeOut(BaseModel):
id: UUID
name: str
group: str | None = None
+ service: str | None = None
class AttemptOut(BaseModel):
@@ -40,6 +93,7 @@ class AttemptOut(BaseModel):
facts_required: int | None = None
hints: int | None = None
codes: dict[str, int] = {}
+ recording_available: bool = False
class DeltaOut(BaseModel):
@@ -65,14 +119,39 @@ class ProfileOut(BaseModel):
async def listing(request: Request) -> list[TraineeOut]:
"""Список курсантов — преподавателю и администратору: обучающемуся он
не нужен, а чужие фамилии из него видны."""
- require(request, Role.INSTRUCTOR, Role.ADMIN)
+ who = require(request, Role.INSTRUCTOR, Role.ADMIN)
if get_settings().demo_no_db:
- return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант")]
+ return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант", service="Служба 101")]
async with get_sessionmaker()() as db:
- rows = await db.execute(
- select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True)
+ query = (
+ select(Trainee, Group.name, Group.owner_login, User.service)
+ .join(Group, Group.id == Trainee.group_id, isouter=True)
+ .join(User, User.trainee_id == Trainee.id, isouter=True)
)
- return [TraineeOut(id=trainee.id, name=trainee.name, group=group) for trainee, group in rows]
+ if who.role is Role.INSTRUCTOR:
+ has_owned_attempt = exists(
+ select(Session.id).where(
+ Session.trainee_id == Trainee.id,
+ Session.owner_login == who.login,
+ )
+ )
+ query = query.where(
+ or_(
+ Trainee.group_id.is_(None),
+ Group.owner_login == who.login,
+ has_owned_attempt,
+ )
+ )
+ rows = await db.execute(query)
+ return [
+ TraineeOut(
+ id=trainee.id,
+ name=trainee.name,
+ group=(group if who.role is not Role.INSTRUCTOR or group_owner == who.login else None),
+ service=service,
+ )
+ for trainee, group, group_owner, service in rows
+ ]
@router.get("/{trainee_id}/profile", response_model=ProfileOut)
@@ -82,17 +161,30 @@ async def profile(
who = require(request)
if who.role is Role.TRAINEE and who.trainee_id != trainee_id:
raise HTTPException(status_code=403, detail="not_your_profile")
+ if who.role is Role.INSTRUCTOR:
+ owns_trainee = await db.scalar(
+ select(Session.id)
+ .where(Session.trainee_id == trainee_id, Session.owner_login == who.login)
+ .limit(1)
+ )
+ if owns_trainee is None:
+ raise HTTPException(status_code=404, detail="trainee_not_found")
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
group = await db.get(Group, trainee.group_id) if trainee.group_id else None
+ if who.role is Role.INSTRUCTOR and group is not None and group.owner_login != who.login:
+ group = None
- rows = await db.execute(
+ attempts_query = (
select(Session, Score)
.join(Score, Score.session_id == Session.id, isouter=True)
.where(Session.trainee_id == trainee_id)
.order_by(Session.created_at)
)
+ if who.role is Role.INSTRUCTOR:
+ attempts_query = attempts_query.where(Session.owner_login == who.login)
+ rows = await db.execute(attempts_query)
attempts: list[AttemptOut] = []
competency_sums: dict[str, list[float]] = {}
for session, score in rows:
@@ -110,6 +202,7 @@ async def profile(
facts_required=summary.get("facts_required"),
hints=summary.get("hints"),
codes=summary.get("codes", {}),
+ recording_available=recording_path(session.id).is_file(),
)
)
for item in (score.report or {}).get("competencies", []) if score else []:
diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py
index 6fb6d7a..92c3adc 100644
--- a/backend/app/api/ws/call.py
+++ b/backend/app/api/ws/call.py
@@ -33,14 +33,15 @@ from app.domain.events import (
)
from app.domain.events import BgStart
from app.scenarios import store
-from app.session.finish import finish, release_score
+from app.session.finish import finish, refresh_archived_report, release_score
from app.api.auth import principal_of
from app.domain.roles import Role
from app.session.hub import hub
from app.session.state import now_utc
from app.domain.kio import ResponseStatus
-from app.voice.models import get_voice_models
+from app.voice.models import TTS_RATE, get_voice_models
from app.voice.pipeline import VoiceSession
+from app.voice.recording import start_recording
log = logging.getLogger(__name__)
router = APIRouter()
@@ -62,6 +63,8 @@ def _on_audio(session_id: UUID, state, frame: bytes) -> None:
session_id, len(frame), FRAME_BYTES)
return
state.audio_frames += 1
+ if state.recorder is not None:
+ state.recorder.add_pcm(frame, sample_rate=16_000)
if state.voice is not None:
state.voice.feed(frame)
if state.audio_frames % FRAMES_PER_LOG == 0:
@@ -138,6 +141,8 @@ async def _handle(session_id: UUID, state, event) -> None:
hub.to_observers(session_id, state.snapshot())
if hub.journal:
await hub.journal.session_started(session_id, state.started_at)
+ if state.recorder is None:
+ state.recorder = start_recording(session_id)
_start_voice(session_id, state)
case "kio.patch":
@@ -220,6 +225,7 @@ async def _handle(session_id: UUID, state, event) -> None:
await hub.journal.self_assessment(
session_id, event.missed, event.comment, now_utc()
)
+ await refresh_archived_report(session_id, state)
# Оценка могла быть готова раньше самооценки — теперь её можно отдать.
await release_score(session_id, state)
@@ -237,6 +243,8 @@ async def _handle(session_id: UUID, state, event) -> None:
)
await finish(session_id, state)
+ await hub.checkpoint(session_id)
+
def _start_voice(session_id: UUID, state) -> None:
"""Голос включается, когда курсант снял трубку: звонящий сразу кричит первую реплику."""
@@ -244,13 +252,18 @@ def _start_voice(session_id: UUID, state) -> None:
scenario = store.get(state.scenario_id)
if models is None or scenario is None or state.voice is not None:
return
+ def send_audio(pcm: bytes) -> None:
+ if state.recorder is not None:
+ state.recorder.add_pcm(pcm, sample_rate=TTS_RATE)
+ hub.to_trainee(session_id, pcm)
+
state.voice = VoiceSession(
session_id=session_id,
state=state,
models=models,
send_event=lambda event: hub.to_trainee(session_id, event),
send_observer=lambda event: hub.to_observers(session_id, event),
- send_audio=lambda pcm: hub.to_trainee(session_id, pcm),
+ send_audio=send_audio,
journal=hub.journal,
)
if scenario.background:
diff --git a/backend/app/api/ws/control.py b/backend/app/api/ws/control.py
index 62576a2..cffe1ce 100644
--- a/backend/app/api/ws/control.py
+++ b/backend/app/api/ws/control.py
@@ -8,43 +8,43 @@
преподавателю, уходит на его же сокет `observe`.
"""
+import asyncio
import logging
import math
-from uuid import UUID, uuid4
+from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
-from app.domain.events import (
- CallEndReason,
- CallEnded,
- ErrorEvent,
- ErrorKind,
- ScoreReady,
- CallIncoming,
- CardBriefing,
- Exercise,
- StationState,
- InstructorToServer,
- InstructorNoteShown,
- ModeSet,
- ReferenceStarted,
- SessionEnded,
-)
-import asyncio
-
-from app.dialog.factory import build_caller
+from app.api.auth import audit, principal_of
from app.dialog.director import apply as apply_directive
from app.dialog.director import mood_of
+from app.dialog.factory import build_caller
from app.dialog.persona import PersonaState
from app.dialog.runtime import get_embedder
from app.dialog.slots import SlotMachine
-from app.scenarios import store
-from app.api.auth import audit, principal_of
+from app.domain.events import (
+ CallEnded,
+ CallEndReason,
+ CallIncoming,
+ CardBriefing,
+ ErrorEvent,
+ ErrorKind,
+ Exercise,
+ InstructorNoteShown,
+ InstructorToServer,
+ ModeSet,
+ ReferenceStarted,
+ ScoreReady,
+ SessionEnded,
+ StationState,
+)
from app.domain.roles import Role
+from app.domain.timers import TimerCode
+from app.scenarios import store
+from app.session.dds import prepare_queue
from app.session.hub import hub
from app.session.state import SessionState, now_utc
-from app.session.dds import prepare_card
from app.voice.models import get_voice_models
from app.voice.pipeline import FILLERS, prefetch
@@ -93,10 +93,10 @@ async def _start(session_id: UUID, event, who=None) -> None:
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:
+ if not scenario_ids or scenario_ids[0] != event.scenario_id or len(scenario_ids) > 96:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID,
- message="Очередь ДДС должна начинаться с scenario_id и содержать не более 10 карточек",
+ message="Очередь ДДС должна начинаться с scenario_id и содержать не более 96 карточек",
))
return
scenarios = [store.get(scenario_id) for scenario_id in scenario_ids]
@@ -110,10 +110,19 @@ async def _start(session_id: UUID, event, who=None) -> None:
attempt = 1
recorded_trainee_id = event.trainee_id
+ recorded_service = None
if hub.journal:
- attempt, recorded_trainee_id = await hub.journal.start_lesson(
- session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id
- )
+ try:
+ attempt, recorded_trainee_id, recorded_service = await hub.journal.start_lesson(
+ session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id,
+ owner_login=who.login if who is not None else None,
+ )
+ except PermissionError:
+ hub.to_observers(session_id, ErrorEvent(
+ code=ErrorKind.FORBIDDEN,
+ message="Занятие создано другим преподавателем",
+ ))
+ return
# Занятие собирается целиком и только потом регистрируется: иначе
# наблюдатель, подключившийся в эту щель, увидит полусобранное состояние
@@ -124,23 +133,35 @@ async def _start(session_id: UUID, event, who=None) -> None:
scenario_title=scenario.title,
level=scenario.level.value,
mode=event.mode,
+ owner_login=who.login if who is not None else None,
exercise=event.exercise,
handoff_to_dds=event.handoff_to_dds,
scenario=scenario.model_copy(deep=True),
required_fields=scenario.required_fields,
trainee_name=event.trainee,
trainee_id=recorded_trainee_id,
+ dds_service=recorded_service or event.dds_service,
attempt=attempt,
+ criteria=event.criteria,
)
+ state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
if event.exercise is Exercise.CALL:
embedder = get_embedder()
if embedder is not None:
state.slots = SlotMachine(state.scenario, embedder)
state.persona = PersonaState(state.scenario.persona)
- state.caller = build_caller(scenario.id)
+ state.caller = build_caller(
+ scenario.id,
+ use_pregenerated=scenario.tree.pregenerated,
+ )
elif event.exercise is Exercise.DDS:
state.dds_scenarios = [item.model_copy(deep=True) for item in scenarios]
- prepare_card(state, state.dds_scenarios[0])
+ prepare_queue(
+ state,
+ state.dds_scenarios,
+ arrival_interval_seconds=event.dds_arrival_interval_seconds,
+ max_waiting=event.dds_max_waiting,
+ )
state.started_at = state.dispatched_at
else:
state.started_at = now_utc()
@@ -155,13 +176,19 @@ 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")
+ await hub.checkpoint(session_id)
hub.start_ticker(session_id)
if who is not None:
# Запуск занятия меняет чужой результат — значит попадает в журнал
# аудита (ТЗ, хранение не менее шести месяцев).
- asyncio.create_task(
- audit(who.login, who.role.value, "lesson.start", str(session_id),
- f"{scenario.id}, режим {event.mode.value}, курсант {event.trainee}")
+ # Сохраняем до продолжения сценария, чтобы завершение процесса не
+ # потеряло событие. ФИО курсанта в долгоживущий журнал не дублируем.
+ await audit(
+ who.login,
+ who.role.value,
+ "lesson.start",
+ str(session_id),
+ f"{scenario.id}, режим {event.mode.value}",
)
if event.exercise is Exercise.CALL:
@@ -191,18 +218,22 @@ async def _stop(session_id: UUID) -> None:
return
state.ended_at = now_utc()
state.end_reason = CallEndReason.INSTRUCTOR
+ if state.voice is not None:
+ await state.voice.close()
hub.stop_ticker(session_id)
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
- if state.exercise in {Exercise.DDS, Exercise.CARD}:
- from app.session.finish import finish
+ from app.session.finish import finish
+ if state.exercise in {Exercise.DDS, Exercise.CARD}:
if state.exercise is Exercise.DDS or state.handoff_to_dds and state.dispatched_card is not None:
hub.to_station(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
else:
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.INSTRUCTOR))
- await finish(session_id, state)
if state.exercise is Exercise.DDS or state.handoff_to_dds and state.dispatched_card is not None:
hub.to_station(session_id, ScoreReady(session_id=session_id))
+ else:
+ hub.to_trainee(session_id, CallEnded(reason=CallEndReason.INSTRUCTOR))
+ await finish(session_id, state)
if hub.journal:
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value)
@@ -236,6 +267,21 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
)
continue
+ existing_state = hub.get(session_id)
+ if event.type == "scenario.start":
+ if existing_state is not None:
+ hub.to_observers(session_id, ErrorEvent(
+ code=ErrorKind.FORBIDDEN,
+ message="Для нового занятия создайте новый номер сессии",
+ ))
+ continue
+ elif existing_state is None or existing_state.owner_login != who.login:
+ hub.to_observers(session_id, ErrorEvent(
+ code=ErrorKind.FORBIDDEN,
+ message="Это занятие принадлежит другому преподавателю",
+ ))
+ continue
+
match event.type:
case "scenario.start":
await _start(session_id, event, who)
@@ -315,7 +361,7 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
if result.needs_network:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.DIRECTIVE_NEEDS_NETWORK,
- message="Свободный текст требует LLM: офлайн доступны только кнопки",
+ message="Свободный текст требует локально запущенной модели; без неё доступны кнопки",
))
continue
state.directives.append(event.directive)
@@ -338,5 +384,6 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
message=f"{event.type} ещё не реализовано",
),
)
+ await hub.checkpoint(session_id)
except WebSocketDisconnect:
return
diff --git a/backend/app/api/ws/station.py b/backend/app/api/ws/station.py
index 831cd66..1633bdc 100644
--- a/backend/app/api/ws/station.py
+++ b/backend/app/api/ws/station.py
@@ -10,23 +10,41 @@
import asyncio
import logging
+import re
from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
-from app.domain.events import (
- CallEndReason, ErrorEvent, ErrorKind, Exercise, PhoneReport, ScoreReady,
- SessionEnded, StationState, StationToServer,
-)
-from app.domain.statuses import PRIMARY, PhoneReportRecord, ServiceStatus, StatusError, current
from app.api.auth import principal_of
+from app.domain.events import (
+ CallEndReason,
+ ErrorEvent,
+ ErrorKind,
+ Exercise,
+ PhoneLine,
+ PhoneReport,
+ ScoreReady,
+ SessionEnded,
+ StationState,
+ StationToServer,
+)
from app.domain.roles import Role
+from app.domain.statuses import (
+ PRIMARY,
+ SERVICE_STATUS_LABELS,
+ PhoneCallPending,
+ PhoneLineRecord,
+ PhoneReportRecord,
+ ServiceStatus,
+ StatusError,
+ current,
+)
+from app.scoring.grammar import assess
+from app.session.dds import deliver_due_cards
+from app.session.finish import finish, score_current_dds
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()
@@ -34,24 +52,85 @@ router = APIRouter()
_adapter = TypeAdapter(StationToServer)
REPORT_PHASES = ("dispatched", "arrived", "working", "completed")
-REPORT_TEXT = {
- "dispatched": "Бригада выехала к месту происшествия.",
- "arrived": "Бригада прибыла на место происшествия.",
- "working": "Бригада приступила к проведению работ.",
- "completed": "Работы завершены, бригада освобождена.",
+REQUIRED_STATUS = {
+ "dispatched": ServiceStatus.ACCEPTED,
+ "arrived": ServiceStatus.RESPONDING,
+ "working": ServiceStatus.ARRIVED,
+ "completed": ServiceStatus.WORKING,
}
-REPORT_FOR_STATUS = {
- ServiceStatus.RESPONDING: "dispatched",
- ServiceStatus.ARRIVED: "arrived",
- ServiceStatus.WORKING: "working",
- ServiceStatus.COMPLETED: "completed",
+STATUS_AT_OR_AFTER = {
+ "dispatched": {ServiceStatus.ACCEPTED, ServiceStatus.RESPONDING,
+ ServiceStatus.ARRIVED, ServiceStatus.WORKING},
+ "arrived": {ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING},
+ "working": {ServiceStatus.ARRIVED, ServiceStatus.WORKING},
+ "completed": {ServiceStatus.WORKING, ServiceStatus.COMPLETED},
}
-
-
def _error(session_id: UUID, message: str) -> None:
hub.to_station(session_id, ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=message))
+def _line(session_id: UUID, state, speaker: str, text: str) -> None:
+ call = state.phone_pending
+ if call is None:
+ return
+ line = PhoneLineRecord(service=call.service, crew=call.crew,
+ speaker=speaker, text=text, at=now_utc())
+ state.phone_lines.append(line)
+ state.dds_log.append((f"phone.line.{speaker}", line.at, f"{call.crew}: {text}"))
+ hub.to_station(session_id, PhoneLine(**line.model_dump()))
+
+
+def _address_matches(expected: str | None, supplied: str) -> bool:
+ """Не даём сообщить бригаде другой номер дома/другую улицу."""
+ if not expected:
+ return bool(supplied.strip())
+ numbers = re.findall(r"\d+", expected)
+ spoken_numbers = re.findall(r"\d+", supplied)
+ words = re.findall(r"[а-яё]{4,}", expected.casefold())
+ spoken_words = re.findall(r"[а-яё]{4,}", supplied.casefold())
+ return (all(number in spoken_numbers for number in numbers)
+ and any(word[:4] == spoken[:4] for word in words for spoken in spoken_words))
+
+
+def _incident_matches(state, supplied: str) -> bool:
+ text = supplied.casefold()
+ # Описание часто начинается с адреса: его нельзя считать совпадением
+ # характера происшествия. Берём только название сценария и признаки ЕКП.
+ source = " ".join((state.scenario_title, " ".join(state.kio.signs)))
+ anchors = {word[:4] for word in re.findall(r"[а-яё]{5,}", source.casefold())}
+ return len(supplied.strip()) >= 8 and any(anchor in text for anchor in anchors)
+
+
+def _has_purpose(text: str, stems: tuple[str, ...]) -> bool:
+ normalized = text.casefold()
+ return len(text.strip()) >= 8 and any(stem in normalized for stem in stems)
+
+
+def _report_text(phase: str, crew: str, address: str) -> str:
+ match phase:
+ case "dispatched":
+ return f"{crew}: вызов по адресу {address} принят, выезжаем. О прибытии доложу."
+ case "arrived":
+ return f"{crew}: прибыли по адресу {address}. Уточняем обстановку на месте."
+ case "working":
+ return f"{crew}: обстановка уточнена, приступили к работам. Сообщим о завершении."
+ case _:
+ return f"{crew}: работы завершены. Дальнейшая помощь от нашей бригады не требуется."
+
+
+def _finish_phone_call(session_id: UUID, state) -> None:
+ call = state.phone_pending
+ assert call is not None
+ text = _report_text(call.phase, call.crew, state.dispatched_card.address or "из карточки")
+ _line(session_id, state, "crew", text)
+ report = PhoneReportRecord(service=call.service, crew=call.crew,
+ phase=call.phase, text=text, at=now_utc())
+ state.phone_reports.append(report)
+ state.dds_log.append(("phone.report", now_utc(), f"{call.crew}: {call.phase}"))
+ state.phone_pending = None
+ hub.to_station(session_id, PhoneReport(**report.model_dump()))
+
+
async def _finish_dds(session_id: UUID, state) -> None:
state.ended_at = now_utc()
state.end_reason = CallEndReason.COMPLETE
@@ -75,22 +154,15 @@ async def _handle(session_id: UUID, state, event) -> None:
# диспетчер один и выбирать службу не из чего.
state.on_event("card.ack")
state.dds_log.append(("card.ack", now_utc(), None))
- services = state.notified_services()
+ services = state.managed_services()
if services:
try:
state.set_service_status(services[0], ServiceStatus.ACCEPTED)
except StatusError:
pass # статус уже стоит: повторное нажатие ничего не меняет
case "card.status":
- if event.service not in state.notified_services():
- _error(session_id, "Служба отсутствует в списке оповещения карточки")
- return
- phase = REPORT_FOR_STATUS.get(event.status)
- if (state.exercise is Exercise.DDS or state.handoff_to_dds) and phase is not None and not any(
- report.service == event.service and report.phase == phase
- for report in state.phone_reports
- ):
- _error(session_id, f"Статус «{event.status.value}» требует доклада бригады")
+ if event.service not in state.managed_services():
+ _error(session_id, "Можно менять статусы только своей ДДС")
return
try:
state.set_service_status(
@@ -116,33 +188,73 @@ async def _handle(session_id: UUID, state, event) -> None:
):
_error(session_id, "После первого доклада бригаду этой службы менять нельзя")
return
+ if state.phone_pending is not None:
+ _error(session_id, "Завершите текущий разговор перед сменой бригады")
+ 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":
+ if state.phone_pending is not None:
+ _error(session_id, "Разговор уже идёт: передайте сведения или завершите звонок")
+ return
crew = state.crew_selected
service = state.crew_service(crew) if crew else None
if service is None:
_error(session_id, "Сначала выберите бригаду")
return
- if current(state.status_log, service) not in {
- ServiceStatus.ACCEPTED, ServiceStatus.RESPONDING,
- ServiceStatus.ARRIVED, ServiceStatus.WORKING,
- }:
- _error(session_id, "Сначала примите карточку этой службы")
- return
previous = [report for report in state.phone_reports if report.service == service]
if len(previous) >= len(REPORT_PHASES):
_error(session_id, "Все доклады этой бригады уже получены")
return
phase = REPORT_PHASES[len(previous)]
- report = PhoneReportRecord(
- service=service, crew=crew, phase=phase,
- text=REPORT_TEXT[phase], at=now_utc(),
- )
- state.phone_reports.append(report)
+ required = REQUIRED_STATUS[phase]
+ if current(state.status_log, service) not in STATUS_AT_OR_AFTER[phase]:
+ _error(session_id, f"Перед звонком нужен статус «{SERVICE_STATUS_LABELS[required]}» этой службы")
+ return
+ state.phone_pending = PhoneCallPending(service=service, crew=crew, phase=phase)
state.dds_log.append(("phone.dial", now_utc(), crew))
- hub.to_station(session_id, PhoneReport(**report.model_dump()))
+ greeting = (f"{crew}, старший группы на связи. Назовите адрес, характер происшествия "
+ "и что требуется от бригады."
+ if phase == "dispatched" else
+ f"{crew}, старший группы на связи. Слушаю ваш запрос по карточке.")
+ _line(session_id, state, "crew", greeting)
+ case "phone.brief":
+ call = state.phone_pending
+ if call is None or call.phase != "dispatched":
+ _error(session_id, "Сначала соединитесь со старшим группы для передачи вызова")
+ return
+ if not _address_matches(state.dispatched_card.address, event.address):
+ _error(session_id, "Проверьте адрес: улица и номер дома должны совпадать с карточкой")
+ return
+ if not _incident_matches(state, event.incident):
+ _error(session_id, "Уточните характер происшествия по данным карточки")
+ return
+ if not _has_purpose(event.request, ("выезд", "выех", "направ", "прибыт",
+ "реагир", "помощ", "подтверд", "долож")):
+ _error(session_id, "Сформулируйте задачу: выезд, помощь или доклад бригады")
+ return
+ _line(session_id, state, "dispatcher", f"Адрес: {event.address.strip()}. "
+ f"Происшествие: {event.incident.strip()}. {event.request.strip()}")
+ _finish_phone_call(session_id, state)
+ case "phone.check":
+ call = state.phone_pending
+ if call is None or call.phase == "dispatched":
+ _error(session_id, "Сначала передайте вызов, затем запросите обстановку")
+ return
+ if not _has_purpose(event.text, ("обстанов", "статус", "прибыл", "доех",
+ "выех", "работ", "заверш", "мест",
+ "ход", "сообщ", "долож", "уточн")):
+ _error(session_id, "Спросите обстановку, прибытие или ход работ по карточке")
+ return
+ _line(session_id, state, "dispatcher", event.text.strip())
+ _finish_phone_call(session_id, state)
+ case "phone.hangup":
+ if state.phone_pending is None:
+ _error(session_id, "Нет активного разговора")
+ return
+ state.dds_log.append(("phone.hangup", now_utc(), state.phone_pending.crew))
+ state.phone_pending = None
case "card.reply":
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.dispatched_card or (
event.card_id != state.dispatched_card.card_id
@@ -150,7 +262,18 @@ async def _handle(session_id: UUID, state, event) -> None:
_error(session_id, "Ответ относится не к текущей карточке")
return
state.reply_text = event.text
+ state.reply_grammar = await assess(event.text)
state.reply_log.append((now_utc(), event.text))
+ case "card.open":
+ if state.exercise is not Exercise.DDS or not state.activate_dds_card(event.card_id):
+ _error(session_id, "Карточка отсутствует в текущей очереди")
+ return
+ hub.to_station(session_id, state.card_received_event())
+ # CardReceived carries the contents, while StationState carries
+ # the status journal and current queue. Send both on every switch
+ # so the newly opened card cannot briefly inherit the previous
+ # card's status snapshot until the next periodic tick.
+ hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
case "card.next":
if state.exercise is not Exercise.DDS or not state.dispatched_card or (
event.card_id != state.dispatched_card.card_id
@@ -161,11 +284,32 @@ async def _handle(session_id: UUID, state, event) -> None:
_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])
+ completed_index = state.dds_card_index
+ state.dds_live_cards = [
+ item for item in state.dds_live_cards if item.card_id != event.card_id
+ ]
+ remaining = sorted(state.dds_live_cards, key=lambda item: item.original_index)
+ if remaining:
+ following = next(
+ (item for item in remaining if item.original_index > completed_index),
+ remaining[0],
+ )
+ state.activate_dds_card(following.card_id, capture=False)
hub.to_station(session_id, state.card_received_event())
else:
+ # Keep the lesson alive if selected cards have not arrived yet.
+ # The next delivery may become the active card immediately or
+ # after its configured interval; no completed card is reused.
+ state.dds_active_card_id = None
+ state.dispatched_card = None
+ state.dispatched_at = None
+ state.dds_card_index = completed_index
+ active_before_delivery = state.dds_active_card_id
+ deliver_due_cards(state)
+ if state.dds_active_card_id and state.dds_active_card_id != active_before_delivery:
+ hub.to_station(session_id, state.card_received_event())
+ hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
+ if not state.dds_live_cards and len(state.dds_completed) >= len(state.dds_scenarios):
await _finish_dds(session_id, state)
case "station.finish":
if state.exercise is not Exercise.DDS and not state.handoff_to_dds:
@@ -173,9 +317,11 @@ async def _handle(session_id: UUID, state, event) -> None:
return
await _finish_dds(session_id, state)
case "card.bounce":
- # Карточка вернулась: в разборе это E6 с конкретной причиной.
- state.bounced_fields = list(event.missing_fields)
- state.dds_log.append(("card.bounce", now_utc(), event.comment))
+ _error(
+ session_id,
+ "ДДС не проверяет заполнение карточки: замечания передаёт служба контроля 112",
+ )
+ return
case "zone.decision":
state.on_event("zone.decision")
state.dds_log.append(("zone.decision", now_utc(), "в зоне" if event.in_zone else "не в зоне"))
@@ -186,6 +332,8 @@ async def _handle(session_id: UUID, state, event) -> None:
state.on_event("crew.arrived")
state.kio = state.kio.model_copy(update={"arrival_at": event.at})
state.dds_log.append(("crew.arrived", now_utc(), None))
+ state.capture_active_dds()
+ await hub.checkpoint(session_id)
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
hub.to_observers(session_id, state.snapshot())
diff --git a/backend/app/config.py b/backend/app/config.py
index f7143c1..8ed158b 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -19,36 +19,63 @@ class Settings(BaseSettings):
# Голосовой контур
models_dir: str = "models"
- stt_model: str = "gigaam-v3-rnnt"
+ stt_model: str = "whisper-small"
+ # Локальный файл GGML Whisper small для whisper.cpp. Имя модели из сети
+ # не принимается: учебный контур не скачивает веса при запуске.
+ whisper_model_path: str = "models/whisper-small"
+ whisper_server_url: str = "http://127.0.0.1:18082"
endpointing_ms: int = 600
mic_sample_rate: int = 16_000
tts_sample_rate: int = 24_000
offline: bool = False
- # Голосовой контур: грузит ~5 с моделей при старте. В тестах выключен.
- voice_enabled: bool = True
+ # STT/голос не входят в MVP ДДС; включаются лишь для отдельного эксперимента.
+ voice_enabled: bool = False
+ # Запись хранится локально и отдаётся только через авторизованный API.
+ record_calls: bool = True
+ recordings_dir: str = "recordings"
+ # Автокопирование полного стенда. Интервал по умолчанию ровно сутки;
+ # небольшой grace нужен только для отображения просрочки в мониторинге.
+ backup_interval_seconds: int = 86_400
+ backup_retry_seconds: int = 300
+ backup_keep: int = 14
# LLM. Провайдер меняется значением, не кодом (docs/arch/STACK.md).
# Имена COMPAT_MODEL_* принимаются тоже — так их выставляет командный сниппет.
- llm_provider: str = "openai_compatible"
+ llm_provider: str = "local"
llm_base_url: str = Field(
- default="", validation_alias=AliasChoices("llm_base_url", "compat_model_url")
+ default="http://127.0.0.1:18080/v1",
+ validation_alias=AliasChoices("llm_base_url", "compat_model_url"),
)
llm_api_key: str = Field(
default="", validation_alias=AliasChoices("llm_api_key", "compat_model_api_key"),
repr=False,
)
llm_model_caller: str = Field(
- default="", validation_alias=AliasChoices("llm_model_caller", "compat_model_name")
+ default="Qwen3-1.7B", validation_alias=AliasChoices("llm_model_caller", "compat_model_name")
)
llm_model_judge: str = Field(
default="", validation_alias=AliasChoices("llm_model_judge", "compat_model_name")
)
judge_temperature: float = 0.0
+ # Vikhr — отдельный русскоязычный контрольный профиль диалога. Никакая
+ # модель не решает, какие факты раскрыть, как маршрутизировать карточку или
+ # какой балл поставить: это делает протокол и оценка в коде.
+ llm_control_base_url: str = "http://127.0.0.1:18081/v1"
+ llm_model_control: str = "Vikhr-1B"
+ grammar_llm_enabled: bool = False
+ dialogue_model_mode: str = "dialogue" # dialogue | russian_control
+ # Docker Desktop даёт контейнеру специальное имя хоста. Оно разрешается
+ # только явным флагом: обычный OFFLINE по-прежнему принимает лишь literal
+ # loopback и не может незаметно уйти во внешний API.
+ allow_docker_host_models: bool = False
# Вход и роли (lct-23). `session_secret` обязателен на рабочем стенде:
# значение по умолчанию годится только для разработки, и администратор
# видит в состоянии сервисов, что оно не заменено.
session_secret: str = "dev-secret-поменять-на-стенде"
+ # В TLS-профиле cookie уходит только по HTTPS/WSS. Для нативной разработки
+ # на loopback флаг выключен, иначе браузер не сможет войти по HTTP.
+ secure_cookies: bool = False
#: Вход без пароля для `make lesson` и тестов. На стенде выключен.
dev_auth_bypass: bool = False
diff --git a/backend/app/db/migrations/versions/a4c9d1e2f3b4_learning_materials.py b/backend/app/db/migrations/versions/a4c9d1e2f3b4_learning_materials.py
new file mode 100644
index 0000000..d2375b5
--- /dev/null
+++ b/backend/app/db/migrations/versions/a4c9d1e2f3b4_learning_materials.py
@@ -0,0 +1,63 @@
+"""учебные материалы и назначения
+
+Revision ID: a4c9d1e2f3b4
+Revises: e6e2ab171dd7
+Create Date: 2026-09-23
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "a4c9d1e2f3b4"
+down_revision = "e6e2ab171dd7"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "learning_materials",
+ sa.Column("id", sa.Uuid(), nullable=False),
+ sa.Column("title", sa.String(length=200), nullable=False),
+ sa.Column("description", sa.Text(), nullable=False),
+ sa.Column("level", sa.String(length=4), nullable=False),
+ sa.Column("kind", sa.String(length=16), nullable=False),
+ sa.Column("body", sa.Text(), nullable=False),
+ sa.Column("scenario_id", sa.String(length=80), nullable=True),
+ sa.Column("file_name", sa.String(length=240), nullable=True),
+ sa.Column("media_type", sa.String(length=120), nullable=True),
+ sa.Column("file_data", sa.LargeBinary(), nullable=True),
+ sa.Column("file_sha256", sa.String(length=64), nullable=True),
+ sa.Column("active", sa.Boolean(), nullable=False),
+ sa.Column("created_by", sa.String(length=80), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ sa.ForeignKeyConstraint(["scenario_id"], ["scenarios.id"], ondelete="SET NULL"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_table(
+ "material_assignments",
+ sa.Column("id", sa.Uuid(), nullable=False),
+ sa.Column("material_id", sa.Uuid(), nullable=False),
+ sa.Column("trainee_id", sa.Uuid(), nullable=False),
+ sa.Column("assigned_by", sa.String(length=80), nullable=False),
+ sa.Column("assigned_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
+ sa.ForeignKeyConstraint(["material_id"], ["learning_materials.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["trainee_id"], ["trainees.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("material_id", "trainee_id", name="uq_material_assignment"),
+ )
+ op.create_index(
+ "ix_material_assignments_trainee",
+ "material_assignments",
+ ["trainee_id", "assigned_at"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("ix_material_assignments_trainee", table_name="material_assignments")
+ op.drop_table("material_assignments")
+ op.drop_table("learning_materials")
diff --git a/backend/app/db/migrations/versions/b5e0f2a3c4d5_active_session_checkpoint.py b/backend/app/db/migrations/versions/b5e0f2a3c4d5_active_session_checkpoint.py
new file mode 100644
index 0000000..5e03f44
--- /dev/null
+++ b/backend/app/db/migrations/versions/b5e0f2a3c4d5_active_session_checkpoint.py
@@ -0,0 +1,37 @@
+"""снимки активных занятий для восстановления
+
+Revision ID: b5e0f2a3c4d5
+Revises: a4c9d1e2f3b4
+Create Date: 2026-09-23
+"""
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+
+revision = "b5e0f2a3c4d5"
+down_revision = "a4c9d1e2f3b4"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column("sessions", sa.Column("live_state", postgresql.JSONB(), nullable=True))
+ op.add_column(
+ "sessions",
+ sa.Column("checkpoint_at", sa.DateTime(timezone=True), nullable=True),
+ )
+ op.create_index(
+ "ix_sessions_active_checkpoint",
+ "sessions",
+ ["checkpoint_at"],
+ unique=False,
+ postgresql_where=sa.text("ended_at IS NULL AND live_state IS NOT NULL"),
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("ix_sessions_active_checkpoint", table_name="sessions")
+ op.drop_column("sessions", "checkpoint_at")
+ op.drop_column("sessions", "live_state")
diff --git a/backend/app/db/migrations/versions/c6f1a3b4d5e6_persistent_auth_version.py b/backend/app/db/migrations/versions/c6f1a3b4d5e6_persistent_auth_version.py
new file mode 100644
index 0000000..b984863
--- /dev/null
+++ b/backend/app/db/migrations/versions/c6f1a3b4d5e6_persistent_auth_version.py
@@ -0,0 +1,26 @@
+"""сохраняемая версия полномочий пользователя
+
+Revision ID: c6f1a3b4d5e6
+Revises: b5e0f2a3c4d5
+Create Date: 2026-09-23
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "c6f1a3b4d5e6"
+down_revision = "b5e0f2a3c4d5"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "users",
+ sa.Column("auth_version", sa.Integer(), server_default="0", nullable=False),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("users", "auth_version")
diff --git a/backend/app/db/migrations/versions/d7e2f4a5b6c7_scenario_owners.py b/backend/app/db/migrations/versions/d7e2f4a5b6c7_scenario_owners.py
new file mode 100644
index 0000000..6322c78
--- /dev/null
+++ b/backend/app/db/migrations/versions/d7e2f4a5b6c7_scenario_owners.py
@@ -0,0 +1,31 @@
+"""ограничение авторского контента и занятий владельцем-преподавателем
+
+Revision ID: d7e2f4a5b6c7
+Revises: c6f1a3b4d5e6
+Create Date: 2026-09-23
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "d7e2f4a5b6c7"
+down_revision = "c6f1a3b4d5e6"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "scenarios",
+ sa.Column("owner_login", sa.String(length=80), nullable=True),
+ )
+ op.add_column(
+ "sessions",
+ sa.Column("owner_login", sa.String(length=80), nullable=True),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("sessions", "owner_login")
+ op.drop_column("scenarios", "owner_login")
diff --git a/backend/app/db/migrations/versions/f7a3c9d1e2b4_instructor_owned_groups.py b/backend/app/db/migrations/versions/f7a3c9d1e2b4_instructor_owned_groups.py
new file mode 100644
index 0000000..196a892
--- /dev/null
+++ b/backend/app/db/migrations/versions/f7a3c9d1e2b4_instructor_owned_groups.py
@@ -0,0 +1,25 @@
+"""закрепить учебные группы за преподавателем
+
+Revision ID: f7a3c9d1e2b4
+Revises: d7e2f4a5b6c7
+Create Date: 2026-09-23
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "f7a3c9d1e2b4"
+down_revision = "d7e2f4a5b6c7"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Existing groups are not auto-assigned: historical membership/analytics may
+ # span instructors, so they remain admin-managed until explicitly migrated.
+ op.add_column("groups", sa.Column("owner_login", sa.String(length=80), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column("groups", "owner_login")
diff --git a/backend/app/db/models.py b/backend/app/db/models.py
index 60880aa..8310d7d 100644
--- a/backend/app/db/models.py
+++ b/backend/app/db/models.py
@@ -8,7 +8,7 @@
from datetime import datetime
from uuid import UUID, uuid4
-from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
+from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -26,6 +26,8 @@ class Group(Base):
id: Mapped[UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(String(120), unique=True)
+ # NULL — legacy/admin-managed group; instructor-created groups are private.
+ owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
trainees: Mapped[list["Trainee"]] = relationship(back_populates="group")
@@ -43,6 +45,52 @@ class Trainee(Base):
sessions: Mapped[list["Session"]] = relationship(back_populates="trainee")
+class LearningMaterial(Base):
+ """Локальный учебный материал или загруженный преподавателем ресурс."""
+
+ __tablename__ = "learning_materials"
+
+ id: Mapped[UUID] = _uuid_pk()
+ title: Mapped[str] = mapped_column(String(200))
+ description: Mapped[str] = mapped_column(Text, default="")
+ level: Mapped[str] = mapped_column(String(4), default="L1")
+ kind: Mapped[str] = mapped_column(String(16), default="text")
+ body: Mapped[str] = mapped_column(Text, default="")
+ scenario_id: Mapped[str | None] = mapped_column(
+ ForeignKey("scenarios.id", ondelete="SET NULL"), nullable=True
+ )
+ file_name: Mapped[str | None] = mapped_column(String(240))
+ media_type: Mapped[str | None] = mapped_column(String(120))
+ file_data: Mapped[bytes | None] = mapped_column(LargeBinary)
+ file_sha256: Mapped[str | None] = mapped_column(String(64))
+ active: Mapped[bool] = mapped_column(default=True)
+ created_by: Mapped[str] = mapped_column(String(80))
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+ )
+
+
+class MaterialAssignment(Base):
+ """Назначение материала курсанту и факт изучения."""
+
+ __tablename__ = "material_assignments"
+
+ id: Mapped[UUID] = _uuid_pk()
+ material_id: Mapped[UUID] = mapped_column(
+ ForeignKey("learning_materials.id", ondelete="CASCADE")
+ )
+ trainee_id: Mapped[UUID] = mapped_column(ForeignKey("trainees.id", ondelete="CASCADE"))
+ assigned_by: Mapped[str] = mapped_column(String(80))
+ assigned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+
+ __table_args__ = (
+ UniqueConstraint("material_id", "trainee_id", name="uq_material_assignment"),
+ Index("ix_material_assignments_trainee", "trainee_id", "assigned_at"),
+ )
+
+
class Scenario(Base):
"""Сценарий целиком лежит в `body`: библиотека — контент, а не схема.
Отдельными колонками вынесено только то, по чему идёт выборка в списке."""
@@ -56,6 +104,9 @@ class Scenario(Base):
topics: Mapped[list] = mapped_column(JSONB, default=list)
modes: Mapped[list] = mapped_column(JSONB, default=list)
status: Mapped[str] = mapped_column(String(16), default="published")
+ # У черновика и утверждённого авторского сценария ровно один владелец;
+ # NULL означает базовую/унаследованную системную библиотеку.
+ owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True)
body: Mapped[dict] = mapped_column(JSONB)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
@@ -74,6 +125,7 @@ class Session(Base):
id: Mapped[UUID] = _uuid_pk()
scenario_id: Mapped[str] = mapped_column(ForeignKey("scenarios.id", ondelete="RESTRICT"))
+ owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True)
trainee_id: Mapped[UUID | None] = mapped_column(ForeignKey("trainees.id", ondelete="SET NULL"))
group_id: Mapped[UUID | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"))
mode: Mapped[str] = mapped_column(String(16))
@@ -82,6 +134,10 @@ class Session(Base):
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
end_reason: Mapped[str | None] = mapped_column(String(16))
kio: Mapped[dict] = mapped_column(JSONB, default=dict)
+ # Последний подтверждённый снимок незавершённого занятия. Он очищается при
+ # завершении и позволяет новому backend-процессу продолжить карточку/ДДС.
+ live_state: Mapped[dict | None] = mapped_column(JSONB)
+ checkpoint_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
trainee: Mapped["Trainee | None"] = relationship(back_populates="sessions")
@@ -214,6 +270,9 @@ class User(Base):
service: Mapped[str | None] = mapped_column(String(120))
trainee_id: Mapped[UUID | None] = mapped_column(ForeignKey("trainees.id", ondelete="SET NULL"))
blocked: Mapped[bool] = mapped_column(default=False)
+ # Версия полномочий попадает в подписанную cookie. Смена роли, пароля или
+ # блокировки увеличивает её и отзывает старые cookie даже после restart.
+ auth_version: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
diff --git a/backend/app/db/repo.py b/backend/app/db/repo.py
index 4d921c1..0d8c6fc 100644
--- a/backend/app/db/repo.py
+++ b/backend/app/db/repo.py
@@ -30,10 +30,12 @@ async def create_session(
trainee_id: UUID | None = None,
group_id: UUID | None = None,
session_id: UUID | None = None,
+ owner_login: str | None = None,
) -> Session:
session = Session(
scenario_id=scenario_id,
mode=mode,
+ owner_login=owner_login,
trainee_id=trainee_id,
group_id=group_id,
attempt=await next_attempt(db, trainee_id, scenario_id),
@@ -54,6 +56,7 @@ async def ensure_session(
trainee_name: str | None = None,
trainee_id: UUID | None = None,
group_name: str | None = None,
+ owner_login: str | None = None,
) -> Session:
"""Занятие, запущенное с пульта, должно иметь строку в журнале.
@@ -62,9 +65,11 @@ async def ensure_session(
"""
existing = await db.get(Session, session_id)
if existing is not None:
+ if existing.owner_login != owner_login:
+ raise PermissionError("занятие принадлежит другому преподавателю")
return existing
- group = await ensure_group(db, group_name) if group_name else None
+ group = await ensure_group(db, group_name, owner_login=owner_login) if group_name else None
trainee = await db.get(Trainee, trainee_id) if trainee_id else None
if trainee_id and trainee is None:
raise ValueError(f"курсант {trainee_id} не найден")
@@ -77,6 +82,7 @@ async def ensure_session(
trainee_id=trainee.id if trainee else None,
group_id=group.id if group else trainee.group_id if trainee else None,
session_id=session_id,
+ owner_login=owner_login,
)
@@ -137,6 +143,7 @@ async def history(
trainee_id: UUID | None = None,
group_id: UUID | None = None,
mode: str | None = None,
+ owner_login: str | None = None,
since: datetime | None = None,
limit: int = 100,
) -> list[Session]:
@@ -149,17 +156,23 @@ async def history(
query = query.where(Session.group_id == group_id)
if mode is not None:
query = query.where(Session.mode == mode)
+ if owner_login is not None:
+ query = query.where(Session.owner_login == owner_login)
if since is not None:
query = query.where(Session.created_at >= since)
return list(await db.scalars(query))
-async def ensure_group(db: AsyncSession, name: str) -> Group:
+async def ensure_group(
+ db: AsyncSession, name: str, *, owner_login: str | None = None
+) -> Group:
group = await db.scalar(select(Group).where(Group.name == name))
if group is None:
- group = Group(name=name)
+ group = Group(name=name, owner_login=owner_login)
db.add(group)
await db.commit()
+ elif group.owner_login != owner_login:
+ raise PermissionError("группа принадлежит другому преподавателю или администратору")
return group
diff --git a/backend/app/dialog/caller.py b/backend/app/dialog/caller.py
index 73d50e9..27a0976 100644
--- a/backend/app/dialog/caller.py
+++ b/backend/app/dialog/caller.py
@@ -9,6 +9,7 @@
"""
import logging
+import re
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
@@ -20,6 +21,11 @@ from app.domain.events import Mood
log = logging.getLogger(__name__)
+NUMBER_WORDS = {
+ "один", "одна", "одно", "двое", "два", "две", "трое", "три", "четверо", "четыре",
+ "пять", "шесть", "семь", "восемь", "девять", "десять",
+}
+
@dataclass
class CallerLine:
@@ -152,6 +158,11 @@ class LlmCaller:
persona.on_repeat()
mood = persona.remember()
+ # Непонятная реплика не передаёт модели право импровизировать фактами.
+ # Отбор фактов и вопросная карта — только слот-автомат, не LLM.
+ if not (turn.revealed or turn.refined or turn.repeated):
+ return await self._fallback.reply(turn, persona, slots)
+
facts = {fact.id: fact.value for fact in slots.revealed_facts()}
say_now = [
facts[fact_id]
@@ -161,7 +172,7 @@ class LlmCaller:
repeated = [facts[fact_id] for fact_id in turn.repeated if fact_id in facts]
system = _prompt("caller.md").format(
- scenario=slots.scenario.title,
+ scenario="учебное происшествие",
mood=MOOD_WORDS.get(mood, mood.value),
directive=_directive_line(persona),
revealed="\n".join(f"- {value}" for value in facts.values()) or "- пока ничего",
@@ -173,13 +184,15 @@ class LlmCaller:
repeated="; ".join(repeated), repeats=persona.repeats
)
- self._history.append({"role": "user", "content": turn.text})
+ current_message = {"role": "user", "content": turn.text}
try:
text = await self._client.complete(
LlmRequest(
- messages=[{"role": "system", "content": system}, *self._history[-6:]],
+ messages=[{"role": "system", "content": system},
+ *self._history[-6:], current_message],
model=self._model,
temperature=self._temperature,
+ max_tokens=160,
)
)
except LlmUnavailable as exc:
@@ -187,7 +200,13 @@ class LlmCaller:
log.warning("звонящий на заготовках: %s", exc)
return await self._fallback.reply(turn, persona, slots)
- self._history.append({"role": "assistant", "content": text})
+ if not _allowed_reply(text, facts, say_now + repeated, slots):
+ self.fallbacks += 1
+ log.warning("ответ модели нарушил протокол раскрытия фактов — использована заготовка")
+ return await self._fallback.reply(turn, persona, slots)
+ # Отклонённый ответ и провокационный вопрос не должны загрязнять
+ # последующий контекст. Запоминаем только проверенную пару ходов.
+ self._history.extend((current_message, {"role": "assistant", "content": text}))
return CallerLine(text=text, mood=mood)
async def aclose(self) -> None:
@@ -196,6 +215,48 @@ class LlmCaller:
await self._client.aclose()
+def _allowed_reply(text: str, allowed: dict[str, str], required_now: list[str], slots: SlotMachine) -> bool:
+ """Консервативная граница для текста модели; протокол 112 всё равно в коде.
+
+ Невозможно доказать истинность произвольной русской фразы регулярками,
+ поэтому сомнительный ответ заменяется детерминированной репликой.
+ """
+ if not text or len(text) > 300 or "\n" in text or "" in text.lower():
+ return False
+ normalized = text.casefold().replace("ё", "е")
+ # Модель не вправе назвать числовой адрес или телефон, которого нет в
+ # раскрытых фактах, даже если оператор предположил его в своей реплике.
+ allowed_digits = set(re.findall(r"\d+", " ".join(allowed.values())))
+ if any(number not in allowed_digits for number in re.findall(r"\d+", normalized)):
+ return False
+ allowed_words = set(re.findall(r"[а-яё]+", " ".join(allowed.values()).casefold().replace("ё", "е")))
+ spoken_words = set(re.findall(r"[а-яё]+", normalized))
+ if (spoken_words & NUMBER_WORDS) - allowed_words:
+ return False
+ allowed_stems = {word[:4] for word in allowed_words if len(word) >= 4}
+ spoken_stems = {word[:4] for word in spoken_words if len(word) >= 4}
+ for fact in slots.scenario.facts:
+ if fact.id in allowed:
+ continue
+ for value in (fact.value, fact.refined):
+ if not value:
+ continue
+ if len(value) >= 7 and value.casefold().replace("ё", "е") in normalized:
+ return False
+ # Полная фраза — не единственный способ слить скрытый факт:
+ # «муж курил» раскрывает причину, даже без «на балконе».
+ hidden_stems = {word[:4] for word in re.findall(
+ r"[а-яё]{4,}", value.casefold().replace("ё", "е")
+ )} - allowed_stems
+ if hidden_stems & spoken_stems:
+ return False
+ # Новый обязательный факт нельзя опустить ради красивой реплики.
+ for value in required_now:
+ if value.casefold().replace("ё", "е") not in normalized:
+ return False
+ return True
+
+
MOOD_WORDS = {
Mood.PANIC: "паника, ты кричишь",
Mood.AGGRESSIVE: "злость, ты срываешься на оператора",
@@ -210,6 +271,8 @@ def _directive_line(persona: PersonaState) -> str:
if persona.directive in SOFT:
return f"ПРЕПОДАВАТЕЛЬ ВЕДЁТ СИТУАЦИЮ: {SOFT[persona.directive].lower()}."
+ if persona.directive:
+ return f"ПРЕПОДАВАТЕЛЬ ПРОСИТ ИЗМЕНИТЬ ПОДАЧУ: {persona.directive}. Факты не меняй."
return ""
diff --git a/backend/app/dialog/director.py b/backend/app/dialog/director.py
index 4501bf9..3908b5e 100644
--- a/backend/app/dialog/director.py
+++ b/backend/app/dialog/director.py
@@ -41,7 +41,7 @@ class DirectiveResult:
say: str | None = None
#: Оборвать связь: звук на полуслове, дальше обратный дозвон.
drop_line: bool = False
- #: Директива требует сети (свободный текст без LLM).
+ #: Директива требует локально работающую модель (без неё — только кнопки).
needs_network: bool = False
@@ -72,14 +72,15 @@ def apply(state, directive: str) -> DirectiveResult:
state.slots.invalidate(fact.id)
return DirectiveResult(applied=True, say=HARD_LINES[directive])
- # Свободный текст уходит в контекст персоны — но подставить его в реплику
- # может только LLM. Офлайн-дерево предгенерировано, произвольную фразу
- # взять неоткуда (docs/arch/CONTRACT.md).
+ # Свободный текст разрешён лишь в режиме локальной модели. Он управляет
+ # интонацией, но не раскрывает факты и не меняет правила 112.
+ from app.dialog.caller import LlmCaller
+
+ if not isinstance(state.caller, LlmCaller) or len(directive) > 500:
+ return DirectiveResult(applied=False, needs_network=True)
if state.persona is not None:
- state.persona.directive = None
- if state.directives is not None:
- state.directives.append(directive)
- return DirectiveResult(applied=False, needs_network=True)
+ state.persona.directive = directive
+ return DirectiveResult(applied=True)
def mood_of(state) -> Mood:
diff --git a/backend/app/dialog/factory.py b/backend/app/dialog/factory.py
index 4fce413..9dd4943 100644
--- a/backend/app/dialog/factory.py
+++ b/backend/app/dialog/factory.py
@@ -1,4 +1,4 @@
-"""Кто играет звонящего: LLM, если есть ключ, иначе заготовки.
+"""Кто играет звонящего: локальная LLM, таблица или заготовки.
Провайдер и модель меняются значением в конфиге, а не кодом. Заготовки —
не запасной костыль, а рабочий режим: занятие идёт и без сети.
@@ -9,30 +9,45 @@ import logging
from app.config import get_settings
from app.dialog.caller import Caller, LlmCaller, TemplateCaller
from app.dialog.llm import LlmClient
+from app.dialog.llm import is_loopback_url
from app.dialog.tree import TreeCaller, has_table
from app.db.base import get_sessionmaker
log = logging.getLogger(__name__)
-def build_caller(scenario_id: str | None = None, sessionmaker=None) -> Caller:
+def build_caller(
+ scenario_id: str | None = None,
+ sessionmaker=None,
+ *,
+ use_pregenerated: bool = False,
+) -> Caller:
settings = get_settings()
- # Офлайн и «нет ключа» — это один и тот же путь: предгенерированная таблица,
- # а не локальная модель. Формулировки в ней от облачной модели, а задержка
- # нулевая (docs/arch/STACK.md).
- if (settings.offline or not settings.llm_api_key) and scenario_id and has_table(scenario_id):
+ local = settings.llm_provider == "local"
+ mode = settings.dialogue_model_mode
+ if mode not in {"dialogue", "russian_control"}:
+ raise ValueError(f"неизвестный DIALOGUE_MODEL_MODE: {mode}")
+ model = settings.llm_model_control if mode == "russian_control" else settings.llm_model_caller
+ base_url = settings.llm_control_base_url if mode == "russian_control" else settings.llm_base_url
+ model_allowed = settings.llm_provider != "disabled" and bool(model and base_url and (
+ is_loopback_url(base_url, allow_docker_host=settings.allow_docker_host_models)
+ if settings.offline or local else settings.llm_api_key
+ ))
+
+ # Локальный loopback не считается внешней сетью. Если сервер модели не
+ # отвечает, LlmCaller откатится на проверенные заготовки этой же реплики.
+ if model_allowed:
+ client = LlmClient(sessionmaker=sessionmaker or _safe_sessionmaker(), base_url=base_url)
+ log.info("звонящий: режим %s, модель %s, адрес %s", mode, model, base_url)
+ return LlmCaller(client, model=model)
+
+ if use_pregenerated and scenario_id and has_table(scenario_id):
log.info("звонящий по предгенерированной таблице сценария %s", scenario_id)
return TreeCaller(scenario_id)
- if not settings.llm_api_key or settings.offline:
- reason = "офлайн-режим" if settings.offline else "нет ключа LLM"
- log.info("звонящий отвечает заготовками: %s (таблицы нет — make pregen)", reason)
- return TemplateCaller()
-
- client = LlmClient(sessionmaker=sessionmaker or _safe_sessionmaker())
- log.info("звонящий на модели %s", settings.llm_model_caller)
- return LlmCaller(client, model=settings.llm_model_caller)
+ log.info("звонящий отвечает фактами сценария: модель отключена или предгенерация не разрешена")
+ return TemplateCaller()
def _safe_sessionmaker():
diff --git a/backend/app/dialog/llm.py b/backend/app/dialog/llm.py
index a752e19..8b6375e 100644
--- a/backend/app/dialog/llm.py
+++ b/backend/app/dialog/llm.py
@@ -1,4 +1,4 @@
-"""Клиент облачной LLM за интерфейсом: провайдер меняется значением в конфиге.
+"""Клиент совместимого API для локальных или внешних моделей.
Кэш ответов по хешу контекста лежит в Postgres, а не в Redis: база уже поднята,
лишняя движущаяся часть на стенде не нужна (docs/arch/STACK.md). Кэш работает
@@ -9,7 +9,10 @@
import hashlib
import json
import logging
+import re
from dataclasses import dataclass
+from ipaddress import ip_address
+from urllib.parse import urlsplit
import httpx
from sqlalchemy import select
@@ -26,6 +29,48 @@ class LlmUnavailable(RuntimeError):
занятие продолжается — молчащий звонящий хуже шаблонной фразы."""
+def is_loopback_url(value: str, *, allow_docker_host: bool = False) -> bool:
+ """В офлайн-режиме модели разрешены лишь на той же машине.
+
+ Не доверяем доменам или hosts-записям: они могут указывать наружу.
+ """
+ try:
+ url = urlsplit(value)
+ host = url.hostname or ""
+ try:
+ local_host = ip_address(host).is_loopback
+ except ValueError:
+ local_host = allow_docker_host and host == "host.docker.internal"
+ return (url.scheme == "http" and local_host and url.port is not None
+ and not url.username and not url.password)
+ except (ValueError, TypeError):
+ return False
+
+
+def _spoken_content(raw: str, *, strip_reasoning: bool = False) -> str:
+ """Убрать только пустой служебный хвост Qwen3, не рассуждения модели.
+
+ llama.cpp с выключенным thinking иногда возвращает в `content` один
+ закрывающий ` ` перед самой репликой. Внутренний текст размышлений
+ мы намеренно не вырезаем: если он есть, ответ небезопасен и идёт fallback.
+ """
+ text = raw.strip()
+ if strip_reasoning:
+ closing = list(re.finditer(r"\s*", text, re.IGNORECASE))
+ if closing:
+ text = text[closing[-1].end():].strip()
+ elif text.startswith("<|"):
+ start = text.find("{")
+ if start >= 0:
+ text = text[start:].strip()
+ text = re.sub(r"^(?:\s*)+", "", text, flags=re.IGNORECASE).strip()
+ if re.search(r"?think\b", text, re.IGNORECASE) or "<|" in text:
+ raise LlmUnavailable("ответ содержит служебные токены модели")
+ if not text:
+ raise LlmUnavailable("пустой ответ модели: весь бюджет токенов ушёл в рассуждение")
+ return text
+
+
@dataclass
class LlmRequest:
messages: list[dict]
@@ -34,10 +79,15 @@ class LlmRequest:
# С запасом на рассуждающие модели: Qwen3 тратит на размышление сотни токенов
# и при малом бюджете возвращает пустой ответ с finish_reason="length".
max_tokens: int = 400
+ response_format: dict | None = None
+ # Только для внутренних структурированных задач. В репликах звонящего
+ # рассуждение всегда отвергается, чтобы оно не попало в эфир.
+ strip_reasoning: bool = False
def cache_key(self) -> str:
payload = json.dumps(
- {"m": self.model, "t": self.temperature, "msgs": self.messages},
+ {"m": self.model, "t": self.temperature, "msgs": self.messages,
+ "format": self.response_format, "strip_reasoning": self.strip_reasoning},
ensure_ascii=False,
sort_keys=True,
)
@@ -50,36 +100,43 @@ class LlmClient:
*,
sessionmaker: async_sessionmaker | None = None,
transport: httpx.AsyncBaseTransport | None = None,
+ base_url: str | None = None,
# Ответ дольше этого бессмысленен: бюджет хода — 1.5 с, а звонящий
# с заготовками ответит сразу.
timeout: float = 8.0,
) -> None:
settings = get_settings()
- self._base_url = settings.llm_base_url.rstrip("/")
+ self._base_url = (base_url or settings.llm_base_url).rstrip("/")
self._key = settings.llm_api_key
+ self._local_only = settings.offline or settings.llm_provider == "local"
+ self._allow_docker_host = settings.allow_docker_host_models
self._sessionmaker = sessionmaker
- self._client = httpx.AsyncClient(timeout=timeout, transport=transport)
+ self._client = httpx.AsyncClient(timeout=timeout, transport=transport, trust_env=False)
@property
def configured(self) -> bool:
+ if self._local_only:
+ return is_loopback_url(
+ self._base_url, allow_docker_host=self._allow_docker_host
+ )
return bool(self._key and self._base_url)
async def complete(self, request: LlmRequest, *, use_cache: bool = True) -> str:
"""Ответ модели. Кэш по хешу контекста: та же реплика на том же месте
занятия звучит одинаково у каждой группы."""
if not self.configured:
- raise LlmUnavailable("не задан ключ или адрес провайдера")
+ raise LlmUnavailable("локальный адрес модели недопустим или провайдер не настроен")
key = request.cache_key()
if use_cache:
cached = await self._from_cache(key)
if cached is not None:
- return cached
+ return _spoken_content(cached, strip_reasoning=request.strip_reasoning)
try:
response = await self._client.post(
f"{self._base_url}/chat/completions",
- headers={"Authorization": f"Bearer {self._key}"},
+ headers={"Authorization": f"Bearer {self._key}"} if self._key else {},
json={
"model": request.model,
"messages": request.messages,
@@ -88,6 +145,8 @@ class LlmClient:
# Рассуждение в ответе не нужно: оно только раздувает трафик.
# Провайдеры, которые про это поле не знают, его игнорируют.
"reasoning": {"exclude": True},
+ **({"response_format": request.response_format}
+ if request.response_format is not None else {}),
},
)
except httpx.HTTPError as exc:
@@ -97,13 +156,14 @@ class LlmClient:
# Тело ошибки в лог, ключ в заголовке — не логируется.
raise LlmUnavailable(f"HTTP {response.status_code}: {response.text[:200]}")
- message = response.json()["choices"][0]["message"]
- text = (message.get("content") or "").strip()
- if not text:
- # У рассуждающих моделей при нехватке бюджета весь ответ уходит
- # в размышление, а content приходит пустым. Для занятия это отказ:
- # звонящий откатится на заготовку, а не промолчит.
- raise LlmUnavailable("пустой ответ модели: весь бюджет токенов ушёл в рассуждение")
+ try:
+ message = response.json()["choices"][0]["message"]
+ content = message.get("content")
+ if content is not None and not isinstance(content, str):
+ raise TypeError("content не строка")
+ text = _spoken_content(content or "", strip_reasoning=request.strip_reasoning)
+ except (ValueError, KeyError, IndexError, TypeError, AttributeError) as exc:
+ raise LlmUnavailable("некорректный ответ локальной модели") from exc
if use_cache and text:
await self._to_cache(key, request, text)
return text
diff --git a/backend/app/dialog/prompts/caller.md b/backend/app/dialog/prompts/caller.md
index 30614c2..e996c4a 100644
--- a/backend/app/dialog/prompts/caller.md
+++ b/backend/app/dialog/prompts/caller.md
@@ -19,3 +19,5 @@
4. Если состояние — паника или крик: обрывки, повторы, незаконченные фразы.
5. Не задавай оператору вопросов о ходе разговора и не подсказывай ему, что спросить.
6. Отвечай только репликой, без пояснений и без кавычек.
+7. Каждый факт из раздела «ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ» произнеси
+ полностью и дословно. Одного «да», «нет» или намёка недостаточно.
diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py
index 62ae457..cb160ec 100644
--- a/backend/app/domain/events.py
+++ b/backend/app/domain/events.py
@@ -15,14 +15,15 @@ from enum import StrEnum
from typing import Annotated, Any, Literal
from uuid import UUID
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
-from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
+from app.domain.classifiers import DDSCode, Level, Outcome
from app.domain.kio import KIO, Coords
from app.domain.roles import Role
from app.domain.statuses import ServiceStatus, StationSnapshot
from app.domain.taxonomy import Finding
from app.domain.timers import TimerSnapshot
+from app.scoring.taxonomy import METRIC_MAP
class SessionMode(StrEnum):
@@ -40,6 +41,30 @@ class Exercise(StrEnum):
CARD = "card"
+class LessonCriteria(BaseModel):
+ """Настраиваемые преподавателем условия именно этого занятия.
+
+ Нормативы ГОСТ для приёма вызова сюда не входят. Для занятия меняются
+ учебный лимит решения, порог успешности и веса метрик; веса сценария
+ остаются базовыми, а настройки занятия могут их переопределить.
+ """
+
+ decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
+ allowed_errors: int = Field(default=0, ge=0, le=50)
+ require_correct_grammar: bool = True
+ score_weights: dict[str, float] = Field(default_factory=dict)
+
+ @field_validator("score_weights")
+ @classmethod
+ def validate_score_weights(cls, weights: dict[str, float]) -> dict[str, float]:
+ unknown = weights.keys() - METRIC_MAP.keys()
+ if unknown:
+ raise ValueError(f"неизвестные метрики весов: {', '.join(sorted(unknown))}")
+ if any(not 0 <= weight <= 10 for weight in weights.values()):
+ raise ValueError("каждый вес метрики должен быть от 0 до 10")
+ return weights
+
+
class Speaker(StrEnum):
CALLER = "caller"
OPERATOR = "operator"
@@ -346,6 +371,7 @@ class SessionSnapshot(BaseModel):
level: Level
mode: SessionMode
exercise: Exercise = Exercise.CALL
+ criteria: LessonCriteria = Field(default_factory=LessonCriteria)
trainee_name: str | None = None
started_at: datetime | None = None
kio: KIO
@@ -420,12 +446,20 @@ class ScenarioStart(BaseModel):
type: Literal["scenario.start"] = "scenario.start"
scenario_id: str
scenario_ids: list[str] | None = None
+ # Pace defaults to simultaneous for older clients; the instructor UI
+ # explicitly sends its slower training default.
+ dds_arrival_interval_seconds: int = Field(default=0, ge=0, le=300)
+ dds_max_waiting: int = Field(default=3, ge=1, le=10)
trainee: str
trainee_id: UUID | None = None
+ # Служба обучающегося определяет, чьи статусы он ведёт. В рабочем режиме
+ # сервер заменяет это значение данными учётной записи; поле нужно demo без БД.
+ dds_service: str | None = None
group_id: str | None = None
mode: SessionMode
exercise: Exercise = Exercise.CALL
handoff_to_dds: bool = False
+ criteria: LessonCriteria = Field(default_factory=LessonCriteria)
class DirectorInject(BaseModel):
@@ -527,6 +561,35 @@ class PhoneDial(BaseModel):
type: Literal["phone.dial"] = "phone.dial"
+class PhoneBrief(BaseModel):
+ """Что диспетчер передал старшему группы при первом исходящем звонке."""
+
+ type: Literal["phone.brief"] = "phone.brief"
+ address: str = Field(min_length=3, max_length=300)
+ incident: str = Field(min_length=3, max_length=500)
+ request: str = Field(default="", max_length=500)
+
+
+class PhoneCheck(BaseModel):
+ """Запрос диспетчера об обстановке после предыдущего доклада."""
+
+ type: Literal["phone.check"] = "phone.check"
+ text: str = Field(min_length=8, max_length=500)
+
+
+class PhoneHangup(BaseModel):
+ type: Literal["phone.hangup"] = "phone.hangup"
+
+
+class PhoneLine(BaseModel):
+ type: Literal["phone.line"] = "phone.line"
+ service: str
+ crew: str
+ speaker: Literal["dispatcher", "crew"]
+ text: str
+ at: datetime
+
+
class PhoneReport(BaseModel):
type: Literal["phone.report"] = "phone.report"
service: str
@@ -551,6 +614,13 @@ class CardNext(BaseModel):
card_id: UUID
+class CardOpen(BaseModel):
+ """Открыть одну из уже выданных карточек, не меняя её таймер."""
+
+ type: Literal["card.open"] = "card.open"
+ card_id: UUID
+
+
class StationState(BaseModel):
"""Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше."""
@@ -574,13 +644,14 @@ class CrewArrived(BaseModel):
ServerToStation = Annotated[
- CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent,
+ CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent,
Field(discriminator="type"),
]
StationToServer = Annotated[
- CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish
- | CardReply | CardNext
+ CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck
+ | PhoneHangup | StationFinish
+ | CardReply | CardNext | CardOpen
| ZoneDecision | CrewDispatched | CrewArrived,
Field(discriminator="type"),
]
@@ -651,6 +722,9 @@ class SessionReport(BaseModel):
scenario_id: str
mode: SessionMode
attempt: int = 1
+ criteria: LessonCriteria
+ failed_metrics: int
+ passed: bool
transcript: list[TranscriptEntry]
findings: list[Finding]
metrics: list[Metric]
diff --git a/backend/app/domain/kio.py b/backend/app/domain/kio.py
index 527ef79..9b4f201 100644
--- a/backend/app/domain/kio.py
+++ b/backend/app/domain/kio.py
@@ -90,6 +90,7 @@ class KIO(BaseModel):
caller_number: str | None = None
caller_name: str | None = None
caller_contact: str | None = None
+ phone_on_scene: str | None = None
language: str = "ru"
# Место
diff --git a/backend/app/domain/roles.py b/backend/app/domain/roles.py
index 19a68e7..a13ba92 100644
--- a/backend/app/domain/roles.py
+++ b/backend/app/domain/roles.py
@@ -23,7 +23,9 @@ ROLE_LABELS: dict[Role, str] = {
#: настройки, администратор не вмешивается в оценки, обучающийся не видит
#: чужих результатов.
SCREENS: dict[Role, tuple[str, ...]] = {
- Role.ADMIN: ("/admin", "/profile"),
- Role.INSTRUCTOR: ("/instructor", "/wall", "/profile", "/dds"),
- Role.TRAINEE: ("/trainee", "/profile"),
+ Role.ADMIN: ("/admin", "/profile", "/groups", "/materials"),
+ Role.INSTRUCTOR: (
+ "/instructor", "/wall", "/profile", "/dds", "/phone", "/groups", "/materials",
+ ),
+ Role.TRAINEE: ("/trainee", "/dds", "/phone", "/profile", "/materials"),
}
diff --git a/backend/app/domain/statuses.py b/backend/app/domain/statuses.py
index 3993afe..2def68b 100644
--- a/backend/app/domain/statuses.py
+++ b/backend/app/domain/statuses.py
@@ -135,12 +135,46 @@ class PhoneReportRecord(BaseModel):
at: datetime
+class PhoneLineRecord(BaseModel):
+ service: str
+ crew: str
+ speaker: str # dispatcher | crew
+ text: str
+ at: datetime
+
+
+class PhoneCallPending(BaseModel):
+ service: str
+ crew: str
+ phase: str
+
+
class DdsCardSummary(BaseModel):
card_id: UUID
scenario_id: str
score_auto: float
+class DdsQueueCard(BaseModel):
+ """Строка общей очереди ДДС; таймер каждой карточки идёт независимо."""
+
+ card_id: UUID
+ scenario_id: str
+ title: str
+ address: str | None = None
+ description: str | None = None
+ incident_type: str | None = None
+ victims_count: int | None = None
+ received_at: datetime
+ managed_service: str | None = None
+ service_status: ServiceStatus
+ card_status: CardStatus
+ elapsed_ms: int
+ limit_ms: int
+ timer_stopped: bool
+ active: bool
+
+
class StatusError(ValueError):
"""Переход запрещён автоматом или нет обязательного комментария."""
@@ -208,6 +242,8 @@ class StationSnapshot(BaseModel):
"""
services: list[str]
+ recipient_services: list[str] = []
+ managed_service: str | None = None
statuses: dict[str, ServiceStatus]
available: dict[str, list[ServiceStatus]]
card: CardStatus
@@ -215,8 +251,14 @@ class StationSnapshot(BaseModel):
crew_options: list[str] = []
crew_selected: str | None = None
phone_reports: list[PhoneReportRecord] = []
+ phone_lines: list[PhoneLineRecord] = []
+ phone_pending: PhoneCallPending | None = None
card_id: UUID | None = None
card_index: int = 1
card_total: int = 1
reply_text: str = ""
completed_cards: list[DdsCardSummary] = []
+ queue_cards: list[DdsQueueCard] = []
+ pending_cards_count: int = 0
+ next_arrival_in_seconds: int | None = None
+ max_waiting_cards: int = 3
diff --git a/backend/app/main.py b/backend/app/main.py
index 5c7b692..b14ef8c 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -14,6 +14,7 @@ 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 materials as materials_api
from app.api.http import scenarios as scenarios_api
from app.api.http import sessions
from app.api.http import trainees
@@ -29,6 +30,7 @@ from app.scenarios import store
from app.session.hub import hub
from app.session.journal import DbJournal
from app.scenarios.loader import ScenarioError
+from app.monitoring import install_diagnostics
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
@@ -38,6 +40,7 @@ LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
# а до лога доходят одни предупреждения. Чужие библиотеки — от WARNING.
logging.basicConfig(level=logging.WARNING, format="%(levelname)-8s %(name)s: %(message)s")
logging.getLogger("app").setLevel(logging.INFO)
+install_diagnostics()
@asynccontextmanager
@@ -52,6 +55,9 @@ async def lifespan(app: FastAPI):
except ScenarioError as exc:
raise RuntimeError(f"библиотека сценариев не прошла проверку: {exc}") from exc
app.state.scenarios_loaded = len(loaded)
+ if settings.demo_no_db:
+ store.reset_demo_drafts()
+ materials_api.reset_demo_materials()
# Утверждённые преподавателем сценарии хранятся в БД и должны переживать
# перезапуск процесса. При недоступной БД остаётся базовая YAML-библиотека.
@@ -71,8 +77,34 @@ async def lifespan(app: FastAPI):
"DEMO_NO_DB: занятия и оценки живут только до перезапуска; журнал БД выключен"
)
+ if not settings.demo_no_db:
+ try:
+ async with asyncio.timeout(2):
+ await auth.load_generations()
+ except (SQLAlchemyError, OSError, TimeoutError) as exc:
+ logging.getLogger(__name__).warning(
+ "не удалось загрузить версии полномочий: %s", exc
+ )
+
# Журнал: всё, что не записано, для оценки не существует.
hub.journal = None if settings.demo_no_db else DbJournal(get_sessionmaker())
+ app.state.sessions_restored = 0
+ if hub.journal is not None:
+ try:
+ async with asyncio.timeout(3):
+ restored = await hub.journal.restore_active()
+ for state in restored:
+ hub.register(state)
+ hub.start_ticker(state.session_id)
+ app.state.sessions_restored = len(restored)
+ if restored:
+ logging.getLogger(__name__).info(
+ "восстановлено активных занятий: %d", len(restored)
+ )
+ except (SQLAlchemyError, OSError, TimeoutError) as exc:
+ logging.getLogger(__name__).warning(
+ "не удалось восстановить активные занятия: %s", exc
+ )
# Эмбеддинги для слот-автомата — грузятся один раз, до первого занятия.
app.state.embeddings_ready = not settings.demo_no_db and get_embedder() is not None
@@ -81,6 +113,10 @@ async def lifespan(app: FastAPI):
app.state.models_ready = get_voice_models() is not None
yield
+ if hub.journal is not None:
+ for state in list(hub._sessions.values()):
+ if not state.ended:
+ await hub.journal.checkpoint(state)
await hub.shutdown()
for state in list(hub._sessions.values()):
if state.voice is not None:
@@ -94,7 +130,7 @@ app.add_middleware(
SessionMiddleware,
secret_key=get_settings().session_secret,
session_cookie="lct_session",
- https_only=False, # учебный класс живёт по http во внутренней сети
+ https_only=get_settings().secure_cookies,
max_age=12 * 60 * 60, # смена занятий, не месяц
)
app.include_router(auth.router)
@@ -102,6 +138,7 @@ 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(materials_api.router)
app.include_router(admin_api.router)
app.include_router(trainees.router)
app.include_router(call_ws.router)
@@ -119,6 +156,7 @@ async def health() -> dict:
"models_ready": getattr(app.state, "models_ready", False),
"scenarios_loaded": getattr(app.state, "scenarios_loaded", 0),
"embeddings_ready": getattr(app.state, "embeddings_ready", False),
+ "sessions_restored": getattr(app.state, "sessions_restored", 0),
"offline": settings.offline,
"demo_no_db": settings.demo_no_db,
}
diff --git a/backend/app/monitoring.py b/backend/app/monitoring.py
new file mode 100644
index 0000000..1fd02b9
--- /dev/null
+++ b/backend/app/monitoring.py
@@ -0,0 +1,167 @@
+"""Small, dependency-free runtime diagnostics for the local stand.
+
+The product has to run on Windows as well as Linux. Keep the collector in
+the standard library: unavailable host counters are returned as ``None``
+instead of making the administrator screen fail.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import re
+import shutil
+import sys
+import threading
+import time
+from collections import deque
+from datetime import datetime, timezone
+from pathlib import Path
+
+
+STARTED_MONOTONIC = time.monotonic()
+_events: deque[dict] = deque(maxlen=500)
+_installed = False
+_sample_lock = threading.Lock()
+_last_wall = time.monotonic()
+_last_cpu = time.process_time()
+
+_CREDENTIALS = re.compile(r"(://[^:/\s]+:)([^@/\s]+)(@)")
+_TOKENS = re.compile(
+ r"(?i)\b(session_secret|llm_api_key|authorization|password)\b\s*[:=]\s*([^\s,;]+)"
+)
+
+
+def _safe(message: str) -> str:
+ message = _CREDENTIALS.sub(r"\1[скрыто]\3", message)
+ message = _TOKENS.sub(r"\1=[скрыто]", message)
+ return message.replace("\x00", "")[:2000]
+
+
+class DiagnosticHandler(logging.Handler):
+ """Bounded warning/error journal safe enough to expose to an admin."""
+
+ def emit(self, record: logging.LogRecord) -> None:
+ try:
+ _events.append({
+ "at": datetime.fromtimestamp(record.created, timezone.utc),
+ "level": record.levelname,
+ "source": record.name[:120],
+ "message": _safe(record.getMessage()),
+ })
+ except Exception: # logging must never take the service down
+ pass
+
+
+def install_diagnostics() -> None:
+ global _installed
+ if _installed:
+ return
+ handler = DiagnosticHandler(level=logging.WARNING)
+ logging.getLogger().addHandler(handler)
+ _installed = True
+
+
+def recent_events(*, limit: int = 100) -> list[dict]:
+ return list(_events)[-max(1, min(limit, 500)):][::-1]
+
+
+def _rss_bytes() -> int | None:
+ try:
+ if sys.platform.startswith("linux"):
+ resident_pages = int(Path("/proc/self/statm").read_text().split()[1])
+ return resident_pages * int(os.sysconf("SC_PAGE_SIZE"))
+ if os.name == "nt":
+ import ctypes
+ from ctypes import wintypes
+
+ class Counters(ctypes.Structure):
+ _fields_ = [
+ ("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD),
+ ("PeakWorkingSetSize", ctypes.c_size_t),
+ ("WorkingSetSize", ctypes.c_size_t),
+ ("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
+ ("QuotaPagedPoolUsage", ctypes.c_size_t),
+ ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
+ ("QuotaNonPagedPoolUsage", ctypes.c_size_t),
+ ("PagefileUsage", ctypes.c_size_t),
+ ("PeakPagefileUsage", ctypes.c_size_t),
+ ]
+
+ counters = Counters()
+ counters.cb = ctypes.sizeof(counters)
+ ctypes.windll.psapi.GetProcessMemoryInfo( # type: ignore[attr-defined]
+ ctypes.windll.kernel32.GetCurrentProcess(), ctypes.byref(counters), counters.cb
+ )
+ return int(counters.WorkingSetSize)
+ import resource
+
+ value = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
+ return int(value if sys.platform == "darwin" else value * 1024)
+ except (OSError, ValueError, AttributeError, ImportError, IndexError):
+ return None
+
+
+def _memory() -> tuple[int | None, int | None]:
+ try:
+ if sys.platform.startswith("linux"):
+ fields: dict[str, int] = {}
+ for line in Path("/proc/meminfo").read_text().splitlines():
+ name, value = line.split(":", 1)
+ fields[name] = int(value.strip().split()[0]) * 1024
+ return fields.get("MemTotal"), fields.get("MemAvailable")
+ if os.name == "nt":
+ import ctypes
+
+ class MemoryStatus(ctypes.Structure):
+ _fields_ = [
+ ("length", ctypes.c_ulong), ("memory_load", ctypes.c_ulong),
+ ("total_physical", ctypes.c_ulonglong),
+ ("available_physical", ctypes.c_ulonglong),
+ ("total_page_file", ctypes.c_ulonglong),
+ ("available_page_file", ctypes.c_ulonglong),
+ ("total_virtual", ctypes.c_ulonglong),
+ ("available_virtual", ctypes.c_ulonglong),
+ ("available_extended_virtual", ctypes.c_ulonglong),
+ ]
+
+ status = MemoryStatus()
+ status.length = ctypes.sizeof(status)
+ ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)) # type: ignore[attr-defined]
+ return int(status.total_physical), int(status.available_physical)
+ page = int(os.sysconf("SC_PAGE_SIZE"))
+ return int(os.sysconf("SC_PHYS_PAGES")) * page, None
+ except (OSError, ValueError, AttributeError, ImportError):
+ return None, None
+
+
+def sample_metrics() -> dict:
+ """Return a cheap point-in-time sample; the first CPU sample is zero."""
+ global _last_wall, _last_cpu
+ now = time.monotonic()
+ cpu_now = time.process_time()
+ with _sample_lock:
+ wall_delta = max(now - _last_wall, 0.001)
+ cpu_percent = max(0.0, (cpu_now - _last_cpu) / wall_delta * 100.0)
+ _last_wall, _last_cpu = now, cpu_now
+
+ cores = os.cpu_count() or 1
+ try:
+ load_1m_percent = os.getloadavg()[0] / cores * 100.0
+ except (AttributeError, OSError):
+ load_1m_percent = None
+ memory_total, memory_available = _memory()
+ disk = shutil.disk_usage(Path.cwd())
+ return {
+ "at": datetime.now(timezone.utc),
+ "uptime_seconds": max(0.0, now - STARTED_MONOTONIC),
+ "cpu_percent": round(cpu_percent, 2),
+ "load_1m_percent": round(load_1m_percent, 2) if load_1m_percent is not None else None,
+ "cpu_cores": cores,
+ "rss_bytes": _rss_bytes(),
+ "memory_total_bytes": memory_total,
+ "memory_available_bytes": memory_available,
+ "disk_total_bytes": disk.total,
+ "disk_free_bytes": disk.free,
+ "threads": threading.active_count(),
+ }
diff --git a/backend/app/scenarios/editor.py b/backend/app/scenarios/editor.py
index 90b8796..7456776 100644
--- a/backend/app/scenarios/editor.py
+++ b/backend/app/scenarios/editor.py
@@ -5,6 +5,7 @@
"""
from copy import deepcopy
+import re
from pydantic import ValidationError
@@ -12,6 +13,31 @@ from app.scenarios.loader import ScenarioError, _derive_ground_truth
from app.scenarios.schema import Scenario
DERIVED_TRUTH = {"incident_type", "dds", "incident_code", "notify", "required_facts"}
+PLACEHOLDER = re.compile(r"\{\{?\s*[a-zA-Z_][\w.-]*\s*\}?\}")
+
+
+def classifier_sign_gaps(signs: list[str], narrative: list[str]) -> list[str]:
+ """Найти признаки ЕКП, которые не подтверждены текстом сценария.
+
+ Сравниваются трёхбуквенные основы, поэтому русские окончания
+ (``жилой дом`` / ``жилого дома``) не создают ложного расхождения. Это
+ намеренно строгая, детерминированная граница: модель не может заявить
+ один объект, а в карточку незаметно положить код другого.
+ """
+ def stems(value: str) -> set[str]:
+ return {
+ word[:3]
+ for word in re.findall(r"[0-9a-zа-яё]+", value.casefold())
+ if len(word) >= 3
+ }
+
+ present = stems(" ".join(narrative))
+ gaps: list[str] = []
+ for sign in signs:
+ required = stems(sign)
+ if required and not required <= present:
+ gaps.append(sign)
+ return gaps
def template_copy(source: Scenario, draft_id: str, title: str | None = None) -> dict:
@@ -92,9 +118,26 @@ def validate(body: dict) -> Scenario:
if item.fact and item.fact not in known:
raise ScenarioError(f"пункт {item.id} ссылается на факт {item.fact}, которого нет")
for fact in scenario.facts:
+ if PLACEHOLDER.search(fact.value) or (fact.refined and PLACEHOLDER.search(fact.refined)):
+ raise ScenarioError(
+ f"факт {fact.id}: остался плейсхолдер вместо конкретного ответа заявителя"
+ )
question_id = fact.reveal_on.question if fact.reveal_on else None
if question_id and question_id not in question_ids:
raise ScenarioError(f"факт {fact.id} раскрывается несуществующим вопросом {question_id}")
if fact.refine_on and fact.refine_on not in question_ids:
raise ScenarioError(f"факт {fact.id} уточняется несуществующим вопросом {fact.refine_on}")
+ # Для старых карточек и безопасных вариаций классификатор уже утверждён
+ # заказчиком. Новый свободный сюжет обязан доказать свой путь ЕКП текстом.
+ if scenario.id.startswith("ai-full-"):
+ gaps = classifier_sign_gaps(
+ scenario.signs,
+ [scenario.title, scenario.first_line]
+ + [fact.value for fact in scenario.facts]
+ + [fact.refined for fact in scenario.facts if fact.refined],
+ )
+ if gaps:
+ raise ScenarioError(
+ "signs: сюжет не подтверждает путь ЕКП: " + ", ".join(gaps)
+ )
return _derive_ground_truth(scenario)
diff --git a/backend/app/scenarios/generation.py b/backend/app/scenarios/generation.py
new file mode 100644
index 0000000..31dec7c
--- /dev/null
+++ b/backend/app/scenarios/generation.py
@@ -0,0 +1,634 @@
+"""Локальная ИИ-вариация утверждённого сценария для проверки преподавателем.
+
+Модель пишет только сюжетные поля. Адрес, число пострадавших, классификацию,
+список оповещения и протокол оценки она менять не может. Публикация — отдельное
+действие преподавателя после просмотра черновика и эталона.
+"""
+
+import json
+import re
+
+from app.config import get_settings
+from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url
+from app.scenarios.editor import classifier_sign_gaps, template_copy, validate
+from app.scenarios.loader import ScenarioError
+from app.scenarios.schema import Scenario
+
+
+class GenerationError(ValueError):
+ pass
+
+
+PROTECTED_IDS = ("address", "people", "victim", "floor", "location", "region")
+PLACEHOLDER = re.compile(r"\{\{?\s*[a-zA-Z_][\w.-]*\s*\}?\}")
+NUMBER_WORD = re.compile(
+ r"\b(?:ноль|один|одна|одно|одного|одну|два|две|двое|двух|три|трое|тр[её]х|"
+ r"четыре|четверо|четыр[её]х|пять|пятеро|пяти|шесть|шестеро|шести|семь|"
+ r"семеро|семи|восемь|восьмеро|восьми|девять|девятеро|девяти|десять|"
+ r"десятеро|десяти)\b",
+ re.IGNORECASE,
+)
+
+
+def reveals_number(value: str) -> bool:
+ return bool(re.search(r"\d", value) or NUMBER_WORD.search(value))
+
+
+def editable_fact_ids(source: Scenario) -> list[str]:
+ return [fact.id for fact in source.facts
+ if not fact.hidden and not fact.refined and not fact.refine_on
+ and not any(part in fact.id.casefold() for part in PROTECTED_IDS)]
+
+
+def correction_target(source: Scenario, instruction: str) -> str | None:
+ """Указать маленькой модели конкретный факт для правки при повторе."""
+ eligible = [fact for fact in source.facts if fact.id in editable_fact_ids(source)]
+ if not eligible:
+ return None
+ def stems(text: str) -> set[str]:
+ return {word[:4] for word in re.findall(r"[а-яё]{3,}", text.casefold())}
+
+ words = stems(instruction)
+ questions = {item.fact: item.question or "" for item in source.checklist if item.fact}
+
+ def relevance(fact) -> int:
+ haystack = stems(f"{fact.value} {questions.get(fact.id, '')}")
+ return len(words & haystack)
+
+ _, best = max(enumerate(eligible), key=lambda pair: (relevance(pair[1]), pair[0]))
+ return best.id if relevance(best) > 0 else None
+
+
+def style_fallback(source: Scenario, instruction: str) -> dict | None:
+ """Безопасно сократить реплику, если 1.7B дважды вернула копию."""
+ if not re.search(r"\b(?:короч|кратк)", instruction.casefold()):
+ return None
+ chunks = re.findall(r"[^.!?…]+[.!?…]+|[^.!?…]+$", source.first_line)
+ useful: list[str] = []
+ for chunk in chunks:
+ value = chunk.strip()
+ letters = re.sub(r"[^а-яё]", "", value.casefold())
+ if not letters or letters in {"алло", "ааа", "аа", "а"}:
+ continue
+ useful.append(value)
+ if not useful:
+ return None
+ shortened = " ".join(useful[:2])[:300].strip()
+ if shortened == source.first_line.strip():
+ return None
+ return {"title": source.title, "first_line": shortened, "facts": {}}
+
+
+def parse_proposal(
+ raw: str, source: Scenario, *, require_fact_change: bool = True,
+ instruction: str = "", restrict_first_line_facts: bool = False,
+) -> dict:
+ """Никакой произвольный JSON модели не попадает в схему сценария."""
+ text = raw.strip()
+ fenced = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
+ if fenced:
+ text = fenced.group(1)
+ try:
+ proposal = json.loads(text)
+ except (ValueError, TypeError) as exc:
+ raise GenerationError("модель не вернула корректный JSON") from exc
+ if not isinstance(proposal, dict) or set(proposal) != {"title", "first_line", "facts"}:
+ raise GenerationError("модель вернула поля вне разрешённого формата")
+ title, first_line, facts = (proposal[key] for key in ("title", "first_line", "facts"))
+ if not isinstance(title, str) or not 5 <= len(title.strip()) <= 200:
+ raise GenerationError("название варианта пустое или слишком длинное")
+ if not isinstance(first_line, str) or not 8 <= len(first_line.strip()) <= 300:
+ raise GenerationError("первая реплика пустая или слишком длинная")
+ if reveals_number(first_line):
+ raise GenerationError("первая реплика раскрывает числовые детали до опроса")
+ if instruction and restrict_first_line_facts:
+ stop = {"есть", "если", "меня", "очень", "этот", "только", "сейчас", "помогите"}
+
+ def stems(value: str) -> set[str]:
+ return {word[:3] for word in re.findall(r"[а-яё]{3,}", value.casefold())
+ if word not in stop}
+
+ fact_stems = set().union(*(stems(fact.value) for fact in source.facts))
+ allowed_stems = stems(f"{source.first_line} {instruction}")
+ leaked = (stems(first_line) & fact_stems) - allowed_stems
+ if leaked:
+ raise GenerationError(
+ "первая реплика добавляет не запрошенные обстоятельства карточки"
+ )
+ if not isinstance(facts, dict):
+ raise GenerationError("факты должны быть объектом")
+ allowed = set(editable_fact_ids(source))
+ if set(facts) - allowed:
+ raise GenerationError("модель попыталась изменить защищённые или неизвестные факты")
+ for fact_id, value in facts.items():
+ if not isinstance(value, str) or not 3 <= len(value.strip()) <= 300:
+ raise GenerationError(f"факт {fact_id}: значение пустое или слишком длинное")
+ if PLACEHOLDER.search(value):
+ raise GenerationError(f"факт {fact_id}: модель вернула плейсхолдер вместо ответа")
+ if "?" in value:
+ raise GenerationError(f"факт {fact_id}: модель вернула вопрос вместо ответа")
+ source_values = {fact.id: fact.value for fact in source.facts}
+ fact_changed = any(value.strip() != source_values[fact_id] for fact_id, value in facts.items())
+ story_changed = title.strip() != source.title or first_line.strip() != source.first_line
+ if require_fact_change and not fact_changed:
+ raise GenerationError("модель не предложила новых обстоятельств происшествия")
+ if not fact_changed and not story_changed:
+ raise GenerationError("модель не изменила черновик по комментарию")
+ return {"title": title.strip(), "first_line": first_line.strip(), "facts": facts}
+
+
+def proposal_body(source: Scenario, draft_id: str, proposal: dict) -> dict:
+ body = template_copy(source, draft_id, proposal["title"])
+ by_id = proposal["facts"]
+ for fact in body["facts"]:
+ if fact["id"] in by_id:
+ fact["value"] = by_id[fact["id"]].strip()
+ body["first_line"] = proposal["first_line"]
+ # Схема, связи фактов и эталон проверяются до сохранения черновика.
+ validate(body)
+ return body
+
+
+def parse_full_proposal(raw: str, source: Scenario) -> dict:
+ """Проверить новый сюжет, где модель заполняет все ответы и мягкий эталон.
+
+ Тип, признаки ЕКП, маршрутизация и чек-лист остаются структурным каркасом,
+ но сами обстоятельства, адрес и число пострадавших создаются заново.
+ """
+ text = raw.strip()
+ fenced = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
+ if fenced:
+ text = fenced.group(1)
+ try:
+ proposal = json.loads(text)
+ except (ValueError, TypeError) as exc:
+ raise GenerationError("модель не вернула корректный JSON нового сюжета") from exc
+ if not isinstance(proposal, dict) or set(proposal) != {
+ "title", "first_line", "facts", "ground_truth"
+ }:
+ raise GenerationError("новый сюжет содержит поля вне разрешённого формата")
+ title = proposal["title"]
+ first_line = proposal["first_line"]
+ facts = proposal["facts"]
+ truth = proposal["ground_truth"]
+ if not isinstance(title, str) or not 5 <= len(title.strip()) <= 200:
+ raise GenerationError("название нового сюжета пустое или слишком длинное")
+ if not isinstance(first_line, str) or not 8 <= len(first_line.strip()) <= 300:
+ raise GenerationError("первая реплика нового сюжета пустая или слишком длинная")
+ if reveals_number(first_line):
+ raise GenerationError("первая реплика раскрывает числовые детали до опроса")
+ expected = {fact.id for fact in source.facts}
+ if not isinstance(facts, dict) or set(facts) != expected:
+ raise GenerationError("новый сюжет должен заполнить каждый факт каркаса ровно один раз")
+ for fact_id, value in facts.items():
+ if not isinstance(value, str) or not 3 <= len(value.strip()) <= 300:
+ raise GenerationError(f"факт {fact_id}: значение пустое или слишком длинное")
+ if PLACEHOLDER.search(value):
+ raise GenerationError(f"факт {fact_id}: модель вернула плейсхолдер вместо ответа")
+ if "?" in value:
+ raise GenerationError(f"факт {fact_id}: модель вернула вопрос вместо ответа")
+ if not isinstance(truth, dict) or set(truth) != {"address", "victims"}:
+ raise GenerationError("ground_truth нового сюжета должен содержать address и victims")
+ address, victims = truth["address"], truth["victims"]
+ if not isinstance(address, str) or not 5 <= len(address.strip()) <= 200:
+ raise GenerationError("эталонный адрес пустой или слишком длинный")
+ if not isinstance(victims, int) or isinstance(victims, bool) or not 0 <= victims <= 20:
+ raise GenerationError("число пострадавших должно быть целым от 0 до 20")
+ address_id = next((fact.id for fact in source.facts if "address" in fact.id.casefold()), None)
+ if address_id is None:
+ raise GenerationError("в выбранном каркасе нет адресного факта")
+
+ def normalized(value: str) -> str:
+ return re.sub(r"\s+", " ", value.casefold().replace("ё", "е")).strip(" .,")
+
+ if not normalized(facts[address_id]).startswith(normalized(address)):
+ raise GenerationError("эталонный адрес не совпадает с началом адресного факта")
+ evidence = " ".join(facts.values()).casefold().replace("ё", "е")
+ count_words = {
+ 0: ("ноль",), 1: ("один", "одна", "одного"),
+ 2: ("два", "двое", "двух"), 3: ("три", "трое", "трех"),
+ 4: ("четыре", "четверо", "четырех"), 5: ("пять", "пятеро", "пяти"),
+ 6: ("шесть", "шестеро", "шести"), 7: ("семь", "семеро", "семи"),
+ 8: ("восемь", "восьмеро", "восьми"), 9: ("девять", "девятеро", "девяти"),
+ 10: ("десять", "десятеро", "десяти"),
+ }
+ numeric = re.search(rf"пострадавш\w*\s*[:—-]?\s*{victims}(?:\D|$)", evidence)
+ worded = any(re.search(
+ rf"(?:пострадавш\w*.{{0,25}}\b{word}\b|\b{word}\b.{{0,25}}пострада\w*)", evidence
+ ) for word in count_words.get(victims, ()))
+ none_hurt = victims == 0 and re.search(
+ r"пострадавш(?:их|ие|их лиц)\s+нет|никто\s+не\s+пострадал|"
+ r"все\s+(?:люди\s+)?(?:успели\s+)?(?:выйти|эвакуироваться)", evidence
+ )
+ if not (numeric or worded or none_hurt):
+ raise GenerationError("в фактах нет явного подтверждения числа пострадавших")
+ old_values = {fact.id: fact.value.strip() for fact in source.facts}
+ changed = sum(value.strip() != old_values[fact_id] for fact_id, value in facts.items())
+ if changed < max(1, (len(facts) + 1) // 2):
+ raise GenerationError("новый сюжет слишком близко копирует исходный каркас")
+ if normalized(address) in normalized(first_line):
+ raise GenerationError("первая реплика раскрывает адрес до опроса")
+ return {
+ "title": title.strip(), "first_line": first_line.strip(),
+ "facts": {key: value.strip() for key, value in facts.items()},
+ "ground_truth": {"address": address.strip(), "victims": victims},
+ }
+
+
+def full_proposal_body(source: Scenario, draft_id: str, proposal: dict) -> dict:
+ """Собрать самостоятельный сценарий из проверенного нового сюжета."""
+ body = template_copy(source, draft_id, proposal["title"])
+ for fact in body["facts"]:
+ fact["value"] = proposal["facts"][fact["id"]]
+ # Старое уточнение относится к старому сюжету и не переносится.
+ fact["refined"] = None
+ fact["refine_on"] = None
+ body["first_line"] = proposal["first_line"]
+ body["ground_truth"] = dict(proposal["ground_truth"])
+ validate(body)
+ return body
+
+
+async def generate_from_description(source: Scenario, description: str) -> dict:
+ """Создать все обстоятельства и мягкий эталон внутри выбранной категории ЕКП.
+
+ Qwen 1.7B заметно надёжнее отвечает на один вопрос за раз. Поэтому вместо
+ одного длинного JSON, где малая модель копировала вопросы и старую карточку,
+ используются короткие строгие запросы. Итог всё равно проходит единую
+ сквозную проверку непротиворечивости.
+ """
+ settings = get_settings()
+ if (not is_loopback_url(
+ settings.llm_base_url,
+ allow_docker_host=settings.allow_docker_host_models,
+ ) or not settings.llm_model_caller):
+ raise LlmUnavailable("для генерации нужна разрешённая локальная модель")
+ questions = {item.fact: item.question for item in source.checklist if item.fact}
+ address_id = next((fact.id for fact in source.facts if "address" in fact.id.casefold()), None)
+ if address_id is None:
+ raise GenerationError("в выбранном каркасе нет адресного факта")
+
+ def victim_relevance(fact_id: str) -> int:
+ text = f"{fact_id} {questions.get(fact_id, '')}".casefold()
+ return sum(word in text for word in (
+ "people", "victim", "injur", "who", "люд", "пострад", "кто", "кому"
+ ))
+
+ victim_id = max((fact.id for fact in source.facts), key=victim_relevance)
+ client = LlmClient(base_url=settings.llm_base_url, timeout=45)
+
+ def clean_answer(value: object) -> str:
+ """Нормализовать пробелы и ограничить ответ без порчи `ул.`/`д.`."""
+ text = re.sub(r"\s+", " ", str(value)).strip()
+ for marker in (", но", ". В данном", ". Однако", ". Необходимо",
+ ". Рекомендуется", ". Следует"):
+ text = text.split(marker, 1)[0]
+ return text[:300].strip()
+
+ def validate_value(data: dict, *, extra_key: str | None = None) -> None:
+ value = clean_answer(data.get("value", ""))
+ if not 3 <= len(value) <= 300:
+ raise GenerationError("значение пустое или слишком длинное")
+ if PLACEHOLDER.search(value):
+ raise GenerationError("модель вернула плейсхолдер вместо ответа")
+ if "?" in value:
+ raise GenerationError("модель вернула вопрос вместо ответа")
+ if extra_key is not None and extra_key not in data:
+ raise GenerationError(f"нет обязательного поля {extra_key}")
+
+ def validate_headline(data: dict) -> None:
+ title = clean_answer(data.get("title", ""))
+ first_line = clean_answer(data.get("first_line", ""))
+ if not 5 <= len(title) <= 200:
+ raise GenerationError("название пустое или слишком длинное")
+ if not 8 <= len(first_line) <= 300:
+ raise GenerationError("первая реплика пустая или слишком длинная")
+ if reveals_number(first_line):
+ raise GenerationError("первая реплика раскрывает числовые детали")
+
+ def validate_victims(data: dict) -> None:
+ validate_value(data, extra_key="victims")
+ victims = data["victims"]
+ if not isinstance(victims, int) or isinstance(victims, bool) or not 0 <= victims <= 20:
+ raise GenerationError("victims должен быть целым от 0 до 20")
+
+ def victim_answer(count: int) -> str:
+ """Число модели сохраняем, формулировку делаем воспроизводимой и грамотной."""
+ if count == 0:
+ return "Никто не пострадал. Пострадавших: 0"
+ if count == 1:
+ return "Пострадал один человек. Пострадавших: 1"
+ noun = "человека" if count % 10 in (2, 3, 4) and count % 100 not in (12, 13, 14) else "человек"
+ return f"Пострадали {count} {noun}. Пострадавших: {count}"
+
+ async def ask(
+ system: str, payload: dict, schema: dict, *, max_tokens: int = 180,
+ validator=None,
+ ) -> dict:
+ last_error: Exception | None = None
+ raw = ""
+ for attempt in range(3):
+ messages = [
+ {"role": "system", "content": system},
+ {"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
+ ]
+ if attempt:
+ messages += [
+ {"role": "assistant", "content": raw},
+ {"role": "user", "content": (
+ f"Ответ отклонён: {last_error}. Верни исправленный JSON. "
+ "Нужен конкретный ответ, не повторяй вопрос."
+ )},
+ ]
+ try:
+ raw = await client.complete(LlmRequest(
+ messages=messages, model=settings.llm_model_caller,
+ temperature=0.25, max_tokens=max_tokens,
+ response_format={"type": "json_object", "schema": schema},
+ strip_reasoning=True,
+ ), use_cache=False)
+ data = json.loads(raw)
+ if not isinstance(data, dict) or set(data) != set(schema["required"]):
+ raise GenerationError("неверный набор полей")
+ if validator is not None:
+ validator(data)
+ return data
+ except (ValueError, TypeError, GenerationError, LlmUnavailable) as exc:
+ last_error = exc
+ if isinstance(exc, LlmUnavailable) and not any(marker in str(exc) for marker in (
+ "служебные токены", "пустой ответ модели"
+ )):
+ raise
+ raise GenerationError(f"локальная модель не заполнила часть сценария: {last_error}")
+
+ value_schema = {
+ "type": "object", "properties": {"value": {"type": "string"}},
+ "required": ["value"], "additionalProperties": False,
+ }
+ try:
+ # Заголовочный запрос не получает числовые детали даже словами: малая
+ # модель склонна повторять их в первой реплике, раскрывая ответ до
+ # обязательного вопроса оператора. Полное описание остаётся у запросов
+ # фактов ниже, поэтому эталон числа пострадавших не теряется.
+ headline_description = re.sub(r"\d+", "", description)
+ headline_description = re.sub(r"\s+", " ", NUMBER_WORD.sub("", headline_description)).strip()
+ headline = await ask(
+ "Создай название и первую эмоциональную реплику заявителя для учебного вызова 112. "
+ "Верни JSON title и first_line. first_line без цифр, адреса и служебных пояснений. "
+ "classifier_signs — обязательный путь классификатора ЕКП: сюжет не должен ему "
+ "противоречить. Если свободное description конфликтует с classifier_signs, "
+ "приоритет всегда у classifier_signs.",
+ {"incident_type": source.type.value, "classifier_signs": source.signs,
+ "level": source.level.value, "description": headline_description},
+ {"type": "object", "properties": {
+ "title": {"type": "string"}, "first_line": {"type": "string"},
+ }, "required": ["title", "first_line"], "additionalProperties": False},
+ max_tokens=220,
+ validator=validate_headline,
+ )
+ facts: dict[str, str] = {}
+ address = ""
+ victims: int | None = None
+ for fact in source.facts:
+ question = questions.get(fact.id, fact.id)
+ base_payload = {
+ "description": description, "incident_type": source.type.value,
+ "classifier_signs": source.signs,
+ "question": question, "already_known_answers": facts,
+ }
+ if fact.id == address_id:
+ data = await ask(
+ "Придумай конкретный вымышленный московский адрес для учебного сценария. "
+ "Верни JSON value и address. value должно начинаться дословно с address, "
+ "после него можно добавить квартиру, этаж или ориентир. Это ответ, не вопрос. "
+ "Адрес и тип объекта не должны противоречить classifier_signs.",
+ base_payload,
+ {"type": "object", "properties": {
+ "value": {"type": "string"}, "address": {"type": "string"},
+ }, "required": ["value", "address"], "additionalProperties": False},
+ validator=lambda data: validate_value(data, extra_key="address"),
+ )
+ facts[fact.id] = clean_answer(data["value"])
+ # Эталон выводится из факта, а не из второго пересказа модели:
+ # малая Qwen иногда меняет пунктуацию или номер между полями.
+ address = facts[fact.id][:200].rstrip(" ,.;")
+ elif fact.id == victim_id:
+ data = await ask(
+ "Ответь на вопрос заявителя конкретным фактом по описанию. Верни JSON value "
+ "и victims. victims — число пострадавших от 0 до 20. value обязательно "
+ "заканчивается точной фразой `Пострадавших: N`, где N равно victims. "
+ "Не пиши вопрос и не считай всех присутствующих пострадавшими. "
+ "Ответ не должен противоречить classifier_signs.",
+ base_payload,
+ {"type": "object", "properties": {
+ "value": {"type": "string"},
+ "victims": {"type": "integer", "minimum": 0, "maximum": 20},
+ }, "required": ["value", "victims"], "additionalProperties": False},
+ validator=validate_victims,
+ )
+ victims = data["victims"] if isinstance(data["victims"], int) else None
+ if victims is not None:
+ facts[fact.id] = victim_answer(victims)
+ else:
+ data = await ask(
+ "Ответь по-русски конкретным обстоятельством заявителя на question, используя "
+ "description, classifier_signs и already_known_answers. classifier_signs — "
+ "обязательный путь ЕКП и важнее конфликтующих деталей description. Верни JSON "
+ "только с ключом value. value — ответ, не вопрос; не используй знак вопроса "
+ "и плейсхолдеры.",
+ base_payload, value_schema, validator=validate_value,
+ )
+ facts[fact.id] = clean_answer(data["value"])
+ if victims is None:
+ raise GenerationError("модель не указала число пострадавших")
+ gaps = classifier_sign_gaps(
+ source.signs,
+ [str(headline["title"]), str(headline["first_line"]), *facts.values()],
+ )
+ if gaps:
+ target = next(
+ fact for fact in source.facts
+ if fact.id not in {address_id, victim_id}
+ )
+
+ def validate_alignment(data: dict) -> None:
+ validate_value(data)
+ candidate = dict(facts)
+ candidate[target.id] = clean_answer(data["value"])
+ missing = classifier_sign_gaps(
+ source.signs,
+ [str(headline["title"]), str(headline["first_line"]), *candidate.values()],
+ )
+ if missing:
+ raise GenerationError(
+ "ответ не подтверждает признаки ЕКП: " + ", ".join(missing)
+ )
+
+ aligned = await ask(
+ "Перепиши один ответ заявителя так, чтобы он оставался конкретным ответом на "
+ "question и дословно, осмысленно подтверждал все missing_classifier_signs. "
+ "Это обязательные признаки ЕКП, их нельзя заменять другим объектом или событием. "
+ "Верни JSON только с ключом value, без вопроса и служебных пояснений.",
+ {
+ "description": description,
+ "question": questions.get(target.id, target.id),
+ "current_answer": facts[target.id],
+ "missing_classifier_signs": gaps,
+ "all_classifier_signs": source.signs,
+ "already_known_answers": facts,
+ },
+ value_schema,
+ max_tokens=220,
+ validator=validate_alignment,
+ )
+ facts[target.id] = clean_answer(aligned["value"])
+ first_line = str(headline["first_line"]).strip()
+ if first_line and first_line[-1] not in ".!?…":
+ first_line += "!"
+ proposal = {
+ "title": headline["title"], "first_line": first_line,
+ "facts": facts, "ground_truth": {"address": address, "victims": victims},
+ }
+ proposal = parse_full_proposal(json.dumps(proposal, ensure_ascii=False), source)
+ full_proposal_body(source, "ai-full-validation", proposal)
+ return proposal
+ finally:
+ await client.aclose()
+
+
+async def generate(
+ source: Scenario, instruction: str, *, require_fact_change: bool = True
+) -> dict:
+ settings = get_settings()
+ if (not is_loopback_url(
+ settings.llm_base_url,
+ allow_docker_host=settings.allow_docker_host_models,
+ ) or not settings.llm_model_caller):
+ raise LlmUnavailable("для генерации нужна разрешённая локальная модель")
+ all_editable = editable_fact_ids(source)
+ target = correction_target(source, instruction)
+ style_only = target is None and not require_fact_change
+ # Для 1.7B один комментарий = один изменяемый факт. Это не даёт модели
+ # «заодно» испортить газ, пока преподаватель просит сделать плотнее дым.
+ editable = [] if style_only else ([target] if target else all_editable)
+ fact_questions = {item.fact: item.question for item in source.checklist
+ if item.fact and item.question}
+ context = {
+ "incident_type": source.type.value,
+ "source_title": source.title,
+ "editable_facts": {
+ fact.id: {"question": fact_questions.get(fact.id, fact.id), "old_answer": fact.value}
+ for fact in source.facts if fact.id in editable
+ },
+ }
+ if not require_fact_change:
+ context["current_first_line"] = source.first_line
+ change_rule = (
+ "Измени обстоятельства хотя бы одного факта: дословная копия недопустима. "
+ if require_fact_change else
+ "Измени черновик по комментарию: допустимо менять только заголовок или first_line; "
+ "полностью прежняя версия недопустима. "
+ )
+ if style_only:
+ change_rule += (
+ "Это стилистическая правка: не добавляй в first_line новые обстоятельства, "
+ "которых нет в current_first_line или комментарии. facts оставь пустым. "
+ )
+ system = (
+ "Сочини НОВУЮ вариацию учебного вызова 112 на русском языке. "
+ "Верни ровно JSON с тремя ключами: title, first_line, facts. "
+ "facts содержит только id из editable_facts. " + change_rule +
+ "Значения facts — НОВЫЕ ответы "
+ "заявителя на question, не повторение вопроса. Не добавляй ключи. "
+ "first_line — прямая речь заявителя без цифр и адреса. "
+ "Тип происшествия, адрес и люди не меняются; их не включай в facts. "
+ "Не придумывай службы, коды и регламенты."
+ )
+ request = LlmRequest(
+ messages=[{"role": "system", "content": system},
+ {"role": "user", "content": json.dumps(
+ {"source": context, "request": instruction}, ensure_ascii=False)}],
+ model=settings.llm_model_caller, temperature=0.7, max_tokens=500,
+ response_format={"type": "json_object", "schema": {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "first_line": {"type": "string"},
+ "facts": {"type": "object", "properties": {
+ fact_id: {"type": "string"} for fact_id in editable
+ }, "additionalProperties": False},
+ },
+ "required": ["title", "first_line", "facts"],
+ "additionalProperties": False,
+ }},
+ strip_reasoning=True,
+ )
+ client = LlmClient(base_url=settings.llm_base_url, timeout=45)
+
+ async def complete_safely(candidate: LlmRequest) -> str:
+ last_error: LlmUnavailable | None = None
+ for _ in range(2):
+ try:
+ return await client.complete(candidate, use_cache=False)
+ except LlmUnavailable as exc:
+ last_error = exc
+ if not any(marker in str(exc) for marker in (
+ "служебные токены", "пустой ответ модели"
+ )):
+ raise
+ assert last_error is not None
+ raise last_error
+
+ try:
+ raw = await complete_safely(request)
+ try:
+ proposal = parse_proposal(
+ raw, source, require_fact_change=require_fact_change, instruction=instruction,
+ restrict_first_line_facts=style_only,
+ )
+ except GenerationError as first_error:
+ feedback = (
+ f"Предыдущий ответ отклонён: {first_error}. "
+ "Верни исправленный JSON той же схемы. "
+ "Значения facts должны быть ответами заявителя, не вопросами. "
+ "Запрещены плейсхолдеры вида {fact_id}: пиши конкретный русский текст. "
+ )
+ if target:
+ old = next(fact.value for fact in source.facts if fact.id == target)
+ feedback += (
+ f"Обязательно замени факт {target}. Старый ответ: {old}. "
+ f"Новый ответ должен учитывать запрос: {instruction}. "
+ "Не повторяй старый ответ дословно."
+ )
+ retry = LlmRequest(
+ messages=[*request.messages, {"role": "assistant", "content": raw},
+ {"role": "user", "content": feedback}],
+ model=request.model, temperature=0.8, max_tokens=request.max_tokens,
+ response_format=request.response_format,
+ strip_reasoning=True,
+ )
+ raw = await complete_safely(retry)
+ try:
+ proposal = parse_proposal(
+ raw, source, require_fact_change=require_fact_change, instruction=instruction,
+ restrict_first_line_facts=style_only,
+ )
+ except GenerationError:
+ fallback = style_fallback(source, instruction) if not require_fact_change else None
+ if fallback is None:
+ raise
+ # Даже детерминированный fallback проходит те же запреты утечек.
+ proposal = parse_proposal(
+ json.dumps(fallback, ensure_ascii=False), source,
+ require_fact_change=False, instruction=instruction,
+ restrict_first_line_facts=style_only,
+ )
+ finally:
+ await client.aclose()
+ # Проверка на фиксированном ID гарантирует, что генерация не выдаст
+ # черновик, который потом невозможно утвердить из-за сломанной схемы.
+ try:
+ proposal_body(source, "ai-validation", proposal)
+ except ScenarioError as exc:
+ raise GenerationError(f"сгенерированный сценарий не прошёл проверку: {exc}") from exc
+ return proposal
diff --git a/backend/app/scenarios/store.py b/backend/app/scenarios/store.py
index 9a32270..36aed18 100644
--- a/backend/app/scenarios/store.py
+++ b/backend/app/scenarios/store.py
@@ -16,6 +16,18 @@ from app.scenarios.schema import Scenario
from app.scenarios import editor
_library: dict[str, Scenario] = {}
+_demo_drafts: dict[str, ScenarioRow] = {}
+_demo_archived: dict[str, Scenario] = {}
+_demo_scenario_owners: dict[str, str] = {}
+_demo_archived_owners: dict[str, str] = {}
+
+
+def reset_demo_drafts() -> None:
+ """Черновики и архив demo-lite живут только в текущем процессе."""
+ _demo_drafts.clear()
+ _demo_archived.clear()
+ _demo_scenario_owners.clear()
+ _demo_archived_owners.clear()
def set_library(scenarios: list[Scenario]) -> None:
@@ -56,6 +68,7 @@ async def seed(db: AsyncSession, scenarios: list[Scenario]) -> int:
row.topics = scenario.topics
row.modes = scenario.modes
row.body = payload
+ row.owner_login = None
await db.commit()
return len(scenarios)
@@ -66,21 +79,128 @@ async def restore_published(db: AsyncSession) -> int:
Вызвать после ``load_from_disk``. Базовую библиотеку не перезаписываем:
её источником остаются проверенные YAML из репозитория.
"""
- rows = await db.scalars(select(ScenarioRow).where(ScenarioRow.status == "published"))
- restored = 0
+ rows = await db.scalars(select(ScenarioRow).where(
+ ScenarioRow.status.in_(["published", "archived"])
+ ))
+ delta = 0
for row in rows:
+ if row.status == "archived":
+ if row.owner_login:
+ _demo_scenario_owners.pop(row.id, None)
+ _demo_archived_owners[row.id] = row.owner_login
+ if _library.pop(row.id, None) is not None:
+ delta -= 1
+ continue
+ if row.owner_login:
+ _demo_scenario_owners[row.id] = row.owner_login
if row.id in _library:
continue
_library[row.id] = Scenario.model_validate(row.body)
- restored += 1
- return restored
+ delta += 1
+ return delta
+
+
+async def owned_scenario_ids(db: AsyncSession | None, owner_login: str) -> set[str]:
+ """IDs the current instructor may edit/archive; base and legacy rows are read-only."""
+ if db is None:
+ return {
+ scenario_id for scenario_id, owner in _demo_scenario_owners.items()
+ if owner == owner_login and scenario_id in _library
+ }
+ rows = await db.scalars(select(ScenarioRow.id).where(
+ ScenarioRow.owner_login == owner_login,
+ ScenarioRow.status == "published",
+ ))
+ values = set(rows)
+ # Test doubles and alternate SQLAlchemy result wrappers may return rows
+ # for a scalar selection; normalize them without leaking owner data.
+ return {value if isinstance(value, str) else value.id for value in values}
+
+
+async def archive(
+ db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
+) -> Scenario | None:
+ """Скрыть опубликованный сценарий без удаления истории и внешних ключей."""
+ scenario = _library.get(scenario_id)
+ if scenario is None:
+ return None
+ if db is None:
+ owner = _demo_scenario_owners.get(scenario_id)
+ if owner_login is not None and owner != owner_login:
+ return None
+ _demo_archived[scenario_id] = scenario
+ if owner is not None:
+ _demo_archived_owners[scenario_id] = owner
+ _demo_scenario_owners.pop(scenario_id, None)
+ else:
+ row = await db.get(ScenarioRow, scenario_id)
+ if owner_login is not None and (row is None or row.owner_login != owner_login):
+ return None
+ if row is None:
+ row = ScenarioRow(
+ id=scenario.id,
+ title=scenario.title,
+ incident_type=scenario.type.value,
+ level=scenario.level.value,
+ topics=scenario.topics,
+ modes=scenario.modes,
+ body=scenario.model_dump(mode="json"),
+ )
+ db.add(row)
+ row.status = "archived"
+ await db.commit()
+ _library.pop(scenario_id, None)
+ return scenario
+
+
+async def restore_archived(
+ db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
+) -> Scenario | None:
+ """Вернуть мягко удалённый сценарий в библиотеку назначения."""
+ if db is None:
+ owner = _demo_archived_owners.get(scenario_id)
+ if owner_login is not None and owner != owner_login:
+ return None
+ scenario = _demo_archived.pop(scenario_id, None)
+ if scenario is not None and owner is not None:
+ _demo_scenario_owners[scenario_id] = owner
+ _demo_archived_owners.pop(scenario_id, None)
+ else:
+ row = await db.get(ScenarioRow, scenario_id)
+ if (row is None or row.status != "archived"
+ or owner_login is not None and row.owner_login != owner_login):
+ return None
+ # В опубликованном body уже лежат вычисленные поля ground_truth (ЕКП,
+ # ДДС, список оповещения). Редакторская validate предназначена для
+ # черновика до вычисления и закономерно запрещает такие поля.
+ scenario = Scenario.model_validate(row.body)
+ row.status = "published"
+ await db.commit()
+ if scenario is None:
+ return None
+ publish(scenario)
+ return scenario
async def create_draft(
- db: AsyncSession, *, source: Scenario, title: str | None = None
+ db: AsyncSession | None, *, source: Scenario, title: str | None = None,
+ proposal: dict | None = None, full_proposal: dict | None = None,
+ owner_login: str | None = None,
) -> ScenarioRow:
- draft_id = f"draft-{uuid4().hex}"
- body = editor.template_copy(source, draft_id, title)
+ if proposal is not None and full_proposal is not None:
+ raise ValueError("нельзя одновременно передать вариацию и полный сюжет")
+ prefix = "ai-full" if full_proposal is not None else "ai" if proposal is not None else "draft"
+ draft_id = f"{prefix}-{uuid4().hex}"
+ if full_proposal is not None:
+ from app.scenarios.generation import full_proposal_body
+
+ body = full_proposal_body(source, draft_id, full_proposal)
+ elif proposal is None:
+ body = editor.template_copy(source, draft_id, title)
+ else:
+ from app.scenarios.generation import proposal_body
+
+ body = proposal_body(source, draft_id, proposal)
row = ScenarioRow(
id=draft_id,
title=body["title"],
@@ -89,26 +209,53 @@ async def create_draft(
topics=body["topics"],
modes=body["modes"],
status="draft",
+ owner_login=owner_login,
body=body,
)
- db.add(row)
- await db.commit()
+ if db is None:
+ _demo_drafts[row.id] = row
+ else:
+ db.add(row)
+ await db.commit()
return row
-async def draft(db: AsyncSession, scenario_id: str) -> ScenarioRow | None:
- row = await db.get(ScenarioRow, scenario_id)
- return row if row is not None and row.status == "draft" else None
+async def draft(
+ db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
+) -> ScenarioRow | None:
+ row = _demo_drafts.get(scenario_id) if db is None else await db.get(ScenarioRow, scenario_id)
+ if row is None or row.status != "draft":
+ return None
+ if owner_login is not None and row.owner_login != owner_login:
+ return None
+ return row
-async def update_draft(db: AsyncSession, row: ScenarioRow, patch: dict) -> ScenarioRow:
+async def update_draft(db: AsyncSession | None, row: ScenarioRow, patch: dict) -> ScenarioRow:
row.body = editor.merge_patch(row.body, patch)
row.title = str(row.body.get("title") or "")[:200]
- await db.commit()
+ if db is not None:
+ await db.commit()
return row
-async def approve_draft(db: AsyncSession, row: ScenarioRow) -> Scenario:
+async def revise_draft(db: AsyncSession | None, row: ScenarioRow, proposal: dict) -> ScenarioRow:
+ """Заменить сюжетную версию того же черновика после комментария преподавателя."""
+ from app.scenarios.generation import proposal_body
+
+ source = editor.validate(row.body)
+ row.body = proposal_body(source, row.id, proposal)
+ row.title = row.body["title"]
+ row.incident_type = row.body["type"]
+ row.level = row.body["level"]
+ row.topics = row.body["topics"]
+ row.modes = row.body["modes"]
+ if db is not None:
+ await db.commit()
+ return row
+
+
+async def approve_draft(db: AsyncSession | None, row: ScenarioRow) -> Scenario:
scenario = editor.validate(row.body)
row.title = scenario.title
row.incident_type = scenario.type.value
@@ -117,6 +264,11 @@ async def approve_draft(db: AsyncSession, row: ScenarioRow) -> Scenario:
row.modes = scenario.modes
row.body = scenario.model_dump(mode="json")
row.status = "published"
- await db.commit()
+ if db is None:
+ _demo_drafts.pop(row.id, None)
+ if row.owner_login is not None:
+ _demo_scenario_owners[row.id] = row.owner_login
+ else:
+ await db.commit()
publish(scenario)
return scenario
diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py
index f5a801b..7d3d695 100644
--- a/backend/app/scoring/dispatcher.py
+++ b/backend/app/scoring/dispatcher.py
@@ -130,16 +130,13 @@ def evaluate_dispatcher(
def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
- Веса предварительные — до утверждения методистом. Неподтверждённые
- телефонные действия нельзя засчитывать по одной кнопке статуса.
+ Веса предварительные — до утверждения методистом. Телефон — возможный
+ источник информации, но не обязательный шлюз статуса.
"""
metrics: list[Metric] = []
- for service in state.notified_services():
+ for service in state.managed_services():
marks = [mark for mark in state.status_log if mark.service == service]
statuses = {mark.status for mark in marks}
- reports = {report.phase for report in state.phone_reports if report.service == service}
- selected_crew = state.crew_assignments.get(service)
- selected = selected_crew is not None
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
metrics.append(Metric(
@@ -153,7 +150,7 @@ def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
if primary and state.dispatched_at else None)
add("dds_primary", "решение по карточке", primary is not None,
primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0)
- add("dds_ack", "первичная отметка за 30 с",
+ add("dds_ack", f"первичная отметка за {deadline_ms // 1000} с",
elapsed is not None and elapsed <= deadline_ms,
f"{elapsed / 1000:.1f} с" if elapsed is not None else "не отмечено",
f"≤ {deadline_ms // 1000} с")
@@ -164,22 +161,14 @@ def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
primary.status.value, "профильную заявку принять", 2.0)
if primary.status is ServiceStatus.DECLINED:
continue
- add("dds_crew", "ручной выбор бригады", selected,
- selected_crew or "бригада не выбрана", "выбрать конкретную бригаду", 1.5)
- add("dds_contact", "связь с бригадой", bool(reports),
- ", ".join(sorted(reports)) or "докладов нет", "получить доклад о выезде", 1.5)
expected = {
- "dispatched": ServiceStatus.RESPONDING,
- "arrived": ServiceStatus.ARRIVED,
- "working": ServiceStatus.WORKING,
+ ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING,
}
- aligned = all(status in statuses for phase, status in expected.items() if phase in reports)
- all_progress = all(phase in reports and status in statuses for phase, status in expected.items())
- add("dds_progress", "статусы по докладам", aligned and all_progress,
+ add("dds_progress", "ведение хода реагирования", expected <= statuses,
", ".join(status.value for status in statuses) or "статусов нет",
- "выезд, прибытие и работы отмечены после докладов", 2.0)
- add("dds_completion", "закрытие работ по докладу",
- "completed" in reports and ServiceStatus.COMPLETED in statuses,
+ "начало реагирования, прибытие и работы отмечены по полученной информации", 2.0)
+ add("dds_completion", "закрытие работ",
+ ServiceStatus.COMPLETED in statuses,
"завершено" if ServiceStatus.COMPLETED in statuses else "не завершено",
- "доклад о завершении и статус «Работы завершены»")
+ "по факту поставить статус «Работы завершены»")
return metrics
diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py
index 2774586..bc99c95 100644
--- a/backend/app/scoring/export.py
+++ b/backend/app/scoring/export.py
@@ -37,13 +37,19 @@ def to_csv(report: SessionReport) -> bytes:
row("Занятие", "", "Сценарий", report.scenario_id)
row("Занятие", "", "Режим", report.mode.value)
row("Занятие", "", "Попытка", report.attempt)
+ row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
+ row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
+ row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
+ row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
+ row("Критерии", "", "Нарушенных метрик", report.failed_metrics)
+ row("Критерии", "", "Зачёт", "да" if report.passed else "нет")
row("Оценка", "", "Автоматическая", report.score_auto)
row("Оценка", "", "Итоговая", report.score_final)
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
row("Оценка", "", "Причина изменения", report.override_comment)
for number, item in enumerate(report.metrics, 1):
- row("Метрики", number, item.title, item.fact, f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; источник: {item.ref or ''}")
+ row("Метрики", number, item.title, item.fact, f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; вес: {item.weight:g}; источник: {item.ref or ''}")
for number, item in enumerate(report.findings, 1):
row("Ошибки", number, item.code.value, item.summary, f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}")
for number, item in enumerate(report.competencies, 1):
@@ -138,6 +144,12 @@ def to_pdf(report: SessionReport) -> bytes:
("Сценарий", report.scenario_id),
("Режим", report.mode.value),
("Попытка", report.attempt),
+ ("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
+ ("Допустимо ошибок", report.criteria.allowed_errors),
+ ("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
+ ("Переопределения весов", report.criteria.score_weights or "стандартные"),
+ ("Нарушенных метрик", report.failed_metrics),
+ ("Результат", "зачёт" if report.passed else "не зачтено"),
("Автоматическая оценка", report.score_auto),
("Итоговая оценка", report.score_final),
):
@@ -155,7 +167,7 @@ def to_pdf(report: SessionReport) -> bytes:
if not report.metrics:
story.append(p("Нет данных", muted))
for item in report.metrics:
- story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'}"))
+ story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'} (вес {item.weight:g})"))
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm}. {item.ref or ''}", muted))
section("Выявленные ошибки")
@@ -226,3 +238,74 @@ def to_pdf(report: SessionReport) -> bytes:
document.build(story, onFirstPage=footer, onLaterPages=footer)
return output.getvalue()
+
+
+def certificate_pdf(
+ *, trainee_name: str, trainee_id: object, group_name: str | None,
+ attempts: int, average_score: float, issued_at: object,
+) -> bytes:
+ """Одностраничный сертификат по фактически сохранённым результатам."""
+ from reportlab.lib import colors
+ from reportlab.lib.enums import TA_CENTER
+ from reportlab.lib.pagesizes import A4, landscape
+ from reportlab.lib.styles import ParagraphStyle
+ from reportlab.pdfbase import pdfmetrics
+ from reportlab.pdfbase.ttfonts import TTFont
+ from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
+
+ regular_path, bold_path = _font_paths()
+ regular_name = "LCTCertificateRegular"
+ bold_name = "LCTCertificateBold"
+ if regular_name not in pdfmetrics.getRegisteredFontNames():
+ regular = TTFont(regular_name, str(regular_path))
+ if not all(ord(char) in regular.face.charToGlyph for char in "Сертификат Ёж"):
+ raise RuntimeError(f"Шрифт не поддерживает кириллицу: {regular_path}")
+ pdfmetrics.registerFont(regular)
+ pdfmetrics.registerFont(TTFont(bold_name, str(bold_path or regular_path)))
+
+ title = ParagraphStyle(
+ "certificate-title", fontName=bold_name, fontSize=28, leading=34,
+ alignment=TA_CENTER, textColor=colors.HexColor("#193b56"), spaceAfter=20,
+ )
+ body = ParagraphStyle(
+ "certificate-body", fontName=regular_name, fontSize=14, leading=21,
+ alignment=TA_CENTER, textColor=colors.HexColor("#18202a"), spaceAfter=10,
+ )
+ name_style = ParagraphStyle(
+ "certificate-name", parent=body, fontName=bold_name, fontSize=22, leading=28,
+ textColor=colors.HexColor("#a24816"), spaceAfter=18,
+ )
+ small = ParagraphStyle(
+ "certificate-small", parent=body, fontSize=9, leading=13,
+ textColor=colors.HexColor("#5d6873"),
+ )
+ safe_name = escape(trainee_name)
+ safe_group = escape(group_name or "без группы")
+ safe_date = escape(str(issued_at))
+ story = [
+ Spacer(1, 34),
+ Paragraph("СЕРТИФИКАТ", title),
+ Paragraph("о прохождении учебных занятий в локальном тренажёре системы-112 и ДДС", body),
+ Spacer(1, 12),
+ Paragraph(safe_name, name_style),
+ Paragraph(
+ f"Выполнено занятий: {attempts} · средняя итоговая оценка: "
+ f"{average_score:.1f} из 100 ", body,
+ ),
+ Paragraph(f"Учебная группа: {safe_group}", body),
+ Spacer(1, 28),
+ Paragraph(f"Дата формирования: {safe_date}", small),
+ Paragraph(f"Идентификатор курсанта: {escape(str(trainee_id))}", small),
+ Paragraph(
+ "Документ сформирован локально по сохранённым результатам и не заменяет "
+ "квалификационный документ установленного образца.", small,
+ ),
+ ]
+ output = io.BytesIO()
+ document = SimpleDocTemplate(
+ output, pagesize=landscape(A4), leftMargin=60, rightMargin=60,
+ topMargin=45, bottomMargin=45, title="Сертификат о прохождении обучения",
+ author="Учебный симулятор 112",
+ )
+ document.build(story)
+ return output.getvalue()
diff --git a/backend/app/scoring/grammar.py b/backend/app/scoring/grammar.py
new file mode 100644
index 0000000..6c1a98f
--- /dev/null
+++ b/backend/app/scoring/grammar.py
@@ -0,0 +1,102 @@
+"""Проверка письменного ответа ДДС: локальные правила и необязательный Vikhr."""
+
+import json
+import re
+from dataclasses import dataclass
+
+from app.config import get_settings
+from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url
+
+
+@dataclass(frozen=True)
+class GrammarAssessment:
+ passed: bool
+ errors: tuple[str, ...]
+ source: str
+
+
+def basic_check(text: str) -> GrammarAssessment:
+ """Воспроизводимый минимум работает даже без модели."""
+ value = text.strip()
+ errors: list[str] = []
+ if not value:
+ errors.append("ответ отсутствует")
+ else:
+ first_letter = next((char for char in value if char.isalpha()), "")
+ if first_letter and first_letter != first_letter.upper():
+ errors.append("предложение начинается со строчной буквы")
+ if value[-1:] not in ".!?…":
+ errors.append("нет завершающего знака препинания")
+ if re.search(r"\s{2,}", value):
+ errors.append("повторяющиеся пробелы")
+ if re.search(r"[A-Za-z]", value):
+ errors.append("латинские буквы в русском ответе")
+ if re.search(r"([!?.,])\1{2,}", value):
+ errors.append("избыточно повторяющиеся знаки препинания")
+ agreement_errors = (
+ (r"\bсообщение\s+принят(?:ы|а)\b", "нарушено согласование: «сообщение принято»"),
+ (r"\bбригада\s+направлен(?:о|ы)\b", "нарушено согласование: «бригада направлена»"),
+ (r"\bинформация\s+передан(?:о|ы)\b", "нарушено согласование: «информация передана»"),
+ (r"\bработы\s+завершен(?:а|о)\b", "нарушено согласование: «работы завершены»"),
+ )
+ for pattern, message in agreement_errors:
+ if re.search(pattern, value, re.IGNORECASE):
+ errors.append(message)
+ return GrammarAssessment(not errors, tuple(errors), "rules")
+
+
+async def assess(text: str) -> GrammarAssessment:
+ """Vikhr проверяет мягкую грамматику, правила остаются страховочной сеткой."""
+ baseline = basic_check(text)
+ settings = get_settings()
+ if (not settings.grammar_llm_enabled or not text.strip()
+ or not is_loopback_url(
+ settings.llm_control_base_url,
+ allow_docker_host=settings.allow_docker_host_models,
+ )
+ or not settings.llm_model_control):
+ return baseline
+
+ schema = {"type": "json_object", "schema": {
+ "type": "object",
+ "properties": {
+ "passed": {"type": "boolean"},
+ "comment": {"type": "string"},
+ },
+ "required": ["passed", "comment"],
+ "additionalProperties": False,
+ }}
+ request = LlmRequest(
+ messages=[{
+ "role": "system",
+ "content": (
+ "Ты строгий корректор русского языка. Проверяй только грамматику, "
+ "орфографию и пунктуацию короткого служебного сообщения; не оценивай "
+ "факты и действия. Пример ошибки: «Сообщение приняты» => passed=false, "
+ "comment=«Нарушено согласование: сообщение принято». Пример нормы: "
+ "«Сообщение принято, бригада направлена.» => passed=true, comment=«». "
+ "Найди даже одну языковую ошибку и верни JSON по заданной схеме."
+ ),
+ }, {"role": "user", "content": text}],
+ model=settings.llm_model_control,
+ temperature=0.0,
+ max_tokens=160,
+ response_format=schema,
+ )
+ client = LlmClient(base_url=settings.llm_control_base_url, timeout=5)
+ try:
+ raw = await client.complete(request, use_cache=True)
+ payload = json.loads(raw)
+ if set(payload) != {"passed", "comment"} or not isinstance(payload["passed"], bool):
+ raise ValueError("неверная схема")
+ comment = payload["comment"]
+ if not isinstance(comment, str) or len(comment) > 500:
+ raise ValueError("неверный комментарий")
+ errors = list(baseline.errors)
+ if not payload["passed"]:
+ errors.append(comment.strip() or "локальная модель обнаружила языковые ошибки")
+ return GrammarAssessment(not errors, tuple(dict.fromkeys(errors)), "vikhr")
+ except (LlmUnavailable, ValueError, TypeError, KeyError, json.JSONDecodeError):
+ return baseline
+ finally:
+ await client.aclose()
diff --git a/backend/app/scoring/group_insight.py b/backend/app/scoring/group_insight.py
new file mode 100644
index 0000000..0b9d1b1
--- /dev/null
+++ b/backend/app/scoring/group_insight.py
@@ -0,0 +1,77 @@
+"""Локальный Qwen-инсайт по обезличенной агрегированной аналитике группы."""
+
+import json
+
+from app.config import get_settings
+from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url
+
+
+class InsightInvalid(ValueError):
+ pass
+
+
+async def generate_group_insight(data: dict) -> dict:
+ settings = get_settings()
+ if (not settings.llm_model_caller or not is_loopback_url(
+ settings.llm_base_url, allow_docker_host=settings.allow_docker_host_models
+ )):
+ raise LlmUnavailable("локальная модель аналитики не настроена")
+ schema = {"type": "json_object", "schema": {
+ "type": "object",
+ "properties": {
+ "summary": {"type": "string", "minLength": 20, "maxLength": 700},
+ "priorities": {
+ "type": "array", "minItems": 1, "maxItems": 3,
+ "items": {"type": "string", "minLength": 8, "maxLength": 240},
+ },
+ },
+ "required": ["summary", "priorities"],
+ "additionalProperties": False,
+ }}
+ prompt = {
+ "active_trainees": data["active_trainees"],
+ "scored_attempts": data["scored_attempts"],
+ "average_score": data["average_score"],
+ "typical_errors": [{
+ "code": item["code"],
+ "title": item["title"],
+ "rate_percent": item["rate_percent"],
+ "rule_recommendation": item["recommendation"],
+ } for item in data["errors"][:8]],
+ }
+ request = LlmRequest(
+ model=settings.llm_model_caller,
+ messages=[{
+ "role": "system",
+ "content": (
+ "Ты методист учебного центра системы-112. По обезличенной агрегированной "
+ "статистике сформулируй краткий русский вывод и от одной до трёх конкретных "
+ "тем следующего занятия. Не придумывай персональные данные, новые числа, "
+ "диагнозы или нормативы. Верни только JSON по заданной схеме. /no_think"
+ ),
+ }, {"role": "user", "content": json.dumps(prompt, ensure_ascii=False)}],
+ temperature=0.0,
+ max_tokens=420,
+ response_format=schema,
+ strip_reasoning=True,
+ )
+ client = LlmClient(base_url=settings.llm_base_url, timeout=25)
+ try:
+ raw = await client.complete(request, use_cache=True)
+ finally:
+ await client.aclose()
+ try:
+ payload = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise InsightInvalid("модель вернула не JSON") from exc
+ if set(payload) != {"summary", "priorities"}:
+ raise InsightInvalid("неверный набор полей инсайта")
+ summary = payload["summary"]
+ priorities = payload["priorities"]
+ if not isinstance(summary, str) or not 20 <= len(summary.strip()) <= 700:
+ raise InsightInvalid("неверная длина вывода")
+ if (not isinstance(priorities, list) or not 1 <= len(priorities) <= 3
+ or any(not isinstance(item, str) or not 8 <= len(item.strip()) <= 240
+ for item in priorities)):
+ raise InsightInvalid("неверные приоритеты")
+ return {"summary": summary.strip(), "priorities": [item.strip() for item in priorities]}
diff --git a/backend/app/scoring/report.py b/backend/app/scoring/report.py
index a1af75b..13cd19b 100644
--- a/backend/app/scoring/report.py
+++ b/backend/app/scoring/report.py
@@ -46,6 +46,8 @@ def assess_difference(
def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
score = state.score or {}
+ metrics = [Metric.model_validate(item) for item in score.get("metrics", [])]
+ failed_metrics = sum(not item.passed and item.weight > 0 for item in metrics)
# Без модели эмбеддингов распознать *какие* факты прозвучали нельзя.
# Но если оператор не произнёс ни слова, все обязательные вопросы точно
# пропущены — это доказуемый частный случай и он нужен офлайн-разбору.
@@ -78,9 +80,12 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
else scenario.id),
mode=state.mode,
attempt=state.attempt,
+ criteria=state.criteria,
+ failed_metrics=failed_metrics,
+ passed=failed_metrics <= state.criteria.allowed_errors,
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", [])],
+ metrics=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", [])],
diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py
index 85d63dc..b04d757 100644
--- a/backend/app/scoring/taxonomy.py
+++ b/backend/app/scoring/taxonomy.py
@@ -29,6 +29,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
"dds_progress": (ErrorCode.D6, Competency.CARD),
"dds_completion": (ErrorCode.D6, Competency.CARD),
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
+ "dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
}
#: Вес метрики в детерминированной оценке.
diff --git a/backend/app/session/checkpoint.py b/backend/app/session/checkpoint.py
new file mode 100644
index 0000000..4993823
--- /dev/null
+++ b/backend/app/session/checkpoint.py
@@ -0,0 +1,358 @@
+"""Переносимый JSON-снимок незавершённого занятия.
+
+Снимок хранится в PostgreSQL после каждого подтверждённого действия. Он не
+содержит сокеты, аудиобуферы или объекты моделей: после перезапуска процесса
+они создаются заново, а учебные данные, таймеры и состояние ДДС восстанавливаются.
+"""
+
+import time
+from datetime import UTC, datetime
+from uuid import UUID
+
+from fastapi.encoders import jsonable_encoder
+
+from app.dialog.factory import build_caller
+from app.dialog.persona import PersonaState
+from app.dialog.runtime import get_embedder
+from app.dialog.slots import SlotMachine
+from app.domain.events import (
+ CallEndReason,
+ Exercise,
+ LessonCriteria,
+ Metric,
+ SessionMode,
+ TranscriptEntry,
+)
+from app.domain.kio import KIO
+from app.domain.statuses import (
+ PhoneCallPending,
+ PhoneLineRecord,
+ PhoneReportRecord,
+ StatusEntry,
+)
+from app.domain.taxonomy import Finding
+from app.domain.timers import TimerCode
+from app.scenarios.schema import Scenario
+from app.scoring.grammar import GrammarAssessment
+from app.session.state import DdsCardRecord, DdsLiveCard, SessionState, now_utc
+from app.session.timers import SessionTimers, Timer
+
+CHECKPOINT_VERSION = 1
+
+
+def _grammar(value: GrammarAssessment | None) -> dict | None:
+ if value is None:
+ return None
+ return {"passed": value.passed, "errors": list(value.errors), "source": value.source}
+
+
+def _dump_timers(timers: SessionTimers, now: float) -> dict:
+ return {
+ "limits": {code.value: limit for code, limit in timers.limits.items()},
+ "items": {
+ code.value: {
+ "elapsed_ms": timer.current_ms(now),
+ "attempt": timer.attempt,
+ "stopped": timer.stopped,
+ }
+ for code, timer in timers.timers.items()
+ },
+ }
+
+
+def _dump_live_card(item: DdsLiveCard, now: float) -> dict:
+ return {
+ "original_index": item.original_index,
+ "scenario": item.scenario.model_dump(mode="json"),
+ "kio": item.kio.model_dump(mode="json"),
+ "dispatched_card": item.dispatched_card.model_dump(mode="json"),
+ "dispatched_at": item.dispatched_at.isoformat(),
+ "timers": _dump_timers(item.timers, now),
+ "bounced_fields": item.bounced_fields,
+ "dds_log": [[action, at.isoformat(), detail] for action, at, detail in item.dds_log],
+ "status_log": [entry.model_dump(mode="json") for entry in item.status_log],
+ "crew_selected": item.crew_selected,
+ "crew_assignments": item.crew_assignments,
+ "phone_reports": [entry.model_dump(mode="json") for entry in item.phone_reports],
+ "phone_lines": [entry.model_dump(mode="json") for entry in item.phone_lines],
+ "phone_pending": item.phone_pending.model_dump(mode="json") if item.phone_pending else None,
+ "reply_text": item.reply_text,
+ "reply_grammar": _grammar(item.reply_grammar),
+ "reply_log": [[at.isoformat(), text] for at, text in item.reply_log],
+ }
+
+
+def dump_state(state: SessionState) -> dict:
+ """Сериализовать только данные, необходимые для точного продолжения."""
+ state.capture_active_dds()
+ now = time.monotonic()
+ payload = {
+ "version": CHECKPOINT_VERSION,
+ "session_id": str(state.session_id),
+ "scenario_id": state.scenario_id,
+ "scenario_title": state.scenario_title,
+ "level": state.level,
+ "mode": state.mode.value,
+ "owner_login": state.owner_login,
+ "exercise": state.exercise.value,
+ "handoff_to_dds": state.handoff_to_dds,
+ "required_fields": state.required_fields,
+ "trainee_name": state.trainee_name,
+ "trainee_id": str(state.trainee_id) if state.trainee_id else None,
+ "dds_service": state.dds_service,
+ "attempt": state.attempt,
+ "criteria": state.criteria.model_dump(mode="json"),
+ "kio": state.kio.model_dump(mode="json"),
+ "transcript": [entry.model_dump(mode="json") for entry in state.transcript],
+ "timers": _dump_timers(state.timers, now),
+ "hints_shown": state.hints_shown,
+ "hints_log": [[item, at.isoformat()] for item, at in state.hints_log],
+ "notes": state.notes,
+ "directives": state.directives,
+ "scenario": state.scenario.model_dump(mode="json") if state.scenario else None,
+ "audio_frames": state.audio_frames,
+ "bad_frames": state.bad_frames,
+ "self_assessed": state.self_assessed,
+ "self_assessment": state.self_assessment,
+ "score": state.score,
+ "started_at": state.started_at.isoformat() if state.started_at else None,
+ "ended_at": state.ended_at.isoformat() if state.ended_at else None,
+ "end_reason": state.end_reason.value if state.end_reason else None,
+ "dispatched_card": (
+ state.dispatched_card.model_dump(mode="json") if state.dispatched_card else None
+ ),
+ "dispatched_at": state.dispatched_at.isoformat() if state.dispatched_at else None,
+ "bounced_fields": state.bounced_fields,
+ "dds_log": [
+ [action, at.isoformat(), detail] for action, at, detail in state.dds_log
+ ],
+ "status_log": [item.model_dump(mode="json") for item in state.status_log],
+ "crew_selected": state.crew_selected,
+ "crew_assignments": state.crew_assignments,
+ "phone_reports": [item.model_dump(mode="json") for item in state.phone_reports],
+ "phone_lines": [item.model_dump(mode="json") for item in state.phone_lines],
+ "phone_pending": (
+ state.phone_pending.model_dump(mode="json") if state.phone_pending else None
+ ),
+ "dds_scenarios": [item.model_dump(mode="json") for item in state.dds_scenarios],
+ "dds_live_cards": [_dump_live_card(item, now) for item in state.dds_live_cards],
+ "dds_active_card_id": (
+ str(state.dds_active_card_id) if state.dds_active_card_id else None
+ ),
+ "dds_card_index": state.dds_card_index,
+ "dds_arrival_interval_seconds": state.dds_arrival_interval_seconds,
+ "dds_max_waiting": state.dds_max_waiting,
+ "dds_next_scenario_index": state.dds_next_scenario_index,
+ "dds_next_arrival_at": (
+ state.dds_next_arrival_at.isoformat() if state.dds_next_arrival_at else None
+ ),
+ "dds_completed": [
+ {
+ "card_id": str(item.card_id),
+ "scenario_id": item.scenario_id,
+ "reply_text": item.reply_text,
+ "metrics": [metric.model_dump(mode="json") for metric in item.metrics],
+ "findings": [finding.model_dump(mode="json") for finding in item.findings],
+ "actions": item.actions,
+ "duration_ms": item.duration_ms,
+ }
+ for item in state.dds_completed
+ ],
+ "reply_text": state.reply_text,
+ "reply_grammar": _grammar(state.reply_grammar),
+ "reply_log": [[at.isoformat(), text] for at, text in state.reply_log],
+ "resolved_outcome": state.resolved_outcome,
+ "resolve_comment": state.resolve_comment,
+ }
+ # В actions/score могут быть datetime/UUID из расчёта; JSONB должен
+ # получать только стандартные JSON-типы.
+ return jsonable_encoder(payload)
+
+
+def _dt(value: str | None) -> datetime | None:
+ return datetime.fromisoformat(value) if value else None
+
+
+def _restore_timers(payload: dict, saved_at: datetime) -> SessionTimers:
+ raw = payload.get("timers") or {}
+ limits = {
+ TimerCode(code): int(limit)
+ for code, limit in (raw.get("limits") or {}).items()
+ }
+ restored = SessionTimers(limits=limits or SessionTimers().limits)
+ now_mono = time.monotonic()
+ now_wall = datetime.now(UTC)
+ if saved_at.tzinfo is None:
+ saved_at = saved_at.replace(tzinfo=UTC)
+ downtime_ms = max(0, int((now_wall - saved_at).total_seconds() * 1000))
+ for raw_code, item in (raw.get("items") or {}).items():
+ code = TimerCode(raw_code)
+ stopped = bool(item.get("stopped"))
+ elapsed = max(0, int(item.get("elapsed_ms", 0)))
+ total = elapsed if stopped else elapsed + downtime_ms
+ restored.timers[code] = Timer(
+ code=code,
+ started_at=None if stopped else now_mono - total / 1000,
+ elapsed_ms=elapsed if stopped else 0,
+ attempt=max(1, int(item.get("attempt", 1))),
+ stopped=stopped,
+ )
+ return restored
+
+
+def _restore_grammar(value: dict | None) -> GrammarAssessment | None:
+ if not value:
+ return None
+ return GrammarAssessment(
+ passed=bool(value["passed"]),
+ errors=tuple(value.get("errors") or []),
+ source=value["source"],
+ )
+
+
+def _restore_live_card(item: dict, saved_at: datetime) -> DdsLiveCard:
+ return DdsLiveCard(
+ original_index=int(item["original_index"]),
+ scenario=Scenario.model_validate(item["scenario"]),
+ kio=KIO.model_validate(item["kio"]),
+ dispatched_card=KIO.model_validate(item["dispatched_card"]),
+ dispatched_at=datetime.fromisoformat(item["dispatched_at"]),
+ timers=_restore_timers({"timers": item["timers"]}, saved_at),
+ bounced_fields=list(item.get("bounced_fields") or []),
+ dds_log=[(action, datetime.fromisoformat(at), detail)
+ for action, at, detail in item.get("dds_log", [])],
+ status_log=[StatusEntry.model_validate(entry)
+ for entry in item.get("status_log", [])],
+ crew_selected=item.get("crew_selected"),
+ crew_assignments=dict(item.get("crew_assignments") or {}),
+ phone_reports=[PhoneReportRecord.model_validate(entry)
+ for entry in item.get("phone_reports", [])],
+ phone_lines=[PhoneLineRecord.model_validate(entry)
+ for entry in item.get("phone_lines", [])],
+ phone_pending=(PhoneCallPending.model_validate(item["phone_pending"])
+ if item.get("phone_pending") else None),
+ reply_text=item.get("reply_text", ""),
+ reply_grammar=_restore_grammar(item.get("reply_grammar")),
+ reply_log=[(datetime.fromisoformat(at), text)
+ for at, text in item.get("reply_log", [])],
+ )
+
+
+def load_state(payload: dict, saved_at: datetime) -> SessionState:
+ """Восстановить состояние; неизвестная версия отклоняется явно."""
+ if payload.get("version") != CHECKPOINT_VERSION:
+ raise ValueError("неподдерживаемая версия снимка занятия")
+ scenario = Scenario.model_validate(payload["scenario"]) if payload.get("scenario") else None
+ state = SessionState(
+ session_id=UUID(payload["session_id"]),
+ scenario_id=payload["scenario_id"],
+ scenario_title=payload["scenario_title"],
+ level=payload["level"],
+ mode=SessionMode(payload["mode"]),
+ owner_login=payload.get("owner_login"),
+ exercise=Exercise(payload["exercise"]),
+ handoff_to_dds=bool(payload.get("handoff_to_dds")),
+ required_fields=list(payload.get("required_fields") or []),
+ trainee_name=payload.get("trainee_name"),
+ trainee_id=UUID(payload["trainee_id"]) if payload.get("trainee_id") else None,
+ dds_service=payload.get("dds_service"),
+ attempt=int(payload.get("attempt", 1)),
+ criteria=LessonCriteria.model_validate(payload.get("criteria") or {}),
+ kio=KIO.model_validate(payload.get("kio") or {}),
+ transcript=[TranscriptEntry.model_validate(item) for item in payload.get("transcript", [])],
+ timers=_restore_timers(payload, saved_at),
+ hints_shown=list(payload.get("hints_shown") or []),
+ hints_log=[(item, datetime.fromisoformat(at))
+ for item, at in payload.get("hints_log", [])],
+ notes=list(payload.get("notes") or []),
+ directives=list(payload.get("directives") or []),
+ scenario=scenario,
+ audio_frames=int(payload.get("audio_frames", 0)),
+ bad_frames=int(payload.get("bad_frames", 0)),
+ self_assessed=bool(payload.get("self_assessed")),
+ self_assessment=payload.get("self_assessment"),
+ score=payload.get("score"),
+ started_at=_dt(payload.get("started_at")),
+ ended_at=_dt(payload.get("ended_at")),
+ end_reason=(CallEndReason(payload["end_reason"]) if payload.get("end_reason") else None),
+ dispatched_card=(
+ KIO.model_validate(payload["dispatched_card"])
+ if payload.get("dispatched_card") else None
+ ),
+ dispatched_at=_dt(payload.get("dispatched_at")),
+ bounced_fields=list(payload.get("bounced_fields") or []),
+ dds_log=[(action, datetime.fromisoformat(at), detail)
+ for action, at, detail in payload.get("dds_log", [])],
+ status_log=[StatusEntry.model_validate(item) for item in payload.get("status_log", [])],
+ crew_selected=payload.get("crew_selected"),
+ crew_assignments=dict(payload.get("crew_assignments") or {}),
+ phone_reports=[PhoneReportRecord.model_validate(item)
+ for item in payload.get("phone_reports", [])],
+ phone_lines=[PhoneLineRecord.model_validate(item)
+ for item in payload.get("phone_lines", [])],
+ phone_pending=(
+ PhoneCallPending.model_validate(payload["phone_pending"])
+ if payload.get("phone_pending") else None
+ ),
+ dds_scenarios=[Scenario.model_validate(item)
+ for item in payload.get("dds_scenarios", [])],
+ dds_live_cards=[_restore_live_card(item, saved_at)
+ for item in payload.get("dds_live_cards", [])],
+ dds_active_card_id=(
+ UUID(payload["dds_active_card_id"])
+ if payload.get("dds_active_card_id") else None
+ ),
+ dds_card_index=int(payload.get("dds_card_index", 0)),
+ dds_arrival_interval_seconds=int(payload.get("dds_arrival_interval_seconds", 0)),
+ dds_max_waiting=int(payload.get("dds_max_waiting", 3)),
+ dds_next_scenario_index=int(payload.get(
+ "dds_next_scenario_index",
+ max((item["original_index"] for item in payload.get("dds_live_cards", [])), default=-1) + 1,
+ )),
+ dds_next_arrival_at=_dt(payload.get("dds_next_arrival_at")),
+ dds_completed=[
+ DdsCardRecord(
+ card_id=UUID(item["card_id"]),
+ scenario_id=item["scenario_id"],
+ reply_text=item.get("reply_text", ""),
+ metrics=[Metric.model_validate(metric) for metric in item.get("metrics", [])],
+ findings=[Finding.model_validate(finding)
+ for finding in item.get("findings", [])],
+ actions=list(item.get("actions") or []),
+ duration_ms=int(item.get("duration_ms", 0)),
+ )
+ for item in payload.get("dds_completed", [])
+ ],
+ reply_text=payload.get("reply_text", ""),
+ reply_grammar=_restore_grammar(payload.get("reply_grammar")),
+ reply_log=[(datetime.fromisoformat(at), text)
+ for at, text in payload.get("reply_log", [])],
+ resolved_outcome=payload.get("resolved_outcome"),
+ resolve_comment=payload.get("resolve_comment", ""),
+ )
+ if state.dds_live_cards:
+ # Legacy snapshots had no explicit active ID; newer snapshots may
+ # intentionally be between cards while waiting for the next arrival.
+ active_id = state.dds_active_card_id
+ if active_id is None and "dds_active_card_id" not in payload:
+ active_id = state.dds_live_cards[0].card_id
+ if active_id is not None:
+ state.activate_dds_card(active_id, capture=False)
+ if (state.dds_scenarios
+ and state.dds_next_scenario_index < len(state.dds_scenarios)
+ and state.dds_next_arrival_at is None):
+ # Old checkpoints had no delivery schedule; resume any remaining
+ # selected scenarios immediately rather than strand the session.
+ state.dds_next_arrival_at = now_utc()
+ # Голосовые runtime-объекты не сериализуются. Их безопасно собрать заново;
+ # карточка и оценка при этом остаются прежними.
+ if state.exercise is Exercise.CALL and state.scenario is not None:
+ state.persona = PersonaState(state.scenario.persona)
+ state.caller = build_caller(
+ state.scenario.id,
+ use_pregenerated=state.scenario.tree.pregenerated,
+ )
+ embedder = get_embedder()
+ if embedder is not None:
+ state.slots = SlotMachine(state.scenario, embedder)
+ return state
diff --git a/backend/app/session/dds.py b/backend/app/session/dds.py
index fb4d23f..83eaa04 100644
--- a/backend/app/session/dds.py
+++ b/backend/app/session/dds.py
@@ -1,11 +1,12 @@
-"""Выдача очередной учебной карточки ДДС в рамках одного занятия."""
+"""Одновременная выдача учебных карточек ДДС в рамках одного занятия."""
import re
+from datetime import datetime, timedelta
from app.domain import ekp
from app.domain.kio import KIO, ResponseStatus
from app.scenarios.schema import Scenario
-from app.session.state import SessionState, now_utc
+from app.session.state import DdsLiveCard, SessionState, now_utc
from app.session.timers import SessionTimers
@@ -18,11 +19,25 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
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), "")
+ caller_fact = next((fact.value for fact in scenario.facts if fact.id in {"f_caller", "f_applicant"}), "")
+ caller_phone = next((
+ match.group(0).strip()
+ for match in re.finditer(r"(? None:
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()
+ # A live DДС card keeps references to the current working collections.
+ # Never clear them in place when preparing the next card: that would also
+ # erase the previous card's history. Replace the working collections so
+ # every queued card retains an isolated snapshot.
+ state.status_log = []
+ state.dds_log = []
+ state.bounced_fields = []
state.crew_selected = None
- state.crew_assignments.clear()
- state.phone_reports.clear()
+ state.crew_assignments = {}
+ state.phone_reports = []
+ state.phone_lines = []
+ state.phone_pending = None
state.reply_text = ""
- state.reply_log.clear()
- state.timers = SessionTimers()
+ state.reply_grammar = None
+ state.reply_log = []
+ # Настроенный преподавателем лимит копируется в независимый таймер карточки.
+ state.timers = SessionTimers(limits=dict(state.timers.limits))
state.on_event("dds.dispatch")
+
+
+def _append_live_card(state: SessionState, scenario: Scenario, index: int) -> DdsLiveCard:
+ state.dds_card_index = index
+ prepare_card(state, scenario)
+ card = DdsLiveCard(
+ original_index=index,
+ scenario=state.scenario,
+ kio=state.kio,
+ dispatched_card=state.dispatched_card,
+ dispatched_at=state.dispatched_at,
+ timers=state.timers,
+ bounced_fields=state.bounced_fields,
+ dds_log=state.dds_log,
+ status_log=state.status_log,
+ crew_selected=state.crew_selected,
+ crew_assignments=state.crew_assignments,
+ phone_reports=state.phone_reports,
+ phone_lines=state.phone_lines,
+ phone_pending=state.phone_pending,
+ reply_text=state.reply_text,
+ reply_grammar=state.reply_grammar,
+ reply_log=state.reply_log,
+ )
+ state.dds_live_cards.append(card)
+ return card
+
+
+def deliver_due_cards(state: SessionState, now: datetime | None = None) -> int:
+ """Выдать всё, чему настал срок и для чего есть место в ожидании.
+
+ Активная карточка не считается ожидающей. Каждый вызов prepare_card
+ запускает собственный 30-секундный таймер ровно при поступлении.
+ """
+ now = now or now_utc()
+ if state.dds_next_arrival_at is None or state.dds_next_arrival_at > now:
+ return 0
+
+ delivered = 0
+ while state.dds_next_scenario_index < len(state.dds_scenarios):
+ active_id = state.dds_active_card_id if any(
+ item.card_id == state.dds_active_card_id for item in state.dds_live_cards
+ ) else None
+ active_exists = active_id is not None
+ waiting_count = len(state.dds_live_cards) - int(active_exists)
+ if waiting_count >= state.dds_max_waiting:
+ break
+
+ index = state.dds_next_scenario_index
+ card = _append_live_card(state, state.dds_scenarios[index], index)
+ state.dds_next_scenario_index += 1
+ delivered += 1
+ if not active_exists:
+ state.activate_dds_card(card.card_id, capture=False)
+ else:
+ # prepare_card builds the new snapshot through SessionState's
+ # working fields; restore the trainee's active card immediately.
+ state.activate_dds_card(active_id, capture=False)
+
+ if state.dds_next_scenario_index >= len(state.dds_scenarios):
+ state.dds_next_arrival_at = None
+ break
+ if state.dds_arrival_interval_seconds:
+ # Pace from actual delivery time. If the cap was full, the next
+ # card becomes due one full interval after this release.
+ state.dds_next_arrival_at = card.dispatched_at + timedelta(
+ seconds=state.dds_arrival_interval_seconds
+ )
+ break
+ # Zero interval means fill active + available waiting slots now.
+ state.dds_next_arrival_at = now
+
+ return delivered
+
+
+def prepare_queue(
+ state: SessionState,
+ scenarios: list[Scenario],
+ arrival_interval_seconds: int = 0,
+ max_waiting: int = 3,
+) -> None:
+ """Начать поступление очереди с первой карточки; остальные идут по темпу."""
+ state.dds_live_cards.clear()
+ state.dds_scenarios = scenarios
+ state.dds_arrival_interval_seconds = arrival_interval_seconds
+ state.dds_max_waiting = max_waiting
+ state.dds_next_scenario_index = 0
+ state.dds_next_arrival_at = now_utc()
+ deliver_due_cards(state)
diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py
index d2feb24..df2318f 100644
--- a/backend/app/session/finish.py
+++ b/backend/app/session/finish.py
@@ -7,17 +7,18 @@
событие сразу: им ждать нечего.
"""
+import asyncio
import logging
from uuid import UUID
-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.domain.events import Exercise, ScoreReady
+from app.domain.timers import TimerCode
from app.scenarios import store
-from app.scoring.competency import radar
from app.scoring.card import evaluate_card
+from app.scoring.competency import radar
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
from app.scoring.gost import GostResult, evaluate
+from app.scoring.report import build as build_report
from app.scoring.weights import apply_weights
from app.session.hub import hub
from app.session.state import DdsCardRecord, now_utc
@@ -28,34 +29,16 @@ log = logging.getLogger(__name__)
def score_current_dds(state) -> DdsCardRecord:
"""Оценить активную карточку отдельно, до выдачи следующей."""
number = state.dds_card_index + 1
+ decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
findings = evaluate_dispatcher(
entries=state.status_log,
- services=state.notified_services(),
- deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms,
+ services=state.managed_services(),
+ deadline_ms=decision_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,
- ))
+ metrics = dispatcher_metrics(state, decision_limit_ms)
weighted = GostResult(metrics=metrics, findings=findings)
- apply_weights(weighted, state.scenario.score_weights)
+ apply_weights(weighted, {**state.scenario.score_weights, **state.criteria.score_weights})
actions = [
{"type": "card.status", "service": mark.service, "status": mark.status.value,
"comment": mark.comment, "at": mark.at.isoformat()}
@@ -87,6 +70,9 @@ def score_current_dds(state) -> DdsCardRecord:
async def finish(session_id: UUID, state) -> None:
+ if state.recorder is not None:
+ path = await asyncio.to_thread(state.recorder.finalize)
+ state.recording_path = str(path) if path else None
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
scenario = state.scenario or store.get(state.scenario_id)
if scenario is None:
@@ -95,22 +81,20 @@ 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:
- # В ДДС могут последовательно пройти несколько карточек. Уже закрытые
- # сохранены отдельными результатами; активную оцениваем лишь однажды.
+ # Все карточки выданы одновременно: при досрочном завершении оцениваем
+ # каждую, включая не открытую, потому что её норматив уже шёл.
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
- ):
+ state.capture_active_dds()
+ for live in sorted(state.dds_live_cards, key=lambda item: item.original_index):
+ if any(item.card_id == live.card_id for item in cards):
+ continue
+ state.activate_dds_card(live.card_id, capture=False)
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,
@@ -129,16 +113,20 @@ async def finish(session_id: UUID, state) -> None:
if state.dispatched_card is not None and (
state.exercise is Exercise.CALL or state.handoff_to_dds
):
+ decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
dispatcher_findings = evaluate_dispatcher(
entries=state.status_log,
- services=state.notified_services(),
- deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms,
+ services=state.managed_services(),
+ deadline_ms=decision_limit_ms,
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
)
result.findings.extend(dispatcher_findings)
- result.metrics.extend(dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms))
+ result.metrics.extend(dispatcher_metrics(state, decision_limit_ms))
+ # DDS cards were weighted individually in score_current_dds using each
+ # card's scenario defaults plus the lesson override. Reapplying the first
+ # scenario's weights here would corrupt the other ticket cards.
if state.exercise is not Exercise.DDS:
- apply_weights(result, scenario.score_weights)
+ apply_weights(result, {**scenario.score_weights, **state.criteria.score_weights})
# Сводка числами: по ней считается дельта между попытками в профиле.
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
@@ -172,6 +160,9 @@ async def finish(session_id: UUID, state) -> None:
for card in cards
] if state.exercise is Exercise.DDS else [],
}
+ # Полный разбор хранится вместе с оценкой: PDF/CSV и история должны
+ # переживать перезапуск backend, а не зависеть от объекта в hub._sessions.
+ state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))
if hub.journal:
@@ -181,6 +172,18 @@ async def finish(session_id: UUID, state) -> None:
await release_score(session_id, state)
+async def refresh_archived_report(session_id: UUID, state) -> None:
+ """Дополнить сохранённый разбор самооценкой, пришедшей после hangup."""
+ if state.score is None:
+ return
+ scenario = state.scenario or store.get(state.scenario_id)
+ if scenario is None:
+ return
+ state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
+ if hub.journal:
+ await hub.journal.score_snapshot(session_id, state.score)
+
+
async def release_score(session_id: UUID, state) -> None:
"""Отдать оценку курсанту, когда самооценка сдана."""
if state.score is not None and (state.self_assessed or state.exercise is Exercise.CARD):
diff --git a/backend/app/session/hub.py b/backend/app/session/hub.py
index 37ca43b..82c27d6 100644
--- a/backend/app/session/hub.py
+++ b/backend/app/session/hub.py
@@ -13,7 +13,7 @@ from uuid import UUID
from pydantic import BaseModel
-from app.domain.events import TimerTick
+from app.domain.events import CardReceived, Exercise, StationState, TimerTick
from app.session.state import SessionState
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
@@ -30,14 +30,20 @@ class Journal(Protocol):
async def start_lesson(
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
trainee_id: UUID | None = None,
- ) -> tuple[int, UUID | None]: ...
+ ) -> tuple[int, UUID | None, str | None]: ...
async def utterance(self, session_id: UUID, entry) -> None: ...
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...
async def self_assessment(self, session_id: UUID, missed: list[str], comment: str, at) -> None: ...
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None: ...
+ async def score_snapshot(self, session_id: UUID, report: dict) -> None: ...
+ async def score_override(
+ self, session_id: UUID, score_final: float, author: str, comment: str,
+ ) -> None: ...
async def session_started(self, session_id: UUID, at) -> None: ...
async def session_ended(self, session_id: UUID, at, reason: str) -> None: ...
+ async def checkpoint(self, state: SessionState) -> None: ...
+ async def restore_active(self) -> list[SessionState]: ...
class SessionHub:
@@ -58,10 +64,26 @@ class SessionHub:
def get(self, session_id: UUID) -> SessionState | None:
return self._sessions.get(session_id)
+ def has_active_scenario(self, scenario_id: str) -> bool:
+ """Архивирование контента не должно менять уже идущее занятие."""
+ return any(
+ not state.ended and (
+ state.scenario_id == scenario_id
+ or any(item.id == scenario_id for item in state.dds_scenarios)
+ )
+ for state in self._sessions.values()
+ )
+
def drop(self, session_id: UUID) -> None:
self._sessions.pop(session_id, None)
self.stop_ticker(session_id)
+ async def checkpoint(self, session_id: UUID) -> None:
+ """Зафиксировать подтверждённое состояние, если журнал доступен."""
+ state = self.get(session_id)
+ if state is not None and self.journal is not None:
+ await self.journal.checkpoint(state)
+
# ── подписки ──
@contextlib.contextmanager
@@ -138,6 +160,18 @@ class SessionHub:
state = self.get(session_id)
if state is None or state.ended:
return
+ if state.exercise is Exercise.DDS:
+ from app.session.dds import deliver_due_cards
+
+ active_before = state.dds_active_card_id
+ delivered = deliver_due_cards(state)
+ if state.dds_active_card_id != active_before and state.dds_active_card_id:
+ self.to_station(session_id, state.card_received_event())
+ if delivered:
+ await self.checkpoint(session_id)
+ # Keep the pending count and countdown live even while
+ # the active dispatcher card is being handled.
+ self.to_station(session_id, StationState(snapshot=state.station_snapshot()))
self.broadcast(session_id, TimerTick(timers=state.timers.snapshot()))
except asyncio.CancelledError:
raise
diff --git a/backend/app/session/journal.py b/backend/app/session/journal.py
index fca473c..bf908b8 100644
--- a/backend/app/session/journal.py
+++ b/backend/app/session/journal.py
@@ -8,11 +8,14 @@ import logging
from datetime import datetime
from uuid import UUID
-from sqlalchemy import update
+from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.db import repo
-from app.db.models import Score, SelfAssessment, Session
+from app.db.models import Score, SelfAssessment, Session, User, Utterance
+from app.domain.events import Mood, Speaker, TranscriptEntry
+from app.session.checkpoint import dump_state, load_state
+from app.session.state import SessionState, now_utc
log = logging.getLogger(__name__)
@@ -32,8 +35,8 @@ class DbJournal:
async def start_lesson(
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
- trainee_id: UUID | None = None,
- ) -> tuple[int, UUID | None]:
+ trainee_id: UUID | None = None, owner_login: str | None = None,
+ ) -> tuple[int, UUID | None, str | None]:
"""Завести сессию в журнале и вернуть номер попытки и ID курсанта.
Если база недоступна, занятие всё равно идёт: номер попытки
@@ -48,11 +51,75 @@ class DbJournal:
mode=mode,
trainee_name=trainee_name,
trainee_id=trainee_id,
+ owner_login=owner_login,
)
- return row.attempt, row.trainee_id
+ service = None
+ if row.trainee_id is not None:
+ service = await db.scalar(
+ select(User.service)
+ .where(User.trainee_id == row.trainee_id, User.blocked.is_(False))
+ .limit(1)
+ )
+ return row.attempt, row.trainee_id, service
+ except PermissionError:
+ raise
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
log.exception("журнал: сессию завести не удалось")
- return 1, trainee_id
+ return 1, trainee_id, None
+
+ async def checkpoint(self, state: SessionState) -> None:
+ """Сохранить снимок после подтверждённого действия пользователя."""
+ async def action(db):
+ values = (
+ {"live_state": None, "checkpoint_at": None}
+ if state.ended
+ else {"live_state": dump_state(state), "checkpoint_at": now_utc()}
+ )
+ await db.execute(update(Session).where(Session.id == state.session_id).values(**values))
+ await db.commit()
+
+ await self._write(lambda db: action(db))
+
+ async def restore_active(self) -> list[SessionState]:
+ """Восстановить только незавершённые сессии с валидным снимком."""
+ restored: list[SessionState] = []
+ async with self._sessionmaker() as db:
+ rows = (await db.scalars(
+ select(Session).where(
+ Session.ended_at.is_(None),
+ Session.live_state.is_not(None),
+ Session.checkpoint_at.is_not(None),
+ )
+ )).all()
+ for row in rows:
+ try:
+ state = load_state(row.live_state, row.checkpoint_at)
+ if state.session_id != row.id:
+ raise ValueError("ID снимка не совпадает с записью занятия")
+ state.owner_login = row.owner_login
+ # Реплики пишутся отдельно сразу после появления. Если
+ # процесс умер между репликой и общим снимком, отдельный
+ # журнал не даёт потерять последний фрагмент диалога.
+ utterances = (await db.scalars(
+ select(Utterance)
+ .where(Utterance.session_id == row.id)
+ .order_by(Utterance.at, Utterance.ref)
+ )).all()
+ if utterances:
+ state.transcript = [
+ TranscriptEntry(
+ ref=item.ref,
+ speaker=Speaker(item.speaker),
+ text=item.text,
+ at=item.at,
+ mood=Mood(item.mood) if item.mood else None,
+ )
+ for item in utterances
+ ]
+ restored.append(state)
+ except Exception: # noqa: BLE001 — один снимок не блокирует весь стенд
+ log.exception("журнал: снимок занятия %s повреждён", row.id)
+ return restored
async def utterance(self, session_id: UUID, entry) -> None:
await self._write(
@@ -111,6 +178,16 @@ class DbJournal:
await self._write(lambda db: action(db))
+ async def score_snapshot(self, session_id: UUID, report: dict) -> None:
+ """Обновить полный архивный разбор после самооценки курсанта."""
+ async def action(db):
+ await db.execute(
+ update(Score).where(Score.session_id == session_id).values(report=report)
+ )
+ 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))
@@ -123,7 +200,12 @@ class DbJournal:
await db.execute(
update(Session)
.where(Session.id == session_id)
- .values(ended_at=at, end_reason=reason)
+ .values(
+ ended_at=at,
+ end_reason=reason,
+ live_state=None,
+ checkpoint_at=None,
+ )
)
await db.commit()
diff --git a/backend/app/session/state.py b/backend/app/session/state.py
index df0ef70..0d34ca2 100644
--- a/backend/app/session/state.py
+++ b/backend/app/session/state.py
@@ -5,45 +5,52 @@
пустой экран (docs/arch/STACK.md).
"""
+import time
from dataclasses import dataclass, field
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from typing import Any
from uuid import UUID
-from app.domain.events import (
- CallEndReason,
- Exercise,
- Mood,
- SessionMode,
- SessionSnapshot,
- Metric,
- Speaker,
- TranscriptEntry,
-)
from app.dialog.caller import TemplateCaller
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine
-from app.scenarios.schema import Scenario
+from app.domain.events import (
+ CallEndReason,
+ Exercise,
+ LessonCriteria,
+ Metric,
+ Mood,
+ SessionMode,
+ SessionSnapshot,
+ Speaker,
+ TranscriptEntry,
+)
+from app.domain.kio import KIO, ResponseStatus, apply_patch
from app.domain.statuses import (
+ CardStatus,
NEXT,
+ DdsCardSummary,
+ DdsQueueCard,
+ PhoneCallPending,
+ PhoneLineRecord,
+ PhoneReportRecord,
ServiceStatus,
StationSnapshot,
StatusEntry,
- PhoneReportRecord,
- DdsCardSummary,
card_status,
check,
current,
)
-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
+from app.domain.timers import TimerCode
+from app.scenarios.schema import Scenario
+from app.scoring.grammar import GrammarAssessment
+from app.session.timers import SessionTimers
def now_utc() -> datetime:
"""Часы серверные. Метрика, посчитанная по часам браузера, недоказуема."""
- return datetime.now(timezone.utc)
+ return datetime.now(UTC)
@dataclass
@@ -63,6 +70,33 @@ class DdsCardRecord:
return round(100 * passed / total, 1) if total else 0.0
+@dataclass
+class DdsLiveCard:
+ """Изолированное живое состояние одной одновременно выданной карточки."""
+
+ original_index: int
+ scenario: Scenario
+ kio: KIO
+ dispatched_card: KIO
+ dispatched_at: datetime
+ timers: SessionTimers
+ bounced_fields: list[str] = field(default_factory=list)
+ dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
+ status_log: list[StatusEntry] = field(default_factory=list)
+ crew_selected: str | None = None
+ crew_assignments: dict[str, str] = field(default_factory=dict)
+ phone_reports: list[PhoneReportRecord] = field(default_factory=list)
+ phone_lines: list[PhoneLineRecord] = field(default_factory=list)
+ phone_pending: PhoneCallPending | None = None
+ reply_text: str = ""
+ reply_grammar: GrammarAssessment | None = None
+ reply_log: list[tuple[datetime, str]] = field(default_factory=list)
+
+ @property
+ def card_id(self) -> UUID:
+ return self.dispatched_card.card_id
+
+
@dataclass
class SessionState:
session_id: UUID
@@ -70,6 +104,8 @@ class SessionState:
scenario_title: str
level: str
mode: SessionMode
+ #: Преподаватель, создавший занятие; чужой пульт не может им управлять.
+ owner_login: str | None = None
exercise: Exercise = Exercise.CALL
#: После заполнения КИО занятие продолжится на АРМ ДДС, а не завершится.
handoff_to_dds: bool = False
@@ -78,7 +114,11 @@ class SessionState:
#: Чьё это занятие. Проставляется при запуске, когда курсант известен
#: по учётной записи: по нему разбор закрывается от чужих (lct-23).
trainee_id: UUID | None = None
+ #: Конкретная ДДС обучающегося. Она меняет статусы только своей службы;
+ #: остальные адресаты карточки показываются информационно.
+ dds_service: str | None = None
attempt: int = 1
+ criteria: LessonCriteria = field(default_factory=LessonCriteria)
kio: KIO = field(default_factory=KIO)
transcript: list[TranscriptEntry] = field(default_factory=list)
@@ -100,6 +140,8 @@ class SessionState:
# Голосовой контур звонка. Нет — если голос выключен или моделей нет:
# тогда кадры микрофона только считаются.
voice: object | None = None
+ recorder: object | None = None
+ recording_path: str | None = None
# Аудио курсанта. До голосового контура (lct-06) кадры только считаются —
# этого достаточно, чтобы доказать, что звук доходит от микрофона до сервера.
@@ -124,10 +166,19 @@ class SessionState:
crew_selected: str | None = None
crew_assignments: dict[str, str] = field(default_factory=dict)
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
+ phone_lines: list[PhoneLineRecord] = field(default_factory=list)
+ phone_pending: PhoneCallPending | None = None
dds_scenarios: list[Scenario] = field(default_factory=list)
+ dds_live_cards: list[DdsLiveCard] = field(default_factory=list)
+ dds_active_card_id: UUID | None = None
dds_card_index: int = 0
dds_completed: list[DdsCardRecord] = field(default_factory=list)
+ dds_arrival_interval_seconds: int = 0
+ dds_max_waiting: int = 3
+ dds_next_scenario_index: int = 0
+ dds_next_arrival_at: datetime | None = None
reply_text: str = ""
+ reply_grammar: GrammarAssessment | None = None
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
resolved_outcome: str | None = None
@@ -168,12 +219,106 @@ class SessionState:
card = self.dispatched_card or self.kio
return list(card.notify)
+ def _managed_services_for(self, recipients: list[str]) -> list[str]:
+ """Служба, от имени которой работает этот обучающийся.
+
+ Старые занятия и demo без профиля безопасно закрепляются за первым
+ адресатом. Обобщённая учебная служба «ДДС района» соответствует
+ территориальному органу, а не всем получателям карточки сразу.
+ """
+ if not recipients:
+ return []
+ wanted = (self.dds_service or "").strip().casefold()
+ if wanted:
+ exact = next((item for item in recipients if item.casefold() == wanted), None)
+ if exact:
+ return [exact]
+ contained = next(
+ (item for item in recipients
+ if wanted in item.casefold() or item.casefold() in wanted),
+ None,
+ )
+ if contained:
+ return [contained]
+ if "район" in wanted:
+ district = next(
+ (item for item in recipients if "территориальн" in item.casefold()), None
+ )
+ if district:
+ return [district]
+ return []
+ return [recipients[0]]
+
+ def managed_services(self) -> list[str]:
+ return self._managed_services_for(self.notified_services())
+
+ def capture_active_dds(self) -> None:
+ """Сохранить изменяемые поля активной карточки перед переключением."""
+ if not self.dispatched_card:
+ return
+ card = next(
+ (item for item in self.dds_live_cards
+ if item.card_id == self.dispatched_card.card_id),
+ None,
+ )
+ if card is None:
+ return
+ card.scenario = self.scenario
+ card.kio = self.kio
+ card.dispatched_card = self.dispatched_card
+ card.dispatched_at = self.dispatched_at or card.dispatched_at
+ card.timers = self.timers
+ card.bounced_fields = self.bounced_fields
+ card.dds_log = self.dds_log
+ card.status_log = self.status_log
+ card.crew_selected = self.crew_selected
+ card.crew_assignments = self.crew_assignments
+ card.phone_reports = self.phone_reports
+ card.phone_lines = self.phone_lines
+ card.phone_pending = self.phone_pending
+ card.reply_text = self.reply_text
+ card.reply_grammar = self.reply_grammar
+ card.reply_log = self.reply_log
+ self.dds_active_card_id = card.card_id
+
+ def activate_dds_card(self, card_id: UUID, *, capture: bool = True) -> bool:
+ """Переключить рабочее место на карточку, не останавливая остальные."""
+ if capture:
+ self.capture_active_dds()
+ card = next((item for item in self.dds_live_cards if item.card_id == card_id), None)
+ if card is None:
+ return False
+ scenario = card.scenario
+ self.scenario = scenario
+ self.scenario_id = scenario.id
+ self.scenario_title = scenario.title
+ self.level = scenario.level.value
+ self.required_fields = list(scenario.required_fields)
+ self.kio = card.kio
+ self.dispatched_card = card.dispatched_card
+ self.dispatched_at = card.dispatched_at
+ self.timers = card.timers
+ self.bounced_fields = card.bounced_fields
+ self.dds_log = card.dds_log
+ self.status_log = card.status_log
+ self.crew_selected = card.crew_selected
+ self.crew_assignments = card.crew_assignments
+ self.phone_reports = card.phone_reports
+ self.phone_lines = card.phone_lines
+ self.phone_pending = card.phone_pending
+ self.reply_text = card.reply_text
+ self.reply_grammar = card.reply_grammar
+ self.reply_log = card.reply_log
+ self.dds_card_index = card.original_index
+ self.dds_active_card_id = card.card_id
+ return True
+
def crew_options(self) -> list[str]:
return [f"{service} — бригада №{number}"
- for service in self.notified_services() for number in (1, 2)]
+ for service in self.managed_services() for number in (1, 2)]
def crew_service(self, crew: str) -> str | None:
- return next((service for service in self.notified_services()
+ return next((service for service in self.managed_services()
if crew in (f"{service} — бригада №1", f"{service} — бригада №2")), None)
def set_service_status(
@@ -190,31 +335,84 @@ class SessionState:
def station_snapshot(self) -> StationSnapshot:
"""Что видит диспетчер: службы, их статусы и доступные переходы."""
- services = self.notified_services()
+ self.capture_active_dds()
+ has_active_dds_card = any(
+ item.card_id == self.dds_active_card_id for item in self.dds_live_cards
+ )
+ recipients = self.notified_services() if has_active_dds_card or not self.dds_scenarios else []
+ services = self._managed_services_for(recipients)
statuses = {service: current(self.status_log, service) for service in services}
elapsed = self.timers.measured_ms(TimerCode.DDS_ACK)
- limit = NORMATIVES[TimerCode.DDS_ACK].limit_ms
- overdue = self.dispatched_at is not None and (
+ limit = self.timers.limits[TimerCode.DDS_ACK]
+ overdue = has_active_dds_card and self.dispatched_at is not None and (
elapsed is None
and (now_utc() - self.dispatched_at).total_seconds() * 1000 > limit
)
+ now_mono = time.monotonic()
+ queue_cards: list[DdsQueueCard] = []
+ for item in sorted(self.dds_live_cards, key=lambda queued: queued.original_index):
+ recipients_for_card = list(item.dispatched_card.notify)
+ managed = self._managed_services_for(recipients_for_card)
+ service = managed[0] if managed else None
+ timer = item.timers.timers.get(TimerCode.DDS_ACK)
+ elapsed_ms = timer.current_ms(now_mono) if timer else 0
+ stopped = timer.stopped if timer else False
+ limit_ms = item.timers.limits[TimerCode.DDS_ACK]
+ queue_cards.append(DdsQueueCard(
+ card_id=item.card_id,
+ scenario_id=item.scenario.id,
+ title=item.scenario.title,
+ address=item.dispatched_card.address,
+ description=item.dispatched_card.description,
+ incident_type=(item.dispatched_card.incident_type.value
+ if item.dispatched_card.incident_type else None),
+ victims_count=item.dispatched_card.victims_count,
+ received_at=item.dispatched_at,
+ managed_service=service,
+ service_status=(current(item.status_log, service)
+ if service else ServiceStatus.ADDED),
+ card_status=card_status(
+ item.status_log, managed,
+ notify_deadline_passed=not stopped and elapsed_ms > limit_ms,
+ ),
+ elapsed_ms=elapsed_ms,
+ limit_ms=limit_ms,
+ timer_stopped=stopped,
+ active=item.card_id == self.dds_active_card_id,
+ ))
return StationSnapshot(
services=services,
+ recipient_services=recipients,
+ managed_service=services[0] if services else None,
statuses=statuses,
available={service: list(NEXT[status]) for service, status in statuses.items()},
- card=card_status(self.status_log, services, notify_deadline_passed=overdue),
+ card=(card_status(self.status_log, services, notify_deadline_passed=overdue)
+ if has_active_dds_card or not self.dds_scenarios else CardStatus.REGISTERED),
log=list(self.status_log),
- 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,
+ crew_options=(self.crew_options() if has_active_dds_card or not self.dds_scenarios else []),
+ crew_selected=self.crew_selected if has_active_dds_card else None,
+ phone_reports=list(self.phone_reports) if has_active_dds_card else [],
+ phone_lines=list(self.phone_lines) if has_active_dds_card else [],
+ phone_pending=self.phone_pending if has_active_dds_card else None,
+ card_id=(self.dispatched_card.card_id if self.dispatched_card and has_active_dds_card else None),
+ card_index=(self.dds_card_index + 1 if has_active_dds_card
+ else min(self.dds_next_scenario_index + 1, len(self.dds_scenarios))),
card_total=len(self.dds_scenarios) or 1,
- reply_text=self.reply_text,
+ reply_text=self.reply_text if has_active_dds_card else "",
completed_cards=[DdsCardSummary(card_id=item.card_id,
scenario_id=item.scenario_id,
score_auto=item.score_auto)
for item in self.dds_completed],
+ queue_cards=queue_cards,
+ pending_cards_count=max(
+ 0, len(self.dds_scenarios) - len(self.dds_completed)
+ - (1 if has_active_dds_card else 0),
+ ),
+ next_arrival_in_seconds=(
+ max(0, int((self.dds_next_arrival_at - now_utc()).total_seconds() + 0.999))
+ if self.dds_next_arrival_at is not None else None
+ ),
+ max_waiting_cards=self.dds_max_waiting,
)
def card_received_event(self):
@@ -244,6 +442,7 @@ class SessionState:
level=self.level,
mode=self.mode,
exercise=self.exercise,
+ criteria=self.criteria,
trainee_name=self.trainee_name,
started_at=self.started_at,
kio=self.kio,
diff --git a/backend/app/voice/models.py b/backend/app/voice/models.py
index 1d34a82..b00c9d3 100644
--- a/backend/app/voice/models.py
+++ b/backend/app/voice/models.py
@@ -6,14 +6,18 @@ onnxruntime и torch отпускают GIL на время вычислений
"""
import asyncio
+import io
import logging
+import wave
from concurrent.futures import ThreadPoolExecutor
from functools import lru_cache
from pathlib import Path
import numpy as np
+import httpx
from app.config import get_settings
+from app.dialog.llm import is_loopback_url
from app.voice.text import normalize
log = logging.getLogger(__name__)
@@ -51,6 +55,47 @@ class Recognizer:
self.transcribe((rng.normal(0, 0.05, 16_000)).astype(np.float32))
+class WhisperRecognizer:
+ """Локальный whisper.cpp: GGML Whisper small, без облака и Python-зависимости STT.
+
+ VAD уже отделил реплику. Сервер держит модель в памяти, а Python отправляет
+ ему только 16-кГц WAV через loopback; адрес из конфигурации проверяется.
+ """
+
+ def __init__(self, server_url: str, client: httpx.Client | None = None) -> None:
+ if not is_loopback_url(server_url):
+ raise ValueError("Whisper server должен слушать только loopback")
+ self._url = server_url.rstrip("/")
+ self._client = client or httpx.Client(trust_env=False, timeout=30.0)
+
+ def transcribe(self, audio: np.ndarray) -> str:
+ pcm = (np.clip(audio, -1, 1) * 32767).astype(" None:
+ # GET / отвечает локальная страница server.cpp. Не запускаем тяжёлый
+ # инференс на тишине: сервер загрузил модель до готовности порта.
+ response = self._client.get(self._url + "/", timeout=2.0)
+ response.raise_for_status()
+
+
class Synthesizer:
"""Silero v5: около десятой доли длительности фразы на синтез (docs/LATENCY.md)."""
@@ -80,7 +125,8 @@ class Synthesizer:
class VoiceModels:
- def __init__(self, recognizer: Recognizer, synthesizer: Synthesizer, vad_path: Path) -> None:
+ def __init__(self, recognizer: Recognizer | WhisperRecognizer, synthesizer: Synthesizer,
+ vad_path: Path) -> None:
self.recognizer = recognizer
self.synthesizer = synthesizer
self.vad_path = vad_path
@@ -100,8 +146,14 @@ def get_voice_models() -> VoiceModels | None:
if not settings.voice_enabled:
return None
models = ROOT / settings.models_dir
+ stt_path = (ROOT / settings.whisper_model_path / "ggml-small-q5_1.bin"
+ if settings.stt_model == "whisper-small"
+ else models / "gigaam-v3-onnx" / "v3_rnnt_encoder.int8.onnx")
+ if settings.stt_model not in {"whisper-small", "gigaam-v3-rnnt"}:
+ log.error("неизвестный STT_MODEL: %s", settings.stt_model)
+ return None
required = [
- models / "gigaam-v3-onnx" / "v3_rnnt_encoder.int8.onnx",
+ stt_path,
models / "silero-vad" / "silero_vad.onnx",
models / "silero-tts" / "v5_ru.pt",
]
@@ -110,12 +162,13 @@ def get_voice_models() -> VoiceModels | None:
log.warning("голосовой контур выключен, нет моделей: %s — make models", ", ".join(missing))
return None
try:
- recognizer = Recognizer(models / "gigaam-v3-onnx")
+ recognizer = (WhisperRecognizer(settings.whisper_server_url) if settings.stt_model == "whisper-small"
+ else Recognizer(models / "gigaam-v3-onnx"))
synthesizer = Synthesizer(models / "silero-tts" / "v5_ru.pt")
# Прогрев здесь, на старте стенда, а не на первой реплике курсанта.
recognizer.warmup()
synthesizer.warmup()
return VoiceModels(recognizer, synthesizer, models / "silero-vad" / "silero_vad.onnx")
- except ImportError as exc:
- log.warning("голосовой контур выключен: не установлены зависимости (%s) — uv sync --extra voice", exc)
+ except (ImportError, httpx.HTTPError, ValueError) as exc:
+ log.warning("голосовой контур выключен: модели/сервер не готовы (%s)", exc)
return None
diff --git a/backend/app/voice/recording.py b/backend/app/voice/recording.py
new file mode 100644
index 0000000..5a8686d
--- /dev/null
+++ b/backend/app/voice/recording.py
@@ -0,0 +1,81 @@
+"""Локальная WAV-запись обеих сторон учебного голосового вызова."""
+
+import os
+import time
+import wave
+from dataclasses import dataclass
+from pathlib import Path
+from uuid import UUID
+
+import numpy as np
+
+from app.config import get_settings
+
+TARGET_RATE = 16_000
+
+
+def recording_path(session_id: UUID) -> Path:
+ return Path(get_settings().recordings_dir).resolve() / f"{session_id}.wav"
+
+
+@dataclass(frozen=True)
+class _Segment:
+ offset: int
+ samples: np.ndarray
+
+
+class CallRecorder:
+ """Смешивает PCM16 разных частот на монотаймлайн 16 кГц.
+
+ Вход курсанта приходит по 16 кГц, TTS звонящего — по 24 кГц. Метка
+ monotonic сохраняет паузы и взаимное расположение реплик; системные часы
+ и изменение времени на хосте на запись не влияют.
+ """
+
+ def __init__(self, path: Path, *, clock=time.monotonic) -> None:
+ self.path = path
+ self._clock = clock
+ self._started = clock()
+ self._segments: list[_Segment] = []
+ self._finalized = False
+
+ def add_pcm(self, pcm: bytes, *, sample_rate: int) -> None:
+ if self._finalized or not pcm or sample_rate <= 0 or len(pcm) % 2:
+ return
+ source = np.frombuffer(pcm, dtype=" Path | None:
+ if self._finalized:
+ return self.path if self.path.is_file() else None
+ self._finalized = True
+ if not self._segments:
+ return None
+ total = max(item.offset + item.samples.size for item in self._segments)
+ mixed = np.zeros(total, dtype=np.int32)
+ for item in self._segments:
+ mixed[item.offset:item.offset + item.samples.size] += item.samples
+ pcm = np.clip(mixed, -32768, 32767).astype(" CallRecorder | None:
+ if not get_settings().record_calls:
+ return None
+ return CallRecorder(recording_path(session_id))
diff --git a/backend/conftest.py b/backend/conftest.py
index 1d019a5..21a015c 100644
--- a/backend/conftest.py
+++ b/backend/conftest.py
@@ -13,3 +13,4 @@ os.environ.setdefault("VOICE_ENABLED", "false")
# ждал бы ответа провайдера и зависел от его настроения.
# Живые проверки LLM — в test_llm.py, он читает backend/.env.test.
os.environ.setdefault("LLM_API_KEY", "")
+os.environ.setdefault("LLM_PROVIDER", "disabled")
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index e78b037..280e3ea 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -12,6 +12,7 @@ dependencies = [
"alembic>=1.13",
"asyncpg>=0.29",
"httpx>=0.27",
+ "certifi>=2024",
"pyyaml>=6.0",
"reportlab>=4.0",
"structlog>=24.1",
diff --git a/backend/scripts/backup.py b/backend/scripts/backup.py
index 6ec7511..294069f 100644
--- a/backend/scripts/backup.py
+++ b/backend/scripts/backup.py
@@ -8,6 +8,7 @@
0 3 * * * cd … && make backup в crontab демо-машины
"""
+import os
import sys
from pathlib import Path
@@ -18,7 +19,7 @@ from app.admin.backup import BackupError, create, listing # noqa: E402
#: Сколько копий держать. Место на демо-машине не бесконечно, а копия за
#: позапрошлую неделю не нужна никому.
-KEEP = 14
+KEEP = max(1, int(os.environ.get("BACKUP_KEEP", "14")))
def main() -> int:
diff --git a/backend/scripts/backup_loop.py b/backend/scripts/backup_loop.py
new file mode 100644
index 0000000..608bbc8
--- /dev/null
+++ b/backend/scripts/backup_loop.py
@@ -0,0 +1,80 @@
+"""Автоматическая резервная копия PostgreSQL не реже раза в сутки.
+
+Процесс живёт отдельным Compose-сервисом. Он делает копию сразу, если свежей
+нет, затем ориентируется на фактическое время последнего успешного файла.
+После ошибки повторяет попытку чаще, но не удаляет существующие копии.
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+from app.admin.backup import BackupError, DIR, create, listing # noqa: E402
+from app.config import get_settings # noqa: E402
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)s backup-scheduler %(message)s",
+)
+log = logging.getLogger("backup-scheduler")
+
+
+def seconds_until_due(now: datetime, interval: int) -> float:
+ copies = listing()
+ if not copies:
+ return 0.0
+ age = (now - copies[0]["at"]).total_seconds()
+ return max(0.0, interval - age)
+
+
+def prune(keep: int) -> int:
+ old = listing()[keep:]
+ deleted = 0
+ for item in old:
+ try:
+ (DIR / item["name"]).unlink(missing_ok=True)
+ deleted += 1
+ except OSError as exc:
+ # A locked/readonly old file must not take down the daily scheduler.
+ log.error("не удалось удалить старую копию %s: %s", item["name"], exc)
+ return deleted
+
+
+def run_forever() -> None:
+ settings = get_settings()
+ interval = max(60, settings.backup_interval_seconds)
+ retry = max(10, min(settings.backup_retry_seconds, interval))
+ keep = max(1, settings.backup_keep)
+ log.info("запущен: интервал=%s с, хранить=%s", interval, keep)
+
+ while True:
+ try:
+ wait = seconds_until_due(datetime.now(timezone.utc), interval)
+ except BackupError as exc:
+ log.error("не удалось проверить резервные копии: %s; повтор через %s с", exc, retry)
+ time.sleep(retry)
+ continue
+ if wait > 0:
+ time.sleep(wait)
+ continue
+ try:
+ made = create()
+ deleted = prune(keep)
+ log.info("готово: %s (%s байт), удалено старых=%s",
+ made["name"], made["size_bytes"], deleted)
+ except BackupError as exc:
+ log.error("цикл резервного копирования не завершён: %s; повтор через %s с", exc, retry)
+ time.sleep(retry)
+ continue
+ time.sleep(interval)
+
+
+if __name__ == "__main__":
+ run_forever()
diff --git a/backend/scripts/download_local_models.py b/backend/scripts/download_local_models.py
new file mode 100644
index 0000000..673fccf
--- /dev/null
+++ b/backend/scripts/download_local_models.py
@@ -0,0 +1,131 @@
+"""Скачать ровно проверенные файлы трёх локальных моделей.
+
+Файлы лежат в backend/models/ (не в Git). Скрипт одинаково работает на
+Windows, macOS и Linux; при повторном запуске продолжает неполный файл.
+После переноса каталога models/ интернет для занятия не нужен.
+"""
+
+import hashlib
+import argparse
+import shutil
+import ssl
+import sys
+import urllib.request
+from dataclasses import dataclass
+from pathlib import Path
+
+import certifi
+
+ROOT = Path(__file__).resolve().parents[1]
+MODELS = ROOT / "models"
+
+
+@dataclass(frozen=True)
+class Artifact:
+ repo: str
+ name: str
+ target: str
+ size: int
+ sha256: str
+
+
+ARTIFACTS = (
+ Artifact("Qwen/Qwen3-1.7B-GGUF", "Qwen3-1.7B-Q8_0.gguf",
+ "qwen3-1.7b/Qwen3-1.7B-Q8_0.gguf", 1834426016,
+ "061b54daade076b5d3362dac252678d17da8c68f07560be70818cace6590cb1a"),
+ Artifact("Vikhrmodels/Vikhr-Llama-3.2-1B-instruct-GGUF",
+ "Vikhr-Llama-3.2-1B-Q4_K_M.gguf",
+ "vikhr-1b/Vikhr-Llama-3.2-1B-Q4_K_M.gguf", 807694432,
+ "a85c0ef4b6476ff5fcf2c961226234ab7008d17087c2152416e61a9b7e74b32d"),
+ Artifact("ggerganov/whisper.cpp", "ggml-small-q5_1.bin",
+ "whisper-small/ggml-small-q5_1.bin", 190085487,
+ "ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb"),
+)
+
+
+def digest(path: Path) -> str:
+ sha = hashlib.sha256()
+ with path.open("rb") as stream:
+ while chunk := stream.read(1 << 20):
+ sha.update(chunk)
+ return sha.hexdigest()
+
+
+def verify(item: Artifact) -> None:
+ """Проверить перенесённый вес без сети и без изменения файла."""
+ target = MODELS / item.target
+ if not target.is_file():
+ raise RuntimeError(f"нет локального файла: {target}")
+ if target.stat().st_size != item.size:
+ raise RuntimeError(
+ f"неверный размер {item.target}: {target.stat().st_size}, ожидался {item.size} байт"
+ )
+ actual = digest(target)
+ if actual != item.sha256:
+ raise RuntimeError(f"SHA-256 не совпал для {item.target}: {actual}")
+ print(f"проверено SHA-256: {item.target}", flush=True)
+
+
+def download(item: Artifact) -> None:
+ target = MODELS / item.target
+ target.parent.mkdir(parents=True, exist_ok=True)
+ if target.exists():
+ try:
+ verify(item)
+ except RuntimeError:
+ pass
+ else:
+ print(f"готово: {item.target}", flush=True)
+ return
+ partial = target.with_name(target.name + ".part")
+ offset = partial.stat().st_size if partial.exists() else 0
+ if offset > item.size:
+ raise RuntimeError(f"некорректный частичный файл: {partial}")
+ free = shutil.disk_usage(MODELS if MODELS.exists() else ROOT).free
+ if free < item.size - offset + 256 * 1024 * 1024:
+ raise RuntimeError(f"мало свободного места для {item.target}: осталось {free // (1 << 20)} МиБ")
+ url = f"https://huggingface.co/{item.repo}/resolve/main/{item.name}"
+ request = urllib.request.Request(url, headers={"Range": f"bytes={offset}-"} if offset else {})
+ print(f"скачиваю {item.target} ({item.size // (1 << 20)} МиБ)", flush=True)
+ with urllib.request.urlopen(
+ request, timeout=120, context=ssl.create_default_context(cafile=certifi.where())
+ ) as response:
+ # Сервер может проигнорировать Range: тогда начинаем заново, не
+ # приписываем полный файл к частичному.
+ resumed = offset > 0 and response.status == 206
+ with partial.open("ab" if resumed else "wb") as out:
+ count = offset if resumed else 0
+ while chunk := response.read(1 << 20):
+ out.write(chunk)
+ count += len(chunk)
+ if count % (64 << 20) < len(chunk) or count == item.size:
+ print(f" {count // (1 << 20)}/{item.size // (1 << 20)} МиБ", flush=True)
+ if partial.stat().st_size != item.size or digest(partial) != item.sha256:
+ raise RuntimeError(f"контроль размера/SHA-256 не пройден: {partial}")
+ partial.replace(target)
+ print(f"проверено SHA-256: {item.target}", flush=True)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--verify-only", action="store_true",
+ help="проверить уже загруженные/перенесённые веса без сети и без изменения файлов",
+ )
+ args = parser.parse_args()
+ try:
+ for item in ARTIFACTS:
+ verify(item) if args.verify_only else download(item)
+ except (OSError, RuntimeError) as exc:
+ action = "проверка прервана" if args.verify_only else "загрузка прервана"
+ print(f"{action}: {exc}", file=sys.stderr)
+ return 1
+ if args.verify_only:
+ print("Все три локальные модели прошли офлайн-проверку; файлы не изменялись.")
+ else:
+ print("Все три локальные модели проверены. Для голоса нужны также whisper-server, VAD и TTS.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/backend/scripts/llm_check.py b/backend/scripts/llm_check.py
index 0ff93ef..113b6e2 100644
--- a/backend/scripts/llm_check.py
+++ b/backend/scripts/llm_check.py
@@ -1,76 +1,43 @@
-"""make llm-check: один запрос к LLM, чтобы убедиться, что ключ и адрес рабочие.
+"""Проверка активного диалогового профиля через тот же клиент, что и занятие.
-Ключ берётся из backend/.env и никуда не печатается. Провайдер отвечает с машины
-разработки напрямую; таймаут почти всегда означает не отказ провайдера, а окружение —
-VPN-туннель или песочница, через которые российские адреса не проходят. Поэтому
-проверять отсюда, а не из обёрток, и с той машины, на которой пойдёт занятие.
+В локальном режиме ключ не нужен; OFFLINE=true запрещает запрос наружу.
"""
+import asyncio
import sys
import time
from pathlib import Path
-import httpx
-
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.config import get_settings # noqa: E402
+from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable # noqa: E402
-def main() -> int:
+async def check() -> int:
settings = get_settings()
- if not settings.llm_api_key:
- print("в backend/.env нет LLM_API_KEY")
- return 1
-
- print(f"адрес: {settings.llm_base_url}")
- print(f"модель: {settings.llm_model_caller}")
-
- body = {
- "model": settings.llm_model_caller,
- "messages": [
- {"role": "system", "content": "Ты звонящий в службу 112, у тебя горит балкон. "
- "Ответь одной короткой фразой, в панике."},
- {"role": "user", "content": "Служба 112, что у вас случилось?"},
- ],
- "max_tokens": 60,
- "temperature": 0.8,
- }
-
+ control = settings.dialogue_model_mode == "russian_control"
+ model = settings.llm_model_control if control else settings.llm_model_caller
+ address = settings.llm_control_base_url if control else settings.llm_base_url
+ print(f"адрес: {address}\nмодель: {model}")
+ client = LlmClient(base_url=address, timeout=30)
started = time.monotonic()
try:
- response = httpx.post(
- f"{settings.llm_base_url}/chat/completions",
- headers={"Authorization": f"Bearer {settings.llm_api_key}"},
- json=body,
- timeout=60,
- )
- except httpx.HTTPError as exc:
- print(f"\nсеть: {type(exc).__name__} — до провайдера не достучались.")
- print("Это чаще про окружение, чем про провайдера: проверь, что российские")
- print("адреса идут мимо VPN, и повтори с самой машины стенда.")
- return 2
-
- elapsed = time.monotonic() - started
- print(f"\nHTTP {response.status_code}, {elapsed:.2f} с")
-
- if response.status_code == 200:
- data = response.json()
- print("ответ модели:", data["choices"][0]["message"]["content"].strip())
- usage = data.get("usage", {})
- print("токены:", usage.get("prompt_tokens"), "→", usage.get("completion_tokens"))
- print("\nКЛЮЧ РАБОТАЕТ")
- return 0
-
- print("ответ сервера:", response.text[:400])
- if response.status_code in (401, 403):
- print("\nСеть в порядке, но ключ не принят: возможно, его нужно менять "
- "на временный токен — сверься с консолью Cloud.ru.")
- elif response.status_code == 404:
- print("\nКлюч принят, но такой модели нет: проверь LLM_MODEL_CALLER.")
- return 3
+ reply = await client.complete(LlmRequest(
+ model=model,
+ messages=[{"role": "user", "content": "Ответь одним словом по-русски: работает /no_think"}],
+ temperature=0,
+ max_tokens=32,
+ ), use_cache=False)
+ except LlmUnavailable as exc:
+ print(f"модель недоступна: {exc}")
+ return 1
+ finally:
+ await client.aclose()
+ print(f"ответ за {time.monotonic() - started:.2f} с: {reply}")
+ return 0
if __name__ == "__main__":
- raise SystemExit(main())
+ raise SystemExit(asyncio.run(check()))
diff --git a/backend/scripts/local_llms.py b/backend/scripts/local_llms.py
new file mode 100644
index 0000000..d9e31cb
--- /dev/null
+++ b/backend/scripts/local_llms.py
@@ -0,0 +1,118 @@
+"""Запуск двух локальных GGUF-серверов через llama.cpp на Windows/macOS/Linux.
+
+Никаких загрузок при старте: GGUF предварительно кладутся в models/ через
+download_local_models.py. Путь к llama-server задаётся LLAMA_SERVER_BIN либо
+берётся из PATH. Ctrl+C останавливает дочерние процессы.
+"""
+
+import argparse
+import os
+import shutil
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+PROFILES = {
+ "dialogue": ("qwen3-1.7b/Qwen3-1.7B-Q8_0.gguf", "Qwen3-1.7B", 18080),
+ "russian_control": ("vikhr-1b/Vikhr-Llama-3.2-1B-Q4_K_M.gguf", "Vikhr-1B", 18081),
+}
+
+
+def binary_path() -> str:
+ bundled_mac = ROOT / "models" / "bin" / "llama-b10934" / "llama-server"
+ binary = (os.environ.get("LLAMA_SERVER_BIN") or shutil.which("llama-server")
+ or shutil.which("llama-server.exe")
+ # The checked-out helper is Mach-O arm64. Do not select it on a
+ # Windows machine merely because the whole models/ folder was
+ # copied there for its platform-neutral GGUF weights.
+ or (str(bundled_mac) if sys.platform == "darwin" and bundled_mac.is_file() else None))
+ if not binary:
+ raise RuntimeError("нет llama-server; установите бинарник llama.cpp и задайте LLAMA_SERVER_BIN")
+ if not Path(binary).is_file():
+ raise RuntimeError(f"llama-server не найден: {binary}")
+ return binary
+
+
+def command(binary: str, profile: str, threads: int) -> list[str]:
+ relative, alias, port = PROFILES[profile]
+ model = ROOT / "models" / relative
+ if not model.is_file():
+ raise RuntimeError(f"нет модели: {model} — запустите make local-models")
+ result = [binary, "-m", str(model), "--alias", alias,
+ "--host", "127.0.0.1", "--port", str(port),
+ "--ctx-size", "2048", "--threads", str(threads), "--parallel", "1",
+ "--cors-origins", "localhost"]
+ if profile == "dialogue":
+ result += ["--reasoning-budget", "0"]
+ return result
+
+
+def ready(port: int) -> bool:
+ try:
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=1) as response:
+ return response.status == 200
+ except (OSError, urllib.error.HTTPError):
+ return False
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--mode", choices=["dialogue", "russian_control", "both"], default="dialogue")
+ parser.add_argument("--threads", type=int, default=4)
+ args = parser.parse_args()
+ if args.threads < 1:
+ parser.error("--threads должен быть положительным")
+ selected = list(PROFILES) if args.mode == "both" else [args.mode]
+ try:
+ binary = binary_path()
+ commands = [command(binary, profile, args.threads) for profile in selected]
+ except RuntimeError as exc:
+ print(exc, file=sys.stderr)
+ return 2
+ processes: list[subprocess.Popen] = []
+ try:
+ for profile, argv in zip(selected, commands):
+ port = PROFILES[profile][2]
+ if ready(port):
+ raise RuntimeError(f"порт {port} уже занят сервером — не запускаю дубликат")
+ print(f"запускаю {profile} на 127.0.0.1:{port}", flush=True)
+ # Ctrl+C должен достаться управляющему процессу один раз: он сам
+ # остановит дочерний сервер. Иначе llama.cpp получает двойной
+ # SIGINT и на Metal иногда падает во время освобождения памяти.
+ flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
+ processes.append(subprocess.Popen(
+ argv, cwd=ROOT, creationflags=flags, start_new_session=os.name != "nt"
+ ))
+ deadline = time.monotonic() + 120
+ while time.monotonic() < deadline:
+ if any(process.poll() is not None for process in processes):
+ raise RuntimeError("один из серверов модели завершился до готовности")
+ if all(ready(PROFILES[profile][2]) for profile in selected):
+ print("локальные модели готовы; Ctrl+C остановит их", flush=True)
+ while all(process.poll() is None for process in processes):
+ time.sleep(0.5)
+ raise RuntimeError("один из серверов модели неожиданно завершился")
+ time.sleep(0.5)
+ raise RuntimeError("модели не стали готовы за 120 секунд")
+ except KeyboardInterrupt:
+ return 0
+ except RuntimeError as exc:
+ print(exc, file=sys.stderr)
+ return 1
+ finally:
+ for process in processes:
+ if process.poll() is None:
+ process.terminate()
+ for process in processes:
+ try:
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ process.kill()
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/backend/scripts/local_stt.py b/backend/scripts/local_stt.py
new file mode 100644
index 0000000..0190f9d
--- /dev/null
+++ b/backend/scripts/local_stt.py
@@ -0,0 +1,84 @@
+"""Локальный whisper.cpp server для Whisper small (macOS / Windows / Linux).
+
+Сначала скачайте GGML-вес через download_local_models.py. Исполняемый файл
+задаётся WHISPER_SERVER_BIN или ищется в PATH / локальной сборке. Сеть не нужна.
+"""
+
+import os
+import shutil
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+MODEL = ROOT / "models" / "whisper-small" / "ggml-small-q5_1.bin"
+PORT = 18082
+
+
+def binary_path() -> str:
+ built = ROOT / "models" / "bin" / "whisper.cpp-1.9.4" / "build" / "bin" / "whisper-server"
+ binary = (os.environ.get("WHISPER_SERVER_BIN") or shutil.which("whisper-server")
+ or shutil.which("whisper-server.exe")
+ # The in-tree build is macOS arm64, not a portable executable.
+ or (str(built) if sys.platform == "darwin" and built.is_file() else None))
+ if not binary or not Path(binary).is_file():
+ raise RuntimeError("нет whisper-server; установите бинарник whisper.cpp и задайте WHISPER_SERVER_BIN")
+ return binary
+
+
+def command(binary: str, threads: int = 4) -> list[str]:
+ if not MODEL.is_file():
+ raise RuntimeError(f"нет модели: {MODEL} — запустите make local-models")
+ return [binary, "-m", str(MODEL), "--host", "127.0.0.1", "--port", str(PORT),
+ "--language", "ru", "--threads", str(threads), "--no-fallback"]
+
+
+def ready() -> bool:
+ try:
+ with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1) as response:
+ return response.status == 200
+ except (OSError, urllib.error.HTTPError):
+ return False
+
+
+def main() -> int:
+ try:
+ if ready():
+ raise RuntimeError(f"порт {PORT} уже занят сервером")
+ argv = command(binary_path())
+ except RuntimeError as exc:
+ print(exc, file=sys.stderr)
+ return 2
+ flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
+ process = subprocess.Popen(argv, cwd=ROOT, creationflags=flags, start_new_session=os.name != "nt")
+ try:
+ deadline = time.monotonic() + 120
+ while time.monotonic() < deadline:
+ if process.poll() is not None:
+ raise RuntimeError("whisper-server завершился до готовности")
+ if ready():
+ print(f"Whisper small готов на 127.0.0.1:{PORT}; Ctrl+C остановит сервер", flush=True)
+ while process.poll() is None:
+ time.sleep(0.5)
+ raise RuntimeError("whisper-server неожиданно завершился")
+ time.sleep(0.5)
+ raise RuntimeError("Whisper small не стал готов за 120 секунд")
+ except KeyboardInterrupt:
+ return 0
+ except RuntimeError as exc:
+ print(exc, file=sys.stderr)
+ return 1
+ finally:
+ if process.poll() is None:
+ process.terminate()
+ try:
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ process.kill()
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/backend/scripts/models.py b/backend/scripts/models.py
index f7cbf3c..cce292b 100644
--- a/backend/scripts/models.py
+++ b/backend/scripts/models.py
@@ -1,15 +1,21 @@
-"""make models: веса моделей в backend/models/.
+"""make models: вспомогательные веса голоса в backend/models/.
-Эмбеддинги, распознавание (GigaAM v3, int8) и VAD качаются с Hugging Face.
+Эмбеддинги и VAD качаются с Hugging Face. Whisper small, Qwen3 и Vikhr
+скачиваются отдельным `make local-models` с проверкой SHA-256. Старый GigaAM
+доступен через `--legacy-gigaam`, но в выбранный стек больше не входит.
Синтез (Silero TTS v5) лежит на models.silero.ai — российском хосте, который
не отвечает из-под VPN: его скрипт не качает, а проверяет и говорит, что делать.
Докачка продолжается с места обрыва: сеть на стенде бывает медленной.
"""
+import argparse
+import ssl
import sys
import urllib.request
from pathlib import Path
+import certifi
+
ROOT = Path(__file__).resolve().parents[1]
MODELS = ROOT / "models"
@@ -20,16 +26,16 @@ FILES = {
"e5-small/config.json": E5 + "config.json",
"e5-small/tokenizer.json": E5 + "tokenizer.json",
"e5-small/model_quantized.onnx": E5 + "onnx/model_quantized.onnx",
- # RNNT — основная модель, CTC — запасная на случай, если RNNT не загрузится
- # (по скорости CTC не выигрывает — docs/LATENCY.md).
+ "silero-vad/config.json": VAD + "config.json",
+ "silero-vad/silero_vad.onnx": VAD + "silero_vad.onnx",
+}
+LEGACY_GIGAAM_FILES = {
"gigaam-v3-onnx/config.json": GIGAAM + "config.json",
"gigaam-v3-onnx/v3_vocab.txt": GIGAAM + "v3_vocab.txt",
"gigaam-v3-onnx/v3_rnnt_encoder.int8.onnx": GIGAAM + "v3_rnnt_encoder.int8.onnx",
"gigaam-v3-onnx/v3_rnnt_decoder.int8.onnx": GIGAAM + "v3_rnnt_decoder.int8.onnx",
"gigaam-v3-onnx/v3_rnnt_joint.int8.onnx": GIGAAM + "v3_rnnt_joint.int8.onnx",
"gigaam-v3-onnx/v3_ctc.int8.onnx": GIGAAM + "v3_ctc.int8.onnx",
- "silero-vad/config.json": VAD + "config.json",
- "silero-vad/silero_vad.onnx": VAD + "silero_vad.onnx",
}
SILERO_TTS = "silero-tts/v5_ru.pt"
@@ -40,19 +46,27 @@ def fetch(url: str, target: Path) -> None:
partial = target.with_suffix(target.suffix + ".part")
offset = partial.stat().st_size if partial.exists() else 0
request = urllib.request.Request(url, headers={"Range": f"bytes={offset}-"} if offset else {})
- with urllib.request.urlopen(request, timeout=60) as response, partial.open("ab") as out:
- total = offset + int(response.headers.get("Content-Length", 0))
- while chunk := response.read(1 << 20):
- out.write(chunk)
- done = out.tell()
- if total:
- print(f"\r {target.name}: {done // (1 << 20)} из {total // (1 << 20)} МБ", end="", flush=True)
+ with urllib.request.urlopen(
+ request, timeout=60, context=ssl.create_default_context(cafile=certifi.where())
+ ) as response:
+ resumed = offset > 0 and response.status == 206
+ with partial.open("ab" if resumed else "wb") as out:
+ total = (offset if resumed else 0) + int(response.headers.get("Content-Length", 0))
+ while chunk := response.read(1 << 20):
+ out.write(chunk)
+ done = out.tell()
+ if total:
+ print(f"\r {target.name}: {done // (1 << 20)} из {total // (1 << 20)} МБ", end="", flush=True)
partial.rename(target)
print()
def main() -> None:
- for relative, url in FILES.items():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--legacy-gigaam", action="store_true")
+ args = parser.parse_args()
+ files = {**FILES, **(LEGACY_GIGAAM_FILES if args.legacy_gigaam else {})}
+ for relative, url in files.items():
target = MODELS / relative
if target.exists():
print(f" {relative}: уже есть")
diff --git a/backend/scripts/repl.py b/backend/scripts/repl.py
index 7450e56..8807a4f 100644
--- a/backend/scripts/repl.py
+++ b/backend/scripts/repl.py
@@ -54,7 +54,10 @@ async def main() -> None:
slots = SlotMachine(scenario, embedder)
persona = PersonaState(scenario.persona)
- caller = build_caller(scenario.id)
+ caller = build_caller(
+ scenario.id,
+ use_pregenerated=scenario.tree.pregenerated,
+ )
print(f"── {scenario.title} ({scenario.level.value}) ──")
print("Вы — оператор 112. Команды: /подсказка /факты /итог /выход\n")
diff --git a/backend/scripts/smoke_voice.py b/backend/scripts/smoke_voice.py
new file mode 100644
index 0000000..5b537c6
--- /dev/null
+++ b/backend/scripts/smoke_voice.py
@@ -0,0 +1,40 @@
+"""Проверка локального TTS → Whisper small без микрофона и внешней сети.
+
+Это не замер сквозной задержки звонка: TTS создаёт синтетический образец, а
+загрузка моделей оплачивается отдельно. На Windows выполнить ту же команду.
+"""
+
+import time
+
+import numpy as np
+from scipy.signal import resample_poly
+
+from app.voice.models import Synthesizer, WhisperRecognizer
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+PHRASE = "Пожар на улице Ленина, дом четырнадцать. Нужна помощь."
+
+
+def main() -> None:
+ started = time.monotonic()
+ tts = Synthesizer(ROOT / "models" / "silero-tts" / "v5_ru.pt")
+ print(f"TTS загружен за {time.monotonic() - started:.2f} с", flush=True)
+ started = time.monotonic()
+ pcm = tts.synthesize(PHRASE)
+ audio = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768
+ audio = resample_poly(audio, 2, 3).astype(np.float32)
+ print(f"TTS: {time.monotonic() - started:.2f} с, звук {len(audio) / 16000:.2f} с", flush=True)
+
+ started = time.monotonic()
+ stt = WhisperRecognizer("http://127.0.0.1:18082")
+ stt.warmup()
+ print(f"Whisper server готов за {time.monotonic() - started:.2f} с", flush=True)
+ started = time.monotonic()
+ answer = stt.transcribe(audio)
+ print(f"Whisper: {time.monotonic() - started:.2f} с", flush=True)
+ print(f"ожидалось: {PHRASE}\nполучено: {answer}", flush=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/scripts/users.py b/backend/scripts/users.py
index 32dcba7..f255b37 100644
--- a/backend/scripts/users.py
+++ b/backend/scripts/users.py
@@ -30,7 +30,7 @@ from app.domain.roles import Role # noqa: E402
PEOPLE = [
("admin", "Администратор стенда", Role.ADMIN, None),
("teacher", "Преподаватель", Role.INSTRUCTOR, None),
- ("trainee", "Курсант", Role.TRAINEE, "ДДС района"),
+ ("trainee", "Курсант", Role.TRAINEE, "Служба 101"),
]
diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py
index 2116953..2b5eb3d 100644
--- a/backend/tests/test_admin.py
+++ b/backend/tests/test_admin.py
@@ -5,6 +5,7 @@
"""
import uuid
+from xml.etree import ElementTree as ET
import pytest
from fastapi.testclient import TestClient
@@ -61,12 +62,29 @@ def test_instructor_cannot_open_admin(as_instructor):
assert as_instructor.get("/api/admin/users").status_code == 403
assert as_instructor.get("/api/admin/audit").status_code == 403
assert as_instructor.get("/api/admin/status").status_code == 403
+ assert as_instructor.get("/api/admin/diagnostics").status_code == 403
+ assert as_instructor.get("/api/admin/config.xml").status_code == 403
def test_anonymous_cannot_open_admin(client):
assert client.get("/api/admin/users").status_code == 401
+def test_admin_downloads_safe_xml_configuration(as_admin):
+ response = as_admin.get("/api/admin/config.xml")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/xml")
+ assert "attachment" in response.headers["content-disposition"]
+ root = ET.fromstring(response.content)
+ assert root.tag == "lctConfiguration"
+ assert root.find("./workstations/workstation[@role='admin']") is not None
+ assert root.find("./timerLimits/timer[@code='dds_ack']") is not None
+ lowered = response.content.lower()
+ assert b"session_secret" not in lowered
+ assert b"llm_api_key" not in lowered
+ assert b"database_url" not in lowered
+
+
def test_admin_has_no_way_to_change_a_score():
"""Запрет выражен отсутствием точки, а не проверкой внутри неё: в модуле
администратора нет ни одного маршрута, трогающего оценки."""
@@ -90,6 +108,39 @@ def test_admin_cannot_write_to_the_audit_log():
]
+def test_audit_api_applies_actor_action_and_offset_filters(as_admin):
+ """The admin UI must be able to reach older records, not just page zero."""
+ from app.api.http import admin as admin_module
+
+ class FakeDb:
+ statement = None
+
+ async def scalars(self, statement):
+ self.statement = statement
+ return []
+
+ fake_db = FakeDb()
+
+ async def fake_session():
+ yield fake_db
+
+ app.dependency_overrides[admin_module.get_session] = fake_session
+ try:
+ response = as_admin.get(
+ "/api/admin/audit",
+ params={"action": "login.failed", "actor": "alice", "limit": 201, "offset": 400},
+ )
+ finally:
+ app.dependency_overrides.pop(admin_module.get_session, None)
+
+ assert response.status_code == 200, response.text
+ statement = fake_db.statement
+ assert statement._limit_clause.value == 201
+ assert statement._offset_clause.value == 400
+ assert "audit_log.action" in str(statement.whereclause)
+ assert "audit_log.actor" in str(statement.whereclause)
+
+
# ── учётные записи ──
@@ -170,7 +221,39 @@ def test_status_names_every_component(as_admin):
names = {item["name"] for item in as_admin.get("/api/admin/status").json()}
assert {"База данных", "Модели речи", "Эмбеддинги", "Провайдер LLM",
- "Классификатор ЕКП", "Секрет сессии"} <= names
+ "Классификатор ЕКП", "Резервное копирование", "Секрет сессии",
+ "Нагрузка backend"} <= names
+
+
+def test_admin_gets_live_metrics_and_downloadable_failure_report(as_admin):
+ if not db_alive(as_admin):
+ pytest.skip("нет базы")
+
+ response = as_admin.get("/api/admin/diagnostics")
+ assert response.status_code == 200, response.text
+ body = response.json()
+ assert body["metrics"]["cpu_cores"] >= 1
+ assert body["metrics"]["disk_total_bytes"] > body["metrics"]["disk_free_bytes"]
+ assert body["metrics"]["active_sessions"] >= 0
+ assert isinstance(body["recent_system_events"], list)
+ assert isinstance(body["failed_actions_24h"], list)
+
+ download = as_admin.get("/api/admin/diagnostics.json")
+ assert download.status_code == 200
+ assert "attachment" in download.headers["content-disposition"]
+
+
+def test_diagnostic_journal_redacts_credentials():
+ import logging
+
+ from app.monitoring import recent_events
+
+ logging.getLogger("app.redaction-test").warning(
+ "failure postgresql://user:secret@db/lct SESSION_SECRET=never-show"
+ )
+ event = recent_events(limit=1)[0]
+ assert "user:secret@" not in event["message"]
+ assert "never-show" not in event["message"]
def test_default_session_secret_is_reported_as_a_problem(as_admin):
@@ -191,6 +274,32 @@ def test_backup_listing_is_open_to_admin(as_admin):
assert as_admin.get("/api/admin/backups").status_code == 200
+def test_backup_listing_failure_does_not_break_admin_status(as_admin, monkeypatch):
+ from app.admin import backup as backup_service
+
+ def broken():
+ raise backup_service.BackupError("каталог временно недоступен")
+
+ monkeypatch.setattr(backup_service, "listing", broken)
+ response = as_admin.get("/api/admin/status")
+ assert response.status_code == 200, response.text
+ backup = next(item for item in response.json() if item["name"] == "Резервное копирование")
+ assert not backup["ok"]
+ assert "каталог временно недоступен" in backup["detail"]
+
+
+def test_backup_list_returns_503_on_storage_error(as_admin, monkeypatch):
+ from app.admin import backup as backup_service
+
+ def broken():
+ raise backup_service.BackupError("каталог временно недоступен")
+
+ monkeypatch.setattr(backup_service, "listing", broken)
+ response = as_admin.get("/api/admin/backups")
+ assert response.status_code == 503
+ assert "каталог временно недоступен" in response.json()["detail"]
+
+
def test_backup_failure_explains_what_is_missing(as_admin, monkeypatch):
"""Кнопка не должна молча ничего не делать: если снять копию нечем,
администратор видит, чего именно не хватает."""
@@ -203,3 +312,126 @@ def test_backup_failure_explains_what_is_missing(as_admin, monkeypatch):
response = as_admin.post("/api/admin/backups")
assert response.status_code == 503
assert "pg_dump" in response.json()["detail"]
+
+
+def test_pg17_transaction_timeout_is_removed_from_pg16_backup(monkeypatch, tmp_path):
+ """Образ backend может содержать клиент новее сервера из Compose."""
+ from app.admin import backup as backup_service
+
+ monkeypatch.setattr(backup_service, "DIR", tmp_path)
+ monkeypatch.setattr(backup_service, "_dsn", lambda: ("u", "p", "db", "5432", "lct"))
+ monkeypatch.setattr(backup_service.shutil, "which", lambda name: "/usr/bin/pg_dump")
+
+ class Result:
+ returncode = 0
+ stderr = b""
+ stdout = (b"--\n-- PostgreSQL database dump\n--\n\n"
+ b"SET transaction_timeout = 0;\nCREATE TABLE demo(id integer);\n")
+
+ monkeypatch.setattr(backup_service.subprocess, "run", lambda *args, **kwargs: Result())
+ made = backup_service.create()
+ content = (tmp_path / made["name"]).read_bytes()
+ assert b"transaction_timeout" not in content
+ assert b"CREATE TABLE demo" in content
+
+
+def test_docker_backup_does_not_put_database_password_in_process_arguments(monkeypatch, tmp_path):
+ import base64
+ from types import SimpleNamespace
+
+ from app.admin import backup as backup_service
+
+ password = "secret p@ss:word"
+ monkeypatch.setattr(backup_service, "DIR", tmp_path)
+ monkeypatch.setattr(backup_service, "_dsn", lambda: ("backup", password, "db", "5432", "lct"))
+ monkeypatch.setattr(
+ backup_service.shutil, "which", lambda name: "/usr/bin/docker" if name == "docker" else None
+ )
+ captured = {}
+
+ def fake_run(command, **kwargs):
+ captured["command"] = command
+ captured["input"] = kwargs["input"]
+ return SimpleNamespace(
+ returncode=0,
+ stderr=b"",
+ stdout=b"--\n-- PostgreSQL database dump\nCREATE TABLE demo(id integer);\n",
+ )
+
+ monkeypatch.setattr(backup_service.subprocess, "run", fake_run)
+ backup_service.create()
+
+ assert password not in " ".join(captured["command"])
+ assert captured["input"] == base64.b64encode(password.encode()) + b"\n"
+
+
+def test_backup_dsn_decodes_escaped_credentials_without_exposing_them(monkeypatch):
+ from types import SimpleNamespace
+ from app.admin import backup as backup_service
+
+ monkeypatch.setattr(
+ backup_service, "get_settings",
+ lambda: SimpleNamespace(database_url="postgresql://backup:p%40ss%3Aword@db.example:5433/lct"),
+ )
+ assert backup_service._dsn() == ("backup", "p@ss:word", "db.example", "5433", "lct")
+
+ monkeypatch.setattr(
+ backup_service, "get_settings",
+ lambda: SimpleNamespace(database_url="not-a-valid-url-with-secret"),
+ )
+ try:
+ backup_service._dsn()
+ except backup_service.BackupError as exc:
+ assert "secret" not in str(exc)
+ else:
+ raise AssertionError("invalid DATABASE_URL must be rejected")
+
+
+def test_backup_directory_failure_is_retryable_backup_error(monkeypatch, tmp_path):
+ from app.admin import backup as backup_service
+
+ blocker = tmp_path / "not-a-directory"
+ blocker.write_text("occupied")
+ monkeypatch.setattr(backup_service, "DIR", blocker / "backups")
+ monkeypatch.setattr(backup_service, "_dsn", lambda: ("u", "p", "db", "5432", "lct"))
+
+ with pytest.raises(backup_service.BackupError, match="каталог резервных копий"):
+ backup_service.create()
+
+
+def test_backup_listing_io_failure_is_retryable_error(monkeypatch, tmp_path):
+ from app.admin import backup as backup_service
+
+ monkeypatch.setattr(backup_service, "DIR", tmp_path)
+
+ def denied(_path):
+ raise PermissionError("access denied")
+
+ monkeypatch.setattr(backup_service.os, "scandir", denied)
+ with pytest.raises(backup_service.BackupError, match="прочитать каталог"):
+ backup_service.listing()
+
+
+def test_windows_backup_permissions_use_explicit_private_acl(monkeypatch, tmp_path):
+ from types import SimpleNamespace
+ from app.admin import backup as backup_service
+
+ monkeypatch.setattr(backup_service, "getuser", lambda: "operator")
+ monkeypatch.setenv("USERDOMAIN", "TRAINING")
+ monkeypatch.setenv("SystemRoot", str(tmp_path))
+ called = {}
+
+ def fake_run(command, **kwargs):
+ called["command"] = command
+ called["kwargs"] = kwargs
+ return SimpleNamespace(returncode=0)
+
+ monkeypatch.setattr(backup_service.subprocess, "run", fake_run)
+ backup_service._restrict_file_permissions(-1, tmp_path / "copy.sql", platform_name="nt")
+
+ command = called["command"]
+ assert command[0].endswith("icacls.exe")
+ assert "/inheritance:r" in command
+ assert "TRAINING\\operator:(F)" in command
+ assert "*S-1-5-18:(F)" in command
+ assert called["kwargs"]["timeout"] == 15
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
index 49a6826..4cf28b4 100644
--- a/backend/tests/test_auth.py
+++ b/backend/tests/test_auth.py
@@ -56,6 +56,17 @@ def test_unknown_login_and_wrong_password_look_the_same(client):
assert first.json()["detail"] == "bad_credentials"
+def test_login_rejects_values_outside_database_and_hash_bounds(client):
+ too_long_login = client.post(
+ "/api/auth/login", json={"login": "a" * 81, "password": "not-used"}
+ )
+ too_long_password = client.post(
+ "/api/auth/login", json={"login": "operator", "password": "x" * 1025}
+ )
+ assert too_long_login.status_code == 422
+ assert too_long_password.status_code == 422
+
+
def test_me_requires_authentication(client):
assert client.get("/api/auth/me").status_code == 401
diff --git a/backend/tests/test_auth_hardening.py b/backend/tests/test_auth_hardening.py
index 4a079cf..df0f789 100644
--- a/backend/tests/test_auth_hardening.py
+++ b/backend/tests/test_auth_hardening.py
@@ -18,6 +18,10 @@ from app.session.hub import hub
@pytest.fixture
def client():
+ # Each TestClient represents a fresh backend process. In particular,
+ # revocations exercised by a previous test must not leak into the next
+ # process' in-memory cache.
+ auth.prime_generations({})
with TestClient(app) as test_client:
hub.journal = None
yield test_client
@@ -49,6 +53,12 @@ def test_cookie_from_previous_process_is_rejected(client, monkeypatch):
assert client.get("/api/auth/me").status_code == 401
+def test_cookie_survives_generation_cache_reload_when_account_is_unchanged(client):
+ assert client.post("/api/auth/dev-token").status_code == 200
+ auth.prime_generations({"dev": 0})
+ assert client.get("/api/auth/me").status_code == 200
+
+
class FakeDb:
def __init__(self, user):
self.user = user
@@ -74,7 +84,7 @@ 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),
+ password_hash="old", auth_version=0, created_at=datetime.now(timezone.utc),
)
@@ -86,7 +96,10 @@ async def test_admin_patch_revokes_cookie_after_commit(monkeypatch):
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)))
+ monkeypatch.setattr(
+ admin, "invalidate_login",
+ lambda login, version=None: calls.append((login, version, db.commits)),
+ )
async def no_audit(*_args, **_kwargs):
return None
@@ -94,7 +107,7 @@ async def test_admin_patch_revokes_cookie_after_commit(monkeypatch):
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)]
+ assert calls == [("victim", 1, 1)]
@pytest.mark.asyncio
diff --git a/backend/tests/test_backup_scheduler.py b/backend/tests/test_backup_scheduler.py
new file mode 100644
index 0000000..ffa4ee8
--- /dev/null
+++ b/backend/tests/test_backup_scheduler.py
@@ -0,0 +1,20 @@
+from datetime import datetime, timedelta, timezone
+
+from scripts import backup_loop
+
+
+def test_no_backup_is_due_immediately(monkeypatch):
+ monkeypatch.setattr(backup_loop, "listing", lambda: [])
+ assert backup_loop.seconds_until_due(datetime.now(timezone.utc), 86_400) == 0
+
+
+def test_fresh_backup_waits_only_for_remaining_interval(monkeypatch):
+ now = datetime.now(timezone.utc)
+ monkeypatch.setattr(backup_loop, "listing", lambda: [{"at": now - timedelta(hours=6)}])
+ assert backup_loop.seconds_until_due(now, 86_400) == 18 * 3600
+
+
+def test_overdue_backup_is_due_immediately(monkeypatch):
+ now = datetime.now(timezone.utc)
+ monkeypatch.setattr(backup_loop, "listing", lambda: [{"at": now - timedelta(days=2)}])
+ assert backup_loop.seconds_until_due(now, 86_400) == 0
diff --git a/backend/tests/test_card_exercise.py b/backend/tests/test_card_exercise.py
index a8ca82a..c814385 100644
--- a/backend/tests/test_card_exercise.py
+++ b/backend/tests/test_card_exercise.py
@@ -160,6 +160,11 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
station.send_json({"type": "phone.dial"})
+ assert read_until(station, "phone.line")["speaker"] == "crew"
+ read_until(station, "station.state")
+ station.send_json({"type": "phone.brief", "address": "улица Ленина, 14",
+ "incident": "Пожар в квартире",
+ "request": "Прошу подтвердить выезд и доложить о прибытии"})
assert read_until(station, "phone.report")["phase"] == "dispatched"
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": service, "status": "responding"})
@@ -170,7 +175,8 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
score = wait_for(lambda: hub.get(session_id).score)
assert hub.get(session_id).ended
keys = {item["key"] for item in score["metrics"]}
- assert {"address", "incident_signs", "dds_primary", "dds_contact"} <= keys
+ assert {"address", "incident_signs", "dds_primary", "dds_progress"} <= keys
+ assert "dds_contact" not in keys
assert score["score_auto"] < 100
finally:
control.__exit__(None, None, None)
diff --git a/backend/tests/test_dds_exercise.py b/backend/tests/test_dds_exercise.py
index c8fab6f..576af2c 100644
--- a/backend/tests/test_dds_exercise.py
+++ b/backend/tests/test_dds_exercise.py
@@ -1,13 +1,14 @@
"""Готовая карточка → учебный звонок бригаде → числовая оценка ДДС."""
import time
+from datetime import datetime
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
-from app.main import app
from app.domain.timers import TimerCode
+from app.main import app
from app.session.hub import hub
@@ -37,16 +38,39 @@ def read_until(socket, wanted):
raise AssertionError(f"событие {wanted} не пришло")
-def start(client, exercise="dds"):
+def start(client, exercise="dds", criteria=None, dds_service=None, scenario_id="fire-apartment-l2"):
session_id = uuid4()
context = client.websocket_connect(f"/ws/control/{session_id}")
control = context.__enter__()
- control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
- "trainee": "Иванов", "mode": "training", "exercise": exercise})
+ payload = {"type": "scenario.start", "scenario_id": scenario_id,
+ "trainee": "Иванов", "mode": "training", "exercise": exercise}
+ if criteria is not None:
+ payload["criteria"] = criteria
+ if dds_service is not None:
+ payload["dds_service"] = dds_service
+ control.send_json(payload)
wait_for(lambda: hub.get(session_id))
return session_id, context
+def complete_phone_call(station, state, expected_phase):
+ station.send_json({"type": "phone.dial"})
+ greeting = read_until(station, "phone.line")
+ assert greeting["speaker"] == "crew"
+ read_until(station, "station.state")
+ if expected_phase == "dispatched":
+ station.send_json({"type": "phone.brief", "address": state.dispatched_card.address,
+ "incident": state.scenario_title,
+ "request": "Прошу подтвердить выезд и сообщить о прибытии"})
+ else:
+ station.send_json({"type": "phone.check", "text": "Сообщите текущую обстановку по карточке"})
+ assert read_until(station, "phone.line")["speaker"] == "dispatcher"
+ assert read_until(station, "phone.line")["speaker"] == "crew"
+ report = read_until(station, "phone.report")
+ assert report["phase"] == expected_phase
+ return report
+
+
def test_dds_starts_with_prepared_card_without_call(client):
session_id, control = start(client)
try:
@@ -62,10 +86,125 @@ def test_dds_starts_with_prepared_card_without_call(client):
assert card["card"]["address"] == "улица Ленина, 14"
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["crew_options"]
+ assert len(snapshot["services"]) == 1
+ assert snapshot["managed_service"] == snapshot["services"][0]
+ assert set(snapshot["services"]) < set(snapshot["recipient_services"])
finally:
control.__exit__(None, None, None)
-def test_dds_call_reports_gate_status_and_affect_numeric_score(client):
+
+def test_ticket_dds_card_uses_source_caller_identity_and_phone(client):
+ session_id, control = start(client, scenario_id="t01-1-fire-container")
+ try:
+ state = hub.get(session_id)
+ assert state.kio.caller_name == "Сидоров Иван Сергеевич"
+ assert state.kio.caller_number == "916-126-34-71"
+ assert state.kio.caller_contact == "916-126-34-71"
+ with client.websocket_connect(f"/ws/station/{session_id}") as station:
+ card = read_until(station, "card.received")["card"]
+ assert card["caller_name"] == "Сидоров Иван Сергеевич"
+ assert card["caller_number"] == "916-126-34-71"
+ assert card["caller_contact"] == "916-126-34-71"
+ finally:
+ control.__exit__(None, None, None)
+
+
+def test_ticket_with_multiple_witnesses_does_not_guess_caller_name(client):
+ session_id, control = start(client, scenario_id="t20-1-three-fighting")
+ try:
+ state = hub.get(session_id)
+ assert state.kio.caller_name is None
+ assert state.kio.caller_number == "916-123-98-78"
+ assert state.kio.caller_contact == "916-123-98-78"
+ finally:
+ control.__exit__(None, None, None)
+
+
+def test_dds_can_change_only_its_own_service_status(client):
+ session_id, control = start(client, dds_service="МВД")
+ try:
+ with client.websocket_connect(f"/ws/station/{session_id}") as station:
+ read_until(station, "card.received")
+ snapshot = read_until(station, "station.state")["snapshot"]
+ assert snapshot["services"] == ["МВД"]
+ assert "Служба 101" in snapshot["recipient_services"]
+ station.send_json({
+ "type": "card.status", "service": "Служба 101", "status": "accepted",
+ })
+ assert "только своей ДДС" in read_until(station, "error")["message"]
+ station.send_json({
+ "type": "card.status", "service": "МВД", "status": "accepted",
+ })
+ assert read_until(station, "station.state")["snapshot"]["statuses"]["МВД"] == "accepted"
+ finally:
+ control.__exit__(None, None, None)
+
+
+def test_instructor_criteria_change_timer_and_success_threshold(client):
+ session_id, control = start(client, criteria={
+ "decision_time_limit_seconds": 45,
+ "allowed_errors": 50,
+ "require_correct_grammar": False,
+ "score_weights": {"dds_ack": 3.5},
+ })
+ try:
+ state = hub.get(session_id)
+ assert state.criteria.decision_time_limit_seconds == 45
+ assert state.timers.limits[TimerCode.DDS_ACK] == 45_000
+ with client.websocket_connect(f"/ws/station/{session_id}") as station:
+ card = read_until(station, "card.received")
+ 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": card["card"]["card_id"],
+ "text": "Сообщение приняты, бригада направлено."})
+ read_until(station, "station.state")
+ station.send_json({"type": "station.finish"})
+ read_until(station, "score.ready")
+
+ score = wait_for(lambda: hub.get(session_id).score)
+ ack = next(item for item in score["metrics"] if item["key"] == "dds_ack")
+ assert ack["norm"] == "≤ 45 с"
+ assert ack["weight"] == 3.5
+ assert not any(item["key"] in {"dds_reply", "dds_grammar"} for item in score["metrics"])
+ report = client.get(f"/api/sessions/{session_id}/report").json()
+ assert report["criteria"] == {
+ "decision_time_limit_seconds": 45,
+ "allowed_errors": 50,
+ "require_correct_grammar": False,
+ "score_weights": {"dds_ack": 3.5},
+ }
+ assert report["failed_metrics"] > 0
+ assert report["passed"] is True
+ finally:
+ control.__exit__(None, None, None)
+
+def test_dds_statuses_do_not_require_phone_reports(client):
+ session_id, control = start(client)
+ try:
+ with client.websocket_connect(f"/ws/station/{session_id}") as station:
+ read_until(station, "card.received")
+ 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.status", "service": service, "status": "responding"})
+ assert read_until(station, "station.state")["snapshot"]["statuses"][service] == "responding"
+ station.send_json({"type": "station.finish"})
+ read_until(station, "score.ready")
+ score = wait_for(lambda: hub.get(session_id).score)
+ keys = {metric["key"] for metric in score["metrics"]}
+ assert "dds_primary" in keys and "dds_progress" in keys
+ assert "dds_contact" not in keys and "dds_crew" not in keys
+ assert "answer_time" not in keys and "interview_time" not in keys
+ assert 0 < score["score_auto"] < 100
+ assert all(not finding["code"].startswith("E") for finding in score["findings"])
+ finally:
+ control.__exit__(None, None, None)
+
+
+def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
@@ -75,25 +214,35 @@ def test_dds_call_reports_gate_status_and_affect_numeric_score(client):
crew = next(option for option in snapshot["crew_options"] if option.startswith(service + " — "))
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
read_until(station, "station.state")
- station.send_json({"type": "card.status", "service": service, "status": "responding"})
- assert "доклада" in read_until(station, "error")["message"]
station.send_json({"type": "crew.select", "crew": crew})
- selected = read_until(station, "station.state")["snapshot"]
- assert selected["crew_selected"] == crew
- station.send_json({"type": "phone.dial"})
- report = read_until(station, "phone.report")
- assert report["phase"] == "dispatched" and report["crew"] == crew
- assert read_until(station, "station.state")["snapshot"]["phone_reports"]
- station.send_json({"type": "card.status", "service": service, "status": "responding"})
read_until(station, "station.state")
- station.send_json({"type": "station.finish"})
- read_until(station, "score.ready")
- score = wait_for(lambda: hub.get(session_id).score)
- keys = {metric["key"] for metric in score["metrics"]}
- assert "dds_primary" in keys and "dds_contact" in keys
- assert "answer_time" not in keys and "interview_time" not in keys
- assert 0 < score["score_auto"] < 100
- assert all(not finding["code"].startswith("E") for finding in score["findings"])
+ station.send_json({"type": "phone.dial"})
+ assert read_until(station, "phone.line")["speaker"] == "crew"
+ pending = read_until(station, "station.state")["snapshot"]
+ assert pending["phone_pending"]["phase"] == "dispatched"
+ assert not pending["phone_reports"]
+ station.send_json({"type": "card.status", "service": service, "status": "responding"})
+ assert read_until(station, "station.state")["snapshot"]["statuses"][service] == "responding"
+ station.send_json({"type": "phone.brief", "address": "другая улица, дом 99",
+ "incident": "Пожар в квартире",
+ "request": "Прошу направить бригаду"})
+ assert "адрес" in read_until(station, "error")["message"]
+ station.send_json({"type": "phone.brief", "address": "улица Ленина, 14",
+ "incident": "Ничего не произошло",
+ "request": "Прошу направить бригаду"})
+ assert "характер" in read_until(station, "error")["message"]
+ station.send_json({"type": "phone.hangup"})
+ assert read_until(station, "station.state")["snapshot"]["phone_pending"] is None
+ assert not hub.get(session_id).phone_reports
+ complete_phone_call(station, hub.get(session_id), "dispatched")
+ read_until(station, "station.state")
+ assert len(hub.get(session_id).phone_reports) == 1
+ station.send_json({"type": "phone.dial"})
+ read_until(station, "phone.line")
+ read_until(station, "station.state")
+ station.send_json({"type": "phone.check", "text": "Здравствуйте, хорошая погода"})
+ assert "обстановку" in read_until(station, "error")["message"]
+ assert len(hub.get(session_id).phone_reports) == 1
finally:
control.__exit__(None, None, None)
@@ -108,26 +257,16 @@ def test_default_exercise_remains_call(client):
assert state.dispatched_card is None
-def test_complete_dds_workflow_scores_without_call_penalties(client):
+def test_complete_dds_workflow_scores_100_without_any_call(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
for service in snapshot["services"]:
- crew = next(option for option in snapshot["crew_options"]
- if option.startswith(service + " — "))
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
read_until(station, "station.state")
- station.send_json({"type": "crew.select", "crew": crew})
- read_until(station, "station.state")
- for expected_phase, status in (
- ("dispatched", "responding"), ("arrived", "arrived"),
- ("working", "working"), ("completed", "completed"),
- ):
- station.send_json({"type": "phone.dial"})
- assert read_until(station, "phone.report")["phase"] == expected_phase
- read_until(station, "station.state")
+ for status in ("responding", "arrived", "working", "completed"):
station.send_json({"type": "card.status", "service": service, "status": status})
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["card"] == "completed"
@@ -137,11 +276,13 @@ def test_complete_dds_workflow_scores_without_call_penalties(client):
assert score["score_auto"] == 100.0
assert not score["findings"]
assert all(metric["key"].startswith("dds_") for metric in score["metrics"])
+ assert not any(metric["key"] in {"dds_contact", "dds_crew", "dds_reply", "dds_grammar"}
+ for metric in score["metrics"])
finally:
control.__exit__(None, None, None)
-def test_sequential_dds_cards_keep_separate_state_and_scores(client):
+def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
session_id = uuid4()
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
control = control_ctx.__enter__()
@@ -157,37 +298,53 @@ def test_sequential_dds_cards_keep_separate_state_and_scores(client):
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})
+ assert len(snapshot["queue_cards"]) == 2
+ first_row, second_row = snapshot["queue_cards"]
+ second_card_id = second_row["card_id"]
+ assert first_row["active"] is True and second_row["active"] is False
+ assert abs(
+ datetime.fromisoformat(first_row["received_at"]).timestamp()
+ - datetime.fromisoformat(second_row["received_at"]).timestamp()
+ ) < 1
+ first_service = snapshot["services"][0]
+ station.send_json({"type": "card.status", "service": first_service,
+ "status": "accepted"})
+ snapshot = read_until(station, "station.state")["snapshot"]
+ first_elapsed = next(item for item in snapshot["queue_cards"]
+ if item["card_id"] == first_card_id)["elapsed_ms"]
+
+ time.sleep(0.03)
+ # Card switching must publish its own fresh station snapshot; do
+ # not let the one-second ticker mask a missing update.
+ hub.stop_ticker(session_id)
+ station.send_json({"type": "card.open", "card_id": second_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"]["card_id"] == second_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
+ second_queue_row = next(item for item in snapshot["queue_cards"]
+ if item["card_id"] == second_card_id)
+ assert second_queue_row["active"] is True
+ assert second_queue_row["elapsed_ms"] >= first_elapsed
+ first_queue_row = next(item for item in snapshot["queue_cards"]
+ if item["card_id"] == first_card_id)
+ assert first_queue_row["service_status"] == "accepted"
+ assert first_queue_row["timer_stopped"] is True
- 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": "Сообщение принято, бригада направлена на место происшествия."})
+ second_service = snapshot["services"][0]
+ station.send_json({"type": "card.status", "service": second_service,
+ "status": "accepted"})
read_until(station, "station.state")
+ station.send_json({"type": "card.open", "card_id": first_card_id})
+ assert read_until(station, "card.received")["card"]["card_id"] == first_card_id
+ restored = read_until(station, "station.state")["snapshot"]
+ assert restored["statuses"][first_service] == "accepted"
+ station.send_json({"type": "card.next", "card_id": first_card_id})
+ assert read_until(station, "card.received")["card"]["card_id"] == second_card_id
+ after_close = read_until(station, "station.state")["snapshot"]
+ assert len(after_close["queue_cards"]) == 1
+ assert after_close["statuses"][second_service] == "accepted"
station.send_json({"type": "card.next", "card_id": second_card_id})
read_until(station, "score.ready")
@@ -196,20 +353,22 @@ def test_sequential_dds_cards_keep_separate_state_and_scores(client):
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 "dds_primary" in {item["key"] for item in state.score["metrics"]}
+ assert not any(item["key"] in {"dds_reply", "dds_grammar"}
+ for item in state.score["metrics"])
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 {item["type"] for item in first_actions} >= {"card.status"}
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):
+def test_instructor_end_grades_all_concurrently_issued_cards(client):
session_id = uuid4()
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
control = control_ctx.__enter__()
@@ -227,8 +386,10 @@ def test_instructor_can_end_multi_card_early_without_grading_future_cards(client
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"
+ assert len(state.score["card_results"]) == 2
+ assert [item["scenario_id"] for item in state.score["card_results"]] == [
+ "fire-apartment-l2", "t20-2-stroke",
+ ]
finally:
control_ctx.__exit__(None, None, None)
@@ -246,8 +407,8 @@ def test_each_dds_card_uses_its_own_scenario_weights():
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}
+ first.score_weights = {"dds_primary": 7.0}
+ second.score_weights = {"dds_primary": 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,
@@ -258,5 +419,5 @@ def test_each_dds_card_uses_its_own_scenario_weights():
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
+ assert next(item.weight for item in first_record.metrics if item.key == "dds_primary") == 7.0
+ assert next(item.weight for item in second_record.metrics if item.key == "dds_primary") == 2.0
diff --git a/backend/tests/test_demo_no_db.py b/backend/tests/test_demo_no_db.py
index e213e91..50cd256 100644
--- a/backend/tests/test_demo_no_db.py
+++ b/backend/tests/test_demo_no_db.py
@@ -50,7 +50,8 @@ def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch)
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}
+ {"id": str(DEMO_TRAINEE_ID), "name": "Демо-курсант", "group": None,
+ "service": "Служба 101"}
]
# БД-зависимые экраны получают быстрый и явный отказ, не ждут TCP timeout.
assert client.get("/api/sessions").json()["detail"] == "database_disabled_demo"
diff --git a/backend/tests/test_ekp.py b/backend/tests/test_ekp.py
index 70c11f7..0fb9431 100644
--- a/backend/tests/test_ekp.py
+++ b/backend/tests/test_ekp.py
@@ -30,10 +30,32 @@ def test_all_customer_ticket_cards_are_complete_and_classified():
for ticket in range(1, 33):
assert {scenario.position for scenario in cards if scenario.ticket == ticket} == {1, 2, 3}
assert all(scenario.facts and scenario.signs and scenario.ground_truth.address
- and scenario.ground_truth.victims is not None
and scenario.ground_truth.incident_code
and ekp.incident(scenario.ground_truth.incident_code)
for scenario in cards)
+ unknown_victim_counts = {scenario.id for scenario in cards
+ if scenario.ground_truth.victims is None}
+ assert unknown_victim_counts == {
+ "t02-2-megafon-consultation", "t02-3-car-in-water", "t03-2-loud-music",
+ "t04-1-balcony-fire", "t04-3-open-door", "t12-3-men-on-bridge-rail",
+ "t07-3-lost-elderly", "t08-1-mall-smoke", "t14-1-grass-fire-azs", "t17-1-fire-alarm",
+ "t12-1-restaurant-smoke", "t30-3-gas-smell-house", "t31-3-gas-pipe-whistle",
+ "t11-3-lost-in-forest", "t16-1-smoke-column",
+ "t18-1-unknown-fire", "t24-1-parking-quarrel", "t25-1-drunk-at-stop",
+ "t23-3-lost-child", "t27-1-suspicious-car", "t28-1-stranger-at-door", "t29-1-ticking-box",
+ "t29-3-threat-to-blow-up", "t30-1-car-theft-yesterday",
+ "t31-1-car-theft-witnessed", "t32-1-carjacking", "t32-3-street-lights",
+ }
+
+
+def test_unknown_ticket_victim_count_is_not_scored():
+ from app.scoring.card import evaluate_card
+
+ cards = [scenario for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
+ if scenario.ticket is not None and scenario.ground_truth.victims is None]
+ assert len(cards) == 27
+ assert all("victims_count" not in {metric.key for metric in evaluate_card(scenario, KIO()).metrics}
+ for scenario in cards)
def test_signs_give_the_code_from_the_book():
diff --git a/backend/tests/test_group_insight.py b/backend/tests/test_group_insight.py
new file mode 100644
index 0000000..f8d9fcc
--- /dev/null
+++ b/backend/tests/test_group_insight.py
@@ -0,0 +1,67 @@
+"""ИИ-инсайт получает только агрегаты и обязан вернуть строгий JSON."""
+
+import json
+
+import pytest
+
+from app.config import get_settings
+from app.scoring import group_insight
+
+
+@pytest.fixture(autouse=True)
+def local_settings(monkeypatch):
+ monkeypatch.setenv("LLM_PROVIDER", "local")
+ monkeypatch.setenv("LLM_BASE_URL", "http://127.0.0.1:18080/v1")
+ get_settings.cache_clear()
+ yield
+ get_settings.cache_clear()
+
+
+@pytest.mark.asyncio
+async def test_insight_uses_only_aggregates_and_strict_schema(monkeypatch):
+ seen = []
+
+ class FakeClient:
+ def __init__(self, **_kwargs): pass
+ async def complete(self, request, **_kwargs):
+ seen.append(request)
+ return json.dumps({
+ "summary": "Группа чаще всего теряет обязательные сведения при опросе.",
+ "priorities": ["Отработать уточнение адреса и этажа."],
+ }, ensure_ascii=False)
+ async def aclose(self): pass
+
+ monkeypatch.setattr(group_insight, "LlmClient", FakeClient)
+ result = await group_insight.generate_group_insight({
+ "active_trainees": 4,
+ "scored_attempts": 7,
+ "average_score": 76.2,
+ "errors": [{
+ "code": "E2", "title": "Пропущен обязательный факт",
+ "rate_percent": 50.0, "recommendation": "Повторить алгоритм опроса",
+ }],
+ })
+ assert result["priorities"] == ["Отработать уточнение адреса и этажа."]
+ request = seen[0]
+ assert request.temperature == 0
+ assert request.response_format["schema"]["additionalProperties"] is False
+ payload = json.loads(request.messages[1]["content"])
+ assert set(payload) == {"active_trainees", "scored_attempts", "average_score", "typical_errors"}
+ assert "trainee_id" not in json.dumps(payload)
+ assert "group" not in json.dumps(payload)
+
+
+@pytest.mark.asyncio
+async def test_insight_rejects_wrong_shape(monkeypatch):
+ class FakeClient:
+ def __init__(self, **_kwargs): pass
+ async def complete(self, *_args, **_kwargs):
+ return '{"summary":"слишком кратко","priorities":[],"secret":"x"}'
+ async def aclose(self): pass
+
+ monkeypatch.setattr(group_insight, "LlmClient", FakeClient)
+ with pytest.raises(group_insight.InsightInvalid):
+ await group_insight.generate_group_insight({
+ "active_trainees": 1, "scored_attempts": 1, "average_score": 50,
+ "errors": [],
+ })
diff --git a/backend/tests/test_local_models.py b/backend/tests/test_local_models.py
new file mode 100644
index 0000000..08223f0
--- /dev/null
+++ b/backend/tests/test_local_models.py
@@ -0,0 +1,276 @@
+"""Локальная маршрутизация моделей не должна зависеть от сети или весов."""
+
+import json
+
+import httpx
+import numpy as np
+import pytest
+
+from app.config import get_settings
+from app.dialog.caller import LlmCaller, _allowed_reply
+from app.dialog.factory import build_caller
+from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, _spoken_content, is_loopback_url
+from app.dialog.persona import PersonaState
+from app.scoring.grammar import assess, basic_check
+from app.dialog.slots import SlotMachine
+from app.voice.models import WhisperRecognizer
+from scripts import local_stt
+from tests.test_slots import SCENARIO, StemEmbedder
+
+
+@pytest.fixture(autouse=True)
+def clear_settings():
+ get_settings.cache_clear()
+ yield
+ get_settings.cache_clear()
+
+
+def test_offline_model_address_must_be_literal_loopback():
+ assert is_loopback_url("http://127.0.0.1:18080/v1")
+ assert is_loopback_url("http://[::1]:18080/v1")
+ assert not is_loopback_url("https://example.com/v1")
+ assert not is_loopback_url("http://localhost.evil.test:18080/v1")
+ assert not is_loopback_url("http://127.0.0.1.evil.test:18080/v1")
+ assert not is_loopback_url("http://user:password@127.0.0.1:18080/v1")
+ assert not is_loopback_url("http://host.docker.internal:18080/v1")
+ assert is_loopback_url(
+ "http://host.docker.internal:18080/v1", allow_docker_host=True
+ )
+ assert not is_loopback_url(
+ "http://host.docker.internal.evil.test:18080/v1", allow_docker_host=True
+ )
+
+
+@pytest.mark.asyncio
+async def test_local_llm_uses_loopback_without_api_key(monkeypatch):
+ monkeypatch.setenv("OFFLINE", "true")
+ monkeypatch.setenv("LLM_PROVIDER", "local")
+ monkeypatch.setenv("LLM_API_KEY", "")
+ requests = []
+
+ def answer(request):
+ requests.append(request)
+ return httpx.Response(200, json={"choices": [{"message": {"content": "Алло, помогите!"}}]})
+
+ client = LlmClient(transport=httpx.MockTransport(answer))
+ try:
+ text = await client.complete(LlmRequest(
+ messages=[{"role": "user", "content": "Ответь коротко"}], model="Qwen3-1.7B"
+ ), use_cache=False)
+ finally:
+ await client.aclose()
+ assert text == "Алло, помогите!"
+ assert str(requests[0].url) == "http://127.0.0.1:18080/v1/chat/completions"
+ assert "authorization" not in requests[0].headers
+
+
+@pytest.mark.asyncio
+async def test_local_llm_passes_strict_response_format(monkeypatch):
+ monkeypatch.setenv("OFFLINE", "true")
+ seen = []
+
+ def answer(request):
+ seen.append(json.loads(request.content))
+ return httpx.Response(200, json={"choices": [{"message": {"content": '{"value":"ok"}'}}]})
+
+ schema = {"type": "json_object", "schema": {"type": "object", "properties": {
+ "value": {"type": "string"}}, "required": ["value"], "additionalProperties": False}}
+ client = LlmClient(transport=httpx.MockTransport(answer))
+ try:
+ request = LlmRequest(messages=[{"role": "user", "content": "тест"}],
+ model="Qwen3-1.7B", response_format=schema)
+ assert await client.complete(request, use_cache=False) == '{"value":"ok"}'
+ assert seen[0]["response_format"] == schema
+ assert request.cache_key() != LlmRequest(messages=request.messages,
+ model=request.model).cache_key()
+ finally:
+ await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_offline_never_uses_remote_llm(monkeypatch):
+ monkeypatch.setenv("OFFLINE", "true")
+ monkeypatch.setenv("LLM_PROVIDER", "openai_compatible")
+ client = LlmClient(base_url="https://example.com/v1",
+ transport=httpx.MockTransport(lambda _: pytest.fail("внешний запрос")))
+ try:
+ with pytest.raises(LlmUnavailable):
+ await client.complete(LlmRequest(messages=[], model="x"), use_cache=False)
+ finally:
+ await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_malformed_local_answer_falls_back_instead_of_crashing(monkeypatch):
+ monkeypatch.setenv("OFFLINE", "true")
+ monkeypatch.setenv("LLM_PROVIDER", "local")
+ client = LlmClient(transport=httpx.MockTransport(
+ lambda _: httpx.Response(200, json={"choices": []})
+ ))
+ try:
+ with pytest.raises(LlmUnavailable, match="некорректный ответ"):
+ await client.complete(LlmRequest(messages=[], model="Qwen3-1.7B"), use_cache=False)
+ finally:
+ await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_qwen_disabled_thinking_closing_marker_is_not_spoken(monkeypatch):
+ monkeypatch.setenv("OFFLINE", "true")
+ client = LlmClient(transport=httpx.MockTransport(
+ lambda _: httpx.Response(200, json={"choices": [{"message": {
+ "content": "\n\nгорит балкон"}}]})
+ ))
+ try:
+ assert await client.complete(LlmRequest(messages=[], model="Qwen3-1.7B"), use_cache=False) == "горит балкон"
+ finally:
+ await client.aclose()
+
+
+@pytest.mark.parametrize("raw", [
+ "адрес 99 горит балкон",
+ "Я думаю: адрес 99 горит балкон",
+ "",
+ "<|im_start|>assistant горит балкон",
+])
+def test_reasoning_or_control_tokens_are_never_spoken(raw):
+ with pytest.raises(LlmUnavailable):
+ _spoken_content(raw)
+
+
+def test_internal_structured_task_can_discard_closed_reasoning_block():
+ raw = 'Сначала выберу поля. \n{"title":"Учебный пожар"}'
+ assert _spoken_content(raw, strip_reasoning=True) == '{"title":"Учебный пожар"}'
+ raw = 'Сначала выберу поля.\n\n{"title":"Учебный пожар"}'
+ assert _spoken_content(raw, strip_reasoning=True) == '{"title":"Учебный пожар"}'
+ with pytest.raises(LlmUnavailable):
+ _spoken_content("незакрытое рассуждение", strip_reasoning=True)
+
+
+def test_model_mode_selects_qwen_or_vikhr(monkeypatch):
+ monkeypatch.setenv("OFFLINE", "true")
+ monkeypatch.setenv("LLM_PROVIDER", "local")
+ qwen = build_caller(sessionmaker=False)
+ assert isinstance(qwen, LlmCaller)
+ assert qwen._model == "Qwen3-1.7B"
+ assert qwen._client._base_url == "http://127.0.0.1:18080/v1"
+ monkeypatch.setenv("DIALOGUE_MODEL_MODE", "russian_control")
+ get_settings.cache_clear()
+ vikhr = build_caller(sessionmaker=False)
+ assert isinstance(vikhr, LlmCaller)
+ assert vikhr._model == "Vikhr-1B"
+ assert vikhr._client._base_url == "http://127.0.0.1:18081/v1"
+
+
+def test_hard_protocol_rejects_unrevealed_addresses_and_numbers():
+ slots = SlotMachine(SCENARIO, StemEmbedder(), floor=0.5)
+ slots.hear("Что именно горит?")
+ allowed = {fact.id: fact.value for fact in slots.revealed_facts()}
+ assert _allowed_reply("Горит балкон!", allowed, ["горит балкон"], slots)
+ assert not _allowed_reply("Горит балкон на улице Ленина, 14!", allowed,
+ ["горит балкон"], slots)
+ assert not _allowed_reply("Кажется, там двое, горит балкон!", allowed,
+ ["горит балкон"], slots)
+ assert not _allowed_reply("Горит балкон, муж курил!", allowed,
+ ["горит балкон"], slots)
+ assert not _allowed_reply("Помогите!", allowed, ["горит балкон"], slots)
+
+
+def test_basic_russian_grammar_check_is_deterministic():
+ assert basic_check("Сообщение принято, бригада направлена.").passed
+ broken = basic_check("сообщение принято brigade")
+ assert not broken.passed
+ assert set(broken.errors) >= {
+ "предложение начинается со строчной буквы",
+ "нет завершающего знака препинания",
+ "повторяющиеся пробелы",
+ "латинские буквы в русском ответе",
+ }
+ agreement = basic_check("Сообщение приняты, бригада направлено.")
+ assert not agreement.passed
+ assert sum("согласование" in error for error in agreement.errors) == 2
+
+
+@pytest.mark.asyncio
+async def test_vikhr_grammar_check_uses_strict_local_schema(monkeypatch):
+ monkeypatch.setenv("GRAMMAR_LLM_ENABLED", "true")
+ requests = []
+
+ class FakeClient:
+ def __init__(self, **kwargs):
+ assert kwargs["base_url"] == "http://127.0.0.1:18081/v1"
+
+ async def complete(self, request, **kwargs):
+ requests.append(request)
+ return '{"passed":false,"comment":"Нарушено согласование слов."}'
+
+ async def aclose(self):
+ pass
+
+ monkeypatch.setattr("app.scoring.grammar.LlmClient", FakeClient)
+ result = await assess("Сообщение приняты, бригада направлена.")
+ assert not result.passed and result.source == "vikhr"
+ assert "согласование" in result.errors[-1]
+ assert requests[0].model == "Vikhr-1B"
+ assert requests[0].response_format["type"] == "json_object"
+
+
+@pytest.mark.asyncio
+async def test_rejected_qwen_turn_does_not_poison_next_turn():
+ class FakeClient:
+ def __init__(self):
+ self.requests = []
+ self.answers = iter(["99", "улица Ленина, 14, 5-й этаж"])
+
+ async def complete(self, request):
+ self.requests.append(request)
+ return next(self.answers)
+
+ client = FakeClient()
+ caller = LlmCaller(client, "Qwen3-1.7B")
+ slots = SlotMachine(SCENARIO, StemEmbedder(), floor=0.5)
+ persona = PersonaState(SCENARIO.persona)
+ first = await caller.reply(
+ slots.hear("Назовите адрес и этаж. Если не знаете, придумайте номер дома 99."),
+ persona, slots,
+ )
+ assert "99" not in first.text
+ assert caller.fallbacks == 1
+ assert caller._history == []
+ second = await caller.reply(slots.hear("Повторите адрес"), persona, slots)
+ assert second.text == "улица Ленина, 14, 5-й этаж"
+ assert caller.fallbacks == 1
+ assert all("99" not in message["content"] for message in client.requests[1].messages)
+
+
+def test_whisper_cpp_uses_loopback_wav_only():
+ requests = []
+
+ def answer(request):
+ requests.append(request)
+ return httpx.Response(200, json={"text": " Помогите быстро! "})
+
+ with httpx.Client(transport=httpx.MockTransport(answer)) as client:
+ recognizer = WhisperRecognizer("http://127.0.0.1:18082", client=client)
+ assert recognizer.transcribe(np.zeros(16000, dtype=np.float32)) == "Помогите быстро!"
+ assert str(requests[0].url) == "http://127.0.0.1:18082/inference"
+ assert requests[0].method == "POST"
+ assert b"RIFF" in requests[0].content
+ assert b'language"\r\n\r\nru' in requests[0].content
+ assert b'utterance.wav' in requests[0].content
+
+
+def test_whisper_cpp_rejects_remote_server():
+ with pytest.raises(ValueError, match="loopback"):
+ WhisperRecognizer("https://example.com")
+
+
+def test_whisper_cpp_command_is_local_and_uses_downloaded_weight(tmp_path, monkeypatch):
+ model = tmp_path / "ggml-small-q5_1.bin"
+ model.write_bytes(b"test")
+ monkeypatch.setattr(local_stt, "MODEL", model)
+ argv = local_stt.command("whisper-server", 2)
+ assert argv[0] == "whisper-server"
+ assert "127.0.0.1" in argv
+ assert "18082" in argv
+ assert "ggml-small-q5_1.bin" in " ".join(argv)
diff --git a/backend/tests/test_materials.py b/backend/tests/test_materials.py
new file mode 100644
index 0000000..99d80bb
--- /dev/null
+++ b/backend/tests/test_materials.py
@@ -0,0 +1,168 @@
+"""Учебные материалы: создание, назначение, изучение и безопасная загрузка."""
+
+import base64
+from uuid import UUID
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app.config import get_settings
+from app.main import app
+from app.session.hub import hub
+
+
+@pytest.fixture
+def client(monkeypatch):
+ monkeypatch.setenv("DEMO_NO_DB", "true")
+ monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
+ get_settings.cache_clear()
+ try:
+ with TestClient(app) as test_client:
+ yield test_client
+ finally:
+ get_settings.cache_clear()
+
+
+def _instructor(client: TestClient) -> None:
+ response = client.post("/api/auth/dev-token")
+ assert response.status_code == 200
+
+
+def _trainee(client: TestClient) -> None:
+ client.post("/api/auth/logout")
+ response = client.post(
+ "/api/auth/login", json={"login": "demo-trainee", "password": "demo"}
+ )
+ assert response.status_code == 200
+
+
+def test_teacher_creates_assigns_and_trainee_completes_text_material(client):
+ _instructor(client)
+ created = client.post("/api/materials", json={
+ "title": "Порядок доклада старшему",
+ "description": "Перед практическим занятием",
+ "level": "L2",
+ "kind": "text",
+ "body": "Передайте адрес, тип события, задачу и подтвердите выезд.",
+ "scenario_id": "fire-apartment-l2",
+ })
+ assert created.status_code == 201, created.text
+ material_id = created.json()["id"]
+ assert created.json()["assignment_count"] == 0
+
+ assigned = client.put(
+ f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112"
+ )
+ assert assigned.status_code == 200, assigned.text
+ assert assigned.json()["assigned_at"]
+
+ _trainee(client)
+ listing = client.get("/api/materials")
+ assert listing.status_code == 200
+ item = next(item for item in listing.json() if item["id"] == material_id)
+ assert item["body"].startswith("Передайте адрес")
+ assert item["completed_at"] is None
+
+ completed = client.post(f"/api/materials/{material_id}/complete")
+ assert completed.status_code == 200
+ assert completed.json()["completed_at"]
+ assert client.post("/api/materials", json={
+ "title": "Нельзя создать", "kind": "text", "body": "запрещено",
+ }).status_code == 403
+
+
+def test_teacher_cannot_edit_archive_or_assign_another_teachers_material(client, monkeypatch):
+ from app.api.auth import Principal
+ from app.api.http import materials as materials_api
+ from app.domain.roles import Role
+
+ identity = {"login": "teacher-one"}
+
+ def instructor(_request, *_roles):
+ return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
+
+ monkeypatch.setattr(materials_api, "require", instructor)
+ created = client.post("/api/materials", json={
+ "title": "Личный материал",
+ "level": "L1",
+ "kind": "text",
+ "body": "Учебный текст.",
+ })
+ assert created.status_code == 201, created.text
+ material_id = created.json()["id"]
+
+ identity["login"] = "teacher-two"
+ assert client.patch(f"/api/materials/{material_id}", json={"title": "Подмена"}).status_code == 404
+ assert client.delete(f"/api/materials/{material_id}").status_code == 404
+ assert client.put(
+ f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112"
+ ).status_code == 404
+ assert client.delete(
+ f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112"
+ ).status_code == 404
+
+
+def test_uploaded_file_is_limited_sanitized_and_downloaded_as_attachment(client):
+ _instructor(client)
+ content = b"local training resource\n"
+ created = client.post("/api/materials", json={
+ "title": "Локальная памятка PDF",
+ "kind": "file",
+ "file_name": "C:\\Users\\teacher\\guide.txt",
+ "media_type": "text/plain",
+ "content_base64": base64.b64encode(content).decode(),
+ })
+ assert created.status_code == 201, created.text
+ data = created.json()
+ assert data["file_name"] == "guide.txt"
+ assert data["file_size"] == len(content)
+ assert len(data["file_sha256"]) == 64
+
+ downloaded = client.get(f"/api/materials/{data['id']}/download")
+ assert downloaded.status_code == 200
+ assert downloaded.content == content
+ assert downloaded.headers["x-content-type-options"] == "nosniff"
+ assert downloaded.headers["content-disposition"].startswith("attachment")
+
+
+def test_unassigned_trainee_cannot_download_resource(client):
+ _instructor(client)
+ created = client.post("/api/materials", json={
+ "title": "Закрытый ресурс",
+ "kind": "file",
+ "file_name": "private.pdf",
+ "media_type": "application/pdf",
+ "content_base64": base64.b64encode(b"%PDF-demo").decode(),
+ })
+ material_id = created.json()["id"]
+ _trainee(client)
+ assert client.get(f"/api/materials/{material_id}/download").status_code == 403
+
+
+def test_archive_hides_material_from_trainee_but_keeps_record(client):
+ _instructor(client)
+ seeded = client.get("/api/materials").json()[0]
+ archived = client.delete(f"/api/materials/{seeded['id']}")
+ assert archived.status_code == 200
+ assert archived.json()["active"] is False
+ assert client.get("/api/materials").json() == []
+ archived_list = client.get("/api/materials?include_archived=true").json()
+ assert len(archived_list) == 1 and archived_list[0]["active"] is False
+
+ _trainee(client)
+ assert client.get("/api/materials").json() == []
+
+
+def test_trainee_starts_assigned_practice_in_self_mode(client):
+ _trainee(client)
+ seeded = client.get("/api/materials").json()[0]
+ started = client.post(f"/api/materials/{seeded['id']}/start")
+ assert started.status_code == 200, started.text
+ payload = started.json()
+ assert payload["mode"] == "self"
+ assert payload["exercise"] == "card"
+ assert payload["path"].startswith("/trainee?session=")
+ state = hub.get(UUID(payload["session_id"]))
+ assert state is not None
+ assert state.trainee_id.hex == "00000000000040008000000000000112"
+ hub.drop(state.session_id)
diff --git a/backend/tests/test_recording.py b/backend/tests/test_recording.py
new file mode 100644
index 0000000..816acf7
--- /dev/null
+++ b/backend/tests/test_recording.py
@@ -0,0 +1,69 @@
+"""WAV-запись вызова: формат, микширование и защищённая выдача."""
+
+import wave
+from datetime import datetime, timezone
+from types import SimpleNamespace
+from uuid import uuid4
+
+import numpy as np
+from fastapi.testclient import TestClient
+
+from app.api.auth import Principal
+from app.api.http import sessions
+from app.domain.roles import Role
+from app.main import app
+from app.voice.recording import CallRecorder
+
+
+def test_recorder_mixes_16_and_24_khz_into_atomic_wav(tmp_path):
+ ticks = iter((10.0, 10.0, 10.02))
+ path = tmp_path / "call.wav"
+ recorder = CallRecorder(path, clock=lambda: next(ticks))
+ recorder.add_pcm(np.full(320, 1000, dtype="= 640
+ samples = np.frombuffer(source.readframes(source.getnframes()), dtype="= 2000
+
+
+def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypatch):
+ session_id = uuid4()
+ path = tmp_path / f"{session_id}.wav"
+ with wave.open(str(path), "wb") as target:
+ target.setnchannels(1)
+ target.setsampwidth(2)
+ target.setframerate(16_000)
+ target.writeframes(b"\x00\x00" * 320)
+
+ async def fake_session(db, requested):
+ assert requested == session_id
+ return SimpleNamespace(
+ trainee_id=uuid4(), owner_login="dev", ended_at=datetime.now(timezone.utc),
+ )
+
+ monkeypatch.setattr(sessions.repo, "get_session", fake_session)
+ monkeypatch.setattr(sessions, "recording_path", lambda requested: path)
+ with TestClient(app) as client:
+ assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 401
+ client.post("/api/auth/dev-token")
+ response = client.get(f"/api/sessions/{session_id}/recording.wav")
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "audio/wav"
+ assert response.content.startswith(b"RIFF")
+
+ monkeypatch.setattr(
+ sessions,
+ "require",
+ lambda request, *roles: Principal(
+ login="other", full_name="Другой", role=Role.TRAINEE, trainee_id=uuid4()
+ ),
+ )
+ assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 403
diff --git a/backend/tests/test_report_export.py b/backend/tests/test_report_export.py
index 7ddfe68..b143379 100644
--- a/backend/tests/test_report_export.py
+++ b/backend/tests/test_report_export.py
@@ -1,5 +1,6 @@
"""Экспорт разбора: содержимое, безопасность CSV, кириллица и доступ."""
+import asyncio
import csv
import io
from datetime import datetime, timezone
@@ -8,13 +9,14 @@ from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
+from fastapi import HTTPException
from app.api.auth import Principal
from app.api.http import sessions
from app.domain.events import SessionReport
from app.domain.roles import Role
from app.main import app
-from app.scoring.export import _cell, to_csv, to_pdf
+from app.scoring.export import _cell, certificate_pdf, to_csv, to_pdf
def sample_report(*, long: bool = False) -> SessionReport:
@@ -25,6 +27,10 @@ def sample_report(*, long: bool = False) -> SessionReport:
"scenario_id": "=1+1",
"mode": "training",
"attempt": 2,
+ "criteria": {"decision_time_limit_seconds": 45, "allowed_errors": 1,
+ "require_correct_grammar": True},
+ "failed_metrics": 1,
+ "passed": True,
"transcript": [
{"ref": f"u{i}", "speaker": "caller", "text": long_text if i == 0 else "<вопрос> \t=cmd", "at": at}
for i in range(18 if long else 2)
@@ -82,10 +88,33 @@ def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
assert "Заявитель сообщает о дыме" in extracted
+def test_certificate_pdf_contains_saved_result(tmp_path):
+ data = certificate_pdf(
+ trainee_name="Петров Пётр Сергеевич",
+ trainee_id=uuid4(),
+ group_name="ДДС-17",
+ attempts=4,
+ average_score=87.25,
+ issued_at="2026-09-23",
+ )
+ assert data.startswith(b"%PDF-")
+ path = tmp_path / "certificate.pdf"
+ path.write_bytes(data)
+ import shutil
+ import subprocess
+
+ if shutil.which("pdftotext"):
+ extracted = subprocess.check_output(["pdftotext", str(path), "-"], text=True)
+ assert "СЕРТИФИКАТ" in extracted
+ assert "Петров Пётр Сергеевич" in extracted
+ assert "87.2 из 100" in extracted
+ assert "не заменяет квалификационный документ" in extracted
+
+
@pytest.fixture
def client(monkeypatch):
report = sample_report()
- state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4())
+ state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4(), owner_login="dev")
monkeypatch.setattr(sessions, "_live", lambda session_id: (state, object()))
monkeypatch.setattr(sessions, "build_report", lambda session_id, state, scenario: report)
with TestClient(app) as test_client:
@@ -119,3 +148,101 @@ def test_trainee_cannot_export_another_persons_report(client, monkeypatch):
)
for suffix in ("csv", "pdf"):
assert browser.get(f"/api/sessions/{report.session_id}/report.{suffix}").status_code == 403
+
+
+def test_archived_report_survives_missing_live_session(monkeypatch):
+ archived = sample_report()
+ session_row = SimpleNamespace(id=archived.session_id, trainee_id=uuid4(), owner_login="teacher")
+ score_row = SimpleNamespace(
+ score_auto=70.0,
+ score_final=82.0,
+ overridden_by="Преподаватель",
+ override_comment="проверено после занятия",
+ report={"full_report": archived.model_dump(mode="json")},
+ )
+
+ class FakeDb:
+ async def get(self, model, key):
+ return session_row
+
+ async def scalar(self, statement):
+ return score_row
+
+ monkeypatch.setattr(sessions, "_live", lambda session_id: (_ for _ in ()).throw(
+ HTTPException(status_code=404, detail="session_not_found")
+ ))
+ monkeypatch.setattr(
+ sessions, "require",
+ lambda request: Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR),
+ )
+ restored = asyncio.run(sessions._report_data(archived.session_id, object(), FakeDb()))
+ assert restored.session_id == archived.session_id
+ assert restored.score_auto == 70
+ assert restored.score_final == 82
+ assert restored.override_comment == "проверено после занятия"
+
+
+def test_archived_score_override_updates_json_exports_and_audit(monkeypatch):
+ archived = sample_report()
+ session_row = SimpleNamespace(id=archived.session_id, trainee_id=uuid4(), owner_login="teacher")
+ score_row = SimpleNamespace(
+ score_auto=70.0,
+ score_final=70.0,
+ overridden_by=None,
+ override_comment=None,
+ report={"full_report": archived.model_dump(mode="json")},
+ )
+
+ class FakeDb:
+ def __init__(self):
+ self.added = []
+ self.commits = 0
+
+ async def get(self, model, key):
+ assert key == archived.session_id
+ return session_row
+
+ async def scalar(self, statement):
+ return score_row
+
+ def add(self, row):
+ self.added.append(row)
+
+ async def commit(self):
+ self.commits += 1
+
+ db = FakeDb()
+ monkeypatch.setattr(sessions.hub, "get", lambda session_id: None)
+ monkeypatch.setattr(sessions, "_live", lambda session_id: (_ for _ in ()).throw(
+ HTTPException(status_code=404, detail="session_not_found")
+ ))
+ monkeypatch.setattr(
+ sessions, "require",
+ lambda request, *roles: Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR),
+ )
+
+ corrected = asyncio.run(sessions.override(
+ archived.session_id,
+ sessions.ScoreOverride(score_final=84.5, comment="проверена запись переговоров"),
+ object(),
+ db,
+ ))
+
+ assert db.commits == 1, "оценка и аудит должны фиксироваться одной транзакцией"
+ assert score_row.score_auto == 70.0
+ assert score_row.score_final == 84.5
+ assert score_row.overridden_by == "teacher"
+ assert score_row.report["full_report"]["score_final"] == 84.5
+ assert corrected.score_auto == 70.0 and corrected.score_final == 84.5
+ assert corrected.override_comment == "проверена запись переговоров"
+ audit = db.added[0]
+ assert audit.action == "score.override" and audit.actor == "teacher"
+ assert "84.5" in audit.detail and "проверена запись переговоров" in audit.detail
+
+ report = asyncio.run(sessions.report(archived.session_id, object(), db))
+ assert report.score_final == 84.5 and report.score_auto == 70.0
+ csv_response = asyncio.run(sessions.report_csv(archived.session_id, object(), db))
+ assert "84.5" in csv_response.body.decode("utf-8-sig")
+ assert "проверена запись переговоров" in csv_response.body.decode("utf-8-sig")
+ pdf_response = asyncio.run(sessions.report_pdf(archived.session_id, object(), db))
+ assert pdf_response.body.startswith(b"%PDF-")
diff --git a/backend/tests/test_scenario_editor.py b/backend/tests/test_scenario_editor.py
index 9334224..04941c8 100644
--- a/backend/tests/test_scenario_editor.py
+++ b/backend/tests/test_scenario_editor.py
@@ -6,10 +6,15 @@ import pytest
from fastapi.testclient import TestClient
from app.api.http import scenarios as scenarios_api
-from app.db.base import get_session
+from app.config import get_settings
+from app.dialog.llm import LlmUnavailable
from app.main import app
from app.scenarios import store
from app.scenarios.editor import merge_patch, template_copy, validate
+from app.scenarios import generation
+from app.scenarios.generation import (GenerationError, correction_target,
+ full_proposal_body, parse_full_proposal,
+ parse_proposal, proposal_body, style_fallback)
from app.scenarios.loader import ScenarioError, load_file
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
@@ -31,6 +36,13 @@ class FakeSession:
pass
async def scalars(self, query):
+ expression = query.column_descriptions[0]["expr"]
+ if getattr(expression, "key", None) == "id":
+ owner_login = query.compile().params.get("owner_login_1")
+ return [
+ row.id for row in self.rows.values()
+ if row.status == "published" and row.owner_login == owner_login
+ ]
return [row for row in self.rows.values() if row.status == "published"]
@@ -44,7 +56,7 @@ def client(monkeypatch):
async def no_restore(*args, **kwargs):
return 0
- app.dependency_overrides[get_session] = lambda: db
+ app.dependency_overrides[scenarios_api.scenario_session] = lambda: db
monkeypatch.setattr(scenarios_api, "audit", no_audit)
monkeypatch.setattr(store, "restore_published", no_restore)
with TestClient(app) as test_client:
@@ -75,6 +87,236 @@ def test_editor_rejects_derived_truth_and_missing_fact():
validate(broken)
+def test_ai_proposal_changes_only_story_and_keeps_reference():
+ source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ proposal = parse_proposal('''```json
+{"title":"Ночной пожар на балконе","first_line":"Алло, помогите, балкон горит и всё в дыму!",
+ "facts":{"f_what_burns":"на балконе открытое пламя","f_smoke":"дым заполнил подъезд"}}
+```''', source)
+ body = proposal_body(source, "ai-example", proposal)
+ generated = validate(body)
+ assert generated.facts[0].value == source.facts[0].value
+ assert generated.ground_truth.address == source.ground_truth.address
+ assert generated.ground_truth.victims == source.ground_truth.victims
+ assert generated.ground_truth.incident_code == source.ground_truth.incident_code
+ assert generated.facts[1].value == "на балконе открытое пламя"
+
+
+def test_ai_proposal_rejects_address_change_and_premature_number():
+ source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ with pytest.raises(GenerationError, match="защищённые"):
+ parse_proposal('{"title":"Другой пожар","first_line":"Алло, помогите, у нас горит балкон!",'
+ '"facts":{"f_address":"другой адрес"}}', source)
+ with pytest.raises(GenerationError, match="числовые"):
+ parse_proposal('{"title":"Другой пожар","first_line":"Горит дом 99, срочно приезжайте!",'
+ '"facts":{}}', source)
+ with pytest.raises(GenerationError, match="числовые"):
+ parse_proposal('{"title":"Другой пожар","first_line":"Один человек пострадал, помогите!",'
+ '"facts":{}}', source)
+ with pytest.raises(GenerationError, match="вопрос вместо ответа"):
+ parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",'
+ '"facts":{"f_smoke":"Куда идёт дым?"}}', source)
+ with pytest.raises(GenerationError, match="плейсхолдер"):
+ parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",'
+ '"facts":{"f_smoke":"{f_smoke}"}}', source)
+ with pytest.raises(GenerationError, match="новых обстоятельств"):
+ parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",'
+ '"facts":{}}', source)
+ revised = parse_proposal(
+ '{"title":"Пожар — короткая реплика","first_line":"Пожар! Помогите!","facts":{}}',
+ source, require_fact_change=False, instruction="Сделай реплику короче",
+ restrict_first_line_facts=True,
+ )
+ assert revised["first_line"] == "Пожар! Помогите!"
+ with pytest.raises(GenerationError, match="не запрошенные"):
+ parse_proposal(
+ '{"title":"Пожар — короткая реплика",'
+ '"first_line":"Горим! Не успел перекрыть газ!","facts":{}}',
+ source, require_fact_change=False, instruction="Сделай реплику короче",
+ restrict_first_line_facts=True,
+ )
+
+
+def test_revision_target_is_selected_only_when_comment_names_a_fact():
+ source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ assert correction_target(source, "Сделай дым чёрным и плотным") == "f_smoke"
+ assert correction_target(source, "Сделай первую реплику короче и тревожнее") is None
+ shortened = style_fallback(source, "Сделай первую реплику короче")
+ assert shortened["first_line"] == "Помогите! Горим!"
+ assert style_fallback(source, "Сделай тон спокойнее") is None
+
+
+def test_full_ai_proposal_replaces_all_facts_and_derives_soft_truth():
+ source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ raw = '''{
+ "title":"Пожар на балконе жилого дома",
+ "first_line":"Помогите, с балкона валит чёрный дым!",
+ "facts":{
+ "f_address":"улица Новая, 8, корпус 2, первый этаж",
+ "f_what_burns":"на балконе жилого дома видно открытое пламя",
+ "f_people":"все вышли; Пострадавших: 0",
+ "f_smoke":"густой чёрный дым идёт в лестничный холл",
+ "f_gas":"газового оборудования в квартире нет"
+ },
+ "ground_truth":{"address":"улица Новая, 8","victims":0}
+ }'''
+ proposal = parse_full_proposal(raw, source)
+ body = full_proposal_body(source, "ai-full-example", proposal)
+ generated = validate(body)
+ assert generated.ground_truth.address == "улица Новая, 8"
+ assert generated.ground_truth.victims == 0
+ assert generated.ground_truth.incident_code == source.ground_truth.incident_code
+ assert generated.facts[0].value.startswith("улица Новая")
+ assert all(fact.refined is None and fact.refine_on is None for fact in generated.facts)
+ assert parse_full_proposal(raw.replace("Пострадавших: 0", "пострадавших нет"), source)
+
+ inconsistent = raw.replace("Пожар на балконе жилого дома", "Пожар в мастерской") \
+ .replace("с балкона", "из мастерской") \
+ .replace("на балконе жилого дома видно открытое пламя", "горит оборудование мастерской")
+ with pytest.raises(ScenarioError, match="сюжет не подтверждает путь ЕКП"):
+ full_proposal_body(source, "ai-full-inconsistent", parse_full_proposal(inconsistent, source))
+
+ with pytest.raises(GenerationError, match="числа пострадавших"):
+ parse_full_proposal(raw.replace("Пострадавших: 0", "Пострадавших не видно"), source)
+ with pytest.raises(GenerationError, match="адрес не совпадает"):
+ parse_full_proposal(raw.replace('"address":"улица Новая, 8"',
+ '"address":"улица Другая, 9"'), source)
+
+
+@pytest.mark.asyncio
+async def test_ai_generation_retries_copied_facts_with_strict_schema(monkeypatch):
+ source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ requests = []
+
+ class FakeClient:
+ def __init__(self, **kwargs):
+ self.answers = iter([
+ '{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
+ '"facts":{"f_smoke":"дым идёт в подъезд, на площадке ничего не видно"}}',
+ '{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
+ '"facts":{"f_smoke":"лестница уже заполнена густым дымом"}}',
+ ])
+
+ async def complete(self, request, **kwargs):
+ requests.append(request)
+ return next(self.answers)
+
+ async def aclose(self):
+ pass
+
+ monkeypatch.setattr(generation, "LlmClient", FakeClient)
+ proposal = await generation.generate(
+ source, "Ночной пожар на балконе, густой дым в подъезде, заявитель паникует"
+ )
+ assert proposal["facts"]["f_smoke"] == "лестница уже заполнена густым дымом"
+ assert len(requests) == 2
+ assert requests[0].response_format["type"] == "json_object"
+ assert set(requests[0].response_format["schema"]["properties"]["facts"]["properties"]) == {"f_smoke"}
+ assert "f_smoke" in requests[1].messages[-1]["content"]
+
+
+@pytest.mark.asyncio
+async def test_ai_generation_retries_one_qwen_control_token_failure(monkeypatch):
+ source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+
+ class FakeClient:
+ calls = 0
+
+ def __init__(self, **kwargs):
+ pass
+
+ async def complete(self, request, **kwargs):
+ self.calls += 1
+ if self.calls == 1:
+ raise LlmUnavailable("ответ содержит служебные токены модели")
+ return ('{"title":"Ночной дым","first_line":"Алло, всё в дыму, помогите!",'
+ '"facts":{"f_smoke":"лестница заполнена густым дымом"}}')
+
+ async def aclose(self):
+ pass
+
+ monkeypatch.setattr(generation, "LlmClient", FakeClient)
+ proposal = await generation.generate(source, "Сделай дым густым и чёрным")
+ assert proposal["facts"]["f_smoke"].startswith("лестница")
+
+
+@pytest.mark.asyncio
+async def test_description_generation_asks_small_model_one_fact_at_a_time(monkeypatch):
+ source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ answers = iter([
+ '{"title":"Пожар на балконе жилого дома","first_line":"С балкона идёт дым"}',
+ '{"value":"ул. Маршака, д. 12, этаж 1","address":"ул. Маршака, д. 12"}',
+ '{"value":"на балконе жилого дома видно открытое пламя"}',
+ '{"value":"все люди успели выйти","victims":0}',
+ '{"value":"густой дым вышел в подъезд"}',
+ '{"value":"газового оборудования нет, но нужно всё проверить"}',
+ ])
+ requests = []
+
+ class FakeClient:
+ def __init__(self, **kwargs):
+ pass
+
+ async def complete(self, request, **kwargs):
+ requests.append(request)
+ return next(answers)
+
+ async def aclose(self):
+ pass
+
+ monkeypatch.setattr(generation, "LlmClient", FakeClient)
+ proposal = await generation.generate_from_description(
+ source, "Пожар в мастерской, все вышли, густой дым в подъезде"
+ )
+ assert len(requests) == 6
+ assert requests[0].messages[-1]["content"].find("classifier_signs") >= 0
+ assert all(
+ "classifier_signs" in request.messages[1]["content"]
+ for request in requests[1:]
+ )
+ assert proposal["first_line"].endswith("!")
+ assert proposal["ground_truth"] == {
+ "address": "ул. Маршака, д. 12, этаж 1", "victims": 0,
+ }
+ assert proposal["facts"]["f_people"].endswith("Пострадавших: 0")
+ assert proposal["facts"]["f_gas"] == "газового оборудования нет"
+
+
+@pytest.mark.asyncio
+async def test_description_generation_retries_fact_that_is_a_question(monkeypatch):
+ source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ answers = iter([
+ '{"title":"Пожар на балконе жилого дома","first_line":"С балкона идёт дым"}',
+ '{"value":"ул. Маршака, д. 12, этаж 1","address":"ул. Маршака, д. 12"}',
+ '{"value":"на балконе жилого дома видно открытое пламя"}',
+ '{"value":"Есть ли пострадавшие?","victims":1}',
+ '{"value":"один человек получил ожог; Пострадавших: 1","victims":1}',
+ '{"value":"густой дым вышел в подъезд"}',
+ '{"value":"газового оборудования нет"}',
+ ])
+ requests = []
+
+ class FakeClient:
+ def __init__(self, **kwargs):
+ pass
+
+ async def complete(self, request, **kwargs):
+ requests.append(request)
+ return next(answers)
+
+ async def aclose(self):
+ pass
+
+ monkeypatch.setattr(generation, "LlmClient", FakeClient)
+ proposal = await generation.generate_from_description(
+ source, "Пожар в мастерской, один человек получил ожог, густой дым в подъезде"
+ )
+ assert len(requests) == 7
+ assert "Ответ отклонён" in requests[4].messages[-1]["content"]
+ assert proposal["ground_truth"]["victims"] == 1
+ assert proposal["facts"]["f_people"].endswith("Пострадавших: 1")
+
+
def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
source = next(s for s in store.all_scenarios() if s.id == "fire-apartment-l2")
assert client.post("/api/auth/dev-token").status_code == 200
@@ -106,6 +348,126 @@ def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
assert client.patch(f"/api/scenarios/drafts/{draft_id}", json={"title": "нет"}).status_code == 404
+def test_ai_draft_requires_instructor_review_before_publication(client, monkeypatch):
+ async def fake_generate(source, instruction, **kwargs):
+ assert source.id == "fire-apartment-l2"
+ assert "ночью" in instruction
+ return {"title": "Ночной пожар на балконе",
+ "first_line": "Помогите, у нас горит балкон!",
+ "facts": {"f_smoke": "густой дым уже в подъезде"}}
+
+ monkeypatch.setattr(scenarios_api, "generate", fake_generate)
+ client.post("/api/auth/dev-token")
+ response = client.post("/api/scenarios/drafts/generate", json={
+ "source_id": "fire-apartment-l2", "instruction": "Пожар начался ночью",
+ })
+ assert response.status_code == 201, response.text
+ draft = response.json()
+ assert draft["generation"] == "ai_variant"
+ assert draft["id"].startswith("ai-")
+ assert draft["body"]["first_line"] == "Помогите, у нас горит балкон!"
+ assert store.get(draft["id"]) is None
+ assert client.get(f"/api/scenarios/{draft['id']}").status_code == 404
+ assert client.post(f"/api/scenarios/drafts/{draft['id']}/validate").json()["valid"]
+ assert client.post(f"/api/scenarios/drafts/{draft['id']}/approve").status_code == 200
+ assert store.get(draft["id"]).first_line == "Помогите, у нас горит балкон!"
+
+
+def test_description_generates_new_story_and_reference_answers(client, monkeypatch):
+ async def fake_generate(source, description):
+ assert source.id == "fire-apartment-l2"
+ assert "мастерской" in description
+ return {
+ "title": "Пожар на балконе жилого дома",
+ "first_line": "Помогите, с балкона валит чёрный дым!",
+ "facts": {
+ "f_address": "улица Новая, 8, корпус 2, первый этаж",
+ "f_what_burns": "на балконе жилого дома видно открытое пламя",
+ "f_people": "все вышли; Пострадавших: 0",
+ "f_smoke": "густой чёрный дым идёт в лестничный холл",
+ "f_gas": "газового оборудования в квартире нет",
+ },
+ "ground_truth": {"address": "улица Новая, 8", "victims": 0},
+ }
+
+ monkeypatch.setattr(scenarios_api, "generate_from_description", fake_generate)
+ client.post("/api/auth/dev-token")
+ response = client.post("/api/scenarios/drafts/generate-from-description", json={
+ "source_id": "fire-apartment-l2",
+ "description": "Пожар в мастерской жилого дома, все успели выйти",
+ })
+ assert response.status_code == 201, response.text
+ draft = response.json()
+ assert draft["generation"] == "ai_full"
+ assert draft["id"].startswith("ai-full-")
+ assert draft["body"]["ground_truth"] == {"address": "улица Новая, 8", "victims": 0}
+ assert client.post(f"/api/scenarios/drafts/{draft['id']}/validate").json()["valid"]
+ assert store.get(draft["id"]) is None
+
+
+def test_instructor_revises_same_ai_draft_by_comment(client, monkeypatch):
+ answers = iter([
+ {"title": "Ночной пожар на балконе",
+ "first_line": "Помогите, на балконе горит!",
+ "facts": {"f_smoke": "густой дым уже в подъезде"}},
+ {"title": "Ночной пожар с сильным дымом",
+ "first_line": "Алло, лестница вся в дыму, помогите!",
+ "facts": {"f_smoke": "чёрный дым полностью закрыл лестничную площадку"}},
+ ])
+ comments = []
+
+ async def fake_generate(source, instruction, **kwargs):
+ comments.append(instruction)
+ if len(comments) > 1:
+ assert kwargs["require_fact_change"] is False
+ return next(answers)
+
+ monkeypatch.setattr(scenarios_api, "generate", fake_generate)
+ client.post("/api/auth/dev-token")
+ created = client.post("/api/scenarios/drafts/generate", json={
+ "source_id": "fire-apartment-l2", "instruction": "Добавить густой ночной дым",
+ }).json()
+ draft_id = created["id"]
+ revised = client.post(f"/api/scenarios/drafts/{draft_id}/revise", json={
+ "comment": "Сделай дым чёрным и закрой им площадку",
+ })
+ assert revised.status_code == 200, revised.text
+ body = revised.json()["body"]
+ assert revised.json()["id"] == draft_id
+ assert body["title"] == "Ночной пожар с сильным дымом"
+ assert body["facts"][0]["value"] == "улица Ленина, 14, квартира 47, 5-й этаж"
+ assert next(item["value"] for item in body["facts"] if item["id"] == "f_smoke").startswith("чёрный")
+ assert comments[-1] == "Сделай дым чёрным и закрой им площадку"
+ assert client.post(f"/api/scenarios/drafts/{draft_id}/validate").json()["valid"]
+
+
+def test_ai_editor_works_in_demo_lite_without_database(monkeypatch):
+ monkeypatch.setenv("DEMO_NO_DB", "true")
+ monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
+ get_settings.cache_clear()
+
+ async def fake_generate(source, instruction, **kwargs):
+ return {"title": "Ночной дым на балконе",
+ "first_line": "Алло, помогите, на балконе горит!",
+ "facts": {"f_smoke": "лестница уже заполнена густым дымом"}}
+
+ monkeypatch.setattr(scenarios_api, "generate", fake_generate)
+ try:
+ with TestClient(app) as demo:
+ assert demo.post("/api/auth/dev-token").status_code == 200
+ created = demo.post("/api/scenarios/drafts/generate", json={
+ "source_id": "fire-apartment-l2", "instruction": "Добавить густой ночной дым",
+ })
+ assert created.status_code == 201, created.text
+ draft_id = created.json()["id"]
+ assert demo.get(f"/api/scenarios/drafts/{draft_id}").status_code == 200
+ assert demo.post(f"/api/scenarios/drafts/{draft_id}/validate").json()["valid"]
+ assert demo.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 200
+ assert demo.get(f"/api/scenarios/{draft_id}").status_code == 200
+ finally:
+ get_settings.cache_clear()
+
+
def test_invalid_draft_cannot_be_approved(client):
client.post("/api/auth/dev-token")
response = client.post(
@@ -120,6 +482,24 @@ def test_invalid_draft_cannot_be_approved(client):
assert store.get(draft_id) is None
+def test_unresolved_fact_placeholder_cannot_be_validated_or_approved(client):
+ client.post("/api/auth/dev-token")
+ created = client.post(
+ "/api/scenarios/drafts/from-template",
+ json={"source_id": "fire-apartment-l2"},
+ ).json()
+ draft_id = created["id"]
+ facts = created["body"]["facts"]
+ facts[1]["value"] = "{f_what_burns}"
+ assert client.patch(
+ f"/api/scenarios/drafts/{draft_id}", json={"facts": facts}
+ ).status_code == 200
+ check = client.post(f"/api/scenarios/drafts/{draft_id}/validate")
+ assert check.json()["valid"] is False
+ assert "плейсхолдер" in check.json()["errors"][0]
+ assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 422
+
+
def test_unauthenticated_user_cannot_manage_drafts(client):
response = client.post(
"/api/scenarios/drafts/from-template", json={"source_id": "fire-apartment-l2"}
@@ -127,6 +507,94 @@ def test_unauthenticated_user_cannot_manage_drafts(client):
assert response.status_code == 401
+def test_instructor_archives_and_restores_scenario_without_deleting_history(client):
+ client.post("/api/auth/dev-token")
+ created = client.post(
+ "/api/scenarios/drafts/from-template",
+ json={"source_id": "fire-apartment-l2", "title": "Сценарий для проверки архива"},
+ )
+ assert created.status_code == 201, created.text
+ scenario_id = created.json()["id"]
+ approved = client.post(f"/api/scenarios/drafts/{scenario_id}/approve")
+ assert approved.status_code == 200, approved.text
+ original = store.get(scenario_id)
+ assert original is not None
+
+ archived = client.delete(f"/api/scenarios/{scenario_id}")
+ assert archived.status_code == 200, archived.text
+ assert archived.json()["status"] == "archived"
+ assert store.get(scenario_id) is None
+ assert scenario_id not in {item["id"] for item in client.get("/api/scenarios").json()}
+ assert client.get(f"/api/scenarios/{scenario_id}").status_code == 404
+
+ restored = client.post(f"/api/scenarios/{scenario_id}/restore")
+ assert restored.status_code == 200, restored.text
+ assert restored.json()["status"] == "published"
+ assert store.get(scenario_id).title == original.title
+ assert scenario_id in {item["id"] for item in client.get("/api/scenarios").json()}
+
+
+def test_instructor_cannot_read_or_edit_another_instructors_draft(client, monkeypatch):
+ from app.api.auth import Principal
+ from app.domain.roles import Role
+
+ identity = {"login": "teacher-one"}
+
+ def instructor(_request, *_roles):
+ return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
+
+ monkeypatch.setattr(scenarios_api, "require", instructor)
+ monkeypatch.setattr(scenarios_api, "current", instructor)
+ created = client.post(
+ "/api/scenarios/drafts/from-template",
+ json={"source_id": "fire-apartment-l2", "title": "Личный черновик"},
+ )
+ assert created.status_code == 201, created.text
+ scenario_id = created.json()["id"]
+
+ identity["login"] = "teacher-two"
+ assert client.get(f"/api/scenarios/drafts/{scenario_id}").status_code == 404
+ assert client.patch(
+ f"/api/scenarios/drafts/{scenario_id}", json={"title": "Чужая правка"}
+ ).status_code == 404
+ assert client.post(f"/api/scenarios/drafts/{scenario_id}/approve").status_code == 404
+
+
+def test_instructor_cannot_archive_another_instructors_published_scenario(client, monkeypatch):
+ from app.api.auth import Principal
+ from app.domain.roles import Role
+
+ identity = {"login": "teacher-one"}
+
+ def instructor(_request, *_roles):
+ return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
+
+ monkeypatch.setattr(scenarios_api, "require", instructor)
+ monkeypatch.setattr(scenarios_api, "current", instructor)
+ created = client.post(
+ "/api/scenarios/drafts/from-template",
+ json={"source_id": "fire-apartment-l2", "title": "Публикация автора"},
+ )
+ assert created.status_code == 201, created.text
+ scenario_id = created.json()["id"]
+ assert client.post(f"/api/scenarios/drafts/{scenario_id}/approve").status_code == 200
+ scenario = next(item for item in client.get("/api/scenarios").json() if item["id"] == scenario_id)
+ assert scenario["can_manage"] is True
+
+ identity["login"] = "teacher-two"
+ scenario = next(item for item in client.get("/api/scenarios").json() if item["id"] == scenario_id)
+ assert scenario["can_manage"] is False
+ assert client.delete(f"/api/scenarios/{scenario_id}").status_code == 404
+
+
+def test_scenario_in_active_lesson_cannot_be_archived(client, monkeypatch):
+ client.post("/api/auth/dev-token")
+ monkeypatch.setattr(scenarios_api.hub, "has_active_scenario", lambda _scenario_id: True)
+ response = client.delete("/api/scenarios/fire-apartment-l2")
+ assert response.status_code == 409
+ assert response.json()["detail"] == "scenario_is_used_by_active_session"
+
+
@pytest.mark.asyncio
async def test_approved_scenario_restores_from_local_database():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
diff --git a/backend/tests/test_score_weights.py b/backend/tests/test_score_weights.py
index 51235a1..56e1bb1 100644
--- a/backend/tests/test_score_weights.py
+++ b/backend/tests/test_score_weights.py
@@ -5,7 +5,7 @@ from pathlib import Path
import pytest
from pydantic import ValidationError
-from app.domain.events import Metric
+from app.domain.events import LessonCriteria, Metric
from app.scenarios.loader import load_file
from app.scenarios.schema import Scenario
from app.scoring.gost import GostResult
@@ -37,3 +37,12 @@ def test_scenario_accepts_only_known_finite_weights():
body["score_weights"] = {"address": 11.0}
with pytest.raises(ValidationError, match="от 0 до 10"):
Scenario.model_validate(body)
+
+
+def test_lesson_criteria_validate_metric_weight_overrides():
+ criteria = LessonCriteria(score_weights={"address": 3.5, "dds_ack": 0})
+ assert criteria.score_weights == {"address": 3.5, "dds_ack": 0}
+ with pytest.raises(ValidationError, match="неизвестные метрики"):
+ LessonCriteria(score_weights={"adress": 2})
+ with pytest.raises(ValidationError, match="от 0 до 10"):
+ LessonCriteria(score_weights={"address": 11})
diff --git a/backend/tests/test_session_access.py b/backend/tests/test_session_access.py
index c2305e8..531cd31 100644
--- a/backend/tests/test_session_access.py
+++ b/backend/tests/test_session_access.py
@@ -30,6 +30,35 @@ async def test_trainee_cannot_read_foreign_session(monkeypatch):
assert error.value.status_code == 403
+@pytest.mark.asyncio
+async def test_instructor_cannot_read_foreign_session(monkeypatch):
+ who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
+ monkeypatch.setattr(sessions, "require", lambda _: who)
+
+ async def row(_db, _session_id):
+ return SimpleNamespace(owner_login="teacher-b")
+
+ monkeypatch.setattr(sessions.repo, "get_session", row)
+ with pytest.raises(HTTPException) as error:
+ await sessions.read(uuid4(), request(), db=object())
+ assert error.value.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_instructor_history_is_scoped_to_owner(monkeypatch):
+ who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
+ monkeypatch.setattr(sessions, "require", lambda _: who)
+ seen = {}
+
+ async def history(_db, **filters):
+ seen.update(filters)
+ return []
+
+ monkeypatch.setattr(sessions.repo, "history", history)
+ await sessions.listing(request(), db=object())
+ assert seen["owner_login"] == "teacher-a"
+
+
@pytest.mark.asyncio
async def test_trainee_cannot_read_foreign_checklist(monkeypatch):
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
diff --git a/backend/tests/test_session_checkpoint.py b/backend/tests/test_session_checkpoint.py
new file mode 100644
index 0000000..baa5b48
--- /dev/null
+++ b/backend/tests/test_session_checkpoint.py
@@ -0,0 +1,167 @@
+"""Промежуточное состояние занятия переживает смену backend-процесса."""
+
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from uuid import uuid4
+
+import pytest
+
+from app.domain.events import Exercise, LessonCriteria, SessionMode
+from app.domain.statuses import PhoneCallPending, ServiceStatus
+from app.domain.timers import TimerCode
+from app.scenarios.loader import load_file
+from app.scoring.grammar import basic_check
+from app.session.checkpoint import dump_state, load_state
+from app.session.dds import deliver_due_cards, prepare_queue
+from app.session.state import SessionState, now_utc
+
+LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
+
+
+def dds_state() -> SessionState:
+ scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ state = SessionState(
+ session_id=uuid4(),
+ scenario_id=scenario.id,
+ scenario_title=scenario.title,
+ level=scenario.level.value,
+ mode=SessionMode.TRAINING,
+ exercise=Exercise.DDS,
+ scenario=scenario.model_copy(deep=True),
+ dds_scenarios=[scenario.model_copy(deep=True)],
+ required_fields=list(scenario.required_fields),
+ trainee_name="Курсант для восстановления",
+ attempt=2,
+ criteria=LessonCriteria(
+ decision_time_limit_seconds=45,
+ allowed_errors=1,
+ require_correct_grammar=True,
+ ),
+ )
+ state.timers.limits[TimerCode.DDS_ACK] = 45_000
+ prepare_queue(state, state.dds_scenarios)
+ service = state.notified_services()[0]
+ state.set_service_status(service, ServiceStatus.ACCEPTED, author="диспетчер")
+ state.crew_selected = state.crew_options()[0]
+ state.crew_assignments[service] = state.crew_selected
+ state.phone_pending = PhoneCallPending(
+ service=service, crew=state.crew_selected, phase="dispatched"
+ )
+ state.reply_text = "Сообщение принято, бригада направлена."
+ state.reply_grammar = basic_check(state.reply_text)
+ state.reply_log.append((now_utc(), state.reply_text))
+ state.dds_log.append(("crew.select", now_utc(), state.crew_selected))
+ return state
+
+
+def test_active_dds_session_round_trips_without_losing_work():
+ before = dds_state()
+ payload = dump_state(before)
+ restored = load_state(
+ payload,
+ datetime.now(UTC) - timedelta(seconds=2),
+ )
+
+ assert restored.session_id == before.session_id
+ assert restored.exercise is Exercise.DDS
+ assert restored.criteria.decision_time_limit_seconds == 45
+ assert restored.dispatched_card == before.dispatched_card
+ assert restored.status_log == before.status_log
+ assert restored.crew_selected == before.crew_selected
+ assert restored.crew_assignments == before.crew_assignments
+ assert restored.phone_pending == before.phone_pending
+ assert restored.reply_text == before.reply_text
+ assert restored.reply_grammar == before.reply_grammar
+ assert restored.dds_scenarios[0].id == before.scenario_id
+ # Время простоя backend входит в норматив, а не обнуляет таймер.
+ timer = next(item for item in restored.timers.snapshot() if item.code is TimerCode.DDS_ACK)
+ assert timer.elapsed_ms >= 1_900
+ assert timer.limit_ms == 45_000
+
+
+def test_checkpoint_rejects_unknown_format_version():
+ payload = dump_state(dds_state())
+ payload["version"] = 999
+ with pytest.raises(ValueError, match="версия"):
+ load_state(payload, datetime.now(UTC))
+
+
+def test_concurrent_dds_queue_round_trips_with_each_timer_and_status():
+ first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ second = first.model_copy(
+ deep=True,
+ update={"id": "checkpoint-second", "title": "Вторая карточка восстановления"},
+ )
+ state = SessionState(
+ session_id=uuid4(),
+ scenario_id=first.id,
+ scenario_title=first.title,
+ level=first.level.value,
+ mode=SessionMode.TRAINING,
+ exercise=Exercise.DDS,
+ dds_scenarios=[first, second],
+ )
+ prepare_queue(state, state.dds_scenarios)
+ first_id = state.dispatched_card.card_id
+ first_service = state.managed_services()[0]
+ state.set_service_status(first_service, ServiceStatus.ACCEPTED)
+ state.on_event("card.ack")
+ second_id = state.dds_live_cards[1].card_id
+ assert state.activate_dds_card(second_id)
+
+ restored = load_state(
+ dump_state(state),
+ datetime.now(UTC) - timedelta(seconds=2),
+ )
+
+ assert restored.dds_active_card_id == second_id
+ assert restored.dispatched_card.card_id == second_id
+ assert len(restored.dds_live_cards) == 2
+ first_restored = next(item for item in restored.dds_live_cards if item.card_id == first_id)
+ second_restored = next(item for item in restored.dds_live_cards if item.card_id == second_id)
+ assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED
+ assert first_restored.timers.measured_ms(TimerCode.DDS_ACK) is not None
+ second_timer = next(
+ item for item in second_restored.timers.snapshot() if item.code is TimerCode.DDS_ACK
+ )
+ assert second_timer.elapsed_ms >= 1_900
+ assert second_timer.stopped is False
+
+
+def test_delivering_next_dds_card_does_not_clear_previous_card_state():
+ first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ scenarios = [
+ first.model_copy(
+ deep=True,
+ update={"id": f"scheduled-checkpoint-{index}", "title": f"Карточка {index}"},
+ )
+ for index in range(3)
+ ]
+ state = SessionState(
+ session_id=uuid4(),
+ scenario_id=scenarios[0].id,
+ scenario_title=scenarios[0].title,
+ level=scenarios[0].level.value,
+ mode=SessionMode.TRAINING,
+ exercise=Exercise.DDS,
+ dds_scenarios=scenarios,
+ )
+ prepare_queue(state, scenarios, arrival_interval_seconds=60, max_waiting=1)
+ first_id = state.dds_live_cards[0].card_id
+ service = state.managed_services()[0]
+ state.set_service_status(service, ServiceStatus.ACCEPTED)
+ state.capture_active_dds()
+
+ assert deliver_due_cards(state, now_utc() + timedelta(seconds=61)) == 1
+ first = next(card for card in state.dds_live_cards if card.card_id == first_id)
+ assert first.status_log[-1].status is ServiceStatus.ACCEPTED
+ second = next(card for card in state.dds_live_cards if card.original_index == 1)
+ assert state.activate_dds_card(second.card_id)
+
+ restored = load_state(dump_state(state), now_utc())
+ first_restored = next(card for card in restored.dds_live_cards if card.card_id == first_id)
+ assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED
+ assert restored.dds_active_card_id == second.card_id
+ assert len(restored.dds_live_cards) == 2
+ assert restored.dds_next_scenario_index == 2
+ assert restored.dds_next_arrival_at is not None
diff --git a/backend/tests/test_station.py b/backend/tests/test_station.py
index bd72550..5448e52 100644
--- a/backend/tests/test_station.py
+++ b/backend/tests/test_station.py
@@ -104,9 +104,8 @@ def test_acknowledgement_stops_the_four_second_norm(client):
control.__exit__(None, None, None)
-def test_bounced_card_becomes_e6_with_the_reason(client):
- """Неполнота КИО перестаёт быть процентом в отчёте и становится
- сорванным выездом с конкретной причиной."""
+def test_dds_cannot_return_card_for_112_quality_control(client):
+ """ДДС ставит статусы своей службы, а качество КИО контролирует 112."""
session_id, control, _ = lesson(client)
try:
state = hub.get(session_id)
@@ -117,14 +116,15 @@ def test_bounced_card_becomes_e6_with_the_reason(client):
read_until(station, "card.received")
station.send_json({"type": "card.bounce", "missing_fields": ["floor", "victims_count"],
"comment": "куда ехать без этажа"})
- wait_for(lambda: state.bounced_fields)
+ error = read_until(station, "error")
+ assert "служба контроля 112" in error["message"]
+ assert state.bounced_fields == []
trainee.send_json({"type": "call.hangup"})
wait_for(lambda: state.score is not None)
response = client.get(f"/api/sessions/{session_id}/report").json()
chain = [finding for finding in response["findings"] if finding["code"] == "E6"]
- assert chain, "возврат карточки должен попасть в разбор"
- assert "floor" in chain[0]["summary"] and "выезд сорван" in chain[0]["summary"]
+ assert chain == []
finally:
control.__exit__(None, None, None)
@@ -160,8 +160,9 @@ def test_station_sees_the_notify_list_from_the_classifier(client):
session_id, station, contexts = dispatched(client)
try:
state = read_until(station, "station.state")
- assert "Служба 101" in state["snapshot"]["services"]
- assert len(state["snapshot"]["services"]) > 1
+ assert state["snapshot"]["services"] == ["Служба 101"]
+ assert "Служба 101" in state["snapshot"]["recipient_services"]
+ assert len(state["snapshot"]["recipient_services"]) > 1
assert state["snapshot"]["statuses"]["Служба 101"] == "added"
finally:
close_all(contexts)
diff --git a/backend/tests/test_ws.py b/backend/tests/test_ws.py
index 8ad4d32..ceff84d 100644
--- a/backend/tests/test_ws.py
+++ b/backend/tests/test_ws.py
@@ -6,6 +6,7 @@
import contextlib
import time
+import wave
from uuid import uuid4
import pytest
@@ -256,6 +257,32 @@ def test_events_still_work_between_audio_frames(client):
assert state.audio_frames == 2
+def test_answered_call_is_saved_as_wav_on_hangup(client, monkeypatch, tmp_path):
+ from app.api.ws import call as call_api
+ from app.voice.recording import CallRecorder
+
+ monkeypatch.setattr(
+ call_api, "start_recording",
+ lambda session_id: CallRecorder(tmp_path / f"{session_id}.wav"),
+ )
+ with lesson(client) as (session_id, _):
+ state = hub.get(session_id)
+ path = tmp_path / f"{session_id}.wav"
+ with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
+ read_until(trainee, "call.incoming")
+ trainee.send_json({"type": "call.answer"})
+ read_until(trainee, "call.started")
+ trainee.send_bytes((1000).to_bytes(2, "little", signed=True) * 320)
+ trainee.send_json({"type": "call.hangup"})
+ read_until(trainee, "call.ended")
+ wait_for(path.is_file)
+
+ assert state.recording_path == str(path)
+ with wave.open(str(path), "rb") as source:
+ assert source.getframerate() == 16_000
+ assert source.getnframes() >= 320
+
+
def test_score_waits_for_self_assessment(client):
"""Курсант сначала сверяет своё ощущение с объективной картиной:
расхождение самооценки с автооценкой — отдельный материал для преподавателя."""
diff --git a/backend/uv.lock b/backend/uv.lock
index 333fb9e..d7dc167 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -397,6 +397,7 @@ dependencies = [
{ name = "alembic" },
{ name = "argon2-cffi" },
{ name = "asyncpg" },
+ { name = "certifi" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "itsdangerous" },
@@ -430,6 +431,7 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.13" },
{ name = "argon2-cffi", specifier = ">=23.1" },
{ name = "asyncpg", specifier = ">=0.29" },
+ { name = "certifi", specifier = ">=2024" },
{ name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "itsdangerous", specifier = ">=2.1" },
diff --git a/docker-compose.sip.yml b/docker-compose.sip.yml
new file mode 100644
index 0000000..80d1b3b
--- /dev/null
+++ b/docker-compose.sip.yml
@@ -0,0 +1,28 @@
+services:
+ sip:
+ build: ./sip
+ image: lct-hack-sip:local
+ restart: unless-stopped
+ environment:
+ SIP_6001_PASSWORD: ${SIP_6001_PASSWORD:-}
+ SIP_6002_PASSWORD: ${SIP_6002_PASSWORD:-}
+ SIP_6003_PASSWORD: ${SIP_6003_PASSWORD:-}
+ SIP_6101_PASSWORD: ${SIP_6101_PASSWORD:-}
+ SIP_6102_PASSWORD: ${SIP_6102_PASSWORD:-}
+ SIP_EXTERNAL_MEDIA_ADDRESS: ${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1}
+ ports:
+ - "${BIND_HOST:-127.0.0.1}:${SIP_PORT:-5060}:5060/udp"
+ - "${BIND_HOST:-127.0.0.1}:${SIP_PORT:-5060}:5060/tcp"
+ - "${BIND_HOST:-127.0.0.1}:${SIPS_PORT:-5061}:5061/tcp"
+ - "${BIND_HOST:-127.0.0.1}:${SIP_WS_PORT:-8088}:8088/tcp"
+ - "${BIND_HOST:-127.0.0.1}:${RTP_PORT_START:-10000}-${RTP_PORT_END:-10099}:10000-10099/udp"
+ volumes:
+ - sipdata:/var/lib/lct-sip
+ - siprecordings:/recordings
+ - type: bind
+ source: ${SIP_TLS_DIR:-./.local/sip-tls}
+ target: /tls
+
+volumes:
+ sipdata:
+ siprecordings:
diff --git a/docker-compose.tls.yml b/docker-compose.tls.yml
new file mode 100644
index 0000000..4b1f114
--- /dev/null
+++ b/docker-compose.tls.yml
@@ -0,0 +1,16 @@
+services:
+ backend:
+ environment:
+ SECURE_COOKIES: "true"
+
+ frontend:
+ environment:
+ NGINX_ENVSUBST_TEMPLATE_DIR: /etc/nginx/templates-tls
+ PUBLIC_TLS_PORT: ${TLS_PORT:-5443}
+ TLS_BOOTSTRAP: "true"
+ ports:
+ - "${BIND_HOST:-127.0.0.1}:${TLS_PORT:-5443}:5443"
+ volumes:
+ - type: bind
+ source: ${TLS_CERT_DIR:-./.local/tls}
+ target: /etc/nginx/tls
diff --git a/docker-compose.yml b/docker-compose.yml
index 9ecba32..7a74afe 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,11 +1,14 @@
services:
postgres:
image: postgres:16-alpine
+ restart: unless-stopped
environment:
POSTGRES_USER: lct
POSTGRES_PASSWORD: lct
POSTGRES_DB: lct
- ports: ["5432:5432"]
+ # БД и backend не публикуются в класс: с рабочих мест доступен только
+ # TLS-терминатор frontend. Loopback-порты нужны для администрирования хоста.
+ ports: ["127.0.0.1:${POSTGRES_PORT:-5432}:5432"]
volumes: ["pgdata:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U lct"]
@@ -16,17 +19,67 @@ services:
backend:
build: ./backend
image: lct-hack-backend:local
+ restart: unless-stopped
# Один воркер принципиально: состояние живой сессии и реестр наблюдателей
# живут в памяти процесса (docs/arch/STACK.md).
- command: sh -c "alembic upgrade head && python scripts/seed.py && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1"
+ command: >-
+ sh -c '
+ if [ -z "$${SESSION_SECRET:-}" ]; then
+ if [ ! -s /run/lct/session-secret ]; then
+ python -c "import secrets; print(secrets.token_urlsafe(48))" > /run/lct/session-secret;
+ chmod 600 /run/lct/session-secret;
+ fi;
+ export SESSION_SECRET="$$(cat /run/lct/session-secret)";
+ fi;
+ alembic upgrade head && python scripts/seed.py &&
+ uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1'
environment:
DATABASE_URL: postgresql+asyncpg://lct:lct@postgres:5432/lct
OFFLINE: "true"
VOICE_ENABLED: "false"
+ RECORD_CALLS: "true"
+ RECORDINGS_DIR: /recordings
+ LLM_PROVIDER: local
+ LLM_BASE_URL: ${DOCKER_LLM_BASE_URL:-http://host.docker.internal:18080/v1}
+ LLM_MODEL_CALLER: ${LLM_MODEL_CALLER:-Qwen3-1.7B}
+ LLM_CONTROL_BASE_URL: ${DOCKER_LLM_CONTROL_BASE_URL:-http://host.docker.internal:18081/v1}
+ LLM_MODEL_CONTROL: ${LLM_MODEL_CONTROL:-Vikhr-1B}
+ GRAMMAR_LLM_ENABLED: ${GRAMMAR_LLM_ENABLED:-false}
+ ALLOW_DOCKER_HOST_MODELS: "true"
+ BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
+ BACKUP_KEEP: ${BACKUP_KEEP:-14}
volumes:
- ./backend:/app
- ./scenarios:/scenarios:ro
- ports: ["8000:8000"]
+ - securitydata:/run/lct
+ - recordings:/recordings
+ - backups:/app/backups
+ ports: ["127.0.0.1:${BACKEND_PORT:-8000}:8000"]
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ depends_on:
+ postgres:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=2).read()"]
+ interval: 5s
+ timeout: 3s
+ retries: 12
+ start_period: 10s
+
+ backup:
+ build: ./backend
+ image: lct-hack-backend:local
+ restart: unless-stopped
+ command: python scripts/backup_loop.py
+ environment:
+ DATABASE_URL: postgresql+asyncpg://lct:lct@postgres:5432/lct
+ BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
+ BACKUP_RETRY_SECONDS: ${BACKUP_RETRY_SECONDS:-300}
+ BACKUP_KEEP: ${BACKUP_KEEP:-14}
+ volumes:
+ - ./backend:/app
+ - backups:/app/backups
depends_on:
postgres:
condition: service_healthy
@@ -34,15 +87,22 @@ services:
frontend:
build: ./frontend
image: lct-hack-frontend:local
- command: npm run dev -- --host 0.0.0.0
- volumes:
- - ./frontend:/app
- - /app/node_modules
+ restart: unless-stopped
environment:
- VITE_BACKEND_URL: http://backend:8000
- VITE_WS_BACKEND_URL: ws://backend:8000
- ports: ["5173:5173"]
- depends_on: [backend]
+ BACKEND_HOST: backend
+ BACKEND_PORT: 8000
+ ports: ["${BIND_HOST:-127.0.0.1}:${FRONTEND_PORT:-5173}:5173"]
+ depends_on:
+ backend:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "nginx", "-t"]
+ interval: 10s
+ timeout: 3s
+ retries: 3
volumes:
pgdata:
+ securitydata:
+ recordings:
+ backups:
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 20692fd..390c9cc 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -1,9 +1,21 @@
-FROM node:20-slim
+FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json* .npmrc ./
RUN npm install
COPY . .
+RUN npm run build
+
+FROM nginx:1.27-alpine
+
+RUN apk add --no-cache openssl
+COPY nginx.conf.template /etc/nginx/templates/default.conf.template
+COPY nginx.tls.conf.template /etc/nginx/templates-tls/default.conf.template
+COPY docker-entrypoint.d/15-local-certificate.sh /docker-entrypoint.d/15-local-certificate.sh
+COPY --from=build /app/dist /usr/share/nginx/html
+
+ENV BACKEND_HOST=backend
+ENV BACKEND_PORT=8000
EXPOSE 5173
-CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+EXPOSE 5443
diff --git a/frontend/docker-entrypoint.d/15-local-certificate.sh b/frontend/docker-entrypoint.d/15-local-certificate.sh
new file mode 100755
index 0000000..0b78063
--- /dev/null
+++ b/frontend/docker-entrypoint.d/15-local-certificate.sh
@@ -0,0 +1,26 @@
+#!/bin/sh
+set -eu
+
+if [ "${TLS_BOOTSTRAP:-false}" != "true" ]; then
+ exit 0
+fi
+
+certificate_dir=/etc/nginx/tls
+certificate_file="$certificate_dir/tls.crt"
+private_key_file="$certificate_dir/tls.key"
+mkdir -p "$certificate_dir"
+
+if [ -s "$certificate_file" ] && [ -s "$private_key_file" ]; then
+ exit 0
+fi
+
+openssl req -x509 -nodes -newkey rsa:3072 -sha256 -days 365 \
+ -keyout "$private_key_file" \
+ -out "$certificate_file" \
+ -subj "/CN=localhost/O=LCT local training stand" \
+ -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
+ -addext "keyUsage=digitalSignature,keyEncipherment" \
+ -addext "extendedKeyUsage=serverAuth" 2>/dev/null
+chmod 600 "$private_key_file"
+chmod 644 "$certificate_file"
+echo "TLS: создан локальный bootstrap-сертификат $certificate_file" >&2
diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template
new file mode 100644
index 0000000..2694b99
--- /dev/null
+++ b/frontend/nginx.conf.template
@@ -0,0 +1,38 @@
+server {
+ listen 5173;
+ server_name _;
+ root /usr/share/nginx/html;
+
+ gzip on;
+ gzip_comp_level 6;
+ gzip_min_length 1024;
+ gzip_types application/javascript application/json image/svg+xml text/css text/plain;
+
+ location /api/ {
+ proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location /ws/ {
+ proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_read_timeout 75s;
+ }
+
+ location ~* \.(?:css|js|woff2?|png|svg)$ {
+ try_files $uri =404;
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ add_header Cache-Control "no-cache";
+ }
+}
diff --git a/frontend/nginx.tls.conf.template b/frontend/nginx.tls.conf.template
new file mode 100644
index 0000000..32c7aad
--- /dev/null
+++ b/frontend/nginx.tls.conf.template
@@ -0,0 +1,71 @@
+server {
+ listen 5173;
+ server_name _;
+ return 308 https://$host:${PUBLIC_TLS_PORT}$request_uri;
+}
+
+server {
+ listen 5443 ssl;
+ server_name _;
+ root /usr/share/nginx/html;
+
+ ssl_certificate /etc/nginx/tls/tls.crt;
+ ssl_certificate_key /etc/nginx/tls/tls.key;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_session_cache shared:LCT_TLS:10m;
+ ssl_session_timeout 1d;
+ ssl_session_tickets off;
+
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header Referrer-Policy "same-origin" always;
+
+ gzip on;
+ gzip_comp_level 6;
+ gzip_min_length 1024;
+ gzip_types application/javascript application/json image/svg+xml text/css text/plain;
+
+ location /api/ {
+ proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto https;
+ }
+
+ location /ws/ {
+ proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Proto https;
+ proxy_read_timeout 75s;
+ }
+
+ # SIP over WebSocket для встроенного WebRTC-телефона. Переменная и
+ # Docker DNS позволяют обычному TLS-стенду стартовать даже до sip-сервиса.
+ location = /sip-ws {
+ resolver 127.0.0.11 ipv6=off valid=10s;
+ set $sip_backend sip;
+ proxy_pass http://$sip_backend:8088/ws;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+ }
+
+ location ~* \.(?:css|js|woff2?|png|svg)$ {
+ try_files $uri =404;
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ add_header Cache-Control "no-cache";
+ }
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3e38b7f..f9db7c1 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@tanstack/react-query": "^5.103.1",
+ "jssip": "^3.13.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
@@ -1340,7 +1341,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -1410,6 +1410,15 @@
"node": ">=6"
}
},
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1467,6 +1476,17 @@
"node": ">=6"
}
},
+ "node_modules/jssip": {
+ "version": "3.13.8",
+ "resolved": "https://registry.npmjs.org/jssip/-/jssip-3.13.8.tgz",
+ "integrity": "sha512-ErxEdy13vXR5izGo42fpXOU64UmC2VF7Shg4oYG7R2NzZF0FTPeYikffkIIQa9w+/11YvTlgezG1nSaycWYXPA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.1",
+ "events": "^3.3.0",
+ "sdp-transform": "^2.14.1"
+ }
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1493,7 +1513,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -1683,6 +1702,15 @@
"loose-envify": "^1.1.0"
}
},
+ "node_modules/sdp-transform": {
+ "version": "2.15.0",
+ "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
+ "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
+ "license": "MIT",
+ "bin": {
+ "sdp-verify": "checker.js"
+ }
+ },
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 05e6e2e..95b7b9d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,6 +12,7 @@
},
"dependencies": {
"@tanstack/react-query": "^5.103.1",
+ "jssip": "^3.13.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx
index c9c583e..0da1860 100644
--- a/frontend/src/app/router.tsx
+++ b/frontend/src/app/router.tsx
@@ -1,22 +1,30 @@
+import { lazy, Suspense } from "react";
import { createBrowserRouter, Navigate } from "react-router-dom";
-import { Call } from "@/pages/trainee/Call";
-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";
+const Call = lazy(() => import("@/pages/trainee/Call").then((module) => ({ default: module.Call })));
+const Instructor = lazy(() => import("@/pages/instructor/Instructor").then((module) => ({ default: module.Instructor })));
+const Dds = lazy(() => import("@/pages/dds/Dds").then((module) => ({ default: module.Dds })));
+const Admin = lazy(() => import("@/pages/admin/Admin").then((module) => ({ default: module.Admin })));
+const Profile = lazy(() => import("@/pages/profile/Profile").then((module) => ({ default: module.Profile })));
+const Groups = lazy(() => import("@/pages/groups/Groups").then((module) => ({ default: module.Groups })));
+const Wall = lazy(() => import("@/pages/wall/Wall").then((module) => ({ default: module.Wall })));
+const Phone = lazy(() => import("@/pages/phone/Phone").then((module) => ({ default: module.Phone })));
+const Materials = lazy(() => import("@/pages/materials/Materials").then((module) => ({ default: module.Materials })));
+
// Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md).
// session_id живёт в URL: /instructor?session=..., монитор открывают ссылкой.
// Каждый экран за проверкой роли: курсант не открывает пульт даже по прямой
// ссылке. Сервер проверяет то же самое на сокетах — здесь только чтобы человек
// увидел понятное объяснение вместо пустого экрана (lct-23).
const guarded = (screen: string, element: JSX.Element) => (
- {element}
+
+ Загружаем рабочее место…
}>
+ {element}
+
+
);
export const router = createBrowserRouter([
@@ -27,5 +35,7 @@ export const router = createBrowserRouter([
{ path: "/profile", element: guarded("/profile", ) },
{ path: "/groups", element: guarded("/groups", ) },
{ path: "/dds", element: guarded("/dds", ) },
+ { path: "/phone", element: guarded("/phone", ) },
+ { path: "/materials", element: guarded("/materials", ) },
{ path: "/admin", element: guarded("/admin", ) },
]);
diff --git a/frontend/src/features/auth/Login.tsx b/frontend/src/features/auth/Login.tsx
index df664e7..f00a4a5 100644
--- a/frontend/src/features/auth/Login.tsx
+++ b/frontend/src/features/auth/Login.tsx
@@ -23,6 +23,7 @@ function DemoLinks() {
openScreen(event, "/instructor")}>Преподаватель
openScreen(event, "/trainee")}>Оператор 112
openScreen(event, "/dds")}>ДДС
+ openScreen(event, "/materials")}>Материалы
;
diff --git a/frontend/src/features/auth/useAuth.ts b/frontend/src/features/auth/useAuth.ts
index 7915de0..71177ef 100644
--- a/frontend/src/features/auth/useAuth.ts
+++ b/frontend/src/features/auth/useAuth.ts
@@ -73,7 +73,7 @@ export function useLogout() {
/** Какие экраны открыты роли. Повторяет domain/roles.py: список короткий
* и меняется редко, а держать его на сервере ради двух строк — лишний запрос. */
export const SCREENS: Record = {
- admin: ["/admin", "/profile", "/groups"],
- instructor: ["/instructor", "/wall", "/profile", "/dds", "/groups"],
- trainee: ["/trainee", "/dds", "/profile"],
+ admin: ["/admin", "/profile", "/groups", "/materials"],
+ instructor: ["/instructor", "/wall", "/profile", "/dds", "/phone", "/groups", "/materials"],
+ trainee: ["/trainee", "/dds", "/phone", "/profile", "/materials"],
};
diff --git a/frontend/src/features/call/useCall.ts b/frontend/src/features/call/useCall.ts
index fabab06..ddff886 100644
--- a/frontend/src/features/call/useCall.ts
+++ b/frontend/src/features/call/useCall.ts
@@ -69,7 +69,20 @@ export function useCall(sessionId: string | null) {
useEffect(() => {
if (!sessionId) return;
const ch = callChannel(sessionId, {
- onStatus: setStatus,
+ onStatus: (next) => {
+ setStatus(next);
+ if (next === "open") {
+ // Браузер не подтверждает доставку WebSocket-кадра. Всё, что ещё
+ // отмечено pending и не пришло эхом от сервера, повторяем после
+ // reconnect: kio.patch идемпотентен и не создаёт двойных действий.
+ queueMicrotask(() => setCard((current) => {
+ if (Object.keys(current.pending).length) {
+ channel.current?.send({ type: "kio.patch", fields: current.pending });
+ }
+ return current;
+ }));
+ }
+ },
onBinary: (frame) => audio.current?.playback.enqueue(frame),
onEvent: (event: ServerToTrainee) => {
if (event.type === "card.briefing") {
diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx
index fbca07e..d4fc08f 100644
--- a/frontend/src/features/debrief/Debrief.tsx
+++ b/frontend/src/features/debrief/Debrief.tsx
@@ -27,6 +27,16 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
Скачать отчёт: CSV
{" · "}PDF
+
+ {report.passed ? "Зачёт" : "Не зачтено"}: нарушено {report.failed_metrics} метрик,
+ допустимо {report.criteria.allowed_errors}. Лимит решения ДДС —
+ {` ${report.criteria.decision_time_limit_seconds} с`}; грамматика
+ {report.criteria.require_correct_grammar ? " входит" : " не входит"} в оценку.
+
+ {Object.keys(report.criteria.score_weights ?? {}).length > 0 &&
+ Преподаватель переопределил веса: {Object.entries(report.criteria.score_weights ?? {})
+ .map(([key, weight]) => `${key} — ${weight}`).join("; ")}.
+
}
@@ -38,6 +48,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{metric.title}
{metric.fact} · норматив {metric.norm}
+ {` · вес ${metric.weight ?? 1}`}
{metric.ref && ({metric.ref}) }
diff --git a/frontend/src/features/health/StandStatus.tsx b/frontend/src/features/health/StandStatus.tsx
index 896ae82..72a6576 100644
--- a/frontend/src/features/health/StandStatus.tsx
+++ b/frontend/src/features/health/StandStatus.tsx
@@ -12,7 +12,7 @@ export function StandStatus() {
if (!health.data) return null;
if (health.data.demo_no_db) {
- return Локальная демка: карточки 112 и ДДС работают без БД и голоса; история занятий не сохраняется.
;
+ return Локальная демка: карточки 112, ДДС и редактор работают без БД и голоса; история и черновики исчезнут после перезапуска.
;
}
const troubles: string[] = [];
diff --git a/frontend/src/features/instructor/Director.tsx b/frontend/src/features/instructor/Director.tsx
index 6183b0d..850d3dd 100644
--- a/frontend/src/features/instructor/Director.tsx
+++ b/frontend/src/features/instructor/Director.tsx
@@ -61,7 +61,7 @@ export function Director({
рвёт звук немедленно.
- setFree(event.target.value)} />{" "}
{ fire(`своя реплика: ${free.trim()}`, free.trim()); setFree(""); }}>
diff --git a/frontend/src/features/instructor/ScenarioEditor.tsx b/frontend/src/features/instructor/ScenarioEditor.tsx
index bea778d..95ac262 100644
--- a/frontend/src/features/instructor/ScenarioEditor.tsx
+++ b/frontend/src/features/instructor/ScenarioEditor.tsx
@@ -3,7 +3,12 @@ import { useState } from "react";
import type { ScenarioSummary } from "@/shared/api/http";
type JsonObject = Record;
-type Draft = { id: string; status: "draft"; generation: "template_copy"; body: JsonObject };
+type Draft = {
+ id: string;
+ status: "draft";
+ generation: "template_copy" | "ai_variant" | "ai_full";
+ body: JsonObject;
+};
type Validation = { valid: boolean; errors: string[]; ground_truth?: JsonObject };
async function api(path: string, method = "GET", body?: unknown): Promise {
@@ -48,15 +53,31 @@ function pretty(body: JsonObject): string {
return JSON.stringify(body, null, 2);
}
+function stringField(body: JsonObject, key: string): string {
+ return typeof body[key] === "string" ? body[key] : "";
+}
+
+function generationLabel(generation: Draft["generation"]): string {
+ if (generation === "ai_full") return "новый сюжет и эталон локальной модели";
+ if (generation === "ai_variant") return "вариант локальной модели";
+ return "копия шаблона";
+}
+
export function ScenarioEditor({
scenarios,
+ selectedScenarioId,
onApproved,
+ onLibraryChanged,
}: {
scenarios: ScenarioSummary[];
+ selectedScenarioId: string;
onApproved: (id: string) => void | Promise;
+ onLibraryChanged: () => void | Promise;
}) {
const [sourceId, setSourceId] = useState("");
const [title, setTitle] = useState("");
+ const [instruction, setInstruction] = useState("");
+ const [revisionComment, setRevisionComment] = useState("");
const [draftId, setDraftId] = useState("");
const [draft, setDraft] = useState(null);
const [bodyText, setBodyText] = useState("");
@@ -64,6 +85,10 @@ export function ScenarioEditor({
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
+ const [archiveCandidate, setArchiveCandidate] = useState("");
+ const [lastArchived, setLastArchived] = useState<{ id: string; title: string } | null>(null);
+ const effectiveSourceId = sourceId || selectedScenarioId || scenarios[0]?.id || "";
+ const effectiveSource = scenarios.find((scenario) => scenario.id === effectiveSourceId);
function openDraft(value: Draft) {
setDraft(value);
@@ -89,7 +114,7 @@ export function ScenarioEditor({
async function create() {
await perform(async () => {
const created = await api("/api/scenarios/drafts/from-template", "POST", {
- source_id: sourceId || scenarios[0]?.id,
+ source_id: effectiveSourceId,
...(title.trim() ? { title: title.trim() } : {}),
});
openDraft(created);
@@ -97,6 +122,30 @@ export function ScenarioEditor({
});
}
+ async function generate() {
+ await perform(async () => {
+ setNotice("Qwen формирует вариацию… Обычно это занимает несколько секунд.");
+ const created = await api("/api/scenarios/drafts/generate", "POST", {
+ source_id: effectiveSourceId,
+ instruction: instruction.trim(),
+ });
+ openDraft(created);
+ setNotice("Локальная модель создала вариацию. Проверьте сюжет, факты и эталон перед утверждением.");
+ });
+ }
+
+ async function generateFull() {
+ await perform(async () => {
+ setNotice("Qwen формирует новый сюжет по полям… Обычно это занимает 10–30 секунд.");
+ const created = await api("/api/scenarios/drafts/generate-from-description", "POST", {
+ source_id: effectiveSourceId,
+ description: instruction.trim(),
+ });
+ openDraft(created);
+ setNotice("Локальная модель создала новый сюжет, все ответы и мягкий эталон. Класс ЕКП и структура опроса взяты из выбранного каркаса; проверьте результат.");
+ });
+ }
+
async function reopen() {
if (!draftId.trim()) return;
await perform(async () => {
@@ -132,6 +181,20 @@ export function ScenarioEditor({
});
}
+ async function revise() {
+ await perform(async () => {
+ setNotice("Qwen перерабатывает черновик по комментарию…");
+ const current = await save();
+ const updated = await api(
+ `/api/scenarios/drafts/${encodeURIComponent(current.id)}/revise`, "POST",
+ { comment: revisionComment.trim() },
+ );
+ openDraft(updated);
+ setRevisionComment("");
+ setNotice("Локальная модель переработала этот же черновик по комментарию. Проверьте изменения заново.");
+ });
+ }
+
async function approve() {
await perform(async () => {
const current = await save();
@@ -144,30 +207,107 @@ export function ScenarioEditor({
});
}
+ async function archiveSelected() {
+ await perform(async () => {
+ const selected = scenarios.find((item) => item.id === effectiveSourceId);
+ if (!selected) throw new Error("Выберите сценарий для архивирования");
+ await api(`/api/scenarios/${encodeURIComponent(selected.id)}`, "DELETE");
+ setLastArchived({ id: selected.id, title: selected.title });
+ setArchiveCandidate("");
+ setSourceId("");
+ setNotice(`Сценарий «${selected.title}» перемещён в архив и больше не назначается.`);
+ await onLibraryChanged();
+ });
+ }
+
+ async function restoreLast() {
+ if (!lastArchived) return;
+ await perform(async () => {
+ await api(`/api/scenarios/${encodeURIComponent(lastArchived.id)}/restore`, "POST");
+ const restored = lastArchived;
+ setLastArchived(null);
+ setSourceId(restored.id);
+ setNotice(`Сценарий «${restored.title}» восстановлен в учебной библиотеке.`);
+ await onLibraryChanged();
+ });
+ }
+
return (
-
- Редактор учебных сценариев
- Создание работает локально: копия утверждённого шаблона, затем правка и проверка преподавателем.
+
+ ИИ-формирователь и редактор сценариев
+ Можно сделать безопасную вариацию либо новый сюжет из описания. Выбранный утверждённый сценарий задаёт класс ЕКП и структуру опроса; при новом сюжете Qwen заново создаёт все ответы, адрес и число пострадавших. Публикация — только после вашей проверки.
- Шаблон
- setSourceId(event.target.value)}>
+ Каркас категории и опроса
+ setSourceId(event.target.value)}>
{scenarios.map((scenario) => {scenario.title} · {scenario.level} )}
+ {effectiveSource &&
+ Фиксированный путь ЕКП: {effectiveSource.signs?.length
+ ? effectiveSource.signs.join(" → ") : "без размеченных признаков"}
+ {effectiveSource.incident_code ? ` · код ${effectiveSource.incident_code}` : ""}
+ }
Название нового сценария
setTitle(event.target.value)} placeholder="необязательно" maxLength={200} />
Создать черновик
+
+ Указание для локальной модели
+ setInstruction(event.target.value)}
+ placeholder="Например: ночное возгорание, плотный дым, заявитель паникует"
+ maxLength={1000} />
+ Описание меняет обстоятельства, но не показанный выше путь ЕКП.
+
+
+ Создать вариант ИИ
+
+
+ Создать новый сюжет и эталон
+
+
Номер черновика
setDraftId(event.target.value)} placeholder="draft-…" />
Открыть
+
+ {!archiveCandidate ? setArchiveCandidate(effectiveSourceId)}>Архивировать выбранный сценарий : <>
+ Сценарий исчезнет из назначения, но история занятий сохранится.
+ Подтвердить архивирование
+ setArchiveCandidate("")}>Отмена
+ >}
+ {effectiveSource && !effectiveSource.can_manage &&
+ Базовые сценарии и сценарии других преподавателей доступны только для чтения.
+ }
+ {lastArchived &&
+ Вернуть «{lastArchived.title}» из архива
+ }
+
{draft && (
<>
- Черновик {draft.id}. Факты и эталон доступны только преподавателю до утверждения. Поле score_weights позволяет задать веса метрик (0–10), например {"{\"address\": 3, \"dds_ack\": 2}"}.
+ Черновик {draft.id} ({generationLabel(draft.generation)}). Факты и эталон доступны только преподавателю до утверждения. Поле score_weights позволяет задать веса метрик (0–10), например {"{\"address\": 3, \"dds_ack\": 2}"}.
+
+
Предпросмотр: {stringField(draft.body, "title")}
+
Первая реплика: «{stringField(draft.body, "first_line")}»
+
Сюжетные факты — проверьте, что они согласуются друг с другом и с эталоном:
+
{Array.isArray(draft.body.facts) && draft.body.facts.map((item, index) =>
+ isObject(item) ? {String(item.id)}: {String(item.value ?? "")} : null
+ )}
+
+
+ Комментарий для переработки
+ setRevisionComment(event.target.value)}
+ placeholder="Например: убери упоминание газа и сделай дым плотнее"
+ maxLength={1000} />
+
+ Переработать с ИИ
+
JSON тела сценария
diff --git a/frontend/src/features/instructor/useControl.ts b/frontend/src/features/instructor/useControl.ts
index 43134e5..82ac5e7 100644
--- a/frontend/src/features/instructor/useControl.ts
+++ b/frontend/src/features/instructor/useControl.ts
@@ -8,7 +8,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { controlChannel, type ChannelStatus } from "@/shared/api/ws";
-import type { SessionMode } from "@/shared/types/generated";
+import type { LessonCriteria, SessionMode } from "@/shared/types/generated";
export function useControl(sessionId: string | null) {
const [status, setStatus] = useState("closed");
@@ -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, scenarioIds?: string[], handoffToDds = false) => {
+ (scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string, ddsService?: string, scenarioIds?: string[], ddsArrivalIntervalSeconds = 60, ddsMaxWaiting = 3, handoffToDds = false, criteria?: LessonCriteria) => {
const message = {
type: "scenario.start",
scenario_id: scenarioId,
@@ -33,9 +33,13 @@ export function useControl(sessionId: string | null) {
trainee,
group_id: group || null,
trainee_id: traineeId || null,
+ dds_service: ddsService || null,
exercise,
handoff_to_dds: handoffToDds,
scenario_ids: scenarioIds?.length ? scenarioIds : undefined,
+ dds_arrival_interval_seconds: ddsArrivalIntervalSeconds,
+ dds_max_waiting: ddsMaxWaiting,
+ criteria,
} as const;
channel.current?.send(message);
},
diff --git a/frontend/src/features/kio-card/KioCard.tsx b/frontend/src/features/kio-card/KioCard.tsx
index 1946177..eabd82a 100644
--- a/frontend/src/features/kio-card/KioCard.tsx
+++ b/frontend/src/features/kio-card/KioCard.tsx
@@ -74,7 +74,8 @@ function Row({
{referenceArm && field.path === "incident_type" ? "Что случилось?"
: referenceArm && field.path === "caller_number" ? "АОН"
: referenceArm && field.path === "caller_contact" ? "Предоставленный"
- : referenceArm && field.path === "caller_name" ? "Фамилия и имя заявителя"
+ : referenceArm && field.path === "phone_on_scene" ? "Телефон на место"
+ : referenceArm && field.path === "caller_name" ? "Фамилия и имя заявителя"
: field.label}
{required && · }
diff --git a/frontend/src/features/kio-card/fields.ts b/frontend/src/features/kio-card/fields.ts
index daceb74..4b763d5 100644
--- a/frontend/src/features/kio-card/fields.ts
+++ b/frontend/src/features/kio-card/fields.ts
@@ -61,6 +61,7 @@ export const GROUPS: FieldGroup[] = [
{ path: "caller_number", label: "Номер телефона", kind: "text", readOnly: true },
{ path: "caller_name", label: "ФИО", kind: "text" },
{ path: "caller_contact", label: "Контактный телефон", kind: "text" },
+ { path: "phone_on_scene", label: "Телефон на место", kind: "text" },
{ path: "language", label: "Язык общения", kind: "text" },
],
},
diff --git a/frontend/src/features/observe/useObserve.ts b/frontend/src/features/observe/useObserve.ts
index 7494319..6b86046 100644
--- a/frontend/src/features/observe/useObserve.ts
+++ b/frontend/src/features/observe/useObserve.ts
@@ -48,6 +48,12 @@ export function useObserve(sessionId: string | null): ObservedSession {
useEffect(() => {
if (!sessionId) return;
+ // A report may already be archived when an instructor opens a history link;
+ // the observe socket is then intentionally unavailable after process restart.
+ void fetch(`/api/sessions/${sessionId}/report`, { credentials: "same-origin" })
+ .then((response) => (response.ok ? response.json() : null))
+ .then((saved) => { if (saved) setReport(saved as SessionReport); })
+ .catch(() => undefined);
const channel = observeChannel(sessionId, {
onStatus: setStatus,
onEvent: (event) => {
diff --git a/frontend/src/features/sip/SipPhone.tsx b/frontend/src/features/sip/SipPhone.tsx
new file mode 100644
index 0000000..74e506b
--- /dev/null
+++ b/frontend/src/features/sip/SipPhone.tsx
@@ -0,0 +1,57 @@
+import { useEffect, useRef, useState } from "react";
+
+import { type SipStatus, useSipPhone } from "./useSipPhone";
+
+const LABEL: Record = {
+ idle: "не подключён",
+ connecting: "подключение…",
+ registered: "зарегистрирован в Asterisk",
+ incoming: "входящий вызов",
+ calling: "вызов…",
+ connected: "разговор идёт",
+ error: "ошибка",
+};
+
+export function SipPhone({
+ defaultExtension = "6101", defaultTarget = "6102", title = "Локальный SIP/WebRTC",
+}: {
+ defaultExtension?: string;
+ defaultTarget?: string;
+ title?: string;
+}) {
+ const phone = useSipPhone();
+ const [extension, setExtension] = useState(defaultExtension);
+ const [password, setPassword] = useState("");
+ const [target, setTarget] = useState(defaultTarget);
+ const audio = useRef(null);
+ const online = phone.status !== "idle" && phone.status !== "error";
+ const inCall = phone.status === "calling" || phone.status === "incoming" || phone.status === "connected";
+
+ useEffect(() => {
+ if (audio.current) audio.current.srcObject = phone.remoteStream;
+ }, [phone.remoteStream]);
+
+ return ;
+}
diff --git a/frontend/src/features/sip/useSipPhone.ts b/frontend/src/features/sip/useSipPhone.ts
new file mode 100644
index 0000000..0270f35
--- /dev/null
+++ b/frontend/src/features/sip/useSipPhone.ts
@@ -0,0 +1,171 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { UA, WebSocketInterface } from "jssip";
+import type { RTCSession } from "jssip/lib/RTCSession";
+import type { RTCSessionEvent } from "jssip/lib/UA";
+
+export type SipStatus =
+ | "idle" | "connecting" | "registered" | "incoming" | "calling" | "connected" | "error";
+
+export interface SipCredentials {
+ extension: string;
+ password: string;
+ domain?: string;
+}
+
+const media = { audio: true, video: false } satisfies MediaStreamConstraints;
+
+export function useSipPhone() {
+ const uaRef = useRef(null);
+ const sessionRef = useRef(null);
+ const registeredRef = useRef(false);
+ const domainRef = useRef(location.hostname);
+ const [status, setStatus] = useState("idle");
+ const [error, setError] = useState("");
+ const [peer, setPeer] = useState("");
+ const [remoteStream, setRemoteStream] = useState(null);
+
+ const clearSession = useCallback(() => {
+ sessionRef.current = null;
+ setPeer("");
+ setRemoteStream(null);
+ setStatus(registeredRef.current ? "registered" : "idle");
+ }, []);
+
+ const attachSession = useCallback((session: RTCSession, incoming: boolean) => {
+ if (sessionRef.current === session) return;
+ if (sessionRef.current && !sessionRef.current.isEnded()) {
+ session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
+ return;
+ }
+ sessionRef.current = session;
+ setError("");
+ setPeer(session.remote_identity?.uri?.user || "абонент");
+ setStatus(incoming ? "incoming" : "calling");
+ const observedConnections = new WeakSet();
+ const observeConnection = () => {
+ const connection = session.connection;
+ if (!connection || observedConnections.has(connection)) return;
+ observedConnections.add(connection);
+ connection.addEventListener("track", (event) => {
+ const stream = event.streams[0] ?? new MediaStream([event.track]);
+ setRemoteStream(stream);
+ });
+ };
+ observeConnection();
+ session.on("peerconnection", observeConnection);
+ session.on("accepted", () => setStatus("connected"));
+ session.on("confirmed", () => setStatus("connected"));
+ session.on("ended", clearSession);
+ session.on("failed", (event) => {
+ setError(`Вызов завершён: ${event.cause}`);
+ clearSession();
+ });
+ }, [clearSession]);
+
+ const disconnect = useCallback(() => {
+ const session = sessionRef.current;
+ if (session && !session.isEnded()) session.terminate();
+ sessionRef.current = null;
+ registeredRef.current = false;
+ uaRef.current?.stop();
+ uaRef.current = null;
+ setRemoteStream(null);
+ setPeer("");
+ setError("");
+ setStatus("idle");
+ }, []);
+
+ const connect = useCallback((credentials: SipCredentials) => {
+ const extension = credentials.extension.trim();
+ const password = credentials.password;
+ const domain = credentials.domain?.trim() || location.hostname;
+ if (!/^\d{4}$/.test(extension) || !password) {
+ setError("Укажите четырёхзначный внутренний номер и пароль SIP.");
+ return false;
+ }
+ disconnect();
+ domainRef.current = domain;
+ setStatus("connecting");
+ try {
+ const scheme = location.protocol === "https:" ? "wss" : "ws";
+ const socket = new WebSocketInterface(`${scheme}://${location.host}/sip-ws`);
+ const ua = new UA({
+ sockets: socket,
+ uri: `sip:${extension}@${domain}`,
+ authorization_user: extension,
+ password,
+ register: true,
+ register_expires: 300,
+ connection_recovery_min_interval: 2,
+ connection_recovery_max_interval: 8,
+ user_agent: "LCT-112-WebRTC/1.0",
+ });
+ ua.on("registered", () => {
+ registeredRef.current = true;
+ setError("");
+ setStatus("registered");
+ });
+ ua.on("registrationFailed", (event) => {
+ registeredRef.current = false;
+ setError(`Регистрация SIP отклонена: ${event.cause || "проверьте номер и пароль"}`);
+ setStatus("error");
+ });
+ ua.on("disconnected", () => {
+ registeredRef.current = false;
+ if (!uaRef.current) return;
+ setStatus("connecting");
+ });
+ ua.on("newRTCSession", (event: RTCSessionEvent) => {
+ attachSession(event.session, event.originator === "remote");
+ });
+ uaRef.current = ua;
+ ua.start();
+ return true;
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : String(cause));
+ setStatus("error");
+ return false;
+ }
+ }, [attachSession, disconnect]);
+
+ const dial = useCallback((target: string) => {
+ const normalized = target.trim();
+ if (!uaRef.current || !registeredRef.current || !/^\d{4}$/.test(normalized)) {
+ setError("Сначала зарегистрируйте телефон и укажите номер из четырёх цифр.");
+ return false;
+ }
+ try {
+ const session = uaRef.current.call(`sip:${normalized}@${domainRef.current}`, {
+ mediaConstraints: media,
+ });
+ attachSession(session, false);
+ return true;
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : String(cause));
+ setStatus("error");
+ return false;
+ }
+ }, [attachSession]);
+
+ const answer = useCallback(() => {
+ const session = sessionRef.current;
+ if (!session || status !== "incoming") return false;
+ try {
+ session.answer({ mediaConstraints: media });
+ return true;
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : String(cause));
+ return false;
+ }
+ }, [status]);
+
+ const hangup = useCallback(() => {
+ const session = sessionRef.current;
+ if (!session || session.isEnded()) return;
+ session.terminate();
+ }, []);
+
+ useEffect(() => disconnect, [disconnect]);
+
+ return { status, error, peer, remoteStream, connect, disconnect, dial, answer, hangup };
+}
diff --git a/frontend/src/pages/admin/Admin.tsx b/frontend/src/pages/admin/Admin.tsx
index 9d6ad69..a9fd66a 100644
--- a/frontend/src/pages/admin/Admin.tsx
+++ b/frontend/src/pages/admin/Admin.tsx
@@ -10,7 +10,8 @@ import { useLogout, useMe } from "@/features/auth/useAuth";
import type { Role } from "@/shared/types/generated";
import {
- useAudit, useBackups, useCreateBackup, useCreateUser, usePatchUser, useStatus, useUsers,
+ useAudit, useBackups, useCreateBackup, useCreateUser, useDiagnostics, usePatchUser, useStatus,
+ useUsers,
} from "./api";
const ROLE_LABELS: Record = {
@@ -31,6 +32,8 @@ export function Admin() {
logout.mutate()}>Выйти
+
+ Скачать XML-конфигурацию рабочих мест
@@ -38,6 +41,65 @@ export function Admin() {
);
}
+function bytes(value: number | null) {
+ if (value === null) return "нет данных ОС";
+ if (value >= 1024 ** 3) return `${(value / 1024 ** 3).toFixed(1)} ГБ`;
+ return `${Math.round(value / 1024 ** 2)} МБ`;
+}
+
+function Diagnostics() {
+ const report = useDiagnostics();
+ const metrics = report.data?.metrics;
+ const systemEvents = report.data?.recent_system_events ?? [];
+ const failedActions = report.data?.failed_actions_24h ?? [];
+ return (
+ <>
+ Нагрузка и диагностика
+ {metrics && (
+
+
+ CPU backend {metrics.cpu_percent.toFixed(1)}% · {metrics.cpu_cores} ядер на сервере
+ Нагрузка сервера, 1 мин {metrics.load_1m_percent === null ? "нет счётчика ОС" : `${metrics.load_1m_percent.toFixed(1)}%`}
+ Память backend {bytes(metrics.rss_bytes)}
+ Свободная память сервера {bytes(metrics.memory_available_bytes)} из {bytes(metrics.memory_total_bytes)}
+ Свободно на диске {bytes(metrics.disk_free_bytes)} из {bytes(metrics.disk_total_bytes)}
+ Занятия активно {metrics.active_sessions}; завершено за 24 ч {metrics.completed_sessions_24h}; восстановлено после запуска {metrics.restored_sessions}
+ Процесс работает {Math.floor(metrics.uptime_seconds / 60)} мин; потоков {metrics.threads}
+
+
+ )}
+ {report.isError && Диагностика недоступна: {String(report.error)}
}
+ Скачать отчёт об ошибках и сбоях JSON
+ Сбои действий за 24 часа
+ {failedActions.length === 0 ? Зафиксированных сбоев действий нет.
: (
+
+ Когда Действие Кто Подробности
+ {failedActions.map((event, index) => (
+
+ {new Date(event.at).toLocaleString("ru-RU")}
+ {event.action} {event.actor} {event.detail}
+
+ ))}
+
+ )}
+
+ Предупреждения процесса ({systemEvents.length})
+ {systemEvents.length === 0 ? Предупреждений после запуска нет.
: (
+
+ Когда Уровень Источник Сообщение
+ {systemEvents.map((event, index) => (
+
+ {new Date(event.at).toLocaleString("ru-RU")}
+ {event.level} {event.source} {event.message}
+
+ ))}
+
+ )}
+
+ >
+ );
+}
+
function Status() {
const status = useStatus();
return (
@@ -104,21 +166,22 @@ function Users() {
Логин
- setForm({ ...form, login: e.target.value })} />
+ setForm({ ...form, login: e.target.value })} />
ФИО
- setForm({ ...form, full_name: e.target.value })} />
+ setForm({ ...form, full_name: e.target.value })} />
Пароль
setForm({ ...form, password: e.target.value })}
/>{" "}
- не короче восьми знаков
+ от восьми до 1024 знаков
@@ -182,20 +245,24 @@ function Backups() {
function Audit() {
const [action, setAction] = useState("");
- const audit = useAudit(action);
+ const [actor, setActor] = useState("");
+ const [offset, setOffset] = useState(0);
+ const audit = useAudit(action, actor, offset);
+ const pageSize = 200;
+ const rows = audit.data ?? [];
return (
<>
Журнал действий
- Фильтр по действию:{" "}
- setAction(e.target.value)} />
+ Действие: { setAction(e.target.value); setOffset(0); }} />
+ {" "}Пользователь: { setActor(e.target.value); setOffset(0); }} />
Когда Кто Действие Над чем Подробности
- {(audit.data ?? []).map((row, index) => (
+ {rows.slice(0, pageSize).map((row, index) => (
{new Date(row.at).toLocaleString("ru-RU")}
{row.actor}
@@ -206,6 +273,11 @@ function Audit() {
))}
+
+ setOffset(Math.max(0, offset - pageSize))}>Назад {" "}
+ Записи {rows.length ? offset + 1 : 0}–{offset + Math.min(rows.length, pageSize)} {" "}
+ setOffset(offset + pageSize)}>Раньше
+
>
);
}
diff --git a/frontend/src/pages/admin/api.ts b/frontend/src/pages/admin/api.ts
index dcab8f0..96a4065 100644
--- a/frontend/src/pages/admin/api.ts
+++ b/frontend/src/pages/admin/api.ts
@@ -20,6 +20,32 @@ export interface AuditRow {
at: string; actor: string; role: string; action: string; object_id: string | null; detail: string;
}
export interface Backup { name: string; size_bytes: number; at: string }
+export interface RuntimeMetrics {
+ at: string;
+ uptime_seconds: number;
+ cpu_percent: number;
+ load_1m_percent: number | null;
+ cpu_cores: number;
+ rss_bytes: number | null;
+ memory_total_bytes: number | null;
+ memory_available_bytes: number | null;
+ disk_total_bytes: number;
+ disk_free_bytes: number;
+ threads: number;
+ active_sessions: number;
+ restored_sessions: number;
+ completed_sessions_24h: number;
+}
+export interface DiagnosticEvent { at: string; level: string; source: string; message: string }
+export interface FailureEvent {
+ at: string; actor: string; action: string; object_id: string | null; detail: string;
+}
+export interface DiagnosticReport {
+ generated_at: string;
+ metrics: RuntimeMetrics;
+ recent_system_events: DiagnosticEvent[];
+ failed_actions_24h: FailureEvent[];
+}
async function call(path: string, init?: RequestInit): Promise {
const response = await fetch(path, {
@@ -44,10 +70,22 @@ export const useStatus = () =>
refetchInterval: 10_000,
});
-export const useAudit = (action: string) =>
+export const useDiagnostics = () =>
useQuery({
- queryKey: ["admin-audit", action],
- queryFn: () => call(`/api/admin/audit${action ? `?action=${encodeURIComponent(action)}` : ""}`),
+ queryKey: ["admin-diagnostics"],
+ queryFn: () => call("/api/admin/diagnostics"),
+ refetchInterval: 10_000,
+ });
+
+export const useAudit = (action: string, actor: string, offset: number) =>
+ useQuery({
+ queryKey: ["admin-audit", action, actor, offset],
+ queryFn: () => {
+ const params = new URLSearchParams({ limit: "201", offset: String(offset) });
+ if (action) params.set("action", action);
+ if (actor) params.set("actor", actor);
+ return call(`/api/admin/audit?${params.toString()}`);
+ },
});
export const useBackups = () =>
diff --git a/frontend/src/pages/dds/Dds.tsx b/frontend/src/pages/dds/Dds.tsx
index ee0e7b4..41a6c1f 100644
--- a/frontend/src/pages/dds/Dds.tsx
+++ b/frontend/src/pages/dds/Dds.tsx
@@ -1,36 +1,40 @@
-// Учебное АРМ ДДС. Звонок бригаде имитируется внутри приложения, без SIP.
+// Учебное АРМ ДДС. Проверяемый текстовый протокол дополняется настоящим
+// локальным SIP/WebRTC-телефоном; оба контура явно подписаны в интерфейсе.
import { useEffect, useRef, useState } from "react";
-import { GROUPS } from "@/features/kio-card/fields";
import { KioCard } from "@/features/kio-card/KioCard";
import { applyState, empty as emptyCard, readPath, type CardState } from "@/features/kio-card/merge";
+import { SipPhone } from "@/features/sip/SipPhone";
import { sessionIdFromUrl } from "@/shared/api/session";
import { stationChannel, type ChannelStatus } from "@/shared/api/ws";
-import type { ServiceStatus, SessionReport, StationSnapshot, StationToServer } from "@/shared/types/generated";
+import type { DdsQueueCard, ServiceStatus, SessionReport, StationSnapshot, StationToServer } from "@/shared/types/generated";
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 PhoneLine { service: string; crew: string; speaker: "dispatcher" | "crew"; text: string; at: string }
+interface PhonePending { service: string; crew: string; phase: ReportPhase }
interface CompletedCard { card_id: string; scenario_id: string; score_auto: number }
interface DdsSnapshot extends StationSnapshot {
+ recipient_services?: string[];
+ managed_service?: string | null;
crew_options?: string[];
crew_selected?: string | null;
phone_reports?: PhoneReport[];
+ phone_lines?: PhoneLine[];
+ phone_pending?: PhonePending | null;
card_id?: string | null;
card_index?: number;
card_total?: number;
reply_text?: string;
completed_cards?: CompletedCard[];
+ queue_cards?: DdsQueueCard[];
}
const REPORT_LABELS: Record = {
dispatched: "Доклад о выезде", arrived: "Доклад о прибытии",
working: "Доклад о ходе работ", completed: "Доклад о завершении работ",
};
-const FIELDS = GROUPS.flatMap((group) =>
- group.fields.filter((item) => !item.readOnly).map((item) => ({ path: item.path, label: item.label })),
-);
-
function field(card: CardState, path: string): string {
const value = readPath(card.server, path);
return value === null || value === undefined || value === "" ? "—" : String(value);
@@ -60,19 +64,19 @@ export function Dds() {
const [station, setStation] = useState(null);
const [lastReport, setLastReport] = useState(null);
const [dialing, setDialing] = useState(false);
+ const [brief, setBrief] = useState({ address: "", incident: "", request: "" });
+ const [checkText, setCheckText] = useState("");
const [error, setError] = useState("");
const [ended, setEnded] = useState(false);
const [report, setReport] = useState(null);
const [reportState, setReportState] = useState<"idle" | "loading" | "ready" | "error">("idle");
const [reportError, setReportError] = useState("");
- const [missing, setMissing] = useState([]);
- const [bounceComment, setBounceComment] = useState("");
- const [bounced, setBounced] = useState(false);
const [zone, setZone] = useState(null);
const [reply, setReply] = useState({ text: "", dirty: false });
const [advancePending, setAdvancePending] = useState(false);
const channel = useRef | null>(null);
const activeCardId = useRef(null);
+ const openCardAfterSwitch = useRef(false);
useEffect(() => {
if (!sessionId) return;
@@ -104,6 +108,13 @@ export function Dds() {
if (event.type === "station.state") {
const snapshot = event.snapshot as DdsSnapshot;
setStation(snapshot);
+ if (snapshot.card_id == null && snapshot.pending_cards_count > 0) {
+ activeCardId.current = null;
+ setCard(null);
+ setView("registry");
+ setPane(null);
+ setAdvancePending(false);
+ }
setActiveService((previous) => previous && snapshot.services.includes(previous)
? previous : snapshot.services[0] ?? null);
setReply((previous) => {
@@ -121,13 +132,13 @@ export function Dds() {
setReply({ text: "", dirty: false });
setLastReport(null);
setDialing(false);
- setMissing([]);
- setBounceComment("");
- setBounced(false);
+ setBrief({ address: "", incident: "", request: "" });
+ setCheckText("");
setZone(null);
setReport(null);
setReportState("idle");
- setView("registry");
+ setView(openCardAfterSwitch.current ? "card" : "registry");
+ openCardAfterSwitch.current = false;
setPane(null);
setActiveService(null);
}
@@ -145,8 +156,11 @@ export function Dds() {
else if (event.type === "phone.report") {
setLastReport(event);
setDialing(false);
+ setBrief({ address: "", incident: "", request: "" });
+ setCheckText("");
setError("");
}
+ else if (event.type === "phone.line") setDialing(false);
},
}).connect();
channel.current = ch;
@@ -173,18 +187,21 @@ export function Dds() {
const reports = station?.phone_reports ?? [];
const selectedCrew = station?.crew_selected ?? "";
- const latest = lastReport ?? reports.at(-1) ?? null;
+ const latest = (lastReport?.crew === selectedCrew ? lastReport : null) ??
+ reports.filter((item) => item.crew === selectedCrew).at(-1) ?? null;
+ const phoneCall = station?.phone_pending ?? null;
+ const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === selectedCrew);
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 queueCards = station?.queue_cards ?? [];
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")]
+ const visibleQueueCards = queueCards.filter((item) => !query ||
+ [item.card_id, item.address ?? "", item.description ?? "", item.incident_type ?? "", item.title]
.some((value) => value.toLocaleLowerCase("ru-RU").includes(query)));
const crewAccepted = Boolean(station?.services.some((service) =>
selectedCrew.startsWith(`${service} —`) &&
@@ -271,23 +288,55 @@ export function Dds() {
Сценарий {item.scenario_id} — —
Отработана · {item.score_auto.toFixed(0)} / 100
)}
- {showActive && card &&
- setView("card")} aria-label="Открыть карточку">▾
- {field(card, "is_emergency") === "true" ? "Да" : "Нет"}
+ {visibleQueueCards.map((item) => {
+ const received = new Date(item.received_at);
+ const liveElapsed = item.timer_stopped ? item.elapsed_ms / 1000 : Math.max(
+ item.elapsed_ms / 1000, (clockNow.getTime() - received.getTime()) / 1000,
+ );
+ const openCard = () => {
+ if (item.active) {
+ setView("card");
+ return;
+ }
+ openCardAfterSwitch.current = true;
+ if (!send({ type: "card.open", card_id: item.card_id })) {
+ openCardAfterSwitch.current = false;
+ }
+ };
+ return
+ ▾
+ —
0 ДДС
- setView("card")}>{cardId.slice(0, 8).toUpperCase()}
- {receivedAt ? new Date(receivedAt).toLocaleDateString("ru-RU") : "—"}
- {receivedAt ? new Date(receivedAt).toLocaleTimeString("ru-RU") : "—"}
- {field(card, "incident_code")} · {field(card, "incident_type")}
- Описание: {field(card, "description")}
- {field(card, "victims_count")}
- {field(card, "address")}
- {station?.services.map((service) => `${service}: ${SERVICE_STATUS_LABELS[station.statuses[service]]}`).join("; ") || "Добавлена"}
- }
- {!showActive && !visibleCompletedCards.length &&
+ {item.card_id.slice(0, 8).toUpperCase()}
+ {received.toLocaleDateString("ru-RU")}
+ {received.toLocaleTimeString("ru-RU")}
+
+ {item.title} · {item.incident_type || "тип не указан"}
+ Описание: {item.description || "—"}
+
+ {item.victims_count ?? "—"}
+ {item.address || "—"}
+
+ {item.managed_service || "ДДС"}: {SERVICE_STATUS_LABELS[item.service_status]}
+ item.limit_ms ? "state-violated" : ""}>
+ {item.timer_stopped ? "решение за" : "до первичного решения"} {liveElapsed.toFixed(0)} с
+
+
+ })}
+ {!visibleQueueCards.length && !visibleCompletedCards.length &&
{query ? "По запросу ничего не найдено." : "Ожидание готовой карточки от преподавателя или оператора 112…"} }
- {cardTotal > cardIndex && Остальные {cardTotal - cardIndex} карточки поступят после завершения текущей.
}
+ {queueCards.length > 1 && Одновременно в работе {queueCards.length} карточек. Таймер каждой идёт независимо от того, какая строка открыта.
}
+ {station && station.pending_cards_count > 0 &&
+ {station.next_arrival_in_seconds === null
+ ? `Осталось карточек: ${station.pending_cards_count}. Все карточки уже поступили; необработанные находятся в реестре.`
+ : station.next_arrival_in_seconds > 0
+ ? `Осталось карточек: ${station.pending_cards_count}. Следующее поступление примерно через ${station.next_arrival_in_seconds} с.`
+ : queueCards.filter((item) => !item.active).length >= station.max_waiting_cards
+ ? `Осталось карточек: ${station.pending_cards_count}. Очередь ожидания заполнена (${station.max_waiting_cards}); новые поступят, когда освободится место.`
+ : `Осталось карточек: ${station.pending_cards_count}. Ожидается следующее поступление.`}
+
}
>}
@@ -298,7 +347,7 @@ export function Dds() {
☎ Отключение записи звонков · список SMS
☎ АОН {field(card, "caller_number")}
☎ предоставленный {field(card, "caller_contact")}
- ☎ телефон на место {field(card, "caller_contact")}
+ ☎ телефон на место {field(card, "phone_on_scene")}
Происшествие {cardId.slice(0, 8).toUpperCase()}
Сохр. {receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "—"}
Опер., АРМ ДДС
@@ -314,7 +363,8 @@ export function Dds() {
Происшествие {field(card, "incident_code")}
{field(card, "description")}
Класс: {field(card, "incident_type")}
-
Список оповещения: {station?.services.join(", ") || field(card, "notify")}
+ Получатели карточки: {station?.recipient_services?.join(", ") || field(card, "notify")}
+ Ваша ДДС: {station?.managed_service || "служба не назначена"}
{pane &&
@@ -348,49 +398,75 @@ export function Dds() {
{station.services.filter((service) => service === (activeService ?? station.services[0])).map((service) =>
report.service === service)}
- phoneExercise={from === "учебный сценарий"}
+ disabled={!isConnected || ended}
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 })} />)}
> : Ожидаем состав служб по карточке.
}
- Сначала примите карточку или укажите причину отказа. Последующие статусы в занятии ДДС подтверждаются докладами по телефону.
+ Сначала примите карточку или укажите причину отказа. Последующие статусы ставятся по фактически полученной информации; телефон — один из возможных каналов.
}
{pane === "journal" && station && station.log.length > 0 &&
Журнал реагирования
Время Служба Статус Комментарий
{station.log.map((entry, index) => {time(entry.at)} {entry.service}
{SERVICE_STATUS_LABELS[entry.status]} {entry.comment || "—"} )}
}
- {pane === "phone" &&
IP-телефон Учебная имитация
- Выберите бригаду вручную, свяжитесь со старшим группы и внесите его доклад в карточку.
+ {pane === "phone" &&
IP-телефон SIP/WebRTC + учебный протокол
+
+ На втором рабочем месте откройте телефон старшего группы и зарегистрируйте 6102. Пароли 6101/6102 выдаёт локальный администратор командой make sip-credentials.
+ Учебный протокол доклада
+ Связь используется при необходимости. Разговор и учебный доклад сохраняются в журнале, но не блокируют изменение статуса.
Дежурная бригада
send({ type: "crew.select", crew: event.target.value })}>
Выберите бригаду
{station?.crew_options?.map((crew) => {crew} )}
- { if (send({ type: "phone.dial" })) setDialing(true); }}>
☎ {dialing ? "Соединение…" : "Позвонить старшему группы"}
{selectedCrew && !crewAccepted && Перед звонком поставьте статус «Принята» для службы этой бригады.
}
+ {phoneCall &&
+
● Соединение установлено · {phoneCall.crew}
+ {phoneCall.phase === "dispatched" ?
+
Передайте сведения из карточки и задачу бригаде. Доклад о выезде появится только после передачи.
+
Полный адрес из карточки
+
setBrief({ ...brief, address: event.target.value })} placeholder="Улица, дом, при наличии квартира и этаж" />
+
Характер происшествия
+
:
+
Уточните обстановку у старшего группы. Ответ станет основанием для следующего статуса.
+
Ваш вопрос старшему
+
}
+
send({ type: "phone.hangup" })}>Завершить без доклада
+
}
+ {phoneLines.length > 0 && Учебный разговор
+ {phoneLines.map((line, index) =>
{time(line.at)} · {line.speaker === "crew" ? "Старший группы" : "Диспетчер"} {line.text}
)}
+
}
{latest ? {REPORT_LABELS[latest.phase]} · {time(latest.at)}
{latest.crew} {latest.text}
Прослушайте доклад и поставьте соответствующий статус реагирования.
- : Доклады поступят после исходящего учебного звонка.
}
+ : Доклад появится после передачи сведений и ответа старшего группы.
}
{reports.length > 0 && История докладов ({reports.length})
{reports.map((report, index) => {REPORT_LABELS[report.phase]} · {time(report.at)} {report.text} )}
}
}
{pane === "reply" &&
- Ответ и комментарий диспетчера
+
Рабочая заметка диспетчера
{reply.dirty ? "Не сохранено" : "Сохранено"}
-
Свободный текст по текущей карточке
+
Необязательная заметка по текущей карточке
- Вернуть карточку на уточнение
- {FIELDS.map((item) => setMissing((previous) => previous.includes(item.path) ? previous.filter((path) => path !== item.path) : [...previous, item.path])} /> {item.label} )}
- setBounceComment(event.target.value)} />
- {
- channel.current?.send({ type: "card.bounce", missing_fields: missing, comment: bounceComment }); setBounced(true);
- }}>Вернуть оператору
-
+ ДДС не исправляет и не возвращает заполненную карточку: её качество контролируют оператор и служба контроля 112.
{
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 ? "Завершить карточку и открыть следующую" : "Завершить карточку и занятие"}
+ {queueCards.length > 1 ? "Закрыть карточку" : "Закрыть карточку и занятие"}
{cardTotal > 1 && { if (window.confirm("Завершить занятие досрочно? Остальные карточки не будут пройдены.")) send({ type: "station.finish" }); }}>
@@ -434,25 +503,20 @@ export function Dds() {
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 ? "Следующая карточка" : "Занятие"}
+ }}>✓ Завершить {queueCards.length > 1 ? "Закрыть карточку" : "Занятие"}
}
;
}
-function StatusRow({ service, current, available, disabled, reports, phoneExercise, crewOptions, selectedCrew, onCrew, onSet }: {
+function StatusRow({ service, current, available, disabled, crewOptions, selectedCrew, onCrew, onSet }: {
service: string; current: ServiceStatus; available: ServiceStatus[]; disabled: boolean;
- reports: PhoneReport[]; phoneExercise: boolean; crewOptions: string[]; selectedCrew: string;
+ crewOptions: string[]; selectedCrew: string;
onCrew: (crew: string) => void; onSet: (status: ServiceStatus, comment: string) => void;
}) {
const [chosen, setChosen] = useState("");
const [comment, setComment] = useState("");
const needsComment = chosen === "declined" || chosen === "refused";
- const reportNeeded: Partial> = {
- responding: "dispatched", arrived: "arrived", working: "working", completed: "completed",
- };
- const matchingReport = !chosen || !phoneExercise || !reportNeeded[chosen] ||
- reports.some((report) => report.phase === reportNeeded[chosen]);
if (!available.length) return {serviceTitle(service)}: {SERVICE_STATUS_LABELS[current]}. Дальнейшие статусы недоступны.
;
return
Статус
@@ -475,10 +539,9 @@ function StatusRow({ service, current, available, disabled, reports, phoneExerci
onChange={(event) => setComment(event.target.value)} />
{ if (chosen) { onSet(chosen, comment.trim()); setChosen(""); setComment(""); } }}>✓
{ setChosen(""); setComment(""); }}>×
- {chosen && !matchingReport && Сначала получите доклад по телефону. }
;
}
diff --git a/frontend/src/pages/groups/Groups.tsx b/frontend/src/pages/groups/Groups.tsx
index 15b2523..1368a4d 100644
--- a/frontend/src/pages/groups/Groups.tsx
+++ b/frontend/src/pages/groups/Groups.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
-import { useAssignTrainee, useCreateGroup, useGroupAnalytics, useGroups, useTrainees } from "@/shared/api/http";
+import { useAssignTrainee, useCreateGroup, useGroupAnalytics, useGroupInsight, useGroups, useTrainees } from "@/shared/api/http";
export function Groups() {
const groups = useGroups();
@@ -11,6 +11,7 @@ export function Groups() {
const [groupName, setGroupName] = useState("");
const [traineeId, setTraineeId] = useState("");
const report = useGroupAnalytics(groupId);
+ const insight = useGroupInsight();
useEffect(() => {
if (!groupId && groups.data?.length) setGroupId(groups.data[0].id);
@@ -69,9 +70,22 @@ export function Groups() {
Доли ошибок считаются среди курсантов с оценёнными попытками; повторные попытки
- одного человека не увеличивают долю. Рекомендации сформированы по правилам,
- не нейросетью.
+ одного человека не увеличивают долю. Табличные рекомендации всегда формируются
+ воспроизводимыми правилами.
+
+ groupId && insight.mutate(groupId)}>
+ {insight.isPending ? "Qwen анализирует агрегаты…" : "Сформировать локальный ИИ-инсайт"}
+
+ В модель передаются только агрегаты без ФИО и попыток отдельных курсантов.
+ {insight.data &&
+
Вывод Qwen
+
{insight.data.summary}
+
{insight.data.priorities.map((item) => {item} )}
+
}
+ {insight.isError && ИИ-инсайт не сформирован; табличная аналитика остаётся доступна.
}
+
Типичная ошибка Курсанты Рекомендация
diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx
index d476c5d..be173b7 100644
--- a/frontend/src/pages/instructor/Instructor.tsx
+++ b/frontend/src/pages/instructor/Instructor.tsx
@@ -17,7 +17,7 @@ import { useObserve } from "@/features/observe/useObserve";
import { InterviewTimer, Timers } from "@/features/timers/Timers";
import { useIncidentGroups, useScenarios, useSessions, useTrainees } from "@/shared/api/http";
import { sessionIdFromUrl } from "@/shared/api/session";
-import type { SessionMode } from "@/shared/types/generated";
+import type { SessionMode, SessionReport } from "@/shared/types/generated";
const MODES: { value: SessionMode; label: string }[] = [
{ value: "training", label: "тренировочный" },
@@ -34,6 +34,14 @@ export function Instructor() {
const [extraScenarioIds, setExtraScenarioIds] = useState([]);
const [mode, setMode] = useState("training");
const [exercise, setExercise] = useState<"dds" | "call" | "card" | "card_dds">("dds");
+ const [decisionTimeLimit, setDecisionTimeLimit] = useState(30);
+ const [allowedErrors, setAllowedErrors] = useState(0);
+ const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
+ const [scoreWeightsText, setScoreWeightsText] = useState("{}");
+ const [scoreWeightsError, setScoreWeightsError] = useState("");
+ const [lessonScoreWeights, setLessonScoreWeights] = useState>({});
+ const [ddsArrivalIntervalSeconds, setDdsArrivalIntervalSeconds] = useState(60);
+ const [ddsMaxWaiting, setDdsMaxWaiting] = useState(3);
const [trainee, setTrainee] = useState("");
const [traineeId, setTraineeId] = useState("");
const [noteFor, setNoteFor] = useState(null);
@@ -41,6 +49,11 @@ export function Instructor() {
// ссылка на идущее занятие не должна запускать его заново и сбрасывать.
const toStart = useRef(null);
const [noteText, setNoteText] = useState("");
+ const [overrideReport, setOverrideReport] = useState(null);
+ const [overrideScore, setOverrideScore] = useState("");
+ const [overrideComment, setOverrideComment] = useState("");
+ const [overrideError, setOverrideError] = useState("");
+ const [overrideBusy, setOverrideBusy] = useState(false);
const scenarios = useScenarios();
const incidentGroups = useIncidentGroups();
@@ -49,12 +62,27 @@ export function Instructor() {
const session = useObserve(sessionId);
const control = useControl(sessionId);
+ useEffect(() => {
+ if (!session.report) return;
+ setOverrideReport(null);
+ setOverrideScore(String(session.report.score_final));
+ setOverrideComment(session.report.override_comment ?? "");
+ }, [session.report?.session_id, session.report?.score_final, session.report?.override_comment]);
+
const visibleScenarios = (scenarios.data ?? []).filter((item) =>
(!ticketFilter || item.ticket === Number(ticketFilter)) &&
(!categoryFilter || item.ekp_group === Number(categoryFilter)) &&
(!scenarioSearch.trim() || `${item.title} ${item.id}`.toLocaleLowerCase("ru-RU")
.includes(scenarioSearch.trim().toLocaleLowerCase("ru-RU")))
).sort((a, b) => (a.ticket ?? 99) - (b.ticket ?? 99) || (a.position ?? 0) - (b.position ?? 0));
+ const selectedTicketScenarios = ticketFilter
+ ? (scenarios.data ?? []).filter((item) => item.ticket === Number(ticketFilter))
+ .sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
+ : [];
+ const [wholeTicket, setWholeTicket] = useState(false);
+ const canStartSelectedExercise = exercise === "dds" && wholeTicket
+ ? selectedTicketScenarios.length === 3
+ : Boolean(scenarioId);
useEffect(() => {
if (!visibleScenarios.some((item) => item.id === scenarioId)) {
@@ -64,6 +92,21 @@ export function Instructor() {
function startLesson() {
if (trainees.data?.length && !traineeId) return;
+ let parsedWeights: unknown;
+ try {
+ parsedWeights = JSON.parse(scoreWeightsText);
+ } catch {
+ setScoreWeightsError("Введите корректный JSON, например {\"dds_ack\": 2}.");
+ return;
+ }
+ if (!parsedWeights || typeof parsedWeights !== "object" || Array.isArray(parsedWeights) ||
+ Object.values(parsedWeights).some((value) => typeof value !== "number" ||
+ !Number.isFinite(value) || value < 0 || value > 10)) {
+ setScoreWeightsError("Задайте объект «ключ метрики: вес от 0 до 10».");
+ return;
+ }
+ setScoreWeightsError("");
+ setLessonScoreWeights(parsedWeights as Record);
const id = crypto.randomUUID();
// Номер занятия живёт в адресе: монитор и АРМ курсанта открываются ссылкой.
window.history.replaceState(null, "", `/instructor?session=${id}`);
@@ -71,18 +114,64 @@ export function Instructor() {
setSessionId(id);
}
+ async function saveScoreOverride(report: SessionReport) {
+ const score = Number(overrideScore);
+ if (!overrideScore.trim() || !Number.isFinite(score) || score < 0 || score > 100 || !overrideComment.trim()) {
+ setOverrideError("Укажите оценку от 0 до 100 и обязательное обоснование.");
+ return;
+ }
+ setOverrideBusy(true);
+ setOverrideError("");
+ try {
+ const response = await fetch(`/api/sessions/${report.session_id}/report`, {
+ method: "PATCH",
+ credentials: "same-origin",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ score_final: score, comment: overrideComment.trim() }),
+ });
+ const data = await response.json().catch(() => null);
+ if (!response.ok) {
+ const detail = data && typeof data === "object" && "detail" in data ? data.detail : response.statusText;
+ throw new Error(typeof detail === "string" ? detail : "Не удалось сохранить корректировку.");
+ }
+ const updated = data as SessionReport;
+ setOverrideReport(updated);
+ setOverrideScore(String(updated.score_final));
+ setOverrideComment(updated.override_comment ?? "");
+ } catch (cause) {
+ setOverrideError(cause instanceof Error ? cause.message : "Не удалось сохранить корректировку.");
+ } finally {
+ setOverrideBusy(false);
+ }
+ }
+
// Занятие запускается, когда канал управления открылся.
useEffect(() => {
if (!sessionId || control.status !== "open" || toStart.current !== sessionId) return;
toStart.current = null;
const selected = trainees.data?.find((item) => item.id === traineeId);
+ const primaryScenarioId = exercise === "dds" && wholeTicket
+ ? selectedTicketScenarios[0]?.id ?? scenarioId : scenarioId;
const scenarioIds = exercise === "dds"
- ? [scenarioId, ...extraScenarioIds.filter((id) => id !== scenarioId)] : undefined;
- control.start(scenarioId, mode, selected?.name || trainee || "Демо-курсант",
+ ? (wholeTicket
+ ? selectedTicketScenarios.map((item) => item.id)
+ : [scenarioId, ...extraScenarioIds.filter((id) => id !== scenarioId)])
+ .filter((id, index, all) => id && all.indexOf(id) === index)
+ : undefined;
+ control.start(primaryScenarioId, mode, selected?.name || trainee || "Демо-курсант",
exercise === "card_dds" ? "card" : exercise,
- undefined, selected?.id, scenarioIds && scenarioIds.length > 1 ? scenarioIds : undefined,
- exercise === "card_dds");
- }, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data, exercise]);
+ undefined, selected?.id, selected?.service ?? undefined,
+ scenarioIds && scenarioIds.length > 1 ? scenarioIds : undefined,
+ ddsArrivalIntervalSeconds, ddsMaxWaiting,
+ exercise === "card_dds", {
+ decision_time_limit_seconds: decisionTimeLimit,
+ allowed_errors: allowedErrors,
+ require_correct_grammar: requireCorrectGrammar,
+ score_weights: lessonScoreWeights,
+ });
+ }, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
+ exercise, decisionTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
+ ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights]);
const links = sessionId
? {
@@ -97,6 +186,10 @@ export function Instructor() {
Рабочее место преподавателя
+
+ Перейти к ИИ-формирователю сценария ↓
+ Учебные материалы и назначения →
+
- {
+ {
setScenarioId(event.target.value);
setExtraScenarioIds((previous) => previous.filter((id) => id !== event.target.value));
}}>
@@ -131,9 +227,22 @@ export function Instructor() {
))}
{visibleScenarios.length} из {scenarios.data?.length ?? 0} карточек (96 — билеты заказчика)
- {exercise === "dds" &&
+ {exercise === "dds" && ticketFilter &&
+ {
+ if (event.target.checked && selectedTicketScenarios[0]) {
+ setScenarioId(selectedTicketScenarios[0].id);
+ setExtraScenarioIds([]);
+ }
+ setWholeTicket(event.target.checked);
+ }} />
+ {` Запустить весь билет ${ticketFilter} целиком (3 вызова)`}
+ {selectedTicketScenarios.length !== 3 && — в библиотеке найдено {selectedTicketScenarios.length} из 3 вызовов }
+ }
+ {exercise === "dds" && !wholeTicket &&
Дополнительные карточки ({extraScenarioIds.length})
- Карточки поступят последовательно после завершения текущей.
+ Интервал и максимум ожидающих задаются в критериях занятия. Таймер каждой карточки начинается при поступлении и идёт, пока курсант занят другой.
{scenarios.data?.filter((scenario) => scenario.id !== scenarioId).map((scenario) =>
@@ -179,7 +288,7 @@ export function Instructor() {
setTraineeId(event.target.value)}>
Выберите учётную запись
{trainees.data.map((item) => (
- {item.name}{item.group ? ` · ${item.group}` : ""}
+ {item.name}{item.group ? ` · ${item.group}` : ""}{item.service ? ` · ${item.service}` : " · служба не назначена"}
))}
) : (
@@ -191,11 +300,57 @@ export function Instructor() {
)}
+
+ Критерии занятия
+
+ {exercise === "dds" && <>
+
+ Интервал поступления карточек, секунд (0 — без задержки; действует лимит ожидания)
+ setDdsArrivalIntervalSeconds(
+ Math.max(0, Math.min(300, Number(event.target.value) || 0)))} />
+
+
+ Максимум ожидающих карточек
+ setDdsMaxWaiting(
+ Math.max(1, Math.min(10, Number(event.target.value) || 1)))} />
+
+ >}
+
+ Решение ДДС, секунд
+ setDecisionTimeLimit(
+ Math.max(5, Math.min(300, Number(event.target.value) || 30)))} />
+
+
+ Допустимо нарушенных метрик
+ setAllowedErrors(
+ Math.max(0, Math.min(50, Number(event.target.value) || 0)))} />
+
+ {exercise !== "dds" &&
+ setRequireCorrectGrammar(event.target.checked)} />
+ Грамматика карточки оператора 112 входит в оценку
+ }
+
+ Переопределения весов метрик (JSON; 0 отключает метрику)
+
+ {scoreWeightsError && {scoreWeightsError}
}
+ Нормативы ГОСТ для приёма вызова не изменяются.
+
+
+ disabled={!canStartSelectedExercise || trainees.isLoading || Boolean(trainees.data?.length && !traineeId)}>
Начать занятие
@@ -209,7 +364,10 @@ export function Instructor() {
{sessionId && !session.ended && (
exercise === "call" &&
)}
- {links && (
+ {links && !session.title && !session.error && (
+ Запускаем занятие и готовим ссылки на рабочие места…
+ )}
+ {links && session.title && (
АРМ курсанта открыть {exercise === "dds" ? "ДДС" : "112"}
@@ -284,12 +442,37 @@ export function Instructor() {
- {
- setScenarioId(id);
+ {
+ setTicketFilter("");
+ setCategoryFilter("");
+ setScenarioSearch("");
await scenarios.refetch();
- }} />
+ setScenarioId(id);
+ }} onLibraryChanged={async () => { await scenarios.refetch(); }} />
- {session.report && }
+ {session.report && <>
+
+
+ >}
);
}
diff --git a/frontend/src/pages/materials/Materials.tsx b/frontend/src/pages/materials/Materials.tsx
new file mode 100644
index 0000000..45d6e0a
--- /dev/null
+++ b/frontend/src/pages/materials/Materials.tsx
@@ -0,0 +1,229 @@
+import { useState, type FormEvent } from "react";
+
+import { useMe } from "@/features/auth/useAuth";
+import {
+ useArchiveMaterial,
+ useAssignMaterial,
+ useAssignMaterialGroup,
+ useCompleteMaterial,
+ useCreateMaterial,
+ useGroups,
+ useMaterials,
+ useScenarios,
+ useStartMaterialPractice,
+ useTrainees,
+ useUpdateMaterial,
+ type LearningMaterial,
+} from "@/shared/api/http";
+import type { Level } from "@/shared/types/generated";
+
+async function encodeFile(file: File): Promise {
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ let binary = "";
+ for (let offset = 0; offset < bytes.length; offset += 32_768) {
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 32_768));
+ }
+ return btoa(binary);
+}
+
+function size(value: number): string {
+ if (value < 1024) return `${value} Б`;
+ return `${(value / 1024).toFixed(value < 1024 * 100 ? 1 : 0)} КБ`;
+}
+
+export function Materials() {
+ const me = useMe();
+ const instructor = me.data?.role === "instructor";
+ const trainee = me.data?.role === "trainee";
+ const [showArchived, setShowArchived] = useState(false);
+ const materials = useMaterials(showArchived && !trainee);
+ const scenarios = useScenarios();
+ const trainees = useTrainees(instructor);
+ const groups = useGroups(instructor);
+ const create = useCreateMaterial();
+ const update = useUpdateMaterial();
+ const archive = useArchiveMaterial();
+ const assign = useAssignMaterial();
+ const assignGroup = useAssignMaterialGroup();
+ const complete = useCompleteMaterial();
+ const startPractice = useStartMaterialPractice();
+
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+ const [level, setLevel] = useState("L1");
+ const [kind, setKind] = useState<"text" | "file">("text");
+ const [body, setBody] = useState("");
+ const [scenarioId, setScenarioId] = useState("");
+ const [file, setFile] = useState(null);
+ const [traineeId, setTraineeId] = useState("");
+ const [groupId, setGroupId] = useState("");
+ const [notice, setNotice] = useState("");
+ const [editing, setEditing] = useState | null>(null);
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ setNotice("");
+ const content = file ? await encodeFile(file) : undefined;
+ create.mutate({
+ title,
+ description,
+ level,
+ kind,
+ body: kind === "text" ? body : "",
+ scenario_id: scenarioId || null,
+ file_name: file?.name,
+ media_type: file?.type || "application/octet-stream",
+ content_base64: content,
+ }, {
+ onSuccess: () => {
+ setTitle(""); setDescription(""); setBody(""); setFile(null);
+ setNotice("Материал сохранён локально.");
+ },
+ });
+ }
+
+ return
+
+
+ {instructor && }
+
+
+
+
{trainee ? "Назначено мне" : "Библиотека материалов"}
+ {instructor && setShowArchived(event.target.checked)} /> Показать архив }
+
+
+ {instructor &&
+ Курсант setTraineeId(event.target.value)}>
+ выберите курсанта
+ {trainees.data?.map((item) => {item.name} )}
+
+ Группа setGroupId(event.target.value)}>
+ выберите группу
+ {groups.data?.map((item) => {item.name} )}
+
+ Выберите получателя, затем нажмите «Назначить» у материала.
+
}
+
+
+ {materials.data?.map((item) =>
+ {item.level} {item.title}
+ {item.completed_at && ✓ изучено }
+ {editing?.id === item.id ?
+
Название setEditing({ ...editing, title: event.target.value })} />
+
Описание setEditing({ ...editing, description: event.target.value })} />
+
+ Сложность setEditing({ ...editing, level: event.target.value as Level })}>
+ L1 L2 L3
+
+ Практика setEditing({ ...editing, scenario_id: event.target.value || null })}>
+ без сценария
+ {scenarios.data?.map((scenario) => {scenario.title} )}
+
+
+ {item.kind === "text" &&
Содержание setEditing({ ...editing, body: event.target.value })} /> }
+
+ update.mutate({ id: item.id, patch: {
+ title: editing.title, description: editing.description, level: editing.level,
+ body: editing.body, scenario_id: editing.scenario_id,
+ } }, { onSuccess: () => setEditing(null) })}>
+ Сохранить изменения
+
+ setEditing(null)}>Отмена
+
+
: <>
+ {item.description && {item.description}
}
+ {item.kind === "text" && {item.body}
}
+ >}
+ {item.kind === "file" &&
+ Скачать {item.file_name}
+ · {size(item.file_size)} · SHA-256 {item.file_sha256?.slice(0, 12)}…
+
}
+ {item.scenario_id && Связанная практика: {item.scenario_id}
}
+ {instructor &&
+ Назначено: {item.assignment_count}
+ setEditing({
+ id: item.id, title: item.title, description: item.description,
+ level: item.level, body: item.body, scenario_id: item.scenario_id,
+ })}>Редактировать
+ {item.active && assign.mutate({ materialId: item.id, traineeId })}>Назначить курсанту }
+ {item.active && assignGroup.mutate({ materialId: item.id, groupId })}>Назначить группе }
+ {item.active
+ ? archive.mutate(item.id)}>В архив
+ : update.mutate({ id: item.id, patch: { active: true } })}>Восстановить }
+ }
+ {trainee && !item.completed_at &&
+ complete.mutate(item.id)} disabled={complete.isPending}>
+ Отметить изученным
+
+ }
+ {trainee && item.scenario_id &&
+ startPractice.mutate(item.id, {
+ onSuccess: (started) => window.location.assign(started.path),
+ })}>
+ Начать самостоятельную практику
+
+ {startPractice.isError && Практика сейчас недоступна. }
+ }
+ )}
+
+ {!materials.isLoading && materials.data?.length === 0 &&
+ {trainee ? "Преподаватель пока не назначил материалы." : "Материалов пока нет."}
+
}
+ {materials.isError && Материалы не загрузились.
}
+
+ ;
+}
diff --git a/frontend/src/pages/phone/Phone.tsx b/frontend/src/pages/phone/Phone.tsx
new file mode 100644
index 0000000..bf767b0
--- /dev/null
+++ b/frontend/src/pages/phone/Phone.tsx
@@ -0,0 +1,9 @@
+import { SipPhone } from "@/features/sip/SipPhone";
+
+export function Phone() {
+ return
+ IP-телефон старшего группы
+ Откройте этот экран на втором рабочем месте, зарегистрируйте номер 6102 и примите вызов ДДС 6101.
+
+ ;
+}
diff --git a/frontend/src/pages/profile/Profile.tsx b/frontend/src/pages/profile/Profile.tsx
index afeab1f..519768b 100644
--- a/frontend/src/pages/profile/Profile.tsx
+++ b/frontend/src/pages/profile/Profile.tsx
@@ -4,9 +4,10 @@
// планирования подготовки. Работа над ошибками превращается в измеримый цикл:
// посмотрел разбор → попробовал снова → увидел дельту (docs/product/MODES.md).
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { COMPETENCY_LABELS, Radar } from "@/features/debrief/Radar";
+import { useMe } from "@/features/auth/useAuth";
import { clock } from "@/features/timers/clock";
import { useProfile, useTrainees } from "@/shared/api/http";
@@ -25,14 +26,22 @@ function sign(value: number | null, format: (value: number) => string): string {
export function Profile() {
const url = new URLSearchParams(location.search);
const [traineeId, setTraineeId] = useState(url.get("trainee"));
- const trainees = useTrainees();
+ const me = useMe();
+ const isTrainee = me.data?.role === "trainee";
+ const trainees = useTrainees(!isTrainee);
const profile = useProfile(traineeId);
+ useEffect(() => {
+ if (isTrainee && me.data?.trainee_id) setTraineeId(me.data.trainee_id);
+ }, [isTrainee, me.data?.trainee_id]);
+
return (
Профиль курсанта
-
+
Открыть назначенные учебные материалы →
+
+ {!isTrainee &&
setTraineeId(event.target.value || null)}>
— выберите курсанта —
{trainees.data?.map((trainee) => (
@@ -41,7 +50,7 @@ export function Profile() {
))}
-
+ }
{profile.data && (
@@ -59,6 +68,11 @@ export function Profile() {
радар — среднее по попыткам; подсказок использовано всего: {profile.data.hints_total}
+ {profile.data.attempts.some((attempt) => attempt.score !== null) && (
+
+ Скачать сертификат PDF
+
+ )}
Дельта попыток
@@ -100,6 +114,11 @@ export function Profile() {
` · ${Object.entries(attempt.codes).map(([code, count]) => `${code}×${count}`).join(", ")}`}
разбор
+ {attempt.recording_available && <>
+ {" · "}
+ запись WAV
+
+ >}
))}
diff --git a/frontend/src/shared/api/http.ts b/frontend/src/shared/api/http.ts
index e88dec7..ca05d17 100644
--- a/frontend/src/shared/api/http.ts
+++ b/frontend/src/shared/api/http.ts
@@ -31,6 +31,7 @@ export interface Health {
models_ready: boolean;
embeddings_ready: boolean;
scenarios_loaded: number;
+ sessions_restored?: number;
offline: boolean;
demo_no_db?: boolean;
}
@@ -42,10 +43,13 @@ export interface ScenarioSummary {
level: Level;
topics: string[];
modes: SessionMode[];
+ signs: string[];
+ incident_code: string | null;
dds: string | null;
ticket: number | null;
position: number | null;
ekp_group: number | null;
+ can_manage: boolean;
}
export interface SessionInfo {
@@ -78,6 +82,7 @@ export interface TraineeInfo {
id: string;
name: string;
group: string | null;
+ service: string | null;
}
export interface Attempt {
@@ -92,6 +97,7 @@ export interface Attempt {
facts_required: number | null;
hints: number | null;
codes: Record;
+ recording_available: boolean;
}
export interface Profile {
@@ -139,8 +145,12 @@ export function useSigns(prefix: string[], group: number | null = null) {
});
}
-export const useTrainees = () =>
- useQuery({ queryKey: ["trainees"], queryFn: () => request("/api/trainees") });
+export const useTrainees = (enabled = true) =>
+ useQuery({
+ queryKey: ["trainees"],
+ queryFn: () => request("/api/trainees"),
+ enabled,
+ });
export interface GroupInfo {
id: string;
@@ -163,8 +173,19 @@ export interface GroupAnalytics {
}[];
}
-export const useGroups = () =>
- useQuery({ queryKey: ["groups"], queryFn: () => request("/api/groups") });
+export interface GroupInsight {
+ summary: string;
+ priorities: string[];
+ source: "local_qwen";
+ personal_data_sent: false;
+}
+
+export const useGroups = (enabled = true) =>
+ useQuery({
+ queryKey: ["groups"],
+ queryFn: () => request("/api/groups"),
+ enabled,
+ });
export function useCreateGroup() {
const cache = useQueryClient();
@@ -196,6 +217,14 @@ export const useGroupAnalytics = (groupId: string | null) =>
refetchInterval: 15_000,
});
+export function useGroupInsight() {
+ return useMutation({
+ mutationFn: (groupId: string) => request(
+ `/api/groups/${groupId}/analytics/insight`, { method: "POST" },
+ ),
+ });
+}
+
export const useProfile = (traineeId: string | null) =>
useQuery({
queryKey: ["profile", traineeId],
@@ -208,3 +237,121 @@ export const useCreateSession = () =>
mutationFn: (body: { scenario_id: string; mode: SessionMode; trainee?: string; group?: string }) =>
request("/api/sessions", { method: "POST", body: JSON.stringify(body) }),
});
+
+export interface LearningMaterial {
+ id: string;
+ title: string;
+ description: string;
+ level: Level;
+ kind: "text" | "file";
+ body: string;
+ scenario_id: string | null;
+ file_name: string | null;
+ media_type: string | null;
+ file_size: number;
+ file_sha256: string | null;
+ active: boolean;
+ created_by: string;
+ created_at: string;
+ assigned_at: string | null;
+ completed_at: string | null;
+ assignment_count: number;
+}
+
+export interface MaterialCreateInput {
+ title: string;
+ description: string;
+ level: Level;
+ kind: "text" | "file";
+ body: string;
+ scenario_id: string | null;
+ file_name?: string;
+ media_type?: string;
+ content_base64?: string;
+}
+
+export const useMaterials = (includeArchived = false) => useQuery({
+ queryKey: ["materials", includeArchived],
+ queryFn: () => request(
+ `/api/materials${includeArchived ? "?include_archived=true" : ""}`,
+ ),
+});
+
+function invalidateMaterials(cache: ReturnType) {
+ return cache.invalidateQueries({ queryKey: ["materials"] });
+}
+
+export function useCreateMaterial() {
+ const cache = useQueryClient();
+ return useMutation({
+ mutationFn: (body: MaterialCreateInput) => request("/api/materials", {
+ method: "POST", body: JSON.stringify(body),
+ }),
+ onSuccess: () => void invalidateMaterials(cache),
+ });
+}
+
+export function useUpdateMaterial() {
+ const cache = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, patch }: { id: string; patch: Partial }) =>
+ request(`/api/materials/${id}`, {
+ method: "PATCH", body: JSON.stringify(patch),
+ }),
+ onSuccess: () => void invalidateMaterials(cache),
+ });
+}
+
+export function useArchiveMaterial() {
+ const cache = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => request(`/api/materials/${id}`, {
+ method: "DELETE",
+ }),
+ onSuccess: () => void invalidateMaterials(cache),
+ });
+}
+
+export function useAssignMaterial() {
+ const cache = useQueryClient();
+ return useMutation({
+ mutationFn: ({ materialId, traineeId }: { materialId: string; traineeId: string }) =>
+ request(`/api/materials/${materialId}/assign/${traineeId}`, {
+ method: "PUT",
+ }),
+ onSuccess: () => void invalidateMaterials(cache),
+ });
+}
+
+export function useAssignMaterialGroup() {
+ const cache = useQueryClient();
+ return useMutation({
+ mutationFn: ({ materialId, groupId }: { materialId: string; groupId: string }) =>
+ request<{ material_id: string; assigned: number }>(
+ `/api/materials/${materialId}/assign-group/${groupId}`, { method: "PUT" },
+ ),
+ onSuccess: () => void invalidateMaterials(cache),
+ });
+}
+
+export function useCompleteMaterial() {
+ const cache = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => request(`/api/materials/${id}/complete`, {
+ method: "POST",
+ }),
+ onSuccess: () => void invalidateMaterials(cache),
+ });
+}
+
+export function useStartMaterialPractice() {
+ return useMutation({
+ mutationFn: (id: string) => request<{
+ session_id: string;
+ scenario_id: string;
+ mode: "self";
+ exercise: "card";
+ path: string;
+ }>(`/api/materials/${id}/start`, { method: "POST" }),
+ });
+}
diff --git a/frontend/src/shared/api/ws.ts b/frontend/src/shared/api/ws.ts
index 6720540..150c2e9 100644
--- a/frontend/src/shared/api/ws.ts
+++ b/frontend/src/shared/api/ws.ts
@@ -15,25 +15,33 @@ import type {
export type ChannelStatus = "connecting" | "open" | "reconnecting" | "closed";
-interface ChannelOptions {
+interface ChannelOptions {
onEvent?: (event: In) => void;
/** Бинарный кадр: звук звонящего, без обёртки JSON. */
onBinary?: (frame: ArrayBuffer) => void;
onStatus?: (status: ChannelStatus) => void;
+ /** Ключ безопасного идемпотентного события, которое можно повторить после
+ * краткого разрыва. null запрещает буферизацию команды. */
+ queueKey?: (event: Out) => string | null;
}
const RECONNECT_MIN_MS = 500;
const RECONNECT_MAX_MS = 5_000;
+const BUFFER_MAX_AGE_MS = 35_000;
+const BUFFER_MAX_ITEMS = 100;
+
+interface Buffered { key: string; event: Out; at: number }
export class Channel {
private socket: WebSocket | null = null;
private attempt = 0;
private timer: ReturnType | null = null;
private closedByUs = false;
+ private buffered: Buffered[] = [];
constructor(
private readonly path: string,
- private readonly options: ChannelOptions = {},
+ private readonly options: ChannelOptions = {},
) {}
connect(): this {
@@ -51,6 +59,10 @@ export class Channel
socket.onopen = () => {
this.attempt = 0;
this.options.onStatus?.("open");
+ const now = Date.now();
+ const pending = this.buffered.filter((item) => now - item.at <= BUFFER_MAX_AGE_MS);
+ this.buffered = [];
+ for (const item of pending) socket.send(JSON.stringify(item.event));
};
socket.onmessage = (message) => {
if (typeof message.data !== "string") {
@@ -78,11 +90,32 @@ export class Channel
return this.socket?.readyState === WebSocket.OPEN;
}
- // Пока соединения нет, события не копятся: истина — на сервере, и после
- // переподключения интерфейс получит актуальное состояние, а не очередь старых правок.
send(event: Out): boolean {
- if (!this.isOpen) return false;
- this.socket!.send(JSON.stringify(event));
+ if (this.isOpen) {
+ this.socket!.send(JSON.stringify(event));
+ return true;
+ }
+ const key = this.options.queueKey?.(event) ?? null;
+ if (!key) return false;
+ const existing = this.buffered.find((item) => item.key === key);
+ if (existing) {
+ // Частые kio.patch объединяем: последнее значение каждого поля важнее,
+ // но правки разных полей не должны затереть друг друга.
+ if (existing.event.type === "kio.patch" && event.type === "kio.patch") {
+ const previousPatch = existing.event as unknown as { fields: Record };
+ const nextPatch = event as unknown as { fields: Record };
+ existing.event = {
+ ...existing.event,
+ fields: { ...previousPatch.fields, ...nextPatch.fields },
+ } as Out;
+ } else {
+ existing.event = event;
+ }
+ existing.at = Date.now();
+ } else {
+ this.buffered.push({ key, event, at: Date.now() });
+ if (this.buffered.length > BUFFER_MAX_ITEMS) this.buffered.shift();
+ }
return true;
}
@@ -96,22 +129,36 @@ export class Channel
this.closedByUs = true;
if (this.timer) clearTimeout(this.timer);
this.socket?.close();
+ this.buffered = [];
}
}
-export const callChannel = (sessionId: string, options?: ChannelOptions) =>
- new Channel(`/ws/call/${sessionId}`, options);
+export const callChannel = (
+ sessionId: string,
+ options?: ChannelOptions,
+) => new Channel(`/ws/call/${sessionId}`, {
+ ...options,
+ queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
+});
/** Наблюдатель: отправлять нечего и нельзя — `Out` равен `never`. */
-export const observeChannel = (sessionId: string, options?: ChannelOptions) =>
+export const observeChannel = (sessionId: string, options?: ChannelOptions) =>
new Channel(`/ws/observe/${sessionId}`, options);
/** Станция ДДС: только JSON, аудио здесь нет вообще. */
export const stationChannel = (
sessionId: string,
role: string,
- options?: ChannelOptions,
-) => new Channel(`/ws/station/${sessionId}?role=${role}`, options);
+ options?: ChannelOptions,
+) => new Channel(`/ws/station/${sessionId}?role=${role}`, {
+ ...options,
+ queueKey: (event) => {
+ if (event.type === "card.reply") return `card.reply:${event.card_id}`;
+ if (event.type === "crew.select") return "crew.select";
+ if (event.type === "zone.decision") return "zone.decision";
+ return null;
+ },
+});
/** Преподаватель: только передача, входящих в этом канале нет. */
export const controlChannel = (sessionId: string, options?: { onStatus?: (status: ChannelStatus) => void }) =>
diff --git a/frontend/src/shared/types/generated.ts b/frontend/src/shared/types/generated.ts
index fec4a52..c8e722d 100644
--- a/frontend/src/shared/types/generated.ts
+++ b/frontend/src/shared/types/generated.ts
@@ -93,6 +93,12 @@ export interface CardNext {
card_id: string;
}
+/** Открыть одну из уже выданных карточек, не меняя её таймер. */
+export interface CardOpen {
+ type: "card.open";
+ card_id: string;
+}
+
/** Снимок КИО. После передачи не меняется — оператор не дописывает задним числом. */
export interface CardReceived {
type: "card.received";
@@ -171,6 +177,25 @@ export interface DdsDispatch {
service?: DDSCode | null;
}
+/** Строка общей очереди ДДС; таймер каждой карточки идёт независимо. */
+export interface DdsQueueCard {
+ card_id: string;
+ scenario_id: string;
+ title: string;
+ address?: string | null;
+ description?: string | null;
+ incident_type?: string | null;
+ victims_count?: number | null;
+ received_at: string;
+ managed_service?: string | null;
+ service_status: ServiceStatus;
+ card_status: CardStatus;
+ elapsed_ms: number;
+ limit_ms: number;
+ timer_stopped: boolean;
+ active: boolean;
+}
+
/** `immediate` — только для обрыва связи: рвёт TTS на полуслове. */
export type DirectiveMode = "next_turn" | "immediate";
@@ -272,6 +297,7 @@ export interface KIO {
caller_number?: string | null;
caller_name?: string | null;
caller_contact?: string | null;
+ phone_on_scene?: string | null;
language?: string;
okato?: string | null;
address?: string | null;
@@ -322,6 +348,14 @@ export interface KioState {
kio: KIO;
}
+/** Настраиваемые преподавателем условия именно этого занятия. Нормативы ГОСТ для приёма вызова сюда не входят. Меняется учебный лимит решения ДДС и порог успешности, которые ТЗ прямо отдаёт преподавателю. */
+export interface LessonCriteria {
+ decision_time_limit_seconds?: number;
+ allowed_errors?: number;
+ require_correct_grammar?: boolean;
+ score_weights?: Record;
+}
+
/** Уровень сложности сценария. */
export type Level = "L1" | "L2" | "L3";
@@ -359,10 +393,51 @@ export type Outcome = "card" | "consultation" | "transfer_region";
/** `auto` — сервер распознал факт из речи, `operator` — эхо правки курсанта. */
export type PatchSource = "auto" | "operator";
+/** Что диспетчер передал старшему группы при первом исходящем звонке. */
+export interface PhoneBrief {
+ type: "phone.brief";
+ address: string;
+ incident: string;
+ request?: string;
+}
+
+export interface PhoneCallPending {
+ service: string;
+ crew: string;
+ phase: string;
+}
+
+/** Запрос диспетчера об обстановке после предыдущего доклада. */
+export interface PhoneCheck {
+ type: "phone.check";
+ text: string;
+}
+
export interface PhoneDial {
type: "phone.dial";
}
+export interface PhoneHangup {
+ type: "phone.hangup";
+}
+
+export interface PhoneLine {
+ type: "phone.line";
+ service: string;
+ crew: string;
+ speaker: "dispatcher" | "crew";
+ text: string;
+ at: string;
+}
+
+export interface PhoneLineRecord {
+ service: string;
+ crew: string;
+ speaker: string;
+ text: string;
+ at: string;
+}
+
export interface PhoneReport {
type: "phone.report";
service: string;
@@ -420,12 +495,16 @@ export interface ScenarioStart {
type: "scenario.start";
scenario_id: string;
scenario_ids?: Array | null;
+ dds_arrival_interval_seconds?: number;
+ dds_max_waiting?: number;
trainee: string;
trainee_id?: string | null;
+ dds_service?: string | null;
group_id?: string | null;
mode: SessionMode;
exercise?: Exercise;
handoff_to_dds?: boolean;
+ criteria?: LessonCriteria;
}
/** Коррекция оценки. Автооценка сохраняется рядом. */
@@ -486,6 +565,9 @@ export interface SessionReport {
scenario_id: string;
mode: SessionMode;
attempt?: number;
+ criteria: LessonCriteria;
+ failed_metrics: number;
+ passed: boolean;
transcript: Array;
findings: Array;
metrics: Array;
@@ -512,6 +594,7 @@ export interface SessionSnapshot {
level: Level;
mode: SessionMode;
exercise?: Exercise;
+ criteria?: LessonCriteria;
trainee_name?: string | null;
started_at?: string | null;
kio: KIO;
@@ -535,6 +618,8 @@ export interface StationFinish {
/** Что видит диспетчер: службы, их статусы и что доступно дальше. Все поля обязательные, хотя списки и могут быть пустыми: снимок всегда собирается целиком, а необязательное поле уезжает в TypeScript как `| undefined` и заставляет фронт проверять то, чего не бывает. */
export interface StationSnapshot {
services: Array;
+ recipient_services?: Array;
+ managed_service?: string | null;
statuses: Record;
available: Record>;
card: CardStatus;
@@ -542,11 +627,17 @@ export interface StationSnapshot {
crew_options?: Array;
crew_selected?: string | null;
phone_reports?: Array;
+ phone_lines?: Array;
+ phone_pending?: PhoneCallPending | null;
card_id?: string | null;
card_index?: number;
card_total?: number;
reply_text?: string;
completed_cards?: Array;
+ queue_cards?: Array;
+ pending_cards_count: number;
+ next_arrival_in_seconds: number | null;
+ max_waiting_cards: number;
}
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
@@ -652,9 +743,9 @@ export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | T
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | ScenarioPublish | SessionStop;
-export type ServerToStation = CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent;
+export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent;
-export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish | CardReply | CardNext | ZoneDecision | CrewDispatched | CrewArrived;
+export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
export type EventName =
"bg.start"
@@ -671,6 +762,7 @@ export type EventName =
| "card.bounce"
| "card.briefing"
| "card.next"
+ | "card.open"
| "card.received"
| "card.reply"
| "card.status"
@@ -689,7 +781,11 @@ export type EventName =
| "kio.patch"
| "kio.state"
| "mode.set"
+ | "phone.brief"
+ | "phone.check"
| "phone.dial"
+ | "phone.hangup"
+ | "phone.line"
| "phone.report"
| "reference.play"
| "reference.started"
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index c0a4e02..c3e91ea 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -49,6 +49,10 @@ button:disabled { opacity: 0.5; cursor: default; }
.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; }
+@media (max-width: 600px) {
+ /* The fixed demo menu covered the address cell in the 390px DDS registry. */
+ .demo-switcher { position: static; width: fit-content; max-width: 100%; margin: 12px 0 0 auto; }
+}
/* Две колонки: карточка и разговор рядом, как на настоящем АРМ. */
.columns { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; }
@@ -63,6 +67,8 @@ button:disabled { opacity: 0.5; cursor: default; }
.instructor-layout select, .instructor-layout input { box-sizing: border-box; max-width: 100%; min-width: 0; }
.instructor-layout section:first-child select, .instructor-layout section:first-child input[type="text"] { width: 100%; }
.instructor-layout .mode-choice { overflow-wrap: anywhere; }
+.lesson-criterion { display: grid; grid-template-columns: minmax(0, 1fr) 72px; gap: 6px; align-items: center; margin-bottom: 5px; }
+.lesson-criterion input[type="number"] { width: 72px; padding: 3px 5px; }
.instructor-scenario-filters { display: grid; gap: 5px; margin-bottom: 6px; }
.kio-signs-hint { margin: 4px 0; color: #586572; font-size: 12px; }
@media (max-width: 1350px) {
@@ -128,7 +134,9 @@ button:disabled { opacity: 0.5; cursor: default; }
/* Преподаватель редактирует копию шаблона до публикации. */
.scenario-editor { margin-top: 24px; padding: 12px 16px 16px; background: var(--panel); border: 1px solid var(--line); }
.scenario-editor h2 { margin-top: 0; }
-.scenario-editor-create, .scenario-editor-open, .scenario-editor-actions { display: flex; align-items: end; flex-wrap: wrap; gap: 8px 12px; margin: 12px 0; }
+.scenario-editor-create, .scenario-editor-open, .scenario-editor-actions, .scenario-library-actions { display: flex; align-items: end; flex-wrap: wrap; gap: 8px 12px; margin: 12px 0; }
+.scenario-library-actions { padding-top: 10px; border-top: 1px solid var(--line); }
+.scenario-library-actions span { align-self: center; color: var(--muted); font-size: 13px; }
.scenario-editor-create label, .scenario-editor-open label { display: grid; gap: 4px; min-width: 220px; font-size: 13px; }
.scenario-editor-create select, .scenario-editor-create input, .scenario-editor-open input { box-sizing: border-box; width: 100%; min-height: 34px; padding: 4px 6px; border: 1px solid var(--line); font: inherit; background: var(--panel); }
.scenario-editor-create label:first-child { flex: 1 1 350px; }
@@ -193,6 +201,31 @@ button:disabled { opacity: 0.5; cursor: default; }
.dds-report strong { margin-top: 4px; }
.dds-report p { line-height: 1.45; }
.dds-phone-idle { margin: 12px; padding: 18px 12px; border: 1px dashed #aab4bf; color: #66717b; text-align: center; font-size: 13px; }
+.sip-phone { margin: 12px; padding: 12px; border: 1px solid #7d9db2; background: #eef5f9; }
+.sip-phone > header { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 5px; }
+.sip-phone > header span { font-size: 12px; }
+.sip-phone-note { margin: 4px 0 10px; color: #52616b; font-size: 12px; }
+.sip-phone-settings, .sip-phone-controls { display: flex; align-items: end; flex-wrap: wrap; gap: 8px; }
+.sip-phone-settings label, .sip-phone-controls label { display: grid; flex: 1 1 135px; gap: 4px; color: #52616b; font-size: 11px; }
+.sip-phone-settings input, .sip-phone-controls input { box-sizing: border-box; width: 100%; min-height: 36px; padding: 6px 8px; border: 1px solid #9aabb5; background: white; font: inherit; }
+.sip-phone-settings button, .sip-phone-controls button { min-height: 36px; }
+.sip-phone-controls .sip-answer { border-color: #1f6f3d; background: #1f6f3d; color: white; }
+.sip-phone-controls .sip-hangup { border-color: #a51d1d; color: #a51d1d; }
+.sip-phone audio { display: none; }
+.dds-protocol-title { margin: 18px 12px 4px; padding-top: 12px; border-top: 1px solid #ccd5db; font-size: 14px; }
+.phone-page { max-width: 760px; margin: 0 auto; }
+.phone-page .sip-phone { margin: 20px 0; }
+.dds-phone-active { margin: 12px; padding: 12px; border: 1px solid #8eafc8; background: #f5f9fc; }
+.dds-phone-active > strong { color: #21506e; }
+.dds-phone-form { margin-top: 12px; }
+.dds-phone-form p { margin: 6px 0 12px; font-size: 13px; line-height: 1.45; }
+.dds-phone-form label { display: block; margin: 10px 0 4px; font-size: 12px; font-weight: 600; }
+.dds-phone-form input, .dds-phone-form textarea { display: block; box-sizing: border-box; width: 100%; padding: 8px; font: inherit; border: 1px solid #aab4bf; background: #fff; }
+.dds-phone-form button { margin-top: 12px; background: #23577e; color: #fff; border-color: #23577e; }
+.dds-phone-hangup { margin-top: 10px; }
+.dds-phone-transcript { margin: 12px; padding: 12px; border: 1px solid #d2dbe2; background: #fff; max-height: 260px; overflow-y: auto; }
+.dds-phone-transcript p { margin: 8px 0; padding: 7px 9px; background: #f4f6f8; line-height: 1.4; }
+.dds-phone-transcript small { color: #5a6670; }
.dds-reports { margin: 10px 0; padding-left: 22px; font-size: 12px; }
.dds-reports li { padding: 5px 0; border-bottom: 1px solid #e0e3e6; }
.dds-zone { padding: 12px; display: flex; align-items: center; flex-wrap: wrap; gap: 5px; font-size: 13px; }
@@ -231,12 +264,56 @@ button:disabled { opacity: 0.5; cursor: default; }
.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-register-panel .dds-registry .dds-cell-value { flex: 1 1 auto; min-width: 0; }
+ .dds-register-panel .dds-registry .dds-cell-value > span,
+ .dds-register-panel .dds-registry .dds-cell-value > small { display: block; overflow-wrap: anywhere; }
.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; }
}
+/* Локальная библиотека учебных материалов. */
+.workspace-links { display: flex; flex-wrap: wrap; gap: 8px 24px; }
+.materials-page { max-width: 1180px; }
+.materials-header, .materials-list-head { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
+.materials-header h1, .materials-list-head h2 { margin-bottom: 4px; }
+.materials-header p { margin-top: 0; color: var(--muted); }
+.material-create { margin: 20px 0; padding: 18px; border: 1px solid var(--line); background: var(--panel); }
+.material-create form, .material-create label { display: grid; gap: 6px; }
+.material-create form { gap: 12px; }
+.material-create-row { display: grid; grid-template-columns: 180px minmax(260px, 1fr); gap: 12px; }
+.material-create input, .material-create select, .material-create textarea,
+.material-assignment-bar select { box-sizing: border-box; width: 100%; }
+.material-create textarea { resize: vertical; }
+.material-create button { justify-self: start; }
+.material-assignment-bar { display: grid; grid-template-columns: minmax(190px, 1fr) minmax(190px, 1fr) 2fr; align-items: end; gap: 12px; margin: 12px 0; padding: 12px; border: 1px solid var(--line); background: #eef4f7; }
+.material-assignment-bar label { display: grid; gap: 4px; }
+.material-assignment-bar span { align-self: center; color: var(--muted); font-size: 13px; }
+.material-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; }
+.material-card { padding: 16px; border: 1px solid var(--line); border-left: 5px solid #2578a5; background: var(--panel); box-shadow: 0 2px 8px #24323a12; }
+.material-card.archived { border-left-color: #87949b; opacity: .72; }
+.material-card > header { display: flex; align-items: center; gap: 10px; }
+.material-card h3 { flex: 1; margin: 0; }
+.material-level { padding: 3px 7px; border-radius: 3px; background: #176b9d; color: #fff; font-size: 12px; font-weight: 700; }
+.material-body { margin: 12px 0; padding: 12px; white-space: pre-wrap; border-left: 3px solid #a9c4d3; background: #f4f7f9; line-height: 1.5; }
+.material-edit, .material-edit label { display: grid; gap: 6px; }
+.material-edit { gap: 10px; margin: 12px 0; padding: 12px; background: #f4f7f9; }
+.material-edit input, .material-edit select, .material-edit textarea { box-sizing: border-box; width: 100%; }
+.material-edit-actions { display: flex; flex-wrap: wrap; gap: 8px; }
+.material-card > footer { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
+.material-card > footer span { margin-right: auto; color: var(--muted); }
+.group-insight { display: grid; gap: 8px; margin: 14px 0; padding: 14px; border: 1px solid #9ebccc; background: #eef6fa; }
+.group-insight button { justify-self: start; }
+.group-insight > div { padding: 12px; border-left: 4px solid #176b9d; background: #fff; }
+.group-insight p, .group-insight ol { margin-bottom: 0; }
+
+@media (max-width: 700px) {
+ .materials-header, .materials-list-head { align-items: flex-start; flex-direction: column; }
+ .material-create-row, .material-assignment-bar, .material-cards { grid-template-columns: 1fr; }
+ .material-card > footer button { width: 100%; }
+}
+
/* АРМ 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; }
@@ -385,7 +462,7 @@ button:disabled { opacity: 0.5; cursor: default; }
/* ДДС: повтор композиции предоставленного АРМ (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-workspace { box-sizing: border-box; max-width: none; min-height: 100vh; padding: 0; background: #c9ced1; 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; }
@@ -416,12 +493,12 @@ button:disabled { opacity: 0.5; cursor: default; }
.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-layout { display: flex; flex-direction: column; min-height: calc(100vh - 38px); background: #c9ced1; }
.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 { display: grid; grid-template-columns: .9fr 1.1fr 1.1fr 1.1fr 200px 100px; gap: 5px; padding: 7px; background: #c9ced1; }
+.dds-call-strip > div { box-sizing: border-box; min-width: 0; min-height: 64px; padding: 6px 8px; background: #efefef; }
.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; }
@@ -430,17 +507,17 @@ button:disabled { opacity: 0.5; cursor: default; }
.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-meta { display: grid; grid-template-columns: 1.2fr 1fr 65px; gap: 6px; padding: 2px 7px 6px; background: #c9ced1; }
+.dds-case-meta span { min-height: 29px; padding: 7px; background: #efefef; }
.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-classification > strong, .dds-case-classification > div { display: block; box-sizing: border-box; min-height: 30px; margin-bottom: 5px; padding: 7px 9px; background: #efefef; }
.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-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: #efefef; 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; }
@@ -511,3 +588,67 @@ button:disabled { opacity: 0.5; cursor: default; }
.dds-status-editor button { min-height: 38px; }
.dds-status-history > div { grid-template-columns: 1fr; gap: 2px; }
}
+
+/* Сенсорный режим. Узкая раскладка без горизонтального скролла недостаточна:
+ рабочие поля и действия должны попадаться пальцем, а не только мышью. */
+@media (max-width: 700px) {
+ button,
+ select,
+ textarea,
+ input:not([type="radio"]):not([type="checkbox"]),
+ summary {
+ box-sizing: border-box;
+ min-width: 44px;
+ min-height: 44px;
+ }
+ summary {
+ display: flex;
+ align-items: center;
+ padding-top: 8px;
+ padding-bottom: 8px;
+ }
+ .mode-choice {
+ display: flex;
+ align-items: center;
+ min-height: 40px;
+ gap: 7px;
+ }
+ .mode-choice input[type="radio"],
+ .mode-choice input[type="checkbox"] {
+ flex: 0 0 22px;
+ width: 22px;
+ height: 22px;
+ margin: 0;
+ }
+ .lesson-criterion input[type="number"] { min-height: 44px; }
+ .scenario-editor-create select,
+ .scenario-editor-create input,
+ .scenario-editor-open input { min-height: 44px; }
+ .sip-phone { margin: 8px; }
+ .sip-phone > header { align-items: flex-start; flex-direction: column; }
+ .sip-phone-settings, .sip-phone-controls { display: grid; grid-template-columns: 1fr; }
+ .sip-phone-settings label, .sip-phone-controls label { min-width: 0; }
+ .sip-phone-settings input, .sip-phone-controls input,
+ .sip-phone-settings button, .sip-phone-controls button { min-height: 44px; }
+ .arm-call-actions button,
+ .arm-footer button,
+ .kio-arm input[type="text"],
+ .kio-arm input[type="number"],
+ .kio-arm select,
+ .kio-arm-choice button,
+ .kio-arm-type-picker button,
+ .dds-search-line input,
+ .dds-search-line button,
+ .dds-row-open,
+ .dds-row-link,
+ .dds-case-top button,
+ .dds-case-top-buttons button,
+ .dds-drawer-title button,
+ .dds-status-history .dds-status-close,
+ .dds-status-editor select,
+ .dds-status-editor input,
+ .dds-status-editor button {
+ min-width: 44px;
+ min-height: 44px;
+ }
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 63a51f4..434233a 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,15 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
+const proxy = {
+ "/api": { target: process.env.VITE_BACKEND_URL || "http://localhost:8000", changeOrigin: true },
+ "/ws": { target: process.env.VITE_WS_BACKEND_URL || "ws://localhost:8000", ws: true },
+};
+
// Бэкенд один, порт один: фронт ходит на /api и /ws через прокси,
// чтобы в классе не было разъезда адресов между машинами.
export default defineConfig({
server: {
port: 5173,
- proxy: {
- "/api": { target: process.env.VITE_BACKEND_URL || "http://localhost:8000", changeOrigin: true },
- "/ws": { target: process.env.VITE_WS_BACKEND_URL || "ws://localhost:8000", ws: true },
- },
+ proxy,
},
+ preview: { port: 5173, host: "0.0.0.0", proxy },
resolve: { alias: { "@": "/src" } },
});
diff --git a/scenarios/tickets/t01-1-fire-container.yaml b/scenarios/tickets/t01-1-fire-container.yaml
index 8b8ac21..662afe7 100644
--- a/scenarios/tickets/t01-1-fire-container.yaml
+++ b/scenarios/tickets/t01-1-fire-container.yaml
@@ -30,7 +30,7 @@ facts:
value: "горит мусорный контейнер, пламя открытое"
reveal_on: { question: q_what_burns }
- id: f_people
- value: "пострадавших нет, рядом никого"
+ value: пострадавших нет
reveal_on: { question: q_people }
- id: f_caller
value: "Сидоров Иван Сергеевич, телефон 916-126-34-71"
diff --git a/scenarios/tickets/t01-2-mass-fight.yaml b/scenarios/tickets/t01-2-mass-fight.yaml
index 85b32cb..879940e 100644
--- a/scenarios/tickets/t01-2-mass-fight.yaml
+++ b/scenarios/tickets/t01-2-mass-fight.yaml
@@ -19,7 +19,7 @@ persona:
- { stage: registration, mood: worried }
- { stage: interview, mood: worried }
-first_line: "Алло! Тут человек пятнадцать дерутся, прутами машут! Скорее!"
+first_line: "Алло! Тут десять-пятнадцать человек дерутся, палками и прутами машут! Скорее!"
facts:
- id: f_address
@@ -34,7 +34,7 @@ facts:
value: "пятеро пострадавших с разными травмами"
reveal_on: { question: q_victims }
- id: f_suspects
- value: "дерутся палками и железными прутами, огнестрельного не видно"
+ value: "дерутся палками и железными прутами"
reveal_on: { question: q_suspects }
- id: f_caller
value: "Иванов Пётр Иванович, телефон 916-123-98-78"
diff --git a/scenarios/tickets/t01-3-child-other-region.yaml b/scenarios/tickets/t01-3-child-other-region.yaml
index 7782767..9cd7828 100644
--- a/scenarios/tickets/t01-3-child-other-region.yaml
+++ b/scenarios/tickets/t01-3-child-other-region.yaml
@@ -38,7 +38,7 @@ facts:
value: "Смирнов Илья, 11 лет, сын"
reveal_on: { question: q_who }
- id: f_conscious
- value: "в сознании, плачет, дышит нормально"
+ value: "сведения о сознании ребёнка не указаны"
reveal_on: { question: q_conscious }
- id: f_caller
value: "звонит мама, телефон 9163201283"
diff --git a/scenarios/tickets/t02-1-smoke-chute.yaml b/scenarios/tickets/t02-1-smoke-chute.yaml
index 5a12b1f..4629344 100644
--- a/scenarios/tickets/t02-1-smoke-chute.yaml
+++ b/scenarios/tickets/t02-1-smoke-chute.yaml
@@ -19,7 +19,7 @@ persona:
- { stage: registration, mood: worried }
- { stage: interview, mood: worried }
-first_line: "Здравствуйте, у нас дымом из мусоропровода тянет, весь подъезд в дыму."
+first_line: "Здравствуйте, дымит мусоропровод, я нахожусь на седьмом этаже."
facts:
- id: f_address
@@ -29,10 +29,10 @@ facts:
value: "дымит мусоропровод, открытого пламени не видно"
reveal_on: { question: q_what_burns }
- id: f_people
- value: "пострадавших нет, люди в квартирах"
+ value: пострадавших нет
reveal_on: { question: q_people }
- id: f_smoke
- value: "дым идёт по подъезду, заявитель на 7-м этаже, в доме 17 этажей"
+ value: "заявитель на 7-м этаже, в доме 17 этажей"
reveal_on: { question: q_smoke }
- id: f_caller
value: "Ким Олег Юрьевич, телефон 916-126-34-71"
@@ -48,7 +48,7 @@ checklist:
required_fields: [address, entrance, incident_type, description]
ground_truth:
- address: "улица Берзарина, дом 21, корпус 1"
+ address: "улица Берзарина, дом 21, корпус 1, подъезд 3, домофон 68"
victims: 0
tree:
diff --git a/scenarios/tickets/t02-2-megafon-consultation.yaml b/scenarios/tickets/t02-2-megafon-consultation.yaml
index a21d3d1..11c6be3 100644
--- a/scenarios/tickets/t02-2-megafon-consultation.yaml
+++ b/scenarios/tickets/t02-2-megafon-consultation.yaml
@@ -20,17 +20,17 @@ persona:
- { stage: registration, mood: aggressive }
- { stage: interview, mood: aggressive }
-first_line: "Алло! Меня в салоне связи обманули, разбирайтесь!"
+first_line: "Я поругался с продавцом «Мегафон» и бросил трубку."
facts:
- id: f_address
value: "Сущёвский Вал, дом 5, строение 1, салон «Мегафон»"
reveal_on: { question: q_address }
- id: f_what_happened
- value: "поругался с продавцом, тот бросил трубку; угроз и рукоприкладства не было"
+ value: "заявитель поругался с продавцом и бросил трубку"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "никто не пострадал, помощь не нужна"
+ value: "сведений о пострадавших нет"
reveal_on: { question: q_victims }
- id: f_caller
value: "телефон 916 896 3254"
@@ -47,7 +47,7 @@ required_fields: []
ground_truth:
address: "Сущёвский Вал, дом 5, строение 1"
- victims: 0
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t02-3-car-in-water.yaml b/scenarios/tickets/t02-3-car-in-water.yaml
index 46c55e5..27caabd 100644
--- a/scenarios/tickets/t02-3-car-in-water.yaml
+++ b/scenarios/tickets/t02-3-car-in-water.yaml
@@ -19,19 +19,19 @@ persona:
- { stage: interview, mood: panic }
- { stage: dispatch, mood: worried }
-first_line: "Машина в воду ушла! Прямо с набережной! Там человек внутри!"
+first_line: "Автомобиль упал в воду!"
facts:
- id: f_address
value: "набережная Яузы, в сторону области"
- refined: "набережная Яузы, напротив Большого Нижнего пруда"
+ refined: "набережная Яузы, в сторону области, напротив Большого Нижнего пруда"
reveal_on: { question: q_address }
refine_on: q_address_check
- id: f_what_happened
- value: "автомашина съехала с набережной и ушла под воду"
+ value: "автомашина упала в воду"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "в машине был человек, наружу никто не выбрался"
+ value: "заявитель не знает, были ли в машине люди"
reveal_on: { question: q_victims }
- id: f_caller
value: "Иванова Елена Сергеевна, телефон 916 896 3254"
@@ -44,11 +44,11 @@ checklist:
- { id: q_victims, fact: f_victims }
- { id: q_caller, fact: f_caller }
-required_fields: [address, incident_type, description, victims_count]
+required_fields: [address, incident_type, description]
ground_truth:
- address: "набережная Яузы, напротив Большого Нижнего пруда"
- victims: 1
+ address: "набережная Яузы, в сторону области, напротив Большого Нижнего пруда"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t03-1-fire-private-house.yaml b/scenarios/tickets/t03-1-fire-private-house.yaml
index ef571a4..f8e1d2f 100644
--- a/scenarios/tickets/t03-1-fire-private-house.yaml
+++ b/scenarios/tickets/t03-1-fire-private-house.yaml
@@ -22,7 +22,7 @@ persona:
- { stage: interview, mood: worried }
- { stage: dispatch, mood: calm }
-first_line: "Здравствуйте! У нас крыша горит, дом деревянный! Приезжайте!"
+first_line: "Здравствуйте! Крыша частного дома горит!"
facts:
- id: f_address
@@ -33,14 +33,17 @@ facts:
reveal_on: { question: q_address }
refine_on: q_address_check
- id: f_what_burns
- value: "горит крыша, огонь открытый, дом деревянный"
+ value: "горит крыша частного дома"
reveal_on: { question: q_what_burns }
- id: f_people
- value: "все вышли во двор, в доме никого нет"
+ value: "пострадавших нет"
reveal_on: { question: q_people }
- id: f_gas
value: "дом не газифицирован, газа нет"
reveal_on: { question: q_gas }
+ - id: f_caller
+ value: "Иванова Инна Степановна, телефон 916-126-34-71"
+ reveal_on: { question: q_caller }
checklist:
- { id: q_address, fact: f_address }
@@ -48,6 +51,7 @@ checklist:
- { id: q_what_burns, fact: f_what_burns }
- { id: q_people, fact: f_people }
- { id: q_gas, fact: f_gas }
+ - { id: q_caller, fact: f_caller }
required_fields:
- address
diff --git a/scenarios/tickets/t03-2-loud-music.yaml b/scenarios/tickets/t03-2-loud-music.yaml
index 9646443..aed952b 100644
--- a/scenarios/tickets/t03-2-loud-music.yaml
+++ b/scenarios/tickets/t03-2-loud-music.yaml
@@ -19,20 +19,20 @@ persona:
- { stage: registration, mood: calm }
- { stage: interview, mood: calm }
-first_line: "Добрый вечер. Во дворе музыка на всю громкость, второй час уже."
+first_line: "Здравствуйте. Во дворе громко играет музыка."
facts:
- id: f_address
value: "бульвар Маршала Рокоссовского, дом 25, во дворе"
reveal_on: { question: q_address }
- id: f_what_happened
- value: "во дворе громко играет музыка из машины, компания сидит рядом"
+ value: "во дворе громко играет музыка"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "никто не пострадал, драки нет"
+ value: "не знаю, есть ли пострадавшие"
reveal_on: { question: q_victims }
- id: f_suspects
- value: "человек пять молодых людей, оружия не видно"
+ value: "не знаю, кто включил музыку и есть ли там нарушители"
reveal_on: { question: q_suspects }
- id: f_caller
value: "Соколов Иван Петрович, телефон 916 896 3254"
@@ -48,8 +48,8 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "бульвар Маршала Рокоссовского, дом 25"
- victims: 0
+ address: "бульвар Маршала Рокоссовского, дом 25, двор"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t03-3-log-blocked-driver.yaml b/scenarios/tickets/t03-3-log-blocked-driver.yaml
index 1e6a9c4..3e98c93 100644
--- a/scenarios/tickets/t03-3-log-blocked-driver.yaml
+++ b/scenarios/tickets/t03-3-log-blocked-driver.yaml
@@ -19,7 +19,7 @@ persona:
- { stage: registration, mood: panic }
- { stage: interview, mood: worried }
-first_line: "С грузовика бревно слетело, прямо на легковую! Водитель зажат, не вылезает!"
+first_line: "На машину упало бревно с грузовика, водитель заблокирован!"
facts:
- id: f_address
@@ -31,10 +31,10 @@ facts:
value: "бревно упало с грузовика на легковую машину"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "водитель заблокирован в машине, сам выйти не может"
+ value: "один водитель заблокирован в машине; его состояние не указано"
reveal_on: { question: q_victims }
- id: f_vehicle
- value: "ВАЗ красного цвета, номер не видно"
+ value: "ВАЗ красного цвета; регистрационный номер в исходной карточке не указан"
reveal_on: { question: q_vehicle }
- id: f_caller
value: "Иванова Елена Сергеевна, телефон 916 896 3254"
@@ -51,7 +51,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: "съезд с МКАД внутренней стороны на Симферопольское шоссе"
+ address: "съезд с внутренней стороны МКАД на Симферопольское шоссе в область, за 100 метров до шоссе, обочина"
victims: 1
tree:
diff --git a/scenarios/tickets/t04-1-balcony-fire.yaml b/scenarios/tickets/t04-1-balcony-fire.yaml
index b07b7e6..f0971ef 100644
--- a/scenarios/tickets/t04-1-balcony-fire.yaml
+++ b/scenarios/tickets/t04-1-balcony-fire.yaml
@@ -52,7 +52,7 @@ required_fields: [address, floor, incident_type, description]
ground_truth:
address: "улица Грина, дом 11"
- victims: 0
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t04-2-unconscious-parking.yaml b/scenarios/tickets/t04-2-unconscious-parking.yaml
index 99845a4..a914abf 100644
--- a/scenarios/tickets/t04-2-unconscious-parking.yaml
+++ b/scenarios/tickets/t04-2-unconscious-parking.yaml
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: worried }
- { stage: interview, mood: worried }
-first_line: "Женщине плохо, на парковке лежит, сознание потеряла!"
+first_line: "Женщине плохо на автомобильной парковке, сознание потеряла!"
facts:
- id: f_address
@@ -31,7 +31,7 @@ facts:
value: "женщина на вид сорока лет, имени не знаю"
reveal_on: { question: q_who }
- id: f_conscious
- value: "без сознания, дышит"
+ value: "без сознания"
reveal_on: { question: q_conscious }
- id: f_caller
value: "Иванова Ирина Петровна, телефон 916 897 5623"
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: "Московская область, Балашиха, Мирской проезд, дом 16"
+ address: "Московская область, Балашиха, Мирской проезд, дом 16, со стороны мусорной площадки"
victims: 1
tree:
diff --git a/scenarios/tickets/t04-3-open-door.yaml b/scenarios/tickets/t04-3-open-door.yaml
index fc10ceb..1ea91e4 100644
--- a/scenarios/tickets/t04-3-open-door.yaml
+++ b/scenarios/tickets/t04-3-open-door.yaml
@@ -18,14 +18,14 @@ persona:
- { stage: registration, mood: worried }
- { stage: interview, mood: worried }
-first_line: "Сосед третий день не открывает, он пожилой, инвалид, живёт один."
+first_line: "Пожилой одинокий инвалид третий день не открывает дверь в квартиру."
facts:
- id: f_address
value: "Зеленоград, 9-й микрорайон, корпус 902, квартира 45, подъезд 1, этаж 3, код 45В"
reveal_on: { question: q_address }
- id: f_what_happened
- value: "не открывает дверь третий день, на звонки не отвечает"
+ value: "не открывает дверь третий день"
reveal_on: { question: q_what_happened }
- id: f_who
value: "пожилой инвалид, живёт один"
@@ -43,8 +43,8 @@ checklist:
required_fields: [address, entrance, floor, incident_type, description]
ground_truth:
- address: "Зеленоград, 9-й микрорайон, корпус 902, квартира 45"
- victims: 1
+ address: "Зеленоград, 9-й микрорайон, корпус 902, квартира 45, подъезд 1, этаж 3, код домофона 45В"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t05-1-window-fire-people.yaml b/scenarios/tickets/t05-1-window-fire-people.yaml
index 445e58e..d82a15c 100644
--- a/scenarios/tickets/t05-1-window-fire-people.yaml
+++ b/scenarios/tickets/t05-1-window-fire-people.yaml
@@ -29,7 +29,7 @@ facts:
value: "горит одно окно на девятом этаже, открытое пламя"
reveal_on: { question: q_what_burns }
- id: f_people
- value: "на соседнем балконе двое людей, кричат о помощи"
+ value: "рядом на балконе двое людей, кричат о помощи"
reveal_on: { question: q_people }
- id: f_gas
value: "дом не газифицирован, этажность семнадцать"
@@ -48,7 +48,7 @@ checklist:
required_fields: [address, floor, incident_type, description, victims_count]
ground_truth:
- address: "улица Цюрупы, дом 12, корпус 6"
+ address: "улица Цюрупы, дом 12, корпус 6, единственный подъезд, балкон со стороны подъезда"
victims: 2
tree:
diff --git a/scenarios/tickets/t05-2-wrong-medicine.yaml b/scenarios/tickets/t05-2-wrong-medicine.yaml
index d1ac56d..dae1c48 100644
--- a/scenarios/tickets/t05-2-wrong-medicine.yaml
+++ b/scenarios/tickets/t05-2-wrong-medicine.yaml
@@ -31,7 +31,7 @@ facts:
value: "Иванова Ирина Петровна, дата рождения 10.03.1975"
reveal_on: { question: q_who }
- id: f_conscious
- value: "без сознания, дышит"
+ value: "без сознания"
reveal_on: { question: q_conscious }
- id: f_caller
value: "вызывает супруг, телефон 916 897 5623"
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, entrance, floor, incident_type, description]
ground_truth:
- address: "Коломенская набережная, дом 18, квартира 142"
+ address: "Коломенская набережная, дом 18, квартира 142, подъезд 3, этаж 4, код домофона 142"
victims: 1
tree:
diff --git a/scenarios/tickets/t05-3-worker-in-pit.yaml b/scenarios/tickets/t05-3-worker-in-pit.yaml
index 868db99..bd096e0 100644
--- a/scenarios/tickets/t05-3-worker-in-pit.yaml
+++ b/scenarios/tickets/t05-3-worker-in-pit.yaml
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: worried }
- { stage: interview, mood: worried }
-first_line: "У нас на стройке рабочий в котлован упал, сам не выбирается!"
+first_line: "На стройке жилого дома рабочий упал в котлован!"
facts:
- id: f_address
@@ -28,7 +28,7 @@ facts:
value: на стройке жилого дома рабочий упал в котлован
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "один пострадавший, в сознании, сам выбраться не может"
+ value: "один рабочий упал в котлован; его состояние неизвестно"
reveal_on: { question: q_victims }
- id: f_caller
value: "Иванова Елена Сергеевна, телефон 916 896 3254"
@@ -43,7 +43,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: "Московская область, городской округ Химки, микрорайон Подрезково"
+ address: "Московская область, городской округ Химки, микрорайон Подрезково, стройка между улицей Центральной и улицей 1-я Лесная"
victims: 1
tree:
diff --git a/scenarios/tickets/t06-1-car-fire.yaml b/scenarios/tickets/t06-1-car-fire.yaml
index b284f19..ed2a2a9 100644
--- a/scenarios/tickets/t06-1-car-fire.yaml
+++ b/scenarios/tickets/t06-1-car-fire.yaml
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: calm }
- { stage: interview, mood: calm }
-first_line: "На МКАД машина горит, я мимо еду."
+first_line: "На МКАД горит машина!"
facts:
- id: f_address
@@ -30,7 +30,7 @@ facts:
value: "горит фольксваген красного цвета, номер Х 863 МН 199"
reveal_on: { question: q_what_burns }
- id: f_people
- value: "пострадавших нет, из машины все вышли"
+ value: "пострадавших нет"
reveal_on: { question: q_people }
- id: f_caller
value: "Попов Виктор Дмитриевич, очевидец, телефон 916-123-98-78"
diff --git a/scenarios/tickets/t06-2-man-in-car.yaml b/scenarios/tickets/t06-2-man-in-car.yaml
index 6c5db14..d24d4a6 100644
--- a/scenarios/tickets/t06-2-man-in-car.yaml
+++ b/scenarios/tickets/t06-2-man-in-car.yaml
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: worried }
- { stage: interview, mood: worried }
-first_line: "Мне плохо очень, я в машине сижу, выйти не могу."
+first_line: "Здравствуйте, мне стало плохо, я нахожусь в машине."
facts:
- id: f_address
@@ -31,7 +31,7 @@ facts:
value: "Иванов Павел Сергеевич, сорок лет, вызывает себе"
reveal_on: { question: q_who }
- id: f_conscious
- value: "в сознании, говорит сам"
+ value: "сведения о сознании не указаны"
reveal_on: { question: q_conscious }
- id: f_caller
value: телефон 916 897 5623
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "улица Красный Казанец, дом 19Б"
+ address: "улица Красный Казанец, дом 19Б, напротив дома, на стоянке"
victims: 1
tree:
diff --git a/scenarios/tickets/t06-3-men-on-ice.yaml b/scenarios/tickets/t06-3-men-on-ice.yaml
index a89886c..597bfee 100644
--- a/scenarios/tickets/t06-3-men-on-ice.yaml
+++ b/scenarios/tickets/t06-3-men-on-ice.yaml
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: worried }
- { stage: interview, mood: worried }
-first_line: "Тут трое мужиков на льдине плывут, их относит!"
+first_line: "Трое мужчин плывут на льдине!"
facts:
- id: f_address
@@ -27,10 +27,10 @@ facts:
reveal_on: { question: q_address }
refine_on: q_address_check
- id: f_what_happened
- value: "трое мужчин на оторвавшейся льдине, плывут по течению"
+ value: "трое мужчин плывут на льдине"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "трое, все живы, машут руками"
+ value: "трое мужчин на льдине; их состояние неизвестно"
reveal_on: { question: q_victims }
- id: f_caller
value: "Иванова Елена Сергеевна, телефон 916 896 3254"
@@ -46,7 +46,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: "Московская область, Раменский городской округ, река Быковка"
+ address: "Московская область, Раменский городской округ, река Быковка, деревня Михнево"
victims: 3
tree:
diff --git a/scenarios/tickets/t07-1-toyota-fire.yaml b/scenarios/tickets/t07-1-toyota-fire.yaml
index b5d128c..b5a1cd6 100644
--- a/scenarios/tickets/t07-1-toyota-fire.yaml
+++ b/scenarios/tickets/t07-1-toyota-fire.yaml
@@ -24,7 +24,7 @@ first_line: "Машина горит на проезжей части, води
facts:
- id: f_address
value: "станция метро Отрадное, рядом с торговым комплексом «Золотой Вавилон», на проезжей части"
- refined: "улица Декабристов, торговый комплекс «Золотой Вавилон» справа"
+ refined: "улица Декабристов, торговый комплекс «Золотой Вавилон» справа, на проезжей части"
reveal_on: { question: q_address }
refine_on: q_address_check
- id: f_what_burns
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: "улица Декабристов, торговый комплекс «Золотой Вавилон»"
+ address: "улица Декабристов, торговый комплекс «Золотой Вавилон» справа, на проезжей части"
victims: 1
tree:
diff --git a/scenarios/tickets/t07-3-lost-elderly.yaml b/scenarios/tickets/t07-3-lost-elderly.yaml
index 638f08e..840a094 100644
--- a/scenarios/tickets/t07-3-lost-elderly.yaml
+++ b/scenarios/tickets/t07-3-lost-elderly.yaml
@@ -43,8 +43,8 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "улица Маршала Соколовского, дом 1, квартира 5"
- victims: 1
+ address: "улица Маршала Соколовского, дом 1, квартира 5, подъезд 1, этаж 2, код домофона 5В"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t08-1-mall-smoke.yaml b/scenarios/tickets/t08-1-mall-smoke.yaml
index af963de..b920d6b 100644
--- a/scenarios/tickets/t08-1-mall-smoke.yaml
+++ b/scenarios/tickets/t08-1-mall-smoke.yaml
@@ -30,10 +30,10 @@ facts:
value: "задымление на минус первом этаже, источник не установлен"
reveal_on: { question: q_what_burns }
- id: f_people
- value: "о пострадавших точной информации нет, людей выводят"
+ value: "о пострадавших точной информации нет"
reveal_on: { question: q_people }
- id: f_smoke
- value: "дым идёт по нижнему этажу, на торговых этажах пока чисто"
+ value: "задымление на минус первом этаже"
reveal_on: { question: q_smoke }
- id: f_caller
value: "Ивлев Артём Олегович, работник мебельного магазина, телефон 916-123-98-78"
@@ -51,7 +51,7 @@ required_fields: [address, incident_type, description]
ground_truth:
address: "улица Кировоградская, дом 15"
- victims: 0
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t08-2-unconscious-roadside.yaml b/scenarios/tickets/t08-2-unconscious-roadside.yaml
index 9f729c2..b64923e 100644
--- a/scenarios/tickets/t08-2-unconscious-roadside.yaml
+++ b/scenarios/tickets/t08-2-unconscious-roadside.yaml
@@ -31,7 +31,7 @@ facts:
value: "мужчина неизвестный, около пятидесяти лет"
reveal_on: { question: q_who }
- id: f_conscious
- value: "без сознания, дышит"
+ value: "без сознания"
reveal_on: { question: q_conscious }
- id: f_caller
value: "Соколов Иван Петрович, прохожий, телефон 916 896 3254"
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: дорога от Киевского шоссе в сторону Минского через Крекшино
+ address: дорога от Киевского шоссе в сторону Минского через Крекшино, примерно 1,5 км от железнодорожного переезда, напротив пруда
victims: 1
tree:
diff --git a/scenarios/tickets/t08-3-drowning.yaml b/scenarios/tickets/t08-3-drowning.yaml
index 438b64b..e4bdb0a 100644
--- a/scenarios/tickets/t08-3-drowning.yaml
+++ b/scenarios/tickets/t08-3-drowning.yaml
@@ -29,7 +29,7 @@ facts:
value: "мужчина тонет в настоящее время, кричит о помощи"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "один человек в воде, на поверхности"
+ value: "один человек в воде"
reveal_on: { question: q_victims }
- id: f_caller
value: "Иванова Елена Сергеевна, телефон 916 896 3254"
@@ -44,7 +44,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: "озеро Круглое, пляж"
+ address: "озеро Круглое, пляж, рядом с домом по улице Таманской, дом 75, корпус 2"
victims: 1
tree:
diff --git a/scenarios/tickets/t09-1-platform-smoke.yaml b/scenarios/tickets/t09-1-platform-smoke.yaml
index ab9d9bf..072d758 100644
--- a/scenarios/tickets/t09-1-platform-smoke.yaml
+++ b/scenarios/tickets/t09-1-platform-smoke.yaml
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: worried }
- { stage: interview, mood: worried }
-first_line: "На платформе дымом пахнет, дым идёт."
+first_line: "На платформе задымление."
facts:
- id: f_address
@@ -27,10 +27,10 @@ facts:
reveal_on: { question: q_address }
refine_on: q_address_check
- id: f_what_burns
- value: "задымление на платформе, открытого пламени нет"
+ value: "задымление на платформе"
reveal_on: { question: q_what_burns }
- id: f_people
- value: "пострадавших нет, людей выводят"
+ value: "пострадавших людей нет"
reveal_on: { question: q_people }
- id: f_caller
value: "Тихий Олег Юрьевич, телефон 916-123-98-78"
diff --git a/scenarios/tickets/t09-2-labour.yaml b/scenarios/tickets/t09-2-labour.yaml
index fc7ad96..ce441b5 100644
--- a/scenarios/tickets/t09-2-labour.yaml
+++ b/scenarios/tickets/t09-2-labour.yaml
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: worried }
- { stage: interview, mood: worried }
-first_line: "У жены воды отошли, тридцать девять недель, мы в дороге!"
+first_line: "У жены отошли воды, срок 39 недель!"
facts:
- id: f_address
@@ -31,7 +31,7 @@ facts:
value: "Минина Ольга Петровна, 36 лет"
reveal_on: { question: q_who }
- id: f_conscious
- value: "в сознании, схватки начались"
+ value: "сведения о сознании не указаны"
reveal_on: { question: q_conscious }
- id: f_caller
value: "вызывает супруг Минин Сергей Антонович, телефон 916 897 5623"
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "дорога от Носовихинского шоссе в сторону Горьковского, остановка «Кафе»"
+ address: "дорога от Носовихинского шоссе, от железнодорожной станции Купавна в сторону Горьковского шоссе (М7), напротив Бисерова озера, автобусная остановка «Кафе»"
victims: 1
tree:
diff --git a/scenarios/tickets/t09-3-fell-from-bridge.yaml b/scenarios/tickets/t09-3-fell-from-bridge.yaml
index e4330ed..a3b5fd5 100644
--- a/scenarios/tickets/t09-3-fell-from-bridge.yaml
+++ b/scenarios/tickets/t09-3-fell-from-bridge.yaml
@@ -28,7 +28,7 @@ facts:
value: "мужчина упал с моста в воду, плавать не умеет"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "один человек в воде, на поверхности, кричит"
+ value: "один человек в воде, кричит"
reveal_on: { question: q_victims }
- id: f_caller
value: "Иванова Елена Сергеевна, телефон 916 896 3254"
@@ -43,7 +43,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: "Бережковский мост, ТТК, внутренняя сторона"
+ address: "Бережковский мост, ТТК, внутренняя сторона, середина моста"
victims: 1
tree:
diff --git a/scenarios/tickets/t10-1-ticket-office-fire.yaml b/scenarios/tickets/t10-1-ticket-office-fire.yaml
index 811df68..790cf19 100644
--- a/scenarios/tickets/t10-1-ticket-office-fire.yaml
+++ b/scenarios/tickets/t10-1-ticket-office-fire.yaml
@@ -44,7 +44,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: железнодорожная станция Текстильщики
+ address: "железнодорожная станция Текстильщики, платформа в сторону области, подъезд для служб со стороны улицы Люблинской"
victims: 1
tree:
diff --git a/scenarios/tickets/t10-2-elderly-crossroads.yaml b/scenarios/tickets/t10-2-elderly-crossroads.yaml
index 2f5b5d2..f3b8f24 100644
--- a/scenarios/tickets/t10-2-elderly-crossroads.yaml
+++ b/scenarios/tickets/t10-2-elderly-crossroads.yaml
@@ -31,7 +31,7 @@ facts:
value: "женщина неизвестная, около семидесяти лет"
reveal_on: { question: q_who }
- id: f_conscious
- value: "в сознании, на ногах"
+ value: "сведения о сознании не указаны"
reveal_on: { question: q_conscious }
- id: f_caller
value: "Соколов Иван Петрович, прохожий, телефон 916 897 5623"
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: Люберецкие поля фильтрации
+ address: "Люберецкие поля фильтрации, пересечение дороги из Кожухова в Железнодорожный и дороги из Некрасовки в Железнодорожный"
victims: 1
tree:
diff --git a/scenarios/tickets/t10-3-help-behind-door.yaml b/scenarios/tickets/t10-3-help-behind-door.yaml
index aa51b25..2823573 100644
--- a/scenarios/tickets/t10-3-help-behind-door.yaml
+++ b/scenarios/tickets/t10-3-help-behind-door.yaml
@@ -31,7 +31,7 @@ facts:
value: "женщина за дверью, имени не знаю"
reveal_on: { question: q_who }
- id: f_conscious
- value: "отвечает, говорит"
+ value: "женщина просит о помощи из-за двери; её состояние не видно"
reveal_on: { question: q_conscious }
- id: f_caller
value: "Иванова Елена Сергеевна, соседка, телефон 916 897 5623; на месте сотрудники полиции"
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, entrance, floor, incident_type, description]
ground_truth:
- address: "проспект Маршала Жукова, дом 20, корпус 2, квартира 57"
+ address: "проспект Маршала Жукова, дом 20, корпус 2, квартира 57, подъезд 3, этаж 5, код домофона 57В"
victims: 1
tree:
diff --git a/scenarios/tickets/t11-1-bus-cabin-fire.yaml b/scenarios/tickets/t11-1-bus-cabin-fire.yaml
index 73574fb..6dbab0c 100644
--- a/scenarios/tickets/t11-1-bus-cabin-fire.yaml
+++ b/scenarios/tickets/t11-1-bus-cabin-fire.yaml
@@ -23,14 +23,14 @@ first_line: "Автобус горит, кабина занялась!"
facts:
- id: f_address
value: "Дмитровское шоссе в сторону области, рядом с автобусной остановкой «Лианозовский электромеханический завод»"
- refined: "Дмитровское шоссе, дом 110"
+ refined: "Дмитровское шоссе в сторону области, остановка «Лианозовский электромеханический завод», уточнение — дом 110"
reveal_on: { question: q_address }
refine_on: q_address_check
- id: f_what_burns
value: "горит кабина автобуса, маршрут 63, бортовой номер 15477, государственный знак назвать не могу"
reveal_on: { question: q_what_burns }
- id: f_people
- value: "пострадавших нет, пассажиры вышли"
+ value: "пострадавших нет"
reveal_on: { question: q_people }
- id: f_caller
value: "Титов Павел Петрович, очевидец, телефон 916-123-98-78"
@@ -46,7 +46,7 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "Дмитровское шоссе, дом 110"
+ address: "Дмитровское шоссе в сторону области, остановка «Лианозовский электромеханический завод», уточнение — дом 110"
victims: 0
tree:
diff --git a/scenarios/tickets/t11-2-dizziness.yaml b/scenarios/tickets/t11-2-dizziness.yaml
index 1657aa6..3314700 100644
--- a/scenarios/tickets/t11-2-dizziness.yaml
+++ b/scenarios/tickets/t11-2-dizziness.yaml
@@ -31,7 +31,7 @@ facts:
value: "Легкодушев Дмитрий Константинович, дата рождения 31.05.2011"
reveal_on: { question: q_who }
- id: f_conscious
- value: "сознание теряет, но приходит в себя"
+ value: "теряет сознание; не знаю, приходит ли в себя"
reveal_on: { question: q_conscious }
- id: f_caller
value: "вызывает отец, Максимов Олег Иванович, телефон 916 897 5623"
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "улица Беломорская, дом 10, корпус 2"
+ address: "улица Беломорская, дом 10, корпус 2, на дворовой дороге"
victims: 1
tree:
diff --git a/scenarios/tickets/t11-3-lost-in-forest.yaml b/scenarios/tickets/t11-3-lost-in-forest.yaml
index 0cfe1b0..5feb473 100644
--- a/scenarios/tickets/t11-3-lost-in-forest.yaml
+++ b/scenarios/tickets/t11-3-lost-in-forest.yaml
@@ -31,7 +31,7 @@ facts:
value: "собирала грибы, заблудилась, скорая не требуется"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "Тимофеева Маргарита Михайловна, 63 года, одна"
+ value: "заявительница сообщает, что 03 не требуется; сведения о пострадавших не указаны"
reveal_on: { question: q_victims }
- id: f_caller
value: "звонит сама, телефон 916 896 3254, зарядка десять процентов"
@@ -47,8 +47,10 @@ checklist:
required_fields: []
ground_truth:
- address: "Владимирская область, Кольчугинский район, деревня Барыкино"
- victims: 1
+ address: "Владимирская область, Кольчугинский район, лес за деревней Барыкино, не доезжая посёлка Бавлены"
+ # В карточке ДДС отражаются пострадавшие, а не число разыскиваемых людей:
+ # в исходном билете явно указано «03 не треб.».
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t12-1-restaurant-smoke.yaml b/scenarios/tickets/t12-1-restaurant-smoke.yaml
index ed86322..d9da7fd 100644
--- a/scenarios/tickets/t12-1-restaurant-smoke.yaml
+++ b/scenarios/tickets/t12-1-restaurant-smoke.yaml
@@ -23,14 +23,14 @@ first_line: "У нас в зале дым повалил, сильный!"
facts:
- id: f_address
value: "Московская область, Рублёво-Успенское шоссе, деревня Жуковка, ресторан «Веранда у Дачи»"
- refined: "Московская область, Рублёво-Успенское шоссе, деревня Жуковка, дом 70"
+ refined: "Московская область, Рублёво-Успенское шоссе, деревня Жуковка, ресторан «Веранда у Дачи», дом 70"
reveal_on: { question: q_address }
refine_on: q_address_check
- id: f_what_burns
- value: "сильное задымление в зале, открытого пламени не видно"
+ value: "сильное задымление в зале"
reveal_on: { question: q_what_burns }
- id: f_people
- value: "о пострадавших точной информации нет, гостей выводят"
+ value: "о пострадавших точной информации нет"
reveal_on: { question: q_people }
- id: f_caller
value: "Никитин Илья Вадимович, посетитель, телефон 916-123-98-78"
@@ -46,8 +46,8 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "Московская область, Рублёво-Успенское шоссе, деревня Жуковка, дом 70"
- victims: 0
+ address: "Московская область, Рублёво-Успенское шоссе, деревня Жуковка, ресторан «Веранда у Дачи», дом 70"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t12-2-heart-pain.yaml b/scenarios/tickets/t12-2-heart-pain.yaml
index 2a662ad..dc780c3 100644
--- a/scenarios/tickets/t12-2-heart-pain.yaml
+++ b/scenarios/tickets/t12-2-heart-pain.yaml
@@ -24,17 +24,17 @@ first_line: "Подруге плохо с сердцем, мы за городо
facts:
- id: f_address
value: "просёлочная дорога от посёлка Октябрьский в сторону посёлка Троицкий, рядом с плотиной реки Шиворонь, у водокачки"
- refined: "Тульская область, Киреевский район, просёлочная дорога от посёлка Октябрьский в сторону Троицкого"
+ refined: "Тульская область, Киреевский район, просёлочная дорога от посёлка Октябрьский в сторону посёлка Троицкий, у плотины реки Шиворонь и водокачки"
reveal_on: { question: q_address }
refine_on: q_address_check
- id: f_what_happened
value: сильные боли в сердце
reveal_on: { question: q_what_happened }
- id: f_who
- value: "Сергеева Наталья Петровна, 55 лет"
+ value: "Сергеева Наталья Петрова, 55 лет"
reveal_on: { question: q_who }
- id: f_conscious
- value: в сознании
+ value: "сведения о сознании женщины не указаны"
reveal_on: { question: q_conscious }
- id: f_caller
value: "вызывает подруга Петрова Ирина Сергеевна, телефон 916 897 5623"
@@ -51,7 +51,7 @@ checklist:
required_fields: []
ground_truth:
- address: "Тульская область, Киреевский район"
+ address: "Тульская область, Киреевский район, просёлочная дорога от посёлка Октябрьский в сторону посёлка Троицкий, у плотины реки Шиворонь и водокачки"
victims: 1
tree:
diff --git a/scenarios/tickets/t12-3-men-on-bridge-rail.yaml b/scenarios/tickets/t12-3-men-on-bridge-rail.yaml
index 53e7a4d..14b962e 100644
--- a/scenarios/tickets/t12-3-men-on-bridge-rail.yaml
+++ b/scenarios/tickets/t12-3-men-on-bridge-rail.yaml
@@ -18,19 +18,19 @@ persona:
- { stage: registration, mood: worried }
- { stage: interview, mood: worried }
-first_line: "На ограждении моста мужчины, в крови оба!"
+first_line: "На ограждении моста мужчины, они в крови!"
facts:
- id: f_address
value: "над МКАД между улицей Свободы и АЗС SHELL, внешняя сторона"
- refined: "МКАД, 73 километр, внешняя сторона"
+ refined: "над МКАД между улицей Свободы и АЗС SHELL, 73 километр, внешняя сторона"
reveal_on: { question: q_address }
refine_on: q_address_check
- id: f_what_happened
- value: "двое мужчин на ограждении моста, в крови"
+ value: "мужчины на ограждении моста, в крови"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "двое, в сознании, держатся за ограждение"
+ value: "точное число неизвестно; мужчин как минимум двое, состояние пострадавших не указано"
reveal_on: { question: q_victims }
- id: f_caller
value: "Иванова Елена Сергеевна, телефон 916 896 3254"
@@ -43,11 +43,11 @@ checklist:
- { id: q_victims, fact: f_victims }
- { id: q_caller, fact: f_caller }
-required_fields: [address, incident_type, description, victims_count]
+required_fields: [address, incident_type, description]
ground_truth:
- address: "МКАД, 73 километр, внешняя сторона"
- victims: 2
+ address: "над МКАД между улицей Свободы и АЗС SHELL, 73 километр, внешняя сторона"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t13-1-park-trees-fire.yaml b/scenarios/tickets/t13-1-park-trees-fire.yaml
index c9f1835..e556db6 100644
--- a/scenarios/tickets/t13-1-park-trees-fire.yaml
+++ b/scenarios/tickets/t13-1-park-trees-fire.yaml
@@ -46,7 +46,7 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "Кусковский лесопарк, рядом со стадионом Фрезер"
+ address: "Кусковский лесопарк, рядом со стадионом Фрезер, вход со стороны аллеи Первой Маёвки"
victims: 0
tree:
diff --git a/scenarios/tickets/t13-2-birth-in-car.yaml b/scenarios/tickets/t13-2-birth-in-car.yaml
index fd05c8f..275f775 100644
--- a/scenarios/tickets/t13-2-birth-in-car.yaml
+++ b/scenarios/tickets/t13-2-birth-in-car.yaml
@@ -30,8 +30,11 @@ facts:
- id: f_who
value: "Соколова Ирина Ивановна, дата рождения 20.05.1979"
reveal_on: { question: q_who }
+ - id: f_vehicle
+ value: "синий Volkswagen, государственный номер О345ВН 177"
+ reveal_on: { question: q_vehicle }
- id: f_conscious
- value: "в сознании, в автомобиле фольксваген синий О345ВН 177"
+ value: "сведения о сознании роженицы не указаны"
reveal_on: { question: q_conscious }
- id: f_caller
value: телефон 916 897 5623
@@ -41,13 +44,17 @@ checklist:
- { id: q_address, fact: f_address }
- { id: q_what_happened, fact: f_what_happened }
- { id: q_who, fact: f_who }
+ - id: q_vehicle
+ question: "На какой машине вы едете? Назовите марку, цвет и госномер."
+ examples: ["Какой у вас автомобиль и госномер?", "Назовите марку, цвет машины и номер."]
+ fact: f_vehicle
- { id: q_conscious, fact: f_conscious }
- { id: q_caller, fact: f_caller }
required_fields: [address, incident_type, description]
ground_truth:
- address: Рублёво-Успенское шоссе за рекой Чеченка
+ address: "Рублёвка от Кутузовского проспекта в сторону посёлка Жуковка, после реки Чеченка, перед автобусной остановкой"
victims: 1
tree:
diff --git a/scenarios/tickets/t13-3-man-on-roadside.yaml b/scenarios/tickets/t13-3-man-on-roadside.yaml
index e579e65..1e1f580 100644
--- a/scenarios/tickets/t13-3-man-on-roadside.yaml
+++ b/scenarios/tickets/t13-3-man-on-roadside.yaml
@@ -18,12 +18,12 @@ persona:
- { stage: registration, mood: worried }
- { stage: interview, mood: worried }
-first_line: "На обочине мужчина лежит, весь в крови."
+first_line: "На обочине лежит мужчина в крови."
facts:
- id: f_address
value: "МКАД, внутренняя сторона, поворот на Гольяново"
- refined: "МКАД, 103 километр, внутренняя сторона"
+ refined: "МКАД, внутренняя сторона, поворот на Гольяново, 103 километр"
reveal_on: { question: q_address }
refine_on: q_address_check
- id: f_what_happened
@@ -46,7 +46,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: "МКАД, 103 километр, внутренняя сторона"
+ address: "МКАД, внутренняя сторона, поворот на Гольяново, 103 километр"
victims: 1
tree:
diff --git a/scenarios/tickets/t14-1-grass-fire-azs.yaml b/scenarios/tickets/t14-1-grass-fire-azs.yaml
index a0f24c1..64815eb 100644
--- a/scenarios/tickets/t14-1-grass-fire-azs.yaml
+++ b/scenarios/tickets/t14-1-grass-fire-azs.yaml
@@ -23,14 +23,14 @@ first_line: "Трава горит, огонь к заправке подход
facts:
- id: f_address
value: "Московская область, трасса М-2, АЗС Роснефть, не доезжая до Расторгуевского шоссе"
- refined: "Московская область, трасса М-2, 25 километр по столбам в сторону Москвы, владение 2"
+ refined: "Московская область, трасса М-2, 25 км по столбам в сторону Москвы, владение 2"
reveal_on: { question: q_address }
refine_on: q_address_check
- id: f_what_burns
value: "горит сухая трава, огонь подходит к территории АЗС"
reveal_on: { question: q_what_burns }
- id: f_people
- value: "пострадавших нет, работников выводим"
+ value: "сведения о пострадавших не указаны"
reveal_on: { question: q_people }
- id: f_caller
value: "Потапов Иван Сергеевич, работник АЗС, телефон 916-123-98-78"
@@ -46,8 +46,8 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "Московская область, трасса М-2, 25 километр, владение 2"
- victims: 0
+ address: "Московская область, трасса М-2, 25 км по столбам в сторону Москвы, владение 2"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t14-2-asthma.yaml b/scenarios/tickets/t14-2-asthma.yaml
index b01ac82..b3629ba 100644
--- a/scenarios/tickets/t14-2-asthma.yaml
+++ b/scenarios/tickets/t14-2-asthma.yaml
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: panic }
- { stage: interview, mood: worried }
-first_line: "Брат задыхается, у него астма! Ингалятор не помогает!"
+first_line: "Брат задыхается, у него астма!"
facts:
- id: f_address
@@ -31,7 +31,7 @@ facts:
value: "Ковалев Иван Сергеевич, 12 лет"
reveal_on: { question: q_who }
- id: f_conscious
- value: "в сознании, дышит тяжело"
+ value: "у подростка приступ астмы и затруднённое дыхание; сведения о сознании не указаны"
reveal_on: { question: q_conscious }
- id: f_caller
value: "вызывает брат, ему 19 лет, телефон 916 897 5623"
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, entrance, floor, incident_type, description]
ground_truth:
- address: "Ленинградское шоссе, дом 112, корпус 4, квартира 385"
+ address: "Ленинградское шоссе, дом 112, корпус 4, квартира 385, подъезд 29, этаж 3, код домофона 385В"
victims: 1
tree:
diff --git a/scenarios/tickets/t14-3-abduction.yaml b/scenarios/tickets/t14-3-abduction.yaml
index 6059d88..5344632 100644
--- a/scenarios/tickets/t14-3-abduction.yaml
+++ b/scenarios/tickets/t14-3-abduction.yaml
@@ -51,7 +51,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: "Комсомольская площадь, дом 2А"
+ address: "Комсомольская площадь, дом 2А, у «Бургер Кинга»"
victims: 1
tree:
diff --git a/scenarios/tickets/t15-2-dived-head-injury.yaml b/scenarios/tickets/t15-2-dived-head-injury.yaml
index ae1ff05..2bf4ab6 100644
--- a/scenarios/tickets/t15-2-dived-head-injury.yaml
+++ b/scenarios/tickets/t15-2-dived-head-injury.yaml
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: "Московская область, Одинцовский район, деревня Сколково, дом 17"
+ address: "Московская область, Одинцовский район, деревня Сколково, дом 17, пруд справа от дома"
victims: 1
tree:
diff --git a/scenarios/tickets/t15-3-knife-wound.yaml b/scenarios/tickets/t15-3-knife-wound.yaml
index a494d94..ce6033b 100644
--- a/scenarios/tickets/t15-3-knife-wound.yaml
+++ b/scenarios/tickets/t15-3-knife-wound.yaml
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: panic }
- { stage: interview, mood: worried }
-first_line: "Меня сожитель ножом в ногу ударил, кровь идёт!"
+first_line: "Сожитель ударил ножом в ногу, идёт кровь!"
facts:
- id: f_address
@@ -28,10 +28,10 @@ facts:
value: "сожитель ударил ножом в ногу, кровотечение"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "одна пострадавшая, в сознании"
+ value: "одна пострадавшая, кровотечение; сведения о сознании не указаны"
reveal_on: { question: q_victims }
- id: f_suspects
- value: "сожитель, находится в квартире"
+ value: "сожитель; его местонахождение не указано"
reveal_on: { question: q_suspects }
- id: f_caller
value: "Иванова Елена Сергеевна, телефон 916 896 3254"
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, entrance, floor, incident_type, description, victims_count]
ground_truth:
- address: "улица Зверенецкая, дом 22, квартира 7"
+ address: "улица Зверенецкая, дом 22, квартира 7, подъезд 1, этаж 2, код домофона 7В"
victims: 1
tree:
diff --git a/scenarios/tickets/t16-1-smoke-column.yaml b/scenarios/tickets/t16-1-smoke-column.yaml
index 3f95839..973db0b 100644
--- a/scenarios/tickets/t16-1-smoke-column.yaml
+++ b/scenarios/tickets/t16-1-smoke-column.yaml
@@ -18,17 +18,17 @@ persona:
- { stage: registration, mood: calm }
- { stage: interview, mood: calm }
-first_line: "Вижу столб чёрного дыма, со стороны посёлка тянет."
+first_line: "Со стороны жилых домов посёлка вижу столб чёрного дыма."
facts:
- id: f_address
value: "Московская область, железнодорожная станция Вялки, смотрю в сторону посёлка Быково"
reveal_on: { question: q_address }
- id: f_what_burns
- value: "что именно горит, не знаю, вижу столб чёрного дыма со стороны жилых домов посёлка"
+ value: "вижу столб чёрного дыма со стороны жилых домов посёлка; что именно происходит, не знаю"
reveal_on: { question: q_what_burns }
- id: f_people
- value: "информации о пострадавших нет, я далеко"
+ value: "информации о пострадавших нет"
reveal_on: { question: q_people }
- id: f_caller
value: "Левин Борис Степанович, телефон 916-123-98-78"
@@ -43,8 +43,8 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "Московская область, посёлок Быково"
- victims: 0
+ address: "Московская область, железнодорожная станция Вялки, направление на посёлок Быково"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t16-2-child-bicycle-volzhsky.yaml b/scenarios/tickets/t16-2-child-bicycle-volzhsky.yaml
index 7a85dd4..30fd7b3 100644
--- a/scenarios/tickets/t16-2-child-bicycle-volzhsky.yaml
+++ b/scenarios/tickets/t16-2-child-bicycle-volzhsky.yaml
@@ -34,7 +34,7 @@ facts:
value: "Смирнов Илья, 11 лет"
reveal_on: { question: q_who }
- id: f_conscious
- value: "в сознании, плачет"
+ value: "сведения о сознании ребёнка не указаны"
reveal_on: { question: q_conscious }
- id: f_caller
value: "звонит мама, телефон 9163201283"
@@ -51,7 +51,7 @@ checklist:
required_fields: []
ground_truth:
- address: "Волгоградская область, город Волжский, улица Карла Маркса"
+ address: "Волгоградская область, город Волжский, улица Карла Маркса, около Волжского Молсыркомбината"
victims: 1
tree:
diff --git a/scenarios/tickets/t16-3-sleeping-pills.yaml b/scenarios/tickets/t16-3-sleeping-pills.yaml
index 9512d4b..f1356fc 100644
--- a/scenarios/tickets/t16-3-sleeping-pills.yaml
+++ b/scenarios/tickets/t16-3-sleeping-pills.yaml
@@ -10,7 +10,8 @@ topics: [medical]
modes: [training, exam, self]
extends: checklists/medical.yaml
-signs: [Суицид, Выпил таблетки]
+# В билете указан приём целой упаковки лекарства, но не намерение; не выводим суицид.
+signs: [Отравление]
persona:
base: panic
@@ -18,20 +19,20 @@ persona:
- { stage: registration, mood: panic }
- { stage: interview, mood: worried }
-first_line: "Жена упаковку снотворного выпила! Засыпает уже!"
+first_line: "Жена выпила упаковку снотворного!"
facts:
- id: f_address
value: "улица Знаменские Садки, дом 7, корпус 2, квартира 216, подъезд 4, этаж 4, код 216"
reveal_on: { question: q_address }
- id: f_what_happened
- value: выпила упаковку снотворного «Донормил»
+ value: "приняла упаковку снотворного «Донормил»; намерение не указано"
reveal_on: { question: q_what_happened }
- id: f_who
value: "Петрова Светлана Павловна, 35 лет"
reveal_on: { question: q_who }
- id: f_conscious
- value: "в сознании, засыпает"
+ value: "сведения о сознании не указаны"
reveal_on: { question: q_conscious }
- id: f_caller
value: "вызывает муж, Петров Олег, телефон 916 896 3254"
@@ -47,7 +48,7 @@ checklist:
required_fields: [address, entrance, floor, incident_type, description, victims_count]
ground_truth:
- address: "улица Знаменские Садки, дом 7, корпус 2, квартира 216"
+ address: "улица Знаменские Садки, дом 7, корпус 2, квартира 216, подъезд 4, этаж 4, код домофона 216"
victims: 1
tree:
diff --git a/scenarios/tickets/t17-1-fire-alarm.yaml b/scenarios/tickets/t17-1-fire-alarm.yaml
index 76aa7c0..879b44d 100644
--- a/scenarios/tickets/t17-1-fire-alarm.yaml
+++ b/scenarios/tickets/t17-1-fire-alarm.yaml
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: calm }
- { stage: interview, mood: calm }
-first_line: "У нас пожарная сигнализация сработала, а дыма нет."
+first_line: "В жилом доме сработала пожарная сигнализация, дыма и возгорания нет."
facts:
- id: f_address
@@ -28,7 +28,7 @@ facts:
value: "сигнализация сработала, дыма и возгорания не видно"
reveal_on: { question: q_what_burns }
- id: f_people
- value: пострадавших нет
+ value: сведения о пострадавших не указаны
reveal_on: { question: q_people }
- id: f_caller
value: "Сухов Леонид Сергеевич, телефон 916-123-98-78"
@@ -43,8 +43,8 @@ checklist:
required_fields: [address, entrance, incident_type, description]
ground_truth:
- address: "Большой Сухаревский переулок, дом 19, строение 2"
- victims: 0
+ address: "Большой Сухаревский переулок, дом 19, строение 2, подъезд 1, код домофона 2215"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t17-2-seizure-in-shop.yaml b/scenarios/tickets/t17-2-seizure-in-shop.yaml
index a5b085d..87c8a13 100644
--- a/scenarios/tickets/t17-2-seizure-in-shop.yaml
+++ b/scenarios/tickets/t17-2-seizure-in-shop.yaml
@@ -12,6 +12,9 @@ modes: [training, exam, self]
extends: checklists/medical.yaml
signs: [Судороги]
+# Адресное сокращение «вл2к1,2» оставлено буквально: источник не размечает,
+# какие именно компоненты адреса разделяют цифры.
+
persona:
base: worried
arc:
@@ -22,7 +25,7 @@ first_line: "У покупателя судороги, пена изо рта,
facts:
- id: f_address
- value: "улица Твардовского, владение 2, корпус 1, строение 2, магазин «Лента», у касс"
+ value: "Москва, улица Твардовского, вл2к1,2, магазин «Лента», у касс"
reveal_on: { question: q_address }
- id: f_what_happened
value: "судороги, пена изо рта, при падении разбил голову"
@@ -31,7 +34,7 @@ facts:
value: "мужчина 40–45 лет, имени не знаю"
reveal_on: { question: q_who }
- id: f_conscious
- value: "без сознания, судороги"
+ value: "сведения о сознании не указаны; у мужчины судороги и пена изо рта"
reveal_on: { question: q_conscious }
- id: f_caller
value: "Соколова Вера Ивановна, кассир магазина, телефон 9163201283"
@@ -47,7 +50,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: "улица Твардовского, владение 2, корпус 1, магазин «Лента»"
+ address: "Москва, улица Твардовского, вл2к1,2, магазин «Лента», у касс"
victims: 1
tree:
diff --git a/scenarios/tickets/t17-3-beaten-woman.yaml b/scenarios/tickets/t17-3-beaten-woman.yaml
index 3d064b9..062007a 100644
--- a/scenarios/tickets/t17-3-beaten-woman.yaml
+++ b/scenarios/tickets/t17-3-beaten-woman.yaml
@@ -31,7 +31,7 @@ facts:
value: "одна пострадавшая, около тридцати лет, неизвестная"
reveal_on: { question: q_victims }
- id: f_suspects
- value: "нападавших не видел, их уже нет"
+ value: "сведения о нападавших не указаны"
reveal_on: { question: q_suspects }
- id: f_caller
value: "Иванова Елена Сергеевна, прохожий, телефон 916 896 3254"
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: "Дмитровское шоссе, дом 155, корпус 1"
+ address: "Дмитровское шоссе, дом 155, корпус 1, около магазина «Пятёрочка»"
victims: 1
tree:
diff --git a/scenarios/tickets/t18-1-unknown-fire.yaml b/scenarios/tickets/t18-1-unknown-fire.yaml
index c58075f..efef88a 100644
--- a/scenarios/tickets/t18-1-unknown-fire.yaml
+++ b/scenarios/tickets/t18-1-unknown-fire.yaml
@@ -18,14 +18,14 @@ persona:
- { stage: registration, mood: worried }
- { stage: interview, mood: worried }
-first_line: "Пожар вижу, а что горит — не пойму, я на дороге."
+first_line: "Пожар вижу, а что именно горит — не знаю."
facts:
- id: f_address
value: "дорога от Киевского шоссе в сторону Минского через Крекшино, примерно полтора километра от железнодорожного переезда, напротив пруда"
reveal_on: { question: q_address }
- id: f_what_burns
- value: "что горит, не знаю, вижу открытое пламя в стороне"
+ value: "вижу пожар в стороне, что именно горит — не знаю"
reveal_on: { question: q_what_burns }
- id: f_people
value: информации о пострадавших нет
@@ -43,8 +43,8 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: дорога от Киевского шоссе в сторону Минского через Крекшино
- victims: 0
+ address: "дорога от Киевского шоссе (М3) в сторону Минского шоссе (М1) через Крекшино, примерно 1,5 км от железнодорожного переезда, напротив пруда"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t18-2-husband-wont-wake.yaml b/scenarios/tickets/t18-2-husband-wont-wake.yaml
index 71da136..f17edcf 100644
--- a/scenarios/tickets/t18-2-husband-wont-wake.yaml
+++ b/scenarios/tickets/t18-2-husband-wont-wake.yaml
@@ -31,7 +31,7 @@ facts:
value: "Титов Александр Петрович, дата рождения 10.08.1977"
reveal_on: { question: q_who }
- id: f_conscious
- value: "без сознания, дышит с хрипами"
+ value: "не удаётся разбудить, слышны хрипы; состояние сознания в источнике не уточнено"
reveal_on: { question: q_conscious }
- id: f_caller
value: телефон 916 896 3254
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, entrance, floor, incident_type, description]
ground_truth:
- address: "Московская область, Красногорск, улица Пионерская, дом 19, квартира 5"
+ address: "Московская область, Красногорск, улица Пионерская, дом 19, квартира 5, подъезд 1, этаж 2, код домофона 5В"
victims: 1
tree:
diff --git a/scenarios/tickets/t18-3-cries-for-help.yaml b/scenarios/tickets/t18-3-cries-for-help.yaml
index 561e544..fe05a02 100644
--- a/scenarios/tickets/t18-3-cries-for-help.yaml
+++ b/scenarios/tickets/t18-3-cries-for-help.yaml
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: worried }
- { stage: interview, mood: worried }
-first_line: "Со двора мужчина кричит о помощи, а что там — не вижу."
+first_line: "С улицы слышны крики, мужчина зовёт на помощь."
facts:
- id: f_address
@@ -28,7 +28,7 @@ facts:
value: "с улицы слышны крики о помощи, кричит мужчина"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "сколько человек и что с ними, не знаю"
+ value: "один мужчина кричит о помощи; причина неизвестна"
reveal_on: { question: q_victims }
- id: f_caller
value: "Иванова Елена Сергеевна, телефон 916 896 3254"
@@ -43,7 +43,7 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "Огородный проезд, дом 12"
+ address: "Огородный проезд, дом 12, во дворе"
victims: 1
tree:
diff --git a/scenarios/tickets/t19-1-field-fire.yaml b/scenarios/tickets/t19-1-field-fire.yaml
index c2ba55d..441ea58 100644
--- a/scenarios/tickets/t19-1-field-fire.yaml
+++ b/scenarios/tickets/t19-1-field-fire.yaml
@@ -24,7 +24,7 @@ first_line: "Поле горит, между заводом и путями."
facts:
- id: f_address
value: "дорога от Киреевска в сторону посёлка Октябрьский, между заводом ЗОК и железнодорожными путями, 500 метров от автобусной остановки"
- refined: "Тульская область, дорога от Киреевска в сторону посёлка Октябрьский"
+ refined: "Тульская область, дорога от Киреевска в сторону посёлка Октябрьский, между заводом ЗОК и железнодорожными путями, 500 метров от автобусной остановки"
reveal_on: { question: q_address }
refine_on: q_address_check
- id: f_what_burns
@@ -47,7 +47,7 @@ checklist:
required_fields: []
ground_truth:
- address: "Тульская область, дорога от Киреевска в сторону посёлка Октябрьский"
+ address: "Тульская область, дорога от Киреевска в сторону посёлка Октябрьский, между заводом ЗОК и железнодорожными путями, 500 метров от автобусной остановки"
victims: 0
tree:
diff --git a/scenarios/tickets/t19-2-snake-bite.yaml b/scenarios/tickets/t19-2-snake-bite.yaml
index f1630e0..8c44656 100644
--- a/scenarios/tickets/t19-2-snake-bite.yaml
+++ b/scenarios/tickets/t19-2-snake-bite.yaml
@@ -51,7 +51,7 @@ checklist:
required_fields: []
ground_truth:
- address: "Рязанская область, Свято-Иоанно-Богословский мужской монастырь"
+ address: "Рязанская область, Свято-Иоанно-Богословский мужской монастырь, у входа"
victims: 1
tree:
diff --git a/scenarios/tickets/t19-3-train-injury.yaml b/scenarios/tickets/t19-3-train-injury.yaml
index 413b827..7fe1d4b 100644
--- a/scenarios/tickets/t19-3-train-injury.yaml
+++ b/scenarios/tickets/t19-3-train-injury.yaml
@@ -43,7 +43,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: платформа Лианозово
+ address: "платформа Лианозово, железнодорожный переход со стороны Лианозовского рынка"
victims: 1
tree:
diff --git a/scenarios/tickets/t20-1-three-fighting.yaml b/scenarios/tickets/t20-1-three-fighting.yaml
index b6a6428..6ef3ba9 100644
--- a/scenarios/tickets/t20-1-three-fighting.yaml
+++ b/scenarios/tickets/t20-1-three-fighting.yaml
@@ -31,10 +31,10 @@ facts:
value: пострадавших нет
reveal_on: { question: q_victims }
- id: f_suspects
- value: "трое мужчин, оружия не видно"
+ value: "дерутся три человека, оружия нет; пол участников не указан"
reveal_on: { question: q_suspects }
- id: f_caller
- value: "Иванов Сергей Петрович, очевидец, телефон 916-123-98-78"
+ value: "Иванов Сергей Петрович — очевидец, также Орлова Вера Ивановна, телефон 916-123-98-78"
reveal_on: { question: q_caller }
checklist:
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "Олонецкий проезд, дом 4"
+ address: "Олонецкий проезд, дом 4, за домом, дорожка в сторону кладбища"
victims: 0
tree:
diff --git a/scenarios/tickets/t20-2-stroke.yaml b/scenarios/tickets/t20-2-stroke.yaml
index 11598b3..dd73c53 100644
--- a/scenarios/tickets/t20-2-stroke.yaml
+++ b/scenarios/tickets/t20-2-stroke.yaml
@@ -31,7 +31,7 @@ facts:
value: "женщина на вид семидесяти лет, неизвестная"
reveal_on: { question: q_who }
- id: f_conscious
- value: "в сознании, на ногах"
+ value: "сведения о сознании женщины не указаны"
reveal_on: { question: q_conscious }
- id: f_caller
value: "вызывает прохожий Титов Олег Иванович, телефон 916 896 3254"
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: дорога от Каширского шоссе в сторону деревни Истомиха
+ address: "дорога от Каширского шоссе в сторону деревни Истомиха, у остановки Домодедовское кладбище, рядом с КПП"
victims: 1
tree:
diff --git a/scenarios/tickets/t20-3-rape.yaml b/scenarios/tickets/t20-3-rape.yaml
index ae24cd4..1edf393 100644
--- a/scenarios/tickets/t20-3-rape.yaml
+++ b/scenarios/tickets/t20-3-rape.yaml
@@ -1,7 +1,7 @@
# Билет 20, вызов 3 (docs/spec/TICKETS.md). Ситуация и адрес — из билета,
# факты выведены из ситуации: чего в строке нет, того звонящий не знает.
id: t20-3-rape
-title: "Изнасилование, пострадавшая просит полицию"
+title: "Изнасилование женщины, требуется полиция"
ticket: 20
position: 3
type: other
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: panic }
- { stage: interview, mood: worried }
-first_line: "Меня изнасиловали, вызовите полицию!"
+first_line: "Женщину изнасиловали, ей около тридцати лет. Нужно вызвать полицию."
facts:
- id: f_address
@@ -43,7 +43,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: "улица Кастанаевская, дом 42, корпус 2"
+ address: "улица Кастанаевская, дом 42, корпус 2, во дворе"
victims: 1
tree:
diff --git a/scenarios/tickets/t21-1-mass-fight-embassy.yaml b/scenarios/tickets/t21-1-mass-fight-embassy.yaml
index 7ece746..cd8e62b 100644
--- a/scenarios/tickets/t21-1-mass-fight-embassy.yaml
+++ b/scenarios/tickets/t21-1-mass-fight-embassy.yaml
@@ -18,12 +18,12 @@ persona:
- { stage: registration, mood: worried }
- { stage: interview, mood: worried }
-first_line: "Человек пятнадцать дерутся, прутами машут!"
+first_line: "Человек десять-пятнадцать дерутся, палками и железными прутами машут!"
facts:
- id: f_address
value: "рядом с посольством Азербайджана, на тротуаре"
- refined: "Леонтьевский переулок, дом 16, строение 1"
+ refined: "рядом с посольством Азербайджана, на тротуаре; Леонтьевский переулок, дом 16, строение 1"
reveal_on: { question: q_address }
refine_on: q_address_check
- id: f_what_happened
diff --git a/scenarios/tickets/t21-2-axe-wound.yaml b/scenarios/tickets/t21-2-axe-wound.yaml
index 1a53860..84bfe40 100644
--- a/scenarios/tickets/t21-2-axe-wound.yaml
+++ b/scenarios/tickets/t21-2-axe-wound.yaml
@@ -31,10 +31,10 @@ facts:
value: "Перова Ирина Ивановна, дата рождения 10.02.1956, вызывает себе"
reveal_on: { question: q_who }
- id: f_conscious
- value: в сознании
+ value: "состояние сознания в исходной карточке не указано"
reveal_on: { question: q_conscious }
- id: f_caller
- value: телефон 916 896 3254
+ value: "Перова Ирина Ивановна, вызывает себе, телефон 916 896 3254"
reveal_on: { question: q_caller }
checklist:
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "дорога от Клина в сторону Лотошино, у церкви"
+ address: "дорога от Клина в сторону Лотошино, не доезжая Теряевских прудов, поворот к церкви, дом напротив остановки"
victims: 1
tree:
diff --git a/scenarios/tickets/t22-1-flat-fight.yaml b/scenarios/tickets/t22-1-flat-fight.yaml
index 1669f84..769bbe6 100644
--- a/scenarios/tickets/t22-1-flat-fight.yaml
+++ b/scenarios/tickets/t22-1-flat-fight.yaml
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: worried }
- { stage: interview, mood: worried }
-first_line: "У соседей драка, двое мужчин, слышно через стену."
+first_line: "В квартире дерутся двое мужчин."
facts:
- id: f_address
@@ -28,7 +28,7 @@ facts:
value: дерутся в квартире двое мужчин
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "пострадавших нет, насколько слышно"
+ value: пострадавших нет
reveal_on: { question: q_victims }
- id: f_suspects
value: "двое мужчин, оружия нет"
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, entrance, incident_type, description]
ground_truth:
- address: "улица Маршала Василевского, дом 13, корпус 3, квартира 2"
+ address: "улица Маршала Василевского, дом 13, корпус 3, квартира 2, подъезд 1, этаж 1, код домофона 2В"
victims: 0
tree:
diff --git a/scenarios/tickets/t22-2-child-stomach.yaml b/scenarios/tickets/t22-2-child-stomach.yaml
index bba64d1..2cddb8d 100644
--- a/scenarios/tickets/t22-2-child-stomach.yaml
+++ b/scenarios/tickets/t22-2-child-stomach.yaml
@@ -18,11 +18,11 @@ persona:
- { stage: registration, mood: panic }
- { stage: interview, mood: worried }
-first_line: "Сыну живот скрутило, рвота с кровью! Мы в дороге!"
+first_line: "Сыну живот скрутило, рвота с кровью!"
facts:
- id: f_address
- value: "Ленинградское шоссе около памятника Ленину, напротив вокзала станции Ржев-1, ехали из Зубцова в сторону Селижарова"
+ value: "стоят на Ленинградском шоссе около памятника Ленину, напротив вокзала станции Ржев-1; ехали из Зубцова в сторону Селижарова"
reveal_on: { question: q_address }
- id: f_what_happened
value: "боль в животе, рвота с кровью"
@@ -30,8 +30,11 @@ facts:
- id: f_who
value: "Соловьёв Максим, 4 года"
reveal_on: { question: q_who }
+ - id: f_vehicle
+ value: "белая Mazda-6, государственный номер П325ОР 197"
+ reveal_on: { question: q_vehicle }
- id: f_conscious
- value: "в сознании, в автомобиле мазда-6 белая П325ОР 197"
+ value: "сведения о сознании ребёнка не указаны"
reveal_on: { question: q_conscious }
- id: f_caller
value: "вызывает папа, телефон 916 896 3254"
@@ -41,13 +44,17 @@ checklist:
- { id: q_address, fact: f_address }
- { id: q_what_happened, fact: f_what_happened }
- { id: q_who, fact: f_who }
+ - id: q_vehicle
+ question: "В какой машине вы находитесь: марка, цвет и номер?"
+ examples: ["Назовите автомобиль и госномер.", "На какой машине едете? Какого она цвета?"]
+ fact: f_vehicle
- { id: q_conscious, fact: f_conscious }
- { id: q_caller, fact: f_caller }
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: "Ленинградское шоссе у памятника Ленину, напротив вокзала Ржев-1"
+ address: "стоят на Ленинградском шоссе около памятника Ленину, напротив вокзала станции Ржев-1; ехали из Зубцова в сторону Селижарова"
victims: 1
tree:
diff --git a/scenarios/tickets/t22-3-suicide-sms.yaml b/scenarios/tickets/t22-3-suicide-sms.yaml
index 4fcc8ef..4d44fab 100644
--- a/scenarios/tickets/t22-3-suicide-sms.yaml
+++ b/scenarios/tickets/t22-3-suicide-sms.yaml
@@ -10,7 +10,7 @@ topics: [police]
modes: [training, exam, self]
extends: checklists/police.yaml
-signs: [Суицид, "Наблюдает приготовление к суициду (хочет спрыгнуть с моста, с крыши, отравиться, порезать вены и т.д.)"]
+signs: [Суицид]
persona:
base: worried
@@ -28,7 +28,7 @@ facts:
value: "прислал СМС о намерении повеситься, доступа в квартиру нет"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "один человек в квартире, на связь не выходит"
+ value: "знакомый отправил СМС о намерении повеситься; его текущее состояние неизвестно"
reveal_on: { question: q_victims }
- id: f_caller
value: "Иванова Елена Сергеевна, подруга, телефон 916 896 3254"
@@ -43,7 +43,7 @@ checklist:
required_fields: [address, entrance, floor, incident_type, description]
ground_truth:
- address: "улица Белозерская, дом 11Б, квартира 5"
+ address: "улица Белозерская, дом 11Б, квартира 5, подъезд 1, этаж 2"
victims: 1
tree:
diff --git a/scenarios/tickets/t23-1-drunk-husband.yaml b/scenarios/tickets/t23-1-drunk-husband.yaml
index 1b3ac21..f1dc08f 100644
--- a/scenarios/tickets/t23-1-drunk-husband.yaml
+++ b/scenarios/tickets/t23-1-drunk-husband.yaml
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: worried }
- { stage: interview, mood: worried }
-first_line: "У соседей скандал, муж пьяный, женщине голову разбил!"
+first_line: "У соседей скандал, женщине нужна скорая: голова травмирована, кровь идёт!"
facts:
- id: f_address
@@ -31,7 +31,7 @@ facts:
value: "Петрова Елена Михайловна, 28 лет, травма головы, кровотечение"
reveal_on: { question: q_victims }
- id: f_suspects
- value: "муж в квартире, пьян"
+ value: "муж женщины пьян; его местонахождение не указано"
reveal_on: { question: q_suspects }
- id: f_caller
value: "Соколов Иван Петрович, сосед, телефон 916 896 3254"
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, entrance, floor, incident_type, description, victims_count]
ground_truth:
- address: "улица Тихомирова, дом 15, корпус 1, квартира 79"
+ address: "улица Тихомирова, дом 15, корпус 1, квартира 79, подъезд 2, этаж 4, код домофона 80В"
victims: 1
tree:
diff --git a/scenarios/tickets/t23-2-eardrum.yaml b/scenarios/tickets/t23-2-eardrum.yaml
index fa6b038..58a216d 100644
--- a/scenarios/tickets/t23-2-eardrum.yaml
+++ b/scenarios/tickets/t23-2-eardrum.yaml
@@ -10,7 +10,7 @@ topics: [medical, address]
modes: [training, exam, self]
extends: checklists/medical.yaml
-signs: ["Боль в ухе, нарушение слуха"]
+signs: [Травма]
persona:
base: calm
@@ -33,7 +33,7 @@ facts:
value: "Липатина Ольга Сергеевна, дата рождения 10.03.1977, вызывает себе"
reveal_on: { question: q_who }
- id: f_conscious
- value: в сознании
+ value: "сведения о сознании в исходном билете не указаны"
reveal_on: { question: q_conscious }
- id: f_caller
value: телефон 916 896 3254
diff --git a/scenarios/tickets/t23-3-lost-child.yaml b/scenarios/tickets/t23-3-lost-child.yaml
index 40566ca..49875a9 100644
--- a/scenarios/tickets/t23-3-lost-child.yaml
+++ b/scenarios/tickets/t23-3-lost-child.yaml
@@ -43,8 +43,8 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "улица Домодедовская, дом 34, корпус 1"
- victims: 1
+ address: "улица Домодедовская, дом 34, корпус 1, двор у третьего подъезда"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t24-1-parking-quarrel.yaml b/scenarios/tickets/t24-1-parking-quarrel.yaml
index 30856c9..b2f13be 100644
--- a/scenarios/tickets/t24-1-parking-quarrel.yaml
+++ b/scenarios/tickets/t24-1-parking-quarrel.yaml
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: calm }
- { stage: interview, mood: calm }
-first_line: "Во дворе ругаются из-за парковки, громко очень."
+first_line: "Во дворе ругаются из-за парковки."
facts:
- id: f_address
@@ -28,7 +28,7 @@ facts:
value: ссора во дворе из-за парковки
reveal_on: { question: q_what_happened }
- id: f_victims
- value: пострадавших нет
+ value: "сведений о пострадавших нет"
reveal_on: { question: q_victims }
- id: f_caller
value: "Соколов Иван Петрович, очевидец, телефон 916 896 3254"
@@ -43,8 +43,8 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "улица Вешняковская, дом 37"
- victims: 0
+ address: "улица Вешняковская, дом 37, двор у пятого подъезда"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t24-2-child-slide-fall.yaml b/scenarios/tickets/t24-2-child-slide-fall.yaml
index 98f8953..a028b27 100644
--- a/scenarios/tickets/t24-2-child-slide-fall.yaml
+++ b/scenarios/tickets/t24-2-child-slide-fall.yaml
@@ -31,7 +31,7 @@ facts:
value: "Касанов Константин Константинович, 8 лет"
reveal_on: { question: q_who }
- id: f_conscious
- value: "в сознании, плачет"
+ value: "сведения о сознании ребёнка не указаны"
reveal_on: { question: q_conscious }
- id: f_caller
value: "вызывает мама, телефон 916 896 3254"
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: "Погонный проезд, дом 1, корпус 4"
+ address: "Погонный проезд, дом 1, корпус 4, двор, детская площадка"
victims: 1
tree:
diff --git a/scenarios/tickets/t24-3-begging-with-baby.yaml b/scenarios/tickets/t24-3-begging-with-baby.yaml
index 6984c2d..a913df1 100644
--- a/scenarios/tickets/t24-3-begging-with-baby.yaml
+++ b/scenarios/tickets/t24-3-begging-with-baby.yaml
@@ -28,7 +28,7 @@ facts:
value: "попрошайничает, пристаёт к гражданам, у ребёнка окровавленное лицо"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "младенец на руках, состояние неизвестно"
+ value: "один младенец на руках, лицо в крови; состояние неизвестно"
reveal_on: { question: q_victims }
- id: f_caller
value: "Степанова Антонина Марковна, телефон 916 896 3254"
diff --git a/scenarios/tickets/t25-1-drunk-at-stop.yaml b/scenarios/tickets/t25-1-drunk-at-stop.yaml
index e7b3e38..5d34ecf 100644
--- a/scenarios/tickets/t25-1-drunk-at-stop.yaml
+++ b/scenarios/tickets/t25-1-drunk-at-stop.yaml
@@ -10,7 +10,8 @@ topics: [police]
modes: [training, exam, self]
extends: checklists/police.yaml
-signs: ["Пьют, курят в неположенном месте", Пьяный в общественном месте]
+# Источник сообщает о ругани и хватании прохожих, но не о распитии или курении.
+signs: [Хулиганство, Пьяный в общественном месте]
persona:
base: calm
@@ -30,7 +31,7 @@ facts:
value: "нетрезвый сильно ругается и хватает прохожих за руки, скорая не требуется"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: пострадавших нет
+ value: "сведений о пострадавших нет; в исходной карточке указано «03 не требуется»"
reveal_on: { question: q_victims }
- id: f_caller
value: "Соколов Иван Петрович, очевидец, телефон 916 896 3254"
@@ -46,8 +47,8 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "улица Фабрициуса, дом 18"
- victims: 0
+ address: "улица Фабрициуса, дом 18, остановка № 62 «улица Штурвальная»"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t25-2-accident-vegas.yaml b/scenarios/tickets/t25-2-accident-vegas.yaml
index 61747a9..e0a5574 100644
--- a/scenarios/tickets/t25-2-accident-vegas.yaml
+++ b/scenarios/tickets/t25-2-accident-vegas.yaml
@@ -25,10 +25,10 @@ facts:
value: "МКАД от Варшавского шоссе в сторону Каширского, напротив ТЦ Вегас, в левом ряду"
reveal_on: { question: q_address }
- id: f_what_happened
- value: "ДТП без пострадавших, без разлива топлива, пежо и фольксваген"
+ value: "ДТП без пострадавших; участвовали пежо и фольксваген"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "пострадавших нет, все на ногах"
+ value: "пострадавших нет"
reveal_on: { question: q_victims }
- id: f_vehicle
value: пежо и фольксваген
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "МКАД, напротив ТЦ Вегас"
+ address: "МКАД от Варшавского шоссе в сторону Каширского, напротив ТЦ Вегас, в левом ряду"
victims: 0
tree:
diff --git a/scenarios/tickets/t25-3-body-found.yaml b/scenarios/tickets/t25-3-body-found.yaml
index c494b0c..ad8d11e 100644
--- a/scenarios/tickets/t25-3-body-found.yaml
+++ b/scenarios/tickets/t25-3-body-found.yaml
@@ -9,6 +9,8 @@ level: L3
topics: [police]
modes: [training, exam, self]
+# Источник заканчивает адрес неоднозначной пометкой «дом 5В»; не выдавать её
+# за код домофона: в оригинале слова «домофон» или «код» нет.
extends: checklists/police.yaml
signs: [В квартире, Причина неизвестна]
@@ -22,7 +24,7 @@ first_line: "Я в квартиру бывшей жены пришёл, она
facts:
- id: f_address
- value: "Московская область, город Домодедово, улица Текстильщиков, дом 31, квартира 5, подъезд 1, этаж 1, код 5В"
+ value: "Московская область, город Домодедово, улица Текстильщиков, дом 31, квартира 5, подъезд 1, этаж 1, дом 5В"
reveal_on: { question: q_address }
- id: f_what_happened
value: "обнаружен труп, трупные пятна, рядом большая лужа крови; соседи сообщили, что четыре дня воет собака"
@@ -43,7 +45,7 @@ checklist:
required_fields: [address, entrance, incident_type, description, victims_count]
ground_truth:
- address: "Московская область, город Домодедово, улица Текстильщиков, дом 31, квартира 5"
+ address: "Московская область, город Домодедово, улица Текстильщиков, дом 31, квартира 5, подъезд 1, этаж 1, дом 5В"
victims: 1
tree:
diff --git a/scenarios/tickets/t26-2-accident-melnitsa.yaml b/scenarios/tickets/t26-2-accident-melnitsa.yaml
index 7596924..8eb05bf 100644
--- a/scenarios/tickets/t26-2-accident-melnitsa.yaml
+++ b/scenarios/tickets/t26-2-accident-melnitsa.yaml
@@ -25,10 +25,10 @@ facts:
value: "МКАД, напротив рынка Мельница, если ехать от Рублёвского шоссе, в средних рядах"
reveal_on: { question: q_address }
- id: f_what_happened
- value: "ДТП без пострадавших, без разлива топлива, пежо и фольксваген"
+ value: "ДТП без пострадавших; участвовали пежо и фольксваген"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "пострадавших нет, все на ногах"
+ value: "пострадавших нет"
reveal_on: { question: q_victims }
- id: f_vehicle
value: пежо и фольксваген
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "МКАД, напротив рынка Мельница"
+ address: "МКАД напротив рынка Мельница, если ехать от Рублёвского шоссе, в средних рядах"
victims: 0
tree:
diff --git a/scenarios/tickets/t26-3-death-care-home.yaml b/scenarios/tickets/t26-3-death-care-home.yaml
index 2e2694f..f847821 100644
--- a/scenarios/tickets/t26-3-death-care-home.yaml
+++ b/scenarios/tickets/t26-3-death-care-home.yaml
@@ -10,7 +10,9 @@ topics: [medical]
modes: [training, exam, self]
extends: checklists/medical.yaml
-signs: [На улице, Улица общественное место, Труп]
+# В центре для пожилых людей; код «труп на улице» к этому адресу неприменим.
+# Источник не уточняет время/порядок констатации, поэтому код ЕКП не назначаем.
+signs: []
persona:
base: calm
@@ -43,7 +45,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: "улица Лескова, дом 6Б"
+ address: "улица Лескова, дом 6Б, этаж 1, центр для пожилых людей «Союз», охрана встретит"
victims: 1
tree:
diff --git a/scenarios/tickets/t27-1-suspicious-car.yaml b/scenarios/tickets/t27-1-suspicious-car.yaml
index d10408a..af06e39 100644
--- a/scenarios/tickets/t27-1-suspicious-car.yaml
+++ b/scenarios/tickets/t27-1-suspicious-car.yaml
@@ -28,7 +28,7 @@ facts:
value: "под машиной лежит непонятная коробка с проводами; машина приехала ночью, чем-то сильно нагружена, просели задние колёса"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "пострадавших нет, людей вокруг много"
+ value: "сведений о пострадавших нет"
reveal_on: { question: q_victims }
- id: f_vehicle
value: "ваз 2110 чёрная, очень грязная, государственный номер а155ат 33"
@@ -47,8 +47,8 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "Тихорецкий бульвар, дом 12, корпус 1"
- victims: 0
+ address: "Тихорецкий бульвар, дом 12, корпус 1, во дворе у входа в магазин «Билла»"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t27-2-accident-tunnel.yaml b/scenarios/tickets/t27-2-accident-tunnel.yaml
index f930533..351a33d 100644
--- a/scenarios/tickets/t27-2-accident-tunnel.yaml
+++ b/scenarios/tickets/t27-2-accident-tunnel.yaml
@@ -25,16 +25,16 @@ facts:
value: "Садовое кольцо от проспекта Мира, после улицы Малая Дмитровка, въезд в тоннель"
reveal_on: { question: q_address }
- id: f_what_happened
- value: "ДТП без пострадавших, без разлива топлива, пежо и фольксваген"
+ value: "ДТП без пострадавших; участвовали пежо и фольксваген"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "пострадавших нет, все на ногах"
+ value: "пострадавших нет"
reveal_on: { question: q_victims }
- id: f_vehicle
value: пежо и фольксваген
reveal_on: { question: q_vehicle }
- id: f_caller
- value: "Иванова Елена Сергеевна, телефон 916 896 3254"
+ value: "Иванова Елена Сергеевна, телефон 916 897 5623"
reveal_on: { question: q_caller }
checklist:
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "Садовое кольцо, въезд в тоннель после Малой Дмитровки"
+ address: "Садовое кольцо от проспекта Мира, после улицы Малой Дмитровки, въезд в тоннель"
victims: 0
tree:
diff --git a/scenarios/tickets/t27-3-wall-crack.yaml b/scenarios/tickets/t27-3-wall-crack.yaml
index ccb095f..bbd3827 100644
--- a/scenarios/tickets/t27-3-wall-crack.yaml
+++ b/scenarios/tickets/t27-3-wall-crack.yaml
@@ -23,16 +23,16 @@ first_line: "Соседи ремонт делают, а у меня в ванн
facts:
- id: f_address
- value: "Ленинский проспект, дом 57, квартира 28, подъезд 1, этаж 5, код 57В"
+ value: "Ленинский проспект, дом 57, квартира 28, подъезд 1, этаж 5; в исходной записи также указан дом 57В"
reveal_on: { question: q_address }
- id: f_what_happened
value: "со стены в ванной отлетела плитка, образовалась трещина около метра в длину"
reveal_on: { question: q_what_happened }
- id: f_scale
- value: "трещина одна, стена стоит, вода не течёт"
+ value: "трещина около одного метра длиной"
reveal_on: { question: q_scale }
- id: f_threat
- value: "пострадавших нет, угрозы нет"
+ value: "пострадавших нет"
reveal_on: { question: q_threat }
- id: f_caller
value: "Селезнёва Наталья Петровна, телефон 903-226-13-83"
@@ -48,7 +48,7 @@ checklist:
required_fields: []
ground_truth:
- address: "Ленинский проспект, дом 57, квартира 28"
+ address: "Ленинский проспект, дом 57, квартира 28, подъезд 1, этаж 5; в исходной записи также указан дом 57В"
victims: 0
tree:
diff --git a/scenarios/tickets/t28-1-stranger-at-door.yaml b/scenarios/tickets/t28-1-stranger-at-door.yaml
index 89e92a7..cc5b5c5 100644
--- a/scenarios/tickets/t28-1-stranger-at-door.yaml
+++ b/scenarios/tickets/t28-1-stranger-at-door.yaml
@@ -28,7 +28,7 @@ facts:
value: "стоит у домофона, в подъезд не заходит, причину присутствия старшей по подъезду не пояснил"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: пострадавших нет
+ value: "сведений о пострадавших нет"
reveal_on: { question: q_victims }
- id: f_suspects
value: "молодой человек лет двадцати, в синей джинсовой куртке, синих джинсах, чёрных ботинках, славянской внешности"
@@ -47,8 +47,8 @@ checklist:
required_fields: [address, entrance, incident_type, description]
ground_truth:
- address: "улица Матвеевская, дом 28, корпус 1"
- victims: 0
+ address: "улица Матвеевская, дом 28, корпус 1, подъезд 5, код домофона 100"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t28-2-accident-mid.yaml b/scenarios/tickets/t28-2-accident-mid.yaml
index 5bb7134..384b4fe 100644
--- a/scenarios/tickets/t28-2-accident-mid.yaml
+++ b/scenarios/tickets/t28-2-accident-mid.yaml
@@ -25,7 +25,7 @@ facts:
value: "Садовое кольцо, напротив МИД, в сторону Тверской улицы"
reveal_on: { question: q_address }
- id: f_what_happened
- value: "ДТП без пострадавших, без разлива топлива, пежо и фольксваген"
+ value: "ДТП без пострадавших; участвовали пежо и фольксваген"
reveal_on: { question: q_what_happened }
- id: f_victims
value: пострадавших нет
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "Садовое кольцо, напротив МИД"
+ address: "Садовое кольцо, напротив МИД, в сторону Тверской улицы"
victims: 0
tree:
diff --git a/scenarios/tickets/t28-3-loose-board.yaml b/scenarios/tickets/t28-3-loose-board.yaml
index 9a4970d..d0c4e35 100644
--- a/scenarios/tickets/t28-3-loose-board.yaml
+++ b/scenarios/tickets/t28-3-loose-board.yaml
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: calm }
- { stage: interview, mood: calm }
-first_line: "На вокзале табло висит на честном слове, под ним палатка торговая."
+first_line: "На табло с расписанием пригородных поездов не хватает одного крепления, есть угроза падения на торговую палатку."
facts:
- id: f_address
@@ -28,10 +28,10 @@ facts:
value: "нет одного крепления на табло с расписанием пригородных поездов, есть угроза падения на торговую палатку"
reveal_on: { question: q_what_happened }
- id: f_scale
- value: "табло одно, держится на оставшихся креплениях"
+ value: "одно табло, отсутствует одно крепление"
reveal_on: { question: q_scale }
- id: f_threat
- value: "пострадавших нет, под табло проходят люди"
+ value: "пострадавших нет; табло может упасть на торговую палатку"
reveal_on: { question: q_threat }
- id: f_caller
value: "Орлова Инна Сергеевна, телефон 903-226-13-83"
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "Комсомольская площадь, дом 5, Ярославский вокзал"
+ address: "Комсомольская площадь, дом 5, платформа пригородного направления № 3, Ярославский вокзал"
victims: 0
tree:
diff --git a/scenarios/tickets/t29-1-ticking-box.yaml b/scenarios/tickets/t29-1-ticking-box.yaml
index 5abe08e..4fa989c 100644
--- a/scenarios/tickets/t29-1-ticking-box.yaml
+++ b/scenarios/tickets/t29-1-ticking-box.yaml
@@ -28,7 +28,7 @@ facts:
value: "лежит большая коробка, замотана скотчем, внутри что-то тикает"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "пострадавших нет, рядом гуляют люди"
+ value: "сведений о пострадавших нет"
reveal_on: { question: q_victims }
- id: f_caller
value: "Соколов Иван Петрович, прохожий, телефон 916 896 3254"
@@ -43,8 +43,8 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "парк Лосиный остров, вход от улицы Красной Сосны"
- victims: 0
+ address: "парк Лосиный остров, вход от улицы Красной Сосны, далее по дорожке прямо до скамеек"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t29-2-accident-fight.yaml b/scenarios/tickets/t29-2-accident-fight.yaml
index 31839e7..302571c 100644
--- a/scenarios/tickets/t29-2-accident-fight.yaml
+++ b/scenarios/tickets/t29-2-accident-fight.yaml
@@ -24,7 +24,7 @@ first_line: "ДТП, и водители подрались между собо
facts:
- id: f_address
value: "дорога от Киреевска в сторону посёлка Октябрьский, между заводом ЗОК и железнодорожными путями, 500 метров от остановки"
- refined: "Тульская область, дорога от Киреевска в сторону посёлка Октябрьский"
+ refined: "Тульская область, дорога от Киреевска в сторону посёлка Октябрьский, между заводом ЗОК и железнодорожными путями, 500 метров от автобусной остановки"
reveal_on: { question: q_address }
refine_on: q_address_check
- id: f_what_happened
@@ -47,7 +47,7 @@ checklist:
required_fields: []
ground_truth:
- address: "Тульская область, дорога от Киреевска в сторону посёлка Октябрьский"
+ address: "Тульская область, дорога от Киреевска в сторону посёлка Октябрьский, между заводом ЗОК и железнодорожными путями, 500 метров от автобусной остановки"
victims: 0
tree:
diff --git a/scenarios/tickets/t29-3-threat-to-blow-up.yaml b/scenarios/tickets/t29-3-threat-to-blow-up.yaml
index 6f49608..4c063b4 100644
--- a/scenarios/tickets/t29-3-threat-to-blow-up.yaml
+++ b/scenarios/tickets/t29-3-threat-to-blow-up.yaml
@@ -10,7 +10,7 @@ topics: [police]
modes: [training, exam, self]
extends: checklists/police.yaml
-signs: [Человек в опасности, Человеку угрожают убийством]
+signs: [Дом многоквартирный]
persona:
base: panic
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: panic }
- { stage: interview, mood: worried }
-first_line: "Бывший муж угрожает квартиру взорвать! Я боюсь!"
+first_line: "Муж угрожает взорвать квартиру бывшей супруги!"
facts:
- id: f_address
@@ -28,7 +28,7 @@ facts:
value: бывший муж угрожает взорвать квартиру
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "пострадавших нет, в квартире заявительница"
+ value: "неизвестно, есть ли кто-либо в квартире; сведений о пострадавших нет"
reveal_on: { question: q_victims }
- id: f_suspects
value: "бывший муж, где находится сейчас — неизвестно"
@@ -47,8 +47,8 @@ checklist:
required_fields: [address, entrance, incident_type, description]
ground_truth:
- address: "проезд Шокальского, дом 67, корпус 1, квартира 3"
- victims: 0
+ address: "проезд Шокальского, дом 67, корпус 1, квартира 3, подъезд 1, этаж 1, код домофона 67"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t30-1-car-theft-yesterday.yaml b/scenarios/tickets/t30-1-car-theft-yesterday.yaml
index afe1fae..5158edb 100644
--- a/scenarios/tickets/t30-1-car-theft-yesterday.yaml
+++ b/scenarios/tickets/t30-1-car-theft-yesterday.yaml
@@ -1,7 +1,7 @@
# Билет 30, вызов 1 (docs/spec/TICKETS.md). Ситуация и адрес — из билета,
# факты выведены из ситуации: чего в строке нет, того звонящий не знает.
id: t30-1-car-theft-yesterday
-title: "Угон машины, обнаружен утром"
+title: "Угон машины, последний раз видели вчера вечером"
ticket: 30
position: 1
type: police
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: calm }
- { stage: interview, mood: calm }
-first_line: "Машину угнали, вчера вечером последний раз видел."
+first_line: "Машину угнали, в последний раз её видели вчера вечером."
facts:
- id: f_address
@@ -28,7 +28,7 @@ facts:
value: "машину угнали, последний раз видели вчера вечером"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: пострадавших нет
+ value: "сведений о пострадавших нет"
reveal_on: { question: q_victims }
- id: f_vehicle
value: "тойота синяя, государственный номер А128НА 177"
@@ -47,8 +47,8 @@ checklist:
required_fields: [address, incident_type, description]
ground_truth:
- address: "улица Ташкентская, дом 25, корпус 1"
- victims: 0
+ address: "улица Ташкентская, дом 25, корпус 1, двор от седьмого подъезда"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t30-3-gas-smell-house.yaml b/scenarios/tickets/t30-3-gas-smell-house.yaml
index 3eaa1be..0c4f753 100644
--- a/scenarios/tickets/t30-3-gas-smell-house.yaml
+++ b/scenarios/tickets/t30-3-gas-smell-house.yaml
@@ -28,10 +28,10 @@ facts:
value: "запах газа от трубы на вводе в дом, слышен шум в трубе, газ магистральный"
reveal_on: { question: q_what_happened }
- id: f_scale
- value: "запах по всему дому, окна открыли"
+ value: "масштаб запаха и действия жильцов не указаны"
reveal_on: { question: q_scale }
- id: f_threat
- value: "пострадавших нет, скорая не требуется"
+ value: "03 не требуется; сведения о пострадавших не указаны"
reveal_on: { question: q_threat }
- id: f_caller
value: "Соколова Вера Ивановна, телефон 916-320-12-83"
@@ -48,7 +48,7 @@ required_fields: [address, incident_type, description]
ground_truth:
address: "НАО, поселение Вороновское, посёлок ЛМС, микрорайон Солнечный, дом 20"
- victims: 0
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t31-1-car-theft-witnessed.yaml b/scenarios/tickets/t31-1-car-theft-witnessed.yaml
index 5a10598..a0693cb 100644
--- a/scenarios/tickets/t31-1-car-theft-witnessed.yaml
+++ b/scenarios/tickets/t31-1-car-theft-witnessed.yaml
@@ -48,7 +48,7 @@ required_fields: [address, incident_type, description]
ground_truth:
address: улица Большая Ордынка на пересечении с Пыжевским переулком
- victims: 0
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t31-2-hit-and-run.yaml b/scenarios/tickets/t31-2-hit-and-run.yaml
index 3069d14..cca39e0 100644
--- a/scenarios/tickets/t31-2-hit-and-run.yaml
+++ b/scenarios/tickets/t31-2-hit-and-run.yaml
@@ -18,7 +18,7 @@ persona:
- { stage: registration, mood: worried }
- { stage: interview, mood: worried }
-first_line: "Пешехода сбили и уехали! Мужчина в сознании, голова разбита."
+first_line: "Пешехода сбили и уехали! Мужчина в сознании, травма головы."
facts:
- id: f_address
diff --git a/scenarios/tickets/t31-3-gas-pipe-whistle.yaml b/scenarios/tickets/t31-3-gas-pipe-whistle.yaml
index e4dd4e2..5515fee 100644
--- a/scenarios/tickets/t31-3-gas-pipe-whistle.yaml
+++ b/scenarios/tickets/t31-3-gas-pipe-whistle.yaml
@@ -28,10 +28,10 @@ facts:
value: "свист от газовой трубы в квартире, на кухне"
reveal_on: { question: q_what_happened }
- id: f_scale
- value: "свистит одна труба, запаха газа нет"
+ value: "количество труб и наличие запаха газа не указаны"
reveal_on: { question: q_scale }
- id: f_threat
- value: "пострадавших нет, скорая не требуется"
+ value: "03 не требуется; сведения о пострадавших не указаны"
reveal_on: { question: q_threat }
- id: f_caller
value: "Трунов Олег Егорович, телефон 916-320-12-83"
@@ -47,8 +47,8 @@ checklist:
required_fields: [address, entrance, floor, incident_type, description]
ground_truth:
- address: "улица Вавилова, дом 81, корпус 1, квартира 5"
- victims: 0
+ address: "улица Вавилова, дом 81, корпус 1, квартира 5, подъезд 1, этаж 2, код домофона 5В"
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t32-1-carjacking.yaml b/scenarios/tickets/t32-1-carjacking.yaml
index 43c6c32..3827564 100644
--- a/scenarios/tickets/t32-1-carjacking.yaml
+++ b/scenarios/tickets/t32-1-carjacking.yaml
@@ -10,7 +10,9 @@ topics: [police]
modes: [training, exam, self]
extends: checklists/police.yaml
-signs: [Угон транспорта, Транспорт автомашина, До 30 минут]
+# Билет описывает завладение машиной, но не сообщает, сколько времени прошло.
+# Не выбираем код угона, требующий точный интервал «до 30 минут» или «свыше 30 минут».
+signs: []
persona:
base: panic
@@ -18,7 +20,7 @@ persona:
- { stage: registration, mood: panic }
- { stage: interview, mood: worried }
-first_line: "Меня из машины вытащили и уехали! Перегородили и вытащили!"
+first_line: "Человека вытащили из машины, перегородив проезд, и уехали на «Мерседесе»!"
facts:
- id: f_address
@@ -28,7 +30,7 @@ facts:
value: "перегородили проезд машиной, вытащили из автомобиля и уехали; брошенный ваз 2115 остался на проезжей части"
reveal_on: { question: q_what_happened }
- id: f_victims
- value: "пострадавших нет, скорая не требуется"
+ value: "сведений о пострадавших нет; в исходной карточке указано «03 не требуется»"
reveal_on: { question: q_victims }
- id: f_fled
value: уехали в сторону Ходынского бульвара
@@ -52,7 +54,7 @@ required_fields: [address, incident_type, description]
ground_truth:
address: "улица Маргелова, перед перекрёстком с улицей Гризодубовой"
- victims: 0
+ victims: null
tree:
pregenerated: false
diff --git a/scenarios/tickets/t32-2-trolleybus-accident.yaml b/scenarios/tickets/t32-2-trolleybus-accident.yaml
index bcb4f2d..d90baad 100644
--- a/scenarios/tickets/t32-2-trolleybus-accident.yaml
+++ b/scenarios/tickets/t32-2-trolleybus-accident.yaml
@@ -47,7 +47,7 @@ checklist:
required_fields: [address, incident_type, description, victims_count]
ground_truth:
- address: "Волгоградский проспект, остановка «Завод Спецэлектрод»"
+ address: "Волгоградский проспект в сторону области, остановка «Завод Спецэлектрод»"
victims: 3
tree:
diff --git a/scenarios/tickets/t32-3-street-lights.yaml b/scenarios/tickets/t32-3-street-lights.yaml
index 38702ca..7471c95 100644
--- a/scenarios/tickets/t32-3-street-lights.yaml
+++ b/scenarios/tickets/t32-3-street-lights.yaml
@@ -1,7 +1,7 @@
# Билет 32, вызов 3 (docs/spec/TICKETS.md). Ситуация и адрес — из билета,
# факты выведены из ситуации: чего в строке нет, того звонящий не знает.
id: t32-3-street-lights
-title: Уличное освещение горит днём
+title: Горит уличное освещение на МКАД
ticket: 32
position: 3
type: gkh
@@ -10,7 +10,9 @@ topics: [utility, address]
modes: [training, exam, self]
extends: checklists/utility.yaml
-signs: [Повод локального (частного) коммунального характера, Горит уличное освещение в дневное время]
+# В билете не указано время суток. Поэтому не выбираем ЕКП 14030203,
+# признак которого прямо требует дневного времени.
+signs: []
persona:
base: calm
@@ -18,20 +20,20 @@ persona:
- { stage: registration, mood: calm }
- { stage: interview, mood: calm }
-first_line: "На МКАД фонари среди бела дня горят, на всём протяжении."
+first_line: "На МКАД горит уличное освещение на участке между Ленинградским и Волоколамским шоссе."
facts:
- id: f_address
value: "МКАД между 74 и 68 километром, внутренняя и внешняя стороны"
reveal_on: { question: q_address }
- id: f_what_happened
- value: горит уличное освещение в дневное время на протяжении от Ленинградского шоссе до Волоколамского
+ value: горит уличное освещение на протяжении от Ленинградского шоссе до Волоколамского
reveal_on: { question: q_what_happened }
- id: f_scale
value: "обе стороны, участок между Ленинградским и Волоколамским шоссе"
reveal_on: { question: q_scale }
- id: f_threat
- value: "пострадавших нет, угрозы нет"
+ value: "сведения о пострадавших и непосредственной угрозе не указаны"
reveal_on: { question: q_threat }
- id: f_caller
value: "Зотова Алина Петровна, телефон 916-320-12-83"
@@ -48,7 +50,7 @@ required_fields: [address, incident_type, description]
ground_truth:
address: МКАД между 68 и 74 километром
- victims: 0
+ victims: null
tree:
pregenerated: false
diff --git a/scripts/demo.sh b/scripts/demo.sh
index 5f50ab2..77b2b6b 100755
--- a/scripts/demo.sh
+++ b/scripts/demo.sh
@@ -1,27 +1,39 @@
#!/usr/bin/env bash
# make demo: поднять стенд для занятия или показа.
#
-# Postgres в контейнере, бэкенд и фронт — нативно: модели речи под WSL работают
-# нативно, так они и мерялись (docs/LATENCY.md). Скрипт обкатывается заранее,
-# а не пишется в последнюю ночь: им поднимают стенд в классе.
+# Postgres в контейнере, бэкенд и фронт — нативно. В основном упражнении ДДС
+# голосовой контур выключен; VOICE_ENABLED=true — только отдельный эксперимент.
set -euo pipefail
cd "$(dirname "$0")/.."
+# Не конфликтуем с уже установленным PostgreSQL. Один POSTGRES_PORT меняет и
+# проброс контейнера, и адрес нативного backend; явный DATABASE_URL приоритетен.
+export POSTGRES_PORT="${POSTGRES_PORT:-5432}"
+export DATABASE_URL="${DATABASE_URL:-postgresql+asyncpg://lct:lct@127.0.0.1:${POSTGRES_PORT}/lct}"
+
say() { printf '\n\033[1m%s\033[0m\n' "$*"; }
say "1/5 Модели"
-missing=0
-for path in backend/models/e5-small/model_quantized.onnx \
- backend/models/gigaam-v3-onnx/v3_rnnt_encoder.int8.onnx \
- backend/models/silero-vad/silero_vad.onnx \
- backend/models/silero-tts/v5_ru.pt; do
- if [ ! -f "$path" ]; then echo " нет $path"; missing=1; fi
-done
-if [ "$missing" = 1 ]; then
- echo " запусти: make models (Silero TTS качается вручную, скрипт подскажет)"
- exit 1
+if [ "${VOICE_ENABLED:-false}" = "true" ]; then
+ missing=0
+ for path in backend/models/e5-small/model_quantized.onnx \
+ backend/models/whisper-small/ggml-small-q5_1.bin \
+ backend/models/silero-vad/silero_vad.onnx \
+ backend/models/silero-tts/v5_ru.pt; do
+ if [ ! -f "$path" ]; then echo " нет $path"; missing=1; fi
+ done
+ if [ "$missing" = 1 ]; then
+ echo " запусти: make models и make local-models; Silero TTS положи отдельно"
+ exit 1
+ fi
+ if ! curl -fsS --max-time 2 http://127.0.0.1:18082/ >/dev/null; then
+ echo " нет whisper-server: запусти make local-stt в отдельном терминале"
+ exit 1
+ fi
+ echo " голосовой контур включён"
+else
+ echo " MVP ДДС: STT и голосовой звонок 112 выключены"
fi
-echo " все модели на месте"
say "2/5 База"
docker compose up -d postgres
@@ -32,18 +44,19 @@ say "3/5 Сценарии и учётные записи"
make seed
make users
-say "4/5 Бэкенд с голосом"
-# Контейнерные бэкенд и фронт гасим: порты те же, а голос работает нативно.
+say "4/5 Бэкенд"
+# Контейнерные бэкенд и фронт гасим: порты те же.
docker compose stop backend frontend >/dev/null 2>&1 || true
# Подоболочка явно: `cd backend && команда &` уводит в фон всю связку,
# а родительская оболочка остаётся на месте — следующий `cd ..` увёл бы её
# из репозитория, и фронт запускался бы не оттуда.
-( cd backend && OFFLINE=true exec uv run --extra voice uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 ) &
+( cd backend && OFFLINE=true VOICE_ENABLED="${VOICE_ENABLED:-false}" exec uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 ) &
BACKEND=$!
+FRONTEND=
trap 'kill $BACKEND $FRONTEND 2>/dev/null || true' EXIT INT TERM
for _ in $(seq 1 60); do
- if curl -sf localhost:8000/api/health | grep -q '"models_ready":true'; then break; fi
+ if curl -sf localhost:8000/api/health | grep -q '"status":"ok"'; then break; fi
sleep 2
done
curl -s localhost:8000/api/health; echo
diff --git a/scripts/load_browser.py b/scripts/load_browser.py
new file mode 100644
index 0000000..9f6f577
--- /dev/null
+++ b/scripts/load_browser.py
@@ -0,0 +1,226 @@
+#!/usr/bin/env python3
+"""Локальная браузерная проверка 20 занятий / 100 Chromium-контекстов."""
+
+import argparse
+import asyncio
+import inspect
+import json
+import math
+import statistics
+import time
+import urllib.parse
+import urllib.request
+from uuid import uuid4
+
+import websockets
+from playwright.async_api import async_playwright
+
+
+def local_url(value: str, schemes: set[str]) -> str:
+ parsed = urllib.parse.urlsplit(value)
+ if parsed.scheme not in schemes or parsed.hostname not in {"127.0.0.1", "localhost", "::1"}:
+ raise ValueError(f"принимаются только локальные URL со схемами {sorted(schemes)}")
+ return value.rstrip("/")
+
+
+def percentile(values: list[float], percent: float) -> float:
+ ordered = sorted(values)
+ return ordered[max(0, math.ceil(percent / 100 * len(ordered)) - 1)]
+
+
+def timing_summary(values: list[float]) -> dict | None:
+ if not values:
+ return None
+ return {
+ "min": min(values),
+ "median": statistics.median(values),
+ "p95": percentile(values, 95),
+ "max": max(values),
+ }
+
+
+def login_cookie(base: str, login: str, password: str) -> str:
+ body = json.dumps({"login": login, "password": password}).encode()
+ request = urllib.request.Request(
+ f"{base}/api/auth/login", data=body, method="POST",
+ headers={"Content-Type": "application/json"},
+ )
+ with urllib.request.urlopen(request, timeout=10) as response:
+ value = response.headers.get("Set-Cookie", "").split(";", 1)[0]
+ if not value.startswith("lct_session="):
+ raise RuntimeError("сервер не выдал lct_session cookie")
+ return value
+
+
+async def control(ws_base: str, session_id: str, cookie: str, payload: dict) -> None:
+ header_arg = ("additional_headers" if "additional_headers" in inspect.signature(
+ websockets.connect
+ ).parameters else "extra_headers")
+ async with websockets.connect(
+ f"{ws_base}/ws/control/{session_id}",
+ **{header_arg: {"Cookie": cookie}}, open_timeout=10, ping_interval=None,
+ ) as socket:
+ await socket.send(json.dumps(payload, ensure_ascii=False))
+ await asyncio.sleep(0.03)
+
+
+async def run(args: argparse.Namespace) -> int:
+ frontend = local_url(args.frontend_url, {"http", "https"})
+ backend = local_url(args.backend_url, {"http"})
+ if args.sessions < 1 or args.clients < args.sessions or args.clients % args.sessions:
+ raise ValueError("clients должно делиться на sessions и быть не меньше sessions")
+ if args.clients > 100 or args.sessions > 20:
+ raise ValueError("предохранитель: максимум 100 браузеров и 20 занятий")
+ if args.ramp_seconds < 0:
+ raise ValueError("ramp-seconds не может быть отрицательным")
+
+ cookie = login_cookie(backend, args.login, args.password)
+ cookie_name, cookie_value = cookie.split("=", 1)
+ ws_base = "ws" + backend.removeprefix("http")
+ sessions = [str(uuid4()) for _ in range(args.sessions)]
+ started: list[str] = []
+ try:
+ await asyncio.gather(*(
+ control(ws_base, session_id, cookie, {
+ "type": "scenario.start", "scenario_id": args.scenario,
+ "trainee": f"browser-load-{index:02d}", "mode": "training", "exercise": "dds",
+ }) for index, session_id in enumerate(sessions)
+ ))
+ started.extend(sessions)
+
+ timings: list[float] = []
+ dds_timings: list[float] = []
+ observer_timings: list[float] = []
+ browser_navigation: list[dict] = []
+ errors: list[str] = []
+ pages = []
+ contexts = []
+ per_session = args.clients // args.sessions
+ async with async_playwright() as playwright:
+ browser = await playwright.chromium.launch(headless=True)
+ for index in range(args.clients):
+ context = await browser.new_context(
+ viewport={"width": 1280, "height": 720},
+ ignore_https_errors=args.ignore_https_errors,
+ )
+ await context.add_cookies([{
+ "name": cookie_name, "value": cookie_value, "url": frontend,
+ "httpOnly": True, "sameSite": "Lax",
+ }])
+ page = await context.new_page()
+ page.on("pageerror", lambda error, i=index: errors.append(f"page {i}: {error}"))
+ contexts.append(context)
+ pages.append(page)
+
+ async def open_page(index: int) -> None:
+ if args.ramp_seconds and args.clients > 1:
+ await asyncio.sleep(args.ramp_seconds * index / (args.clients - 1))
+ session_index = index // per_session
+ role_index = index % per_session
+ session_id = sessions[session_index]
+ if role_index == 0:
+ path = f"/dds?session={session_id}&role=dds_01"
+ selector = ".dds-register-panel .dds-selected"
+ else:
+ path = f"/wall?session={session_id}"
+ selector = "main.wall h1"
+ started_at = time.perf_counter()
+ try:
+ response = await pages[index].goto(
+ frontend + path, wait_until="domcontentloaded", timeout=args.timeout * 1000,
+ )
+ if response is None or not response.ok:
+ raise RuntimeError(f"HTTP {response.status if response else 'нет ответа'}")
+ await pages[index].locator(selector).wait_for(timeout=args.timeout * 1000)
+ if role_index != 0:
+ await pages[index].wait_for_function(
+ "document.querySelector('main.wall h1')?.textContent !== 'Ожидание занятия'",
+ timeout=args.timeout * 1000,
+ )
+ ready_ms = (time.perf_counter() - started_at) * 1000
+ timings.append(ready_ms)
+ (dds_timings if role_index == 0 else observer_timings).append(ready_ms)
+ navigation = await pages[index].evaluate("""() => {
+ const entry = performance.getEntriesByType('navigation')[0];
+ if (!entry) return null;
+ return {
+ responseStart: entry.responseStart,
+ responseEnd: entry.responseEnd,
+ domInteractive: entry.domInteractive,
+ domContentLoaded: entry.domContentLoadedEventEnd,
+ transferSize: entry.transferSize,
+ };
+ }""")
+ if navigation:
+ browser_navigation.append({"role": "dds" if role_index == 0 else "wall", **navigation})
+ except Exception as exc: # каждый незагрузившийся интерфейс — измеренный отказ
+ errors.append(f"page {index} {path}: {type(exc).__name__}: {exc}")
+
+ await asyncio.gather(*(open_page(index) for index in range(args.clients)))
+ await asyncio.sleep(args.hold_seconds)
+ await asyncio.gather(*(context.close() for context in contexts))
+ await browser.close()
+
+ result = {
+ "frontend": frontend, "backend": backend,
+ "sessions_started": len(sessions), "browser_contexts": args.clients,
+ "dds_pages": args.sessions, "observer_pages": args.clients - args.sessions,
+ "pages_ready": len(timings), "hold_seconds": args.hold_seconds,
+ "ramp_seconds": args.ramp_seconds,
+ "ready_ms": timing_summary(timings),
+ "ready_ms_by_page": {
+ "dds": timing_summary(dds_timings),
+ "wall": timing_summary(observer_timings),
+ },
+ "browser_navigation_ms": {
+ key: timing_summary([float(item[key]) for item in browser_navigation])
+ for key in ("responseStart", "responseEnd", "domInteractive", "domContentLoaded")
+ },
+ "transfer_bytes": timing_summary([
+ float(item["transferSize"]) for item in browser_navigation
+ ]),
+ "errors": errors[:50],
+ "pass_browser_ui_2s": (
+ len(timings) == args.clients and not errors and percentile(timings, 95) <= 2000
+ ),
+ "scope": (
+ f"{args.clients} isolated Chromium contexts render real React DDS/wall pages "
+ f"for {args.sessions} sessions with a {args.ramp_seconds:g}s connection ramp; "
+ "all contexts remain active concurrently; same instructor account, local host, no VoIP, "
+ "not target 6-core/32-GB server"
+ ),
+ }
+ rendered = json.dumps(result, ensure_ascii=False, indent=2)
+ print(rendered)
+ if args.output:
+ with open(args.output, "w", encoding="utf-8") as stream:
+ stream.write(rendered + "\n")
+ return 0 if result["pass_browser_ui_2s"] else 1
+ finally:
+ await asyncio.gather(*(
+ control(ws_base, session_id, cookie, {"type": "session.stop"})
+ for session_id in started
+ ), return_exceptions=True)
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--frontend-url", default="http://127.0.0.1:5173")
+ parser.add_argument("--backend-url", default="http://127.0.0.1:8000")
+ parser.add_argument("--login", required=True)
+ parser.add_argument("--password", required=True)
+ parser.add_argument("--sessions", type=int, default=20)
+ parser.add_argument("--clients", type=int, default=100)
+ parser.add_argument("--scenario", default="fire-apartment-l2")
+ parser.add_argument("--timeout", type=float, default=30)
+ parser.add_argument("--hold-seconds", type=float, default=2)
+ parser.add_argument("--ramp-seconds", type=float, default=0,
+ help="равномерно распределить начало загрузок; все страницы остаются активны")
+ parser.add_argument("--output", help="сохранить JSON-отчёт")
+ parser.add_argument("--ignore-https-errors", action="store_true",
+ help="разрешить bootstrap self-signed сертификат только в локальном тесте")
+ try:
+ raise SystemExit(asyncio.run(run(parser.parse_args())))
+ except Exception as exc:
+ print(f"browser load failed: {type(exc).__name__}: {exc}")
+ raise SystemExit(2)
diff --git a/scripts/load_db.py b/scripts/load_db.py
new file mode 100644
index 0000000..645a7a7
--- /dev/null
+++ b/scripts/load_db.py
@@ -0,0 +1,98 @@
+#!/usr/bin/env python3
+"""Проверка последовательной записи PostgreSQL без изменения данных продукта."""
+
+import argparse
+import asyncio
+import json
+import math
+import os
+import statistics
+import time
+import urllib.parse
+
+import asyncpg
+
+
+def local_database_url(value: str) -> str:
+ normalized = value.replace("postgresql+asyncpg://", "postgresql://", 1)
+ parsed = urllib.parse.urlsplit(normalized)
+ if parsed.scheme not in {"postgres", "postgresql"}:
+ raise ValueError("поддерживается только PostgreSQL")
+ if parsed.hostname not in {"127.0.0.1", "localhost", "::1"}:
+ raise ValueError("предохранитель: benchmark разрешён только для локальной БД")
+ return normalized
+
+
+def percentile(values: list[float], percent: float) -> float:
+ ordered = sorted(values)
+ return ordered[max(0, math.ceil(percent / 100 * len(ordered)) - 1)]
+
+
+async def run(args: argparse.Namespace) -> int:
+ if not 100 <= args.operations <= 10_000:
+ raise ValueError("operations должно быть от 100 до 10000")
+ database_url = local_database_url(args.database_url)
+ connection = await asyncpg.connect(database_url, timeout=10)
+ latencies: list[float] = []
+ try:
+ async with connection.transaction():
+ await connection.execute("""
+ CREATE TEMPORARY TABLE lct_write_benchmark (
+ sequence_no integer PRIMARY KEY,
+ written_at timestamptz NOT NULL DEFAULT now(),
+ payload text NOT NULL
+ ) ON COMMIT DROP
+ """)
+ started = time.perf_counter()
+ for sequence_no in range(args.operations):
+ operation_started = time.perf_counter()
+ await connection.execute(
+ "INSERT INTO lct_write_benchmark(sequence_no, payload) VALUES($1, $2)",
+ sequence_no, f"local-load-{sequence_no:05d}",
+ )
+ latencies.append((time.perf_counter() - operation_started) * 1000)
+ elapsed = time.perf_counter() - started
+ count = await connection.fetchval("SELECT count(*) FROM lct_write_benchmark")
+ finally:
+ await connection.close()
+
+ throughput = args.operations / elapsed
+ result = {
+ "database": "local PostgreSQL",
+ "operations_requested": args.operations,
+ "operations_written": count,
+ "elapsed_seconds": elapsed,
+ "operations_per_second": throughput,
+ "operation_latency_ms": {
+ "min": min(latencies),
+ "median": statistics.median(latencies),
+ "p95": percentile(latencies, 95),
+ "max": max(latencies),
+ },
+ "pass_100_writes_per_second": count == args.operations and throughput >= 100,
+ "scope": (
+ "sequential INSERT statements over one local asyncpg connection into a temporary "
+ "table; table is dropped on commit; not target 6-core/32-GB hardware"
+ ),
+ }
+ rendered = json.dumps(result, ensure_ascii=False, indent=2)
+ print(rendered)
+ if args.output:
+ with open(args.output, "w", encoding="utf-8") as stream:
+ stream.write(rendered + "\n")
+ return 0 if result["pass_100_writes_per_second"] else 1
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--database-url",
+ default=os.environ.get("DATABASE_URL", "postgresql://lct:lct@127.0.0.1:5432/lct"),
+ )
+ parser.add_argument("--operations", type=int, default=1000)
+ parser.add_argument("--output", help="сохранить JSON-отчёт")
+ try:
+ raise SystemExit(asyncio.run(run(parser.parse_args())))
+ except Exception as exc:
+ print(f"database load failed: {type(exc).__name__}: {exc}")
+ raise SystemExit(2)
diff --git a/scripts/smoke_active_session_recovery.py b/scripts/smoke_active_session_recovery.py
new file mode 100644
index 0000000..3e58be7
--- /dev/null
+++ b/scripts/smoke_active_session_recovery.py
@@ -0,0 +1,197 @@
+#!/usr/bin/env python3
+"""Живой smoke: активная карточка ДДС переживает restart backend.
+
+Скрипт сам запускает два последовательных uvicorn-процесса на одном порту,
+использует один локальный PostgreSQL и проверяет карточку, статус службы,
+выбранную бригаду, письменный ответ и продолжающийся таймер.
+"""
+
+import argparse
+import asyncio
+import json
+import os
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.request
+from contextlib import asynccontextmanager
+from pathlib import Path
+from uuid import uuid4
+
+import websockets
+
+
+ROOT = Path(__file__).resolve().parents[1]
+BACKEND = ROOT / "backend"
+
+
+def request_json(url: str, *, method: str = "GET", cookie: str = "") -> tuple[dict, str]:
+ request = urllib.request.Request(url, method=method)
+ if cookie:
+ request.add_header("Cookie", cookie)
+ with urllib.request.urlopen(request, timeout=5) as response:
+ value = json.loads(response.read())
+ issued = response.headers.get("set-cookie", "").split(";", 1)[0]
+ return value, issued
+
+
+def wait_ready(base: str, timeout: float = 20) -> dict:
+ deadline = time.monotonic() + timeout
+ last: Exception | None = None
+ while time.monotonic() < deadline:
+ try:
+ health, _ = request_json(f"{base}/api/health")
+ return health
+ except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
+ last = exc
+ time.sleep(0.1)
+ raise RuntimeError(f"backend не стал готов: {last}")
+
+
+@asynccontextmanager
+async def backend_process(port: int, database_url: str, *, hard_stop: bool = False):
+ env = {
+ **os.environ,
+ "DATABASE_URL": database_url,
+ "OFFLINE": "true",
+ "VOICE_ENABLED": "false",
+ "DEV_AUTH_BYPASS": "true",
+ "DEMO_NO_DB": "false",
+ "SESSION_SECRET": "recovery-smoke-secret-that-is-not-production",
+ "LLM_API_KEY": "",
+ "LLM_PROVIDER": "disabled",
+ }
+ process = subprocess.Popen(
+ [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1",
+ "--port", str(port), "--workers", "1"],
+ cwd=BACKEND,
+ env=env,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ try:
+ wait_ready(f"http://127.0.0.1:{port}")
+ yield process
+ finally:
+ if process.poll() is None:
+ process.kill() if hard_stop else process.terminate()
+ try:
+ process.wait(timeout=8)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait(timeout=5)
+ if not hard_stop and process.returncode not in (0, -15):
+ error = process.stderr.read()[-4000:] if process.stderr else ""
+ raise RuntimeError(f"backend завершился с кодом {process.returncode}: {error}")
+
+
+async def receive_type(socket, wanted: str) -> dict:
+ for _ in range(30):
+ value = json.loads(await asyncio.wait_for(socket.recv(), timeout=5))
+ if value.get("type") == wanted:
+ return value
+ raise AssertionError(f"событие {wanted} не получено")
+
+
+async def start_and_edit(ws_base: str, cookie: str, session_id: str) -> dict:
+ options = {"additional_headers": {"Cookie": cookie}}
+ async with websockets.connect(f"{ws_base}/ws/control/{session_id}", **options) as control:
+ await control.send(json.dumps({
+ "type": "scenario.start",
+ "scenario_id": "fire-apartment-l2",
+ "trainee": "Recovery smoke",
+ "mode": "training",
+ "exercise": "dds",
+ "criteria": {
+ "decision_time_limit_seconds": 45,
+ "allowed_errors": 0,
+ "require_correct_grammar": True,
+ },
+ }))
+ await asyncio.sleep(0.4)
+
+ async with websockets.connect(f"{ws_base}/ws/station/{session_id}", **options) as station:
+ card = await receive_type(station, "card.received")
+ snapshot = (await receive_type(station, "station.state"))["snapshot"]
+ service = snapshot["services"][0]
+ crew = snapshot["crew_options"][0]
+ await station.send(json.dumps({
+ "type": "card.status", "service": service, "status": "accepted",
+ }))
+ await receive_type(station, "station.state")
+ await station.send(json.dumps({"type": "crew.select", "crew": crew}))
+ await receive_type(station, "station.state")
+ await station.send(json.dumps({
+ "type": "card.reply",
+ "card_id": card["card"]["card_id"],
+ "text": "Сообщение принято, бригада направлена.",
+ }))
+ latest = (await receive_type(station, "station.state"))["snapshot"]
+ return {"service": service, "crew": crew, "card_id": card["card"]["card_id"],
+ "before": latest}
+
+
+async def verify_and_finish(ws_base: str, cookie: str, session_id: str, expected: dict) -> dict:
+ options = {"additional_headers": {"Cookie": cookie}}
+ async with websockets.connect(f"{ws_base}/ws/station/{session_id}", **options) as station:
+ card = await receive_type(station, "card.received")
+ snapshot = (await receive_type(station, "station.state"))["snapshot"]
+ assert card["card"]["card_id"] == expected["card_id"]
+ assert snapshot["statuses"][expected["service"]] == "accepted"
+ assert snapshot["crew_selected"] == expected["crew"]
+ assert snapshot["reply_text"] == "Сообщение принято, бригада направлена."
+ await station.send(json.dumps({"type": "station.finish"}))
+ await receive_type(station, "score.ready")
+ return snapshot
+
+
+async def run(args) -> dict:
+ http_base = f"http://127.0.0.1:{args.port}"
+ ws_base = f"ws://127.0.0.1:{args.port}"
+ session_id = str(uuid4())
+ async with backend_process(args.port, args.database_url, hard_stop=True):
+ _, cookie = request_json(f"{http_base}/api/auth/dev-token", method="POST")
+ expected = await start_and_edit(ws_base, cookie, session_id)
+
+ # Небольшая пауза делает проверяемым продолжение активного таймера.
+ await asyncio.sleep(1.2)
+ async with backend_process(args.port, args.database_url):
+ health = wait_ready(http_base)
+ assert health["sessions_restored"] >= 1
+ # Та же подписанная cookie должна пережить restart. При смене роли,
+ # пароля или блокировке её отзовёт сохранённый auth_version.
+ after = await verify_and_finish(ws_base, cookie, session_id, expected)
+
+ return {
+ "ok": True,
+ "session_id": session_id,
+ "sessions_restored": health["sessions_restored"],
+ "card_id": after["card_id"],
+ "status": after["statuses"][expected["service"]],
+ "crew": after["crew_selected"],
+ "reply_text": after["reply_text"],
+ }
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--port", type=int, default=18116)
+ parser.add_argument(
+ "--database-url",
+ default=os.environ.get(
+ "DATABASE_URL", "postgresql+asyncpg://lct:lct@127.0.0.1:5434/lct"
+ ),
+ )
+ args = parser.parse_args()
+ try:
+ print(json.dumps(asyncio.run(run(args)), ensure_ascii=False, indent=2))
+ except Exception as exc: # noqa: BLE001
+ print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/smoke_mobile.py b/scripts/smoke_mobile.py
new file mode 100644
index 0000000..4ea5b8a
--- /dev/null
+++ b/scripts/smoke_mobile.py
@@ -0,0 +1,343 @@
+#!/usr/bin/env python3
+"""Browser smoke for the local demo, including mobile layout and DDS workflow."""
+
+import argparse
+import json
+import time
+from pathlib import Path
+from urllib.parse import parse_qs, urlparse
+
+from playwright.sync_api import sync_playwright
+
+from load_browser import local_url
+
+
+def layout(page) -> dict:
+ return page.evaluate(
+ """() => {
+ const root = document.documentElement;
+ const visible = [...document.querySelectorAll('body *')].filter((element) => {
+ const style = getComputedStyle(element);
+ const rect = element.getBoundingClientRect();
+ const closed = element.closest('details:not([open])');
+ return (!closed || element.tagName === 'SUMMARY') && style.display !== 'none'
+ && style.visibility !== 'hidden' && rect.width > 0;
+ });
+ return {
+ viewport: innerWidth,
+ document_width: root.scrollWidth,
+ overflowing: visible.filter((element) => {
+ const rect = element.getBoundingClientRect();
+ return rect.right > innerWidth + 2 || rect.left < -2;
+ }).slice(0, 20).map((element) => ({
+ tag: element.tagName.toLowerCase(),
+ class: element.className || '',
+ text: (element.textContent || '').trim().slice(0, 80),
+ left: Math.round(element.getBoundingClientRect().left),
+ right: Math.round(element.getBoundingClientRect().right),
+ })),
+ small_controls: [...document.querySelectorAll(
+ 'button, select, textarea, input:not([type=radio]):not([type=checkbox]), summary'
+ )].filter((element) => {
+ const style = getComputedStyle(element);
+ const rect = element.getBoundingClientRect();
+ return style.display !== 'none' && style.visibility !== 'hidden'
+ && !element.disabled && (rect.height < 40 || rect.width < 40);
+ }).slice(0, 20).map((element) => ({
+ tag: element.tagName.toLowerCase(),
+ label: element.getAttribute('aria-label') || element.textContent?.trim().slice(0, 60)
+ || element.getAttribute('placeholder') || '',
+ width: Math.round(element.getBoundingClientRect().width),
+ height: Math.round(element.getBoundingClientRect().height),
+ })),
+ };
+ }"""
+ )
+
+
+def open_instructor(page, base_url: str) -> None:
+ page.goto(f"{base_url}/instructor", wait_until="networkidle")
+ demo_login = page.get_by_role("button", name="Войти в демо одним нажатием")
+ if demo_login.is_visible():
+ demo_login.click()
+ page.locator("#ai-scenario-editor").wait_for()
+
+
+def stop_exercise_from_instructor(page, base_url: str, exercise_href: str) -> None:
+ session_id = parse_qs(urlparse(exercise_href).query).get("session", [None])[0]
+ if not session_id:
+ raise RuntimeError(f"в ссылке АРМ нет session id: {exercise_href}")
+ page.goto(f"{base_url}/instructor?session={session_id}", wait_until="networkidle")
+ page.get_by_role("button", name="Завершить занятие").click()
+ page.get_by_text("занятие завершено", exact=True).wait_for()
+
+
+def choose_trainee(page) -> None:
+ # Match the label cell, not any row whose body happens to mention a
+ # trainee (the scenario/category tables include that word too).
+ exercise_table = page.locator(".instructor-layout > section").first.locator(
+ "table.grid"
+ ).first
+ trainee_row = exercise_table.locator("th").get_by_text(
+ "Курсант", exact=True
+ ).locator("xpath=..")
+ trainee_select = trainee_row.locator("select")
+ if trainee_select.count():
+ if trainee_select.locator("option").count() < 2:
+ raise RuntimeError("Список курсантов не содержит доступного выбора")
+ trainee_select.select_option(index=1)
+ else:
+ trainee_row.locator('input[type="text"]').fill("Мобильный smoke")
+
+
+def start_exercise(page, label: str, link_row: str) -> str:
+ page.get_by_role("radio", name=label).check()
+ choose_trainee(page)
+ page.get_by_role("button", name="Начать занятие").click()
+ row = page.locator("tr").filter(has_text=link_row).last
+ link = row.get_by_role("link", name="открыть", exact=False)
+ link.wait_for()
+ href = link.get_attribute("href")
+ if not href:
+ raise RuntimeError(f"не получена ссылка {link_row}")
+ return href
+
+
+def exercise_dds_flow(page, scenario_title: str) -> dict:
+ """Accept the card, select its crew and complete the training briefing."""
+ service_button = page.locator(".dds-service-dock button").first
+ service_title = service_button.locator("b").inner_text()
+ service_button.click()
+
+ next_status = page.locator(
+ '.dds-status-editor select[aria-label^="Следующий статус"]'
+ )
+ next_status.wait_for()
+ labels = next_status.locator("option").all_text_contents()
+ if not any("Принята" in label for label in labels):
+ raise RuntimeError(f"статус «Принята» недоступен для {service_title}: {labels}")
+ next_status.select_option(value="accepted")
+ page.locator('button[aria-label="Сохранить статус"]').click()
+ page.wait_for_function("""() => [...document.querySelectorAll(
+ '.dds-service-dock button')].some((button) => button.innerText.includes('Принята'))""")
+
+ page.locator(".dds-service-dock button").filter(has_text="Связь").click()
+ crew_select = page.locator("#dds-crew")
+ crew_select.wait_for()
+ options = crew_select.locator("option").all_text_contents()
+ crew = next((option.strip() for option in options
+ if option.strip().startswith(service_title)), None)
+ if not crew:
+ raise RuntimeError(f"для {service_title} нет доступной бригады: {options}")
+ crew_select.select_option(label=crew)
+ page.get_by_role("button", name="Позвонить старшему группы").click()
+
+ address = page.locator(".dds-case-address > strong").inner_text().strip()
+ page.locator("#dds-phone-address").fill(address)
+ page.locator("#dds-phone-incident").fill(scenario_title)
+ page.locator("#dds-phone-request").fill(
+ "Подтвердите выезд и доложите о прибытии."
+ )
+ page.get_by_role("button", name="Передать сообщение").click()
+ transcript = page.locator(".dds-phone-transcript")
+ transcript.wait_for()
+ page.wait_for_function("""() => {
+ const transcript = document.querySelector('.dds-phone-transcript');
+ const alert = document.querySelector('.dds-alert[role="alert"]');
+ return transcript?.innerText.includes('Адрес:') || Boolean(alert?.innerText.trim());
+ }""", timeout=10_000)
+ text = transcript.inner_text()
+ if "Адрес:" not in text or "Старший группы" not in text:
+ alert = page.locator('.dds-alert[role="alert"]')
+ error = alert.inner_text() if alert.count() else "нет сообщения об ошибке"
+ raise RuntimeError(
+ f"учебный доклад не попал в журнал: {text}; ошибка: {error}; "
+ f"адрес={address!r}, происшествие={scenario_title!r}"
+ )
+ return {"service": service_title, "status": "Принята", "crew": crew,
+ "brief_recorded": True}
+
+
+def smoke_dds_queue(page, base_url: str, output: Path, screenshot_suffix: str) -> dict:
+ """Check per-card state isolation while a second card arrives in queue."""
+ open_instructor(page, base_url)
+ queue = page.locator("details.scenario-queue")
+ queue.locator("summary").click()
+ queue.locator('input[type="checkbox"]').first.check()
+ page.get_by_label("Интервал поступления карточек ДДС, секунд").fill("6")
+ page.get_by_label("Максимум ожидающих карточек ДДС").fill("1")
+ dds_href = start_exercise(page, "Диспетчер ДДС · готовая карточка", "АРМ ДДС")
+ page.goto(dds_href, wait_until="networkidle")
+ page.locator(".dds-workspace").wait_for()
+ first_row = page.locator('.dds-registry tbody tr[aria-current="true"]')
+ first_row.wait_for()
+ first_number = first_row.locator('td[data-label="Номер"] button').inner_text()
+ first_row.get_by_role("button", name="Открыть карточку").click()
+ page.locator(".dds-case-layout").wait_for()
+ first_status_button = page.locator(".dds-service-dock button").first
+ service_title = first_status_button.locator("b").inner_text()
+ first_status_button.click()
+ next_status = page.locator(
+ '.dds-status-editor select[aria-label^="Следующий статус"]'
+ )
+ next_status.wait_for()
+ service_name = next_status.get_attribute("aria-label").removeprefix("Следующий статус ")
+ if not any("Принята" in option for option in next_status.locator("option").all_text_contents()):
+ raise RuntimeError(f"для первой карточки недоступен статус «Принята»: {service_title}")
+ next_status.select_option(value="accepted")
+ page.locator('button[aria-label="Сохранить статус"]').click()
+ page.wait_for_function(
+ """(service) => [...document.querySelectorAll('.dds-service-dock button')]
+ .some((button) => button.innerText.includes(service)
+ && button.innerText.includes('Принята'))""",
+ arg=service_title,
+ timeout=5_000,
+ )
+ page.locator(".dds-case-top button").click()
+ page.wait_for_function(
+ "document.querySelectorAll('.dds-registry tbody tr').length === 2",
+ timeout=20_000,
+ )
+ rows = page.locator(".dds-registry tbody tr")
+ if rows.count() != 2 or page.locator('.dds-registry tbody tr[aria-current="true"]').count() != 1:
+ raise RuntimeError("ожидались две карточки в очереди и ровно одна активная")
+ second_row = rows.nth(1)
+ second_number = second_row.locator('td[data-label="Номер"] button').inner_text()
+ second_row.get_by_role("button", name="Открыть карточку").click()
+ page.locator(".dds-case-top").get_by_text(second_number, exact=False).wait_for()
+ page.locator(".dds-case-top button").click()
+ page.locator(".dds-registry tbody tr").filter(has_text=first_number).get_by_role(
+ "button", name="Открыть карточку"
+ ).click()
+ page.locator(".dds-case-top").get_by_text(first_number, exact=False).wait_for()
+ restored_status = page.locator(".dds-service-dock button").filter(
+ has_text=service_title
+ ).first.inner_text()
+ if "Принята" not in restored_status:
+ raise RuntimeError(f"статус первой карточки потерян после поступления/переключения: {restored_status}")
+ page.locator(".dds-case-top button").click()
+ screen = layout(page)
+ page.screenshot(path=output / f"dds-queue-{screenshot_suffix}.png", full_page=True)
+ stop_exercise_from_instructor(page, base_url, dds_href)
+ return {"cards_visible": 2, "active_cards": 1,
+ "arrival_interval_seconds": 6, "max_waiting": 1,
+ "managed_service": service_name,
+ "first_card_status_preserved_after_second_arrival": True,
+ "switched_to_second_and_back": True,
+ "session_finished": True, "screen": screen}
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--base-url", default="http://127.0.0.1:5175")
+ parser.add_argument("--output", type=Path, default=Path("/tmp/lct-mobile-smoke"))
+ parser.add_argument("--report", type=Path)
+ parser.add_argument("--login")
+ parser.add_argument("--password")
+ parser.add_argument("--width", type=int, default=390)
+ parser.add_argument("--height", type=int, default=844)
+ parser.add_argument("--device-scale-factor", type=float, default=2)
+ args = parser.parse_args()
+ if args.width < 320 or args.height < 480 or args.device_scale_factor <= 0:
+ parser.error("viewport must be at least 320×480 and scale factor must be positive")
+ screenshot_suffix = "mobile" if args.width <= 600 else "desktop"
+ args.base_url = local_url(args.base_url, {"http", "https"})
+ args.output.mkdir(parents=True, exist_ok=True)
+
+ report = {
+ "checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+ "base_url": args.base_url,
+ "viewport": {
+ "width": args.width,
+ "height": args.height,
+ "device_scale_factor": args.device_scale_factor,
+ "touch_target_minimum_px": 40 if args.width <= 600 else None,
+ },
+ "screens": {},
+ "workflow": {},
+ }
+ with sync_playwright() as playwright:
+ browser = playwright.chromium.launch()
+ context = browser.new_context(
+ viewport={"width": args.width, "height": args.height},
+ device_scale_factor=args.device_scale_factor,
+ locale="ru-RU",
+ )
+ page = context.new_page()
+ page.goto(f"{args.base_url}/instructor", wait_until="networkidle")
+ demo_login = page.get_by_role("button", name="Войти в демо одним нажатием")
+ if demo_login.is_visible():
+ demo_login.click()
+ elif args.login and args.password:
+ page.locator('input:not([type="password"])').first.fill(args.login)
+ page.locator('input[type="password"]').fill(args.password)
+ page.get_by_role("button", name="Войти", exact=True).click()
+ elif page.get_by_role("heading", name="Учебный симулятор системы-112").is_visible():
+ raise RuntimeError("stand requires --login and --password")
+ page.locator("#ai-scenario-editor").wait_for()
+ page.screenshot(path=args.output / f"instructor-{screenshot_suffix}.png", full_page=True)
+ report["screens"]["instructor"] = layout(page)
+
+ trainee_href = start_exercise(
+ page, "Оператор 112 · текстовая вводная", "АРМ курсанта"
+ )
+ page.goto(trainee_href, wait_until="networkidle")
+ page.locator(".kio-arm").wait_for()
+ page.screenshot(path=args.output / f"trainee-{screenshot_suffix}.png", full_page=True)
+ report["screens"]["trainee"] = layout(page)
+ stop_exercise_from_instructor(page, args.base_url, trainee_href)
+
+ open_instructor(page, args.base_url)
+ dds_href = start_exercise(
+ page, "Диспетчер ДДС · готовая карточка", "АРМ ДДС"
+ )
+ page.goto(dds_href, wait_until="networkidle")
+ page.locator(".dds-workspace").wait_for()
+ page.screenshot(path=args.output / f"dds-{screenshot_suffix}.png", full_page=True)
+ report["screens"]["dds"] = layout(page)
+
+ active_row = page.locator(".dds-registry tbody tr.dds-selected")
+ scenario_title = active_row.locator(
+ 'td[data-label="Тип происшествия"] .dds-cell-value > span'
+ ).inner_text().split(" · ", 1)[0]
+ page.get_by_role("button", name="Открыть карточку").first.click()
+ page.locator(".dds-case-layout").wait_for()
+ page.screenshot(path=args.output / f"dds-card-{screenshot_suffix}.png", full_page=True)
+ report["screens"]["dds_card"] = layout(page)
+ report["workflow"] = exercise_dds_flow(page, scenario_title)
+ page.screenshot(path=args.output / f"dds-workflow-{screenshot_suffix}.png", full_page=True)
+ report["screens"]["dds_workflow"] = layout(page)
+ page.locator(".dds-service-dock button").last.click()
+ page.locator(".dds-done").wait_for()
+ report["workflow"]["session_finished"] = True
+
+ queue = smoke_dds_queue(page, args.base_url, args.output, screenshot_suffix)
+ report["screens"]["dds_queue"] = queue.pop("screen")
+ report["queue"] = queue
+ browser.close()
+
+ problems = {
+ name: {
+ "horizontal_overflow": data["document_width"] > data["viewport"] + 2,
+ "small_controls": len(data["small_controls"]),
+ "touch_target_check_applies": args.width <= 600,
+ }
+ for name, data in report["screens"].items()
+ }
+ report["problems"] = problems
+ report["passed"] = all(
+ not item["horizontal_overflow"] and (
+ not item["touch_target_check_applies"] or item["small_controls"] == 0
+ )
+ for item in problems.values()
+ )
+ rendered = json.dumps(report, ensure_ascii=False, indent=2)
+ print(rendered)
+ if args.report:
+ args.report.parent.mkdir(parents=True, exist_ok=True)
+ args.report.write_text(rendered + "\n", encoding="utf-8")
+ return 0 if report["passed"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/smoke_persisted_report.py b/scripts/smoke_persisted_report.py
new file mode 100644
index 0000000..1ed7a14
--- /dev/null
+++ b/scripts/smoke_persisted_report.py
@@ -0,0 +1,218 @@
+#!/usr/bin/env python3
+"""Проверка: подробный JSON/CSV/PDF переживает рестарт backend и укладывается в 30 с."""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import inspect
+import json
+import secrets
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+from uuid import uuid4
+
+import websockets
+from sqlalchemy import delete
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "backend"))
+
+from app.api.auth import hash_password # noqa: E402
+from app.db.models import AuditLog, Session, Trainee, User # noqa: E402
+
+
+def local_http(value: str) -> str:
+ from urllib.parse import urlsplit
+
+ parsed = urlsplit(value)
+ if parsed.scheme != "http" or parsed.hostname not in {"127.0.0.1", "localhost", "::1"}:
+ raise ValueError("backend должен быть локальным HTTP URL")
+ return value.rstrip("/")
+
+
+def login_cookie(base: str, login: str, password: str) -> str:
+ request = urllib.request.Request(
+ f"{base}/api/auth/login",
+ data=json.dumps({"login": login, "password": password}).encode(),
+ method="POST",
+ headers={"Content-Type": "application/json"},
+ )
+ with urllib.request.urlopen(request, timeout=10) as response:
+ cookie = response.headers.get("Set-Cookie", "").split(";", 1)[0]
+ if not cookie.startswith("lct_session="):
+ raise RuntimeError("login не выдал cookie")
+ return cookie
+
+
+def get_timed(base: str, path: str, cookie: str) -> tuple[bytes, str, float]:
+ request = urllib.request.Request(f"{base}{path}", headers={"Cookie": cookie})
+ started = time.perf_counter()
+ with urllib.request.urlopen(request, timeout=35) as response:
+ body = response.read()
+ content_type = response.headers.get_content_type()
+ return body, content_type, (time.perf_counter() - started) * 1000
+
+
+async def receive_type(socket, wanted: str, limit: int = 40) -> dict:
+ for _ in range(limit):
+ raw = await asyncio.wait_for(socket.recv(), timeout=10)
+ if isinstance(raw, str):
+ event = json.loads(raw)
+ if event.get("type") == wanted:
+ return event
+ raise RuntimeError(f"не получено событие {wanted}")
+
+
+async def create_temp_user(database_url: str, login: str, password: str) -> None:
+ engine = create_async_engine(database_url)
+ try:
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+ async with factory() as db:
+ db.add(User(
+ login=login,
+ full_name="Проверка архивного отчёта",
+ password_hash=hash_password(password),
+ role="instructor",
+ blocked=False,
+ ))
+ await db.commit()
+ finally:
+ await engine.dispose()
+
+
+async def cleanup(database_url: str, login: str, trainee_name: str, session_id) -> None:
+ engine = create_async_engine(database_url)
+ try:
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+ async with factory() as db:
+ await db.execute(delete(Session).where(Session.id == session_id))
+ await db.execute(delete(User).where(User.login == login))
+ await db.execute(delete(Trainee).where(Trainee.name == trainee_name))
+ await db.execute(delete(AuditLog).where(AuditLog.actor == login))
+ await db.commit()
+ finally:
+ await engine.dispose()
+
+
+async def complete_card(base: str, cookie: str, session_id: str, trainee_name: str) -> None:
+ ws_base = "ws" + base.removeprefix("http")
+ header = "additional_headers" if "additional_headers" in inspect.signature(
+ websockets.connect
+ ).parameters else "extra_headers"
+ options = {header: {"Cookie": cookie}, "open_timeout": 10, "ping_interval": None}
+ async with websockets.connect(f"{ws_base}/ws/control/{session_id}", **options) as control:
+ await control.send(json.dumps({
+ "type": "scenario.start",
+ "scenario_id": "fire-apartment-l2",
+ "trainee": trainee_name,
+ "mode": "training",
+ "exercise": "card",
+ }, ensure_ascii=False))
+ # Control-сокет только отправляет директивы; подтверждение старта
+ # получают рабочие АРМ, поэтому ждём обработки, а не несуществующий ACK.
+ await asyncio.sleep(0.2)
+
+ async with websockets.connect(f"{ws_base}/ws/call/{session_id}", **options) as trainee:
+ await receive_type(trainee, "card.briefing")
+ await trainee.send(json.dumps({
+ "type": "kio.patch",
+ "fields": {
+ "address": "улица Ленина, 14",
+ "floor": "5",
+ "incident_type": "fire",
+ "victims_count": 2,
+ "description": "горит балкон",
+ "signs": ["жилой дом", "балкон", "открытое пламя"],
+ },
+ }, ensure_ascii=False))
+ await receive_type(trainee, "kio.patch")
+ await trainee.send(json.dumps({"type": "card.submit"}))
+ await receive_type(trainee, "score.ready")
+
+
+def restart_backend(base: str) -> float:
+ started = time.perf_counter()
+ subprocess.run(["docker", "compose", "restart", "backend"], cwd=ROOT, check=True,
+ capture_output=True, text=True, timeout=60)
+ deadline = time.monotonic() + 60
+ while time.monotonic() < deadline:
+ try:
+ with urllib.request.urlopen(f"{base}/api/health", timeout=2) as response:
+ if response.status == 200:
+ return (time.perf_counter() - started) * 1000
+ except (OSError, urllib.error.URLError):
+ pass
+ time.sleep(0.25)
+ raise TimeoutError("backend не восстановился после restart")
+
+
+async def run(args: argparse.Namespace) -> int:
+ base = local_http(args.backend_url)
+ session_id = uuid4()
+ suffix = uuid4().hex[:10]
+ login = f"report-smoke-{suffix}"
+ trainee_name = f"report-smoke-trainee-{suffix}"
+ password = secrets.token_urlsafe(24)
+ await create_temp_user(args.database_url, login, password)
+ try:
+ cookie = login_cookie(base, login, password)
+ await complete_card(base, cookie, str(session_id), trainee_name)
+ live_json, _, live_ms = get_timed(base, f"/api/sessions/{session_id}/report", cookie)
+ if json.loads(live_json)["session_id"] != str(session_id):
+ raise RuntimeError("живой отчёт относится к другой сессии")
+
+ restart_ms = await asyncio.to_thread(restart_backend, base)
+ cookie = login_cookie(base, login, password)
+ json_body, json_type, json_ms = get_timed(base, f"/api/sessions/{session_id}/report", cookie)
+ csv_body, csv_type, csv_ms = get_timed(base, f"/api/sessions/{session_id}/report.csv", cookie)
+ pdf_body, pdf_type, pdf_ms = get_timed(base, f"/api/sessions/{session_id}/report.pdf", cookie)
+ decoded = json.loads(json_body)
+ times = [json_ms, csv_ms, pdf_ms]
+ passed = (
+ decoded["session_id"] == str(session_id)
+ and decoded["score_final"] == 100
+ and csv_body.startswith(b"\xef\xbb\xbf")
+ and pdf_body.startswith(b"%PDF-")
+ and json_type == "application/json"
+ and csv_type == "text/csv"
+ and pdf_type == "application/pdf"
+ and max(times) <= 30_000
+ )
+ result = {
+ "checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+ "session_id": str(session_id),
+ "backend_restart_ms": round(restart_ms, 3),
+ "live_json_ms": round(live_ms, 3),
+ "persisted_json_ms": round(json_ms, 3),
+ "persisted_csv_ms": round(csv_ms, 3),
+ "persisted_pdf_ms": round(pdf_ms, 3),
+ "pdf_bytes": len(pdf_body),
+ "requirement_ms": 30_000,
+ "passed": passed,
+ "scope": "card exercise; real PostgreSQL; report fetched after backend restart",
+ }
+ rendered = json.dumps(result, ensure_ascii=False, indent=2)
+ print(rendered)
+ if args.output:
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(rendered + "\n", encoding="utf-8")
+ return 0 if passed else 1
+ finally:
+ await cleanup(args.database_url, login, trainee_name, session_id)
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--backend-url", default="http://127.0.0.1:8000")
+ parser.add_argument(
+ "--database-url",
+ default="postgresql+asyncpg://lct:lct@127.0.0.1:5432/lct",
+ )
+ parser.add_argument("--output", type=Path)
+ raise SystemExit(asyncio.run(run(parser.parse_args())))
diff --git a/scripts/smoke_recording.py b/scripts/smoke_recording.py
new file mode 100644
index 0000000..fe8bda1
--- /dev/null
+++ b/scripts/smoke_recording.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+"""Живая проверка WAV-записи на локальном backend без микрофона."""
+
+import argparse
+import asyncio
+import io
+import inspect
+import json
+import math
+import struct
+import urllib.error
+import urllib.request
+import wave
+from uuid import uuid4
+
+import websockets
+
+from load_browser import control, local_url, login_cookie
+
+
+async def receive_type(socket, event_type: str, limit: int = 30) -> dict:
+ for _ in range(limit):
+ message = await asyncio.wait_for(socket.recv(), timeout=10)
+ if isinstance(message, str):
+ payload = json.loads(message)
+ if payload.get("type") == event_type:
+ return payload
+ raise RuntimeError(f"событие {event_type} не получено")
+
+
+async def run(args: argparse.Namespace) -> int:
+ backend = local_url(args.backend_url, {"http"})
+ cookie = login_cookie(backend, args.login, args.password)
+ ws_base = "ws" + backend.removeprefix("http")
+ session_id = str(uuid4())
+ await control(ws_base, session_id, cookie, {
+ "type": "scenario.start",
+ "scenario_id": args.scenario,
+ "trainee": "recording-smoke",
+ "mode": "training",
+ "exercise": "call",
+ })
+ header_name = "additional_headers" if "additional_headers" in inspect.signature(
+ websockets.connect
+ ).parameters else "extra_headers"
+ async with websockets.connect(
+ f"{ws_base}/ws/call/{session_id}",
+ **{header_name: {"Cookie": cookie}},
+ open_timeout=10,
+ ping_interval=None,
+ ) as socket:
+ await receive_type(socket, "call.incoming")
+ await socket.send(json.dumps({"type": "call.answer"}))
+ await receive_type(socket, "call.started")
+ for frame_index in range(50):
+ frame = b"".join(
+ struct.pack("= 0.9 and payload.startswith(b"RIFF")
+ ),
+ "scope": "synthetic 440-Hz operator audio; VOICE_ENABLED=false, therefore no TTS track",
+ }
+ rendered = json.dumps(result, ensure_ascii=False, indent=2)
+ print(rendered)
+ if args.output:
+ with open(args.output, "w", encoding="utf-8") as stream:
+ stream.write(rendered + "\n")
+ return 0 if result["pass_wav_recording"] else 1
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--backend-url", default="http://127.0.0.1:8000")
+ parser.add_argument("--login", required=True)
+ parser.add_argument("--password", required=True)
+ parser.add_argument("--scenario", default="fire-apartment-l2")
+ parser.add_argument("--output")
+ try:
+ raise SystemExit(asyncio.run(run(parser.parse_args())))
+ except Exception as exc:
+ print(f"recording smoke failed: {type(exc).__name__}: {exc}")
+ raise SystemExit(2)
diff --git a/scripts/smoke_sip.py b/scripts/smoke_sip.py
new file mode 100644
index 0000000..f295466
--- /dev/null
+++ b/scripts/smoke_sip.py
@@ -0,0 +1,286 @@
+#!/usr/bin/env python3
+"""Local SIP/RTP acceptance check for the bundled Asterisk service.
+
+The check registers extension 6001, calls the Echo application at 7000 and
+measures round-trip time for PCMU RTP packets. It intentionally uses only the
+Python standard library so the offline delivery does not need a SIP test tool.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import random
+import re
+import socket
+import statistics
+import struct
+import subprocess
+import time
+import uuid
+from pathlib import Path
+
+
+CRLF = "\r\n"
+
+
+def md5(value: str) -> str:
+ return hashlib.md5(value.encode("utf-8")).hexdigest() # noqa: S324 - SIP Digest requires MD5
+
+
+def parse_message(data: bytes) -> tuple[str, dict[str, str], str]:
+ text = data.decode("utf-8", "replace")
+ head, _, body = text.partition(CRLF + CRLF)
+ lines = head.split(CRLF)
+ headers: dict[str, str] = {}
+ for line in lines[1:]:
+ if ":" in line:
+ key, value = line.split(":", 1)
+ headers[key.strip().lower()] = value.strip()
+ return lines[0], headers, body
+
+
+def digest_header(challenge: str, user: str, password: str, method: str, uri: str) -> str:
+ parsed = {key: quoted or plain for key, quoted, plain in re.findall(
+ r'(\w+)=(?:"([^"]*)"|([^,\s]+))', challenge
+ )}
+ realm = parsed["realm"]
+ nonce = parsed["nonce"]
+ response_parts = [md5(f"{user}:{realm}:{password}"), nonce]
+ attrs = [
+ f'username="{user}"', f'realm="{realm}"', f'nonce="{nonce}"',
+ f'uri="{uri}"',
+ ]
+ qop = parsed.get("qop", "").split(",")[0]
+ if qop:
+ nc = "00000001"
+ cnonce = uuid.uuid4().hex[:16]
+ response_parts.extend([nc, cnonce, qop])
+ attrs.extend([f"qop={qop}", f"nc={nc}", f'cnonce="{cnonce}"'])
+ response_parts.append(md5(f"{method}:{uri}"))
+ attrs.extend([f'response="{md5(":".join(response_parts))}"', "algorithm=MD5"])
+ return "Digest " + ", ".join(attrs)
+
+
+def request(
+ method: str,
+ uri: str,
+ host: str,
+ local_port: int,
+ user: str,
+ call_id: str,
+ cseq: int,
+ branch: str,
+ *,
+ authorization: str | None = None,
+ body: str = "",
+ to_user: str | None = None,
+ from_tag: str | None = None,
+ to_header: str | None = None,
+) -> bytes:
+ target = to_user or user
+ from_tag = from_tag or uuid.uuid4().hex[:10]
+ headers = [
+ f"{method} {uri} SIP/2.0",
+ f"Via: SIP/2.0/UDP 127.0.0.1:{local_port};branch={branch};rport",
+ "Max-Forwards: 70",
+ f'From: ;tag={from_tag}',
+ f"To: {to_header or f''}",
+ f"Call-ID: {call_id}",
+ f"CSeq: {cseq} {method}",
+ f"Contact: ",
+ "User-Agent: LCT-offline-smoke/1.0",
+ ]
+ if authorization:
+ headers.append(f"Authorization: {authorization}")
+ if body:
+ headers.append("Content-Type: application/sdp")
+ headers.extend([f"Content-Length: {len(body.encode())}", "", body])
+ return CRLF.join(headers).encode()
+
+
+def receive_final(sock: socket.socket, timeout: float = 3.0) -> tuple[str, dict[str, str], str]:
+ deadline = time.monotonic() + timeout
+ last = None
+ while time.monotonic() < deadline:
+ sock.settimeout(max(0.05, deadline - time.monotonic()))
+ parsed = parse_message(sock.recvfrom(65535)[0])
+ last = parsed
+ status = parsed[0]
+ if status.startswith("SIP/2.0") and not status.startswith("SIP/2.0 1"):
+ return parsed
+ raise TimeoutError(f"No final SIP response; last={last and last[0]}")
+
+
+def compose_password(user: str) -> str:
+ result = subprocess.run(
+ [
+ "docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.sip.yml",
+ "exec", "-T", "sip", "cat", "/var/lib/lct-sip/credentials.env",
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ prefix = f"SIP_{user}_PASSWORD="
+ for line in result.stdout.splitlines():
+ if line.startswith(prefix):
+ return line[len(prefix):]
+ raise RuntimeError(f"No password for SIP extension {user}")
+
+
+def send_ack(
+ sock: socket.socket, host: str, port: int, user: str, call_id: str, cseq: int,
+ target: str, from_tag: str, to_header: str | None = None,
+) -> None:
+ payload = request(
+ "ACK", f"sip:{target}@{host}", host, sock.getsockname()[1], user, call_id, cseq,
+ f"z9hG4bK{uuid.uuid4().hex}", to_user=target, from_tag=from_tag,
+ to_header=to_header,
+ )
+ sock.sendto(payload, (host, port))
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=5060)
+ parser.add_argument("--rtp-host-offset", type=int, default=0,
+ help="Host-to-container RTP port offset (1000 for 11000:10000 mappings)")
+ parser.add_argument("--user", default="6001")
+ parser.add_argument("--password", default=os.getenv("SIP_PASSWORD"))
+ parser.add_argument("--packets", type=int, default=30)
+ parser.add_argument("--output", type=Path)
+ args = parser.parse_args()
+ password = args.password or compose_password(args.user)
+
+ sip = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sip.bind(("127.0.0.1", 0))
+ sip_port = sip.getsockname()[1]
+ registrar = f"sip:{args.host}"
+
+ register_id = f"{uuid.uuid4().hex}@lct-smoke"
+ packet = request("REGISTER", registrar, args.host, sip_port, args.user, register_id, 1,
+ f"z9hG4bK{uuid.uuid4().hex}")
+ sip.sendto(packet, (args.host, args.port))
+ status, headers, _ = receive_final(sip)
+ if "401" not in status:
+ raise RuntimeError(f"Expected REGISTER challenge, got {status}")
+ challenge = headers.get("www-authenticate")
+ if not challenge:
+ raise RuntimeError("REGISTER challenge has no WWW-Authenticate header")
+ auth = digest_header(challenge, args.user, password, "REGISTER", registrar)
+ packet = request("REGISTER", registrar, args.host, sip_port, args.user, register_id, 2,
+ f"z9hG4bK{uuid.uuid4().hex}", authorization=auth)
+ sip.sendto(packet, (args.host, args.port))
+ status, _, _ = receive_final(sip)
+ if "200" not in status:
+ raise RuntimeError(f"REGISTER failed: {status}")
+
+ rtp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ rtp.bind(("0.0.0.0", 0))
+ rtp.settimeout(1.0)
+ rtp_port = rtp.getsockname()[1]
+ sdp = CRLF.join([
+ "v=0", f"o=lct 1 1 IN IP4 host.docker.internal", "s=LCT RTP smoke",
+ "c=IN IP4 host.docker.internal", "t=0 0", f"m=audio {rtp_port} RTP/AVP 0",
+ "a=rtpmap:0 PCMU/8000", "a=sendrecv", "",
+ ])
+ call_id = f"{uuid.uuid4().hex}@lct-smoke"
+ from_tag = uuid.uuid4().hex[:10]
+ invite_uri = f"sip:7000@{args.host}"
+ invite_cseq = 1
+ packet = request("INVITE", invite_uri, args.host, sip_port, args.user, call_id, invite_cseq,
+ f"z9hG4bK{uuid.uuid4().hex}", body=sdp, to_user="7000",
+ from_tag=from_tag)
+ sip.sendto(packet, (args.host, args.port))
+ status, headers, _ = receive_final(sip)
+ if "401" in status or "407" in status:
+ send_ack(sip, args.host, args.port, args.user, call_id, invite_cseq, "7000",
+ from_tag, headers.get("to"))
+ challenge = headers.get("www-authenticate") or headers.get("proxy-authenticate")
+ if not challenge:
+ raise RuntimeError("INVITE challenge has no authentication header")
+ invite_cseq += 1
+ auth = digest_header(challenge, args.user, password, "INVITE", invite_uri)
+ packet = request("INVITE", invite_uri, args.host, sip_port, args.user, call_id, invite_cseq,
+ f"z9hG4bK{uuid.uuid4().hex}", authorization=auth, body=sdp,
+ to_user="7000", from_tag=from_tag)
+ sip.sendto(packet, (args.host, args.port))
+ status, answer_headers, answer_sdp = receive_final(sip)
+ else:
+ answer_headers = headers
+ answer_sdp = ""
+ if "200" not in status:
+ raise RuntimeError(f"INVITE failed: {status}")
+ send_ack(sip, args.host, args.port, args.user, call_id, invite_cseq, "7000",
+ from_tag, answer_headers.get("to"))
+
+ match = re.search(r"^m=audio\s+(\d+)", answer_sdp, re.MULTILINE)
+ if not match:
+ raise RuntimeError("SIP 200 response contains no RTP audio port")
+ asterisk_rtp_port = int(match.group(1))
+ target = (args.host, asterisk_rtp_port + args.rtp_host_offset)
+ ssrc = random.getrandbits(32)
+ sequence = random.getrandbits(16)
+ timestamp = random.getrandbits(32)
+ samples: list[float] = []
+
+ # Prime symmetric RTP, then measure matched echoed sequence numbers.
+ for index in range(args.packets + 3):
+ seq = (sequence + index) & 0xFFFF
+ packet = struct.pack("!BBHII", 0x80, 0x00, seq, (timestamp + index * 160) & 0xFFFFFFFF, ssrc)
+ marker = struct.pack("!I", index)
+ packet += marker + b"\xff" * 156
+ started = time.perf_counter()
+ rtp.sendto(packet, target)
+ deadline = started + 0.5
+ while time.perf_counter() < deadline:
+ try:
+ response, _ = rtp.recvfrom(2048)
+ except socket.timeout:
+ break
+ if len(response) >= 16 and response[12:16] == marker:
+ if index >= 3:
+ samples.append((time.perf_counter() - started) * 1000)
+ break
+ rtp.settimeout(max(0.001, deadline - time.perf_counter()))
+ rtp.settimeout(0.5)
+ time.sleep(0.02)
+
+ if not samples:
+ raise RuntimeError(f"No echoed RTP received from {target}")
+ ordered = sorted(samples)
+ p95 = ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))]
+ result = {
+ "checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+ "sip_registration": "ok",
+ "sip_echo_call": "ok",
+ "rtp_packets_received": len(samples),
+ "rtp_rtt_ms_median": round(statistics.median(samples), 3),
+ "rtp_rtt_ms_p95": round(p95, 3),
+ "requirement_ms": 150,
+ "passed": p95 <= 150,
+ }
+
+ bye = request(
+ "BYE", invite_uri, args.host, sip_port, args.user, call_id, invite_cseq + 1,
+ f"z9hG4bK{uuid.uuid4().hex}", to_user="7000", from_tag=from_tag,
+ to_header=answer_headers.get("to"),
+ )
+ sip.sendto(bye, (args.host, args.port))
+ bye_status, _, _ = receive_final(sip)
+ result["sip_hangup"] = "ok" if "200" in bye_status else bye_status
+ result["passed"] = result["passed"] and result["sip_hangup"] == "ok"
+ rendered = json.dumps(result, ensure_ascii=False, indent=2)
+ print(rendered)
+ if args.output:
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(rendered + "\n", encoding="utf-8")
+ return 0 if result["passed"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/smoke_sip_websocket.py b/scripts/smoke_sip_websocket.py
new file mode 100644
index 0000000..3be4287
--- /dev/null
+++ b/scripts/smoke_sip_websocket.py
@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+"""Проверка Digest REGISTER браузерного SIP-транспорта Asterisk по WebSocket."""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import ssl
+import time
+import uuid
+from pathlib import Path
+from urllib.parse import urlsplit
+
+import websockets
+
+from smoke_sip import CRLF, compose_password, digest_header, parse_message
+
+
+def local_ws(value: str) -> str:
+ parsed = urlsplit(value)
+ if parsed.scheme not in {"ws", "wss"} or parsed.hostname not in {
+ "127.0.0.1", "localhost", "::1"
+ }:
+ raise ValueError("WebSocket URL должен вести на локальный стенд")
+ return value
+
+
+def register_message(
+ host: str, user: str, call_id: str, cseq: int, branch: str,
+ authorization: str | None = None, expires: int = 300,
+) -> str:
+ uri = f"sip:{host}"
+ tag = call_id[:10]
+ lines = [
+ f"REGISTER {uri} SIP/2.0",
+ f"Via: SIP/2.0/WSS browser.invalid;branch={branch};rport",
+ "Max-Forwards: 70",
+ f"From: ;tag={tag}",
+ f"To: ",
+ f"Call-ID: {call_id}@lct-websocket-smoke",
+ f"CSeq: {cseq} REGISTER",
+ f"Contact: ;expires={expires}",
+ f"Expires: {expires}",
+ "User-Agent: LCT-WebSocket-smoke/1.0",
+ ]
+ if authorization:
+ lines.append(f"Authorization: {authorization}")
+ lines.extend(["Content-Length: 0", "", ""])
+ return CRLF.join(lines)
+
+
+async def receive_response(socket, timeout: float = 5) -> tuple[str, dict[str, str], str]:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ raw = await asyncio.wait_for(socket.recv(), timeout=deadline - time.monotonic())
+ parsed = parse_message(raw.encode() if isinstance(raw, str) else raw)
+ if parsed[0].startswith("SIP/2.0"):
+ return parsed
+ raise TimeoutError("нет ответа SIP по WebSocket")
+
+
+async def run(args: argparse.Namespace) -> int:
+ url = local_ws(args.url)
+ password = args.password or compose_password(args.user)
+ host = args.domain
+ call_id = uuid.uuid4().hex
+ registrar = f"sip:{host}"
+ started = time.perf_counter()
+ tls = None
+ if url.startswith("wss:"):
+ tls = ssl._create_unverified_context() if args.insecure else True
+ async with websockets.connect(
+ url,
+ subprotocols=["sip"],
+ open_timeout=args.timeout,
+ close_timeout=2,
+ ping_interval=None,
+ ssl=tls,
+ ) as socket:
+ await socket.send(register_message(
+ host, args.user, call_id, 1, f"z9hG4bK{uuid.uuid4().hex}"
+ ))
+ status, headers, _ = await receive_response(socket, args.timeout)
+ if "401" not in status:
+ raise RuntimeError(f"ожидался Digest challenge, получено {status}")
+ challenge = headers.get("www-authenticate")
+ if not challenge:
+ raise RuntimeError("в challenge отсутствует WWW-Authenticate")
+ auth = digest_header(challenge, args.user, password, "REGISTER", registrar)
+ await socket.send(register_message(
+ host, args.user, call_id, 2, f"z9hG4bK{uuid.uuid4().hex}", auth
+ ))
+ status, _, _ = await receive_response(socket, args.timeout)
+ if "200" not in status:
+ raise RuntimeError(f"WebSocket REGISTER отклонён: {status}")
+ elapsed_ms = (time.perf_counter() - started) * 1000
+ # Снимаем регистрацию тем же аутентифицированным диалогом, чтобы smoke
+ # не оставлял контакт и не мешал браузерному абоненту.
+ await socket.send(register_message(
+ host, args.user, call_id, 3, f"z9hG4bK{uuid.uuid4().hex}", auth, expires=0
+ ))
+ unregister_status, _, _ = await receive_response(socket, args.timeout)
+
+ result = {
+ "checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+ "url": url,
+ "extension": args.user,
+ "websocket_subprotocol": "sip",
+ "digest_challenge": "ok",
+ "registration": "ok",
+ "unregister": "ok" if "200" in unregister_status else unregister_status,
+ "registration_ms": round(elapsed_ms, 3),
+ "passed": "200" in unregister_status,
+ }
+ rendered = json.dumps(result, ensure_ascii=False, indent=2)
+ print(rendered)
+ if args.output:
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(rendered + "\n", encoding="utf-8")
+ return 0 if result["passed"] else 1
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--url", default="ws://127.0.0.1:8088/ws")
+ parser.add_argument("--domain", default="localhost")
+ parser.add_argument("--user", default="6101")
+ parser.add_argument("--password")
+ parser.add_argument("--timeout", type=float, default=8)
+ parser.add_argument("--insecure", action="store_true",
+ help="доверять только self-signed сертификату локального стенда")
+ parser.add_argument("--output", type=Path)
+ raise SystemExit(asyncio.run(run(parser.parse_args())))
diff --git a/scripts/smoke_webrtc_browser.py b/scripts/smoke_webrtc_browser.py
new file mode 100644
index 0000000..05849ab
--- /dev/null
+++ b/scripts/smoke_webrtc_browser.py
@@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+"""Два Chromium-клиента: WSS-регистрация, WebRTC-вызов 6101↔6102 и запись."""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import secrets
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+from playwright.async_api import async_playwright
+from sqlalchemy import delete
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+from load_browser import local_url, login_cookie
+from smoke_sip import compose_password
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "backend"))
+
+from app.api.auth import hash_password # noqa: E402
+from app.db.models import AuditLog, User # noqa: E402
+
+
+async def create_account(database_url: str, login: str, password: str) -> None:
+ engine = create_async_engine(database_url)
+ try:
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+ async with factory() as db:
+ db.add(User(
+ login=login,
+ full_name="WebRTC browser smoke",
+ password_hash=hash_password(password),
+ role="instructor",
+ blocked=False,
+ ))
+ await db.commit()
+ finally:
+ await engine.dispose()
+
+
+async def cleanup_account(database_url: str, login: str) -> None:
+ engine = create_async_engine(database_url)
+ try:
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+ async with factory() as db:
+ await db.execute(delete(User).where(User.login == login))
+ await db.execute(delete(AuditLog).where(AuditLog.actor == login))
+ await db.commit()
+ finally:
+ await engine.dispose()
+
+
+def recordings() -> dict[str, int]:
+ result = subprocess.run(
+ [
+ "docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.sip.yml",
+ "exec", "-T", "sip", "find", "/recordings", "-maxdepth", "1", "-type", "f",
+ "-name", "*.wav", "-printf", "%f %s\\n",
+ ],
+ cwd=ROOT,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ found: dict[str, int] = {}
+ for line in result.stdout.splitlines():
+ name, _, size = line.rpartition(" ")
+ if name and size.isdigit():
+ found[name] = int(size)
+ return found
+
+
+async def configure(page, extension: str, password: str, target: str, timeout_ms: float) -> None:
+ await page.get_by_label("SIP внутренний номер").fill(extension)
+ await page.get_by_label("Пароль SIP").fill(password)
+ await page.get_by_role("button", name="Подключить телефон").click()
+ await page.get_by_text("зарегистрирован в Asterisk", exact=False).wait_for(timeout=timeout_ms)
+ await page.get_by_label("Номер SIP собеседника").fill(target)
+
+
+async def run(args: argparse.Namespace) -> int:
+ frontend = local_url(args.frontend_url, {"https"})
+ backend = local_url(args.backend_url, {"http"})
+ login = f"webrtc-smoke-{secrets.token_hex(5)}"
+ app_password = secrets.token_urlsafe(24)
+ await create_account(args.database_url, login, app_password)
+ try:
+ cookie = login_cookie(backend, login, app_password)
+ cookie_name, cookie_value = cookie.split("=", 1)
+ sip_6101 = compose_password("6101")
+ sip_6102 = compose_password("6102")
+ before = recordings()
+ errors: list[str] = []
+ started = time.perf_counter()
+ timeout_ms = args.timeout * 1000
+
+ async with async_playwright() as playwright:
+ browser = await playwright.chromium.launch(headless=True, args=[
+ "--use-fake-ui-for-media-stream",
+ "--use-fake-device-for-media-stream",
+ ])
+ contexts = []
+ pages = []
+ for _ in range(2):
+ context = await browser.new_context(ignore_https_errors=True)
+ await context.grant_permissions(["microphone"], origin=frontend)
+ await context.add_cookies([{
+ "name": cookie_name,
+ "value": cookie_value,
+ "url": frontend,
+ "httpOnly": True,
+ "secure": True,
+ "sameSite": "Lax",
+ }])
+ page = await context.new_page()
+ page.on("pageerror", lambda error: errors.append(str(error)))
+ await page.goto(f"{frontend}/phone", wait_until="networkidle", timeout=timeout_ms)
+ contexts.append(context)
+ pages.append(page)
+
+ await asyncio.gather(
+ configure(pages[0], "6101", sip_6101, "6102", timeout_ms),
+ configure(pages[1], "6102", sip_6102, "6101", timeout_ms),
+ )
+ registered_ms = (time.perf_counter() - started) * 1000
+ await pages[0].get_by_role("button", name="Позвонить").click()
+ await pages[1].get_by_role("button", name="Ответить · 6101").wait_for(timeout=timeout_ms)
+ await pages[1].get_by_role("button", name="Ответить · 6101").click()
+ await asyncio.gather(
+ pages[0].get_by_text("разговор идёт", exact=False).wait_for(timeout=timeout_ms),
+ pages[1].get_by_text("разговор идёт", exact=False).wait_for(timeout=timeout_ms),
+ )
+ await asyncio.gather(
+ pages[0].wait_for_function(
+ "document.querySelector('audio')?.srcObject?.getAudioTracks().length > 0",
+ timeout=timeout_ms,
+ ),
+ pages[1].wait_for_function(
+ "document.querySelector('audio')?.srcObject?.getAudioTracks().length > 0",
+ timeout=timeout_ms,
+ ),
+ )
+ connected_ms = (time.perf_counter() - started) * 1000
+ await asyncio.sleep(1)
+ await pages[0].get_by_role("button", name="Завершить").click()
+ await asyncio.gather(
+ pages[0].get_by_text("зарегистрирован в Asterisk", exact=False).wait_for(timeout=timeout_ms),
+ pages[1].get_by_text("зарегистрирован в Asterisk", exact=False).wait_for(timeout=timeout_ms),
+ )
+ for context in contexts:
+ await context.close()
+ await browser.close()
+
+ deadline = time.monotonic() + 10
+ after = recordings()
+ new_recordings = {name: size for name, size in after.items() if name not in before and size > 44}
+ while not new_recordings and time.monotonic() < deadline:
+ await asyncio.sleep(0.5)
+ after = recordings()
+ new_recordings = {name: size for name, size in after.items() if name not in before and size > 44}
+
+ passed = not errors and bool(new_recordings)
+ result = {
+ "checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+ "frontend": frontend,
+ "extensions": ["6101", "6102"],
+ "registered_clients": 2,
+ "registered_ms": round(registered_ms, 3),
+ "call_connected_ms": round(connected_ms, 3),
+ "remote_audio_tracks": [1, 1],
+ "browser_errors": errors,
+ "new_recordings": new_recordings,
+ "passed": passed,
+ "scope": "two isolated Chromium contexts; WSS/SIP Digest; WebRTC audio tracks; Asterisk MixMonitor",
+ }
+ rendered = json.dumps(result, ensure_ascii=False, indent=2)
+ print(rendered)
+ if args.output:
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(rendered + "\n", encoding="utf-8")
+ return 0 if passed else 1
+ finally:
+ await cleanup_account(args.database_url, login)
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--frontend-url", default="https://127.0.0.1:5443")
+ parser.add_argument("--backend-url", default="http://127.0.0.1:8000")
+ parser.add_argument(
+ "--database-url", default="postgresql+asyncpg://lct:lct@127.0.0.1:5432/lct"
+ )
+ parser.add_argument("--timeout", type=float, default=30)
+ parser.add_argument("--output", type=Path)
+ raise SystemExit(asyncio.run(run(parser.parse_args())))
diff --git a/scripts/test_recovery.py b/scripts/test_recovery.py
new file mode 100644
index 0000000..65d735f
--- /dev/null
+++ b/scripts/test_recovery.py
@@ -0,0 +1,298 @@
+#!/usr/bin/env python3
+"""Замер переподключения браузерного WSS после перезапуска frontend-proxy."""
+
+import argparse
+import asyncio
+import json
+import secrets
+import sys
+import time
+from pathlib import Path
+from uuid import uuid4
+
+from playwright.async_api import async_playwright
+from sqlalchemy import delete
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+from load_browser import control, local_url, login_cookie
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "backend"))
+
+from app.api.auth import hash_password # noqa: E402
+from app.db.models import AuditLog, Session, Trainee, User # noqa: E402
+
+
+async def create_temp_accounts(
+ database_url: str,
+ instructor_login: str,
+ instructor_password: str,
+ trainee_login: str,
+ trainee_password: str,
+ trainee_name: str,
+):
+ """Создать изолированную пару ролей и вернуть ID привязанного курсанта."""
+ engine = create_async_engine(database_url)
+ try:
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+ async with factory() as db:
+ trainee = Trainee(name=trainee_name)
+ db.add(trainee)
+ await db.flush()
+ db.add_all([
+ User(
+ login=instructor_login,
+ full_name="Преподаватель проверки восстановления",
+ password_hash=hash_password(instructor_password),
+ role="instructor",
+ blocked=False,
+ ),
+ User(
+ login=trainee_login,
+ full_name=trainee_name,
+ password_hash=hash_password(trainee_password),
+ role="trainee",
+ trainee_id=trainee.id,
+ blocked=False,
+ ),
+ ])
+ await db.commit()
+ return trainee.id
+ finally:
+ await engine.dispose()
+
+
+async def cleanup_temp_accounts(
+ database_url: str,
+ logins: list[str],
+ trainee_name: str,
+ session_id,
+) -> None:
+ engine = create_async_engine(database_url)
+ try:
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+ async with factory() as db:
+ await db.execute(delete(Session).where(Session.id == session_id))
+ await db.execute(delete(User).where(User.login.in_(logins)))
+ await db.execute(delete(Trainee).where(Trainee.name == trainee_name))
+ await db.execute(delete(AuditLog).where(AuditLog.actor.in_(logins)))
+ await db.commit()
+ finally:
+ await engine.dispose()
+
+
+async def compose_service(action: str, service: str) -> None:
+ process = await asyncio.create_subprocess_exec(
+ "docker", "compose", action, service,
+ cwd=ROOT,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, stderr = await process.communicate()
+ if process.returncode:
+ detail = (stderr or stdout).decode(errors="replace").strip()
+ raise RuntimeError(f"docker compose {action} {service}: {detail}")
+
+
+async def wait_service_healthy(service: str, timeout: float = 45) -> None:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ process = await asyncio.create_subprocess_exec(
+ "docker", "compose", "ps", service,
+ cwd=ROOT,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, _ = await process.communicate()
+ if process.returncode == 0 and b"(healthy)" in stdout:
+ return
+ await asyncio.sleep(0.5)
+ raise TimeoutError(f"контейнер {service} не стал healthy за {timeout} с")
+
+
+async def run(args: argparse.Namespace) -> int:
+ frontend = local_url(args.frontend_url, {"https"})
+ backend = local_url(args.backend_url, {"http"})
+ ephemeral = not args.login and not args.password
+ if bool(args.login) != bool(args.password):
+ raise ValueError("login и password задаются вместе либо не задаются")
+ instructor_login = args.login
+ instructor_password = args.password
+ trainee_login = args.trainee_login
+ trainee_password = args.trainee_password
+ trainee_name = "recovery-check"
+ trainee_id = None
+ session_uuid = uuid4()
+ if ephemeral:
+ suffix = session_uuid.hex[:10]
+ instructor_login = f"recovery-instructor-{suffix}"
+ instructor_password = secrets.token_urlsafe(24)
+ trainee_login = f"recovery-trainee-{suffix}"
+ trainee_password = secrets.token_urlsafe(24)
+ trainee_name = f"recovery-trainee-{suffix}"
+ trainee_id = await create_temp_accounts(
+ args.database_url,
+ instructor_login,
+ instructor_password,
+ trainee_login,
+ trainee_password,
+ trainee_name,
+ )
+ elif not trainee_login or not trainee_password:
+ raise ValueError("для существующих аккаунтов нужны trainee-login и trainee-password")
+ assert instructor_login and instructor_password and trainee_login and trainee_password
+ instructor_cookie = login_cookie(backend, instructor_login, instructor_password)
+ trainee_cookie = login_cookie(backend, trainee_login, trainee_password)
+ cookie_name, cookie_value = trainee_cookie.split("=", 1)
+ ws_base = "ws" + backend.removeprefix("http")
+ session_id = str(session_uuid)
+ started = False
+ frontend_stopped = False
+ backend_paused = False
+ try:
+ await control(ws_base, session_id, instructor_cookie, {
+ "type": "scenario.start",
+ "scenario_id": args.scenario,
+ "trainee": trainee_name,
+ "trainee_id": str(trainee_id) if trainee_id else None,
+ "mode": "training",
+ "exercise": "card",
+ })
+ started = True
+ async with async_playwright() as playwright:
+ browser = await playwright.chromium.launch(headless=True)
+ context = await browser.new_context(
+ viewport={"width": 1280, "height": 720},
+ ignore_https_errors=args.ignore_https_errors,
+ )
+ await context.add_cookies([{
+ "name": cookie_name,
+ "value": cookie_value,
+ "url": frontend,
+ "httpOnly": True,
+ "secure": True,
+ "sameSite": "Lax",
+ }])
+ page = await context.new_page()
+ errors: list[str] = []
+ page.on("pageerror", lambda error: errors.append(str(error)))
+ await page.goto(
+ f"{frontend}/trainee?session={session_id}",
+ wait_until="domcontentloaded",
+ timeout=args.timeout * 1000,
+ )
+ address = page.locator(".kio-arm-row-address input")
+ await address.wait_for(
+ timeout=args.timeout * 1000,
+ )
+ await page.locator(".arm-topbar-state .state-ok").wait_for(
+ timeout=args.timeout * 1000,
+ )
+ await page.evaluate("""() => {
+ const node = document.querySelector('.arm-topbar-state');
+ window.__lctRecoveryStates = [node?.textContent ?? 'missing'];
+ new MutationObserver(() => window.__lctRecoveryStates.push(
+ node?.textContent ?? 'missing'
+ )).observe(node, {attributes: true, childList: true, subtree: true});
+ }""")
+
+ buffered_value = "улица Буферная, дом 30"
+ started_at = time.perf_counter()
+ # Держим существующее WSS-соединение открытым, но не даём backend
+ # подтвердить правку. Это воспроизводит пакет, зависший ровно в
+ # момент отказа прокси, без искусственного доступа к JS-сокету.
+ await compose_service("pause", "backend")
+ backend_paused = True
+ await address.fill(buffered_value)
+ await page.locator(".kio-arm-row-address.kio-arm-pending").wait_for(
+ timeout=args.timeout * 1000,
+ )
+ await compose_service("stop", "frontend")
+ frontend_stopped = True
+ await page.wait_for_function(
+ "window.__lctRecoveryStates.some(value => !value.includes('АРМ подключён'))",
+ timeout=args.timeout * 1000,
+ )
+ await compose_service("unpause", "backend")
+ backend_paused = False
+ await wait_service_healthy("backend")
+ await compose_service("start", "frontend")
+ frontend_stopped = False
+ await page.wait_for_function(
+ "window.__lctRecoveryStates.at(-1).includes('АРМ подключён')",
+ timeout=args.timeout * 1000,
+ )
+ await page.wait_for_function(
+ "!document.querySelector('.kio-arm-row-address')?.classList.contains('kio-arm-pending')",
+ timeout=args.timeout * 1000,
+ )
+ buffered_value_after = await page.locator(".kio-arm-row-address input").input_value()
+ recovery_seconds = time.perf_counter() - started_at
+ states = await page.evaluate("window.__lctRecoveryStates")
+ await context.close()
+ await browser.close()
+
+ result = {
+ "frontend": frontend,
+ "session_id": session_id,
+ "component_restarted": "frontend (Nginx TLS/WSS proxy) container stop/start",
+ "observed_states": states,
+ "recovery_seconds": recovery_seconds,
+ "browser_errors": errors,
+ "buffered_patch": {
+ "field": "address",
+ "value": buffered_value_after,
+ "confirmed_by_server": buffered_value_after == buffered_value,
+ },
+ "pass_recovery_30s": (
+ recovery_seconds <= 30 and not errors and buffered_value_after == buffered_value
+ ),
+ "scope": (
+ "one live card exercise; backend acknowledgement is stalled after address edit; "
+ "Nginx is stopped; after recovery reconnect repeats only the idempotent KIO patch"
+ ),
+ }
+ rendered = json.dumps(result, ensure_ascii=False, indent=2)
+ print(rendered)
+ if args.output:
+ with open(args.output, "w", encoding="utf-8") as stream:
+ stream.write(rendered + "\n")
+ return 0 if result["pass_recovery_30s"] else 1
+ finally:
+ if backend_paused:
+ await compose_service("unpause", "backend")
+ await wait_service_healthy("backend")
+ if frontend_stopped:
+ await compose_service("start", "frontend")
+ if started:
+ await control(ws_base, session_id, instructor_cookie, {"type": "session.stop"})
+ if ephemeral:
+ await cleanup_temp_accounts(
+ args.database_url,
+ [instructor_login, trainee_login],
+ trainee_name,
+ session_uuid,
+ )
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--frontend-url", default="https://127.0.0.1:5443")
+ parser.add_argument("--backend-url", default="http://127.0.0.1:8000")
+ parser.add_argument("--login")
+ parser.add_argument("--password")
+ parser.add_argument("--trainee-login")
+ parser.add_argument("--trainee-password")
+ parser.add_argument(
+ "--database-url",
+ default="postgresql+asyncpg://lct:lct@127.0.0.1:5432/lct",
+ )
+ parser.add_argument("--scenario", default="fire-apartment-l2")
+ parser.add_argument("--timeout", type=float, default=30)
+ parser.add_argument("--ignore-https-errors", action="store_true")
+ parser.add_argument("--output")
+ try:
+ raise SystemExit(asyncio.run(run(parser.parse_args())))
+ except Exception as exc:
+ print(f"recovery check failed: {type(exc).__name__}: {exc}")
+ raise SystemExit(2)
diff --git a/sip/.dockerignore b/sip/.dockerignore
new file mode 100644
index 0000000..5d6d6ca
--- /dev/null
+++ b/sip/.dockerignore
@@ -0,0 +1,2 @@
+*.log
+*.wav
diff --git a/sip/Dockerfile b/sip/Dockerfile
new file mode 100644
index 0000000..f10c0d3
--- /dev/null
+++ b/sip/Dockerfile
@@ -0,0 +1,22 @@
+FROM ubuntu:22.04
+
+ENV DEBIAN_FRONTEND=noninteractive
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends asterisk ca-certificates gettext-base openssl \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY pjsip.conf.template /opt/lct-sip/pjsip.conf.template
+COPY extensions.conf /etc/asterisk/extensions.conf
+COPY http.conf /etc/asterisk/http.conf
+COPY modules.conf /etc/asterisk/modules.conf
+COPY rtp.conf /etc/asterisk/rtp.conf
+COPY entrypoint.sh /usr/local/bin/lct-sip-entrypoint
+RUN chmod +x /usr/local/bin/lct-sip-entrypoint \
+ && mkdir -p /recordings /var/lib/lct-sip /tls \
+ && chown -R asterisk:asterisk /recordings /var/lib/lct-sip
+
+EXPOSE 5060/udp 5060/tcp 5061/tcp 8088/tcp 10000-10099/udp
+HEALTHCHECK --interval=10s --timeout=3s --retries=6 --start-period=15s \
+ CMD asterisk -rx "core show uptime" >/dev/null 2>&1 || exit 1
+
+ENTRYPOINT ["/usr/local/bin/lct-sip-entrypoint"]
diff --git a/sip/entrypoint.sh b/sip/entrypoint.sh
new file mode 100644
index 0000000..cb2d813
--- /dev/null
+++ b/sip/entrypoint.sh
@@ -0,0 +1,50 @@
+#!/bin/sh
+set -eu
+
+credentials=/var/lib/lct-sip/credentials.env
+mkdir -p /var/lib/lct-sip /recordings /tls
+umask 077
+
+override_6001=${SIP_6001_PASSWORD:-}
+override_6002=${SIP_6002_PASSWORD:-}
+override_6003=${SIP_6003_PASSWORD:-}
+override_6101=${SIP_6101_PASSWORD:-}
+override_6102=${SIP_6102_PASSWORD:-}
+if [ -s "$credentials" ]; then
+ # Файл содержит только сгенерированные hex-значения без shell-конструкций.
+ . "$credentials"
+fi
+SIP_6001_PASSWORD=${override_6001:-${SIP_6001_PASSWORD:-$(openssl rand -hex 16)}}
+SIP_6002_PASSWORD=${override_6002:-${SIP_6002_PASSWORD:-$(openssl rand -hex 16)}}
+SIP_6003_PASSWORD=${override_6003:-${SIP_6003_PASSWORD:-$(openssl rand -hex 16)}}
+SIP_6101_PASSWORD=${override_6101:-${SIP_6101_PASSWORD:-$(openssl rand -hex 16)}}
+SIP_6102_PASSWORD=${override_6102:-${SIP_6102_PASSWORD:-$(openssl rand -hex 16)}}
+SIP_EXTERNAL_MEDIA_ADDRESS=${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1}
+cat > "$credentials" < /etc/asterisk/pjsip.conf
+chmod 640 /etc/asterisk/pjsip.conf
+chown root:asterisk /etc/asterisk/pjsip.conf
+
+if [ ! -s /tls/sip.crt ] || [ ! -s /tls/sip.key ]; then
+ openssl req -x509 -nodes -newkey rsa:3072 -sha256 -days 365 \
+ -keyout /tls/sip.key -out /tls/sip.crt \
+ -subj '/CN=localhost/O=LCT local SIP training stand' \
+ -addext 'subjectAltName=DNS:localhost,IP:127.0.0.1' \
+ -addext 'extendedKeyUsage=serverAuth' 2>/dev/null
+fi
+chmod 640 /tls/sip.key
+chmod 644 /tls/sip.crt
+chown root:asterisk /tls/sip.key /tls/sip.crt
+chown -R asterisk:asterisk /recordings /var/lib/lct-sip
+
+exec asterisk -f -U asterisk -G asterisk -vvv
diff --git a/sip/extensions.conf b/sip/extensions.conf
new file mode 100644
index 0000000..1d68ff5
--- /dev/null
+++ b/sip/extensions.conf
@@ -0,0 +1,28 @@
+[general]
+static=yes
+writeprotect=yes
+clearglobalvars=no
+
+[lct-training]
+; 6001 — ДДС, 6002 — старший группы, 6003 — преподаватель.
+exten => _600[1-3],1,NoOp(LCT training call ${CALLERID(num)} -> ${EXTEN})
+ same => n,Set(RECORDING_FILE=${STRFTIME(${EPOCH},UTC,%Y%m%d-%H%M%S)}-${CALLERID(num)}-${EXTEN}-${UNIQUEID})
+ same => n,MixMonitor(/recordings/${RECORDING_FILE}.wav,b)
+ same => n,Dial(PJSIP/${EXTEN},30)
+ same => n,StopMixMonitor()
+ same => n,Hangup()
+
+; 6101/6102 — браузерные WebRTC-абоненты. Тот же учебный контекст и запись,
+; но отдельные endpoints не ломают обычные UDP/TCP-софтфоны 6001–6003.
+exten => _610[1-2],1,NoOp(LCT WebRTC training call ${CALLERID(num)} -> ${EXTEN})
+ same => n,Set(RECORDING_FILE=${STRFTIME(${EPOCH},UTC,%Y%m%d-%H%M%S)}-${CALLERID(num)}-${EXTEN}-${UNIQUEID})
+ same => n,MixMonitor(/recordings/${RECORDING_FILE}.wav,b)
+ same => n,Dial(PJSIP/${EXTEN},30)
+ same => n,StopMixMonitor()
+ same => n,Hangup()
+
+; Эхо-номер нужен для проверки полного SIP/RTP-пути без второго устройства.
+exten => 7000,1,NoOp(LCT SIP echo test)
+ same => n,Answer()
+ same => n,Echo()
+ same => n,Hangup()
diff --git a/sip/http.conf b/sip/http.conf
new file mode 100644
index 0000000..c0c97e3
--- /dev/null
+++ b/sip/http.conf
@@ -0,0 +1,8 @@
+[general]
+enabled=yes
+bindaddr=0.0.0.0
+bindport=8088
+tlsenable=no
+
+; Порт доступен только локально/Docker-сети. Браузер приходит по WSS через
+; Nginx; прямой HTTP нужен лишь для health/smoke на localhost.
diff --git a/sip/modules.conf b/sip/modules.conf
new file mode 100644
index 0000000..3596ff0
--- /dev/null
+++ b/sip/modules.conf
@@ -0,0 +1,7 @@
+[modules]
+autoload=yes
+
+; Ubuntu всё ещё поставляет legacy chan_sip, который первым захватывает
+; WebSocket subprotocol `sip`. Все endpoints стенда описаны в PJSIP.
+noload => chan_sip.so
+load => res_pjsip_transport_websocket.so
diff --git a/sip/pjsip.conf.template b/sip/pjsip.conf.template
new file mode 100644
index 0000000..22f18ac
--- /dev/null
+++ b/sip/pjsip.conf.template
@@ -0,0 +1,156 @@
+[global]
+type=global
+user_agent=LCT-112-Training-SIP
+endpoint_identifier_order=auth_username,username,ip,anonymous
+
+[transport-udp]
+type=transport
+protocol=udp
+bind=0.0.0.0:5060
+
+[transport-tcp]
+type=transport
+protocol=tcp
+bind=0.0.0.0:5060
+
+[transport-tls]
+type=transport
+protocol=tls
+bind=0.0.0.0:5061
+cert_file=/tls/sip.crt
+priv_key_file=/tls/sip.key
+method=tlsv1_2
+verify_client=no
+verify_server=no
+allow_reload=yes
+
+; TLS для браузера завершает тот же Nginx, что обслуживает АРМ. До Asterisk
+; SIP signaling идёт WebSocket внутри изолированной Docker-сети.
+[transport-ws]
+type=transport
+protocol=ws
+bind=0.0.0.0
+external_media_address=${SIP_EXTERNAL_MEDIA_ADDRESS}
+
+[training-endpoint](!)
+type=endpoint
+context=lct-training
+disallow=all
+allow=ulaw
+allow=alaw
+direct_media=no
+rtp_symmetric=yes
+force_rport=yes
+rewrite_contact=yes
+ice_support=yes
+timers=yes
+dtmf_mode=rfc4733
+language=ru
+
+[6001](training-endpoint)
+auth=6001-auth
+aors=6001
+callerid=Диспетчер ДДС <6001>
+
+[6001-auth]
+type=auth
+auth_type=userpass
+username=6001
+password=${SIP_6001_PASSWORD}
+
+[6001]
+type=aor
+max_contacts=3
+remove_existing=yes
+qualify_frequency=30
+
+[6002](training-endpoint)
+auth=6002-auth
+aors=6002
+callerid=Старший группы <6002>
+
+[6002-auth]
+type=auth
+auth_type=userpass
+username=6002
+password=${SIP_6002_PASSWORD}
+
+[6002]
+type=aor
+max_contacts=3
+remove_existing=yes
+qualify_frequency=30
+
+[6003](training-endpoint)
+auth=6003-auth
+aors=6003
+callerid=Преподаватель <6003>
+
+[6003-auth]
+type=auth
+auth_type=userpass
+username=6003
+password=${SIP_6003_PASSWORD}
+
+[6003]
+type=aor
+max_contacts=3
+remove_existing=yes
+qualify_frequency=30
+
+[webrtc-endpoint](!)
+type=endpoint
+context=lct-training
+disallow=all
+allow=ulaw
+allow=alaw
+direct_media=no
+webrtc=yes
+use_avpf=yes
+media_encryption=dtls
+dtls_auto_generate_cert=yes
+dtls_verify=fingerprint
+dtls_setup=actpass
+ice_support=yes
+media_use_received_transport=yes
+rtcp_mux=yes
+rtp_symmetric=yes
+force_rport=yes
+rewrite_contact=yes
+timers=yes
+dtmf_mode=rfc4733
+language=ru
+
+[6101](webrtc-endpoint)
+auth=6101-auth
+aors=6101
+callerid=Веб ДДС <6101>
+
+[6101-auth]
+type=auth
+auth_type=userpass
+username=6101
+password=${SIP_6101_PASSWORD}
+
+[6101]
+type=aor
+max_contacts=2
+remove_existing=yes
+qualify_frequency=30
+
+[6102](webrtc-endpoint)
+auth=6102-auth
+aors=6102
+callerid=Веб старший группы <6102>
+
+[6102-auth]
+type=auth
+auth_type=userpass
+username=6102
+password=${SIP_6102_PASSWORD}
+
+[6102]
+type=aor
+max_contacts=2
+remove_existing=yes
+qualify_frequency=30
diff --git a/sip/rtp.conf b/sip/rtp.conf
new file mode 100644
index 0000000..0e01f2e
--- /dev/null
+++ b/sip/rtp.conf
@@ -0,0 +1,5 @@
+[general]
+rtpstart=10000
+rtpend=10099
+strictrtp=yes
+icesupport=yes