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
|
||||
|
|
|
|||
Loading…
Reference in a new issue