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

@ -94,7 +94,7 @@ async def checklist(session_id: UUID) -> list[ChecklistItemOut]:
raise HTTPException(status_code=404, detail="session_not_found") raise HTTPException(status_code=404, detail="session_not_found")
if not state.ended: if not state.ended:
raise HTTPException(status_code=409, detail="call_not_ended") raise HTTPException(status_code=409, detail="call_not_ended")
scenario = store.get(state.scenario_id) scenario = state.scenario or store.get(state.scenario_id)
if scenario is None: if scenario is None:
raise HTTPException(status_code=404, detail="scenario_not_found") raise HTTPException(status_code=404, detail="scenario_not_found")
return [ return [
@ -116,7 +116,7 @@ def _live(session_id: UUID):
state = hub.get(session_id) state = hub.get(session_id)
if state is None: if state is None:
raise HTTPException(status_code=404, detail="session_not_found") raise HTTPException(status_code=404, detail="session_not_found")
scenario = store.get(state.scenario_id) scenario = state.scenario or store.get(state.scenario_id)
if scenario is None: if scenario is None:
raise HTTPException(status_code=404, detail="scenario_not_found") raise HTTPException(status_code=404, detail="scenario_not_found")
return state, scenario return state, scenario

View file

@ -70,7 +70,7 @@ def _next_hint(state) -> tuple[str, str] | None:
if state.slots is not None: if state.slots is not None:
candidates = state.slots.unasked() candidates = state.slots.unasked()
else: else:
scenario = store.get(state.scenario_id) scenario = state.scenario or store.get(state.scenario_id)
candidates = scenario.checklist if scenario else [] candidates = scenario.checklist if scenario else []
for item in candidates: for item in candidates:
if item.id not in state.hints_shown and item.question: if item.id not in state.hints_shown and item.question:

View file

@ -16,6 +16,9 @@ from pydantic import TypeAdapter, ValidationError
from app.domain.events import ( from app.domain.events import (
CallEndReason, CallEndReason,
CallEnded,
ErrorEvent,
ErrorKind,
ScoreReady, ScoreReady,
CallIncoming, CallIncoming,
ErrorEvent, ErrorEvent,
@ -29,6 +32,8 @@ from app.domain.events import (
import asyncio import asyncio
from app.dialog.caller import TemplateCaller from app.dialog.caller import TemplateCaller
from app.dialog.director import apply as apply_directive
from app.dialog.director import mood_of
from app.dialog.persona import PersonaState from app.dialog.persona import PersonaState
from app.dialog.runtime import get_embedder from app.dialog.runtime import get_embedder
from app.dialog.slots import SlotMachine from app.dialog.slots import SlotMachine
@ -59,23 +64,26 @@ async def _start(session_id: UUID, event) -> None:
session_id, scenario.id, event.mode.value, event.trainee session_id, scenario.id, event.mode.value, event.trainee
) )
state = hub.register( # Занятие собирается целиком и только потом регистрируется: иначе
SessionState( # наблюдатель, подключившийся в эту щель, увидит полусобранное состояние
session_id=session_id, # без слот-автомата и звонящего.
scenario_id=scenario.id, state = SessionState(
scenario_title=scenario.title, session_id=session_id,
level=scenario.level.value, scenario_id=scenario.id,
mode=event.mode, scenario_title=scenario.title,
required_fields=scenario.required_fields, level=scenario.level.value,
trainee_name=event.trainee, mode=event.mode,
attempt=attempt, scenario=scenario.model_copy(deep=True),
) required_fields=scenario.required_fields,
trainee_name=event.trainee,
attempt=attempt,
) )
embedder = get_embedder() embedder = get_embedder()
if embedder is not None: if embedder is not None:
state.slots = SlotMachine(scenario, embedder) state.slots = SlotMachine(state.scenario, embedder)
state.persona = PersonaState(scenario.persona) state.persona = PersonaState(state.scenario.persona)
state.caller = TemplateCaller() state.caller = TemplateCaller()
hub.register(state)
# Первая реплика и филлеры синтезируются, пока курсант не снял трубку: # Первая реплика и филлеры синтезируются, пока курсант не снял трубку:
# «Алло! Помогите!» должно прозвучать мгновенно (docs/arch/BACKEND.md). # «Алло! Помогите!» должно прозвучать мгновенно (docs/arch/BACKEND.md).
@ -92,6 +100,7 @@ async def _start(session_id: UUID, event) -> None:
caller_number="+7 (495) 000-00-00", caller_number="+7 (495) 000-00-00",
level=scenario.level, level=scenario.level,
mode=event.mode, mode=event.mode,
scenario=scenario.model_copy(deep=True),
required_fields=scenario.required_fields, required_fields=scenario.required_fields,
), ),
) )
@ -168,11 +177,28 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
} }
hub.to_observers(session_id, ScoreReady(session_id=session_id)) hub.to_observers(session_id, ScoreReady(session_id=session_id))
case "director.inject": case "director.inject":
# Поведение звонящего — карточка lct-07, пульт — lct-22.
# До них директива копится в состоянии и видна в разборе.
state = hub.get(session_id) state = hub.get(session_id)
if state is not None: if state is None:
state.directives.append(event.directive) continue
result = apply_directive(state, event.directive)
if result.needs_network:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.DIRECTIVE_NEEDS_NETWORK,
message="Свободный текст требует LLM: офлайн доступны только кнопки",
))
continue
state.directives.append(event.directive)
voice = state.voice
if result.drop_line and voice is not None:
# Обрыв рвёт звук на полуслове тем же механизмом, что
# перебивание, и запускает норматив обратного дозвона.
voice.barge_in()
if result.drop_line:
state.on_event("call.dropped")
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.DROPPED))
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.DROPPED))
elif result.say and voice is not None:
voice.speak(result.say, mood_of(state))
case _: case _:
hub.to_observers( hub.to_observers(
session_id, session_id,

View file

@ -0,0 +1,86 @@
"""Директивы преподавателя: он ведёт ситуацию по ходу разговора.
«Управление ситуаций с рабочего места преподавателя» — дословное требование ТЗ,
понятое буквально (docs/product/CALL-SIM.md#преподаватель-режиссёр).
Мягкие директивы меняют подачу и живут до конца звонка, жёсткие правят факты
и таймеры. **Ни одна не трогает карточку курсанта:** преподаватель управляет
ситуацией, а не работой обучаемого.
"""
from dataclasses import dataclass
from app.domain.events import Mood
#: Мягкие директивы: только подача. Настроение берёт `PersonaState`.
SOFT = {
"panic_rises": "Паника нарастает",
"screaming": "Переходит на крик",
"turns_aggressive": "Переходит в агрессию",
"distracted": "Звонящий отвлёкся",
}
#: Жёсткие: правят факты, таймеры или ход звонка.
HARD = {
"line_dropped": "Связь обрывается",
"second_victim": "Появился второй пострадавший",
"address_wrong": "Адрес оказался неточным",
}
#: Что звонящий скажет следующей репликой по жёсткой директиве.
HARD_LINES = {
"second_victim": "Тут ещё женщина на площадке! Она не дышит! Быстрее!",
"address_wrong": "Стойте! Я перепутал, адрес другой! Запишите заново!",
}
@dataclass
class DirectiveResult:
applied: bool
#: Реплика, которую звонящий должен сказать сразу.
say: str | None = None
#: Оборвать связь: звук на полуслове, дальше обратный дозвон.
drop_line: bool = False
#: Директива требует сети (свободный текст без LLM).
needs_network: bool = False
def apply(state, directive: str) -> DirectiveResult:
"""Применить директиву к живому занятию."""
if directive in SOFT:
if state.persona is not None:
state.persona.directive = directive
return DirectiveResult(applied=True)
if directive == "line_dropped":
return DirectiveResult(applied=True, drop_line=True)
if directive == "second_victim":
# Правка эталона: теперь пострадавших на одного больше, и оценка
# карточки считается против нового числа.
truth = state.scenario.ground_truth if state.scenario else None
if truth is not None:
truth.victims = (truth.victims or 0) + 1
return DirectiveResult(applied=True, say=HARD_LINES[directive])
if directive == "address_wrong":
# Факт снимается с раскрытых: оператор обязан переспросить адрес,
# а в оценке полнота опроса снова считает его недобытым.
if state.slots is not None:
for fact in list(state.slots.revealed_facts()):
if "address" in fact.id:
state.slots.invalidate(fact.id)
return DirectiveResult(applied=True, say=HARD_LINES[directive])
# Свободный текст уходит в контекст персоны — но подставить его в реплику
# может только LLM. Офлайн-дерево предгенерировано, произвольную фразу
# взять неоткуда (docs/arch/CONTRACT.md).
if state.persona is not None:
state.persona.directive = None
if state.directives is not None:
state.directives.append(directive)
return DirectiveResult(applied=False, needs_network=True)
def mood_of(state) -> Mood:
return state.persona.mood if state.persona else Mood.PANIC

View file

@ -20,7 +20,8 @@ log = logging.getLogger(__name__)
async def finish(session_id: UUID, state) -> None: async def finish(session_id: UUID, state) -> None:
scenario = store.get(state.scenario_id) # Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
scenario = state.scenario or store.get(state.scenario_id)
if scenario is None: if scenario is None:
return return

View file

@ -21,6 +21,7 @@ from app.domain.events import (
from app.dialog.caller import TemplateCaller from app.dialog.caller import TemplateCaller
from app.dialog.persona import PersonaState from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine from app.dialog.slots import SlotMachine
from app.scenarios.schema import Scenario
from app.domain.kio import KIO, apply_patch from app.domain.kio import KIO, apply_patch
from app.session.timers import SessionTimers from app.session.timers import SessionTimers
@ -52,6 +53,9 @@ class SessionState:
# Звонящий. Автомата нет, если не скачана модель эмбеддингов: # Звонящий. Автомата нет, если не скачана модель эмбеддингов:
# занятие идёт, подсказки откатываются на порядок чек-листа. # занятие идёт, подсказки откатываются на порядок чек-листа.
# Своя копия сценария на занятие: директивы преподавателя правят факты
# и эталон, и правка в одной группе не должна протекать в остальные.
scenario: Scenario | None = None
slots: SlotMachine | None = None slots: SlotMachine | None = None
persona: PersonaState | None = None persona: PersonaState | None = None
caller: TemplateCaller | None = None caller: TemplateCaller | None = None

View file

@ -26,6 +26,7 @@ STARTS: dict[str, tuple[TimerCode, ...]] = {
"call.answer": (TimerCode.INTERVIEW,), "call.answer": (TimerCode.INTERVIEW,),
"dds.dispatch": (TimerCode.DDS_ACK, TimerCode.CLOSE), "dds.dispatch": (TimerCode.DDS_ACK, TimerCode.CLOSE),
"card.received": (TimerCode.ZONE_CHECK,), "card.received": (TimerCode.ZONE_CHECK,),
"call.dropped": (TimerCode.CALLBACK,),
"callback.dial": (TimerCode.CALLBACK,), "callback.dial": (TimerCode.CALLBACK,),
} }

View file

@ -12,6 +12,7 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app from app.main import app
from app.scenarios import store
from app.session.hub import hub from app.session.hub import hub
@ -307,3 +308,68 @@ def test_instructor_correction_keeps_the_automatic_score(client):
assert corrected["score_final"] == 80.0 assert corrected["score_final"] == 80.0
assert corrected["score_auto"] == auto, "автооценка должна сохраниться рядом" assert corrected["score_auto"] == auto, "автооценка должна сохраниться рядом"
assert corrected["overridden_by"] == "преподаватель" assert corrected["overridden_by"] == "преподаватель"
def test_soft_directive_changes_how_the_caller_sounds(client):
from app.domain.events import Mood
with lesson(client) as (session_id, control):
state = hub.get(session_id)
control.send_json({"type": "director.inject", "directive": "turns_aggressive", "mode": "next_turn"})
wait_for(lambda: state.persona.directive == "turns_aggressive")
assert state.persona.mood is Mood.AGGRESSIVE, "директива должна перекрывать дугу сценария"
def test_wrong_address_makes_the_operator_ask_again(client):
"""Жёсткая директива правит факты: раскрытый адрес снимается,
и в оценке полнота опроса снова считает его недобытым."""
with lesson(client) as (session_id, control):
state = hub.get(session_id)
if state.slots is None:
pytest.skip("нет модели эмбеддингов — make models")
state.slots.hear("Назовите адрес")
assert "f_address" in state.slots.revealed
control.send_json({"type": "director.inject", "directive": "address_wrong", "mode": "next_turn"})
wait_for(lambda: "f_address" not in state.slots.revealed)
assert "f_address" in state.slots.missing_required()
def test_second_victim_corrects_the_reference(client):
with lesson(client) as (session_id, control):
state = hub.get(session_id)
before = state.scenario.ground_truth.victims
control.send_json({"type": "director.inject", "directive": "second_victim", "mode": "next_turn"})
wait_for(lambda: state.scenario.ground_truth.victims != before)
assert state.scenario.ground_truth.victims == before + 1
library = store.get(state.scenario_id)
assert library.ground_truth.victims == before, "правка занятия не должна менять библиотеку"
def test_dropped_line_ends_the_call_and_starts_callback(client):
from app.domain.timers import TimerCode
with lesson(client) as (session_id, control):
state = hub.get(session_id)
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
wait_for(lambda: state.started_at)
control.send_json({"type": "director.inject", "directive": "line_dropped", "mode": "immediate"})
message = read_until(trainee, "call.ended")
assert message["reason"] == "dropped"
assert TimerCode.CALLBACK in state.timers.timers, "норматив обратного дозвона должен пойти"
def test_free_text_directive_says_it_needs_network(client):
"""Офлайн-дерево предгенерировано: произвольную фразу взять неоткуда."""
with lesson(client) as (session_id, control):
with client.websocket_connect(f"/ws/observe/{session_id}") as observer:
observer.receive_json()
control.send_json({"type": "director.inject", "directive": "скажи, что у тебя кот на балконе", "mode": "next_turn"})
message = read_until(observer, "error")
assert message["code"] == "directive_needs_network"

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 stop = useCallback(() => channel.current?.send({ type: "session.stop" }), []);
const playReference = useCallback(() => channel.current?.send({ type: "reference.play" }), []); 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>; notes: Map<string, string>;
ended: boolean; ended: boolean;
report: SessionReport | null; report: SessionReport | null;
error: string | null;
} }
export function useObserve(sessionId: string | null): ObservedSession { 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 [notes, setNotes] = useState<Map<string, string>>(new Map());
const [ended, setEnded] = useState(false); const [ended, setEnded] = useState(false);
const [report, setReport] = useState<SessionReport | null>(null); const [report, setReport] = useState<SessionReport | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!sessionId) return; if (!sessionId) return;
@ -82,6 +84,9 @@ export function useObserve(sessionId: string | null): ObservedSession {
case "session.ended": case "session.ended":
setEnded(true); setEnded(true);
break; break;
case "error":
setError(event.message);
break;
case "score.ready": case "score.ready":
void fetch(`/api/sessions/${sessionId}/report`) void fetch(`/api/sessions/${sessionId}/report`)
.then((response) => (response.ok ? response.json() : null)) .then((response) => (response.ok ? response.json() : null))
@ -94,5 +99,5 @@ export function useObserve(sessionId: string | null): ObservedSession {
return () => channel.close(); return () => channel.close();
}, [sessionId]); }, [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 };
} }

View file

@ -7,6 +7,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Debrief } from "@/features/debrief/Debrief"; import { Debrief } from "@/features/debrief/Debrief";
import { Director } from "@/features/instructor/Director";
import { useControl } from "@/features/instructor/useControl"; import { useControl } from "@/features/instructor/useControl";
import { KioCard } from "@/features/kio-card/KioCard"; import { KioCard } from "@/features/kio-card/KioCard";
import { ModeBanner } from "@/features/mode-banner/ModeBanner"; import { ModeBanner } from "@/features/mode-banner/ModeBanner";
@ -122,6 +123,9 @@ export function Instructor() {
)} )}
</p> </p>
)} )}
{sessionId && !session.ended && (
<Director onInject={control.inject} disabled={control.status !== "open"} error={session.error} />
)}
{links && ( {links && (
<table className="grid"> <table className="grid">
<tbody> <tbody>

View file

@ -88,3 +88,9 @@ button:disabled { opacity: 0.5; cursor: default; }
.mode-choice { display: block; } .mode-choice { display: block; }
.clickable { cursor: pointer; } .clickable { cursor: pointer; }
.clickable:hover th { background: #e8ecf1; } .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%; }