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:
Ivan Gerasimov 2026-09-17 22:24:48 +03:00
commit 34eb88270e
6 changed files with 99 additions and 2 deletions

View 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>;
}

View file

@ -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 (
<main className="page">
<StandStatus />
<ModeBanner mode={session.mode ?? mode} />
<h1>Рабочее место преподавателя</h1>

View file

@ -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 (
<main className="page">
<StandStatus />
<ModeBanner mode={call.incoming?.mode} />
<h1>АРМ оператора 112</h1>
<InterviewTimer timers={call.timers} />

View file

@ -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); }