Implement local task 09 training workflows

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

106
backend/uv.lock generated
View file

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