Implement DDS exercise, customer UI and local demo
This commit is contained in:
parent
cec84ffcd0
commit
0526b11f91
46 changed files with 2504 additions and 253 deletions
|
|
@ -15,7 +15,10 @@
|
|||
протокола авторизации в канале нет.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
import weakref
|
||||
from uuid import UUID
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
|
|
@ -31,8 +34,58 @@ from app.domain.roles import Role
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
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)
|
||||
_generations: dict[str, int] = {}
|
||||
_active_sockets: dict[str, weakref.WeakKeyDictionary] = {}
|
||||
|
||||
|
||||
async def _close_revoked(ws: WebSocket) -> None:
|
||||
try:
|
||||
await ws.close(code=1008, reason="Учётная запись изменена: войдите снова")
|
||||
except (RuntimeError, OSError):
|
||||
# The peer may already have disconnected; revocation still stands.
|
||||
pass
|
||||
|
||||
|
||||
def invalidate_login(login: str) -> None:
|
||||
"""Revoke previously issued cookies after account/role/password changes."""
|
||||
_generations[login] = _generations.get(login, 0) + 1
|
||||
for ws, loop in list(_active_sockets.get(login, {}).items()):
|
||||
try:
|
||||
if not loop.is_closed():
|
||||
loop.call_soon_threadsafe(lambda socket=ws: asyncio.create_task(_close_revoked(socket)))
|
||||
except RuntimeError:
|
||||
pass # loop closed between the check and scheduling
|
||||
|
||||
|
||||
def _session_principal(session: dict) -> "Principal | None":
|
||||
data = session.get("principal")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
login = data.get("login")
|
||||
if not isinstance(login, str):
|
||||
return None
|
||||
if session.get("auth_instance") != _INSTANCE:
|
||||
return None
|
||||
if session.get("auth_generation") != _generations.get(login, 0):
|
||||
return None
|
||||
try:
|
||||
return Principal.model_validate(data)
|
||||
except Exception: # malformed signed cookie: reject, do not 500
|
||||
log.warning("неверный формат principal в cookie")
|
||||
return None
|
||||
|
||||
|
||||
def _issue_session(request: Request, who: "Principal") -> None:
|
||||
request.session["principal"] = who.model_dump(mode="json")
|
||||
request.session["auth_instance"] = _INSTANCE
|
||||
request.session["auth_generation"] = _generations.get(who.login, 0)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
|
|
@ -50,8 +103,7 @@ def verify_password(password_hash: str, password: str) -> bool:
|
|||
|
||||
|
||||
class Principal(BaseModel):
|
||||
"""Кто действует. Хранится в cookie целиком: ходить в базу за ролью
|
||||
на каждый запрос незачем, а меняется она правкой учётной записи."""
|
||||
"""Кто действует. Cookie stores a snapshot, revoked on account edits."""
|
||||
|
||||
login: str
|
||||
full_name: str
|
||||
|
|
@ -69,9 +121,23 @@ class LoginIn(BaseModel):
|
|||
password: str
|
||||
|
||||
|
||||
def _demo_local(request: Request) -> None:
|
||||
# При ошибочном bind 0.0.0.0 удалённый клиент всё равно не получит cookie.
|
||||
if request.client is None or request.client.host not in {"127.0.0.1", "::1", "localhost", "testclient"}:
|
||||
raise HTTPException(status_code=403, detail="demo_local_only")
|
||||
|
||||
|
||||
def _demo_principal(role: Role) -> Principal:
|
||||
return Principal(
|
||||
login="demo-trainee" if role is Role.TRAINEE else "demo-instructor",
|
||||
full_name="Демо-курсант" if role is Role.TRAINEE else "Демо-преподаватель",
|
||||
role=role,
|
||||
trainee_id=DEMO_TRAINEE_ID if role is Role.TRAINEE else None,
|
||||
)
|
||||
|
||||
|
||||
def current(request: Request) -> Principal | None:
|
||||
data = request.session.get("principal")
|
||||
return Principal.model_validate(data) if data else None
|
||||
return _session_principal(request.session)
|
||||
|
||||
|
||||
def principal_of(websocket: WebSocket) -> Principal | None:
|
||||
|
|
@ -80,8 +146,10 @@ def principal_of(websocket: WebSocket) -> Principal | None:
|
|||
`WebSocket.session` доступен, потому что `SessionMiddleware` стоит до
|
||||
роутера: значит роль известна до входа в цикл приёма сообщений.
|
||||
"""
|
||||
data = websocket.session.get("principal") if "session" in websocket.scope else None
|
||||
return Principal.model_validate(data) if data else None
|
||||
who = _session_principal(websocket.session) if "session" in websocket.scope else None
|
||||
if who is not None:
|
||||
_active_sockets.setdefault(who.login, weakref.WeakKeyDictionary())[websocket] = asyncio.get_running_loop()
|
||||
return who
|
||||
|
||||
|
||||
def require(request: Request, *roles: Role) -> Principal:
|
||||
|
|
@ -99,6 +167,8 @@ async def audit(
|
|||
) -> None:
|
||||
"""Запись в журнал. Аудит не должен ронять действие: если база недоступна,
|
||||
занятие продолжается, а пропуск виден в логе."""
|
||||
if get_settings().demo_no_db:
|
||||
return # в явном demo-режиме запись и долговременный аудит недоступны
|
||||
try:
|
||||
async with get_sessionmaker()() as db:
|
||||
db.add(
|
||||
|
|
@ -113,6 +183,16 @@ async def audit(
|
|||
|
||||
@router.post("/login")
|
||||
async def login(payload: LoginIn, request: Request) -> dict:
|
||||
if get_settings().demo_no_db:
|
||||
_demo_local(request)
|
||||
demo_roles = {"demo-instructor": Role.INSTRUCTOR, "demo-trainee": Role.TRAINEE}
|
||||
role = demo_roles.get(payload.login)
|
||||
if role is None or not secrets.compare_digest(payload.password, "demo"):
|
||||
raise HTTPException(status_code=401, detail="bad_credentials")
|
||||
who = _demo_principal(role)
|
||||
_issue_session(request, who)
|
||||
return who.model_dump(mode="json")
|
||||
|
||||
async with get_sessionmaker()() as db:
|
||||
user = await db.scalar(select(User).where(User.login == payload.login))
|
||||
|
||||
|
|
@ -130,7 +210,7 @@ async def login(payload: LoginIn, request: Request) -> dict:
|
|||
service=user.service,
|
||||
trainee_id=user.trainee_id,
|
||||
)
|
||||
request.session["principal"] = who.model_dump(mode="json")
|
||||
_issue_session(request, who)
|
||||
await audit(who.login, who.role.value, "login")
|
||||
return who.model_dump(mode="json")
|
||||
|
||||
|
|
@ -153,7 +233,7 @@ async def me(request: Request) -> dict:
|
|||
|
||||
|
||||
@router.post("/dev-token")
|
||||
async def dev_token(request: Request) -> dict:
|
||||
async def dev_token(request: Request, role: Role = Role.INSTRUCTOR) -> dict:
|
||||
"""Вход без пароля для наших же инструментов.
|
||||
|
||||
`make lesson` и тесты открывают сокеты напрямую и после включения ролей
|
||||
|
|
@ -161,8 +241,17 @@ async def dev_token(request: Request) -> dict:
|
|||
умолчанию выключенным: в рабочем стенде она отвечает 404, а не 401 —
|
||||
выключенной функции не должно быть видно вовсе.
|
||||
"""
|
||||
if not get_settings().dev_auth_bypass:
|
||||
settings = get_settings()
|
||||
if not settings.dev_auth_bypass:
|
||||
raise HTTPException(status_code=404, detail="not_found")
|
||||
who = Principal(login="dev", full_name="Разработка", role=Role.INSTRUCTOR)
|
||||
request.session["principal"] = who.model_dump(mode="json")
|
||||
if role is not Role.INSTRUCTOR and not settings.demo_no_db:
|
||||
raise HTTPException(status_code=404, detail="not_found")
|
||||
if settings.demo_no_db:
|
||||
_demo_local(request)
|
||||
if role not in {Role.INSTRUCTOR, Role.TRAINEE}:
|
||||
raise HTTPException(status_code=403, detail="demo_role_forbidden")
|
||||
who = _demo_principal(role) if settings.demo_no_db else Principal(
|
||||
login="dev", full_name="Разработка", role=Role.INSTRUCTOR
|
||||
)
|
||||
_issue_session(request, who)
|
||||
return who.model_dump(mode="json")
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
|
@ -18,9 +19,10 @@ from pydantic import BaseModel, Field
|
|||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.admin import backup as backup_service
|
||||
from app.api.auth import audit, hash_password, require
|
||||
from app.api.auth import audit, hash_password, invalidate_login, require
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_session
|
||||
from app.db.models import AuditLog, Session as SessionRow, Trainee, User
|
||||
|
|
@ -122,6 +124,13 @@ async def patch_user(
|
|||
|
||||
changed: list[str] = []
|
||||
if body.role is not None:
|
||||
if user.login == who.login and body.role is not Role.ADMIN:
|
||||
raise HTTPException(status_code=409, detail="cannot_demote_yourself")
|
||||
if body.role is Role.TRAINEE and user.trainee_id is None:
|
||||
trainee = Trainee(name=user.full_name)
|
||||
db.add(trainee)
|
||||
await db.flush()
|
||||
user.trainee_id = trainee.id
|
||||
user.role = body.role.value
|
||||
changed.append(f"роль {body.role.value}")
|
||||
if body.service is not None:
|
||||
|
|
@ -137,7 +146,10 @@ async def patch_user(
|
|||
user.password_hash = hash_password(body.password)
|
||||
changed.append("пароль сброшен")
|
||||
|
||||
if not changed:
|
||||
return _out(user)
|
||||
await db.commit()
|
||||
invalidate_login(user.login)
|
||||
await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed))
|
||||
return _out(user)
|
||||
|
||||
|
|
@ -162,7 +174,7 @@ async def audit_log(
|
|||
"""Журнал действий. Администратор его читает, но не правит: точки удаления
|
||||
или изменения записи здесь нет — ТЗ требует хранения, а не управления."""
|
||||
require(request, Role.ADMIN)
|
||||
query = select(AuditLog).order_by(AuditLog.at.desc()).limit(min(limit, 1000))
|
||||
query = select(AuditLog).order_by(AuditLog.at.desc()).limit(max(1, min(limit, 1000)))
|
||||
if action:
|
||||
query = query.where(AuditLog.action == action)
|
||||
if actor:
|
||||
|
|
@ -261,7 +273,19 @@ 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 backup_service.listing()]
|
||||
return [BackupOut(**item) for item in await run_in_threadpool(backup_service.listing)]
|
||||
|
||||
|
||||
def _safe_backup_error(exc: backup_service.BackupError) -> str:
|
||||
"""Never echo DATABASE_URL/its password from backup diagnostics to HTTP."""
|
||||
message = str(exc)
|
||||
dsn = get_settings().database_url
|
||||
if dsn:
|
||||
message = message.replace(dsn, "[DATABASE_URL скрыт]")
|
||||
match = re.search(r"://[^:]+:([^@]+)@", dsn)
|
||||
if match and match.group(1):
|
||||
message = message.replace(match.group(1), "[пароль скрыт]")
|
||||
return message
|
||||
|
||||
|
||||
@router.post("/backups", response_model=BackupOut, status_code=201)
|
||||
|
|
@ -270,8 +294,11 @@ async def make_backup(request: Request) -> BackupOut:
|
|||
кнопка нужна перед занятием, расписание — чтобы о нём не вспоминали."""
|
||||
who = require(request, Role.ADMIN)
|
||||
try:
|
||||
created = backup_service.create()
|
||||
# pg_dump may run for two minutes; never block the event loop for it.
|
||||
created = await run_in_threadpool(backup_service.create)
|
||||
except backup_service.BackupError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
detail = _safe_backup_error(exc)
|
||||
await audit(who.login, who.role.value, "backup.failed", detail=detail)
|
||||
raise HTTPException(status_code=503, detail=detail) from exc
|
||||
await audit(who.login, who.role.value, "backup.create", created["name"])
|
||||
return BackupOut(**created)
|
||||
|
|
|
|||
125
backend/app/api/http/groups.py
Normal file
125
backend/app/api/http/groups.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Сводка ошибок и рекомендаций учебной группы для преподавателя."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
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.domain.roles import Role
|
||||
from app.scoring.group import ScoredAttempt, summarize
|
||||
|
||||
router = APIRouter(prefix="/api/groups", tags=["groups"])
|
||||
|
||||
|
||||
class GroupOut(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
|
||||
|
||||
class GroupCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
class GroupErrorOut(BaseModel):
|
||||
code: str
|
||||
title: str
|
||||
affected_trainees: int
|
||||
occurrences: int
|
||||
rate_percent: float
|
||||
recommendation: str
|
||||
|
||||
|
||||
class GroupAnalyticsOut(BaseModel):
|
||||
group: GroupOut
|
||||
enrolled_trainees: int
|
||||
active_trainees: int
|
||||
scored_attempts: int
|
||||
average_score: float | None
|
||||
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]
|
||||
|
||||
|
||||
@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)
|
||||
) -> GroupAnalyticsOut:
|
||||
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")
|
||||
enrolled = await db.scalar(
|
||||
select(func.count()).select_from(Trainee).where(Trainee.group_id == group_id)
|
||||
)
|
||||
rows = await db.execute(
|
||||
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)
|
||||
.where(
|
||||
or_(
|
||||
Session.group_id == group_id,
|
||||
and_(Session.group_id.is_(None), Trainee.group_id == group_id),
|
||||
),
|
||||
Session.ended_at.is_not(None),
|
||||
)
|
||||
)
|
||||
attempts = [
|
||||
ScoredAttempt(
|
||||
trainee_id=trainee_id,
|
||||
score=score,
|
||||
codes=(report or {}).get("summary", {}).get("codes", {}),
|
||||
)
|
||||
for trainee_id, score, report in rows
|
||||
]
|
||||
return GroupAnalyticsOut(
|
||||
group=GroupOut(id=group.id, name=group.name),
|
||||
**summarize(attempts, int(enrolled or 0)),
|
||||
)
|
||||
|
|
@ -8,7 +8,7 @@ from datetime import datetime
|
|||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import audit, require
|
||||
|
|
@ -117,9 +117,8 @@ async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut
|
|||
class ScoreOverride(BaseModel):
|
||||
"""Коррекция оценки преподавателем. Автооценка сохраняется рядом."""
|
||||
|
||||
score_final: float
|
||||
score_final: float = Field(ge=0, le=100)
|
||||
comment: str = ""
|
||||
author: str = "преподаватель"
|
||||
|
||||
|
||||
def _live(session_id: UUID):
|
||||
|
|
@ -188,9 +187,13 @@ async def override(session_id: UUID, body: ScoreOverride, request: Request) -> S
|
|||
state.score = {
|
||||
**state.score,
|
||||
"score_final": body.score_final,
|
||||
"overridden_by": body.author,
|
||||
"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}",
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ from pydantic import BaseModel
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import require
|
||||
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
|
||||
from app.db.base import get_session, get_sessionmaker
|
||||
from app.db.models import Group, Score, Session, Trainee
|
||||
|
||||
router = APIRouter(prefix="/api/trainees", tags=["trainees"])
|
||||
|
|
@ -61,14 +62,17 @@ class ProfileOut(BaseModel):
|
|||
|
||||
|
||||
@router.get("", response_model=list[TraineeOut])
|
||||
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[TraineeOut]:
|
||||
async def listing(request: Request) -> list[TraineeOut]:
|
||||
"""Список курсантов — преподавателю и администратору: обучающемуся он
|
||||
не нужен, а чужие фамилии из него видны."""
|
||||
require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
rows = await db.execute(
|
||||
select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True)
|
||||
)
|
||||
return [TraineeOut(id=trainee.id, name=trainee.name, group=group) for trainee, group in rows]
|
||||
if get_settings().demo_no_db:
|
||||
return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант")]
|
||||
async with get_sessionmaker()() as db:
|
||||
rows = await db.execute(
|
||||
select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True)
|
||||
)
|
||||
return [TraineeOut(id=trainee.id, name=trainee.name, group=group) for trainee, group in rows]
|
||||
|
||||
|
||||
@router.get("/{trainee_id}/profile", response_model=ProfileOut)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import math
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
|
@ -44,7 +44,7 @@ from app.api.auth import audit, principal_of
|
|||
from app.domain.roles import Role
|
||||
from app.session.hub import hub
|
||||
from app.session.state import SessionState, now_utc
|
||||
from app.domain.kio import KIO, ResponseStatus
|
||||
from app.session.dds import prepare_card
|
||||
from app.voice.models import get_voice_models
|
||||
from app.voice.pipeline import FILLERS, prefetch
|
||||
|
||||
|
|
@ -84,6 +84,23 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
)
|
||||
return
|
||||
|
||||
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:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="Очередь ДДС должна начинаться с scenario_id и содержать не более 10 карточек",
|
||||
))
|
||||
return
|
||||
scenarios = [store.get(scenario_id) for scenario_id in scenario_ids]
|
||||
if any(item is None for item in scenarios):
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID, message="В очереди ДДС есть неизвестный сценарий",
|
||||
))
|
||||
return
|
||||
else:
|
||||
scenarios = []
|
||||
|
||||
attempt = 1
|
||||
recorded_trainee_id = event.trainee_id
|
||||
if hub.journal:
|
||||
|
|
@ -114,29 +131,8 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
state.persona = PersonaState(state.scenario.persona)
|
||||
state.caller = build_caller(scenario.id)
|
||||
elif event.exercise is Exercise.DDS:
|
||||
# В ДДС поступает уже оформленная учебная карточка. Содержимое берётся
|
||||
# из утверждённого сценария, а не из действий несуществующего оператора.
|
||||
truth = scenario.ground_truth
|
||||
address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "")
|
||||
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
|
||||
service = truth.dds.value if truth.dds else None
|
||||
fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"}
|
||||
state.kio = KIO(
|
||||
registered_at=now_utc(), caller_number="+7 (495) 000-00-00",
|
||||
address=truth.address or address_fact or None,
|
||||
floor=floor.group(1) if floor else None,
|
||||
incident_type=truth.incident_type, incident_code=truth.incident_code,
|
||||
dds=truth.dds, signs=list(scenario.signs),
|
||||
notify=list(truth.notify) or ([fallback[service]] if service in fallback else []),
|
||||
victims_count=truth.victims,
|
||||
description="; ".join(fact.value for fact in scenario.facts[:3]) or scenario.first_line,
|
||||
)
|
||||
if service:
|
||||
state.dispatch(service)
|
||||
else:
|
||||
state.kio.response_status = ResponseStatus.TRANSFERRED
|
||||
state.dispatched_card = state.kio.model_copy(deep=True)
|
||||
state.dispatched_at = now_utc()
|
||||
state.dds_scenarios = [item.model_copy(deep=True) for item in scenarios]
|
||||
prepare_card(state, state.dds_scenarios[0])
|
||||
state.started_at = state.dispatched_at
|
||||
else:
|
||||
state.started_at = now_utc()
|
||||
|
|
@ -151,8 +147,6 @@ 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")
|
||||
elif event.exercise is Exercise.DDS:
|
||||
state.on_event("dds.dispatch")
|
||||
hub.start_ticker(session_id)
|
||||
if who is not None:
|
||||
# Запуск занятия меняет чужой результат — значит попадает в журнал
|
||||
|
|
@ -265,15 +259,39 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
if state is not None:
|
||||
hub.to_observers(session_id, ReferenceStarted(scenario_id=state.scenario_id))
|
||||
case "score.override":
|
||||
if event.session_id != session_id:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.FORBIDDEN,
|
||||
message="Оценка относится к другому занятию",
|
||||
))
|
||||
continue
|
||||
state = hub.get(session_id)
|
||||
if state is not None and state.score is not None:
|
||||
try:
|
||||
verdict = float(event.verdict)
|
||||
except ValueError:
|
||||
verdict = float("nan")
|
||||
if not math.isfinite(verdict) or not 0 <= verdict <= 100:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||
message="Оценка должна быть числом от 0 до 100",
|
||||
))
|
||||
continue
|
||||
# Автооценка остаётся рядом: видно, что скорректировано и кем.
|
||||
state.score = {
|
||||
**state.score,
|
||||
"score_final": float(event.verdict) if event.verdict.replace(".", "", 1).isdigit() else state.score["score_auto"],
|
||||
"overridden_by": "преподаватель",
|
||||
"score_final": verdict,
|
||||
"overridden_by": who.login,
|
||||
"override_comment": event.comment,
|
||||
}
|
||||
if hub.journal:
|
||||
await hub.journal.score_override(
|
||||
session_id, verdict, who.login, event.comment
|
||||
)
|
||||
await audit(
|
||||
who.login, who.role.value, "score.override", str(session_id),
|
||||
f"{state.score.get('score_auto')} → {verdict}: {event.comment}",
|
||||
)
|
||||
hub.to_observers(session_id, ScoreReady(session_id=session_id))
|
||||
case "director.inject":
|
||||
state = hub.get(session_id)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ from app.domain.roles import Role
|
|||
from app.session.hub import hub
|
||||
from app.session.state import now_utc
|
||||
from app.session.finish import finish
|
||||
from app.session.finish import score_current_dds
|
||||
from app.session.dds import prepare_card
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
|
@ -50,6 +52,18 @@ def _error(session_id: UUID, message: str) -> None:
|
|||
hub.to_station(session_id, ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=message))
|
||||
|
||||
|
||||
async def _finish_dds(session_id: UUID, state) -> None:
|
||||
state.ended_at = now_utc()
|
||||
state.end_reason = CallEndReason.COMPLETE
|
||||
hub.stop_ticker(session_id)
|
||||
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
if hub.journal:
|
||||
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.COMPLETE.value)
|
||||
await finish(session_id, state)
|
||||
hub.to_station(session_id, ScoreReady(session_id=session_id))
|
||||
|
||||
|
||||
async def _handle(session_id: UUID, state, event) -> None:
|
||||
if state.ended:
|
||||
_error(session_id, "Занятие уже завершено")
|
||||
|
|
@ -104,6 +118,7 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
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":
|
||||
crew = state.crew_selected
|
||||
service = state.crew_service(crew) if crew else None
|
||||
|
|
@ -126,20 +141,37 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
text=REPORT_TEXT[phase], at=now_utc(),
|
||||
)
|
||||
state.phone_reports.append(report)
|
||||
state.dds_log.append(("phone.dial", now_utc(), crew))
|
||||
hub.to_station(session_id, PhoneReport(**report.model_dump()))
|
||||
case "card.reply":
|
||||
if state.exercise is not Exercise.DDS or not state.dispatched_card or (
|
||||
event.card_id != state.dispatched_card.card_id
|
||||
):
|
||||
_error(session_id, "Ответ относится не к текущей карточке")
|
||||
return
|
||||
state.reply_text = event.text
|
||||
state.reply_log.append((now_utc(), event.text))
|
||||
case "card.next":
|
||||
if state.exercise is not Exercise.DDS or not state.dispatched_card or (
|
||||
event.card_id != state.dispatched_card.card_id
|
||||
):
|
||||
_error(session_id, "Следующая карточка недоступна: ID текущей не совпадает")
|
||||
return
|
||||
if any(item.card_id == event.card_id for item in state.dds_completed):
|
||||
_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])
|
||||
hub.to_station(session_id, state.card_received_event())
|
||||
else:
|
||||
await _finish_dds(session_id, state)
|
||||
case "station.finish":
|
||||
if state.exercise is not Exercise.DDS:
|
||||
_error(session_id, "Операторское занятие завершается после звонка 112")
|
||||
return
|
||||
state.ended_at = now_utc()
|
||||
state.end_reason = CallEndReason.COMPLETE
|
||||
hub.stop_ticker(session_id)
|
||||
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
if hub.journal:
|
||||
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.COMPLETE.value)
|
||||
await finish(session_id, state)
|
||||
hub.to_station(session_id, ScoreReady(session_id=session_id))
|
||||
await _finish_dds(session_id, state)
|
||||
case "card.bounce":
|
||||
# Карточка вернулась: в разборе это E6 с конкретной причиной.
|
||||
state.bounced_fields = list(event.missing_fields)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ class Settings(BaseSettings):
|
|||
|
||||
# Данные
|
||||
database_url: str = "postgresql+asyncpg://lct:lct@localhost:5432/lct"
|
||||
# Только локальная демонстрация: живые занятия и отчёты в памяти, без
|
||||
# Postgres и без долговременного журнала. Не включать на учебном стенде.
|
||||
demo_no_db: bool = False
|
||||
|
||||
# Голосовой контур
|
||||
models_dir: str = "models"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
|
@ -45,5 +46,7 @@ def reset() -> None:
|
|||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
"""Зависимость FastAPI: сессия на запрос, коммит явный."""
|
||||
if get_settings().demo_no_db:
|
||||
raise HTTPException(status_code=503, detail="database_disabled_demo")
|
||||
async with get_sessionmaker()() as session:
|
||||
yield session
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ async def ensure_session(
|
|||
scenario_id=scenario_id,
|
||||
mode=mode,
|
||||
trainee_id=trainee.id if trainee else None,
|
||||
group_id=group.id if group else None,
|
||||
group_id=group.id if group else trainee.group_id if trainee else None,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -418,6 +418,7 @@ ServerToObserver = Annotated[
|
|||
class ScenarioStart(BaseModel):
|
||||
type: Literal["scenario.start"] = "scenario.start"
|
||||
scenario_id: str
|
||||
scenario_ids: list[str] | None = None
|
||||
trainee: str
|
||||
trainee_id: UUID | None = None
|
||||
group_id: str | None = None
|
||||
|
|
@ -483,6 +484,8 @@ class CardReceived(BaseModel):
|
|||
card: KIO
|
||||
from_operator: str
|
||||
at: datetime
|
||||
card_index: int = 1
|
||||
card_total: int = 1
|
||||
|
||||
|
||||
class CardAck(BaseModel):
|
||||
|
|
@ -535,6 +538,17 @@ class StationFinish(BaseModel):
|
|||
type: Literal["station.finish"] = "station.finish"
|
||||
|
||||
|
||||
class CardReply(BaseModel):
|
||||
type: Literal["card.reply"] = "card.reply"
|
||||
card_id: UUID
|
||||
text: str = Field(max_length=2000)
|
||||
|
||||
|
||||
class CardNext(BaseModel):
|
||||
type: Literal["card.next"] = "card.next"
|
||||
card_id: UUID
|
||||
|
||||
|
||||
class StationState(BaseModel):
|
||||
"""Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше."""
|
||||
|
||||
|
|
@ -564,6 +578,7 @@ ServerToStation = Annotated[
|
|||
|
||||
StationToServer = Annotated[
|
||||
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish
|
||||
| CardReply | CardNext
|
||||
| ZoneDecision | CrewDispatched | CrewArrived,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
|
@ -616,6 +631,17 @@ class SelfAssessmentDiff(BaseModel):
|
|||
overcautious: list[str] = []
|
||||
|
||||
|
||||
class DdsCardReport(BaseModel):
|
||||
card_id: UUID
|
||||
scenario_id: str
|
||||
score_auto: float
|
||||
reply_text: str
|
||||
metrics: list[Metric]
|
||||
findings: list[Finding]
|
||||
actions: list[dict[str, Any]] = []
|
||||
duration_ms: int = 0
|
||||
|
||||
|
||||
class SessionReport(BaseModel):
|
||||
"""Единица истории: из отчётов складываются профиль, дельта попыток, аналитика."""
|
||||
|
||||
|
|
@ -626,6 +652,7 @@ class SessionReport(BaseModel):
|
|||
transcript: list[TranscriptEntry]
|
||||
findings: list[Finding]
|
||||
metrics: list[Metric]
|
||||
card_results: list[DdsCardReport] = []
|
||||
competencies: list[CompetencyScore]
|
||||
reference_questions: list[HintShown]
|
||||
missed_checklist: list[str]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
|
@ -134,6 +135,12 @@ class PhoneReportRecord(BaseModel):
|
|||
at: datetime
|
||||
|
||||
|
||||
class DdsCardSummary(BaseModel):
|
||||
card_id: UUID
|
||||
scenario_id: str
|
||||
score_auto: float
|
||||
|
||||
|
||||
class StatusError(ValueError):
|
||||
"""Переход запрещён автоматом или нет обязательного комментария."""
|
||||
|
||||
|
|
@ -208,3 +215,8 @@ class StationSnapshot(BaseModel):
|
|||
crew_options: list[str] = []
|
||||
crew_selected: str | None = None
|
||||
phone_reports: list[PhoneReportRecord] = []
|
||||
card_id: UUID | None = None
|
||||
card_index: int = 1
|
||||
card_total: int = 1
|
||||
reply_text: str = ""
|
||||
completed_cards: list[DdsCardSummary] = []
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from pathlib import Path
|
|||
from app.api import auth
|
||||
from app.api.http import admin as admin_api
|
||||
from app.api.http import ekp as ekp_api
|
||||
from app.api.http import groups as groups_api
|
||||
from app.api.http import scenarios as scenarios_api
|
||||
from app.api.http import sessions
|
||||
from app.api.http import trainees
|
||||
|
|
@ -41,6 +42,9 @@ logging.getLogger("app").setLevel(logging.INFO)
|
|||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
if settings.demo_no_db and not settings.dev_auth_bypass:
|
||||
raise RuntimeError("DEMO_NO_DB требует DEV_AUTH_BYPASS=true для локального входа")
|
||||
# Библиотека проверяется на старте целиком: сломанный сценарий, найденный
|
||||
# посреди занятия, — сценарий, которого не должно случиться.
|
||||
try:
|
||||
|
|
@ -51,22 +55,27 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
# Утверждённые преподавателем сценарии хранятся в БД и должны переживать
|
||||
# перезапуск процесса. При недоступной БД остаётся базовая YAML-библиотека.
|
||||
try:
|
||||
# БД может ещё подниматься или отсутствовать в unit-тесте. Не задерживаем
|
||||
# старт АРМ на минуту ради необязательной пользовательской библиотеки.
|
||||
async with asyncio.timeout(1):
|
||||
async with get_sessionmaker()() as db:
|
||||
app.state.scenarios_loaded += await store.restore_published(db)
|
||||
except (SQLAlchemyError, OSError, TimeoutError) as exc:
|
||||
if not settings.demo_no_db:
|
||||
try:
|
||||
# БД может ещё подниматься или отсутствовать в unit-тесте. Не задерживаем
|
||||
# старт АРМ на минуту ради необязательной пользовательской библиотеки.
|
||||
async with asyncio.timeout(1):
|
||||
async with get_sessionmaker()() as db:
|
||||
app.state.scenarios_loaded += await store.restore_published(db)
|
||||
except (SQLAlchemyError, OSError, TimeoutError) as exc:
|
||||
logging.getLogger(__name__).warning(
|
||||
"не удалось восстановить утверждённые сценарии из БД: %s", exc
|
||||
)
|
||||
else:
|
||||
logging.getLogger(__name__).warning(
|
||||
"не удалось восстановить утверждённые сценарии из БД: %s", exc
|
||||
"DEMO_NO_DB: занятия и оценки живут только до перезапуска; журнал БД выключен"
|
||||
)
|
||||
|
||||
# Журнал: всё, что не записано, для оценки не существует.
|
||||
hub.journal = DbJournal(get_sessionmaker())
|
||||
hub.journal = None if settings.demo_no_db else DbJournal(get_sessionmaker())
|
||||
|
||||
# Эмбеддинги для слот-автомата — грузятся один раз, до первого занятия.
|
||||
app.state.embeddings_ready = get_embedder() is not None
|
||||
app.state.embeddings_ready = not settings.demo_no_db and get_embedder() is not None
|
||||
|
||||
# Модели речи: ~5 секунд на старте стенда вместо паузы на первом звонке.
|
||||
app.state.models_ready = get_voice_models() is not None
|
||||
|
|
@ -92,6 +101,7 @@ app.include_router(auth.router)
|
|||
app.include_router(sessions.router)
|
||||
app.include_router(scenarios_api.router)
|
||||
app.include_router(ekp_api.router)
|
||||
app.include_router(groups_api.router)
|
||||
app.include_router(admin_api.router)
|
||||
app.include_router(trainees.router)
|
||||
app.include_router(call_ws.router)
|
||||
|
|
@ -110,4 +120,5 @@ async def health() -> dict:
|
|||
"scenarios_loaded": getattr(app.state, "scenarios_loaded", 0),
|
||||
"embeddings_ready": getattr(app.state, "embeddings_ready", False),
|
||||
"offline": settings.offline,
|
||||
"demo_no_db": settings.demo_no_db,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|||
|
||||
from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
|
||||
from app.domain.events import Mood
|
||||
from app.scoring.taxonomy import METRIC_MAP
|
||||
|
||||
|
||||
class Strict(BaseModel):
|
||||
|
|
@ -157,6 +158,9 @@ class Scenario(Strict):
|
|||
facts: list[Fact] = []
|
||||
checklist: list[ChecklistItem] = []
|
||||
required_fields: list[str] = Field(default_factory=list)
|
||||
# Учебные веса задаёт преподаватель в утверждаемом сценарии. Ноль
|
||||
# отключает метрику из знаменателя; значения методики предварительные.
|
||||
score_weights: dict[str, float] = Field(default_factory=dict)
|
||||
# Реплики оператора, которые вопросом не являются: «успокойтесь»,
|
||||
# «оставайтесь на линии». Общий список — checklists/common.yaml,
|
||||
# сценарий может дополнить своими.
|
||||
|
|
@ -165,6 +169,15 @@ class Scenario(Strict):
|
|||
era_glonass: EraGlonass | None = None
|
||||
tree: Tree = Tree()
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_score_weights(self):
|
||||
unknown = self.score_weights.keys() - METRIC_MAP.keys()
|
||||
if unknown:
|
||||
raise ValueError(f"неизвестные метрики score_weights: {', '.join(sorted(unknown))}")
|
||||
if any(not 0 <= weight <= 10 for weight in self.score_weights.values()):
|
||||
raise ValueError("score_weights: каждый вес должен быть от 0 до 10")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def ticket_needs_position(self):
|
||||
if (self.ticket is None) != (self.position is None):
|
||||
|
|
|
|||
69
backend/app/scoring/group.py
Normal file
69
backend/app/scoring/group.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Воспроизводимая аналитика учебной группы по сохранённым оценкам.
|
||||
|
||||
Рекомендации здесь основаны на кодах ошибок, а не выдаются за вывод ИИ.
|
||||
Повторные попытки учитываются в среднем балле, но один курсант не может
|
||||
увеличить долю группы повторением одной и той же ошибки.
|
||||
"""
|
||||
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.taxonomy import ERRORS, ErrorCode
|
||||
|
||||
|
||||
RECOMMENDATIONS: dict[str, str] = {
|
||||
"E1": "Повторить опросную карту и обязательные уточняющие вопросы.",
|
||||
"E2": "Отработать классификацию происшествия и маршрутизацию по ЕКП.",
|
||||
"E3": "Провести тренировку по временным нормативам.",
|
||||
"E4": "Разобрать управление разговором и коммуникацию в стрессе.",
|
||||
"E5": "Потренировать полноту заполнения карточки 112.",
|
||||
"E6": "Повторить проверку карточки перед завершением и передачей.",
|
||||
"D1": "Отработать первичную отметку ДДС в течение 30 секунд.",
|
||||
"D2": "Сверять статусы реагирования с фактическими докладами.",
|
||||
"D3": "Разобрать профильность обращений и допустимость отказа.",
|
||||
"D4": "Тренировать обоснование отказа в комментарии.",
|
||||
"D5": "Тренировать полноту комментария: действие и передача информации.",
|
||||
"D6": "Повторить цепочку статусов выезда, прибытия и работ.",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScoredAttempt:
|
||||
trainee_id: UUID | None
|
||||
score: float
|
||||
codes: dict[str, int]
|
||||
|
||||
|
||||
def summarize(attempts: list[ScoredAttempt], enrolled: int) -> dict:
|
||||
"""Сводка только по завершённым попыткам с оценкой."""
|
||||
active = {item.trainee_id for item in attempts if item.trainee_id is not None}
|
||||
affected: dict[str, set[UUID]] = defaultdict(set)
|
||||
occurrences: Counter[str] = Counter()
|
||||
for attempt in attempts:
|
||||
for code, count in attempt.codes.items():
|
||||
if code not in RECOMMENDATIONS or not isinstance(count, int) or count <= 0:
|
||||
continue
|
||||
occurrences[code] += count
|
||||
if attempt.trainee_id is not None:
|
||||
affected[code].add(attempt.trainee_id)
|
||||
|
||||
errors = []
|
||||
for code in occurrences:
|
||||
errors.append({
|
||||
"code": code,
|
||||
"title": ERRORS[ErrorCode(code)].title,
|
||||
"affected_trainees": len(affected[code]),
|
||||
"occurrences": occurrences[code],
|
||||
"rate_percent": round(100 * len(affected[code]) / len(active), 1) if active else 0.0,
|
||||
"recommendation": RECOMMENDATIONS[code],
|
||||
})
|
||||
errors.sort(key=lambda item: (-item["affected_trainees"], -item["occurrences"], item["code"]))
|
||||
return {
|
||||
"enrolled_trainees": enrolled,
|
||||
"active_trainees": len(active),
|
||||
"scored_attempts": len(attempts),
|
||||
"average_score": round(sum(item.score for item in attempts) / len(attempts), 1)
|
||||
if attempts else None,
|
||||
"errors": errors,
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ from uuid import UUID
|
|||
|
||||
from app.domain.events import (
|
||||
CompetencyScore,
|
||||
DdsCardReport,
|
||||
HintShown,
|
||||
HintUsage,
|
||||
InstructorNoteShown,
|
||||
|
|
@ -68,23 +69,27 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
|||
step.checklist_id
|
||||
for step in reference.steps
|
||||
if step.required and revealed is not None and step.fact_id not in revealed
|
||||
] if state.exercise is not Exercise.CARD else []
|
||||
] if state.exercise is Exercise.CALL else []
|
||||
|
||||
return SessionReport(
|
||||
session_id=session_id,
|
||||
scenario_id=scenario.id,
|
||||
scenario_id=(state.dds_scenarios[0].id
|
||||
if state.exercise is Exercise.DDS and state.dds_scenarios
|
||||
else scenario.id),
|
||||
mode=state.mode,
|
||||
attempt=state.attempt,
|
||||
transcript=list(state.transcript),
|
||||
findings=[Finding.model_validate(item) for item in score.get("findings", [])],
|
||||
metrics=[Metric.model_validate(item) for item in score.get("metrics", [])],
|
||||
card_results=[DdsCardReport.model_validate(item)
|
||||
for item in score.get("card_results", [])],
|
||||
competencies=[CompetencyScore.model_validate(item) for item in score.get("competencies", [])],
|
||||
# Эталонные вопросы по шагам: по каждому пропущенному пункту видно,
|
||||
# какой вопрос был правильным.
|
||||
reference_questions=[
|
||||
HintShown(checklist_id=step.checklist_id, question=step.question)
|
||||
for step in reference.steps
|
||||
] if state.exercise is not Exercise.CARD else [],
|
||||
] if state.exercise is Exercise.CALL else [],
|
||||
missed_checklist=missed_checklist,
|
||||
hints_used=[
|
||||
HintUsage(checklist_id=checklist_id, question=questions.get(checklist_id, ""), at=at)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
|
|||
"dds_contact": (ErrorCode.D6, Competency.COMMUNICATION),
|
||||
"dds_progress": (ErrorCode.D6, Competency.CARD),
|
||||
"dds_completion": (ErrorCode.D6, Competency.CARD),
|
||||
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
|
||||
}
|
||||
|
||||
#: Вес метрики в детерминированной оценке.
|
||||
|
|
|
|||
10
backend/app/scoring/weights.py
Normal file
10
backend/app/scoring/weights.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"""Пересчёт предварительной шкалы под утверждённый методистом сценарий."""
|
||||
|
||||
from app.scoring.gost import GostResult
|
||||
|
||||
|
||||
def apply_weights(result: GostResult, overrides: dict[str, float]) -> None:
|
||||
"""Меняет только веса метрик; факты, нормативы и ошибки неизменны."""
|
||||
for metric in result.metrics:
|
||||
if metric.key in overrides:
|
||||
metric.weight = overrides[metric.key]
|
||||
50
backend/app/session/dds.py
Normal file
50
backend/app/session/dds.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Выдача очередной учебной карточки ДДС в рамках одного занятия."""
|
||||
|
||||
import re
|
||||
|
||||
from app.domain.kio import KIO, ResponseStatus
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.session.state import SessionState, now_utc
|
||||
from app.session.timers import SessionTimers
|
||||
|
||||
|
||||
def prepare_card(state: SessionState, scenario: Scenario) -> None:
|
||||
"""Сменить активную карточку, сохранив общий ID занятия и прошлые результаты."""
|
||||
state.scenario = scenario.model_copy(deep=True)
|
||||
state.scenario_id = scenario.id
|
||||
state.scenario_title = scenario.title
|
||||
state.level = scenario.level.value
|
||||
state.required_fields = list(scenario.required_fields)
|
||||
truth = scenario.ground_truth
|
||||
address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "")
|
||||
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
|
||||
service = truth.dds.value if truth.dds else None
|
||||
fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"}
|
||||
state.kio = KIO(
|
||||
registered_at=now_utc(), caller_number="+7 (495) 000-00-00",
|
||||
address=truth.address or address_fact or None,
|
||||
floor=floor.group(1) if floor else None,
|
||||
incident_type=truth.incident_type, incident_code=truth.incident_code,
|
||||
dds=truth.dds, signs=list(scenario.signs),
|
||||
notify=list(truth.notify) or ([fallback[service]] if service in fallback else []),
|
||||
victims_count=truth.victims,
|
||||
description="; ".join(fact.value for fact in scenario.facts[:3]) or scenario.first_line,
|
||||
)
|
||||
state.dispatched_card = None
|
||||
if service:
|
||||
state.dispatch(service)
|
||||
else:
|
||||
state.kio.response_status = ResponseStatus.TRANSFERRED
|
||||
state.dispatched_card = state.kio.model_copy(deep=True)
|
||||
state.dispatched_at = now_utc()
|
||||
|
||||
state.status_log.clear()
|
||||
state.dds_log.clear()
|
||||
state.bounced_fields.clear()
|
||||
state.crew_selected = None
|
||||
state.crew_assignments.clear()
|
||||
state.phone_reports.clear()
|
||||
state.reply_text = ""
|
||||
state.reply_log.clear()
|
||||
state.timers = SessionTimers()
|
||||
state.on_event("dds.dispatch")
|
||||
|
|
@ -10,18 +10,82 @@
|
|||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.events import Exercise, ScoreReady
|
||||
from app.domain.events import Exercise, Metric, ScoreReady
|
||||
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||
from app.domain.timers import NORMATIVES, TimerCode
|
||||
from app.scenarios import store
|
||||
from app.scoring.competency import radar
|
||||
from app.scoring.card import evaluate_card
|
||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||
from app.scoring.gost import GostResult, evaluate
|
||||
from app.scoring.weights import apply_weights
|
||||
from app.session.hub import hub
|
||||
from app.session.state import DdsCardRecord, now_utc
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def score_current_dds(state) -> DdsCardRecord:
|
||||
"""Оценить активную карточку отдельно, до выдачи следующей."""
|
||||
number = state.dds_card_index + 1
|
||||
findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=state.notified_services(),
|
||||
deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms,
|
||||
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
||||
)
|
||||
metrics = dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms)
|
||||
reply = state.reply_text.strip()
|
||||
if reply or len(state.dds_scenarios) > 1:
|
||||
passed = len(reply) >= 20 and len(reply.split()) >= 3
|
||||
metrics.append(Metric(
|
||||
key="dds_reply", title="Свободный ответ по карточке",
|
||||
fact=reply or "ответ отсутствует",
|
||||
norm="содержательный текст не короче 20 символов и 3 слов",
|
||||
ref="ТЗ: действия с карточками, ввод текста",
|
||||
passed=passed, weight=1.5,
|
||||
))
|
||||
if not passed:
|
||||
findings.append(Finding(
|
||||
code=ErrorCode.D5, source=FindingSource.DISPATCHER,
|
||||
summary="Свободный ответ отсутствует или слишком краток",
|
||||
fact=reply or "ответ отсутствует",
|
||||
norm="не менее 20 символов и 3 слов; грамматика отдельно не проверялась",
|
||||
ref="ТЗ: действия с карточками, ввод текста",
|
||||
competency=Competency.COMMUNICATION,
|
||||
))
|
||||
weighted = GostResult(metrics=metrics, findings=findings)
|
||||
apply_weights(weighted, state.scenario.score_weights)
|
||||
actions = [
|
||||
{"type": "card.status", "service": mark.service, "status": mark.status.value,
|
||||
"comment": mark.comment, "at": mark.at.isoformat()}
|
||||
for mark in state.status_log
|
||||
] + [
|
||||
{"type": "phone.report", "service": report.service, "crew": report.crew,
|
||||
"phase": report.phase, "text": report.text, "at": report.at.isoformat()}
|
||||
for report in state.phone_reports
|
||||
] + [
|
||||
{"type": "card.reply", "text": text, "at": at.isoformat()}
|
||||
for at, text in state.reply_log
|
||||
] + [
|
||||
{"type": kind, "text": text or "", "at": at.isoformat()}
|
||||
for kind, at, text in state.dds_log
|
||||
]
|
||||
actions.sort(key=lambda item: item["at"])
|
||||
return DdsCardRecord(
|
||||
card_id=state.dispatched_card.card_id,
|
||||
scenario_id=state.scenario_id,
|
||||
reply_text=state.reply_text,
|
||||
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}"})
|
||||
for metric in weighted.metrics],
|
||||
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}"})
|
||||
for finding in weighted.findings],
|
||||
actions=actions,
|
||||
duration_ms=(max(0, int((now_utc() - state.dispatched_at).total_seconds() * 1000))
|
||||
if state.dispatched_at else 0),
|
||||
)
|
||||
|
||||
|
||||
async def finish(session_id: UUID, state) -> None:
|
||||
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
|
||||
scenario = state.scenario or store.get(state.scenario_id)
|
||||
|
|
@ -31,9 +95,22 @@ async def finish(session_id: UUID, state) -> None:
|
|||
if state.exercise is Exercise.CARD:
|
||||
result = evaluate_card(scenario, state.dispatched_card or state.kio)
|
||||
elif state.exercise is Exercise.DDS:
|
||||
# В готовой карточке не было входящего звонка, опроса и действий
|
||||
# оператора 112. Ни одна E-метрика здесь не применима.
|
||||
# В ДДС могут последовательно пройти несколько карточек. Уже закрытые
|
||||
# сохранены отдельными результатами; активную оцениваем лишь однажды.
|
||||
result = GostResult()
|
||||
cards = list(state.dds_completed)
|
||||
if state.dispatched_card and not any(
|
||||
item.card_id == state.dispatched_card.card_id for item in cards
|
||||
):
|
||||
cards.append(score_current_dds(state))
|
||||
state.dds_completed = cards
|
||||
for card in cards:
|
||||
result.metrics.extend(card.metrics)
|
||||
result.findings.extend(card.findings)
|
||||
result.unavailable.append(
|
||||
"Грамматика свободного ответа: автоматическая проверка не настроена; "
|
||||
"оценивались только наличие и минимальная полнота текста"
|
||||
)
|
||||
else:
|
||||
result = evaluate(
|
||||
scenario=scenario,
|
||||
|
|
@ -49,7 +126,7 @@ async def finish(session_id: UUID, state) -> None:
|
|||
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
||||
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
||||
# участвуют оба (docs/spec/DATASET.md#статусы-реагирования).
|
||||
if state.dispatched_card is not None and state.exercise is not Exercise.CARD:
|
||||
if state.dispatched_card is not None and state.exercise is Exercise.CALL:
|
||||
dispatcher_findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=state.notified_services(),
|
||||
|
|
@ -58,6 +135,8 @@ async def finish(session_id: UUID, state) -> None:
|
|||
)
|
||||
result.findings.extend(dispatcher_findings)
|
||||
result.metrics.extend(dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms))
|
||||
if state.exercise is not Exercise.DDS:
|
||||
apply_weights(result, scenario.score_weights)
|
||||
|
||||
# Сводка числами: по ней считается дельта между попытками в профиле.
|
||||
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
|
||||
|
|
@ -82,6 +161,14 @@ async def finish(session_id: UUID, state) -> None:
|
|||
"unavailable": result.unavailable,
|
||||
# Статус карточки — готовая красная метка, понятная любому диспетчеру.
|
||||
"card_status": state.station_snapshot().card.value,
|
||||
"card_results": [
|
||||
{"card_id": str(card.card_id), "scenario_id": card.scenario_id,
|
||||
"score_auto": card.score_auto, "reply_text": card.reply_text,
|
||||
"actions": card.actions, "duration_ms": card.duration_ms,
|
||||
"metrics": [metric.model_dump(mode="json") for metric in card.metrics],
|
||||
"findings": [finding.model_dump(mode="json") for finding in card.findings]}
|
||||
for card in cards
|
||||
] if state.exercise is Exercise.DDS else [],
|
||||
}
|
||||
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,24 @@ class DbJournal:
|
|||
|
||||
await self._write(lambda db: action(db))
|
||||
|
||||
async def score_override(
|
||||
self, session_id: UUID, score_final: float, author: str, comment: str
|
||||
) -> None:
|
||||
"""Сохранить решение преподавателя рядом с неизменной автооценкой."""
|
||||
async def action(db):
|
||||
await db.execute(
|
||||
update(Score)
|
||||
.where(Score.session_id == session_id)
|
||||
.values(
|
||||
score_final=score_final,
|
||||
overridden_by=author,
|
||||
override_comment=comment,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
|
||||
async def session_started(self, session_id: UUID, at: datetime) -> None:
|
||||
async def action(db):
|
||||
await db.execute(update(Session).where(Session.id == session_id).values(started_at=at))
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from app.domain.events import (
|
|||
Mood,
|
||||
SessionMode,
|
||||
SessionSnapshot,
|
||||
Metric,
|
||||
Speaker,
|
||||
TranscriptEntry,
|
||||
)
|
||||
|
|
@ -29,6 +30,7 @@ from app.domain.statuses import (
|
|||
StationSnapshot,
|
||||
StatusEntry,
|
||||
PhoneReportRecord,
|
||||
DdsCardSummary,
|
||||
card_status,
|
||||
check,
|
||||
current,
|
||||
|
|
@ -36,6 +38,7 @@ from app.domain.statuses import (
|
|||
from app.domain.timers import NORMATIVES, TimerCode
|
||||
from app.domain.kio import KIO, ResponseStatus, apply_patch
|
||||
from app.session.timers import SessionTimers
|
||||
from app.domain.taxonomy import Finding
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
|
|
@ -43,6 +46,23 @@ def now_utc() -> datetime:
|
|||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DdsCardRecord:
|
||||
card_id: UUID
|
||||
scenario_id: str
|
||||
reply_text: str
|
||||
metrics: list[Metric]
|
||||
findings: list[Finding]
|
||||
actions: list[dict[str, Any]]
|
||||
duration_ms: int
|
||||
|
||||
@property
|
||||
def score_auto(self) -> float:
|
||||
total = sum(metric.weight for metric in self.metrics)
|
||||
passed = sum(metric.weight for metric in self.metrics if metric.passed)
|
||||
return round(100 * passed / total, 1) if total else 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionState:
|
||||
session_id: UUID
|
||||
|
|
@ -102,6 +122,11 @@ class SessionState:
|
|||
crew_selected: str | None = None
|
||||
crew_assignments: dict[str, str] = field(default_factory=dict)
|
||||
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
|
||||
dds_scenarios: list[Scenario] = field(default_factory=list)
|
||||
dds_card_index: int = 0
|
||||
dds_completed: list[DdsCardRecord] = field(default_factory=list)
|
||||
reply_text: str = ""
|
||||
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
|
||||
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
|
||||
resolved_outcome: str | None = None
|
||||
resolve_comment: str = ""
|
||||
|
|
@ -180,6 +205,14 @@ class SessionState:
|
|||
crew_options=self.crew_options(),
|
||||
crew_selected=self.crew_selected,
|
||||
phone_reports=list(self.phone_reports),
|
||||
card_id=self.dispatched_card.card_id if self.dispatched_card else None,
|
||||
card_index=self.dds_card_index + 1,
|
||||
card_total=len(self.dds_scenarios) or 1,
|
||||
reply_text=self.reply_text,
|
||||
completed_cards=[DdsCardSummary(card_id=item.card_id,
|
||||
scenario_id=item.scenario_id,
|
||||
score_auto=item.score_auto)
|
||||
for item in self.dds_completed],
|
||||
)
|
||||
|
||||
def card_received_event(self):
|
||||
|
|
@ -191,6 +224,8 @@ class SessionState:
|
|||
from_operator=("учебный сценарий" if self.exercise is Exercise.DDS
|
||||
else self.trainee_name or "оператор 112"),
|
||||
at=self.dispatched_at or now_utc(),
|
||||
card_index=self.dds_card_index + 1,
|
||||
card_total=len(self.dds_scenarios) or 1,
|
||||
)
|
||||
|
||||
@property
|
||||
|
|
|
|||
167
backend/tests/test_auth_hardening.py
Normal file
167
backend/tests/test_auth_hardening.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""Regressions for stale cookies and privileged admin operations."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
import app.api.auth as auth
|
||||
from app.api.http import admin
|
||||
from app.domain.roles import Role
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with TestClient(app) as test_client:
|
||||
hub.journal = None
|
||||
yield test_client
|
||||
|
||||
|
||||
def test_account_change_revokes_http_and_new_websocket_handshakes(client):
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
assert client.get("/api/auth/me").status_code == 200
|
||||
auth.invalidate_login("dev")
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
with client.websocket_connect(f"/ws/control/{uuid4()}") as socket:
|
||||
assert socket.receive_json()["code"] == "forbidden"
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
assert client.get("/api/auth/me").status_code == 200
|
||||
|
||||
|
||||
def test_account_change_closes_an_existing_websocket(client):
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
with client.websocket_connect(f"/ws/control/{uuid4()}") as socket:
|
||||
auth.invalidate_login("dev")
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
socket.receive_json()
|
||||
assert exc.value.code == 1008
|
||||
|
||||
|
||||
def test_cookie_from_previous_process_is_rejected(client, monkeypatch):
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
monkeypatch.setattr(auth, "_INSTANCE", "new-server-instance")
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
class FakeDb:
|
||||
def __init__(self, user):
|
||||
self.user = user
|
||||
self.commits = 0
|
||||
self.added = []
|
||||
|
||||
async def get(self, _model, _id):
|
||||
return self.user
|
||||
|
||||
def add(self, object_):
|
||||
self.added.append(object_)
|
||||
|
||||
async def flush(self):
|
||||
for object_ in self.added:
|
||||
if getattr(object_, "id", None) is None:
|
||||
object_.id = uuid4()
|
||||
|
||||
async def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
|
||||
def fake_user(login="victim", role="instructor"):
|
||||
return SimpleNamespace(
|
||||
id=uuid4(), login=login, full_name="Проверка", role=role,
|
||||
service=None, trainee_id=None, blocked=False,
|
||||
password_hash="old", created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_patch_revokes_cookie_after_commit(monkeypatch):
|
||||
user = fake_user()
|
||||
db = FakeDb(user)
|
||||
calls = []
|
||||
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
|
||||
login="admin", full_name="Администратор", role=Role.ADMIN,
|
||||
))
|
||||
monkeypatch.setattr(admin, "invalidate_login", lambda login: calls.append((login, db.commits)))
|
||||
|
||||
async def no_audit(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(admin, "audit", no_audit)
|
||||
await admin.patch_user(user.id, admin.UserPatch(blocked=True), object(), db)
|
||||
assert user.blocked is True
|
||||
assert calls == [("victim", 1)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_cannot_demote_self(monkeypatch):
|
||||
user = fake_user(login="admin", role="admin")
|
||||
db = FakeDb(user)
|
||||
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
|
||||
login="admin", full_name="Администратор", role=Role.ADMIN,
|
||||
))
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await admin.patch_user(user.id, admin.UserPatch(role=Role.TRAINEE), object(), db)
|
||||
assert exc.value.status_code == 409
|
||||
assert db.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_promotion_to_trainee_creates_profile(monkeypatch):
|
||||
user = fake_user()
|
||||
db = FakeDb(user)
|
||||
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
|
||||
login="admin", full_name="Администратор", role=Role.ADMIN,
|
||||
))
|
||||
|
||||
async def no_audit(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(admin, "audit", no_audit)
|
||||
await admin.patch_user(user.id, admin.UserPatch(role=Role.TRAINEE), object(), db)
|
||||
assert user.role == "trainee"
|
||||
assert user.trainee_id is not None
|
||||
assert db.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_runs_off_event_loop_and_failure_is_audited(monkeypatch):
|
||||
who = auth.Principal(login="admin", full_name="Администратор", role=Role.ADMIN)
|
||||
monkeypatch.setattr(admin, "require", lambda _request, *_roles: who)
|
||||
calls = []
|
||||
|
||||
async def fake_threadpool(fn):
|
||||
calls.append(fn)
|
||||
return fn()
|
||||
|
||||
async def fake_audit(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
|
||||
monkeypatch.setattr(admin, "run_in_threadpool", fake_threadpool)
|
||||
monkeypatch.setattr(admin, "audit", fake_audit)
|
||||
monkeypatch.setattr(admin.backup_service, "create", lambda: {
|
||||
"name": "example.sql", "size_bytes": 1, "at": datetime.now(timezone.utc),
|
||||
})
|
||||
assert (await admin.make_backup(object())).name == "example.sql"
|
||||
assert calls[0] is admin.backup_service.create
|
||||
|
||||
def broken():
|
||||
raise admin.backup_service.BackupError("pg_dump failed")
|
||||
|
||||
monkeypatch.setattr(admin.backup_service, "create", broken)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await admin.make_backup(object())
|
||||
assert exc.value.status_code == 503
|
||||
assert any(isinstance(item, tuple) and item[0][2] == "backup.failed" for item in calls)
|
||||
|
||||
|
||||
def test_backup_error_redacts_database_credentials(monkeypatch):
|
||||
dsn = "postgresql://user:supersecret@localhost:5432/example"
|
||||
monkeypatch.setattr(admin, "get_settings", lambda: SimpleNamespace(database_url=dsn))
|
||||
detail = admin._safe_backup_error(admin.backup_service.BackupError(f"bad DSN: {dsn}"))
|
||||
assert "supersecret" not in detail
|
||||
assert dsn not in detail
|
||||
|
|
@ -139,3 +139,124 @@ def test_complete_dds_workflow_scores_without_call_penalties(client):
|
|||
assert all(metric["key"].startswith("dds_") for metric in score["metrics"])
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_sequential_dds_cards_keep_separate_state_and_scores(client):
|
||||
session_id = uuid4()
|
||||
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = control_ctx.__enter__()
|
||||
control.send_json({
|
||||
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
|
||||
"trainee": "Иванов", "mode": "training", "exercise": "dds",
|
||||
})
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
first = read_until(station, "card.received")
|
||||
first_card_id = first["card"]["card_id"]
|
||||
assert (first["card_index"], first["card_total"]) == (1, 2)
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
service = snapshot["services"][0]
|
||||
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.reply", "card_id": first_card_id,
|
||||
"text": "Сообщение принято, дежурная бригада направлена на место."})
|
||||
assert read_until(station, "station.state")["snapshot"]["reply_text"].startswith("Сообщение")
|
||||
station.send_json({"type": "card.next", "card_id": first_card_id})
|
||||
second = read_until(station, "card.received")
|
||||
second_card_id = second["card"]["card_id"]
|
||||
assert second_card_id != first_card_id
|
||||
assert second["card"]["incident_type"] == "medical"
|
||||
assert (second["card_index"], second["card_total"]) == (2, 2)
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
assert snapshot["reply_text"] == ""
|
||||
assert snapshot["statuses"].get(service) == "added" or service not in snapshot["statuses"]
|
||||
assert len(snapshot["completed_cards"]) == 1
|
||||
assert snapshot["completed_cards"][0]["card_id"] == first_card_id
|
||||
station.send_json({"type": "card.reply", "card_id": first_card_id,
|
||||
"text": "Запоздалый ответ к прошлой карточке"})
|
||||
assert "не к текущей" in read_until(station, "error")["message"]
|
||||
assert hub.get(session_id).reply_text == ""
|
||||
station.send_json({"type": "card.next", "card_id": first_card_id})
|
||||
assert "ID" in read_until(station, "error")["message"]
|
||||
assert hub.get(session_id).dds_card_index == 1
|
||||
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
assert read_until(station, "card.received")["card"]["card_id"] == second_card_id
|
||||
assert read_until(station, "station.state")["snapshot"]["card_index"] == 2
|
||||
station.send_json({"type": "card.reply", "card_id": second_card_id,
|
||||
"text": "Сообщение принято, бригада направлена на место происшествия."})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.next", "card_id": second_card_id})
|
||||
read_until(station, "score.ready")
|
||||
|
||||
state = hub.get(session_id)
|
||||
assert state.ended and len(state.dds_completed) == 2
|
||||
assert len(state.score["card_results"]) == 2
|
||||
assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2"
|
||||
assert state.score["card_results"][1]["scenario_id"] == "t20-2-stroke"
|
||||
assert {item["key"] for item in state.score["metrics"]} >= {"dds_reply", "dds_primary"}
|
||||
assert all(item["code"].startswith("D") for item in state.score["findings"])
|
||||
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||
assert report["scenario_id"] == "fire-apartment-l2"
|
||||
assert len(report["card_results"]) == 2
|
||||
assert report["missed_checklist"] == [] and report["reference_questions"] == []
|
||||
first_actions = report["card_results"][0]["actions"]
|
||||
assert {item["type"] for item in first_actions} >= {"card.status", "card.reply"}
|
||||
assert report["card_results"][0]["duration_ms"] >= 0
|
||||
finally:
|
||||
control_ctx.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_instructor_can_end_multi_card_early_without_grading_future_cards(client):
|
||||
session_id = uuid4()
|
||||
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = control_ctx.__enter__()
|
||||
control.send_json({
|
||||
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
|
||||
"trainee": "Иванов", "mode": "training", "exercise": "dds",
|
||||
})
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "card.received")
|
||||
read_until(station, "station.state")
|
||||
control.send_json({"type": "session.stop"})
|
||||
read_until(station, "score.ready")
|
||||
state = hub.get(session_id)
|
||||
assert state.ended
|
||||
assert len(state.score["card_results"]) == 1
|
||||
assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2"
|
||||
finally:
|
||||
control_ctx.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_each_dds_card_uses_its_own_scenario_weights():
|
||||
from pathlib import Path
|
||||
|
||||
from app.domain.events import Exercise, SessionMode
|
||||
from app.scenarios.loader import load_file
|
||||
from app.session.dds import prepare_card
|
||||
from app.session.finish import score_current_dds
|
||||
from app.session.state import SessionState
|
||||
|
||||
root = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
base = load_file(root / "fire-apartment-l2.yaml", root)
|
||||
first = base.model_copy(deep=True)
|
||||
second = base.model_copy(deep=True)
|
||||
first.score_weights = {"dds_reply": 7.0}
|
||||
second.score_weights = {"dds_reply": 2.0}
|
||||
state = SessionState(
|
||||
session_id=uuid4(), scenario_id=base.id, scenario_title=base.title,
|
||||
level=base.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||
dds_scenarios=[first, second],
|
||||
)
|
||||
prepare_card(state, first)
|
||||
first_record = score_current_dds(state)
|
||||
state.dds_card_index = 1
|
||||
prepare_card(state, second)
|
||||
second_record = score_current_dds(state)
|
||||
assert next(item.weight for item in first_record.metrics if item.key == "dds_reply") == 7.0
|
||||
assert next(item.weight for item in second_record.metrics if item.key == "dds_reply") == 2.0
|
||||
|
|
|
|||
75
backend/tests/test_demo_no_db.py
Normal file
75
backend/tests/test_demo_no_db.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Явный локальный демо-режим не зависит от Postgres."""
|
||||
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.auth import DEMO_TRAINEE_ID
|
||||
from app.config import get_settings
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
|
||||
|
||||
def _wait_for(predicate, timeout=3):
|
||||
until = time.monotonic() + timeout
|
||||
while time.monotonic() < until:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
time.sleep(0.02)
|
||||
raise AssertionError("занятие не стартовало")
|
||||
|
||||
|
||||
def _read_until(socket, wanted):
|
||||
for _ in range(20):
|
||||
event = socket.receive_json()
|
||||
if event["type"] == wanted:
|
||||
return event
|
||||
raise AssertionError(f"не пришло событие {wanted}")
|
||||
|
||||
|
||||
def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch):
|
||||
monkeypatch.setenv("DEMO_NO_DB", "true")
|
||||
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
|
||||
monkeypatch.setenv("VOICE_ENABLED", "false")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
health = client.get("/api/health").json()
|
||||
assert health["status"] == "ok" and health["demo_no_db"] is True
|
||||
assert health["scenarios_loaded"] > 0
|
||||
assert hub.journal is None
|
||||
|
||||
assert client.post("/api/auth/login", json={
|
||||
"login": "demo-instructor", "password": "demo"
|
||||
}).json()["role"] == "instructor"
|
||||
assert client.post("/api/auth/login", json={
|
||||
"login": "unknown", "password": "demo"
|
||||
}).status_code == 401
|
||||
assert client.post("/api/auth/dev-token?role=trainee").json()["trainee_id"] == str(DEMO_TRAINEE_ID)
|
||||
assert client.post("/api/auth/dev-token").json()["role"] == "instructor"
|
||||
assert client.get("/api/trainees").json() == [
|
||||
{"id": str(DEMO_TRAINEE_ID), "name": "Демо-курсант", "group": None}
|
||||
]
|
||||
# БД-зависимые экраны получают быстрый и явный отказ, не ждут TCP timeout.
|
||||
assert client.get("/api/sessions").json()["detail"] == "database_disabled_demo"
|
||||
|
||||
session_id = uuid4()
|
||||
with client.websocket_connect(f"/ws/control/{session_id}") as control:
|
||||
control.send_json({
|
||||
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Демо-курсант", "trainee_id": str(DEMO_TRAINEE_ID),
|
||||
"mode": "training", "exercise": "dds",
|
||||
})
|
||||
state = _wait_for(lambda: hub.get(session_id))
|
||||
assert state.trainee_id == DEMO_TRAINEE_ID
|
||||
who = client.post("/api/auth/login", json={
|
||||
"login": "demo-trainee", "password": "demo"
|
||||
}).json()
|
||||
assert who["trainee_id"] == str(DEMO_TRAINEE_ID)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
assert _read_until(station, "card.received")["card"]["address"]
|
||||
assert _read_until(station, "station.state")["snapshot"]["card_index"] == 1
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
75
backend/tests/test_group_analytics.py
Normal file
75
backend/tests/test_group_analytics.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Групповая сводка считает людей, а не число их повторных попыток."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.api import auth
|
||||
from app.api.http import groups as group_api
|
||||
from app.api.auth import Principal
|
||||
from app.domain.roles import Role
|
||||
from app.scoring.group import ScoredAttempt, summarize
|
||||
|
||||
|
||||
def test_group_errors_count_distinct_trainees_and_give_actions():
|
||||
first, second = uuid4(), uuid4()
|
||||
result = summarize([
|
||||
ScoredAttempt(first, 40.0, {"E1": 2, "D1": 1}),
|
||||
ScoredAttempt(first, 60.0, {"E1": 1}),
|
||||
ScoredAttempt(second, 80.0, {"E1": 1}),
|
||||
], enrolled=3)
|
||||
|
||||
assert result["active_trainees"] == 2
|
||||
assert result["scored_attempts"] == 3
|
||||
assert result["average_score"] == 60.0
|
||||
assert result["errors"][0]["code"] == "E1"
|
||||
assert result["errors"][0]["affected_trainees"] == 2
|
||||
assert result["errors"][0]["occurrences"] == 4
|
||||
assert result["errors"][0]["rate_percent"] == 100.0
|
||||
assert result["errors"][0]["recommendation"]
|
||||
|
||||
|
||||
def test_group_without_scored_attempts_has_no_fake_recommendations():
|
||||
result = summarize([], enrolled=5)
|
||||
assert result == {
|
||||
"enrolled_trainees": 5,
|
||||
"active_trainees": 0,
|
||||
"scored_attempts": 0,
|
||||
"average_score": None,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
|
||||
def test_unknown_codes_do_not_break_group_summary():
|
||||
result = summarize([ScoredAttempt(uuid4(), 75.0, {"unknown": 2, "E5": 1})], enrolled=1)
|
||||
assert [item["code"] for item in result["errors"]] == ["E5"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_creation_requires_staff_and_returns_new_group(monkeypatch):
|
||||
class FakeDb:
|
||||
def add(self, group):
|
||||
group.id = uuid4()
|
||||
|
||||
async def commit(self):
|
||||
pass
|
||||
|
||||
async def no_audit(*args):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(group_api, "audit", no_audit)
|
||||
instructor = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
|
||||
request = Request({"type": "http", "session": {}})
|
||||
auth._issue_session(request, instructor)
|
||||
created = await group_api.create(group_api.GroupCreate(name=" Группа 1 "), request, FakeDb())
|
||||
assert created.name == "Группа 1"
|
||||
assert created.id
|
||||
|
||||
trainee = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE)
|
||||
forbidden = Request({"type": "http", "session": {}})
|
||||
auth._issue_session(forbidden, trainee)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await group_api.create(group_api.GroupCreate(name="Чужая"), forbidden, FakeDb())
|
||||
assert exc.value.status_code == 403
|
||||
39
backend/tests/test_score_weights.py
Normal file
39
backend/tests/test_score_weights.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Веса метрик настраиваются сценарием и не меняют факт проверки."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain.events import Metric
|
||||
from app.scenarios.loader import load_file
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scoring.gost import GostResult
|
||||
from app.scoring.weights import apply_weights
|
||||
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
||||
|
||||
def test_weight_override_changes_score_but_not_findings():
|
||||
result = GostResult(metrics=[
|
||||
Metric(key="address", title="Адрес", fact="пусто", norm="заполнен", passed=False, weight=2),
|
||||
Metric(key="required_fields", title="Поля", fact="есть", norm="есть", passed=True, weight=2),
|
||||
])
|
||||
assert result.score == 50.0
|
||||
apply_weights(result, {"address": 6.0})
|
||||
assert result.score == 25.0
|
||||
assert result.metrics[0].fact == "пусто"
|
||||
|
||||
|
||||
def test_scenario_accepts_only_known_finite_weights():
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
body = source.model_dump(mode="json")
|
||||
body["score_weights"] = {"address": 3.0, "dds_ack": 0.0}
|
||||
assert Scenario.model_validate(body).score_weights["address"] == 3.0
|
||||
body["score_weights"] = {"typo": 3.0}
|
||||
with pytest.raises(ValidationError, match="неизвестные метрики"):
|
||||
Scenario.model_validate(body)
|
||||
body["score_weights"] = {"address": 11.0}
|
||||
with pytest.raises(ValidationError, match="от 0 до 10"):
|
||||
Scenario.model_validate(body)
|
||||
|
|
@ -366,7 +366,34 @@ def test_instructor_correction_keeps_the_automatic_score(client):
|
|||
|
||||
assert corrected["score_final"] == 80.0
|
||||
assert corrected["score_auto"] == auto, "автооценка должна сохраниться рядом"
|
||||
assert corrected["overridden_by"] == "преподаватель"
|
||||
assert corrected["overridden_by"] == "dev"
|
||||
|
||||
|
||||
def test_ws_score_override_rejects_other_session_and_invalid_value(client):
|
||||
with lesson(client) as (session_id, control):
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
trainee.send_json({"type": "call.answer"})
|
||||
trainee.send_json({"type": "call.hangup"})
|
||||
wait_for(lambda: state.score is not None)
|
||||
auto = state.score["score_auto"]
|
||||
control.send_json({
|
||||
"type": "score.override", "session_id": str(uuid4()),
|
||||
"verdict": "90", "comment": "не тот номер",
|
||||
})
|
||||
control.send_json({
|
||||
"type": "score.override", "session_id": str(session_id),
|
||||
"verdict": "nan", "comment": "не число",
|
||||
})
|
||||
time.sleep(0.1)
|
||||
assert state.score["score_auto"] == auto
|
||||
assert "score_final" not in state.score
|
||||
control.send_json({
|
||||
"type": "score.override", "session_id": str(session_id),
|
||||
"verdict": "85", "comment": "ручная проверка",
|
||||
})
|
||||
wait_for(lambda: state.score.get("score_final") == 85)
|
||||
assert state.score["overridden_by"] == "dev"
|
||||
|
||||
|
||||
def test_soft_directive_changes_how_the_caller_sounds(client):
|
||||
|
|
|
|||
Loading…
Reference in a new issue