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([]); + + 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 ( +
+

Ведение ситуации

+
+ {SOFT.map((item) => ( + + ))} + {HARD.map((item) => ( + + ))} +
+

+ Мягкие применяются со следующей реплики — разговор не дёргается. «Связь обрывается» + рвёт звук немедленно. +

+

+ setFree(event.target.value)} />{" "} + +

+ {error &&

{error}

} + {log.length > 0 && ( + + + {log.map((line, index) => ( + + ))} + +
{line}
+ )} +
+ ); +} diff --git a/frontend/src/features/instructor/useControl.ts b/frontend/src/features/instructor/useControl.ts index 10ac6ba..b90ca98 100644 --- a/frontend/src/features/instructor/useControl.ts +++ b/frontend/src/features/instructor/useControl.ts @@ -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 }; } diff --git a/frontend/src/features/observe/useObserve.ts b/frontend/src/features/observe/useObserve.ts index 0ae78ab..7494319 100644 --- a/frontend/src/features/observe/useObserve.ts +++ b/frontend/src/features/observe/useObserve.ts @@ -28,6 +28,7 @@ export interface ObservedSession { notes: Map; 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>(new Map()); const [ended, setEnded] = useState(false); const [report, setReport] = useState(null); + const [error, setError] = useState(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 }; } diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx index 67ae728..251d0ff 100644 --- a/frontend/src/pages/instructor/Instructor.tsx +++ b/frontend/src/pages/instructor/Instructor.tsx @@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from "react"; import { Debrief } from "@/features/debrief/Debrief"; +import { Director } from "@/features/instructor/Director"; import { useControl } from "@/features/instructor/useControl"; import { KioCard } from "@/features/kio-card/KioCard"; import { ModeBanner } from "@/features/mode-banner/ModeBanner"; @@ -122,6 +123,9 @@ export function Instructor() { )}

)} + {sessionId && !session.ended && ( + + )} {links && ( diff --git a/frontend/src/styles.css b/frontend/src/styles.css index d4b2552..df58424 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -88,3 +88,9 @@ button:disabled { opacity: 0.5; cursor: default; } .mode-choice { display: block; } .clickable { cursor: pointer; } .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%; }