diff --git a/Makefile b/Makefile
index b5e8273..e585e6a 100644
--- a/Makefile
+++ b/Makefile
@@ -2,6 +2,11 @@
# и называют карточку, которая их закрывает, — README не должен врать.
UV ?= uv
+
+# `make test` и `make demo` не запускать одновременно: они просят у uv разные
+# группы зависимостей, и uv пересобирает одно и то же окружение, а второй запуск
+# молча ждёт блокировку. Один раз поставить оба набора:
+# cd backend && uv sync --extra dev --extra voice
COMPOSE ?= docker compose
.DEFAULT_GOAL := help
@@ -57,7 +62,7 @@ repl: ## Текстовый диалог со звонящим без голос
pregen: ## Дерево диалога и WAV первых реплик для офлайна
@echo "не реализовано — карточка tasks/lct-19-reference-dialog.md"; exit 1
-demo: ## Поднять всё в демо-режиме: офлайн, прогретые модели
- @echo "не реализовано — карточка tasks/lct-21-demo-readiness.md"; exit 1
+demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт
+ ./scripts/demo.sh
.PHONY: help dev down back front types test test-voice typecheck lesson latency migrate revision models seed repl pregen demo
diff --git a/frontend/src/features/health/StandStatus.tsx b/frontend/src/features/health/StandStatus.tsx
new file mode 100644
index 0000000..8505613
--- /dev/null
+++ b/frontend/src/features/health/StandStatus.tsx
@@ -0,0 +1,21 @@
+// Готовность стенда. Модели грузятся несколько секунд на старте, и курсант
+// не должен догадываться, почему звонящий молчит (docs/arch/FRONTEND.md).
+
+import { useHealth } from "@/shared/api/http";
+
+export function StandStatus() {
+ const health = useHealth();
+
+ if (health.isError) {
+ return
Сервер не отвечает — занятие не запустить.
;
+ }
+ if (!health.data) return null;
+
+ const troubles: string[] = [];
+ if (!health.data.models_ready) troubles.push("голосовой контур выключен: звонок будет без звука");
+ if (!health.data.embeddings_ready) troubles.push("нет модели эмбеддингов: подсказки идут по порядку чек-листа");
+ if (!health.data.scenarios_loaded) troubles.push("библиотека сценариев пуста");
+
+ if (troubles.length === 0) return null;
+ return Стенд не в полной готовности — {troubles.join("; ")}.
;
+}
diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx
index 8da5bad..69a763e 100644
--- a/frontend/src/pages/instructor/Instructor.tsx
+++ b/frontend/src/pages/instructor/Instructor.tsx
@@ -10,6 +10,7 @@ import { Debrief } from "@/features/debrief/Debrief";
import { Director } from "@/features/instructor/Director";
import { useControl } from "@/features/instructor/useControl";
import { KioCard } from "@/features/kio-card/KioCard";
+import { StandStatus } from "@/features/health/StandStatus";
import { ModeBanner } from "@/features/mode-banner/ModeBanner";
import { useObserve } from "@/features/observe/useObserve";
import { InterviewTimer, Timers } from "@/features/timers/Timers";
@@ -68,6 +69,7 @@ export function Instructor() {
return (
+
Рабочее место преподавателя
diff --git a/frontend/src/pages/trainee/Call.tsx b/frontend/src/pages/trainee/Call.tsx
index a8f2739..023021a 100644
--- a/frontend/src/pages/trainee/Call.tsx
+++ b/frontend/src/pages/trainee/Call.tsx
@@ -2,6 +2,7 @@
// Карточка КИО — lct-10, таймеры — lct-11.
import { KioCard } from "@/features/kio-card/KioCard";
+import { StandStatus } from "@/features/health/StandStatus";
import { ModeBanner, hintsAllowed } from "@/features/mode-banner/ModeBanner";
import { Debrief } from "@/features/debrief/Debrief";
import { SelfAssessment } from "@/features/self-assessment/SelfAssessment";
@@ -32,6 +33,7 @@ export function Call() {
return (
+
АРМ оператора 112
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index f912c88..940fd82 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -97,3 +97,8 @@ button:disabled { opacity: 0.5; cursor: default; }
/* Возврат карточки: список полей в две колонки, чтобы диспетчер не скроллил. */
.bounce-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; margin: 8px 0; }
+
+/* Готовность стенда: курсант не должен гадать, почему звонящий молчит. */
+.stand { padding: 6px 24px; margin: -16px -24px 12px; font-weight: 600; }
+.stand-warn { background: #fff4d6; border-bottom: 1px solid var(--warn); }
+.stand-bad { background: #fde8e8; border-bottom: 1px solid var(--violated); color: var(--violated); }
diff --git a/scripts/demo.sh b/scripts/demo.sh
new file mode 100755
index 0000000..fe4b79b
--- /dev/null
+++ b/scripts/demo.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# make demo: поднять стенд для занятия или показа.
+#
+# Postgres в контейнере, бэкенд и фронт — нативно: модели речи под WSL работают
+# нативно, так они и мерялись (docs/LATENCY.md). Скрипт обкатывается заранее,
+# а не пишется в последнюю ночь: им поднимают стенд в классе.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+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
+fi
+echo " все модели на месте"
+
+say "2/5 База"
+docker compose up -d postgres
+( cd backend && uv run alembic upgrade head >/dev/null )
+echo " миграции накатаны"
+
+say "3/5 Сценарии"
+make seed
+
+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 ) &
+BACKEND=$!
+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
+ sleep 2
+done
+curl -s localhost:8000/api/health; echo
+
+say "5/5 Фронт"
+npm --prefix frontend run dev -- --host 0.0.0.0 &
+FRONTEND=$!
+
+say "Стенд поднят"
+cat <<'LINKS'
+ пульт преподавателя: http://localhost:5173/instructor
+ профили курсантов: http://localhost:5173/profile
+ ссылки на АРМ курсанта, монитор и ДДС печатает сам пульт
+
+ Ctrl+C гасит стенд.
+LINKS
+wait $BACKEND