From cec84ffcd030257d851ecf45824cc604dfa1590c Mon Sep 17 00:00:00 2001 From: andreysk0304 Date: Mon, 21 Sep 2026 17:40:54 +0300 Subject: [PATCH] Implement local task 09 training workflows --- Makefile | 8 +- backend/Dockerfile | 5 + backend/app/api/http/scenarios.py | 103 +++- backend/app/api/http/sessions.py | 46 +- backend/app/api/ws/call.py | 99 +++- backend/app/api/ws/control.py | 133 ++++- backend/app/api/ws/station.py | 97 +++- backend/app/db/repo.py | 7 +- backend/app/domain/events.py | 58 ++- backend/app/domain/kio.py | 8 +- backend/app/domain/statuses.py | 14 +- backend/app/main.py | 15 + backend/app/scenarios/editor.py | 100 ++++ backend/app/scenarios/store.py | 69 +++ backend/app/scoring/card.py | 70 +++ backend/app/scoring/dispatcher.py | 60 +++ backend/app/scoring/export.py | 228 +++++++++ backend/app/scoring/report.py | 13 +- backend/app/scoring/taxonomy.py | 7 + backend/app/session/finish.py | 54 +- backend/app/session/hub.py | 5 +- backend/app/session/journal.py | 12 +- backend/app/session/state.py | 29 +- backend/pyproject.toml | 1 + backend/tests/test_card_exercise.py | 125 +++++ backend/tests/test_dds_exercise.py | 141 ++++++ backend/tests/test_ekp.py | 11 + backend/tests/test_report_export.py | 121 +++++ backend/tests/test_scenario_editor.py | 140 ++++++ backend/tests/test_session_access.py | 64 +++ backend/tests/test_ws.py | 56 +++ backend/tests/test_ws_ownership.py | 63 +++ backend/uv.lock | 106 +++- docker-compose.yml | 9 +- frontend/src/features/auth/useAuth.ts | 2 +- frontend/src/features/call/useCall.ts | 55 +- frontend/src/features/debrief/Debrief.tsx | 4 + .../features/instructor/ScenarioEditor.tsx | 189 +++++++ .../src/features/instructor/useControl.ts | 10 +- frontend/src/pages/dds/Dds.tsx | 469 +++++++++--------- frontend/src/pages/instructor/Instructor.tsx | 51 +- frontend/src/pages/trainee/Call.tsx | 43 +- frontend/src/shared/types/generated.ts | 72 ++- frontend/src/styles.css | 69 +++ frontend/vite.config.ts | 4 +- 45 files changed, 2679 insertions(+), 366 deletions(-) create mode 100644 backend/app/scenarios/editor.py create mode 100644 backend/app/scoring/card.py create mode 100644 backend/app/scoring/export.py create mode 100644 backend/tests/test_card_exercise.py create mode 100644 backend/tests/test_dds_exercise.py create mode 100644 backend/tests/test_report_export.py create mode 100644 backend/tests/test_scenario_editor.py create mode 100644 backend/tests/test_session_access.py create mode 100644 backend/tests/test_ws_ownership.py create mode 100644 frontend/src/features/instructor/ScenarioEditor.tsx diff --git a/Makefile b/Makefile index 8b2f699..5cef303 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,9 @@ help: ## Список целей dev: ## Поднять стенд: postgres + backend --reload + frontend $(COMPOSE) up --build +offline: ## Поднять карточки/ДДС из заранее собранных локальных образов, без скачивания + $(COMPOSE) up --pull never --no-build + down: ## Погасить стенд $(COMPOSE) down @@ -47,6 +50,9 @@ backup: ## Резервная копия базы в backend/backups/ (для cr users: ## Завести учётные записи ролей и напечатать пароли: make users force=1 cd backend && $(UV) run python scripts/users.py +users-docker: ## Завести учётные записи внутри локального контейнера (после make offline) + $(COMPOSE) exec backend python scripts/users.py + seed: ## Залить сценарии из /scenarios в БД cd backend && $(UV) run python scripts/seed.py @@ -77,4 +83,4 @@ pregen: ## Офлайн-таблицы реплик: make pregen [s=<сцена demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт ./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 diff --git a/backend/Dockerfile b/backend/Dockerfile index a498151..d30b636 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,6 +5,11 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv WORKDIR /app 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, а не отдельным списком: ручной список разошёлся # с проектом, и бэкенд в контейнере упал на первом же новом пакете (num2words). # Голосовой контур (группа voice: torch, onnx-asr) в образ не входит: под WSL diff --git a/backend/app/api/http/scenarios.py b/backend/app/api/http/scenarios.py index e96b517..e9cae59 100644 --- a/backend/app/api/http/scenarios.py +++ b/backend/app/api/http/scenarios.py @@ -10,15 +10,116 @@ `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.editor import validate +from app.scenarios.loader import ScenarioError router = APIRouter(prefix="/api/scenarios", tags=["scenarios"]) 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("") async def listing() -> list[dict]: return [ diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py index 4afb4f7..a39e10a 100644 --- a/backend/app/api/http/sessions.py +++ b/backend/app/api/http/sessions.py @@ -7,16 +7,17 @@ from datetime import datetime 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 sqlalchemy.ext.asyncio import AsyncSession from app.api.auth import audit, require from app.db import repo 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.scoring.report import build as build_report +from app.scoring.export import to_csv, to_pdf from app.domain.roles import Role from app.session.hub import hub @@ -57,7 +58,8 @@ def _out(session) -> SessionOut: @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 trainee = await repo.ensure_trainee(db, body.trainee, group) if body.trainee else None 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) -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) if session is None: 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) @@ -84,16 +89,19 @@ class ChecklistItemOut(BaseModel): @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) if state is None: 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: raise HTTPException(status_code=409, detail="call_not_ended") 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) if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id: 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: raise HTTPException(status_code=409, detail="score_not_ready") 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) async def override(session_id: UUID, body: ScoreOverride, request: Request) -> SessionReport: """Тренажёр готовит материал, преподаватель имеет последнее слово. @@ -177,6 +211,8 @@ async def listing( who = require(request) # Обучающийся видит только свою историю, что бы он ни передал в фильтре. 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 rows = await repo.history( db, diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py index 2c4be98..bc73106 100644 --- a/backend/app/api/ws/call.py +++ b/backend/app/api/ws/call.py @@ -14,13 +14,18 @@ from pydantic import TypeAdapter, ValidationError from app.domain.events import ( StationState, + CallIncoming, CallEnded, CallEndReason, CallStarted, ErrorEvent, ErrorKind, + Exercise, HintShown, KioState, + KioPatchOut, + PatchSource, + ScoreReady, SessionEnded, SessionMode, TimerTick, @@ -33,6 +38,7 @@ from app.api.auth import principal_of from app.domain.roles import Role from app.session.hub import hub from app.session.state import now_utc +from app.domain.kio import ResponseStatus from app.voice.models import get_voice_models 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: + 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: + 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": state.on_event("call.answer") state.started_at = now_utc() @@ -93,7 +129,17 @@ async def _handle(session_id: UUID, state, event) -> None: _start_voice(session_id, state) case "kio.patch": + old_code, old_notify = state.kio.incident_code, list(state.kio.notify) 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)) @@ -121,8 +167,19 @@ async def _handle(session_id: UUID, state, event) -> None: await hub.journal.hint(session_id, checklist_id, question, now_utc()) 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.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()) @@ -227,8 +284,40 @@ async def call(ws: WebSocket, session_id: UUID) -> None: ) await ws.close() 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: + 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)) try: while True: @@ -238,7 +327,13 @@ async def call(ws: WebSocket, session_id: UUID) -> None: # Бинарные кадры — аудио, текстовые — события. Направление определяется # каналом, обёртки JSON вокруг звука нет (docs/arch/CONTRACT.md). if message.get("bytes") is not None: - _on_audio(session_id, state, message["bytes"]) + if state.exercise is Exercise.CALL: + _on_audio(session_id, state, message["bytes"]) + else: + hub.to_trainee(session_id, ErrorEvent( + code=ErrorKind.UNSUPPORTED_EVENT, + message="Аудио не используется в этом упражнении", + )) continue try: payload = json.loads(message.get("text") or "") diff --git a/backend/app/api/ws/control.py b/backend/app/api/ws/control.py index 9a463fd..d1b5961 100644 --- a/backend/app/api/ws/control.py +++ b/backend/app/api/ws/control.py @@ -9,6 +9,7 @@ """ import logging +import re from uuid import UUID, uuid4 from fastapi import APIRouter, WebSocket, WebSocketDisconnect @@ -21,8 +22,9 @@ from app.domain.events import ( ErrorKind, ScoreReady, CallIncoming, - ErrorEvent, - ErrorKind, + CardBriefing, + Exercise, + StationState, InstructorToServer, InstructorNoteShown, ModeSet, @@ -42,6 +44,7 @@ from app.api.auth import audit, principal_of from app.domain.roles import Role from app.session.hub import hub 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.pipeline import FILLERS, prefetch @@ -51,6 +54,27 @@ router = APIRouter() _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: scenario = store.get(event.scenario_id) if scenario is None: @@ -61,9 +85,10 @@ async def _start(session_id: UUID, event, who=None) -> None: return attempt = 1 + recorded_trainee_id = event.trainee_id if hub.journal: - attempt = await hub.journal.start_lesson( - session_id, scenario.id, event.mode.value, event.trainee + attempt, recorded_trainee_id = await hub.journal.start_lesson( + 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, level=scenario.level.value, mode=event.mode, + exercise=event.exercise, scenario=scenario.model_copy(deep=True), required_fields=scenario.required_fields, trainee_name=event.trainee, - trainee_id=getattr(event, "trainee_id", None), + trainee_id=recorded_trainee_id, attempt=attempt, ) - embedder = get_embedder() - if embedder is not None: - state.slots = SlotMachine(state.scenario, embedder) - state.persona = PersonaState(state.scenario.persona) - state.caller = build_caller(scenario.id) + if event.exercise is Exercise.CALL: + embedder = get_embedder() + if embedder is not None: + state.slots = SlotMachine(state.scenario, embedder) + state.persona = PersonaState(state.scenario.persona) + 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) + 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). - models = get_voice_models() - if models is not None: - asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()])) - state.on_event("call.incoming") + if event.exercise is Exercise.CALL: + models = get_voice_models() + if models is not None: + asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()])) + state.on_event("call.incoming") + elif event.exercise is Exercise.DDS: + state.on_event("dds.dispatch") hub.start_ticker(session_id) if who is not None: # Запуск занятия меняет чужой результат — значит попадает в журнал @@ -103,29 +162,45 @@ async def _start(session_id: UUID, event, who=None) -> None: f"{scenario.id}, режим {event.mode.value}, курсант {event.trainee}") ) - hub.to_trainee( - session_id, - CallIncoming( - scenario_id=scenario.id, - caller_number="+7 (495) 000-00-00", - level=scenario.level, - mode=event.mode, - scenario=scenario.model_copy(deep=True), - required_fields=scenario.required_fields, - ), - ) + if event.exercise is Exercise.CALL: + hub.to_trainee( + session_id, + CallIncoming( + scenario_id=scenario.id, + caller_number="+7 (495) 000-00-00", + level=scenario.level, + mode=event.mode, + required_fields=[field for field in 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, state.snapshot()) async def _stop(session_id: UUID) -> None: state = hub.get(session_id) - if state is None: + if state is None or state.ended: return state.ended_at = now_utc() state.end_reason = CallEndReason.INSTRUCTOR hub.stop_ticker(session_id) 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: 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) if state is None: 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) if result.needs_network: hub.to_observers(session_id, ErrorEvent( diff --git a/backend/app/api/ws/station.py b/backend/app/api/ws/station.py index 8a3cccc..83c687a 100644 --- a/backend/app/api/ws/station.py +++ b/backend/app/api/ws/station.py @@ -1,7 +1,7 @@ """Сокет диспетчера ДДС. -**Только JSON: аудио здесь нет вообще** — значит, нет ни VAD, ни распознавания, -ни синтеза. Голосовой контур станция не трогает (docs/arch/CONTRACT.md). +Учебный исходящий звонок бригаде имитируется событиями JSON. Для целевого +локального прототипа не нужна внешняя телефония или аудиомодель. Самая ценная механика цепочки — `card.bounce`: диспетчер видит, что не указан этаж, и отбивает карточку обратно. Неполнота КИО перестаёт быть процентом @@ -15,20 +15,45 @@ from uuid import UUID from fastapi import APIRouter, WebSocket, WebSocketDisconnect from pydantic import TypeAdapter, ValidationError -from app.domain.events import ErrorEvent, ErrorKind, StationState, StationToServer -from app.domain.statuses import PRIMARY, ServiceStatus, StatusError +from app.domain.events import ( + 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.domain.roles import Role from app.session.hub import hub from app.session.state import now_utc +from app.session.finish import finish log = logging.getLogger(__name__) router = APIRouter() _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: + if state.ended: + _error(session_id, "Занятие уже завершено") + return match event.type: case "card.ack": # Подтверждение приёма — это статус «Принята» у главной службы. @@ -43,6 +68,16 @@ async def _handle(session_id: UUID, state, event) -> None: except StatusError: pass # статус уже стоит: повторное нажатие ничего не меняет 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: state.set_service_status( event.service, event.status, event.comment, author="диспетчер" @@ -56,6 +91,55 @@ async def _handle(session_id: UUID, state, event) -> None: # Первичный статус останавливает норматив 30 секунд. if event.status in PRIMARY: 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": # Карточка вернулась: в разборе это E6 с конкретной причиной. 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() 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: sender = asyncio.create_task(_pump(ws, queue)) diff --git a/backend/app/db/repo.py b/backend/app/db/repo.py index a297b1d..15b7b14 100644 --- a/backend/app/db/repo.py +++ b/backend/app/db/repo.py @@ -52,6 +52,7 @@ async def ensure_session( scenario_id: str, mode: str, trainee_name: str | None = None, + trainee_id: UUID | None = None, group_name: str | None = None, ) -> Session: """Занятие, запущенное с пульта, должно иметь строку в журнале. @@ -64,7 +65,11 @@ async def ensure_session( return existing 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( db, scenario_id=scenario_id, diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py index 4586f06..b644e41 100644 --- a/backend/app/domain/events.py +++ b/backend/app/domain/events.py @@ -34,6 +34,12 @@ class SessionMode(StrEnum): SELF = "self" +class Exercise(StrEnum): + CALL = "call" + DDS = "dds" + CARD = "card" + + class Speaker(StrEnum): CALLER = "caller" OPERATOR = "operator" @@ -53,6 +59,7 @@ class CallEndReason(StrEnum): HANGUP = "hangup" DROPPED = "dropped" INSTRUCTOR = "instructor" + COMPLETE = "complete" class PatchSource(StrEnum): @@ -112,6 +119,17 @@ class CallIncoming(BaseModel): 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): type: Literal["call.started"] = "call.started" started_at: datetime @@ -215,6 +233,7 @@ class ErrorEvent(BaseModel): ServerToTrainee = Annotated[ CallIncoming + | CardBriefing | CallStarted | SttPartial | SttFinal @@ -244,6 +263,10 @@ class CallAnswer(BaseModel): type: Literal["call.answer"] = "call.answer" +class CardSubmit(BaseModel): + type: Literal["card.submit"] = "card.submit" + + class KioPatchIn(BaseModel): """Правка карточки. Дебаунс 300 мс, шлётся только дельта.""" @@ -266,10 +289,10 @@ class SelfAssessmentSubmit(BaseModel): class DdsDispatch(BaseModel): - """Передача в ДДС. Замораживает карточку снимком и останавливает опрос (`interview`).""" + """Передача в ДДС. При маршрутизации по ЕКП служба уже есть в notify.""" type: Literal["dds.dispatch"] = "dds.dispatch" - service: DDSCode + service: DDSCode | None = None class CallHangup(BaseModel): @@ -297,6 +320,7 @@ class CallResolve(BaseModel): TraineeToServer = Annotated[ CallAnswer + | CardSubmit | KioPatchIn | HintRequest | SelfAssessmentSubmit @@ -320,6 +344,7 @@ class SessionSnapshot(BaseModel): scenario_title: str level: Level mode: SessionMode + exercise: Exercise = Exercise.CALL trainee_name: str | None = None started_at: datetime | None = None kio: KIO @@ -394,8 +419,10 @@ class ScenarioStart(BaseModel): type: Literal["scenario.start"] = "scenario.start" scenario_id: str trainee: str + trainee_id: UUID | None = None group_id: str | None = None mode: SessionMode + exercise: Exercise = Exercise.CALL class DirectorInject(BaseModel): @@ -486,6 +513,28 @@ class ServiceStatusSet(BaseModel): 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): """Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше.""" @@ -509,12 +558,13 @@ class CrewArrived(BaseModel): ServerToStation = Annotated[ - CardReceived | StationState | TimerTick | SessionEnded | ErrorEvent, + CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent, Field(discriminator="type"), ] StationToServer = Annotated[ - CardAck | CardBounce | ServiceStatusSet | ZoneDecision | CrewDispatched | CrewArrived, + CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish + | ZoneDecision | CrewDispatched | CrewArrived, Field(discriminator="type"), ] diff --git a/backend/app/domain/kio.py b/backend/app/domain/kio.py index 035bacd..e54584d 100644 --- a/backend/app/domain/kio.py +++ b/backend/app/domain/kio.py @@ -193,10 +193,10 @@ def derive_incident(card: KIO) -> KIO: keys = modifiers(card) data = card.model_dump() data["incident_code"] = found.code if found else None - computed = ekp.notify_list(found.code, keys) if found else [] - # Добавленную вручную службу не теряем: удалить её оператор не может. - manual = [name for name in card.notify if name not in ekp.notify_list(data["incident_code"] or "", keys)] - data["notify"] = computed + manual + # `notify` недоступен оператору для записи (READ_ONLY_FIELDS), поэтому + # хранить прошлый авторасчёт как «добавленные вручную службы» нельзя: + # при снятии признака газа МОСГАЗ должен исчезнуть из нового расчёта. + data["notify"] = ekp.notify_list(found.code, keys) if found else [] return KIO.model_validate(data) diff --git a/backend/app/domain/statuses.py b/backend/app/domain/statuses.py index 2242d77..c3c19db 100644 --- a/backend/app/domain/statuses.py +++ b/backend/app/domain/statuses.py @@ -126,6 +126,14 @@ class StatusEntry(BaseModel): author: str = "" +class PhoneReportRecord(BaseModel): + service: str + crew: str + phase: str + text: str + at: datetime + + class StatusError(ValueError): """Переход запрещён автоматом или нет обязательного комментария.""" @@ -168,7 +176,8 @@ def card_status( latest = {service: current(entries, service) for service in services} 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: return CardStatus.NOT_NOTIFIED @@ -196,3 +205,6 @@ class StationSnapshot(BaseModel): available: dict[str, list[ServiceStatus]] card: CardStatus log: list[StatusEntry] + crew_options: list[str] = [] + crew_selected: str | None = None + phone_reports: list[PhoneReportRecord] = [] diff --git a/backend/app/main.py b/backend/app/main.py index 74971d9..1945bb4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,9 +1,11 @@ """Сборка приложения. Роутеры подключаются по мере готовности — см. tasks/.""" +import asyncio import logging from contextlib import asynccontextmanager from fastapi import FastAPI +from sqlalchemy.exc import SQLAlchemyError from starlette.middleware.sessions import SessionMiddleware from pathlib import Path @@ -47,6 +49,19 @@ async def lifespan(app: FastAPI): raise RuntimeError(f"библиотека сценариев не прошла проверку: {exc}") from exc 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()) diff --git a/backend/app/scenarios/editor.py b/backend/app/scenarios/editor.py new file mode 100644 index 0000000..90b8796 --- /dev/null +++ b/backend/app/scenarios/editor.py @@ -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) diff --git a/backend/app/scenarios/store.py b/backend/app/scenarios/store.py index 31a4d83..9a32270 100644 --- a/backend/app/scenarios/store.py +++ b/backend/app/scenarios/store.py @@ -5,6 +5,7 @@ """ from pathlib import Path +from uuid import uuid4 from sqlalchemy import select 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.scenarios.loader import load_library from app.scenarios.schema import Scenario +from app.scenarios import editor _library: dict[str, Scenario] = {} @@ -29,6 +31,11 @@ def all_scenarios() -> list[Scenario]: return list(_library.values()) +def publish(scenario: Scenario) -> None: + """Сделать утверждённую версию доступной запуску занятия немедленно.""" + _library[scenario.id] = scenario + + def load_from_disk(root: Path) -> list[Scenario]: scenarios = load_library(root) set_library(scenarios) @@ -51,3 +58,65 @@ async def seed(db: AsyncSession, scenarios: list[Scenario]) -> int: row.body = payload await db.commit() 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 diff --git a/backend/app/scoring/card.py b/backend/app/scoring/card.py new file mode 100644 index 0000000..3c68579 --- /dev/null +++ b/backend/app/scoring/card.py @@ -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 diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py index 258c405..f5a801b 100644 --- a/backend/app/scoring/dispatcher.py +++ b/backend/app/scoring/dispatcher.py @@ -18,6 +18,8 @@ from app.domain.statuses import ( current, ) 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) @@ -123,3 +125,61 @@ def evaluate_dispatcher( ) 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 diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py new file mode 100644 index 0000000..2774586 --- /dev/null +++ b/backend/app/scoring/export.py @@ -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", "
") + 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"{escape(label)}: {escape(str(value))}", base)) + if report.overridden_by: + story.append(Paragraph(f"Оценка изменена: {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() diff --git a/backend/app/scoring/report.py b/backend/app/scoring/report.py index 0baaac0..d1248c2 100644 --- a/backend/app/scoring/report.py +++ b/backend/app/scoring/report.py @@ -16,6 +16,8 @@ from app.domain.events import ( SelfAssessment, SelfAssessmentDiff, SessionReport, + Speaker, + Exercise, ) from app.domain.taxonomy import Finding from app.scenarios.schema import Scenario @@ -43,7 +45,12 @@ def assess_difference( def build(session_id: UUID, state, scenario: Scenario) -> SessionReport: 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) self_assessment = None @@ -61,7 +68,7 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport: step.checklist_id for step in reference.steps 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( session_id=session_id, @@ -77,7 +84,7 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport: reference_questions=[ HintShown(checklist_id=step.checklist_id, question=step.question) for step in reference.steps - ], + ] if state.exercise is not Exercise.CARD else [], missed_checklist=missed_checklist, hints_used=[ HintUsage(checklist_id=checklist_id, question=questions.get(checklist_id, ""), at=at) diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py index ead5fab..0f6a5eb 100644 --- a/backend/app/scoring/taxonomy.py +++ b/backend/app/scoring/taxonomy.py @@ -21,6 +21,13 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = { "address": (ErrorCode.E5, Competency.CARD), "victims_count": (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), } #: Вес метрики в детерминированной оценке. diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py index a94975a..997677e 100644 --- a/backend/app/session/finish.py +++ b/backend/app/session/finish.py @@ -10,12 +10,13 @@ import logging 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.scenarios import store from app.scoring.competency import radar -from app.scoring.dispatcher import evaluate_dispatcher -from app.scoring.gost import evaluate +from app.scoring.card import evaluate_card +from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher +from app.scoring.gost import GostResult, evaluate from app.session.hub import hub log = logging.getLogger(__name__) @@ -27,27 +28,36 @@ async def finish(session_id: UUID, state) -> None: if scenario is None: return - result = evaluate( - scenario=scenario, - kio=state.kio, - timers=state.timers, - revealed_facts=[fact.id for fact in state.slots.revealed_facts()] if state.slots else None, - refined_facts=list(state.slots.refined) if state.slots else None, - end_reason=state.end_reason, - bounced_fields=state.bounced_fields, - resolved_outcome=state.resolved_outcome, - dispatched=state.dispatched_card is not None, - ) + 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( + scenario=scenario, + kio=state.kio, + timers=state.timers, + revealed_facts=[fact.id for fact in state.slots.revealed_facts()] if state.slots else None, + refined_facts=list(state.slots.refined) if state.slots else None, + end_reason=state.end_reason, + bounced_fields=state.bounced_fields, + resolved_outcome=state.resolved_outcome, + dispatched=state.dispatched_card is not None, + ) # Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут # рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии # участвуют оба (docs/spec/DATASET.md#статусы-реагирования). - dispatcher_findings = evaluate_dispatcher( - entries=state.status_log, - services=state.notified_services(), - deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms, - elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK), - ) - result.findings.extend(dispatcher_findings) + if state.dispatched_card is not None and state.exercise is not Exercise.CARD: + dispatcher_findings = evaluate_dispatcher( + entries=state.status_log, + services=state.notified_services(), + deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms, + elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK), + ) + result.findings.extend(dispatcher_findings) + result.metrics.extend(dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms)) # Сводка числами: по ней считается дельта между попытками в профиле. # Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам. @@ -84,5 +94,5 @@ async def finish(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)) diff --git a/backend/app/session/hub.py b/backend/app/session/hub.py index c179cfa..37ca43b 100644 --- a/backend/app/session/hub.py +++ b/backend/app/session/hub.py @@ -28,8 +28,9 @@ class Journal(Protocol): но молчать об ошибке записи нельзя.""" async def start_lesson( - self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None - ) -> int: ... + self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None, + trainee_id: UUID | None = None, + ) -> tuple[int, UUID | 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 note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ... diff --git a/backend/app/session/journal.py b/backend/app/session/journal.py index 003f3ae..cb24f93 100644 --- a/backend/app/session/journal.py +++ b/backend/app/session/journal.py @@ -31,9 +31,10 @@ class DbJournal: log.exception("журнал: запись не удалась") async def start_lesson( - self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None - ) -> int: - """Завести сессию в журнале и вернуть номер попытки. + self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None, + trainee_id: UUID | None = None, + ) -> tuple[int, UUID | None]: + """Завести сессию в журнале и вернуть номер попытки и ID курсанта. Если база недоступна, занятие всё равно идёт: номер попытки деградирует до первого, и это видно в логе. @@ -46,11 +47,12 @@ class DbJournal: scenario_id=scenario_id, mode=mode, trainee_name=trainee_name, + trainee_id=trainee_id, ) - return row.attempt + return row.attempt, row.trainee_id except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию log.exception("журнал: сессию завести не удалось") - return 1 + return 1, trainee_id async def utterance(self, session_id: UUID, entry) -> None: await self._write( diff --git a/backend/app/session/state.py b/backend/app/session/state.py index cc77355..0e47cea 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -12,6 +12,7 @@ from uuid import UUID from app.domain.events import ( CallEndReason, + Exercise, Mood, SessionMode, SessionSnapshot, @@ -27,12 +28,13 @@ from app.domain.statuses import ( ServiceStatus, StationSnapshot, StatusEntry, + PhoneReportRecord, card_status, check, current, ) 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 @@ -48,6 +50,7 @@ class SessionState: scenario_title: str level: str mode: SessionMode + exercise: Exercise = Exercise.CALL required_fields: list[str] = field(default_factory=list) trainee_name: str | None = None #: Чьё это занятие. Проставляется при запуске, когда курсант известен @@ -96,6 +99,9 @@ class SessionState: dds_log: list[tuple[str, datetime, str | None]] = 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). resolved_outcome: str | None = None resolve_comment: str = "" @@ -119,10 +125,12 @@ class SessionState: self.kio = apply_patch(self.kio, fields) 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_at = now_utc() return self.dispatched_card @@ -133,6 +141,14 @@ class SessionState: card = self.dispatched_card or self.kio 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( self, service: str, status: ServiceStatus, comment: str = "", author: str = "" ) -> StatusEntry: @@ -161,6 +177,9 @@ class SessionState: available={service: list(NEXT[status]) for service, status in statuses.items()}, card=card_status(self.status_log, services, notify_deadline_passed=overdue), 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): @@ -169,7 +188,8 @@ class SessionState: return CardReceived( 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(), ) @@ -186,6 +206,7 @@ class SessionState: scenario_title=self.scenario_title, level=self.level, mode=self.mode, + exercise=self.exercise, trainee_name=self.trainee_name, started_at=self.started_at, kio=self.kio, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d606b5e..e78b037 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "asyncpg>=0.29", "httpx>=0.27", "pyyaml>=6.0", + "reportlab>=4.0", "structlog>=24.1", # Эмбеддинги для слот-автомата: матчинг вопроса к чек-листу. Нужны ядру, # а не только голосу, поэтому не в группе voice. diff --git a/backend/tests/test_card_exercise.py b/backend/tests/test_card_exercise.py new file mode 100644 index 0000000..ac0b643 --- /dev/null +++ b/backend/tests/test_card_exercise.py @@ -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) diff --git a/backend/tests/test_dds_exercise.py b/backend/tests/test_dds_exercise.py new file mode 100644 index 0000000..e5e7170 --- /dev/null +++ b/backend/tests/test_dds_exercise.py @@ -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) diff --git a/backend/tests/test_ekp.py b/backend/tests/test_ekp.py index 4da2e5c..866e872 100644 --- a/backend/tests/test_ekp.py +++ b/backend/tests/test_ekp.py @@ -120,3 +120,14 @@ def test_gasified_object_brings_mosgaz(): KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True)) ) 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 diff --git a/backend/tests/test_report_export.py b/backend/tests/test_report_export.py new file mode 100644 index 0000000..7ddfe68 --- /dev/null +++ b/backend/tests/test_report_export.py @@ -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 diff --git a/backend/tests/test_scenario_editor.py b/backend/tests/test_scenario_editor.py new file mode 100644 index 0000000..9334224 --- /dev/null +++ b/backend/tests/test_scenario_editor.py @@ -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 diff --git a/backend/tests/test_session_access.py b/backend/tests/test_session_access.py new file mode 100644 index 0000000..c2305e8 --- /dev/null +++ b/backend/tests/test_session_access.py @@ -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 diff --git a/backend/tests/test_ws.py b/backend/tests/test_ws.py index 469c77b..4121d80 100644 --- a/backend/tests/test_ws.py +++ b/backend/tests/test_ws.py @@ -117,6 +117,62 @@ def test_interview_timer_starts_on_answer_and_stops_on_dispatch(client): 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): with lesson(client) as (session_id, _): with client.websocket_connect(f"/ws/observe/{session_id}") as observer: diff --git a/backend/tests/test_ws_ownership.py b/backend/tests/test_ws_ownership.py new file mode 100644 index 0000000..1bc8931 --- /dev/null +++ b/backend/tests/test_ws_ownership.py @@ -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("владелец занятия не допущен") diff --git a/backend/uv.lock b/backend/uv.lock index c73f676..333fb9e 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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" }, ] +[[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]] name = "click" version = "8.5.0" @@ -365,6 +406,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyyaml" }, + { name = "reportlab" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "structlog" }, { name = "tokenizers" }, @@ -400,6 +442,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "reportlab", specifier = ">=4.0" }, { name = "scipy", marker = "extra == 'voice'", specifier = ">=1.11" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, { 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" }, ] +[[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]] name = "pluggy" 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" }, ] +[[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]] name = "scipy" version = "1.17.1" @@ -823,13 +901,13 @@ resolution-markers = [ "sys_platform == 'darwin'", ] dependencies = [ - { name = "filelock", marker = "sys_platform == 'darwin'" }, - { name = "fsspec", marker = "sys_platform == 'darwin'" }, - { name = "jinja2", marker = "sys_platform == 'darwin'" }, - { name = "networkx", marker = "sys_platform == 'darwin'" }, - { name = "setuptools", marker = "sys_platform == 'darwin'" }, - { name = "sympy", marker = "sys_platform == 'darwin'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "typing-extensions" }, ] 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" }, @@ -843,13 +921,13 @@ resolution-markers = [ "sys_platform != 'darwin'", ] dependencies = [ - { name = "filelock", marker = "sys_platform != 'darwin'" }, - { name = "fsspec", marker = "sys_platform != 'darwin'" }, - { name = "jinja2", marker = "sys_platform != 'darwin'" }, - { name = "networkx", marker = "sys_platform != 'darwin'" }, - { name = "setuptools", marker = "sys_platform != 'darwin'" }, - { name = "sympy", marker = "sys_platform != 'darwin'" }, - { name = "typing-extensions", marker = "sys_platform != 'darwin'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "typing-extensions" }, ] 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" }, diff --git a/docker-compose.yml b/docker-compose.yml index b8706d2..9ecba32 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,11 +15,14 @@ services: backend: build: ./backend + image: lct-hack-backend:local # Один воркер принципиально: состояние живой сессии и реестр наблюдателей # живут в памяти процесса (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: DATABASE_URL: postgresql+asyncpg://lct:lct@postgres:5432/lct + OFFLINE: "true" + VOICE_ENABLED: "false" volumes: - ./backend:/app - ./scenarios:/scenarios:ro @@ -30,10 +33,14 @@ services: frontend: build: ./frontend + image: lct-hack-frontend:local command: npm run dev -- --host 0.0.0.0 volumes: - ./frontend:/app - /app/node_modules + environment: + VITE_BACKEND_URL: http://backend:8000 + VITE_WS_BACKEND_URL: ws://backend:8000 ports: ["5173:5173"] depends_on: [backend] diff --git a/frontend/src/features/auth/useAuth.ts b/frontend/src/features/auth/useAuth.ts index 2cb22c7..334f8c6 100644 --- a/frontend/src/features/auth/useAuth.ts +++ b/frontend/src/features/auth/useAuth.ts @@ -60,5 +60,5 @@ export function useLogout() { export const SCREENS: Record = { admin: ["/admin", "/profile"], instructor: ["/instructor", "/wall", "/profile", "/dds"], - trainee: ["/trainee", "/profile"], + trainee: ["/trainee", "/dds", "/profile"], }; diff --git a/frontend/src/features/call/useCall.ts b/frontend/src/features/call/useCall.ts index a0745b0..fabab06 100644 --- a/frontend/src/features/call/useCall.ts +++ b/frontend/src/features/call/useCall.ts @@ -10,11 +10,12 @@ import { Ambience } from "@/shared/audio/ambience"; import { type Capture, startCapture } from "@/shared/audio/capture"; import { EnergyGate } from "@/shared/audio/levels"; 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 { Outcome, CallIncoming, + CardBriefing, DDSCode, ServerToTrainee, SessionReport, @@ -36,6 +37,8 @@ export function useCall(sessionId: string | null) { const [status, setStatus] = useState("connecting"); const [phase, setPhase] = useState("waiting"); const [incoming, setIncoming] = useState(null); + const [briefing, setBriefing] = useState(null); + const [cardSubmitted, setCardSubmitted] = useState(false); const [lines, setLines] = useState([]); const [timers, setTimers] = useState([]); const [callerSpeaking, setCallerSpeaking] = useState(false); @@ -45,6 +48,7 @@ export function useCall(sessionId: string | null) { const [selfAssessed, setSelfAssessed] = useState(false); const [scoreReady, setScoreReady] = useState(false); const [report, setReport] = useState(null); + const [reportError, setReportError] = useState(null); const [error, setError] = useState(null); const channel = useRef | null>(null); @@ -53,6 +57,7 @@ export function useCall(sessionId: string | null) { const gate = useRef(new EnergyGate()); const outbox = useRef>({}); const patchTimer = useRef | null>(null); + const textMode = useRef(false); const append = useCallback((line: Line) => { setLines((prev) => { @@ -67,6 +72,16 @@ export function useCall(sessionId: string | null) { onStatus: setStatus, onBinary: (frame) => audio.current?.playback.enqueue(frame), 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) { case "call.incoming": setIncoming(event); @@ -110,6 +125,7 @@ export function useCall(sessionId: string | null) { case "call.ended": setPhase("ended"); audio.current?.ambience.stop(); + if (textMode.current) { setCardSubmitted(true); break; } // Чек-лист открывается только после звонка: во время него это // содержимое подсказок. void fetch(`/api/sessions/${sessionId}/checklist`) @@ -119,10 +135,16 @@ export function useCall(sessionId: string | null) { break; case "score.ready": setScoreReady(true); + setReportError(null); 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) - .catch(() => setReport(null)); + .catch((cause: unknown) => setReportError(cause instanceof Error ? cause.message : "Не удалось загрузить отчёт.")); break; case "error": setError(event.message); @@ -183,8 +205,25 @@ export function useCall(sessionId: string | null) { }, PATCH_DEBOUNCE_MS); }, []); - const dispatch = useCallback((service: DDSCode) => { - channel.current?.send({ type: "dds.dispatch", service }); + const dispatch = useCallback((service?: DDSCode) => { + 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 { resolve, - status, phase, incoming, lines, timers, callerSpeaking, micOn, error, card, - checklist, selfAssessed, scoreReady, report, - answer, hangup, hint, patchKio, dispatch, submitSelfAssessment, + status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, card, + checklist, selfAssessed, scoreReady, report, reportError, + answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment, }; } diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx index 875f15c..fbca07e 100644 --- a/frontend/src/features/debrief/Debrief.tsx +++ b/frontend/src/features/debrief/Debrief.tsx @@ -23,6 +23,10 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?: Разбор · оценка {report.score_final.toFixed(0)} из 100 {report.overridden_by && ` · скорректировано (${report.overridden_by}), автооценка ${report.score_auto.toFixed(0)}`} +

+ Скачать отчёт: CSV + {" · "}PDF +

diff --git a/frontend/src/features/instructor/ScenarioEditor.tsx b/frontend/src/features/instructor/ScenarioEditor.tsx new file mode 100644 index 0000000..b0e3d1a --- /dev/null +++ b/frontend/src/features/instructor/ScenarioEditor.tsx @@ -0,0 +1,189 @@ +import { useState } from "react"; + +import type { ScenarioSummary } from "@/shared/api/http"; + +type JsonObject = Record; +type Draft = { id: string; status: "draft"; generation: "template_copy"; body: JsonObject }; +type Validation = { valid: boolean; errors: string[]; ground_truth?: JsonObject }; + +async function api(path: string, method = "GET", body?: unknown): Promise { + 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; +}) { + const [sourceId, setSourceId] = useState(""); + const [title, setTitle] = useState(""); + const [draftId, setDraftId] = useState(""); + const [draft, setDraft] = useState(null); + const [bodyText, setBodyText] = useState(""); + const [validation, setValidation] = useState(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) { + 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("/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(`/api/scenarios/drafts/${encodeURIComponent(draftId.trim())}`)); + }); + } + + async function save(): Promise { + 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(`/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(`/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 ( +
+

Редактор учебных сценариев

+

Создание работает локально: копия утверждённого шаблона, затем правка и проверка преподавателем.

+
+ + + +
+
+ + +
+ {draft && ( + <> +

Черновик {draft.id}. Факты и эталон доступны только преподавателю до утверждения.

+