diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py index ae56910..46cade5 100644 --- a/backend/app/api/ws/call.py +++ b/backend/app/api/ws/call.py @@ -120,6 +120,9 @@ async def _handle(session_id: UUID, state, event) -> None: case "dds.dispatch": state.on_event("dds.dispatch") 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, TimerTick(timers=state.timers.snapshot())) diff --git a/backend/app/api/ws/station.py b/backend/app/api/ws/station.py new file mode 100644 index 0000000..de556d0 --- /dev/null +++ b/backend/app/api/ws/station.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index 5857723..a452f4d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,6 +13,7 @@ from app.api.http import trainees from app.api.ws import call as call_ws from app.api.ws import control as control_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.db.base import get_sessionmaker 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(observe_ws.router) app.include_router(control_ws.router) +app.include_router(station_ws.router) @app.get("/api/health") diff --git a/backend/app/scoring/gost.py b/backend/app/scoring/gost.py index 669811d..655d6e5 100644 --- a/backend/app/scoring/gost.py +++ b/backend/app/scoring/gost.py @@ -18,6 +18,7 @@ from app.scoring.taxonomy import METRIC_MAP, METRIC_WEIGHTS from app.session.timers import SessionTimers SOURCE_BY_CODE = { + "E6": FindingSource.CHAIN, "E1": FindingSource.SLOTS, "E2": FindingSource.GROUND_TRUTH, "E3": FindingSource.TIMERS, @@ -110,6 +111,7 @@ def evaluate( timers: SessionTimers, revealed_facts: list[str] | None, end_reason: CallEndReason | None = None, + bounced_fields: list[str] | None = None, ) -> GostResult: """Посчитать детерминированный слой по завершённому занятию. @@ -230,4 +232,15 @@ def evaluate( 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 diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py index 2eb8e47..f91efe5 100644 --- a/backend/app/scoring/taxonomy.py +++ b/backend/app/scoring/taxonomy.py @@ -9,6 +9,7 @@ from app.domain.taxonomy import Competency, ErrorCode #: Метрика → (код ошибки при провале, компетенция радара). METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = { "answer_time": (ErrorCode.E3, Competency.INTAKE), + "dds_chain": (ErrorCode.E6, Competency.CARD), "callback": (ErrorCode.E3, Competency.INTAKE), "checklist_completeness": (ErrorCode.E1, Competency.INTERVIEW), "interview_time": (ErrorCode.E3, Competency.NORMS), @@ -36,6 +37,7 @@ METRIC_WEIGHTS: dict[str, float] = { "victims_count": 1.0, "answer_time": 1.0, "callback": 1.0, + "dds_chain": 2.0, } #: Вес детерминированного слоя в итоговой оценке. Остальное — LLM-судья diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py index 4b1c40d..5853cd5 100644 --- a/backend/app/session/finish.py +++ b/backend/app/session/finish.py @@ -32,6 +32,7 @@ async def finish(session_id: UUID, state) -> None: timers=state.timers, revealed_facts=[fact.id for fact in state.slots.revealed_facts()] if state.slots else None, end_reason=state.end_reason, + bounced_fields=state.bounced_fields, ) # Сводка числами: по ней считается дельта между попытками в профиле. # Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам. diff --git a/backend/app/session/hub.py b/backend/app/session/hub.py index 7fa83c6..c179cfa 100644 --- a/backend/app/session/hub.py +++ b/backend/app/session/hub.py @@ -45,6 +45,7 @@ class SessionHub: self._sessions: dict[UUID, SessionState] = {} self._observers: 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] = {} # ── реестр ── @@ -77,6 +78,9 @@ class SessionHub: def trainee(self, session_id: UUID): return self._subscribe(self._trainees, session_id) + def station(self, session_id: UUID): + return self._subscribe(self._stations, session_id) + # ── вещание ── @staticmethod @@ -93,6 +97,9 @@ class SessionHub: def to_trainee(self, session_id: UUID, event: BaseModel | bytes) -> None: 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: self.to_trainee(session_id, event) self.to_observers(session_id, event) diff --git a/backend/app/session/state.py b/backend/app/session/state.py index 7f57266..6b2bde3 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -77,6 +77,10 @@ class SessionState: ended_at: datetime | None = None end_reason: CallEndReason | 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: """Единственная точка, где событие двигает таймеры.""" @@ -102,8 +106,19 @@ class SessionState: возможности дописать задним числом поле, которое забыл.""" self.kio = apply_patch(self.kio, {"dds": service, "response_status": "transferred"}) self.dispatched_card = self.kio.model_copy(deep=True) + self.dispatched_at = now_utc() 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 def ended(self) -> bool: return self.ended_at is not None diff --git a/backend/tests/test_station.py b/backend/tests/test_station.py new file mode 100644 index 0000000..8d8c12d --- /dev/null +++ b/backend/tests/test_station.py @@ -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) diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index bcf6cf5..aa02f3b 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -2,9 +2,9 @@ import { createBrowserRouter, Navigate } from "react-router-dom"; import { Call } from "@/pages/trainee/Call"; import { Instructor } from "@/pages/instructor/Instructor"; +import { Dds } from "@/pages/dds/Dds"; import { Profile } from "@/pages/profile/Profile"; import { Wall } from "@/pages/wall/Wall"; -import { Stub } from "@/shared/ui/Stub"; // Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md). // session_id живёт в URL: /instructor?session=..., монитор открывают ссылкой. @@ -14,5 +14,5 @@ export const router = createBrowserRouter([ { path: "/instructor", element: }, { path: "/wall", element: }, { path: "/profile", element: }, - { path: "/dds", element: }, + { path: "/dds", element: }, ]); diff --git a/frontend/src/pages/dds/Dds.tsx b/frontend/src/pages/dds/Dds.tsx new file mode 100644 index 0000000..b4250b2 --- /dev/null +++ b/frontend/src/pages/dds/Dds.tsx @@ -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("connecting"); + const [card, setCard] = useState(null); + const [from, setFrom] = useState(""); + const [receivedAt, setReceivedAt] = useState(null); + const [elapsed, setElapsed] = useState(0); + const [acked, setAcked] = useState(false); + const [bounced, setBounced] = useState(null); + const [missing, setMissing] = useState([]); + const [comment, setComment] = useState(""); + const [zone, setZone] = useState(null); + const [marks, setMarks] = useState([]); + const channel = useRef | 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)); + 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 ( +
+

АРМ диспетчера ДДС

+

Откройте ссылку вида /dds?session=…&role=dds_01

+
+ ); + } + + const mark = (label: string) => setMarks((prev) => [`${new Date().toLocaleTimeString("ru-RU")} — ${label}`, ...prev]); + + return ( +
+

АРМ диспетчера ДДС · {role}

+ + + + + {card && !acked && ( + + + + + )} + +
Канал{status}
Карточка{card ? `от оператора ${from}` : "ожидание карточки от 112"}
Подтверждение ACK_LIMIT_S ? "state-violated" : "state-ok"}> + {elapsed.toFixed(1)} с из {ACK_LIMIT_S} с +
+ + {card && ( + <> +

+ {" "} + +

+ + {!bounced && ( +
+ Чего не хватает для выезда +
+ {FIELDS.map((field) => ( + + ))} +
+

+ setComment(event.target.value)} /> +

+
+ )} + +

+ Зона ответственности:{" "} + {" "} + +

+

+ {" "} + +

+ +

Карточка от оператора 112

+ {/* Снимок: после передачи он не меняется. */} + + + )} + + {marks.length > 0 && ( + <> +

Отметки времени

+ + {marks.map((line, index) => )} +
{line}
+ + )} +
+ ); +} diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx index 20c16e3..8da5bad 100644 --- a/frontend/src/pages/instructor/Instructor.tsx +++ b/frontend/src/pages/instructor/Instructor.tsx @@ -62,6 +62,7 @@ export function Instructor() { ? { trainee: `${location.origin}/trainee?session=${sessionId}`, wall: `${location.origin}/wall?session=${sessionId}`, + dds: `${location.origin}/dds?session=${sessionId}&role=dds_01`, } : null; @@ -131,6 +132,7 @@ export function Instructor() { АРМ курсантаоткрыть Внешний монитороткрыть + АРМ ДДСоткрыть )} diff --git a/frontend/src/shared/api/ws.ts b/frontend/src/shared/api/ws.ts index c12e4c9..6720540 100644 --- a/frontend/src/shared/api/ws.ts +++ b/frontend/src/shared/api/ws.ts @@ -7,7 +7,9 @@ import type { InstructorToServer, ServerToObserver, + ServerToStation, ServerToTrainee, + StationToServer, TraineeToServer, } from "@/shared/types/generated"; @@ -104,6 +106,13 @@ export const callChannel = (sessionId: string, options?: ChannelOptions) => new Channel(`/ws/observe/${sessionId}`, options); +/** Станция ДДС: только JSON, аудио здесь нет вообще. */ +export const stationChannel = ( + sessionId: string, + role: string, + options?: ChannelOptions, +) => new Channel(`/ws/station/${sessionId}?role=${role}`, options); + /** Преподаватель: только передача, входящих в этом канале нет. */ export const controlChannel = (sessionId: string, options?: { onStatus?: (status: ChannelStatus) => void }) => new Channel(`/ws/control/${sessionId}`, options); diff --git a/frontend/src/shared/ui/Stub.tsx b/frontend/src/shared/ui/Stub.tsx deleted file mode 100644 index c3c71cf..0000000 --- a/frontend/src/shared/ui/Stub.tsx +++ /dev/null @@ -1,11 +0,0 @@ -// Заглушка экрана. Каждая ссылается на карточку, которая её закрывает. -export function Stub({ title, card }: { title: string; card: string }) { - return ( -
-

{title}

-

- Экран не реализован. Карточка: tasks/{card}.md -

-
- ); -} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index df58424..f912c88 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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 .danger { border-color: var(--violated); color: var(--violated); font-weight: 600; } .director input[type="text"] { width: 60%; } + +/* Возврат карточки: список полей в две колонки, чтобы диспетчер не скроллил. */ +.bounce-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; margin: 8px 0; }