Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -1,10 +1,22 @@
|
|||
// Экран входа. Ставится перед всеми остальными: номер занятия перестал быть
|
||||
// пропуском, по ссылке пускает роль, а не знание адреса (lct-23).
|
||||
|
||||
import { useState, type MouseEvent, type ReactNode } from "react";
|
||||
import { useEffect, useState, type MouseEvent, type ReactNode } from "react";
|
||||
|
||||
import { DEMO_MODE, SCREENS, useDemoLogin, useLogin, useLogout, useMe } from "./useAuth";
|
||||
|
||||
const preloadScreenModule: Record<string, () => Promise<unknown>> = {
|
||||
"/trainee": () => import("@/pages/trainee/Call"),
|
||||
"/instructor": () => import("@/pages/instructor/Instructor"),
|
||||
"/wall": () => import("@/pages/wall/Wall"),
|
||||
"/profile": () => import("@/pages/profile/Profile"),
|
||||
"/groups": () => import("@/pages/groups/Groups"),
|
||||
"/dds": () => import("@/pages/dds/Dds"),
|
||||
"/phone": () => import("@/pages/phone/Phone"),
|
||||
"/materials": () => import("@/pages/materials/Materials"),
|
||||
"/admin": () => import("@/pages/admin/Admin"),
|
||||
};
|
||||
|
||||
function DemoLinks() {
|
||||
const openScreen = (event: MouseEvent<HTMLAnchorElement>, path: string) => {
|
||||
event.preventDefault();
|
||||
|
|
@ -33,6 +45,12 @@ export function RequireAuth({ screen, children }: { screen: string; children: Re
|
|||
const me = useMe();
|
||||
const logout = useLogout();
|
||||
|
||||
// Fetch only static route code while the signed cookie is being checked;
|
||||
// user data and the screen itself remain gated by the role check below.
|
||||
useEffect(() => {
|
||||
void preloadScreenModule[screen]?.().catch(() => undefined);
|
||||
}, [screen]);
|
||||
|
||||
if (me.isLoading) {
|
||||
return <main className="page"><p>Проверяем доступ…</p></main>;
|
||||
}
|
||||
|
|
@ -76,7 +94,7 @@ function Login() {
|
|||
<tr>
|
||||
<th>Логин</th>
|
||||
<td>
|
||||
<input value={name} autoFocus onChange={(event) => setName(event.target.value)} />
|
||||
<input aria-label="Логин" value={name} autoFocus onChange={(event) => setName(event.target.value)} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
@ -84,6 +102,7 @@ function Login() {
|
|||
<td>
|
||||
<input
|
||||
type="password"
|
||||
aria-label="Пароль"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ export function useLogout() {
|
|||
/** Какие экраны открыты роли. Повторяет domain/roles.py: список короткий
|
||||
* и меняется редко, а держать его на сервере ради двух строк — лишний запрос. */
|
||||
export const SCREENS: Record<Role, string[]> = {
|
||||
admin: ["/admin", "/profile", "/groups", "/materials"],
|
||||
admin: ["/admin", "/wall", "/profile", "/groups", "/materials"],
|
||||
instructor: ["/instructor", "/wall", "/profile", "/dds", "/phone", "/groups", "/materials"],
|
||||
trainee: ["/trainee", "/dds", "/phone", "/profile", "/materials"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const PATCH_DEBOUNCE_MS = 300;
|
|||
export interface Line {
|
||||
speaker: "caller" | "operator";
|
||||
text: string;
|
||||
source?: "local_llm" | "scenario";
|
||||
partial?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +86,10 @@ export function useCall(sessionId: string | null) {
|
|||
},
|
||||
onBinary: (frame) => audio.current?.playback.enqueue(frame),
|
||||
onEvent: (event: ServerToTrainee) => {
|
||||
if (event.type === "text.turn.accepted") {
|
||||
append({ speaker: "operator", text: event.text });
|
||||
return;
|
||||
}
|
||||
if (event.type === "card.briefing") {
|
||||
const briefing = event;
|
||||
textMode.current = true;
|
||||
|
|
@ -110,7 +115,7 @@ export function useCall(sessionId: string | null) {
|
|||
append({ speaker: "operator", text: event.text });
|
||||
break;
|
||||
case "caller.utterance":
|
||||
append({ speaker: "caller", text: event.text });
|
||||
append({ speaker: "caller", text: event.text, source: event.source });
|
||||
break;
|
||||
case "tts.begin":
|
||||
setCallerSpeaking(true);
|
||||
|
|
@ -222,6 +227,12 @@ export function useCall(sessionId: string | null) {
|
|||
channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" });
|
||||
}, []);
|
||||
|
||||
const sendText = useCallback((text: string) => {
|
||||
const clean = text.trim();
|
||||
if (!clean || clean.length > 1000) return false;
|
||||
return Boolean(channel.current?.send({ type: "text.turn", text: clean }));
|
||||
}, []);
|
||||
|
||||
const submitCard = useCallback(() => {
|
||||
if (patchTimer.current) clearTimeout(patchTimer.current);
|
||||
patchTimer.current = null;
|
||||
|
|
@ -255,6 +266,6 @@ export function useCall(sessionId: string | null) {
|
|||
resolve,
|
||||
status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, card,
|
||||
checklist, selfAssessed, scoreReady, report, reportError,
|
||||
answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment,
|
||||
answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment, sendText,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,39 +16,102 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
|||
const noticed = diff?.noticed ?? [];
|
||||
const overcautious = diff?.overcautious ?? [];
|
||||
const notes = report.notes ?? [];
|
||||
const passedMetrics = Math.max(0, report.metrics.length - report.failed_metrics);
|
||||
const improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3);
|
||||
|
||||
return (
|
||||
<section className={big ? "debrief debrief-big" : "debrief"}>
|
||||
<h2>
|
||||
Разбор · оценка {report.score_final.toFixed(0)} из 100
|
||||
{report.overridden_by && ` · скорректировано (${report.overridden_by}), автооценка ${report.score_auto.toFixed(0)}`}
|
||||
</h2>
|
||||
<h2>Результаты занятия</h2>
|
||||
<div className="debrief-summary" aria-label="Краткие итоги занятия">
|
||||
<article className={report.passed ? "debrief-stat is-passed" : "debrief-stat is-failed"}>
|
||||
<span>Итог</span><strong>{report.passed ? "Зачёт" : "Не зачтено"}</strong>
|
||||
<small>Критерии: выполнено {passedMetrics} из {report.metrics.length}</small>
|
||||
<small>Допустимо не выполнить: {report.criteria.allowed_errors}</small>
|
||||
</article>
|
||||
<article className="debrief-stat debrief-score">
|
||||
<span>Ваша итоговая оценка</span><strong>{report.score_final.toFixed(0)}<small> из 100</small></strong>
|
||||
{report.overridden_by && <small>Изменено: {report.overridden_by}</small>}
|
||||
</article>
|
||||
<article className="debrief-stat">
|
||||
<span>Автоматическая оценка</span><strong>{report.score_auto.toFixed(0)}<small> из 100</small></strong>
|
||||
<small>{report.overridden_by ? "до корректировки преподавателя" : "расчёт по критериям занятия"}</small>
|
||||
</article>
|
||||
<article className="debrief-stat">
|
||||
<span>Упражнение</span><strong>{report.exercise === "dds" ? "ДДС" : report.exercise === "card" ? "КИО" : report.exercise === "call" ? "Вызов 112" : "112 → ДДС"}</strong>
|
||||
<small>{report.attempt ? `Попытка ${report.attempt}` : "Завершённое упражнение"}</small>
|
||||
</article>
|
||||
</div>
|
||||
<details className="debrief-tech-details">
|
||||
<summary>Служебные сведения</summary>
|
||||
<p>Идентификатор сценария: {report.scenario_id} · номер занятия: {report.session_id}</p>
|
||||
</details>
|
||||
<section className={`debrief-next-step ${report.passed ? "is-passed" : "is-failed"}`} aria-label="Что обсудить на разборе">
|
||||
<strong>{report.passed ? "Что получилось" : "На чём сосредоточиться"}</strong>
|
||||
{report.passed ? (
|
||||
<p>Курсант выполнил критерии зачёта. Разберите сильные решения и закрепите порядок действий.</p>
|
||||
) : improvementPoints.length ? (
|
||||
<>
|
||||
<p>Начните с этих критериев — по ним нужно разобрать решение курсанта:</p>
|
||||
<ul>{improvementPoints.map((metric) => <li key={metric.key}>{metric.title} — {metric.fact}</li>)}</ul>
|
||||
</>
|
||||
) : (
|
||||
<p>Зачёт не получен. Сверьте отметки и факты ниже с ходом занятия.</p>
|
||||
)}
|
||||
</section>
|
||||
<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>
|
||||
Скачать отчёт: <a href={`/api/sessions/${report.session_id}/report.csv`}>таблица (CSV)</a>
|
||||
{" · "}<a href={`/api/sessions/${report.session_id}/report.pdf`}>документ (PDF)</a>
|
||||
</p>
|
||||
<p className={report.passed ? "state-ok" : "state-violated"}>
|
||||
{report.passed ? "Зачёт" : "Не зачтено"}: нарушено {report.failed_metrics} метрик,
|
||||
допустимо {report.criteria.allowed_errors}. Лимит решения ДДС —
|
||||
{` ${report.criteria.decision_time_limit_seconds} с`}; грамматика
|
||||
{report.criteria.require_correct_grammar ? " входит" : " не входит"} в оценку.
|
||||
{report.passed ? "Зачёт" : "Не зачтено"}: на разборе {report.failed_metrics} пунктов; допустимо пропустить {report.criteria.allowed_errors}. {report.exercise === "dds" && <>На первичное решение ДДС отводится
|
||||
{` ${report.criteria.decision_time_limit_seconds} с`}</>}
|
||||
{report.exercise === "dds" && <>; норматив отработки карточки ДДС —
|
||||
{` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}</>}
|
||||
{report.exercise === "card" && <>Норматив заполнения КИО —
|
||||
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>}
|
||||
{report.exercise && report.exercise !== "dds" && <>
|
||||
; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку
|
||||
</>}.
|
||||
</p>
|
||||
{Object.keys(report.criteria.score_weights ?? {}).length > 0 && <p className="ref">
|
||||
Преподаватель переопределил веса: {Object.entries(report.criteria.score_weights ?? {})
|
||||
.map(([key, weight]) => `${key} — ${weight}`).join("; ")}.
|
||||
</p>}
|
||||
|
||||
{Boolean(report.card_results?.length) && <section className="debrief-card-results">
|
||||
<h3>Карточки занятия ({report.card_results!.length})</h3>
|
||||
{report.card_results!.map((card, index) => <details key={card.card_id}>
|
||||
<summary>Карточка {index + 1}: {card.title || card.scenario_id} · {card.score_auto.toFixed(0)} / 100
|
||||
{card.address ? ` · ${card.address}` : ""}</summary>
|
||||
{card.managed_service && <p><strong>Ответственная служба:</strong> {card.managed_service}</p>}
|
||||
<p><strong>Ответ диспетчера:</strong> {card.reply_text || "не внесён"}</p>
|
||||
{card.metrics.some((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)) && <ul>
|
||||
{card.metrics.filter((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)).map((metric) =>
|
||||
<li key={metric.key} className={metric.passed ? "ref" : "state-violated"}>
|
||||
<strong>{metric.title}:</strong> {metric.fact} · норматив {metric.norm}
|
||||
{metric.credit != null && ` · вклад ${Math.round(metric.credit * 100)}%`}
|
||||
</li>)}
|
||||
</ul>}
|
||||
{card.findings.map((finding, findingIndex) => <p className="state-violated" key={`${finding.code}-${findingIndex}`}>
|
||||
{finding.code}: {finding.summary}{finding.fact ? ` — ${finding.fact}` : ""}
|
||||
</p>)}
|
||||
</details>)}
|
||||
</section>}
|
||||
|
||||
<div className="columns">
|
||||
<section>
|
||||
<h3>Нормативы и факты</h3>
|
||||
<h3>Разбор по критериям</h3>
|
||||
<p className="ref">Сравните, что сделал курсант, с ожидаемым порядком действий.</p>
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
{report.metrics.map((metric) => (
|
||||
<tr key={metric.key}>
|
||||
<th>{metric.title}</th>
|
||||
<td className={metric.passed ? "state-ok" : "state-violated"}>
|
||||
{metric.fact} · норматив {metric.norm}
|
||||
{` · вес ${metric.weight ?? 1}`}
|
||||
<strong className={`debrief-metric-status ${metric.passed ? "is-ok" : "is-needs-work"}`}>
|
||||
{metric.passed ? "Выполнено" : "Разобрать"}
|
||||
</strong>
|
||||
<span>{metric.fact} · ожидалось: {metric.norm}</span>
|
||||
{metric.ref && <span className="ref"> ({metric.ref})</span>}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -56,6 +119,14 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
|||
</tbody>
|
||||
</table>
|
||||
|
||||
{report.metrics.some((metric) => metric.weight != null || metric.credit != null) && <details className="debrief-scoring-details">
|
||||
<summary>Как критерии повлияли на балл</summary>
|
||||
<ul>{report.metrics.filter((metric) => metric.weight != null || metric.credit != null).map((metric) => <li key={metric.key}>
|
||||
<strong>{metric.title}:</strong> вес {metric.weight ?? 1}
|
||||
{metric.credit != null && ` · вклад в оценку ${Math.round(metric.credit * 100)}%`}
|
||||
</li>)}</ul>
|
||||
</details>}
|
||||
|
||||
<h3>Отметки</h3>
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
|
|
@ -68,7 +139,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
|||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{report.findings.length === 0 && <tr><td colSpan={2}>ошибок не найдено</td></tr>}
|
||||
{report.findings.length === 0 && <tr><td colSpan={2}>Замечаний по ходу занятия нет.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
|
@ -135,7 +206,9 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
|||
</section>
|
||||
</div>
|
||||
|
||||
<h3>Транскрипт</h3>
|
||||
<details className="debrief-transcript">
|
||||
<summary>Весь ход разговора ({report.transcript.length} реплик)</summary>
|
||||
<p className="ref">Отметки и комментарии преподавателя привязаны к соответствующим репликам.</p>
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
{report.transcript.map((line) => {
|
||||
|
|
@ -158,6 +231,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
|||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,17 +15,21 @@ export const COMPETENCY_LABELS: Record<string, string> = {
|
|||
|
||||
export function Radar({ values, size = 220 }: { values: CompetencyScore[]; size?: number }) {
|
||||
if (values.length < 3) return null;
|
||||
const center = size / 2;
|
||||
const radius = center - 28;
|
||||
// The chart needs horizontal gutters: the left/right competency names are
|
||||
// outside the polygon and used to be clipped by the square SVG viewport.
|
||||
const width = size + 160;
|
||||
const centerX = width / 2;
|
||||
const centerY = size / 2;
|
||||
const radius = Math.min(centerY - 28, centerX - 100);
|
||||
|
||||
const point = (index: number, value: number) => {
|
||||
const angle = (Math.PI * 2 * index) / values.length - Math.PI / 2;
|
||||
return [center + radius * value * Math.cos(angle), center + radius * value * Math.sin(angle)];
|
||||
return [centerX + radius * value * Math.cos(angle), centerY + radius * value * Math.sin(angle)];
|
||||
};
|
||||
const polygon = values.map((item, index) => point(index, item.value).join(",")).join(" ");
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className="radar" role="img" aria-label="радар компетенций">
|
||||
<svg width={width} height={size} viewBox={`0 0 ${width} ${size}`} className="radar" role="img" aria-label="радар компетенций">
|
||||
{[0.25, 0.5, 0.75, 1].map((ring) => (
|
||||
<polygon
|
||||
key={ring}
|
||||
|
|
@ -36,9 +40,11 @@ export function Radar({ values, size = 220 }: { values: CompetencyScore[]; size?
|
|||
))}
|
||||
<polygon points={polygon} fill="rgba(31,111,61,0.18)" stroke="var(--ok)" strokeWidth={2} />
|
||||
{values.map((item, index) => {
|
||||
const [x, y] = point(index, 1.16);
|
||||
const [x, y] = point(index, 1.08);
|
||||
const angle = (Math.PI * 2 * index) / values.length - Math.PI / 2;
|
||||
const anchor = Math.cos(angle) > 0.25 ? "start" : Math.cos(angle) < -0.25 ? "end" : "middle";
|
||||
return (
|
||||
<text key={item.competency} x={x} y={y} textAnchor="middle" fontSize={10}>
|
||||
<text key={item.competency} x={x} y={y} textAnchor={anchor} fontSize={11}>
|
||||
{COMPETENCY_LABELS[item.competency] ?? item.competency}
|
||||
</text>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export function Director({
|
|||
рвёт звук немедленно.
|
||||
</p>
|
||||
<p>
|
||||
<input type="text" value={free} placeholder="своя реплика — нужна локальная модель"
|
||||
<input type="text" aria-label="Своя реплика звонящему" value={free} placeholder="своя реплика — нужна локальная модель"
|
||||
onChange={(event) => setFree(event.target.value)} />{" "}
|
||||
<button type="button" disabled={disabled || !free.trim()}
|
||||
onClick={() => { fire(`своя реплика: ${free.trim()}`, free.trim()); setFree(""); }}>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ type Draft = {
|
|||
body: JsonObject;
|
||||
};
|
||||
type Validation = { valid: boolean; errors: string[]; ground_truth?: JsonObject };
|
||||
type GrammarCheck = { valid: boolean; checks: Array<{
|
||||
field: string; passed: boolean; errors: string[]; source: string;
|
||||
}> };
|
||||
|
||||
async function api<T>(path: string, method = "GET", body?: unknown): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
|
|
@ -21,7 +24,7 @@ async function api<T>(path: string, method = "GET", body?: unknown): Promise<T>
|
|||
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));
|
||||
throw new Error(typeof detail === "string" ? detail : "Не удалось выполнить действие. Проверьте поля и повторите попытку.");
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
|
@ -49,10 +52,6 @@ function diff(before: JsonObject, after: JsonObject): JsonObject {
|
|||
return patch;
|
||||
}
|
||||
|
||||
function pretty(body: JsonObject): string {
|
||||
return JSON.stringify(body, null, 2);
|
||||
}
|
||||
|
||||
function stringField(body: JsonObject, key: string): string {
|
||||
return typeof body[key] === "string" ? body[key] : "";
|
||||
}
|
||||
|
|
@ -63,6 +62,40 @@ function generationLabel(generation: Draft["generation"]): string {
|
|||
return "копия шаблона";
|
||||
}
|
||||
|
||||
const SCENARIO_CONTENT_LABELS: Record<string, string> = {
|
||||
f_address: "Адрес", f_caller: "Данные заявителя", f_conscious: "Состояние сознания",
|
||||
f_fled: "Сведения о скрывшемся", f_gas: "Запах газа", f_observer: "Сведения очевидца",
|
||||
f_people: "Люди на месте", f_scale: "Масштаб происшествия", f_smoke: "Дым",
|
||||
f_suspects: "Сведения о подозреваемых", f_threat: "Угроза", f_vehicle: "Транспортное средство",
|
||||
f_victims: "Пострадавшие", f_what_burns: "Что горит", f_what_happened: "Что произошло",
|
||||
f_who: "Кто пострадал или участвует", q_address: "Уточнить адрес",
|
||||
q_address_check: "Проверить адрес", q_caller: "Уточнить данные заявителя",
|
||||
q_can_move: "Может ли человек передвигаться", q_conscious: "Проверить сознание",
|
||||
q_fled: "Уточнить сведения о скрывшемся", q_gas: "Уточнить запах газа",
|
||||
q_observer: "Уточнить сведения очевидца", q_people: "Спросить о людях на месте",
|
||||
q_scale: "Уточнить масштаб происшествия", q_smoke: "Уточнить сведения о дыме",
|
||||
q_suspects: "Уточнить сведения о подозреваемых", q_threat: "Уточнить угрозу",
|
||||
q_vehicle: "Уточнить сведения о транспорте", q_victims: "Спросить о пострадавших",
|
||||
q_what_burns: "Уточнить, что горит", q_what_happened: "Уточнить, что произошло",
|
||||
q_who: "Уточнить, кто пострадал или участвует",
|
||||
};
|
||||
|
||||
function contentLabel(id: unknown, kind: "fact" | "question", index: number): string {
|
||||
const key = typeof id === "string" ? id : "";
|
||||
return SCENARIO_CONTENT_LABELS[key]
|
||||
?? `${kind === "fact" ? "Дополнительный факт" : "Дополнительный вопрос"} ${index + 1}`;
|
||||
}
|
||||
|
||||
function grammarFieldLabel(field: string): string {
|
||||
if (field === "first_line") return "Первая реплика звонящего";
|
||||
const fact = /^facts\.([^.]+)\.(value|refined)$/.exec(field);
|
||||
if (fact) {
|
||||
const subject = SCENARIO_CONTENT_LABELS[fact[1]] ?? "Факт сценария";
|
||||
return `${fact[2] === "refined" ? "Уточнённый ответ" : "Ответ звонящего"}: ${subject}`;
|
||||
}
|
||||
return "Текст сценария";
|
||||
}
|
||||
|
||||
export function ScenarioEditor({
|
||||
scenarios,
|
||||
selectedScenarioId,
|
||||
|
|
@ -80,8 +113,10 @@ export function ScenarioEditor({
|
|||
const [revisionComment, setRevisionComment] = useState("");
|
||||
const [draftId, setDraftId] = useState("");
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [bodyText, setBodyText] = useState("");
|
||||
const [editorBody, setEditorBody] = useState<JsonObject | null>(null);
|
||||
const [validation, setValidation] = useState<Validation | null>(null);
|
||||
const [grammarCheck, setGrammarCheck] = useState<GrammarCheck | null>(null);
|
||||
const [manualEdits, setManualEdits] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
|
|
@ -93,8 +128,10 @@ export function ScenarioEditor({
|
|||
function openDraft(value: Draft) {
|
||||
setDraft(value);
|
||||
setDraftId(value.id);
|
||||
setBodyText(pretty(value.body));
|
||||
setEditorBody(value.body);
|
||||
setValidation(null);
|
||||
setGrammarCheck(null);
|
||||
setManualEdits(false);
|
||||
setError("");
|
||||
setNotice("");
|
||||
}
|
||||
|
|
@ -155,23 +192,51 @@ export function ScenarioEditor({
|
|||
|
||||
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 (!editorBody) throw new Error("Откройте черновик заново.");
|
||||
const patch = diff(draft.body, editorBody);
|
||||
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));
|
||||
setEditorBody(updated.body);
|
||||
setValidation(null);
|
||||
return updated;
|
||||
}
|
||||
|
||||
function editBodyField(key: string, value: unknown) {
|
||||
setEditorBody((previous) => previous ? { ...previous, [key]: value } : previous);
|
||||
setValidation(null);
|
||||
setGrammarCheck(null);
|
||||
setManualEdits(true);
|
||||
}
|
||||
|
||||
function editArrayField(arrayKey: string, index: number, key: string, value: unknown) {
|
||||
setEditorBody((previous) => {
|
||||
if (!previous || !Array.isArray(previous[arrayKey])) return previous;
|
||||
const items = [...previous[arrayKey] as unknown[]];
|
||||
if (!isObject(items[index])) return previous;
|
||||
items[index] = { ...items[index], [key]: value };
|
||||
return { ...previous, [arrayKey]: items };
|
||||
});
|
||||
setValidation(null);
|
||||
setGrammarCheck(null);
|
||||
setManualEdits(true);
|
||||
}
|
||||
|
||||
function editDdsDecision(key: "expected" | "reason", value: string) {
|
||||
setEditorBody((previous) => {
|
||||
if (!previous) return previous;
|
||||
const current = isObject(previous.dds_decision)
|
||||
? previous.dds_decision : { expected: "accept" };
|
||||
const decision = { ...current, [key]: value };
|
||||
if (key === "expected" && value === "accept") delete decision.reason;
|
||||
if (key === "expected" && value === "decline" && typeof decision.reason !== "string") {
|
||||
decision.reason = "";
|
||||
}
|
||||
return { ...previous, dds_decision: decision };
|
||||
});
|
||||
setValidation(null);
|
||||
}
|
||||
|
||||
async function validate() {
|
||||
await perform(async () => {
|
||||
const current = await save();
|
||||
|
|
@ -181,6 +246,20 @@ export function ScenarioEditor({
|
|||
});
|
||||
}
|
||||
|
||||
async function checkGrammar() {
|
||||
await perform(async () => {
|
||||
const current = await save();
|
||||
const result = await api<GrammarCheck>(
|
||||
`/api/scenarios/drafts/${encodeURIComponent(current.id)}/grammar-check`, "POST",
|
||||
);
|
||||
setGrammarCheck(result);
|
||||
setManualEdits(!result.valid);
|
||||
setNotice(result.valid
|
||||
? "Языковая проверка пройдена для первой реплики и текстовых фактов."
|
||||
: "Языковая проверка нашла замечания; исправьте их и запустите проверку повторно.");
|
||||
});
|
||||
}
|
||||
|
||||
async function revise() {
|
||||
await perform(async () => {
|
||||
setNotice("Qwen перерабатывает черновик по комментарию…");
|
||||
|
|
@ -232,6 +311,16 @@ export function ScenarioEditor({
|
|||
});
|
||||
}
|
||||
|
||||
const workingBody = editorBody ?? draft?.body ?? {};
|
||||
const ddsDecision = isObject(workingBody.dds_decision) ? workingBody.dds_decision : {};
|
||||
const expectedDdsDecision = ddsDecision.expected === "decline" ? "decline" : "accept";
|
||||
const editableFacts = Array.isArray(workingBody.facts)
|
||||
? workingBody.facts.map((item, index) => ({ item, index })).filter(({ item }) => isObject(item)) as Array<{ item: JsonObject; index: number }>
|
||||
: [];
|
||||
const editableChecklist = Array.isArray(workingBody.checklist)
|
||||
? workingBody.checklist.map((item, index) => ({ item, index })).filter(({ item }) => isObject(item)) as Array<{ item: JsonObject; index: number }>
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section className="scenario-editor" id="ai-scenario-editor" aria-busy={busy}>
|
||||
<h2>ИИ-формирователь и редактор сценариев</h2>
|
||||
|
|
@ -290,15 +379,72 @@ export function ScenarioEditor({
|
|||
</div>
|
||||
{draft && (
|
||||
<>
|
||||
<p className="ref">Черновик {draft.id} ({generationLabel(draft.generation)}). Факты и эталон доступны только преподавателю до утверждения. Поле score_weights позволяет задать веса метрик (0–10), например {"{\"address\": 3, \"dds_ack\": 2}"}.</p>
|
||||
<p className="ref">Черновик {draft.id} ({generationLabel(draft.generation)}). Факты и эталон доступны только преподавателю до утверждения. Веса критериев занятия настраиваются отдельно при запуске.</p>
|
||||
<div className="scenario-editor-result">
|
||||
<strong>Предпросмотр: {stringField(draft.body, "title")}</strong>
|
||||
<p>Первая реплика: «{stringField(draft.body, "first_line")}»</p>
|
||||
<p className="ref">Сюжетные факты — проверьте, что они согласуются друг с другом и с эталоном:</p>
|
||||
<ul>{Array.isArray(draft.body.facts) && draft.body.facts.map((item, index) =>
|
||||
isObject(item) ? <li key={`${index}-${String(item.id)}`}>{String(item.id)}: {String(item.value ?? "")}</li> : null
|
||||
<strong>Предпросмотр: {stringField(workingBody, "title")}</strong>
|
||||
<p>Первая реплика: «{stringField(workingBody, "first_line")}»</p>
|
||||
<p>Эталон первичного решения ДДС: {expectedDdsDecision === "decline"
|
||||
? `отказать — ${stringField(ddsDecision, "reason") || "нужно указать причину"}`
|
||||
: "принять карточку"}</p>
|
||||
<p className="ref">Проверьте, что сюжетные факты и ответы согласуются друг с другом.</p>
|
||||
<ul>{editableFacts.map(({ item, index }) =>
|
||||
<li key={`${index}-${String(item.id)}`}><strong>{contentLabel(item.id, "fact", index)}:</strong> {String(item.value ?? "")}</li>
|
||||
)}</ul>
|
||||
</div>
|
||||
<div className="scenario-human-editor">
|
||||
<h3>Содержание сценария</h3>
|
||||
<label>Название занятия
|
||||
<input value={stringField(workingBody, "title")} maxLength={200}
|
||||
onChange={(event) => editBodyField("title", event.target.value)} />
|
||||
</label>
|
||||
<label>Первая реплика звонящего
|
||||
<textarea rows={3} value={stringField(workingBody, "first_line")}
|
||||
onChange={(event) => editBodyField("first_line", event.target.value)} />
|
||||
</label>
|
||||
<fieldset className="scenario-dds-decision">
|
||||
<legend>Эталон первичного решения ДДС</legend>
|
||||
<p className="ref">Оставьте «Принять», если карточка относится к компетенции службы. Выберите отказ только для подтверждённого исключения: например, дубль или неподведомственная территория.</p>
|
||||
<label>Какое решение считается правильным
|
||||
<select value={expectedDdsDecision}
|
||||
onChange={(event) => editDdsDecision("expected", event.target.value)}>
|
||||
<option value="accept">Принять карточку</option>
|
||||
<option value="decline">Не принять карточку</option>
|
||||
</select>
|
||||
</label>
|
||||
{expectedDdsDecision === "decline" && <label>Почему отказ здесь правильный
|
||||
<input aria-label="Причина эталонного отказа ДДС" value={stringField(ddsDecision, "reason")}
|
||||
maxLength={500} placeholder="Например: дубликат уже передан по карточке…"
|
||||
onChange={(event) => editDdsDecision("reason", event.target.value)} />
|
||||
<small className="ref">Причина обязательна; она войдёт в норму оценки и отчёт курсанта.</small>
|
||||
</label>}
|
||||
</fieldset>
|
||||
<h4>Факты и ответы</h4>
|
||||
{editableFacts.map(({ item, index }) => <fieldset key={`${index}-${String(item.id)}`}>
|
||||
<legend>{contentLabel(item.id, "fact", index)}</legend>
|
||||
<label>Что сообщает звонящий
|
||||
<textarea rows={2} value={stringField(item, "value")}
|
||||
onChange={(event) => editArrayField("facts", index, "value", event.target.value)} />
|
||||
</label>
|
||||
{typeof item.refined === "string" && <label>Уточнённый ответ после переспроса
|
||||
<input value={item.refined} onChange={(event) => editArrayField("facts", index, "refined", event.target.value)} />
|
||||
</label>}
|
||||
</fieldset>)}
|
||||
{editableChecklist.length > 0 && <>
|
||||
<h4>Контрольные вопросы и эталонные ответы</h4>
|
||||
{editableChecklist.map(({ item, index }) => <fieldset key={`${index}-${String(item.id)}`}>
|
||||
<legend>{contentLabel(item.id, "question", index)}</legend>
|
||||
<label>Ожидаемый вопрос оператора
|
||||
<input value={stringField(item, "question")}
|
||||
onChange={(event) => editArrayField("checklist", index, "question", event.target.value)} />
|
||||
</label>
|
||||
<label>Факт, который нужно выяснить
|
||||
<textarea rows={2} value={stringField(item, "fact")}
|
||||
onChange={(event) => editArrayField("checklist", index, "fact", event.target.value)} />
|
||||
</label>
|
||||
</fieldset>)}
|
||||
</>}
|
||||
<p className="ref">После правок сохраните черновик, проверьте сценарий и повторите языковую проверку перед утверждением.</p>
|
||||
</div>
|
||||
<div className="scenario-editor-create">
|
||||
<label>Комментарий для переработки
|
||||
<input value={revisionComment} onChange={(event) => setRevisionComment(event.target.value)}
|
||||
|
|
@ -308,20 +454,34 @@ export function ScenarioEditor({
|
|||
<button type="button" onClick={revise}
|
||||
disabled={busy || revisionComment.trim().length < 10}>Переработать с ИИ</button>
|
||||
</div>
|
||||
<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>
|
||||
{manualEdits && <button type="button" onClick={checkGrammar} disabled={busy}>Проверить грамматику после правок</button>}
|
||||
<button type="button" className="start" onClick={approve}
|
||||
disabled={busy || !validation?.valid || manualEdits}>Утвердить</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{validation && <div className={validation.valid ? "scenario-editor-result" : "scenario-editor-result violated"}>
|
||||
{validation.valid ? <><strong>Проверка пройдена</strong><pre>{pretty(validation.ground_truth ?? {})}</pre></> :
|
||||
{validation.valid ? <><strong>Проверка пройдена</strong><dl className="scenario-ground-truth">
|
||||
{typeof validation.ground_truth?.incident_type === "string" && <div><dt>Тип происшествия</dt><dd>{validation.ground_truth.incident_type}</dd></div>}
|
||||
{typeof validation.ground_truth?.dds === "string" && <div><dt>Ответственная служба</dt><dd>{validation.ground_truth.dds}</dd></div>}
|
||||
{typeof validation.ground_truth?.incident_code === "string" && <div><dt>Код ЕКП</dt><dd>{validation.ground_truth.incident_code}</dd></div>}
|
||||
{typeof validation.ground_truth?.address === "string" && <div><dt>Адрес</dt><dd>{validation.ground_truth.address}</dd></div>}
|
||||
{typeof validation.ground_truth?.victims === "number" && <div><dt>Пострадавших</dt><dd>{validation.ground_truth.victims}</dd></div>}
|
||||
{Array.isArray(validation.ground_truth?.notify) && <div><dt>Оповещаемые службы</dt><dd>{validation.ground_truth.notify.map(String).join(", ") || "не назначены"}</dd></div>}
|
||||
{Array.isArray(validation.ground_truth?.required_facts) && <div><dt>Обязательные факты</dt><dd>{validation.ground_truth.required_facts.map(String).join(", ") || "не заданы"}</dd></div>}
|
||||
</dl></> :
|
||||
<><strong>Ошибки проверки</strong><ul>{validation.errors.map((item, index) => <li key={`${index}-${item}`}>{item}</li>)}</ul></>}
|
||||
</div>}
|
||||
{grammarCheck && <div className={grammarCheck.valid ? "scenario-editor-result" : "scenario-editor-result violated"}>
|
||||
<strong>{grammarCheck.valid ? "Грамматика: замечаний нет" : "Замечания к грамматике"}</strong>
|
||||
<ul>{grammarCheck.checks.flatMap((item) => item.errors.map((message, index) =>
|
||||
<li key={`${item.field}-${index}`}><strong>{grammarFieldLabel(item.field)}:</strong> {message}</li>
|
||||
))}</ul>
|
||||
<small className="ref">Проверены первая реплика и текстовые значения фактов; код не переписывает текст. Источник проверки для каждого поля: правила или Vikhr.</small>
|
||||
</div>}
|
||||
{error && <p className="violated" role="alert">{error}</p>}
|
||||
{notice && <p className="state-ok" role="status">{notice}</p>}
|
||||
</section>
|
||||
|
|
|
|||
105
frontend/src/features/instructor/ScoreWeightsEditor.tsx
Normal file
105
frontend/src/features/instructor/ScoreWeightsEditor.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { useState } from "react";
|
||||
import type { Exercise } from "@/shared/types/generated";
|
||||
|
||||
type LessonExercise = Exercise | "card_dds";
|
||||
|
||||
type MetricOption = { key: string; label: string; hint: string };
|
||||
|
||||
const OPERATOR_METRICS: MetricOption[] = [
|
||||
{ key: "answer_time", label: "Ответ на вызов", hint: "Скорость принятия звонка." },
|
||||
{ key: "interview_time", label: "Длительность опроса", hint: "Соблюдение норматива опроса." },
|
||||
{ key: "checklist_completeness", label: "Полнота опроса", hint: "Получение обязательных сведений из сценария." },
|
||||
{ key: "incident_signs", label: "Признаки происшествия", hint: "Верность признаков и кода ЕКП." },
|
||||
{ key: "incident_type", label: "Тип происшествия", hint: "Верность типа, если код ЕКП не задан." },
|
||||
{ key: "dds_choice", label: "Выбор службы", hint: "Маршрутизация для сценариев без кода ЕКП." },
|
||||
{ key: "outcome", label: "Исход вызова", hint: "Карточка, справка или иной предусмотренный итог." },
|
||||
{ key: "address_refined", label: "Уточнение адреса", hint: "Подтверждение уточняемых адресных ориентиров." },
|
||||
{ key: "address", label: "Адрес в карточке", hint: "Совпадение адреса с эталоном по компонентам." },
|
||||
{ key: "victims_count", label: "Пострадавшие", hint: "Точность числа, если оно известно из сценария." },
|
||||
{ key: "required_fields", label: "Обязательные поля КИО", hint: "Полнота заполнения полей карточки." },
|
||||
{ key: "dds_chain", label: "Передача карточки в ДДС", hint: "Успешная передача, если она предусмотрена." },
|
||||
{ key: "callback", label: "Обратный дозвон", hint: "Норматив повторного вызова при обрыве." },
|
||||
{ key: "description_grammar", label: "Грамматика описания", hint: "Учитывается, если включён критерий занятия." },
|
||||
{ key: "card_fill_time", label: "Время заполнения КИО", hint: "Соблюдение заданного преподавателем норматива." },
|
||||
];
|
||||
|
||||
const DDS_METRICS: MetricOption[] = [
|
||||
{ key: "dds_primary", label: "Первичное решение", hint: "Зафиксирован ли статус «Принята» или «Не принята»." },
|
||||
{ key: "dds_ack", label: "Срок первичного решения", hint: "Соблюдение установленного лимита реакции." },
|
||||
{ key: "dds_decision", label: "Решение по эталону", hint: "Соответствие принятию или обоснованному отказу." },
|
||||
{ key: "dds_crew", label: "Назначение бригады", hint: "Выбор необходимой бригады ДДС." },
|
||||
{ key: "dds_progress", label: "Ход реагирования", hint: "Статусы по фактическому ходу работ или отказу." },
|
||||
{ key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." },
|
||||
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
|
||||
{ key: "dds_work_time", label: "Время отработки карточки", hint: "Соблюдение лимита полного цикла карточки." },
|
||||
];
|
||||
|
||||
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
|
||||
"incident_signs", "incident_type", "dds_choice", "address", "victims_count",
|
||||
"required_fields", "description_grammar", "card_fill_time",
|
||||
].includes(key));
|
||||
const CALL_METRICS = OPERATOR_METRICS.filter(({ key }) => key !== "card_fill_time");
|
||||
|
||||
export function ScoreWeightsEditor({
|
||||
exercise,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
exercise: LessonExercise;
|
||||
values: Record<string, number>;
|
||||
onChange: (weights: Record<string, number>) => void;
|
||||
}) {
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const groups: [string, MetricOption[]][] = exercise === "dds"
|
||||
? [["Реагирование ДДС", DDS_METRICS]]
|
||||
: exercise === "card"
|
||||
? [["Карточка КИО", CARD_METRICS]]
|
||||
: exercise === "card_dds"
|
||||
? [["Карточка КИО", CARD_METRICS], ["Реагирование ДДС", DDS_METRICS]]
|
||||
: [["Опрос 112", CALL_METRICS], ["Реагирование ДДС", DDS_METRICS]];
|
||||
|
||||
function setWeight(key: string, raw: string) {
|
||||
const next = { ...values };
|
||||
if (raw === "") delete next[key];
|
||||
else {
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value)) return;
|
||||
next[key] = Math.max(0, Math.min(10, value));
|
||||
}
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
return <details className="lesson-score-weight-editor">
|
||||
<summary>Настроить веса оценки (необязательно)</summary>
|
||||
<p className="ref">
|
||||
Пустое поле сохраняет вес сценария или стандартный вес. Значение 0 исключает
|
||||
метрику из расчёта; допустимый диапазон — от 0 до 10.
|
||||
</p>
|
||||
{groups.map(([group, metrics]) => <fieldset key={group}>
|
||||
<legend>{group}</legend>
|
||||
{metrics.map((metric) => <label className="lesson-criterion" key={metric.key}>
|
||||
<span title={metric.hint}>{metric.label}
|
||||
<small className="weight-metric-hint" id={`weight-hint-${metric.key}`}>{metric.hint}</small>
|
||||
</span>
|
||||
<input type="number" min={0} max={10} step={0.5}
|
||||
aria-label={`Вес метрики «${metric.label}»`}
|
||||
aria-describedby={`weight-hint-${metric.key}`}
|
||||
value={drafts[metric.key] ?? values[metric.key] ?? ""} placeholder="—"
|
||||
onChange={(event) => setDrafts((previous) => ({ ...previous, [metric.key]: event.target.value }))}
|
||||
onBlur={(event) => {
|
||||
const raw = event.target.value.trim();
|
||||
if (!raw) {
|
||||
setDrafts((previous) => ({ ...previous, [metric.key]: "" }));
|
||||
setWeight(metric.key, "");
|
||||
return;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
const normalized = Math.max(0, Math.min(10, parsed));
|
||||
setDrafts((previous) => ({ ...previous, [metric.key]: String(normalized) }));
|
||||
setWeight(metric.key, String(normalized));
|
||||
}} />
|
||||
</label>)}
|
||||
</fieldset>)}
|
||||
</details>;
|
||||
}
|
||||
|
|
@ -12,20 +12,33 @@ import type { LessonCriteria, SessionMode } from "@/shared/types/generated";
|
|||
|
||||
export function useControl(sessionId: string | null) {
|
||||
const [status, setStatus] = useState<ChannelStatus>("closed");
|
||||
const channel = useRef<ReturnType<typeof controlChannel> | null>(null);
|
||||
const [openSessionId, setOpenSessionId] = useState<string | null>(null);
|
||||
const channel = useRef<{ sessionId: string; socket: ReturnType<typeof controlChannel> } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
const ch = controlChannel(sessionId, { onStatus: setStatus }).connect();
|
||||
channel.current = ch;
|
||||
channel.current = null;
|
||||
setOpenSessionId(null);
|
||||
if (!sessionId) {
|
||||
setStatus("closed");
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
setStatus("connecting");
|
||||
const ch = controlChannel(sessionId, { onStatus: (next) => {
|
||||
if (!active) return;
|
||||
setStatus(next);
|
||||
setOpenSessionId(next === "open" ? sessionId : null);
|
||||
} }).connect();
|
||||
channel.current = { sessionId, socket: ch };
|
||||
return () => {
|
||||
active = false;
|
||||
ch.close();
|
||||
channel.current = null;
|
||||
if (channel.current?.socket === ch) channel.current = null;
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const start = useCallback(
|
||||
(scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string, ddsService?: string, scenarioIds?: string[], ddsArrivalIntervalSeconds = 60, ddsMaxWaiting = 3, handoffToDds = false, criteria?: LessonCriteria) => {
|
||||
(targetSessionId: string, scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string, ddsService?: string, scenarioIds?: string[], ddsArrivalIntervalSeconds = 60, ddsMaxWaiting = 3, handoffToDds = false, criteria?: LessonCriteria, randomScenarioIds?: string[]) => {
|
||||
const message = {
|
||||
type: "scenario.start",
|
||||
scenario_id: scenarioId,
|
||||
|
|
@ -37,24 +50,27 @@ export function useControl(sessionId: string | null) {
|
|||
exercise,
|
||||
handoff_to_dds: handoffToDds,
|
||||
scenario_ids: scenarioIds?.length ? scenarioIds : undefined,
|
||||
random_scenario_ids: randomScenarioIds?.length ? randomScenarioIds : undefined,
|
||||
dds_arrival_interval_seconds: ddsArrivalIntervalSeconds,
|
||||
dds_max_waiting: ddsMaxWaiting,
|
||||
criteria,
|
||||
} as const;
|
||||
channel.current?.send(message);
|
||||
const current = channel.current;
|
||||
if (!current || current.sessionId !== targetSessionId) return false;
|
||||
return current.socket.send(message);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const note = useCallback(
|
||||
(transcriptRef: string, text: string) =>
|
||||
channel.current?.send({ type: "instructor_note.add", transcript_ref: transcriptRef, text }),
|
||||
channel.current?.socket.send({ type: "instructor_note.add", transcript_ref: transcriptRef, text }),
|
||||
[],
|
||||
);
|
||||
|
||||
const inject = useCallback(
|
||||
(directive: string, immediate: boolean) =>
|
||||
channel.current?.send({
|
||||
channel.current?.socket.send({
|
||||
type: "director.inject",
|
||||
directive,
|
||||
// `immediate` рвёт звук на полуслове и годится только для обрыва связи:
|
||||
|
|
@ -64,8 +80,8 @@ export function useControl(sessionId: string | null) {
|
|||
[],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => channel.current?.send({ type: "session.stop" }), []);
|
||||
const playReference = useCallback(() => channel.current?.send({ type: "reference.play" }), []);
|
||||
const stop = useCallback(() => channel.current?.socket.send({ type: "session.stop" }), []);
|
||||
const playReference = useCallback(() => channel.current?.socket.send({ type: "reference.play" }), []);
|
||||
|
||||
return { status, start, note, inject, stop, playReference };
|
||||
return { status, openSessionId, start, note, inject, stop, playReference };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm =
|
|||
const groups = visibleGroups(dds);
|
||||
const section = (group: FieldGroup) => (
|
||||
<section className={`kio-arm-section kio-arm-${group.area}`} key={group.title}>
|
||||
{(!referenceArm || group.area !== "header") && <div className="kio-arm-section-title">{group.title}</div>}
|
||||
{(!referenceArm || group.area !== "header") && <div className="kio-arm-section-title">
|
||||
{referenceArm && group.area === "incident" ? "Введите тип происшествия" : group.title}
|
||||
</div>}
|
||||
<div className="kio-arm-fields">
|
||||
{(referenceArm && group.area === "incident"
|
||||
? [...group.fields].sort((a, b) => Number(b.path === "incident_type") - Number(a.path === "incident_type"))
|
||||
|
|
@ -198,6 +200,7 @@ function SignsPicker({
|
|||
{levels.map((level) => (
|
||||
<SignSelect
|
||||
key={level}
|
||||
level={level}
|
||||
prefix={value.slice(0, level)}
|
||||
group={group}
|
||||
chosen={value[level] ?? ""}
|
||||
|
|
@ -218,8 +221,9 @@ function SignsPicker({
|
|||
}
|
||||
|
||||
function SignSelect({
|
||||
prefix, group, chosen, onPick,
|
||||
level, prefix, group, chosen, onPick,
|
||||
}: {
|
||||
level: number;
|
||||
prefix: string[];
|
||||
group: number | null;
|
||||
chosen: string;
|
||||
|
|
@ -230,7 +234,7 @@ function SignSelect({
|
|||
// Уровня может не быть: часть происшествий классифицируется двумя признаками.
|
||||
if (!page.isLoading && values.length === 0) return null;
|
||||
return (
|
||||
<select aria-label={`Признак происшествия ${prefix.length + 1}`} value={chosen} onChange={(e) => onPick(e.target.value)} disabled={page.isLoading}>
|
||||
<select aria-label={`Признак происшествия ${level + 1}`} value={chosen} onChange={(e) => onPick(e.target.value)} disabled={page.isLoading}>
|
||||
<option value="">—</option>
|
||||
{values.map((sign) => (
|
||||
<option key={sign} value={sign}>{sign}</option>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,24 @@ export interface CardState {
|
|||
|
||||
export const empty: CardState = { server: {}, pending: {} };
|
||||
|
||||
function sameValue(left: unknown, right: unknown): boolean {
|
||||
if (Object.is(left, right)) return true;
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
return Array.isArray(left) && Array.isArray(right)
|
||||
&& left.length === right.length
|
||||
&& left.every((item, index) => sameValue(item, right[index]));
|
||||
}
|
||||
if (left && right && typeof left === "object" && typeof right === "object") {
|
||||
const leftRecord = left as Record<string, unknown>;
|
||||
const rightRecord = right as Record<string, unknown>;
|
||||
const keys = Object.keys(leftRecord);
|
||||
return keys.length === Object.keys(rightRecord).length
|
||||
&& keys.every((key) => key in rightRecord
|
||||
&& sameValue(leftRecord[key], rightRecord[key]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function setNested(card: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
|
||||
const [head, ...rest] = path.split(".");
|
||||
if (rest.length === 0) return { ...card, [head]: value };
|
||||
|
|
@ -42,7 +60,7 @@ export function applyPatch(
|
|||
const pending = { ...state.pending };
|
||||
for (const [path, value] of Object.entries(fields)) {
|
||||
server = setNested(server, path, value);
|
||||
if (source === "auto" || pending[path] === value) delete pending[path];
|
||||
if (source === "auto" || sameValue(pending[path], value)) delete pending[path];
|
||||
}
|
||||
return { server, pending };
|
||||
}
|
||||
|
|
@ -51,7 +69,7 @@ export function applyPatch(
|
|||
export function applyState(state: CardState, card: Record<string, unknown>): CardState {
|
||||
const pending = { ...state.pending };
|
||||
for (const path of Object.keys(pending)) {
|
||||
if (readPath(card, path) === pending[path]) delete pending[path];
|
||||
if (sameValue(readPath(card, path), pending[path])) delete pending[path];
|
||||
}
|
||||
return { server: card, pending };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,16 +47,46 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setStatus(sessionId ? "connecting" : "closed");
|
||||
setTitle(null);
|
||||
setMode(undefined);
|
||||
setTrainee(null);
|
||||
setCard({ server: { ...emptyCard.server }, pending: { ...emptyCard.pending } });
|
||||
setRequiredFields([]);
|
||||
setTranscript([]);
|
||||
setTimers([]);
|
||||
setHintsUsed(0);
|
||||
setNotes(new Map());
|
||||
setEnded(false);
|
||||
setReport(null);
|
||||
setError(null);
|
||||
if (!sessionId) return;
|
||||
// A report may already be archived when an instructor opens a history link;
|
||||
// the observe socket is then intentionally unavailable after process restart.
|
||||
void fetch(`/api/sessions/${sessionId}/report`, { credentials: "same-origin" })
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((saved) => { if (saved) setReport(saved as SessionReport); })
|
||||
.catch(() => undefined);
|
||||
let active = true;
|
||||
// Active monitors used to query this on every open. Almost all such requests
|
||||
// could only return score_not_ready, competing with the snapshot under load.
|
||||
// Ask for the report only after completion or when no live session exists
|
||||
// (e.g. history opened after a backend restart).
|
||||
let reportLoading = false;
|
||||
let reportLoaded = false;
|
||||
const loadReport = () => {
|
||||
if (reportLoading || reportLoaded) return;
|
||||
reportLoading = true;
|
||||
void fetch(`/api/sessions/${sessionId}/report`, { credentials: "same-origin" })
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((saved) => {
|
||||
if (!active) return;
|
||||
if (saved) {
|
||||
reportLoaded = true;
|
||||
setReport(saved as SessionReport);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => { reportLoading = false; });
|
||||
};
|
||||
const channel = observeChannel(sessionId, {
|
||||
onStatus: setStatus,
|
||||
onStatus: (next) => { if (active) setStatus(next); },
|
||||
onEvent: (event) => {
|
||||
if (!active) return;
|
||||
switch (event.type) {
|
||||
case "session.snapshot":
|
||||
setTitle(event.scenario_title);
|
||||
|
|
@ -68,6 +98,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
|||
setTimers(event.timers);
|
||||
setHintsUsed(event.hints_used ?? 0);
|
||||
setEnded(event.ended ?? false);
|
||||
if (event.ended) loadReport();
|
||||
break;
|
||||
case "kio.state":
|
||||
setCard((prev) => applyState(prev, event.kio as unknown as Record<string, unknown>));
|
||||
|
|
@ -89,20 +120,19 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
|||
break;
|
||||
case "session.ended":
|
||||
setEnded(true);
|
||||
loadReport();
|
||||
break;
|
||||
case "error":
|
||||
setError(event.message);
|
||||
if (event.code === "session_not_found") loadReport();
|
||||
break;
|
||||
case "score.ready":
|
||||
void fetch(`/api/sessions/${sessionId}/report`)
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then(setReport)
|
||||
.catch(() => setReport(null));
|
||||
loadReport();
|
||||
break;
|
||||
}
|
||||
},
|
||||
}).connect();
|
||||
return () => channel.close();
|
||||
return () => { active = false; channel.close(); };
|
||||
}, [sessionId]);
|
||||
|
||||
return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, report, error };
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export function SelfAssessment({
|
|||
<td>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Комментарий к самооценке"
|
||||
value={comment}
|
||||
placeholder="например: запнулся на адресе, растерялся"
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ const TITLES: Record<string, string> = {
|
|||
interview: "Опрос",
|
||||
dds_notify: "Оповещение ДДС",
|
||||
dds_ack: "Подтверждение ДДС",
|
||||
dds_work: "Отработка карточки ДДС",
|
||||
card_fill: "Заполнение карточки КИО",
|
||||
zone_check: "Зона ответственности",
|
||||
callback: "Обратный дозвон",
|
||||
close: "Снятие с контроля",
|
||||
|
|
|
|||
Loading…
Reference in a new issue