Implement local task 09 training workflows
This commit is contained in:
parent
e081fa410b
commit
cec84ffcd0
45 changed files with 2679 additions and 366 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 "")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
100
backend/app/scenarios/editor.py
Normal file
100
backend/app/scenarios/editor.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Локальное, детерминированное редактирование учебных сценариев.
|
||||
|
||||
Это копирование утверждённого шаблона, а не генерация нового сюжета ИИ.
|
||||
Никаких сетевых вызовов в этом модуле нет.
|
||||
"""
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.scenarios.loader import ScenarioError, _derive_ground_truth
|
||||
from app.scenarios.schema import Scenario
|
||||
|
||||
DERIVED_TRUTH = {"incident_type", "dds", "incident_code", "notify", "required_facts"}
|
||||
|
||||
|
||||
def template_copy(source: Scenario, draft_id: str, title: str | None = None) -> dict:
|
||||
"""Сделать полностью самостоятельный шаблонный черновик из сценария."""
|
||||
body = source.model_dump(mode="json")
|
||||
body["id"] = draft_id
|
||||
body["title"] = title or f"{source.title} — копия"
|
||||
body["extends"] = None # чек-лист уже развёрнут загрузчиком
|
||||
body["ticket"] = None
|
||||
body["position"] = None
|
||||
for key in DERIVED_TRUTH:
|
||||
body["ground_truth"].pop(key, None)
|
||||
return body
|
||||
|
||||
|
||||
def merge_patch(body: dict, patch: dict) -> dict:
|
||||
"""JSON Merge Patch: словари сливаются, списки заменяются целиком.
|
||||
|
||||
`id` — идентичность записи, а вычисляемые поля эталона не принимаются от
|
||||
клиента. Некорректные обычные поля можно временно сохранить в черновике;
|
||||
утверждение всё равно потребует полной проверки.
|
||||
"""
|
||||
if "id" in patch and patch["id"] != body["id"]:
|
||||
raise ScenarioError("id сценария менять нельзя")
|
||||
truth = patch.get("ground_truth")
|
||||
if isinstance(truth, dict) and DERIVED_TRUTH & truth.keys():
|
||||
raise ScenarioError("вычисляемые поля ground_truth нельзя задавать вручную")
|
||||
|
||||
result = deepcopy(body)
|
||||
|
||||
def apply(target: dict, changes: dict) -> None:
|
||||
for key, value in changes.items():
|
||||
if value is None:
|
||||
target.pop(key, None)
|
||||
elif isinstance(value, dict) and isinstance(target.get(key), dict):
|
||||
apply(target[key], value)
|
||||
else:
|
||||
target[key] = deepcopy(value)
|
||||
|
||||
apply(result, patch)
|
||||
result["id"] = body["id"]
|
||||
return result
|
||||
|
||||
|
||||
def validate(body: dict) -> Scenario:
|
||||
"""Проверить схему и связи фактов, затем пересчитать эталон по ЕКП."""
|
||||
declared = body.get("ground_truth") or {}
|
||||
if not isinstance(declared, dict):
|
||||
raise ScenarioError("ground_truth: ожидается объект")
|
||||
forbidden = DERIVED_TRUTH & declared.keys()
|
||||
if forbidden:
|
||||
raise ScenarioError(
|
||||
f"{', '.join(sorted(forbidden))} в ground_truth выводится кодом и руками не пишется"
|
||||
)
|
||||
try:
|
||||
scenario = Scenario.model_validate(body)
|
||||
except ValidationError as exc:
|
||||
first = exc.errors()[0]
|
||||
where = ".".join(str(part) for part in first["loc"])
|
||||
raise ScenarioError(f"{where}: {first['msg']}") from exc
|
||||
|
||||
if not scenario.title.strip() or len(scenario.title) > 200:
|
||||
raise ScenarioError("title: требуется название длиной от 1 до 200 символов")
|
||||
if not scenario.first_line.strip():
|
||||
raise ScenarioError("first_line: требуется первая реплика")
|
||||
if not scenario.facts or not scenario.checklist:
|
||||
raise ScenarioError("сценарию нужны факты и чек-лист для оценки занятия")
|
||||
|
||||
known = scenario.fact_ids()
|
||||
question_ids = {item.id for item in scenario.checklist}
|
||||
if len(known) != len(scenario.facts):
|
||||
raise ScenarioError("id фактов должны быть уникальны")
|
||||
if len(question_ids) != len(scenario.checklist):
|
||||
raise ScenarioError("id пунктов чек-листа должны быть уникальны")
|
||||
for item in scenario.checklist:
|
||||
if not item.question:
|
||||
raise ScenarioError(f"у пункта {item.id} нет текста вопроса")
|
||||
if item.fact and item.fact not in known:
|
||||
raise ScenarioError(f"пункт {item.id} ссылается на факт {item.fact}, которого нет")
|
||||
for fact in scenario.facts:
|
||||
question_id = fact.reveal_on.question if fact.reveal_on else None
|
||||
if question_id and question_id not in question_ids:
|
||||
raise ScenarioError(f"факт {fact.id} раскрывается несуществующим вопросом {question_id}")
|
||||
if fact.refine_on and fact.refine_on not in question_ids:
|
||||
raise ScenarioError(f"факт {fact.id} уточняется несуществующим вопросом {fact.refine_on}")
|
||||
return _derive_ground_truth(scenario)
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from 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
|
||||
|
|
|
|||
70
backend/app/scoring/card.py
Normal file
70
backend/app/scoring/card.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Детерминированная оценка текстового упражнения оператора 112.
|
||||
|
||||
Нет входящего звонка и опроса: оцениваются только поля готовой КИО и
|
||||
маршрутизация. Эталон берётся из утверждённого сценария, но не показывается
|
||||
в `card.briefing` до завершения упражнения.
|
||||
"""
|
||||
|
||||
from app.domain.events import Metric
|
||||
from app.domain.kio import KIO, missing_fields
|
||||
from app.domain.taxonomy import Finding, FindingSource
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scoring.gost import GostResult, _normalize_address
|
||||
from app.scoring.taxonomy import METRIC_MAP, METRIC_WEIGHTS
|
||||
|
||||
|
||||
def evaluate_card(scenario: Scenario, kio: KIO) -> GostResult:
|
||||
result = GostResult()
|
||||
truth = scenario.ground_truth
|
||||
|
||||
def add(key: str, title: str, fact: str, norm: str, passed: bool, ref: str) -> None:
|
||||
result.metrics.append(Metric(
|
||||
key=key, title=title, fact=fact, norm=norm, passed=passed,
|
||||
ref=ref, weight=METRIC_WEIGHTS.get(key, 1.0),
|
||||
))
|
||||
if not passed:
|
||||
code, competency = METRIC_MAP[key]
|
||||
result.findings.append(Finding(
|
||||
code=code,
|
||||
source=FindingSource.KIO if code.value == "E5" else FindingSource.GROUND_TRUTH,
|
||||
summary=f"{title}: {fact}; требуется {norm}",
|
||||
fact=fact, norm=norm, ref=ref, competency=competency,
|
||||
))
|
||||
|
||||
if truth.incident_code:
|
||||
actual = kio.incident_code or "не определён"
|
||||
add("incident_signs", "Признаки происшествия", actual,
|
||||
truth.incident_code, actual == truth.incident_code, "классификатор ЕКП")
|
||||
elif truth.incident_type:
|
||||
actual = kio.incident_type.value if kio.incident_type else "не указан"
|
||||
add("incident_type", "Тип происшествия", actual,
|
||||
truth.incident_type.value, kio.incident_type == truth.incident_type,
|
||||
"классификатор происшествий")
|
||||
|
||||
if truth.dds and truth.incident_code is None:
|
||||
actual = kio.dds.value if kio.dds else "не выбрана"
|
||||
add("dds_choice", "ДДС", actual, truth.dds.value,
|
||||
kio.dds == truth.dds, "список оповещения по сценарию")
|
||||
|
||||
if truth.address:
|
||||
written = kio.address or " ".join(filter(None, (kio.street, kio.building)))
|
||||
add("address", "Адрес происшествия", written or "не заполнен", truth.address,
|
||||
bool(_normalize_address(truth.address))
|
||||
and _normalize_address(truth.address) <= _normalize_address(written),
|
||||
"эталон сценария")
|
||||
|
||||
if truth.victims is not None:
|
||||
actual = str(kio.victims_count) if kio.victims_count is not None else "не указано"
|
||||
add("victims_count", "Число пострадавших", actual, str(truth.victims),
|
||||
kio.victims_count == truth.victims, "эталон сценария")
|
||||
|
||||
required_fields = ([field for field in scenario.required_fields if field != "dds"]
|
||||
if truth.incident_code else scenario.required_fields)
|
||||
if required_fields:
|
||||
missing = missing_fields(kio, required_fields)
|
||||
add("required_fields", "Обязательные поля карточки",
|
||||
"все заполнены" if not missing else "пусто: " + ", ".join(missing),
|
||||
"все обязательные поля заполнены", not missing,
|
||||
"поля КИО утверждённого сценария")
|
||||
|
||||
return result
|
||||
|
|
@ -18,6 +18,8 @@ from app.domain.statuses import (
|
|||
current,
|
||||
)
|
||||
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
|
||||
|
|
|
|||
228
backend/app/scoring/export.py
Normal file
228
backend/app/scoring/export.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
"""Экспорт разбора занятия без обращения к внешним сервисам.
|
||||
|
||||
CSV - плоский журнал с пометкой раздела. PDF - читаемый протокол с переносом
|
||||
длинных реплик и автоматическим разбиением на страницы.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
|
||||
from app.domain.events import SessionReport
|
||||
|
||||
|
||||
def _cell(value: object) -> str:
|
||||
"""Excel/LibreOffice не должны исполнять текст курсанта как формулу."""
|
||||
if value is None:
|
||||
return ""
|
||||
result = str(value)
|
||||
probe = result.lstrip(" \t\r\n\ufeff\u200b")
|
||||
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
|
||||
return "'" + result
|
||||
return result
|
||||
|
||||
|
||||
def to_csv(report: SessionReport) -> bytes:
|
||||
"""UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки."""
|
||||
output = io.StringIO(newline="")
|
||||
writer = csv.writer(output, lineterminator="\r\n")
|
||||
writer.writerow(["Раздел", "№", "Поле", "Значение", "Дополнительно"])
|
||||
|
||||
def row(section: str, index: object, field: str, value: object, detail: object = "") -> None:
|
||||
writer.writerow([_cell(part) for part in (section, index, field, value, detail)])
|
||||
|
||||
row("Занятие", "", "ID", report.session_id)
|
||||
row("Занятие", "", "Сценарий", report.scenario_id)
|
||||
row("Занятие", "", "Режим", report.mode.value)
|
||||
row("Занятие", "", "Попытка", report.attempt)
|
||||
row("Оценка", "", "Автоматическая", report.score_auto)
|
||||
row("Оценка", "", "Итоговая", report.score_final)
|
||||
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
|
||||
row("Оценка", "", "Причина изменения", report.override_comment)
|
||||
|
||||
for number, item in enumerate(report.metrics, 1):
|
||||
row("Метрики", number, item.title, item.fact, f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; источник: {item.ref or ''}")
|
||||
for number, item in enumerate(report.findings, 1):
|
||||
row("Ошибки", number, item.code.value, item.summary, f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}")
|
||||
for number, item in enumerate(report.competencies, 1):
|
||||
row("Компетенции", number, item.competency, item.value)
|
||||
for number, item in enumerate(report.transcript, 1):
|
||||
row("Транскрипт", number, item.speaker.value, item.text, item.at.isoformat())
|
||||
for number, item in enumerate(report.reference_questions, 1):
|
||||
row("Эталонные вопросы", number, item.checklist_id, item.question)
|
||||
for number, item in enumerate(report.missed_checklist, 1):
|
||||
row("Пропущенные вопросы", number, "Пункт", item)
|
||||
for number, item in enumerate(report.hints_used, 1):
|
||||
row("Подсказки", number, item.checklist_id, item.question, item.at.isoformat())
|
||||
if report.self_assessment:
|
||||
row("Самооценка", "", "Пропущено по мнению курсанта", ", ".join(report.self_assessment.missed))
|
||||
row("Самооценка", "", "Комментарий", report.self_assessment.comment)
|
||||
if report.self_assessment_diff:
|
||||
for label, values in (
|
||||
("Заметил", report.self_assessment_diff.noticed),
|
||||
("Не заметил", report.self_assessment_diff.unnoticed),
|
||||
("Отметил ошибочно", report.self_assessment_diff.overcautious),
|
||||
):
|
||||
row("Сверка самооценки", "", label, ", ".join(values))
|
||||
for number, item in enumerate(report.notes, 1):
|
||||
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
|
||||
return ("\ufeff" + output.getvalue()).encode("utf-8")
|
||||
|
||||
|
||||
def _font_paths() -> tuple[Path, Path | None]:
|
||||
"""Находить TTF с кириллицей явно, без молчаливой подмены Helvetica."""
|
||||
custom = os.environ.get("LCT_PDF_FONT")
|
||||
if custom:
|
||||
path = Path(custom)
|
||||
if not path.is_file():
|
||||
raise RuntimeError(f"LCT_PDF_FONT не найден: {path}")
|
||||
return path, None
|
||||
candidates = (
|
||||
("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
|
||||
("/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf", "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf"),
|
||||
("/System/Library/Fonts/Supplemental/Arial.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf"),
|
||||
)
|
||||
for regular, bold in candidates:
|
||||
if Path(regular).is_file():
|
||||
return Path(regular), Path(bold) if Path(bold).is_file() else None
|
||||
raise RuntimeError("Не найден TTF-шрифт с кириллицей; установите DejaVu Sans или задайте LCT_PDF_FONT")
|
||||
|
||||
|
||||
def to_pdf(report: SessionReport) -> bytes:
|
||||
"""Собрать многостраничный PDF с кириллицей и переносом длинных текстов."""
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_LEFT
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.platypus import HRFlowable, Paragraph, SimpleDocTemplate, Spacer
|
||||
|
||||
regular_path, bold_path = _font_paths()
|
||||
regular_name = "LCTReportRegular"
|
||||
bold_name = "LCTReportBold"
|
||||
if regular_name not in pdfmetrics.getRegisteredFontNames():
|
||||
regular = TTFont(regular_name, str(regular_path))
|
||||
if not all(ord(char) in regular.face.charToGlyph for char in "Отчёт курсанта Ёж"):
|
||||
raise RuntimeError(f"Шрифт не поддерживает кириллицу: {regular_path}")
|
||||
pdfmetrics.registerFont(regular)
|
||||
pdfmetrics.registerFont(TTFont(bold_name, str(bold_path or regular_path)))
|
||||
pdfmetrics.registerFontFamily(regular_name, normal=regular_name, bold=bold_name)
|
||||
|
||||
base = ParagraphStyle(
|
||||
"body", fontName=regular_name, fontSize=9, leading=13,
|
||||
textColor=colors.HexColor("#18202a"), spaceAfter=5,
|
||||
allowWidows=0, allowOrphans=0, splitLongWords=1,
|
||||
)
|
||||
title_style = ParagraphStyle(
|
||||
"title", parent=base, fontName=bold_name, fontSize=17, leading=22,
|
||||
textColor=colors.HexColor("#193b56"), spaceAfter=10,
|
||||
)
|
||||
heading = ParagraphStyle(
|
||||
"heading", parent=base, fontName=bold_name, fontSize=12, leading=17,
|
||||
textColor=colors.HexColor("#193b56"), spaceBefore=13, spaceAfter=5,
|
||||
keepWithNext=True,
|
||||
)
|
||||
muted = ParagraphStyle("muted", parent=base, fontSize=8, textColor=colors.HexColor("#5d6873"))
|
||||
|
||||
def p(value: object, style=base):
|
||||
# XML-экранирование необходимо: реплики и пометки вводит человек.
|
||||
safe = escape("" if value is None else str(value)).replace("\n", "<br/>")
|
||||
return Paragraph(safe or "-", style)
|
||||
|
||||
story = [p("Отчёт по учебному занятию", title_style)]
|
||||
for label, value in (
|
||||
("Идентификатор", report.session_id),
|
||||
("Сценарий", report.scenario_id),
|
||||
("Режим", report.mode.value),
|
||||
("Попытка", report.attempt),
|
||||
("Автоматическая оценка", report.score_auto),
|
||||
("Итоговая оценка", report.score_final),
|
||||
):
|
||||
story.append(Paragraph(f"<b>{escape(label)}:</b> {escape(str(value))}", base))
|
||||
if report.overridden_by:
|
||||
story.append(Paragraph(f"<b>Оценка изменена:</b> {escape(report.overridden_by)}", base))
|
||||
if report.override_comment:
|
||||
story.append(p(f"Причина изменения: {report.override_comment}"))
|
||||
story.extend([Spacer(1, 7), HRFlowable(width="100%", color=colors.HexColor("#aeb8c2"))])
|
||||
|
||||
def section(label: str):
|
||||
story.append(p(label, heading))
|
||||
|
||||
section("Метрики")
|
||||
if not report.metrics:
|
||||
story.append(p("Нет данных", muted))
|
||||
for item in report.metrics:
|
||||
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'}"))
|
||||
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm}. {item.ref or ''}", muted))
|
||||
|
||||
section("Выявленные ошибки")
|
||||
if not report.findings:
|
||||
story.append(p("Ошибок не выявлено", muted))
|
||||
for item in report.findings:
|
||||
story.append(p(f"{item.code.value} - {item.summary}"))
|
||||
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. Источник: {item.ref or 'не указан'}", muted))
|
||||
|
||||
section("Компетенции")
|
||||
for item in report.competencies:
|
||||
story.append(p(f"{item.competency}: {item.value:g}"))
|
||||
if not report.competencies:
|
||||
story.append(p("Нет данных", muted))
|
||||
|
||||
section("Транскрипт")
|
||||
if not report.transcript:
|
||||
story.append(p("Реплик нет", muted))
|
||||
for item in report.transcript:
|
||||
story.append(p(f"{item.at.isoformat()} - {item.speaker.value}", muted))
|
||||
story.append(p(item.text))
|
||||
|
||||
section("Эталонные вопросы")
|
||||
for item in report.reference_questions:
|
||||
story.append(p(f"{item.checklist_id}: {item.question}"))
|
||||
if not report.reference_questions:
|
||||
story.append(p("Нет данных", muted))
|
||||
if report.missed_checklist:
|
||||
section("Пропущенные пункты")
|
||||
for item in report.missed_checklist:
|
||||
story.append(p(item))
|
||||
|
||||
if report.hints_used:
|
||||
section("Использованные подсказки")
|
||||
for item in report.hints_used:
|
||||
story.append(p(f"{item.at.isoformat()} - {item.checklist_id}: {item.question}"))
|
||||
|
||||
if report.self_assessment:
|
||||
section("Самооценка")
|
||||
story.append(p("Пропущено по мнению курсанта: " + ", ".join(report.self_assessment.missed)))
|
||||
story.append(p("Комментарий: " + report.self_assessment.comment))
|
||||
if report.self_assessment_diff:
|
||||
for label, items in (
|
||||
("Заметил", report.self_assessment_diff.noticed),
|
||||
("Не заметил", report.self_assessment_diff.unnoticed),
|
||||
("Отметил ошибочно", report.self_assessment_diff.overcautious),
|
||||
):
|
||||
story.append(p(f"{label}: {', '.join(items) or '-'}"))
|
||||
if report.notes:
|
||||
section("Пометки преподавателя")
|
||||
for item in report.notes:
|
||||
story.append(p(f"{item.transcript_ref} - {item.author}: {item.text}"))
|
||||
|
||||
output = io.BytesIO()
|
||||
document = SimpleDocTemplate(
|
||||
output, pagesize=A4, leftMargin=42, rightMargin=42,
|
||||
topMargin=45, bottomMargin=44,
|
||||
title="Отчёт по учебному занятию", author="Учебный симулятор 112",
|
||||
)
|
||||
|
||||
def footer(canvas, doc):
|
||||
canvas.saveState()
|
||||
canvas.setFont(regular_name, 8)
|
||||
canvas.setFillColor(colors.HexColor("#5d6873"))
|
||||
canvas.drawString(42, 25, "Учебный симулятор 112")
|
||||
canvas.drawRightString(A4[0] - 42, 25, f"Стр. {doc.page}")
|
||||
canvas.restoreState()
|
||||
|
||||
document.build(story, onFirstPage=footer, onLaterPages=footer)
|
||||
return output.getvalue()
|
||||
|
|
@ -16,6 +16,8 @@ from app.domain.events import (
|
|||
SelfAssessment,
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
#: Вес метрики в детерминированной оценке.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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: ...
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ dependencies = [
|
|||
"asyncpg>=0.29",
|
||||
"httpx>=0.27",
|
||||
"pyyaml>=6.0",
|
||||
"reportlab>=4.0",
|
||||
"structlog>=24.1",
|
||||
# Эмбеддинги для слот-автомата: матчинг вопроса к чек-листу. Нужны ядру,
|
||||
# а не только голосу, поэтому не в группе voice.
|
||||
|
|
|
|||
125
backend/tests/test_card_exercise.py
Normal file
125
backend/tests/test_card_exercise.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Текстовая вводная 112: карточка без голоса, опроса и ДДС-оценки."""
|
||||
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with TestClient(app) as test_client:
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.journal = None
|
||||
yield test_client
|
||||
|
||||
|
||||
def wait_for(predicate, timeout=3):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
time.sleep(0.02)
|
||||
raise AssertionError("не дождались обновления состояния")
|
||||
|
||||
|
||||
def read_until(socket, wanted):
|
||||
for _ in range(20):
|
||||
event = socket.receive_json()
|
||||
if event["type"] == wanted:
|
||||
return event
|
||||
raise AssertionError(f"событие {wanted} не пришло")
|
||||
|
||||
|
||||
def start(client):
|
||||
session_id = uuid4()
|
||||
context = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = context.__enter__()
|
||||
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Иванов", "mode": "training", "exercise": "card"})
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
return session_id, context
|
||||
|
||||
|
||||
def test_card_briefing_is_text_only_and_replayed_on_late_join(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
assert state.exercise.value == "card"
|
||||
assert state.dispatched_card is None
|
||||
assert state.slots is None and state.voice is None
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
briefing = read_until(trainee, "card.briefing")
|
||||
assert briefing["scenario_id"] == "fire-apartment-l2"
|
||||
assert "Помогите" in briefing["text"]
|
||||
assert "горит балкон" in briefing["text"]
|
||||
assert "ground_truth" not in briefing
|
||||
assert briefing["card"]["address"] is None
|
||||
assert "address" in briefing["required_fields"]
|
||||
trainee.send_bytes(b"\0" * 640)
|
||||
assert read_until(trainee, "error")["code"] == "unsupported_event"
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as late:
|
||||
assert read_until(late, "card.briefing")["text"] == briefing["text"]
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_correct_card_scores_100_without_call_or_dds_metrics(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
briefing = read_until(trainee, "card.briefing")
|
||||
assert "dds" not in briefing["required_fields"]
|
||||
trainee.send_json({"type": "kio.patch", "fields": {
|
||||
"address": "улица Ленина, 14", "floor": "5", "incident_type": "fire",
|
||||
"victims_count": 2, "description": "горит балкон",
|
||||
"signs": ["жилой дом", "балкон", "открытое пламя"],
|
||||
}})
|
||||
wait_for(lambda: hub.get(session_id).kio.incident_code)
|
||||
trainee.send_json({"type": "card.submit"})
|
||||
assert read_until(trainee, "call.ended")["reason"] == "complete"
|
||||
assert read_until(trainee, "score.ready")["session_id"] == str(session_id)
|
||||
received = read_until(station, "card.received")
|
||||
assert received["card"]["address"] == "улица Ленина, 14"
|
||||
read_until(station, "station.state")
|
||||
state = hub.get(session_id)
|
||||
assert state.dispatched_card is not None
|
||||
assert state.score["score_auto"] == 100.0
|
||||
assert not state.score["findings"]
|
||||
assert {item["key"] for item in state.score["metrics"]} == {
|
||||
"incident_signs", "address", "victims_count", "required_fields"
|
||||
}
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as late:
|
||||
assert read_until(late, "card.briefing")["card"]["address"] == "улица Ленина, 14"
|
||||
assert read_until(late, "call.ended")["reason"] == "complete"
|
||||
assert read_until(late, "score.ready")["session_id"] == str(session_id)
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_incomplete_card_has_only_card_findings(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
read_until(trainee, "card.briefing")
|
||||
trainee.send_json({"type": "card.submit"})
|
||||
read_until(trainee, "score.ready")
|
||||
trainee.send_json({"type": "kio.patch", "fields": {"address": "после сдачи"}})
|
||||
assert read_until(trainee, "error")["code"] == "unsupported_event"
|
||||
state = hub.get(session_id)
|
||||
assert state.kio.address is None
|
||||
assert state.score["score_auto"] < 100
|
||||
assert {item["code"] for item in state.score["findings"]} <= {"E2", "E5"}
|
||||
assert all(item["key"] not in {"answer_time", "interview_time", "dds_ack"}
|
||||
for item in state.score["metrics"])
|
||||
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||
assert report["missed_checklist"] == []
|
||||
assert report["reference_questions"] == []
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
141
backend/tests/test_dds_exercise.py
Normal file
141
backend/tests/test_dds_exercise.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""Готовая карточка → учебный звонок бригаде → числовая оценка ДДС."""
|
||||
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.domain.timers import TimerCode
|
||||
from app.session.hub import hub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with TestClient(app) as test_client:
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.journal = None
|
||||
yield test_client
|
||||
|
||||
|
||||
def wait_for(predicate, timeout=3):
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
time.sleep(0.02)
|
||||
raise AssertionError("состояние не обновилось")
|
||||
|
||||
|
||||
def read_until(socket, wanted):
|
||||
for _ in range(20):
|
||||
event = socket.receive_json()
|
||||
if event["type"] == wanted:
|
||||
return event
|
||||
raise AssertionError(f"событие {wanted} не пришло")
|
||||
|
||||
|
||||
def start(client, exercise="dds"):
|
||||
session_id = uuid4()
|
||||
context = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = context.__enter__()
|
||||
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Иванов", "mode": "training", "exercise": exercise})
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
return session_id, context
|
||||
|
||||
|
||||
def test_dds_starts_with_prepared_card_without_call(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
assert state.exercise.value == "dds"
|
||||
assert state.dispatched_card is not None
|
||||
assert state.kio.address == "улица Ленина, 14"
|
||||
assert state.kio.notify
|
||||
assert state.timers.measured_ms(TimerCode.ANSWER) is None
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
card = read_until(station, "card.received")
|
||||
assert card["from_operator"] == "учебный сценарий"
|
||||
assert card["card"]["address"] == "улица Ленина, 14"
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
assert snapshot["crew_options"]
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
def test_dds_call_reports_gate_status_and_affect_numeric_score(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "card.received")
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
service = snapshot["services"][0]
|
||||
crew = next(option for option in snapshot["crew_options"] if option.startswith(service + " — "))
|
||||
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.status", "service": service, "status": "responding"})
|
||||
assert "доклада" in read_until(station, "error")["message"]
|
||||
station.send_json({"type": "crew.select", "crew": crew})
|
||||
selected = read_until(station, "station.state")["snapshot"]
|
||||
assert selected["crew_selected"] == crew
|
||||
station.send_json({"type": "phone.dial"})
|
||||
report = read_until(station, "phone.report")
|
||||
assert report["phase"] == "dispatched" and report["crew"] == crew
|
||||
assert read_until(station, "station.state")["snapshot"]["phone_reports"]
|
||||
station.send_json({"type": "card.status", "service": service, "status": "responding"})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "station.finish"})
|
||||
read_until(station, "score.ready")
|
||||
score = wait_for(lambda: hub.get(session_id).score)
|
||||
keys = {metric["key"] for metric in score["metrics"]}
|
||||
assert "dds_primary" in keys and "dds_contact" in keys
|
||||
assert "answer_time" not in keys and "interview_time" not in keys
|
||||
assert 0 < score["score_auto"] < 100
|
||||
assert all(not finding["code"].startswith("E") for finding in score["findings"])
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_default_exercise_remains_call(client):
|
||||
session_id = uuid4()
|
||||
with client.websocket_connect(f"/ws/control/{session_id}") as control:
|
||||
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Иванов", "mode": "training"})
|
||||
state = wait_for(lambda: hub.get(session_id))
|
||||
assert state.exercise.value == "call"
|
||||
assert state.dispatched_card is None
|
||||
|
||||
|
||||
def test_complete_dds_workflow_scores_without_call_penalties(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "card.received")
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
for service in snapshot["services"]:
|
||||
crew = next(option for option in snapshot["crew_options"]
|
||||
if option.startswith(service + " — "))
|
||||
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "crew.select", "crew": crew})
|
||||
read_until(station, "station.state")
|
||||
for expected_phase, status in (
|
||||
("dispatched", "responding"), ("arrived", "arrived"),
|
||||
("working", "working"), ("completed", "completed"),
|
||||
):
|
||||
station.send_json({"type": "phone.dial"})
|
||||
assert read_until(station, "phone.report")["phase"] == expected_phase
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.status", "service": service, "status": status})
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
assert snapshot["card"] == "completed"
|
||||
station.send_json({"type": "station.finish"})
|
||||
read_until(station, "score.ready")
|
||||
score = wait_for(lambda: hub.get(session_id).score)
|
||||
assert score["score_auto"] == 100.0
|
||||
assert not score["findings"]
|
||||
assert all(metric["key"].startswith("dds_") for metric in score["metrics"])
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
|
@ -120,3 +120,14 @@ def test_gasified_object_brings_mosgaz():
|
|||
KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True))
|
||||
)
|
||||
assert "МОСГАЗ" in card.notify
|
||||
|
||||
|
||||
def test_removed_modifier_recalculates_notify_without_stale_service():
|
||||
from app.domain.kio import KIO, FireDetails, apply_patch, derive_incident
|
||||
|
||||
card = derive_incident(
|
||||
KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True))
|
||||
)
|
||||
assert "МОСГАЗ" in card.notify
|
||||
updated = apply_patch(card, {"fire.gasified": False})
|
||||
assert "МОСГАЗ" not in updated.notify
|
||||
|
|
|
|||
121
backend/tests/test_report_export.py
Normal file
121
backend/tests/test_report_export.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Экспорт разбора: содержимое, безопасность CSV, кириллица и доступ."""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.auth import Principal
|
||||
from app.api.http import sessions
|
||||
from app.domain.events import SessionReport
|
||||
from app.domain.roles import Role
|
||||
from app.main import app
|
||||
from app.scoring.export import _cell, to_csv, to_pdf
|
||||
|
||||
|
||||
def sample_report(*, long: bool = False) -> SessionReport:
|
||||
at = datetime(2026, 9, 21, 12, 0, tzinfo=timezone.utc)
|
||||
long_text = "Заявитель сообщает о дыме в учебном помещении. " * (240 if long else 1)
|
||||
return SessionReport.model_validate({
|
||||
"session_id": str(uuid4()),
|
||||
"scenario_id": "=1+1",
|
||||
"mode": "training",
|
||||
"attempt": 2,
|
||||
"transcript": [
|
||||
{"ref": f"u{i}", "speaker": "caller", "text": long_text if i == 0 else "<вопрос> \t=cmd", "at": at}
|
||||
for i in range(18 if long else 2)
|
||||
],
|
||||
"findings": [{
|
||||
"code": "E1", "source": "slots", "summary": "+HYPERLINK(\"x\")",
|
||||
"fact": "Адрес не уточнён", "norm": "Уточнить адрес происшествия",
|
||||
"ref": "ГОСТ", "at": at,
|
||||
}],
|
||||
"metrics": [{
|
||||
"key": "address", "title": "Адрес", "fact": "Не назван",
|
||||
"norm": "Адрес должен быть уточнён", "passed": False,
|
||||
}],
|
||||
"competencies": [{"competency": "interview", "value": 65}],
|
||||
"reference_questions": [{"checklist_id": "q_address", "question": "Назовите адрес?"}],
|
||||
"missed_checklist": ["q_address"],
|
||||
"hints_used": [{"checklist_id": "q_address", "question": "Уточните адрес", "at": at}],
|
||||
"self_assessment": {"missed": ["q_address"], "comment": "@SUM(1,2)", "submitted_at": at},
|
||||
"self_assessment_diff": {"noticed": ["q_address"], "unnoticed": [], "overcautious": []},
|
||||
"notes": [{"transcript_ref": "u0", "text": "Внимательнее к адресу", "author": "Преподаватель"}],
|
||||
"score_auto": 70,
|
||||
"score_final": 75,
|
||||
"overridden_by": "Преподаватель",
|
||||
"override_comment": "Ручная корректировка",
|
||||
})
|
||||
|
||||
|
||||
def test_csv_contains_sections_and_blocks_formula_injection():
|
||||
rows = list(csv.reader(io.StringIO(to_csv(sample_report()).decode("utf-8-sig"))))
|
||||
assert rows[0] == ["Раздел", "№", "Поле", "Значение", "Дополнительно"]
|
||||
assert any(row[0] == "Транскрипт" and row[3] == "<вопрос> \t=cmd" for row in rows)
|
||||
assert any(row[0] == "Занятие" and row[3] == "'=1+1" for row in rows)
|
||||
assert any(row[0] == "Ошибки" and row[3] == "'+HYPERLINK(\"x\")" for row in rows)
|
||||
assert any(row[0] == "Самооценка" and row[3] == "'@SUM(1,2)" for row in rows)
|
||||
assert _cell(" =cmd") == "' =cmd"
|
||||
assert _cell("\tОбычный текст") == "'\tОбычный текст"
|
||||
|
||||
|
||||
def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
|
||||
data = to_pdf(sample_report(long=True))
|
||||
assert data.startswith(b"%PDF-")
|
||||
path = tmp_path / "report.pdf"
|
||||
path.write_bytes(data)
|
||||
# Poppler даёт проверку извлекаемого текста, не только сигнатуры файла.
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
if shutil.which("pdftotext") and shutil.which("pdfinfo"):
|
||||
info = subprocess.check_output(["pdfinfo", str(path)], text=True)
|
||||
pages = int(next(line.split(":", 1)[1].strip() for line in info.splitlines() if line.startswith("Pages:")))
|
||||
assert pages >= 2
|
||||
extracted = subprocess.check_output(["pdftotext", str(path), "-"], text=True)
|
||||
assert "Отчёт по учебному занятию" in extracted
|
||||
assert "Адрес должен быть уточнён" in extracted
|
||||
assert "Заявитель сообщает о дыме" in extracted
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
report = sample_report()
|
||||
state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4())
|
||||
monkeypatch.setattr(sessions, "_live", lambda session_id: (state, object()))
|
||||
monkeypatch.setattr(sessions, "build_report", lambda session_id, state, scenario: report)
|
||||
with TestClient(app) as test_client:
|
||||
test_client.post("/api/auth/dev-token")
|
||||
yield test_client, state, report
|
||||
|
||||
|
||||
def test_export_routes_return_downloads_with_json_report_rights(client):
|
||||
browser, state, report = client
|
||||
csv_response = browser.get(f"/api/sessions/{report.session_id}/report.csv")
|
||||
assert csv_response.status_code == 200
|
||||
assert csv_response.headers["content-type"].startswith("text/csv")
|
||||
assert csv_response.content.startswith(b"\xef\xbb\xbf")
|
||||
assert "attachment" in csv_response.headers["content-disposition"]
|
||||
|
||||
pdf_response = browser.get(f"/api/sessions/{report.session_id}/report.pdf")
|
||||
assert pdf_response.status_code == 200
|
||||
assert pdf_response.headers["content-type"] == "application/pdf"
|
||||
assert pdf_response.content.startswith(b"%PDF-")
|
||||
|
||||
state.score = None
|
||||
assert browser.get(f"/api/sessions/{report.session_id}/report.csv").status_code == 409
|
||||
assert browser.get(f"/api/sessions/{report.session_id}/report.pdf").status_code == 409
|
||||
|
||||
|
||||
def test_trainee_cannot_export_another_persons_report(client, monkeypatch):
|
||||
browser, state, report = client
|
||||
monkeypatch.setattr(
|
||||
sessions, "require",
|
||||
lambda request: Principal(login="trainee", full_name="Учебный", role=Role.TRAINEE, trainee_id=uuid4()),
|
||||
)
|
||||
for suffix in ("csv", "pdf"):
|
||||
assert browser.get(f"/api/sessions/{report.session_id}/report.{suffix}").status_code == 403
|
||||
140
backend/tests/test_scenario_editor.py
Normal file
140
backend/tests/test_scenario_editor.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""Преподавательский цикл: шаблон → черновик → проверка → утверждение."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.http import scenarios as scenarios_api
|
||||
from app.db.base import get_session
|
||||
from app.main import app
|
||||
from app.scenarios import store
|
||||
from app.scenarios.editor import merge_patch, template_copy, validate
|
||||
from app.scenarios.loader import ScenarioError, load_file
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""Минимальная транзакционная замена БД для проверки HTTP без Postgres."""
|
||||
|
||||
def __init__(self):
|
||||
self.rows = {}
|
||||
|
||||
def add(self, row):
|
||||
self.rows[row.id] = row
|
||||
|
||||
async def get(self, model, key):
|
||||
return self.rows.get(key)
|
||||
|
||||
async def commit(self):
|
||||
pass
|
||||
|
||||
async def scalars(self, query):
|
||||
return [row for row in self.rows.values() if row.status == "published"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
db = FakeSession()
|
||||
|
||||
async def no_audit(*args, **kwargs):
|
||||
pass
|
||||
|
||||
async def no_restore(*args, **kwargs):
|
||||
return 0
|
||||
|
||||
app.dependency_overrides[get_session] = lambda: db
|
||||
monkeypatch.setattr(scenarios_api, "audit", no_audit)
|
||||
monkeypatch.setattr(store, "restore_published", no_restore)
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_template_copy_is_local_independent_and_valid():
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
body = template_copy(source, "draft-example")
|
||||
assert body["id"] == "draft-example"
|
||||
assert body["extends"] is None
|
||||
assert body["ticket"] is None
|
||||
assert body["ground_truth"].keys() == {"address", "victims"}
|
||||
assert validate(body).ground_truth.dds == source.ground_truth.dds
|
||||
assert source.id != body["id"] # оригинал не меняется
|
||||
|
||||
|
||||
def test_editor_rejects_derived_truth_and_missing_fact():
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
body = template_copy(source, "draft-example")
|
||||
with pytest.raises(ScenarioError, match="ground_truth"):
|
||||
merge_patch(body, {"ground_truth": {"dds": "02"}})
|
||||
with pytest.raises(ScenarioError, match="id сценария"):
|
||||
merge_patch(body, {"id": "other"})
|
||||
broken = merge_patch(body, {"facts": []})
|
||||
with pytest.raises(ScenarioError, match="нужны факты"):
|
||||
validate(broken)
|
||||
|
||||
|
||||
def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
|
||||
source = next(s for s in store.all_scenarios() if s.id == "fire-apartment-l2")
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
response = client.post(
|
||||
"/api/scenarios/drafts/from-template",
|
||||
json={"source_id": source.id, "title": "Учебная копия"},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
draft = response.json()
|
||||
draft_id = draft["id"]
|
||||
assert draft["generation"] == "template_copy"
|
||||
assert draft["status"] == "draft"
|
||||
assert store.get(draft_id) is None
|
||||
assert client.get(f"/api/scenarios/{draft_id}").status_code == 404
|
||||
|
||||
changed = client.patch(
|
||||
f"/api/scenarios/drafts/{draft_id}",
|
||||
json={"first_line": "Соседи! В доме дым!"},
|
||||
)
|
||||
assert changed.status_code == 200, changed.text
|
||||
assert changed.json()["body"]["first_line"] == "Соседи! В доме дым!"
|
||||
check = client.post(f"/api/scenarios/drafts/{draft_id}/validate")
|
||||
assert check.status_code == 200 and check.json()["valid"]
|
||||
approved = client.post(f"/api/scenarios/drafts/{draft_id}/approve")
|
||||
assert approved.status_code == 200, approved.text
|
||||
assert approved.json()["status"] == "published"
|
||||
assert store.get(draft_id).first_line == "Соседи! В доме дым!"
|
||||
assert client.get(f"/api/scenarios/{draft_id}").status_code == 200
|
||||
assert client.patch(f"/api/scenarios/drafts/{draft_id}", json={"title": "нет"}).status_code == 404
|
||||
|
||||
|
||||
def test_invalid_draft_cannot_be_approved(client):
|
||||
client.post("/api/auth/dev-token")
|
||||
response = client.post(
|
||||
"/api/scenarios/drafts/from-template",
|
||||
json={"source_id": "fire-apartment-l2"},
|
||||
)
|
||||
draft_id = response.json()["id"]
|
||||
assert client.patch(f"/api/scenarios/drafts/{draft_id}", json={"facts": []}).status_code == 200
|
||||
check = client.post(f"/api/scenarios/drafts/{draft_id}/validate")
|
||||
assert check.json()["valid"] is False
|
||||
assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 422
|
||||
assert store.get(draft_id) is None
|
||||
|
||||
|
||||
def test_unauthenticated_user_cannot_manage_drafts(client):
|
||||
response = client.post(
|
||||
"/api/scenarios/drafts/from-template", json={"source_id": "fire-apartment-l2"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approved_scenario_restores_from_local_database():
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
db = FakeSession()
|
||||
row = await store.create_draft(db, source=source)
|
||||
approved = await store.approve_draft(db, row)
|
||||
store.set_library([source]) # имитация нового процесса после чтения YAML
|
||||
assert store.get(approved.id) is None
|
||||
assert await store.restore_published(db) == 1
|
||||
assert store.get(approved.id).title == approved.title
|
||||
assert await store.restore_published(db) == 0
|
||||
64
backend/tests/test_session_access.py
Normal file
64
backend/tests/test_session_access.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""HTTP-ссылки на занятие не дают курсанту чужую карточку или чек-лист."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.api.auth import Principal
|
||||
from app.api.http import sessions
|
||||
from app.domain.events import Exercise
|
||||
from app.domain.roles import Role
|
||||
|
||||
|
||||
def request() -> Request:
|
||||
return Request({"type": "http", "method": "GET", "path": "/", "headers": []})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trainee_cannot_read_foreign_session(monkeypatch):
|
||||
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
|
||||
monkeypatch.setattr(sessions, "require", lambda _: who)
|
||||
|
||||
async def row(_db, _session_id):
|
||||
return SimpleNamespace(trainee_id=uuid4())
|
||||
|
||||
monkeypatch.setattr(sessions.repo, "get_session", row)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await sessions.read(uuid4(), request(), db=object())
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trainee_cannot_read_foreign_checklist(monkeypatch):
|
||||
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
|
||||
monkeypatch.setattr(sessions, "require", lambda _: who)
|
||||
monkeypatch.setattr(sessions.hub, "get", lambda _: SimpleNamespace(trainee_id=uuid4(), ended=True))
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await sessions.checklist(uuid4(), request())
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trainee_cannot_bypass_self_assessment_via_report(monkeypatch):
|
||||
trainee_id = uuid4()
|
||||
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=trainee_id)
|
||||
monkeypatch.setattr(sessions, "require", lambda _: who)
|
||||
state = SimpleNamespace(
|
||||
trainee_id=trainee_id, exercise=Exercise.CALL, self_assessed=False,
|
||||
score={"score_auto": 100},
|
||||
)
|
||||
monkeypatch.setattr(sessions, "_live", lambda _: (state, object()))
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await sessions.report(uuid4(), request())
|
||||
assert error.value.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trainee_without_profile_cannot_list_everyones_sessions(monkeypatch):
|
||||
who = Principal(login="unlinked", full_name="Курсант", role=Role.TRAINEE, trainee_id=None)
|
||||
monkeypatch.setattr(sessions, "require", lambda _: who)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await sessions.listing(request(), db=object())
|
||||
assert error.value.status_code == 403
|
||||
|
|
@ -117,6 +117,62 @@ def test_interview_timer_starts_on_answer_and_stops_on_dispatch(client):
|
|||
assert state.dispatched_card is not None, "карточка не заморожена снимком"
|
||||
|
||||
|
||||
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:
|
||||
|
|
|
|||
63
backend/tests/test_ws_ownership.py
Normal file
63
backend/tests/test_ws_ownership.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Знание URL занятия не даёт курсанту доступ к чужому АРМ."""
|
||||
|
||||
import importlib
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.auth import Principal
|
||||
from app.domain.roles import Role
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with TestClient(app) as test_client:
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.journal = None
|
||||
yield test_client
|
||||
|
||||
|
||||
def test_trainee_cannot_open_foreign_or_unassigned_call_or_station(client, monkeypatch):
|
||||
session_id = uuid4()
|
||||
with client.websocket_connect(f"/ws/control/{session_id}") as control:
|
||||
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Назначенный", "mode": "training"})
|
||||
deadline = time.monotonic() + 3
|
||||
while hub.get(session_id) is None and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
state = hub.get(session_id)
|
||||
assert state is not None
|
||||
|
||||
owner_id, foreign_id = uuid4(), uuid4()
|
||||
modules = {
|
||||
"call": importlib.import_module("app.api.ws.call"),
|
||||
"station": importlib.import_module("app.api.ws.station"),
|
||||
}
|
||||
for assigned in (owner_id, None):
|
||||
state.trainee_id = assigned
|
||||
for role_name, module in modules.items():
|
||||
who = Principal(login="foreign", full_name="Чужой",
|
||||
role=Role.TRAINEE, trainee_id=foreign_id)
|
||||
monkeypatch.setattr(module, "principal_of", lambda _ws, user=who: user)
|
||||
with client.websocket_connect(f"/ws/{role_name}/{session_id}") as socket:
|
||||
event = socket.receive_json()
|
||||
assert event["type"] == "error" and event["code"] == "forbidden"
|
||||
|
||||
state.trainee_id = owner_id
|
||||
for role_name, module in modules.items():
|
||||
who = Principal(login="owner", full_name="Назначенный",
|
||||
role=Role.TRAINEE, trainee_id=owner_id)
|
||||
monkeypatch.setattr(module, "principal_of", lambda _ws, user=who: user)
|
||||
with client.websocket_connect(f"/ws/{role_name}/{session_id}") as socket:
|
||||
socket.send_json({"type": "unknown"})
|
||||
for _ in range(8):
|
||||
event = socket.receive_json()
|
||||
if event["type"] == "error":
|
||||
assert event["code"] == "unsupported_event"
|
||||
break
|
||||
else:
|
||||
raise AssertionError("владелец занятия не допущен")
|
||||
106
backend/uv.lock
generated
106
backend/uv.lock
generated
|
|
@ -137,6 +137,47 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
|
||||
]
|
||||
|
||||
[[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" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue