lct-22: пульт директив — преподаватель ведёт ситуацию
Мягкие директивы перекрывают дугу сценария и применяются со следующей реплики: разговор не дёргается от того, что преподаватель что-то нажал. Жёсткие правят ситуацию — обрыв связи рвёт звук тем же механизмом, что перебивание, и запускает норматив обратного дозвона; второй пострадавший правит эталон; неточный адрес снимает раскрытый факт, и оператор обязан переспросить. Своя копия сценария на занятие: директивы правят факты и эталон, а сценарий был общим на библиотеку — правка в одной группе протекла бы во все остальные. Тест проверяет, что библиотека не изменилась. Свободный текст честно отказывает: офлайн-дерево предгенерировано, произвольную фразу взять неоткуда, и преподаватель видит это на пульте. Ни одна директива не трогает карточку курсанта — он управляет ситуацией, а не работой обучаемого. Занятие теперь собирается целиком и только потом регистрируется: наблюдатель мог увидеть его без слот-автомата и звонящего.
This commit is contained in:
parent
978d7dc1aa
commit
57f4c858b6
13 changed files with 317 additions and 23 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
# наблюдатель, подключившийся в эту щель, увидит полусобранное состояние
|
||||||
|
# без слот-автомата и звонящего.
|
||||||
|
state = SessionState(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
scenario_id=scenario.id,
|
scenario_id=scenario.id,
|
||||||
scenario_title=scenario.title,
|
scenario_title=scenario.title,
|
||||||
level=scenario.level.value,
|
level=scenario.level.value,
|
||||||
mode=event.mode,
|
mode=event.mode,
|
||||||
|
scenario=scenario.model_copy(deep=True),
|
||||||
required_fields=scenario.required_fields,
|
required_fields=scenario.required_fields,
|
||||||
trainee_name=event.trainee,
|
trainee_name=event.trainee,
|
||||||
attempt=attempt,
|
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:
|
||||||
|
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)
|
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,
|
||||||
|
|
|
||||||
86
backend/app/dialog/director.py
Normal file
86
backend/app/dialog/director.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
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 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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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%; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue