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")
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

View file

@ -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:

View file

@ -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,

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:
scenario = store.get(state.scenario_id)
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
scenario = state.scenario or store.get(state.scenario_id)
if scenario is None:
return

View file

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

View file

@ -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,),
}