lct-21: make demo, честный README, готовность стенда
make demo поднимает стенд целиком: проверяет модели, базу, миграции, сценарии, бэкенд с голосом в офлайн-режиме и фронт. Обкатан заранее, как и требует карточка, — и сразу поймал ошибку: `cd backend && команда &` уводит в фон всю связку, родительская оболочка остаётся на месте, и фронт запускался не из репозитория. Пять диалогов подряд через make demo: без сбоев, ход 965–1210 мс, первая реплика 78–115 мс. make dev проверен целиком — образ фронта собирается, страница отдаётся. README переписан под факт: два шага до стенда и таблица того, что работает, вместо обещаний. Экран готовности показывает курсанту, почему звонящий молчит, вместо тишины без объяснений. В Makefile записано, что make test и make demo нельзя запускать одновременно: они просят у uv разные группы зависимостей, uv пересобирает одно окружение, и второй запуск молча ждёт блокировку.
This commit is contained in:
parent
ee2a526831
commit
34eb88270e
6 changed files with 99 additions and 2 deletions
9
Makefile
9
Makefile
|
|
@ -2,6 +2,11 @@
|
||||||
# и называют карточку, которая их закрывает, — README не должен врать.
|
# и называют карточку, которая их закрывает, — README не должен врать.
|
||||||
|
|
||||||
UV ?= uv
|
UV ?= uv
|
||||||
|
|
||||||
|
# `make test` и `make demo` не запускать одновременно: они просят у uv разные
|
||||||
|
# группы зависимостей, и uv пересобирает одно и то же окружение, а второй запуск
|
||||||
|
# молча ждёт блокировку. Один раз поставить оба набора:
|
||||||
|
# cd backend && uv sync --extra dev --extra voice
|
||||||
COMPOSE ?= docker compose
|
COMPOSE ?= docker compose
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
@ -57,7 +62,7 @@ repl: ## Текстовый диалог со звонящим без голос
|
||||||
pregen: ## Дерево диалога и WAV первых реплик для офлайна
|
pregen: ## Дерево диалога и WAV первых реплик для офлайна
|
||||||
@echo "не реализовано — карточка tasks/lct-19-reference-dialog.md"; exit 1
|
@echo "не реализовано — карточка tasks/lct-19-reference-dialog.md"; exit 1
|
||||||
|
|
||||||
demo: ## Поднять всё в демо-режиме: офлайн, прогретые модели
|
demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт
|
||||||
@echo "не реализовано — карточка tasks/lct-21-demo-readiness.md"; exit 1
|
./scripts/demo.sh
|
||||||
|
|
||||||
.PHONY: help dev down back front types test test-voice typecheck lesson latency migrate revision models seed repl pregen demo
|
.PHONY: help dev down back front types test test-voice typecheck lesson latency migrate revision models seed repl pregen demo
|
||||||
|
|
|
||||||
21
frontend/src/features/health/StandStatus.tsx
Normal file
21
frontend/src/features/health/StandStatus.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// Готовность стенда. Модели грузятся несколько секунд на старте, и курсант
|
||||||
|
// не должен догадываться, почему звонящий молчит (docs/arch/FRONTEND.md).
|
||||||
|
|
||||||
|
import { useHealth } from "@/shared/api/http";
|
||||||
|
|
||||||
|
export function StandStatus() {
|
||||||
|
const health = useHealth();
|
||||||
|
|
||||||
|
if (health.isError) {
|
||||||
|
return <div className="stand stand-bad">Сервер не отвечает — занятие не запустить.</div>;
|
||||||
|
}
|
||||||
|
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 <div className="stand stand-warn">Стенд не в полной готовности — {troubles.join("; ")}.</div>;
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import { Debrief } from "@/features/debrief/Debrief";
|
||||||
import { Director } from "@/features/instructor/Director";
|
import { Director } from "@/features/instructor/Director";
|
||||||
import { useControl } from "@/features/instructor/useControl";
|
import { useControl } from "@/features/instructor/useControl";
|
||||||
import { KioCard } from "@/features/kio-card/KioCard";
|
import { KioCard } from "@/features/kio-card/KioCard";
|
||||||
|
import { StandStatus } from "@/features/health/StandStatus";
|
||||||
import { ModeBanner } from "@/features/mode-banner/ModeBanner";
|
import { ModeBanner } from "@/features/mode-banner/ModeBanner";
|
||||||
import { useObserve } from "@/features/observe/useObserve";
|
import { useObserve } from "@/features/observe/useObserve";
|
||||||
import { InterviewTimer, Timers } from "@/features/timers/Timers";
|
import { InterviewTimer, Timers } from "@/features/timers/Timers";
|
||||||
|
|
@ -68,6 +69,7 @@ export function Instructor() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page">
|
<main className="page">
|
||||||
|
<StandStatus />
|
||||||
<ModeBanner mode={session.mode ?? mode} />
|
<ModeBanner mode={session.mode ?? mode} />
|
||||||
<h1>Рабочее место преподавателя</h1>
|
<h1>Рабочее место преподавателя</h1>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// Карточка КИО — lct-10, таймеры — lct-11.
|
// Карточка КИО — lct-10, таймеры — lct-11.
|
||||||
|
|
||||||
import { KioCard } from "@/features/kio-card/KioCard";
|
import { KioCard } from "@/features/kio-card/KioCard";
|
||||||
|
import { StandStatus } from "@/features/health/StandStatus";
|
||||||
import { ModeBanner, hintsAllowed } from "@/features/mode-banner/ModeBanner";
|
import { ModeBanner, hintsAllowed } from "@/features/mode-banner/ModeBanner";
|
||||||
import { Debrief } from "@/features/debrief/Debrief";
|
import { Debrief } from "@/features/debrief/Debrief";
|
||||||
import { SelfAssessment } from "@/features/self-assessment/SelfAssessment";
|
import { SelfAssessment } from "@/features/self-assessment/SelfAssessment";
|
||||||
|
|
@ -32,6 +33,7 @@ export function Call() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page">
|
<main className="page">
|
||||||
|
<StandStatus />
|
||||||
<ModeBanner mode={call.incoming?.mode} />
|
<ModeBanner mode={call.incoming?.mode} />
|
||||||
<h1>АРМ оператора 112</h1>
|
<h1>АРМ оператора 112</h1>
|
||||||
<InterviewTimer timers={call.timers} />
|
<InterviewTimer timers={call.timers} />
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
.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); }
|
||||||
|
|
|
||||||
62
scripts/demo.sh
Executable file
62
scripts/demo.sh
Executable file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue