lct-39: пауза останавливает действия и голос

This commit is contained in:
kaifarikman 2026-09-27 18:06:49 +03:00
commit 88bb54fc24
12 changed files with 252 additions and 47 deletions

View file

@ -228,7 +228,10 @@ async def active(
states[state.session_id] = state states[state.session_id] = state
for state in states.values(): for state in states.values():
elapsed = (max(0, int((now - state.started_at).total_seconds())) paused_ms = state.total_paused_ms
if state.paused and state.paused_at is not None:
paused_ms += max(0, int((now - state.paused_at).total_seconds() * 1000))
elapsed = (max(0, int((now - state.started_at).total_seconds() - paused_ms / 1000))
if state.started_at else 0) if state.started_at else 0)
station = state.station_snapshot() if state.exercise is Exercise.DDS else None station = state.station_snapshot() if state.exercise is Exercise.DDS else None
queue = station.queue_cards if station else [] queue = station.queue_cards if station else []

View file

@ -235,6 +235,9 @@ async def _handle(session_id: UUID, state, event) -> None:
code=ErrorKind.INTERNAL, message="Не удалось получить ответ заявителя. Попробуйте ещё раз.", code=ErrorKind.INTERNAL, message="Не удалось получить ответ заявителя. Попробуйте ещё раз.",
)) ))
return return
if state.paused:
# Ответ модели мог закончиться уже после команды преподавателя.
return
caller_entry = state.append(Speaker.CALLER, line.text, line.mood) caller_entry = state.append(Speaker.CALLER, line.text, line.mood)
hub.to_trainee(session_id, CallerUtterance( hub.to_trainee(session_id, CallerUtterance(
utterance_id=uuid4(), text=line.text, utterance_id=uuid4(), text=line.text,
@ -399,10 +402,9 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
_who, state = entered _who, state = entered
with hub.trainee(session_id) as queue: with hub.trainee(session_id) as queue:
if state.paused: # Каждый новый канал получает серверное состояние, включая снятую паузу:
# Переподключение не ждёт следующего `session.pause`: баннер должен # клиент мог потерять событие resume во время переподключения.
# быть на экране сразу, раз сервер уже отклоняет команды (lct-39). queue.put_nowait(SessionPaused(paused=state.paused))
hub.to_trainee(session_id, SessionPaused(paused=True))
if state.exercise is Exercise.CARD: if state.exercise is Exercise.CARD:
from app.api.ws.control import card_briefing from app.api.ws.control import card_briefing

View file

@ -12,7 +12,7 @@ import logging
import time import time
from uuid import UUID from uuid import UUID
from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded, SessionPaused
from app.domain.statuses import ServiceStatus, current from app.domain.statuses import ServiceStatus, current
from app.domain.timers import TimerCode from app.domain.timers import TimerCode
from app.scoring.ai_coach import coach from app.scoring.ai_coach import coach
@ -27,6 +27,7 @@ from app.session.score import ScoringSnapshot, score_session, scoring_scenario
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
from app.session.store import ( from app.session.store import (
LessonEnded, LessonEnded,
LessonResumed,
ScoreArchived, ScoreArchived,
ScoreCalculated, ScoreCalculated,
ScoreOverridden, ScoreOverridden,
@ -181,7 +182,16 @@ async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
""" """
if state.ended: if state.ended:
return return
was_paused = state.paused
paused_ms_before = state.total_paused_ms
ended_at = state.end(reason) ended_at = state.end(reason)
if was_paused:
hub.record(session_id, LessonResumed(
at=ended_at, author="система", role="system",
paused_ms=state.total_paused_ms - paused_ms_before,
))
hub.broadcast(session_id, SessionPaused(paused=False))
hub.to_station(session_id, SessionPaused(paused=False))
if state.exercise is Exercise.CARD and state.dispatched_card is None: if state.exercise is Exercise.CARD and state.dispatched_card is None:
state.on_event("card.end") state.on_event("card.end")
# Норматив отработки фиксируется событием, а не текущим значением часов. # Норматив отработки фиксируется событием, а не текущим значением часов.

View file

@ -197,6 +197,8 @@ class SessionState(PersistedSession):
card.timers.pause(now) card.timers.pause(now)
self.paused = True self.paused = True
self.paused_at = now_utc() self.paused_at = now_utc()
if self.voice is not None:
self.voice.pause()
def resume(self) -> None: def resume(self) -> None:
"""Снять паузу: таймеры продолжают с прежней отметки, очередь сдвигается на простой.""" """Снять паузу: таймеры продолжают с прежней отметки, очередь сдвигается на простой."""

View file

@ -86,6 +86,8 @@ class VoiceSession:
# ── вход: кадры микрофона ── # ── вход: кадры микрофона ──
def feed(self, frame: bytes) -> None: def feed(self, frame: bytes) -> None:
if self.state.paused:
return
for event in self._vad.push(frame): for event in self._vad.push(frame):
if isinstance(event, SpeechStarted) and self.speaking: if isinstance(event, SpeechStarted) and self.speaking:
self.barge_in() self.barge_in()
@ -104,6 +106,8 @@ class VoiceSession:
первая фраза «Алло! Помогите!» — ровно та, которую оператор перебивает первая фраза «Алло! Помогите!» — ровно та, которую оператор перебивает
чаще всего, — не гасилась вовсе. чаще всего, — не гасилась вовсе.
""" """
if self.state.paused:
return asyncio.create_task(asyncio.sleep(0))
if self.speaking: if self.speaking:
self._reply.cancel() self._reply.cancel()
self._reply = asyncio.create_task(self.say(text, mood)) self._reply = asyncio.create_task(self.say(text, mood))
@ -119,6 +123,17 @@ class VoiceSession:
self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="barge_in")) self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="barge_in"))
log.info("сессия %s: перебивание", self.session_id) log.info("сессия %s: перебивание", self.session_id)
def pause(self) -> None:
"""Прервать текущую реплику и убрать речь, накопленную до паузы."""
if self.speaking:
self._reply.cancel()
if self._utterance_id is not None:
self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="director"))
self._utterance_id = None
while not self._queue.empty():
self._queue.get_nowait()
self._vad.reset()
async def close(self) -> None: async def close(self) -> None:
for task in (self._reply, self._worker): for task in (self._reply, self._worker):
if task is not None: if task is not None:
@ -129,6 +144,8 @@ class VoiceSession:
async def _work(self) -> None: async def _work(self) -> None:
while True: while True:
audio, ended_at = await self._queue.get() audio, ended_at = await self._queue.get()
if self.state.paused:
continue
# Новая фраза оператора, пока звонящий ещё говорит, — тоже перебивание. # Новая фраза оператора, пока звонящий ещё говорит, — тоже перебивание.
if self.speaking: if self.speaking:
self.barge_in() self.barge_in()
@ -143,6 +160,8 @@ class VoiceSession:
timing = TurnTiming() timing = TurnTiming()
started = time.monotonic() started = time.monotonic()
text = await self.models.transcribe(audio) text = await self.models.transcribe(audio)
if self.state.paused:
return
timing.stt_ms = (time.monotonic() - started) * 1000 timing.stt_ms = (time.monotonic() - started) * 1000
if not text: if not text:
return return
@ -155,6 +174,8 @@ class VoiceSession:
started = time.monotonic() started = time.monotonic()
line = await self._caller_line(text) line = await self._caller_line(text)
if self.state.paused:
return
timing.caller_ms = (time.monotonic() - started) * 1000 timing.caller_ms = (time.monotonic() - started) * 1000
await self.say(line.text, line.mood, ended_at=ended_at, timing=timing) await self.say(line.text, line.mood, ended_at=ended_at, timing=timing)
@ -171,6 +192,8 @@ class VoiceSession:
self, text: str, mood: Mood, *, ended_at: float | None = None, timing: TurnTiming | None = None self, text: str, mood: Mood, *, ended_at: float | None = None, timing: TurnTiming | None = None
) -> None: ) -> None:
"""Произнести реплику: событие с текстом, звук по предложениям, ожидание конца.""" """Произнести реплику: событие с текстом, звук по предложениям, ожидание конца."""
if self.state.paused:
return
self._utterance_id = utterance_id = uuid4() self._utterance_id = utterance_id = uuid4()
entry = self.state.append(Speaker.CALLER, text, mood) entry = self.state.append(Speaker.CALLER, text, mood)
self.send_event(CallerUtterance(utterance_id=utterance_id, text=text, at=entry.at, mood=mood)) self.send_event(CallerUtterance(utterance_id=utterance_id, text=text, at=entry.at, mood=mood))
@ -186,6 +209,8 @@ class VoiceSession:
pcm = await self._first_sentence(sentence, mood, ended_at, timing) pcm = await self._first_sentence(sentence, mood, ended_at, timing)
else: else:
pcm = await self.synthesize(sentence) pcm = await self.synthesize(sentence)
if self.state.paused:
return
if index == 0 and timing is not None: if index == 0 and timing is not None:
timing.tts_first_ms = (time.monotonic() - synth_started) * 1000 timing.tts_first_ms = (time.monotonic() - synth_started) * 1000
if not pcm: if not pcm:
@ -205,6 +230,7 @@ class VoiceSession:
# Ждём, пока курсант дослушает: перебивание в это время отменит задачу. # Ждём, пока курсант дослушает: перебивание в это время отменит задачу.
await asyncio.sleep(max(0.0, playback_ends - time.monotonic())) await asyncio.sleep(max(0.0, playback_ends - time.monotonic()))
self.send_event(TtsEnd(utterance_id=utterance_id)) self.send_event(TtsEnd(utterance_id=utterance_id))
self._utterance_id = None
async def _first_sentence( async def _first_sentence(
self, sentence: str, mood: Mood, ended_at: float, timing: TurnTiming | None self, sentence: str, mood: Mood, ended_at: float, timing: TurnTiming | None
@ -212,17 +238,25 @@ class VoiceSession:
"""Первое предложение с филлером: если к секунде после конца фразы звука """Первое предложение с филлером: если к секунде после конца фразы звука
ещё нет, звонящий «переспрашивает», а ответ встанет в очередь за ним.""" ещё нет, звонящий «переспрашивает», а ответ встанет в очередь за ним."""
synthesis = asyncio.ensure_future(self.synthesize(sentence)) synthesis = asyncio.ensure_future(self.synthesize(sentence))
try:
remaining = FILLER_AFTER_S - (time.monotonic() - ended_at) remaining = FILLER_AFTER_S - (time.monotonic() - ended_at)
if remaining > 0: if remaining > 0:
done, _ = await asyncio.wait({synthesis}, timeout=remaining) done, _ = await asyncio.wait({synthesis}, timeout=remaining)
if done: if done:
return synthesis.result() return synthesis.result()
filler = await self.synthesize(FILLERS.get(mood, FILLERS[Mood.PANIC])) filler = await self.synthesize(FILLERS.get(mood, FILLERS[Mood.PANIC]))
if filler: if filler and not self.state.paused:
self.send_audio(filler) self.send_audio(filler)
if timing is not None: if timing is not None:
timing.filler = True timing.filler = True
return await synthesis return await synthesis
finally:
if not synthesis.done():
synthesis.cancel()
try:
await synthesis
except asyncio.CancelledError:
pass
async def synthesize(self, text: str) -> bytes: async def synthesize(self, text: str) -> bytes:
return await cached_synthesize(self.models, text) return await cached_synthesize(self.models, text)

View file

@ -71,6 +71,17 @@ class StreamingVad:
def in_speech(self) -> bool: def in_speech(self) -> bool:
return self._in_speech return self._in_speech
def reset(self) -> None:
"""Не склеивать фрагменты речи до и после паузы занятия."""
self._state.fill(0)
self._context.fill(0)
self._pending = np.zeros(0, dtype=np.float32)
self._preroll.clear()
self._speech.clear()
self._voiced_ms = 0
self._silence_ms = 0
self._in_speech = False
def _probability(self, window: np.ndarray) -> float: def _probability(self, window: np.ndarray) -> float:
frame = np.concatenate([self._context, window])[None, :] frame = np.concatenate([self._context, window])[None, :]
output, self._state = self._session.run( output, self._state = self._session.run(

View file

@ -9,6 +9,7 @@ import asyncio
import time import time
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@ -16,7 +17,8 @@ from fastapi.testclient import TestClient
from app.api.http import sessions as sessions_http from app.api.http import sessions as sessions_http
from app.api.ws import control as control_ws from app.api.ws import control as control_ws
from app.domain.events import CallEndReason, Exercise, LessonCriteria, SessionMode from app.api.ws import call as call_ws
from app.domain.events import CallEndReason, Exercise, LessonCriteria, SessionMode, SessionPaused, TtsCancel, TtsEnd
from app.domain.statuses import ServiceStatus from app.domain.statuses import ServiceStatus
from app.domain.timers import TimerCode from app.domain.timers import TimerCode
from app.main import app from app.main import app
@ -163,6 +165,12 @@ def test_checkpoint_round_trip_keeps_pause_without_adding_downtime(clock):
assert restored_timer.elapsed_ms == card.timers.timers[TimerCode.DDS_ACK].elapsed_ms assert restored_timer.elapsed_ms == card.timers.timers[TimerCode.DDS_ACK].elapsed_ms
assert restored_timer.started_at is None assert restored_timer.started_at is None
# После takeover пауза всё ещё действует; лишь явное возобновление
# продолжает норматив с пяти секунд до прерывания.
restored.resume()
clock.advance(10)
assert restored_timer.current_ms(clock.now) == 15_000
def test_pause_and_resume_are_recorded_for_audit(monkeypatch): def test_pause_and_resume_are_recorded_for_audit(monkeypatch):
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
@ -250,15 +258,30 @@ def test_ending_session_while_paused_finalizes_pause_and_unblocks_report(clock,
idle = timedelta(seconds=45) idle = timedelta(seconds=45)
monkeypatch.setattr("app.session.state.now_utc", lambda: base + idle) monkeypatch.setattr("app.session.state.now_utc", lambda: base + idle)
events = []
async def run(): async def run():
with hub.trainee(state.session_id) as queue:
async with hub.operation(state.session_id): async with hub.operation(state.session_id):
await end_session(state.session_id, state, CallEndReason.INSTRUCTOR) await end_session(state.session_id, state, CallEndReason.INSTRUCTOR)
async with hub.operation(state.session_id):
await call_ws._handle(
state.session_id, state,
SimpleNamespace(type="self_assessment.submit", missed=[], comment="Проверил"),
)
while not queue.empty():
events.append(queue.get_nowait())
asyncio.run(run()) asyncio.run(run())
assert state.ended assert state.ended
assert state.paused is False assert state.paused is False
assert state.total_paused_ms >= 30_000 assert state.total_paused_ms >= 30_000
assert state.self_assessed
assert state.score is not None
assert state.score["full_report"]["total_paused_ms"] >= 30_000
assert any(isinstance(event, SessionPaused) and not event.paused for event in events)
assert any(isinstance(record, LessonResumed) for _sid, batch in store.commits for record in batch)
def test_dds_work_overdue_survives_pause(): def test_dds_work_overdue_survives_pause():
@ -280,6 +303,27 @@ def test_dds_work_overdue_survives_pause():
assert overdue is True assert overdue is True
def test_active_registry_keeps_existing_overdue_during_pause(http_client, clock):
app.dependency_overrides[sessions_http.optional_session] = lambda: None
state, card = _dds_state_with_active_card(clock)
state.owner_login = "dev"
timer = card.timers.timers.setdefault(TimerCode.DDS_WORK, Timer(code=TimerCode.DDS_WORK))
card.timers.limits[TimerCode.DDS_WORK] = 1_000
timer.start(clock.now)
clock.advance(5)
state.pause()
hub._sessions[state.session_id] = state
try:
response = http_client.get("/api/sessions/active")
assert response.status_code == 200
row = next(item for item in response.json() if item["session_id"] == str(state.session_id))
assert row["dds_work_overdue_cards"] == 1
assert row["paused"] is True
finally:
hub._sessions.pop(state.session_id, None)
app.dependency_overrides.pop(sessions_http.optional_session, None)
@pytest.fixture @pytest.fixture
def http_client(monkeypatch): def http_client(monkeypatch):
async def audit_in_memory(*_args, **_kwargs): async def audit_in_memory(*_args, **_kwargs):
@ -347,3 +391,62 @@ def test_reconnect_to_call_replays_paused_banner(http_client):
message["type"] == "session.paused" and message["paused"] message["type"] == "session.paused" and message["paused"]
for message in messages for message in messages
) )
control.send_json({"type": "session.resume"})
_wait(lambda: not hub.get(session_id).paused)
with http_client.websocket_connect(f"/ws/call/{session_id}") as trainee:
message = trainee.receive_json()
assert message == {"type": "session.paused", "paused": False}
@pytest.mark.asyncio
async def test_pause_cancels_inflight_voice_and_clears_buffer(monkeypatch):
from app.voice import pipeline
class FakeVad:
def __init__(self, *_args, **_kwargs):
self.reset_count = 0
def reset(self):
self.reset_count += 1
def push(self, _frame):
return []
monkeypatch.setattr(pipeline, "StreamingVad", FakeVad)
state, _card = _dds_state_with_active_card(lambda: 1_000.0)
events = []
audio = []
started = asyncio.Event()
release = asyncio.Event()
class Models:
vad_path = None
voice = pipeline.VoiceSession(
state.session_id, state, Models(), events.append, lambda _event: None, audio.append,
)
state.voice = voice
async def slow_synthesis(_text):
started.set()
await release.wait()
return b"\x00\x00" * 2_400
monkeypatch.setattr(voice, "synthesize", slow_synthesis)
try:
speaking = voice.speak("Алло, помогите!", state.persona.mood if state.persona else pipeline.Mood.PANIC)
await asyncio.wait_for(started.wait(), timeout=1)
voice._queue.put_nowait((b"old", time.monotonic()))
state.pause()
release.set()
with pytest.raises(asyncio.CancelledError):
await speaking
await asyncio.sleep(0)
assert any(isinstance(event, TtsCancel) and event.reason == "director" for event in events)
assert not any(isinstance(event, TtsEnd) for event in events)
assert audio == []
assert voice._queue.empty()
assert voice._vad.reset_count == 1
finally:
await voice.close()

View file

@ -59,6 +59,7 @@ export function useCall(sessionId: string | null) {
const outbox = useRef<Record<string, unknown>>({}); const outbox = useRef<Record<string, unknown>>({});
const patchTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const patchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const textMode = useRef(false); const textMode = useRef(false);
const pausedRef = useRef(false);
const append = useCallback((line: Line) => { const append = useCallback((line: Line) => {
setLines((prev) => { setLines((prev) => {
@ -72,19 +73,10 @@ export function useCall(sessionId: string | null) {
const ch = callChannel(sessionId, { const ch = callChannel(sessionId, {
onStatus: (next) => { onStatus: (next) => {
setStatus(next); setStatus(next);
if (next === "open") {
// Браузер не подтверждает доставку WebSocket-кадра. Всё, что ещё
// отмечено pending и не пришло эхом от сервера, повторяем после
// reconnect: kio.patch идемпотентен и не создаёт двойных действий.
queueMicrotask(() => setCard((current) => {
if (Object.keys(current.pending).length) {
channel.current?.send({ type: "kio.patch", fields: current.pending });
}
return current;
}));
}
}, },
onBinary: (frame) => audio.current?.playback.enqueue(frame), onBinary: (frame) => {
if (!pausedRef.current) audio.current?.playback.enqueue(frame);
},
onEvent: (event: ServerToTrainee) => { onEvent: (event: ServerToTrainee) => {
if (event.type === "text.turn.accepted") { if (event.type === "text.turn.accepted") {
append({ speaker: "operator", text: event.text }); append({ speaker: "operator", text: event.text });
@ -155,7 +147,27 @@ export function useCall(sessionId: string | null) {
setScoreReady(true); setScoreReady(true);
break; break;
case "session.paused": case "session.paused":
pausedRef.current = event.paused;
setPaused(event.paused); setPaused(event.paused);
if (event.paused) {
audio.current?.playback.flush();
setCallerSpeaking(false);
if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = null;
} else {
// При переподключении сервер первым присылает состояние паузы.
// Только после этого повторяем правки, ожидающие подтверждения.
queueMicrotask(() => setCard((current) => {
if (Object.keys(current.pending).length) {
channel.current?.send({ type: "kio.patch", fields: current.pending });
}
if (Object.keys(outbox.current).length) {
channel.current?.send({ type: "kio.patch", fields: outbox.current });
outbox.current = {};
}
return current;
}));
}
break; break;
case "error": case "error":
setError(event.message); setError(event.message);
@ -174,13 +186,15 @@ export function useCall(sessionId: string | null) {
}, [sessionId, append]); }, [sessionId, append]);
const answer = useCallback(async () => { const answer = useCallback(async () => {
if (pausedRef.current) return;
try { try {
setError(null); setError(null);
// Контекст создаётся по нажатию: браузер не даёт играть звук без действия человека. // Контекст создаётся по нажатию: браузер не даёт играть звук без действия человека.
const context = new AudioContext({ sampleRate: CALLER_RATE }); const context = new AudioContext({ sampleRate: CALLER_RATE });
audio.current = { context, playback: new Playback(context), ambience: new Ambience(context) }; audio.current = { context, playback: new Playback(context), ambience: new Ambience(context) };
capture.current = await startCapture((frame) => { const startedCapture = await startCapture((frame) => {
if (pausedRef.current) return;
const playback = audio.current?.playback; const playback = audio.current?.playback;
// Перебивание: фронт гасит звук мгновенно, сервер подтвердит `tts.cancel`. // Перебивание: фронт гасит звук мгновенно, сервер подтвердит `tts.cancel`.
if (gate.current.push(frame) && playback?.speaking) { if (gate.current.push(frame) && playback?.speaking) {
@ -189,6 +203,13 @@ export function useCall(sessionId: string | null) {
} }
channel.current?.sendBinary(frame); channel.current?.sendBinary(frame);
}); });
if (pausedRef.current) {
await startedCapture.stop();
await context.close();
audio.current = null;
return;
}
capture.current = startedCapture;
setMicOn(true); setMicOn(true);
channel.current?.send({ type: "call.answer" }); channel.current?.send({ type: "call.answer" });
} catch (err) { } catch (err) {
@ -197,36 +218,45 @@ export function useCall(sessionId: string | null) {
}, []); }, []);
const hangup = useCallback(() => { const hangup = useCallback(() => {
if (pausedRef.current) return;
channel.current?.send({ type: "call.hangup" }); channel.current?.send({ type: "call.hangup" });
void capture.current?.stop(); void capture.current?.stop();
capture.current = null; capture.current = null;
setMicOn(false); setMicOn(false);
}, []); }, []);
const hint = useCallback(() => channel.current?.send({ type: "hint.request" }), []); const hint = useCallback(() => {
if (!pausedRef.current) channel.current?.send({ type: "hint.request" });
}, []);
const patchKio = useCallback((path: string, value: unknown) => { const patchKio = useCallback((path: string, value: unknown) => {
if (pausedRef.current) return;
setCard((prev) => edit(prev, path, value)); setCard((prev) => edit(prev, path, value));
outbox.current[path] = value; outbox.current[path] = value;
if (patchTimer.current) clearTimeout(patchTimer.current); if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = setTimeout(() => { patchTimer.current = setTimeout(() => {
const fields = outbox.current; const fields = outbox.current;
outbox.current = {}; outbox.current = {};
if (Object.keys(fields).length) channel.current?.send({ type: "kio.patch", fields }); if (pausedRef.current) {
outbox.current = { ...fields, ...outbox.current };
} else if (Object.keys(fields).length) channel.current?.send({ type: "kio.patch", fields });
}, PATCH_DEBOUNCE_MS); }, PATCH_DEBOUNCE_MS);
}, []); }, []);
const dispatch = useCallback((service?: DDSCode) => { const dispatch = useCallback((service?: DDSCode) => {
if (pausedRef.current) return;
channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" }); channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" });
}, []); }, []);
const sendText = useCallback((text: string) => { const sendText = useCallback((text: string) => {
if (pausedRef.current) return false;
const clean = text.trim(); const clean = text.trim();
if (!clean || clean.length > 1000) return false; if (!clean || clean.length > 1000) return false;
return Boolean(channel.current?.send({ type: "text.turn", text: clean })); return Boolean(channel.current?.send({ type: "text.turn", text: clean }));
}, []); }, []);
const submitCard = useCallback(() => { const submitCard = useCallback(() => {
if (pausedRef.current) return;
if (patchTimer.current) clearTimeout(patchTimer.current); if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = null; patchTimer.current = null;
const fields = outbox.current; const fields = outbox.current;
@ -247,10 +277,12 @@ export function useCall(sessionId: string | null) {
* Отдельное действие, а не «положил трубку»: система должна отличить * Отдельное действие, а не «положил трубку»: система должна отличить
* осознанное решение от брошенного вызова (docs/spec/TICKETS.md). */ * осознанное решение от брошенного вызова (docs/spec/TICKETS.md). */
const resolve = useCallback((outcome: Outcome, comment: string) => { const resolve = useCallback((outcome: Outcome, comment: string) => {
if (pausedRef.current) return;
channel.current?.send({ type: "call.resolve", outcome, comment }); channel.current?.send({ type: "call.resolve", outcome, comment });
}, []); }, []);
const submitSelfAssessment = useCallback((missed: string[], comment: string) => { const submitSelfAssessment = useCallback((missed: string[], comment: string) => {
if (pausedRef.current) return;
channel.current?.send({ type: "self_assessment.submit", missed, comment }); channel.current?.send({ type: "self_assessment.submit", missed, comment });
setSelfAssessed(true); setSelfAssessed(true);
}, []); }, []);

View file

@ -20,6 +20,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
const improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3); const improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3);
const metricTitles = new Map(report.metrics.map((metric) => [metric.key, metric.title])); const metricTitles = new Map(report.metrics.map((metric) => [metric.key, metric.title]));
const coaching = report.ai_coaching?.status === "ready" ? report.ai_coaching.recommendations ?? [] : []; const coaching = report.ai_coaching?.status === "ready" ? report.ai_coaching.recommendations ?? [] : [];
const pausedSeconds = Math.round((report.total_paused_ms ?? 0) / 1000);
return ( return (
<section className={big ? "debrief debrief-big" : "debrief"}> <section className={big ? "debrief debrief-big" : "debrief"}>
@ -43,6 +44,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
<small>{report.attempt ? `Попытка ${report.attempt}` : "Завершённое упражнение"}</small> <small>{report.attempt ? `Попытка ${report.attempt}` : "Завершённое упражнение"}</small>
</article> </article>
</div> </div>
<p className="ref">Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.</p>
<details className="debrief-tech-details"> <details className="debrief-tech-details">
<summary>Служебные сведения</summary> <summary>Служебные сведения</summary>
<p>Идентификатор сценария: {report.scenario_id} · номер занятия: {report.session_id}</p> <p>Идентификатор сценария: {report.scenario_id} · номер занятия: {report.session_id}</p>

View file

@ -321,7 +321,7 @@ export function Dds() {
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
const send = (command: StationToServer): boolean => channel.current?.send(command) ?? false; const send = (command: StationToServer): boolean => !paused && (channel.current?.send(command) ?? false);
if (!sessionId) return <DdsHistoryOnly cards={ddsHistory.cards} error={ddsHistory.error} />; if (!sessionId) return <DdsHistoryOnly cards={ddsHistory.cards} error={ddsHistory.error} />;
const reports = station?.phone_reports ?? []; const reports = station?.phone_reports ?? [];
@ -331,6 +331,7 @@ export function Dds() {
const phoneCall = station?.phone_pending ?? null; const phoneCall = station?.phone_pending ?? null;
const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === selectedCrew); const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === selectedCrew);
const isConnected = status === "open"; const isConnected = status === "open";
const pausedSeconds = Math.round((report?.total_paused_ms ?? 0) / 1000);
const cardId = card ? String(readPath(card.server, "card_id") ?? "") : ""; const cardId = card ? String(readPath(card.server, "card_id") ?? "") : "";
const cardIndex = station?.card_index ?? 1; const cardIndex = station?.card_index ?? 1;
const cardTotal = station?.card_total ?? 1; const cardTotal = station?.card_total ?? 1;
@ -378,6 +379,7 @@ export function Dds() {
{report ? <div style={{ padding: 12 }}> {report ? <div style={{ padding: 12 }}>
<p><strong>Итоговая оценка: {report.score_final.toFixed(0)} из 100</strong> <p><strong>Итоговая оценка: {report.score_final.toFixed(0)} из 100</strong>
{report.score_final !== report.score_auto && ` · автооценка ${report.score_auto.toFixed(0)}`}</p> {report.score_final !== report.score_auto && ` · автооценка ${report.score_auto.toFixed(0)}`}</p>
<p className="ref">Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.</p>
<p className="ref">Скачать отчёт: <a href={`/api/sessions/${sessionId}/report.csv`}>CSV</a> <p className="ref">Скачать отчёт: <a href={`/api/sessions/${sessionId}/report.csv`}>CSV</a>
{" · "}<a href={`/api/sessions/${sessionId}/report.pdf`}>PDF</a></p> {" · "}<a href={`/api/sessions/${sessionId}/report.pdf`}>PDF</a></p>
{Boolean(report.card_results?.length) && <> {Boolean(report.card_results?.length) && <>
@ -413,6 +415,7 @@ export function Dds() {
</div>} </div>}
</section>} </section>}
<fieldset className="dds-pause-controls" disabled={paused && !ended}>
{view === "registry" && <> {view === "registry" && <>
<section className="dds-search"> <section className="dds-search">
<div className="dds-search-main"> <div className="dds-search-main">
@ -707,6 +710,7 @@ export function Dds() {
</div> </div>
</nav> </nav>
</div>} </div>}
</fieldset>
</main>; </main>;
} }

View file

@ -122,16 +122,16 @@ export function Call() {
event.preventDefault(); event.preventDefault();
if (call.sendText(textTurn)) setTextTurn(""); if (call.sendText(textTurn)) setTextTurn("");
}}> }}>
<input aria-label="Ваша реплика заявителю" value={textTurn} maxLength={1000} <input aria-label="Ваша реплика заявителю" value={textTurn} maxLength={1000} disabled={call.paused}
placeholder="Ваша реплика…" onChange={(event) => setTextTurn(event.target.value)} /> placeholder="Ваша реплика…" onChange={(event) => setTextTurn(event.target.value)} />
<button type="submit" disabled={call.status !== "open" || !textTurn.trim() || call.cardSubmitted}>Отправить</button> <button type="submit" disabled={call.paused || call.status !== "open" || !textTurn.trim() || call.cardSubmitted}>Отправить</button>
</form> </form>
{call.paused && <p role="status" className="state-warn">Пауза, ждите преподавателя</p>} {call.paused && <p role="status" className="state-warn">Пауза, ждите преподавателя</p>}
{call.error && <p role="alert" className="state-violated">{call.error}</p>} {call.error && <p role="alert" className="state-violated">{call.error}</p>}
</section> </section>
<div className="arm-card-wrap"> <div className="arm-card-wrap">
<KioCard state={call.card} required={call.briefing.required_fields} <KioCard state={call.card} required={call.briefing.required_fields}
readOnly={call.cardSubmitted} referenceArm onChange={call.patchKio} /> readOnly={call.paused || call.cardSubmitted} referenceArm onChange={call.patchKio} />
</div> </div>
</div> </div>
<section className="arm-support" aria-label="Норматив заполнения карточки"> <section className="arm-support" aria-label="Норматив заполнения карточки">
@ -143,7 +143,7 @@ export function Call() {
? notifiedServices.map((service) => <b key={service}>{service}</b>) ? notifiedServices.map((service) => <b key={service}>{service}</b>)
: <em>появятся после выбора признаков</em>} : <em>появятся после выбора признаков</em>}
</span> </span>
{!call.cardSubmitted && <button type="button" className="arm-primary" disabled={call.status !== "open"} {!call.cardSubmitted && <button type="button" className="arm-primary" disabled={call.paused || call.status !== "open"}
onClick={call.submitCard}>Сохранить и проверить</button>} onClick={call.submitCard}>Сохранить и проверить</button>}
{call.cardSubmitted && <strong>{call.briefing.handoff_to_dds ? "Карточка передана ДДС" : "Карточка отправлена"}</strong>} {call.cardSubmitted && <strong>{call.briefing.handoff_to_dds ? "Карточка передана ДДС" : "Карточка отправлена"}</strong>}
</footer> </footer>
@ -175,11 +175,11 @@ export function Call() {
</header> </header>
<div className="arm-call-actions"> <div className="arm-call-actions">
{(call.phase === "incoming" || call.phase === "talking") && !call.micOn && <button type="button" className="arm-answer" onClick={() => void call.answer()}> {(call.phase === "incoming" || call.phase === "talking") && !call.micOn && <button type="button" className="arm-answer" disabled={call.paused} onClick={() => void call.answer()}>
{call.phase === "talking" ? "☎ Продолжить разговор" : "☎ Ответить на вызов"} {call.phase === "talking" ? "☎ Продолжить разговор" : "☎ Ответить на вызов"}
</button>} </button>}
{call.micOn && <span className="arm-live">● Разговор идёт</span>} {call.micOn && <span className="arm-live">● Разговор идёт</span>}
{call.micOn && hintsAllowed(call.incoming?.mode) && <button type="button" onClick={call.hint}>Подсказка</button>} {call.micOn && hintsAllowed(call.incoming?.mode) && <button type="button" disabled={call.paused} onClick={call.hint}>Подсказка</button>}
<span className="arm-call-actions-note">{call.phase === "ended" <span className="arm-call-actions-note">{call.phase === "ended"
? "Вызов завершён" ? "Вызов завершён"
: !call.micOn && call.phase === "incoming" : !call.micOn && call.phase === "incoming"
@ -198,7 +198,7 @@ export function Call() {
<KioCard <KioCard
state={call.card} state={call.card}
required={call.incoming?.required_fields ?? []} required={call.incoming?.required_fields ?? []}
readOnly={!call.micOn} readOnly={call.paused || !call.micOn}
referenceArm referenceArm
onChange={call.patchKio} onChange={call.patchKio}
/> />
@ -209,12 +209,12 @@ export function Call() {
<button <button
type="button" type="button"
className="arm-primary" className="arm-primary"
disabled={!call.micOn || (!service && !autoRoute)} disabled={call.paused || !call.micOn || (!service && !autoRoute)}
onClick={() => call.dispatch(service)} onClick={() => call.dispatch(service)}
> >
Передать в ДДС Передать в ДДС
</button> </button>
{call.micOn && <button type="button" onClick={call.hangup}>Завершить вызов</button>} {call.micOn && <button type="button" disabled={call.paused} onClick={call.hangup}>Завершить вызов</button>}
</div> </div>
</footer> </footer>
<div className="arm-secondary"> <div className="arm-secondary">
@ -227,6 +227,7 @@ export function Call() {
type="text" type="text"
aria-label="Комментарий при закрытии вызова без карточки" aria-label="Комментарий при закрытии вызова без карточки"
value={resolveComment} value={resolveComment}
disabled={call.paused}
placeholder="что сказали заявителю, куда передали" placeholder="что сказали заявителю, куда передали"
onChange={(event) => setResolveComment(event.target.value)} onChange={(event) => setResolveComment(event.target.value)}
/> />
@ -234,7 +235,7 @@ export function Call() {
<p> <p>
<button <button
type="button" type="button"
disabled={!call.micOn || resolved !== null} disabled={call.paused || !call.micOn || resolved !== null}
onClick={() => { onClick={() => {
call.resolve("consultation", resolveComment); call.resolve("consultation", resolveComment);
setResolved("consultation"); setResolved("consultation");
@ -244,7 +245,7 @@ export function Call() {
</button>{" "} </button>{" "}
<button <button
type="button" type="button"
disabled={!call.micOn || resolved !== null} disabled={call.paused || !call.micOn || resolved !== null}
onClick={() => { onClick={() => {
call.resolve("transfer_region", resolveComment); call.resolve("transfer_region", resolveComment);
setResolved("transfer_region"); setResolved("transfer_region");

View file

@ -316,6 +316,7 @@ button:disabled { opacity: 0.5; cursor: default; }
/* АРМ ДДС: реестр остаётся плотной таблицей, карточка и связь — рядом. */ /* АРМ ДДС: реестр остаётся плотной таблицей, карточка и связь — рядом. */
.dds-workspace { max-width: 1800px; margin: 0 auto; } .dds-workspace { max-width: 1800px; margin: 0 auto; }
.dds-pause-controls { display: contents; border: 0; margin: 0; padding: 0; min-width: 0; }
.dds-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 8px 0 16px; } .dds-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 8px 0 16px; }
.dds-header h1 { margin: 2px 0 0; } .dds-header h1 { margin: 2px 0 0; }
.dds-header h1 span { color: #576273; font-size: 15px; font-weight: 400; } .dds-header h1 span { color: #576273; font-size: 15px; font-weight: 400; }