lct-22: пульт директив — преподаватель ведёт ситуацию
Мягкие директивы перекрывают дугу сценария и применяются со следующей реплики: разговор не дёргается от того, что преподаватель что-то нажал. Жёсткие правят ситуацию — обрыв связи рвёт звук тем же механизмом, что перебивание, и запускает норматив обратного дозвона; второй пострадавший правит эталон; неточный адрес снимает раскрытый факт, и оператор обязан переспросить. Своя копия сценария на занятие: директивы правят факты и эталон, а сценарий был общим на библиотеку — правка в одной группе протекла бы во все остальные. Тест проверяет, что библиотека не изменилась. Свободный текст честно отказывает: офлайн-дерево предгенерировано, произвольную фразу взять неоткуда, и преподаватель видит это на пульте. Ни одна директива не трогает карточку курсанта — он управляет ситуацией, а не работой обучаемого. Занятие теперь собирается целиком и только потом регистрируется: наблюдатель мог увидеть его без слот-автомата и звонящего.
This commit is contained in:
parent
978d7dc1aa
commit
57f4c858b6
13 changed files with 317 additions and 23 deletions
83
frontend/src/features/instructor/Director.tsx
Normal file
83
frontend/src/features/instructor/Director.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Пульт директив: преподаватель ведёт ситуацию по ходу разговора.
|
||||
//
|
||||
// За этим пультом будет сидеть человек, который видит его впервые и не может
|
||||
// ошибиться: кнопки крупные, подписаны человеческим языком, нажатие сразу видно
|
||||
// в журнале — даже если эффект применится со следующей реплики
|
||||
// (docs/arch/FRONTEND.md).
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
const SOFT: { key: string; label: string }[] = [
|
||||
{ key: "panic_rises", label: "Паника нарастает" },
|
||||
{ key: "screaming", label: "Переходит на крик" },
|
||||
{ key: "turns_aggressive", label: "Переходит в агрессию" },
|
||||
{ key: "distracted", label: "Звонящий отвлёкся" },
|
||||
];
|
||||
|
||||
const HARD: { key: string; label: string; danger?: boolean }[] = [
|
||||
{ key: "line_dropped", label: "Связь обрывается", danger: true },
|
||||
{ key: "second_victim", label: "Появился второй пострадавший" },
|
||||
{ key: "address_wrong", label: "Адрес оказался неточным" },
|
||||
];
|
||||
|
||||
export function Director({
|
||||
onInject,
|
||||
disabled,
|
||||
error,
|
||||
}: {
|
||||
onInject: (directive: string, immediate: boolean) => void;
|
||||
disabled?: boolean;
|
||||
error?: string | null;
|
||||
}) {
|
||||
const [free, setFree] = useState("");
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
|
||||
const fire = (label: string, key: string, immediate = false) => {
|
||||
onInject(key, immediate);
|
||||
const time = new Date().toLocaleTimeString("ru-RU");
|
||||
setLog((prev) => [`${time} — ${label}`, ...prev].slice(0, 6));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="director">
|
||||
<h3>Ведение ситуации</h3>
|
||||
<div className="director-buttons">
|
||||
{SOFT.map((item) => (
|
||||
<button key={item.key} type="button" disabled={disabled}
|
||||
onClick={() => fire(item.label, item.key)}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
{HARD.map((item) => (
|
||||
<button key={item.key} type="button" disabled={disabled}
|
||||
className={item.danger ? "danger" : ""}
|
||||
onClick={() => fire(item.label, item.key, item.key === "line_dropped")}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="ref">
|
||||
Мягкие применяются со следующей реплики — разговор не дёргается. «Связь обрывается»
|
||||
рвёт звук немедленно.
|
||||
</p>
|
||||
<p>
|
||||
<input type="text" value={free} placeholder="своя реплика — нужна сеть и LLM"
|
||||
onChange={(event) => setFree(event.target.value)} />{" "}
|
||||
<button type="button" disabled={disabled || !free.trim()}
|
||||
onClick={() => { fire(`своя реплика: ${free.trim()}`, free.trim()); setFree(""); }}>
|
||||
Отправить
|
||||
</button>
|
||||
</p>
|
||||
{error && <p className="violated">{error}</p>}
|
||||
{log.length > 0 && (
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
{log.map((line, index) => (
|
||||
<tr key={index}><td>{line}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -42,8 +42,20 @@ export function useControl(sessionId: string | null) {
|
|||
[],
|
||||
);
|
||||
|
||||
const inject = useCallback(
|
||||
(directive: string, immediate: boolean) =>
|
||||
channel.current?.send({
|
||||
type: "director.inject",
|
||||
directive,
|
||||
// `immediate` рвёт звук на полуслове и годится только для обрыва связи:
|
||||
// разговор не должен дёргаться от того, что преподаватель что-то нажал.
|
||||
mode: immediate ? "immediate" : "next_turn",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => channel.current?.send({ type: "session.stop" }), []);
|
||||
const playReference = useCallback(() => channel.current?.send({ type: "reference.play" }), []);
|
||||
|
||||
return { status, start, note, stop, playReference };
|
||||
return { status, start, note, inject, stop, playReference };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export interface ObservedSession {
|
|||
notes: Map<string, string>;
|
||||
ended: boolean;
|
||||
report: SessionReport | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useObserve(sessionId: string | null): ObservedSession {
|
||||
|
|
@ -43,6 +44,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
|||
const [notes, setNotes] = useState<Map<string, string>>(new Map());
|
||||
const [ended, setEnded] = useState(false);
|
||||
const [report, setReport] = useState<SessionReport | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
|
@ -82,6 +84,9 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
|||
case "session.ended":
|
||||
setEnded(true);
|
||||
break;
|
||||
case "error":
|
||||
setError(event.message);
|
||||
break;
|
||||
case "score.ready":
|
||||
void fetch(`/api/sessions/${sessionId}/report`)
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
|
|
@ -94,5 +99,5 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
|||
return () => channel.close();
|
||||
}, [sessionId]);
|
||||
|
||||
return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, report };
|
||||
return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, report, error };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Debrief } from "@/features/debrief/Debrief";
|
||||
import { Director } from "@/features/instructor/Director";
|
||||
import { useControl } from "@/features/instructor/useControl";
|
||||
import { KioCard } from "@/features/kio-card/KioCard";
|
||||
import { ModeBanner } from "@/features/mode-banner/ModeBanner";
|
||||
|
|
@ -122,6 +123,9 @@ export function Instructor() {
|
|||
)}
|
||||
</p>
|
||||
)}
|
||||
{sessionId && !session.ended && (
|
||||
<Director onInject={control.inject} disabled={control.status !== "open"} error={session.error} />
|
||||
)}
|
||||
{links && (
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
|
|
|
|||
|
|
@ -88,3 +88,9 @@ button:disabled { opacity: 0.5; cursor: default; }
|
|||
.mode-choice { display: block; }
|
||||
.clickable { cursor: pointer; }
|
||||
.clickable:hover th { background: #e8ecf1; }
|
||||
|
||||
/* Пульт директив: кнопки крупные, обрыв связи отделён цветом. */
|
||||
.director-buttons { display: grid; grid-template-columns: 1fr; gap: 6px; }
|
||||
.director-buttons button { padding: 10px 12px; text-align: left; font-size: 15px; }
|
||||
.director-buttons .danger { border-color: var(--violated); color: var(--violated); font-weight: 600; }
|
||||
.director input[type="text"] { width: 60%; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue