Complete DDS training workflow and delivery package

This commit is contained in:
andreysk0304 2026-09-24 01:10:49 +03:00
commit 4c4b91064f
229 changed files with 11969 additions and 1024 deletions

View file

@ -16,6 +16,7 @@
"""
import asyncio
import hashlib
import logging
import secrets
import weakref
@ -24,7 +25,7 @@ from uuid import UUID
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import APIRouter, HTTPException, Request, WebSocket
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy import select
from app.config import get_settings
@ -37,10 +38,12 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
DEMO_TRAINEE_ID = UUID("00000000-0000-4000-8000-000000000112")
_hasher = PasswordHasher()
# Cookie is signed, but carries a role snapshot. A changed account must not
# keep its old privileges for the full 12-hour cookie lifetime. Compose runs
# one worker; the process marker also invalidates all cookies after restart.
_INSTANCE = secrets.token_urlsafe(32)
# Cookie is signed and may safely survive a backend restart. Marker changes
# only together with SESSION_SECRET; account edits are revoked independently
# through the persistent auth_version loaded below.
_INSTANCE = hashlib.sha256(
f"lct-auth-instance:{get_settings().session_secret}".encode()
).hexdigest()
_generations: dict[str, int] = {}
_active_sockets: dict[str, weakref.WeakKeyDictionary] = {}
@ -53,9 +56,21 @@ async def _close_revoked(ws: WebSocket) -> None:
pass
def invalidate_login(login: str) -> None:
def prime_generations(values: dict[str, int]) -> None:
"""Загрузить версии полномочий из БД при старте нового процесса."""
_generations.clear()
_generations.update(values)
async def load_generations() -> None:
async with get_sessionmaker()() as db:
rows = (await db.execute(select(User.login, User.auth_version))).all()
prime_generations({login: version for login, version in rows})
def invalidate_login(login: str, version: int | None = None) -> None:
"""Revoke previously issued cookies after account/role/password changes."""
_generations[login] = _generations.get(login, 0) + 1
_generations[login] = version if version is not None else _generations.get(login, 0) + 1
for ws, loop in list(_active_sockets.get(login, {}).items()):
try:
if not loop.is_closed():
@ -117,8 +132,8 @@ class Principal(BaseModel):
class LoginIn(BaseModel):
login: str
password: str
login: str = Field(min_length=3, max_length=80)
password: str = Field(min_length=1, max_length=1024)
def _demo_local(request: Request) -> None:
@ -199,10 +214,17 @@ async def login(payload: LoginIn, request: Request) -> dict:
# Одинаковый ответ на неизвестный логин и неверный пароль: иначе форма
# входа превращается в список действующих учётных записей.
if user is None or not verify_password(user.password_hash, payload.password):
# Не записываем пароль, IP либо факт существования учётной записи.
# Логин нужен администратору для расследования перебора; ограничиваем
# длину до размера поля AuditLog.actor.
await audit(payload.login[:80], "unknown", "login.failed")
raise HTTPException(status_code=401, detail="bad_credentials")
if user.blocked:
await audit(user.login, user.role, "login.blocked")
raise HTTPException(status_code=403, detail="blocked")
_generations[user.login] = user.auth_version
who = Principal(
login=user.login,
full_name=user.full_name,

View file

@ -11,10 +11,13 @@
import logging
import re
from datetime import datetime
from datetime import datetime, timedelta, timezone
from uuid import UUID
from xml.etree import ElementTree as ET
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
@ -27,13 +30,68 @@ from app.config import get_settings
from app.db.base import get_session
from app.db.models import AuditLog, Session as SessionRow, Trainee, User
from app.domain import ekp
from app.domain.roles import ROLE_LABELS, Role
from app.domain.roles import ROLE_LABELS, SCREENS, Role
from app.domain.timers import NORMATIVES
from app.dialog.llm import is_loopback_url
from app.monitoring import recent_events, sample_metrics
from app.session.hub import hub
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin", tags=["admin"])
def _configuration_xml() -> bytes:
"""Безопасный переносимый снимок конфигурации без паролей и ключей."""
settings = get_settings()
root = ET.Element("lctConfiguration", {"version": "1"})
ET.SubElement(root, "platform", {
"offline": str(settings.offline).lower(),
"voiceEnabled": str(settings.voice_enabled).lower(),
"secureCookies": str(settings.secure_cookies).lower(),
})
models = ET.SubElement(root, "localModels")
ET.SubElement(models, "dialogue", {"name": settings.llm_model_caller})
ET.SubElement(models, "russianControl", {
"name": settings.llm_model_control,
"grammarEnabled": str(settings.grammar_llm_enabled).lower(),
})
ET.SubElement(models, "speechToText", {
"name": settings.stt_model,
"enabled": str(settings.voice_enabled).lower(),
})
workstations = ET.SubElement(root, "workstations")
for role, screens in SCREENS.items():
workstation = ET.SubElement(workstations, "workstation", {
"role": role.value, "label": ROLE_LABELS[role],
})
for path in screens:
ET.SubElement(workstation, "screen", {"path": path})
timers = ET.SubElement(root, "timerLimits")
for code, normative in NORMATIVES.items():
ET.SubElement(timers, "timer", {
"code": code.value,
"milliseconds": str(settings.limit_ms(code)),
"defaultMilliseconds": str(normative.limit_ms),
})
reference = ekp.reference()
ET.SubElement(root, "ekp", {
"version": reference.version,
"incidents": str(len(reference.incidents)),
})
ET.indent(root, space=" ")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
@router.get("/config.xml")
async def configuration_xml(request: Request) -> Response:
require(request, Role.ADMIN)
return Response(
content=_configuration_xml(),
media_type="application/xml",
headers={"Content-Disposition": 'attachment; filename="lct-workstations.xml"'},
)
class UserOut(BaseModel):
id: UUID
login: str
@ -47,7 +105,7 @@ class UserOut(BaseModel):
class UserCreate(BaseModel):
login: str = Field(min_length=3, max_length=80)
full_name: str = Field(min_length=1, max_length=120)
password: str = Field(min_length=8, description="Короткий пароль не заводится")
password: str = Field(min_length=8, max_length=1024, description="Пароль должен быть от 8 до 1024 символов")
role: Role
service: str | None = None
@ -59,7 +117,7 @@ class UserPatch(BaseModel):
role: Role | None = None
service: str | None = None
blocked: bool | None = None
password: str | None = Field(default=None, min_length=8)
password: str | None = Field(default=None, min_length=8, max_length=1024)
def _out(user: User) -> UserOut:
@ -148,8 +206,9 @@ async def patch_user(
if not changed:
return _out(user)
user.auth_version += 1
await db.commit()
invalidate_login(user.login)
invalidate_login(user.login, user.auth_version)
await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed))
return _out(user)
@ -169,12 +228,18 @@ async def audit_log(
action: str | None = None,
actor: str | None = None,
limit: int = 200,
offset: int = 0,
db: AsyncSession = Depends(get_session),
) -> list[AuditOut]:
"""Журнал действий. Администратор его читает, но не правит: точки удаления
или изменения записи здесь нет — ТЗ требует хранения, а не управления."""
require(request, Role.ADMIN)
query = select(AuditLog).order_by(AuditLog.at.desc()).limit(max(1, min(limit, 1000)))
query = (
select(AuditLog)
.order_by(AuditLog.at.desc(), AuditLog.id.desc())
.limit(max(1, min(limit, 1001)))
.offset(max(0, min(offset, 10_000_000)))
)
if action:
query = query.where(AuditLog.action == action)
if actor:
@ -195,6 +260,104 @@ class ServiceState(BaseModel):
detail: str
class RuntimeMetrics(BaseModel):
at: datetime
uptime_seconds: float
cpu_percent: float
load_1m_percent: float | None
cpu_cores: int
rss_bytes: int | None
memory_total_bytes: int | None
memory_available_bytes: int | None
disk_total_bytes: int
disk_free_bytes: int
threads: int
active_sessions: int
restored_sessions: int
completed_sessions_24h: int
class DiagnosticEvent(BaseModel):
at: datetime
level: str
source: str
message: str
class FailureEvent(BaseModel):
at: datetime
actor: str
action: str
object_id: str | None
detail: str
class DiagnosticReport(BaseModel):
generated_at: datetime
metrics: RuntimeMetrics
recent_system_events: list[DiagnosticEvent]
failed_actions_24h: list[FailureEvent]
async def _runtime_metrics(db: AsyncSession) -> RuntimeMetrics:
from app.main import app
raw = sample_metrics()
since = datetime.now(timezone.utc) - timedelta(hours=24)
completed = await db.scalar(
select(func.count()).select_from(SessionRow).where(SessionRow.ended_at >= since)
)
return RuntimeMetrics(
**raw,
active_sessions=sum(not item.ended for item in hub._sessions.values()), # noqa: SLF001
restored_sessions=getattr(app.state, "sessions_restored", 0),
completed_sessions_24h=completed or 0,
)
async def _diagnostic_report(db: AsyncSession) -> DiagnosticReport:
since = datetime.now(timezone.utc) - timedelta(hours=24)
rows = await db.scalars(
select(AuditLog)
.where(AuditLog.at >= since, AuditLog.action.like("%.failed"))
.order_by(AuditLog.at.desc())
.limit(200)
)
return DiagnosticReport(
generated_at=datetime.now(timezone.utc),
metrics=await _runtime_metrics(db),
recent_system_events=[DiagnosticEvent(**item) for item in recent_events(limit=100)],
failed_actions_24h=[
FailureEvent(
at=row.at, actor=row.actor, action=row.action,
object_id=row.object_id, detail=row.detail,
)
for row in rows
],
)
@router.get("/diagnostics", response_model=DiagnosticReport)
async def diagnostics(
request: Request, db: AsyncSession = Depends(get_session)
) -> DiagnosticReport:
"""Live load plus a bounded, redacted incident report for the admin."""
require(request, Role.ADMIN)
return await _diagnostic_report(db)
@router.get("/diagnostics.json")
async def download_diagnostics(
request: Request, db: AsyncSession = Depends(get_session)
) -> JSONResponse:
require(request, Role.ADMIN)
report = await _diagnostic_report(db)
return JSONResponse(
jsonable_encoder(report),
headers={"Content-Disposition": 'attachment; filename="lct-diagnostics.json"'},
)
@router.get("/status", response_model=list[ServiceState])
async def status(request: Request, db: AsyncSession = Depends(get_session)) -> list[ServiceState]:
"""Состояние компонентов стенда — то, что администратор смотрит до занятия,
@ -220,6 +383,21 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
detail="распознавание и синтез готовы" if models_ready else "не загружены: занятие пойдёт без голоса",
)
)
metrics = sample_metrics()
disk_free = metrics["disk_free_bytes"]
disk_total = max(metrics["disk_total_bytes"], 1)
disk_ok = disk_free >= 1024 ** 3 and disk_free / disk_total >= 0.05
load = metrics["load_1m_percent"]
states.append(ServiceState(
name="Нагрузка backend",
ok=disk_ok and (load is None or load < 100),
detail=(
f"CPU процесса {metrics['cpu_percent']:.1f}%; "
+ (f"нагрузка хоста {load:.1f}%; " if load is not None else "")
+ f"RAM процесса {(metrics['rss_bytes'] or 0) / 1024 ** 2:.0f} МБ; "
+ f"свободно на диске {disk_free / 1024 ** 3:.1f} ГБ"
),
))
states.append(
ServiceState(
name="Эмбеддинги",
@ -228,11 +406,20 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
else "нет модели: подсказки идут по порядку чек-листа",
)
)
llm_configured = (
is_loopback_url(
settings.llm_base_url,
allow_docker_host=settings.allow_docker_host_models,
)
if settings.offline or settings.llm_provider == "local"
else bool(settings.llm_api_key and settings.llm_base_url)
)
states.append(
ServiceState(
name="Провайдер LLM",
ok=bool(settings.llm_api_key and settings.llm_base_url),
detail=settings.llm_base_url or "не настроен: звонящий читает офлайн-таблицу",
ok=llm_configured,
detail=(f"локальный адрес разрешён: {settings.llm_base_url}"
if llm_configured else "не настроен: звонящий читает офлайн-таблицу"),
)
)
@ -248,9 +435,39 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
ServiceState(
name="Живых занятий",
ok=True,
detail=str(len(hub._sessions)), # noqa: SLF001 — реестр в памяти процесса
detail=(
f"активно {sum(not item.ended for item in hub._sessions.values())}; "
f"восстановлено после запуска {getattr(app.state, 'sessions_restored', 0)}"
), # noqa: SLF001 — реестр в памяти процесса
)
)
try:
copies = backup_service.listing()
except backup_service.BackupError as exc:
states.append(ServiceState(
name="Резервное копирование",
ok=False,
detail=f"ошибка чтения копий: {exc}",
))
copies = []
if copies:
latest = copies[0]
age_seconds = max(0.0, (datetime.now(timezone.utc) - latest["at"]).total_seconds())
allowed_age = max(60, settings.backup_interval_seconds) + max(
60, settings.backup_retry_seconds
)
states.append(ServiceState(
name="Резервное копирование",
ok=age_seconds <= allowed_age,
detail=(f"последняя копия {latest['name']}, "
f"{age_seconds / 3600:.1f} ч назад; хранится {len(copies)}"),
))
else:
states.append(ServiceState(
name="Резервное копирование",
ok=False,
detail="успешных копий ещё нет",
))
# Секрет сессии по умолчанию — не ошибка запуска, но на стенде это дыра,
# и увидеть её должен администратор, а не проверяющий.
default_secret = settings.session_secret.startswith("dev-secret")
@ -273,7 +490,12 @@ class BackupOut(BaseModel):
@router.get("/backups", response_model=list[BackupOut])
async def backups(request: Request) -> list[BackupOut]:
require(request, Role.ADMIN)
return [BackupOut(**item) for item in await run_in_threadpool(backup_service.listing)]
try:
copies = await run_in_threadpool(backup_service.listing)
except backup_service.BackupError as exc:
detail = _safe_backup_error(exc)
raise HTTPException(status_code=503, detail=detail) from exc
return [BackupOut(**item) for item in copies]
def _safe_backup_error(exc: backup_service.BackupError) -> str:

View file

@ -10,9 +10,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
from app.db.base import get_session
from app.db.models import Group, Score, Session, Trainee
from app.db.models import Group, Score, Session, Trainee, User
from app.domain.roles import Role
from app.scoring.group import ScoredAttempt, summarize
from app.scoring.group_insight import InsightInvalid, generate_group_insight
from app.dialog.llm import LlmUnavailable
router = APIRouter(prefix="/api/groups", tags=["groups"])
@ -26,6 +28,10 @@ class GroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
class GroupOwnerPatch(BaseModel):
owner_login: str | None
class GroupErrorOut(BaseModel):
code: str
title: str
@ -44,62 +50,23 @@ class GroupAnalyticsOut(BaseModel):
errors: list[GroupErrorOut]
@router.get("", response_model=list[GroupOut])
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[GroupOut]:
require(request, Role.INSTRUCTOR, Role.ADMIN)
groups = await db.scalars(select(Group).order_by(Group.name))
return [GroupOut(id=group.id, name=group.name) for group in groups]
class GroupInsightOut(BaseModel):
summary: str
priorities: list[str]
source: str = "local_qwen"
personal_data_sent: bool = False
@router.post("", response_model=GroupOut, status_code=201)
async def create(
body: GroupCreate, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
name = body.name.strip()
if not name:
raise HTTPException(status_code=422, detail="group_name_required")
group = Group(name=name)
db.add(group)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="group_exists") from exc
await audit(who.login, who.role.value, "group.create", str(group.id), group.name)
return GroupOut(id=group.id, name=group.name)
@router.put("/{group_id}/trainees/{trainee_id}", response_model=GroupOut)
async def assign_trainee(
group_id: UUID, trainee_id: UUID, request: Request,
db: AsyncSession = Depends(get_session),
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None:
raise HTTPException(status_code=404, detail="group_not_found")
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
trainee.group_id = group_id
await db.commit()
await audit(who.login, who.role.value, "group.assign", str(group.id), str(trainee_id))
return GroupOut(id=group.id, name=group.name)
@router.get("/{group_id}/analytics", response_model=GroupAnalyticsOut)
async def analytics(
group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
async def _analytics(
group_id: UUID, db: AsyncSession, *, owner_login: str | None = None
) -> GroupAnalyticsOut:
require(request, Role.INSTRUCTOR, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None:
if group is None or (owner_login is not None and group.owner_login != owner_login):
raise HTTPException(status_code=404, detail="group_not_found")
enrolled = await db.scalar(
select(func.count()).select_from(Trainee).where(Trainee.group_id == group_id)
)
rows = await db.execute(
attempts_query = (
select(Session.trainee_id, Score.score_final, Score.report)
.join(Score, Score.session_id == Session.id)
.join(Trainee, Trainee.id == Session.trainee_id, isouter=True)
@ -111,6 +78,9 @@ async def analytics(
Session.ended_at.is_not(None),
)
)
if owner_login is not None:
attempts_query = attempts_query.where(Session.owner_login == owner_login)
rows = await db.execute(attempts_query)
attempts = [
ScoredAttempt(
trainee_id=trainee_id,
@ -123,3 +93,116 @@ async def analytics(
group=GroupOut(id=group.id, name=group.name),
**summarize(attempts, int(enrolled or 0)),
)
@router.get("", response_model=list[GroupOut])
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[GroupOut]:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
query = select(Group).order_by(Group.name)
if who.role is Role.INSTRUCTOR:
query = query.where(Group.owner_login == who.login)
groups = await db.scalars(query)
return [GroupOut(id=group.id, name=group.name) for group in groups]
@router.post("", response_model=GroupOut, status_code=201)
async def create(
body: GroupCreate, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
name = body.name.strip()
if not name:
raise HTTPException(status_code=422, detail="group_name_required")
group = Group(name=name, owner_login=who.login if who.role is Role.INSTRUCTOR else None)
db.add(group)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="group_exists") from exc
await audit(who.login, who.role.value, "group.create", str(group.id), group.name)
return GroupOut(id=group.id, name=group.name)
@router.patch("/{group_id}/owner", response_model=GroupOut)
async def transfer_ownership(
group_id: UUID,
body: GroupOwnerPatch,
request: Request,
db: AsyncSession = Depends(get_session),
) -> GroupOut:
"""Администратор безопасно закрепляет legacy-группу за преподавателем."""
who = require(request, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None:
raise HTTPException(status_code=404, detail="group_not_found")
if body.owner_login is not None:
user = await db.scalar(select(User).where(User.login == body.owner_login))
if user is None or user.role != Role.INSTRUCTOR.value or user.blocked:
raise HTTPException(status_code=422, detail="active_instructor_required")
previous_owner = group.owner_login
group.owner_login = body.owner_login
await db.commit()
await audit(
who.login,
who.role.value,
"group.transfer",
str(group.id),
f"{previous_owner or 'admin'} -> {body.owner_login or 'admin'}",
)
return GroupOut(id=group.id, name=group.name)
@router.put("/{group_id}/trainees/{trainee_id}", response_model=GroupOut)
async def assign_trainee(
group_id: UUID, trainee_id: UUID, request: Request,
db: AsyncSession = Depends(get_session),
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None or (who.role is Role.INSTRUCTOR and group.owner_login != who.login):
raise HTTPException(status_code=404, detail="group_not_found")
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
if (
who.role is Role.INSTRUCTOR
and trainee.group_id is not None
and trainee.group_id != group_id
):
current_group = await db.get(Group, trainee.group_id)
if current_group is None or current_group.owner_login != who.login:
raise HTTPException(status_code=409, detail="trainee_in_other_instructor_group")
trainee.group_id = group_id
await db.commit()
await audit(who.login, who.role.value, "group.assign", str(group.id), str(trainee_id))
return GroupOut(id=group.id, name=group.name)
@router.get("/{group_id}/analytics", response_model=GroupAnalyticsOut)
async def analytics(
group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupAnalyticsOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
return await _analytics(
group_id, db, owner_login=who.login if who.role is Role.INSTRUCTOR else None
)
@router.post("/{group_id}/analytics/insight", response_model=GroupInsightOut)
async def ai_insight(
group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupInsightOut:
"""Сформировать по запросу преподавателя локальный обезличенный инсайт."""
who = require(request, Role.INSTRUCTOR)
data = await _analytics(group_id, db, owner_login=who.login)
if data.scored_attempts == 0:
raise HTTPException(status_code=409, detail="no_scored_attempts")
try:
insight = await generate_group_insight(data.model_dump(mode="json"))
except LlmUnavailable as exc:
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
except InsightInvalid as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "group.ai_insight", str(group_id), "aggregated_only")
return GroupInsightOut(**insight)

View file

@ -0,0 +1,549 @@
"""Учебные материалы, локальные вложения и назначения курсантам.
Файлы хранятся в PostgreSQL и отдаются только как attachment: учебный контур
не зависит от внешнего файлового сервиса и не исполняет загруженный HTML.
"""
import base64
import binascii
import hashlib
from collections.abc import AsyncIterator
from datetime import datetime, timezone
from typing import Literal
from urllib.parse import quote
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import DEMO_TRAINEE_ID, audit, require
from app.config import get_settings
from app.db.base import get_session
from app.db.models import Group, LearningMaterial, MaterialAssignment, Trainee
from app.domain.events import Exercise, ScenarioStart, SessionMode
from app.domain.roles import Role
from app.scenarios import store
from app.session.hub import hub
router = APIRouter(prefix="/api/materials", tags=["materials"])
MAX_FILE_BYTES = 5 * 1024 * 1024
LEVELS = {"L1", "L2", "L3"}
_DEMO_GUIDE_ID = UUID("00000000-0000-4000-8000-000000000901")
_demo_materials: dict[UUID, LearningMaterial] = {}
_demo_assignments: dict[tuple[UUID, UUID], dict] = {}
def reset_demo_materials() -> None:
"""Демонстрационный справочник воспроизводим после каждого старта."""
_demo_materials.clear()
_demo_assignments.clear()
now = datetime.now(timezone.utc)
_demo_materials[_DEMO_GUIDE_ID] = LearningMaterial(
id=_DEMO_GUIDE_ID,
title="Памятка диспетчера ДДС",
description="Короткий алгоритм работы с готовой карточкой происшествия.",
level="L1",
kind="text",
body=(
"1. Подтвердите получение карточки.\n"
"2. Проверьте зону ответственности и список оповещения.\n"
"3. Назначьте бригаду и передайте адрес, событие и задачу.\n"
"4. Фиксируйте выезд, прибытие, локализацию и завершение работ."
),
scenario_id="t01-1-fire-container",
file_name=None,
media_type=None,
file_data=None,
file_sha256=None,
active=True,
created_by="system",
created_at=now,
updated_at=now,
)
_demo_assignments[(_DEMO_GUIDE_ID, DEMO_TRAINEE_ID)] = {
"assigned_by": "system", "assigned_at": now, "completed_at": None,
}
async def material_session() -> AsyncIterator[AsyncSession | None]:
if get_settings().demo_no_db:
yield None
else:
async for db in get_session():
yield db
class MaterialCreate(BaseModel):
title: str = Field(min_length=3, max_length=200)
description: str = Field(default="", max_length=2000)
level: Literal["L1", "L2", "L3"] = "L1"
kind: Literal["text", "file"] = "text"
body: str = Field(default="", max_length=30_000)
scenario_id: str | None = Field(default=None, max_length=80)
file_name: str | None = Field(default=None, max_length=240)
media_type: str | None = Field(default=None, max_length=120)
content_base64: str | None = None
@model_validator(mode="after")
def valid_content(self):
if self.kind == "text" and not self.body.strip():
raise ValueError("текст материала пуст")
if self.kind == "file" and (not self.file_name or not self.content_base64):
raise ValueError("для файла нужны имя и содержимое")
return self
class MaterialPatch(BaseModel):
title: str | None = Field(default=None, min_length=3, max_length=200)
description: str | None = Field(default=None, max_length=2000)
level: Literal["L1", "L2", "L3"] | None = None
body: str | None = Field(default=None, max_length=30_000)
scenario_id: str | None = Field(default=None, max_length=80)
active: bool | None = None
class MaterialOut(BaseModel):
id: UUID
title: str
description: str
level: str
kind: str
body: str
scenario_id: str | None
file_name: str | None
media_type: str | None
file_size: int
file_sha256: str | None
active: bool
created_by: str
created_at: datetime
assigned_at: datetime | None = None
completed_at: datetime | None = None
assignment_count: int = 0
def _out(
row: LearningMaterial, *, assignment: MaterialAssignment | dict | None = None,
assignment_count: int = 0,
) -> MaterialOut:
if isinstance(assignment, dict):
assigned_at = assignment.get("assigned_at")
completed_at = assignment.get("completed_at")
else:
assigned_at = assignment.assigned_at if assignment else None
completed_at = assignment.completed_at if assignment else None
return MaterialOut(
id=row.id,
title=row.title,
description=row.description,
level=row.level,
kind=row.kind,
body=row.body,
scenario_id=row.scenario_id,
file_name=row.file_name,
media_type=row.media_type,
file_size=len(row.file_data or b""),
file_sha256=row.file_sha256,
active=row.active,
created_by=row.created_by,
created_at=row.created_at,
assigned_at=assigned_at,
completed_at=completed_at,
assignment_count=assignment_count,
)
def _decode_file(payload: MaterialCreate) -> bytes | None:
if payload.kind != "file":
return None
try:
data = base64.b64decode(payload.content_base64 or "", validate=True)
except (binascii.Error, ValueError) as exc:
raise HTTPException(status_code=422, detail="invalid_file_base64") from exc
if not data:
raise HTTPException(status_code=422, detail="empty_file")
if len(data) > MAX_FILE_BYTES:
raise HTTPException(status_code=413, detail="file_too_large_5mb")
return data
def _validate_scenario(scenario_id: str | None) -> None:
if scenario_id and store.get(scenario_id) is None:
raise HTTPException(status_code=422, detail="scenario_not_found")
def _safe_filename(value: str) -> str:
"""Убрать и POSIX-, и Windows-путь; в БД остаётся только имя файла."""
return value.replace("\\", "/").rsplit("/", 1)[-1] or "resource.bin"
@router.get("", response_model=list[MaterialOut])
async def listing(
request: Request,
include_archived: bool = False,
db: AsyncSession | None = Depends(material_session),
) -> list[MaterialOut]:
who = require(request)
if db is None:
if who.role is Role.TRAINEE:
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
result = []
for material_id, row in _demo_materials.items():
assignment = _demo_assignments.get((material_id, who.trainee_id))
if assignment and row.active:
result.append(_out(row, assignment=assignment))
return result
return [
_out(row, assignment_count=sum(mid == row.id for mid, _ in _demo_assignments))
for row in _demo_materials.values()
if (include_archived or row.active)
and (who.role is not Role.INSTRUCTOR or row.created_by in {who.login, "system"})
]
if who.role is Role.TRAINEE:
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
rows = await db.execute(
select(LearningMaterial, MaterialAssignment)
.join(MaterialAssignment, MaterialAssignment.material_id == LearningMaterial.id)
.where(
MaterialAssignment.trainee_id == who.trainee_id,
LearningMaterial.active.is_(True),
)
.order_by(MaterialAssignment.assigned_at.desc())
)
return [_out(row, assignment=assignment) for row, assignment in rows]
require(request, Role.INSTRUCTOR, Role.ADMIN)
statement = (
select(LearningMaterial, func.count(MaterialAssignment.id))
.outerjoin(MaterialAssignment, MaterialAssignment.material_id == LearningMaterial.id)
.group_by(LearningMaterial.id)
.order_by(LearningMaterial.active.desc(), LearningMaterial.updated_at.desc())
)
if who.role is Role.INSTRUCTOR:
statement = statement.where(LearningMaterial.created_by == who.login)
if not include_archived:
statement = statement.where(LearningMaterial.active.is_(True))
rows = await db.execute(statement)
return [_out(row, assignment_count=count) for row, count in rows]
@router.post("", response_model=MaterialOut, status_code=201)
async def create(
payload: MaterialCreate,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> MaterialOut:
who = require(request, Role.INSTRUCTOR)
_validate_scenario(payload.scenario_id)
data = _decode_file(payload)
now = datetime.now(timezone.utc)
row = LearningMaterial(
id=uuid4(),
title=payload.title.strip(),
description=payload.description.strip(),
level=payload.level,
kind=payload.kind,
body=payload.body.strip(),
scenario_id=payload.scenario_id,
file_name=_safe_filename(payload.file_name) if payload.file_name else None,
media_type=(payload.media_type or "application/octet-stream") if data else None,
file_data=data,
file_sha256=hashlib.sha256(data).hexdigest() if data else None,
active=True,
created_by=who.login,
created_at=now,
updated_at=now,
)
if db is None:
_demo_materials[row.id] = row
else:
db.add(row)
await db.commit()
await audit(who.login, who.role.value, "material.create", str(row.id), row.title)
return _out(row)
async def _material(db: AsyncSession | None, material_id: UUID) -> LearningMaterial | None:
return _demo_materials.get(material_id) if db is None else await db.get(LearningMaterial, material_id)
def _require_owner(row: LearningMaterial, login: str) -> None:
"""Only the instructor who authored a resource may manage it."""
if row.created_by != login:
raise HTTPException(status_code=404, detail="material_not_found")
@router.patch("/{material_id}", response_model=MaterialOut)
async def update(
material_id: UUID,
payload: MaterialPatch,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> MaterialOut:
who = require(request, Role.INSTRUCTOR)
row = await _material(db, material_id)
if row is None:
raise HTTPException(status_code=404, detail="material_not_found")
_require_owner(row, who.login)
patch = payload.model_dump(exclude_unset=True)
if "scenario_id" in patch:
patch["scenario_id"] = patch["scenario_id"] or None
_validate_scenario(patch["scenario_id"])
if row.kind == "text" and "body" in patch and not (patch["body"] or "").strip():
raise HTTPException(status_code=422, detail="empty_material_body")
for key, value in patch.items():
setattr(row, key, value.strip() if isinstance(value, str) else value)
row.updated_at = datetime.now(timezone.utc)
if db is not None:
await db.commit()
await audit(who.login, who.role.value, "material.update", str(row.id))
return _out(row)
@router.delete("/{material_id}", response_model=MaterialOut)
async def archive(
material_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> MaterialOut:
who = require(request, Role.INSTRUCTOR)
row = await _material(db, material_id)
if row is None:
raise HTTPException(status_code=404, detail="material_not_found")
_require_owner(row, who.login)
row.active = False
row.updated_at = datetime.now(timezone.utc)
if db is not None:
await db.commit()
await audit(who.login, who.role.value, "material.archive", str(row.id))
return _out(row)
@router.put("/{material_id}/assign/{trainee_id}", response_model=MaterialOut)
async def assign(
material_id: UUID,
trainee_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> MaterialOut:
who = require(request, Role.INSTRUCTOR)
row = await _material(db, material_id)
if row is None or not row.active:
raise HTTPException(status_code=404, detail="material_not_found")
_require_owner(row, who.login)
if db is None:
if trainee_id != DEMO_TRAINEE_ID:
raise HTTPException(status_code=404, detail="trainee_not_found")
assignment = _demo_assignments.setdefault(
(material_id, trainee_id),
{"assigned_by": who.login, "assigned_at": datetime.now(timezone.utc), "completed_at": None},
)
else:
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
if trainee.group_id is not None:
group = await db.get(Group, trainee.group_id)
if group is None or group.owner_login != who.login:
raise HTTPException(status_code=404, detail="trainee_not_found")
assignment = await db.scalar(select(MaterialAssignment).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id == trainee_id,
))
if assignment is None:
assignment = MaterialAssignment(
material_id=material_id, trainee_id=trainee_id, assigned_by=who.login
)
db.add(assignment)
await db.commit()
await db.refresh(assignment)
await audit(who.login, who.role.value, "material.assign", str(row.id), str(trainee_id))
return _out(row, assignment=assignment)
@router.put("/{material_id}/assign-group/{group_id}")
async def assign_group(
material_id: UUID,
group_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
row = await _material(db, material_id)
if row is None or not row.active:
raise HTTPException(status_code=404, detail="material_not_found")
_require_owner(row, who.login)
if db is None:
raise HTTPException(status_code=409, detail="groups_unavailable_in_demo")
group = await db.get(Group, group_id)
if group is None or group.owner_login != who.login:
raise HTTPException(status_code=404, detail="group_not_found")
trainee_ids = list(await db.scalars(select(Trainee.id).where(Trainee.group_id == group_id)))
existing = set(await db.scalars(select(MaterialAssignment.trainee_id).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id.in_(trainee_ids),
))) if trainee_ids else set()
for trainee_id in trainee_ids:
if trainee_id not in existing:
db.add(MaterialAssignment(
material_id=material_id, trainee_id=trainee_id, assigned_by=who.login
))
await db.commit()
await audit(who.login, who.role.value, "material.assign_group", str(row.id), str(group_id))
return {"material_id": str(row.id), "assigned": len(trainee_ids)}
@router.delete("/{material_id}/assign/{trainee_id}")
async def unassign(
material_id: UUID,
trainee_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
row = await _material(db, material_id)
if row is None:
raise HTTPException(status_code=404, detail="material_not_found")
_require_owner(row, who.login)
if db is None:
removed = _demo_assignments.pop((material_id, trainee_id), None) is not None
else:
assignment = await db.scalar(select(MaterialAssignment).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id == trainee_id,
))
if assignment is not None and assignment.assigned_by != who.login:
raise HTTPException(status_code=404, detail="assignment_not_found")
if assignment is not None:
await db.delete(assignment)
await db.commit()
removed = assignment is not None
await audit(who.login, who.role.value, "material.unassign", str(material_id), str(trainee_id))
return {"removed": removed}
@router.post("/{material_id}/complete", response_model=MaterialOut)
async def complete(
material_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> MaterialOut:
who = require(request, Role.TRAINEE)
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
row = await _material(db, material_id)
if row is None or not row.active:
raise HTTPException(status_code=404, detail="material_not_found")
if db is None:
assignment = _demo_assignments.get((material_id, who.trainee_id))
else:
assignment = await db.scalar(select(MaterialAssignment).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id == who.trainee_id,
))
if assignment is None:
raise HTTPException(status_code=403, detail="material_not_assigned")
completed_at = datetime.now(timezone.utc)
if isinstance(assignment, dict):
assignment["completed_at"] = completed_at
else:
assignment.completed_at = completed_at
await db.commit()
await audit(who.login, who.role.value, "material.complete", str(material_id))
return _out(row, assignment=assignment)
@router.post("/{material_id}/start")
async def start_assigned_practice(
material_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> dict:
"""Курсант сам выбирает назначенный модуль и запускает текстовую практику."""
who = require(request, Role.TRAINEE)
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
row = await _material(db, material_id)
if row is None or not row.active:
raise HTTPException(status_code=404, detail="material_not_found")
if db is None:
assignment = _demo_assignments.get((material_id, who.trainee_id))
else:
assignment = await db.scalar(select(MaterialAssignment).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id == who.trainee_id,
))
if assignment is None:
raise HTTPException(status_code=403, detail="material_not_assigned")
if not row.scenario_id:
raise HTTPException(status_code=409, detail="material_has_no_practice")
scenario = store.get(row.scenario_id)
if scenario is None:
raise HTTPException(status_code=409, detail="scenario_not_found")
# Явное назначение преподавателя — и есть разрешение на самостоятельный
# модуль. Оно не открывает курсанту остальные сценарии библиотеки.
from app.api.ws.control import _start
session_id = uuid4()
await _start(session_id, ScenarioStart(
scenario_id=scenario.id,
trainee=who.full_name,
trainee_id=who.trainee_id,
mode=SessionMode.SELF,
exercise=Exercise.CARD,
), who)
if hub.get(session_id) is None:
raise HTTPException(status_code=409, detail="practice_start_failed")
return {
"session_id": str(session_id),
"scenario_id": scenario.id,
"mode": SessionMode.SELF.value,
"exercise": Exercise.CARD.value,
"path": f"/trainee?session={session_id}",
}
@router.get("/{material_id}/download")
async def download(
material_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> Response:
who = require(request)
row = await _material(db, material_id)
if row is None or not row.active or row.kind != "file" or row.file_data is None:
raise HTTPException(status_code=404, detail="file_not_found")
if who.role is Role.TRAINEE:
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
if db is None:
allowed = (material_id, who.trainee_id) in _demo_assignments
else:
allowed = await db.scalar(select(MaterialAssignment.id).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id == who.trainee_id,
)) is not None
if not allowed:
raise HTTPException(status_code=403, detail="material_not_assigned")
elif who.role is Role.INSTRUCTOR:
_require_owner(row, who.login)
elif who.role not in {Role.INSTRUCTOR, Role.ADMIN}:
raise HTTPException(status_code=403, detail="forbidden")
filename = _safe_filename(row.file_name or "resource.bin")
disposition = f"attachment; filename=resource; filename*=UTF-8''{quote(filename)}"
return Response(
content=row.file_data,
media_type=row.media_type or "application/octet-stream",
headers={
"Content-Disposition": disposition,
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'",
},
)

View file

@ -10,6 +10,7 @@
`hint.shown` из живой сессии, эталонные вопросы — только в разборе.
"""
from collections.abc import AsyncIterator
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
@ -19,49 +20,126 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
from app.domain import ekp
from app.db.base import get_session
from app.config import get_settings
from app.domain.roles import Role
from app.scenarios import store
from app.scenarios.editor import validate
from app.scenarios.generation import GenerationError, generate, generate_from_description
from app.dialog.llm import LlmUnavailable
from app.scenarios.loader import ScenarioError
from app.session.hub import hub
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"}
async def scenario_session() -> AsyncIterator[AsyncSession | None]:
"""Только редактор в demo-lite использует временное хранилище без БД."""
if get_settings().demo_no_db:
yield None
else:
async for db in get_session():
yield db
class TemplateDraftIn(BaseModel):
source_id: str = Field(min_length=1)
title: str | None = Field(default=None, min_length=1, max_length=200)
class GenerateDraftIn(BaseModel):
source_id: str = Field(min_length=1)
instruction: str = Field(min_length=10, max_length=1000)
class GenerateFullDraftIn(BaseModel):
source_id: str = Field(min_length=1)
description: str = Field(min_length=20, max_length=1500)
class ReviseDraftIn(BaseModel):
comment: str = Field(min_length=10, max_length=1000)
def _draft_out(row) -> dict:
if row.id.startswith("ai-full-"):
generation = "ai_full"
elif row.id.startswith("ai-"):
generation = "ai_variant"
else:
generation = "template_copy"
return {
"id": row.id,
"status": row.status,
"generation": "template_copy",
"generation": generation,
"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)
body: TemplateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_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)
row = await store.create_draft(db, source=source, title=body.title, owner_login=who.login)
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
return _draft_out(row)
@router.post("/drafts/generate", status_code=201)
async def create_ai_draft(
body: GenerateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_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")
try:
proposal = await generate(source, body.instruction.strip(), require_fact_change=False)
row = await store.create_draft(db, source=source, proposal=proposal, owner_login=who.login)
except LlmUnavailable as exc:
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
except GenerationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "scenario.draft.ai_generate", row.id,
f"source:{source.id}")
return _draft_out(row)
@router.post("/drafts/generate-from-description", status_code=201)
async def create_full_ai_draft(
body: GenerateFullDraftIn, request: Request,
db: AsyncSession | None = Depends(scenario_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")
try:
proposal = await generate_from_description(source, body.description.strip())
row = await store.create_draft(
db, source=source, full_proposal=proposal, owner_login=who.login
)
except LlmUnavailable as exc:
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
except GenerationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "scenario.draft.ai_generate_full", row.id,
f"class_source:{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)
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id)
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
return _draft_out(row)
@ -72,10 +150,10 @@ async def patch_draft(
scenario_id: str,
body: dict[str, Any],
request: Request,
db: AsyncSession = Depends(get_session),
db: AsyncSession | None = Depends(scenario_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id)
row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
@ -86,12 +164,36 @@ async def patch_draft(
return _draft_out(row)
@router.post("/drafts/{scenario_id}/revise")
async def revise_ai_draft(
scenario_id: str,
body: ReviseDraftIn,
request: Request,
db: AsyncSession | None = Depends(scenario_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
source = validate(row.body)
proposal = await generate(source, body.comment.strip(), require_fact_change=False)
row = await store.revise_draft(db, row, proposal)
except LlmUnavailable as exc:
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
except (GenerationError, ScenarioError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "scenario.draft.ai_revise", row.id,
body.comment.strip()[:500])
return _draft_out(row)
@router.post("/drafts/{scenario_id}/validate")
async def validate_draft(
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id)
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
@ -107,10 +209,10 @@ async def validate_draft(
@router.post("/drafts/{scenario_id}/approve")
async def approve_draft(
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id)
row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
@ -122,7 +224,15 @@ async def approve_draft(
@router.get("")
async def listing() -> list[dict]:
async def listing(
request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> list[dict]:
who = require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
owned_ids = (
await store.owned_scenario_ids(db, who.login)
if who is not None and who.role is Role.INSTRUCTOR
else set()
)
return [
{
"id": scenario.id,
@ -131,19 +241,57 @@ async def listing() -> list[dict]:
"level": scenario.level.value,
"topics": scenario.topics,
"modes": scenario.modes,
# Преподаватель должен видеть не только название карточки, но и
# зафиксированный путь классификатора. ИИ меняет сюжет внутри
# этого пути, а не незаметно подменяет код происшествия.
"signs": scenario.signs,
"incident_code": scenario.ground_truth.incident_code,
"dds": scenario.ground_truth.dds.value if scenario.ground_truth.dds else None,
"ticket": scenario.ticket,
"position": scenario.position,
"ekp_group": (ekp.incident(scenario.ground_truth.incident_code).group
if scenario.ground_truth.incident_code
and ekp.incident(scenario.ground_truth.incident_code) else None),
"can_manage": scenario.id in owned_ids,
}
for scenario in store.all_scenarios()
]
@router.delete("/{scenario_id}")
async def archive_scenario(
scenario_id: str, request: Request,
db: AsyncSession | None = Depends(scenario_session),
) -> dict:
"""Мягкое удаление: история занятий остаётся целой, сценарий можно вернуть."""
who = require(request, Role.INSTRUCTOR)
if hub.has_active_scenario(scenario_id):
raise HTTPException(status_code=409, detail="scenario_is_used_by_active_session")
scenario = await store.archive(db, scenario_id, owner_login=who.login)
if scenario is None:
raise HTTPException(status_code=404, detail="scenario_not_found")
await audit(who.login, who.role.value, "scenario.archive", scenario_id)
return {"id": scenario_id, "status": "archived", "title": scenario.title}
@router.post("/{scenario_id}/restore")
async def restore_scenario(
scenario_id: str, request: Request,
db: AsyncSession | None = Depends(scenario_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
scenario = await store.restore_archived(db, scenario_id, owner_login=who.login)
if scenario is None:
raise HTTPException(status_code=404, detail="archived_scenario_not_found")
await audit(who.login, who.role.value, "scenario.restore", scenario_id)
return {"id": scenario_id, "status": "published", "title": scenario.title}
@router.get("/{scenario_id}")
async def read(scenario_id: str) -> dict:
async def read(scenario_id: str, request: Request) -> dict:
# Training content is local but not public: anonymous clients must not be
# able to enumerate cards or inspect even the trainee-safe scenario body.
require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
scenario = store.get(scenario_id)
if scenario is None:
raise HTTPException(status_code=404, detail="scenario_not_found")

View file

@ -8,18 +8,23 @@ from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
from app.config import get_settings
from app.db import repo
from app.db.base import get_session
from app.db.models import AuditLog, Score
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
from app.voice.recording import recording_path
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
@ -59,8 +64,11 @@ def _out(session) -> SessionOut:
@router.post("", response_model=SessionOut, status_code=201)
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
who = require(request, Role.INSTRUCTOR)
try:
group = await repo.ensure_group(db, body.group, owner_login=who.login) if body.group else None
except PermissionError as exc:
raise HTTPException(status_code=404, detail="group_not_found") from exc
trainee = await repo.ensure_trainee(db, body.trainee, group) if body.trainee else None
session = await repo.create_session(
db,
@ -68,6 +76,14 @@ async def create(body: SessionCreate, request: Request, db: AsyncSession = Depen
mode=body.mode.value,
trainee_id=trainee.id if trainee else None,
group_id=group.id if group else None,
owner_login=who.login,
)
await audit(
who.login,
who.role.value,
"session.create",
str(session.id),
f"scenario={session.scenario_id}; mode={session.mode}; attempt={session.attempt}",
)
return _out(session)
@ -80,6 +96,8 @@ async def read(session_id: UUID, request: Request, db: AsyncSession = Depends(ge
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")
if who.role is Role.INSTRUCTOR and session.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
return _out(session)
@ -102,6 +120,8 @@ async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut
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 who.role is Role.INSTRUCTOR and state.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
if not state.ended:
raise HTTPException(status_code=409, detail="call_not_ended")
scenario = state.scenario or store.get(state.scenario_id)
@ -118,7 +138,15 @@ class ScoreOverride(BaseModel):
"""Коррекция оценки преподавателем. Автооценка сохраняется рядом."""
score_final: float = Field(ge=0, le=100)
comment: str = ""
comment: str = Field(min_length=1, max_length=2000)
@field_validator("comment")
@classmethod
def comment_must_not_be_blank(cls, comment: str) -> str:
cleaned = comment.strip()
if not cleaned:
raise ValueError("обоснование корректировки обязательно")
return cleaned
def _live(session_id: UUID):
@ -131,8 +159,9 @@ def _live(session_id: UUID):
return state, scenario
@router.get("/{session_id}/report", response_model=SessionReport)
async def report(session_id: UUID, request: Request) -> SessionReport:
async def _report_data(
session_id: UUID, request: Request, db: AsyncSession,
) -> SessionReport:
"""Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки.
Обучающийся открывает только свой разбор: ТЗ запрещает доступ к чужим
@ -140,20 +169,62 @@ async def report(session_id: UUID, request: Request) -> SessionReport:
занятия, а не по номеру в ссылке.
"""
who = require(request)
state, scenario = _live(session_id)
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
try:
state, scenario = _live(session_id)
except HTTPException as exc:
if exc.status_code != 404:
raise
state = None
scenario = None
if state is not None and scenario is not None:
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
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 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)
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")
if who.role is Role.TRAINEE and state.exercise is Exercise.CALL and not state.self_assessed:
raise HTTPException(status_code=409, detail="self_assessment_required")
if state.score is None:
if who.role is Role.INSTRUCTOR and session.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
score = await db.scalar(select(Score).where(Score.session_id == session_id))
if score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
return build_report(session_id, state, scenario)
archived = (score.report or {}).get("full_report")
if archived is None:
raise HTTPException(status_code=409, detail="report_not_archived")
data = SessionReport.model_validate(archived)
if (who.role is Role.TRAINEE and data.reference_questions
and data.self_assessment is None):
raise HTTPException(status_code=409, detail="self_assessment_required")
return data.model_copy(update={
"score_auto": score.score_auto,
"score_final": score.score_final,
"overridden_by": score.overridden_by,
"override_comment": score.override_comment,
})
@router.get("/{session_id}/report", response_model=SessionReport)
async def report(
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
) -> SessionReport:
return await _report_data(session_id, request, db)
@router.get("/{session_id}/report.csv")
async def report_csv(session_id: UUID, request: Request) -> Response:
async def report_csv(
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
) -> Response:
"""Те же права и готовность оценки, что у JSON-разбора."""
data = await report(session_id, request)
data = await _report_data(session_id, request, db)
return Response(
content=to_csv(data), media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.csv"'},
@ -161,9 +232,11 @@ async def report_csv(session_id: UUID, request: Request) -> Response:
@router.get("/{session_id}/report.pdf")
async def report_pdf(session_id: UUID, request: Request) -> Response:
async def report_pdf(
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
) -> Response:
"""Печатный разбор; генерация полностью локальна."""
data = await report(session_id, request)
data = await _report_data(session_id, request, db)
try:
content = to_pdf(data)
except RuntimeError as exc:
@ -174,31 +247,120 @@ async def report_pdf(session_id: UUID, request: Request) -> Response:
)
@router.get("/{session_id}/recording.wav")
async def recording(session_id: UUID, request: Request, db: AsyncSession = Depends(get_session)):
"""Запись учебного звонка: преподавателю либо владельцу занятия."""
who = require(request, Role.INSTRUCTOR, Role.TRAINEE)
if get_settings().demo_no_db:
state = hub.get(session_id)
if state is None:
raise HTTPException(status_code=404, detail="session_not_found")
owner_id = state.trainee_id
owner_login = state.owner_login
ended = state.ended
else:
row = await repo.get_session(db, session_id)
if row is None:
raise HTTPException(status_code=404, detail="session_not_found")
owner_id = row.trainee_id
owner_login = row.owner_login
ended = row.ended_at is not None
if who.role is Role.TRAINEE and (owner_id is None or owner_id != who.trainee_id):
raise HTTPException(status_code=403, detail="not_your_recording")
if who.role is Role.INSTRUCTOR and owner_login != who.login:
raise HTTPException(status_code=404, detail="recording_not_found")
if not ended:
raise HTTPException(status_code=409, detail="recording_not_ready")
path = recording_path(session_id)
if not path.is_file():
raise HTTPException(status_code=404, detail="recording_not_found")
return FileResponse(
path,
media_type="audio/wav",
filename=f"session-{session_id}-recording.wav",
)
@router.patch("/{session_id}/report", response_model=SessionReport)
async def override(session_id: UUID, body: ScoreOverride, request: Request) -> SessionReport:
async def override(
session_id: UUID,
body: ScoreOverride,
request: Request,
db: AsyncSession = Depends(get_session),
) -> SessionReport:
"""Тренажёр готовит материал, преподаватель имеет последнее слово.
Администратору сюда нельзя: ТЗ запрещает ему вмешиваться в оценки прямо.
"""
who = require(request, Role.INSTRUCTOR)
state, scenario = _live(session_id)
if state.score is None:
session = await repo.get_session(db, session_id)
if session is None:
raise HTTPException(status_code=404, detail="session_not_found")
if session.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
state = hub.get(session_id)
live_ready = state is not None and state.score is not None
scenario = (state.scenario or store.get(state.scenario_id)) if live_ready else None
if live_ready and scenario is None:
raise HTTPException(status_code=409, detail="scenario_not_found")
score = await db.scalar(select(Score).where(Score.session_id == session_id))
if score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
state.score = {
**state.score,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
}
if hub.journal:
await hub.journal.score_override(
session_id, body.score_final, who.login, body.comment
)
await audit(
who.login, who.role.value, "score.override", str(session_id),
f"{state.score.get('score_auto')} → {body.score_final}: {body.comment}",
)
return build_report(session_id, state, scenario)
archived = (score.report or {}).get("full_report")
if not live_ready and archived is None:
raise HTTPException(status_code=409, detail="report_not_archived")
# Persist score and audit entry in the same request transaction: the API
# must not report success if either durable record failed to commit.
score.score_final = body.score_final
score.overridden_by = who.login
score.override_comment = body.comment
report_payload = dict(score.report or {})
if archived is not None:
archived_payload = dict(archived)
archived_payload.update({
"score_auto": score.score_auto,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
})
report_payload["full_report"] = archived_payload
score.report = report_payload
db.add(AuditLog(
actor=who.login,
role=who.role.value,
action="score.override",
object_id=str(session_id),
detail=f"{score.score_auto} → {body.score_final}: {body.comment}"[:2000],
))
await db.commit()
if live_ready:
assert state is not None and state.score is not None and scenario is not None
state.score = {
**state.score,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
}
state.score["full_report"] = {
**state.score.get("full_report", {}),
"score_auto": score.score_auto,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
}
result = build_report(session_id, state, scenario)
else:
# Historical/archived sessions no longer have a live object in the hub.
# Keep score correction available from the instructor's report history.
result = SessionReport.model_validate(archived).model_copy(update={
"score_auto": score.score_auto,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
})
return result
@router.get("", response_model=list[SessionOut])
@ -217,11 +379,13 @@ async def listing(
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
trainee = who.trainee_id
owner_login = who.login if who.role is Role.INSTRUCTOR else None
rows = await repo.history(
db,
trainee_id=trainee,
group_id=group,
mode=mode.value if mode else None,
owner_login=owner_login,
since=since,
limit=limit,
)

View file

@ -8,24 +8,77 @@
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import DEMO_TRAINEE_ID, require
from app.config import get_settings
from app.domain.roles import Role
from app.db.base import get_session, get_sessionmaker
from app.db.models import Group, Score, Session, Trainee
from app.db.models import Group, Score, Session, Trainee, User
from app.scoring.export import certificate_pdf
from app.voice.recording import recording_path
router = APIRouter(prefix="/api/trainees", tags=["trainees"])
@router.get("/{trainee_id}/certificate.pdf")
async def certificate(
trainee_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
) -> Response:
who = require(request)
if who.role is Role.TRAINEE and who.trainee_id != trainee_id:
raise HTTPException(status_code=403, detail="not_your_certificate")
if who.role is Role.INSTRUCTOR:
owns_trainee = await db.scalar(
select(Session.id)
.where(Session.trainee_id == trainee_id, Session.owner_login == who.login)
.limit(1)
)
if owns_trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
group = await db.get(Group, trainee.group_id) if trainee.group_id else None
if who.role is Role.INSTRUCTOR and group is not None and group.owner_login != who.login:
group = None
scores_query = (
select(func.count(Score.id), func.avg(Score.score_final), func.max(Score.created_at))
.join(Session, Session.id == Score.session_id)
.where(Session.trainee_id == trainee_id)
)
if who.role is Role.INSTRUCTOR:
scores_query = scores_query.where(Session.owner_login == who.login)
result = await db.execute(scores_query)
attempts, average_score, completed_at = result.one()
if not attempts:
raise HTTPException(status_code=409, detail="no_scored_attempts")
try:
content = certificate_pdf(
trainee_name=trainee.name,
trainee_id=trainee.id,
group_name=group.name if group else None,
attempts=int(attempts),
average_score=float(average_score),
issued_at=completed_at.date().isoformat(),
)
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="trainee-{trainee_id}-certificate.pdf"'},
)
class TraineeOut(BaseModel):
id: UUID
name: str
group: str | None = None
service: str | None = None
class AttemptOut(BaseModel):
@ -40,6 +93,7 @@ class AttemptOut(BaseModel):
facts_required: int | None = None
hints: int | None = None
codes: dict[str, int] = {}
recording_available: bool = False
class DeltaOut(BaseModel):
@ -65,14 +119,39 @@ class ProfileOut(BaseModel):
async def listing(request: Request) -> list[TraineeOut]:
"""Список курсантов — преподавателю и администратору: обучающемуся он
не нужен, а чужие фамилии из него видны."""
require(request, Role.INSTRUCTOR, Role.ADMIN)
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
if get_settings().demo_no_db:
return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант")]
return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант", service="Служба 101")]
async with get_sessionmaker()() as db:
rows = await db.execute(
select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True)
query = (
select(Trainee, Group.name, Group.owner_login, User.service)
.join(Group, Group.id == Trainee.group_id, isouter=True)
.join(User, User.trainee_id == Trainee.id, isouter=True)
)
return [TraineeOut(id=trainee.id, name=trainee.name, group=group) for trainee, group in rows]
if who.role is Role.INSTRUCTOR:
has_owned_attempt = exists(
select(Session.id).where(
Session.trainee_id == Trainee.id,
Session.owner_login == who.login,
)
)
query = query.where(
or_(
Trainee.group_id.is_(None),
Group.owner_login == who.login,
has_owned_attempt,
)
)
rows = await db.execute(query)
return [
TraineeOut(
id=trainee.id,
name=trainee.name,
group=(group if who.role is not Role.INSTRUCTOR or group_owner == who.login else None),
service=service,
)
for trainee, group, group_owner, service in rows
]
@router.get("/{trainee_id}/profile", response_model=ProfileOut)
@ -82,17 +161,30 @@ async def profile(
who = require(request)
if who.role is Role.TRAINEE and who.trainee_id != trainee_id:
raise HTTPException(status_code=403, detail="not_your_profile")
if who.role is Role.INSTRUCTOR:
owns_trainee = await db.scalar(
select(Session.id)
.where(Session.trainee_id == trainee_id, Session.owner_login == who.login)
.limit(1)
)
if owns_trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
group = await db.get(Group, trainee.group_id) if trainee.group_id else None
if who.role is Role.INSTRUCTOR and group is not None and group.owner_login != who.login:
group = None
rows = await db.execute(
attempts_query = (
select(Session, Score)
.join(Score, Score.session_id == Session.id, isouter=True)
.where(Session.trainee_id == trainee_id)
.order_by(Session.created_at)
)
if who.role is Role.INSTRUCTOR:
attempts_query = attempts_query.where(Session.owner_login == who.login)
rows = await db.execute(attempts_query)
attempts: list[AttemptOut] = []
competency_sums: dict[str, list[float]] = {}
for session, score in rows:
@ -110,6 +202,7 @@ async def profile(
facts_required=summary.get("facts_required"),
hints=summary.get("hints"),
codes=summary.get("codes", {}),
recording_available=recording_path(session.id).is_file(),
)
)
for item in (score.report or {}).get("competencies", []) if score else []:

View file

@ -33,14 +33,15 @@ from app.domain.events import (
)
from app.domain.events import BgStart
from app.scenarios import store
from app.session.finish import finish, release_score
from app.session.finish import finish, refresh_archived_report, release_score
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.models import TTS_RATE, get_voice_models
from app.voice.pipeline import VoiceSession
from app.voice.recording import start_recording
log = logging.getLogger(__name__)
router = APIRouter()
@ -62,6 +63,8 @@ def _on_audio(session_id: UUID, state, frame: bytes) -> None:
session_id, len(frame), FRAME_BYTES)
return
state.audio_frames += 1
if state.recorder is not None:
state.recorder.add_pcm(frame, sample_rate=16_000)
if state.voice is not None:
state.voice.feed(frame)
if state.audio_frames % FRAMES_PER_LOG == 0:
@ -138,6 +141,8 @@ async def _handle(session_id: UUID, state, event) -> None:
hub.to_observers(session_id, state.snapshot())
if hub.journal:
await hub.journal.session_started(session_id, state.started_at)
if state.recorder is None:
state.recorder = start_recording(session_id)
_start_voice(session_id, state)
case "kio.patch":
@ -220,6 +225,7 @@ async def _handle(session_id: UUID, state, event) -> None:
await hub.journal.self_assessment(
session_id, event.missed, event.comment, now_utc()
)
await refresh_archived_report(session_id, state)
# Оценка могла быть готова раньше самооценки — теперь её можно отдать.
await release_score(session_id, state)
@ -237,6 +243,8 @@ async def _handle(session_id: UUID, state, event) -> None:
)
await finish(session_id, state)
await hub.checkpoint(session_id)
def _start_voice(session_id: UUID, state) -> None:
"""Голос включается, когда курсант снял трубку: звонящий сразу кричит первую реплику."""
@ -244,13 +252,18 @@ def _start_voice(session_id: UUID, state) -> None:
scenario = store.get(state.scenario_id)
if models is None or scenario is None or state.voice is not None:
return
def send_audio(pcm: bytes) -> None:
if state.recorder is not None:
state.recorder.add_pcm(pcm, sample_rate=TTS_RATE)
hub.to_trainee(session_id, pcm)
state.voice = VoiceSession(
session_id=session_id,
state=state,
models=models,
send_event=lambda event: hub.to_trainee(session_id, event),
send_observer=lambda event: hub.to_observers(session_id, event),
send_audio=lambda pcm: hub.to_trainee(session_id, pcm),
send_audio=send_audio,
journal=hub.journal,
)
if scenario.background:

View file

@ -8,43 +8,43 @@
преподавателю, уходит на его же сокет `observe`.
"""
import asyncio
import logging
import math
from uuid import UUID, uuid4
from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.domain.events import (
CallEndReason,
CallEnded,
ErrorEvent,
ErrorKind,
ScoreReady,
CallIncoming,
CardBriefing,
Exercise,
StationState,
InstructorToServer,
InstructorNoteShown,
ModeSet,
ReferenceStarted,
SessionEnded,
)
import asyncio
from app.dialog.factory import build_caller
from app.api.auth import audit, principal_of
from app.dialog.director import apply as apply_directive
from app.dialog.director import mood_of
from app.dialog.factory import build_caller
from app.dialog.persona import PersonaState
from app.dialog.runtime import get_embedder
from app.dialog.slots import SlotMachine
from app.scenarios import store
from app.api.auth import audit, principal_of
from app.domain.events import (
CallEnded,
CallEndReason,
CallIncoming,
CardBriefing,
ErrorEvent,
ErrorKind,
Exercise,
InstructorNoteShown,
InstructorToServer,
ModeSet,
ReferenceStarted,
ScoreReady,
SessionEnded,
StationState,
)
from app.domain.roles import Role
from app.domain.timers import TimerCode
from app.scenarios import store
from app.session.dds import prepare_queue
from app.session.hub import hub
from app.session.state import SessionState, now_utc
from app.session.dds import prepare_card
from app.voice.models import get_voice_models
from app.voice.pipeline import FILLERS, prefetch
@ -93,10 +93,10 @@ async def _start(session_id: UUID, event, who=None) -> None:
scenario_ids = event.scenario_ids or [event.scenario_id]
if event.exercise is Exercise.DDS:
if not scenario_ids or scenario_ids[0] != event.scenario_id or len(scenario_ids) > 10:
if not scenario_ids or scenario_ids[0] != event.scenario_id or len(scenario_ids) > 96:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID,
message="Очередь ДДС должна начинаться с scenario_id и содержать не более 10 карточек",
message="Очередь ДДС должна начинаться с scenario_id и содержать не более 96 карточек",
))
return
scenarios = [store.get(scenario_id) for scenario_id in scenario_ids]
@ -110,10 +110,19 @@ async def _start(session_id: UUID, event, who=None) -> None:
attempt = 1
recorded_trainee_id = event.trainee_id
recorded_service = None
if hub.journal:
attempt, recorded_trainee_id = await hub.journal.start_lesson(
session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id
)
try:
attempt, recorded_trainee_id, recorded_service = await hub.journal.start_lesson(
session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id,
owner_login=who.login if who is not None else None,
)
except PermissionError:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.FORBIDDEN,
message="Занятие создано другим преподавателем",
))
return
# Занятие собирается целиком и только потом регистрируется: иначе
# наблюдатель, подключившийся в эту щель, увидит полусобранное состояние
@ -124,23 +133,35 @@ async def _start(session_id: UUID, event, who=None) -> None:
scenario_title=scenario.title,
level=scenario.level.value,
mode=event.mode,
owner_login=who.login if who is not None else None,
exercise=event.exercise,
handoff_to_dds=event.handoff_to_dds,
scenario=scenario.model_copy(deep=True),
required_fields=scenario.required_fields,
trainee_name=event.trainee,
trainee_id=recorded_trainee_id,
dds_service=recorded_service or event.dds_service,
attempt=attempt,
criteria=event.criteria,
)
state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
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)
state.caller = build_caller(
scenario.id,
use_pregenerated=scenario.tree.pregenerated,
)
elif event.exercise is Exercise.DDS:
state.dds_scenarios = [item.model_copy(deep=True) for item in scenarios]
prepare_card(state, state.dds_scenarios[0])
prepare_queue(
state,
state.dds_scenarios,
arrival_interval_seconds=event.dds_arrival_interval_seconds,
max_waiting=event.dds_max_waiting,
)
state.started_at = state.dispatched_at
else:
state.started_at = now_utc()
@ -155,13 +176,19 @@ async def _start(session_id: UUID, event, who=None) -> None:
if models is not None:
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
state.on_event("call.incoming")
await hub.checkpoint(session_id)
hub.start_ticker(session_id)
if who is not None:
# Запуск занятия меняет чужой результат — значит попадает в журнал
# аудита (ТЗ, хранение не менее шести месяцев).
asyncio.create_task(
audit(who.login, who.role.value, "lesson.start", str(session_id),
f"{scenario.id}, режим {event.mode.value}, курсант {event.trainee}")
# Сохраняем до продолжения сценария, чтобы завершение процесса не
# потеряло событие. ФИО курсанта в долгоживущий журнал не дублируем.
await audit(
who.login,
who.role.value,
"lesson.start",
str(session_id),
f"{scenario.id}, режим {event.mode.value}",
)
if event.exercise is Exercise.CALL:
@ -191,18 +218,22 @@ async def _stop(session_id: UUID) -> None:
return
state.ended_at = now_utc()
state.end_reason = CallEndReason.INSTRUCTOR
if state.voice is not None:
await state.voice.close()
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
from app.session.finish import finish
if state.exercise in {Exercise.DDS, Exercise.CARD}:
if state.exercise is Exercise.DDS or state.handoff_to_dds and state.dispatched_card is not None:
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 or state.handoff_to_dds and state.dispatched_card is not None:
hub.to_station(session_id, ScoreReady(session_id=session_id))
else:
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.INSTRUCTOR))
await finish(session_id, state)
if hub.journal:
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value)
@ -236,6 +267,21 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
)
continue
existing_state = hub.get(session_id)
if event.type == "scenario.start":
if existing_state is not None:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.FORBIDDEN,
message="Для нового занятия создайте новый номер сессии",
))
continue
elif existing_state is None or existing_state.owner_login != who.login:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.FORBIDDEN,
message="Это занятие принадлежит другому преподавателю",
))
continue
match event.type:
case "scenario.start":
await _start(session_id, event, who)
@ -315,7 +361,7 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
if result.needs_network:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.DIRECTIVE_NEEDS_NETWORK,
message="Свободный текст требует LLM: офлайн доступны только кнопки",
message="Свободный текст требует локально запущенной модели; без неё доступны кнопки",
))
continue
state.directives.append(event.directive)
@ -338,5 +384,6 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
message=f"{event.type} ещё не реализовано",
),
)
await hub.checkpoint(session_id)
except WebSocketDisconnect:
return

View file

@ -10,23 +10,41 @@
import asyncio
import logging
import re
from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
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.events import (
CallEndReason,
ErrorEvent,
ErrorKind,
Exercise,
PhoneLine,
PhoneReport,
ScoreReady,
SessionEnded,
StationState,
StationToServer,
)
from app.domain.roles import Role
from app.domain.statuses import (
PRIMARY,
SERVICE_STATUS_LABELS,
PhoneCallPending,
PhoneLineRecord,
PhoneReportRecord,
ServiceStatus,
StatusError,
current,
)
from app.scoring.grammar import assess
from app.session.dds import deliver_due_cards
from app.session.finish import finish, score_current_dds
from app.session.hub import hub
from app.session.state import now_utc
from app.session.finish import finish
from app.session.finish import score_current_dds
from app.session.dds import prepare_card
log = logging.getLogger(__name__)
router = APIRouter()
@ -34,24 +52,85 @@ router = APIRouter()
_adapter = TypeAdapter(StationToServer)
REPORT_PHASES = ("dispatched", "arrived", "working", "completed")
REPORT_TEXT = {
"dispatched": "Бригада выехала к месту происшествия.",
"arrived": "Бригада прибыла на место происшествия.",
"working": "Бригада приступила к проведению работ.",
"completed": "Работы завершены, бригада освобождена.",
REQUIRED_STATUS = {
"dispatched": ServiceStatus.ACCEPTED,
"arrived": ServiceStatus.RESPONDING,
"working": ServiceStatus.ARRIVED,
"completed": ServiceStatus.WORKING,
}
REPORT_FOR_STATUS = {
ServiceStatus.RESPONDING: "dispatched",
ServiceStatus.ARRIVED: "arrived",
ServiceStatus.WORKING: "working",
ServiceStatus.COMPLETED: "completed",
STATUS_AT_OR_AFTER = {
"dispatched": {ServiceStatus.ACCEPTED, ServiceStatus.RESPONDING,
ServiceStatus.ARRIVED, ServiceStatus.WORKING},
"arrived": {ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING},
"working": {ServiceStatus.ARRIVED, ServiceStatus.WORKING},
"completed": {ServiceStatus.WORKING, ServiceStatus.COMPLETED},
}
def _error(session_id: UUID, message: str) -> None:
hub.to_station(session_id, ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=message))
def _line(session_id: UUID, state, speaker: str, text: str) -> None:
call = state.phone_pending
if call is None:
return
line = PhoneLineRecord(service=call.service, crew=call.crew,
speaker=speaker, text=text, at=now_utc())
state.phone_lines.append(line)
state.dds_log.append((f"phone.line.{speaker}", line.at, f"{call.crew}: {text}"))
hub.to_station(session_id, PhoneLine(**line.model_dump()))
def _address_matches(expected: str | None, supplied: str) -> bool:
"""Не даём сообщить бригаде другой номер дома/другую улицу."""
if not expected:
return bool(supplied.strip())
numbers = re.findall(r"\d+", expected)
spoken_numbers = re.findall(r"\d+", supplied)
words = re.findall(r"[а-яё]{4,}", expected.casefold())
spoken_words = re.findall(r"[а-яё]{4,}", supplied.casefold())
return (all(number in spoken_numbers for number in numbers)
and any(word[:4] == spoken[:4] for word in words for spoken in spoken_words))
def _incident_matches(state, supplied: str) -> bool:
text = supplied.casefold()
# Описание часто начинается с адреса: его нельзя считать совпадением
# характера происшествия. Берём только название сценария и признаки ЕКП.
source = " ".join((state.scenario_title, " ".join(state.kio.signs)))
anchors = {word[:4] for word in re.findall(r"[а-яё]{5,}", source.casefold())}
return len(supplied.strip()) >= 8 and any(anchor in text for anchor in anchors)
def _has_purpose(text: str, stems: tuple[str, ...]) -> bool:
normalized = text.casefold()
return len(text.strip()) >= 8 and any(stem in normalized for stem in stems)
def _report_text(phase: str, crew: str, address: str) -> str:
match phase:
case "dispatched":
return f"{crew}: вызов по адресу {address} принят, выезжаем. О прибытии доложу."
case "arrived":
return f"{crew}: прибыли по адресу {address}. Уточняем обстановку на месте."
case "working":
return f"{crew}: обстановка уточнена, приступили к работам. Сообщим о завершении."
case _:
return f"{crew}: работы завершены. Дальнейшая помощь от нашей бригады не требуется."
def _finish_phone_call(session_id: UUID, state) -> None:
call = state.phone_pending
assert call is not None
text = _report_text(call.phase, call.crew, state.dispatched_card.address or "из карточки")
_line(session_id, state, "crew", text)
report = PhoneReportRecord(service=call.service, crew=call.crew,
phase=call.phase, text=text, at=now_utc())
state.phone_reports.append(report)
state.dds_log.append(("phone.report", now_utc(), f"{call.crew}: {call.phase}"))
state.phone_pending = None
hub.to_station(session_id, PhoneReport(**report.model_dump()))
async def _finish_dds(session_id: UUID, state) -> None:
state.ended_at = now_utc()
state.end_reason = CallEndReason.COMPLETE
@ -75,22 +154,15 @@ async def _handle(session_id: UUID, state, event) -> None:
# диспетчер один и выбирать службу не из чего.
state.on_event("card.ack")
state.dds_log.append(("card.ack", now_utc(), None))
services = state.notified_services()
services = state.managed_services()
if services:
try:
state.set_service_status(services[0], ServiceStatus.ACCEPTED)
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 or state.handoff_to_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}» требует доклада бригады")
if event.service not in state.managed_services():
_error(session_id, "Можно менять статусы только своей ДДС")
return
try:
state.set_service_status(
@ -116,33 +188,73 @@ async def _handle(session_id: UUID, state, event) -> None:
):
_error(session_id, "После первого доклада бригаду этой службы менять нельзя")
return
if state.phone_pending is not None:
_error(session_id, "Завершите текущий разговор перед сменой бригады")
return
state.crew_selected = event.crew
state.crew_assignments[service] = event.crew
state.dds_log.append(("crew.select", now_utc(), event.crew))
case "phone.dial":
if state.phone_pending is not None:
_error(session_id, "Разговор уже идёт: передайте сведения или завершите звонок")
return
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)
required = REQUIRED_STATUS[phase]
if current(state.status_log, service) not in STATUS_AT_OR_AFTER[phase]:
_error(session_id, f"Перед звонком нужен статус «{SERVICE_STATUS_LABELS[required]}» этой службы")
return
state.phone_pending = PhoneCallPending(service=service, crew=crew, phase=phase)
state.dds_log.append(("phone.dial", now_utc(), crew))
hub.to_station(session_id, PhoneReport(**report.model_dump()))
greeting = (f"{crew}, старший группы на связи. Назовите адрес, характер происшествия "
"и что требуется от бригады."
if phase == "dispatched" else
f"{crew}, старший группы на связи. Слушаю ваш запрос по карточке.")
_line(session_id, state, "crew", greeting)
case "phone.brief":
call = state.phone_pending
if call is None or call.phase != "dispatched":
_error(session_id, "Сначала соединитесь со старшим группы для передачи вызова")
return
if not _address_matches(state.dispatched_card.address, event.address):
_error(session_id, "Проверьте адрес: улица и номер дома должны совпадать с карточкой")
return
if not _incident_matches(state, event.incident):
_error(session_id, "Уточните характер происшествия по данным карточки")
return
if not _has_purpose(event.request, ("выезд", "выех", "направ", "прибыт",
"реагир", "помощ", "подтверд", "долож")):
_error(session_id, "Сформулируйте задачу: выезд, помощь или доклад бригады")
return
_line(session_id, state, "dispatcher", f"Адрес: {event.address.strip()}. "
f"Происшествие: {event.incident.strip()}. {event.request.strip()}")
_finish_phone_call(session_id, state)
case "phone.check":
call = state.phone_pending
if call is None or call.phase == "dispatched":
_error(session_id, "Сначала передайте вызов, затем запросите обстановку")
return
if not _has_purpose(event.text, ("обстанов", "статус", "прибыл", "доех",
"выех", "работ", "заверш", "мест",
"ход", "сообщ", "долож", "уточн")):
_error(session_id, "Спросите обстановку, прибытие или ход работ по карточке")
return
_line(session_id, state, "dispatcher", event.text.strip())
_finish_phone_call(session_id, state)
case "phone.hangup":
if state.phone_pending is None:
_error(session_id, "Нет активного разговора")
return
state.dds_log.append(("phone.hangup", now_utc(), state.phone_pending.crew))
state.phone_pending = None
case "card.reply":
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.dispatched_card or (
event.card_id != state.dispatched_card.card_id
@ -150,7 +262,18 @@ async def _handle(session_id: UUID, state, event) -> None:
_error(session_id, "Ответ относится не к текущей карточке")
return
state.reply_text = event.text
state.reply_grammar = await assess(event.text)
state.reply_log.append((now_utc(), event.text))
case "card.open":
if state.exercise is not Exercise.DDS or not state.activate_dds_card(event.card_id):
_error(session_id, "Карточка отсутствует в текущей очереди")
return
hub.to_station(session_id, state.card_received_event())
# CardReceived carries the contents, while StationState carries
# the status journal and current queue. Send both on every switch
# so the newly opened card cannot briefly inherit the previous
# card's status snapshot until the next periodic tick.
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
case "card.next":
if state.exercise is not Exercise.DDS or not state.dispatched_card or (
event.card_id != state.dispatched_card.card_id
@ -161,11 +284,32 @@ async def _handle(session_id: UUID, state, event) -> None:
_error(session_id, "Эта карточка уже завершена")
return
state.dds_completed.append(score_current_dds(state))
if state.dds_card_index + 1 < len(state.dds_scenarios):
state.dds_card_index += 1
prepare_card(state, state.dds_scenarios[state.dds_card_index])
completed_index = state.dds_card_index
state.dds_live_cards = [
item for item in state.dds_live_cards if item.card_id != event.card_id
]
remaining = sorted(state.dds_live_cards, key=lambda item: item.original_index)
if remaining:
following = next(
(item for item in remaining if item.original_index > completed_index),
remaining[0],
)
state.activate_dds_card(following.card_id, capture=False)
hub.to_station(session_id, state.card_received_event())
else:
# Keep the lesson alive if selected cards have not arrived yet.
# The next delivery may become the active card immediately or
# after its configured interval; no completed card is reused.
state.dds_active_card_id = None
state.dispatched_card = None
state.dispatched_at = None
state.dds_card_index = completed_index
active_before_delivery = state.dds_active_card_id
deliver_due_cards(state)
if state.dds_active_card_id and state.dds_active_card_id != active_before_delivery:
hub.to_station(session_id, state.card_received_event())
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
if not state.dds_live_cards and len(state.dds_completed) >= len(state.dds_scenarios):
await _finish_dds(session_id, state)
case "station.finish":
if state.exercise is not Exercise.DDS and not state.handoff_to_dds:
@ -173,9 +317,11 @@ async def _handle(session_id: UUID, state, event) -> None:
return
await _finish_dds(session_id, state)
case "card.bounce":
# Карточка вернулась: в разборе это E6 с конкретной причиной.
state.bounced_fields = list(event.missing_fields)
state.dds_log.append(("card.bounce", now_utc(), event.comment))
_error(
session_id,
"ДДС не проверяет заполнение карточки: замечания передаёт служба контроля 112",
)
return
case "zone.decision":
state.on_event("zone.decision")
state.dds_log.append(("zone.decision", now_utc(), "в зоне" if event.in_zone else "не в зоне"))
@ -186,6 +332,8 @@ async def _handle(session_id: UUID, state, event) -> None:
state.on_event("crew.arrived")
state.kio = state.kio.model_copy(update={"arrival_at": event.at})
state.dds_log.append(("crew.arrived", now_utc(), None))
state.capture_active_dds()
await hub.checkpoint(session_id)
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
hub.to_observers(session_id, state.snapshot())