Complete DDS training workflow and delivery package

This commit is contained in:
andreysk0304 2026-09-24 01:10:49 +03:00
commit 4c4b91064f
229 changed files with 11969 additions and 1024 deletions

View file

@ -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<string[]>([]);
const [mode, setMode] = useState<SessionMode>("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<Record<string, number>>({});
const [ddsArrivalIntervalSeconds, setDdsArrivalIntervalSeconds] = useState(60);
const [ddsMaxWaiting, setDdsMaxWaiting] = useState(3);
const [trainee, setTrainee] = useState("");
const [traineeId, setTraineeId] = useState("");
const [noteFor, setNoteFor] = useState<string | null>(null);
@ -41,6 +49,11 @@ export function Instructor() {
// ссылка на идущее занятие не должна запускать его заново и сбрасывать.
const toStart = useRef<string | null>(null);
const [noteText, setNoteText] = useState("");
const [overrideReport, setOverrideReport] = useState<SessionReport | null>(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<string, number>);
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() {
<StandStatus />
<ModeBanner mode={session.mode ?? mode} />
<h1>Рабочее место преподавателя</h1>
<p className="workspace-links">
<a href="#ai-scenario-editor">Перейти к ИИ-формирователю сценария ↓</a>
<a href="/materials">Учебные материалы и назначения →</a>
</p>
<div className="instructor-layout">
<section>
@ -112,14 +205,17 @@ export function Instructor() {
{incidentGroups.data?.groups.map((group) =>
<option key={group.id} value={group.id}>{group.title}</option>)}
</select>
<select aria-label="Билет заказчика" value={ticketFilter} onChange={(event) => setTicketFilter(event.target.value)}>
<select aria-label="Билет заказчика" value={ticketFilter} onChange={(event) => {
setTicketFilter(event.target.value);
setWholeTicket(false);
}}>
<option value="">Все 32 билета</option>
{Array.from({ length: 32 }, (_, index) => <option key={index} value={index + 1}>Билет {index + 1}</option>)}
</select>
<input type="search" aria-label="Поиск сценария" value={scenarioSearch}
placeholder="Название или ID карточки" onChange={(event) => setScenarioSearch(event.target.value)} />
</div>
<select value={scenarioId} onChange={(event) => {
<select value={scenarioId} disabled={exercise === "dds" && wholeTicket} onChange={(event) => {
setScenarioId(event.target.value);
setExtraScenarioIds((previous) => previous.filter((id) => id !== event.target.value));
}}>
@ -131,9 +227,22 @@ export function Instructor() {
))}
</select>
<small className="ref">{visibleScenarios.length} из {scenarios.data?.length ?? 0} карточек (96 — билеты заказчика)</small>
{exercise === "dds" && <details className="scenario-queue">
{exercise === "dds" && ticketFilter && <label className="mode-choice">
<input type="checkbox" checked={wholeTicket && selectedTicketScenarios.length === 3}
disabled={selectedTicketScenarios.length !== 3}
onChange={(event) => {
if (event.target.checked && selectedTicketScenarios[0]) {
setScenarioId(selectedTicketScenarios[0].id);
setExtraScenarioIds([]);
}
setWholeTicket(event.target.checked);
}} />
{` Запустить весь билет ${ticketFilter} целиком (3 вызова)`}
{selectedTicketScenarios.length !== 3 && <small className="ref"> — в библиотеке найдено {selectedTicketScenarios.length} из 3 вызовов</small>}
</label>}
{exercise === "dds" && !wholeTicket && <details className="scenario-queue">
<summary>Дополнительные карточки ({extraScenarioIds.length})</summary>
<p className="ref">Карточки поступят последовательно после завершения текущей.</p>
<p className="ref">Интервал и максимум ожидающих задаются в критериях занятия. Таймер каждой карточки начинается при поступлении и идёт, пока курсант занят другой.</p>
{scenarios.data?.filter((scenario) => scenario.id !== scenarioId).map((scenario) =>
<label key={scenario.id} className="mode-choice">
<input type="checkbox" checked={extraScenarioIds.includes(scenario.id)} onChange={() =>
@ -179,7 +288,7 @@ export function Instructor() {
<select value={traineeId} onChange={(event) => setTraineeId(event.target.value)}>
<option value="">Выберите учётную запись</option>
{trainees.data.map((item) => (
<option key={item.id} value={item.id}>{item.name}{item.group ? ` · ${item.group}` : ""}</option>
<option key={item.id} value={item.id}>{item.name}{item.group ? ` · ${item.group}` : ""}{item.service ? ` · ${item.service}` : " · служба не назначена"}</option>
))}
</select>
) : (
@ -191,11 +300,57 @@ export function Instructor() {
)}
</td>
</tr>
<tr>
<th>Критерии занятия</th>
<td>
{exercise === "dds" && <>
<label className="lesson-criterion">
Интервал поступления карточек, секунд (0 — без задержки; действует лимит ожидания)
<input aria-label="Интервал поступления карточек ДДС, секунд" type="number" min={0} max={300}
value={ddsArrivalIntervalSeconds} onChange={(event) => setDdsArrivalIntervalSeconds(
Math.max(0, Math.min(300, Number(event.target.value) || 0)))} />
</label>
<label className="lesson-criterion">
Максимум ожидающих карточек
<input aria-label="Максимум ожидающих карточек ДДС" type="number" min={1} max={10}
value={ddsMaxWaiting} onChange={(event) => setDdsMaxWaiting(
Math.max(1, Math.min(10, Number(event.target.value) || 1)))} />
</label>
</>}
<label className="lesson-criterion">
Решение ДДС, секунд
<input aria-label="Лимит решения ДДС, секунд" type="number" min={5} max={300}
value={decisionTimeLimit} onChange={(event) => setDecisionTimeLimit(
Math.max(5, Math.min(300, Number(event.target.value) || 30)))} />
</label>
<label className="lesson-criterion">
Допустимо нарушенных метрик
<input aria-label="Допустимое число ошибок" type="number" min={0} max={50}
value={allowedErrors} onChange={(event) => setAllowedErrors(
Math.max(0, Math.min(50, Number(event.target.value) || 0)))} />
</label>
{exercise !== "dds" && <label className="mode-choice">
<input type="checkbox" checked={requireCorrectGrammar}
onChange={(event) => setRequireCorrectGrammar(event.target.checked)} />
Грамматика карточки оператора 112 входит в оценку
</label>}
<label className="lesson-criterion">
Переопределения весов метрик (JSON; 0 отключает метрику)
<textarea aria-label="Переопределения весов метрик" rows={3}
value={scoreWeightsText}
onChange={(event) => { setScoreWeightsText(event.target.value); setScoreWeightsError(""); }}
placeholder={'{"address": 3, "dds_ack": 2}'} spellCheck={false} />
<small className="ref">Оставьте {"{}"} для стандартной шкалы. Диапазон каждого веса — 0–10; допустимые ключи проверяются сервером.</small>
</label>
{scoreWeightsError && <p className="violated" role="alert">{scoreWeightsError}</p>}
<small className="ref">Нормативы ГОСТ для приёма вызова не изменяются.</small>
</td>
</tr>
</tbody>
</table>
<p>
<button type="button" className="start" onClick={startLesson}
disabled={!scenarioId || trainees.isLoading || Boolean(trainees.data?.length && !traineeId)}>
disabled={!canStartSelectedExercise || trainees.isLoading || Boolean(trainees.data?.length && !traineeId)}>
Начать занятие
</button>
</p>
@ -209,7 +364,10 @@ export function Instructor() {
{sessionId && !session.ended && (
exercise === "call" && <Director onInject={control.inject} disabled={control.status !== "open"} error={session.error} />
)}
{links && (
{links && !session.title && !session.error && (
<p className="ref" role="status">Запускаем занятие и готовим ссылки на рабочие места…</p>
)}
{links && session.title && (
<table className="grid">
<tbody>
<tr><th>АРМ курсанта</th><td><a href={exercise === "dds" ? links.dds : links.trainee}>открыть {exercise === "dds" ? "ДДС" : "112"}</a></td></tr>
@ -284,12 +442,37 @@ export function Instructor() {
</section>
</div>
<ScenarioEditor scenarios={scenarios.data ?? []} onApproved={async (id) => {
setScenarioId(id);
<ScenarioEditor scenarios={scenarios.data ?? []} selectedScenarioId={scenarioId} onApproved={async (id) => {
setTicketFilter("");
setCategoryFilter("");
setScenarioSearch("");
await scenarios.refetch();
}} />
setScenarioId(id);
}} onLibraryChanged={async () => { await scenarios.refetch(); }} />
{session.report && <Debrief report={session.report} />}
{session.report && <>
<section className="debrief">
<h2>Корректировка итоговой оценки</h2>
<p className="ref">Автооценка {session.report.score_auto.toFixed(1)} / 100. Изменение сохраняется в журнале с вашим логином и обоснованием.</p>
<label>Итоговая оценка, 0–100
<input type="number" min={0} max={100} step={0.1}
value={overrideScore}
onChange={(event) => setOverrideScore(event.target.value)} />
</label>
<label>Обязательное обоснование
<textarea rows={3} maxLength={2000} value={overrideComment}
onChange={(event) => setOverrideComment(event.target.value)}
placeholder="Почему итоговая оценка отличается от автоматической" />
</label>
<button type="button" disabled={overrideBusy || !overrideComment.trim()}
onClick={() => void saveScoreOverride(session.report!)}>
{overrideBusy ? "Сохраняем…" : "Сохранить оценку с аудитом"}
</button>
{overrideError && <p className="violated" role="alert">{overrideError}</p>}
</section>
<Debrief report={overrideReport?.session_id === session.report.session_id
? overrideReport : session.report} />
</>}
</main>
);
}