Implement local task 09 training workflows
This commit is contained in:
parent
e081fa410b
commit
cec84ffcd0
45 changed files with 2679 additions and 366 deletions
|
|
@ -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"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
189
frontend/src/features/instructor/ScenarioEditor.tsx
Normal file
189
frontend/src/features/instructor/ScenarioEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue