Complete training workflow and acceptance hardening

This commit is contained in:
andreysk0304 2026-09-26 18:12:27 +03:00 • committed by gglamer
commit 7237265833
243 changed files with 17014 additions and 1500 deletions

View file

@ -19,7 +19,9 @@ import asyncio
import hashlib
import logging
import secrets
import time
import weakref
from urllib.parse import urlsplit
from uuid import UUID
from argon2 import PasswordHasher
@ -27,10 +29,13 @@ from argon2.exceptions import VerifyMismatchError
from fastapi import APIRouter, HTTPException, Request, WebSocket
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from starlette.websockets import WebSocketDisconnect
from app.config import get_settings
from app.db.base import get_sessionmaker
from app.db.models import AuditLog, User
from app.db.models import AuditLog, Trainee, User
from app.directory import DirectoryDenied, DirectoryIdentity, DirectoryUnavailable
from app.domain.roles import Role
log = logging.getLogger(__name__)
@ -46,20 +51,46 @@ _INSTANCE = hashlib.sha256(
).hexdigest()
_generations: dict[str, int] = {}
_active_sockets: dict[str, weakref.WeakKeyDictionary] = {}
AUTH_GENERATION_SYNC_SECONDS = 1.0
AUTH_GENERATION_MAX_AGE_SECONDS = 2.0
_generations_synced_at: float | None = None
async def _close_revoked(ws: WebSocket) -> None:
try:
await ws.close(code=1008, reason="Учётная запись изменена: войдите снова")
except (RuntimeError, OSError):
except (RuntimeError, OSError, WebSocketDisconnect):
# The peer may already have disconnected; revocation still stands.
pass
async def _close_auth_state_unavailable(ws: WebSocket) -> None:
try:
await ws.close(code=1013, reason="Состояние доступа временно недоступно")
except (RuntimeError, OSError, WebSocketDisconnect):
pass
def _close_unverified_sockets() -> None:
for sockets in list(_active_sockets.values()):
for ws, loop in list(sockets.items()):
try:
if not loop.is_closed():
loop.call_soon_threadsafe(
lambda socket=ws: asyncio.create_task(
_close_auth_state_unavailable(socket)
)
)
except RuntimeError:
pass
def prime_generations(values: dict[str, int]) -> None:
"""Загрузить версии полномочий из БД при старте нового процесса."""
global _generations_synced_at
_generations.clear()
_generations.update(values)
_generations_synced_at = time.monotonic()
async def load_generations() -> None:
@ -68,6 +99,106 @@ async def load_generations() -> None:
prime_generations({login: version for login, version in rows})
async def sync_generations() -> None:
"""Refresh shared account epochs and close sockets revoked on peer nodes."""
async with get_sessionmaker()() as db:
rows = (await db.execute(select(User.login, User.auth_version))).all()
current = {login: version for login, version in rows}
for login, version in current.items():
previous = _generations.get(login)
if previous is None:
_generations[login] = version
elif previous != version:
invalidate_login(login, version)
# Account deletion is not exposed by the application. Still close active
# sockets if an operator removes one directly from the shared directory DB.
# The local-only dev-token principal is synthetic, never stored in users;
# a DB watcher must not revoke its in-memory generation during test/demo
# flows that deliberately exercise account invalidation.
synthetic = {"dev"} if get_settings().dev_auth_bypass else set()
for login in _generations.keys() - current.keys() - synthetic:
invalidate_login(login)
_generations.pop(login, None)
global _generations_synced_at
_generations_synced_at = time.monotonic()
async def watch_generations() -> None:
"""Poll PostgreSQL once per node so remote logout/role changes close WS."""
while True:
try:
async with asyncio.timeout(AUTH_GENERATION_MAX_AGE_SECONDS):
await sync_generations()
except Exception as exc: # noqa: BLE001 — retry; authenticated requests fail closed
log.error("не удалось синхронизировать версии полномочий (%s)",
type(exc).__name__)
if (
_generations_synced_at is None
or time.monotonic() - _generations_synced_at > AUTH_GENERATION_MAX_AGE_SECONDS
):
_close_unverified_sockets()
await asyncio.sleep(AUTH_GENERATION_SYNC_SECONDS)
class AuthVersionMiddleware:
"""Check signed-cookie epochs against the fresh, DB-synchronized node cache."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] not in {"http", "websocket"}:
await self.app(scope, receive, send)
return
if scope["type"] == "http" and scope.get("path") in {
"/api/health", "/api/auth/logout",
}:
# Liveness must remain observable and logout must always be able to
# clear the browser cookie even while PostgreSQL is unreachable.
await self.app(scope, receive, send)
return
settings = get_settings()
session = scope.get("session")
data = session.get("principal") if isinstance(session, dict) else None
login = data.get("login") if isinstance(data, dict) else None
if (
settings.demo_no_db
or not isinstance(login, str)
or login == "dev" and settings.dev_auth_bypass
or session.get("auth_instance") != _INSTANCE
):
await self.app(scope, receive, send)
return
synced_at = _generations_synced_at
if (
synced_at is None
or time.monotonic() - synced_at > AUTH_GENERATION_MAX_AGE_SECONDS
):
if scope["type"] == "websocket":
await send({"type": "websocket.close", "code": 1013})
else:
await send({
"type": "http.response.start",
"status": 503,
"headers": [(b"content-type", b"application/json")],
})
await send({
"type": "http.response.body",
"body": b'{"detail":"auth_state_unavailable"}',
})
return
cookie_version = session.get("auth_generation")
version = _generations.get(login)
if version is None or cookie_version != version:
if session is not None:
session.clear()
await self.app(scope, receive, send)
return
await self.app(scope, receive, send)
def invalidate_login(login: str, version: int | None = None) -> None:
"""Revoke previously issued cookies after account/role/password changes."""
_generations[login] = version if version is not None else _generations.get(login, 0) + 1
@ -112,8 +243,9 @@ def verify_password(password_hash: str, password: str) -> bool:
return _hasher.verify(password_hash, password)
except VerifyMismatchError:
return False
except Exception: # noqa: BLE001 — битый хеш не должен пускать в систему
log.exception("проверка пароля не удалась")
except Exception as exc: # noqa: BLE001 — битый хеш не должен пускать в систему
# Do not echo malformed stored hash material in diagnostic tracebacks.
log.error("проверка пароля не удалась (%s)", type(exc).__name__)
return False
@ -167,6 +299,48 @@ def principal_of(websocket: WebSocket) -> Principal | None:
return who
def websocket_origin_allowed(websocket: WebSocket) -> bool:
"""Reject browser cross-site WebSocket handshakes (CSWSH).
Non-browser clients may omit Origin. Browser Origins must exactly match
the external host and scheme; the bundled reverse proxies forward the
original Host and scheme explicitly for this check.
"""
origin = websocket.headers.get("origin")
if origin is None:
return True
try:
parsed_origin = urlsplit(origin)
host = websocket.headers.get("x-forwarded-host") or websocket.headers.get("host")
scheme = (
websocket.headers.get("x-forwarded-proto")
or {"ws": "http", "wss": "https"}.get(websocket.scope.get("scheme", ""), "")
).casefold()
if not host or scheme not in {"http", "https"}:
return False
expected = urlsplit(f"{scheme}://{host}")
if parsed_origin.scheme.casefold() != scheme:
return False
if (
parsed_origin.username
or parsed_origin.password
or not parsed_origin.hostname
or not expected.hostname
):
return False
origin_port = parsed_origin.port or (443 if scheme == "https" else 80)
expected_port = expected.port or (443 if scheme == "https" else 80)
return (
parsed_origin.hostname.casefold() == expected.hostname.casefold()
and origin_port == expected_port
and parsed_origin.path in {"", "/"}
and not parsed_origin.query
and not parsed_origin.fragment
)
except ValueError:
return False
def require(request: Request, *roles: Role) -> Principal:
"""Принципал нужной роли или отказ. Единственная точка проверки на HTTP."""
who = current(request)
@ -179,21 +353,134 @@ def require(request: Request, *roles: Role) -> Principal:
async def audit(
actor: str, role: str, action: str, object_id: str | None = None, detail: str = ""
) -> None:
"""Запись в журнал. Аудит не должен ронять действие: если база недоступна,
занятие продолжается, а пропуск виден в логе."""
) -> bool:
"""Best-effort audit write for actions that cannot be rolled back."""
if get_settings().demo_no_db:
return # в явном demo-режиме запись и долговременный аудит недоступны
return True # явный demo-режим не обещает долговременное хранение
try:
async with get_sessionmaker()() as db:
db.add(
AuditLog(
actor=actor, role=role, action=action, object_id=object_id, detail=detail[:2000]
)
)
add_audit_entry(db, actor, role, action, object_id, detail)
await db.commit()
except Exception: # noqa: BLE001
log.exception("аудит: запись %s не удалась", action)
return True
except Exception as exc: # noqa: BLE001
# SQL traces can include audit detail and user-provided text.
log.error("аудит: запись %s не удалась (%s)", action, type(exc).__name__)
return False
async def audit_required(
actor: str, role: str, action: str, object_id: str | None = None, detail: str = ""
) -> None:
"""Fail closed for authentication decisions that must be auditable."""
written = await audit(actor, role, action, object_id, detail)
# `is False` preserves simple third-party/test audit hooks that return None.
if written is False:
raise HTTPException(status_code=503, detail="audit_unavailable")
def add_audit_entry(
db, actor: str, role: str, action: str, object_id: str | None = None, detail: str = ""
) -> None:
"""Добавить audit row к текущей транзакции, не коммитя отдельно.
Для административных операций, где изменение без audit trail недопустимо,
вызывающий код коммитит предметную запись и журнал одним commit.
"""
db.add(AuditLog(
actor=actor,
role=role,
action=action,
object_id=object_id,
detail=detail[:2000],
))
async def _directory_account(identity: DirectoryIdentity) -> User:
"""Just-in-time provision and sync one explicitly group-mapped account."""
async with get_sessionmaker()() as db:
by_login = await db.scalar(select(User).where(User.login == identity.login))
by_subject = await db.scalar(
select(User).where(User.directory_subject == identity.subject)
)
if by_login is not None and by_login.auth_provider != "ldap":
raise DirectoryDenied("directory login conflicts with a local account")
if by_login is not None and by_subject is not None and by_login.id != by_subject.id:
raise DirectoryDenied("directory identity conflicts with an existing account")
user = by_subject or by_login
if user is not None and user.blocked:
# Let the login endpoint record the blocked attempt with the same
# audit path used for local accounts. Do not sync any account fields.
return user
if user is not None and user.directory_subject not in {None, identity.subject}:
raise DirectoryDenied("directory login is bound to another identity")
if user is None:
trainee_id = None
if identity.role is Role.TRAINEE:
trainee = Trainee(name=identity.full_name)
db.add(trainee)
await db.flush()
trainee_id = trainee.id
user = User(
login=identity.login,
password_hash=hash_password(secrets.token_urlsafe(48)),
full_name=identity.full_name,
role=identity.role.value,
service=identity.service,
trainee_id=trainee_id,
auth_provider="ldap",
directory_subject=identity.subject,
auth_version=0,
blocked=False,
)
db.add(user)
add_audit_entry(
db,
"system",
"system",
"user.provision.ldap",
identity.login,
f"role={identity.role.value}; service_assigned={identity.service is not None}",
)
else:
if user.role != identity.role.value and identity.role is Role.TRAINEE and user.trainee_id is None:
trainee = Trainee(name=identity.full_name)
db.add(trainee)
await db.flush()
user.trainee_id = trainee.id
changed = (
user.full_name != identity.full_name
or user.role != identity.role.value
or user.service != identity.service
or user.directory_subject != identity.subject
)
user.full_name = identity.full_name
user.role = identity.role.value
user.service = identity.service
user.directory_subject = identity.subject
if user.trainee_id is not None:
trainee = await db.get(Trainee, user.trainee_id)
if trainee is not None:
trainee.name = identity.full_name
if changed:
user.auth_version += 1
add_audit_entry(
db,
"system",
"system",
"user.sync.ldap",
identity.login,
f"role={user.role}; service_assigned={user.service is not None}",
)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
# Concurrent first login or duplicate directory subject is denied;
# the caller can retry after the account mapping is unambiguous.
raise DirectoryDenied("directory account provisioning conflict") from exc
await db.refresh(user)
return user
@router.post("/login")
@ -211,19 +498,35 @@ async def login(payload: LoginIn, request: Request) -> dict:
async with get_sessionmaker()() as db:
user = await db.scalar(select(User).where(User.login == payload.login))
# Одинаковый ответ на неизвестный логин и неверный пароль: иначе форма
# входа превращается в список действующих учётных записей.
if user is None or not verify_password(user.password_hash, payload.password):
settings = get_settings()
if user is None or user.auth_provider == "ldap":
if not settings.ldap_enabled:
raise HTTPException(status_code=401, detail="bad_credentials")
from app.directory import authenticate
try:
identity = await authenticate(payload.login, payload.password)
if identity is None:
raise DirectoryDenied("unknown directory account")
user = await _directory_account(identity)
except DirectoryDenied as exc:
await audit_required(payload.login[:80], "unknown", "login.failed")
raise HTTPException(status_code=401, detail="bad_credentials") from exc
except DirectoryUnavailable as exc:
log.error("local directory unavailable: %s", exc)
raise HTTPException(status_code=503, detail="directory_unavailable") from exc
elif not verify_password(user.password_hash, payload.password):
# Не записываем пароль, IP либо факт существования учётной записи.
# Логин нужен администратору для расследования перебора; ограничиваем
# длину до размера поля AuditLog.actor.
await audit(payload.login[:80], "unknown", "login.failed")
await audit_required(payload.login[:80], "unknown", "login.failed")
raise HTTPException(status_code=401, detail="bad_credentials")
if user.blocked:
await audit(user.login, user.role, "login.blocked")
await audit_required(user.login, user.role, "login.blocked")
raise HTTPException(status_code=403, detail="blocked")
_generations[user.login] = user.auth_version
if _generations.get(user.login) != user.auth_version:
invalidate_login(user.login, user.auth_version)
who = Principal(
login=user.login,
@ -232,17 +535,41 @@ async def login(payload: LoginIn, request: Request) -> dict:
service=user.service,
trainee_id=user.trainee_id,
)
await audit_required(who.login, who.role.value, "login")
_issue_session(request, who)
await audit(who.login, who.role.value, "login")
return who.model_dump(mode="json")
@router.post("/logout")
async def logout(request: Request) -> dict:
who = current(request)
if who is None:
request.session.clear()
return {"ok": True}
# Drop the browser cookie even if durable revocation is unavailable.
request.session.clear()
if who:
if get_settings().demo_no_db:
invalidate_login(who.login)
await audit(who.login, who.role.value, "logout")
return {"ok": True}
try:
async with get_sessionmaker()() as db:
user = await db.scalar(select(User).where(User.login == who.login).with_for_update())
if user is not None:
user.auth_version += 1
version = user.auth_version
else:
version = _generations.get(who.login, 0) + 1
add_audit_entry(db, who.login, who.role.value, "logout")
await db.commit()
except Exception as exc: # noqa: BLE001 — fail closed for revocation/audit
log.error("выход: отзыв cookie и аудит не удалось сохранить (%s)",
type(exc).__name__)
invalidate_login(who.login)
raise HTTPException(status_code=503, detail="audit_unavailable") from exc
invalidate_login(who.login, version)
return {"ok": True}

View file

@ -9,23 +9,29 @@
но с проверкой».
"""
import csv
import io
import logging
import re
from datetime import datetime, timedelta, timezone
from urllib.parse import quote, quote_plus
from uuid import UUID
from xml.etree import ElementTree as ET
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.engine import make_url
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, invalidate_login, require
from app.api.auth import (
add_audit_entry, audit, audit_required, 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
@ -97,6 +103,7 @@ class UserOut(BaseModel):
login: str
full_name: str
role: Role
auth_provider: str
service: str | None
blocked: bool
created_at: datetime
@ -126,6 +133,7 @@ def _out(user: User) -> UserOut:
login=user.login,
full_name=user.full_name,
role=Role(user.role),
auth_provider=user.auth_provider,
service=user.service,
blocked=user.blocked,
created_at=user.created_at,
@ -161,13 +169,14 @@ async def create_user(
user.trainee_id = trainee.id
db.add(user)
add_audit_entry(db, who.login, who.role.value, "user.create", body.login,
ROLE_LABELS[body.role])
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="login_taken") from exc
await audit(who.login, who.role.value, "user.create", body.login, ROLE_LABELS[body.role])
return _out(user)
@ -180,6 +189,11 @@ async def patch_user(
if user is None:
raise HTTPException(status_code=404, detail="user_not_found")
if user.auth_provider == "ldap" and any(
value is not None for value in (body.role, body.service, body.password)
):
raise HTTPException(status_code=409, detail="directory_managed_account")
changed: list[str] = []
if body.role is not None:
if user.login == who.login and body.role is not Role.ADMIN:
@ -207,9 +221,10 @@ async def patch_user(
if not changed:
return _out(user)
user.auth_version += 1
add_audit_entry(db, who.login, who.role.value, "user.update", user.login,
", ".join(changed))
await db.commit()
invalidate_login(user.login, user.auth_version)
await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed))
return _out(user)
@ -222,6 +237,25 @@ class AuditOut(BaseModel):
detail: str
def _csv_value(value: object) -> str:
"""Prevent spreadsheet formula execution in user-controlled audit fields."""
if value is None:
return ""
text = str(value)
probe = text.lstrip(" \t\r\n\ufeff\u200b")
if probe.startswith(("=", "+", "-", "@")) or text.startswith(("\t", "\r", "\n")):
return "'" + text
return text
def _csv_row(values: tuple[object, ...]) -> str:
output = io.StringIO(newline="")
csv.writer(output, lineterminator="\r\n").writerow(
[_csv_value(value) for value in values]
)
return output.getvalue()
@router.get("/audit", response_model=list[AuditOut])
async def audit_log(
request: Request,
@ -254,6 +288,37 @@ async def audit_log(
]
@router.get("/audit.csv")
async def audit_csv(
request: Request,
action: str | None = None,
actor: str | None = None,
db: AsyncSession = Depends(get_session),
) -> StreamingResponse:
"""Stream the complete filtered security log for offline review/archive."""
require(request, Role.ADMIN)
query = select(AuditLog).order_by(AuditLog.at.asc(), AuditLog.id.asc())
if action:
query = query.where(AuditLog.action == action)
if actor:
query = query.where(AuditLog.actor == actor)
async def rows():
yield "\ufeff" + _csv_row(("Когда UTC", "Пользователь", "Роль", "Действие", "Объект", "Подробности"))
result = await db.stream_scalars(query)
async for row in result:
yield _csv_row((
row.at.isoformat(), row.actor, row.role, row.action,
row.object_id, row.detail,
))
return StreamingResponse(
rows(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="lct-audit.csv"'},
)
class ServiceState(BaseModel):
name: str
ok: bool
@ -504,9 +569,25 @@ def _safe_backup_error(exc: backup_service.BackupError) -> str:
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), "[пароль скрыт]")
try:
password = make_url(dsn).password
except Exception: # malformed DSN is handled by backup setup separately
password = None
if password:
# Driver errors may echo the DSN either as configured (percent
# encoded) or after the URL parser decoded credentials. Redact all
# common representations; checking only the raw password misses
# secrets containing @, :, spaces, or other escaped characters.
encoded = {quote(password, safe=""), quote_plus(password, safe="")}
variants = {
password,
*encoded,
*(re.sub(r"%[0-9A-F]{2}", lambda match: match.group(0).lower(), item)
for item in encoded),
}
for secret in sorted(variants, key=len, reverse=True):
if secret:
message = message.replace(secret, "[пароль скрыт]")
return message
@ -515,12 +596,21 @@ async def make_backup(request: Request) -> BackupOut:
"""Копия прямо сейчас. Расписание — отдельно, в `scripts/backup.py`:
кнопка нужна перед занятием, расписание — чтобы о нём не вспоминали."""
who = require(request, Role.ADMIN)
# Record intent before the irreversible filesystem operation. If the DB
# audit store fails after pg_dump finishes, the attempt is still visible.
await audit_required(who.login, who.role.value, "backup.create.requested")
try:
# 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:
detail = _safe_backup_error(exc)
# The durable requested event above preserves the attempt even if the
# outcome write also fails. Keep the concrete storage error visible to
# the operator instead of replacing it with an audit-store error.
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"])
# A completed backup must not be reported as successful when its security
# audit could not be persisted. The file remains visible in the backup list
# so an administrator can reconcile it after the audit store recovers.
await audit_required(who.login, who.role.value, "backup.create", created["name"])
return BackupOut(**created)

View file

@ -1,6 +1,6 @@
"""Сводка ошибок и рекомендаций учебной группы для преподавателя."""
from uuid import UUID
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
@ -8,7 +8,7 @@ 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.api.auth import add_audit_entry, audit_required, require
from app.db.base import get_session
from app.db.models import Group, Score, Session, Trainee, User
from app.domain.roles import Role
@ -113,14 +113,16 @@ async def create(
name = body.name.strip()
if not name:
raise HTTPException(status_code=422, detail="group_name_required")
group = Group(name=name, owner_login=who.login if who.role is Role.INSTRUCTOR else None)
group = Group(
id=uuid4(), name=name, owner_login=who.login if who.role is Role.INSTRUCTOR else None
)
db.add(group)
add_audit_entry(db, who.login, who.role.value, "group.create", str(group.id), group.name)
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)
@ -142,14 +144,11 @@ async def transfer_ownership(
raise HTTPException(status_code=422, detail="active_instructor_required")
previous_owner = group.owner_login
group.owner_login = body.owner_login
await db.commit()
await audit(
who.login,
who.role.value,
"group.transfer",
str(group.id),
add_audit_entry(
db, who.login, who.role.value, "group.transfer", str(group.id),
f"{previous_owner or 'admin'} -> {body.owner_login or 'admin'}",
)
await db.commit()
return GroupOut(id=group.id, name=group.name)
@ -174,8 +173,8 @@ async def assign_trainee(
if current_group is None or current_group.owner_login != who.login:
raise HTTPException(status_code=409, detail="trainee_in_other_instructor_group")
trainee.group_id = group_id
add_audit_entry(db, who.login, who.role.value, "group.assign", str(group.id), str(trainee_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)
@ -204,5 +203,7 @@ async def ai_insight(
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
except InsightInvalid as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "group.ai_insight", str(group_id), "aggregated_only")
await audit_required(
who.login, who.role.value, "group.ai_insight", str(group_id), "aggregated_only"
)
return GroupInsightOut(**insight)

View file

@ -18,7 +18,7 @@ from pydantic import BaseModel, Field, model_validator
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import DEMO_TRAINEE_ID, audit, require
from app.api.auth import DEMO_TRAINEE_ID, add_audit_entry, audit, require
from app.config import get_settings
from app.db.base import get_session
from app.db.models import Group, LearningMaterial, MaterialAssignment, Trainee
@ -264,8 +264,8 @@ async def create(
_demo_materials[row.id] = row
else:
db.add(row)
add_audit_entry(db, who.login, who.role.value, "material.create", str(row.id), row.title)
await db.commit()
await audit(who.login, who.role.value, "material.create", str(row.id), row.title)
return _out(row)
@ -301,8 +301,8 @@ async def update(
setattr(row, key, value.strip() if isinstance(value, str) else value)
row.updated_at = datetime.now(timezone.utc)
if db is not None:
add_audit_entry(db, who.login, who.role.value, "material.update", str(row.id))
await db.commit()
await audit(who.login, who.role.value, "material.update", str(row.id))
return _out(row)
@ -320,8 +320,8 @@ async def archive(
row.active = False
row.updated_at = datetime.now(timezone.utc)
if db is not None:
add_audit_entry(db, who.login, who.role.value, "material.archive", str(row.id))
await db.commit()
await audit(who.login, who.role.value, "material.archive", str(row.id))
return _out(row)
@ -361,9 +361,13 @@ async def assign(
material_id=material_id, trainee_id=trainee_id, assigned_by=who.login
)
db.add(assignment)
await db.commit()
await db.refresh(assignment)
await audit(who.login, who.role.value, "material.assign", str(row.id), str(trainee_id))
add_audit_entry(
db, who.login, who.role.value, "material.assign", str(row.id), str(trainee_id)
)
await db.commit()
await db.refresh(assignment)
if db is None:
await audit(who.login, who.role.value, "material.assign", str(row.id), str(trainee_id))
return _out(row, assignment=assignment)
@ -394,8 +398,10 @@ async def assign_group(
db.add(MaterialAssignment(
material_id=material_id, trainee_id=trainee_id, assigned_by=who.login
))
add_audit_entry(
db, who.login, who.role.value, "material.assign_group", str(row.id), str(group_id)
)
await db.commit()
await audit(who.login, who.role.value, "material.assign_group", str(row.id), str(group_id))
return {"material_id": str(row.id), "assigned": len(trainee_ids)}
@ -422,9 +428,13 @@ async def unassign(
raise HTTPException(status_code=404, detail="assignment_not_found")
if assignment is not None:
await db.delete(assignment)
await db.commit()
removed = assignment is not None
await audit(who.login, who.role.value, "material.unassign", str(material_id), str(trainee_id))
add_audit_entry(
db, who.login, who.role.value, "material.unassign", str(material_id), str(trainee_id)
)
await db.commit()
if db is None:
await audit(who.login, who.role.value, "material.unassign", str(material_id), str(trainee_id))
return {"removed": removed}
@ -454,8 +464,10 @@ async def complete(
assignment["completed_at"] = completed_at
else:
assignment.completed_at = completed_at
add_audit_entry(db, who.login, who.role.value, "material.complete", str(material_id))
await db.commit()
await audit(who.login, who.role.value, "material.complete", str(material_id))
if isinstance(assignment, dict):
await audit(who.login, who.role.value, "material.complete", str(material_id))
return _out(row, assignment=assignment)

View file

@ -0,0 +1,402 @@
"""Student-authored case outlines and instructor moderation."""
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from typing import Literal
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import Principal, add_audit_entry, audit, require
from app.config import get_settings
from app.db.base import get_session
from app.db.models import Group, ScenarioSubmission, Trainee
from app.db.models import Scenario as ScenarioRow
from app.domain.classifiers import IncidentType, Level
from app.domain.kio import KIO, derive_incident
from app.domain.roles import Role
from app.scenarios import store
from app.scenarios.editor import validate
from app.scenarios.loader import ScenarioError
router = APIRouter(prefix="/api/scenario-submissions", tags=["scenario submissions"])
_demo_submissions: dict[UUID, dict] = {}
def _card_address(card: KIO) -> str:
explicit = (card.address or "").strip()
fallback = " ".join(filter(None, (card.street, card.building))).strip()
return explicit or fallback
async def submission_session() -> AsyncIterator[AsyncSession | None]:
if get_settings().demo_no_db:
yield None
else:
async for db in get_session():
yield db
class SubmissionIn(BaseModel):
title: str = Field(min_length=3, max_length=200)
level: Level
kio: KIO
@field_validator("title")
@classmethod
def normalize_title(cls, value: str) -> str:
normalized = value.strip()
if len(normalized) < 3:
raise ValueError("title must contain at least three non-space characters")
return normalized
@model_validator(mode="after")
def validate_kio(self):
card = derive_incident(self.kio)
if card.incident_type is None or len((card.description or "").strip()) < 20:
raise ValueError("KIO needs incident type and a meaningful description")
if not _card_address(card):
raise ValueError("KIO needs a usable address")
if card.incident_group is None or not card.signs:
raise ValueError("KIO needs a classifier group and signs")
if not card.notify:
raise ValueError("KIO needs at least one derived DDS recipient")
data = card.model_dump()
data.update(
{
"card_id": uuid4(),
"registered_at": None,
"response_status": "registered",
"caller_number": None,
"incident_code": None,
"notify": [],
"dispatch_order_at": None,
"arrival_at": None,
}
)
object.__setattr__(self, "kio", derive_incident(KIO.model_validate(data)))
return self
class ReviewIn(BaseModel):
decision: Literal["approve", "reject"]
comment: str = Field(default="", max_length=1000)
@model_validator(mode="after")
def rejection_needs_reason(self):
if self.decision == "reject" and not self.comment.strip():
raise ValueError("comment is required when rejecting a proposal")
return self
def _out(row, author_name: str | None = None) -> dict:
def get(name, default=None):
if isinstance(row, dict):
return row.get(name, default)
return getattr(row, name, default)
return {
"id": str(get("id")),
"author_name": author_name or get("author_name", "Курсант"),
"title": get("title"),
"incident_type": get("incident_type"),
"level": get("level"),
"description": get("description"),
"address": get("address", ""),
"victims": get("victims"),
"kio": get("kio"),
"status": get("status"),
"review_comment": get("review_comment", ""),
"scenario_id": get("scenario_id"),
"created_at": get("created_at"),
"reviewed_at": get("reviewed_at"),
}
def _scenario_for(row) -> object:
sid = f"student-{row['id'].hex if isinstance(row, dict) else row.id.hex}"
title = row["title"] if isinstance(row, dict) else row.title
level = row["level"] if isinstance(row, dict) else row.level
kio_data = row.get("kio") if isinstance(row, dict) else row.kio
if kio_data:
card = derive_incident(KIO.model_validate(kio_data))
if card.incident_type is None:
raise ScenarioError("КИО не содержит тип происшествия")
address = _card_address(card) or None
facts = [{"id": "event", "value": card.description or title}]
if address:
facts.append({"id": "address", "value": address})
caller = "; ".join(
filter(None, (card.caller_name, card.caller_contact, card.phone_on_scene))
)
if caller:
facts.append({"id": "f_caller", "value": caller})
raw = {
"id": sid,
"title": title.strip(),
"type": card.incident_type.value,
"level": level,
"topics": ["student-created", "moderated-kio"],
"modes": ["training", "exam"],
"persona": {"base": "Утверждённая преподавателем учебная карточка КИО."},
"first_line": card.description or title,
"signs": card.signs,
"facts": facts,
"checklist": [
{"id": "q_event", "question": "Что произошло?", "fact": "event"}
],
"required_fields": ["address", "description"],
"outcome": "card",
"dds_decision": {"expected": "accept"},
"ground_truth": {
**({"address": address} if address else {}),
**(
{"victims": card.victims_count}
if card.victims_count is not None
else {}
),
},
"student_card": card.model_dump(mode="json"),
}
return validate(raw)
incident_type = row["incident_type"] if isinstance(row, dict) else row.incident_type
description = row["description"] if isinstance(row, dict) else row.description
address = row.get("address", "") if isinstance(row, dict) else row.address
victims = row.get("victims") if isinstance(row, dict) else row.victims
facts = [{"id": "event", "value": description.strip()}]
if address and address.strip():
facts.append({"id": "address", "value": address.strip()})
raw = {
"id": sid,
"title": title.strip(),
"type": incident_type,
"level": level,
"topics": ["student-created"],
"modes": ["training", "exam"],
"persona": {
"base": "Авторский учебный сюжет курсанта, проверенный преподавателем."
},
"first_line": description.strip(),
"facts": facts,
"checklist": [{"id": "q_event", "question": "Что произошло?", "fact": "event"}],
"outcome": "card",
"ground_truth": {
**({"address": address.strip()} if address and address.strip() else {}),
**({"victims": victims} if victims is not None else {}),
},
}
return validate(raw)
@router.post("", status_code=201)
async def create_submission(
body: SubmissionIn,
request: Request,
db: AsyncSession | None = Depends(submission_session),
) -> dict:
who: Principal = require(request, Role.TRAINEE)
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
now = datetime.now(UTC)
card = body.kio
incident_type = card.incident_type
description = card.description or ""
address = _card_address(card)
victims = card.victims_count
if db is None:
row = {
"id": uuid4(),
"author_trainee_id": who.trainee_id,
"author_name": who.full_name,
"group_id": None,
"title": body.title,
"level": body.level.value,
"kio": card.model_dump(mode="json"),
"incident_type": incident_type.value,
"description": description,
"address": address,
"victims": victims,
"status": "pending",
"review_comment": "",
"reviewed_by": None,
"scenario_id": None,
"created_at": now,
"reviewed_at": None,
}
_demo_submissions[row["id"]] = row
else:
trainee = await db.get(Trainee, who.trainee_id)
if trainee is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
if trainee.group_id is None:
raise HTTPException(
status_code=409, detail="trainee_group_required_for_review"
)
group = await db.get(Group, trainee.group_id)
if group is None or group.owner_login is None:
raise HTTPException(
status_code=409, detail="instructor_group_required_for_review"
)
row = ScenarioSubmission(
id=uuid4(),
author_trainee_id=trainee.id,
group_id=trainee.group_id,
title=body.title.strip(),
incident_type=incident_type.value,
level=body.level.value,
description=description,
address=address,
victims=victims,
kio=card.model_dump(mode="json"),
)
db.add(row)
add_audit_entry(
db, who.login, who.role.value, "scenario.submission.create", str(row.id)
)
await db.commit()
if isinstance(row, dict):
await audit(who.login, who.role.value, "scenario.submission.create", str(row["id"]))
return _out(row, who.full_name)
@router.get("")
async def list_submissions(
request: Request,
db: AsyncSession | None = Depends(submission_session),
) -> list[dict]:
who: Principal = require(request, Role.TRAINEE, Role.INSTRUCTOR, Role.ADMIN)
if db is None:
if who.role is Role.TRAINEE:
rows = [
row
for row in _demo_submissions.values()
if row["author_trainee_id"] == who.trainee_id
]
else:
rows = list(_demo_submissions.values())
rows.sort(key=lambda item: item["created_at"], reverse=True)
return [_out(row) for row in rows]
query = select(ScenarioSubmission, Trainee.name).join(
Trainee, Trainee.id == ScenarioSubmission.author_trainee_id
)
if who.role is Role.TRAINEE:
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
query = query.where(ScenarioSubmission.author_trainee_id == who.trainee_id)
elif who.role is Role.INSTRUCTOR:
owned_groups = select(Group.id).where(Group.owner_login == who.login)
query = query.where(ScenarioSubmission.group_id.in_(owned_groups))
rows = (
await db.execute(query.order_by(ScenarioSubmission.created_at.desc()))
).all()
return [_out(row, name) for row, name in rows]
async def _reviewable(
db: AsyncSession, submission_id: UUID, who: Principal
) -> ScenarioSubmission | None:
# Serialize concurrent teacher decisions. Under PostgreSQL READ COMMITTED,
# a second reviewer waits and then observes the committed non-pending status,
# instead of racing to publish the same scenario twice.
query = (
select(ScenarioSubmission)
.where(ScenarioSubmission.id == submission_id)
.with_for_update()
)
if who.role is Role.INSTRUCTOR:
owned_groups = select(Group.id).where(Group.owner_login == who.login)
query = query.where(ScenarioSubmission.group_id.in_(owned_groups))
return await db.scalar(query)
@router.post("/{submission_id}/review")
async def review_submission(
submission_id: UUID,
body: ReviewIn,
request: Request,
db: AsyncSession | None = Depends(submission_session),
) -> dict:
who: Principal = require(request, Role.INSTRUCTOR, Role.ADMIN)
if db is None:
row = _demo_submissions.get(submission_id)
if row is None:
raise HTTPException(status_code=404, detail="submission_not_found")
if row["status"] != "pending":
raise HTTPException(status_code=409, detail="submission_already_reviewed")
else:
row = await _reviewable(db, submission_id, who)
if row is None:
raise HTTPException(status_code=404, detail="submission_not_found")
if row.status != "pending":
raise HTTPException(status_code=409, detail="submission_already_reviewed")
scenario = None
if body.decision == "approve":
try:
scenario = _scenario_for(row)
except ScenarioError as exc:
raise HTTPException(
status_code=422, detail=f"scenario_invalid: {exc}"
) from exc
now = datetime.now(UTC)
if isinstance(row, dict):
row["status"] = "approved" if scenario else "rejected"
row["review_comment"] = body.comment.strip()
row["reviewed_by"] = who.login
row["reviewed_at"] = now
if scenario is not None:
row["scenario_id"] = scenario.id
else:
row.status = "approved" if scenario else "rejected"
row.review_comment = body.comment.strip()
row.reviewed_by = who.login
row.reviewed_at = now
if scenario is not None:
db.add(
ScenarioRow(
id=scenario.id,
title=scenario.title,
incident_type=scenario.type.value,
level=scenario.level.value,
topics=scenario.topics,
modes=scenario.modes,
status="published",
owner_login=who.login,
body=scenario.model_dump(mode="json"),
)
)
row.scenario_id = scenario.id
add_audit_entry(
db,
who.login,
who.role.value,
f"scenario.submission.{body.decision}",
str(submission_id),
f"comment_chars={len(body.comment.strip())}" if body.comment else "",
)
await db.commit()
if scenario is not None:
store.register_owned_scenario(scenario, who.login)
if isinstance(row, dict):
await audit(
who.login,
who.role.value,
f"scenario.submission.{body.decision}",
str(submission_id),
f"comment_chars={len(body.comment.strip())}" if body.comment else "",
)
result = _out(row)
if scenario is not None:
result["scenario_id"] = scenario.id
return result
def reset_demo_submissions() -> None:
_demo_submissions.clear()

View file

@ -1,37 +1,58 @@
"""Библиотека сценариев по HTTP.
`GET /api/scenarios/{id}` **не отдаёт** `facts` и `ground_truth`: иначе курсант
откроет DevTools и прочитает адрес до того, как его спросит.
`checklist` скрыт по той же причине и даже более веской: чек-лист — это
содержимое подсказок. Отдать его целиком значит выдать в контрольном режиме
то, чего там не должно быть вовсе, и обойти выдачу по одному пункту
(docs/product/MODES.md#подсказка-по-запросу). Подсказки идут только событием
`hint.shown` из живой сессии, эталонные вопросы — только в разборе.
Курсантский каталог и карточка отдают только заголовок, сложность и доступные
режимы: классификатор, факты, личность звонящего и чек-лист не должны быть
доступны заранее через DevTools. Инструктор и администратор получают редакторскую
карточку. Подсказки в сессии выдаются по одному пункту через `hint.shown`,
эталонные вопросы — только в разборе (docs/product/MODES.md#подсказка-по-запросу).
"""
import hashlib
import json
from collections.abc import AsyncIterator
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
from app.domain import ekp
from app.db.base import get_session
from app.api.auth import add_audit_entry, audit, require
from app.config import get_settings
from app.db.base import get_session
from app.db.models import Group, Trainee
from app.dialog.llm import LlmUnavailable
from app.domain import ekp
from app.domain.roles import Role
from app.scenarios import store
from app.scenarios.editor import validate
from app.scenarios.generation import GenerationError, generate, generate_from_description
from app.dialog.llm import LlmUnavailable
from app.scenarios.generation import (
GenerationError,
generate,
generate_from_description,
)
from app.scenarios.loader import ScenarioError
from app.scoring.grammar import assess
from app.session.hub import hub
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"}
async def _hidden_scenario_ids(db: AsyncSession | None, who) -> set[str]:
"""Scenario drafts are private to their instructor and that instructor's class."""
if who.role is Role.ADMIN:
return set()
owner_login = who.login
if who.role is Role.TRAINEE:
if db is None or who.trainee_id is None:
owner_login = ""
else:
owner_login = await db.scalar(
select(Group.owner_login)
.join(Trainee, Trainee.group_id == Group.id)
.where(Trainee.id == who.trainee_id)
) or ""
return await store.scenario_ids_owned_by_other(db, owner_login)
async def scenario_session() -> AsyncIterator[AsyncSession | None]:
@ -77,6 +98,25 @@ def _draft_out(row) -> dict:
}
def _draft_grammar_hash(scenario) -> str:
"""Stable fingerprint of the caller dialogue fields covered by grammar QA."""
payload = {
"first_line": scenario.first_line,
"facts": [
{"id": fact.id, "value": fact.value, "refined": fact.refined}
for fact in scenario.facts
],
}
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _audit_before_commit(actor: str, role: str, action: str, detail: str = ""):
return lambda transaction, row: add_audit_entry(
transaction, actor, role, action, str(row.id), detail
)
@router.post("/drafts/from-template", status_code=201)
async def create_template_draft(
body: TemplateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_session)
@ -85,8 +125,17 @@ async def create_template_draft(
source = store.get(body.source_id)
if source is None:
raise HTTPException(status_code=404, detail="published_source_not_found")
row = await store.create_draft(db, source=source, title=body.title, owner_login=who.login)
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
row = await store.create_draft(
db,
source=source,
title=body.title,
owner_login=who.login,
before_commit=_audit_before_commit(
who.login, who.role.value, "scenario.draft.create", f"template:{source.id}"
),
)
if db is None:
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
return _draft_out(row)
@ -100,13 +149,19 @@ async def create_ai_draft(
raise HTTPException(status_code=404, detail="published_source_not_found")
try:
proposal = await generate(source, body.instruction.strip(), require_fact_change=False)
row = await store.create_draft(db, source=source, proposal=proposal, owner_login=who.login)
row = await store.create_draft(
db, source=source, proposal=proposal, owner_login=who.login,
before_commit=_audit_before_commit(
who.login, who.role.value, "scenario.draft.ai_generate", f"source:{source.id}",
),
)
except LlmUnavailable as exc:
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
except GenerationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "scenario.draft.ai_generate", row.id,
f"source:{source.id}")
if db is None:
await audit(who.login, who.role.value, "scenario.draft.ai_generate", row.id,
f"source:{source.id}")
return _draft_out(row)
@ -123,14 +178,19 @@ async def create_full_ai_draft(
try:
proposal = await generate_from_description(source, body.description.strip())
row = await store.create_draft(
db, source=source, full_proposal=proposal, owner_login=who.login
db, source=source, full_proposal=proposal, owner_login=who.login,
before_commit=_audit_before_commit(
who.login, who.role.value, "scenario.draft.ai_generate_full",
f"class_source:{source.id}",
),
)
except LlmUnavailable as exc:
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
except GenerationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "scenario.draft.ai_generate_full", row.id,
f"class_source:{source.id}")
if db is None:
await audit(who.login, who.role.value, "scenario.draft.ai_generate_full", row.id,
f"class_source:{source.id}")
return _draft_out(row)
@ -157,10 +217,16 @@ async def patch_draft(
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
row = await store.update_draft(db, row, body)
row = await store.update_draft(
db, row, body,
before_commit=_audit_before_commit(
who.login, who.role.value, "scenario.draft.update"
),
)
except ScenarioError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "scenario.draft.update", row.id)
if db is None:
await audit(who.login, who.role.value, "scenario.draft.update", row.id)
return _draft_out(row)
@ -178,13 +244,22 @@ async def revise_ai_draft(
try:
source = validate(row.body)
proposal = await generate(source, body.comment.strip(), require_fact_change=False)
row = await store.revise_draft(db, row, proposal)
row = await store.revise_draft(
db, row, proposal,
before_commit=_audit_before_commit(
who.login, who.role.value, "scenario.draft.ai_revise",
f"instruction_chars={len(body.comment.strip())}",
),
)
except LlmUnavailable as exc:
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
except (GenerationError, ScenarioError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "scenario.draft.ai_revise", row.id,
body.comment.strip()[:500])
# Editorial instructions can contain names, addresses, or other sensitive
# details. Keep only non-content metadata in the durable admin audit log.
if db is None:
await audit(who.login, who.role.value, "scenario.draft.ai_revise", row.id,
f"instruction_chars={len(body.comment.strip())}")
return _draft_out(row)
@ -207,6 +282,45 @@ async def validate_draft(
}
@router.post("/drafts/{scenario_id}/grammar-check")
async def check_draft_grammar(
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
"""Явная языковая проверка после ручного редактирования сценария.
Это только диагностический результат: проверяются реплика звонящего и
текстовые значения фактов, но содержимое не исправляется и не публикуется.
"""
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
scenario = validate(row.body)
except ScenarioError as exc:
raise HTTPException(status_code=422, detail=f"сначала исправьте структуру: {exc}") from exc
fields = [("first_line", scenario.first_line)]
for fact in scenario.facts:
fields.append((f"facts.{fact.id}.value", fact.value))
if fact.refined:
fields.append((f"facts.{fact.id}.refined", fact.refined))
checks = []
for field, value in fields:
result = await assess(value)
checks.append({
"field": field,
"passed": result.passed,
"errors": list(result.errors),
"source": result.source,
})
passed = all(item["passed"] for item in checks)
row.grammar_check_hash = _draft_grammar_hash(scenario) if passed else None
if db is not None:
await db.commit()
return {"valid": passed, "checks": checks}
@router.post("/drafts/{scenario_id}/approve")
async def approve_draft(
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
@ -216,10 +330,23 @@ async def approve_draft(
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
scenario = await store.approve_draft(db, row)
current = validate(row.body)
if (row.manual_edit_pending
and row.grammar_check_hash != _draft_grammar_hash(current)):
raise HTTPException(
status_code=409,
detail="после ручных правок требуется успешная проверка грамматики",
)
scenario = await store.approve_draft(
db, row,
before_commit=_audit_before_commit(
who.login, who.role.value, "scenario.approve"
),
)
except ScenarioError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "scenario.approve", scenario.id)
if db is None:
await audit(who.login, who.role.value, "scenario.approve", scenario.id)
return {"id": scenario.id, "status": "published", "title": scenario.title}
@ -228,15 +355,36 @@ async def listing(
request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> list[dict]:
who = require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
if db is not None:
# Published student scenarios may have been approved on a peer backend.
# Refresh this process-local catalog from the shared authoritative DB.
await store.restore_published(db)
owned_ids = (
await store.owned_scenario_ids(db, who.login)
if who is not None and who.role is Role.INSTRUCTOR
else set()
)
return [
{
hidden_ids = await _hidden_scenario_ids(db, who)
result = []
for scenario in store.all_scenarios():
if scenario.id in hidden_ids:
continue
if who.role is Role.TRAINEE:
# A trainee may select a scenario for self-practice, but the catalog
# must not reveal dispatch codes, answer hints, or instructor-only metadata.
if "self" not in scenario.modes:
continue
result.append({
"id": scenario.id,
"title": scenario.title,
"level": scenario.level.value,
"modes": scenario.modes,
})
continue
result.append({
"id": scenario.id,
"title": scenario.title,
"outcome": scenario.outcome.value,
"type": scenario.type.value,
"level": scenario.level.value,
"topics": scenario.topics,
@ -253,9 +401,9 @@ async def listing(
if scenario.ground_truth.incident_code
and ekp.incident(scenario.ground_truth.incident_code) else None),
"can_manage": scenario.id in owned_ids,
}
for scenario in store.all_scenarios()
]
"source": "trainee" if "student-created" in scenario.topics else "system",
})
return result
@router.delete("/{scenario_id}")
@ -267,10 +415,16 @@ async def archive_scenario(
who = require(request, Role.INSTRUCTOR)
if hub.has_active_scenario(scenario_id):
raise HTTPException(status_code=409, detail="scenario_is_used_by_active_session")
scenario = await store.archive(db, scenario_id, owner_login=who.login)
scenario = await store.archive(
db, scenario_id, owner_login=who.login,
before_commit=_audit_before_commit(
who.login, who.role.value, "scenario.archive"
),
)
if scenario is None:
raise HTTPException(status_code=404, detail="scenario_not_found")
await audit(who.login, who.role.value, "scenario.archive", scenario_id)
if db is None:
await audit(who.login, who.role.value, "scenario.archive", scenario_id)
return {"id": scenario_id, "status": "archived", "title": scenario.title}
@ -280,23 +434,42 @@ async def restore_scenario(
db: AsyncSession | None = Depends(scenario_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
scenario = await store.restore_archived(db, scenario_id, owner_login=who.login)
scenario = await store.restore_archived(
db, scenario_id, owner_login=who.login,
before_commit=_audit_before_commit(
who.login, who.role.value, "scenario.restore"
),
)
if scenario is None:
raise HTTPException(status_code=404, detail="archived_scenario_not_found")
await audit(who.login, who.role.value, "scenario.restore", scenario_id)
if db is None:
await audit(who.login, who.role.value, "scenario.restore", scenario_id)
return {"id": scenario_id, "status": "published", "title": scenario.title}
@router.get("/{scenario_id}")
async def read(scenario_id: str, request: Request) -> dict:
async def read(
scenario_id: str,
request: Request,
db: AsyncSession | None = Depends(scenario_session),
) -> dict:
# Training content is local but not public: anonymous clients must not be
# able to enumerate cards or inspect even the trainee-safe scenario body.
require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
who = require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
if scenario_id in await _hidden_scenario_ids(db, who):
raise HTTPException(status_code=404, detail="scenario_not_found")
if db is not None:
await store.restore_published(db)
scenario = store.get(scenario_id)
if scenario is None:
raise HTTPException(status_code=404, detail="scenario_not_found")
payload = scenario.model_dump(mode="json")
for key in HIDDEN_FROM_TRAINEE:
payload.pop(key, None)
payload["required_fields"] = scenario.required_fields
return payload
if who.role is Role.TRAINEE:
if "self" not in scenario.modes:
raise HTTPException(status_code=404, detail="scenario_not_found")
return {
"id": scenario.id,
"title": scenario.title,
"level": scenario.level.value,
"modes": scenario.modes,
}
return scenario.model_dump(mode="json")

View file

@ -4,7 +4,10 @@
задним числом не надо (docs/arch/CONTRACT.md#http-api).
"""
from datetime import datetime
import logging
import time
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
@ -13,20 +16,32 @@ from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
from app.api.auth import add_audit_entry, audit, audit_required, require
from app.config import get_settings
from app.db import repo
from app.db.base import get_session
from app.db.models import AuditLog, Score
from app.db.models import AuditLog, Group, Score, Session, Trainee
from app.domain.events import Exercise, SessionMode, SessionReport
from app.scenarios import store
from app.scoring.report import build as build_report
from app.scoring.export import to_csv, to_pdf
from app.domain.roles import Role
from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current
from app.domain.timers import TimerCode
from app.scenarios import store
from app.scoring.export import to_csv, to_pdf
from app.scoring.report import build as build_report
from app.session.checkpoint import load_state
from app.session.hub import hub
from app.voice.recording import recording_path
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
log = logging.getLogger(__name__)
async def optional_session() -> AsyncIterator[AsyncSession | None]:
if get_settings().demo_no_db:
yield None
else:
async for db in get_session():
yield db
class SessionCreate(BaseModel):
@ -48,6 +63,43 @@ class SessionOut(BaseModel):
end_reason: str | None = None
class DdsHistoryOut(BaseModel):
"""Одна завершённая карточка из отчёта занятия; только в границах владельца."""
session_id: UUID
ended_at: datetime
card_id: UUID
scenario_id: str
score_auto: float
score_final: float
reply_text: str = ""
title: str | None = None
address: str | None = None
description: str | None = None
incident_type: str | None = None
victims_count: int | None = None
received_at: datetime | None = None
managed_service: str | None = None
recipient_services: list[str] = []
class ActiveSessionOut(BaseModel):
session_id: UUID
trainee_name: str | None
scenario_id: str
scenario_title: str
mode: SessionMode
exercise: Exercise
started_at: datetime | None
elapsed_seconds: int
dds_card_total: int
dds_open_cards: int
dds_overdue_cards: int
dds_work_overdue_cards: int
dds_statuses: dict[str, str]
dds_snapshot: StationSnapshot | None = None
def _out(session) -> SessionOut:
return SessionOut(
session_id=session.id,
@ -62,14 +114,204 @@ def _out(session) -> SessionOut:
)
@router.get("/dds-history", response_model=list[DdsHistoryOut])
async def dds_history(
request: Request,
limit: int = Query(default=200, ge=1, le=500),
db: AsyncSession | None = Depends(optional_session),
) -> list[DdsHistoryOut]:
"""Durable completed-card registry, limited to the current trainee/instructor."""
who = require(request, Role.TRAINEE, Role.INSTRUCTOR)
if db is None:
await audit_required(
who.login, who.role.value, "dds.history.read", detail="cards=0"
)
return []
statement = (
select(Session, Score)
.join(Score, Score.session_id == Session.id)
.where(Session.ended_at.is_not(None))
.order_by(Session.ended_at.desc())
.limit(limit)
)
if who.role is Role.TRAINEE:
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
statement = statement.where(Session.trainee_id == who.trainee_id)
else:
statement = statement.where(Session.owner_login == who.login)
rows = (await db.execute(statement)).all()
result: list[DdsHistoryOut] = []
for session, score in rows:
report = score.report or {}
full_report = report.get("full_report") or report
if full_report.get("exercise") != Exercise.DDS.value:
continue
for card in full_report.get("card_results", []):
try:
result.append(DdsHistoryOut(
session_id=session.id,
ended_at=session.ended_at,
card_id=card["card_id"],
scenario_id=card["scenario_id"],
score_auto=card["score_auto"],
score_final=score.score_final,
reply_text=card.get("reply_text", ""),
title=card.get("title"),
address=card.get("address"),
description=card.get("description"),
incident_type=card.get("incident_type"),
victims_count=card.get("victims_count"),
received_at=card.get("received_at"),
managed_service=card.get("managed_service"),
recipient_services=card.get("recipient_services", []),
))
except (KeyError, TypeError, ValueError):
log.warning("Пропущена некорректная карточка ДДС в отчёте сессии %s", session.id)
if len(result) >= limit:
await audit_required(
who.login, who.role.value, "dds.history.read",
detail=f"cards={len(result)}",
)
return result
await audit_required(
who.login, who.role.value, "dds.history.read", detail=f"cards={len(result)}"
)
return result
@router.get("/active", response_model=list[ActiveSessionOut])
async def active(
request: Request,
db: AsyncSession | None = Depends(optional_session),
) -> list[ActiveSessionOut]:
"""Компактный live-реестр сессий преподавателя; детали остаются в /ws/observe."""
who = require(request, Role.INSTRUCTOR)
now = datetime.now(UTC)
result: list[ActiveSessionOut] = []
states = {
state.session_id: state
for state in hub.active_sessions(who.login)
}
if db is not None:
rows = (
await db.scalars(
select(Session).where(
Session.owner_login == who.login,
Session.ended_at.is_(None),
Session.live_state.is_not(None),
Session.checkpoint_at.is_not(None),
)
)
).all()
for row in rows:
local = hub.get(row.id)
if local is not None:
if local.owner_login == who.login and not local.ended:
states[row.id] = local
else:
states.pop(row.id, None)
continue
try:
state = load_state(row.live_state, row.checkpoint_at)
except Exception as exc: # noqa: BLE001 — один плохой checkpoint не ломает весь реестр
log.error("Не удалось прочитать checkpoint сессии %s (%s)",
row.id, type(exc).__name__)
continue
state.owner_login = row.owner_login
if not state.ended:
states[state.session_id] = state
for state in states.values():
elapsed = (max(0, int((now - state.started_at).total_seconds()))
if state.started_at else 0)
station = state.station_snapshot() if state.exercise is Exercise.DDS else None
queue = station.queue_cards if station else []
managed_services = state.managed_services()
latest_statuses = {
service: SERVICE_STATUS_LABELS[current(state.status_log, service)]
for service in managed_services
if (state.status_log or state.exercise is Exercise.DDS)
}
result.append(ActiveSessionOut(
session_id=state.session_id,
trainee_name=state.trainee_name,
scenario_id=state.scenario_id,
scenario_title=state.scenario_title,
mode=state.mode,
exercise=state.exercise,
started_at=state.started_at,
elapsed_seconds=elapsed,
dds_card_total=len(state.dds_scenarios),
dds_open_cards=len(queue),
dds_overdue_cards=sum(
not card.timer_stopped and card.elapsed_ms > card.limit_ms for card in queue
),
dds_work_overdue_cards=sum(
(timer := card.timers.timers.get(TimerCode.DDS_WORK)) is not None
and timer.started_at is not None
and not timer.stopped
and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK]
for card in state.dds_live_cards
),
dds_statuses=latest_statuses,
dds_snapshot=station,
))
return result
@router.post("", response_model=SessionOut, status_code=201)
async def create(body: SessionCreate, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
who = require(request, Role.INSTRUCTOR)
group_created = False
try:
group = await repo.ensure_group(db, body.group, owner_login=who.login) if body.group else None
if body.group:
group = await db.scalar(select(Group).where(Group.name == body.group))
group_created = group is None
group = await repo.ensure_group(
db, body.group, owner_login=who.login, commit=False
)
else:
group = None
except PermissionError as exc:
raise HTTPException(status_code=404, detail="group_not_found") from exc
trainee = await repo.ensure_trainee(db, body.trainee, group) if body.trainee else None
trainee_created = False
if body.trainee:
trainee = await db.scalar(select(Trainee).where(Trainee.name == body.trainee))
trainee_created = trainee is None
try:
trainee = await repo.ensure_trainee(
db, body.trainee, group, owner_login=who.login, commit=False
)
except PermissionError as exc:
# A group created earlier in this same request must not be left
# behind when the selected learner is outside this instructor's scope.
await db.rollback()
raise HTTPException(status_code=404, detail="trainee_not_found") from exc
else:
trainee = None
def audit_creation(transaction, row):
if group_created and group is not None:
add_audit_entry(
transaction, who.login, who.role.value,
"group.create", str(group.id), group.name,
)
if trainee_created and trainee is not None:
add_audit_entry(
transaction, who.login, who.role.value,
"trainee.profile.create", str(trainee.id),
)
add_audit_entry(
transaction,
who.login,
who.role.value,
"session.create",
str(row.id),
f"scenario={row.scenario_id}; mode={row.mode}; attempt={row.attempt}",
)
session = await repo.create_session(
db,
scenario_id=body.scenario_id,
@ -77,13 +319,8 @@ async def create(body: SessionCreate, request: Request, db: AsyncSession = Depen
trainee_id=trainee.id if trainee else None,
group_id=group.id if group else None,
owner_login=who.login,
)
await audit(
who.login,
who.role.value,
"session.create",
str(session.id),
f"scenario={session.scenario_id}; mode={session.mode}; attempt={session.attempt}",
backend_node_id=get_settings().backend_node_id,
before_commit=audit_creation,
)
return _out(session)
@ -160,7 +397,7 @@ def _live(session_id: UUID):
async def _report_data(
session_id: UUID, request: Request, db: AsyncSession,
session_id: UUID, request: Request, db: AsyncSession | None,
) -> SessionReport:
"""Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки.
@ -185,8 +422,21 @@ async def _report_data(
raise HTTPException(status_code=409, detail="self_assessment_required")
if state.score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
if hub.journal is not None and isinstance(db, AsyncSession):
persisted_session = await db.scalar(
select(Session.id).where(Session.id == session_id)
)
if (persisted_session is not None and await db.scalar(
select(Score.session_id).where(Score.session_id == session_id)
) is None):
# Live state is populated just before the journal transaction commits.
# Do not expose a report that looks ready but cannot yet be corrected
# or retrieved after restart.
raise HTTPException(status_code=409, detail="score_not_ready")
return build_report(session_id, state, scenario)
if db is None:
raise HTTPException(status_code=404, detail="session_not_found")
session = await repo.get_session(db, session_id)
if session is None:
raise HTTPException(status_code=404, detail="session_not_found")
@ -214,26 +464,32 @@ async def _report_data(
@router.get("/{session_id}/report", response_model=SessionReport)
async def report(
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
) -> SessionReport:
return await _report_data(session_id, request, db)
data = await _report_data(session_id, request, db)
who = require(request)
await audit_required(who.login, who.role.value, "report.read", str(session_id))
return data
@router.get("/{session_id}/report.csv")
async def report_csv(
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
) -> Response:
"""Те же права и готовность оценки, что у JSON-разбора."""
data = await _report_data(session_id, request, db)
content = to_csv(data)
who = require(request)
await audit_required(who.login, who.role.value, "report.export.csv", str(session_id))
return Response(
content=to_csv(data), media_type="text/csv; charset=utf-8",
content=content, media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.csv"'},
)
@router.get("/{session_id}/report.pdf")
async def report_pdf(
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
) -> Response:
"""Печатный разбор; генерация полностью локальна."""
data = await _report_data(session_id, request, db)
@ -241,6 +497,8 @@ async def report_pdf(
content = to_pdf(data)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
who = require(request)
await audit_required(who.login, who.role.value, "report.export.pdf", str(session_id))
return Response(
content=content, media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.pdf"'},
@ -274,6 +532,7 @@ async def recording(session_id: UUID, request: Request, db: AsyncSession = Depen
path = recording_path(session_id)
if not path.is_file():
raise HTTPException(status_code=404, detail="recording_not_found")
await audit_required(who.login, who.role.value, "recording.read", str(session_id))
return FileResponse(
path,
media_type="audio/wav",
@ -331,7 +590,11 @@ async def override(
role=who.role.value,
action="score.override",
object_id=str(session_id),
detail=f"{score.score_auto} → {body.score_final}: {body.comment}"[:2000],
# The actual reason remains attached to the instructor-facing score
# report. The durable security audit needs the change and actor, not
# a second indefinite copy of free-text that may contain personal data.
detail=(f"{score.score_auto} → {body.score_final}; "
f"comment_chars={len(body.comment)}"),
))
await db.commit()
@ -371,7 +634,7 @@ async def listing(
mode: SessionMode | None = None,
since: datetime | None = Query(default=None, alias="from"),
limit: int = 100,
db: AsyncSession = Depends(get_session),
db: AsyncSession | None = Depends(optional_session),
) -> list[SessionOut]:
who = require(request)
# Обучающийся видит только свою историю, что бы он ни передал в фильтре.
@ -380,6 +643,30 @@ async def listing(
raise HTTPException(status_code=403, detail="trainee_profile_required")
trainee = who.trainee_id
owner_login = who.login if who.role is Role.INSTRUCTOR else None
if db is None:
# The explicit in-memory demo keeps completed session state in `hub`
# until restart. It has no group records, so group-filtered history is
# empty rather than silently leaking sessions outside that filter.
if group is not None:
return []
states = hub.history(
owner_login=owner_login,
trainee_id=trainee,
mode=mode.value if mode else None,
since=since,
limit=limit,
)
return [SessionOut(
session_id=state.session_id,
scenario_id=state.scenario_id,
mode=state.mode,
attempt=state.attempt,
trainee_id=state.trainee_id,
group_id=None,
started_at=state.started_at,
ended_at=state.ended_at,
end_reason=state.end_reason.value if state.end_reason else None,
) for state in states]
rows = await repo.history(
db,
trainee_id=trainee,

View file

@ -13,12 +13,14 @@ from pydantic import BaseModel
from sqlalchemy import exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import DEMO_TRAINEE_ID, require
from app.api.auth import DEMO_TRAINEE_ID, audit_required, require
from app.config import get_settings
from app.domain.roles import Role
from app.db.base import get_session, get_sessionmaker
from app.db.models import Group, Score, Session, Trainee, User
from app.domain.taxonomy import ERRORS, ErrorCode
from app.scoring.export import certificate_pdf
from app.scoring.group import RECOMMENDATIONS
from app.voice.recording import recording_path
router = APIRouter(prefix="/api/trainees", tags=["trainees"])
@ -67,6 +69,9 @@ async def certificate(
)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
await audit_required(
who.login, who.role.value, "trainee.certificate.export.pdf", str(trainee_id)
)
return Response(
content=content,
media_type="application/pdf",
@ -107,14 +112,38 @@ class DeltaOut(BaseModel):
facts_got: int | None = None
class RecommendationOut(BaseModel):
code: str
title: str
recommendation: str
occurrences: int
class ProfileOut(BaseModel):
trainee: TraineeOut
attempts: list[AttemptOut]
competencies: dict[str, float]
deltas: list[DeltaOut]
recommendations: list[RecommendationOut]
hints_total: int
def _personal_recommendations(codes: dict[str, int]) -> list[RecommendationOut]:
"""Следующие упражнения опираются на коды последней оценённой попытки."""
recommendations = []
for code, count in codes.items():
if code not in RECOMMENDATIONS or not isinstance(count, int) or count <= 0:
continue
error = ERRORS[ErrorCode(code)]
recommendations.append(RecommendationOut(
code=code,
title=error.title,
recommendation=RECOMMENDATIONS[code],
occurrences=count,
))
return sorted(recommendations, key=lambda item: (-item.occurrences, item.code))[:5]
@router.get("", response_model=list[TraineeOut])
async def listing(request: Request) -> list[TraineeOut]:
"""Список курсантов — преподавателю и администратору: обучающемуся он
@ -187,8 +216,11 @@ async def profile(
rows = await db.execute(attempts_query)
attempts: list[AttemptOut] = []
competency_sums: dict[str, list[float]] = {}
latest_scored_codes: dict[str, int] = {}
for session, score in rows:
summary = (score.report or {}).get("summary", {}) if score else {}
if score is not None:
latest_scored_codes = summary.get("codes", {})
attempts.append(
AttemptOut(
session_id=session.id,
@ -230,13 +262,18 @@ async def profile(
)
)
return ProfileOut(
result = ProfileOut(
trainee=TraineeOut(id=trainee.id, name=trainee.name, group=group.name if group else None),
attempts=attempts,
competencies=competencies,
deltas=deltas,
recommendations=_personal_recommendations(latest_scored_codes),
hints_total=sum(attempt.hints or 0 for attempt in attempts),
)
await audit_required(
who.login, who.role.value, "trainee.profile.read", str(trainee_id)
)
return result
def _diff(before, after):

View file

@ -7,38 +7,46 @@
import asyncio
import json
import logging
from uuid import UUID
import re
from types import SimpleNamespace
from uuid import UUID, uuid4
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.api.auth import principal_of, websocket_origin_allowed
from app.domain.events import (
StationState,
CallIncoming,
BgStart,
CallEnded,
CallEndReason,
CallIncoming,
CallStarted,
CallerUtterance,
ErrorEvent,
ErrorKind,
Exercise,
HintShown,
KioState,
KioPatchOut,
KioState,
PatchSource,
ScoreReady,
SessionEnded,
SessionMode,
StationState,
TimerTick,
TextTurnAccepted,
Speaker,
TranscriptAppend,
TraineeToServer,
)
from app.domain.events import BgStart
from app.scenarios import store
from app.session.finish import finish, refresh_archived_report, release_score
from app.api.auth import principal_of
from app.domain.roles import Role
from app.session.hub import hub
from app.session.state import now_utc
from app.domain.kio import ResponseStatus
from app.dialog.slots import TurnResult
from app.domain.roles import Role
from app.scenarios import store
from app.session.dds import prepare_handoff_queue
from app.session.finish import finish, refresh_archived_report, release_score
from app.session.hub import LEASE_FENCED_MESSAGE, hub
from app.session.state import now_utc
from app.voice.models import TTS_RATE, get_voice_models
from app.voice.pipeline import VoiceSession
from app.voice.recording import start_recording
@ -53,6 +61,89 @@ FRAMES_PER_LOG = 250 # раз в пять секунд звука
_adapter = TypeAdapter(TraineeToServer)
class _TextSlotView:
"""Grounded facts for the text exercise when the optional embedder is absent."""
def __init__(self, state):
self.scenario = state.scenario
self.state = state
def revealed_facts(self):
return [SimpleNamespace(id=fact.id, value=self.state.text_revealed_facts[fact.id])
for fact in self.scenario.facts if fact.id in self.state.text_revealed_facts]
def _text_turn(state, text: str):
"""Match typed questions to approved checklist prompts; never let the model
decide which hidden scenario fact becomes available."""
turn = None
if state.slots is not None:
turn = state.slots.hear(text)
for fact in state.slots.revealed_facts():
state.text_revealed_facts[fact.id] = fact.value
if turn.refined:
return turn
# The lexical offline matcher misses natural follow-ups such as “а точнее,
# ближайший дом?”. Once the caller has disclosed a fact with a refinement,
# allow an explicit request for precision to reveal only that refined value.
# This remains a deterministic slot rule: the model never chooses the fact.
normalized = text.casefold().replace("ё", "е")
asks_for_precision = bool(re.search(
r"\b(точн\w*|конкретн\w*|ближ\w*|номер\w*|уточн\w*)\b", normalized
))
if asks_for_precision:
for fact in state.scenario.facts:
if (fact.id in state.text_revealed_facts and fact.refine_on and fact.refined):
state.text_revealed_facts[fact.id] = fact.refined
if state.slots is not None:
if fact.id not in state.slots.refined:
state.slots.refined.append(fact.id)
if fact.id not in state.slots.revealed:
state.slots.revealed.append(fact.id)
if fact.refine_on not in state.slots.asked:
state.slots.asked.append(fact.refine_on)
return TurnResult(text=text, matched=[fact.refine_on], refined=[fact.id])
if turn is not None and turn.matched:
return turn
words = set(re.findall(r"[а-яё]{3,}", text.casefold().replace("ё", "е")))
stop = {"что", "как", "где", "когда", "сколько", "есть", "это", "или", "вас", "вам", "пожалуйста"}
words -= stop
best = None
best_score = 0.0
for item in state.scenario.checklist:
if not item.question:
continue
for phrase in [item.question, *item.examples]:
prompt_words = set(re.findall(r"[а-яё]{3,}", phrase.casefold().replace("ё", "е"))) - stop
score = len(words & prompt_words) / max(1, len(prompt_words))
if score > best_score:
best, best_score = item, score
turn = TurnResult(text=text)
if best is None or best_score < 0.25:
return turn
turn.matched.append(best.id)
fact_ids = [fact.id for fact in state.scenario.facts
if fact.reveal_on and fact.reveal_on.question == best.id]
if best.fact and best.fact not in fact_ids:
fact_ids.append(best.fact)
for fact in state.scenario.facts:
if fact.refine_on == best.id and fact.refined:
state.text_revealed_facts[fact.id] = fact.refined
turn.refined.append(fact.id)
for fact_id in fact_ids:
fact = next((item for item in state.scenario.facts if item.id == fact_id), None)
if fact is None:
continue
if fact_id in state.text_revealed_facts:
turn.repeated.append(fact_id)
else:
state.text_revealed_facts[fact_id] = fact.value
turn.revealed.append(fact_id)
return turn
def _on_audio(session_id: UUID, state, frame: bytes) -> None:
"""Приём аудиокадра: в голосовой контур, а без него — только счёт."""
if len(frame) != FRAME_BYTES:
@ -96,8 +187,8 @@ async def _handle(session_id: UUID, state, event) -> None:
code=ErrorKind.UNSUPPORTED_EVENT, message="Занятие уже завершено",
))
return
if state.exercise is Exercise.DDS or (
state.exercise is Exercise.CARD and event.type not in {"kio.patch", "card.submit"}
if (event.type == "text.turn" and state.exercise is not Exercise.CARD) or state.exercise is Exercise.DDS or (
state.exercise is Exercise.CARD and event.type not in {"kio.patch", "card.submit", "text.turn"}
) or (state.exercise is Exercise.CALL and event.type == "card.submit"):
hub.to_trainee(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT, message="Действие недоступно в этом упражнении",
@ -110,13 +201,53 @@ async def _handle(session_id: UUID, state, event) -> None:
))
return
match event.type:
case "text.turn":
if state.caller is None or state.persona is None or state.scenario is None:
hub.to_trainee(session_id, ErrorEvent(
code=ErrorKind.MODELS_WARMING_UP,
message="Текстовый диалог пока не готов. Обновите занятие или заполните карточку по вводной.",
))
return
turn = _text_turn(state, event.text)
operator_entry = state.append(Speaker.OPERATOR, event.text)
accepted = TextTurnAccepted(text=event.text, at=operator_entry.at)
hub.to_trainee(session_id, accepted)
hub.to_observers(session_id, TranscriptAppend(entry=operator_entry))
if hub.journal:
await hub.journal.utterance(session_id, operator_entry)
try:
slots = state.slots if state.slots is not None else _TextSlotView(state)
line = await state.caller.reply(turn, state.persona, slots)
except Exception as exc: # noqa: BLE001
# The model/provider exception can contain the prompt and incident facts.
log.error("text dialogue failed for session %s (%s)",
session_id, type(exc).__name__)
hub.to_trainee(session_id, ErrorEvent(
code=ErrorKind.INTERNAL, message="Не удалось получить ответ заявителя. Попробуйте ещё раз.",
))
return
caller_entry = state.append(Speaker.CALLER, line.text, line.mood)
hub.to_trainee(session_id, CallerUtterance(
utterance_id=uuid4(), text=line.text,
at=caller_entry.at, mood=line.mood, source=line.source,
))
hub.to_observers(session_id, TranscriptAppend(entry=caller_entry))
if hub.journal:
await hub.journal.utterance(session_id, caller_entry)
case "card.submit":
state.on_event("card.submit")
state.kio.registered_at = state.started_at or now_utc()
state.kio.response_status = ResponseStatus.TRANSFERRED
state.dispatched_card = state.kio.model_copy(deep=True)
state.dispatched_at = now_utc()
if state.handoff_to_dds:
state.on_event("dds.dispatch")
prepare_handoff_queue(
state,
state.pending_dds_scenarios,
arrival_interval_seconds=state.dds_arrival_interval_seconds,
max_waiting=state.dds_max_waiting,
)
state.pending_dds_scenarios = []
else:
state.ended_at = state.dispatched_at
state.end_reason = CallEndReason.COMPLETE
@ -135,15 +266,17 @@ async def _handle(session_id: UUID, state, event) -> None:
)
await finish(session_id, state)
case "call.answer":
state.on_event("call.answer")
state.started_at = now_utc()
first_answer = state.started_at is None
if first_answer:
state.on_event("call.answer")
state.started_at = now_utc()
if hub.journal:
await hub.journal.session_started(session_id, state.started_at)
hub.to_trainee(session_id, CallStarted(started_at=state.started_at))
hub.to_observers(session_id, state.snapshot())
if hub.journal:
await hub.journal.session_started(session_id, state.started_at)
if state.recorder is None:
state.recorder = start_recording(session_id)
_start_voice(session_id, state)
_start_voice(session_id, state, initial_statement=first_answer)
case "kio.patch":
old_code, old_notify = state.kio.incident_code, list(state.kio.notify)
@ -219,12 +352,16 @@ async def _handle(session_id: UUID, state, event) -> None:
state.on_event("callback.dial")
case "self_assessment.submit":
if hub.journal and not await hub.journal.self_assessment(
session_id, event.missed, event.comment, now_utc()
):
hub.to_trainee(session_id, ErrorEvent(
code=ErrorKind.INTERNAL,
message="Не удалось сохранить самооценку и аудит; итог пока не выдан.",
))
return
state.self_assessed = True
state.self_assessment = {"missed": event.missed, "comment": event.comment}
if hub.journal:
await hub.journal.self_assessment(
session_id, event.missed, event.comment, now_utc()
)
await refresh_archived_report(session_id, state)
# Оценка могла быть готова раньше самооценки — теперь её можно отдать.
await release_score(session_id, state)
@ -246,7 +383,7 @@ async def _handle(session_id: UUID, state, event) -> None:
await hub.checkpoint(session_id)
def _start_voice(session_id: UUID, state) -> None:
def _start_voice(session_id: UUID, state, *, initial_statement: bool = True) -> None:
"""Голос включается, когда курсант снял трубку: звонящий сразу кричит первую реплику."""
models = get_voice_models()
scenario = store.get(state.scenario_id)
@ -269,7 +406,8 @@ def _start_voice(session_id: UUID, state) -> None:
if scenario.background:
event = BgStart(loop=scenario.background.loop, gain_db=scenario.background.gain_db)
hub.broadcast(session_id, event)
state.voice.speak(scenario.first_line, state.persona.mood)
if initial_statement:
state.voice.speak(scenario.first_line, state.persona.mood)
async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
@ -280,6 +418,10 @@ async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
await ws.send_bytes(item)
else:
await ws.send_text(item.model_dump_json())
if (isinstance(item, ErrorEvent) and item.code is ErrorKind.INTERNAL
and item.message == LEASE_FENCED_MESSAGE):
await ws.close(code=1012)
return
async def _reject(ws: WebSocket, message: str) -> None:
@ -292,6 +434,12 @@ async def _reject(ws: WebSocket, message: str) -> None:
@router.websocket("/ws/call/{session_id}")
async def call(ws: WebSocket, session_id: UUID) -> None:
if not websocket_origin_allowed(ws):
await ws.close(code=1008)
return
if hub.is_lease_fenced(session_id):
await ws.close(code=1012)
return
await ws.accept()
# АРМ курсанта. Преподаватель допущен, чтобы показать приём вызова группе.
@ -309,6 +457,14 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
)
await ws.close()
return
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
await ws.send_text(
ErrorEvent(
code=ErrorKind.SESSION_NOT_FOUND, message="Занятие ещё не запущено преподавателем"
).model_dump_json()
)
await ws.close()
return
if who.role is Role.TRAINEE and (
state.trainee_id is None or state.trainee_id != who.trainee_id
):
@ -343,6 +499,12 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
if state.score is not None and state.self_assessed:
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
hub.to_trainee(session_id, TimerTick(timers=state.timers.snapshot()))
if state.started_at is not None and not state.ended and state.voice is None:
# Rebuild non-serializable audio services after backend recovery;
# the audio journal rehydrates the existing recording timeline.
if state.recorder is None:
state.recorder = start_recording(session_id)
_start_voice(session_id, state, initial_statement=False)
writer = asyncio.create_task(_pump(ws, queue))
try:
while True:
@ -376,7 +538,8 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=str(payload)[:200]),
)
continue
await _handle(session_id, state, event)
async with hub.durable_transition(session_id):
await _handle(session_id, state, event)
except WebSocketDisconnect:
return
finally:

View file

@ -11,18 +11,23 @@
import asyncio
import logging
import math
import secrets
from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.api.auth import audit, principal_of
from app.api.auth import audit, principal_of, websocket_origin_allowed
from app.config import get_settings
from app.db.base import get_sessionmaker
from app.db.repo import SessionNodeConflict
from app.dialog.director import apply as apply_directive
from app.dialog.director import mood_of
from app.dialog.factory import build_caller
from app.dialog.persona import PersonaState
from app.dialog.runtime import get_embedder
from app.dialog.slots import SlotMachine
from app.domain.classifiers import Outcome
from app.domain.events import (
CallEnded,
CallEndReason,
@ -43,7 +48,7 @@ from app.domain.roles import Role
from app.domain.timers import TimerCode
from app.scenarios import store
from app.session.dds import prepare_queue
from app.session.hub import hub
from app.session.hub import LEASE_FENCED_MESSAGE, hub
from app.session.state import SessionState, now_utc
from app.voice.models import get_voice_models
from app.voice.pipeline import FILLERS, prefetch
@ -54,21 +59,16 @@ router = APIRouter()
_adapter = TypeAdapter(InstructorToServer)
def card_briefing(state: SessionState) -> CardBriefing:
"""Учебная текстовая вводная — исходные реплики, а не эталон карточки.
def _dds_ineligible_scenarios(scenarios):
"""Консультация и передача региона не являются готовыми карточками ДДС."""
return [scenario for scenario in scenarios if scenario.outcome is not Outcome.CARD]
В отсутствие диалога факты раскрываются сразу. Если факт уточняется,
показываем и уточнение: иначе правильно заполнить карточку невозможно.
"""
def card_briefing(state: SessionState) -> CardBriefing:
"""Первую реплику показывает курсант; факты раскрываются только в ответах."""
scenario = state.scenario
lines = ["Учебная текстовая вводная: сведения заявителя приведены ниже.",
scenario.first_line]
for fact in scenario.facts:
lines.append(f"• {fact.value}")
if fact.refined:
lines.append(f" Уточнено: {fact.refined}")
return CardBriefing(
scenario_id=scenario.id, mode=state.mode, text="\n".join(lines),
scenario_id=scenario.id, mode=state.mode, text=scenario.first_line,
required_fields=([field for field in state.required_fields if field != "dds"]
if scenario.ground_truth.incident_code else list(state.required_fields)),
card=state.kio,
@ -83,40 +83,151 @@ async def _start(session_id: UUID, event, who=None) -> None:
message="Передача в ДДС доступна только для текстовой карточки 112",
))
return
scenario = store.get(event.scenario_id)
scenario_ids = list(dict.fromkeys([
event.scenario_id, *(event.scenario_ids or []), *(event.random_scenario_ids or []),
]))
if get_settings().demo_no_db or hub.journal is None:
catalog = {
scenario_id: scenario for scenario_id in scenario_ids
if (scenario := store.get(scenario_id)) is not None
}
hidden_scenario_ids = (
await store.scenario_ids_owned_by_other(None, who.login)
if who is not None else set()
)
else:
try:
async with get_sessionmaker()() as db:
catalog, hidden_scenario_ids = await store.published_catalog(
db, scenario_ids, who.login if who is not None else None,
)
except Exception as exc:
# Avoid serializing scenario facts or SQL bind values into application logs.
log.error("не удалось разрешить сценарий из общей библиотеки (%s)", type(exc).__name__)
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.INTERNAL,
message="Не удалось проверить сценарий в общей библиотеке; запуск отменён.",
))
return
scenario = catalog.get(event.scenario_id)
if scenario is None:
hub.to_observers(
session_id,
ErrorEvent(code=ErrorKind.SCENARIO_INVALID, message=f"Нет сценария {event.scenario_id}"),
)
return
if scenario.id in hidden_scenario_ids:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID,
message="Сценарий не найден или недоступен этому преподавателю",
))
return
if event.random_scenario_ids:
if len(event.random_scenario_ids) > 96:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID,
message="Случайный отбор ограничен 96 карточками",
))
return
pool_ids = list(dict.fromkeys(event.random_scenario_ids))
pool = [catalog.get(scenario_id) for scenario_id in pool_ids]
if any(item is None for item in pool):
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID,
message="В случайном отборе есть неизвестный сценарий",
))
return
if hidden_scenario_ids.intersection(pool_ids):
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID,
message="Сценарий не найден или недоступен этому преподавателю",
))
return
ineligible_pool = _dds_ineligible_scenarios(pool)
if ineligible_pool:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID,
message="Случайный отбор должен содержать только готовые карточки ДДС",
))
return
scenario = secrets.choice(pool)
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) > 96:
if event.exercise is Exercise.DDS or event.handoff_to_dds:
if (not scenario_ids or scenario_ids[0] != event.scenario_id
or len(scenario_ids) > 96):
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID,
message="Очередь ДДС должна начинаться с scenario_id и содержать не более 96 карточек",
))
return
scenarios = [store.get(scenario_id) for scenario_id in scenario_ids]
if event.random_scenario_ids:
extra_ids = list(dict.fromkeys(
item for item in scenario_ids[1:] if item != scenario.id
))
extras = [catalog.get(item) for item in extra_ids]
remaining_random = [item for item in pool if item.id != scenario.id
and item.id not in extra_ids]
randomized_tail = secrets.SystemRandom().sample(
remaining_random, k=len(remaining_random)
)
scenarios = [scenario, *extras, *randomized_tail]
else:
scenarios = [catalog.get(scenario_id) for scenario_id in scenario_ids]
if len(scenarios) > 96:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID,
message="Очередь ДДС не может содержать более 96 карточек",
))
return
if any(item is None for item in scenarios):
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID, message="В очереди ДДС есть неизвестный сценарий",
))
return
if any(item.id in hidden_scenario_ids for item in scenarios):
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID,
message="Сценарий не найден или недоступен этому преподавателю",
))
return
ineligible = _dds_ineligible_scenarios(scenarios)
if ineligible:
titles = ", ".join(item.title for item in ineligible)
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID,
message=("В очередь ДДС можно добавить только готовые карточки с исходом "
f"«карточка и передача в ДДС». Исключите: {titles}"),
))
return
else:
scenarios = []
attempt = 1
recorded_trainee_id = event.trainee_id
recorded_service = None
fencing_epoch = 0
if hub.journal:
try:
attempt, recorded_trainee_id, recorded_service = await hub.journal.start_lesson(
persisted = await hub.journal.start_lesson(
session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id,
owner_login=who.login if who is not None else None,
backend_node_id=get_settings().backend_node_id,
)
if persisted is None:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.INTERNAL,
message="Не удалось записать занятие и аудит; запуск отменён.",
))
return
attempt, recorded_trainee_id, recorded_service, fencing_epoch = persisted
except SessionNodeConflict:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.FORBIDDEN,
message="Сессия закреплена за другим backend-узлом; проверьте маршрутизацию proxy",
))
return
except PermissionError:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.FORBIDDEN,
@ -134,6 +245,7 @@ async def _start(session_id: UUID, event, who=None) -> None:
level=scenario.level.value,
mode=event.mode,
owner_login=who.login if who is not None else None,
backend_fencing_epoch=fencing_epoch,
exercise=event.exercise,
handoff_to_dds=event.handoff_to_dds,
scenario=scenario.model_copy(deep=True),
@ -145,6 +257,8 @@ async def _start(session_id: UUID, event, who=None) -> None:
criteria=event.criteria,
)
state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
state.timers.limits[TimerCode.CARD_FILL] = event.criteria.card_fill_time_limit_seconds * 1000
state.timers.limits[TimerCode.DDS_WORK] = event.criteria.dds_card_work_time_limit_seconds * 1000
if event.exercise is Exercise.CALL:
embedder = get_embedder()
if embedder is not None:
@ -165,6 +279,19 @@ async def _start(session_id: UUID, event, who=None) -> None:
state.started_at = state.dispatched_at
else:
state.started_at = now_utc()
if event.exercise is Exercise.CARD:
embedder = get_embedder()
if embedder is not None:
state.slots = SlotMachine(state.scenario, embedder)
state.persona = PersonaState(state.scenario.persona)
state.caller = build_caller(
scenario.id, use_pregenerated=scenario.tree.pregenerated,
)
state.on_event("card.start")
if event.handoff_to_dds:
state.pending_dds_scenarios = [item.model_copy(deep=True) for item in scenarios[1:]]
state.dds_arrival_interval_seconds = event.dds_arrival_interval_seconds
state.dds_max_waiting = event.dds_max_waiting
hub.register(state)
if event.exercise is not Exercise.CALL and hub.journal and state.started_at is not None:
await hub.journal.session_started(session_id, state.started_at)
@ -178,19 +305,6 @@ async def _start(session_id: UUID, event, who=None) -> None:
state.on_event("call.incoming")
await hub.checkpoint(session_id)
hub.start_ticker(session_id)
if who is not None:
# Запуск занятия меняет чужой результат — значит попадает в журнал
# аудита (ТЗ, хранение не менее шести месяцев).
# Сохраняем до продолжения сценария, чтобы завершение процесса не
# потеряло событие. ФИО курсанта в долгоживущий журнал не дублируем.
await audit(
who.login,
who.role.value,
"lesson.start",
str(session_id),
f"{scenario.id}, режим {event.mode.value}",
)
if event.exercise is Exercise.CALL:
hub.to_trainee(
session_id,
@ -217,6 +331,8 @@ async def _stop(session_id: UUID) -> None:
if state is None or state.ended:
return
state.ended_at = now_utc()
if state.exercise is Exercise.CARD and state.dispatched_card is None:
state.on_event("card.end")
state.end_reason = CallEndReason.INSTRUCTOR
if state.voice is not None:
await state.voice.close()
@ -248,6 +364,12 @@ async def _reject(ws: WebSocket, message: str) -> None:
@router.websocket("/ws/control/{session_id}")
async def control(ws: WebSocket, session_id: UUID) -> None:
if not websocket_origin_allowed(ws):
await ws.close(code=1008)
return
if hub.is_lease_fenced(session_id):
await ws.close(code=1012)
return
await ws.accept()
# Пульт преподавателя: управление занятием доступно только ему.
@ -255,9 +377,19 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
if who is None or who.role not in (Role.INSTRUCTOR,):
await _reject(ws, "Недостаточно прав для этого экрана")
return
event_stream = hub.begin_event_stream(session_id)
try:
while True:
payload = await ws.receive_json()
try:
payload = await asyncio.wait_for(ws.receive_json(), timeout=1)
except TimeoutError:
if hub.is_lease_fenced(session_id):
await ws.send_text(ErrorEvent(
code=ErrorKind.INTERNAL, message=LEASE_FENCED_MESSAGE
).model_dump_json())
await ws.close(code=1012)
return
continue
try:
event = _adapter.validate_python(payload)
except ValidationError:
@ -331,6 +463,23 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
message="Оценка должна быть числом от 0 до 100",
))
continue
if hub.journal is not None:
saved = await hub.journal.score_override(
session_id, verdict, who.login, event.comment
)
if not saved:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.INTERNAL,
message="Не удалось сохранить оценку и запись аудита; изменение отменено",
))
continue
else:
# Explicit in-memory demo mode has no Score table.
await audit(
who.login, who.role.value, "score.override", str(session_id),
f"{state.score.get('score_auto')} → {verdict}; "
f"comment_chars={len(event.comment)}",
)
# Автооценка остаётся рядом: видно, что скорректировано и кем.
state.score = {
**state.score,
@ -338,14 +487,6 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
"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)
@ -384,6 +525,20 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
message=f"{event.type} ещё не реализовано",
),
)
await hub.checkpoint(session_id)
try:
await hub.checkpoint(session_id)
except Exception:
if hub.is_lease_fenced(session_id):
await ws.send_text(ErrorEvent(
code=ErrorKind.INTERNAL, message=LEASE_FENCED_MESSAGE
).model_dump_json())
await ws.close(code=1012)
return
raise
if hub.is_lease_fenced(session_id):
await ws.close(code=1012)
return
except WebSocketDisconnect:
return
finally:
await hub.end_event_stream(event_stream)

View file

@ -16,9 +16,9 @@ from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.domain.events import ErrorEvent, ErrorKind
from app.api.auth import principal_of
from app.api.auth import principal_of, websocket_origin_allowed
from app.domain.roles import Role
from app.session.hub import hub
from app.session.hub import LEASE_FENCED_MESSAGE, hub
router = APIRouter()
@ -27,6 +27,10 @@ async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
while True:
event = await queue.get()
await ws.send_text(event.model_dump_json())
if (isinstance(event, ErrorEvent) and event.code is ErrorKind.INTERNAL
and event.message == LEASE_FENCED_MESSAGE):
await ws.close(code=1012)
return
async def _wait_for_disconnect(ws: WebSocket) -> None:
@ -48,6 +52,12 @@ async def _reject(ws: WebSocket, message: str) -> None:
@router.websocket("/ws/observe/{session_id}")
async def observe(ws: WebSocket, session_id: UUID) -> None:
if not websocket_origin_allowed(ws):
await ws.close(code=1008)
return
if hub.is_lease_fenced(session_id):
await ws.close(code=1012)
return
await ws.accept()
# Наблюдение за чужим занятием — не для обучающегося.
@ -64,6 +74,16 @@ async def observe(ws: WebSocket, session_id: UUID) -> None:
await ws.close()
return
# Live state is process-local, so authorize against the owner snapshot on
# the state itself. Instructors may observe only their own sessions;
# administrators retain the cross-owner diagnostic view.
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
await ws.send_text(
ErrorEvent(code=ErrorKind.SESSION_NOT_FOUND, message="Занятие не запущено").model_dump_json()
)
await ws.close()
return
# Снимок при подключении обязателен: монитор в классе включают посреди
# занятия, и он должен показать текущее состояние, а не ждать событий.
await ws.send_text(state.snapshot().model_dump_json())

View file

@ -16,9 +16,10 @@ from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.api.auth import principal_of
from app.api.auth import principal_of, websocket_origin_allowed
from app.domain.events import (
CallEndReason,
CommandAck,
ErrorEvent,
ErrorKind,
Exercise,
@ -40,10 +41,12 @@ from app.domain.statuses import (
StatusError,
current,
)
from app.domain.timers import TimerCode
from app.scoring.address import address_matches
from app.scoring.grammar import assess
from app.session.dds import deliver_due_cards
from app.session.finish import finish, score_current_dds
from app.session.hub import hub
from app.session.hub import LEASE_FENCED_MESSAGE, hub
from app.session.state import now_utc
log = logging.getLogger(__name__)
@ -51,6 +54,13 @@ router = APIRouter()
_adapter = TypeAdapter(StationToServer)
def _start_dds_work_timer(state) -> None:
"""Start the three-minute work clock once, when the card is opened."""
timer = state.timers.timers.get(TimerCode.DDS_WORK)
if timer is None or timer.started_at is None:
state.on_event("dds.open")
REPORT_PHASES = ("dispatched", "arrived", "working", "completed")
REQUIRED_STATUS = {
"dispatched": ServiceStatus.ACCEPTED,
@ -82,14 +92,7 @@ def _line(session_id: UUID, state, speaker: str, text: str) -> None:
def _address_matches(expected: str | None, supplied: str) -> bool:
"""Не даём сообщить бригаде другой номер дома/другую улицу."""
if not expected:
return bool(supplied.strip())
numbers = re.findall(r"\d+", expected)
spoken_numbers = re.findall(r"\d+", supplied)
words = re.findall(r"[а-яё]{4,}", expected.casefold())
spoken_words = re.findall(r"[а-яё]{4,}", supplied.casefold())
return (all(number in spoken_numbers for number in numbers)
and any(word[:4] == spoken[:4] for word in words for spoken in spoken_words))
return address_matches(expected, supplied)
def _incident_matches(state, supplied: str) -> bool:
@ -134,6 +137,11 @@ def _finish_phone_call(session_id: UUID, state) -> None:
async def _finish_dds(session_id: UUID, state) -> None:
state.ended_at = now_utc()
state.end_reason = CallEndReason.COMPLETE
state.capture_active_dds()
for card in state.dds_live_cards:
timer = card.timers.timers.get(TimerCode.DDS_WORK)
if timer is not None and timer.started_at is not None:
card.timers.on_event("dds.finish")
hub.stop_ticker(session_id)
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
@ -152,18 +160,27 @@ async def _handle(session_id: UUID, state, event) -> None:
# Подтверждение приёма — это статус «Принята» у главной службы.
# Кнопка осталась ради живой цепочки 112 → ДДС (lct-20), где
# диспетчер один и выбирать службу не из чего.
if not event.comment.strip():
_error(session_id, "Для подтверждения приёма добавьте комментарий с основанием")
return
if any(action == "card.ack" for action, _at, _detail in state.dds_log):
return
state.on_event("card.ack")
state.dds_log.append(("card.ack", now_utc(), None))
services = state.managed_services()
if services:
_start_dds_work_timer(state)
try:
state.set_service_status(services[0], ServiceStatus.ACCEPTED)
state.set_service_status(
services[0], ServiceStatus.ACCEPTED, event.comment, author="диспетчер"
)
except StatusError:
pass # статус уже стоит: повторное нажатие ничего не меняет
case "card.status":
if event.service not in state.managed_services():
_error(session_id, "Можно менять статусы только своей ДДС")
return
_start_dds_work_timer(state)
try:
state.set_service_status(
event.service, event.status, event.comment, author="диспетчер"
@ -177,6 +194,10 @@ async def _handle(session_id: UUID, state, event) -> None:
# Первичный статус останавливает норматив 30 секунд.
if event.status in PRIMARY:
state.on_event("card.ack")
if event.status in {
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
}:
state.on_event("dds.complete")
case "crew.select":
if event.crew not in state.crew_options():
_error(session_id, "Выберите бригаду из списка доступных")
@ -191,6 +212,8 @@ async def _handle(session_id: UUID, state, event) -> None:
if state.phone_pending is not None:
_error(session_id, "Завершите текущий разговор перед сменой бригады")
return
if state.crew_selected == event.crew and assigned == event.crew:
return
state.crew_selected = event.crew
state.crew_assignments[service] = event.crew
state.dds_log.append(("crew.select", now_utc(), event.crew))
@ -261,13 +284,20 @@ async def _handle(session_id: UUID, state, event) -> None:
):
_error(session_id, "Ответ относится не к текущей карточке")
return
# A browser may lose the acknowledgement after the server has
# committed this replace-style value. Reconnect retries are safe:
# don't create another journal row (or rerun grammar assessment)
# when the current card already contains exactly this text.
if state.reply_text == event.text:
return
state.reply_text = event.text
state.reply_grammar = await assess(event.text)
state.reply_log.append((now_utc(), event.text))
case "card.open":
if state.exercise is not Exercise.DDS or not state.activate_dds_card(event.card_id):
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.activate_dds_card(event.card_id):
_error(session_id, "Карточка отсутствует в текущей очереди")
return
_start_dds_work_timer(state)
hub.to_station(session_id, state.card_received_event())
# CardReceived carries the contents, while StationState carries
# the status journal and current queue. Send both on every switch
@ -275,7 +305,7 @@ async def _handle(session_id: UUID, state, event) -> None:
# card's status snapshot until the next periodic tick.
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
case "card.next":
if state.exercise is not Exercise.DDS or not state.dispatched_card or (
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.dispatched_card or (
event.card_id != state.dispatched_card.card_id
):
_error(session_id, "Следующая карточка недоступна: ID текущей не совпадает")
@ -323,8 +353,18 @@ async def _handle(session_id: UUID, state, event) -> None:
)
return
case "zone.decision":
previous = next(
(detail for action, _at, detail in reversed(state.dds_log)
if action == "zone.decision"),
None,
)
decision = "в зоне" if event.in_zone else "не в зоне"
if previous is not None:
if previous != decision:
_error(session_id, "Решение по зоне уже записано для этой карточки")
return
state.on_event("zone.decision")
state.dds_log.append(("zone.decision", now_utc(), "в зоне" if event.in_zone else "не в зоне"))
state.dds_log.append(("zone.decision", now_utc(), decision))
case "crew.dispatched":
state.kio = state.kio.model_copy(update={"dispatch_order_at": event.at})
state.dds_log.append(("crew.dispatched", now_utc(), None))
@ -342,6 +382,10 @@ async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
while True:
event = await queue.get()
await ws.send_text(event.model_dump_json())
if (isinstance(event, ErrorEvent) and event.code is ErrorKind.INTERNAL
and event.message == LEASE_FENCED_MESSAGE):
await ws.close(code=1012)
return
async def _reject(ws: WebSocket, message: str) -> None:
@ -354,6 +398,12 @@ async def _reject(ws: WebSocket, message: str) -> None:
@router.websocket("/ws/station/{session_id}")
async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
if not websocket_origin_allowed(ws):
await ws.close(code=1008)
return
if hub.is_lease_fenced(session_id):
await ws.close(code=1012)
return
await ws.accept()
# За АРМ ДДС садится обучающийся, преподаватель смотрит и подменяет.
@ -369,6 +419,12 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
)
await ws.close()
return
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
await ws.send_text(
ErrorEvent(code=ErrorKind.SESSION_NOT_FOUND, message="Занятие не запущено").model_dump_json()
)
await ws.close()
return
if who.role is Role.TRAINEE and (
state.trainee_id is None or state.trainee_id != who.trainee_id
):
@ -393,8 +449,47 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=str(payload)[:200]),
)
continue
await _handle(session_id, state, event)
raw_command_id = payload.get("_command_id") if isinstance(payload, dict) else None
try:
command_id = UUID(raw_command_id) if raw_command_id is not None else None
except (ValueError, TypeError, AttributeError):
hub.to_station(
session_id,
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT,
message="Некорректный идентификатор команды"),
)
continue
if command_id is not None and str(command_id) in state.processed_station_commands:
# The checkpoint already proves this exact command committed.
# Re-ack it without rerunning its business transition.
hub.to_station(session_id, CommandAck(command_id=command_id))
continue
async with hub.durable_transition(session_id):
await _handle(session_id, state, event)
if command_id is not None:
state.processed_station_commands.append(str(command_id))
del state.processed_station_commands[:-512]
# Commit the state+dedupe ID before acknowledging. The
# transition context can have already flushed other
# events; an explicit checkpoint here makes the
# command/ACK boundary independent of that batch state.
await hub.checkpoint(session_id)
# The hub stages non-error events until the checkpoint
# transaction has committed, including this ack.
hub.to_station(session_id, CommandAck(command_id=command_id))
except WebSocketDisconnect:
return
except Exception: # noqa: BLE001 — failed durable transition may fence the owner
if not state.lease_fenced:
raise
log.info(
"закрытие станционного WebSocket после fencing занятия %s",
session_id,
)
# `hub.checkpoint` broadcasts a structured fence event before
# propagating the failed database write. Let the sender deliver
# that event and close with 1012 instead of an opaque 1006.
await asyncio.gather(sender, return_exceptions=True)
return
finally:
sender.cancel()