Implement local task 09 training workflows
This commit is contained in:
parent
e081fa410b
commit
cec84ffcd0
45 changed files with 2679 additions and 366 deletions
8
Makefile
8
Makefile
|
|
@ -17,6 +17,9 @@ help: ## Список целей
|
||||||
dev: ## Поднять стенд: postgres + backend --reload + frontend
|
dev: ## Поднять стенд: postgres + backend --reload + frontend
|
||||||
$(COMPOSE) up --build
|
$(COMPOSE) up --build
|
||||||
|
|
||||||
|
offline: ## Поднять карточки/ДДС из заранее собранных локальных образов, без скачивания
|
||||||
|
$(COMPOSE) up --pull never --no-build
|
||||||
|
|
||||||
down: ## Погасить стенд
|
down: ## Погасить стенд
|
||||||
$(COMPOSE) down
|
$(COMPOSE) down
|
||||||
|
|
||||||
|
|
@ -47,6 +50,9 @@ backup: ## Резервная копия базы в backend/backups/ (для cr
|
||||||
users: ## Завести учётные записи ролей и напечатать пароли: make users force=1
|
users: ## Завести учётные записи ролей и напечатать пароли: make users force=1
|
||||||
cd backend && $(UV) run python scripts/users.py
|
cd backend && $(UV) run python scripts/users.py
|
||||||
|
|
||||||
|
users-docker: ## Завести учётные записи внутри локального контейнера (после make offline)
|
||||||
|
$(COMPOSE) exec backend python scripts/users.py
|
||||||
|
|
||||||
seed: ## Залить сценарии из /scenarios в БД
|
seed: ## Залить сценарии из /scenarios в БД
|
||||||
cd backend && $(UV) run python scripts/seed.py
|
cd backend && $(UV) run python scripts/seed.py
|
||||||
|
|
||||||
|
|
@ -77,4 +83,4 @@ pregen: ## Офлайн-таблицы реплик: make pregen [s=<сцена
|
||||||
demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт
|
demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт
|
||||||
./scripts/demo.sh
|
./scripts/demo.sh
|
||||||
|
|
||||||
.PHONY: help dev down back front types users backup test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo
|
.PHONY: help dev offline down back front types users users-docker backup test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Экспорт PDF требует локальный TTF с кириллицей; базовый slim-образ его не
|
||||||
|
# гарантирует. Шрифт запекается в образ при подготовке офлайн-комплекта.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Зависимости — из pyproject.toml, а не отдельным списком: ручной список разошёлся
|
# Зависимости — из pyproject.toml, а не отдельным списком: ручной список разошёлся
|
||||||
# с проектом, и бэкенд в контейнере упал на первом же новом пакете (num2words).
|
# с проектом, и бэкенд в контейнере упал на первом же новом пакете (num2words).
|
||||||
# Голосовой контур (группа voice: torch, onnx-asr) в образ не входит: под WSL
|
# Голосовой контур (группа voice: torch, onnx-asr) в образ не входит: под WSL
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,116 @@
|
||||||
`hint.shown` из живой сессии, эталонные вопросы — только в разборе.
|
`hint.shown` из живой сессии, эталонные вопросы — только в разборе.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.auth import audit, require
|
||||||
|
from app.db.base import get_session
|
||||||
|
from app.domain.roles import Role
|
||||||
from app.scenarios import store
|
from app.scenarios import store
|
||||||
|
from app.scenarios.editor import validate
|
||||||
|
from app.scenarios.loader import ScenarioError
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
|
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
|
||||||
|
|
||||||
HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"}
|
HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"}
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateDraftIn(BaseModel):
|
||||||
|
source_id: str = Field(min_length=1)
|
||||||
|
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
def _draft_out(row) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"status": row.status,
|
||||||
|
"generation": "template_copy",
|
||||||
|
"body": row.body,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/drafts/from-template", status_code=201)
|
||||||
|
async def create_template_draft(
|
||||||
|
body: TemplateDraftIn, request: Request, db: AsyncSession = Depends(get_session)
|
||||||
|
) -> dict:
|
||||||
|
who = require(request, Role.INSTRUCTOR)
|
||||||
|
source = store.get(body.source_id)
|
||||||
|
if source is None:
|
||||||
|
raise HTTPException(status_code=404, detail="published_source_not_found")
|
||||||
|
row = await store.create_draft(db, source=source, title=body.title)
|
||||||
|
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
|
||||||
|
return _draft_out(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/drafts/{scenario_id}")
|
||||||
|
async def read_draft(
|
||||||
|
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
|
||||||
|
) -> dict:
|
||||||
|
require(request, Role.INSTRUCTOR)
|
||||||
|
row = await store.draft(db, scenario_id)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||||
|
return _draft_out(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/drafts/{scenario_id}")
|
||||||
|
async def patch_draft(
|
||||||
|
scenario_id: str,
|
||||||
|
body: dict[str, Any],
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
who = require(request, Role.INSTRUCTOR)
|
||||||
|
row = await store.draft(db, scenario_id)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||||
|
try:
|
||||||
|
row = await store.update_draft(db, row, body)
|
||||||
|
except ScenarioError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
await audit(who.login, who.role.value, "scenario.draft.update", row.id)
|
||||||
|
return _draft_out(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/drafts/{scenario_id}/validate")
|
||||||
|
async def validate_draft(
|
||||||
|
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
|
||||||
|
) -> dict:
|
||||||
|
require(request, Role.INSTRUCTOR)
|
||||||
|
row = await store.draft(db, scenario_id)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||||
|
try:
|
||||||
|
scenario = validate(row.body)
|
||||||
|
except ScenarioError as exc:
|
||||||
|
return {"valid": False, "errors": [str(exc)]}
|
||||||
|
return {
|
||||||
|
"valid": True,
|
||||||
|
"errors": [],
|
||||||
|
"ground_truth": scenario.ground_truth.model_dump(mode="json"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/drafts/{scenario_id}/approve")
|
||||||
|
async def approve_draft(
|
||||||
|
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
|
||||||
|
) -> dict:
|
||||||
|
who = require(request, Role.INSTRUCTOR)
|
||||||
|
row = await store.draft(db, scenario_id)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||||
|
try:
|
||||||
|
scenario = await store.approve_draft(db, row)
|
||||||
|
except ScenarioError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
await audit(who.login, who.role.value, "scenario.approve", scenario.id)
|
||||||
|
return {"id": scenario.id, "status": "published", "title": scenario.title}
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def listing() -> list[dict]:
|
async def listing() -> list[dict]:
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,17 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.auth import audit, require
|
from app.api.auth import audit, require
|
||||||
from app.db import repo
|
from app.db import repo
|
||||||
from app.db.base import get_session
|
from app.db.base import get_session
|
||||||
from app.domain.events import SessionMode, SessionReport
|
from app.domain.events import Exercise, SessionMode, SessionReport
|
||||||
from app.scenarios import store
|
from app.scenarios import store
|
||||||
from app.scoring.report import build as build_report
|
from app.scoring.report import build as build_report
|
||||||
|
from app.scoring.export import to_csv, to_pdf
|
||||||
from app.domain.roles import Role
|
from app.domain.roles import Role
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
|
|
||||||
|
|
@ -57,7 +58,8 @@ def _out(session) -> SessionOut:
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=SessionOut, status_code=201)
|
@router.post("", response_model=SessionOut, status_code=201)
|
||||||
async def create(body: SessionCreate, db: AsyncSession = Depends(get_session)) -> SessionOut:
|
async def create(body: SessionCreate, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
|
||||||
|
require(request, Role.INSTRUCTOR)
|
||||||
group = await repo.ensure_group(db, body.group) if body.group else None
|
group = await repo.ensure_group(db, body.group) if body.group else None
|
||||||
trainee = await repo.ensure_trainee(db, body.trainee, group) if body.trainee else None
|
trainee = await repo.ensure_trainee(db, body.trainee, group) if body.trainee else None
|
||||||
session = await repo.create_session(
|
session = await repo.create_session(
|
||||||
|
|
@ -71,10 +73,13 @@ async def create(body: SessionCreate, db: AsyncSession = Depends(get_session)) -
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{session_id}", response_model=SessionOut)
|
@router.get("/{session_id}", response_model=SessionOut)
|
||||||
async def read(session_id: UUID, db: AsyncSession = Depends(get_session)) -> SessionOut:
|
async def read(session_id: UUID, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
|
||||||
|
who = require(request)
|
||||||
session = await repo.get_session(db, session_id)
|
session = await repo.get_session(db, session_id)
|
||||||
if session is None:
|
if session is None:
|
||||||
raise HTTPException(status_code=404, detail="session_not_found")
|
raise HTTPException(status_code=404, detail="session_not_found")
|
||||||
|
if who.role is Role.TRAINEE and session.trainee_id != who.trainee_id:
|
||||||
|
raise HTTPException(status_code=403, detail="not_your_session")
|
||||||
return _out(session)
|
return _out(session)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -84,16 +89,19 @@ class ChecklistItemOut(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{session_id}/checklist", response_model=list[ChecklistItemOut])
|
@router.get("/{session_id}/checklist", response_model=list[ChecklistItemOut])
|
||||||
async def checklist(session_id: UUID) -> list[ChecklistItemOut]:
|
async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut]:
|
||||||
"""Чек-лист для самооценки — **только после конца звонка**.
|
"""Чек-лист для самооценки — **только после конца звонка**.
|
||||||
|
|
||||||
Во время звонка это содержимое подсказок: отдать его значит выдать
|
Во время звонка это содержимое подсказок: отдать его значит выдать
|
||||||
в контрольном режиме то, чего там быть не должно. После звонка курсант
|
в контрольном режиме то, чего там быть не должно. После звонка курсант
|
||||||
по нему отмечает, что, по его мнению, пропустил.
|
по нему отмечает, что, по его мнению, пропустил.
|
||||||
"""
|
"""
|
||||||
|
who = require(request)
|
||||||
state = hub.get(session_id)
|
state = hub.get(session_id)
|
||||||
if state is None:
|
if state is None:
|
||||||
raise HTTPException(status_code=404, detail="session_not_found")
|
raise HTTPException(status_code=404, detail="session_not_found")
|
||||||
|
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
|
||||||
|
raise HTTPException(status_code=403, detail="not_your_session")
|
||||||
if not state.ended:
|
if not state.ended:
|
||||||
raise HTTPException(status_code=409, detail="call_not_ended")
|
raise HTTPException(status_code=409, detail="call_not_ended")
|
||||||
scenario = state.scenario or store.get(state.scenario_id)
|
scenario = state.scenario or store.get(state.scenario_id)
|
||||||
|
|
@ -136,11 +144,37 @@ async def report(session_id: UUID, request: Request) -> SessionReport:
|
||||||
state, scenario = _live(session_id)
|
state, scenario = _live(session_id)
|
||||||
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
|
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
|
||||||
raise HTTPException(status_code=403, detail="not_your_session")
|
raise HTTPException(status_code=403, detail="not_your_session")
|
||||||
|
if who.role is Role.TRAINEE and state.exercise is Exercise.CALL and not state.self_assessed:
|
||||||
|
raise HTTPException(status_code=409, detail="self_assessment_required")
|
||||||
if state.score is None:
|
if state.score is None:
|
||||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||||
return build_report(session_id, state, scenario)
|
return build_report(session_id, state, scenario)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}/report.csv")
|
||||||
|
async def report_csv(session_id: UUID, request: Request) -> Response:
|
||||||
|
"""Те же права и готовность оценки, что у JSON-разбора."""
|
||||||
|
data = await report(session_id, request)
|
||||||
|
return Response(
|
||||||
|
content=to_csv(data), media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.csv"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}/report.pdf")
|
||||||
|
async def report_pdf(session_id: UUID, request: Request) -> Response:
|
||||||
|
"""Печатный разбор; генерация полностью локальна."""
|
||||||
|
data = await report(session_id, request)
|
||||||
|
try:
|
||||||
|
content = to_pdf(data)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
return Response(
|
||||||
|
content=content, media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.pdf"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{session_id}/report", response_model=SessionReport)
|
@router.patch("/{session_id}/report", response_model=SessionReport)
|
||||||
async def override(session_id: UUID, body: ScoreOverride, request: Request) -> SessionReport:
|
async def override(session_id: UUID, body: ScoreOverride, request: Request) -> SessionReport:
|
||||||
"""Тренажёр готовит материал, преподаватель имеет последнее слово.
|
"""Тренажёр готовит материал, преподаватель имеет последнее слово.
|
||||||
|
|
@ -177,6 +211,8 @@ async def listing(
|
||||||
who = require(request)
|
who = require(request)
|
||||||
# Обучающийся видит только свою историю, что бы он ни передал в фильтре.
|
# Обучающийся видит только свою историю, что бы он ни передал в фильтре.
|
||||||
if who.role is Role.TRAINEE:
|
if who.role is Role.TRAINEE:
|
||||||
|
if who.trainee_id is None:
|
||||||
|
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||||
trainee = who.trainee_id
|
trainee = who.trainee_id
|
||||||
rows = await repo.history(
|
rows = await repo.history(
|
||||||
db,
|
db,
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,18 @@ from pydantic import TypeAdapter, ValidationError
|
||||||
|
|
||||||
from app.domain.events import (
|
from app.domain.events import (
|
||||||
StationState,
|
StationState,
|
||||||
|
CallIncoming,
|
||||||
CallEnded,
|
CallEnded,
|
||||||
CallEndReason,
|
CallEndReason,
|
||||||
CallStarted,
|
CallStarted,
|
||||||
ErrorEvent,
|
ErrorEvent,
|
||||||
ErrorKind,
|
ErrorKind,
|
||||||
|
Exercise,
|
||||||
HintShown,
|
HintShown,
|
||||||
KioState,
|
KioState,
|
||||||
|
KioPatchOut,
|
||||||
|
PatchSource,
|
||||||
|
ScoreReady,
|
||||||
SessionEnded,
|
SessionEnded,
|
||||||
SessionMode,
|
SessionMode,
|
||||||
TimerTick,
|
TimerTick,
|
||||||
|
|
@ -33,6 +38,7 @@ from app.api.auth import principal_of
|
||||||
from app.domain.roles import Role
|
from app.domain.roles import Role
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
from app.session.state import now_utc
|
from app.session.state import now_utc
|
||||||
|
from app.domain.kio import ResponseStatus
|
||||||
from app.voice.models import get_voice_models
|
from app.voice.models import get_voice_models
|
||||||
from app.voice.pipeline import VoiceSession
|
from app.voice.pipeline import VoiceSession
|
||||||
|
|
||||||
|
|
@ -82,7 +88,37 @@ def _next_hint(state) -> tuple[str, str] | None:
|
||||||
|
|
||||||
|
|
||||||
async def _handle(session_id: UUID, state, event) -> None:
|
async def _handle(session_id: UUID, state, event) -> None:
|
||||||
|
if state.ended and event.type != "self_assessment.submit":
|
||||||
|
hub.to_trainee(session_id, ErrorEvent(
|
||||||
|
code=ErrorKind.UNSUPPORTED_EVENT, message="Занятие уже завершено",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
if state.exercise is Exercise.DDS or (
|
||||||
|
state.exercise is Exercise.CARD and event.type not in {"kio.patch", "card.submit"}
|
||||||
|
) or (state.exercise is Exercise.CALL and event.type == "card.submit"):
|
||||||
|
hub.to_trainee(session_id, ErrorEvent(
|
||||||
|
code=ErrorKind.UNSUPPORTED_EVENT, message="Действие недоступно в этом упражнении",
|
||||||
|
))
|
||||||
|
return
|
||||||
match event.type:
|
match event.type:
|
||||||
|
case "card.submit":
|
||||||
|
state.kio.registered_at = state.started_at or now_utc()
|
||||||
|
state.kio.response_status = ResponseStatus.TRANSFERRED
|
||||||
|
state.dispatched_card = state.kio.model_copy(deep=True)
|
||||||
|
state.dispatched_at = now_utc()
|
||||||
|
state.ended_at = state.dispatched_at
|
||||||
|
state.end_reason = CallEndReason.COMPLETE
|
||||||
|
hub.stop_ticker(session_id)
|
||||||
|
hub.to_station(session_id, state.card_received_event())
|
||||||
|
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
|
||||||
|
hub.to_observers(session_id, KioState(kio=state.kio))
|
||||||
|
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.COMPLETE))
|
||||||
|
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||||
|
if hub.journal:
|
||||||
|
await hub.journal.session_ended(
|
||||||
|
session_id, state.ended_at, CallEndReason.COMPLETE.value
|
||||||
|
)
|
||||||
|
await finish(session_id, state)
|
||||||
case "call.answer":
|
case "call.answer":
|
||||||
state.on_event("call.answer")
|
state.on_event("call.answer")
|
||||||
state.started_at = now_utc()
|
state.started_at = now_utc()
|
||||||
|
|
@ -93,7 +129,17 @@ async def _handle(session_id: UUID, state, event) -> None:
|
||||||
_start_voice(session_id, state)
|
_start_voice(session_id, state)
|
||||||
|
|
||||||
case "kio.patch":
|
case "kio.patch":
|
||||||
|
old_code, old_notify = state.kio.incident_code, list(state.kio.notify)
|
||||||
state.patch_kio(event.fields)
|
state.patch_kio(event.fields)
|
||||||
|
hub.to_trainee(session_id, KioPatchOut(
|
||||||
|
fields=event.fields, source=PatchSource.OPERATOR,
|
||||||
|
))
|
||||||
|
if (state.kio.incident_code, state.kio.notify) != (old_code, old_notify):
|
||||||
|
hub.to_trainee(session_id, KioPatchOut(
|
||||||
|
fields={"incident_code": state.kio.incident_code,
|
||||||
|
"notify": list(state.kio.notify)},
|
||||||
|
source=PatchSource.AUTO,
|
||||||
|
))
|
||||||
# Наблюдателю уходит карточка целиком: рассинхрон на внешнем мониторе
|
# Наблюдателю уходит карточка целиком: рассинхрон на внешнем мониторе
|
||||||
# посреди занятия дороже лишних килобайт.
|
# посреди занятия дороже лишних килобайт.
|
||||||
hub.to_observers(session_id, KioState(kio=state.kio))
|
hub.to_observers(session_id, KioState(kio=state.kio))
|
||||||
|
|
@ -121,8 +167,19 @@ async def _handle(session_id: UUID, state, event) -> None:
|
||||||
await hub.journal.hint(session_id, checklist_id, question, now_utc())
|
await hub.journal.hint(session_id, checklist_id, question, now_utc())
|
||||||
|
|
||||||
case "dds.dispatch":
|
case "dds.dispatch":
|
||||||
|
if event.service is None and not (state.kio.incident_code or state.kio.notify):
|
||||||
|
hub.to_trainee(session_id, ErrorEvent(
|
||||||
|
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||||
|
message="Укажите ДДС или проставьте признаки для маршрутизации по ЕКП",
|
||||||
|
))
|
||||||
|
return
|
||||||
state.on_event("dds.dispatch")
|
state.on_event("dds.dispatch")
|
||||||
state.dispatch(event.service.value)
|
state.dispatch(event.service.value if event.service else None)
|
||||||
|
hub.to_trainee(session_id, KioPatchOut(
|
||||||
|
fields={"response_status": state.kio.response_status.value,
|
||||||
|
"dds": state.kio.dds.value if state.kio.dds else None},
|
||||||
|
source=PatchSource.AUTO,
|
||||||
|
))
|
||||||
# Карточка замораживается снимком и уходит диспетчеру: оператор
|
# Карточка замораживается снимком и уходит диспетчеру: оператор
|
||||||
# не должен иметь возможности дописать поле задним числом.
|
# не должен иметь возможности дописать поле задним числом.
|
||||||
hub.to_station(session_id, state.card_received_event())
|
hub.to_station(session_id, state.card_received_event())
|
||||||
|
|
@ -227,8 +284,40 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
|
||||||
)
|
)
|
||||||
await ws.close()
|
await ws.close()
|
||||||
return
|
return
|
||||||
|
if who.role is Role.TRAINEE and (
|
||||||
|
state.trainee_id is None or state.trainee_id != who.trainee_id
|
||||||
|
):
|
||||||
|
await _reject(ws, "Занятие не назначено этому обучающемуся")
|
||||||
|
return
|
||||||
|
|
||||||
with hub.trainee(session_id) as queue:
|
with hub.trainee(session_id) as queue:
|
||||||
|
if state.exercise is Exercise.CARD:
|
||||||
|
from app.api.ws.control import card_briefing
|
||||||
|
|
||||||
|
hub.to_trainee(session_id, card_briefing(state))
|
||||||
|
if state.ended:
|
||||||
|
hub.to_trainee(session_id, CallEnded(reason=state.end_reason or CallEndReason.COMPLETE))
|
||||||
|
if state.score is not None:
|
||||||
|
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
|
||||||
|
elif state.exercise is Exercise.CALL:
|
||||||
|
scenario = state.scenario or store.get(state.scenario_id)
|
||||||
|
required = ([field for field in state.required_fields if field != "dds"]
|
||||||
|
if scenario and scenario.ground_truth.incident_code
|
||||||
|
else list(state.required_fields))
|
||||||
|
hub.to_trainee(session_id, CallIncoming(
|
||||||
|
scenario_id=state.scenario_id, caller_number="+7 (495) 000-00-00",
|
||||||
|
level=state.level, mode=state.mode, required_fields=required,
|
||||||
|
))
|
||||||
|
hub.to_trainee(session_id, KioPatchOut(
|
||||||
|
fields=state.kio.model_dump(mode="json"), source=PatchSource.OPERATOR,
|
||||||
|
))
|
||||||
|
if state.started_at is not None:
|
||||||
|
hub.to_trainee(session_id, CallStarted(started_at=state.started_at))
|
||||||
|
if state.ended:
|
||||||
|
hub.to_trainee(session_id, CallEnded(reason=state.end_reason or CallEndReason.HANGUP))
|
||||||
|
if state.score is not None and state.self_assessed:
|
||||||
|
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
|
||||||
|
hub.to_trainee(session_id, TimerTick(timers=state.timers.snapshot()))
|
||||||
writer = asyncio.create_task(_pump(ws, queue))
|
writer = asyncio.create_task(_pump(ws, queue))
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -238,7 +327,13 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
|
||||||
# Бинарные кадры — аудио, текстовые — события. Направление определяется
|
# Бинарные кадры — аудио, текстовые — события. Направление определяется
|
||||||
# каналом, обёртки JSON вокруг звука нет (docs/arch/CONTRACT.md).
|
# каналом, обёртки JSON вокруг звука нет (docs/arch/CONTRACT.md).
|
||||||
if message.get("bytes") is not None:
|
if message.get("bytes") is not None:
|
||||||
|
if state.exercise is Exercise.CALL:
|
||||||
_on_audio(session_id, state, message["bytes"])
|
_on_audio(session_id, state, message["bytes"])
|
||||||
|
else:
|
||||||
|
hub.to_trainee(session_id, ErrorEvent(
|
||||||
|
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||||
|
message="Аудио не используется в этом упражнении",
|
||||||
|
))
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
payload = json.loads(message.get("text") or "")
|
payload = json.loads(message.get("text") or "")
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
|
@ -21,8 +22,9 @@ from app.domain.events import (
|
||||||
ErrorKind,
|
ErrorKind,
|
||||||
ScoreReady,
|
ScoreReady,
|
||||||
CallIncoming,
|
CallIncoming,
|
||||||
ErrorEvent,
|
CardBriefing,
|
||||||
ErrorKind,
|
Exercise,
|
||||||
|
StationState,
|
||||||
InstructorToServer,
|
InstructorToServer,
|
||||||
InstructorNoteShown,
|
InstructorNoteShown,
|
||||||
ModeSet,
|
ModeSet,
|
||||||
|
|
@ -42,6 +44,7 @@ from app.api.auth import audit, principal_of
|
||||||
from app.domain.roles import Role
|
from app.domain.roles import Role
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
from app.session.state import SessionState, now_utc
|
from app.session.state import SessionState, now_utc
|
||||||
|
from app.domain.kio import KIO, ResponseStatus
|
||||||
from app.voice.models import get_voice_models
|
from app.voice.models import get_voice_models
|
||||||
from app.voice.pipeline import FILLERS, prefetch
|
from app.voice.pipeline import FILLERS, prefetch
|
||||||
|
|
||||||
|
|
@ -51,6 +54,27 @@ router = APIRouter()
|
||||||
_adapter = TypeAdapter(InstructorToServer)
|
_adapter = TypeAdapter(InstructorToServer)
|
||||||
|
|
||||||
|
|
||||||
|
def card_briefing(state: SessionState) -> CardBriefing:
|
||||||
|
"""Учебная текстовая вводная — исходные реплики, а не эталон карточки.
|
||||||
|
|
||||||
|
В отсутствие диалога факты раскрываются сразу. Если факт уточняется,
|
||||||
|
показываем и уточнение: иначе правильно заполнить карточку невозможно.
|
||||||
|
"""
|
||||||
|
scenario = state.scenario
|
||||||
|
lines = ["Учебная текстовая вводная: сведения заявителя приведены ниже.",
|
||||||
|
scenario.first_line]
|
||||||
|
for fact in scenario.facts:
|
||||||
|
lines.append(f"• {fact.value}")
|
||||||
|
if fact.refined:
|
||||||
|
lines.append(f" Уточнено: {fact.refined}")
|
||||||
|
return CardBriefing(
|
||||||
|
scenario_id=scenario.id, mode=state.mode, text="\n".join(lines),
|
||||||
|
required_fields=([field for field in state.required_fields if field != "dds"]
|
||||||
|
if scenario.ground_truth.incident_code else list(state.required_fields)),
|
||||||
|
card=state.kio,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _start(session_id: UUID, event, who=None) -> None:
|
async def _start(session_id: UUID, event, who=None) -> None:
|
||||||
scenario = store.get(event.scenario_id)
|
scenario = store.get(event.scenario_id)
|
||||||
if scenario is None:
|
if scenario is None:
|
||||||
|
|
@ -61,9 +85,10 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
attempt = 1
|
attempt = 1
|
||||||
|
recorded_trainee_id = event.trainee_id
|
||||||
if hub.journal:
|
if hub.journal:
|
||||||
attempt = await hub.journal.start_lesson(
|
attempt, recorded_trainee_id = await hub.journal.start_lesson(
|
||||||
session_id, scenario.id, event.mode.value, event.trainee
|
session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id
|
||||||
)
|
)
|
||||||
|
|
||||||
# Занятие собирается целиком и только потом регистрируется: иначе
|
# Занятие собирается целиком и только потом регистрируется: иначе
|
||||||
|
|
@ -75,25 +100,59 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
||||||
scenario_title=scenario.title,
|
scenario_title=scenario.title,
|
||||||
level=scenario.level.value,
|
level=scenario.level.value,
|
||||||
mode=event.mode,
|
mode=event.mode,
|
||||||
|
exercise=event.exercise,
|
||||||
scenario=scenario.model_copy(deep=True),
|
scenario=scenario.model_copy(deep=True),
|
||||||
required_fields=scenario.required_fields,
|
required_fields=scenario.required_fields,
|
||||||
trainee_name=event.trainee,
|
trainee_name=event.trainee,
|
||||||
trainee_id=getattr(event, "trainee_id", None),
|
trainee_id=recorded_trainee_id,
|
||||||
attempt=attempt,
|
attempt=attempt,
|
||||||
)
|
)
|
||||||
|
if event.exercise is Exercise.CALL:
|
||||||
embedder = get_embedder()
|
embedder = get_embedder()
|
||||||
if embedder is not None:
|
if embedder is not None:
|
||||||
state.slots = SlotMachine(state.scenario, embedder)
|
state.slots = SlotMachine(state.scenario, embedder)
|
||||||
state.persona = PersonaState(state.scenario.persona)
|
state.persona = PersonaState(state.scenario.persona)
|
||||||
state.caller = build_caller(scenario.id)
|
state.caller = build_caller(scenario.id)
|
||||||
|
elif event.exercise is Exercise.DDS:
|
||||||
|
# В ДДС поступает уже оформленная учебная карточка. Содержимое берётся
|
||||||
|
# из утверждённого сценария, а не из действий несуществующего оператора.
|
||||||
|
truth = scenario.ground_truth
|
||||||
|
address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "")
|
||||||
|
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
|
||||||
|
service = truth.dds.value if truth.dds else None
|
||||||
|
fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"}
|
||||||
|
state.kio = KIO(
|
||||||
|
registered_at=now_utc(), caller_number="+7 (495) 000-00-00",
|
||||||
|
address=truth.address or address_fact or None,
|
||||||
|
floor=floor.group(1) if floor else None,
|
||||||
|
incident_type=truth.incident_type, incident_code=truth.incident_code,
|
||||||
|
dds=truth.dds, signs=list(scenario.signs),
|
||||||
|
notify=list(truth.notify) or ([fallback[service]] if service in fallback else []),
|
||||||
|
victims_count=truth.victims,
|
||||||
|
description="; ".join(fact.value for fact in scenario.facts[:3]) or scenario.first_line,
|
||||||
|
)
|
||||||
|
if service:
|
||||||
|
state.dispatch(service)
|
||||||
|
else:
|
||||||
|
state.kio.response_status = ResponseStatus.TRANSFERRED
|
||||||
|
state.dispatched_card = state.kio.model_copy(deep=True)
|
||||||
|
state.dispatched_at = now_utc()
|
||||||
|
state.started_at = state.dispatched_at
|
||||||
|
else:
|
||||||
|
state.started_at = now_utc()
|
||||||
hub.register(state)
|
hub.register(state)
|
||||||
|
if event.exercise is not Exercise.CALL and hub.journal and state.started_at is not None:
|
||||||
|
await hub.journal.session_started(session_id, state.started_at)
|
||||||
|
|
||||||
# Первая реплика и филлеры синтезируются, пока курсант не снял трубку:
|
# Первая реплика и филлеры синтезируются, пока курсант не снял трубку:
|
||||||
# «Алло! Помогите!» должно прозвучать мгновенно (docs/arch/BACKEND.md).
|
# «Алло! Помогите!» должно прозвучать мгновенно (docs/arch/BACKEND.md).
|
||||||
|
if event.exercise is Exercise.CALL:
|
||||||
models = get_voice_models()
|
models = get_voice_models()
|
||||||
if models is not None:
|
if models is not None:
|
||||||
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
|
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
|
||||||
state.on_event("call.incoming")
|
state.on_event("call.incoming")
|
||||||
|
elif event.exercise is Exercise.DDS:
|
||||||
|
state.on_event("dds.dispatch")
|
||||||
hub.start_ticker(session_id)
|
hub.start_ticker(session_id)
|
||||||
if who is not None:
|
if who is not None:
|
||||||
# Запуск занятия меняет чужой результат — значит попадает в журнал
|
# Запуск занятия меняет чужой результат — значит попадает в журнал
|
||||||
|
|
@ -103,6 +162,7 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
||||||
f"{scenario.id}, режим {event.mode.value}, курсант {event.trainee}")
|
f"{scenario.id}, режим {event.mode.value}, курсант {event.trainee}")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if event.exercise is Exercise.CALL:
|
||||||
hub.to_trainee(
|
hub.to_trainee(
|
||||||
session_id,
|
session_id,
|
||||||
CallIncoming(
|
CallIncoming(
|
||||||
|
|
@ -110,22 +170,37 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
||||||
caller_number="+7 (495) 000-00-00",
|
caller_number="+7 (495) 000-00-00",
|
||||||
level=scenario.level,
|
level=scenario.level,
|
||||||
mode=event.mode,
|
mode=event.mode,
|
||||||
scenario=scenario.model_copy(deep=True),
|
required_fields=[field for field in scenario.required_fields
|
||||||
required_fields=scenario.required_fields,
|
if field != "dds" or not scenario.ground_truth.incident_code],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
elif event.exercise is Exercise.DDS:
|
||||||
|
hub.to_station(session_id, state.card_received_event())
|
||||||
|
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
|
||||||
|
else:
|
||||||
|
hub.to_trainee(session_id, card_briefing(state))
|
||||||
hub.to_observers(session_id, ModeSet(mode=event.mode))
|
hub.to_observers(session_id, ModeSet(mode=event.mode))
|
||||||
hub.to_observers(session_id, state.snapshot())
|
hub.to_observers(session_id, state.snapshot())
|
||||||
|
|
||||||
|
|
||||||
async def _stop(session_id: UUID) -> None:
|
async def _stop(session_id: UUID) -> None:
|
||||||
state = hub.get(session_id)
|
state = hub.get(session_id)
|
||||||
if state is None:
|
if state is None or state.ended:
|
||||||
return
|
return
|
||||||
state.ended_at = now_utc()
|
state.ended_at = now_utc()
|
||||||
state.end_reason = CallEndReason.INSTRUCTOR
|
state.end_reason = CallEndReason.INSTRUCTOR
|
||||||
hub.stop_ticker(session_id)
|
hub.stop_ticker(session_id)
|
||||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
|
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
|
||||||
|
if state.exercise in {Exercise.DDS, Exercise.CARD}:
|
||||||
|
from app.session.finish import finish
|
||||||
|
|
||||||
|
if state.exercise is Exercise.DDS:
|
||||||
|
hub.to_station(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
|
||||||
|
else:
|
||||||
|
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.INSTRUCTOR))
|
||||||
|
await finish(session_id, state)
|
||||||
|
if state.exercise is Exercise.DDS:
|
||||||
|
hub.to_station(session_id, ScoreReady(session_id=session_id))
|
||||||
if hub.journal:
|
if hub.journal:
|
||||||
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value)
|
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value)
|
||||||
|
|
||||||
|
|
@ -204,6 +279,12 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
||||||
state = hub.get(session_id)
|
state = hub.get(session_id)
|
||||||
if state is None:
|
if state is None:
|
||||||
continue
|
continue
|
||||||
|
if state.exercise is not Exercise.CALL:
|
||||||
|
hub.to_observers(session_id, ErrorEvent(
|
||||||
|
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||||
|
message="Директивы звонящему доступны только в голосовом упражнении",
|
||||||
|
))
|
||||||
|
continue
|
||||||
result = apply_directive(state, event.directive)
|
result = apply_directive(state, event.directive)
|
||||||
if result.needs_network:
|
if result.needs_network:
|
||||||
hub.to_observers(session_id, ErrorEvent(
|
hub.to_observers(session_id, ErrorEvent(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Сокет диспетчера ДДС.
|
"""Сокет диспетчера ДДС.
|
||||||
|
|
||||||
**Только JSON: аудио здесь нет вообще** — значит, нет ни VAD, ни распознавания,
|
Учебный исходящий звонок бригаде имитируется событиями JSON. Для целевого
|
||||||
ни синтеза. Голосовой контур станция не трогает (docs/arch/CONTRACT.md).
|
локального прототипа не нужна внешняя телефония или аудиомодель.
|
||||||
|
|
||||||
Самая ценная механика цепочки — `card.bounce`: диспетчер видит, что не указан
|
Самая ценная механика цепочки — `card.bounce`: диспетчер видит, что не указан
|
||||||
этаж, и отбивает карточку обратно. Неполнота КИО перестаёт быть процентом
|
этаж, и отбивает карточку обратно. Неполнота КИО перестаёт быть процентом
|
||||||
|
|
@ -15,20 +15,45 @@ from uuid import UUID
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
from pydantic import TypeAdapter, ValidationError
|
from pydantic import TypeAdapter, ValidationError
|
||||||
|
|
||||||
from app.domain.events import ErrorEvent, ErrorKind, StationState, StationToServer
|
from app.domain.events import (
|
||||||
from app.domain.statuses import PRIMARY, ServiceStatus, StatusError
|
CallEndReason, ErrorEvent, ErrorKind, Exercise, PhoneReport, ScoreReady,
|
||||||
|
SessionEnded, StationState, StationToServer,
|
||||||
|
)
|
||||||
|
from app.domain.statuses import PRIMARY, PhoneReportRecord, ServiceStatus, StatusError, current
|
||||||
from app.api.auth import principal_of
|
from app.api.auth import principal_of
|
||||||
from app.domain.roles import Role
|
from app.domain.roles import Role
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
from app.session.state import now_utc
|
from app.session.state import now_utc
|
||||||
|
from app.session.finish import finish
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_adapter = TypeAdapter(StationToServer)
|
_adapter = TypeAdapter(StationToServer)
|
||||||
|
|
||||||
|
REPORT_PHASES = ("dispatched", "arrived", "working", "completed")
|
||||||
|
REPORT_TEXT = {
|
||||||
|
"dispatched": "Бригада выехала к месту происшествия.",
|
||||||
|
"arrived": "Бригада прибыла на место происшествия.",
|
||||||
|
"working": "Бригада приступила к проведению работ.",
|
||||||
|
"completed": "Работы завершены, бригада освобождена.",
|
||||||
|
}
|
||||||
|
REPORT_FOR_STATUS = {
|
||||||
|
ServiceStatus.RESPONDING: "dispatched",
|
||||||
|
ServiceStatus.ARRIVED: "arrived",
|
||||||
|
ServiceStatus.WORKING: "working",
|
||||||
|
ServiceStatus.COMPLETED: "completed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _error(session_id: UUID, message: str) -> None:
|
||||||
|
hub.to_station(session_id, ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=message))
|
||||||
|
|
||||||
|
|
||||||
async def _handle(session_id: UUID, state, event) -> None:
|
async def _handle(session_id: UUID, state, event) -> None:
|
||||||
|
if state.ended:
|
||||||
|
_error(session_id, "Занятие уже завершено")
|
||||||
|
return
|
||||||
match event.type:
|
match event.type:
|
||||||
case "card.ack":
|
case "card.ack":
|
||||||
# Подтверждение приёма — это статус «Принята» у главной службы.
|
# Подтверждение приёма — это статус «Принята» у главной службы.
|
||||||
|
|
@ -43,6 +68,16 @@ async def _handle(session_id: UUID, state, event) -> None:
|
||||||
except StatusError:
|
except StatusError:
|
||||||
pass # статус уже стоит: повторное нажатие ничего не меняет
|
pass # статус уже стоит: повторное нажатие ничего не меняет
|
||||||
case "card.status":
|
case "card.status":
|
||||||
|
if event.service not in state.notified_services():
|
||||||
|
_error(session_id, "Служба отсутствует в списке оповещения карточки")
|
||||||
|
return
|
||||||
|
phase = REPORT_FOR_STATUS.get(event.status)
|
||||||
|
if state.exercise is Exercise.DDS and phase is not None and not any(
|
||||||
|
report.service == event.service and report.phase == phase
|
||||||
|
for report in state.phone_reports
|
||||||
|
):
|
||||||
|
_error(session_id, f"Статус «{event.status.value}» требует доклада бригады")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
state.set_service_status(
|
state.set_service_status(
|
||||||
event.service, event.status, event.comment, author="диспетчер"
|
event.service, event.status, event.comment, author="диспетчер"
|
||||||
|
|
@ -56,6 +91,55 @@ async def _handle(session_id: UUID, state, event) -> None:
|
||||||
# Первичный статус останавливает норматив 30 секунд.
|
# Первичный статус останавливает норматив 30 секунд.
|
||||||
if event.status in PRIMARY:
|
if event.status in PRIMARY:
|
||||||
state.on_event("card.ack")
|
state.on_event("card.ack")
|
||||||
|
case "crew.select":
|
||||||
|
if event.crew not in state.crew_options():
|
||||||
|
_error(session_id, "Выберите бригаду из списка доступных")
|
||||||
|
return
|
||||||
|
service = state.crew_service(event.crew)
|
||||||
|
assigned = state.crew_assignments.get(service)
|
||||||
|
if assigned and assigned != event.crew and any(
|
||||||
|
report.service == service for report in state.phone_reports
|
||||||
|
):
|
||||||
|
_error(session_id, "После первого доклада бригаду этой службы менять нельзя")
|
||||||
|
return
|
||||||
|
state.crew_selected = event.crew
|
||||||
|
state.crew_assignments[service] = event.crew
|
||||||
|
case "phone.dial":
|
||||||
|
crew = state.crew_selected
|
||||||
|
service = state.crew_service(crew) if crew else None
|
||||||
|
if service is None:
|
||||||
|
_error(session_id, "Сначала выберите бригаду")
|
||||||
|
return
|
||||||
|
if current(state.status_log, service) not in {
|
||||||
|
ServiceStatus.ACCEPTED, ServiceStatus.RESPONDING,
|
||||||
|
ServiceStatus.ARRIVED, ServiceStatus.WORKING,
|
||||||
|
}:
|
||||||
|
_error(session_id, "Сначала примите карточку этой службы")
|
||||||
|
return
|
||||||
|
previous = [report for report in state.phone_reports if report.service == service]
|
||||||
|
if len(previous) >= len(REPORT_PHASES):
|
||||||
|
_error(session_id, "Все доклады этой бригады уже получены")
|
||||||
|
return
|
||||||
|
phase = REPORT_PHASES[len(previous)]
|
||||||
|
report = PhoneReportRecord(
|
||||||
|
service=service, crew=crew, phase=phase,
|
||||||
|
text=REPORT_TEXT[phase], at=now_utc(),
|
||||||
|
)
|
||||||
|
state.phone_reports.append(report)
|
||||||
|
hub.to_station(session_id, PhoneReport(**report.model_dump()))
|
||||||
|
case "station.finish":
|
||||||
|
if state.exercise is not Exercise.DDS:
|
||||||
|
_error(session_id, "Операторское занятие завершается после звонка 112")
|
||||||
|
return
|
||||||
|
state.ended_at = now_utc()
|
||||||
|
state.end_reason = CallEndReason.COMPLETE
|
||||||
|
hub.stop_ticker(session_id)
|
||||||
|
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||||
|
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||||
|
if hub.journal:
|
||||||
|
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.COMPLETE.value)
|
||||||
|
await finish(session_id, state)
|
||||||
|
hub.to_station(session_id, ScoreReady(session_id=session_id))
|
||||||
case "card.bounce":
|
case "card.bounce":
|
||||||
# Карточка вернулась: в разборе это E6 с конкретной причиной.
|
# Карточка вернулась: в разборе это E6 с конкретной причиной.
|
||||||
state.bounced_fields = list(event.missing_fields)
|
state.bounced_fields = list(event.missing_fields)
|
||||||
|
|
@ -105,6 +189,11 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
|
||||||
)
|
)
|
||||||
await ws.close()
|
await ws.close()
|
||||||
return
|
return
|
||||||
|
if who.role is Role.TRAINEE and (
|
||||||
|
state.trainee_id is None or state.trainee_id != who.trainee_id
|
||||||
|
):
|
||||||
|
await _reject(ws, "Занятие не назначено этому обучающемуся")
|
||||||
|
return
|
||||||
|
|
||||||
with hub.station(session_id) as queue:
|
with hub.station(session_id) as queue:
|
||||||
sender = asyncio.create_task(_pump(ws, queue))
|
sender = asyncio.create_task(_pump(ws, queue))
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ async def ensure_session(
|
||||||
scenario_id: str,
|
scenario_id: str,
|
||||||
mode: str,
|
mode: str,
|
||||||
trainee_name: str | None = None,
|
trainee_name: str | None = None,
|
||||||
|
trainee_id: UUID | None = None,
|
||||||
group_name: str | None = None,
|
group_name: str | None = None,
|
||||||
) -> Session:
|
) -> Session:
|
||||||
"""Занятие, запущенное с пульта, должно иметь строку в журнале.
|
"""Занятие, запущенное с пульта, должно иметь строку в журнале.
|
||||||
|
|
@ -64,7 +65,11 @@ async def ensure_session(
|
||||||
return existing
|
return existing
|
||||||
|
|
||||||
group = await ensure_group(db, group_name) if group_name else None
|
group = await ensure_group(db, group_name) if group_name else None
|
||||||
trainee = await ensure_trainee(db, trainee_name, group) if trainee_name else None
|
trainee = await db.get(Trainee, trainee_id) if trainee_id else None
|
||||||
|
if trainee_id and trainee is None:
|
||||||
|
raise ValueError(f"курсант {trainee_id} не найден")
|
||||||
|
if trainee is None and trainee_name:
|
||||||
|
trainee = await ensure_trainee(db, trainee_name, group)
|
||||||
return await create_session(
|
return await create_session(
|
||||||
db,
|
db,
|
||||||
scenario_id=scenario_id,
|
scenario_id=scenario_id,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,12 @@ class SessionMode(StrEnum):
|
||||||
SELF = "self"
|
SELF = "self"
|
||||||
|
|
||||||
|
|
||||||
|
class Exercise(StrEnum):
|
||||||
|
CALL = "call"
|
||||||
|
DDS = "dds"
|
||||||
|
CARD = "card"
|
||||||
|
|
||||||
|
|
||||||
class Speaker(StrEnum):
|
class Speaker(StrEnum):
|
||||||
CALLER = "caller"
|
CALLER = "caller"
|
||||||
OPERATOR = "operator"
|
OPERATOR = "operator"
|
||||||
|
|
@ -53,6 +59,7 @@ class CallEndReason(StrEnum):
|
||||||
HANGUP = "hangup"
|
HANGUP = "hangup"
|
||||||
DROPPED = "dropped"
|
DROPPED = "dropped"
|
||||||
INSTRUCTOR = "instructor"
|
INSTRUCTOR = "instructor"
|
||||||
|
COMPLETE = "complete"
|
||||||
|
|
||||||
|
|
||||||
class PatchSource(StrEnum):
|
class PatchSource(StrEnum):
|
||||||
|
|
@ -112,6 +119,17 @@ class CallIncoming(BaseModel):
|
||||||
required_fields: list[str] = []
|
required_fields: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class CardBriefing(BaseModel):
|
||||||
|
"""Текстовая вводная упражнения 112; эталонные значения сюда не входят."""
|
||||||
|
|
||||||
|
type: Literal["card.briefing"] = "card.briefing"
|
||||||
|
scenario_id: str
|
||||||
|
mode: SessionMode
|
||||||
|
text: str
|
||||||
|
required_fields: list[str]
|
||||||
|
card: KIO | None = None
|
||||||
|
|
||||||
|
|
||||||
class CallStarted(BaseModel):
|
class CallStarted(BaseModel):
|
||||||
type: Literal["call.started"] = "call.started"
|
type: Literal["call.started"] = "call.started"
|
||||||
started_at: datetime
|
started_at: datetime
|
||||||
|
|
@ -215,6 +233,7 @@ class ErrorEvent(BaseModel):
|
||||||
|
|
||||||
ServerToTrainee = Annotated[
|
ServerToTrainee = Annotated[
|
||||||
CallIncoming
|
CallIncoming
|
||||||
|
| CardBriefing
|
||||||
| CallStarted
|
| CallStarted
|
||||||
| SttPartial
|
| SttPartial
|
||||||
| SttFinal
|
| SttFinal
|
||||||
|
|
@ -244,6 +263,10 @@ class CallAnswer(BaseModel):
|
||||||
type: Literal["call.answer"] = "call.answer"
|
type: Literal["call.answer"] = "call.answer"
|
||||||
|
|
||||||
|
|
||||||
|
class CardSubmit(BaseModel):
|
||||||
|
type: Literal["card.submit"] = "card.submit"
|
||||||
|
|
||||||
|
|
||||||
class KioPatchIn(BaseModel):
|
class KioPatchIn(BaseModel):
|
||||||
"""Правка карточки. Дебаунс 300 мс, шлётся только дельта."""
|
"""Правка карточки. Дебаунс 300 мс, шлётся только дельта."""
|
||||||
|
|
||||||
|
|
@ -266,10 +289,10 @@ class SelfAssessmentSubmit(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class DdsDispatch(BaseModel):
|
class DdsDispatch(BaseModel):
|
||||||
"""Передача в ДДС. Замораживает карточку снимком и останавливает опрос (`interview`)."""
|
"""Передача в ДДС. При маршрутизации по ЕКП служба уже есть в notify."""
|
||||||
|
|
||||||
type: Literal["dds.dispatch"] = "dds.dispatch"
|
type: Literal["dds.dispatch"] = "dds.dispatch"
|
||||||
service: DDSCode
|
service: DDSCode | None = None
|
||||||
|
|
||||||
|
|
||||||
class CallHangup(BaseModel):
|
class CallHangup(BaseModel):
|
||||||
|
|
@ -297,6 +320,7 @@ class CallResolve(BaseModel):
|
||||||
|
|
||||||
TraineeToServer = Annotated[
|
TraineeToServer = Annotated[
|
||||||
CallAnswer
|
CallAnswer
|
||||||
|
| CardSubmit
|
||||||
| KioPatchIn
|
| KioPatchIn
|
||||||
| HintRequest
|
| HintRequest
|
||||||
| SelfAssessmentSubmit
|
| SelfAssessmentSubmit
|
||||||
|
|
@ -320,6 +344,7 @@ class SessionSnapshot(BaseModel):
|
||||||
scenario_title: str
|
scenario_title: str
|
||||||
level: Level
|
level: Level
|
||||||
mode: SessionMode
|
mode: SessionMode
|
||||||
|
exercise: Exercise = Exercise.CALL
|
||||||
trainee_name: str | None = None
|
trainee_name: str | None = None
|
||||||
started_at: datetime | None = None
|
started_at: datetime | None = None
|
||||||
kio: KIO
|
kio: KIO
|
||||||
|
|
@ -394,8 +419,10 @@ class ScenarioStart(BaseModel):
|
||||||
type: Literal["scenario.start"] = "scenario.start"
|
type: Literal["scenario.start"] = "scenario.start"
|
||||||
scenario_id: str
|
scenario_id: str
|
||||||
trainee: str
|
trainee: str
|
||||||
|
trainee_id: UUID | None = None
|
||||||
group_id: str | None = None
|
group_id: str | None = None
|
||||||
mode: SessionMode
|
mode: SessionMode
|
||||||
|
exercise: Exercise = Exercise.CALL
|
||||||
|
|
||||||
|
|
||||||
class DirectorInject(BaseModel):
|
class DirectorInject(BaseModel):
|
||||||
|
|
@ -486,6 +513,28 @@ class ServiceStatusSet(BaseModel):
|
||||||
comment: str = ""
|
comment: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class CrewSelect(BaseModel):
|
||||||
|
type: Literal["crew.select"] = "crew.select"
|
||||||
|
crew: str
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneDial(BaseModel):
|
||||||
|
type: Literal["phone.dial"] = "phone.dial"
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneReport(BaseModel):
|
||||||
|
type: Literal["phone.report"] = "phone.report"
|
||||||
|
service: str
|
||||||
|
crew: str
|
||||||
|
phase: Literal["dispatched", "arrived", "working", "completed"]
|
||||||
|
text: str
|
||||||
|
at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class StationFinish(BaseModel):
|
||||||
|
type: Literal["station.finish"] = "station.finish"
|
||||||
|
|
||||||
|
|
||||||
class StationState(BaseModel):
|
class StationState(BaseModel):
|
||||||
"""Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше."""
|
"""Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше."""
|
||||||
|
|
||||||
|
|
@ -509,12 +558,13 @@ class CrewArrived(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
ServerToStation = Annotated[
|
ServerToStation = Annotated[
|
||||||
CardReceived | StationState | TimerTick | SessionEnded | ErrorEvent,
|
CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent,
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|
||||||
StationToServer = Annotated[
|
StationToServer = Annotated[
|
||||||
CardAck | CardBounce | ServiceStatusSet | ZoneDecision | CrewDispatched | CrewArrived,
|
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish
|
||||||
|
| ZoneDecision | CrewDispatched | CrewArrived,
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -193,10 +193,10 @@ def derive_incident(card: KIO) -> KIO:
|
||||||
keys = modifiers(card)
|
keys = modifiers(card)
|
||||||
data = card.model_dump()
|
data = card.model_dump()
|
||||||
data["incident_code"] = found.code if found else None
|
data["incident_code"] = found.code if found else None
|
||||||
computed = ekp.notify_list(found.code, keys) if found else []
|
# `notify` недоступен оператору для записи (READ_ONLY_FIELDS), поэтому
|
||||||
# Добавленную вручную службу не теряем: удалить её оператор не может.
|
# хранить прошлый авторасчёт как «добавленные вручную службы» нельзя:
|
||||||
manual = [name for name in card.notify if name not in ekp.notify_list(data["incident_code"] or "", keys)]
|
# при снятии признака газа МОСГАЗ должен исчезнуть из нового расчёта.
|
||||||
data["notify"] = computed + manual
|
data["notify"] = ekp.notify_list(found.code, keys) if found else []
|
||||||
return KIO.model_validate(data)
|
return KIO.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,14 @@ class StatusEntry(BaseModel):
|
||||||
author: str = ""
|
author: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneReportRecord(BaseModel):
|
||||||
|
service: str
|
||||||
|
crew: str
|
||||||
|
phase: str
|
||||||
|
text: str
|
||||||
|
at: datetime
|
||||||
|
|
||||||
|
|
||||||
class StatusError(ValueError):
|
class StatusError(ValueError):
|
||||||
"""Переход запрещён автоматом или нет обязательного комментария."""
|
"""Переход запрещён автоматом или нет обязательного комментария."""
|
||||||
|
|
||||||
|
|
@ -168,7 +176,8 @@ def card_status(
|
||||||
|
|
||||||
latest = {service: current(entries, service) for service in services}
|
latest = {service: current(entries, service) for service in services}
|
||||||
silent = [
|
silent = [
|
||||||
service for service, status in latest.items() if status not in PRIMARY | TERMINAL
|
service for service, status in latest.items()
|
||||||
|
if status in {ServiceStatus.ADDED, ServiceStatus.RECEIVED}
|
||||||
]
|
]
|
||||||
if silent and notify_deadline_passed:
|
if silent and notify_deadline_passed:
|
||||||
return CardStatus.NOT_NOTIFIED
|
return CardStatus.NOT_NOTIFIED
|
||||||
|
|
@ -196,3 +205,6 @@ class StationSnapshot(BaseModel):
|
||||||
available: dict[str, list[ServiceStatus]]
|
available: dict[str, list[ServiceStatus]]
|
||||||
card: CardStatus
|
card: CardStatus
|
||||||
log: list[StatusEntry]
|
log: list[StatusEntry]
|
||||||
|
crew_options: list[str] = []
|
||||||
|
crew_selected: str | None = None
|
||||||
|
phone_reports: list[PhoneReportRecord] = []
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
"""Сборка приложения. Роутеры подключаются по мере готовности — см. tasks/."""
|
"""Сборка приложения. Роутеры подключаются по мере готовности — см. tasks/."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -47,6 +49,19 @@ async def lifespan(app: FastAPI):
|
||||||
raise RuntimeError(f"библиотека сценариев не прошла проверку: {exc}") from exc
|
raise RuntimeError(f"библиотека сценариев не прошла проверку: {exc}") from exc
|
||||||
app.state.scenarios_loaded = len(loaded)
|
app.state.scenarios_loaded = len(loaded)
|
||||||
|
|
||||||
|
# Утверждённые преподавателем сценарии хранятся в БД и должны переживать
|
||||||
|
# перезапуск процесса. При недоступной БД остаётся базовая YAML-библиотека.
|
||||||
|
try:
|
||||||
|
# БД может ещё подниматься или отсутствовать в unit-тесте. Не задерживаем
|
||||||
|
# старт АРМ на минуту ради необязательной пользовательской библиотеки.
|
||||||
|
async with asyncio.timeout(1):
|
||||||
|
async with get_sessionmaker()() as db:
|
||||||
|
app.state.scenarios_loaded += await store.restore_published(db)
|
||||||
|
except (SQLAlchemyError, OSError, TimeoutError) as exc:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"не удалось восстановить утверждённые сценарии из БД: %s", exc
|
||||||
|
)
|
||||||
|
|
||||||
# Журнал: всё, что не записано, для оценки не существует.
|
# Журнал: всё, что не записано, для оценки не существует.
|
||||||
hub.journal = DbJournal(get_sessionmaker())
|
hub.journal = DbJournal(get_sessionmaker())
|
||||||
|
|
||||||
|
|
|
||||||
100
backend/app/scenarios/editor.py
Normal file
100
backend/app/scenarios/editor.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
"""Локальное, детерминированное редактирование учебных сценариев.
|
||||||
|
|
||||||
|
Это копирование утверждённого шаблона, а не генерация нового сюжета ИИ.
|
||||||
|
Никаких сетевых вызовов в этом модуле нет.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.scenarios.loader import ScenarioError, _derive_ground_truth
|
||||||
|
from app.scenarios.schema import Scenario
|
||||||
|
|
||||||
|
DERIVED_TRUTH = {"incident_type", "dds", "incident_code", "notify", "required_facts"}
|
||||||
|
|
||||||
|
|
||||||
|
def template_copy(source: Scenario, draft_id: str, title: str | None = None) -> dict:
|
||||||
|
"""Сделать полностью самостоятельный шаблонный черновик из сценария."""
|
||||||
|
body = source.model_dump(mode="json")
|
||||||
|
body["id"] = draft_id
|
||||||
|
body["title"] = title or f"{source.title} — копия"
|
||||||
|
body["extends"] = None # чек-лист уже развёрнут загрузчиком
|
||||||
|
body["ticket"] = None
|
||||||
|
body["position"] = None
|
||||||
|
for key in DERIVED_TRUTH:
|
||||||
|
body["ground_truth"].pop(key, None)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def merge_patch(body: dict, patch: dict) -> dict:
|
||||||
|
"""JSON Merge Patch: словари сливаются, списки заменяются целиком.
|
||||||
|
|
||||||
|
`id` — идентичность записи, а вычисляемые поля эталона не принимаются от
|
||||||
|
клиента. Некорректные обычные поля можно временно сохранить в черновике;
|
||||||
|
утверждение всё равно потребует полной проверки.
|
||||||
|
"""
|
||||||
|
if "id" in patch and patch["id"] != body["id"]:
|
||||||
|
raise ScenarioError("id сценария менять нельзя")
|
||||||
|
truth = patch.get("ground_truth")
|
||||||
|
if isinstance(truth, dict) and DERIVED_TRUTH & truth.keys():
|
||||||
|
raise ScenarioError("вычисляемые поля ground_truth нельзя задавать вручную")
|
||||||
|
|
||||||
|
result = deepcopy(body)
|
||||||
|
|
||||||
|
def apply(target: dict, changes: dict) -> None:
|
||||||
|
for key, value in changes.items():
|
||||||
|
if value is None:
|
||||||
|
target.pop(key, None)
|
||||||
|
elif isinstance(value, dict) and isinstance(target.get(key), dict):
|
||||||
|
apply(target[key], value)
|
||||||
|
else:
|
||||||
|
target[key] = deepcopy(value)
|
||||||
|
|
||||||
|
apply(result, patch)
|
||||||
|
result["id"] = body["id"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def validate(body: dict) -> Scenario:
|
||||||
|
"""Проверить схему и связи фактов, затем пересчитать эталон по ЕКП."""
|
||||||
|
declared = body.get("ground_truth") or {}
|
||||||
|
if not isinstance(declared, dict):
|
||||||
|
raise ScenarioError("ground_truth: ожидается объект")
|
||||||
|
forbidden = DERIVED_TRUTH & declared.keys()
|
||||||
|
if forbidden:
|
||||||
|
raise ScenarioError(
|
||||||
|
f"{', '.join(sorted(forbidden))} в ground_truth выводится кодом и руками не пишется"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
scenario = Scenario.model_validate(body)
|
||||||
|
except ValidationError as exc:
|
||||||
|
first = exc.errors()[0]
|
||||||
|
where = ".".join(str(part) for part in first["loc"])
|
||||||
|
raise ScenarioError(f"{where}: {first['msg']}") from exc
|
||||||
|
|
||||||
|
if not scenario.title.strip() or len(scenario.title) > 200:
|
||||||
|
raise ScenarioError("title: требуется название длиной от 1 до 200 символов")
|
||||||
|
if not scenario.first_line.strip():
|
||||||
|
raise ScenarioError("first_line: требуется первая реплика")
|
||||||
|
if not scenario.facts or not scenario.checklist:
|
||||||
|
raise ScenarioError("сценарию нужны факты и чек-лист для оценки занятия")
|
||||||
|
|
||||||
|
known = scenario.fact_ids()
|
||||||
|
question_ids = {item.id for item in scenario.checklist}
|
||||||
|
if len(known) != len(scenario.facts):
|
||||||
|
raise ScenarioError("id фактов должны быть уникальны")
|
||||||
|
if len(question_ids) != len(scenario.checklist):
|
||||||
|
raise ScenarioError("id пунктов чек-листа должны быть уникальны")
|
||||||
|
for item in scenario.checklist:
|
||||||
|
if not item.question:
|
||||||
|
raise ScenarioError(f"у пункта {item.id} нет текста вопроса")
|
||||||
|
if item.fact and item.fact not in known:
|
||||||
|
raise ScenarioError(f"пункт {item.id} ссылается на факт {item.fact}, которого нет")
|
||||||
|
for fact in scenario.facts:
|
||||||
|
question_id = fact.reveal_on.question if fact.reveal_on else None
|
||||||
|
if question_id and question_id not in question_ids:
|
||||||
|
raise ScenarioError(f"факт {fact.id} раскрывается несуществующим вопросом {question_id}")
|
||||||
|
if fact.refine_on and fact.refine_on not in question_ids:
|
||||||
|
raise ScenarioError(f"факт {fact.id} уточняется несуществующим вопросом {fact.refine_on}")
|
||||||
|
return _derive_ground_truth(scenario)
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
@ -12,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.db.models import Scenario as ScenarioRow
|
from app.db.models import Scenario as ScenarioRow
|
||||||
from app.scenarios.loader import load_library
|
from app.scenarios.loader import load_library
|
||||||
from app.scenarios.schema import Scenario
|
from app.scenarios.schema import Scenario
|
||||||
|
from app.scenarios import editor
|
||||||
|
|
||||||
_library: dict[str, Scenario] = {}
|
_library: dict[str, Scenario] = {}
|
||||||
|
|
||||||
|
|
@ -29,6 +31,11 @@ def all_scenarios() -> list[Scenario]:
|
||||||
return list(_library.values())
|
return list(_library.values())
|
||||||
|
|
||||||
|
|
||||||
|
def publish(scenario: Scenario) -> None:
|
||||||
|
"""Сделать утверждённую версию доступной запуску занятия немедленно."""
|
||||||
|
_library[scenario.id] = scenario
|
||||||
|
|
||||||
|
|
||||||
def load_from_disk(root: Path) -> list[Scenario]:
|
def load_from_disk(root: Path) -> list[Scenario]:
|
||||||
scenarios = load_library(root)
|
scenarios = load_library(root)
|
||||||
set_library(scenarios)
|
set_library(scenarios)
|
||||||
|
|
@ -51,3 +58,65 @@ async def seed(db: AsyncSession, scenarios: list[Scenario]) -> int:
|
||||||
row.body = payload
|
row.body = payload
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return len(scenarios)
|
return len(scenarios)
|
||||||
|
|
||||||
|
|
||||||
|
async def restore_published(db: AsyncSession) -> int:
|
||||||
|
"""После старта вернуть утверждённые преподавателем сценарии из БД.
|
||||||
|
|
||||||
|
Вызвать после ``load_from_disk``. Базовую библиотеку не перезаписываем:
|
||||||
|
её источником остаются проверенные YAML из репозитория.
|
||||||
|
"""
|
||||||
|
rows = await db.scalars(select(ScenarioRow).where(ScenarioRow.status == "published"))
|
||||||
|
restored = 0
|
||||||
|
for row in rows:
|
||||||
|
if row.id in _library:
|
||||||
|
continue
|
||||||
|
_library[row.id] = Scenario.model_validate(row.body)
|
||||||
|
restored += 1
|
||||||
|
return restored
|
||||||
|
|
||||||
|
|
||||||
|
async def create_draft(
|
||||||
|
db: AsyncSession, *, source: Scenario, title: str | None = None
|
||||||
|
) -> ScenarioRow:
|
||||||
|
draft_id = f"draft-{uuid4().hex}"
|
||||||
|
body = editor.template_copy(source, draft_id, title)
|
||||||
|
row = ScenarioRow(
|
||||||
|
id=draft_id,
|
||||||
|
title=body["title"],
|
||||||
|
incident_type=body["type"],
|
||||||
|
level=body["level"],
|
||||||
|
topics=body["topics"],
|
||||||
|
modes=body["modes"],
|
||||||
|
status="draft",
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
await db.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def draft(db: AsyncSession, scenario_id: str) -> ScenarioRow | None:
|
||||||
|
row = await db.get(ScenarioRow, scenario_id)
|
||||||
|
return row if row is not None and row.status == "draft" else None
|
||||||
|
|
||||||
|
|
||||||
|
async def update_draft(db: AsyncSession, row: ScenarioRow, patch: dict) -> ScenarioRow:
|
||||||
|
row.body = editor.merge_patch(row.body, patch)
|
||||||
|
row.title = str(row.body.get("title") or "")[:200]
|
||||||
|
await db.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def approve_draft(db: AsyncSession, row: ScenarioRow) -> Scenario:
|
||||||
|
scenario = editor.validate(row.body)
|
||||||
|
row.title = scenario.title
|
||||||
|
row.incident_type = scenario.type.value
|
||||||
|
row.level = scenario.level.value
|
||||||
|
row.topics = scenario.topics
|
||||||
|
row.modes = scenario.modes
|
||||||
|
row.body = scenario.model_dump(mode="json")
|
||||||
|
row.status = "published"
|
||||||
|
await db.commit()
|
||||||
|
publish(scenario)
|
||||||
|
return scenario
|
||||||
|
|
|
||||||
70
backend/app/scoring/card.py
Normal file
70
backend/app/scoring/card.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""Детерминированная оценка текстового упражнения оператора 112.
|
||||||
|
|
||||||
|
Нет входящего звонка и опроса: оцениваются только поля готовой КИО и
|
||||||
|
маршрутизация. Эталон берётся из утверждённого сценария, но не показывается
|
||||||
|
в `card.briefing` до завершения упражнения.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.domain.events import Metric
|
||||||
|
from app.domain.kio import KIO, missing_fields
|
||||||
|
from app.domain.taxonomy import Finding, FindingSource
|
||||||
|
from app.scenarios.schema import Scenario
|
||||||
|
from app.scoring.gost import GostResult, _normalize_address
|
||||||
|
from app.scoring.taxonomy import METRIC_MAP, METRIC_WEIGHTS
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_card(scenario: Scenario, kio: KIO) -> GostResult:
|
||||||
|
result = GostResult()
|
||||||
|
truth = scenario.ground_truth
|
||||||
|
|
||||||
|
def add(key: str, title: str, fact: str, norm: str, passed: bool, ref: str) -> None:
|
||||||
|
result.metrics.append(Metric(
|
||||||
|
key=key, title=title, fact=fact, norm=norm, passed=passed,
|
||||||
|
ref=ref, weight=METRIC_WEIGHTS.get(key, 1.0),
|
||||||
|
))
|
||||||
|
if not passed:
|
||||||
|
code, competency = METRIC_MAP[key]
|
||||||
|
result.findings.append(Finding(
|
||||||
|
code=code,
|
||||||
|
source=FindingSource.KIO if code.value == "E5" else FindingSource.GROUND_TRUTH,
|
||||||
|
summary=f"{title}: {fact}; требуется {norm}",
|
||||||
|
fact=fact, norm=norm, ref=ref, competency=competency,
|
||||||
|
))
|
||||||
|
|
||||||
|
if truth.incident_code:
|
||||||
|
actual = kio.incident_code or "не определён"
|
||||||
|
add("incident_signs", "Признаки происшествия", actual,
|
||||||
|
truth.incident_code, actual == truth.incident_code, "классификатор ЕКП")
|
||||||
|
elif truth.incident_type:
|
||||||
|
actual = kio.incident_type.value if kio.incident_type else "не указан"
|
||||||
|
add("incident_type", "Тип происшествия", actual,
|
||||||
|
truth.incident_type.value, kio.incident_type == truth.incident_type,
|
||||||
|
"классификатор происшествий")
|
||||||
|
|
||||||
|
if truth.dds and truth.incident_code is None:
|
||||||
|
actual = kio.dds.value if kio.dds else "не выбрана"
|
||||||
|
add("dds_choice", "ДДС", actual, truth.dds.value,
|
||||||
|
kio.dds == truth.dds, "список оповещения по сценарию")
|
||||||
|
|
||||||
|
if truth.address:
|
||||||
|
written = kio.address or " ".join(filter(None, (kio.street, kio.building)))
|
||||||
|
add("address", "Адрес происшествия", written or "не заполнен", truth.address,
|
||||||
|
bool(_normalize_address(truth.address))
|
||||||
|
and _normalize_address(truth.address) <= _normalize_address(written),
|
||||||
|
"эталон сценария")
|
||||||
|
|
||||||
|
if truth.victims is not None:
|
||||||
|
actual = str(kio.victims_count) if kio.victims_count is not None else "не указано"
|
||||||
|
add("victims_count", "Число пострадавших", actual, str(truth.victims),
|
||||||
|
kio.victims_count == truth.victims, "эталон сценария")
|
||||||
|
|
||||||
|
required_fields = ([field for field in scenario.required_fields if field != "dds"]
|
||||||
|
if truth.incident_code else scenario.required_fields)
|
||||||
|
if required_fields:
|
||||||
|
missing = missing_fields(kio, required_fields)
|
||||||
|
add("required_fields", "Обязательные поля карточки",
|
||||||
|
"все заполнены" if not missing else "пусто: " + ", ".join(missing),
|
||||||
|
"все обязательные поля заполнены", not missing,
|
||||||
|
"поля КИО утверждённого сценария")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
@ -18,6 +18,8 @@ from app.domain.statuses import (
|
||||||
current,
|
current,
|
||||||
)
|
)
|
||||||
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||||
|
from app.domain.events import Metric
|
||||||
|
from app.domain.timers import GOST_REF
|
||||||
|
|
||||||
#: Статусы, означающие, что реагирование действительно шло.
|
#: Статусы, означающие, что реагирование действительно шло.
|
||||||
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
|
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
|
||||||
|
|
@ -123,3 +125,61 @@ def evaluate_dispatcher(
|
||||||
)
|
)
|
||||||
|
|
||||||
return findings
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
|
||||||
|
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
|
||||||
|
|
||||||
|
Веса предварительные — до утверждения методистом. Неподтверждённые
|
||||||
|
телефонные действия нельзя засчитывать по одной кнопке статуса.
|
||||||
|
"""
|
||||||
|
metrics: list[Metric] = []
|
||||||
|
for service in state.notified_services():
|
||||||
|
marks = [mark for mark in state.status_log if mark.service == service]
|
||||||
|
statuses = {mark.status for mark in marks}
|
||||||
|
reports = {report.phase for report in state.phone_reports if report.service == service}
|
||||||
|
selected_crew = state.crew_assignments.get(service)
|
||||||
|
selected = selected_crew is not None
|
||||||
|
|
||||||
|
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
|
||||||
|
metrics.append(Metric(
|
||||||
|
key=key, title=f"{service}: {title}", fact=fact, norm=norm,
|
||||||
|
ref=GOST_REF if key == "dds_ack" else "памятка «Работа на АРМ-112», статусы реагирования",
|
||||||
|
passed=passed, weight=weight,
|
||||||
|
))
|
||||||
|
|
||||||
|
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
|
||||||
|
elapsed = (int((primary.at - state.dispatched_at).total_seconds() * 1000)
|
||||||
|
if primary and state.dispatched_at else None)
|
||||||
|
add("dds_primary", "решение по карточке", primary is not None,
|
||||||
|
primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0)
|
||||||
|
add("dds_ack", "первичная отметка за 30 с",
|
||||||
|
elapsed is not None and elapsed <= deadline_ms,
|
||||||
|
f"{elapsed / 1000:.1f} с" if elapsed is not None else "не отмечено",
|
||||||
|
f"≤ {deadline_ms // 1000} с")
|
||||||
|
if primary is None:
|
||||||
|
continue
|
||||||
|
add("dds_decision", "профильное реагирование",
|
||||||
|
primary.status is ServiceStatus.ACCEPTED,
|
||||||
|
primary.status.value, "профильную заявку принять", 2.0)
|
||||||
|
if primary.status is ServiceStatus.DECLINED:
|
||||||
|
continue
|
||||||
|
add("dds_crew", "ручной выбор бригады", selected,
|
||||||
|
selected_crew or "бригада не выбрана", "выбрать конкретную бригаду", 1.5)
|
||||||
|
add("dds_contact", "связь с бригадой", bool(reports),
|
||||||
|
", ".join(sorted(reports)) or "докладов нет", "получить доклад о выезде", 1.5)
|
||||||
|
expected = {
|
||||||
|
"dispatched": ServiceStatus.RESPONDING,
|
||||||
|
"arrived": ServiceStatus.ARRIVED,
|
||||||
|
"working": ServiceStatus.WORKING,
|
||||||
|
}
|
||||||
|
aligned = all(status in statuses for phase, status in expected.items() if phase in reports)
|
||||||
|
all_progress = all(phase in reports and status in statuses for phase, status in expected.items())
|
||||||
|
add("dds_progress", "статусы по докладам", aligned and all_progress,
|
||||||
|
", ".join(status.value for status in statuses) or "статусов нет",
|
||||||
|
"выезд, прибытие и работы отмечены после докладов", 2.0)
|
||||||
|
add("dds_completion", "закрытие работ по докладу",
|
||||||
|
"completed" in reports and ServiceStatus.COMPLETED in statuses,
|
||||||
|
"завершено" if ServiceStatus.COMPLETED in statuses else "не завершено",
|
||||||
|
"доклад о завершении и статус «Работы завершены»")
|
||||||
|
return metrics
|
||||||
|
|
|
||||||
228
backend/app/scoring/export.py
Normal file
228
backend/app/scoring/export.py
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
"""Экспорт разбора занятия без обращения к внешним сервисам.
|
||||||
|
|
||||||
|
CSV - плоский журнал с пометкой раздела. PDF - читаемый протокол с переносом
|
||||||
|
длинных реплик и автоматическим разбиением на страницы.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
from html import escape
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.domain.events import SessionReport
|
||||||
|
|
||||||
|
|
||||||
|
def _cell(value: object) -> str:
|
||||||
|
"""Excel/LibreOffice не должны исполнять текст курсанта как формулу."""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
result = str(value)
|
||||||
|
probe = result.lstrip(" \t\r\n\ufeff\u200b")
|
||||||
|
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
|
||||||
|
return "'" + result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def to_csv(report: SessionReport) -> bytes:
|
||||||
|
"""UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки."""
|
||||||
|
output = io.StringIO(newline="")
|
||||||
|
writer = csv.writer(output, lineterminator="\r\n")
|
||||||
|
writer.writerow(["Раздел", "№", "Поле", "Значение", "Дополнительно"])
|
||||||
|
|
||||||
|
def row(section: str, index: object, field: str, value: object, detail: object = "") -> None:
|
||||||
|
writer.writerow([_cell(part) for part in (section, index, field, value, detail)])
|
||||||
|
|
||||||
|
row("Занятие", "", "ID", report.session_id)
|
||||||
|
row("Занятие", "", "Сценарий", report.scenario_id)
|
||||||
|
row("Занятие", "", "Режим", report.mode.value)
|
||||||
|
row("Занятие", "", "Попытка", report.attempt)
|
||||||
|
row("Оценка", "", "Автоматическая", report.score_auto)
|
||||||
|
row("Оценка", "", "Итоговая", report.score_final)
|
||||||
|
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
|
||||||
|
row("Оценка", "", "Причина изменения", report.override_comment)
|
||||||
|
|
||||||
|
for number, item in enumerate(report.metrics, 1):
|
||||||
|
row("Метрики", number, item.title, item.fact, f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; источник: {item.ref or ''}")
|
||||||
|
for number, item in enumerate(report.findings, 1):
|
||||||
|
row("Ошибки", number, item.code.value, item.summary, f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}")
|
||||||
|
for number, item in enumerate(report.competencies, 1):
|
||||||
|
row("Компетенции", number, item.competency, item.value)
|
||||||
|
for number, item in enumerate(report.transcript, 1):
|
||||||
|
row("Транскрипт", number, item.speaker.value, item.text, item.at.isoformat())
|
||||||
|
for number, item in enumerate(report.reference_questions, 1):
|
||||||
|
row("Эталонные вопросы", number, item.checklist_id, item.question)
|
||||||
|
for number, item in enumerate(report.missed_checklist, 1):
|
||||||
|
row("Пропущенные вопросы", number, "Пункт", item)
|
||||||
|
for number, item in enumerate(report.hints_used, 1):
|
||||||
|
row("Подсказки", number, item.checklist_id, item.question, item.at.isoformat())
|
||||||
|
if report.self_assessment:
|
||||||
|
row("Самооценка", "", "Пропущено по мнению курсанта", ", ".join(report.self_assessment.missed))
|
||||||
|
row("Самооценка", "", "Комментарий", report.self_assessment.comment)
|
||||||
|
if report.self_assessment_diff:
|
||||||
|
for label, values in (
|
||||||
|
("Заметил", report.self_assessment_diff.noticed),
|
||||||
|
("Не заметил", report.self_assessment_diff.unnoticed),
|
||||||
|
("Отметил ошибочно", report.self_assessment_diff.overcautious),
|
||||||
|
):
|
||||||
|
row("Сверка самооценки", "", label, ", ".join(values))
|
||||||
|
for number, item in enumerate(report.notes, 1):
|
||||||
|
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
|
||||||
|
return ("\ufeff" + output.getvalue()).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _font_paths() -> tuple[Path, Path | None]:
|
||||||
|
"""Находить TTF с кириллицей явно, без молчаливой подмены Helvetica."""
|
||||||
|
custom = os.environ.get("LCT_PDF_FONT")
|
||||||
|
if custom:
|
||||||
|
path = Path(custom)
|
||||||
|
if not path.is_file():
|
||||||
|
raise RuntimeError(f"LCT_PDF_FONT не найден: {path}")
|
||||||
|
return path, None
|
||||||
|
candidates = (
|
||||||
|
("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
|
||||||
|
("/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf", "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf"),
|
||||||
|
("/System/Library/Fonts/Supplemental/Arial.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf"),
|
||||||
|
)
|
||||||
|
for regular, bold in candidates:
|
||||||
|
if Path(regular).is_file():
|
||||||
|
return Path(regular), Path(bold) if Path(bold).is_file() else None
|
||||||
|
raise RuntimeError("Не найден TTF-шрифт с кириллицей; установите DejaVu Sans или задайте LCT_PDF_FONT")
|
||||||
|
|
||||||
|
|
||||||
|
def to_pdf(report: SessionReport) -> bytes:
|
||||||
|
"""Собрать многостраничный PDF с кириллицей и переносом длинных текстов."""
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.lib.enums import TA_LEFT
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.lib.styles import ParagraphStyle
|
||||||
|
from reportlab.pdfbase import pdfmetrics
|
||||||
|
from reportlab.pdfbase.ttfonts import TTFont
|
||||||
|
from reportlab.platypus import HRFlowable, Paragraph, SimpleDocTemplate, Spacer
|
||||||
|
|
||||||
|
regular_path, bold_path = _font_paths()
|
||||||
|
regular_name = "LCTReportRegular"
|
||||||
|
bold_name = "LCTReportBold"
|
||||||
|
if regular_name not in pdfmetrics.getRegisteredFontNames():
|
||||||
|
regular = TTFont(regular_name, str(regular_path))
|
||||||
|
if not all(ord(char) in regular.face.charToGlyph for char in "Отчёт курсанта Ёж"):
|
||||||
|
raise RuntimeError(f"Шрифт не поддерживает кириллицу: {regular_path}")
|
||||||
|
pdfmetrics.registerFont(regular)
|
||||||
|
pdfmetrics.registerFont(TTFont(bold_name, str(bold_path or regular_path)))
|
||||||
|
pdfmetrics.registerFontFamily(regular_name, normal=regular_name, bold=bold_name)
|
||||||
|
|
||||||
|
base = ParagraphStyle(
|
||||||
|
"body", fontName=regular_name, fontSize=9, leading=13,
|
||||||
|
textColor=colors.HexColor("#18202a"), spaceAfter=5,
|
||||||
|
allowWidows=0, allowOrphans=0, splitLongWords=1,
|
||||||
|
)
|
||||||
|
title_style = ParagraphStyle(
|
||||||
|
"title", parent=base, fontName=bold_name, fontSize=17, leading=22,
|
||||||
|
textColor=colors.HexColor("#193b56"), spaceAfter=10,
|
||||||
|
)
|
||||||
|
heading = ParagraphStyle(
|
||||||
|
"heading", parent=base, fontName=bold_name, fontSize=12, leading=17,
|
||||||
|
textColor=colors.HexColor("#193b56"), spaceBefore=13, spaceAfter=5,
|
||||||
|
keepWithNext=True,
|
||||||
|
)
|
||||||
|
muted = ParagraphStyle("muted", parent=base, fontSize=8, textColor=colors.HexColor("#5d6873"))
|
||||||
|
|
||||||
|
def p(value: object, style=base):
|
||||||
|
# XML-экранирование необходимо: реплики и пометки вводит человек.
|
||||||
|
safe = escape("" if value is None else str(value)).replace("\n", "<br/>")
|
||||||
|
return Paragraph(safe or "-", style)
|
||||||
|
|
||||||
|
story = [p("Отчёт по учебному занятию", title_style)]
|
||||||
|
for label, value in (
|
||||||
|
("Идентификатор", report.session_id),
|
||||||
|
("Сценарий", report.scenario_id),
|
||||||
|
("Режим", report.mode.value),
|
||||||
|
("Попытка", report.attempt),
|
||||||
|
("Автоматическая оценка", report.score_auto),
|
||||||
|
("Итоговая оценка", report.score_final),
|
||||||
|
):
|
||||||
|
story.append(Paragraph(f"<b>{escape(label)}:</b> {escape(str(value))}", base))
|
||||||
|
if report.overridden_by:
|
||||||
|
story.append(Paragraph(f"<b>Оценка изменена:</b> {escape(report.overridden_by)}", base))
|
||||||
|
if report.override_comment:
|
||||||
|
story.append(p(f"Причина изменения: {report.override_comment}"))
|
||||||
|
story.extend([Spacer(1, 7), HRFlowable(width="100%", color=colors.HexColor("#aeb8c2"))])
|
||||||
|
|
||||||
|
def section(label: str):
|
||||||
|
story.append(p(label, heading))
|
||||||
|
|
||||||
|
section("Метрики")
|
||||||
|
if not report.metrics:
|
||||||
|
story.append(p("Нет данных", muted))
|
||||||
|
for item in report.metrics:
|
||||||
|
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'}"))
|
||||||
|
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm}. {item.ref or ''}", muted))
|
||||||
|
|
||||||
|
section("Выявленные ошибки")
|
||||||
|
if not report.findings:
|
||||||
|
story.append(p("Ошибок не выявлено", muted))
|
||||||
|
for item in report.findings:
|
||||||
|
story.append(p(f"{item.code.value} - {item.summary}"))
|
||||||
|
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. Источник: {item.ref or 'не указан'}", muted))
|
||||||
|
|
||||||
|
section("Компетенции")
|
||||||
|
for item in report.competencies:
|
||||||
|
story.append(p(f"{item.competency}: {item.value:g}"))
|
||||||
|
if not report.competencies:
|
||||||
|
story.append(p("Нет данных", muted))
|
||||||
|
|
||||||
|
section("Транскрипт")
|
||||||
|
if not report.transcript:
|
||||||
|
story.append(p("Реплик нет", muted))
|
||||||
|
for item in report.transcript:
|
||||||
|
story.append(p(f"{item.at.isoformat()} - {item.speaker.value}", muted))
|
||||||
|
story.append(p(item.text))
|
||||||
|
|
||||||
|
section("Эталонные вопросы")
|
||||||
|
for item in report.reference_questions:
|
||||||
|
story.append(p(f"{item.checklist_id}: {item.question}"))
|
||||||
|
if not report.reference_questions:
|
||||||
|
story.append(p("Нет данных", muted))
|
||||||
|
if report.missed_checklist:
|
||||||
|
section("Пропущенные пункты")
|
||||||
|
for item in report.missed_checklist:
|
||||||
|
story.append(p(item))
|
||||||
|
|
||||||
|
if report.hints_used:
|
||||||
|
section("Использованные подсказки")
|
||||||
|
for item in report.hints_used:
|
||||||
|
story.append(p(f"{item.at.isoformat()} - {item.checklist_id}: {item.question}"))
|
||||||
|
|
||||||
|
if report.self_assessment:
|
||||||
|
section("Самооценка")
|
||||||
|
story.append(p("Пропущено по мнению курсанта: " + ", ".join(report.self_assessment.missed)))
|
||||||
|
story.append(p("Комментарий: " + report.self_assessment.comment))
|
||||||
|
if report.self_assessment_diff:
|
||||||
|
for label, items in (
|
||||||
|
("Заметил", report.self_assessment_diff.noticed),
|
||||||
|
("Не заметил", report.self_assessment_diff.unnoticed),
|
||||||
|
("Отметил ошибочно", report.self_assessment_diff.overcautious),
|
||||||
|
):
|
||||||
|
story.append(p(f"{label}: {', '.join(items) or '-'}"))
|
||||||
|
if report.notes:
|
||||||
|
section("Пометки преподавателя")
|
||||||
|
for item in report.notes:
|
||||||
|
story.append(p(f"{item.transcript_ref} - {item.author}: {item.text}"))
|
||||||
|
|
||||||
|
output = io.BytesIO()
|
||||||
|
document = SimpleDocTemplate(
|
||||||
|
output, pagesize=A4, leftMargin=42, rightMargin=42,
|
||||||
|
topMargin=45, bottomMargin=44,
|
||||||
|
title="Отчёт по учебному занятию", author="Учебный симулятор 112",
|
||||||
|
)
|
||||||
|
|
||||||
|
def footer(canvas, doc):
|
||||||
|
canvas.saveState()
|
||||||
|
canvas.setFont(regular_name, 8)
|
||||||
|
canvas.setFillColor(colors.HexColor("#5d6873"))
|
||||||
|
canvas.drawString(42, 25, "Учебный симулятор 112")
|
||||||
|
canvas.drawRightString(A4[0] - 42, 25, f"Стр. {doc.page}")
|
||||||
|
canvas.restoreState()
|
||||||
|
|
||||||
|
document.build(story, onFirstPage=footer, onLaterPages=footer)
|
||||||
|
return output.getvalue()
|
||||||
|
|
@ -16,6 +16,8 @@ from app.domain.events import (
|
||||||
SelfAssessment,
|
SelfAssessment,
|
||||||
SelfAssessmentDiff,
|
SelfAssessmentDiff,
|
||||||
SessionReport,
|
SessionReport,
|
||||||
|
Speaker,
|
||||||
|
Exercise,
|
||||||
)
|
)
|
||||||
from app.domain.taxonomy import Finding
|
from app.domain.taxonomy import Finding
|
||||||
from app.scenarios.schema import Scenario
|
from app.scenarios.schema import Scenario
|
||||||
|
|
@ -43,7 +45,12 @@ def assess_difference(
|
||||||
|
|
||||||
def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
||||||
score = state.score or {}
|
score = state.score or {}
|
||||||
revealed = [fact.id for fact in state.slots.revealed_facts()] if state.slots else None
|
# Без модели эмбеддингов распознать *какие* факты прозвучали нельзя.
|
||||||
|
# Но если оператор не произнёс ни слова, все обязательные вопросы точно
|
||||||
|
# пропущены — это доказуемый частный случай и он нужен офлайн-разбору.
|
||||||
|
revealed = ([fact.id for fact in state.slots.revealed_facts()] if state.slots else
|
||||||
|
[] if not any(entry.speaker is Speaker.OPERATOR for entry in state.transcript)
|
||||||
|
else None)
|
||||||
reference = build_reference(scenario)
|
reference = build_reference(scenario)
|
||||||
|
|
||||||
self_assessment = None
|
self_assessment = None
|
||||||
|
|
@ -61,7 +68,7 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
||||||
step.checklist_id
|
step.checklist_id
|
||||||
for step in reference.steps
|
for step in reference.steps
|
||||||
if step.required and revealed is not None and step.fact_id not in revealed
|
if step.required and revealed is not None and step.fact_id not in revealed
|
||||||
]
|
] if state.exercise is not Exercise.CARD else []
|
||||||
|
|
||||||
return SessionReport(
|
return SessionReport(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
|
@ -77,7 +84,7 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
||||||
reference_questions=[
|
reference_questions=[
|
||||||
HintShown(checklist_id=step.checklist_id, question=step.question)
|
HintShown(checklist_id=step.checklist_id, question=step.question)
|
||||||
for step in reference.steps
|
for step in reference.steps
|
||||||
],
|
] if state.exercise is not Exercise.CARD else [],
|
||||||
missed_checklist=missed_checklist,
|
missed_checklist=missed_checklist,
|
||||||
hints_used=[
|
hints_used=[
|
||||||
HintUsage(checklist_id=checklist_id, question=questions.get(checklist_id, ""), at=at)
|
HintUsage(checklist_id=checklist_id, question=questions.get(checklist_id, ""), at=at)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,13 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
|
||||||
"address": (ErrorCode.E5, Competency.CARD),
|
"address": (ErrorCode.E5, Competency.CARD),
|
||||||
"victims_count": (ErrorCode.E5, Competency.CARD),
|
"victims_count": (ErrorCode.E5, Competency.CARD),
|
||||||
"required_fields": (ErrorCode.E5, Competency.CARD),
|
"required_fields": (ErrorCode.E5, Competency.CARD),
|
||||||
|
"dds_primary": (ErrorCode.D1, Competency.CARD),
|
||||||
|
"dds_ack": (ErrorCode.D1, Competency.NORMS),
|
||||||
|
"dds_decision": (ErrorCode.D3, Competency.ROUTING),
|
||||||
|
"dds_crew": (ErrorCode.D2, Competency.ROUTING),
|
||||||
|
"dds_contact": (ErrorCode.D6, Competency.COMMUNICATION),
|
||||||
|
"dds_progress": (ErrorCode.D6, Competency.CARD),
|
||||||
|
"dds_completion": (ErrorCode.D6, Competency.CARD),
|
||||||
}
|
}
|
||||||
|
|
||||||
#: Вес метрики в детерминированной оценке.
|
#: Вес метрики в детерминированной оценке.
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,13 @@
|
||||||
import logging
|
import logging
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.domain.events import ScoreReady
|
from app.domain.events import Exercise, ScoreReady
|
||||||
from app.domain.timers import NORMATIVES, TimerCode
|
from app.domain.timers import NORMATIVES, TimerCode
|
||||||
from app.scenarios import store
|
from app.scenarios import store
|
||||||
from app.scoring.competency import radar
|
from app.scoring.competency import radar
|
||||||
from app.scoring.dispatcher import evaluate_dispatcher
|
from app.scoring.card import evaluate_card
|
||||||
from app.scoring.gost import evaluate
|
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||||
|
from app.scoring.gost import GostResult, evaluate
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
@ -27,6 +28,13 @@ async def finish(session_id: UUID, state) -> None:
|
||||||
if scenario is None:
|
if scenario is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if state.exercise is Exercise.CARD:
|
||||||
|
result = evaluate_card(scenario, state.dispatched_card or state.kio)
|
||||||
|
elif state.exercise is Exercise.DDS:
|
||||||
|
# В готовой карточке не было входящего звонка, опроса и действий
|
||||||
|
# оператора 112. Ни одна E-метрика здесь не применима.
|
||||||
|
result = GostResult()
|
||||||
|
else:
|
||||||
result = evaluate(
|
result = evaluate(
|
||||||
scenario=scenario,
|
scenario=scenario,
|
||||||
kio=state.kio,
|
kio=state.kio,
|
||||||
|
|
@ -41,6 +49,7 @@ async def finish(session_id: UUID, state) -> None:
|
||||||
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
||||||
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
||||||
# участвуют оба (docs/spec/DATASET.md#статусы-реагирования).
|
# участвуют оба (docs/spec/DATASET.md#статусы-реагирования).
|
||||||
|
if state.dispatched_card is not None and state.exercise is not Exercise.CARD:
|
||||||
dispatcher_findings = evaluate_dispatcher(
|
dispatcher_findings = evaluate_dispatcher(
|
||||||
entries=state.status_log,
|
entries=state.status_log,
|
||||||
services=state.notified_services(),
|
services=state.notified_services(),
|
||||||
|
|
@ -48,6 +57,7 @@ async def finish(session_id: UUID, state) -> None:
|
||||||
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
||||||
)
|
)
|
||||||
result.findings.extend(dispatcher_findings)
|
result.findings.extend(dispatcher_findings)
|
||||||
|
result.metrics.extend(dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms))
|
||||||
|
|
||||||
# Сводка числами: по ней считается дельта между попытками в профиле.
|
# Сводка числами: по ней считается дельта между попытками в профиле.
|
||||||
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
|
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
|
||||||
|
|
@ -84,5 +94,5 @@ async def finish(session_id: UUID, state) -> None:
|
||||||
|
|
||||||
async def release_score(session_id: UUID, state) -> None:
|
async def release_score(session_id: UUID, state) -> None:
|
||||||
"""Отдать оценку курсанту, когда самооценка сдана."""
|
"""Отдать оценку курсанту, когда самооценка сдана."""
|
||||||
if state.score is not None and state.self_assessed:
|
if state.score is not None and (state.self_assessed or state.exercise is Exercise.CARD):
|
||||||
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
|
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,9 @@ class Journal(Protocol):
|
||||||
но молчать об ошибке записи нельзя."""
|
но молчать об ошибке записи нельзя."""
|
||||||
|
|
||||||
async def start_lesson(
|
async def start_lesson(
|
||||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None
|
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
|
||||||
) -> int: ...
|
trainee_id: UUID | None = None,
|
||||||
|
) -> tuple[int, UUID | None]: ...
|
||||||
async def utterance(self, session_id: UUID, entry) -> None: ...
|
async def utterance(self, session_id: UUID, entry) -> None: ...
|
||||||
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
|
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
|
||||||
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...
|
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,10 @@ class DbJournal:
|
||||||
log.exception("журнал: запись не удалась")
|
log.exception("журнал: запись не удалась")
|
||||||
|
|
||||||
async def start_lesson(
|
async def start_lesson(
|
||||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None
|
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
|
||||||
) -> int:
|
trainee_id: UUID | None = None,
|
||||||
"""Завести сессию в журнале и вернуть номер попытки.
|
) -> tuple[int, UUID | None]:
|
||||||
|
"""Завести сессию в журнале и вернуть номер попытки и ID курсанта.
|
||||||
|
|
||||||
Если база недоступна, занятие всё равно идёт: номер попытки
|
Если база недоступна, занятие всё равно идёт: номер попытки
|
||||||
деградирует до первого, и это видно в логе.
|
деградирует до первого, и это видно в логе.
|
||||||
|
|
@ -46,11 +47,12 @@ class DbJournal:
|
||||||
scenario_id=scenario_id,
|
scenario_id=scenario_id,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
trainee_name=trainee_name,
|
trainee_name=trainee_name,
|
||||||
|
trainee_id=trainee_id,
|
||||||
)
|
)
|
||||||
return row.attempt
|
return row.attempt, row.trainee_id
|
||||||
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
|
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||||
log.exception("журнал: сессию завести не удалось")
|
log.exception("журнал: сессию завести не удалось")
|
||||||
return 1
|
return 1, trainee_id
|
||||||
|
|
||||||
async def utterance(self, session_id: UUID, entry) -> None:
|
async def utterance(self, session_id: UUID, entry) -> None:
|
||||||
await self._write(
|
await self._write(
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from uuid import UUID
|
||||||
|
|
||||||
from app.domain.events import (
|
from app.domain.events import (
|
||||||
CallEndReason,
|
CallEndReason,
|
||||||
|
Exercise,
|
||||||
Mood,
|
Mood,
|
||||||
SessionMode,
|
SessionMode,
|
||||||
SessionSnapshot,
|
SessionSnapshot,
|
||||||
|
|
@ -27,12 +28,13 @@ from app.domain.statuses import (
|
||||||
ServiceStatus,
|
ServiceStatus,
|
||||||
StationSnapshot,
|
StationSnapshot,
|
||||||
StatusEntry,
|
StatusEntry,
|
||||||
|
PhoneReportRecord,
|
||||||
card_status,
|
card_status,
|
||||||
check,
|
check,
|
||||||
current,
|
current,
|
||||||
)
|
)
|
||||||
from app.domain.timers import NORMATIVES, TimerCode
|
from app.domain.timers import NORMATIVES, TimerCode
|
||||||
from app.domain.kio import KIO, apply_patch
|
from app.domain.kio import KIO, ResponseStatus, apply_patch
|
||||||
from app.session.timers import SessionTimers
|
from app.session.timers import SessionTimers
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -48,6 +50,7 @@ class SessionState:
|
||||||
scenario_title: str
|
scenario_title: str
|
||||||
level: str
|
level: str
|
||||||
mode: SessionMode
|
mode: SessionMode
|
||||||
|
exercise: Exercise = Exercise.CALL
|
||||||
required_fields: list[str] = field(default_factory=list)
|
required_fields: list[str] = field(default_factory=list)
|
||||||
trainee_name: str | None = None
|
trainee_name: str | None = None
|
||||||
#: Чьё это занятие. Проставляется при запуске, когда курсант известен
|
#: Чьё это занятие. Проставляется при запуске, когда курсант известен
|
||||||
|
|
@ -96,6 +99,9 @@ class SessionState:
|
||||||
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
|
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
|
||||||
#: Отметки служб по списку оповещения — предмет второго режима занятия.
|
#: Отметки служб по списку оповещения — предмет второго режима занятия.
|
||||||
status_log: list[StatusEntry] = field(default_factory=list)
|
status_log: list[StatusEntry] = field(default_factory=list)
|
||||||
|
crew_selected: str | None = None
|
||||||
|
crew_assignments: dict[str, str] = field(default_factory=dict)
|
||||||
|
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
|
||||||
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
|
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
|
||||||
resolved_outcome: str | None = None
|
resolved_outcome: str | None = None
|
||||||
resolve_comment: str = ""
|
resolve_comment: str = ""
|
||||||
|
|
@ -119,10 +125,12 @@ class SessionState:
|
||||||
self.kio = apply_patch(self.kio, fields)
|
self.kio = apply_patch(self.kio, fields)
|
||||||
return self.kio
|
return self.kio
|
||||||
|
|
||||||
def dispatch(self, service: str) -> KIO:
|
def dispatch(self, service: str | None = None) -> KIO:
|
||||||
"""Карточка замораживается снимком: оператор не должен иметь
|
"""Карточка замораживается снимком: оператор не должен иметь
|
||||||
возможности дописать задним числом поле, которое забыл."""
|
возможности дописать задним числом поле, которое забыл."""
|
||||||
self.kio = apply_patch(self.kio, {"dds": service, "response_status": "transferred"})
|
if service is not None:
|
||||||
|
self.kio = apply_patch(self.kio, {"dds": service})
|
||||||
|
self.kio.response_status = ResponseStatus.TRANSFERRED
|
||||||
self.dispatched_card = self.kio.model_copy(deep=True)
|
self.dispatched_card = self.kio.model_copy(deep=True)
|
||||||
self.dispatched_at = now_utc()
|
self.dispatched_at = now_utc()
|
||||||
return self.dispatched_card
|
return self.dispatched_card
|
||||||
|
|
@ -133,6 +141,14 @@ class SessionState:
|
||||||
card = self.dispatched_card or self.kio
|
card = self.dispatched_card or self.kio
|
||||||
return list(card.notify)
|
return list(card.notify)
|
||||||
|
|
||||||
|
def crew_options(self) -> list[str]:
|
||||||
|
return [f"{service} — бригада №{number}"
|
||||||
|
for service in self.notified_services() for number in (1, 2)]
|
||||||
|
|
||||||
|
def crew_service(self, crew: str) -> str | None:
|
||||||
|
return next((service for service in self.notified_services()
|
||||||
|
if crew in (f"{service} — бригада №1", f"{service} — бригада №2")), None)
|
||||||
|
|
||||||
def set_service_status(
|
def set_service_status(
|
||||||
self, service: str, status: ServiceStatus, comment: str = "", author: str = ""
|
self, service: str, status: ServiceStatus, comment: str = "", author: str = ""
|
||||||
) -> StatusEntry:
|
) -> StatusEntry:
|
||||||
|
|
@ -161,6 +177,9 @@ class SessionState:
|
||||||
available={service: list(NEXT[status]) for service, status in statuses.items()},
|
available={service: list(NEXT[status]) for service, status in statuses.items()},
|
||||||
card=card_status(self.status_log, services, notify_deadline_passed=overdue),
|
card=card_status(self.status_log, services, notify_deadline_passed=overdue),
|
||||||
log=list(self.status_log),
|
log=list(self.status_log),
|
||||||
|
crew_options=self.crew_options(),
|
||||||
|
crew_selected=self.crew_selected,
|
||||||
|
phone_reports=list(self.phone_reports),
|
||||||
)
|
)
|
||||||
|
|
||||||
def card_received_event(self):
|
def card_received_event(self):
|
||||||
|
|
@ -169,7 +188,8 @@ class SessionState:
|
||||||
|
|
||||||
return CardReceived(
|
return CardReceived(
|
||||||
card=self.dispatched_card,
|
card=self.dispatched_card,
|
||||||
from_operator=self.trainee_name or "оператор 112",
|
from_operator=("учебный сценарий" if self.exercise is Exercise.DDS
|
||||||
|
else self.trainee_name or "оператор 112"),
|
||||||
at=self.dispatched_at or now_utc(),
|
at=self.dispatched_at or now_utc(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -186,6 +206,7 @@ class SessionState:
|
||||||
scenario_title=self.scenario_title,
|
scenario_title=self.scenario_title,
|
||||||
level=self.level,
|
level=self.level,
|
||||||
mode=self.mode,
|
mode=self.mode,
|
||||||
|
exercise=self.exercise,
|
||||||
trainee_name=self.trainee_name,
|
trainee_name=self.trainee_name,
|
||||||
started_at=self.started_at,
|
started_at=self.started_at,
|
||||||
kio=self.kio,
|
kio=self.kio,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ dependencies = [
|
||||||
"asyncpg>=0.29",
|
"asyncpg>=0.29",
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
|
"reportlab>=4.0",
|
||||||
"structlog>=24.1",
|
"structlog>=24.1",
|
||||||
# Эмбеддинги для слот-автомата: матчинг вопроса к чек-листу. Нужны ядру,
|
# Эмбеддинги для слот-автомата: матчинг вопроса к чек-листу. Нужны ядру,
|
||||||
# а не только голосу, поэтому не в группе voice.
|
# а не только голосу, поэтому не в группе voice.
|
||||||
|
|
|
||||||
125
backend/tests/test_card_exercise.py
Normal file
125
backend/tests/test_card_exercise.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
"""Текстовая вводная 112: карточка без голоса, опроса и ДДС-оценки."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.session.hub import hub
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
with TestClient(app) as test_client:
|
||||||
|
test_client.post("/api/auth/dev-token")
|
||||||
|
hub.journal = None
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for(predicate, timeout=3):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
value = predicate()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
time.sleep(0.02)
|
||||||
|
raise AssertionError("не дождались обновления состояния")
|
||||||
|
|
||||||
|
|
||||||
|
def read_until(socket, wanted):
|
||||||
|
for _ in range(20):
|
||||||
|
event = socket.receive_json()
|
||||||
|
if event["type"] == wanted:
|
||||||
|
return event
|
||||||
|
raise AssertionError(f"событие {wanted} не пришло")
|
||||||
|
|
||||||
|
|
||||||
|
def start(client):
|
||||||
|
session_id = uuid4()
|
||||||
|
context = client.websocket_connect(f"/ws/control/{session_id}")
|
||||||
|
control = context.__enter__()
|
||||||
|
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||||
|
"trainee": "Иванов", "mode": "training", "exercise": "card"})
|
||||||
|
wait_for(lambda: hub.get(session_id))
|
||||||
|
return session_id, context
|
||||||
|
|
||||||
|
|
||||||
|
def test_card_briefing_is_text_only_and_replayed_on_late_join(client):
|
||||||
|
session_id, control = start(client)
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
assert state.exercise.value == "card"
|
||||||
|
assert state.dispatched_card is None
|
||||||
|
assert state.slots is None and state.voice is None
|
||||||
|
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||||
|
briefing = read_until(trainee, "card.briefing")
|
||||||
|
assert briefing["scenario_id"] == "fire-apartment-l2"
|
||||||
|
assert "Помогите" in briefing["text"]
|
||||||
|
assert "горит балкон" in briefing["text"]
|
||||||
|
assert "ground_truth" not in briefing
|
||||||
|
assert briefing["card"]["address"] is None
|
||||||
|
assert "address" in briefing["required_fields"]
|
||||||
|
trainee.send_bytes(b"\0" * 640)
|
||||||
|
assert read_until(trainee, "error")["code"] == "unsupported_event"
|
||||||
|
with client.websocket_connect(f"/ws/call/{session_id}") as late:
|
||||||
|
assert read_until(late, "card.briefing")["text"] == briefing["text"]
|
||||||
|
finally:
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_correct_card_scores_100_without_call_or_dds_metrics(client):
|
||||||
|
session_id, control = start(client)
|
||||||
|
try:
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||||
|
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||||
|
briefing = read_until(trainee, "card.briefing")
|
||||||
|
assert "dds" not in briefing["required_fields"]
|
||||||
|
trainee.send_json({"type": "kio.patch", "fields": {
|
||||||
|
"address": "улица Ленина, 14", "floor": "5", "incident_type": "fire",
|
||||||
|
"victims_count": 2, "description": "горит балкон",
|
||||||
|
"signs": ["жилой дом", "балкон", "открытое пламя"],
|
||||||
|
}})
|
||||||
|
wait_for(lambda: hub.get(session_id).kio.incident_code)
|
||||||
|
trainee.send_json({"type": "card.submit"})
|
||||||
|
assert read_until(trainee, "call.ended")["reason"] == "complete"
|
||||||
|
assert read_until(trainee, "score.ready")["session_id"] == str(session_id)
|
||||||
|
received = read_until(station, "card.received")
|
||||||
|
assert received["card"]["address"] == "улица Ленина, 14"
|
||||||
|
read_until(station, "station.state")
|
||||||
|
state = hub.get(session_id)
|
||||||
|
assert state.dispatched_card is not None
|
||||||
|
assert state.score["score_auto"] == 100.0
|
||||||
|
assert not state.score["findings"]
|
||||||
|
assert {item["key"] for item in state.score["metrics"]} == {
|
||||||
|
"incident_signs", "address", "victims_count", "required_fields"
|
||||||
|
}
|
||||||
|
with client.websocket_connect(f"/ws/call/{session_id}") as late:
|
||||||
|
assert read_until(late, "card.briefing")["card"]["address"] == "улица Ленина, 14"
|
||||||
|
assert read_until(late, "call.ended")["reason"] == "complete"
|
||||||
|
assert read_until(late, "score.ready")["session_id"] == str(session_id)
|
||||||
|
finally:
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_incomplete_card_has_only_card_findings(client):
|
||||||
|
session_id, control = start(client)
|
||||||
|
try:
|
||||||
|
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||||
|
read_until(trainee, "card.briefing")
|
||||||
|
trainee.send_json({"type": "card.submit"})
|
||||||
|
read_until(trainee, "score.ready")
|
||||||
|
trainee.send_json({"type": "kio.patch", "fields": {"address": "после сдачи"}})
|
||||||
|
assert read_until(trainee, "error")["code"] == "unsupported_event"
|
||||||
|
state = hub.get(session_id)
|
||||||
|
assert state.kio.address is None
|
||||||
|
assert state.score["score_auto"] < 100
|
||||||
|
assert {item["code"] for item in state.score["findings"]} <= {"E2", "E5"}
|
||||||
|
assert all(item["key"] not in {"answer_time", "interview_time", "dds_ack"}
|
||||||
|
for item in state.score["metrics"])
|
||||||
|
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||||
|
assert report["missed_checklist"] == []
|
||||||
|
assert report["reference_questions"] == []
|
||||||
|
finally:
|
||||||
|
control.__exit__(None, None, None)
|
||||||
141
backend/tests/test_dds_exercise.py
Normal file
141
backend/tests/test_dds_exercise.py
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
"""Готовая карточка → учебный звонок бригаде → числовая оценка ДДС."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.domain.timers import TimerCode
|
||||||
|
from app.session.hub import hub
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
with TestClient(app) as test_client:
|
||||||
|
test_client.post("/api/auth/dev-token")
|
||||||
|
hub.journal = None
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for(predicate, timeout=3):
|
||||||
|
end = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < end:
|
||||||
|
value = predicate()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
time.sleep(0.02)
|
||||||
|
raise AssertionError("состояние не обновилось")
|
||||||
|
|
||||||
|
|
||||||
|
def read_until(socket, wanted):
|
||||||
|
for _ in range(20):
|
||||||
|
event = socket.receive_json()
|
||||||
|
if event["type"] == wanted:
|
||||||
|
return event
|
||||||
|
raise AssertionError(f"событие {wanted} не пришло")
|
||||||
|
|
||||||
|
|
||||||
|
def start(client, exercise="dds"):
|
||||||
|
session_id = uuid4()
|
||||||
|
context = client.websocket_connect(f"/ws/control/{session_id}")
|
||||||
|
control = context.__enter__()
|
||||||
|
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||||
|
"trainee": "Иванов", "mode": "training", "exercise": exercise})
|
||||||
|
wait_for(lambda: hub.get(session_id))
|
||||||
|
return session_id, context
|
||||||
|
|
||||||
|
|
||||||
|
def test_dds_starts_with_prepared_card_without_call(client):
|
||||||
|
session_id, control = start(client)
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
assert state.exercise.value == "dds"
|
||||||
|
assert state.dispatched_card is not None
|
||||||
|
assert state.kio.address == "улица Ленина, 14"
|
||||||
|
assert state.kio.notify
|
||||||
|
assert state.timers.measured_ms(TimerCode.ANSWER) is None
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||||
|
card = read_until(station, "card.received")
|
||||||
|
assert card["from_operator"] == "учебный сценарий"
|
||||||
|
assert card["card"]["address"] == "улица Ленина, 14"
|
||||||
|
snapshot = read_until(station, "station.state")["snapshot"]
|
||||||
|
assert snapshot["crew_options"]
|
||||||
|
finally:
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
def test_dds_call_reports_gate_status_and_affect_numeric_score(client):
|
||||||
|
session_id, control = start(client)
|
||||||
|
try:
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||||
|
read_until(station, "card.received")
|
||||||
|
snapshot = read_until(station, "station.state")["snapshot"]
|
||||||
|
service = snapshot["services"][0]
|
||||||
|
crew = next(option for option in snapshot["crew_options"] if option.startswith(service + " — "))
|
||||||
|
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
station.send_json({"type": "card.status", "service": service, "status": "responding"})
|
||||||
|
assert "доклада" in read_until(station, "error")["message"]
|
||||||
|
station.send_json({"type": "crew.select", "crew": crew})
|
||||||
|
selected = read_until(station, "station.state")["snapshot"]
|
||||||
|
assert selected["crew_selected"] == crew
|
||||||
|
station.send_json({"type": "phone.dial"})
|
||||||
|
report = read_until(station, "phone.report")
|
||||||
|
assert report["phase"] == "dispatched" and report["crew"] == crew
|
||||||
|
assert read_until(station, "station.state")["snapshot"]["phone_reports"]
|
||||||
|
station.send_json({"type": "card.status", "service": service, "status": "responding"})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
station.send_json({"type": "station.finish"})
|
||||||
|
read_until(station, "score.ready")
|
||||||
|
score = wait_for(lambda: hub.get(session_id).score)
|
||||||
|
keys = {metric["key"] for metric in score["metrics"]}
|
||||||
|
assert "dds_primary" in keys and "dds_contact" in keys
|
||||||
|
assert "answer_time" not in keys and "interview_time" not in keys
|
||||||
|
assert 0 < score["score_auto"] < 100
|
||||||
|
assert all(not finding["code"].startswith("E") for finding in score["findings"])
|
||||||
|
finally:
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_exercise_remains_call(client):
|
||||||
|
session_id = uuid4()
|
||||||
|
with client.websocket_connect(f"/ws/control/{session_id}") as control:
|
||||||
|
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||||
|
"trainee": "Иванов", "mode": "training"})
|
||||||
|
state = wait_for(lambda: hub.get(session_id))
|
||||||
|
assert state.exercise.value == "call"
|
||||||
|
assert state.dispatched_card is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_dds_workflow_scores_without_call_penalties(client):
|
||||||
|
session_id, control = start(client)
|
||||||
|
try:
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||||
|
read_until(station, "card.received")
|
||||||
|
snapshot = read_until(station, "station.state")["snapshot"]
|
||||||
|
for service in snapshot["services"]:
|
||||||
|
crew = next(option for option in snapshot["crew_options"]
|
||||||
|
if option.startswith(service + " — "))
|
||||||
|
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
station.send_json({"type": "crew.select", "crew": crew})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
for expected_phase, status in (
|
||||||
|
("dispatched", "responding"), ("arrived", "arrived"),
|
||||||
|
("working", "working"), ("completed", "completed"),
|
||||||
|
):
|
||||||
|
station.send_json({"type": "phone.dial"})
|
||||||
|
assert read_until(station, "phone.report")["phase"] == expected_phase
|
||||||
|
read_until(station, "station.state")
|
||||||
|
station.send_json({"type": "card.status", "service": service, "status": status})
|
||||||
|
snapshot = read_until(station, "station.state")["snapshot"]
|
||||||
|
assert snapshot["card"] == "completed"
|
||||||
|
station.send_json({"type": "station.finish"})
|
||||||
|
read_until(station, "score.ready")
|
||||||
|
score = wait_for(lambda: hub.get(session_id).score)
|
||||||
|
assert score["score_auto"] == 100.0
|
||||||
|
assert not score["findings"]
|
||||||
|
assert all(metric["key"].startswith("dds_") for metric in score["metrics"])
|
||||||
|
finally:
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
@ -120,3 +120,14 @@ def test_gasified_object_brings_mosgaz():
|
||||||
KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True))
|
KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True))
|
||||||
)
|
)
|
||||||
assert "МОСГАЗ" in card.notify
|
assert "МОСГАЗ" in card.notify
|
||||||
|
|
||||||
|
|
||||||
|
def test_removed_modifier_recalculates_notify_without_stale_service():
|
||||||
|
from app.domain.kio import KIO, FireDetails, apply_patch, derive_incident
|
||||||
|
|
||||||
|
card = derive_incident(
|
||||||
|
KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True))
|
||||||
|
)
|
||||||
|
assert "МОСГАЗ" in card.notify
|
||||||
|
updated = apply_patch(card, {"fire.gasified": False})
|
||||||
|
assert "МОСГАЗ" not in updated.notify
|
||||||
|
|
|
||||||
121
backend/tests/test_report_export.py
Normal file
121
backend/tests/test_report_export.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
"""Экспорт разбора: содержимое, безопасность CSV, кириллица и доступ."""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.auth import Principal
|
||||||
|
from app.api.http import sessions
|
||||||
|
from app.domain.events import SessionReport
|
||||||
|
from app.domain.roles import Role
|
||||||
|
from app.main import app
|
||||||
|
from app.scoring.export import _cell, to_csv, to_pdf
|
||||||
|
|
||||||
|
|
||||||
|
def sample_report(*, long: bool = False) -> SessionReport:
|
||||||
|
at = datetime(2026, 9, 21, 12, 0, tzinfo=timezone.utc)
|
||||||
|
long_text = "Заявитель сообщает о дыме в учебном помещении. " * (240 if long else 1)
|
||||||
|
return SessionReport.model_validate({
|
||||||
|
"session_id": str(uuid4()),
|
||||||
|
"scenario_id": "=1+1",
|
||||||
|
"mode": "training",
|
||||||
|
"attempt": 2,
|
||||||
|
"transcript": [
|
||||||
|
{"ref": f"u{i}", "speaker": "caller", "text": long_text if i == 0 else "<вопрос> \t=cmd", "at": at}
|
||||||
|
for i in range(18 if long else 2)
|
||||||
|
],
|
||||||
|
"findings": [{
|
||||||
|
"code": "E1", "source": "slots", "summary": "+HYPERLINK(\"x\")",
|
||||||
|
"fact": "Адрес не уточнён", "norm": "Уточнить адрес происшествия",
|
||||||
|
"ref": "ГОСТ", "at": at,
|
||||||
|
}],
|
||||||
|
"metrics": [{
|
||||||
|
"key": "address", "title": "Адрес", "fact": "Не назван",
|
||||||
|
"norm": "Адрес должен быть уточнён", "passed": False,
|
||||||
|
}],
|
||||||
|
"competencies": [{"competency": "interview", "value": 65}],
|
||||||
|
"reference_questions": [{"checklist_id": "q_address", "question": "Назовите адрес?"}],
|
||||||
|
"missed_checklist": ["q_address"],
|
||||||
|
"hints_used": [{"checklist_id": "q_address", "question": "Уточните адрес", "at": at}],
|
||||||
|
"self_assessment": {"missed": ["q_address"], "comment": "@SUM(1,2)", "submitted_at": at},
|
||||||
|
"self_assessment_diff": {"noticed": ["q_address"], "unnoticed": [], "overcautious": []},
|
||||||
|
"notes": [{"transcript_ref": "u0", "text": "Внимательнее к адресу", "author": "Преподаватель"}],
|
||||||
|
"score_auto": 70,
|
||||||
|
"score_final": 75,
|
||||||
|
"overridden_by": "Преподаватель",
|
||||||
|
"override_comment": "Ручная корректировка",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_csv_contains_sections_and_blocks_formula_injection():
|
||||||
|
rows = list(csv.reader(io.StringIO(to_csv(sample_report()).decode("utf-8-sig"))))
|
||||||
|
assert rows[0] == ["Раздел", "№", "Поле", "Значение", "Дополнительно"]
|
||||||
|
assert any(row[0] == "Транскрипт" and row[3] == "<вопрос> \t=cmd" for row in rows)
|
||||||
|
assert any(row[0] == "Занятие" and row[3] == "'=1+1" for row in rows)
|
||||||
|
assert any(row[0] == "Ошибки" and row[3] == "'+HYPERLINK(\"x\")" for row in rows)
|
||||||
|
assert any(row[0] == "Самооценка" and row[3] == "'@SUM(1,2)" for row in rows)
|
||||||
|
assert _cell(" =cmd") == "' =cmd"
|
||||||
|
assert _cell("\tОбычный текст") == "'\tОбычный текст"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
|
||||||
|
data = to_pdf(sample_report(long=True))
|
||||||
|
assert data.startswith(b"%PDF-")
|
||||||
|
path = tmp_path / "report.pdf"
|
||||||
|
path.write_bytes(data)
|
||||||
|
# Poppler даёт проверку извлекаемого текста, не только сигнатуры файла.
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
if shutil.which("pdftotext") and shutil.which("pdfinfo"):
|
||||||
|
info = subprocess.check_output(["pdfinfo", str(path)], text=True)
|
||||||
|
pages = int(next(line.split(":", 1)[1].strip() for line in info.splitlines() if line.startswith("Pages:")))
|
||||||
|
assert pages >= 2
|
||||||
|
extracted = subprocess.check_output(["pdftotext", str(path), "-"], text=True)
|
||||||
|
assert "Отчёт по учебному занятию" in extracted
|
||||||
|
assert "Адрес должен быть уточнён" in extracted
|
||||||
|
assert "Заявитель сообщает о дыме" in extracted
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch):
|
||||||
|
report = sample_report()
|
||||||
|
state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4())
|
||||||
|
monkeypatch.setattr(sessions, "_live", lambda session_id: (state, object()))
|
||||||
|
monkeypatch.setattr(sessions, "build_report", lambda session_id, state, scenario: report)
|
||||||
|
with TestClient(app) as test_client:
|
||||||
|
test_client.post("/api/auth/dev-token")
|
||||||
|
yield test_client, state, report
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_routes_return_downloads_with_json_report_rights(client):
|
||||||
|
browser, state, report = client
|
||||||
|
csv_response = browser.get(f"/api/sessions/{report.session_id}/report.csv")
|
||||||
|
assert csv_response.status_code == 200
|
||||||
|
assert csv_response.headers["content-type"].startswith("text/csv")
|
||||||
|
assert csv_response.content.startswith(b"\xef\xbb\xbf")
|
||||||
|
assert "attachment" in csv_response.headers["content-disposition"]
|
||||||
|
|
||||||
|
pdf_response = browser.get(f"/api/sessions/{report.session_id}/report.pdf")
|
||||||
|
assert pdf_response.status_code == 200
|
||||||
|
assert pdf_response.headers["content-type"] == "application/pdf"
|
||||||
|
assert pdf_response.content.startswith(b"%PDF-")
|
||||||
|
|
||||||
|
state.score = None
|
||||||
|
assert browser.get(f"/api/sessions/{report.session_id}/report.csv").status_code == 409
|
||||||
|
assert browser.get(f"/api/sessions/{report.session_id}/report.pdf").status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_trainee_cannot_export_another_persons_report(client, monkeypatch):
|
||||||
|
browser, state, report = client
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sessions, "require",
|
||||||
|
lambda request: Principal(login="trainee", full_name="Учебный", role=Role.TRAINEE, trainee_id=uuid4()),
|
||||||
|
)
|
||||||
|
for suffix in ("csv", "pdf"):
|
||||||
|
assert browser.get(f"/api/sessions/{report.session_id}/report.{suffix}").status_code == 403
|
||||||
140
backend/tests/test_scenario_editor.py
Normal file
140
backend/tests/test_scenario_editor.py
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"""Преподавательский цикл: шаблон → черновик → проверка → утверждение."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.http import scenarios as scenarios_api
|
||||||
|
from app.db.base import get_session
|
||||||
|
from app.main import app
|
||||||
|
from app.scenarios import store
|
||||||
|
from app.scenarios.editor import merge_patch, template_copy, validate
|
||||||
|
from app.scenarios.loader import ScenarioError, load_file
|
||||||
|
|
||||||
|
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
"""Минимальная транзакционная замена БД для проверки HTTP без Postgres."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.rows = {}
|
||||||
|
|
||||||
|
def add(self, row):
|
||||||
|
self.rows[row.id] = row
|
||||||
|
|
||||||
|
async def get(self, model, key):
|
||||||
|
return self.rows.get(key)
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def scalars(self, query):
|
||||||
|
return [row for row in self.rows.values() if row.status == "published"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch):
|
||||||
|
db = FakeSession()
|
||||||
|
|
||||||
|
async def no_audit(*args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def no_restore(*args, **kwargs):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
app.dependency_overrides[get_session] = lambda: db
|
||||||
|
monkeypatch.setattr(scenarios_api, "audit", no_audit)
|
||||||
|
monkeypatch.setattr(store, "restore_published", no_restore)
|
||||||
|
with TestClient(app) as test_client:
|
||||||
|
yield test_client
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_copy_is_local_independent_and_valid():
|
||||||
|
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||||
|
body = template_copy(source, "draft-example")
|
||||||
|
assert body["id"] == "draft-example"
|
||||||
|
assert body["extends"] is None
|
||||||
|
assert body["ticket"] is None
|
||||||
|
assert body["ground_truth"].keys() == {"address", "victims"}
|
||||||
|
assert validate(body).ground_truth.dds == source.ground_truth.dds
|
||||||
|
assert source.id != body["id"] # оригинал не меняется
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_rejects_derived_truth_and_missing_fact():
|
||||||
|
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||||
|
body = template_copy(source, "draft-example")
|
||||||
|
with pytest.raises(ScenarioError, match="ground_truth"):
|
||||||
|
merge_patch(body, {"ground_truth": {"dds": "02"}})
|
||||||
|
with pytest.raises(ScenarioError, match="id сценария"):
|
||||||
|
merge_patch(body, {"id": "other"})
|
||||||
|
broken = merge_patch(body, {"facts": []})
|
||||||
|
with pytest.raises(ScenarioError, match="нужны факты"):
|
||||||
|
validate(broken)
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
|
||||||
|
source = next(s for s in store.all_scenarios() if s.id == "fire-apartment-l2")
|
||||||
|
assert client.post("/api/auth/dev-token").status_code == 200
|
||||||
|
response = client.post(
|
||||||
|
"/api/scenarios/drafts/from-template",
|
||||||
|
json={"source_id": source.id, "title": "Учебная копия"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
draft = response.json()
|
||||||
|
draft_id = draft["id"]
|
||||||
|
assert draft["generation"] == "template_copy"
|
||||||
|
assert draft["status"] == "draft"
|
||||||
|
assert store.get(draft_id) is None
|
||||||
|
assert client.get(f"/api/scenarios/{draft_id}").status_code == 404
|
||||||
|
|
||||||
|
changed = client.patch(
|
||||||
|
f"/api/scenarios/drafts/{draft_id}",
|
||||||
|
json={"first_line": "Соседи! В доме дым!"},
|
||||||
|
)
|
||||||
|
assert changed.status_code == 200, changed.text
|
||||||
|
assert changed.json()["body"]["first_line"] == "Соседи! В доме дым!"
|
||||||
|
check = client.post(f"/api/scenarios/drafts/{draft_id}/validate")
|
||||||
|
assert check.status_code == 200 and check.json()["valid"]
|
||||||
|
approved = client.post(f"/api/scenarios/drafts/{draft_id}/approve")
|
||||||
|
assert approved.status_code == 200, approved.text
|
||||||
|
assert approved.json()["status"] == "published"
|
||||||
|
assert store.get(draft_id).first_line == "Соседи! В доме дым!"
|
||||||
|
assert client.get(f"/api/scenarios/{draft_id}").status_code == 200
|
||||||
|
assert client.patch(f"/api/scenarios/drafts/{draft_id}", json={"title": "нет"}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_draft_cannot_be_approved(client):
|
||||||
|
client.post("/api/auth/dev-token")
|
||||||
|
response = client.post(
|
||||||
|
"/api/scenarios/drafts/from-template",
|
||||||
|
json={"source_id": "fire-apartment-l2"},
|
||||||
|
)
|
||||||
|
draft_id = response.json()["id"]
|
||||||
|
assert client.patch(f"/api/scenarios/drafts/{draft_id}", json={"facts": []}).status_code == 200
|
||||||
|
check = client.post(f"/api/scenarios/drafts/{draft_id}/validate")
|
||||||
|
assert check.json()["valid"] is False
|
||||||
|
assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 422
|
||||||
|
assert store.get(draft_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauthenticated_user_cannot_manage_drafts(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/scenarios/drafts/from-template", json={"source_id": "fire-apartment-l2"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approved_scenario_restores_from_local_database():
|
||||||
|
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||||
|
db = FakeSession()
|
||||||
|
row = await store.create_draft(db, source=source)
|
||||||
|
approved = await store.approve_draft(db, row)
|
||||||
|
store.set_library([source]) # имитация нового процесса после чтения YAML
|
||||||
|
assert store.get(approved.id) is None
|
||||||
|
assert await store.restore_published(db) == 1
|
||||||
|
assert store.get(approved.id).title == approved.title
|
||||||
|
assert await store.restore_published(db) == 0
|
||||||
64
backend/tests/test_session_access.py
Normal file
64
backend/tests/test_session_access.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""HTTP-ссылки на занятие не дают курсанту чужую карточку или чек-лист."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
from app.api.auth import Principal
|
||||||
|
from app.api.http import sessions
|
||||||
|
from app.domain.events import Exercise
|
||||||
|
from app.domain.roles import Role
|
||||||
|
|
||||||
|
|
||||||
|
def request() -> Request:
|
||||||
|
return Request({"type": "http", "method": "GET", "path": "/", "headers": []})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trainee_cannot_read_foreign_session(monkeypatch):
|
||||||
|
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
|
||||||
|
monkeypatch.setattr(sessions, "require", lambda _: who)
|
||||||
|
|
||||||
|
async def row(_db, _session_id):
|
||||||
|
return SimpleNamespace(trainee_id=uuid4())
|
||||||
|
|
||||||
|
monkeypatch.setattr(sessions.repo, "get_session", row)
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
await sessions.read(uuid4(), request(), db=object())
|
||||||
|
assert error.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trainee_cannot_read_foreign_checklist(monkeypatch):
|
||||||
|
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
|
||||||
|
monkeypatch.setattr(sessions, "require", lambda _: who)
|
||||||
|
monkeypatch.setattr(sessions.hub, "get", lambda _: SimpleNamespace(trainee_id=uuid4(), ended=True))
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
await sessions.checklist(uuid4(), request())
|
||||||
|
assert error.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trainee_cannot_bypass_self_assessment_via_report(monkeypatch):
|
||||||
|
trainee_id = uuid4()
|
||||||
|
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=trainee_id)
|
||||||
|
monkeypatch.setattr(sessions, "require", lambda _: who)
|
||||||
|
state = SimpleNamespace(
|
||||||
|
trainee_id=trainee_id, exercise=Exercise.CALL, self_assessed=False,
|
||||||
|
score={"score_auto": 100},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(sessions, "_live", lambda _: (state, object()))
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
await sessions.report(uuid4(), request())
|
||||||
|
assert error.value.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trainee_without_profile_cannot_list_everyones_sessions(monkeypatch):
|
||||||
|
who = Principal(login="unlinked", full_name="Курсант", role=Role.TRAINEE, trainee_id=None)
|
||||||
|
monkeypatch.setattr(sessions, "require", lambda _: who)
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
await sessions.listing(request(), db=object())
|
||||||
|
assert error.value.status_code == 403
|
||||||
|
|
@ -117,6 +117,62 @@ def test_interview_timer_starts_on_answer_and_stops_on_dispatch(client):
|
||||||
assert state.dispatched_card is not None, "карточка не заморожена снимком"
|
assert state.dispatched_card is not None, "карточка не заморожена снимком"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ekp_dispatch_needs_no_manual_dds_and_reaches_station(client):
|
||||||
|
with lesson(client) as (session_id, _):
|
||||||
|
state = hub.get(session_id)
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||||
|
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||||
|
read_until(trainee, "call.incoming")
|
||||||
|
trainee.send_json({"type": "call.answer"})
|
||||||
|
trainee.send_json({"type": "kio.patch", "fields": {
|
||||||
|
"signs": ["жилой дом", "балкон", "открытое пламя"]
|
||||||
|
}})
|
||||||
|
auto = next(
|
||||||
|
event for event in (trainee.receive_json() for _ in range(8))
|
||||||
|
if event["type"] == "kio.patch" and event["source"] == "auto"
|
||||||
|
)
|
||||||
|
assert auto["fields"]["incident_code"]
|
||||||
|
assert auto["fields"]["notify"]
|
||||||
|
trainee.send_json({"type": "dds.dispatch"})
|
||||||
|
received = read_until(station, "card.received")
|
||||||
|
assert received["card"]["notify"]
|
||||||
|
assert received["card"]["dds"] is None
|
||||||
|
assert received["card"]["response_status"] == "transferred"
|
||||||
|
assert state.dispatched_card is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_dispatch_without_routing_needs_manual_dds(client):
|
||||||
|
with lesson(client) as (session_id, _):
|
||||||
|
state = hub.get(session_id)
|
||||||
|
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||||
|
read_until(trainee, "call.incoming")
|
||||||
|
trainee.send_json({"type": "dds.dispatch"})
|
||||||
|
error = read_until(trainee, "error")
|
||||||
|
assert "ДДС" in error["message"]
|
||||||
|
assert state.dispatched_card is None
|
||||||
|
trainee.send_json({"type": "dds.dispatch", "service": "01"})
|
||||||
|
wait_for(lambda: state.dispatched_card)
|
||||||
|
assert state.kio.dds.value == "01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_late_call_join_replays_incoming_and_current_kio(client):
|
||||||
|
with lesson(client) as (session_id, _):
|
||||||
|
with client.websocket_connect(f"/ws/call/{session_id}") as first:
|
||||||
|
read_until(first, "call.incoming")
|
||||||
|
first.send_json({"type": "kio.patch", "fields": {
|
||||||
|
"address": "улица Ленина, 14",
|
||||||
|
"signs": ["жилой дом", "балкон", "открытое пламя"],
|
||||||
|
}})
|
||||||
|
wait_for(lambda: hub.get(session_id).kio.incident_code)
|
||||||
|
with client.websocket_connect(f"/ws/call/{session_id}") as late:
|
||||||
|
incoming = read_until(late, "call.incoming")
|
||||||
|
assert incoming["scenario_id"] == "fire-apartment-l2"
|
||||||
|
assert "dds" not in incoming["required_fields"]
|
||||||
|
card = read_until(late, "kio.patch")
|
||||||
|
assert card["fields"]["address"] == "улица Ленина, 14"
|
||||||
|
assert card["fields"]["notify"]
|
||||||
|
|
||||||
|
|
||||||
def test_card_edit_reaches_observer(client):
|
def test_card_edit_reaches_observer(client):
|
||||||
with lesson(client) as (session_id, _):
|
with lesson(client) as (session_id, _):
|
||||||
with client.websocket_connect(f"/ws/observe/{session_id}") as observer:
|
with client.websocket_connect(f"/ws/observe/{session_id}") as observer:
|
||||||
|
|
|
||||||
63
backend/tests/test_ws_ownership.py
Normal file
63
backend/tests/test_ws_ownership.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""Знание URL занятия не даёт курсанту доступ к чужому АРМ."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import time
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.auth import Principal
|
||||||
|
from app.domain.roles import Role
|
||||||
|
from app.main import app
|
||||||
|
from app.session.hub import hub
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
with TestClient(app) as test_client:
|
||||||
|
test_client.post("/api/auth/dev-token")
|
||||||
|
hub.journal = None
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
def test_trainee_cannot_open_foreign_or_unassigned_call_or_station(client, monkeypatch):
|
||||||
|
session_id = uuid4()
|
||||||
|
with client.websocket_connect(f"/ws/control/{session_id}") as control:
|
||||||
|
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||||
|
"trainee": "Назначенный", "mode": "training"})
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while hub.get(session_id) is None and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
state = hub.get(session_id)
|
||||||
|
assert state is not None
|
||||||
|
|
||||||
|
owner_id, foreign_id = uuid4(), uuid4()
|
||||||
|
modules = {
|
||||||
|
"call": importlib.import_module("app.api.ws.call"),
|
||||||
|
"station": importlib.import_module("app.api.ws.station"),
|
||||||
|
}
|
||||||
|
for assigned in (owner_id, None):
|
||||||
|
state.trainee_id = assigned
|
||||||
|
for role_name, module in modules.items():
|
||||||
|
who = Principal(login="foreign", full_name="Чужой",
|
||||||
|
role=Role.TRAINEE, trainee_id=foreign_id)
|
||||||
|
monkeypatch.setattr(module, "principal_of", lambda _ws, user=who: user)
|
||||||
|
with client.websocket_connect(f"/ws/{role_name}/{session_id}") as socket:
|
||||||
|
event = socket.receive_json()
|
||||||
|
assert event["type"] == "error" and event["code"] == "forbidden"
|
||||||
|
|
||||||
|
state.trainee_id = owner_id
|
||||||
|
for role_name, module in modules.items():
|
||||||
|
who = Principal(login="owner", full_name="Назначенный",
|
||||||
|
role=Role.TRAINEE, trainee_id=owner_id)
|
||||||
|
monkeypatch.setattr(module, "principal_of", lambda _ws, user=who: user)
|
||||||
|
with client.websocket_connect(f"/ws/{role_name}/{session_id}") as socket:
|
||||||
|
socket.send_json({"type": "unknown"})
|
||||||
|
for _ in range(8):
|
||||||
|
event = socket.receive_json()
|
||||||
|
if event["type"] == "error":
|
||||||
|
assert event["code"] == "unsupported_event"
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise AssertionError("владелец занятия не допущен")
|
||||||
106
backend/uv.lock
generated
106
backend/uv.lock
generated
|
|
@ -137,6 +137,47 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
|
{ url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.5.0"
|
version = "8.5.0"
|
||||||
|
|
@ -365,6 +406,7 @@ dependencies = [
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
|
{ name = "reportlab" },
|
||||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||||
{ name = "structlog" },
|
{ name = "structlog" },
|
||||||
{ name = "tokenizers" },
|
{ name = "tokenizers" },
|
||||||
|
|
@ -400,6 +442,7 @@ requires-dist = [
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0" },
|
{ name = "pyyaml", specifier = ">=6.0" },
|
||||||
|
{ name = "reportlab", specifier = ">=4.0" },
|
||||||
{ name = "scipy", marker = "extra == 'voice'", specifier = ">=1.11" },
|
{ name = "scipy", marker = "extra == 'voice'", specifier = ">=1.11" },
|
||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
||||||
{ name = "structlog", specifier = ">=24.1" },
|
{ name = "structlog", specifier = ">=24.1" },
|
||||||
|
|
@ -535,6 +578,28 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pillow"
|
||||||
|
version = "12.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
|
|
@ -699,6 +764,19 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reportlab"
|
||||||
|
version = "5.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "pillow" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4a/51/dbe28534ae12c852f61be91f039f343305fd1f34f1c66b8de75afae7a525/reportlab-5.0.1.tar.gz", hash = "sha256:ebd13154be1c8515e665de70bd2d303ae9ddc3ef47e44afd5116441ca0283a26", size = 3945711, upload-time = "2026-08-20T13:48:16.461Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/cb/dacbc268cb68d0428ea2cbd85266195a9ab3e677449589ddae59bd7542ac/reportlab-5.0.1-py3-none-any.whl", hash = "sha256:1c36e6bb0e71780c72331eba60da7f602e8d4389a8723825af71342e49d791e8", size = 1957258, upload-time = "2026-08-20T13:48:14.026Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scipy"
|
name = "scipy"
|
||||||
version = "1.17.1"
|
version = "1.17.1"
|
||||||
|
|
@ -823,13 +901,13 @@ resolution-markers = [
|
||||||
"sys_platform == 'darwin'",
|
"sys_platform == 'darwin'",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "filelock", marker = "sys_platform == 'darwin'" },
|
{ name = "filelock" },
|
||||||
{ name = "fsspec", marker = "sys_platform == 'darwin'" },
|
{ name = "fsspec" },
|
||||||
{ name = "jinja2", marker = "sys_platform == 'darwin'" },
|
{ name = "jinja2" },
|
||||||
{ name = "networkx", marker = "sys_platform == 'darwin'" },
|
{ name = "networkx" },
|
||||||
{ name = "setuptools", marker = "sys_platform == 'darwin'" },
|
{ name = "setuptools" },
|
||||||
{ name = "sympy", marker = "sys_platform == 'darwin'" },
|
{ name = "sympy" },
|
||||||
{ name = "typing-extensions", marker = "sys_platform == 'darwin'" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1040f689e560ba6e02a0a147dfb9ec8dfdd3e4486ebec4b24dc8fdaa258fa9ba", upload-time = "2026-09-02T00:26:07Z" },
|
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1040f689e560ba6e02a0a147dfb9ec8dfdd3e4486ebec4b24dc8fdaa258fa9ba", upload-time = "2026-09-02T00:26:07Z" },
|
||||||
|
|
@ -843,13 +921,13 @@ resolution-markers = [
|
||||||
"sys_platform != 'darwin'",
|
"sys_platform != 'darwin'",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "filelock", marker = "sys_platform != 'darwin'" },
|
{ name = "filelock" },
|
||||||
{ name = "fsspec", marker = "sys_platform != 'darwin'" },
|
{ name = "fsspec" },
|
||||||
{ name = "jinja2", marker = "sys_platform != 'darwin'" },
|
{ name = "jinja2" },
|
||||||
{ name = "networkx", marker = "sys_platform != 'darwin'" },
|
{ name = "networkx" },
|
||||||
{ name = "setuptools", marker = "sys_platform != 'darwin'" },
|
{ name = "setuptools" },
|
||||||
{ name = "sympy", marker = "sys_platform != 'darwin'" },
|
{ name = "sympy" },
|
||||||
{ name = "typing-extensions", marker = "sys_platform != 'darwin'" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:3fa39713d13f23563ad11636bfff76b677ab4addbc240c54e6b7e47622e8973d", upload-time = "2026-09-02T18:31:08Z" },
|
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:3fa39713d13f23563ad11636bfff76b677ab4addbc240c54e6b7e47622e8973d", upload-time = "2026-09-02T18:31:08Z" },
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,14 @@ services:
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
|
image: lct-hack-backend:local
|
||||||
# Один воркер принципиально: состояние живой сессии и реестр наблюдателей
|
# Один воркер принципиально: состояние живой сессии и реестр наблюдателей
|
||||||
# живут в памяти процесса (docs/arch/STACK.md).
|
# живут в памяти процесса (docs/arch/STACK.md).
|
||||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --workers 1
|
command: sh -c "alembic upgrade head && python scripts/seed.py && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://lct:lct@postgres:5432/lct
|
DATABASE_URL: postgresql+asyncpg://lct:lct@postgres:5432/lct
|
||||||
|
OFFLINE: "true"
|
||||||
|
VOICE_ENABLED: "false"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./scenarios:/scenarios:ro
|
- ./scenarios:/scenarios:ro
|
||||||
|
|
@ -30,10 +33,14 @@ services:
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
|
image: lct-hack-frontend:local
|
||||||
command: npm run dev -- --host 0.0.0.0
|
command: npm run dev -- --host 0.0.0.0
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
environment:
|
||||||
|
VITE_BACKEND_URL: http://backend:8000
|
||||||
|
VITE_WS_BACKEND_URL: ws://backend:8000
|
||||||
ports: ["5173:5173"]
|
ports: ["5173:5173"]
|
||||||
depends_on: [backend]
|
depends_on: [backend]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,5 +60,5 @@ export function useLogout() {
|
||||||
export const SCREENS: Record<Role, string[]> = {
|
export const SCREENS: Record<Role, string[]> = {
|
||||||
admin: ["/admin", "/profile"],
|
admin: ["/admin", "/profile"],
|
||||||
instructor: ["/instructor", "/wall", "/profile", "/dds"],
|
instructor: ["/instructor", "/wall", "/profile", "/dds"],
|
||||||
trainee: ["/trainee", "/profile"],
|
trainee: ["/trainee", "/dds", "/profile"],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,12 @@ import { Ambience } from "@/shared/audio/ambience";
|
||||||
import { type Capture, startCapture } from "@/shared/audio/capture";
|
import { type Capture, startCapture } from "@/shared/audio/capture";
|
||||||
import { EnergyGate } from "@/shared/audio/levels";
|
import { EnergyGate } from "@/shared/audio/levels";
|
||||||
import { CALLER_RATE, Playback } from "@/shared/audio/playback";
|
import { CALLER_RATE, Playback } from "@/shared/audio/playback";
|
||||||
import { type CardState, applyPatch, edit, empty as emptyCard } from "@/features/kio-card/merge";
|
import { type CardState, applyPatch, applyState, edit, empty as emptyCard } from "@/features/kio-card/merge";
|
||||||
import type { ChecklistItem } from "@/features/self-assessment/SelfAssessment";
|
import type { ChecklistItem } from "@/features/self-assessment/SelfAssessment";
|
||||||
import type {
|
import type {
|
||||||
Outcome,
|
Outcome,
|
||||||
CallIncoming,
|
CallIncoming,
|
||||||
|
CardBriefing,
|
||||||
DDSCode,
|
DDSCode,
|
||||||
ServerToTrainee,
|
ServerToTrainee,
|
||||||
SessionReport,
|
SessionReport,
|
||||||
|
|
@ -36,6 +37,8 @@ export function useCall(sessionId: string | null) {
|
||||||
const [status, setStatus] = useState<ChannelStatus>("connecting");
|
const [status, setStatus] = useState<ChannelStatus>("connecting");
|
||||||
const [phase, setPhase] = useState<CallPhase>("waiting");
|
const [phase, setPhase] = useState<CallPhase>("waiting");
|
||||||
const [incoming, setIncoming] = useState<CallIncoming | null>(null);
|
const [incoming, setIncoming] = useState<CallIncoming | null>(null);
|
||||||
|
const [briefing, setBriefing] = useState<CardBriefing | null>(null);
|
||||||
|
const [cardSubmitted, setCardSubmitted] = useState(false);
|
||||||
const [lines, setLines] = useState<Line[]>([]);
|
const [lines, setLines] = useState<Line[]>([]);
|
||||||
const [timers, setTimers] = useState<TimerSnapshot[]>([]);
|
const [timers, setTimers] = useState<TimerSnapshot[]>([]);
|
||||||
const [callerSpeaking, setCallerSpeaking] = useState(false);
|
const [callerSpeaking, setCallerSpeaking] = useState(false);
|
||||||
|
|
@ -45,6 +48,7 @@ export function useCall(sessionId: string | null) {
|
||||||
const [selfAssessed, setSelfAssessed] = useState(false);
|
const [selfAssessed, setSelfAssessed] = useState(false);
|
||||||
const [scoreReady, setScoreReady] = useState(false);
|
const [scoreReady, setScoreReady] = useState(false);
|
||||||
const [report, setReport] = useState<SessionReport | null>(null);
|
const [report, setReport] = useState<SessionReport | null>(null);
|
||||||
|
const [reportError, setReportError] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
|
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
|
||||||
|
|
@ -53,6 +57,7 @@ export function useCall(sessionId: string | null) {
|
||||||
const gate = useRef(new EnergyGate());
|
const gate = useRef(new EnergyGate());
|
||||||
const outbox = useRef<Record<string, unknown>>({});
|
const outbox = useRef<Record<string, unknown>>({});
|
||||||
const patchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const patchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const textMode = useRef(false);
|
||||||
|
|
||||||
const append = useCallback((line: Line) => {
|
const append = useCallback((line: Line) => {
|
||||||
setLines((prev) => {
|
setLines((prev) => {
|
||||||
|
|
@ -67,6 +72,16 @@ export function useCall(sessionId: string | null) {
|
||||||
onStatus: setStatus,
|
onStatus: setStatus,
|
||||||
onBinary: (frame) => audio.current?.playback.enqueue(frame),
|
onBinary: (frame) => audio.current?.playback.enqueue(frame),
|
||||||
onEvent: (event: ServerToTrainee) => {
|
onEvent: (event: ServerToTrainee) => {
|
||||||
|
if (event.type === "card.briefing") {
|
||||||
|
const briefing = event;
|
||||||
|
textMode.current = true;
|
||||||
|
setBriefing(briefing);
|
||||||
|
setIncoming(null);
|
||||||
|
setCard(applyState(emptyCard, { ...briefing.card }));
|
||||||
|
setCardSubmitted(false);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "call.incoming":
|
case "call.incoming":
|
||||||
setIncoming(event);
|
setIncoming(event);
|
||||||
|
|
@ -110,6 +125,7 @@ export function useCall(sessionId: string | null) {
|
||||||
case "call.ended":
|
case "call.ended":
|
||||||
setPhase("ended");
|
setPhase("ended");
|
||||||
audio.current?.ambience.stop();
|
audio.current?.ambience.stop();
|
||||||
|
if (textMode.current) { setCardSubmitted(true); break; }
|
||||||
// Чек-лист открывается только после звонка: во время него это
|
// Чек-лист открывается только после звонка: во время него это
|
||||||
// содержимое подсказок.
|
// содержимое подсказок.
|
||||||
void fetch(`/api/sessions/${sessionId}/checklist`)
|
void fetch(`/api/sessions/${sessionId}/checklist`)
|
||||||
|
|
@ -119,10 +135,16 @@ export function useCall(sessionId: string | null) {
|
||||||
break;
|
break;
|
||||||
case "score.ready":
|
case "score.ready":
|
||||||
setScoreReady(true);
|
setScoreReady(true);
|
||||||
|
setReportError(null);
|
||||||
void fetch(`/api/sessions/${sessionId}/report`)
|
void fetch(`/api/sessions/${sessionId}/report`)
|
||||||
.then((response) => (response.ok ? response.json() : null))
|
.then((response) => {
|
||||||
|
if (response.status === 403) throw new Error("Отчёт недоступен: занятие привязано к другому обучающемуся.");
|
||||||
|
if (response.status === 409) throw new Error("Оценка ещё не готова. Обновите страницу позже.");
|
||||||
|
if (!response.ok) throw new Error(`Не удалось загрузить отчёт (HTTP ${response.status}).`);
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
.then(setReport)
|
.then(setReport)
|
||||||
.catch(() => setReport(null));
|
.catch((cause: unknown) => setReportError(cause instanceof Error ? cause.message : "Не удалось загрузить отчёт."));
|
||||||
break;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
setError(event.message);
|
setError(event.message);
|
||||||
|
|
@ -183,8 +205,25 @@ export function useCall(sessionId: string | null) {
|
||||||
}, PATCH_DEBOUNCE_MS);
|
}, PATCH_DEBOUNCE_MS);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const dispatch = useCallback((service: DDSCode) => {
|
const dispatch = useCallback((service?: DDSCode) => {
|
||||||
channel.current?.send({ type: "dds.dispatch", service });
|
channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitCard = useCallback(() => {
|
||||||
|
if (patchTimer.current) clearTimeout(patchTimer.current);
|
||||||
|
patchTimer.current = null;
|
||||||
|
const fields = outbox.current;
|
||||||
|
outbox.current = {};
|
||||||
|
if (Object.keys(fields).length && !channel.current?.send({ type: "kio.patch", fields })) {
|
||||||
|
setError("Канал отключён: карточка не отправлена.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (channel.current?.send({ type: "card.submit" })) {
|
||||||
|
setCardSubmitted(true);
|
||||||
|
setError(null);
|
||||||
|
} else {
|
||||||
|
setError("Канал отключён: карточка не отправлена.");
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** Закрыть вызов не карточкой: справкой или передачей в другой регион.
|
/** Закрыть вызов не карточкой: справкой или передачей в другой регион.
|
||||||
|
|
@ -201,8 +240,8 @@ export function useCall(sessionId: string | null) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resolve,
|
resolve,
|
||||||
status, phase, incoming, lines, timers, callerSpeaking, micOn, error, card,
|
status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, card,
|
||||||
checklist, selfAssessed, scoreReady, report,
|
checklist, selfAssessed, scoreReady, report, reportError,
|
||||||
answer, hangup, hint, patchKio, dispatch, submitSelfAssessment,
|
answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
||||||
Разбор · оценка {report.score_final.toFixed(0)} из 100
|
Разбор · оценка {report.score_final.toFixed(0)} из 100
|
||||||
{report.overridden_by && ` · скорректировано (${report.overridden_by}), автооценка ${report.score_auto.toFixed(0)}`}
|
{report.overridden_by && ` · скорректировано (${report.overridden_by}), автооценка ${report.score_auto.toFixed(0)}`}
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="ref">
|
||||||
|
Скачать отчёт: <a href={`/api/sessions/${report.session_id}/report.csv`}>CSV</a>
|
||||||
|
{" · "}<a href={`/api/sessions/${report.session_id}/report.pdf`}>PDF</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="columns">
|
<div className="columns">
|
||||||
<section>
|
<section>
|
||||||
|
|
|
||||||
189
frontend/src/features/instructor/ScenarioEditor.tsx
Normal file
189
frontend/src/features/instructor/ScenarioEditor.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { ScenarioSummary } from "@/shared/api/http";
|
||||||
|
|
||||||
|
type JsonObject = Record<string, unknown>;
|
||||||
|
type Draft = { id: string; status: "draft"; generation: "template_copy"; body: JsonObject };
|
||||||
|
type Validation = { valid: boolean; errors: string[]; ground_truth?: JsonObject };
|
||||||
|
|
||||||
|
async function api<T>(path: string, method = "GET", body?: unknown): Promise<T> {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method,
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data: unknown = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = data && typeof data === "object" && "detail" in data ? data.detail : response.statusText;
|
||||||
|
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is JsonObject {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сервер принимает JSON Merge Patch, поэтому удалённое в редакторе поле
|
||||||
|
* передаётся как null, а неизменённые поля не отправляются. */
|
||||||
|
function diff(before: JsonObject, after: JsonObject): JsonObject {
|
||||||
|
const patch: JsonObject = {};
|
||||||
|
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
||||||
|
if (!(key in after)) {
|
||||||
|
patch[key] = null;
|
||||||
|
} else if (!(key in before)) {
|
||||||
|
patch[key] = after[key];
|
||||||
|
} else if (isObject(before[key]) && isObject(after[key])) {
|
||||||
|
const nested = diff(before[key], after[key]);
|
||||||
|
if (Object.keys(nested).length) patch[key] = nested;
|
||||||
|
} else if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
|
||||||
|
patch[key] = after[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pretty(body: JsonObject): string {
|
||||||
|
return JSON.stringify(body, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScenarioEditor({
|
||||||
|
scenarios,
|
||||||
|
onApproved,
|
||||||
|
}: {
|
||||||
|
scenarios: ScenarioSummary[];
|
||||||
|
onApproved: (id: string) => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [sourceId, setSourceId] = useState("");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [draftId, setDraftId] = useState("");
|
||||||
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
const [bodyText, setBodyText] = useState("");
|
||||||
|
const [validation, setValidation] = useState<Validation | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
|
||||||
|
function openDraft(value: Draft) {
|
||||||
|
setDraft(value);
|
||||||
|
setDraftId(value.id);
|
||||||
|
setBodyText(pretty(value.body));
|
||||||
|
setValidation(null);
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function perform(work: () => Promise<void>) {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await work();
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof Error ? cause.message : String(cause));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
await perform(async () => {
|
||||||
|
const created = await api<Draft>("/api/scenarios/drafts/from-template", "POST", {
|
||||||
|
source_id: sourceId || scenarios[0]?.id,
|
||||||
|
...(title.trim() ? { title: title.trim() } : {}),
|
||||||
|
});
|
||||||
|
openDraft(created);
|
||||||
|
setNotice("Создан шаблонный черновик. Это копия сценария, не генерация ИИ.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reopen() {
|
||||||
|
if (!draftId.trim()) return;
|
||||||
|
await perform(async () => {
|
||||||
|
openDraft(await api<Draft>(`/api/scenarios/drafts/${encodeURIComponent(draftId.trim())}`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(): Promise<Draft> {
|
||||||
|
if (!draft) throw new Error("Сначала создайте или откройте черновик");
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(bodyText);
|
||||||
|
} catch (cause) {
|
||||||
|
throw new Error(`Некорректный JSON: ${cause instanceof Error ? cause.message : String(cause)}`);
|
||||||
|
}
|
||||||
|
if (!isObject(parsed)) throw new Error("Тело сценария должно быть JSON-объектом");
|
||||||
|
if (parsed.id !== draft.id) throw new Error("id черновика менять нельзя");
|
||||||
|
const patch = diff(draft.body, parsed);
|
||||||
|
if (!Object.keys(patch).length) return draft;
|
||||||
|
const updated = await api<Draft>(`/api/scenarios/drafts/${encodeURIComponent(draft.id)}`, "PATCH", patch);
|
||||||
|
setDraft(updated);
|
||||||
|
setBodyText(pretty(updated.body));
|
||||||
|
setValidation(null);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validate() {
|
||||||
|
await perform(async () => {
|
||||||
|
const current = await save();
|
||||||
|
const result = await api<Validation>(`/api/scenarios/drafts/${encodeURIComponent(current.id)}/validate`, "POST");
|
||||||
|
setValidation(result);
|
||||||
|
setNotice(result.valid ? "Черновик прошёл проверку и может быть утверждён." : "Найдены ошибки в сценарии.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approve() {
|
||||||
|
await perform(async () => {
|
||||||
|
const current = await save();
|
||||||
|
if (!validation?.valid) throw new Error("Сначала проверьте черновик кнопкой «Проверить»");
|
||||||
|
await api(`/api/scenarios/drafts/${encodeURIComponent(current.id)}/approve`, "POST");
|
||||||
|
setDraft(null);
|
||||||
|
setValidation(null);
|
||||||
|
setNotice("Сценарий утверждён и доступен для нового занятия.");
|
||||||
|
await onApproved(current.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="scenario-editor">
|
||||||
|
<h2>Редактор учебных сценариев</h2>
|
||||||
|
<p className="ref">Создание работает локально: копия утверждённого шаблона, затем правка и проверка преподавателем.</p>
|
||||||
|
<div className="scenario-editor-create">
|
||||||
|
<label>Шаблон
|
||||||
|
<select value={sourceId || scenarios[0]?.id || ""} onChange={(event) => setSourceId(event.target.value)}>
|
||||||
|
{scenarios.map((scenario) => <option key={scenario.id} value={scenario.id}>{scenario.title} · {scenario.level}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Название нового сценария
|
||||||
|
<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="необязательно" maxLength={200} />
|
||||||
|
</label>
|
||||||
|
<button type="button" onClick={create} disabled={busy || !scenarios.length}>Создать черновик</button>
|
||||||
|
</div>
|
||||||
|
<div className="scenario-editor-open">
|
||||||
|
<label>Номер черновика
|
||||||
|
<input value={draftId} onChange={(event) => setDraftId(event.target.value)} placeholder="draft-…" />
|
||||||
|
</label>
|
||||||
|
<button type="button" onClick={reopen} disabled={busy || !draftId.trim()}>Открыть</button>
|
||||||
|
</div>
|
||||||
|
{draft && (
|
||||||
|
<>
|
||||||
|
<p className="ref">Черновик {draft.id}. Факты и эталон доступны только преподавателю до утверждения.</p>
|
||||||
|
<label className="scenario-editor-json">JSON тела сценария
|
||||||
|
<textarea value={bodyText} onChange={(event) => { setBodyText(event.target.value); setValidation(null); }} spellCheck={false} />
|
||||||
|
</label>
|
||||||
|
<div className="scenario-editor-actions">
|
||||||
|
<button type="button" onClick={() => void perform(async () => { await save(); setNotice("Черновик сохранён."); })} disabled={busy}>Сохранить</button>
|
||||||
|
<button type="button" onClick={validate} disabled={busy}>Проверить</button>
|
||||||
|
<button type="button" className="start" onClick={approve} disabled={busy || !validation?.valid}>Утвердить</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{validation && <div className={validation.valid ? "scenario-editor-result" : "scenario-editor-result violated"}>
|
||||||
|
{validation.valid ? <><strong>Проверка пройдена</strong><pre>{pretty(validation.ground_truth ?? {})}</pre></> :
|
||||||
|
<><strong>Ошибки проверки</strong><ul>{validation.errors.map((item, index) => <li key={`${index}-${item}`}>{item}</li>)}</ul></>}
|
||||||
|
</div>}
|
||||||
|
{error && <p className="violated" role="alert">{error}</p>}
|
||||||
|
{notice && <p className="state-ok" role="status">{notice}</p>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -25,14 +25,18 @@ export function useControl(sessionId: string | null) {
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const start = useCallback(
|
const start = useCallback(
|
||||||
(scenarioId: string, mode: SessionMode, trainee: string, group?: string) =>
|
(scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string) => {
|
||||||
channel.current?.send({
|
const message = {
|
||||||
type: "scenario.start",
|
type: "scenario.start",
|
||||||
scenario_id: scenarioId,
|
scenario_id: scenarioId,
|
||||||
mode,
|
mode,
|
||||||
trainee,
|
trainee,
|
||||||
group_id: group || null,
|
group_id: group || null,
|
||||||
}),
|
trainee_id: traineeId || null,
|
||||||
|
exercise,
|
||||||
|
} as const;
|
||||||
|
channel.current?.send(message);
|
||||||
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,282 +1,281 @@
|
||||||
// АРМ диспетчера ДДС.
|
// Учебное АРМ ДДС. Звонок бригаде имитируется внутри приложения, без SIP.
|
||||||
//
|
|
||||||
// Аудио здесь нет вообще: приходит снимок карточки от оператора 112,
|
|
||||||
// дальше — подтверждение по нормативу, возврат на уточнение и отметки времени.
|
|
||||||
// Возврат карточки — самая ценная механика цепочки: неполнота КИО становится
|
|
||||||
// сорванным выездом с конкретной причиной (docs/arch/CONTRACT.md).
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { GROUPS } from "@/features/kio-card/fields";
|
import { GROUPS } from "@/features/kio-card/fields";
|
||||||
import { KioCard } from "@/features/kio-card/KioCard";
|
import { KioCard } from "@/features/kio-card/KioCard";
|
||||||
import { applyState, empty as emptyCard, type CardState } from "@/features/kio-card/merge";
|
import { applyState, empty as emptyCard, readPath, type CardState } from "@/features/kio-card/merge";
|
||||||
import { stationChannel, type ChannelStatus } from "@/shared/api/ws";
|
|
||||||
import type { ServiceStatus, StationSnapshot } from "@/shared/types/generated";
|
|
||||||
import { SERVICE_STATUS_LABELS, CARD_STATUS_LABELS, ALARMING } from "./statuses";
|
|
||||||
import { sessionIdFromUrl } from "@/shared/api/session";
|
import { sessionIdFromUrl } from "@/shared/api/session";
|
||||||
|
import { stationChannel, type ChannelStatus } from "@/shared/api/ws";
|
||||||
|
import type { ServiceStatus, SessionReport, StationSnapshot, StationToServer } from "@/shared/types/generated";
|
||||||
|
import { ALARMING, CARD_STATUS_LABELS, SERVICE_STATUS_LABELS } from "./statuses";
|
||||||
|
|
||||||
const ACK_LIMIT_S = 30; // норматив подтверждения, ПП РФ № 1931 и памятка АРМ-112
|
const ACK_LIMIT_S = 30;
|
||||||
|
type ReportPhase = "dispatched" | "arrived" | "working" | "completed";
|
||||||
|
interface PhoneReport { service: string; crew: string; phase: ReportPhase; text: string; at: string }
|
||||||
|
interface DdsSnapshot extends StationSnapshot {
|
||||||
|
crew_options?: string[];
|
||||||
|
crew_selected?: string | null;
|
||||||
|
phone_reports?: PhoneReport[];
|
||||||
|
}
|
||||||
|
type DdsCommand = { type: "crew.select"; crew: string } | { type: "phone.dial" } | { type: "station.finish" };
|
||||||
|
const REPORT_LABELS: Record<ReportPhase, string> = {
|
||||||
|
dispatched: "Доклад о выезде", arrived: "Доклад о прибытии",
|
||||||
|
working: "Доклад о ходе работ", completed: "Доклад о завершении работ",
|
||||||
|
};
|
||||||
const FIELDS = GROUPS.flatMap((group) =>
|
const FIELDS = GROUPS.flatMap((group) =>
|
||||||
group.fields.filter((field) => !field.readOnly).map((field) => ({ path: field.path, label: field.label })),
|
group.fields.filter((item) => !item.readOnly).map((item) => ({ path: item.path, label: item.label })),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function field(card: CardState, path: string): string {
|
||||||
|
const value = readPath(card.server, path);
|
||||||
|
return value === null || value === undefined || value === "" ? "—" : String(value);
|
||||||
|
}
|
||||||
|
function time(value: string): string {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
export function Dds() {
|
export function Dds() {
|
||||||
const sessionId = sessionIdFromUrl();
|
const sessionId = sessionIdFromUrl();
|
||||||
const role = new URLSearchParams(location.search).get("role") ?? "dds_01";
|
const role = new URLSearchParams(location.search).get("role") ?? "dds_01";
|
||||||
|
|
||||||
const [status, setStatus] = useState<ChannelStatus>("connecting");
|
const [status, setStatus] = useState<ChannelStatus>("connecting");
|
||||||
const [card, setCard] = useState<CardState | null>(null);
|
const [card, setCard] = useState<CardState | null>(null);
|
||||||
const [from, setFrom] = useState<string>("");
|
const [from, setFrom] = useState("");
|
||||||
const [receivedAt, setReceivedAt] = useState<number | null>(null);
|
const [receivedAt, setReceivedAt] = useState<number | null>(null);
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
const [acked, setAcked] = useState(false);
|
const [station, setStation] = useState<DdsSnapshot | null>(null);
|
||||||
const [bounced, setBounced] = useState<string[] | null>(null);
|
const [lastReport, setLastReport] = useState<PhoneReport | null>(null);
|
||||||
|
const [dialing, setDialing] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [ended, setEnded] = useState(false);
|
||||||
|
const [report, setReport] = useState<SessionReport | null>(null);
|
||||||
|
const [reportState, setReportState] = useState<"idle" | "loading" | "ready" | "error">("idle");
|
||||||
|
const [reportError, setReportError] = useState("");
|
||||||
const [missing, setMissing] = useState<string[]>([]);
|
const [missing, setMissing] = useState<string[]>([]);
|
||||||
const [comment, setComment] = useState("");
|
const [bounceComment, setBounceComment] = useState("");
|
||||||
|
const [bounced, setBounced] = useState(false);
|
||||||
const [zone, setZone] = useState<boolean | null>(null);
|
const [zone, setZone] = useState<boolean | null>(null);
|
||||||
const [marks, setMarks] = useState<string[]>([]);
|
|
||||||
const [station, setStation] = useState<StationSnapshot | null>(null);
|
|
||||||
const channel = useRef<ReturnType<typeof stationChannel> | null>(null);
|
const channel = useRef<ReturnType<typeof stationChannel> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
async function loadReport() {
|
||||||
|
setReportState("loading");
|
||||||
|
setReportError("");
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/sessions/${sessionId}/report`, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 403) throw new Error("Отчёт недоступен: это занятие другого обучающегося.");
|
||||||
|
if (response.status === 409) throw new Error("Оценка ещё не готова. Попробуйте получить разбор чуть позже.");
|
||||||
|
throw new Error(`Не удалось получить отчёт (HTTP ${response.status}).`);
|
||||||
|
}
|
||||||
|
setReport(await response.json() as SessionReport);
|
||||||
|
setReportState("ready");
|
||||||
|
} catch (cause) {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setReportState("error");
|
||||||
|
setReportError(cause instanceof Error ? cause.message : "Не удалось получить отчёт.");
|
||||||
|
}
|
||||||
|
}
|
||||||
const ch = stationChannel(sessionId, role, {
|
const ch = stationChannel(sessionId, role, {
|
||||||
onStatus: setStatus,
|
onStatus: (next) => { setStatus(next); if (next !== "open") setDialing(false); },
|
||||||
onEvent: (event) => {
|
onEvent: (event) => {
|
||||||
if (event.type === "station.state") setStation(event.snapshot);
|
if (event.type === "station.state") { setStation(event.snapshot as DdsSnapshot); setError(""); }
|
||||||
if (event.type === "error") mark(`отклонено: ${event.message}`);
|
else if (event.type === "card.received") {
|
||||||
if (event.type === "card.received") {
|
|
||||||
setCard(applyState(emptyCard, event.card as unknown as Record<string, unknown>));
|
setCard(applyState(emptyCard, event.card as unknown as Record<string, unknown>));
|
||||||
setFrom(event.from_operator);
|
setFrom(event.from_operator);
|
||||||
setReceivedAt(Date.now());
|
// Норматив идёт с передачи карточки сервером, в том числе если
|
||||||
|
// диспетчер открыл АРМ уже после начала занятия.
|
||||||
|
const transmittedAt = new Date(event.at).getTime();
|
||||||
|
setReceivedAt(Number.isNaN(transmittedAt) ? Date.now() : transmittedAt);
|
||||||
|
setEnded(false);
|
||||||
|
} else if (event.type === "error") { setError(event.message); setDialing(false); }
|
||||||
|
else if (event.type === "session.ended") { setEnded(true); setDialing(false); }
|
||||||
|
else if (event.type === "score.ready") { void loadReport(); }
|
||||||
|
else if ((event as { type: string }).type === "phone.report") {
|
||||||
|
setLastReport(event as unknown as PhoneReport);
|
||||||
|
setDialing(false);
|
||||||
|
setError("");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}).connect();
|
}).connect();
|
||||||
channel.current = ch;
|
channel.current = ch;
|
||||||
return () => ch.close();
|
return () => { controller.abort(); ch.close(); channel.current = null; };
|
||||||
}, [sessionId, role]);
|
}, [sessionId, role]);
|
||||||
|
|
||||||
// Обратный отсчёт норматива: пока карточка не подтверждена, счёт идёт.
|
const primaryRecorded = Boolean(station?.services.length && station.services.every((service) =>
|
||||||
|
["accepted", "declined", "responding", "arrived", "working", "completed", "refused"]
|
||||||
|
.includes(station.statuses[service])));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (receivedAt === null || acked) return;
|
if (receivedAt === null || primaryRecorded) return;
|
||||||
const timer = setInterval(() => setElapsed((Date.now() - receivedAt) / 1000), 200);
|
const timer = setInterval(() => setElapsed((Date.now() - receivedAt) / 1000), 200);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [receivedAt, acked]);
|
}, [receivedAt, primaryRecorded]);
|
||||||
|
|
||||||
if (!sessionId) {
|
const send = (command: DdsCommand): boolean => channel.current?.send(command as StationToServer) ?? false;
|
||||||
return (
|
if (!sessionId) return <main className="page"><h1>АРМ диспетчера ДДС</h1>
|
||||||
<main className="page">
|
<p className="warn">Откройте ссылку занятия вида /dds?session=…&role=dds_01</p></main>;
|
||||||
<h1>АРМ диспетчера ДДС</h1>
|
|
||||||
<p className="warn">Откройте ссылку вида /dds?session=…&role=dds_01</p>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mark = (label: string) => setMarks((prev) => [`${new Date().toLocaleTimeString("ru-RU")} — ${label}`, ...prev]);
|
const reports = station?.phone_reports ?? [];
|
||||||
|
const selectedCrew = station?.crew_selected ?? "";
|
||||||
|
const latest = lastReport ?? reports.at(-1) ?? null;
|
||||||
|
const isConnected = status === "open";
|
||||||
|
const crewAccepted = Boolean(station?.services.some((service) =>
|
||||||
|
selectedCrew.startsWith(`${service} —`) &&
|
||||||
|
["accepted", "responding", "arrived", "working"].includes(station.statuses[service])));
|
||||||
|
|
||||||
return (
|
return <main className="page dds-workspace">
|
||||||
<main className="page">
|
<header className="dds-header"><div><div className="dds-eyebrow">Учебный контур · АРМ системы-112</div>
|
||||||
<h1>АРМ диспетчера ДДС · {role}</h1>
|
<h1>Дежурно-диспетчерская служба <span>{role}</span></h1></div>
|
||||||
<table className="grid">
|
<div className={`dds-connection ${isConnected ? "state-ok" : "state-warn"}`}>
|
||||||
<tbody>
|
{isConnected ? "● Канал подключён" : `● ${status}`}</div></header>
|
||||||
<tr><th>Канал</th><td className={status === "open" ? "" : "warn"}>{status}</td></tr>
|
{error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>}
|
||||||
<tr><th>Карточка</th><td>{card ? `от оператора ${from}` : "ожидание карточки от 112"}</td></tr>
|
{ended && <div className="dds-alert dds-done">Занятие завершено. Оценка появится после расчёта.</div>}
|
||||||
{card && !acked && (
|
{(ended || reportState !== "idle") && <section className="dds-panel" aria-live="polite">
|
||||||
<tr>
|
<div className="dds-panel-title"><h2>Итог упражнения</h2></div>
|
||||||
<th>Подтверждение</th>
|
{report ? <div style={{ padding: 12 }}>
|
||||||
<td className={elapsed > ACK_LIMIT_S ? "state-violated" : "state-ok"}>
|
<p><strong>Итоговая оценка: {report.score_final.toFixed(0)} из 100</strong>
|
||||||
{elapsed.toFixed(1)} с из {ACK_LIMIT_S} с
|
{report.score_final !== report.score_auto && ` · автооценка ${report.score_auto.toFixed(0)}`}</p>
|
||||||
</td>
|
<p className="ref">Скачать отчёт: <a href={`/api/sessions/${sessionId}/report.csv`}>CSV</a>
|
||||||
</tr>
|
{" · "}<a href={`/api/sessions/${sessionId}/report.pdf`}>PDF</a></p>
|
||||||
)}
|
<h3>Не выполнены нормативы</h3>
|
||||||
</tbody>
|
<table className="dds-registry"><tbody>
|
||||||
</table>
|
{report.metrics.filter((metric) => !metric.passed).map((metric) => <tr key={metric.key}>
|
||||||
|
<th>{metric.title}</th><td>{metric.fact} · норматив: {metric.norm}</td></tr>)}
|
||||||
|
{report.metrics.every((metric) => metric.passed) && <tr><td>Все проверенные нормативы выполнены.</td></tr>}
|
||||||
|
</tbody></table>
|
||||||
|
<h3>Замечания</h3>
|
||||||
|
<table className="dds-registry"><tbody>
|
||||||
|
{report.findings.map((finding, index) => <tr key={`${finding.code}-${index}`}>
|
||||||
|
<th>{finding.code}</th><td>{finding.summary}<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div></td></tr>)}
|
||||||
|
{report.findings.length === 0 && <tr><td>Замечаний нет.</td></tr>}
|
||||||
|
</tbody></table>
|
||||||
|
</div> : <div style={{ padding: 12 }}>
|
||||||
|
{reportState === "loading" ? "Подготавливаем разбор…" : reportError || "Ожидаем итоговую оценку…"}
|
||||||
|
{reportState === "error" && <button type="button" onClick={() => {
|
||||||
|
setReportState("loading");
|
||||||
|
void fetch(`/api/sessions/${sessionId}/report`, { credentials: "same-origin" }).then(async (response) => {
|
||||||
|
if (response.status === 403) throw new Error("Отчёт недоступен: это занятие другого обучающегося.");
|
||||||
|
if (response.status === 409) throw new Error("Оценка ещё не готова. Попробуйте получить разбор чуть позже.");
|
||||||
|
if (!response.ok) throw new Error(`Не удалось получить отчёт (HTTP ${response.status}).`);
|
||||||
|
setReport(await response.json() as SessionReport);
|
||||||
|
setReportState("ready");
|
||||||
|
}).catch((cause: unknown) => {
|
||||||
|
setReportError(cause instanceof Error ? cause.message : "Не удалось получить отчёт.");
|
||||||
|
setReportState("error");
|
||||||
|
});
|
||||||
|
}}>Повторить запрос</button>}
|
||||||
|
</div>}
|
||||||
|
</section>}
|
||||||
|
|
||||||
{card && (
|
<section className="dds-panel"><div className="dds-panel-title"><h2>Реестр обращений</h2><span>Входящие карточки</span></div>
|
||||||
<>
|
<div className="dds-table-wrap"><table className="dds-registry"><thead><tr>
|
||||||
<p>
|
<th>№ карточки</th><th>Время</th><th>Адрес происшествия</th><th>Тип</th><th>Службы</th><th>Статус</th><th>Решение ДДС</th>
|
||||||
<button type="button" disabled={acked} onClick={() => {
|
</tr></thead><tbody>{card ? <tr className="dds-selected">
|
||||||
channel.current?.send({ type: "card.ack" });
|
<td>{sessionId.slice(0, 8).toUpperCase()}</td><td>{time(field(card, "registered_at"))}</td>
|
||||||
setAcked(true);
|
<td>{field(card, "address")}</td><td>{field(card, "incident_type")}</td>
|
||||||
mark("карточка принята");
|
<td>{station?.services.join(", ") || field(card, "notify")}</td>
|
||||||
}}>Принять карточку</button>{" "}
|
<td>{station ? CARD_STATUS_LABELS[station.card] : "Получена"}</td>
|
||||||
<button type="button" disabled={Boolean(bounced)} onClick={() => {
|
<td>{station?.services.map((service) => `${service}: ${SERVICE_STATUS_LABELS[station.statuses[service]]}`).join("; ") || "ожидается"}</td>
|
||||||
if (!missing.length) return;
|
</tr> : <tr><td colSpan={7} className="dds-empty">Ожидание готовой карточки от преподавателя или оператора 112…</td></tr>}</tbody></table></div>
|
||||||
channel.current?.send({ type: "card.bounce", missing_fields: missing, comment });
|
</section>
|
||||||
setBounced(missing);
|
|
||||||
mark(`возврат на уточнение: ${missing.join(", ")}`);
|
|
||||||
}}>Вернуть на уточнение</button>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{!bounced && (
|
{card && <div className="dds-main"><div className="dds-left">
|
||||||
<details>
|
<section className="dds-panel"><div className="dds-panel-title"><h2>Карточка обращения</h2>
|
||||||
<summary>Чего не хватает для выезда</summary>
|
<span>Источник: {from || "112"}</span></div>
|
||||||
<div className="bounce-fields">
|
<div className="dds-summary">
|
||||||
{FIELDS.map((field) => (
|
<div><small>Адрес</small><strong>{field(card, "address")}</strong></div>
|
||||||
<label key={field.path}>
|
<div><small>Заявитель</small><strong>{field(card, "caller_name")}</strong></div>
|
||||||
<input type="checkbox" checked={missing.includes(field.path)}
|
<div><small>Телефон</small><strong>{field(card, "caller_contact")}</strong></div>
|
||||||
onChange={() => setMissing((prev) =>
|
<div><small>Происшествие</small><strong>{field(card, "description")}</strong></div>
|
||||||
prev.includes(field.path) ? prev.filter((item) => item !== field.path) : [...prev, field.path])} />{" "}
|
</div><details className="dds-details"><summary>Полная карточка информационного обмена</summary><KioCard state={card} readOnly /></details>
|
||||||
{field.label}
|
</section>
|
||||||
</label>
|
<section className="dds-panel"><div className="dds-panel-title"><h2>Реагирование служб</h2>
|
||||||
))}
|
<span className={station && ALARMING.includes(station.card) ? "state-violated" : "state-ok"}>
|
||||||
</div>
|
{station ? CARD_STATUS_LABELS[station.card] : "—"}</span></div>
|
||||||
<p>
|
{!primaryRecorded && receivedAt !== null && <div className={`dds-deadline ${elapsed > ACK_LIMIT_S ? "state-violated" : "state-ok"}`}>
|
||||||
<input type="text" value={comment} placeholder="куда ехать без этажа"
|
Первичное решение «Принята» / «Не принята»: {elapsed.toFixed(0)} из {ACK_LIMIT_S} секунд</div>}
|
||||||
onChange={(event) => setComment(event.target.value)} />
|
{station && station.services.length > 0 ? <div className="dds-table-wrap"><table className="dds-registry dds-status-table">
|
||||||
</p>
|
<thead><tr><th>Служба</th><th>Текущий статус</th><th>Следующее действие</th></tr></thead><tbody>
|
||||||
|
{station.services.map((service) => <StatusRow key={service} service={service}
|
||||||
|
current={station.statuses[service]} available={station.available[service] ?? []}
|
||||||
|
disabled={!isConnected || ended} reports={reports.filter((report) => report.service === service)}
|
||||||
|
phoneExercise={from === "учебный сценарий"}
|
||||||
|
onSet={(next, comment) => channel.current?.send({ type: "card.status", service, status: next, comment })} />)}
|
||||||
|
</tbody></table></div> : <p className="dds-empty">Ожидаем состав служб по карточке.</p>}
|
||||||
|
<p className="dds-hint">Сначала примите карточку или укажите причину отказа. Последующие статусы в занятии ДДС подтверждаются докладами по телефону.</p>
|
||||||
|
</section>
|
||||||
|
{station && station.log.length > 0 && <section className="dds-panel"><div className="dds-panel-title"><h2>Журнал реагирования</h2></div>
|
||||||
|
<div className="dds-table-wrap"><table className="dds-registry"><thead><tr><th>Время</th><th>Служба</th><th>Статус</th><th>Комментарий</th></tr></thead>
|
||||||
|
<tbody>{station.log.map((entry, index) => <tr key={`${entry.at}-${index}`}><td>{time(entry.at)}</td><td>{entry.service}</td>
|
||||||
|
<td>{SERVICE_STATUS_LABELS[entry.status]}</td><td>{entry.comment || "—"}</td></tr>)}</tbody></table></div></section>}
|
||||||
|
</div><aside className="dds-right">
|
||||||
|
<section className="dds-panel dds-phone"><div className="dds-panel-title"><h2>IP-телефон</h2><span>Учебная имитация</span></div>
|
||||||
|
<p className="dds-hint">Выберите бригаду вручную, свяжитесь со старшим группы и внесите его доклад в карточку.</p>
|
||||||
|
<label htmlFor="dds-crew">Дежурная бригада</label>
|
||||||
|
<select id="dds-crew" value={selectedCrew} disabled={!isConnected || ended || !station?.crew_options?.length}
|
||||||
|
onChange={(event) => send({ type: "crew.select", crew: event.target.value })}>
|
||||||
|
<option value="">Выберите бригаду</option>
|
||||||
|
{station?.crew_options?.map((crew) => <option key={crew} value={crew}>{crew}</option>)}
|
||||||
|
</select>
|
||||||
|
<button type="button" className="dds-dial" disabled={!crewAccepted || !isConnected || ended || dialing}
|
||||||
|
onClick={() => { if (send({ type: "phone.dial" })) setDialing(true); }}>
|
||||||
|
☎ {dialing ? "Соединение…" : "Позвонить старшему группы"}</button>
|
||||||
|
{selectedCrew && !crewAccepted && <p className="dds-hint state-warn">Перед звонком поставьте статус «Принята» для службы этой бригады.</p>}
|
||||||
|
{latest ? <div className="dds-report" aria-live="polite"><small>{REPORT_LABELS[latest.phase]} · {time(latest.at)}</small>
|
||||||
|
<strong>{latest.crew}</strong><p>{latest.text}</p>
|
||||||
|
<span>Прослушайте доклад и поставьте соответствующий статус реагирования.</span></div>
|
||||||
|
: <div className="dds-phone-idle">Доклады поступят после исходящего учебного звонка.</div>}
|
||||||
|
{reports.length > 0 && <details className="dds-details"><summary>История докладов ({reports.length})</summary>
|
||||||
|
<ol className="dds-reports">{reports.map((report, index) => <li key={`${report.at}-${index}`}><b>{REPORT_LABELS[report.phase]}</b> · {time(report.at)}<br />{report.text}</li>)}</ol>
|
||||||
|
</details>}
|
||||||
|
</section>
|
||||||
|
<section className="dds-panel dds-secondary"><div className="dds-panel-title"><h2>Дополнительные действия</h2></div>
|
||||||
|
<div className="dds-zone">Зона ответственности: <button type="button" disabled={zone !== null || !isConnected || ended}
|
||||||
|
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: true }); setZone(true); }}>Наша</button>
|
||||||
|
<button type="button" disabled={zone !== null || !isConnected || ended}
|
||||||
|
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: false }); setZone(false); }}>Не наша</button></div>
|
||||||
|
<details className="dds-details"><summary>Вернуть карточку на уточнение</summary>
|
||||||
|
<div className="bounce-fields">{FIELDS.map((item) => <label key={item.path}><input type="checkbox" checked={missing.includes(item.path)}
|
||||||
|
onChange={() => setMissing((previous) => previous.includes(item.path) ? previous.filter((path) => path !== item.path) : [...previous, item.path])} /> {item.label}</label>)}</div>
|
||||||
|
<input type="text" value={bounceComment} placeholder="Что необходимо уточнить" onChange={(event) => setBounceComment(event.target.value)} />
|
||||||
|
<button type="button" disabled={!missing.length || bounced || !isConnected || ended} onClick={() => {
|
||||||
|
channel.current?.send({ type: "card.bounce", missing_fields: missing, comment: bounceComment }); setBounced(true);
|
||||||
|
}}>Вернуть оператору</button>
|
||||||
</details>
|
</details>
|
||||||
)}
|
<button type="button" className="dds-finish" disabled={!isConnected || ended} onClick={() => send({ type: "station.finish" })}>Завершить упражнение ДДС</button>
|
||||||
|
</section>
|
||||||
<p>
|
</aside></div>}
|
||||||
Зона ответственности:{" "}
|
</main>;
|
||||||
<button type="button" disabled={zone !== null} onClick={() => {
|
|
||||||
channel.current?.send({ type: "zone.decision", in_zone: true });
|
|
||||||
setZone(true); mark("наша зона");
|
|
||||||
}}>наша</button>{" "}
|
|
||||||
<button type="button" disabled={zone !== null} onClick={() => {
|
|
||||||
channel.current?.send({ type: "zone.decision", in_zone: false });
|
|
||||||
setZone(false); mark("не наша зона");
|
|
||||||
}}>не наша</button>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<button type="button" onClick={() => {
|
|
||||||
channel.current?.send({ type: "crew.dispatched", at: new Date().toISOString() });
|
|
||||||
mark("приказ на выезд");
|
|
||||||
}}>Приказ на выезд</button>{" "}
|
|
||||||
<button type="button" onClick={() => {
|
|
||||||
channel.current?.send({ type: "crew.arrived", at: new Date().toISOString() });
|
|
||||||
mark("прибытие на место");
|
|
||||||
}}>Прибытие</button>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{station && station.services.length > 0 && (
|
|
||||||
<>
|
|
||||||
<h2>Статусы реагирования</h2>
|
|
||||||
<p>
|
|
||||||
Статус карточки:{" "}
|
|
||||||
<b className={ALARMING.includes(station.card) ? "state-violated" : "state-ok"}>
|
|
||||||
{CARD_STATUS_LABELS[station.card]}
|
|
||||||
</b>
|
|
||||||
</p>
|
|
||||||
<table className="grid">
|
|
||||||
<thead>
|
|
||||||
<tr><th>Служба</th><th>Статус</th><th>Отметить</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{station.services.map((service) => (
|
|
||||||
<StatusRow
|
|
||||||
key={service}
|
|
||||||
service={service}
|
|
||||||
current={station.statuses[service]}
|
|
||||||
available={station.available[service] ?? []}
|
|
||||||
onSet={(status, comment) => {
|
|
||||||
channel.current?.send({ type: "card.status", service, status, comment });
|
|
||||||
mark(`${service}: ${SERVICE_STATUS_LABELS[status]}`);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h2>Карточка от оператора 112</h2>
|
|
||||||
{/* Снимок: после передачи он не меняется. */}
|
|
||||||
<KioCard state={card} readOnly />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{station && station.log.length > 0 && (
|
|
||||||
<>
|
|
||||||
<h2>Журнал отметок</h2>
|
|
||||||
<table className="grid">
|
|
||||||
<tbody>
|
|
||||||
{station.log.map((entry, index) => (
|
|
||||||
<tr key={index}>
|
|
||||||
<th>{entry.service}</th>
|
|
||||||
<td>
|
|
||||||
{SERVICE_STATUS_LABELS[entry.status]}
|
|
||||||
{entry.comment && <> — {entry.comment}</>}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{marks.length > 0 && (
|
|
||||||
<>
|
|
||||||
<h2>Отметки времени</h2>
|
|
||||||
<table className="grid">
|
|
||||||
<tbody>{marks.map((line, index) => <tr key={index}><td>{line}</td></tr>)}</tbody>
|
|
||||||
</table>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Одна служба списка оповещения.
|
function StatusRow({ service, current, available, disabled, reports, phoneExercise, onSet }: {
|
||||||
*
|
service: string; current: ServiceStatus; available: ServiceStatus[]; disabled: boolean;
|
||||||
* Доступные статусы приходят с сервера: автомат переходов живёт там, и
|
reports: PhoneReport[]; phoneExercise: boolean; onSet: (status: ServiceStatus, comment: string) => void;
|
||||||
* дублировать его на фронте нельзя — разъедется. Поле комментария появляется
|
|
||||||
* у отказов, потому что без комментария сервер такую отметку не примет. */
|
|
||||||
function StatusRow({
|
|
||||||
service, current, available, onSet,
|
|
||||||
}: {
|
|
||||||
service: string;
|
|
||||||
current: ServiceStatus;
|
|
||||||
available: ServiceStatus[];
|
|
||||||
onSet: (status: ServiceStatus, comment: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [chosen, setChosen] = useState<ServiceStatus | "">("");
|
const [chosen, setChosen] = useState<ServiceStatus | "">("");
|
||||||
const [comment, setComment] = useState("");
|
const [comment, setComment] = useState("");
|
||||||
const needsComment = chosen === "declined" || chosen === "refused";
|
const needsComment = chosen === "declined" || chosen === "refused";
|
||||||
|
const reportNeeded: Partial<Record<ServiceStatus, ReportPhase>> = {
|
||||||
if (available.length === 0) {
|
responding: "dispatched", arrived: "arrived", working: "working", completed: "completed",
|
||||||
return (
|
};
|
||||||
<tr>
|
const matchingReport = !chosen || !phoneExercise || !reportNeeded[chosen] ||
|
||||||
<th>{service}</th>
|
reports.some((report) => report.phase === reportNeeded[chosen]);
|
||||||
<td>{SERVICE_STATUS_LABELS[current]}</td>
|
return <tr><td>{service}</td><td><strong>{SERVICE_STATUS_LABELS[current]}</strong></td><td>
|
||||||
<td>карточка закрыта для редактирования</td>
|
{available.length ? <div className="dds-status-action">
|
||||||
</tr>
|
<select value={chosen} disabled={disabled} aria-label={`Следующий статус ${service}`}
|
||||||
);
|
onChange={(event) => setChosen(event.target.value as ServiceStatus)}>
|
||||||
}
|
<option value="">Выберите статус</option>
|
||||||
return (
|
{available.map((next) => <option key={next} value={next}>{SERVICE_STATUS_LABELS[next]}</option>)}
|
||||||
<tr>
|
</select>
|
||||||
<th>{service}</th>
|
{needsComment && <input type="text" value={comment} placeholder="Причина отказа и кому передана информация"
|
||||||
<td>{SERVICE_STATUS_LABELS[current]}</td>
|
onChange={(event) => setComment(event.target.value)} />}
|
||||||
<td>
|
{chosen && !matchingReport && <span className="state-warn">Сначала получите доклад по телефону.</span>}
|
||||||
<select value={chosen} onChange={(event) => setChosen(event.target.value as ServiceStatus)}>
|
<button type="button" disabled={disabled || !chosen || (needsComment && !comment.trim()) || !matchingReport}
|
||||||
<option value="">—</option>
|
onClick={() => { if (chosen) { onSet(chosen, comment.trim()); setChosen(""); setComment(""); } }}>Сохранить статус</button>
|
||||||
{available.map((status) => (
|
</div> : "Карточка закрыта для редактирования"}</td></tr>;
|
||||||
<option key={status} value={status}>{SERVICE_STATUS_LABELS[status]}</option>
|
|
||||||
))}
|
|
||||||
</select>{" "}
|
|
||||||
{needsComment && (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={comment}
|
|
||||||
placeholder="причина и куда передана информация"
|
|
||||||
onChange={(event) => setComment(event.target.value)}
|
|
||||||
/>
|
|
||||||
)}{" "}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!chosen || (needsComment && !comment.trim())}
|
|
||||||
onClick={() => {
|
|
||||||
if (!chosen) return;
|
|
||||||
onSet(chosen, comment);
|
|
||||||
setChosen("");
|
|
||||||
setComment("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Поставить
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,14 @@ import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { Debrief } from "@/features/debrief/Debrief";
|
import { Debrief } from "@/features/debrief/Debrief";
|
||||||
import { Director } from "@/features/instructor/Director";
|
import { Director } from "@/features/instructor/Director";
|
||||||
|
import { ScenarioEditor } from "@/features/instructor/ScenarioEditor";
|
||||||
import { useControl } from "@/features/instructor/useControl";
|
import { useControl } from "@/features/instructor/useControl";
|
||||||
import { KioCard } from "@/features/kio-card/KioCard";
|
import { KioCard } from "@/features/kio-card/KioCard";
|
||||||
import { StandStatus } from "@/features/health/StandStatus";
|
import { StandStatus } from "@/features/health/StandStatus";
|
||||||
import { ModeBanner } from "@/features/mode-banner/ModeBanner";
|
import { ModeBanner } from "@/features/mode-banner/ModeBanner";
|
||||||
import { useObserve } from "@/features/observe/useObserve";
|
import { useObserve } from "@/features/observe/useObserve";
|
||||||
import { InterviewTimer, Timers } from "@/features/timers/Timers";
|
import { InterviewTimer, Timers } from "@/features/timers/Timers";
|
||||||
import { useScenarios, useSessions } from "@/shared/api/http";
|
import { useScenarios, useSessions, useTrainees } from "@/shared/api/http";
|
||||||
import { sessionIdFromUrl } from "@/shared/api/session";
|
import { sessionIdFromUrl } from "@/shared/api/session";
|
||||||
import type { SessionMode } from "@/shared/types/generated";
|
import type { SessionMode } from "@/shared/types/generated";
|
||||||
|
|
||||||
|
|
@ -28,7 +29,9 @@ export function Instructor() {
|
||||||
const [sessionId, setSessionId] = useState<string | null>(sessionIdFromUrl());
|
const [sessionId, setSessionId] = useState<string | null>(sessionIdFromUrl());
|
||||||
const [scenarioId, setScenarioId] = useState("");
|
const [scenarioId, setScenarioId] = useState("");
|
||||||
const [mode, setMode] = useState<SessionMode>("training");
|
const [mode, setMode] = useState<SessionMode>("training");
|
||||||
|
const [exercise, setExercise] = useState<"dds" | "call" | "card">("dds");
|
||||||
const [trainee, setTrainee] = useState("");
|
const [trainee, setTrainee] = useState("");
|
||||||
|
const [traineeId, setTraineeId] = useState("");
|
||||||
const [noteFor, setNoteFor] = useState<string | null>(null);
|
const [noteFor, setNoteFor] = useState<string | null>(null);
|
||||||
// Запускается только занятие, созданное этой кнопкой. Открытая из истории
|
// Запускается только занятие, созданное этой кнопкой. Открытая из истории
|
||||||
// ссылка на идущее занятие не должна запускать его заново и сбрасывать.
|
// ссылка на идущее занятие не должна запускать его заново и сбрасывать.
|
||||||
|
|
@ -37,6 +40,7 @@ export function Instructor() {
|
||||||
|
|
||||||
const scenarios = useScenarios();
|
const scenarios = useScenarios();
|
||||||
const history = useSessions();
|
const history = useSessions();
|
||||||
|
const trainees = useTrainees();
|
||||||
const session = useObserve(sessionId);
|
const session = useObserve(sessionId);
|
||||||
const control = useControl(sessionId);
|
const control = useControl(sessionId);
|
||||||
|
|
||||||
|
|
@ -45,6 +49,7 @@ export function Instructor() {
|
||||||
}, [scenarios.data, scenarioId]);
|
}, [scenarios.data, scenarioId]);
|
||||||
|
|
||||||
function startLesson() {
|
function startLesson() {
|
||||||
|
if (trainees.data?.length && !traineeId) return;
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
// Номер занятия живёт в адресе: монитор и АРМ курсанта открываются ссылкой.
|
// Номер занятия живёт в адресе: монитор и АРМ курсанта открываются ссылкой.
|
||||||
window.history.replaceState(null, "", `/instructor?session=${id}`);
|
window.history.replaceState(null, "", `/instructor?session=${id}`);
|
||||||
|
|
@ -56,8 +61,9 @@ export function Instructor() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId || control.status !== "open" || toStart.current !== sessionId) return;
|
if (!sessionId || control.status !== "open" || toStart.current !== sessionId) return;
|
||||||
toStart.current = null;
|
toStart.current = null;
|
||||||
control.start(scenarioId, mode, trainee || "Курсант");
|
const selected = trainees.data?.find((item) => item.id === traineeId);
|
||||||
}, [sessionId, control, scenarioId, mode, trainee]);
|
control.start(scenarioId, mode, selected?.name || trainee || "Демо-курсант", exercise, undefined, selected?.id);
|
||||||
|
}, [sessionId, control, scenarioId, mode, trainee, traineeId, trainees.data, exercise]);
|
||||||
|
|
||||||
const links = sessionId
|
const links = sessionId
|
||||||
? {
|
? {
|
||||||
|
|
@ -90,6 +96,17 @@ export function Instructor() {
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Упражнение</th>
|
||||||
|
<td>
|
||||||
|
<label className="mode-choice"><input type="radio" checked={exercise === "dds"}
|
||||||
|
onChange={() => setExercise("dds")} /> Диспетчер ДДС · готовая карточка</label>
|
||||||
|
<label className="mode-choice"><input type="radio" checked={exercise === "call"}
|
||||||
|
onChange={() => setExercise("call")} /> Оператор 112 · звонок</label>
|
||||||
|
<label className="mode-choice"><input type="radio" checked={exercise === "card"}
|
||||||
|
onChange={() => setExercise("card")} /> Оператор 112 · текстовая вводная</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Режим</th>
|
<th>Режим</th>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -108,14 +125,27 @@ export function Instructor() {
|
||||||
<tr>
|
<tr>
|
||||||
<th>Курсант</th>
|
<th>Курсант</th>
|
||||||
<td>
|
<td>
|
||||||
<input type="text" value={trainee} placeholder="фамилия"
|
{trainees.data?.length ? (
|
||||||
|
<select value={traineeId} onChange={(event) => setTraineeId(event.target.value)}>
|
||||||
|
<option value="">Выберите учётную запись</option>
|
||||||
|
{trainees.data.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.name}{item.group ? ` · ${item.group}` : ""}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input type="text" value={trainee} placeholder="имя для демо"
|
||||||
onChange={(event) => setTrainee(event.target.value)} />
|
onChange={(event) => setTrainee(event.target.value)} />
|
||||||
|
<small className="ref">Без учётной записи: личный отчёт курсанту недоступен.</small>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p>
|
<p>
|
||||||
<button type="button" className="start" onClick={startLesson} disabled={!scenarioId}>
|
<button type="button" className="start" onClick={startLesson}
|
||||||
|
disabled={!scenarioId || trainees.isLoading || Boolean(trainees.data?.length && !traineeId)}>
|
||||||
Начать занятие
|
Начать занятие
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -127,12 +157,12 @@ export function Instructor() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{sessionId && !session.ended && (
|
{sessionId && !session.ended && (
|
||||||
<Director onInject={control.inject} disabled={control.status !== "open"} error={session.error} />
|
exercise === "call" && <Director onInject={control.inject} disabled={control.status !== "open"} error={session.error} />
|
||||||
)}
|
)}
|
||||||
{links && (
|
{links && (
|
||||||
<table className="grid">
|
<table className="grid">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><th>АРМ курсанта</th><td><a href={links.trainee}>открыть</a></td></tr>
|
<tr><th>АРМ курсанта</th><td><a href={exercise === "dds" ? links.dds : links.trainee}>открыть {exercise === "dds" ? "ДДС" : "112"}</a></td></tr>
|
||||||
<tr><th>Внешний монитор</th><td><a href={links.wall}>открыть</a></td></tr>
|
<tr><th>Внешний монитор</th><td><a href={links.wall}>открыть</a></td></tr>
|
||||||
<tr><th>АРМ ДДС</th><td><a href={links.dds}>открыть</a></td></tr>
|
<tr><th>АРМ ДДС</th><td><a href={links.dds}>открыть</a></td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -204,6 +234,11 @@ export function Instructor() {
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ScenarioEditor scenarios={scenarios.data ?? []} onApproved={async (id) => {
|
||||||
|
setScenarioId(id);
|
||||||
|
await scenarios.refetch();
|
||||||
|
}} />
|
||||||
|
|
||||||
{session.report && <Debrief report={session.report} />}
|
{session.report && <Debrief report={session.report} />}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { InterviewTimer, Timers } from "@/features/timers/Timers";
|
||||||
import { display } from "@/features/kio-card/merge";
|
import { display } from "@/features/kio-card/merge";
|
||||||
import { useCall } from "@/features/call/useCall";
|
import { useCall } from "@/features/call/useCall";
|
||||||
import { sessionIdFromUrl } from "@/shared/api/session";
|
import { sessionIdFromUrl } from "@/shared/api/session";
|
||||||
|
import type { DDSCode } from "@/shared/types/generated";
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
connecting: "подключение",
|
connecting: "подключение",
|
||||||
|
|
@ -25,6 +26,11 @@ const RESOLVE_LABELS = {
|
||||||
transfer_region: "передан в систему-112 другого региона",
|
transfer_region: "передан в систему-112 другого региона",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
function ddsCode(value: unknown): DDSCode | undefined {
|
||||||
|
return value === "01" || value === "02" || value === "03" || value === "04" || value === "gkh"
|
||||||
|
? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function Call() {
|
export function Call() {
|
||||||
const [resolveComment, setResolveComment] = useState("");
|
const [resolveComment, setResolveComment] = useState("");
|
||||||
const [resolved, setResolved] = useState<"consultation" | "transfer_region" | null>(null);
|
const [resolved, setResolved] = useState<"consultation" | "transfer_region" | null>(null);
|
||||||
|
|
@ -40,6 +46,39 @@ export function Call() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (call.briefing) {
|
||||||
|
return <main className="page">
|
||||||
|
<ModeBanner mode={call.briefing.mode} />
|
||||||
|
<h1>АРМ оператора 112 · учебная карточка</h1>
|
||||||
|
<p className="ref">Текстовое упражнение: входящий голосовой вызов и микрофон не используются.</p>
|
||||||
|
<table className="grid"><tbody>
|
||||||
|
<tr><th>Канал</th><td className={call.status === "open" ? "state-ok" : "state-warn"}>{STATUS_LABEL[call.status]}</td></tr>
|
||||||
|
<tr><th>Сценарий</th><td>{call.briefing.scenario_id}</td></tr>
|
||||||
|
<tr><th>Состояние</th><td>{call.cardSubmitted ? "карточка отправлена на оценку" : "карточка заполняется"}</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
<section style={{ maxWidth: 900, marginTop: 16 }}>
|
||||||
|
<h2>Вводная о происшествии</h2>
|
||||||
|
<div style={{ background: "#fff", border: "1px solid var(--line)", padding: 16, whiteSpace: "pre-wrap", lineHeight: 1.5 }}>
|
||||||
|
{call.briefing.text}
|
||||||
|
</div>
|
||||||
|
<p className="ref">Извлеките сведения из вводной, заполните поля карточки и проверьте предлагаемый список оповещения.</p>
|
||||||
|
<h2>Карточка информационного обмена</h2>
|
||||||
|
<KioCard state={call.card} required={call.briefing.required_fields}
|
||||||
|
readOnly={call.cardSubmitted || call.status !== "open"} onChange={call.patchKio} />
|
||||||
|
{!call.cardSubmitted && <p><button type="button" className="start" disabled={call.status !== "open"}
|
||||||
|
onClick={call.submitCard}>Отправить карточку на проверку</button></p>}
|
||||||
|
{call.error && <p className="state-violated" role="alert">{call.error}</p>}
|
||||||
|
{call.cardSubmitted && !call.report && !call.reportError && <p>Карточка отправлена. Ожидаем итоговую оценку…</p>}
|
||||||
|
{call.reportError && <p className="state-violated" role="alert">{call.reportError}</p>}
|
||||||
|
</section>
|
||||||
|
{call.report && <Debrief report={call.report} />}
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = ddsCode(display(call.card, "dds"));
|
||||||
|
const notify = display(call.card, "notify");
|
||||||
|
const autoRoute = Boolean(display(call.card, "incident_code")) && Array.isArray(notify) && notify.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<StandStatus />
|
<StandStatus />
|
||||||
|
|
@ -85,8 +124,8 @@ export function Call() {
|
||||||
<p>
|
<p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!call.micOn || !display(call.card, "dds")}
|
disabled={!call.micOn || (!service && !autoRoute)}
|
||||||
onClick={() => call.dispatch(display(call.card, "dds") as never)}
|
onClick={() => call.dispatch(service)}
|
||||||
>
|
>
|
||||||
Передать в ДДС
|
Передать в ДДС
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export interface CallAnswer {
|
||||||
type: "call.answer";
|
type: "call.answer";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CallEndReason = "hangup" | "dropped" | "instructor";
|
export type CallEndReason = "hangup" | "dropped" | "instructor" | "complete";
|
||||||
|
|
||||||
export interface CallEnded {
|
export interface CallEnded {
|
||||||
type: "call.ended";
|
type: "call.ended";
|
||||||
|
|
@ -77,6 +77,16 @@ export interface CardBounce {
|
||||||
comment?: string;
|
comment?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Текстовая вводная упражнения 112; эталонные значения сюда не входят. */
|
||||||
|
export interface CardBriefing {
|
||||||
|
type: "card.briefing";
|
||||||
|
scenario_id: string;
|
||||||
|
mode: SessionMode;
|
||||||
|
text: string;
|
||||||
|
required_fields: Array<string>;
|
||||||
|
card?: KIO | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Снимок КИО. После передачи не меняется — оператор не дописывает задним числом. */
|
/** Снимок КИО. После передачи не меняется — оператор не дописывает задним числом. */
|
||||||
export interface CardReceived {
|
export interface CardReceived {
|
||||||
type: "card.received";
|
type: "card.received";
|
||||||
|
|
@ -88,6 +98,10 @@ export interface CardReceived {
|
||||||
/** Статус карточки целиком — считается из статусов служб, руками не ставится. */
|
/** Статус карточки целиком — считается из статусов служб, руками не ставится. */
|
||||||
export type CardStatus = "registered" | "processed" | "checked" | "not_notified" | "refused" | "not_completed" | "completed";
|
export type CardStatus = "registered" | "processed" | "checked" | "not_notified" | "refused" | "not_completed" | "completed";
|
||||||
|
|
||||||
|
export interface CardSubmit {
|
||||||
|
type: "card.submit";
|
||||||
|
}
|
||||||
|
|
||||||
/** Шесть осей радара. Проекция уже посчитанных метрик, без пересчёта весов. */
|
/** Шесть осей радара. Проекция уже посчитанных метрик, без пересчёта весов. */
|
||||||
export type Competency = "intake" | "interview" | "card" | "routing" | "norms" | "communication";
|
export type Competency = "intake" | "interview" | "card" | "routing" | "norms" | "communication";
|
||||||
|
|
||||||
|
|
@ -112,13 +126,18 @@ export interface CrewDispatched {
|
||||||
at: string;
|
at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CrewSelect {
|
||||||
|
type: "crew.select";
|
||||||
|
crew: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Дежурно-диспетчерская служба, в которую уходит карточка. */
|
/** Дежурно-диспетчерская служба, в которую уходит карточка. */
|
||||||
export type DDSCode = "01" | "02" | "03" | "04" | "gkh";
|
export type DDSCode = "01" | "02" | "03" | "04" | "gkh";
|
||||||
|
|
||||||
/** Передача в ДДС. Замораживает карточку снимком и останавливает опрос (`interview`). */
|
/** Передача в ДДС. При маршрутизации по ЕКП служба уже есть в notify. */
|
||||||
export interface DdsDispatch {
|
export interface DdsDispatch {
|
||||||
type: "dds.dispatch";
|
type: "dds.dispatch";
|
||||||
service: DDSCode;
|
service?: DDSCode | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `immediate` — только для обрыва связи: рвёт TTS на полуслове. */
|
/** `immediate` — только для обрыва связи: рвёт TTS на полуслове. */
|
||||||
|
|
@ -152,6 +171,8 @@ export interface ErrorEvent {
|
||||||
/** Коды канала `error`. Фронт разбирает код, а не текст сообщения: текст — для человека, код — для поведения интерфейса. */
|
/** Коды канала `error`. Фронт разбирает код, а не текст сообщения: текст — для человека, код — для поведения интерфейса. */
|
||||||
export type ErrorKind = "session_not_found" | "call_not_started" | "hint_denied_in_exam" | "models_warming_up" | "directive_needs_network" | "scenario_invalid" | "unsupported_event" | "forbidden" | "internal";
|
export type ErrorKind = "session_not_found" | "call_not_started" | "hint_denied_in_exam" | "models_warming_up" | "directive_needs_network" | "scenario_invalid" | "unsupported_event" | "forbidden" | "internal";
|
||||||
|
|
||||||
|
export type Exercise = "call" | "dds" | "card";
|
||||||
|
|
||||||
/** Отметка в разборе. `fact` и `norm` — то самое обоснование. */
|
/** Отметка в разборе. `fact` и `norm` — то самое обоснование. */
|
||||||
export interface Finding {
|
export interface Finding {
|
||||||
code: ErrorCode;
|
code: ErrorCode;
|
||||||
|
|
@ -306,6 +327,27 @@ export type Outcome = "card" | "consultation" | "transfer_region";
|
||||||
/** `auto` — сервер распознал факт из речи, `operator` — эхо правки курсанта. */
|
/** `auto` — сервер распознал факт из речи, `operator` — эхо правки курсанта. */
|
||||||
export type PatchSource = "auto" | "operator";
|
export type PatchSource = "auto" | "operator";
|
||||||
|
|
||||||
|
export interface PhoneDial {
|
||||||
|
type: "phone.dial";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhoneReport {
|
||||||
|
type: "phone.report";
|
||||||
|
service: string;
|
||||||
|
crew: string;
|
||||||
|
phase: "dispatched" | "arrived" | "working" | "completed";
|
||||||
|
text: string;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhoneReportRecord {
|
||||||
|
service: string;
|
||||||
|
crew: string;
|
||||||
|
phase: string;
|
||||||
|
text: string;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Уточняющие поля 02 — полиция. */
|
/** Уточняющие поля 02 — полиция. */
|
||||||
export interface PoliceDetails {
|
export interface PoliceDetails {
|
||||||
offence_kind?: string | null;
|
offence_kind?: string | null;
|
||||||
|
|
@ -346,8 +388,10 @@ export interface ScenarioStart {
|
||||||
type: "scenario.start";
|
type: "scenario.start";
|
||||||
scenario_id: string;
|
scenario_id: string;
|
||||||
trainee: string;
|
trainee: string;
|
||||||
|
trainee_id?: string | null;
|
||||||
group_id?: string | null;
|
group_id?: string | null;
|
||||||
mode: SessionMode;
|
mode: SessionMode;
|
||||||
|
exercise?: Exercise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Коррекция оценки. Автооценка сохраняется рядом. */
|
/** Коррекция оценки. Автооценка сохраняется рядом. */
|
||||||
|
|
@ -432,6 +476,7 @@ export interface SessionSnapshot {
|
||||||
scenario_title: string;
|
scenario_title: string;
|
||||||
level: Level;
|
level: Level;
|
||||||
mode: SessionMode;
|
mode: SessionMode;
|
||||||
|
exercise?: Exercise;
|
||||||
trainee_name?: string | null;
|
trainee_name?: string | null;
|
||||||
started_at?: string | null;
|
started_at?: string | null;
|
||||||
kio: KIO;
|
kio: KIO;
|
||||||
|
|
@ -448,6 +493,10 @@ export interface SessionStop {
|
||||||
|
|
||||||
export type Speaker = "caller" | "operator";
|
export type Speaker = "caller" | "operator";
|
||||||
|
|
||||||
|
export interface StationFinish {
|
||||||
|
type: "station.finish";
|
||||||
|
}
|
||||||
|
|
||||||
/** Что видит диспетчер: службы, их статусы и что доступно дальше. Все поля обязательные, хотя списки и могут быть пустыми: снимок всегда собирается целиком, а необязательное поле уезжает в TypeScript как `| undefined` и заставляет фронт проверять то, чего не бывает. */
|
/** Что видит диспетчер: службы, их статусы и что доступно дальше. Все поля обязательные, хотя списки и могут быть пустыми: снимок всегда собирается целиком, а необязательное поле уезжает в TypeScript как `| undefined` и заставляет фронт проверять то, чего не бывает. */
|
||||||
export interface StationSnapshot {
|
export interface StationSnapshot {
|
||||||
services: Array<string>;
|
services: Array<string>;
|
||||||
|
|
@ -455,6 +504,9 @@ export interface StationSnapshot {
|
||||||
available: Record<string, Array<ServiceStatus>>;
|
available: Record<string, Array<ServiceStatus>>;
|
||||||
card: CardStatus;
|
card: CardStatus;
|
||||||
log: Array<StatusEntry>;
|
log: Array<StatusEntry>;
|
||||||
|
crew_options?: Array<string>;
|
||||||
|
crew_selected?: string | null;
|
||||||
|
phone_reports?: Array<PhoneReportRecord>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
|
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
|
||||||
|
|
@ -552,17 +604,17 @@ export interface ZoneDecision {
|
||||||
|
|
||||||
// Союзы по каналам — смотри docs/arch/CONTRACT.md#каналы
|
// Союзы по каналам — смотри docs/arch/CONTRACT.md#каналы
|
||||||
|
|
||||||
export type ServerToTrainee = CallIncoming | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | ErrorEvent;
|
export type ServerToTrainee = CallIncoming | CardBriefing | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | ErrorEvent;
|
||||||
|
|
||||||
export type TraineeToServer = CallAnswer | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial;
|
export type TraineeToServer = CallAnswer | CardSubmit | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial;
|
||||||
|
|
||||||
export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | ErrorEvent;
|
export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | ErrorEvent;
|
||||||
|
|
||||||
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | ScenarioPublish | SessionStop;
|
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | ScenarioPublish | SessionStop;
|
||||||
|
|
||||||
export type ServerToStation = CardReceived | StationState | TimerTick | SessionEnded | ErrorEvent;
|
export type ServerToStation = CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent;
|
||||||
|
|
||||||
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | ZoneDecision | CrewDispatched | CrewArrived;
|
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish | ZoneDecision | CrewDispatched | CrewArrived;
|
||||||
|
|
||||||
export type EventName =
|
export type EventName =
|
||||||
"bg.start"
|
"bg.start"
|
||||||
|
|
@ -577,10 +629,13 @@ export type EventName =
|
||||||
| "caller.utterance"
|
| "caller.utterance"
|
||||||
| "card.ack"
|
| "card.ack"
|
||||||
| "card.bounce"
|
| "card.bounce"
|
||||||
|
| "card.briefing"
|
||||||
| "card.received"
|
| "card.received"
|
||||||
| "card.status"
|
| "card.status"
|
||||||
|
| "card.submit"
|
||||||
| "crew.arrived"
|
| "crew.arrived"
|
||||||
| "crew.dispatched"
|
| "crew.dispatched"
|
||||||
|
| "crew.select"
|
||||||
| "dds.dispatch"
|
| "dds.dispatch"
|
||||||
| "director.inject"
|
| "director.inject"
|
||||||
| "era.data"
|
| "era.data"
|
||||||
|
|
@ -592,6 +647,8 @@ export type EventName =
|
||||||
| "kio.patch"
|
| "kio.patch"
|
||||||
| "kio.state"
|
| "kio.state"
|
||||||
| "mode.set"
|
| "mode.set"
|
||||||
|
| "phone.dial"
|
||||||
|
| "phone.report"
|
||||||
| "reference.play"
|
| "reference.play"
|
||||||
| "reference.started"
|
| "reference.started"
|
||||||
| "scenario.publish"
|
| "scenario.publish"
|
||||||
|
|
@ -602,6 +659,7 @@ export type EventName =
|
||||||
| "session.ended"
|
| "session.ended"
|
||||||
| "session.snapshot"
|
| "session.snapshot"
|
||||||
| "session.stop"
|
| "session.stop"
|
||||||
|
| "station.finish"
|
||||||
| "station.state"
|
| "station.state"
|
||||||
| "stt.final"
|
| "stt.final"
|
||||||
| "stt.partial"
|
| "stt.partial"
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,21 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||||
.director-buttons .danger { border-color: var(--violated); color: var(--violated); font-weight: 600; }
|
.director-buttons .danger { border-color: var(--violated); color: var(--violated); font-weight: 600; }
|
||||||
.director input[type="text"] { width: 60%; }
|
.director input[type="text"] { width: 60%; }
|
||||||
|
|
||||||
|
/* Преподаватель редактирует копию шаблона до публикации. */
|
||||||
|
.scenario-editor { margin-top: 24px; padding: 12px 16px 16px; background: var(--panel); border: 1px solid var(--line); }
|
||||||
|
.scenario-editor h2 { margin-top: 0; }
|
||||||
|
.scenario-editor-create, .scenario-editor-open, .scenario-editor-actions { display: flex; align-items: end; flex-wrap: wrap; gap: 8px 12px; margin: 12px 0; }
|
||||||
|
.scenario-editor-create label, .scenario-editor-open label { display: grid; gap: 4px; min-width: 220px; font-size: 13px; }
|
||||||
|
.scenario-editor-create select, .scenario-editor-create input, .scenario-editor-open input { box-sizing: border-box; width: 100%; min-height: 34px; padding: 4px 6px; border: 1px solid var(--line); font: inherit; background: var(--panel); }
|
||||||
|
.scenario-editor-create label:first-child { flex: 1 1 350px; }
|
||||||
|
.scenario-editor-open label { flex: 1 1 350px; }
|
||||||
|
.scenario-editor-json { display: grid; gap: 6px; font-weight: 600; }
|
||||||
|
.scenario-editor-json textarea { box-sizing: border-box; width: 100%; min-height: 320px; padding: 10px; border: 1px solid var(--line); font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; resize: vertical; }
|
||||||
|
.scenario-editor-result { padding: 10px 12px; border: 1px solid var(--ok); background: #e7f4e9; }
|
||||||
|
.scenario-editor-result.violated { border-color: var(--violated); background: #fde8e8; }
|
||||||
|
.scenario-editor-result pre { margin: 8px 0 0; overflow-x: auto; white-space: pre-wrap; font-size: 12px; }
|
||||||
|
.scenario-editor-result ul { margin: 8px 0 0; }
|
||||||
|
|
||||||
/* Возврат карточки: список полей в две колонки, чтобы диспетчер не скроллил. */
|
/* Возврат карточки: список полей в две колонки, чтобы диспетчер не скроллил. */
|
||||||
.bounce-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; margin: 8px 0; }
|
.bounce-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; margin: 8px 0; }
|
||||||
|
|
||||||
|
|
@ -102,3 +117,57 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||||
.stand { padding: 6px 24px; margin: -16px -24px 12px; font-weight: 600; }
|
.stand { padding: 6px 24px; margin: -16px -24px 12px; font-weight: 600; }
|
||||||
.stand-warn { background: #fff4d6; border-bottom: 1px solid var(--warn); }
|
.stand-warn { background: #fff4d6; border-bottom: 1px solid var(--warn); }
|
||||||
.stand-bad { background: #fde8e8; border-bottom: 1px solid var(--violated); color: var(--violated); }
|
.stand-bad { background: #fde8e8; border-bottom: 1px solid var(--violated); color: var(--violated); }
|
||||||
|
|
||||||
|
/* АРМ ДДС: реестр остаётся плотной таблицей, карточка и связь — рядом. */
|
||||||
|
.dds-workspace { max-width: 1800px; margin: 0 auto; }
|
||||||
|
.dds-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 8px 0 16px; }
|
||||||
|
.dds-header h1 { margin: 2px 0 0; }
|
||||||
|
.dds-header h1 span { color: #576273; font-size: 15px; font-weight: 400; }
|
||||||
|
.dds-eyebrow { color: #576273; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
|
||||||
|
.dds-connection { white-space: nowrap; font-size: 13px; }
|
||||||
|
.dds-panel { background: var(--panel); border: 1px solid var(--line); margin-bottom: 14px; min-width: 0; }
|
||||||
|
.dds-panel-title { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 9px 12px; background: #e8ebef; border-bottom: 1px solid var(--line); }
|
||||||
|
.dds-panel-title h2 { margin: 0; font-size: 15px; }
|
||||||
|
.dds-panel-title span { font-size: 12px; color: #576273; }
|
||||||
|
.dds-main { display: grid; grid-template-columns: minmax(0, 1fr) 340px; align-items: start; gap: 14px; margin-top: 14px; }
|
||||||
|
.dds-left, .dds-right { min-width: 0; }
|
||||||
|
.dds-table-wrap { width: 100%; overflow-x: auto; }
|
||||||
|
.dds-registry { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.dds-registry th, .dds-registry td { padding: 8px 10px; border-right: 1px solid #d4d8dd; border-bottom: 1px solid #d4d8dd; text-align: left; vertical-align: top; }
|
||||||
|
.dds-registry th { background: #f1f3f5; white-space: nowrap; font-weight: 600; }
|
||||||
|
.dds-registry .dds-selected { background: #e8f0fb; box-shadow: inset 3px 0 #316798; }
|
||||||
|
.dds-registry td:last-child, .dds-registry th:last-child { border-right: 0; }
|
||||||
|
.dds-empty { padding: 20px !important; color: #66717b; }
|
||||||
|
.dds-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; }
|
||||||
|
.dds-summary > div { display: grid; gap: 3px; padding: 12px; border-right: 1px solid #e0e3e6; border-bottom: 1px solid #e0e3e6; }
|
||||||
|
.dds-summary small { color: #66717b; }
|
||||||
|
.dds-summary strong { font-weight: 500; overflow-wrap: anywhere; }
|
||||||
|
.dds-details { padding: 10px 12px; }
|
||||||
|
.dds-details summary { cursor: pointer; color: #23577e; font-weight: 600; }
|
||||||
|
.dds-details .kio { padding-top: 12px; }
|
||||||
|
.dds-deadline { padding: 10px 12px; font-weight: 600; border-bottom: 1px solid #e0e3e6; }
|
||||||
|
.dds-hint { margin: 10px 12px; color: #56616c; font-size: 12px; line-height: 1.4; }
|
||||||
|
.dds-status-action { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; }
|
||||||
|
.dds-status-action select { max-width: 190px; }
|
||||||
|
.dds-status-action input { min-width: 180px; flex: 1; }
|
||||||
|
.dds-status-action button { padding: 4px 8px; }
|
||||||
|
.dds-alert { padding: 10px 12px; margin: 0 0 12px; color: var(--violated); background: #fde8e8; border: 1px solid var(--violated); }
|
||||||
|
.dds-done { color: var(--ok); background: #e7f4e9; border-color: var(--ok); }
|
||||||
|
.dds-phone > label { display: block; margin: 14px 12px 5px; font-size: 13px; font-weight: 600; }
|
||||||
|
.dds-phone > select { display: block; width: calc(100% - 24px); margin: 0 12px; padding: 7px; box-sizing: border-box; }
|
||||||
|
.dds-dial { display: block; margin: 12px; width: calc(100% - 24px); text-align: center; background: #23577e; border-color: #23577e; color: white; font-weight: 600; }
|
||||||
|
.dds-dial:disabled { background: #70869a; }
|
||||||
|
.dds-report { margin: 12px; padding: 12px; border: 1px solid #8eafc8; background: #edf5fb; }
|
||||||
|
.dds-report small, .dds-report strong, .dds-report span { display: block; }
|
||||||
|
.dds-report small, .dds-report span { color: #56616c; font-size: 12px; }
|
||||||
|
.dds-report strong { margin-top: 4px; }
|
||||||
|
.dds-report p { line-height: 1.45; }
|
||||||
|
.dds-phone-idle { margin: 12px; padding: 18px 12px; border: 1px dashed #aab4bf; color: #66717b; text-align: center; font-size: 13px; }
|
||||||
|
.dds-reports { margin: 10px 0; padding-left: 22px; font-size: 12px; }
|
||||||
|
.dds-reports li { padding: 5px 0; border-bottom: 1px solid #e0e3e6; }
|
||||||
|
.dds-zone { padding: 12px; display: flex; align-items: center; flex-wrap: wrap; gap: 5px; font-size: 13px; }
|
||||||
|
.dds-secondary .bounce-fields { max-height: 200px; overflow-y: auto; font-size: 12px; }
|
||||||
|
.dds-secondary input[type="text"] { max-width: 100%; margin: 4px 0; }
|
||||||
|
.dds-finish { display: block; margin: 12px; }
|
||||||
|
@media (max-width: 900px) { .dds-main { grid-template-columns: 1fr; } .dds-right { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } }
|
||||||
|
@media (max-width: 640px) { .dds-right, .dds-summary { display: block; } .dds-header { align-items: flex-start; flex-direction: column; } }
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": { target: "http://localhost:8000", changeOrigin: true },
|
"/api": { target: process.env.VITE_BACKEND_URL || "http://localhost:8000", changeOrigin: true },
|
||||||
"/ws": { target: "ws://localhost:8000", ws: true },
|
"/ws": { target: process.env.VITE_WS_BACKEND_URL || "ws://localhost:8000", ws: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resolve: { alias: { "@": "/src" } },
|
resolve: { alias: { "@": "/src" } },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue