lct-22: пульт директив — преподаватель ведёт ситуацию

Мягкие директивы перекрывают дугу сценария и применяются со следующей
реплики: разговор не дёргается от того, что преподаватель что-то нажал.
Жёсткие правят ситуацию — обрыв связи рвёт звук тем же механизмом, что
перебивание, и запускает норматив обратного дозвона; второй пострадавший
правит эталон; неточный адрес снимает раскрытый факт, и оператор обязан
переспросить.

Своя копия сценария на занятие: директивы правят факты и эталон, а
сценарий был общим на библиотеку — правка в одной группе протекла бы во
все остальные. Тест проверяет, что библиотека не изменилась.

Свободный текст честно отказывает: офлайн-дерево предгенерировано,
произвольную фразу взять неоткуда, и преподаватель видит это на пульте.

Ни одна директива не трогает карточку курсанта — он управляет ситуацией,
а не работой обучаемого.

Занятие теперь собирается целиком и только потом регистрируется:
наблюдатель мог увидеть его без слот-автомата и звонящего.
This commit is contained in:
Ivan Gerasimov 2026-09-17 21:38:43 +03:00
commit 57f4c858b6
13 changed files with 317 additions and 23 deletions

View 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>
);
}

View file

@ -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 };
}

View file

@ -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 };
}