Complete DDS training workflow and delivery package
This commit is contained in:
parent
68dd83c7c2
commit
4c4b91064f
229 changed files with 11969 additions and 1024 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
549
backend/app/api/http/materials.py
Normal file
549
backend/app/api/http/materials.py
Normal 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'",
|
||||
},
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 []:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue