Implement local task 09 training workflows

This commit is contained in:
andreysk0304 2026-09-21 17:40:54 +03:00
commit cec84ffcd0
45 changed files with 2679 additions and 366 deletions

View file

@ -60,5 +60,5 @@ export function useLogout() {
export const SCREENS: Record<Role, string[]> = {
admin: ["/admin", "/profile"],
instructor: ["/instructor", "/wall", "/profile", "/dds"],
trainee: ["/trainee", "/profile"],
trainee: ["/trainee", "/dds", "/profile"],
};

View file

@ -10,11 +10,12 @@ import { Ambience } from "@/shared/audio/ambience";
import { type Capture, startCapture } from "@/shared/audio/capture";
import { EnergyGate } from "@/shared/audio/levels";
import { CALLER_RATE, Playback } from "@/shared/audio/playback";
import { type CardState, applyPatch, edit, empty as emptyCard } from "@/features/kio-card/merge";
import { type CardState, applyPatch, applyState, edit, empty as emptyCard } from "@/features/kio-card/merge";
import type { ChecklistItem } from "@/features/self-assessment/SelfAssessment";
import type {
Outcome,
CallIncoming,
CardBriefing,
DDSCode,
ServerToTrainee,
SessionReport,
@ -36,6 +37,8 @@ export function useCall(sessionId: string | null) {
const [status, setStatus] = useState<ChannelStatus>("connecting");
const [phase, setPhase] = useState<CallPhase>("waiting");
const [incoming, setIncoming] = useState<CallIncoming | null>(null);
const [briefing, setBriefing] = useState<CardBriefing | null>(null);
const [cardSubmitted, setCardSubmitted] = useState(false);
const [lines, setLines] = useState<Line[]>([]);
const [timers, setTimers] = useState<TimerSnapshot[]>([]);
const [callerSpeaking, setCallerSpeaking] = useState(false);
@ -45,6 +48,7 @@ export function useCall(sessionId: string | null) {
const [selfAssessed, setSelfAssessed] = useState(false);
const [scoreReady, setScoreReady] = useState(false);
const [report, setReport] = useState<SessionReport | null>(null);
const [reportError, setReportError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
@ -53,6 +57,7 @@ export function useCall(sessionId: string | null) {
const gate = useRef(new EnergyGate());
const outbox = useRef<Record<string, unknown>>({});
const patchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const textMode = useRef(false);
const append = useCallback((line: Line) => {
setLines((prev) => {
@ -67,6 +72,16 @@ export function useCall(sessionId: string | null) {
onStatus: setStatus,
onBinary: (frame) => audio.current?.playback.enqueue(frame),
onEvent: (event: ServerToTrainee) => {
if (event.type === "card.briefing") {
const briefing = event;
textMode.current = true;
setBriefing(briefing);
setIncoming(null);
setCard(applyState(emptyCard, { ...briefing.card }));
setCardSubmitted(false);
setError(null);
return;
}
switch (event.type) {
case "call.incoming":
setIncoming(event);
@ -110,6 +125,7 @@ export function useCall(sessionId: string | null) {
case "call.ended":
setPhase("ended");
audio.current?.ambience.stop();
if (textMode.current) { setCardSubmitted(true); break; }
// Чек-лист открывается только после звонка: во время него это
// содержимое подсказок.
void fetch(`/api/sessions/${sessionId}/checklist`)
@ -119,10 +135,16 @@ export function useCall(sessionId: string | null) {
break;
case "score.ready":
setScoreReady(true);
setReportError(null);
void fetch(`/api/sessions/${sessionId}/report`)
.then((response) => (response.ok ? response.json() : null))
.then((response) => {
if (response.status === 403) throw new Error("Отчёт недоступен: занятие привязано к другому обучающемуся.");
if (response.status === 409) throw new Error("Оценка ещё не готова. Обновите страницу позже.");
if (!response.ok) throw new Error(`Не удалось загрузить отчёт (HTTP ${response.status}).`);
return response.json();
})
.then(setReport)
.catch(() => setReport(null));
.catch((cause: unknown) => setReportError(cause instanceof Error ? cause.message : "Не удалось загрузить отчёт."));
break;
case "error":
setError(event.message);
@ -183,8 +205,25 @@ export function useCall(sessionId: string | null) {
}, PATCH_DEBOUNCE_MS);
}, []);
const dispatch = useCallback((service: DDSCode) => {
channel.current?.send({ type: "dds.dispatch", service });
const dispatch = useCallback((service?: DDSCode) => {
channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" });
}, []);
const submitCard = useCallback(() => {
if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = null;
const fields = outbox.current;
outbox.current = {};
if (Object.keys(fields).length && !channel.current?.send({ type: "kio.patch", fields })) {
setError("Канал отключён: карточка не отправлена.");
return;
}
if (channel.current?.send({ type: "card.submit" })) {
setCardSubmitted(true);
setError(null);
} else {
setError("Канал отключён: карточка не отправлена.");
}
}, []);
/** Закрыть вызов не карточкой: справкой или передачей в другой регион.
@ -201,8 +240,8 @@ export function useCall(sessionId: string | null) {
return {
resolve,
status, phase, incoming, lines, timers, callerSpeaking, micOn, error, card,
checklist, selfAssessed, scoreReady, report,
answer, hangup, hint, patchKio, dispatch, submitSelfAssessment,
status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, card,
checklist, selfAssessed, scoreReady, report, reportError,
answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment,
};
}

View file

@ -23,6 +23,10 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
Разбор · оценка {report.score_final.toFixed(0)} из 100
{report.overridden_by && ` · скорректировано (${report.overridden_by}), автооценка ${report.score_auto.toFixed(0)}`}
</h2>
<p className="ref">
Скачать отчёт: <a href={`/api/sessions/${report.session_id}/report.csv`}>CSV</a>
{" · "}<a href={`/api/sessions/${report.session_id}/report.pdf`}>PDF</a>
</p>
<div className="columns">
<section>

View file

@ -0,0 +1,189 @@
import { useState } from "react";
import type { ScenarioSummary } from "@/shared/api/http";
type JsonObject = Record<string, unknown>;
type Draft = { id: string; status: "draft"; generation: "template_copy"; body: JsonObject };
type Validation = { valid: boolean; errors: string[]; ground_truth?: JsonObject };
async function api<T>(path: string, method = "GET", body?: unknown): Promise<T> {
const response = await fetch(path, {
method,
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const data: unknown = 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 : JSON.stringify(detail));
}
return data as T;
}
function isObject(value: unknown): value is JsonObject {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/** Сервер принимает JSON Merge Patch, поэтому удалённое в редакторе поле
* передаётся как null, а неизменённые поля не отправляются. */
function diff(before: JsonObject, after: JsonObject): JsonObject {
const patch: JsonObject = {};
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
if (!(key in after)) {
patch[key] = null;
} else if (!(key in before)) {
patch[key] = after[key];
} else if (isObject(before[key]) && isObject(after[key])) {
const nested = diff(before[key], after[key]);
if (Object.keys(nested).length) patch[key] = nested;
} else if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
patch[key] = after[key];
}
}
return patch;
}
function pretty(body: JsonObject): string {
return JSON.stringify(body, null, 2);
}
export function ScenarioEditor({
scenarios,
onApproved,
}: {
scenarios: ScenarioSummary[];
onApproved: (id: string) => void | Promise<void>;
}) {
const [sourceId, setSourceId] = useState("");
const [title, setTitle] = useState("");
const [draftId, setDraftId] = useState("");
const [draft, setDraft] = useState<Draft | null>(null);
const [bodyText, setBodyText] = useState("");
const [validation, setValidation] = useState<Validation | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
function openDraft(value: Draft) {
setDraft(value);
setDraftId(value.id);
setBodyText(pretty(value.body));
setValidation(null);
setError("");
setNotice("");
}
async function perform(work: () => Promise<void>) {
setBusy(true);
setError("");
try {
await work();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setBusy(false);
}
}
async function create() {
await perform(async () => {
const created = await api<Draft>("/api/scenarios/drafts/from-template", "POST", {
source_id: sourceId || scenarios[0]?.id,
...(title.trim() ? { title: title.trim() } : {}),
});
openDraft(created);
setNotice("Создан шаблонный черновик. Это копия сценария, не генерация ИИ.");
});
}
async function reopen() {
if (!draftId.trim()) return;
await perform(async () => {
openDraft(await api<Draft>(`/api/scenarios/drafts/${encodeURIComponent(draftId.trim())}`));
});
}
async function save(): Promise<Draft> {
if (!draft) throw new Error("Сначала создайте или откройте черновик");
let parsed: unknown;
try {
parsed = JSON.parse(bodyText);
} catch (cause) {
throw new Error(`Некорректный JSON: ${cause instanceof Error ? cause.message : String(cause)}`);
}
if (!isObject(parsed)) throw new Error("Тело сценария должно быть JSON-объектом");
if (parsed.id !== draft.id) throw new Error("id черновика менять нельзя");
const patch = diff(draft.body, parsed);
if (!Object.keys(patch).length) return draft;
const updated = await api<Draft>(`/api/scenarios/drafts/${encodeURIComponent(draft.id)}`, "PATCH", patch);
setDraft(updated);
setBodyText(pretty(updated.body));
setValidation(null);
return updated;
}
async function validate() {
await perform(async () => {
const current = await save();
const result = await api<Validation>(`/api/scenarios/drafts/${encodeURIComponent(current.id)}/validate`, "POST");
setValidation(result);
setNotice(result.valid ? "Черновик прошёл проверку и может быть утверждён." : "Найдены ошибки в сценарии.");
});
}
async function approve() {
await perform(async () => {
const current = await save();
if (!validation?.valid) throw new Error("Сначала проверьте черновик кнопкой «Проверить»");
await api(`/api/scenarios/drafts/${encodeURIComponent(current.id)}/approve`, "POST");
setDraft(null);
setValidation(null);
setNotice("Сценарий утверждён и доступен для нового занятия.");
await onApproved(current.id);
});
}
return (
<section className="scenario-editor">
<h2>Редактор учебных сценариев</h2>
<p className="ref">Создание работает локально: копия утверждённого шаблона, затем правка и проверка преподавателем.</p>
<div className="scenario-editor-create">
<label>Шаблон
<select value={sourceId || scenarios[0]?.id || ""} onChange={(event) => setSourceId(event.target.value)}>
{scenarios.map((scenario) => <option key={scenario.id} value={scenario.id}>{scenario.title} · {scenario.level}</option>)}
</select>
</label>
<label>Название нового сценария
<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="необязательно" maxLength={200} />
</label>
<button type="button" onClick={create} disabled={busy || !scenarios.length}>Создать черновик</button>
</div>
<div className="scenario-editor-open">
<label>Номер черновика
<input value={draftId} onChange={(event) => setDraftId(event.target.value)} placeholder="draft-…" />
</label>
<button type="button" onClick={reopen} disabled={busy || !draftId.trim()}>Открыть</button>
</div>
{draft && (
<>
<p className="ref">Черновик {draft.id}. Факты и эталон доступны только преподавателю до утверждения.</p>
<label className="scenario-editor-json">JSON тела сценария
<textarea value={bodyText} onChange={(event) => { setBodyText(event.target.value); setValidation(null); }} spellCheck={false} />
</label>
<div className="scenario-editor-actions">
<button type="button" onClick={() => void perform(async () => { await save(); setNotice("Черновик сохранён."); })} disabled={busy}>Сохранить</button>
<button type="button" onClick={validate} disabled={busy}>Проверить</button>
<button type="button" className="start" onClick={approve} disabled={busy || !validation?.valid}>Утвердить</button>
</div>
</>
)}
{validation && <div className={validation.valid ? "scenario-editor-result" : "scenario-editor-result violated"}>
{validation.valid ? <><strong>Проверка пройдена</strong><pre>{pretty(validation.ground_truth ?? {})}</pre></> :
<><strong>Ошибки проверки</strong><ul>{validation.errors.map((item, index) => <li key={`${index}-${item}`}>{item}</li>)}</ul></>}
</div>}
{error && <p className="violated" role="alert">{error}</p>}
{notice && <p className="state-ok" role="status">{notice}</p>}
</section>
);
}

View file

@ -25,14 +25,18 @@ export function useControl(sessionId: string | null) {
}, [sessionId]);
const start = useCallback(
(scenarioId: string, mode: SessionMode, trainee: string, group?: string) =>
channel.current?.send({
(scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string) => {
const message = {
type: "scenario.start",
scenario_id: scenarioId,
mode,
trainee,
group_id: group || null,
}),
trainee_id: traineeId || null,
exercise,
} as const;
channel.current?.send(message);
},
[],
);