diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py
index 81d97e0..243f574 100644
--- a/backend/app/api/http/sessions.py
+++ b/backend/app/api/http/sessions.py
@@ -94,7 +94,7 @@ async def checklist(session_id: UUID) -> list[ChecklistItemOut]:
raise HTTPException(status_code=404, detail="session_not_found")
if not state.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:
raise HTTPException(status_code=404, detail="scenario_not_found")
return [
@@ -116,7 +116,7 @@ def _live(session_id: UUID):
state = hub.get(session_id)
if state is None:
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:
raise HTTPException(status_code=404, detail="scenario_not_found")
return state, scenario
diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py
index 32395a6..ae56910 100644
--- a/backend/app/api/ws/call.py
+++ b/backend/app/api/ws/call.py
@@ -70,7 +70,7 @@ def _next_hint(state) -> tuple[str, str] | None:
if state.slots is not None:
candidates = state.slots.unasked()
else:
- scenario = store.get(state.scenario_id)
+ scenario = state.scenario or store.get(state.scenario_id)
candidates = scenario.checklist if scenario else []
for item in candidates:
if item.id not in state.hints_shown and item.question:
diff --git a/backend/app/api/ws/control.py b/backend/app/api/ws/control.py
index 7e0792f..2d2cf59 100644
--- a/backend/app/api/ws/control.py
+++ b/backend/app/api/ws/control.py
@@ -16,6 +16,9 @@ from pydantic import TypeAdapter, ValidationError
from app.domain.events import (
CallEndReason,
+ CallEnded,
+ ErrorEvent,
+ ErrorKind,
ScoreReady,
CallIncoming,
ErrorEvent,
@@ -29,6 +32,8 @@ from app.domain.events import (
import asyncio
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.runtime import get_embedder
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
)
- state = hub.register(
- SessionState(
- session_id=session_id,
- scenario_id=scenario.id,
- scenario_title=scenario.title,
- level=scenario.level.value,
- mode=event.mode,
- required_fields=scenario.required_fields,
- trainee_name=event.trainee,
- attempt=attempt,
- )
+ # Занятие собирается целиком и только потом регистрируется: иначе
+ # наблюдатель, подключившийся в эту щель, увидит полусобранное состояние
+ # без слот-автомата и звонящего.
+ state = SessionState(
+ session_id=session_id,
+ scenario_id=scenario.id,
+ scenario_title=scenario.title,
+ level=scenario.level.value,
+ mode=event.mode,
+ scenario=scenario.model_copy(deep=True),
+ required_fields=scenario.required_fields,
+ trainee_name=event.trainee,
+ attempt=attempt,
)
embedder = get_embedder()
if embedder is not None:
- state.slots = SlotMachine(scenario, embedder)
- state.persona = PersonaState(scenario.persona)
+ state.slots = SlotMachine(state.scenario, embedder)
+ state.persona = PersonaState(state.scenario.persona)
state.caller = TemplateCaller()
+ hub.register(state)
# Первая реплика и филлеры синтезируются, пока курсант не снял трубку:
# «Алло! Помогите!» должно прозвучать мгновенно (docs/arch/BACKEND.md).
@@ -92,6 +100,7 @@ async def _start(session_id: UUID, event) -> None:
caller_number="+7 (495) 000-00-00",
level=scenario.level,
mode=event.mode,
+ scenario=scenario.model_copy(deep=True),
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))
case "director.inject":
- # Поведение звонящего — карточка lct-07, пульт — lct-22.
- # До них директива копится в состоянии и видна в разборе.
state = hub.get(session_id)
- if state is not None:
- state.directives.append(event.directive)
+ if state is None:
+ 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 _:
hub.to_observers(
session_id,
diff --git a/backend/app/dialog/director.py b/backend/app/dialog/director.py
new file mode 100644
index 0000000..4501bf9
--- /dev/null
+++ b/backend/app/dialog/director.py
@@ -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
diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py
index a37eeb5..d022589 100644
--- a/backend/app/session/finish.py
+++ b/backend/app/session/finish.py
@@ -20,7 +20,8 @@ log = logging.getLogger(__name__)
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:
return
diff --git a/backend/app/session/state.py b/backend/app/session/state.py
index a333e7d..7f57266 100644
--- a/backend/app/session/state.py
+++ b/backend/app/session/state.py
@@ -21,6 +21,7 @@ from app.domain.events import (
from app.dialog.caller import TemplateCaller
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine
+from app.scenarios.schema import Scenario
from app.domain.kio import KIO, apply_patch
from app.session.timers import SessionTimers
@@ -52,6 +53,9 @@ class SessionState:
# Звонящий. Автомата нет, если не скачана модель эмбеддингов:
# занятие идёт, подсказки откатываются на порядок чек-листа.
+ # Своя копия сценария на занятие: директивы преподавателя правят факты
+ # и эталон, и правка в одной группе не должна протекать в остальные.
+ scenario: Scenario | None = None
slots: SlotMachine | None = None
persona: PersonaState | None = None
caller: TemplateCaller | None = None
diff --git a/backend/app/session/timers.py b/backend/app/session/timers.py
index 21fba4d..6c4e85f 100644
--- a/backend/app/session/timers.py
+++ b/backend/app/session/timers.py
@@ -26,6 +26,7 @@ STARTS: dict[str, tuple[TimerCode, ...]] = {
"call.answer": (TimerCode.INTERVIEW,),
"dds.dispatch": (TimerCode.DDS_ACK, TimerCode.CLOSE),
"card.received": (TimerCode.ZONE_CHECK,),
+ "call.dropped": (TimerCode.CALLBACK,),
"callback.dial": (TimerCode.CALLBACK,),
}
diff --git a/backend/tests/test_ws.py b/backend/tests/test_ws.py
index 669b7a8..b8dad49 100644
--- a/backend/tests/test_ws.py
+++ b/backend/tests/test_ws.py
@@ -12,6 +12,7 @@ import pytest
from fastapi.testclient import TestClient
from app.main import app
+from app.scenarios import store
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_auto"] == auto, "автооценка должна сохраниться рядом"
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"
diff --git a/frontend/src/features/instructor/Director.tsx b/frontend/src/features/instructor/Director.tsx
new file mode 100644
index 0000000..6183b0d
--- /dev/null
+++ b/frontend/src/features/instructor/Director.tsx
@@ -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
+ Мягкие применяются со следующей реплики — разговор не дёргается. «Связь обрывается»
+ рвёт звук немедленно.
+
+ setFree(event.target.value)} />{" "}
+
+ {error}Ведение ситуации
+
+
+ {log.map((line, index) => (
+
+ )}
+
+ ))}
+
+ {line}