lct-20: АРМ диспетчера ДДС и цепочка 112 → ДДС

Возврат карточки — самая ценная механика цепочки: неполнота КИО
перестаёт быть процентом в отчёте и становится сорванным выездом
с конкретной причиной. В разборе это отметка E6: «не заполнено floor,
victims_count — выезд сорван».

Снимок карточки не меняется после передачи: оператор не дописывает
задним числом поле, которое забыл. Тест правит карточку после передачи
и проверяет, что в снимке этого нет.

Диспетчер садится за АРМ, когда вызов уже идёт, — станция, подключённая
после передачи, всё равно получает карточку.

У станции нет аудио вообще: ни VAD, ни распознавания, ни синтеза.
This commit is contained in:
Ivan Gerasimov 2026-09-17 21:45:47 +03:00
commit ee2a526831
15 changed files with 439 additions and 13 deletions

View file

@ -120,6 +120,9 @@ async def _handle(session_id: UUID, state, event) -> None:
case "dds.dispatch": case "dds.dispatch":
state.on_event("dds.dispatch") state.on_event("dds.dispatch")
state.dispatch(event.service.value) state.dispatch(event.service.value)
# Карточка замораживается снимком и уходит диспетчеру: оператор
# не должен иметь возможности дописать поле задним числом.
hub.to_station(session_id, state.card_received_event())
hub.to_observers(session_id, KioState(kio=state.kio)) hub.to_observers(session_id, KioState(kio=state.kio))
hub.to_observers(session_id, TimerTick(timers=state.timers.snapshot())) hub.to_observers(session_id, TimerTick(timers=state.timers.snapshot()))

View file

@ -0,0 +1,89 @@
"""Сокет диспетчера ДДС.
**Только JSON: аудио здесь нет вообще** — значит, нет ни VAD, ни распознавания,
ни синтеза. Голосовой контур станция не трогает (docs/arch/CONTRACT.md).
Самая ценная механика цепочки — `card.bounce`: диспетчер видит, что не указан
этаж, и отбивает карточку обратно. Неполнота КИО перестаёт быть процентом
в отчёте и становится сорванным выездом с конкретной причиной.
"""
import asyncio
import logging
from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.domain.events import ErrorEvent, ErrorKind, StationToServer
from app.session.hub import hub
from app.session.state import now_utc
log = logging.getLogger(__name__)
router = APIRouter()
_adapter = TypeAdapter(StationToServer)
async def _handle(session_id: UUID, state, event) -> None:
match event.type:
case "card.ack":
state.on_event("card.ack")
state.dds_log.append(("card.ack", now_utc(), None))
case "card.bounce":
# Карточка вернулась: в разборе это E6 с конкретной причиной.
state.bounced_fields = list(event.missing_fields)
state.dds_log.append(("card.bounce", now_utc(), event.comment))
case "zone.decision":
state.on_event("zone.decision")
state.dds_log.append(("zone.decision", now_utc(), "в зоне" if event.in_zone else "не в зоне"))
case "crew.dispatched":
state.kio = state.kio.model_copy(update={"dispatch_order_at": event.at})
state.dds_log.append(("crew.dispatched", now_utc(), None))
case "crew.arrived":
state.on_event("crew.arrived")
state.kio = state.kio.model_copy(update={"arrival_at": event.at})
state.dds_log.append(("crew.arrived", now_utc(), None))
hub.to_observers(session_id, state.snapshot())
async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
while True:
event = await queue.get()
await ws.send_text(event.model_dump_json())
@router.websocket("/ws/station/{session_id}")
async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
await ws.accept()
state = hub.get(session_id)
if state is None:
await ws.send_text(
ErrorEvent(code=ErrorKind.SESSION_NOT_FOUND, message="Занятие не запущено").model_dump_json()
)
await ws.close()
return
with hub.station(session_id) as queue:
sender = asyncio.create_task(_pump(ws, queue))
try:
# Карточка, переданная до подключения станции, не теряется:
# диспетчер садится за АРМ, когда вызов уже идёт.
if state.dispatched_card is not None:
hub.to_station(session_id, state.card_received_event())
while True:
payload = await ws.receive_json()
try:
event = _adapter.validate_python(payload)
except ValidationError:
hub.to_station(
session_id,
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=str(payload)[:200]),
)
continue
await _handle(session_id, state, event)
except WebSocketDisconnect:
return
finally:
sender.cancel()

View file

@ -13,6 +13,7 @@ from app.api.http import trainees
from app.api.ws import call as call_ws from app.api.ws import call as call_ws
from app.api.ws import control as control_ws from app.api.ws import control as control_ws
from app.api.ws import observe as observe_ws from app.api.ws import observe as observe_ws
from app.api.ws import station as station_ws
from app.config import get_settings from app.config import get_settings
from app.db.base import get_sessionmaker from app.db.base import get_sessionmaker
from app.dialog.runtime import get_embedder from app.dialog.runtime import get_embedder
@ -65,6 +66,7 @@ app.include_router(trainees.router)
app.include_router(call_ws.router) app.include_router(call_ws.router)
app.include_router(observe_ws.router) app.include_router(observe_ws.router)
app.include_router(control_ws.router) app.include_router(control_ws.router)
app.include_router(station_ws.router)
@app.get("/api/health") @app.get("/api/health")

View file

@ -18,6 +18,7 @@ from app.scoring.taxonomy import METRIC_MAP, METRIC_WEIGHTS
from app.session.timers import SessionTimers from app.session.timers import SessionTimers
SOURCE_BY_CODE = { SOURCE_BY_CODE = {
"E6": FindingSource.CHAIN,
"E1": FindingSource.SLOTS, "E1": FindingSource.SLOTS,
"E2": FindingSource.GROUND_TRUTH, "E2": FindingSource.GROUND_TRUTH,
"E3": FindingSource.TIMERS, "E3": FindingSource.TIMERS,
@ -110,6 +111,7 @@ def evaluate(
timers: SessionTimers, timers: SessionTimers,
revealed_facts: list[str] | None, revealed_facts: list[str] | None,
end_reason: CallEndReason | None = None, end_reason: CallEndReason | None = None,
bounced_fields: list[str] | None = None,
) -> GostResult: ) -> GostResult:
"""Посчитать детерминированный слой по завершённому занятию. """Посчитать детерминированный слой по завершённому занятию.
@ -230,4 +232,15 @@ def evaluate(
finding=f"Не заполнены обязательные поля: {', '.join(empty)}" if empty else None, finding=f"Не заполнены обязательные поля: {', '.join(empty)}" if empty else None,
) )
# ── цепочка 112 → ДДС, E6 ──
if bounced_fields:
build.add(
"dds_chain", "Карточка принята ДДС",
f"возвращена на уточнение: {', '.join(bounced_fields)}",
"карточка пригодна для выезда",
passed=False,
ref="цепочка 112 → ДДС",
finding=f"Диспетчер вернул карточку: не заполнено {', '.join(bounced_fields)} — выезд сорван",
)
return build.result return build.result

View file

@ -9,6 +9,7 @@ from app.domain.taxonomy import Competency, ErrorCode
#: Метрика → (код ошибки при провале, компетенция радара). #: Метрика → (код ошибки при провале, компетенция радара).
METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = { METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
"answer_time": (ErrorCode.E3, Competency.INTAKE), "answer_time": (ErrorCode.E3, Competency.INTAKE),
"dds_chain": (ErrorCode.E6, Competency.CARD),
"callback": (ErrorCode.E3, Competency.INTAKE), "callback": (ErrorCode.E3, Competency.INTAKE),
"checklist_completeness": (ErrorCode.E1, Competency.INTERVIEW), "checklist_completeness": (ErrorCode.E1, Competency.INTERVIEW),
"interview_time": (ErrorCode.E3, Competency.NORMS), "interview_time": (ErrorCode.E3, Competency.NORMS),
@ -36,6 +37,7 @@ METRIC_WEIGHTS: dict[str, float] = {
"victims_count": 1.0, "victims_count": 1.0,
"answer_time": 1.0, "answer_time": 1.0,
"callback": 1.0, "callback": 1.0,
"dds_chain": 2.0,
} }
#: Вес детерминированного слоя в итоговой оценке. Остальное — LLM-судья #: Вес детерминированного слоя в итоговой оценке. Остальное — LLM-судья

View file

@ -32,6 +32,7 @@ async def finish(session_id: UUID, state) -> None:
timers=state.timers, timers=state.timers,
revealed_facts=[fact.id for fact in state.slots.revealed_facts()] if state.slots else None, revealed_facts=[fact.id for fact in state.slots.revealed_facts()] if state.slots else None,
end_reason=state.end_reason, end_reason=state.end_reason,
bounced_fields=state.bounced_fields,
) )
# Сводка числами: по ней считается дельта между попытками в профиле. # Сводка числами: по ней считается дельта между попытками в профиле.
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам. # Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.

View file

@ -45,6 +45,7 @@ class SessionHub:
self._sessions: dict[UUID, SessionState] = {} self._sessions: dict[UUID, SessionState] = {}
self._observers: dict[UUID, set[asyncio.Queue]] = {} self._observers: dict[UUID, set[asyncio.Queue]] = {}
self._trainees: dict[UUID, set[asyncio.Queue]] = {} self._trainees: dict[UUID, set[asyncio.Queue]] = {}
self._stations: dict[UUID, set[asyncio.Queue]] = {}
self._tickers: dict[UUID, asyncio.Task] = {} self._tickers: dict[UUID, asyncio.Task] = {}
# ── реестр ── # ── реестр ──
@ -77,6 +78,9 @@ class SessionHub:
def trainee(self, session_id: UUID): def trainee(self, session_id: UUID):
return self._subscribe(self._trainees, session_id) return self._subscribe(self._trainees, session_id)
def station(self, session_id: UUID):
return self._subscribe(self._stations, session_id)
# ── вещание ── # ── вещание ──
@staticmethod @staticmethod
@ -93,6 +97,9 @@ class SessionHub:
def to_trainee(self, session_id: UUID, event: BaseModel | bytes) -> None: def to_trainee(self, session_id: UUID, event: BaseModel | bytes) -> None:
self._put(self._trainees.get(session_id, set()), event) self._put(self._trainees.get(session_id, set()), event)
def to_station(self, session_id: UUID, event: BaseModel) -> None:
self._put(self._stations.get(session_id, set()), event)
def broadcast(self, session_id: UUID, event: BaseModel) -> None: def broadcast(self, session_id: UUID, event: BaseModel) -> None:
self.to_trainee(session_id, event) self.to_trainee(session_id, event)
self.to_observers(session_id, event) self.to_observers(session_id, event)

View file

@ -77,6 +77,10 @@ class SessionState:
ended_at: datetime | None = None ended_at: datetime | None = None
end_reason: CallEndReason | None = None end_reason: CallEndReason | None = None
dispatched_card: KIO | None = None dispatched_card: KIO | None = None
dispatched_at: datetime | None = None
#: Поля, из-за которых диспетчер вернул карточку, — основание E6.
bounced_fields: list[str] = field(default_factory=list)
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
def on_event(self, event_type: str) -> None: def on_event(self, event_type: str) -> None:
"""Единственная точка, где событие двигает таймеры.""" """Единственная точка, где событие двигает таймеры."""
@ -102,8 +106,19 @@ class SessionState:
возможности дописать задним числом поле, которое забыл.""" возможности дописать задним числом поле, которое забыл."""
self.kio = apply_patch(self.kio, {"dds": service, "response_status": "transferred"}) self.kio = apply_patch(self.kio, {"dds": service, "response_status": "transferred"})
self.dispatched_card = self.kio.model_copy(deep=True) self.dispatched_card = self.kio.model_copy(deep=True)
self.dispatched_at = now_utc()
return self.dispatched_card return self.dispatched_card
def card_received_event(self):
"""Снимок карточки для станции ДДС."""
from app.domain.events import CardReceived
return CardReceived(
card=self.dispatched_card,
from_operator=self.trainee_name or "оператор 112",
at=self.dispatched_at or now_utc(),
)
@property @property
def ended(self) -> bool: def ended(self) -> bool:
return self.ended_at is not None return self.ended_at is not None

View file

@ -0,0 +1,126 @@
"""Цепочка 112 → ДДС: карточка уходит снимком, диспетчер подтверждает или отбивает."""
import time
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.session.hub import hub
@pytest.fixture
def client():
with TestClient(app) as test_client:
hub.journal = None
yield test_client
def wait_for(predicate, timeout: float = 3.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
value = predicate()
if value:
return value
time.sleep(0.02)
raise AssertionError("не дождались")
def read_until(ws, event_type: str, limit: int = 20) -> dict:
for _ in range(limit):
message = ws.receive_json()
if message["type"] == event_type:
return message
raise AssertionError(f"событие {event_type} не пришло")
def lesson(client):
session_id = uuid4()
control = client.websocket_connect(f"/ws/control/{session_id}")
socket = control.__enter__()
socket.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Иванов", "mode": "training"})
wait_for(lambda: hub.get(session_id))
return session_id, control, socket
def test_card_goes_to_the_dispatcher_frozen(client):
"""Снимок не меняется после передачи: оператор не дописывает задним числом."""
session_id, control, _ = lesson(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}?role=dds_01") as station:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "kio.patch", "fields": {"address": "улица Ленина, 14", "dds": "01"}})
wait_for(lambda: hub.get(session_id).kio.address)
trainee.send_json({"type": "dds.dispatch", "service": "01"})
received = read_until(station, "card.received")
# Правка после передачи в снимок не попадает.
trainee.send_json({"type": "kio.patch", "fields": {"floor": "5"}})
wait_for(lambda: hub.get(session_id).kio.floor == "5")
assert received["card"]["address"] == "улица Ленина, 14"
assert received["card"]["floor"] is None, "снимок изменился после передачи"
assert received["from_operator"] == "Иванов"
finally:
control.__exit__(None, None, None)
def test_station_joining_late_still_gets_the_card(client):
"""Диспетчер садится за АРМ, когда вызов уже идёт."""
session_id, control, _ = lesson(client)
try:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "dds.dispatch", "service": "01"})
wait_for(lambda: hub.get(session_id).dispatched_card)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
assert read_until(station, "card.received")["card"] is not None
finally:
control.__exit__(None, None, None)
def test_acknowledgement_stops_the_four_second_norm(client):
from app.domain.timers import TimerCode
session_id, control, _ = lesson(client)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "dds.dispatch", "service": "01"})
read_until(station, "card.received")
station.send_json({"type": "card.ack"})
measured = wait_for(lambda: state.timers.measured_ms(TimerCode.DDS_ACK) is not None)
assert measured
finally:
control.__exit__(None, None, None)
def test_bounced_card_becomes_e6_with_the_reason(client):
"""Неполнота КИО перестаёт быть процентом в отчёте и становится
сорванным выездом с конкретной причиной."""
session_id, control, _ = lesson(client)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "dds.dispatch", "service": "01"})
read_until(station, "card.received")
station.send_json({"type": "card.bounce", "missing_fields": ["floor", "victims_count"],
"comment": "куда ехать без этажа"})
wait_for(lambda: state.bounced_fields)
trainee.send_json({"type": "call.hangup"})
wait_for(lambda: state.score is not None)
response = client.get(f"/api/sessions/{session_id}/report").json()
chain = [finding for finding in response["findings"] if finding["code"] == "E6"]
assert chain, "возврат карточки должен попасть в разбор"
assert "floor" in chain[0]["summary"] and "выезд сорван" in chain[0]["summary"]
finally:
control.__exit__(None, None, None)

View file

@ -2,9 +2,9 @@ import { createBrowserRouter, Navigate } from "react-router-dom";
import { Call } from "@/pages/trainee/Call"; import { Call } from "@/pages/trainee/Call";
import { Instructor } from "@/pages/instructor/Instructor"; import { Instructor } from "@/pages/instructor/Instructor";
import { Dds } from "@/pages/dds/Dds";
import { Profile } from "@/pages/profile/Profile"; import { Profile } from "@/pages/profile/Profile";
import { Wall } from "@/pages/wall/Wall"; import { Wall } from "@/pages/wall/Wall";
import { Stub } from "@/shared/ui/Stub";
// Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md). // Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md).
// session_id живёт в URL: /instructor?session=..., монитор открывают ссылкой. // session_id живёт в URL: /instructor?session=..., монитор открывают ссылкой.
@ -14,5 +14,5 @@ export const router = createBrowserRouter([
{ path: "/instructor", element: <Instructor /> }, { path: "/instructor", element: <Instructor /> },
{ path: "/wall", element: <Wall /> }, { path: "/wall", element: <Wall /> },
{ path: "/profile", element: <Profile /> }, { path: "/profile", element: <Profile /> },
{ path: "/dds", element: <Stub title="АРМ диспетчера ДДС" card="lct-20" /> }, { path: "/dds", element: <Dds /> },
]); ]);

View file

@ -0,0 +1,165 @@
// АРМ диспетчера ДДС.
//
// Аудио здесь нет вообще: приходит снимок карточки от оператора 112,
// дальше — подтверждение по нормативу, возврат на уточнение и отметки времени.
// Возврат карточки — самая ценная механика цепочки: неполнота КИО становится
// сорванным выездом с конкретной причиной (docs/arch/CONTRACT.md).
import { useEffect, useRef, useState } from "react";
import { GROUPS } from "@/features/kio-card/fields";
import { KioCard } from "@/features/kio-card/KioCard";
import { applyState, empty as emptyCard, type CardState } from "@/features/kio-card/merge";
import { stationChannel, type ChannelStatus } from "@/shared/api/ws";
import { sessionIdFromUrl } from "@/shared/api/session";
const ACK_LIMIT_S = 4; // норматив подтверждения получения карточки
const FIELDS = GROUPS.flatMap((group) =>
group.fields.filter((field) => !field.readOnly).map((field) => ({ path: field.path, label: field.label })),
);
export function Dds() {
const sessionId = sessionIdFromUrl();
const role = new URLSearchParams(location.search).get("role") ?? "dds_01";
const [status, setStatus] = useState<ChannelStatus>("connecting");
const [card, setCard] = useState<CardState | null>(null);
const [from, setFrom] = useState<string>("");
const [receivedAt, setReceivedAt] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
const [acked, setAcked] = useState(false);
const [bounced, setBounced] = useState<string[] | null>(null);
const [missing, setMissing] = useState<string[]>([]);
const [comment, setComment] = useState("");
const [zone, setZone] = useState<boolean | null>(null);
const [marks, setMarks] = useState<string[]>([]);
const channel = useRef<ReturnType<typeof stationChannel> | null>(null);
useEffect(() => {
if (!sessionId) return;
const ch = stationChannel(sessionId, role, {
onStatus: setStatus,
onEvent: (event) => {
if (event.type === "card.received") {
setCard(applyState(emptyCard, event.card as unknown as Record<string, unknown>));
setFrom(event.from_operator);
setReceivedAt(Date.now());
}
},
}).connect();
channel.current = ch;
return () => ch.close();
}, [sessionId, role]);
// Обратный отсчёт норматива: пока карточка не подтверждена, счёт идёт.
useEffect(() => {
if (receivedAt === null || acked) return;
const timer = setInterval(() => setElapsed((Date.now() - receivedAt) / 1000), 200);
return () => clearInterval(timer);
}, [receivedAt, acked]);
if (!sessionId) {
return (
<main className="page">
<h1>АРМ диспетчера ДДС</h1>
<p className="warn">Откройте ссылку вида /dds?session=…&role=dds_01</p>
</main>
);
}
const mark = (label: string) => setMarks((prev) => [`${new Date().toLocaleTimeString("ru-RU")} — ${label}`, ...prev]);
return (
<main className="page">
<h1>АРМ диспетчера ДДС · {role}</h1>
<table className="grid">
<tbody>
<tr><th>Канал</th><td className={status === "open" ? "" : "warn"}>{status}</td></tr>
<tr><th>Карточка</th><td>{card ? `от оператора ${from}` : "ожидание карточки от 112"}</td></tr>
{card && !acked && (
<tr>
<th>Подтверждение</th>
<td className={elapsed > ACK_LIMIT_S ? "state-violated" : "state-ok"}>
{elapsed.toFixed(1)} с из {ACK_LIMIT_S} с
</td>
</tr>
)}
</tbody>
</table>
{card && (
<>
<p>
<button type="button" disabled={acked} onClick={() => {
channel.current?.send({ type: "card.ack" });
setAcked(true);
mark("карточка принята");
}}>Принять карточку</button>{" "}
<button type="button" disabled={Boolean(bounced)} onClick={() => {
if (!missing.length) return;
channel.current?.send({ type: "card.bounce", missing_fields: missing, comment });
setBounced(missing);
mark(`возврат на уточнение: ${missing.join(", ")}`);
}}>Вернуть на уточнение</button>
</p>
{!bounced && (
<details>
<summary>Чего не хватает для выезда</summary>
<div className="bounce-fields">
{FIELDS.map((field) => (
<label key={field.path}>
<input type="checkbox" checked={missing.includes(field.path)}
onChange={() => setMissing((prev) =>
prev.includes(field.path) ? prev.filter((item) => item !== field.path) : [...prev, field.path])} />{" "}
{field.label}
</label>
))}
</div>
<p>
<input type="text" value={comment} placeholder="куда ехать без этажа"
onChange={(event) => setComment(event.target.value)} />
</p>
</details>
)}
<p>
Зона ответственности:{" "}
<button type="button" disabled={zone !== null} onClick={() => {
channel.current?.send({ type: "zone.decision", in_zone: true });
setZone(true); mark("наша зона");
}}>наша</button>{" "}
<button type="button" disabled={zone !== null} onClick={() => {
channel.current?.send({ type: "zone.decision", in_zone: false });
setZone(false); mark("не наша зона");
}}>не наша</button>
</p>
<p>
<button type="button" onClick={() => {
channel.current?.send({ type: "crew.dispatched", at: new Date().toISOString() });
mark("приказ на выезд");
}}>Приказ на выезд</button>{" "}
<button type="button" onClick={() => {
channel.current?.send({ type: "crew.arrived", at: new Date().toISOString() });
mark("прибытие на место");
}}>Прибытие</button>
</p>
<h2>Карточка от оператора 112</h2>
{/* Снимок: после передачи он не меняется. */}
<KioCard state={card} readOnly />
</>
)}
{marks.length > 0 && (
<>
<h2>Отметки времени</h2>
<table className="grid">
<tbody>{marks.map((line, index) => <tr key={index}><td>{line}</td></tr>)}</tbody>
</table>
</>
)}
</main>
);
}

View file

@ -62,6 +62,7 @@ export function Instructor() {
? { ? {
trainee: `${location.origin}/trainee?session=${sessionId}`, trainee: `${location.origin}/trainee?session=${sessionId}`,
wall: `${location.origin}/wall?session=${sessionId}`, wall: `${location.origin}/wall?session=${sessionId}`,
dds: `${location.origin}/dds?session=${sessionId}&role=dds_01`,
} }
: null; : null;
@ -131,6 +132,7 @@ export function Instructor() {
<tbody> <tbody>
<tr><th>АРМ курсанта</th><td><a href={links.trainee}>открыть</a></td></tr> <tr><th>АРМ курсанта</th><td><a href={links.trainee}>открыть</a></td></tr>
<tr><th>Внешний монитор</th><td><a href={links.wall}>открыть</a></td></tr> <tr><th>Внешний монитор</th><td><a href={links.wall}>открыть</a></td></tr>
<tr><th>АРМ ДДС</th><td><a href={links.dds}>открыть</a></td></tr>
</tbody> </tbody>
</table> </table>
)} )}

View file

@ -7,7 +7,9 @@
import type { import type {
InstructorToServer, InstructorToServer,
ServerToObserver, ServerToObserver,
ServerToStation,
ServerToTrainee, ServerToTrainee,
StationToServer,
TraineeToServer, TraineeToServer,
} from "@/shared/types/generated"; } from "@/shared/types/generated";
@ -104,6 +106,13 @@ export const callChannel = (sessionId: string, options?: ChannelOptions<ServerTo
export const observeChannel = (sessionId: string, options?: ChannelOptions<ServerToObserver>) => export const observeChannel = (sessionId: string, options?: ChannelOptions<ServerToObserver>) =>
new Channel<ServerToObserver, never>(`/ws/observe/${sessionId}`, options); new Channel<ServerToObserver, never>(`/ws/observe/${sessionId}`, options);
/** Станция ДДС: только JSON, аудио здесь нет вообще. */
export const stationChannel = (
sessionId: string,
role: string,
options?: ChannelOptions<ServerToStation>,
) => new Channel<ServerToStation, StationToServer>(`/ws/station/${sessionId}?role=${role}`, options);
/** Преподаватель: только передача, входящих в этом канале нет. */ /** Преподаватель: только передача, входящих в этом канале нет. */
export const controlChannel = (sessionId: string, options?: { onStatus?: (status: ChannelStatus) => void }) => export const controlChannel = (sessionId: string, options?: { onStatus?: (status: ChannelStatus) => void }) =>
new Channel<never, InstructorToServer>(`/ws/control/${sessionId}`, options); new Channel<never, InstructorToServer>(`/ws/control/${sessionId}`, options);

View file

@ -1,11 +0,0 @@
// Заглушка экрана. Каждая ссылается на карточку, которая её закрывает.
export function Stub({ title, card }: { title: string; card: string }) {
return (
<main className="stub">
<h1>{title}</h1>
<p>
Экран не реализован. Карточка: <code>tasks/{card}.md</code>
</p>
</main>
);
}

View file

@ -94,3 +94,6 @@ button:disabled { opacity: 0.5; cursor: default; }
.director-buttons button { padding: 10px 12px; text-align: left; font-size: 15px; } .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-buttons .danger { border-color: var(--violated); color: var(--violated); font-weight: 600; }
.director input[type="text"] { width: 60%; } .director input[type="text"] { width: 60%; }
/* Возврат карточки: список полей в две колонки, чтобы диспетчер не скроллил. */
.bounce-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; margin: 8px 0; }