Implement local task 09 training workflows

This commit is contained in:
andreysk0304 2026-09-21 17:40:54 +03:00
commit cec84ffcd0
45 changed files with 2679 additions and 366 deletions

View file

@ -17,6 +17,9 @@ help: ## Список целей
dev: ## Поднять стенд: postgres + backend --reload + frontend dev: ## Поднять стенд: postgres + backend --reload + frontend
$(COMPOSE) up --build $(COMPOSE) up --build
offline: ## Поднять карточки/ДДС из заранее собранных локальных образов, без скачивания
$(COMPOSE) up --pull never --no-build
down: ## Погасить стенд down: ## Погасить стенд
$(COMPOSE) down $(COMPOSE) down
@ -47,6 +50,9 @@ backup: ## Резервная копия базы в backend/backups/ (для cr
users: ## Завести учётные записи ролей и напечатать пароли: make users force=1 users: ## Завести учётные записи ролей и напечатать пароли: make users force=1
cd backend && $(UV) run python scripts/users.py cd backend && $(UV) run python scripts/users.py
users-docker: ## Завести учётные записи внутри локального контейнера (после make offline)
$(COMPOSE) exec backend python scripts/users.py
seed: ## Залить сценарии из /scenarios в БД seed: ## Залить сценарии из /scenarios в БД
cd backend && $(UV) run python scripts/seed.py cd backend && $(UV) run python scripts/seed.py
@ -77,4 +83,4 @@ pregen: ## Офлайн-таблицы реплик: make pregen [s=<сцена
demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт
./scripts/demo.sh ./scripts/demo.sh
.PHONY: help dev down back front types users backup test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo .PHONY: help dev offline down back front types users users-docker backup test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo

View file

@ -5,6 +5,11 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app WORKDIR /app
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
# Экспорт PDF требует локальный TTF с кириллицей; базовый slim-образ его не
# гарантирует. Шрифт запекается в образ при подготовке офлайн-комплекта.
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# Зависимости — из pyproject.toml, а не отдельным списком: ручной список разошёлся # Зависимости — из pyproject.toml, а не отдельным списком: ручной список разошёлся
# с проектом, и бэкенд в контейнере упал на первом же новом пакете (num2words). # с проектом, и бэкенд в контейнере упал на первом же новом пакете (num2words).
# Голосовой контур (группа voice: torch, onnx-asr) в образ не входит: под WSL # Голосовой контур (группа voice: torch, onnx-asr) в образ не входит: под WSL

View file

@ -10,15 +10,116 @@
`hint.shown` из живой сессии, эталонные вопросы — только в разборе. `hint.shown` из живой сессии, эталонные вопросы — только в разборе.
""" """
from fastapi import APIRouter, HTTPException from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
from app.db.base import get_session
from app.domain.roles import Role
from app.scenarios import store from app.scenarios import store
from app.scenarios.editor import validate
from app.scenarios.loader import ScenarioError
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"]) router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"} HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"}
class TemplateDraftIn(BaseModel):
source_id: str = Field(min_length=1)
title: str | None = Field(default=None, min_length=1, max_length=200)
def _draft_out(row) -> dict:
return {
"id": row.id,
"status": row.status,
"generation": "template_copy",
"body": row.body,
}
@router.post("/drafts/from-template", status_code=201)
async def create_template_draft(
body: TemplateDraftIn, request: Request, db: AsyncSession = Depends(get_session)
) -> dict:
who = require(request, Role.INSTRUCTOR)
source = store.get(body.source_id)
if source is None:
raise HTTPException(status_code=404, detail="published_source_not_found")
row = await store.create_draft(db, source=source, title=body.title)
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
return _draft_out(row)
@router.get("/drafts/{scenario_id}")
async def read_draft(
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
) -> dict:
require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
return _draft_out(row)
@router.patch("/drafts/{scenario_id}")
async def patch_draft(
scenario_id: str,
body: dict[str, Any],
request: Request,
db: AsyncSession = Depends(get_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
row = await store.update_draft(db, row, body)
except ScenarioError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "scenario.draft.update", row.id)
return _draft_out(row)
@router.post("/drafts/{scenario_id}/validate")
async def validate_draft(
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
) -> dict:
require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
scenario = validate(row.body)
except ScenarioError as exc:
return {"valid": False, "errors": [str(exc)]}
return {
"valid": True,
"errors": [],
"ground_truth": scenario.ground_truth.model_dump(mode="json"),
}
@router.post("/drafts/{scenario_id}/approve")
async def approve_draft(
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
) -> dict:
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
scenario = await store.approve_draft(db, row)
except ScenarioError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "scenario.approve", scenario.id)
return {"id": scenario.id, "status": "published", "title": scenario.title}
@router.get("") @router.get("")
async def listing() -> list[dict]: async def listing() -> list[dict]:
return [ return [

View file

@ -7,16 +7,17 @@
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require from app.api.auth import audit, require
from app.db import repo from app.db import repo
from app.db.base import get_session from app.db.base import get_session
from app.domain.events import SessionMode, SessionReport from app.domain.events import Exercise, SessionMode, SessionReport
from app.scenarios import store from app.scenarios import store
from app.scoring.report import build as build_report from app.scoring.report import build as build_report
from app.scoring.export import to_csv, to_pdf
from app.domain.roles import Role from app.domain.roles import Role
from app.session.hub import hub from app.session.hub import hub
@ -57,7 +58,8 @@ def _out(session) -> SessionOut:
@router.post("", response_model=SessionOut, status_code=201) @router.post("", response_model=SessionOut, status_code=201)
async def create(body: SessionCreate, db: AsyncSession = Depends(get_session)) -> SessionOut: async def create(body: SessionCreate, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
require(request, Role.INSTRUCTOR)
group = await repo.ensure_group(db, body.group) if body.group else None group = await repo.ensure_group(db, body.group) if body.group else None
trainee = await repo.ensure_trainee(db, body.trainee, group) if body.trainee else None trainee = await repo.ensure_trainee(db, body.trainee, group) if body.trainee else None
session = await repo.create_session( session = await repo.create_session(
@ -71,10 +73,13 @@ async def create(body: SessionCreate, db: AsyncSession = Depends(get_session)) -
@router.get("/{session_id}", response_model=SessionOut) @router.get("/{session_id}", response_model=SessionOut)
async def read(session_id: UUID, db: AsyncSession = Depends(get_session)) -> SessionOut: async def read(session_id: UUID, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
who = require(request)
session = await repo.get_session(db, session_id) session = await repo.get_session(db, session_id)
if session is None: if session is None:
raise HTTPException(status_code=404, detail="session_not_found") raise HTTPException(status_code=404, detail="session_not_found")
if who.role is Role.TRAINEE and session.trainee_id != who.trainee_id:
raise HTTPException(status_code=403, detail="not_your_session")
return _out(session) return _out(session)
@ -84,16 +89,19 @@ class ChecklistItemOut(BaseModel):
@router.get("/{session_id}/checklist", response_model=list[ChecklistItemOut]) @router.get("/{session_id}/checklist", response_model=list[ChecklistItemOut])
async def checklist(session_id: UUID) -> list[ChecklistItemOut]: async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut]:
"""Чек-лист для самооценки — **только после конца звонка**. """Чек-лист для самооценки — **только после конца звонка**.
Во время звонка это содержимое подсказок: отдать его значит выдать Во время звонка это содержимое подсказок: отдать его значит выдать
в контрольном режиме то, чего там быть не должно. После звонка курсант в контрольном режиме то, чего там быть не должно. После звонка курсант
по нему отмечает, что, по его мнению, пропустил. по нему отмечает, что, по его мнению, пропустил.
""" """
who = require(request)
state = hub.get(session_id) state = hub.get(session_id)
if state is None: if state is None:
raise HTTPException(status_code=404, detail="session_not_found") raise HTTPException(status_code=404, detail="session_not_found")
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
raise HTTPException(status_code=403, detail="not_your_session")
if not state.ended: if not state.ended:
raise HTTPException(status_code=409, detail="call_not_ended") raise HTTPException(status_code=409, detail="call_not_ended")
scenario = state.scenario or store.get(state.scenario_id) scenario = state.scenario or store.get(state.scenario_id)
@ -136,11 +144,37 @@ async def report(session_id: UUID, request: Request) -> SessionReport:
state, scenario = _live(session_id) state, scenario = _live(session_id)
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id: if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
raise HTTPException(status_code=403, detail="not_your_session") raise HTTPException(status_code=403, detail="not_your_session")
if who.role is Role.TRAINEE and state.exercise is Exercise.CALL and not state.self_assessed:
raise HTTPException(status_code=409, detail="self_assessment_required")
if state.score is None: if state.score is None:
raise HTTPException(status_code=409, detail="score_not_ready") raise HTTPException(status_code=409, detail="score_not_ready")
return build_report(session_id, state, scenario) return build_report(session_id, state, scenario)
@router.get("/{session_id}/report.csv")
async def report_csv(session_id: UUID, request: Request) -> Response:
"""Те же права и готовность оценки, что у JSON-разбора."""
data = await report(session_id, request)
return Response(
content=to_csv(data), media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.csv"'},
)
@router.get("/{session_id}/report.pdf")
async def report_pdf(session_id: UUID, request: Request) -> Response:
"""Печатный разбор; генерация полностью локальна."""
data = await report(session_id, request)
try:
content = to_pdf(data)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
return Response(
content=content, media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.pdf"'},
)
@router.patch("/{session_id}/report", response_model=SessionReport) @router.patch("/{session_id}/report", response_model=SessionReport)
async def override(session_id: UUID, body: ScoreOverride, request: Request) -> SessionReport: async def override(session_id: UUID, body: ScoreOverride, request: Request) -> SessionReport:
"""Тренажёр готовит материал, преподаватель имеет последнее слово. """Тренажёр готовит материал, преподаватель имеет последнее слово.
@ -177,6 +211,8 @@ async def listing(
who = require(request) who = require(request)
# Обучающийся видит только свою историю, что бы он ни передал в фильтре. # Обучающийся видит только свою историю, что бы он ни передал в фильтре.
if who.role is Role.TRAINEE: if who.role is Role.TRAINEE:
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
trainee = who.trainee_id trainee = who.trainee_id
rows = await repo.history( rows = await repo.history(
db, db,

View file

@ -14,13 +14,18 @@ from pydantic import TypeAdapter, ValidationError
from app.domain.events import ( from app.domain.events import (
StationState, StationState,
CallIncoming,
CallEnded, CallEnded,
CallEndReason, CallEndReason,
CallStarted, CallStarted,
ErrorEvent, ErrorEvent,
ErrorKind, ErrorKind,
Exercise,
HintShown, HintShown,
KioState, KioState,
KioPatchOut,
PatchSource,
ScoreReady,
SessionEnded, SessionEnded,
SessionMode, SessionMode,
TimerTick, TimerTick,
@ -33,6 +38,7 @@ from app.api.auth import principal_of
from app.domain.roles import Role from app.domain.roles import Role
from app.session.hub import hub from app.session.hub import hub
from app.session.state import now_utc from app.session.state import now_utc
from app.domain.kio import ResponseStatus
from app.voice.models import get_voice_models from app.voice.models import get_voice_models
from app.voice.pipeline import VoiceSession from app.voice.pipeline import VoiceSession
@ -82,7 +88,37 @@ def _next_hint(state) -> tuple[str, str] | None:
async def _handle(session_id: UUID, state, event) -> None: async def _handle(session_id: UUID, state, event) -> None:
if state.ended and event.type != "self_assessment.submit":
hub.to_trainee(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT, message="Занятие уже завершено",
))
return
if state.exercise is Exercise.DDS or (
state.exercise is Exercise.CARD and event.type not in {"kio.patch", "card.submit"}
) or (state.exercise is Exercise.CALL and event.type == "card.submit"):
hub.to_trainee(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT, message="Действие недоступно в этом упражнении",
))
return
match event.type: match event.type:
case "card.submit":
state.kio.registered_at = state.started_at or now_utc()
state.kio.response_status = ResponseStatus.TRANSFERRED
state.dispatched_card = state.kio.model_copy(deep=True)
state.dispatched_at = now_utc()
state.ended_at = state.dispatched_at
state.end_reason = CallEndReason.COMPLETE
hub.stop_ticker(session_id)
hub.to_station(session_id, state.card_received_event())
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
hub.to_observers(session_id, KioState(kio=state.kio))
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.COMPLETE))
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
if hub.journal:
await hub.journal.session_ended(
session_id, state.ended_at, CallEndReason.COMPLETE.value
)
await finish(session_id, state)
case "call.answer": case "call.answer":
state.on_event("call.answer") state.on_event("call.answer")
state.started_at = now_utc() state.started_at = now_utc()
@ -93,7 +129,17 @@ async def _handle(session_id: UUID, state, event) -> None:
_start_voice(session_id, state) _start_voice(session_id, state)
case "kio.patch": case "kio.patch":
old_code, old_notify = state.kio.incident_code, list(state.kio.notify)
state.patch_kio(event.fields) state.patch_kio(event.fields)
hub.to_trainee(session_id, KioPatchOut(
fields=event.fields, source=PatchSource.OPERATOR,
))
if (state.kio.incident_code, state.kio.notify) != (old_code, old_notify):
hub.to_trainee(session_id, KioPatchOut(
fields={"incident_code": state.kio.incident_code,
"notify": list(state.kio.notify)},
source=PatchSource.AUTO,
))
# Наблюдателю уходит карточка целиком: рассинхрон на внешнем мониторе # Наблюдателю уходит карточка целиком: рассинхрон на внешнем мониторе
# посреди занятия дороже лишних килобайт. # посреди занятия дороже лишних килобайт.
hub.to_observers(session_id, KioState(kio=state.kio)) hub.to_observers(session_id, KioState(kio=state.kio))
@ -121,8 +167,19 @@ async def _handle(session_id: UUID, state, event) -> None:
await hub.journal.hint(session_id, checklist_id, question, now_utc()) await hub.journal.hint(session_id, checklist_id, question, now_utc())
case "dds.dispatch": case "dds.dispatch":
if event.service is None and not (state.kio.incident_code or state.kio.notify):
hub.to_trainee(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message="Укажите ДДС или проставьте признаки для маршрутизации по ЕКП",
))
return
state.on_event("dds.dispatch") state.on_event("dds.dispatch")
state.dispatch(event.service.value) state.dispatch(event.service.value if event.service else None)
hub.to_trainee(session_id, KioPatchOut(
fields={"response_status": state.kio.response_status.value,
"dds": state.kio.dds.value if state.kio.dds else None},
source=PatchSource.AUTO,
))
# Карточка замораживается снимком и уходит диспетчеру: оператор # Карточка замораживается снимком и уходит диспетчеру: оператор
# не должен иметь возможности дописать поле задним числом. # не должен иметь возможности дописать поле задним числом.
hub.to_station(session_id, state.card_received_event()) hub.to_station(session_id, state.card_received_event())
@ -227,8 +284,40 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
) )
await ws.close() await ws.close()
return return
if who.role is Role.TRAINEE and (
state.trainee_id is None or state.trainee_id != who.trainee_id
):
await _reject(ws, "Занятие не назначено этому обучающемуся")
return
with hub.trainee(session_id) as queue: with hub.trainee(session_id) as queue:
if state.exercise is Exercise.CARD:
from app.api.ws.control import card_briefing
hub.to_trainee(session_id, card_briefing(state))
if state.ended:
hub.to_trainee(session_id, CallEnded(reason=state.end_reason or CallEndReason.COMPLETE))
if state.score is not None:
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
elif state.exercise is Exercise.CALL:
scenario = state.scenario or store.get(state.scenario_id)
required = ([field for field in state.required_fields if field != "dds"]
if scenario and scenario.ground_truth.incident_code
else list(state.required_fields))
hub.to_trainee(session_id, CallIncoming(
scenario_id=state.scenario_id, caller_number="+7 (495) 000-00-00",
level=state.level, mode=state.mode, required_fields=required,
))
hub.to_trainee(session_id, KioPatchOut(
fields=state.kio.model_dump(mode="json"), source=PatchSource.OPERATOR,
))
if state.started_at is not None:
hub.to_trainee(session_id, CallStarted(started_at=state.started_at))
if state.ended:
hub.to_trainee(session_id, CallEnded(reason=state.end_reason or CallEndReason.HANGUP))
if state.score is not None and state.self_assessed:
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
hub.to_trainee(session_id, TimerTick(timers=state.timers.snapshot()))
writer = asyncio.create_task(_pump(ws, queue)) writer = asyncio.create_task(_pump(ws, queue))
try: try:
while True: while True:
@ -238,7 +327,13 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
# Бинарные кадры — аудио, текстовые — события. Направление определяется # Бинарные кадры — аудио, текстовые — события. Направление определяется
# каналом, обёртки JSON вокруг звука нет (docs/arch/CONTRACT.md). # каналом, обёртки JSON вокруг звука нет (docs/arch/CONTRACT.md).
if message.get("bytes") is not None: if message.get("bytes") is not None:
if state.exercise is Exercise.CALL:
_on_audio(session_id, state, message["bytes"]) _on_audio(session_id, state, message["bytes"])
else:
hub.to_trainee(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message="Аудио не используется в этом упражнении",
))
continue continue
try: try:
payload = json.loads(message.get("text") or "") payload = json.loads(message.get("text") or "")

View file

@ -9,6 +9,7 @@
""" """
import logging import logging
import re
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
@ -21,8 +22,9 @@ from app.domain.events import (
ErrorKind, ErrorKind,
ScoreReady, ScoreReady,
CallIncoming, CallIncoming,
ErrorEvent, CardBriefing,
ErrorKind, Exercise,
StationState,
InstructorToServer, InstructorToServer,
InstructorNoteShown, InstructorNoteShown,
ModeSet, ModeSet,
@ -42,6 +44,7 @@ from app.api.auth import audit, principal_of
from app.domain.roles import Role from app.domain.roles import Role
from app.session.hub import hub from app.session.hub import hub
from app.session.state import SessionState, now_utc from app.session.state import SessionState, now_utc
from app.domain.kio import KIO, ResponseStatus
from app.voice.models import get_voice_models from app.voice.models import get_voice_models
from app.voice.pipeline import FILLERS, prefetch from app.voice.pipeline import FILLERS, prefetch
@ -51,6 +54,27 @@ router = APIRouter()
_adapter = TypeAdapter(InstructorToServer) _adapter = TypeAdapter(InstructorToServer)
def card_briefing(state: SessionState) -> CardBriefing:
"""Учебная текстовая вводная — исходные реплики, а не эталон карточки.
В отсутствие диалога факты раскрываются сразу. Если факт уточняется,
показываем и уточнение: иначе правильно заполнить карточку невозможно.
"""
scenario = state.scenario
lines = ["Учебная текстовая вводная: сведения заявителя приведены ниже.",
scenario.first_line]
for fact in scenario.facts:
lines.append(f"• {fact.value}")
if fact.refined:
lines.append(f" Уточнено: {fact.refined}")
return CardBriefing(
scenario_id=scenario.id, mode=state.mode, text="\n".join(lines),
required_fields=([field for field in state.required_fields if field != "dds"]
if scenario.ground_truth.incident_code else list(state.required_fields)),
card=state.kio,
)
async def _start(session_id: UUID, event, who=None) -> None: async def _start(session_id: UUID, event, who=None) -> None:
scenario = store.get(event.scenario_id) scenario = store.get(event.scenario_id)
if scenario is None: if scenario is None:
@ -61,9 +85,10 @@ async def _start(session_id: UUID, event, who=None) -> None:
return return
attempt = 1 attempt = 1
recorded_trainee_id = event.trainee_id
if hub.journal: if hub.journal:
attempt = await hub.journal.start_lesson( attempt, recorded_trainee_id = await hub.journal.start_lesson(
session_id, scenario.id, event.mode.value, event.trainee session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id
) )
# Занятие собирается целиком и только потом регистрируется: иначе # Занятие собирается целиком и только потом регистрируется: иначе
@ -75,25 +100,59 @@ async def _start(session_id: UUID, event, who=None) -> None:
scenario_title=scenario.title, scenario_title=scenario.title,
level=scenario.level.value, level=scenario.level.value,
mode=event.mode, mode=event.mode,
exercise=event.exercise,
scenario=scenario.model_copy(deep=True), scenario=scenario.model_copy(deep=True),
required_fields=scenario.required_fields, required_fields=scenario.required_fields,
trainee_name=event.trainee, trainee_name=event.trainee,
trainee_id=getattr(event, "trainee_id", None), trainee_id=recorded_trainee_id,
attempt=attempt, attempt=attempt,
) )
if event.exercise is Exercise.CALL:
embedder = get_embedder() embedder = get_embedder()
if embedder is not None: if embedder is not None:
state.slots = SlotMachine(state.scenario, embedder) state.slots = SlotMachine(state.scenario, embedder)
state.persona = PersonaState(state.scenario.persona) state.persona = PersonaState(state.scenario.persona)
state.caller = build_caller(scenario.id) state.caller = build_caller(scenario.id)
elif event.exercise is Exercise.DDS:
# В ДДС поступает уже оформленная учебная карточка. Содержимое берётся
# из утверждённого сценария, а не из действий несуществующего оператора.
truth = scenario.ground_truth
address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "")
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
service = truth.dds.value if truth.dds else None
fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"}
state.kio = KIO(
registered_at=now_utc(), caller_number="+7 (495) 000-00-00",
address=truth.address or address_fact or None,
floor=floor.group(1) if floor else None,
incident_type=truth.incident_type, incident_code=truth.incident_code,
dds=truth.dds, signs=list(scenario.signs),
notify=list(truth.notify) or ([fallback[service]] if service in fallback else []),
victims_count=truth.victims,
description="; ".join(fact.value for fact in scenario.facts[:3]) or scenario.first_line,
)
if service:
state.dispatch(service)
else:
state.kio.response_status = ResponseStatus.TRANSFERRED
state.dispatched_card = state.kio.model_copy(deep=True)
state.dispatched_at = now_utc()
state.started_at = state.dispatched_at
else:
state.started_at = now_utc()
hub.register(state) hub.register(state)
if event.exercise is not Exercise.CALL and hub.journal and state.started_at is not None:
await hub.journal.session_started(session_id, state.started_at)
# Первая реплика и филлеры синтезируются, пока курсант не снял трубку: # Первая реплика и филлеры синтезируются, пока курсант не снял трубку:
# «Алло! Помогите!» должно прозвучать мгновенно (docs/arch/BACKEND.md). # «Алло! Помогите!» должно прозвучать мгновенно (docs/arch/BACKEND.md).
if event.exercise is Exercise.CALL:
models = get_voice_models() models = get_voice_models()
if models is not None: if models is not None:
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()])) asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
state.on_event("call.incoming") state.on_event("call.incoming")
elif event.exercise is Exercise.DDS:
state.on_event("dds.dispatch")
hub.start_ticker(session_id) hub.start_ticker(session_id)
if who is not None: if who is not None:
# Запуск занятия меняет чужой результат — значит попадает в журнал # Запуск занятия меняет чужой результат — значит попадает в журнал
@ -103,6 +162,7 @@ async def _start(session_id: UUID, event, who=None) -> None:
f"{scenario.id}, режим {event.mode.value}, курсант {event.trainee}") f"{scenario.id}, режим {event.mode.value}, курсант {event.trainee}")
) )
if event.exercise is Exercise.CALL:
hub.to_trainee( hub.to_trainee(
session_id, session_id,
CallIncoming( CallIncoming(
@ -110,22 +170,37 @@ async def _start(session_id: UUID, event, who=None) -> None:
caller_number="+7 (495) 000-00-00", caller_number="+7 (495) 000-00-00",
level=scenario.level, level=scenario.level,
mode=event.mode, mode=event.mode,
scenario=scenario.model_copy(deep=True), required_fields=[field for field in scenario.required_fields
required_fields=scenario.required_fields, if field != "dds" or not scenario.ground_truth.incident_code],
), ),
) )
elif event.exercise is Exercise.DDS:
hub.to_station(session_id, state.card_received_event())
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
else:
hub.to_trainee(session_id, card_briefing(state))
hub.to_observers(session_id, ModeSet(mode=event.mode)) hub.to_observers(session_id, ModeSet(mode=event.mode))
hub.to_observers(session_id, state.snapshot()) hub.to_observers(session_id, state.snapshot())
async def _stop(session_id: UUID) -> None: async def _stop(session_id: UUID) -> None:
state = hub.get(session_id) state = hub.get(session_id)
if state is None: if state is None or state.ended:
return return
state.ended_at = now_utc() state.ended_at = now_utc()
state.end_reason = CallEndReason.INSTRUCTOR state.end_reason = CallEndReason.INSTRUCTOR
hub.stop_ticker(session_id) hub.stop_ticker(session_id)
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR)) hub.to_observers(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
if state.exercise in {Exercise.DDS, Exercise.CARD}:
from app.session.finish import finish
if state.exercise is Exercise.DDS:
hub.to_station(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
else:
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.INSTRUCTOR))
await finish(session_id, state)
if state.exercise is Exercise.DDS:
hub.to_station(session_id, ScoreReady(session_id=session_id))
if hub.journal: if hub.journal:
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value) await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value)
@ -204,6 +279,12 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
state = hub.get(session_id) state = hub.get(session_id)
if state is None: if state is None:
continue continue
if state.exercise is not Exercise.CALL:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message="Директивы звонящему доступны только в голосовом упражнении",
))
continue
result = apply_directive(state, event.directive) result = apply_directive(state, event.directive)
if result.needs_network: if result.needs_network:
hub.to_observers(session_id, ErrorEvent( hub.to_observers(session_id, ErrorEvent(

View file

@ -1,7 +1,7 @@
"""Сокет диспетчера ДДС. """Сокет диспетчера ДДС.
**Только JSON: аудио здесь нет вообще** — значит, нет ни VAD, ни распознавания, Учебный исходящий звонок бригаде имитируется событиями JSON. Для целевого
ни синтеза. Голосовой контур станция не трогает (docs/arch/CONTRACT.md). локального прототипа не нужна внешняя телефония или аудиомодель.
Самая ценная механика цепочки — `card.bounce`: диспетчер видит, что не указан Самая ценная механика цепочки — `card.bounce`: диспетчер видит, что не указан
этаж, и отбивает карточку обратно. Неполнота КИО перестаёт быть процентом этаж, и отбивает карточку обратно. Неполнота КИО перестаёт быть процентом
@ -15,20 +15,45 @@ from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError from pydantic import TypeAdapter, ValidationError
from app.domain.events import ErrorEvent, ErrorKind, StationState, StationToServer from app.domain.events import (
from app.domain.statuses import PRIMARY, ServiceStatus, StatusError CallEndReason, ErrorEvent, ErrorKind, Exercise, PhoneReport, ScoreReady,
SessionEnded, StationState, StationToServer,
)
from app.domain.statuses import PRIMARY, PhoneReportRecord, ServiceStatus, StatusError, current
from app.api.auth import principal_of from app.api.auth import principal_of
from app.domain.roles import Role from app.domain.roles import Role
from app.session.hub import hub from app.session.hub import hub
from app.session.state import now_utc from app.session.state import now_utc
from app.session.finish import finish
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
_adapter = TypeAdapter(StationToServer) _adapter = TypeAdapter(StationToServer)
REPORT_PHASES = ("dispatched", "arrived", "working", "completed")
REPORT_TEXT = {
"dispatched": "Бригада выехала к месту происшествия.",
"arrived": "Бригада прибыла на место происшествия.",
"working": "Бригада приступила к проведению работ.",
"completed": "Работы завершены, бригада освобождена.",
}
REPORT_FOR_STATUS = {
ServiceStatus.RESPONDING: "dispatched",
ServiceStatus.ARRIVED: "arrived",
ServiceStatus.WORKING: "working",
ServiceStatus.COMPLETED: "completed",
}
def _error(session_id: UUID, message: str) -> None:
hub.to_station(session_id, ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=message))
async def _handle(session_id: UUID, state, event) -> None: async def _handle(session_id: UUID, state, event) -> None:
if state.ended:
_error(session_id, "Занятие уже завершено")
return
match event.type: match event.type:
case "card.ack": case "card.ack":
# Подтверждение приёма — это статус «Принята» у главной службы. # Подтверждение приёма — это статус «Принята» у главной службы.
@ -43,6 +68,16 @@ async def _handle(session_id: UUID, state, event) -> None:
except StatusError: except StatusError:
pass # статус уже стоит: повторное нажатие ничего не меняет pass # статус уже стоит: повторное нажатие ничего не меняет
case "card.status": case "card.status":
if event.service not in state.notified_services():
_error(session_id, "Служба отсутствует в списке оповещения карточки")
return
phase = REPORT_FOR_STATUS.get(event.status)
if state.exercise is Exercise.DDS and phase is not None and not any(
report.service == event.service and report.phase == phase
for report in state.phone_reports
):
_error(session_id, f"Статус «{event.status.value}» требует доклада бригады")
return
try: try:
state.set_service_status( state.set_service_status(
event.service, event.status, event.comment, author="диспетчер" event.service, event.status, event.comment, author="диспетчер"
@ -56,6 +91,55 @@ async def _handle(session_id: UUID, state, event) -> None:
# Первичный статус останавливает норматив 30 секунд. # Первичный статус останавливает норматив 30 секунд.
if event.status in PRIMARY: if event.status in PRIMARY:
state.on_event("card.ack") state.on_event("card.ack")
case "crew.select":
if event.crew not in state.crew_options():
_error(session_id, "Выберите бригаду из списка доступных")
return
service = state.crew_service(event.crew)
assigned = state.crew_assignments.get(service)
if assigned and assigned != event.crew and any(
report.service == service for report in state.phone_reports
):
_error(session_id, "После первого доклада бригаду этой службы менять нельзя")
return
state.crew_selected = event.crew
state.crew_assignments[service] = event.crew
case "phone.dial":
crew = state.crew_selected
service = state.crew_service(crew) if crew else None
if service is None:
_error(session_id, "Сначала выберите бригаду")
return
if current(state.status_log, service) not in {
ServiceStatus.ACCEPTED, ServiceStatus.RESPONDING,
ServiceStatus.ARRIVED, ServiceStatus.WORKING,
}:
_error(session_id, "Сначала примите карточку этой службы")
return
previous = [report for report in state.phone_reports if report.service == service]
if len(previous) >= len(REPORT_PHASES):
_error(session_id, "Все доклады этой бригады уже получены")
return
phase = REPORT_PHASES[len(previous)]
report = PhoneReportRecord(
service=service, crew=crew, phase=phase,
text=REPORT_TEXT[phase], at=now_utc(),
)
state.phone_reports.append(report)
hub.to_station(session_id, PhoneReport(**report.model_dump()))
case "station.finish":
if state.exercise is not Exercise.DDS:
_error(session_id, "Операторское занятие завершается после звонка 112")
return
state.ended_at = now_utc()
state.end_reason = CallEndReason.COMPLETE
hub.stop_ticker(session_id)
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
if hub.journal:
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.COMPLETE.value)
await finish(session_id, state)
hub.to_station(session_id, ScoreReady(session_id=session_id))
case "card.bounce": case "card.bounce":
# Карточка вернулась: в разборе это E6 с конкретной причиной. # Карточка вернулась: в разборе это E6 с конкретной причиной.
state.bounced_fields = list(event.missing_fields) state.bounced_fields = list(event.missing_fields)
@ -105,6 +189,11 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
) )
await ws.close() await ws.close()
return return
if who.role is Role.TRAINEE and (
state.trainee_id is None or state.trainee_id != who.trainee_id
):
await _reject(ws, "Занятие не назначено этому обучающемуся")
return
with hub.station(session_id) as queue: with hub.station(session_id) as queue:
sender = asyncio.create_task(_pump(ws, queue)) sender = asyncio.create_task(_pump(ws, queue))

View file

@ -52,6 +52,7 @@ async def ensure_session(
scenario_id: str, scenario_id: str,
mode: str, mode: str,
trainee_name: str | None = None, trainee_name: str | None = None,
trainee_id: UUID | None = None,
group_name: str | None = None, group_name: str | None = None,
) -> Session: ) -> Session:
"""Занятие, запущенное с пульта, должно иметь строку в журнале. """Занятие, запущенное с пульта, должно иметь строку в журнале.
@ -64,7 +65,11 @@ async def ensure_session(
return existing return existing
group = await ensure_group(db, group_name) if group_name else None group = await ensure_group(db, group_name) if group_name else None
trainee = await ensure_trainee(db, trainee_name, group) if trainee_name else None trainee = await db.get(Trainee, trainee_id) if trainee_id else None
if trainee_id and trainee is None:
raise ValueError(f"курсант {trainee_id} не найден")
if trainee is None and trainee_name:
trainee = await ensure_trainee(db, trainee_name, group)
return await create_session( return await create_session(
db, db,
scenario_id=scenario_id, scenario_id=scenario_id,

View file

@ -34,6 +34,12 @@ class SessionMode(StrEnum):
SELF = "self" SELF = "self"
class Exercise(StrEnum):
CALL = "call"
DDS = "dds"
CARD = "card"
class Speaker(StrEnum): class Speaker(StrEnum):
CALLER = "caller" CALLER = "caller"
OPERATOR = "operator" OPERATOR = "operator"
@ -53,6 +59,7 @@ class CallEndReason(StrEnum):
HANGUP = "hangup" HANGUP = "hangup"
DROPPED = "dropped" DROPPED = "dropped"
INSTRUCTOR = "instructor" INSTRUCTOR = "instructor"
COMPLETE = "complete"
class PatchSource(StrEnum): class PatchSource(StrEnum):
@ -112,6 +119,17 @@ class CallIncoming(BaseModel):
required_fields: list[str] = [] required_fields: list[str] = []
class CardBriefing(BaseModel):
"""Текстовая вводная упражнения 112; эталонные значения сюда не входят."""
type: Literal["card.briefing"] = "card.briefing"
scenario_id: str
mode: SessionMode
text: str
required_fields: list[str]
card: KIO | None = None
class CallStarted(BaseModel): class CallStarted(BaseModel):
type: Literal["call.started"] = "call.started" type: Literal["call.started"] = "call.started"
started_at: datetime started_at: datetime
@ -215,6 +233,7 @@ class ErrorEvent(BaseModel):
ServerToTrainee = Annotated[ ServerToTrainee = Annotated[
CallIncoming CallIncoming
| CardBriefing
| CallStarted | CallStarted
| SttPartial | SttPartial
| SttFinal | SttFinal
@ -244,6 +263,10 @@ class CallAnswer(BaseModel):
type: Literal["call.answer"] = "call.answer" type: Literal["call.answer"] = "call.answer"
class CardSubmit(BaseModel):
type: Literal["card.submit"] = "card.submit"
class KioPatchIn(BaseModel): class KioPatchIn(BaseModel):
"""Правка карточки. Дебаунс 300 мс, шлётся только дельта.""" """Правка карточки. Дебаунс 300 мс, шлётся только дельта."""
@ -266,10 +289,10 @@ class SelfAssessmentSubmit(BaseModel):
class DdsDispatch(BaseModel): class DdsDispatch(BaseModel):
"""Передача в ДДС. Замораживает карточку снимком и останавливает опрос (`interview`).""" """Передача в ДДС. При маршрутизации по ЕКП служба уже есть в notify."""
type: Literal["dds.dispatch"] = "dds.dispatch" type: Literal["dds.dispatch"] = "dds.dispatch"
service: DDSCode service: DDSCode | None = None
class CallHangup(BaseModel): class CallHangup(BaseModel):
@ -297,6 +320,7 @@ class CallResolve(BaseModel):
TraineeToServer = Annotated[ TraineeToServer = Annotated[
CallAnswer CallAnswer
| CardSubmit
| KioPatchIn | KioPatchIn
| HintRequest | HintRequest
| SelfAssessmentSubmit | SelfAssessmentSubmit
@ -320,6 +344,7 @@ class SessionSnapshot(BaseModel):
scenario_title: str scenario_title: str
level: Level level: Level
mode: SessionMode mode: SessionMode
exercise: Exercise = Exercise.CALL
trainee_name: str | None = None trainee_name: str | None = None
started_at: datetime | None = None started_at: datetime | None = None
kio: KIO kio: KIO
@ -394,8 +419,10 @@ class ScenarioStart(BaseModel):
type: Literal["scenario.start"] = "scenario.start" type: Literal["scenario.start"] = "scenario.start"
scenario_id: str scenario_id: str
trainee: str trainee: str
trainee_id: UUID | None = None
group_id: str | None = None group_id: str | None = None
mode: SessionMode mode: SessionMode
exercise: Exercise = Exercise.CALL
class DirectorInject(BaseModel): class DirectorInject(BaseModel):
@ -486,6 +513,28 @@ class ServiceStatusSet(BaseModel):
comment: str = "" comment: str = ""
class CrewSelect(BaseModel):
type: Literal["crew.select"] = "crew.select"
crew: str
class PhoneDial(BaseModel):
type: Literal["phone.dial"] = "phone.dial"
class PhoneReport(BaseModel):
type: Literal["phone.report"] = "phone.report"
service: str
crew: str
phase: Literal["dispatched", "arrived", "working", "completed"]
text: str
at: datetime
class StationFinish(BaseModel):
type: Literal["station.finish"] = "station.finish"
class StationState(BaseModel): class StationState(BaseModel):
"""Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше.""" """Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше."""
@ -509,12 +558,13 @@ class CrewArrived(BaseModel):
ServerToStation = Annotated[ ServerToStation = Annotated[
CardReceived | StationState | TimerTick | SessionEnded | ErrorEvent, CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent,
Field(discriminator="type"), Field(discriminator="type"),
] ]
StationToServer = Annotated[ StationToServer = Annotated[
CardAck | CardBounce | ServiceStatusSet | ZoneDecision | CrewDispatched | CrewArrived, CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish
| ZoneDecision | CrewDispatched | CrewArrived,
Field(discriminator="type"), Field(discriminator="type"),
] ]

View file

@ -193,10 +193,10 @@ def derive_incident(card: KIO) -> KIO:
keys = modifiers(card) keys = modifiers(card)
data = card.model_dump() data = card.model_dump()
data["incident_code"] = found.code if found else None data["incident_code"] = found.code if found else None
computed = ekp.notify_list(found.code, keys) if found else [] # `notify` недоступен оператору для записи (READ_ONLY_FIELDS), поэтому
# Добавленную вручную службу не теряем: удалить её оператор не может. # хранить прошлый авторасчёт как «добавленные вручную службы» нельзя:
manual = [name for name in card.notify if name not in ekp.notify_list(data["incident_code"] or "", keys)] # при снятии признака газа МОСГАЗ должен исчезнуть из нового расчёта.
data["notify"] = computed + manual data["notify"] = ekp.notify_list(found.code, keys) if found else []
return KIO.model_validate(data) return KIO.model_validate(data)

View file

@ -126,6 +126,14 @@ class StatusEntry(BaseModel):
author: str = "" author: str = ""
class PhoneReportRecord(BaseModel):
service: str
crew: str
phase: str
text: str
at: datetime
class StatusError(ValueError): class StatusError(ValueError):
"""Переход запрещён автоматом или нет обязательного комментария.""" """Переход запрещён автоматом или нет обязательного комментария."""
@ -168,7 +176,8 @@ def card_status(
latest = {service: current(entries, service) for service in services} latest = {service: current(entries, service) for service in services}
silent = [ silent = [
service for service, status in latest.items() if status not in PRIMARY | TERMINAL service for service, status in latest.items()
if status in {ServiceStatus.ADDED, ServiceStatus.RECEIVED}
] ]
if silent and notify_deadline_passed: if silent and notify_deadline_passed:
return CardStatus.NOT_NOTIFIED return CardStatus.NOT_NOTIFIED
@ -196,3 +205,6 @@ class StationSnapshot(BaseModel):
available: dict[str, list[ServiceStatus]] available: dict[str, list[ServiceStatus]]
card: CardStatus card: CardStatus
log: list[StatusEntry] log: list[StatusEntry]
crew_options: list[str] = []
crew_selected: str | None = None
phone_reports: list[PhoneReportRecord] = []

View file

@ -1,9 +1,11 @@
"""Сборка приложения. Роутеры подключаются по мере готовности — см. tasks/.""" """Сборка приложения. Роутеры подключаются по мере готовности — см. tasks/."""
import asyncio
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from sqlalchemy.exc import SQLAlchemyError
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from pathlib import Path from pathlib import Path
@ -47,6 +49,19 @@ async def lifespan(app: FastAPI):
raise RuntimeError(f"библиотека сценариев не прошла проверку: {exc}") from exc raise RuntimeError(f"библиотека сценариев не прошла проверку: {exc}") from exc
app.state.scenarios_loaded = len(loaded) app.state.scenarios_loaded = len(loaded)
# Утверждённые преподавателем сценарии хранятся в БД и должны переживать
# перезапуск процесса. При недоступной БД остаётся базовая YAML-библиотека.
try:
# БД может ещё подниматься или отсутствовать в unit-тесте. Не задерживаем
# старт АРМ на минуту ради необязательной пользовательской библиотеки.
async with asyncio.timeout(1):
async with get_sessionmaker()() as db:
app.state.scenarios_loaded += await store.restore_published(db)
except (SQLAlchemyError, OSError, TimeoutError) as exc:
logging.getLogger(__name__).warning(
"не удалось восстановить утверждённые сценарии из БД: %s", exc
)
# Журнал: всё, что не записано, для оценки не существует. # Журнал: всё, что не записано, для оценки не существует.
hub.journal = DbJournal(get_sessionmaker()) hub.journal = DbJournal(get_sessionmaker())

View file

@ -0,0 +1,100 @@
"""Локальное, детерминированное редактирование учебных сценариев.
Это копирование утверждённого шаблона, а не генерация нового сюжета ИИ.
Никаких сетевых вызовов в этом модуле нет.
"""
from copy import deepcopy
from pydantic import ValidationError
from app.scenarios.loader import ScenarioError, _derive_ground_truth
from app.scenarios.schema import Scenario
DERIVED_TRUTH = {"incident_type", "dds", "incident_code", "notify", "required_facts"}
def template_copy(source: Scenario, draft_id: str, title: str | None = None) -> dict:
"""Сделать полностью самостоятельный шаблонный черновик из сценария."""
body = source.model_dump(mode="json")
body["id"] = draft_id
body["title"] = title or f"{source.title} — копия"
body["extends"] = None # чек-лист уже развёрнут загрузчиком
body["ticket"] = None
body["position"] = None
for key in DERIVED_TRUTH:
body["ground_truth"].pop(key, None)
return body
def merge_patch(body: dict, patch: dict) -> dict:
"""JSON Merge Patch: словари сливаются, списки заменяются целиком.
`id` — идентичность записи, а вычисляемые поля эталона не принимаются от
клиента. Некорректные обычные поля можно временно сохранить в черновике;
утверждение всё равно потребует полной проверки.
"""
if "id" in patch and patch["id"] != body["id"]:
raise ScenarioError("id сценария менять нельзя")
truth = patch.get("ground_truth")
if isinstance(truth, dict) and DERIVED_TRUTH & truth.keys():
raise ScenarioError("вычисляемые поля ground_truth нельзя задавать вручную")
result = deepcopy(body)
def apply(target: dict, changes: dict) -> None:
for key, value in changes.items():
if value is None:
target.pop(key, None)
elif isinstance(value, dict) and isinstance(target.get(key), dict):
apply(target[key], value)
else:
target[key] = deepcopy(value)
apply(result, patch)
result["id"] = body["id"]
return result
def validate(body: dict) -> Scenario:
"""Проверить схему и связи фактов, затем пересчитать эталон по ЕКП."""
declared = body.get("ground_truth") or {}
if not isinstance(declared, dict):
raise ScenarioError("ground_truth: ожидается объект")
forbidden = DERIVED_TRUTH & declared.keys()
if forbidden:
raise ScenarioError(
f"{', '.join(sorted(forbidden))} в ground_truth выводится кодом и руками не пишется"
)
try:
scenario = Scenario.model_validate(body)
except ValidationError as exc:
first = exc.errors()[0]
where = ".".join(str(part) for part in first["loc"])
raise ScenarioError(f"{where}: {first['msg']}") from exc
if not scenario.title.strip() or len(scenario.title) > 200:
raise ScenarioError("title: требуется название длиной от 1 до 200 символов")
if not scenario.first_line.strip():
raise ScenarioError("first_line: требуется первая реплика")
if not scenario.facts or not scenario.checklist:
raise ScenarioError("сценарию нужны факты и чек-лист для оценки занятия")
known = scenario.fact_ids()
question_ids = {item.id for item in scenario.checklist}
if len(known) != len(scenario.facts):
raise ScenarioError("id фактов должны быть уникальны")
if len(question_ids) != len(scenario.checklist):
raise ScenarioError("id пунктов чек-листа должны быть уникальны")
for item in scenario.checklist:
if not item.question:
raise ScenarioError(f"у пункта {item.id} нет текста вопроса")
if item.fact and item.fact not in known:
raise ScenarioError(f"пункт {item.id} ссылается на факт {item.fact}, которого нет")
for fact in scenario.facts:
question_id = fact.reveal_on.question if fact.reveal_on else None
if question_id and question_id not in question_ids:
raise ScenarioError(f"факт {fact.id} раскрывается несуществующим вопросом {question_id}")
if fact.refine_on and fact.refine_on not in question_ids:
raise ScenarioError(f"факт {fact.id} уточняется несуществующим вопросом {fact.refine_on}")
return _derive_ground_truth(scenario)

View file

@ -5,6 +5,7 @@
""" """
from pathlib import Path from pathlib import Path
from uuid import uuid4
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -12,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Scenario as ScenarioRow from app.db.models import Scenario as ScenarioRow
from app.scenarios.loader import load_library from app.scenarios.loader import load_library
from app.scenarios.schema import Scenario from app.scenarios.schema import Scenario
from app.scenarios import editor
_library: dict[str, Scenario] = {} _library: dict[str, Scenario] = {}
@ -29,6 +31,11 @@ def all_scenarios() -> list[Scenario]:
return list(_library.values()) return list(_library.values())
def publish(scenario: Scenario) -> None:
"""Сделать утверждённую версию доступной запуску занятия немедленно."""
_library[scenario.id] = scenario
def load_from_disk(root: Path) -> list[Scenario]: def load_from_disk(root: Path) -> list[Scenario]:
scenarios = load_library(root) scenarios = load_library(root)
set_library(scenarios) set_library(scenarios)
@ -51,3 +58,65 @@ async def seed(db: AsyncSession, scenarios: list[Scenario]) -> int:
row.body = payload row.body = payload
await db.commit() await db.commit()
return len(scenarios) return len(scenarios)
async def restore_published(db: AsyncSession) -> int:
"""После старта вернуть утверждённые преподавателем сценарии из БД.
Вызвать после ``load_from_disk``. Базовую библиотеку не перезаписываем:
её источником остаются проверенные YAML из репозитория.
"""
rows = await db.scalars(select(ScenarioRow).where(ScenarioRow.status == "published"))
restored = 0
for row in rows:
if row.id in _library:
continue
_library[row.id] = Scenario.model_validate(row.body)
restored += 1
return restored
async def create_draft(
db: AsyncSession, *, source: Scenario, title: str | None = None
) -> ScenarioRow:
draft_id = f"draft-{uuid4().hex}"
body = editor.template_copy(source, draft_id, title)
row = ScenarioRow(
id=draft_id,
title=body["title"],
incident_type=body["type"],
level=body["level"],
topics=body["topics"],
modes=body["modes"],
status="draft",
body=body,
)
db.add(row)
await db.commit()
return row
async def draft(db: AsyncSession, scenario_id: str) -> ScenarioRow | None:
row = await db.get(ScenarioRow, scenario_id)
return row if row is not None and row.status == "draft" else None
async def update_draft(db: AsyncSession, row: ScenarioRow, patch: dict) -> ScenarioRow:
row.body = editor.merge_patch(row.body, patch)
row.title = str(row.body.get("title") or "")[:200]
await db.commit()
return row
async def approve_draft(db: AsyncSession, row: ScenarioRow) -> Scenario:
scenario = editor.validate(row.body)
row.title = scenario.title
row.incident_type = scenario.type.value
row.level = scenario.level.value
row.topics = scenario.topics
row.modes = scenario.modes
row.body = scenario.model_dump(mode="json")
row.status = "published"
await db.commit()
publish(scenario)
return scenario

View file

@ -0,0 +1,70 @@
"""Детерминированная оценка текстового упражнения оператора 112.
Нет входящего звонка и опроса: оцениваются только поля готовой КИО и
маршрутизация. Эталон берётся из утверждённого сценария, но не показывается
в `card.briefing` до завершения упражнения.
"""
from app.domain.events import Metric
from app.domain.kio import KIO, missing_fields
from app.domain.taxonomy import Finding, FindingSource
from app.scenarios.schema import Scenario
from app.scoring.gost import GostResult, _normalize_address
from app.scoring.taxonomy import METRIC_MAP, METRIC_WEIGHTS
def evaluate_card(scenario: Scenario, kio: KIO) -> GostResult:
result = GostResult()
truth = scenario.ground_truth
def add(key: str, title: str, fact: str, norm: str, passed: bool, ref: str) -> None:
result.metrics.append(Metric(
key=key, title=title, fact=fact, norm=norm, passed=passed,
ref=ref, weight=METRIC_WEIGHTS.get(key, 1.0),
))
if not passed:
code, competency = METRIC_MAP[key]
result.findings.append(Finding(
code=code,
source=FindingSource.KIO if code.value == "E5" else FindingSource.GROUND_TRUTH,
summary=f"{title}: {fact}; требуется {norm}",
fact=fact, norm=norm, ref=ref, competency=competency,
))
if truth.incident_code:
actual = kio.incident_code or "не определён"
add("incident_signs", "Признаки происшествия", actual,
truth.incident_code, actual == truth.incident_code, "классификатор ЕКП")
elif truth.incident_type:
actual = kio.incident_type.value if kio.incident_type else "не указан"
add("incident_type", "Тип происшествия", actual,
truth.incident_type.value, kio.incident_type == truth.incident_type,
"классификатор происшествий")
if truth.dds and truth.incident_code is None:
actual = kio.dds.value if kio.dds else "не выбрана"
add("dds_choice", "ДДС", actual, truth.dds.value,
kio.dds == truth.dds, "список оповещения по сценарию")
if truth.address:
written = kio.address or " ".join(filter(None, (kio.street, kio.building)))
add("address", "Адрес происшествия", written or "не заполнен", truth.address,
bool(_normalize_address(truth.address))
and _normalize_address(truth.address) <= _normalize_address(written),
"эталон сценария")
if truth.victims is not None:
actual = str(kio.victims_count) if kio.victims_count is not None else "не указано"
add("victims_count", "Число пострадавших", actual, str(truth.victims),
kio.victims_count == truth.victims, "эталон сценария")
required_fields = ([field for field in scenario.required_fields if field != "dds"]
if truth.incident_code else scenario.required_fields)
if required_fields:
missing = missing_fields(kio, required_fields)
add("required_fields", "Обязательные поля карточки",
"все заполнены" if not missing else "пусто: " + ", ".join(missing),
"все обязательные поля заполнены", not missing,
"поля КИО утверждённого сценария")
return result

View file

@ -18,6 +18,8 @@ from app.domain.statuses import (
current, current,
) )
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
from app.domain.events import Metric
from app.domain.timers import GOST_REF
#: Статусы, означающие, что реагирование действительно шло. #: Статусы, означающие, что реагирование действительно шло.
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING) PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
@ -123,3 +125,61 @@ def evaluate_dispatcher(
) )
return findings return findings
def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
Веса предварительные — до утверждения методистом. Неподтверждённые
телефонные действия нельзя засчитывать по одной кнопке статуса.
"""
metrics: list[Metric] = []
for service in state.notified_services():
marks = [mark for mark in state.status_log if mark.service == service]
statuses = {mark.status for mark in marks}
reports = {report.phase for report in state.phone_reports if report.service == service}
selected_crew = state.crew_assignments.get(service)
selected = selected_crew is not None
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
metrics.append(Metric(
key=key, title=f"{service}: {title}", fact=fact, norm=norm,
ref=GOST_REF if key == "dds_ack" else "памятка «Работа на АРМ-112», статусы реагирования",
passed=passed, weight=weight,
))
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
elapsed = (int((primary.at - state.dispatched_at).total_seconds() * 1000)
if primary and state.dispatched_at else None)
add("dds_primary", "решение по карточке", primary is not None,
primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0)
add("dds_ack", "первичная отметка за 30 с",
elapsed is not None and elapsed <= deadline_ms,
f"{elapsed / 1000:.1f} с" if elapsed is not None else "не отмечено",
f"≤ {deadline_ms // 1000} с")
if primary is None:
continue
add("dds_decision", "профильное реагирование",
primary.status is ServiceStatus.ACCEPTED,
primary.status.value, "профильную заявку принять", 2.0)
if primary.status is ServiceStatus.DECLINED:
continue
add("dds_crew", "ручной выбор бригады", selected,
selected_crew or "бригада не выбрана", "выбрать конкретную бригаду", 1.5)
add("dds_contact", "связь с бригадой", bool(reports),
", ".join(sorted(reports)) or "докладов нет", "получить доклад о выезде", 1.5)
expected = {
"dispatched": ServiceStatus.RESPONDING,
"arrived": ServiceStatus.ARRIVED,
"working": ServiceStatus.WORKING,
}
aligned = all(status in statuses for phase, status in expected.items() if phase in reports)
all_progress = all(phase in reports and status in statuses for phase, status in expected.items())
add("dds_progress", "статусы по докладам", aligned and all_progress,
", ".join(status.value for status in statuses) or "статусов нет",
"выезд, прибытие и работы отмечены после докладов", 2.0)
add("dds_completion", "закрытие работ по докладу",
"completed" in reports and ServiceStatus.COMPLETED in statuses,
"завершено" if ServiceStatus.COMPLETED in statuses else "не завершено",
"доклад о завершении и статус «Работы завершены»")
return metrics

View file

@ -0,0 +1,228 @@
"""Экспорт разбора занятия без обращения к внешним сервисам.
CSV - плоский журнал с пометкой раздела. PDF - читаемый протокол с переносом
длинных реплик и автоматическим разбиением на страницы.
"""
import csv
import io
import os
from html import escape
from pathlib import Path
from app.domain.events import SessionReport
def _cell(value: object) -> str:
"""Excel/LibreOffice не должны исполнять текст курсанта как формулу."""
if value is None:
return ""
result = str(value)
probe = result.lstrip(" \t\r\n\ufeff\u200b")
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
return "'" + result
return result
def to_csv(report: SessionReport) -> bytes:
"""UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки."""
output = io.StringIO(newline="")
writer = csv.writer(output, lineterminator="\r\n")
writer.writerow(["Раздел", "№", "Поле", "Значение", "Дополнительно"])
def row(section: str, index: object, field: str, value: object, detail: object = "") -> None:
writer.writerow([_cell(part) for part in (section, index, field, value, detail)])
row("Занятие", "", "ID", report.session_id)
row("Занятие", "", "Сценарий", report.scenario_id)
row("Занятие", "", "Режим", report.mode.value)
row("Занятие", "", "Попытка", report.attempt)
row("Оценка", "", "Автоматическая", report.score_auto)
row("Оценка", "", "Итоговая", report.score_final)
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
row("Оценка", "", "Причина изменения", report.override_comment)
for number, item in enumerate(report.metrics, 1):
row("Метрики", number, item.title, item.fact, f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; источник: {item.ref or ''}")
for number, item in enumerate(report.findings, 1):
row("Ошибки", number, item.code.value, item.summary, f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}")
for number, item in enumerate(report.competencies, 1):
row("Компетенции", number, item.competency, item.value)
for number, item in enumerate(report.transcript, 1):
row("Транскрипт", number, item.speaker.value, item.text, item.at.isoformat())
for number, item in enumerate(report.reference_questions, 1):
row("Эталонные вопросы", number, item.checklist_id, item.question)
for number, item in enumerate(report.missed_checklist, 1):
row("Пропущенные вопросы", number, "Пункт", item)
for number, item in enumerate(report.hints_used, 1):
row("Подсказки", number, item.checklist_id, item.question, item.at.isoformat())
if report.self_assessment:
row("Самооценка", "", "Пропущено по мнению курсанта", ", ".join(report.self_assessment.missed))
row("Самооценка", "", "Комментарий", report.self_assessment.comment)
if report.self_assessment_diff:
for label, values in (
("Заметил", report.self_assessment_diff.noticed),
("Не заметил", report.self_assessment_diff.unnoticed),
("Отметил ошибочно", report.self_assessment_diff.overcautious),
):
row("Сверка самооценки", "", label, ", ".join(values))
for number, item in enumerate(report.notes, 1):
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
return ("\ufeff" + output.getvalue()).encode("utf-8")
def _font_paths() -> tuple[Path, Path | None]:
"""Находить TTF с кириллицей явно, без молчаливой подмены Helvetica."""
custom = os.environ.get("LCT_PDF_FONT")
if custom:
path = Path(custom)
if not path.is_file():
raise RuntimeError(f"LCT_PDF_FONT не найден: {path}")
return path, None
candidates = (
("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
("/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf", "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf"),
("/System/Library/Fonts/Supplemental/Arial.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf"),
)
for regular, bold in candidates:
if Path(regular).is_file():
return Path(regular), Path(bold) if Path(bold).is_file() else None
raise RuntimeError("Не найден TTF-шрифт с кириллицей; установите DejaVu Sans или задайте LCT_PDF_FONT")
def to_pdf(report: SessionReport) -> bytes:
"""Собрать многостраничный PDF с кириллицей и переносом длинных текстов."""
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import HRFlowable, Paragraph, SimpleDocTemplate, Spacer
regular_path, bold_path = _font_paths()
regular_name = "LCTReportRegular"
bold_name = "LCTReportBold"
if regular_name not in pdfmetrics.getRegisteredFontNames():
regular = TTFont(regular_name, str(regular_path))
if not all(ord(char) in regular.face.charToGlyph for char in "Отчёт курсанта Ёж"):
raise RuntimeError(f"Шрифт не поддерживает кириллицу: {regular_path}")
pdfmetrics.registerFont(regular)
pdfmetrics.registerFont(TTFont(bold_name, str(bold_path or regular_path)))
pdfmetrics.registerFontFamily(regular_name, normal=regular_name, bold=bold_name)
base = ParagraphStyle(
"body", fontName=regular_name, fontSize=9, leading=13,
textColor=colors.HexColor("#18202a"), spaceAfter=5,
allowWidows=0, allowOrphans=0, splitLongWords=1,
)
title_style = ParagraphStyle(
"title", parent=base, fontName=bold_name, fontSize=17, leading=22,
textColor=colors.HexColor("#193b56"), spaceAfter=10,
)
heading = ParagraphStyle(
"heading", parent=base, fontName=bold_name, fontSize=12, leading=17,
textColor=colors.HexColor("#193b56"), spaceBefore=13, spaceAfter=5,
keepWithNext=True,
)
muted = ParagraphStyle("muted", parent=base, fontSize=8, textColor=colors.HexColor("#5d6873"))
def p(value: object, style=base):
# XML-экранирование необходимо: реплики и пометки вводит человек.
safe = escape("" if value is None else str(value)).replace("\n", "<br/>")
return Paragraph(safe or "-", style)
story = [p("Отчёт по учебному занятию", title_style)]
for label, value in (
("Идентификатор", report.session_id),
("Сценарий", report.scenario_id),
("Режим", report.mode.value),
("Попытка", report.attempt),
("Автоматическая оценка", report.score_auto),
("Итоговая оценка", report.score_final),
):
story.append(Paragraph(f"<b>{escape(label)}:</b> {escape(str(value))}", base))
if report.overridden_by:
story.append(Paragraph(f"<b>Оценка изменена:</b> {escape(report.overridden_by)}", base))
if report.override_comment:
story.append(p(f"Причина изменения: {report.override_comment}"))
story.extend([Spacer(1, 7), HRFlowable(width="100%", color=colors.HexColor("#aeb8c2"))])
def section(label: str):
story.append(p(label, heading))
section("Метрики")
if not report.metrics:
story.append(p("Нет данных", muted))
for item in report.metrics:
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'}"))
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm}. {item.ref or ''}", muted))
section("Выявленные ошибки")
if not report.findings:
story.append(p("Ошибок не выявлено", muted))
for item in report.findings:
story.append(p(f"{item.code.value} - {item.summary}"))
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. Источник: {item.ref or 'не указан'}", muted))
section("Компетенции")
for item in report.competencies:
story.append(p(f"{item.competency}: {item.value:g}"))
if not report.competencies:
story.append(p("Нет данных", muted))
section("Транскрипт")
if not report.transcript:
story.append(p("Реплик нет", muted))
for item in report.transcript:
story.append(p(f"{item.at.isoformat()} - {item.speaker.value}", muted))
story.append(p(item.text))
section("Эталонные вопросы")
for item in report.reference_questions:
story.append(p(f"{item.checklist_id}: {item.question}"))
if not report.reference_questions:
story.append(p("Нет данных", muted))
if report.missed_checklist:
section("Пропущенные пункты")
for item in report.missed_checklist:
story.append(p(item))
if report.hints_used:
section("Использованные подсказки")
for item in report.hints_used:
story.append(p(f"{item.at.isoformat()} - {item.checklist_id}: {item.question}"))
if report.self_assessment:
section("Самооценка")
story.append(p("Пропущено по мнению курсанта: " + ", ".join(report.self_assessment.missed)))
story.append(p("Комментарий: " + report.self_assessment.comment))
if report.self_assessment_diff:
for label, items in (
("Заметил", report.self_assessment_diff.noticed),
("Не заметил", report.self_assessment_diff.unnoticed),
("Отметил ошибочно", report.self_assessment_diff.overcautious),
):
story.append(p(f"{label}: {', '.join(items) or '-'}"))
if report.notes:
section("Пометки преподавателя")
for item in report.notes:
story.append(p(f"{item.transcript_ref} - {item.author}: {item.text}"))
output = io.BytesIO()
document = SimpleDocTemplate(
output, pagesize=A4, leftMargin=42, rightMargin=42,
topMargin=45, bottomMargin=44,
title="Отчёт по учебному занятию", author="Учебный симулятор 112",
)
def footer(canvas, doc):
canvas.saveState()
canvas.setFont(regular_name, 8)
canvas.setFillColor(colors.HexColor("#5d6873"))
canvas.drawString(42, 25, "Учебный симулятор 112")
canvas.drawRightString(A4[0] - 42, 25, f"Стр. {doc.page}")
canvas.restoreState()
document.build(story, onFirstPage=footer, onLaterPages=footer)
return output.getvalue()

View file

@ -16,6 +16,8 @@ from app.domain.events import (
SelfAssessment, SelfAssessment,
SelfAssessmentDiff, SelfAssessmentDiff,
SessionReport, SessionReport,
Speaker,
Exercise,
) )
from app.domain.taxonomy import Finding from app.domain.taxonomy import Finding
from app.scenarios.schema import Scenario from app.scenarios.schema import Scenario
@ -43,7 +45,12 @@ def assess_difference(
def build(session_id: UUID, state, scenario: Scenario) -> SessionReport: def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
score = state.score or {} score = state.score or {}
revealed = [fact.id for fact in state.slots.revealed_facts()] if state.slots else None # Без модели эмбеддингов распознать *какие* факты прозвучали нельзя.
# Но если оператор не произнёс ни слова, все обязательные вопросы точно
# пропущены — это доказуемый частный случай и он нужен офлайн-разбору.
revealed = ([fact.id for fact in state.slots.revealed_facts()] if state.slots else
[] if not any(entry.speaker is Speaker.OPERATOR for entry in state.transcript)
else None)
reference = build_reference(scenario) reference = build_reference(scenario)
self_assessment = None self_assessment = None
@ -61,7 +68,7 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
step.checklist_id step.checklist_id
for step in reference.steps for step in reference.steps
if step.required and revealed is not None and step.fact_id not in revealed if step.required and revealed is not None and step.fact_id not in revealed
] ] if state.exercise is not Exercise.CARD else []
return SessionReport( return SessionReport(
session_id=session_id, session_id=session_id,
@ -77,7 +84,7 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
reference_questions=[ reference_questions=[
HintShown(checklist_id=step.checklist_id, question=step.question) HintShown(checklist_id=step.checklist_id, question=step.question)
for step in reference.steps for step in reference.steps
], ] if state.exercise is not Exercise.CARD else [],
missed_checklist=missed_checklist, missed_checklist=missed_checklist,
hints_used=[ hints_used=[
HintUsage(checklist_id=checklist_id, question=questions.get(checklist_id, ""), at=at) HintUsage(checklist_id=checklist_id, question=questions.get(checklist_id, ""), at=at)

View file

@ -21,6 +21,13 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
"address": (ErrorCode.E5, Competency.CARD), "address": (ErrorCode.E5, Competency.CARD),
"victims_count": (ErrorCode.E5, Competency.CARD), "victims_count": (ErrorCode.E5, Competency.CARD),
"required_fields": (ErrorCode.E5, Competency.CARD), "required_fields": (ErrorCode.E5, Competency.CARD),
"dds_primary": (ErrorCode.D1, Competency.CARD),
"dds_ack": (ErrorCode.D1, Competency.NORMS),
"dds_decision": (ErrorCode.D3, Competency.ROUTING),
"dds_crew": (ErrorCode.D2, Competency.ROUTING),
"dds_contact": (ErrorCode.D6, Competency.COMMUNICATION),
"dds_progress": (ErrorCode.D6, Competency.CARD),
"dds_completion": (ErrorCode.D6, Competency.CARD),
} }
#: Вес метрики в детерминированной оценке. #: Вес метрики в детерминированной оценке.

View file

@ -10,12 +10,13 @@
import logging import logging
from uuid import UUID from uuid import UUID
from app.domain.events import ScoreReady from app.domain.events import Exercise, ScoreReady
from app.domain.timers import NORMATIVES, TimerCode from app.domain.timers import NORMATIVES, TimerCode
from app.scenarios import store from app.scenarios import store
from app.scoring.competency import radar from app.scoring.competency import radar
from app.scoring.dispatcher import evaluate_dispatcher from app.scoring.card import evaluate_card
from app.scoring.gost import evaluate from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
from app.scoring.gost import GostResult, evaluate
from app.session.hub import hub from app.session.hub import hub
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -27,6 +28,13 @@ async def finish(session_id: UUID, state) -> None:
if scenario is None: if scenario is None:
return return
if state.exercise is Exercise.CARD:
result = evaluate_card(scenario, state.dispatched_card or state.kio)
elif state.exercise is Exercise.DDS:
# В готовой карточке не было входящего звонка, опроса и действий
# оператора 112. Ни одна E-метрика здесь не применима.
result = GostResult()
else:
result = evaluate( result = evaluate(
scenario=scenario, scenario=scenario,
kio=state.kio, kio=state.kio,
@ -41,6 +49,7 @@ async def finish(session_id: UUID, state) -> None:
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут # Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии # рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
# участвуют оба (docs/spec/DATASET.md#статусы-реагирования). # участвуют оба (docs/spec/DATASET.md#статусы-реагирования).
if state.dispatched_card is not None and state.exercise is not Exercise.CARD:
dispatcher_findings = evaluate_dispatcher( dispatcher_findings = evaluate_dispatcher(
entries=state.status_log, entries=state.status_log,
services=state.notified_services(), services=state.notified_services(),
@ -48,6 +57,7 @@ async def finish(session_id: UUID, state) -> None:
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK), elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
) )
result.findings.extend(dispatcher_findings) result.findings.extend(dispatcher_findings)
result.metrics.extend(dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms))
# Сводка числами: по ней считается дельта между попытками в профиле. # Сводка числами: по ней считается дельта между попытками в профиле.
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам. # Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
@ -84,5 +94,5 @@ async def finish(session_id: UUID, state) -> None:
async def release_score(session_id: UUID, state) -> None: async def release_score(session_id: UUID, state) -> None:
"""Отдать оценку курсанту, когда самооценка сдана.""" """Отдать оценку курсанту, когда самооценка сдана."""
if state.score is not None and state.self_assessed: if state.score is not None and (state.self_assessed or state.exercise is Exercise.CARD):
hub.to_trainee(session_id, ScoreReady(session_id=session_id)) hub.to_trainee(session_id, ScoreReady(session_id=session_id))

View file

@ -28,8 +28,9 @@ class Journal(Protocol):
но молчать об ошибке записи нельзя.""" но молчать об ошибке записи нельзя."""
async def start_lesson( async def start_lesson(
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
) -> int: ... trainee_id: UUID | None = None,
) -> tuple[int, UUID | None]: ...
async def utterance(self, session_id: UUID, entry) -> None: ... async def utterance(self, session_id: UUID, entry) -> None: ...
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ... async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ... async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...

View file

@ -31,9 +31,10 @@ class DbJournal:
log.exception("журнал: запись не удалась") log.exception("журнал: запись не удалась")
async def start_lesson( async def start_lesson(
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
) -> int: trainee_id: UUID | None = None,
"""Завести сессию в журнале и вернуть номер попытки. ) -> tuple[int, UUID | None]:
"""Завести сессию в журнале и вернуть номер попытки и ID курсанта.
Если база недоступна, занятие всё равно идёт: номер попытки Если база недоступна, занятие всё равно идёт: номер попытки
деградирует до первого, и это видно в логе. деградирует до первого, и это видно в логе.
@ -46,11 +47,12 @@ class DbJournal:
scenario_id=scenario_id, scenario_id=scenario_id,
mode=mode, mode=mode,
trainee_name=trainee_name, trainee_name=trainee_name,
trainee_id=trainee_id,
) )
return row.attempt return row.attempt, row.trainee_id
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
log.exception("журнал: сессию завести не удалось") log.exception("журнал: сессию завести не удалось")
return 1 return 1, trainee_id
async def utterance(self, session_id: UUID, entry) -> None: async def utterance(self, session_id: UUID, entry) -> None:
await self._write( await self._write(

View file

@ -12,6 +12,7 @@ from uuid import UUID
from app.domain.events import ( from app.domain.events import (
CallEndReason, CallEndReason,
Exercise,
Mood, Mood,
SessionMode, SessionMode,
SessionSnapshot, SessionSnapshot,
@ -27,12 +28,13 @@ from app.domain.statuses import (
ServiceStatus, ServiceStatus,
StationSnapshot, StationSnapshot,
StatusEntry, StatusEntry,
PhoneReportRecord,
card_status, card_status,
check, check,
current, current,
) )
from app.domain.timers import NORMATIVES, TimerCode from app.domain.timers import NORMATIVES, TimerCode
from app.domain.kio import KIO, apply_patch from app.domain.kio import KIO, ResponseStatus, apply_patch
from app.session.timers import SessionTimers from app.session.timers import SessionTimers
@ -48,6 +50,7 @@ class SessionState:
scenario_title: str scenario_title: str
level: str level: str
mode: SessionMode mode: SessionMode
exercise: Exercise = Exercise.CALL
required_fields: list[str] = field(default_factory=list) required_fields: list[str] = field(default_factory=list)
trainee_name: str | None = None trainee_name: str | None = None
#: Чьё это занятие. Проставляется при запуске, когда курсант известен #: Чьё это занятие. Проставляется при запуске, когда курсант известен
@ -96,6 +99,9 @@ class SessionState:
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list) dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
#: Отметки служб по списку оповещения — предмет второго режима занятия. #: Отметки служб по списку оповещения — предмет второго режима занятия.
status_log: list[StatusEntry] = field(default_factory=list) status_log: list[StatusEntry] = field(default_factory=list)
crew_selected: str | None = None
crew_assignments: dict[str, str] = field(default_factory=dict)
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
#: Чем курсант закрыл вызов, если не карточкой (lct-36). #: Чем курсант закрыл вызов, если не карточкой (lct-36).
resolved_outcome: str | None = None resolved_outcome: str | None = None
resolve_comment: str = "" resolve_comment: str = ""
@ -119,10 +125,12 @@ class SessionState:
self.kio = apply_patch(self.kio, fields) self.kio = apply_patch(self.kio, fields)
return self.kio return self.kio
def dispatch(self, service: str) -> KIO: def dispatch(self, service: str | None = None) -> KIO:
"""Карточка замораживается снимком: оператор не должен иметь """Карточка замораживается снимком: оператор не должен иметь
возможности дописать задним числом поле, которое забыл.""" возможности дописать задним числом поле, которое забыл."""
self.kio = apply_patch(self.kio, {"dds": service, "response_status": "transferred"}) if service is not None:
self.kio = apply_patch(self.kio, {"dds": service})
self.kio.response_status = ResponseStatus.TRANSFERRED
self.dispatched_card = self.kio.model_copy(deep=True) self.dispatched_card = self.kio.model_copy(deep=True)
self.dispatched_at = now_utc() self.dispatched_at = now_utc()
return self.dispatched_card return self.dispatched_card
@ -133,6 +141,14 @@ class SessionState:
card = self.dispatched_card or self.kio card = self.dispatched_card or self.kio
return list(card.notify) return list(card.notify)
def crew_options(self) -> list[str]:
return [f"{service} — бригада №{number}"
for service in self.notified_services() for number in (1, 2)]
def crew_service(self, crew: str) -> str | None:
return next((service for service in self.notified_services()
if crew in (f"{service} — бригада №1", f"{service} — бригада №2")), None)
def set_service_status( def set_service_status(
self, service: str, status: ServiceStatus, comment: str = "", author: str = "" self, service: str, status: ServiceStatus, comment: str = "", author: str = ""
) -> StatusEntry: ) -> StatusEntry:
@ -161,6 +177,9 @@ class SessionState:
available={service: list(NEXT[status]) for service, status in statuses.items()}, available={service: list(NEXT[status]) for service, status in statuses.items()},
card=card_status(self.status_log, services, notify_deadline_passed=overdue), card=card_status(self.status_log, services, notify_deadline_passed=overdue),
log=list(self.status_log), log=list(self.status_log),
crew_options=self.crew_options(),
crew_selected=self.crew_selected,
phone_reports=list(self.phone_reports),
) )
def card_received_event(self): def card_received_event(self):
@ -169,7 +188,8 @@ class SessionState:
return CardReceived( return CardReceived(
card=self.dispatched_card, card=self.dispatched_card,
from_operator=self.trainee_name or "оператор 112", from_operator=("учебный сценарий" if self.exercise is Exercise.DDS
else self.trainee_name or "оператор 112"),
at=self.dispatched_at or now_utc(), at=self.dispatched_at or now_utc(),
) )
@ -186,6 +206,7 @@ class SessionState:
scenario_title=self.scenario_title, scenario_title=self.scenario_title,
level=self.level, level=self.level,
mode=self.mode, mode=self.mode,
exercise=self.exercise,
trainee_name=self.trainee_name, trainee_name=self.trainee_name,
started_at=self.started_at, started_at=self.started_at,
kio=self.kio, kio=self.kio,

View file

@ -13,6 +13,7 @@ dependencies = [
"asyncpg>=0.29", "asyncpg>=0.29",
"httpx>=0.27", "httpx>=0.27",
"pyyaml>=6.0", "pyyaml>=6.0",
"reportlab>=4.0",
"structlog>=24.1", "structlog>=24.1",
# Эмбеддинги для слот-автомата: матчинг вопроса к чек-листу. Нужны ядру, # Эмбеддинги для слот-автомата: матчинг вопроса к чек-листу. Нужны ядру,
# а не только голосу, поэтому не в группе voice. # а не только голосу, поэтому не в группе voice.

View file

@ -0,0 +1,125 @@
"""Текстовая вводная 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:
test_client.post("/api/auth/dev-token")
hub.journal = None
yield test_client
def wait_for(predicate, timeout=3):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
value = predicate()
if value:
return value
time.sleep(0.02)
raise AssertionError("не дождались обновления состояния")
def read_until(socket, wanted):
for _ in range(20):
event = socket.receive_json()
if event["type"] == wanted:
return event
raise AssertionError(f"событие {wanted} не пришло")
def start(client):
session_id = uuid4()
context = client.websocket_connect(f"/ws/control/{session_id}")
control = context.__enter__()
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Иванов", "mode": "training", "exercise": "card"})
wait_for(lambda: hub.get(session_id))
return session_id, context
def test_card_briefing_is_text_only_and_replayed_on_late_join(client):
session_id, control = start(client)
try:
state = hub.get(session_id)
assert state.exercise.value == "card"
assert state.dispatched_card is None
assert state.slots is None and state.voice is None
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
briefing = read_until(trainee, "card.briefing")
assert briefing["scenario_id"] == "fire-apartment-l2"
assert "Помогите" in briefing["text"]
assert "горит балкон" in briefing["text"]
assert "ground_truth" not in briefing
assert briefing["card"]["address"] is None
assert "address" in briefing["required_fields"]
trainee.send_bytes(b"\0" * 640)
assert read_until(trainee, "error")["code"] == "unsupported_event"
with client.websocket_connect(f"/ws/call/{session_id}") as late:
assert read_until(late, "card.briefing")["text"] == briefing["text"]
finally:
control.__exit__(None, None, None)
def test_correct_card_scores_100_without_call_or_dds_metrics(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
briefing = read_until(trainee, "card.briefing")
assert "dds" not in briefing["required_fields"]
trainee.send_json({"type": "kio.patch", "fields": {
"address": "улица Ленина, 14", "floor": "5", "incident_type": "fire",
"victims_count": 2, "description": "горит балкон",
"signs": ["жилой дом", "балкон", "открытое пламя"],
}})
wait_for(lambda: hub.get(session_id).kio.incident_code)
trainee.send_json({"type": "card.submit"})
assert read_until(trainee, "call.ended")["reason"] == "complete"
assert read_until(trainee, "score.ready")["session_id"] == str(session_id)
received = read_until(station, "card.received")
assert received["card"]["address"] == "улица Ленина, 14"
read_until(station, "station.state")
state = hub.get(session_id)
assert state.dispatched_card is not None
assert state.score["score_auto"] == 100.0
assert not state.score["findings"]
assert {item["key"] for item in state.score["metrics"]} == {
"incident_signs", "address", "victims_count", "required_fields"
}
with client.websocket_connect(f"/ws/call/{session_id}") as late:
assert read_until(late, "card.briefing")["card"]["address"] == "улица Ленина, 14"
assert read_until(late, "call.ended")["reason"] == "complete"
assert read_until(late, "score.ready")["session_id"] == str(session_id)
finally:
control.__exit__(None, None, None)
def test_incomplete_card_has_only_card_findings(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
read_until(trainee, "card.briefing")
trainee.send_json({"type": "card.submit"})
read_until(trainee, "score.ready")
trainee.send_json({"type": "kio.patch", "fields": {"address": "после сдачи"}})
assert read_until(trainee, "error")["code"] == "unsupported_event"
state = hub.get(session_id)
assert state.kio.address is None
assert state.score["score_auto"] < 100
assert {item["code"] for item in state.score["findings"]} <= {"E2", "E5"}
assert all(item["key"] not in {"answer_time", "interview_time", "dds_ack"}
for item in state.score["metrics"])
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["missed_checklist"] == []
assert report["reference_questions"] == []
finally:
control.__exit__(None, None, None)

View file

@ -0,0 +1,141 @@
"""Готовая карточка → учебный звонок бригаде → числовая оценка ДДС."""
import time
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.domain.timers import TimerCode
from app.session.hub import hub
@pytest.fixture
def client():
with TestClient(app) as test_client:
test_client.post("/api/auth/dev-token")
hub.journal = None
yield test_client
def wait_for(predicate, timeout=3):
end = time.monotonic() + timeout
while time.monotonic() < end:
value = predicate()
if value:
return value
time.sleep(0.02)
raise AssertionError("состояние не обновилось")
def read_until(socket, wanted):
for _ in range(20):
event = socket.receive_json()
if event["type"] == wanted:
return event
raise AssertionError(f"событие {wanted} не пришло")
def start(client, exercise="dds"):
session_id = uuid4()
context = client.websocket_connect(f"/ws/control/{session_id}")
control = context.__enter__()
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Иванов", "mode": "training", "exercise": exercise})
wait_for(lambda: hub.get(session_id))
return session_id, context
def test_dds_starts_with_prepared_card_without_call(client):
session_id, control = start(client)
try:
state = hub.get(session_id)
assert state.exercise.value == "dds"
assert state.dispatched_card is not None
assert state.kio.address == "улица Ленина, 14"
assert state.kio.notify
assert state.timers.measured_ms(TimerCode.ANSWER) is None
with client.websocket_connect(f"/ws/station/{session_id}") as station:
card = read_until(station, "card.received")
assert card["from_operator"] == "учебный сценарий"
assert card["card"]["address"] == "улица Ленина, 14"
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["crew_options"]
finally:
control.__exit__(None, None, None)
def test_dds_call_reports_gate_status_and_affect_numeric_score(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
crew = next(option for option in snapshot["crew_options"] if option.startswith(service + " — "))
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": service, "status": "responding"})
assert "доклада" in read_until(station, "error")["message"]
station.send_json({"type": "crew.select", "crew": crew})
selected = read_until(station, "station.state")["snapshot"]
assert selected["crew_selected"] == crew
station.send_json({"type": "phone.dial"})
report = read_until(station, "phone.report")
assert report["phase"] == "dispatched" and report["crew"] == crew
assert read_until(station, "station.state")["snapshot"]["phone_reports"]
station.send_json({"type": "card.status", "service": service, "status": "responding"})
read_until(station, "station.state")
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
keys = {metric["key"] for metric in score["metrics"]}
assert "dds_primary" in keys and "dds_contact" in keys
assert "answer_time" not in keys and "interview_time" not in keys
assert 0 < score["score_auto"] < 100
assert all(not finding["code"].startswith("E") for finding in score["findings"])
finally:
control.__exit__(None, None, None)
def test_default_exercise_remains_call(client):
session_id = uuid4()
with client.websocket_connect(f"/ws/control/{session_id}") as control:
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Иванов", "mode": "training"})
state = wait_for(lambda: hub.get(session_id))
assert state.exercise.value == "call"
assert state.dispatched_card is None
def test_complete_dds_workflow_scores_without_call_penalties(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
for service in snapshot["services"]:
crew = next(option for option in snapshot["crew_options"]
if option.startswith(service + " — "))
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
read_until(station, "station.state")
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
for expected_phase, status in (
("dispatched", "responding"), ("arrived", "arrived"),
("working", "working"), ("completed", "completed"),
):
station.send_json({"type": "phone.dial"})
assert read_until(station, "phone.report")["phase"] == expected_phase
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": service, "status": status})
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["card"] == "completed"
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
assert score["score_auto"] == 100.0
assert not score["findings"]
assert all(metric["key"].startswith("dds_") for metric in score["metrics"])
finally:
control.__exit__(None, None, None)

View file

@ -120,3 +120,14 @@ def test_gasified_object_brings_mosgaz():
KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True)) KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True))
) )
assert "МОСГАЗ" in card.notify assert "МОСГАЗ" in card.notify
def test_removed_modifier_recalculates_notify_without_stale_service():
from app.domain.kio import KIO, FireDetails, apply_patch, derive_incident
card = derive_incident(
KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True))
)
assert "МОСГАЗ" in card.notify
updated = apply_patch(card, {"fire.gasified": False})
assert "МОСГАЗ" not in updated.notify

View file

@ -0,0 +1,121 @@
"""Экспорт разбора: содержимое, безопасность CSV, кириллица и доступ."""
import csv
import io
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.api.auth import Principal
from app.api.http import sessions
from app.domain.events import SessionReport
from app.domain.roles import Role
from app.main import app
from app.scoring.export import _cell, to_csv, to_pdf
def sample_report(*, long: bool = False) -> SessionReport:
at = datetime(2026, 9, 21, 12, 0, tzinfo=timezone.utc)
long_text = "Заявитель сообщает о дыме в учебном помещении. " * (240 if long else 1)
return SessionReport.model_validate({
"session_id": str(uuid4()),
"scenario_id": "=1+1",
"mode": "training",
"attempt": 2,
"transcript": [
{"ref": f"u{i}", "speaker": "caller", "text": long_text if i == 0 else "<вопрос> \t=cmd", "at": at}
for i in range(18 if long else 2)
],
"findings": [{
"code": "E1", "source": "slots", "summary": "+HYPERLINK(\"x\")",
"fact": "Адрес не уточнён", "norm": "Уточнить адрес происшествия",
"ref": "ГОСТ", "at": at,
}],
"metrics": [{
"key": "address", "title": "Адрес", "fact": "Не назван",
"norm": "Адрес должен быть уточнён", "passed": False,
}],
"competencies": [{"competency": "interview", "value": 65}],
"reference_questions": [{"checklist_id": "q_address", "question": "Назовите адрес?"}],
"missed_checklist": ["q_address"],
"hints_used": [{"checklist_id": "q_address", "question": "Уточните адрес", "at": at}],
"self_assessment": {"missed": ["q_address"], "comment": "@SUM(1,2)", "submitted_at": at},
"self_assessment_diff": {"noticed": ["q_address"], "unnoticed": [], "overcautious": []},
"notes": [{"transcript_ref": "u0", "text": "Внимательнее к адресу", "author": "Преподаватель"}],
"score_auto": 70,
"score_final": 75,
"overridden_by": "Преподаватель",
"override_comment": "Ручная корректировка",
})
def test_csv_contains_sections_and_blocks_formula_injection():
rows = list(csv.reader(io.StringIO(to_csv(sample_report()).decode("utf-8-sig"))))
assert rows[0] == ["Раздел", "№", "Поле", "Значение", "Дополнительно"]
assert any(row[0] == "Транскрипт" and row[3] == "<вопрос> \t=cmd" for row in rows)
assert any(row[0] == "Занятие" and row[3] == "'=1+1" for row in rows)
assert any(row[0] == "Ошибки" and row[3] == "'+HYPERLINK(\"x\")" for row in rows)
assert any(row[0] == "Самооценка" and row[3] == "'@SUM(1,2)" for row in rows)
assert _cell(" =cmd") == "' =cmd"
assert _cell("\tОбычный текст") == "'\tОбычный текст"
def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
data = to_pdf(sample_report(long=True))
assert data.startswith(b"%PDF-")
path = tmp_path / "report.pdf"
path.write_bytes(data)
# Poppler даёт проверку извлекаемого текста, не только сигнатуры файла.
import shutil
import subprocess
if shutil.which("pdftotext") and shutil.which("pdfinfo"):
info = subprocess.check_output(["pdfinfo", str(path)], text=True)
pages = int(next(line.split(":", 1)[1].strip() for line in info.splitlines() if line.startswith("Pages:")))
assert pages >= 2
extracted = subprocess.check_output(["pdftotext", str(path), "-"], text=True)
assert "Отчёт по учебному занятию" in extracted
assert "Адрес должен быть уточнён" in extracted
assert "Заявитель сообщает о дыме" in extracted
@pytest.fixture
def client(monkeypatch):
report = sample_report()
state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4())
monkeypatch.setattr(sessions, "_live", lambda session_id: (state, object()))
monkeypatch.setattr(sessions, "build_report", lambda session_id, state, scenario: report)
with TestClient(app) as test_client:
test_client.post("/api/auth/dev-token")
yield test_client, state, report
def test_export_routes_return_downloads_with_json_report_rights(client):
browser, state, report = client
csv_response = browser.get(f"/api/sessions/{report.session_id}/report.csv")
assert csv_response.status_code == 200
assert csv_response.headers["content-type"].startswith("text/csv")
assert csv_response.content.startswith(b"\xef\xbb\xbf")
assert "attachment" in csv_response.headers["content-disposition"]
pdf_response = browser.get(f"/api/sessions/{report.session_id}/report.pdf")
assert pdf_response.status_code == 200
assert pdf_response.headers["content-type"] == "application/pdf"
assert pdf_response.content.startswith(b"%PDF-")
state.score = None
assert browser.get(f"/api/sessions/{report.session_id}/report.csv").status_code == 409
assert browser.get(f"/api/sessions/{report.session_id}/report.pdf").status_code == 409
def test_trainee_cannot_export_another_persons_report(client, monkeypatch):
browser, state, report = client
monkeypatch.setattr(
sessions, "require",
lambda request: Principal(login="trainee", full_name="Учебный", role=Role.TRAINEE, trainee_id=uuid4()),
)
for suffix in ("csv", "pdf"):
assert browser.get(f"/api/sessions/{report.session_id}/report.{suffix}").status_code == 403

View file

@ -0,0 +1,140 @@
"""Преподавательский цикл: шаблон → черновик → проверка → утверждение."""
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.api.http import scenarios as scenarios_api
from app.db.base import get_session
from app.main import app
from app.scenarios import store
from app.scenarios.editor import merge_patch, template_copy, validate
from app.scenarios.loader import ScenarioError, load_file
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
class FakeSession:
"""Минимальная транзакционная замена БД для проверки HTTP без Postgres."""
def __init__(self):
self.rows = {}
def add(self, row):
self.rows[row.id] = row
async def get(self, model, key):
return self.rows.get(key)
async def commit(self):
pass
async def scalars(self, query):
return [row for row in self.rows.values() if row.status == "published"]
@pytest.fixture
def client(monkeypatch):
db = FakeSession()
async def no_audit(*args, **kwargs):
pass
async def no_restore(*args, **kwargs):
return 0
app.dependency_overrides[get_session] = lambda: db
monkeypatch.setattr(scenarios_api, "audit", no_audit)
monkeypatch.setattr(store, "restore_published", no_restore)
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
def test_template_copy_is_local_independent_and_valid():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
body = template_copy(source, "draft-example")
assert body["id"] == "draft-example"
assert body["extends"] is None
assert body["ticket"] is None
assert body["ground_truth"].keys() == {"address", "victims"}
assert validate(body).ground_truth.dds == source.ground_truth.dds
assert source.id != body["id"] # оригинал не меняется
def test_editor_rejects_derived_truth_and_missing_fact():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
body = template_copy(source, "draft-example")
with pytest.raises(ScenarioError, match="ground_truth"):
merge_patch(body, {"ground_truth": {"dds": "02"}})
with pytest.raises(ScenarioError, match="id сценария"):
merge_patch(body, {"id": "other"})
broken = merge_patch(body, {"facts": []})
with pytest.raises(ScenarioError, match="нужны факты"):
validate(broken)
def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
source = next(s for s in store.all_scenarios() if s.id == "fire-apartment-l2")
assert client.post("/api/auth/dev-token").status_code == 200
response = client.post(
"/api/scenarios/drafts/from-template",
json={"source_id": source.id, "title": "Учебная копия"},
)
assert response.status_code == 201, response.text
draft = response.json()
draft_id = draft["id"]
assert draft["generation"] == "template_copy"
assert draft["status"] == "draft"
assert store.get(draft_id) is None
assert client.get(f"/api/scenarios/{draft_id}").status_code == 404
changed = client.patch(
f"/api/scenarios/drafts/{draft_id}",
json={"first_line": "Соседи! В доме дым!"},
)
assert changed.status_code == 200, changed.text
assert changed.json()["body"]["first_line"] == "Соседи! В доме дым!"
check = client.post(f"/api/scenarios/drafts/{draft_id}/validate")
assert check.status_code == 200 and check.json()["valid"]
approved = client.post(f"/api/scenarios/drafts/{draft_id}/approve")
assert approved.status_code == 200, approved.text
assert approved.json()["status"] == "published"
assert store.get(draft_id).first_line == "Соседи! В доме дым!"
assert client.get(f"/api/scenarios/{draft_id}").status_code == 200
assert client.patch(f"/api/scenarios/drafts/{draft_id}", json={"title": "нет"}).status_code == 404
def test_invalid_draft_cannot_be_approved(client):
client.post("/api/auth/dev-token")
response = client.post(
"/api/scenarios/drafts/from-template",
json={"source_id": "fire-apartment-l2"},
)
draft_id = response.json()["id"]
assert client.patch(f"/api/scenarios/drafts/{draft_id}", json={"facts": []}).status_code == 200
check = client.post(f"/api/scenarios/drafts/{draft_id}/validate")
assert check.json()["valid"] is False
assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 422
assert store.get(draft_id) is None
def test_unauthenticated_user_cannot_manage_drafts(client):
response = client.post(
"/api/scenarios/drafts/from-template", json={"source_id": "fire-apartment-l2"}
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_approved_scenario_restores_from_local_database():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
db = FakeSession()
row = await store.create_draft(db, source=source)
approved = await store.approve_draft(db, row)
store.set_library([source]) # имитация нового процесса после чтения YAML
assert store.get(approved.id) is None
assert await store.restore_published(db) == 1
assert store.get(approved.id).title == approved.title
assert await store.restore_published(db) == 0

View file

@ -0,0 +1,64 @@
"""HTTP-ссылки на занятие не дают курсанту чужую карточку или чек-лист."""
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import HTTPException, Request
from app.api.auth import Principal
from app.api.http import sessions
from app.domain.events import Exercise
from app.domain.roles import Role
def request() -> Request:
return Request({"type": "http", "method": "GET", "path": "/", "headers": []})
@pytest.mark.asyncio
async def test_trainee_cannot_read_foreign_session(monkeypatch):
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
monkeypatch.setattr(sessions, "require", lambda _: who)
async def row(_db, _session_id):
return SimpleNamespace(trainee_id=uuid4())
monkeypatch.setattr(sessions.repo, "get_session", row)
with pytest.raises(HTTPException) as error:
await sessions.read(uuid4(), request(), db=object())
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_trainee_cannot_read_foreign_checklist(monkeypatch):
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
monkeypatch.setattr(sessions, "require", lambda _: who)
monkeypatch.setattr(sessions.hub, "get", lambda _: SimpleNamespace(trainee_id=uuid4(), ended=True))
with pytest.raises(HTTPException) as error:
await sessions.checklist(uuid4(), request())
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_trainee_cannot_bypass_self_assessment_via_report(monkeypatch):
trainee_id = uuid4()
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=trainee_id)
monkeypatch.setattr(sessions, "require", lambda _: who)
state = SimpleNamespace(
trainee_id=trainee_id, exercise=Exercise.CALL, self_assessed=False,
score={"score_auto": 100},
)
monkeypatch.setattr(sessions, "_live", lambda _: (state, object()))
with pytest.raises(HTTPException) as error:
await sessions.report(uuid4(), request())
assert error.value.status_code == 409
@pytest.mark.asyncio
async def test_trainee_without_profile_cannot_list_everyones_sessions(monkeypatch):
who = Principal(login="unlinked", full_name="Курсант", role=Role.TRAINEE, trainee_id=None)
monkeypatch.setattr(sessions, "require", lambda _: who)
with pytest.raises(HTTPException) as error:
await sessions.listing(request(), db=object())
assert error.value.status_code == 403

View file

@ -117,6 +117,62 @@ def test_interview_timer_starts_on_answer_and_stops_on_dispatch(client):
assert state.dispatched_card is not None, "карточка не заморожена снимком" assert state.dispatched_card is not None, "карточка не заморожена снимком"
def test_ekp_dispatch_needs_no_manual_dds_and_reaches_station(client):
with lesson(client) as (session_id, _):
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:
read_until(trainee, "call.incoming")
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "kio.patch", "fields": {
"signs": ["жилой дом", "балкон", "открытое пламя"]
}})
auto = next(
event for event in (trainee.receive_json() for _ in range(8))
if event["type"] == "kio.patch" and event["source"] == "auto"
)
assert auto["fields"]["incident_code"]
assert auto["fields"]["notify"]
trainee.send_json({"type": "dds.dispatch"})
received = read_until(station, "card.received")
assert received["card"]["notify"]
assert received["card"]["dds"] is None
assert received["card"]["response_status"] == "transferred"
assert state.dispatched_card is not None
def test_legacy_dispatch_without_routing_needs_manual_dds(client):
with lesson(client) as (session_id, _):
state = hub.get(session_id)
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
read_until(trainee, "call.incoming")
trainee.send_json({"type": "dds.dispatch"})
error = read_until(trainee, "error")
assert "ДДС" in error["message"]
assert state.dispatched_card is None
trainee.send_json({"type": "dds.dispatch", "service": "01"})
wait_for(lambda: state.dispatched_card)
assert state.kio.dds.value == "01"
def test_late_call_join_replays_incoming_and_current_kio(client):
with lesson(client) as (session_id, _):
with client.websocket_connect(f"/ws/call/{session_id}") as first:
read_until(first, "call.incoming")
first.send_json({"type": "kio.patch", "fields": {
"address": "улица Ленина, 14",
"signs": ["жилой дом", "балкон", "открытое пламя"],
}})
wait_for(lambda: hub.get(session_id).kio.incident_code)
with client.websocket_connect(f"/ws/call/{session_id}") as late:
incoming = read_until(late, "call.incoming")
assert incoming["scenario_id"] == "fire-apartment-l2"
assert "dds" not in incoming["required_fields"]
card = read_until(late, "kio.patch")
assert card["fields"]["address"] == "улица Ленина, 14"
assert card["fields"]["notify"]
def test_card_edit_reaches_observer(client): def test_card_edit_reaches_observer(client):
with lesson(client) as (session_id, _): with lesson(client) as (session_id, _):
with client.websocket_connect(f"/ws/observe/{session_id}") as observer: with client.websocket_connect(f"/ws/observe/{session_id}") as observer:

View file

@ -0,0 +1,63 @@
"""Знание URL занятия не даёт курсанту доступ к чужому АРМ."""
import importlib
import time
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.api.auth import Principal
from app.domain.roles import Role
from app.main import app
from app.session.hub import hub
@pytest.fixture
def client():
with TestClient(app) as test_client:
test_client.post("/api/auth/dev-token")
hub.journal = None
yield test_client
def test_trainee_cannot_open_foreign_or_unassigned_call_or_station(client, monkeypatch):
session_id = uuid4()
with client.websocket_connect(f"/ws/control/{session_id}") as control:
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Назначенный", "mode": "training"})
deadline = time.monotonic() + 3
while hub.get(session_id) is None and time.monotonic() < deadline:
time.sleep(0.02)
state = hub.get(session_id)
assert state is not None
owner_id, foreign_id = uuid4(), uuid4()
modules = {
"call": importlib.import_module("app.api.ws.call"),
"station": importlib.import_module("app.api.ws.station"),
}
for assigned in (owner_id, None):
state.trainee_id = assigned
for role_name, module in modules.items():
who = Principal(login="foreign", full_name="Чужой",
role=Role.TRAINEE, trainee_id=foreign_id)
monkeypatch.setattr(module, "principal_of", lambda _ws, user=who: user)
with client.websocket_connect(f"/ws/{role_name}/{session_id}") as socket:
event = socket.receive_json()
assert event["type"] == "error" and event["code"] == "forbidden"
state.trainee_id = owner_id
for role_name, module in modules.items():
who = Principal(login="owner", full_name="Назначенный",
role=Role.TRAINEE, trainee_id=owner_id)
monkeypatch.setattr(module, "principal_of", lambda _ws, user=who: user)
with client.websocket_connect(f"/ws/{role_name}/{session_id}") as socket:
socket.send_json({"type": "unknown"})
for _ in range(8):
event = socket.receive_json()
if event["type"] == "error":
assert event["code"] == "unsupported_event"
break
else:
raise AssertionError("владелец занятия не допущен")

106
backend/uv.lock generated
View file

@ -137,6 +137,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
] ]
[[package]]
name = "charset-normalizer"
version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" },
{ url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" },
{ url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" },
{ url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" },
{ url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" },
{ url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" },
{ url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" },
{ url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" },
{ url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" },
{ url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" },
{ url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" },
{ url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" },
{ url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" },
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.5.0" version = "8.5.0"
@ -365,6 +406,7 @@ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "reportlab" },
{ name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlalchemy", extra = ["asyncio"] },
{ name = "structlog" }, { name = "structlog" },
{ name = "tokenizers" }, { name = "tokenizers" },
@ -400,6 +442,7 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
{ name = "pyyaml", specifier = ">=6.0" }, { name = "pyyaml", specifier = ">=6.0" },
{ name = "reportlab", specifier = ">=4.0" },
{ name = "scipy", marker = "extra == 'voice'", specifier = ">=1.11" }, { name = "scipy", marker = "extra == 'voice'", specifier = ">=1.11" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
{ name = "structlog", specifier = ">=24.1" }, { name = "structlog", specifier = ">=24.1" },
@ -535,6 +578,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
] ]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
{ url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
{ url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
{ url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
{ url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
{ url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
{ url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
{ url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
{ url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
{ url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@ -699,6 +764,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
] ]
[[package]]
name = "reportlab"
version = "5.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "pillow" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4a/51/dbe28534ae12c852f61be91f039f343305fd1f34f1c66b8de75afae7a525/reportlab-5.0.1.tar.gz", hash = "sha256:ebd13154be1c8515e665de70bd2d303ae9ddc3ef47e44afd5116441ca0283a26", size = 3945711, upload-time = "2026-08-20T13:48:16.461Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/cb/dacbc268cb68d0428ea2cbd85266195a9ab3e677449589ddae59bd7542ac/reportlab-5.0.1-py3-none-any.whl", hash = "sha256:1c36e6bb0e71780c72331eba60da7f602e8d4389a8723825af71342e49d791e8", size = 1957258, upload-time = "2026-08-20T13:48:14.026Z" },
]
[[package]] [[package]]
name = "scipy" name = "scipy"
version = "1.17.1" version = "1.17.1"
@ -823,13 +901,13 @@ resolution-markers = [
"sys_platform == 'darwin'", "sys_platform == 'darwin'",
] ]
dependencies = [ dependencies = [
{ name = "filelock", marker = "sys_platform == 'darwin'" }, { name = "filelock" },
{ name = "fsspec", marker = "sys_platform == 'darwin'" }, { name = "fsspec" },
{ name = "jinja2", marker = "sys_platform == 'darwin'" }, { name = "jinja2" },
{ name = "networkx", marker = "sys_platform == 'darwin'" }, { name = "networkx" },
{ name = "setuptools", marker = "sys_platform == 'darwin'" }, { name = "setuptools" },
{ name = "sympy", marker = "sys_platform == 'darwin'" }, { name = "sympy" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin'" }, { name = "typing-extensions" },
] ]
wheels = [ wheels = [
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1040f689e560ba6e02a0a147dfb9ec8dfdd3e4486ebec4b24dc8fdaa258fa9ba", upload-time = "2026-09-02T00:26:07Z" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1040f689e560ba6e02a0a147dfb9ec8dfdd3e4486ebec4b24dc8fdaa258fa9ba", upload-time = "2026-09-02T00:26:07Z" },
@ -843,13 +921,13 @@ resolution-markers = [
"sys_platform != 'darwin'", "sys_platform != 'darwin'",
] ]
dependencies = [ dependencies = [
{ name = "filelock", marker = "sys_platform != 'darwin'" }, { name = "filelock" },
{ name = "fsspec", marker = "sys_platform != 'darwin'" }, { name = "fsspec" },
{ name = "jinja2", marker = "sys_platform != 'darwin'" }, { name = "jinja2" },
{ name = "networkx", marker = "sys_platform != 'darwin'" }, { name = "networkx" },
{ name = "setuptools", marker = "sys_platform != 'darwin'" }, { name = "setuptools" },
{ name = "sympy", marker = "sys_platform != 'darwin'" }, { name = "sympy" },
{ name = "typing-extensions", marker = "sys_platform != 'darwin'" }, { name = "typing-extensions" },
] ]
wheels = [ wheels = [
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:3fa39713d13f23563ad11636bfff76b677ab4addbc240c54e6b7e47622e8973d", upload-time = "2026-09-02T18:31:08Z" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:3fa39713d13f23563ad11636bfff76b677ab4addbc240c54e6b7e47622e8973d", upload-time = "2026-09-02T18:31:08Z" },

View file

@ -15,11 +15,14 @@ services:
backend: backend:
build: ./backend build: ./backend
image: lct-hack-backend:local
# Один воркер принципиально: состояние живой сессии и реестр наблюдателей # Один воркер принципиально: состояние живой сессии и реестр наблюдателей
# живут в памяти процесса (docs/arch/STACK.md). # живут в памяти процесса (docs/arch/STACK.md).
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --workers 1 command: sh -c "alembic upgrade head && python scripts/seed.py && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1"
environment: environment:
DATABASE_URL: postgresql+asyncpg://lct:lct@postgres:5432/lct DATABASE_URL: postgresql+asyncpg://lct:lct@postgres:5432/lct
OFFLINE: "true"
VOICE_ENABLED: "false"
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./scenarios:/scenarios:ro - ./scenarios:/scenarios:ro
@ -30,10 +33,14 @@ services:
frontend: frontend:
build: ./frontend build: ./frontend
image: lct-hack-frontend:local
command: npm run dev -- --host 0.0.0.0 command: npm run dev -- --host 0.0.0.0
volumes: volumes:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules
environment:
VITE_BACKEND_URL: http://backend:8000
VITE_WS_BACKEND_URL: ws://backend:8000
ports: ["5173:5173"] ports: ["5173:5173"]
depends_on: [backend] depends_on: [backend]

View file

@ -60,5 +60,5 @@ export function useLogout() {
export const SCREENS: Record<Role, string[]> = { export const SCREENS: Record<Role, string[]> = {
admin: ["/admin", "/profile"], admin: ["/admin", "/profile"],
instructor: ["/instructor", "/wall", "/profile", "/dds"], instructor: ["/instructor", "/wall", "/profile", "/dds"],
trainee: ["/trainee", "/profile"], trainee: ["/trainee", "/dds", "/profile"],
}; };

View file

@ -10,11 +10,12 @@ import { Ambience } from "@/shared/audio/ambience";
import { type Capture, startCapture } from "@/shared/audio/capture"; import { type Capture, startCapture } from "@/shared/audio/capture";
import { EnergyGate } from "@/shared/audio/levels"; import { EnergyGate } from "@/shared/audio/levels";
import { CALLER_RATE, Playback } from "@/shared/audio/playback"; import { CALLER_RATE, Playback } from "@/shared/audio/playback";
import { type CardState, applyPatch, edit, empty as emptyCard } from "@/features/kio-card/merge"; import { type CardState, applyPatch, applyState, edit, empty as emptyCard } from "@/features/kio-card/merge";
import type { ChecklistItem } from "@/features/self-assessment/SelfAssessment"; import type { ChecklistItem } from "@/features/self-assessment/SelfAssessment";
import type { import type {
Outcome, Outcome,
CallIncoming, CallIncoming,
CardBriefing,
DDSCode, DDSCode,
ServerToTrainee, ServerToTrainee,
SessionReport, SessionReport,
@ -36,6 +37,8 @@ export function useCall(sessionId: string | null) {
const [status, setStatus] = useState<ChannelStatus>("connecting"); const [status, setStatus] = useState<ChannelStatus>("connecting");
const [phase, setPhase] = useState<CallPhase>("waiting"); const [phase, setPhase] = useState<CallPhase>("waiting");
const [incoming, setIncoming] = useState<CallIncoming | null>(null); const [incoming, setIncoming] = useState<CallIncoming | null>(null);
const [briefing, setBriefing] = useState<CardBriefing | null>(null);
const [cardSubmitted, setCardSubmitted] = useState(false);
const [lines, setLines] = useState<Line[]>([]); const [lines, setLines] = useState<Line[]>([]);
const [timers, setTimers] = useState<TimerSnapshot[]>([]); const [timers, setTimers] = useState<TimerSnapshot[]>([]);
const [callerSpeaking, setCallerSpeaking] = useState(false); const [callerSpeaking, setCallerSpeaking] = useState(false);
@ -45,6 +48,7 @@ export function useCall(sessionId: string | null) {
const [selfAssessed, setSelfAssessed] = useState(false); const [selfAssessed, setSelfAssessed] = useState(false);
const [scoreReady, setScoreReady] = useState(false); const [scoreReady, setScoreReady] = useState(false);
const [report, setReport] = useState<SessionReport | null>(null); const [report, setReport] = useState<SessionReport | null>(null);
const [reportError, setReportError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const channel = useRef<ReturnType<typeof callChannel> | null>(null); const channel = useRef<ReturnType<typeof callChannel> | null>(null);
@ -53,6 +57,7 @@ export function useCall(sessionId: string | null) {
const gate = useRef(new EnergyGate()); const gate = useRef(new EnergyGate());
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 append = useCallback((line: Line) => { const append = useCallback((line: Line) => {
setLines((prev) => { setLines((prev) => {
@ -67,6 +72,16 @@ export function useCall(sessionId: string | null) {
onStatus: setStatus, onStatus: setStatus,
onBinary: (frame) => audio.current?.playback.enqueue(frame), onBinary: (frame) => audio.current?.playback.enqueue(frame),
onEvent: (event: ServerToTrainee) => { onEvent: (event: ServerToTrainee) => {
if (event.type === "card.briefing") {
const briefing = event;
textMode.current = true;
setBriefing(briefing);
setIncoming(null);
setCard(applyState(emptyCard, { ...briefing.card }));
setCardSubmitted(false);
setError(null);
return;
}
switch (event.type) { switch (event.type) {
case "call.incoming": case "call.incoming":
setIncoming(event); setIncoming(event);
@ -110,6 +125,7 @@ export function useCall(sessionId: string | null) {
case "call.ended": case "call.ended":
setPhase("ended"); setPhase("ended");
audio.current?.ambience.stop(); audio.current?.ambience.stop();
if (textMode.current) { setCardSubmitted(true); break; }
// Чек-лист открывается только после звонка: во время него это // Чек-лист открывается только после звонка: во время него это
// содержимое подсказок. // содержимое подсказок.
void fetch(`/api/sessions/${sessionId}/checklist`) void fetch(`/api/sessions/${sessionId}/checklist`)
@ -119,10 +135,16 @@ export function useCall(sessionId: string | null) {
break; break;
case "score.ready": case "score.ready":
setScoreReady(true); setScoreReady(true);
setReportError(null);
void fetch(`/api/sessions/${sessionId}/report`) void fetch(`/api/sessions/${sessionId}/report`)
.then((response) => (response.ok ? response.json() : null)) .then((response) => {
if (response.status === 403) throw new Error("Отчёт недоступен: занятие привязано к другому обучающемуся.");
if (response.status === 409) throw new Error("Оценка ещё не готова. Обновите страницу позже.");
if (!response.ok) throw new Error(`Не удалось загрузить отчёт (HTTP ${response.status}).`);
return response.json();
})
.then(setReport) .then(setReport)
.catch(() => setReport(null)); .catch((cause: unknown) => setReportError(cause instanceof Error ? cause.message : "Не удалось загрузить отчёт."));
break; break;
case "error": case "error":
setError(event.message); setError(event.message);
@ -183,8 +205,25 @@ export function useCall(sessionId: string | null) {
}, PATCH_DEBOUNCE_MS); }, PATCH_DEBOUNCE_MS);
}, []); }, []);
const dispatch = useCallback((service: DDSCode) => { const dispatch = useCallback((service?: DDSCode) => {
channel.current?.send({ type: "dds.dispatch", service }); channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" });
}, []);
const submitCard = useCallback(() => {
if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = null;
const fields = outbox.current;
outbox.current = {};
if (Object.keys(fields).length && !channel.current?.send({ type: "kio.patch", fields })) {
setError("Канал отключён: карточка не отправлена.");
return;
}
if (channel.current?.send({ type: "card.submit" })) {
setCardSubmitted(true);
setError(null);
} else {
setError("Канал отключён: карточка не отправлена.");
}
}, []); }, []);
/** Закрыть вызов не карточкой: справкой или передачей в другой регион. /** Закрыть вызов не карточкой: справкой или передачей в другой регион.
@ -201,8 +240,8 @@ export function useCall(sessionId: string | null) {
return { return {
resolve, resolve,
status, phase, incoming, lines, timers, callerSpeaking, micOn, error, card, status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, card,
checklist, selfAssessed, scoreReady, report, checklist, selfAssessed, scoreReady, report, reportError,
answer, hangup, hint, patchKio, dispatch, submitSelfAssessment, answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment,
}; };
} }

View file

@ -23,6 +23,10 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
Разбор · оценка {report.score_final.toFixed(0)} из 100 Разбор · оценка {report.score_final.toFixed(0)} из 100
{report.overridden_by && ` · скорректировано (${report.overridden_by}), автооценка ${report.score_auto.toFixed(0)}`} {report.overridden_by && ` · скорректировано (${report.overridden_by}), автооценка ${report.score_auto.toFixed(0)}`}
</h2> </h2>
<p className="ref">
Скачать отчёт: <a href={`/api/sessions/${report.session_id}/report.csv`}>CSV</a>
{" · "}<a href={`/api/sessions/${report.session_id}/report.pdf`}>PDF</a>
</p>
<div className="columns"> <div className="columns">
<section> <section>

View file

@ -0,0 +1,189 @@
import { useState } from "react";
import type { ScenarioSummary } from "@/shared/api/http";
type JsonObject = Record<string, unknown>;
type Draft = { id: string; status: "draft"; generation: "template_copy"; body: JsonObject };
type Validation = { valid: boolean; errors: string[]; ground_truth?: JsonObject };
async function api<T>(path: string, method = "GET", body?: unknown): Promise<T> {
const response = await fetch(path, {
method,
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const data: unknown = await response.json().catch(() => null);
if (!response.ok) {
const detail = data && typeof data === "object" && "detail" in data ? data.detail : response.statusText;
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
}
return data as T;
}
function isObject(value: unknown): value is JsonObject {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/** Сервер принимает JSON Merge Patch, поэтому удалённое в редакторе поле
* передаётся как null, а неизменённые поля не отправляются. */
function diff(before: JsonObject, after: JsonObject): JsonObject {
const patch: JsonObject = {};
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
if (!(key in after)) {
patch[key] = null;
} else if (!(key in before)) {
patch[key] = after[key];
} else if (isObject(before[key]) && isObject(after[key])) {
const nested = diff(before[key], after[key]);
if (Object.keys(nested).length) patch[key] = nested;
} else if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
patch[key] = after[key];
}
}
return patch;
}
function pretty(body: JsonObject): string {
return JSON.stringify(body, null, 2);
}
export function ScenarioEditor({
scenarios,
onApproved,
}: {
scenarios: ScenarioSummary[];
onApproved: (id: string) => void | Promise<void>;
}) {
const [sourceId, setSourceId] = useState("");
const [title, setTitle] = useState("");
const [draftId, setDraftId] = useState("");
const [draft, setDraft] = useState<Draft | null>(null);
const [bodyText, setBodyText] = useState("");
const [validation, setValidation] = useState<Validation | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
function openDraft(value: Draft) {
setDraft(value);
setDraftId(value.id);
setBodyText(pretty(value.body));
setValidation(null);
setError("");
setNotice("");
}
async function perform(work: () => Promise<void>) {
setBusy(true);
setError("");
try {
await work();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setBusy(false);
}
}
async function create() {
await perform(async () => {
const created = await api<Draft>("/api/scenarios/drafts/from-template", "POST", {
source_id: sourceId || scenarios[0]?.id,
...(title.trim() ? { title: title.trim() } : {}),
});
openDraft(created);
setNotice("Создан шаблонный черновик. Это копия сценария, не генерация ИИ.");
});
}
async function reopen() {
if (!draftId.trim()) return;
await perform(async () => {
openDraft(await api<Draft>(`/api/scenarios/drafts/${encodeURIComponent(draftId.trim())}`));
});
}
async function save(): Promise<Draft> {
if (!draft) throw new Error("Сначала создайте или откройте черновик");
let parsed: unknown;
try {
parsed = JSON.parse(bodyText);
} catch (cause) {
throw new Error(`Некорректный JSON: ${cause instanceof Error ? cause.message : String(cause)}`);
}
if (!isObject(parsed)) throw new Error("Тело сценария должно быть JSON-объектом");
if (parsed.id !== draft.id) throw new Error("id черновика менять нельзя");
const patch = diff(draft.body, parsed);
if (!Object.keys(patch).length) return draft;
const updated = await api<Draft>(`/api/scenarios/drafts/${encodeURIComponent(draft.id)}`, "PATCH", patch);
setDraft(updated);
setBodyText(pretty(updated.body));
setValidation(null);
return updated;
}
async function validate() {
await perform(async () => {
const current = await save();
const result = await api<Validation>(`/api/scenarios/drafts/${encodeURIComponent(current.id)}/validate`, "POST");
setValidation(result);
setNotice(result.valid ? "Черновик прошёл проверку и может быть утверждён." : "Найдены ошибки в сценарии.");
});
}
async function approve() {
await perform(async () => {
const current = await save();
if (!validation?.valid) throw new Error("Сначала проверьте черновик кнопкой «Проверить»");
await api(`/api/scenarios/drafts/${encodeURIComponent(current.id)}/approve`, "POST");
setDraft(null);
setValidation(null);
setNotice("Сценарий утверждён и доступен для нового занятия.");
await onApproved(current.id);
});
}
return (
<section className="scenario-editor">
<h2>Редактор учебных сценариев</h2>
<p className="ref">Создание работает локально: копия утверждённого шаблона, затем правка и проверка преподавателем.</p>
<div className="scenario-editor-create">
<label>Шаблон
<select value={sourceId || scenarios[0]?.id || ""} onChange={(event) => setSourceId(event.target.value)}>
{scenarios.map((scenario) => <option key={scenario.id} value={scenario.id}>{scenario.title} · {scenario.level}</option>)}
</select>
</label>
<label>Название нового сценария
<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="необязательно" maxLength={200} />
</label>
<button type="button" onClick={create} disabled={busy || !scenarios.length}>Создать черновик</button>
</div>
<div className="scenario-editor-open">
<label>Номер черновика
<input value={draftId} onChange={(event) => setDraftId(event.target.value)} placeholder="draft-…" />
</label>
<button type="button" onClick={reopen} disabled={busy || !draftId.trim()}>Открыть</button>
</div>
{draft && (
<>
<p className="ref">Черновик {draft.id}. Факты и эталон доступны только преподавателю до утверждения.</p>
<label className="scenario-editor-json">JSON тела сценария
<textarea value={bodyText} onChange={(event) => { setBodyText(event.target.value); setValidation(null); }} spellCheck={false} />
</label>
<div className="scenario-editor-actions">
<button type="button" onClick={() => void perform(async () => { await save(); setNotice("Черновик сохранён."); })} disabled={busy}>Сохранить</button>
<button type="button" onClick={validate} disabled={busy}>Проверить</button>
<button type="button" className="start" onClick={approve} disabled={busy || !validation?.valid}>Утвердить</button>
</div>
</>
)}
{validation && <div className={validation.valid ? "scenario-editor-result" : "scenario-editor-result violated"}>
{validation.valid ? <><strong>Проверка пройдена</strong><pre>{pretty(validation.ground_truth ?? {})}</pre></> :
<><strong>Ошибки проверки</strong><ul>{validation.errors.map((item, index) => <li key={`${index}-${item}`}>{item}</li>)}</ul></>}
</div>}
{error && <p className="violated" role="alert">{error}</p>}
{notice && <p className="state-ok" role="status">{notice}</p>}
</section>
);
}

View file

@ -25,14 +25,18 @@ export function useControl(sessionId: string | null) {
}, [sessionId]); }, [sessionId]);
const start = useCallback( const start = useCallback(
(scenarioId: string, mode: SessionMode, trainee: string, group?: string) => (scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string) => {
channel.current?.send({ const message = {
type: "scenario.start", type: "scenario.start",
scenario_id: scenarioId, scenario_id: scenarioId,
mode, mode,
trainee, trainee,
group_id: group || null, group_id: group || null,
}), trainee_id: traineeId || null,
exercise,
} as const;
channel.current?.send(message);
},
[], [],
); );

View file

@ -1,282 +1,281 @@
// АРМ диспетчера ДДС. // Учебное АРМ ДДС. Звонок бригаде имитируется внутри приложения, без SIP.
//
// Аудио здесь нет вообще: приходит снимок карточки от оператора 112,
// дальше — подтверждение по нормативу, возврат на уточнение и отметки времени.
// Возврат карточки — самая ценная механика цепочки: неполнота КИО становится
// сорванным выездом с конкретной причиной (docs/arch/CONTRACT.md).
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { GROUPS } from "@/features/kio-card/fields"; import { GROUPS } from "@/features/kio-card/fields";
import { KioCard } from "@/features/kio-card/KioCard"; import { KioCard } from "@/features/kio-card/KioCard";
import { applyState, empty as emptyCard, type CardState } from "@/features/kio-card/merge"; import { applyState, empty as emptyCard, readPath, type CardState } from "@/features/kio-card/merge";
import { stationChannel, type ChannelStatus } from "@/shared/api/ws";
import type { ServiceStatus, StationSnapshot } from "@/shared/types/generated";
import { SERVICE_STATUS_LABELS, CARD_STATUS_LABELS, ALARMING } from "./statuses";
import { sessionIdFromUrl } from "@/shared/api/session"; import { sessionIdFromUrl } from "@/shared/api/session";
import { stationChannel, type ChannelStatus } from "@/shared/api/ws";
import type { ServiceStatus, SessionReport, StationSnapshot, StationToServer } from "@/shared/types/generated";
import { ALARMING, CARD_STATUS_LABELS, SERVICE_STATUS_LABELS } from "./statuses";
const ACK_LIMIT_S = 30; // норматив подтверждения, ПП РФ № 1931 и памятка АРМ-112 const ACK_LIMIT_S = 30;
type ReportPhase = "dispatched" | "arrived" | "working" | "completed";
interface PhoneReport { service: string; crew: string; phase: ReportPhase; text: string; at: string }
interface DdsSnapshot extends StationSnapshot {
crew_options?: string[];
crew_selected?: string | null;
phone_reports?: PhoneReport[];
}
type DdsCommand = { type: "crew.select"; crew: string } | { type: "phone.dial" } | { type: "station.finish" };
const REPORT_LABELS: Record<ReportPhase, string> = {
dispatched: "Доклад о выезде", arrived: "Доклад о прибытии",
working: "Доклад о ходе работ", completed: "Доклад о завершении работ",
};
const FIELDS = GROUPS.flatMap((group) => const FIELDS = GROUPS.flatMap((group) =>
group.fields.filter((field) => !field.readOnly).map((field) => ({ path: field.path, label: field.label })), group.fields.filter((item) => !item.readOnly).map((item) => ({ path: item.path, label: item.label })),
); );
function field(card: CardState, path: string): string {
const value = readPath(card.server, path);
return value === null || value === undefined || value === "" ? "—" : String(value);
}
function time(value: string): string {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString("ru-RU");
}
export function Dds() { export function Dds() {
const sessionId = sessionIdFromUrl(); const sessionId = sessionIdFromUrl();
const role = new URLSearchParams(location.search).get("role") ?? "dds_01"; const role = new URLSearchParams(location.search).get("role") ?? "dds_01";
const [status, setStatus] = useState<ChannelStatus>("connecting"); const [status, setStatus] = useState<ChannelStatus>("connecting");
const [card, setCard] = useState<CardState | null>(null); const [card, setCard] = useState<CardState | null>(null);
const [from, setFrom] = useState<string>(""); const [from, setFrom] = useState("");
const [receivedAt, setReceivedAt] = useState<number | null>(null); const [receivedAt, setReceivedAt] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
const [acked, setAcked] = useState(false); const [station, setStation] = useState<DdsSnapshot | null>(null);
const [bounced, setBounced] = useState<string[] | null>(null); const [lastReport, setLastReport] = useState<PhoneReport | null>(null);
const [dialing, setDialing] = useState(false);
const [error, setError] = useState("");
const [ended, setEnded] = useState(false);
const [report, setReport] = useState<SessionReport | null>(null);
const [reportState, setReportState] = useState<"idle" | "loading" | "ready" | "error">("idle");
const [reportError, setReportError] = useState("");
const [missing, setMissing] = useState<string[]>([]); const [missing, setMissing] = useState<string[]>([]);
const [comment, setComment] = useState(""); const [bounceComment, setBounceComment] = useState("");
const [bounced, setBounced] = useState(false);
const [zone, setZone] = useState<boolean | null>(null); const [zone, setZone] = useState<boolean | null>(null);
const [marks, setMarks] = useState<string[]>([]);
const [station, setStation] = useState<StationSnapshot | null>(null);
const channel = useRef<ReturnType<typeof stationChannel> | null>(null); const channel = useRef<ReturnType<typeof stationChannel> | null>(null);
useEffect(() => { useEffect(() => {
if (!sessionId) return; if (!sessionId) return;
const controller = new AbortController();
async function loadReport() {
setReportState("loading");
setReportError("");
try {
const response = await fetch(`/api/sessions/${sessionId}/report`, {
credentials: "same-origin",
signal: controller.signal,
});
if (!response.ok) {
if (response.status === 403) throw new Error("Отчёт недоступен: это занятие другого обучающегося.");
if (response.status === 409) throw new Error("Оценка ещё не готова. Попробуйте получить разбор чуть позже.");
throw new Error(`Не удалось получить отчёт (HTTP ${response.status}).`);
}
setReport(await response.json() as SessionReport);
setReportState("ready");
} catch (cause) {
if (controller.signal.aborted) return;
setReportState("error");
setReportError(cause instanceof Error ? cause.message : "Не удалось получить отчёт.");
}
}
const ch = stationChannel(sessionId, role, { const ch = stationChannel(sessionId, role, {
onStatus: setStatus, onStatus: (next) => { setStatus(next); if (next !== "open") setDialing(false); },
onEvent: (event) => { onEvent: (event) => {
if (event.type === "station.state") setStation(event.snapshot); if (event.type === "station.state") { setStation(event.snapshot as DdsSnapshot); setError(""); }
if (event.type === "error") mark(`отклонено: ${event.message}`); else if (event.type === "card.received") {
if (event.type === "card.received") {
setCard(applyState(emptyCard, event.card as unknown as Record<string, unknown>)); setCard(applyState(emptyCard, event.card as unknown as Record<string, unknown>));
setFrom(event.from_operator); setFrom(event.from_operator);
setReceivedAt(Date.now()); // Норматив идёт с передачи карточки сервером, в том числе если
// диспетчер открыл АРМ уже после начала занятия.
const transmittedAt = new Date(event.at).getTime();
setReceivedAt(Number.isNaN(transmittedAt) ? Date.now() : transmittedAt);
setEnded(false);
} else if (event.type === "error") { setError(event.message); setDialing(false); }
else if (event.type === "session.ended") { setEnded(true); setDialing(false); }
else if (event.type === "score.ready") { void loadReport(); }
else if ((event as { type: string }).type === "phone.report") {
setLastReport(event as unknown as PhoneReport);
setDialing(false);
setError("");
} }
}, },
}).connect(); }).connect();
channel.current = ch; channel.current = ch;
return () => ch.close(); return () => { controller.abort(); ch.close(); channel.current = null; };
}, [sessionId, role]); }, [sessionId, role]);
// Обратный отсчёт норматива: пока карточка не подтверждена, счёт идёт. const primaryRecorded = Boolean(station?.services.length && station.services.every((service) =>
["accepted", "declined", "responding", "arrived", "working", "completed", "refused"]
.includes(station.statuses[service])));
useEffect(() => { useEffect(() => {
if (receivedAt === null || acked) return; if (receivedAt === null || primaryRecorded) return;
const timer = setInterval(() => setElapsed((Date.now() - receivedAt) / 1000), 200); const timer = setInterval(() => setElapsed((Date.now() - receivedAt) / 1000), 200);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [receivedAt, acked]); }, [receivedAt, primaryRecorded]);
if (!sessionId) { const send = (command: DdsCommand): boolean => channel.current?.send(command as StationToServer) ?? false;
return ( if (!sessionId) return <main className="page"><h1>АРМ диспетчера ДДС</h1>
<main className="page"> <p className="warn">Откройте ссылку занятия вида /dds?session=…&role=dds_01</p></main>;
<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]); const reports = station?.phone_reports ?? [];
const selectedCrew = station?.crew_selected ?? "";
const latest = lastReport ?? reports.at(-1) ?? null;
const isConnected = status === "open";
const crewAccepted = Boolean(station?.services.some((service) =>
selectedCrew.startsWith(`${service} —`) &&
["accepted", "responding", "arrived", "working"].includes(station.statuses[service])));
return ( return <main className="page dds-workspace">
<main className="page"> <header className="dds-header"><div><div className="dds-eyebrow">Учебный контур · АРМ системы-112</div>
<h1>АРМ диспетчера ДДС · {role}</h1> <h1>Дежурно-диспетчерская служба <span>{role}</span></h1></div>
<table className="grid"> <div className={`dds-connection ${isConnected ? "state-ok" : "state-warn"}`}>
<tbody> {isConnected ? "● Канал подключён" : `● ${status}`}</div></header>
<tr><th>Канал</th><td className={status === "open" ? "" : "warn"}>{status}</td></tr> {error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>}
<tr><th>Карточка</th><td>{card ? `от оператора ${from}` : "ожидание карточки от 112"}</td></tr> {ended && <div className="dds-alert dds-done">Занятие завершено. Оценка появится после расчёта.</div>}
{card && !acked && ( {(ended || reportState !== "idle") && <section className="dds-panel" aria-live="polite">
<tr> <div className="dds-panel-title"><h2>Итог упражнения</h2></div>
<th>Подтверждение</th> {report ? <div style={{ padding: 12 }}>
<td className={elapsed > ACK_LIMIT_S ? "state-violated" : "state-ok"}> <p><strong>Итоговая оценка: {report.score_final.toFixed(0)} из 100</strong>
{elapsed.toFixed(1)} с из {ACK_LIMIT_S} с {report.score_final !== report.score_auto && ` · автооценка ${report.score_auto.toFixed(0)}`}</p>
</td> <p className="ref">Скачать отчёт: <a href={`/api/sessions/${sessionId}/report.csv`}>CSV</a>
</tr> {" · "}<a href={`/api/sessions/${sessionId}/report.pdf`}>PDF</a></p>
)} <h3>Не выполнены нормативы</h3>
</tbody> <table className="dds-registry"><tbody>
</table> {report.metrics.filter((metric) => !metric.passed).map((metric) => <tr key={metric.key}>
<th>{metric.title}</th><td>{metric.fact} · норматив: {metric.norm}</td></tr>)}
{report.metrics.every((metric) => metric.passed) && <tr><td>Все проверенные нормативы выполнены.</td></tr>}
</tbody></table>
<h3>Замечания</h3>
<table className="dds-registry"><tbody>
{report.findings.map((finding, index) => <tr key={`${finding.code}-${index}`}>
<th>{finding.code}</th><td>{finding.summary}<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div></td></tr>)}
{report.findings.length === 0 && <tr><td>Замечаний нет.</td></tr>}
</tbody></table>
</div> : <div style={{ padding: 12 }}>
{reportState === "loading" ? "Подготавливаем разбор…" : reportError || "Ожидаем итоговую оценку…"}
{reportState === "error" && <button type="button" onClick={() => {
setReportState("loading");
void fetch(`/api/sessions/${sessionId}/report`, { credentials: "same-origin" }).then(async (response) => {
if (response.status === 403) throw new Error("Отчёт недоступен: это занятие другого обучающегося.");
if (response.status === 409) throw new Error("Оценка ещё не готова. Попробуйте получить разбор чуть позже.");
if (!response.ok) throw new Error(`Не удалось получить отчёт (HTTP ${response.status}).`);
setReport(await response.json() as SessionReport);
setReportState("ready");
}).catch((cause: unknown) => {
setReportError(cause instanceof Error ? cause.message : "Не удалось получить отчёт.");
setReportState("error");
});
}}>Повторить запрос</button>}
</div>}
</section>}
{card && ( <section className="dds-panel"><div className="dds-panel-title"><h2>Реестр обращений</h2><span>Входящие карточки</span></div>
<> <div className="dds-table-wrap"><table className="dds-registry"><thead><tr>
<p> <th>№ карточки</th><th>Время</th><th>Адрес происшествия</th><th>Тип</th><th>Службы</th><th>Статус</th><th>Решение ДДС</th>
<button type="button" disabled={acked} onClick={() => { </tr></thead><tbody>{card ? <tr className="dds-selected">
channel.current?.send({ type: "card.ack" }); <td>{sessionId.slice(0, 8).toUpperCase()}</td><td>{time(field(card, "registered_at"))}</td>
setAcked(true); <td>{field(card, "address")}</td><td>{field(card, "incident_type")}</td>
mark("карточка принята"); <td>{station?.services.join(", ") || field(card, "notify")}</td>
}}>Принять карточку</button>{" "} <td>{station ? CARD_STATUS_LABELS[station.card] : "Получена"}</td>
<button type="button" disabled={Boolean(bounced)} onClick={() => { <td>{station?.services.map((service) => `${service}: ${SERVICE_STATUS_LABELS[station.statuses[service]]}`).join("; ") || "ожидается"}</td>
if (!missing.length) return; </tr> : <tr><td colSpan={7} className="dds-empty">Ожидание готовой карточки от преподавателя или оператора 112…</td></tr>}</tbody></table></div>
channel.current?.send({ type: "card.bounce", missing_fields: missing, comment }); </section>
setBounced(missing);
mark(`возврат на уточнение: ${missing.join(", ")}`);
}}>Вернуть на уточнение</button>
</p>
{!bounced && ( {card && <div className="dds-main"><div className="dds-left">
<details> <section className="dds-panel"><div className="dds-panel-title"><h2>Карточка обращения</h2>
<summary>Чего не хватает для выезда</summary> <span>Источник: {from || "112"}</span></div>
<div className="bounce-fields"> <div className="dds-summary">
{FIELDS.map((field) => ( <div><small>Адрес</small><strong>{field(card, "address")}</strong></div>
<label key={field.path}> <div><small>Заявитель</small><strong>{field(card, "caller_name")}</strong></div>
<input type="checkbox" checked={missing.includes(field.path)} <div><small>Телефон</small><strong>{field(card, "caller_contact")}</strong></div>
onChange={() => setMissing((prev) => <div><small>Происшествие</small><strong>{field(card, "description")}</strong></div>
prev.includes(field.path) ? prev.filter((item) => item !== field.path) : [...prev, field.path])} />{" "} </div><details className="dds-details"><summary>Полная карточка информационного обмена</summary><KioCard state={card} readOnly /></details>
{field.label} </section>
</label> <section className="dds-panel"><div className="dds-panel-title"><h2>Реагирование служб</h2>
))} <span className={station && ALARMING.includes(station.card) ? "state-violated" : "state-ok"}>
</div> {station ? CARD_STATUS_LABELS[station.card] : "—"}</span></div>
<p> {!primaryRecorded && receivedAt !== null && <div className={`dds-deadline ${elapsed > ACK_LIMIT_S ? "state-violated" : "state-ok"}`}>
<input type="text" value={comment} placeholder="куда ехать без этажа" Первичное решение «Принята» / «Не принята»: {elapsed.toFixed(0)} из {ACK_LIMIT_S} секунд</div>}
onChange={(event) => setComment(event.target.value)} /> {station && station.services.length > 0 ? <div className="dds-table-wrap"><table className="dds-registry dds-status-table">
</p> <thead><tr><th>Служба</th><th>Текущий статус</th><th>Следующее действие</th></tr></thead><tbody>
{station.services.map((service) => <StatusRow key={service} service={service}
current={station.statuses[service]} available={station.available[service] ?? []}
disabled={!isConnected || ended} reports={reports.filter((report) => report.service === service)}
phoneExercise={from === "учебный сценарий"}
onSet={(next, comment) => channel.current?.send({ type: "card.status", service, status: next, comment })} />)}
</tbody></table></div> : <p className="dds-empty">Ожидаем состав служб по карточке.</p>}
<p className="dds-hint">Сначала примите карточку или укажите причину отказа. Последующие статусы в занятии ДДС подтверждаются докладами по телефону.</p>
</section>
{station && station.log.length > 0 && <section className="dds-panel"><div className="dds-panel-title"><h2>Журнал реагирования</h2></div>
<div className="dds-table-wrap"><table className="dds-registry"><thead><tr><th>Время</th><th>Служба</th><th>Статус</th><th>Комментарий</th></tr></thead>
<tbody>{station.log.map((entry, index) => <tr key={`${entry.at}-${index}`}><td>{time(entry.at)}</td><td>{entry.service}</td>
<td>{SERVICE_STATUS_LABELS[entry.status]}</td><td>{entry.comment || "—"}</td></tr>)}</tbody></table></div></section>}
</div><aside className="dds-right">
<section className="dds-panel dds-phone"><div className="dds-panel-title"><h2>IP-телефон</h2><span>Учебная имитация</span></div>
<p className="dds-hint">Выберите бригаду вручную, свяжитесь со старшим группы и внесите его доклад в карточку.</p>
<label htmlFor="dds-crew">Дежурная бригада</label>
<select id="dds-crew" value={selectedCrew} disabled={!isConnected || ended || !station?.crew_options?.length}
onChange={(event) => send({ type: "crew.select", crew: event.target.value })}>
<option value="">Выберите бригаду</option>
{station?.crew_options?.map((crew) => <option key={crew} value={crew}>{crew}</option>)}
</select>
<button type="button" className="dds-dial" disabled={!crewAccepted || !isConnected || ended || dialing}
onClick={() => { if (send({ type: "phone.dial" })) setDialing(true); }}>
☎ {dialing ? "Соединение…" : "Позвонить старшему группы"}</button>
{selectedCrew && !crewAccepted && <p className="dds-hint state-warn">Перед звонком поставьте статус «Принята» для службы этой бригады.</p>}
{latest ? <div className="dds-report" aria-live="polite"><small>{REPORT_LABELS[latest.phase]} · {time(latest.at)}</small>
<strong>{latest.crew}</strong><p>{latest.text}</p>
<span>Прослушайте доклад и поставьте соответствующий статус реагирования.</span></div>
: <div className="dds-phone-idle">Доклады поступят после исходящего учебного звонка.</div>}
{reports.length > 0 && <details className="dds-details"><summary>История докладов ({reports.length})</summary>
<ol className="dds-reports">{reports.map((report, index) => <li key={`${report.at}-${index}`}><b>{REPORT_LABELS[report.phase]}</b> · {time(report.at)}<br />{report.text}</li>)}</ol>
</details>}
</section>
<section className="dds-panel dds-secondary"><div className="dds-panel-title"><h2>Дополнительные действия</h2></div>
<div className="dds-zone">Зона ответственности: <button type="button" disabled={zone !== null || !isConnected || ended}
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: true }); setZone(true); }}>Наша</button>
<button type="button" disabled={zone !== null || !isConnected || ended}
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: false }); setZone(false); }}>Не наша</button></div>
<details className="dds-details"><summary>Вернуть карточку на уточнение</summary>
<div className="bounce-fields">{FIELDS.map((item) => <label key={item.path}><input type="checkbox" checked={missing.includes(item.path)}
onChange={() => setMissing((previous) => previous.includes(item.path) ? previous.filter((path) => path !== item.path) : [...previous, item.path])} /> {item.label}</label>)}</div>
<input type="text" value={bounceComment} placeholder="Что необходимо уточнить" onChange={(event) => setBounceComment(event.target.value)} />
<button type="button" disabled={!missing.length || bounced || !isConnected || ended} onClick={() => {
channel.current?.send({ type: "card.bounce", missing_fields: missing, comment: bounceComment }); setBounced(true);
}}>Вернуть оператору</button>
</details> </details>
)} <button type="button" className="dds-finish" disabled={!isConnected || ended} onClick={() => send({ type: "station.finish" })}>Завершить упражнение ДДС</button>
</section>
<p> </aside></div>}
Зона ответственности:{" "} </main>;
<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>
{station && station.services.length > 0 && (
<>
<h2>Статусы реагирования</h2>
<p>
Статус карточки:{" "}
<b className={ALARMING.includes(station.card) ? "state-violated" : "state-ok"}>
{CARD_STATUS_LABELS[station.card]}
</b>
</p>
<table className="grid">
<thead>
<tr><th>Служба</th><th>Статус</th><th>Отметить</th></tr>
</thead>
<tbody>
{station.services.map((service) => (
<StatusRow
key={service}
service={service}
current={station.statuses[service]}
available={station.available[service] ?? []}
onSet={(status, comment) => {
channel.current?.send({ type: "card.status", service, status, comment });
mark(`${service}: ${SERVICE_STATUS_LABELS[status]}`);
}}
/>
))}
</tbody>
</table>
</>
)}
<h2>Карточка от оператора 112</h2>
{/* Снимок: после передачи он не меняется. */}
<KioCard state={card} readOnly />
</>
)}
{station && station.log.length > 0 && (
<>
<h2>Журнал отметок</h2>
<table className="grid">
<tbody>
{station.log.map((entry, index) => (
<tr key={index}>
<th>{entry.service}</th>
<td>
{SERVICE_STATUS_LABELS[entry.status]}
{entry.comment && <> — {entry.comment}</>}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
{marks.length > 0 && (
<>
<h2>Отметки времени</h2>
<table className="grid">
<tbody>{marks.map((line, index) => <tr key={index}><td>{line}</td></tr>)}</tbody>
</table>
</>
)}
</main>
);
} }
/** Одна служба списка оповещения. function StatusRow({ service, current, available, disabled, reports, phoneExercise, onSet }: {
* service: string; current: ServiceStatus; available: ServiceStatus[]; disabled: boolean;
* Доступные статусы приходят с сервера: автомат переходов живёт там, и reports: PhoneReport[]; phoneExercise: boolean; onSet: (status: ServiceStatus, comment: string) => void;
* дублировать его на фронте нельзя — разъедется. Поле комментария появляется
* у отказов, потому что без комментария сервер такую отметку не примет. */
function StatusRow({
service, current, available, onSet,
}: {
service: string;
current: ServiceStatus;
available: ServiceStatus[];
onSet: (status: ServiceStatus, comment: string) => void;
}) { }) {
const [chosen, setChosen] = useState<ServiceStatus | "">(""); const [chosen, setChosen] = useState<ServiceStatus | "">("");
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
const needsComment = chosen === "declined" || chosen === "refused"; const needsComment = chosen === "declined" || chosen === "refused";
const reportNeeded: Partial<Record<ServiceStatus, ReportPhase>> = {
if (available.length === 0) { responding: "dispatched", arrived: "arrived", working: "working", completed: "completed",
return ( };
<tr> const matchingReport = !chosen || !phoneExercise || !reportNeeded[chosen] ||
<th>{service}</th> reports.some((report) => report.phase === reportNeeded[chosen]);
<td>{SERVICE_STATUS_LABELS[current]}</td> return <tr><td>{service}</td><td><strong>{SERVICE_STATUS_LABELS[current]}</strong></td><td>
<td>карточка закрыта для редактирования</td> {available.length ? <div className="dds-status-action">
</tr> <select value={chosen} disabled={disabled} aria-label={`Следующий статус ${service}`}
); onChange={(event) => setChosen(event.target.value as ServiceStatus)}>
} <option value="">Выберите статус</option>
return ( {available.map((next) => <option key={next} value={next}>{SERVICE_STATUS_LABELS[next]}</option>)}
<tr> </select>
<th>{service}</th> {needsComment && <input type="text" value={comment} placeholder="Причина отказа и кому передана информация"
<td>{SERVICE_STATUS_LABELS[current]}</td> onChange={(event) => setComment(event.target.value)} />}
<td> {chosen && !matchingReport && <span className="state-warn">Сначала получите доклад по телефону.</span>}
<select value={chosen} onChange={(event) => setChosen(event.target.value as ServiceStatus)}> <button type="button" disabled={disabled || !chosen || (needsComment && !comment.trim()) || !matchingReport}
<option value="">—</option> onClick={() => { if (chosen) { onSet(chosen, comment.trim()); setChosen(""); setComment(""); } }}>Сохранить статус</button>
{available.map((status) => ( </div> : "Карточка закрыта для редактирования"}</td></tr>;
<option key={status} value={status}>{SERVICE_STATUS_LABELS[status]}</option>
))}
</select>{" "}
{needsComment && (
<input
type="text"
value={comment}
placeholder="причина и куда передана информация"
onChange={(event) => setComment(event.target.value)}
/>
)}{" "}
<button
type="button"
disabled={!chosen || (needsComment && !comment.trim())}
onClick={() => {
if (!chosen) return;
onSet(chosen, comment);
setChosen("");
setComment("");
}}
>
Поставить
</button>
</td>
</tr>
);
} }

View file

@ -8,13 +8,14 @@ import { useEffect, useRef, useState } from "react";
import { Debrief } from "@/features/debrief/Debrief"; import { Debrief } from "@/features/debrief/Debrief";
import { Director } from "@/features/instructor/Director"; import { Director } from "@/features/instructor/Director";
import { ScenarioEditor } from "@/features/instructor/ScenarioEditor";
import { useControl } from "@/features/instructor/useControl"; import { useControl } from "@/features/instructor/useControl";
import { KioCard } from "@/features/kio-card/KioCard"; import { KioCard } from "@/features/kio-card/KioCard";
import { StandStatus } from "@/features/health/StandStatus"; import { StandStatus } from "@/features/health/StandStatus";
import { ModeBanner } from "@/features/mode-banner/ModeBanner"; import { ModeBanner } from "@/features/mode-banner/ModeBanner";
import { useObserve } from "@/features/observe/useObserve"; import { useObserve } from "@/features/observe/useObserve";
import { InterviewTimer, Timers } from "@/features/timers/Timers"; import { InterviewTimer, Timers } from "@/features/timers/Timers";
import { useScenarios, useSessions } from "@/shared/api/http"; import { useScenarios, useSessions, useTrainees } from "@/shared/api/http";
import { sessionIdFromUrl } from "@/shared/api/session"; import { sessionIdFromUrl } from "@/shared/api/session";
import type { SessionMode } from "@/shared/types/generated"; import type { SessionMode } from "@/shared/types/generated";
@ -28,7 +29,9 @@ export function Instructor() {
const [sessionId, setSessionId] = useState<string | null>(sessionIdFromUrl()); const [sessionId, setSessionId] = useState<string | null>(sessionIdFromUrl());
const [scenarioId, setScenarioId] = useState(""); const [scenarioId, setScenarioId] = useState("");
const [mode, setMode] = useState<SessionMode>("training"); const [mode, setMode] = useState<SessionMode>("training");
const [exercise, setExercise] = useState<"dds" | "call" | "card">("dds");
const [trainee, setTrainee] = useState(""); const [trainee, setTrainee] = useState("");
const [traineeId, setTraineeId] = useState("");
const [noteFor, setNoteFor] = useState<string | null>(null); const [noteFor, setNoteFor] = useState<string | null>(null);
// Запускается только занятие, созданное этой кнопкой. Открытая из истории // Запускается только занятие, созданное этой кнопкой. Открытая из истории
// ссылка на идущее занятие не должна запускать его заново и сбрасывать. // ссылка на идущее занятие не должна запускать его заново и сбрасывать.
@ -37,6 +40,7 @@ export function Instructor() {
const scenarios = useScenarios(); const scenarios = useScenarios();
const history = useSessions(); const history = useSessions();
const trainees = useTrainees();
const session = useObserve(sessionId); const session = useObserve(sessionId);
const control = useControl(sessionId); const control = useControl(sessionId);
@ -45,6 +49,7 @@ export function Instructor() {
}, [scenarios.data, scenarioId]); }, [scenarios.data, scenarioId]);
function startLesson() { function startLesson() {
if (trainees.data?.length && !traineeId) return;
const id = crypto.randomUUID(); const id = crypto.randomUUID();
// Номер занятия живёт в адресе: монитор и АРМ курсанта открываются ссылкой. // Номер занятия живёт в адресе: монитор и АРМ курсанта открываются ссылкой.
window.history.replaceState(null, "", `/instructor?session=${id}`); window.history.replaceState(null, "", `/instructor?session=${id}`);
@ -56,8 +61,9 @@ export function Instructor() {
useEffect(() => { useEffect(() => {
if (!sessionId || control.status !== "open" || toStart.current !== sessionId) return; if (!sessionId || control.status !== "open" || toStart.current !== sessionId) return;
toStart.current = null; toStart.current = null;
control.start(scenarioId, mode, trainee || "Курсант"); const selected = trainees.data?.find((item) => item.id === traineeId);
}, [sessionId, control, scenarioId, mode, trainee]); control.start(scenarioId, mode, selected?.name || trainee || "Демо-курсант", exercise, undefined, selected?.id);
}, [sessionId, control, scenarioId, mode, trainee, traineeId, trainees.data, exercise]);
const links = sessionId const links = sessionId
? { ? {
@ -90,6 +96,17 @@ export function Instructor() {
</select> </select>
</td> </td>
</tr> </tr>
<tr>
<th>Упражнение</th>
<td>
<label className="mode-choice"><input type="radio" checked={exercise === "dds"}
onChange={() => setExercise("dds")} /> Диспетчер ДДС · готовая карточка</label>
<label className="mode-choice"><input type="radio" checked={exercise === "call"}
onChange={() => setExercise("call")} /> Оператор 112 · звонок</label>
<label className="mode-choice"><input type="radio" checked={exercise === "card"}
onChange={() => setExercise("card")} /> Оператор 112 · текстовая вводная</label>
</td>
</tr>
<tr> <tr>
<th>Режим</th> <th>Режим</th>
<td> <td>
@ -108,14 +125,27 @@ export function Instructor() {
<tr> <tr>
<th>Курсант</th> <th>Курсант</th>
<td> <td>
<input type="text" value={trainee} placeholder="фамилия" {trainees.data?.length ? (
<select value={traineeId} onChange={(event) => setTraineeId(event.target.value)}>
<option value="">Выберите учётную запись</option>
{trainees.data.map((item) => (
<option key={item.id} value={item.id}>{item.name}{item.group ? ` · ${item.group}` : ""}</option>
))}
</select>
) : (
<>
<input type="text" value={trainee} placeholder="имя для демо"
onChange={(event) => setTrainee(event.target.value)} /> onChange={(event) => setTrainee(event.target.value)} />
<small className="ref">Без учётной записи: личный отчёт курсанту недоступен.</small>
</>
)}
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<p> <p>
<button type="button" className="start" onClick={startLesson} disabled={!scenarioId}> <button type="button" className="start" onClick={startLesson}
disabled={!scenarioId || trainees.isLoading || Boolean(trainees.data?.length && !traineeId)}>
Начать занятие Начать занятие
</button> </button>
</p> </p>
@ -127,12 +157,12 @@ export function Instructor() {
</p> </p>
)} )}
{sessionId && !session.ended && ( {sessionId && !session.ended && (
<Director onInject={control.inject} disabled={control.status !== "open"} error={session.error} /> exercise === "call" && <Director onInject={control.inject} disabled={control.status !== "open"} error={session.error} />
)} )}
{links && ( {links && (
<table className="grid"> <table className="grid">
<tbody> <tbody>
<tr><th>АРМ курсанта</th><td><a href={links.trainee}>открыть</a></td></tr> <tr><th>АРМ курсанта</th><td><a href={exercise === "dds" ? links.dds : links.trainee}>открыть {exercise === "dds" ? "ДДС" : "112"}</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> <tr><th>АРМ ДДС</th><td><a href={links.dds}>открыть</a></td></tr>
</tbody> </tbody>
@ -204,6 +234,11 @@ export function Instructor() {
</section> </section>
</div> </div>
<ScenarioEditor scenarios={scenarios.data ?? []} onApproved={async (id) => {
setScenarioId(id);
await scenarios.refetch();
}} />
{session.report && <Debrief report={session.report} />} {session.report && <Debrief report={session.report} />}
</main> </main>
); );

View file

@ -12,6 +12,7 @@ import { InterviewTimer, Timers } from "@/features/timers/Timers";
import { display } from "@/features/kio-card/merge"; import { display } from "@/features/kio-card/merge";
import { useCall } from "@/features/call/useCall"; import { useCall } from "@/features/call/useCall";
import { sessionIdFromUrl } from "@/shared/api/session"; import { sessionIdFromUrl } from "@/shared/api/session";
import type { DDSCode } from "@/shared/types/generated";
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
connecting: "подключение", connecting: "подключение",
@ -25,6 +26,11 @@ const RESOLVE_LABELS = {
transfer_region: "передан в систему-112 другого региона", transfer_region: "передан в систему-112 другого региона",
} as const; } as const;
function ddsCode(value: unknown): DDSCode | undefined {
return value === "01" || value === "02" || value === "03" || value === "04" || value === "gkh"
? value : undefined;
}
export function Call() { export function Call() {
const [resolveComment, setResolveComment] = useState(""); const [resolveComment, setResolveComment] = useState("");
const [resolved, setResolved] = useState<"consultation" | "transfer_region" | null>(null); const [resolved, setResolved] = useState<"consultation" | "transfer_region" | null>(null);
@ -40,6 +46,39 @@ export function Call() {
); );
} }
if (call.briefing) {
return <main className="page">
<ModeBanner mode={call.briefing.mode} />
<h1>АРМ оператора 112 · учебная карточка</h1>
<p className="ref">Текстовое упражнение: входящий голосовой вызов и микрофон не используются.</p>
<table className="grid"><tbody>
<tr><th>Канал</th><td className={call.status === "open" ? "state-ok" : "state-warn"}>{STATUS_LABEL[call.status]}</td></tr>
<tr><th>Сценарий</th><td>{call.briefing.scenario_id}</td></tr>
<tr><th>Состояние</th><td>{call.cardSubmitted ? "карточка отправлена на оценку" : "карточка заполняется"}</td></tr>
</tbody></table>
<section style={{ maxWidth: 900, marginTop: 16 }}>
<h2>Вводная о происшествии</h2>
<div style={{ background: "#fff", border: "1px solid var(--line)", padding: 16, whiteSpace: "pre-wrap", lineHeight: 1.5 }}>
{call.briefing.text}
</div>
<p className="ref">Извлеките сведения из вводной, заполните поля карточки и проверьте предлагаемый список оповещения.</p>
<h2>Карточка информационного обмена</h2>
<KioCard state={call.card} required={call.briefing.required_fields}
readOnly={call.cardSubmitted || call.status !== "open"} onChange={call.patchKio} />
{!call.cardSubmitted && <p><button type="button" className="start" disabled={call.status !== "open"}
onClick={call.submitCard}>Отправить карточку на проверку</button></p>}
{call.error && <p className="state-violated" role="alert">{call.error}</p>}
{call.cardSubmitted && !call.report && !call.reportError && <p>Карточка отправлена. Ожидаем итоговую оценку…</p>}
{call.reportError && <p className="state-violated" role="alert">{call.reportError}</p>}
</section>
{call.report && <Debrief report={call.report} />}
</main>;
}
const service = ddsCode(display(call.card, "dds"));
const notify = display(call.card, "notify");
const autoRoute = Boolean(display(call.card, "incident_code")) && Array.isArray(notify) && notify.length > 0;
return ( return (
<main className="page"> <main className="page">
<StandStatus /> <StandStatus />
@ -85,8 +124,8 @@ export function Call() {
<p> <p>
<button <button
type="button" type="button"
disabled={!call.micOn || !display(call.card, "dds")} disabled={!call.micOn || (!service && !autoRoute)}
onClick={() => call.dispatch(display(call.card, "dds") as never)} onClick={() => call.dispatch(service)}
> >
Передать в ДДС Передать в ДДС
</button> </button>

View file

@ -19,7 +19,7 @@ export interface CallAnswer {
type: "call.answer"; type: "call.answer";
} }
export type CallEndReason = "hangup" | "dropped" | "instructor"; export type CallEndReason = "hangup" | "dropped" | "instructor" | "complete";
export interface CallEnded { export interface CallEnded {
type: "call.ended"; type: "call.ended";
@ -77,6 +77,16 @@ export interface CardBounce {
comment?: string; comment?: string;
} }
/** Текстовая вводная упражнения 112; эталонные значения сюда не входят. */
export interface CardBriefing {
type: "card.briefing";
scenario_id: string;
mode: SessionMode;
text: string;
required_fields: Array<string>;
card?: KIO | null;
}
/** Снимок КИО. После передачи не меняется — оператор не дописывает задним числом. */ /** Снимок КИО. После передачи не меняется — оператор не дописывает задним числом. */
export interface CardReceived { export interface CardReceived {
type: "card.received"; type: "card.received";
@ -88,6 +98,10 @@ export interface CardReceived {
/** Статус карточки целиком — считается из статусов служб, руками не ставится. */ /** Статус карточки целиком — считается из статусов служб, руками не ставится. */
export type CardStatus = "registered" | "processed" | "checked" | "not_notified" | "refused" | "not_completed" | "completed"; export type CardStatus = "registered" | "processed" | "checked" | "not_notified" | "refused" | "not_completed" | "completed";
export interface CardSubmit {
type: "card.submit";
}
/** Шесть осей радара. Проекция уже посчитанных метрик, без пересчёта весов. */ /** Шесть осей радара. Проекция уже посчитанных метрик, без пересчёта весов. */
export type Competency = "intake" | "interview" | "card" | "routing" | "norms" | "communication"; export type Competency = "intake" | "interview" | "card" | "routing" | "norms" | "communication";
@ -112,13 +126,18 @@ export interface CrewDispatched {
at: string; at: string;
} }
export interface CrewSelect {
type: "crew.select";
crew: string;
}
/** Дежурно-диспетчерская служба, в которую уходит карточка. */ /** Дежурно-диспетчерская служба, в которую уходит карточка. */
export type DDSCode = "01" | "02" | "03" | "04" | "gkh"; export type DDSCode = "01" | "02" | "03" | "04" | "gkh";
/** Передача в ДДС. Замораживает карточку снимком и останавливает опрос (`interview`). */ /** Передача в ДДС. При маршрутизации по ЕКП служба уже есть в notify. */
export interface DdsDispatch { export interface DdsDispatch {
type: "dds.dispatch"; type: "dds.dispatch";
service: DDSCode; service?: DDSCode | null;
} }
/** `immediate` — только для обрыва связи: рвёт TTS на полуслове. */ /** `immediate` — только для обрыва связи: рвёт TTS на полуслове. */
@ -152,6 +171,8 @@ export interface ErrorEvent {
/** Коды канала `error`. Фронт разбирает код, а не текст сообщения: текст — для человека, код — для поведения интерфейса. */ /** Коды канала `error`. Фронт разбирает код, а не текст сообщения: текст — для человека, код — для поведения интерфейса. */
export type ErrorKind = "session_not_found" | "call_not_started" | "hint_denied_in_exam" | "models_warming_up" | "directive_needs_network" | "scenario_invalid" | "unsupported_event" | "forbidden" | "internal"; export type ErrorKind = "session_not_found" | "call_not_started" | "hint_denied_in_exam" | "models_warming_up" | "directive_needs_network" | "scenario_invalid" | "unsupported_event" | "forbidden" | "internal";
export type Exercise = "call" | "dds" | "card";
/** Отметка в разборе. `fact` и `norm` — то самое обоснование. */ /** Отметка в разборе. `fact` и `norm` — то самое обоснование. */
export interface Finding { export interface Finding {
code: ErrorCode; code: ErrorCode;
@ -306,6 +327,27 @@ export type Outcome = "card" | "consultation" | "transfer_region";
/** `auto` — сервер распознал факт из речи, `operator` — эхо правки курсанта. */ /** `auto` — сервер распознал факт из речи, `operator` — эхо правки курсанта. */
export type PatchSource = "auto" | "operator"; export type PatchSource = "auto" | "operator";
export interface PhoneDial {
type: "phone.dial";
}
export interface PhoneReport {
type: "phone.report";
service: string;
crew: string;
phase: "dispatched" | "arrived" | "working" | "completed";
text: string;
at: string;
}
export interface PhoneReportRecord {
service: string;
crew: string;
phase: string;
text: string;
at: string;
}
/** Уточняющие поля 02 — полиция. */ /** Уточняющие поля 02 — полиция. */
export interface PoliceDetails { export interface PoliceDetails {
offence_kind?: string | null; offence_kind?: string | null;
@ -346,8 +388,10 @@ export interface ScenarioStart {
type: "scenario.start"; type: "scenario.start";
scenario_id: string; scenario_id: string;
trainee: string; trainee: string;
trainee_id?: string | null;
group_id?: string | null; group_id?: string | null;
mode: SessionMode; mode: SessionMode;
exercise?: Exercise;
} }
/** Коррекция оценки. Автооценка сохраняется рядом. */ /** Коррекция оценки. Автооценка сохраняется рядом. */
@ -432,6 +476,7 @@ export interface SessionSnapshot {
scenario_title: string; scenario_title: string;
level: Level; level: Level;
mode: SessionMode; mode: SessionMode;
exercise?: Exercise;
trainee_name?: string | null; trainee_name?: string | null;
started_at?: string | null; started_at?: string | null;
kio: KIO; kio: KIO;
@ -448,6 +493,10 @@ export interface SessionStop {
export type Speaker = "caller" | "operator"; export type Speaker = "caller" | "operator";
export interface StationFinish {
type: "station.finish";
}
/** Что видит диспетчер: службы, их статусы и что доступно дальше. Все поля обязательные, хотя списки и могут быть пустыми: снимок всегда собирается целиком, а необязательное поле уезжает в TypeScript как `| undefined` и заставляет фронт проверять то, чего не бывает. */ /** Что видит диспетчер: службы, их статусы и что доступно дальше. Все поля обязательные, хотя списки и могут быть пустыми: снимок всегда собирается целиком, а необязательное поле уезжает в TypeScript как `| undefined` и заставляет фронт проверять то, чего не бывает. */
export interface StationSnapshot { export interface StationSnapshot {
services: Array<string>; services: Array<string>;
@ -455,6 +504,9 @@ export interface StationSnapshot {
available: Record<string, Array<ServiceStatus>>; available: Record<string, Array<ServiceStatus>>;
card: CardStatus; card: CardStatus;
log: Array<StatusEntry>; log: Array<StatusEntry>;
crew_options?: Array<string>;
crew_selected?: string | null;
phone_reports?: Array<PhoneReportRecord>;
} }
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */ /** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
@ -552,17 +604,17 @@ export interface ZoneDecision {
// Союзы по каналам — смотри docs/arch/CONTRACT.md#каналы // Союзы по каналам — смотри docs/arch/CONTRACT.md#каналы
export type ServerToTrainee = CallIncoming | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | ErrorEvent; export type ServerToTrainee = CallIncoming | CardBriefing | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | ErrorEvent;
export type TraineeToServer = CallAnswer | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial; export type TraineeToServer = CallAnswer | CardSubmit | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial;
export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | ErrorEvent; export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | ErrorEvent;
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | ScenarioPublish | SessionStop; export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | ScenarioPublish | SessionStop;
export type ServerToStation = CardReceived | StationState | TimerTick | SessionEnded | ErrorEvent; export type ServerToStation = CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | ZoneDecision | CrewDispatched | CrewArrived; export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish | ZoneDecision | CrewDispatched | CrewArrived;
export type EventName = export type EventName =
"bg.start" "bg.start"
@ -577,10 +629,13 @@ export type EventName =
| "caller.utterance" | "caller.utterance"
| "card.ack" | "card.ack"
| "card.bounce" | "card.bounce"
| "card.briefing"
| "card.received" | "card.received"
| "card.status" | "card.status"
| "card.submit"
| "crew.arrived" | "crew.arrived"
| "crew.dispatched" | "crew.dispatched"
| "crew.select"
| "dds.dispatch" | "dds.dispatch"
| "director.inject" | "director.inject"
| "era.data" | "era.data"
@ -592,6 +647,8 @@ export type EventName =
| "kio.patch" | "kio.patch"
| "kio.state" | "kio.state"
| "mode.set" | "mode.set"
| "phone.dial"
| "phone.report"
| "reference.play" | "reference.play"
| "reference.started" | "reference.started"
| "scenario.publish" | "scenario.publish"
@ -602,6 +659,7 @@ export type EventName =
| "session.ended" | "session.ended"
| "session.snapshot" | "session.snapshot"
| "session.stop" | "session.stop"
| "station.finish"
| "station.state" | "station.state"
| "stt.final" | "stt.final"
| "stt.partial" | "stt.partial"

View file

@ -95,6 +95,21 @@ button:disabled { opacity: 0.5; cursor: default; }
.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%; }
/* Преподаватель редактирует копию шаблона до публикации. */
.scenario-editor { margin-top: 24px; padding: 12px 16px 16px; background: var(--panel); border: 1px solid var(--line); }
.scenario-editor h2 { margin-top: 0; }
.scenario-editor-create, .scenario-editor-open, .scenario-editor-actions { display: flex; align-items: end; flex-wrap: wrap; gap: 8px 12px; margin: 12px 0; }
.scenario-editor-create label, .scenario-editor-open label { display: grid; gap: 4px; min-width: 220px; font-size: 13px; }
.scenario-editor-create select, .scenario-editor-create input, .scenario-editor-open input { box-sizing: border-box; width: 100%; min-height: 34px; padding: 4px 6px; border: 1px solid var(--line); font: inherit; background: var(--panel); }
.scenario-editor-create label:first-child { flex: 1 1 350px; }
.scenario-editor-open label { flex: 1 1 350px; }
.scenario-editor-json { display: grid; gap: 6px; font-weight: 600; }
.scenario-editor-json textarea { box-sizing: border-box; width: 100%; min-height: 320px; padding: 10px; border: 1px solid var(--line); font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; resize: vertical; }
.scenario-editor-result { padding: 10px 12px; border: 1px solid var(--ok); background: #e7f4e9; }
.scenario-editor-result.violated { border-color: var(--violated); background: #fde8e8; }
.scenario-editor-result pre { margin: 8px 0 0; overflow-x: auto; white-space: pre-wrap; font-size: 12px; }
.scenario-editor-result ul { margin: 8px 0 0; }
/* Возврат карточки: список полей в две колонки, чтобы диспетчер не скроллил. */ /* Возврат карточки: список полей в две колонки, чтобы диспетчер не скроллил. */
.bounce-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; margin: 8px 0; } .bounce-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; margin: 8px 0; }
@ -102,3 +117,57 @@ button:disabled { opacity: 0.5; cursor: default; }
.stand { padding: 6px 24px; margin: -16px -24px 12px; font-weight: 600; } .stand { padding: 6px 24px; margin: -16px -24px 12px; font-weight: 600; }
.stand-warn { background: #fff4d6; border-bottom: 1px solid var(--warn); } .stand-warn { background: #fff4d6; border-bottom: 1px solid var(--warn); }
.stand-bad { background: #fde8e8; border-bottom: 1px solid var(--violated); color: var(--violated); } .stand-bad { background: #fde8e8; border-bottom: 1px solid var(--violated); color: var(--violated); }
/* АРМ ДДС: реестр остаётся плотной таблицей, карточка и связь — рядом. */
.dds-workspace { max-width: 1800px; margin: 0 auto; }
.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 span { color: #576273; font-size: 15px; font-weight: 400; }
.dds-eyebrow { color: #576273; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
.dds-connection { white-space: nowrap; font-size: 13px; }
.dds-panel { background: var(--panel); border: 1px solid var(--line); margin-bottom: 14px; min-width: 0; }
.dds-panel-title { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 9px 12px; background: #e8ebef; border-bottom: 1px solid var(--line); }
.dds-panel-title h2 { margin: 0; font-size: 15px; }
.dds-panel-title span { font-size: 12px; color: #576273; }
.dds-main { display: grid; grid-template-columns: minmax(0, 1fr) 340px; align-items: start; gap: 14px; margin-top: 14px; }
.dds-left, .dds-right { min-width: 0; }
.dds-table-wrap { width: 100%; overflow-x: auto; }
.dds-registry { width: 100%; border-collapse: collapse; font-size: 13px; }
.dds-registry th, .dds-registry td { padding: 8px 10px; border-right: 1px solid #d4d8dd; border-bottom: 1px solid #d4d8dd; text-align: left; vertical-align: top; }
.dds-registry th { background: #f1f3f5; white-space: nowrap; font-weight: 600; }
.dds-registry .dds-selected { background: #e8f0fb; box-shadow: inset 3px 0 #316798; }
.dds-registry td:last-child, .dds-registry th:last-child { border-right: 0; }
.dds-empty { padding: 20px !important; color: #66717b; }
.dds-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; }
.dds-summary > div { display: grid; gap: 3px; padding: 12px; border-right: 1px solid #e0e3e6; border-bottom: 1px solid #e0e3e6; }
.dds-summary small { color: #66717b; }
.dds-summary strong { font-weight: 500; overflow-wrap: anywhere; }
.dds-details { padding: 10px 12px; }
.dds-details summary { cursor: pointer; color: #23577e; font-weight: 600; }
.dds-details .kio { padding-top: 12px; }
.dds-deadline { padding: 10px 12px; font-weight: 600; border-bottom: 1px solid #e0e3e6; }
.dds-hint { margin: 10px 12px; color: #56616c; font-size: 12px; line-height: 1.4; }
.dds-status-action { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; }
.dds-status-action select { max-width: 190px; }
.dds-status-action input { min-width: 180px; flex: 1; }
.dds-status-action button { padding: 4px 8px; }
.dds-alert { padding: 10px 12px; margin: 0 0 12px; color: var(--violated); background: #fde8e8; border: 1px solid var(--violated); }
.dds-done { color: var(--ok); background: #e7f4e9; border-color: var(--ok); }
.dds-phone > label { display: block; margin: 14px 12px 5px; font-size: 13px; font-weight: 600; }
.dds-phone > select { display: block; width: calc(100% - 24px); margin: 0 12px; padding: 7px; box-sizing: border-box; }
.dds-dial { display: block; margin: 12px; width: calc(100% - 24px); text-align: center; background: #23577e; border-color: #23577e; color: white; font-weight: 600; }
.dds-dial:disabled { background: #70869a; }
.dds-report { margin: 12px; padding: 12px; border: 1px solid #8eafc8; background: #edf5fb; }
.dds-report small, .dds-report strong, .dds-report span { display: block; }
.dds-report small, .dds-report span { color: #56616c; font-size: 12px; }
.dds-report strong { margin-top: 4px; }
.dds-report p { line-height: 1.45; }
.dds-phone-idle { margin: 12px; padding: 18px 12px; border: 1px dashed #aab4bf; color: #66717b; text-align: center; font-size: 13px; }
.dds-reports { margin: 10px 0; padding-left: 22px; font-size: 12px; }
.dds-reports li { padding: 5px 0; border-bottom: 1px solid #e0e3e6; }
.dds-zone { padding: 12px; display: flex; align-items: center; flex-wrap: wrap; gap: 5px; font-size: 13px; }
.dds-secondary .bounce-fields { max-height: 200px; overflow-y: auto; font-size: 12px; }
.dds-secondary input[type="text"] { max-width: 100%; margin: 4px 0; }
.dds-finish { display: block; margin: 12px; }
@media (max-width: 900px) { .dds-main { grid-template-columns: 1fr; } .dds-right { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } }
@media (max-width: 640px) { .dds-right, .dds-summary { display: block; } .dds-header { align-items: flex-start; flex-direction: column; } }

View file

@ -7,8 +7,8 @@ export default defineConfig({
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
"/api": { target: "http://localhost:8000", changeOrigin: true }, "/api": { target: process.env.VITE_BACKEND_URL || "http://localhost:8000", changeOrigin: true },
"/ws": { target: "ws://localhost:8000", ws: true }, "/ws": { target: process.env.VITE_WS_BACKEND_URL || "ws://localhost:8000", ws: true },
}, },
}, },
resolve: { alias: { "@": "/src" } }, resolve: { alias: { "@": "/src" } },