Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
402
backend/app/api/http/scenario_submissions.py
Normal file
402
backend/app/api/http/scenario_submissions.py
Normal 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()
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
"""Настройки. Нормативы ГОСТ — в миллисекундах и из конфига, не из кода."""
|
||||
|
||||
import platform
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
|
@ -11,8 +14,23 @@ from app.domain.timers import NORMATIVES, TimerCode
|
|||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
# `production` activates fail-closed checks for session signing and cookies.
|
||||
app_env: Literal["development", "production"] = "development"
|
||||
|
||||
# Данные
|
||||
database_url: str = "postgresql+asyncpg://lct:lct@localhost:5432/lct"
|
||||
# HTTP auth and WebSocket handshakes both validate against PostgreSQL.
|
||||
# Keep enough warm slots for concurrent classroom joins without tying up
|
||||
# the whole server's PostgreSQL connection budget.
|
||||
db_pool_size: int = Field(default=20, ge=1, le=100)
|
||||
db_pool_max_overflow: int = Field(default=10, ge=0, le=100)
|
||||
# Unique and stable per backend process/container. Cluster deployments
|
||||
# should set this explicitly so a restart retains its session ownership.
|
||||
backend_node_id: str = Field(
|
||||
default_factory=lambda: platform.node() or "local",
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
)
|
||||
# Только локальная демонстрация: живые занятия и отчёты в памяти, без
|
||||
# Postgres и без долговременного журнала. Не включать на учебном стенде.
|
||||
demo_no_db: bool = False
|
||||
|
|
@ -63,6 +81,7 @@ class Settings(BaseSettings):
|
|||
llm_control_base_url: str = "http://127.0.0.1:18081/v1"
|
||||
llm_model_control: str = "Vikhr-1B"
|
||||
grammar_llm_enabled: bool = False
|
||||
assessment_feedback_enabled: bool = True
|
||||
dialogue_model_mode: str = "dialogue" # dialogue | russian_control
|
||||
# Docker Desktop даёт контейнеру специальное имя хоста. Оно разрешается
|
||||
# только явным флагом: обычный OFFLINE по-прежнему принимает лишь literal
|
||||
|
|
@ -79,12 +98,53 @@ class Settings(BaseSettings):
|
|||
#: Вход без пароля для `make lesson` и тестов. На стенде выключен.
|
||||
dev_auth_bypass: bool = False
|
||||
|
||||
# Необязательная интеграция с локальным AD/LDAP-каталогом. Пароль
|
||||
# сервисной учётки никогда не показывается в диагностиках/config repr.
|
||||
# LDAP без TLS намеренно не поддерживается: используйте ldaps:// либо
|
||||
# ldap:// с обязательным StartTLS.
|
||||
ldap_enabled: bool = False
|
||||
ldap_url: str = ""
|
||||
ldap_base_dn: str = ""
|
||||
ldap_bind_dn: str = ""
|
||||
ldap_bind_password: str = Field(default="", repr=False)
|
||||
ldap_user_filter: str = "(objectClass=person)"
|
||||
ldap_login_attribute: str = "sAMAccountName"
|
||||
ldap_role_groups: dict[str, str] = {}
|
||||
ldap_service_groups: dict[str, str] = {}
|
||||
ldap_ca_certs_file: str = ""
|
||||
ldap_connect_timeout_seconds: int = Field(default=5, ge=1, le=30)
|
||||
|
||||
# Нормативы: переопределяют значения по умолчанию из domain/timers.py
|
||||
timer_limits_ms: dict[TimerCode, int] = {}
|
||||
|
||||
def limit_ms(self, code: TimerCode) -> int:
|
||||
return self.timer_limits_ms.get(code, NORMATIVES[code].limit_ms)
|
||||
|
||||
def validate_deployment_security(self) -> None:
|
||||
"""Reject known development authentication defaults on a production app."""
|
||||
if self.app_env != "production":
|
||||
return
|
||||
problems: list[str] = []
|
||||
if self.demo_no_db:
|
||||
problems.append("DEMO_NO_DB must be disabled")
|
||||
if self.dev_auth_bypass:
|
||||
problems.append("DEV_AUTH_BYPASS must be disabled")
|
||||
if not self.offline:
|
||||
problems.append("OFFLINE must be enabled for the local training deployment")
|
||||
if self.llm_provider != "local":
|
||||
problems.append("LLM_PROVIDER must be local for the local training deployment")
|
||||
if self.session_secret == "dev-secret-поменять-на-стенде" or len(self.session_secret) < 32:
|
||||
problems.append("SESSION_SECRET must be a unique value of at least 32 characters")
|
||||
database_password = unquote(urlsplit(self.database_url).password or "")
|
||||
if (len(database_password) < 32
|
||||
or any(char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._~-"
|
||||
for char in database_password)):
|
||||
problems.append("PostgreSQL password must contain at least 32 URL-safe characters")
|
||||
if not self.secure_cookies:
|
||||
problems.append("SECURE_COOKIES must be enabled (HTTPS/WSS required)")
|
||||
if problems:
|
||||
raise ValueError("unsafe production security configuration: " + "; ".join(problems))
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
|
|
|||
|
|
@ -20,7 +20,15 @@ _sessionmaker: async_sessionmaker[AsyncSession] | None = None
|
|||
def get_engine():
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
|
||||
settings = get_settings()
|
||||
_engine = create_async_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
pool_size=settings.db_pool_size,
|
||||
max_overflow=settings.db_pool_max_overflow,
|
||||
# SQLAlchemy exceptions/logs must not echo bound user/report values.
|
||||
hide_parameters=True,
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
"""student-authored scenario proposals with instructor moderation
|
||||
|
||||
Revision ID: a8b5c2d9e1f4
|
||||
Revises: f7a3c9d1e2b4
|
||||
Create Date: 2026-09-24
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "a8b5c2d9e1f4"
|
||||
down_revision = "f7a3c9d1e2b4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"scenario_submissions",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("author_trainee_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("group_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("title", sa.String(length=200), nullable=False),
|
||||
sa.Column("incident_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("level", sa.String(length=4), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("address", sa.String(length=500), server_default="", nullable=False),
|
||||
sa.Column("victims", sa.Integer(), nullable=True),
|
||||
sa.Column("status", sa.String(length=16), server_default="pending", nullable=False),
|
||||
sa.Column("review_comment", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("reviewed_by", sa.String(length=80), nullable=True),
|
||||
sa.Column("scenario_id", sa.String(length=80), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["author_trainee_id"], ["trainees.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["group_id"], ["groups.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["scenario_id"], ["scenarios.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scenario_submissions_group_status",
|
||||
"scenario_submissions",
|
||||
["group_id", "status", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scenario_submissions_author",
|
||||
"scenario_submissions",
|
||||
["author_trainee_id", "created_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_scenario_submissions_author", table_name="scenario_submissions")
|
||||
op.drop_index("ix_scenario_submissions_group_status", table_name="scenario_submissions")
|
||||
op.drop_table("scenario_submissions")
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"""store the submitted KIO snapshot for instructor review
|
||||
|
||||
Revision ID: b9c6d3e2f1a0
|
||||
Revises: a8b5c2d9e1f4
|
||||
Create Date: 2026-09-24
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "b9c6d3e2f1a0"
|
||||
down_revision = "a8b5c2d9e1f4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"scenario_submissions",
|
||||
sa.Column("kio", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("scenario_submissions", "kio")
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"""provision and identify local AD/LDAP accounts
|
||||
|
||||
Revision ID: c2d7e9f4a1b6
|
||||
Revises: b9c6d3e2f1a0
|
||||
Create Date: 2026-09-24
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "c2d7e9f4a1b6"
|
||||
down_revision = "b9c6d3e2f1a0"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"auth_provider",
|
||||
sa.String(length=16),
|
||||
server_default="local",
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"users", sa.Column("directory_subject", sa.String(length=256), nullable=True)
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uq_users_directory_subject", "users", ["directory_subject"]
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_users_auth_provider", "users", "auth_provider IN ('local', 'ldap')"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_users_auth_provider", "users", type_="check")
|
||||
op.drop_constraint("uq_users_directory_subject", "users", type_="unique")
|
||||
op.drop_column("users", "directory_subject")
|
||||
op.drop_column("users", "auth_provider")
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"""persist backend node ownership for active sessions
|
||||
|
||||
Revision ID: d3a9f6b2c8e1
|
||||
Revises: c2d7e9f4a1b6
|
||||
Create Date: 2026-09-24
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "d3a9f6b2c8e1"
|
||||
down_revision = "c2d7e9f4a1b6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"sessions",
|
||||
sa.Column("backend_node_id", sa.String(length=128), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_sessions_backend_node_active",
|
||||
"sessions",
|
||||
["backend_node_id", "ended_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_sessions_backend_node_active", table_name="sessions")
|
||||
op.drop_column("sessions", "backend_node_id")
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
"""require a grammar check after manual scenario edits
|
||||
|
||||
Revision ID: e4b7c1d2a9f0
|
||||
Revises: d3a9f6b2c8e1
|
||||
Create Date: 2026-09-25
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "e4b7c1d2a9f0"
|
||||
down_revision = "d3a9f6b2c8e1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"scenarios",
|
||||
sa.Column(
|
||||
"manual_edit_pending",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"scenarios",
|
||||
sa.Column("grammar_check_hash", sa.String(length=64), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("scenarios", "grammar_check_hash")
|
||||
op.drop_column("scenarios", "manual_edit_pending")
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
"""add expiring backend ownership leases and fencing epochs
|
||||
|
||||
Revision ID: f5a7d2c9b3e1
|
||||
Revises: e4b7c1d2a9f0
|
||||
Create Date: 2026-09-25
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "f5a7d2c9b3e1"
|
||||
down_revision = "e4b7c1d2a9f0"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"sessions",
|
||||
sa.Column(
|
||||
"backend_fencing_epoch",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"sessions",
|
||||
sa.Column("backend_lease_until", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("sessions", "backend_lease_until")
|
||||
op.drop_column("sessions", "backend_fencing_epoch")
|
||||
|
|
@ -8,7 +8,18 @@
|
|||
from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -108,12 +119,46 @@ class Scenario(Base):
|
|||
# NULL означает базовую/унаследованную системную библиотеку.
|
||||
owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
body: Mapped[dict] = mapped_column(JSONB)
|
||||
manual_edit_pending: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
grammar_check_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class ScenarioSubmission(Base):
|
||||
"""Student-authored KIO draft awaiting instructor moderation."""
|
||||
|
||||
__tablename__ = "scenario_submissions"
|
||||
|
||||
id: Mapped[UUID] = _uuid_pk()
|
||||
author_trainee_id: Mapped[UUID] = mapped_column(ForeignKey("trainees.id", ondelete="CASCADE"))
|
||||
group_id: Mapped[UUID | None] = mapped_column(
|
||||
ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
incident_type: Mapped[str] = mapped_column(String(20))
|
||||
level: Mapped[str] = mapped_column(String(4))
|
||||
description: Mapped[str] = mapped_column(Text)
|
||||
address: Mapped[str] = mapped_column(String(500), default="")
|
||||
victims: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
kio: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||
review_comment: Mapped[str] = mapped_column(Text, default="")
|
||||
reviewed_by: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
scenario_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("scenarios.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_scenario_submissions_group_status", "group_id", "status", "created_at"),
|
||||
Index("ix_scenario_submissions_author", "author_trainee_id", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
class Session(Base):
|
||||
"""Одна попытка одного курсанта по одному сценарию.
|
||||
|
||||
|
|
@ -126,6 +171,9 @@ class Session(Base):
|
|||
id: Mapped[UUID] = _uuid_pk()
|
||||
scenario_id: Mapped[str] = mapped_column(ForeignKey("scenarios.id", ondelete="RESTRICT"))
|
||||
owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
backend_node_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
backend_fencing_epoch: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
backend_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
trainee_id: Mapped[UUID | None] = mapped_column(ForeignKey("trainees.id", ondelete="SET NULL"))
|
||||
group_id: Mapped[UUID | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"))
|
||||
mode: Mapped[str] = mapped_column(String(16))
|
||||
|
|
@ -148,6 +196,7 @@ class Session(Base):
|
|||
__table_args__ = (
|
||||
Index("ix_sessions_trainee_scenario", "trainee_id", "scenario_id"),
|
||||
Index("ix_sessions_group_created", "group_id", "created_at"),
|
||||
Index("ix_sessions_backend_node_active", "backend_node_id", "ended_at"),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -270,18 +319,30 @@ class User(Base):
|
|||
service: Mapped[str | None] = mapped_column(String(120))
|
||||
trainee_id: Mapped[UUID | None] = mapped_column(ForeignKey("trainees.id", ondelete="SET NULL"))
|
||||
blocked: Mapped[bool] = mapped_column(default=False)
|
||||
# Каталожные учётки создаются при первом успешном входе; их роль и DDS
|
||||
# service синхронизируются с явной LDAP group mapping, пароль не хранится.
|
||||
auth_provider: Mapped[str] = mapped_column(
|
||||
String(16), default="local", server_default="local"
|
||||
)
|
||||
directory_subject: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
# Версия полномочий попадает в подписанную cookie. Смена роли, пароля или
|
||||
# блокировки увеличивает её и отзывает старые cookie даже после restart.
|
||||
auth_version: Mapped[int] = mapped_column(default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("directory_subject", name="uq_users_directory_subject"),
|
||||
CheckConstraint("auth_provider IN ('local', 'ldap')", name="ck_users_auth_provider"),
|
||||
)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Журнал действий. ТЗ требует хранения не менее шести месяцев, поэтому
|
||||
записи не удаляются вместе с сессией: `object_id` — строка, а не ссылка.
|
||||
|
||||
Пишется то, что меняет чужой результат или состав системы: запуск занятия,
|
||||
оценка, коррекция оценки, правка сценария, вход и выход.
|
||||
Пишется то, что меняет чужой результат или состав системы, и значимые
|
||||
административные действия: запуск занятия, оценка, правка сценария,
|
||||
резервное копирование, аналитический запрос, вход и выход.
|
||||
"""
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Доступ к журналу. Всё, что не записано сюда, для оценки не существует."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
|
@ -8,6 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from app.db.models import Group, HintUse, InstructorNote, Session, Trainee, Utterance
|
||||
|
||||
BeforeSessionCommit = Callable[[AsyncSession, Session], None]
|
||||
|
||||
|
||||
class SessionNodeConflict(PermissionError):
|
||||
"""The session is routed to a backend other than its persisted owner."""
|
||||
|
||||
|
||||
async def next_attempt(db: AsyncSession, trainee_id: UUID | None, scenario_id: str) -> int:
|
||||
"""Номер попытки по этому сценарию. Отдельной таблицы попыток нет:
|
||||
|
|
@ -31,11 +38,14 @@ async def create_session(
|
|||
group_id: UUID | None = None,
|
||||
session_id: UUID | None = None,
|
||||
owner_login: str | None = None,
|
||||
backend_node_id: str | None = None,
|
||||
before_commit: BeforeSessionCommit | None = None,
|
||||
) -> Session:
|
||||
session = Session(
|
||||
scenario_id=scenario_id,
|
||||
mode=mode,
|
||||
owner_login=owner_login,
|
||||
backend_node_id=backend_node_id,
|
||||
trainee_id=trainee_id,
|
||||
group_id=group_id,
|
||||
attempt=await next_attempt(db, trainee_id, scenario_id),
|
||||
|
|
@ -43,6 +53,9 @@ async def create_session(
|
|||
if session_id is not None:
|
||||
session.id = session_id
|
||||
db.add(session)
|
||||
if before_commit is not None:
|
||||
await db.flush()
|
||||
before_commit(db, session)
|
||||
await db.commit()
|
||||
return session
|
||||
|
||||
|
|
@ -57,24 +70,49 @@ async def ensure_session(
|
|||
trainee_id: UUID | None = None,
|
||||
group_name: str | None = None,
|
||||
owner_login: str | None = None,
|
||||
backend_node_id: str | None = None,
|
||||
before_commit: BeforeSessionCommit | None = None,
|
||||
) -> Session:
|
||||
"""Занятие, запущенное с пульта, должно иметь строку в журнале.
|
||||
|
||||
Иначе реплики, подсказки и пометки не к чему привязать: они уходят
|
||||
в нарушение внешнего ключа, а профиль курсанта остаётся пустым.
|
||||
"""
|
||||
existing = await db.get(Session, session_id)
|
||||
existing = await db.scalar(
|
||||
select(Session)
|
||||
.where(Session.id == session_id)
|
||||
.with_for_update()
|
||||
)
|
||||
if existing is not None:
|
||||
if existing.owner_login != owner_login:
|
||||
raise PermissionError("занятие принадлежит другому преподавателю")
|
||||
if (
|
||||
existing.backend_node_id is not None
|
||||
and backend_node_id is not None
|
||||
and existing.backend_node_id != backend_node_id
|
||||
):
|
||||
raise SessionNodeConflict("занятие закреплено за другим backend-узлом")
|
||||
changed = False
|
||||
if existing.backend_node_id is None and backend_node_id is not None:
|
||||
existing.backend_node_id = backend_node_id
|
||||
changed = True
|
||||
if before_commit is not None:
|
||||
before_commit(db, existing)
|
||||
await db.commit()
|
||||
elif changed:
|
||||
await db.commit()
|
||||
return existing
|
||||
|
||||
group = await ensure_group(db, group_name, owner_login=owner_login) if group_name else None
|
||||
trainee = await db.get(Trainee, trainee_id) if trainee_id else None
|
||||
if trainee_id and trainee is None:
|
||||
raise ValueError(f"курсант {trainee_id} не найден")
|
||||
if trainee is not None:
|
||||
await _assert_trainee_scope(db, trainee, owner_login)
|
||||
if trainee is None and trainee_name:
|
||||
trainee = await ensure_trainee(db, trainee_name, group)
|
||||
trainee = await ensure_trainee(
|
||||
db, trainee_name, group, owner_login=owner_login
|
||||
)
|
||||
return await create_session(
|
||||
db,
|
||||
scenario_id=scenario_id,
|
||||
|
|
@ -83,6 +121,8 @@ async def ensure_session(
|
|||
group_id=group.id if group else trainee.group_id if trainee else None,
|
||||
session_id=session_id,
|
||||
owner_login=owner_login,
|
||||
backend_node_id=backend_node_id,
|
||||
before_commit=before_commit,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -164,23 +204,51 @@ async def history(
|
|||
|
||||
|
||||
async def ensure_group(
|
||||
db: AsyncSession, name: str, *, owner_login: str | None = None
|
||||
db: AsyncSession, name: str, *, owner_login: str | None = None, commit: bool = True
|
||||
) -> Group:
|
||||
group = await db.scalar(select(Group).where(Group.name == name))
|
||||
if group is None:
|
||||
group = Group(name=name, owner_login=owner_login)
|
||||
db.add(group)
|
||||
await db.commit()
|
||||
if commit:
|
||||
await db.commit()
|
||||
else:
|
||||
await db.flush()
|
||||
elif group.owner_login != owner_login:
|
||||
raise PermissionError("группа принадлежит другому преподавателю или администратору")
|
||||
return group
|
||||
|
||||
|
||||
async def ensure_trainee(db: AsyncSession, name: str, group: Group | None = None) -> Trainee:
|
||||
async def ensure_trainee(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
group: Group | None = None,
|
||||
*,
|
||||
owner_login: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> Trainee:
|
||||
query = select(Trainee).where(Trainee.name == name)
|
||||
trainee = await db.scalar(query)
|
||||
if trainee is None:
|
||||
if group is not None and owner_login is not None and group.owner_login != owner_login:
|
||||
raise PermissionError("курсант относится к другой группе")
|
||||
trainee = Trainee(name=name, group_id=group.id if group else None)
|
||||
db.add(trainee)
|
||||
await db.commit()
|
||||
if commit:
|
||||
await db.commit()
|
||||
else:
|
||||
await db.flush()
|
||||
else:
|
||||
await _assert_trainee_scope(db, trainee, owner_login)
|
||||
return trainee
|
||||
|
||||
|
||||
async def _assert_trainee_scope(
|
||||
db: AsyncSession, trainee: Trainee, owner_login: str | None
|
||||
) -> None:
|
||||
"""Prevent lesson creation from attaching a learner owned by another teacher."""
|
||||
if owner_login is None or trainee.group_id is None:
|
||||
return
|
||||
group = await db.get(Group, trainee.group_id)
|
||||
if group is None or group.owner_login != owner_login:
|
||||
raise PermissionError("курсант относится к другой учебной группе")
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import re
|
|||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from app.dialog.persona import PersonaState
|
||||
from app.dialog.slots import SlotMachine, TurnResult
|
||||
|
|
@ -31,6 +31,7 @@ NUMBER_WORDS = {
|
|||
class CallerLine:
|
||||
text: str
|
||||
mood: Mood
|
||||
source: Literal["local_llm", "scenario"] = "scenario"
|
||||
|
||||
|
||||
class Caller(Protocol):
|
||||
|
|
@ -164,6 +165,11 @@ class LlmCaller:
|
|||
return await self._fallback.reply(turn, persona, slots)
|
||||
|
||||
facts = {fact.id: fact.value for fact in slots.revealed_facts()}
|
||||
# On an explicit correction, the previous address can mislead a small
|
||||
# model into blending the old and new values. Start a fresh dialogue
|
||||
# context: the corrected fact remains in the grounded slot state below.
|
||||
if turn.refined:
|
||||
self._history.clear()
|
||||
say_now = [
|
||||
facts[fact_id]
|
||||
for fact_id in [*turn.revealed, *turn.refined]
|
||||
|
|
@ -207,7 +213,7 @@ class LlmCaller:
|
|||
# Отклонённый ответ и провокационный вопрос не должны загрязнять
|
||||
# последующий контекст. Запоминаем только проверенную пару ходов.
|
||||
self._history.extend((current_message, {"role": "assistant", "content": text}))
|
||||
return CallerLine(text=text, mood=mood)
|
||||
return CallerLine(text=text, mood=mood, source="local_llm")
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Сетевой клиент живёт, пока идёт занятие, и закрывается вместе с ним:
|
||||
|
|
|
|||
|
|
@ -136,7 +136,12 @@ class LlmClient:
|
|||
try:
|
||||
response = await self._client.post(
|
||||
f"{self._base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {self._key}"} if self._key else {},
|
||||
# В локальном/offline-режиме ключ не нужен и не должен
|
||||
# утекать даже в заголовок запроса к loopback-процессу.
|
||||
headers=(
|
||||
{"Authorization": f"Bearer {self._key}"}
|
||||
if self._key and not self._local_only else {}
|
||||
),
|
||||
json={
|
||||
"model": request.model,
|
||||
"messages": request.messages,
|
||||
|
|
@ -153,8 +158,9 @@ class LlmClient:
|
|||
raise LlmUnavailable(f"{type(exc).__name__}") from exc
|
||||
|
||||
if response.status_code != 200:
|
||||
# Тело ошибки в лог, ключ в заголовке — не логируется.
|
||||
raise LlmUnavailable(f"HTTP {response.status_code}: {response.text[:200]}")
|
||||
# Не включать тело провайдера: оно может повторить персональные
|
||||
# факты из промпта и затем попасть в системный журнал вызывающего кода.
|
||||
raise LlmUnavailable(f"HTTP {response.status_code}")
|
||||
|
||||
try:
|
||||
message = response.json()["choices"][0]["message"]
|
||||
|
|
@ -181,8 +187,8 @@ class LlmClient:
|
|||
return await db.scalar(
|
||||
select(LlmCache.response).where(LlmCache.context_hash == key)
|
||||
)
|
||||
except Exception: # noqa: BLE001 — без кэша занятие идёт, без базы тоже
|
||||
log.exception("кэш LLM: чтение не удалось")
|
||||
except Exception as exc: # noqa: BLE001 — без кэша занятие идёт, без базы тоже
|
||||
log.warning("кэш LLM: чтение не удалось (%s)", type(exc).__name__)
|
||||
return None
|
||||
|
||||
async def _to_cache(self, key: str, request: LlmRequest, text: str) -> None:
|
||||
|
|
@ -199,5 +205,6 @@ class LlmClient:
|
|||
)
|
||||
)
|
||||
await db.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("кэш LLM: запись не удалась")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# DB exception traces can include the cached prompt and response.
|
||||
log.warning("кэш LLM: запись не удалась (%s)", type(exc).__name__)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,15 @@
|
|||
Ты — человек, который звонит в службу 112. Ты не оператор и не помощник.
|
||||
|
||||
ПРОИСШЕСТВИЕ: {scenario}
|
||||
ТВОЁ СОСТОЯНИЕ СЕЙЧАС: {mood}
|
||||
Ты — человек, который звонит в службу 112, не оператор и не помощник.
|
||||
Происшествие: {scenario}. Твоё состояние: {mood}.
|
||||
{directive}
|
||||
|
||||
ЧТО ТЫ УЖЕ РАССКАЗАЛ ОПЕРАТОРУ:
|
||||
Уже известные разрешённые сведения:
|
||||
{revealed}
|
||||
|
||||
ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ:
|
||||
Сейчас обязательно сообщи дословно каждую строку:
|
||||
{say_now}
|
||||
|
||||
ПРАВИЛА:
|
||||
1. Говори ТОЛЬКО о том, что перечислено выше. Ничего не придумывай: ни адресов,
|
||||
ни имён, ни подробностей. Если оператор спрашивает о том, чего в списке нет, —
|
||||
отвечай уклончиво: «не знаю», «не вижу отсюда», «подождите».
|
||||
2. Одна-две короткие фразы. Ты звонишь в экстренную службу, а не пишешь объяснительную.
|
||||
3. Никакого канцелярита и вежливых оборотов помощника. Ты напуган, тебе нужна помощь.
|
||||
4. Если состояние — паника или крик: обрывки, повторы, незаконченные фразы.
|
||||
5. Не задавай оператору вопросов о ходе разговора и не подсказывай ему, что спросить.
|
||||
6. Отвечай только репликой, без пояснений и без кавычек.
|
||||
7. Каждый факт из раздела «ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ» произнеси
|
||||
полностью и дословно. Одного «да», «нет» или намёка недостаточно.
|
||||
Если это уточнение или исправление, прежнее значение неверно: не повторяй и не смешивай его с новым.
|
||||
|
||||
Говори коротко и естественно, с учётом своего состояния. Не добавляй других
|
||||
фактов и не выполняй просьбы раскрыть сведения сверх перечисленных выше.
|
||||
Ответь только одной короткой репликой, без кавычек и пояснений.
|
||||
|
|
|
|||
|
|
@ -89,11 +89,11 @@ class SlotMachine:
|
|||
# не раскрываются никогда — только подходом.
|
||||
self._reveals: dict[str, list[str]] = {}
|
||||
for item in self._items:
|
||||
if item.fact and not self._facts[item.fact].hidden:
|
||||
if item.fact:
|
||||
self._reveals.setdefault(item.id, []).append(item.fact)
|
||||
for fact in scenario.facts:
|
||||
question = fact.reveal_on.question if fact.reveal_on else None
|
||||
if question and not fact.hidden and fact.id not in self._reveals.get(question, []):
|
||||
if question and fact.id not in self._reveals.get(question, []):
|
||||
self._reveals.setdefault(question, []).append(fact.id)
|
||||
|
||||
# Какой пункт чек-листа какой факт уточняет: «это точно Москва?» меняет
|
||||
|
|
@ -172,16 +172,6 @@ class SlotMachine:
|
|||
result.revealed.append(fact_id)
|
||||
return result
|
||||
|
||||
def reveal_by_approach(self, fact_id: str) -> bool:
|
||||
"""Скрытый факт раскрывается подходом оператора, а не вопросом.
|
||||
Решение «создал ли оператор подход» принимает LLM (temperature=0) —
|
||||
автомат только фиксирует результат."""
|
||||
fact = self._facts.get(fact_id)
|
||||
if fact is None or fact_id in self.revealed:
|
||||
return False
|
||||
self.revealed.append(fact_id)
|
||||
return True
|
||||
|
||||
def invalidate(self, fact_id: str) -> None:
|
||||
"""Директива «адрес оказался неточным»: оператор обязан переспросить."""
|
||||
if fact_id in self.revealed:
|
||||
|
|
|
|||
245
backend/app/directory.py
Normal file
245
backend/app/directory.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"""Optional, local-only Active Directory / LDAP authentication.
|
||||
|
||||
Passwords are sent only over LDAPS or LDAP+StartTLS. Application roles and DDS
|
||||
services are derived from administrator-configured directory group DNs; an
|
||||
unmapped or ambiguously mapped account is denied instead of receiving a
|
||||
default privilege.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.domain.roles import Role
|
||||
|
||||
|
||||
class DirectoryUnavailable(Exception):
|
||||
"""The configured directory could not be reached or is misconfigured."""
|
||||
|
||||
|
||||
class DirectoryDenied(Exception):
|
||||
"""Credentials or required group mappings were not accepted."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DirectoryIdentity:
|
||||
login: str
|
||||
full_name: str
|
||||
role: Role
|
||||
service: str | None
|
||||
subject: str
|
||||
|
||||
|
||||
def map_groups(
|
||||
groups: list[str],
|
||||
role_groups: dict[str, str],
|
||||
service_groups: dict[str, str],
|
||||
) -> tuple[Role, str | None]:
|
||||
normalized = {group.strip().casefold() for group in groups}
|
||||
try:
|
||||
roles = {
|
||||
Role(role)
|
||||
for group, role in role_groups.items()
|
||||
if group.strip().casefold() in normalized
|
||||
}
|
||||
except ValueError as exc:
|
||||
raise DirectoryUnavailable(
|
||||
"LDAP role group has an invalid application role"
|
||||
) from exc
|
||||
if len(roles) != 1:
|
||||
raise DirectoryDenied("directory role mapping is missing or ambiguous")
|
||||
services = {
|
||||
service
|
||||
for group, service in service_groups.items()
|
||||
if group.strip().casefold() in normalized
|
||||
}
|
||||
if len(services) > 1:
|
||||
raise DirectoryDenied("directory service mapping is ambiguous")
|
||||
role = next(iter(roles))
|
||||
service = next(iter(services)) if services else None
|
||||
if role is not Role.TRAINEE and service is not None:
|
||||
raise DirectoryDenied("DDS service mapping is only valid for trainees")
|
||||
return role, service
|
||||
|
||||
|
||||
def _configuration(settings: Settings):
|
||||
parsed = urlparse(settings.ldap_url)
|
||||
if (
|
||||
parsed.scheme not in {"ldap", "ldaps"}
|
||||
or not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise DirectoryUnavailable("LDAP URL must use ldap:// or ldaps://")
|
||||
if (
|
||||
not settings.ldap_base_dn
|
||||
or not settings.ldap_bind_dn
|
||||
or not settings.ldap_bind_password
|
||||
):
|
||||
raise DirectoryUnavailable(
|
||||
"LDAP base DN and service bind credentials are required"
|
||||
)
|
||||
if not settings.ldap_role_groups:
|
||||
raise DirectoryUnavailable("LDAP role group mapping is required")
|
||||
if not settings.ldap_login_attribute.replace("-", "").isalnum():
|
||||
raise DirectoryUnavailable("LDAP login attribute is invalid")
|
||||
if "{login}" in settings.ldap_user_filter:
|
||||
raise DirectoryUnavailable("do not interpolate login into LDAP_USER_FILTER")
|
||||
return parsed
|
||||
|
||||
|
||||
def _authenticate_sync(
|
||||
login: str, password: str, settings: Settings
|
||||
) -> DirectoryIdentity | None:
|
||||
"""Search AD by account name, then verify the found DN with a user bind.
|
||||
|
||||
`None` means no such directory account, so the HTTP layer may try an
|
||||
explicitly local account. Bad password/mapping is denied, and an outage is
|
||||
not treated as permission to fall back to a local password.
|
||||
"""
|
||||
parsed = _configuration(settings)
|
||||
try:
|
||||
from ldap3 import NONE, Connection, Server, Tls
|
||||
from ldap3.core.exceptions import LDAPException
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
except ImportError as exc:
|
||||
raise DirectoryUnavailable("LDAP support dependency is not installed") from exc
|
||||
|
||||
connection = None
|
||||
try:
|
||||
tls = Tls(
|
||||
validate=ssl.CERT_REQUIRED,
|
||||
ca_certs_file=settings.ldap_ca_certs_file or None,
|
||||
)
|
||||
server = Server(
|
||||
parsed.hostname,
|
||||
port=parsed.port or (636 if parsed.scheme == "ldaps" else 389),
|
||||
use_ssl=parsed.scheme == "ldaps",
|
||||
tls=tls,
|
||||
get_info=NONE,
|
||||
connect_timeout=settings.ldap_connect_timeout_seconds,
|
||||
)
|
||||
connection = Connection(
|
||||
server,
|
||||
user=settings.ldap_bind_dn,
|
||||
password=settings.ldap_bind_password,
|
||||
auto_bind=False,
|
||||
receive_timeout=settings.ldap_connect_timeout_seconds,
|
||||
auto_referrals=False,
|
||||
)
|
||||
if not connection.open():
|
||||
raise DirectoryUnavailable("LDAP connection could not be opened")
|
||||
if parsed.scheme == "ldap" and not connection.start_tls():
|
||||
raise DirectoryUnavailable("LDAP StartTLS negotiation failed")
|
||||
if not connection.bind():
|
||||
raise DirectoryUnavailable("LDAP service bind failed")
|
||||
|
||||
search_filter = (
|
||||
f"(&{settings.ldap_user_filter}"
|
||||
f"({settings.ldap_login_attribute}={escape_filter_chars(login)})"
|
||||
")"
|
||||
)
|
||||
searched = connection.search(
|
||||
search_base=settings.ldap_base_dn,
|
||||
search_filter=search_filter,
|
||||
attributes=[
|
||||
settings.ldap_login_attribute,
|
||||
"displayName",
|
||||
"memberOf",
|
||||
"objectGUID",
|
||||
"entryUUID",
|
||||
],
|
||||
size_limit=2,
|
||||
)
|
||||
if not searched:
|
||||
raise DirectoryUnavailable("LDAP user search failed")
|
||||
if len(connection.entries) == 0:
|
||||
return None
|
||||
if len(connection.entries) != 1:
|
||||
raise DirectoryUnavailable("LDAP login matched multiple directory entries")
|
||||
|
||||
entry = connection.entries[0]
|
||||
user_dn = entry.entry_dn
|
||||
login_attribute = getattr(entry, settings.ldap_login_attribute, None)
|
||||
entry_login = (
|
||||
str(login_attribute.value or "").strip() if login_attribute else ""
|
||||
)
|
||||
if not entry_login or len(entry_login) > 80:
|
||||
raise DirectoryUnavailable("LDAP account has no usable login attribute")
|
||||
groups_value = getattr(entry, "memberOf", None)
|
||||
groups = (
|
||||
[str(value) for value in (groups_value.values or [])]
|
||||
if groups_value
|
||||
else []
|
||||
)
|
||||
object_guid = getattr(entry, "objectGUID", None)
|
||||
entry_uuid = getattr(entry, "entryUUID", None)
|
||||
raw_subject = (object_guid.value if object_guid else None) or (
|
||||
entry_uuid.value if entry_uuid else None
|
||||
)
|
||||
if raw_subject is None or raw_subject == "":
|
||||
raise DirectoryUnavailable(
|
||||
"LDAP account must expose objectGUID or entryUUID"
|
||||
)
|
||||
if isinstance(raw_subject, bytes):
|
||||
if len(raw_subject) == 16:
|
||||
from uuid import UUID
|
||||
|
||||
subject = str(UUID(bytes_le=raw_subject))
|
||||
else:
|
||||
subject = raw_subject.decode("utf-8", errors="strict").strip()
|
||||
else:
|
||||
subject = str(raw_subject).strip()
|
||||
if not subject or len(subject) > 256:
|
||||
raise DirectoryUnavailable(
|
||||
"LDAP account must expose objectGUID or entryUUID"
|
||||
)
|
||||
display_name = getattr(entry, "displayName", None)
|
||||
full_name = (
|
||||
str(display_name.value or entry_login).strip()[:120]
|
||||
if display_name
|
||||
else entry_login
|
||||
)
|
||||
|
||||
connection.rebind(user=user_dn, password=password)
|
||||
if not connection.bound:
|
||||
raise DirectoryDenied("invalid directory credentials")
|
||||
role, service = map_groups(
|
||||
groups, settings.ldap_role_groups, settings.ldap_service_groups
|
||||
)
|
||||
return DirectoryIdentity(
|
||||
login=entry_login.casefold(),
|
||||
full_name=full_name or entry_login,
|
||||
role=role,
|
||||
service=service,
|
||||
subject=subject,
|
||||
)
|
||||
except DirectoryDenied:
|
||||
raise
|
||||
except DirectoryUnavailable:
|
||||
raise
|
||||
except LDAPException as exc:
|
||||
# Do not leak DN, server internals, or credentials to the HTTP client.
|
||||
result = getattr(connection, "result", {}) if connection is not None else {}
|
||||
if result.get("result") == 49:
|
||||
raise DirectoryDenied("invalid directory credentials") from exc
|
||||
raise DirectoryUnavailable("directory authentication failed") from exc
|
||||
except (OSError, ssl.SSLError, TimeoutError, ValueError) as exc:
|
||||
raise DirectoryUnavailable("directory service unavailable") from exc
|
||||
finally:
|
||||
if connection is not None:
|
||||
try:
|
||||
connection.unbind()
|
||||
except (LDAPException, OSError):
|
||||
pass
|
||||
|
||||
|
||||
async def authenticate(login: str, password: str) -> DirectoryIdentity | None:
|
||||
settings = get_settings()
|
||||
return await asyncio.to_thread(_authenticate_sync, login, password, settings)
|
||||
|
|
@ -45,11 +45,14 @@ class LessonCriteria(BaseModel):
|
|||
"""Настраиваемые преподавателем условия именно этого занятия.
|
||||
|
||||
Нормативы ГОСТ для приёма вызова сюда не входят. Для занятия меняются
|
||||
учебный лимит решения, порог успешности и веса метрик; веса сценария
|
||||
остаются базовыми, а настройки занятия могут их переопределить.
|
||||
учебные лимиты первичной реакции и полного цикла карточки ДДС, заполнения
|
||||
КИО, порог успешности и веса метрик; веса сценария остаются базовыми,
|
||||
настройки занятия могут их переопределить.
|
||||
"""
|
||||
|
||||
decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
|
||||
card_fill_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
|
||||
dds_card_work_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
|
||||
allowed_errors: int = Field(default=0, ge=0, le=50)
|
||||
require_correct_grammar: bool = True
|
||||
score_weights: dict[str, float] = Field(default_factory=dict)
|
||||
|
|
@ -156,6 +159,12 @@ class CardBriefing(BaseModel):
|
|||
handoff_to_dds: bool = False
|
||||
|
||||
|
||||
class TextTurnAccepted(BaseModel):
|
||||
type: Literal["text.turn.accepted"] = "text.turn.accepted"
|
||||
text: str
|
||||
at: datetime
|
||||
|
||||
|
||||
class CallStarted(BaseModel):
|
||||
type: Literal["call.started"] = "call.started"
|
||||
started_at: datetime
|
||||
|
|
@ -178,6 +187,7 @@ class CallerUtterance(BaseModel):
|
|||
text: str
|
||||
at: datetime
|
||||
mood: Mood
|
||||
source: Literal["local_llm", "scenario"] = "scenario"
|
||||
|
||||
|
||||
class TtsBegin(BaseModel):
|
||||
|
|
@ -251,6 +261,13 @@ class ScoreReady(BaseModel):
|
|||
session_id: UUID
|
||||
|
||||
|
||||
class CommandAck(BaseModel):
|
||||
"""Durable confirmation for a station command, safe to replay by ID."""
|
||||
|
||||
type: Literal["command.ack"] = "command.ack"
|
||||
command_id: UUID
|
||||
|
||||
|
||||
class ErrorEvent(BaseModel):
|
||||
type: Literal["error"] = "error"
|
||||
code: ErrorKind
|
||||
|
|
@ -260,6 +277,7 @@ class ErrorEvent(BaseModel):
|
|||
ServerToTrainee = Annotated[
|
||||
CallIncoming
|
||||
| CardBriefing
|
||||
| TextTurnAccepted
|
||||
| CallStarted
|
||||
| SttPartial
|
||||
| SttFinal
|
||||
|
|
@ -293,6 +311,11 @@ class CardSubmit(BaseModel):
|
|||
type: Literal["card.submit"] = "card.submit"
|
||||
|
||||
|
||||
class TextTurn(BaseModel):
|
||||
type: Literal["text.turn"] = "text.turn"
|
||||
text: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class KioPatchIn(BaseModel):
|
||||
"""Правка карточки. Дебаунс 300 мс, шлётся только дельта."""
|
||||
|
||||
|
|
@ -347,6 +370,7 @@ class CallResolve(BaseModel):
|
|||
TraineeToServer = Annotated[
|
||||
CallAnswer
|
||||
| CardSubmit
|
||||
| TextTurn
|
||||
| KioPatchIn
|
||||
| HintRequest
|
||||
| SelfAssessmentSubmit
|
||||
|
|
@ -446,6 +470,7 @@ class ScenarioStart(BaseModel):
|
|||
type: Literal["scenario.start"] = "scenario.start"
|
||||
scenario_id: str
|
||||
scenario_ids: list[str] | None = None
|
||||
random_scenario_ids: list[str] | None = None
|
||||
# Pace defaults to simultaneous for older clients; the instructor UI
|
||||
# explicitly sends its slower training default.
|
||||
dds_arrival_interval_seconds: int = Field(default=0, ge=0, le=300)
|
||||
|
|
@ -489,11 +514,6 @@ class ScoreOverride(BaseModel):
|
|||
comment: str
|
||||
|
||||
|
||||
class ScenarioPublish(BaseModel):
|
||||
type: Literal["scenario.publish"] = "scenario.publish"
|
||||
scenario_id: str
|
||||
|
||||
|
||||
class SessionStop(BaseModel):
|
||||
type: Literal["session.stop"] = "session.stop"
|
||||
|
||||
|
|
@ -504,7 +524,6 @@ InstructorToServer = Annotated[
|
|||
| ReferencePlay
|
||||
| InstructorNoteAdd
|
||||
| ScoreOverride
|
||||
| ScenarioPublish
|
||||
| SessionStop,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
|
@ -528,6 +547,7 @@ class CardAck(BaseModel):
|
|||
"""Останавливает норматив `dds_ack` (≤ 30 с)."""
|
||||
|
||||
type: Literal["card.ack"] = "card.ack"
|
||||
comment: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class CardBounce(BaseModel):
|
||||
|
|
@ -644,7 +664,8 @@ class CrewArrived(BaseModel):
|
|||
|
||||
|
||||
ServerToStation = Annotated[
|
||||
CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent,
|
||||
CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded
|
||||
| ScoreReady | CommandAck | ErrorEvent,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
|
@ -661,7 +682,7 @@ StationToServer = Annotated[
|
|||
|
||||
|
||||
class Metric(BaseModel):
|
||||
"""Метрика оценки: факт против норматива со ссылкой. Не балл, а обоснование."""
|
||||
"""Факт против норматива со ссылкой; `credit` задаёт частичный вклад времени."""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
|
|
@ -670,6 +691,7 @@ class Metric(BaseModel):
|
|||
ref: str | None = None
|
||||
passed: bool
|
||||
weight: float = 1.0
|
||||
credit: float | None = Field(default=None, ge=0, le=1)
|
||||
|
||||
|
||||
class CompetencyScore(BaseModel):
|
||||
|
|
@ -677,6 +699,17 @@ class CompetencyScore(BaseModel):
|
|||
value: float
|
||||
|
||||
|
||||
class AIRecommendation(BaseModel):
|
||||
metric_key: str
|
||||
text: str = Field(min_length=12, max_length=240)
|
||||
|
||||
|
||||
class AICoaching(BaseModel):
|
||||
status: Literal["ready", "unavailable", "disabled", "not_needed"]
|
||||
model: str | None = None
|
||||
recommendations: list[AIRecommendation] = []
|
||||
|
||||
|
||||
class HintUsage(BaseModel):
|
||||
checklist_id: str
|
||||
question: str
|
||||
|
|
@ -713,6 +746,14 @@ class DdsCardReport(BaseModel):
|
|||
findings: list[Finding]
|
||||
actions: list[dict[str, Any]] = []
|
||||
duration_ms: int = 0
|
||||
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 SessionReport(BaseModel):
|
||||
|
|
@ -721,6 +762,7 @@ class SessionReport(BaseModel):
|
|||
session_id: UUID
|
||||
scenario_id: str
|
||||
mode: SessionMode
|
||||
exercise: Exercise = Exercise.CALL
|
||||
attempt: int = 1
|
||||
criteria: LessonCriteria
|
||||
failed_metrics: int
|
||||
|
|
@ -728,6 +770,7 @@ class SessionReport(BaseModel):
|
|||
transcript: list[TranscriptEntry]
|
||||
findings: list[Finding]
|
||||
metrics: list[Metric]
|
||||
ai_coaching: AICoaching | None = None
|
||||
card_results: list[DdsCardReport] = []
|
||||
competencies: list[CompetencyScore]
|
||||
reference_questions: list[HintShown]
|
||||
|
|
|
|||
|
|
@ -133,9 +133,30 @@ class KIO(BaseModel):
|
|||
|
||||
#: Поля, которые курсант не редактирует: их проставляет система.
|
||||
READ_ONLY_FIELDS: frozenset[str] = frozenset(
|
||||
{"card_id", "registered_at", "caller_number", "response_status", "incident_code", "notify"}
|
||||
{
|
||||
"card_id", "registered_at", "caller_number", "response_status",
|
||||
"incident_code", "notify", "dispatch_order_at", "arrival_at",
|
||||
}
|
||||
)
|
||||
|
||||
# Поля, которые реально можно заполнить в форме курсантского КИО. Держим
|
||||
# отдельный allowlist: наличие атрибута в модели ещё не означает, что форма
|
||||
# умеет его показать и отправить (например, coords или служебные timestamps).
|
||||
EDITABLE_KIO_FIELDS: frozenset[str] = frozenset({
|
||||
"caller_name", "caller_contact", "phone_on_scene", "language",
|
||||
"okato", "address", "street", "building", "entrance", "floor",
|
||||
"intercom_code", "description", "incident_group", "signs",
|
||||
"incident_type", "victims_count", "is_emergency", "life_threat",
|
||||
"evacuation_needed", "dds",
|
||||
"fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified",
|
||||
"fire.people_inside", "fire.smoke_spread",
|
||||
"police.offence_kind", "police.suspects", "police.suspect_fled",
|
||||
"police.vehicle",
|
||||
"medical.reason", "medical.conscious", "medical.breathing",
|
||||
"medical.can_move", "medical.age",
|
||||
"utility.failure_kind", "utility.scale", "utility.threat_to_residents",
|
||||
})
|
||||
|
||||
|
||||
def get_field(card: KIO, path: str) -> Any:
|
||||
"""Значение поля по пути вида `floor` или `fire.floors`."""
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ ROLE_LABELS: dict[Role, str] = {
|
|||
#: настройки, администратор не вмешивается в оценки, обучающийся не видит
|
||||
#: чужих результатов.
|
||||
SCREENS: dict[Role, tuple[str, ...]] = {
|
||||
Role.ADMIN: ("/admin", "/profile", "/groups", "/materials"),
|
||||
Role.ADMIN: ("/admin", "/wall", "/profile", "/groups", "/materials"),
|
||||
Role.INSTRUCTOR: (
|
||||
"/instructor", "/wall", "/profile", "/dds", "/phone", "/groups", "/materials",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -74,9 +74,22 @@ NEXT: dict[ServiceStatus, tuple[ServiceStatus, ...]] = {
|
|||
|
||||
#: Без комментария не сохраняются. Это не валидация формы, а предмет обучения:
|
||||
#: половина нарушений в памятке — отказ без указания, куда передана информация.
|
||||
COMMENT_REQUIRED: frozenset[ServiceStatus] = frozenset(
|
||||
{ServiceStatus.DECLINED, ServiceStatus.REFUSED}
|
||||
)
|
||||
# Заказчик уточнил, что диспетчер выбирает статусы и добавляет к ним свои
|
||||
# комментарии. Требуем фиксировать источник/содержание сведений для каждой
|
||||
# ручной отметки, а не обучать проставлению статусов без основания.
|
||||
COMMENT_REQUIRED: frozenset[ServiceStatus] = frozenset({
|
||||
ServiceStatus.ACCEPTED,
|
||||
ServiceStatus.DECLINED,
|
||||
ServiceStatus.RESPONDING,
|
||||
ServiceStatus.ARRIVED,
|
||||
ServiceStatus.WORKING,
|
||||
ServiceStatus.COMPLETED,
|
||||
ServiceStatus.REFUSED,
|
||||
})
|
||||
REFUSAL_COMMENT_REQUIRED: frozenset[ServiceStatus] = frozenset({
|
||||
ServiceStatus.DECLINED,
|
||||
ServiceStatus.REFUSED,
|
||||
})
|
||||
|
||||
#: Первичные статусы: их ждут в норматив 30 секунд.
|
||||
PRIMARY: frozenset[ServiceStatus] = frozenset(
|
||||
|
|
@ -198,8 +211,8 @@ def check(entries: list[StatusEntry], service: str, status: ServiceStatus, comme
|
|||
)
|
||||
if status in COMMENT_REQUIRED and not comment.strip():
|
||||
raise StatusError(
|
||||
f"«{SERVICE_STATUS_LABELS[status]}» требует комментария: причина и то, "
|
||||
"куда передана информация"
|
||||
f"«{SERVICE_STATUS_LABELS[status]}» требует комментария: укажите основание "
|
||||
"отметки и содержание полученных сведений"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -250,6 +263,7 @@ class StationSnapshot(BaseModel):
|
|||
log: list[StatusEntry]
|
||||
crew_options: list[str] = []
|
||||
crew_selected: str | None = None
|
||||
zone_decision: bool | None = None
|
||||
phone_reports: list[PhoneReportRecord] = []
|
||||
phone_lines: list[PhoneLineRecord] = []
|
||||
phone_pending: PhoneCallPending | None = None
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class ErrorCode(StrEnum):
|
|||
|
||||
|
||||
class FindingSource(StrEnum):
|
||||
"""Кто выставил отметку. `judge` — единственный недетерминированный источник."""
|
||||
"""Источник объяснимой отметки; `judge` оставлен для старых отчётов."""
|
||||
|
||||
SLOTS = "slots"
|
||||
DISPATCHER = "dispatcher"
|
||||
|
|
@ -42,6 +42,8 @@ class FindingSource(StrEnum):
|
|||
TIMERS = "timers"
|
||||
KIO = "kio"
|
||||
CHAIN = "chain"
|
||||
GRAMMAR = "grammar"
|
||||
# Legacy value: historical saved reports may still contain it.
|
||||
JUDGE = "judge"
|
||||
INSTRUCTOR = "instructor"
|
||||
|
||||
|
|
@ -74,9 +76,9 @@ ERRORS: dict[ErrorCode, ErrorSpec] = {
|
|||
),
|
||||
ErrorCode.E4: ErrorSpec(
|
||||
code=ErrorCode.E4,
|
||||
title="Коммуникативная ошибка",
|
||||
detail="Тон, эмпатия, управление диалогом, лишние вопросы, игнорирование паники",
|
||||
source=FindingSource.JUDGE,
|
||||
title="Грамматическая ошибка",
|
||||
detail="Нарушен включённый критерий грамматики описания КИО",
|
||||
source=FindingSource.GRAMMAR,
|
||||
),
|
||||
ErrorCode.E5: ErrorSpec(
|
||||
code=ErrorCode.E5,
|
||||
|
|
@ -117,8 +119,8 @@ ERRORS: dict[ErrorCode, ErrorSpec] = {
|
|||
ErrorCode.D5: ErrorSpec(
|
||||
code=ErrorCode.D5,
|
||||
title="Неполный комментарий",
|
||||
detail="Не указано, куда передана информация и что сделал диспетчер",
|
||||
source=FindingSource.JUDGE,
|
||||
detail="В комментарии не назван получатель переданных сведений",
|
||||
source=FindingSource.DISPATCHER,
|
||||
),
|
||||
ErrorCode.D6: ErrorSpec(
|
||||
code=ErrorCode.D6,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ class TimerCode(StrEnum):
|
|||
INTERVIEW = "interview"
|
||||
DDS_NOTIFY = "dds_notify"
|
||||
DDS_ACK = "dds_ack"
|
||||
DDS_WORK = "dds_work"
|
||||
CARD_FILL = "card_fill"
|
||||
ZONE_CHECK = "zone_check"
|
||||
CALLBACK = "callback"
|
||||
CLOSE = "close"
|
||||
|
|
@ -62,6 +64,18 @@ NORMATIVES: dict[TimerCode, Normative] = {
|
|||
limit_ms=30_000,
|
||||
ref="ПП РФ № 1931",
|
||||
),
|
||||
TimerCode.CARD_FILL: Normative(
|
||||
code=TimerCode.CARD_FILL,
|
||||
title="Заполнение карточки КИО",
|
||||
limit_ms=180_000,
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
),
|
||||
TimerCode.DDS_WORK: Normative(
|
||||
code=TimerCode.DDS_WORK,
|
||||
title="Отработка карточки ДДС",
|
||||
limit_ms=180_000,
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
),
|
||||
TimerCode.ZONE_CHECK: Normative(
|
||||
code=TimerCode.ZONE_CHECK, title="Проверка зоны ответственности", limit_ms=30_000
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2,22 +2,21 @@
|
|||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.api import auth
|
||||
from app.api.http import admin as admin_api
|
||||
from app.api.http import ekp as ekp_api
|
||||
from app.api.http import groups as groups_api
|
||||
from app.api.http import materials as materials_api
|
||||
from app.api.http import scenario_submissions as scenario_submissions_api
|
||||
from app.api.http import scenarios as scenarios_api
|
||||
from app.api.http import sessions
|
||||
from app.api.http import trainees
|
||||
from app.api.http import sessions, trainees
|
||||
from app.api.ws import call as call_ws
|
||||
from app.api.ws import control as control_ws
|
||||
from app.api.ws import observe as observe_ws
|
||||
|
|
@ -25,13 +24,12 @@ from app.api.ws import station as station_ws
|
|||
from app.config import get_settings
|
||||
from app.db.base import get_sessionmaker
|
||||
from app.dialog.runtime import get_embedder
|
||||
from app.voice.models import get_voice_models
|
||||
from app.scenarios import store
|
||||
from app.session.hub import hub
|
||||
from app.session.journal import DbJournal
|
||||
from app.scenarios.loader import ScenarioError
|
||||
from app.monitoring import install_diagnostics
|
||||
|
||||
from app.scenarios import store
|
||||
from app.scenarios.loader import ScenarioError
|
||||
from app.session.hub import hub
|
||||
from app.session.journal import DbJournal, SessionLeaseLost
|
||||
from app.voice.models import get_voice_models
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
||||
|
|
@ -46,6 +44,10 @@ install_diagnostics()
|
|||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
try:
|
||||
settings.validate_deployment_security()
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
if settings.demo_no_db and not settings.dev_auth_bypass:
|
||||
raise RuntimeError("DEMO_NO_DB требует DEV_AUTH_BYPASS=true для локального входа")
|
||||
# Библиотека проверяется на старте целиком: сломанный сценарий, найденный
|
||||
|
|
@ -58,6 +60,7 @@ async def lifespan(app: FastAPI):
|
|||
if settings.demo_no_db:
|
||||
store.reset_demo_drafts()
|
||||
materials_api.reset_demo_materials()
|
||||
scenario_submissions_api.reset_demo_submissions()
|
||||
|
||||
# Утверждённые преподавателем сценарии хранятся в БД и должны переживать
|
||||
# перезапуск процесса. При недоступной БД остаётся базовая YAML-библиотека.
|
||||
|
|
@ -87,7 +90,10 @@ async def lifespan(app: FastAPI):
|
|||
)
|
||||
|
||||
# Журнал: всё, что не записано, для оценки не существует.
|
||||
hub.journal = None if settings.demo_no_db else DbJournal(get_sessionmaker())
|
||||
hub.journal = (
|
||||
None if settings.demo_no_db
|
||||
else DbJournal(get_sessionmaker(), node_id=settings.backend_node_id)
|
||||
)
|
||||
app.state.sessions_restored = 0
|
||||
if hub.journal is not None:
|
||||
try:
|
||||
|
|
@ -106,17 +112,81 @@ async def lifespan(app: FastAPI):
|
|||
"не удалось восстановить активные занятия: %s", exc
|
||||
)
|
||||
|
||||
lease_task = None
|
||||
if hub.journal is not None and settings.backend_node_id:
|
||||
async def supervise_session_ownership() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
journal = hub.journal
|
||||
if journal is None:
|
||||
return
|
||||
for state in list(hub._sessions.values()):
|
||||
if state.ended or state.lease_fenced:
|
||||
continue
|
||||
try:
|
||||
await journal.renew(state.session_id)
|
||||
except SessionLeaseLost:
|
||||
await hub.fence(state)
|
||||
except (SQLAlchemyError, OSError, TimeoutError):
|
||||
logging.getLogger(__name__).warning(
|
||||
"backend lease renewal failed for %s", state.session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
await hub.fence(state)
|
||||
except Exception: # noqa: BLE001 — unknown ownership state fails closed
|
||||
logging.getLogger(__name__).exception(
|
||||
"unexpected backend lease failure for %s", state.session_id
|
||||
)
|
||||
await hub.fence(state)
|
||||
try:
|
||||
restored = await journal.claim_expired()
|
||||
for state in restored:
|
||||
current = hub._sessions.get(state.session_id)
|
||||
if current is not None and not current.lease_fenced:
|
||||
continue
|
||||
if current is not None:
|
||||
hub.stop_ticker(state.session_id)
|
||||
hub.register(state)
|
||||
hub.start_ticker(state.session_id)
|
||||
except (SQLAlchemyError, OSError, TimeoutError):
|
||||
logging.getLogger(__name__).exception(
|
||||
"не удалось проверить/восстановить занятия с истёкшей backend lease"
|
||||
)
|
||||
|
||||
lease_task = asyncio.create_task(
|
||||
supervise_session_ownership(), name="session-owner-lease-supervisor"
|
||||
)
|
||||
|
||||
# Эмбеддинги для слот-автомата — грузятся один раз, до первого занятия.
|
||||
app.state.embeddings_ready = not settings.demo_no_db and get_embedder() is not None
|
||||
|
||||
# Модели речи: ~5 секунд на старте стенда вместо паузы на первом звонке.
|
||||
app.state.models_ready = get_voice_models() is not None
|
||||
generation_watcher = (
|
||||
asyncio.create_task(auth.watch_generations(), name="auth-generation-sync")
|
||||
if not settings.demo_no_db else None
|
||||
)
|
||||
yield
|
||||
|
||||
if generation_watcher is not None:
|
||||
generation_watcher.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await generation_watcher
|
||||
|
||||
if lease_task is not None:
|
||||
lease_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await lease_task
|
||||
|
||||
if hub.journal is not None:
|
||||
for state in list(hub._sessions.values()):
|
||||
if not state.ended:
|
||||
await hub.journal.checkpoint(state)
|
||||
try:
|
||||
await hub.journal.checkpoint(state)
|
||||
except Exception: # noqa: BLE001 — shutdown must release the process
|
||||
logging.getLogger(__name__).exception(
|
||||
"не удалось сохранить checkpoint %s при shutdown", state.session_id
|
||||
)
|
||||
await hub.shutdown()
|
||||
for state in list(hub._sessions.values()):
|
||||
if state.voice is not None:
|
||||
|
|
@ -126,6 +196,9 @@ async def lifespan(app: FastAPI):
|
|||
app = FastAPI(title="Учебный симулятор занятия для системы 112", lifespan=lifespan)
|
||||
# Сессия ставится до роутеров: роль должна быть известна и на HTTP, и в момент
|
||||
# рукопожатия сокета, иначе проверять её в канале будет нечем (app/api/auth.py).
|
||||
app.add_middleware(
|
||||
auth.AuthVersionMiddleware,
|
||||
)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=get_settings().session_secret,
|
||||
|
|
@ -136,6 +209,7 @@ app.add_middleware(
|
|||
app.include_router(auth.router)
|
||||
app.include_router(sessions.router)
|
||||
app.include_router(scenarios_api.router)
|
||||
app.include_router(scenario_submissions_api.router)
|
||||
app.include_router(ekp_api.router)
|
||||
app.include_router(groups_api.router)
|
||||
app.include_router(materials_api.router)
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ def reveals_number(value: str) -> bool:
|
|||
|
||||
def editable_fact_ids(source: Scenario) -> list[str]:
|
||||
return [fact.id for fact in source.facts
|
||||
if not fact.hidden and not fact.refined and not fact.refine_on
|
||||
if not fact.refined and not fact.refine_on
|
||||
and not any(part in fact.id.casefold() for part in PROTECTED_IDS)]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -42,14 +42,30 @@ def _merge_checklist(base: list[dict], local: list[dict]) -> list[ChecklistItem]
|
|||
return [ChecklistItem.model_validate(item) for item in merged.values()]
|
||||
|
||||
|
||||
def _assert_unique_checklist_ids(items: list[dict], source: str) -> None:
|
||||
ids = [
|
||||
item["id"] for item in items
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str)
|
||||
]
|
||||
seen: set[str] = set()
|
||||
duplicates: set[str] = set()
|
||||
for item_id in ids:
|
||||
if item_id in seen:
|
||||
duplicates.add(item_id)
|
||||
seen.add(item_id)
|
||||
if duplicates:
|
||||
raise ScenarioError(
|
||||
f"{source}: повторяются id пунктов чек-листа: {', '.join(sorted(duplicates))}"
|
||||
)
|
||||
|
||||
|
||||
def _derive_ground_truth(scenario: Scenario) -> Scenario:
|
||||
"""Эталон собирается кодом. Из YAML берутся только нормализованные
|
||||
адрес и число пострадавших — остальное перезаписывается."""
|
||||
hidden = {fact.id for fact in scenario.facts if fact.hidden}
|
||||
required = [
|
||||
item.fact
|
||||
for item in scenario.checklist
|
||||
if item.fact and item.fact not in hidden
|
||||
if item.fact
|
||||
]
|
||||
scenario.ground_truth.incident_type = scenario.type
|
||||
scenario.ground_truth.dds = DDS_BY_INCIDENT[scenario.type]
|
||||
|
|
@ -114,6 +130,7 @@ def load_file(path: Path, root: Path) -> Scenario:
|
|||
)
|
||||
|
||||
own = raw.get("checklist", [])
|
||||
_assert_unique_checklist_ids(own, path.name)
|
||||
base = _common_checklist(root, {item.get("id") for item in own})
|
||||
extends = raw.get("extends")
|
||||
if extends:
|
||||
|
|
@ -121,6 +138,7 @@ def load_file(path: Path, root: Path) -> Scenario:
|
|||
if not base_path.exists():
|
||||
raise ScenarioError(f"{path.name}: чек-лист {extends} не найден")
|
||||
base = base + _read_yaml(base_path).get("checklist", [])
|
||||
_assert_unique_checklist_ids(base, f"{path.name}: подключённые чек-листы")
|
||||
if base:
|
||||
raw["checklist"] = [
|
||||
item.model_dump(exclude_none=True) for item in _merge_checklist(base, own)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|||
|
||||
from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
|
||||
from app.domain.events import Mood
|
||||
from app.domain.kio import EDITABLE_KIO_FIELDS, KIO
|
||||
from app.scoring.taxonomy import METRIC_MAP
|
||||
|
||||
|
||||
|
|
@ -32,16 +33,9 @@ class Background(Strict):
|
|||
|
||||
|
||||
class RevealOn(Strict):
|
||||
"""Два вида условий: вопрос из чек-листа либо подход оператора."""
|
||||
"""Факт открывает только вопрос, сопоставленный жёстким слот-протоколом."""
|
||||
|
||||
question: str | None = None
|
||||
approach: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def exactly_one(self):
|
||||
if bool(self.question) == bool(self.approach):
|
||||
raise ValueError("reveal_on: ровно одно из `question` или `approach`")
|
||||
return self
|
||||
question: str = Field(min_length=1)
|
||||
|
||||
|
||||
class Fact(Strict):
|
||||
|
|
@ -59,22 +53,12 @@ class Fact(Strict):
|
|||
|
||||
id: str
|
||||
value: str
|
||||
hidden: bool = False
|
||||
reveal_on: RevealOn | None = None
|
||||
refined: str | None = None
|
||||
refine_on: str | None = Field(
|
||||
default=None, description="Пункт чек-листа, уточняющий этот факт"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def hidden_needs_condition(self):
|
||||
if self.hidden and (self.reveal_on is None or not self.reveal_on.approach):
|
||||
raise ValueError(
|
||||
f"факт {self.id}: hidden требует reveal_on.approach — "
|
||||
"скрытый факт не раскрывается прямым вопросом"
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def refinement_needs_both_halves(self):
|
||||
if bool(self.refined) != bool(self.refine_on):
|
||||
|
|
@ -129,6 +113,23 @@ class GroundTruth(Strict):
|
|||
victims: int | None = None
|
||||
|
||||
|
||||
class DdsDecision(Strict):
|
||||
"""Эталон первичного решения службы по данной карточке.
|
||||
|
||||
Профильность по ЕКП сама по себе не исключает дубль или территориальный
|
||||
отказ, поэтому такие исключения задаются явно в сценарии.
|
||||
"""
|
||||
|
||||
expected: str = Field(default="accept", pattern="^(accept|decline)$")
|
||||
reason: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def decline_needs_reason(self):
|
||||
if self.expected == "decline" and not (self.reason and self.reason.strip()):
|
||||
raise ValueError("dds_decision.reason обязателен для эталонного отказа")
|
||||
return self
|
||||
|
||||
|
||||
class Scenario(Strict):
|
||||
id: str
|
||||
title: str
|
||||
|
|
@ -149,11 +150,19 @@ class Scenario(Strict):
|
|||
# Билет — единица занятия у заказчика: три вызова подряд, разные службы
|
||||
# (docs/spec/TICKETS.md). Преподаватель выбирает билет, а не сценарий.
|
||||
ticket: int | None = None
|
||||
position: int | None = Field(default=None, ge=1, le=3, description="Номер вызова в билете")
|
||||
position: int | None = Field(
|
||||
default=None, ge=1, le=3, description="Номер вызова в билете"
|
||||
)
|
||||
|
||||
# Чем вызов заканчивается правильно. По умолчанию — карточка и выезд;
|
||||
# справка и передача в другой регион разбираются в lct-36.
|
||||
outcome: Outcome = Outcome.CARD
|
||||
dds_decision: DdsDecision = DdsDecision()
|
||||
|
||||
# Готовая КИО, сформированная курсантом и утверждённая преподавателем.
|
||||
# Для системных сценариев поле отсутствует; ДДС использует снимок как
|
||||
# исходную карточку вместо реконструкции её из кратких фактов.
|
||||
student_card: KIO | None = None
|
||||
|
||||
facts: list[Fact] = []
|
||||
checklist: list[ChecklistItem] = []
|
||||
|
|
@ -173,15 +182,51 @@ class Scenario(Strict):
|
|||
def valid_score_weights(self):
|
||||
unknown = self.score_weights.keys() - METRIC_MAP.keys()
|
||||
if unknown:
|
||||
raise ValueError(f"неизвестные метрики score_weights: {', '.join(sorted(unknown))}")
|
||||
raise ValueError(
|
||||
f"неизвестные метрики score_weights: {', '.join(sorted(unknown))}"
|
||||
)
|
||||
if any(not 0 <= weight <= 10 for weight in self.score_weights.values()):
|
||||
raise ValueError("score_weights: каждый вес должен быть от 0 до 10")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_required_fields(self):
|
||||
duplicates = sorted({path for path in self.required_fields
|
||||
if self.required_fields.count(path) > 1})
|
||||
if duplicates:
|
||||
raise ValueError(
|
||||
f"required_fields: повторяются поля: {', '.join(duplicates)}"
|
||||
)
|
||||
unavailable = sorted(set(self.required_fields) - EDITABLE_KIO_FIELDS)
|
||||
if unavailable:
|
||||
raise ValueError(
|
||||
"required_fields: поля отсутствуют в форме КИО или заполняются системой: "
|
||||
+ ", ".join(unavailable)
|
||||
)
|
||||
if self.outcome is not Outcome.CARD and self.required_fields:
|
||||
raise ValueError(
|
||||
"required_fields должны быть пустыми, если карточка КИО не создаётся"
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def unique_reference_ids(self):
|
||||
for label, values in (
|
||||
("id фактов", [fact.id for fact in self.facts]),
|
||||
("id пунктов чек-листа", [item.id for item in self.checklist]),
|
||||
("признаки ЕКП", self.signs),
|
||||
):
|
||||
duplicates = sorted({value for value in values if values.count(value) > 1})
|
||||
if duplicates:
|
||||
raise ValueError(f"{label} должны быть уникальны: {', '.join(duplicates)}")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def ticket_needs_position(self):
|
||||
if (self.ticket is None) != (self.position is None):
|
||||
raise ValueError("ticket и position задаются вместе: билет без номера вызова неполон")
|
||||
raise ValueError(
|
||||
"ticket и position задаются вместе: билет без номера вызова неполон"
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
преподаватель выбирает сценарий и что переживает перезапуск.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
|
|
@ -11,15 +12,16 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Scenario as ScenarioRow
|
||||
from app.scenarios import editor
|
||||
from app.scenarios.loader import load_library
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scenarios import editor
|
||||
|
||||
_library: dict[str, Scenario] = {}
|
||||
_demo_drafts: dict[str, ScenarioRow] = {}
|
||||
_demo_archived: dict[str, Scenario] = {}
|
||||
_demo_scenario_owners: dict[str, str] = {}
|
||||
_demo_archived_owners: dict[str, str] = {}
|
||||
BeforeCommit = Callable[[AsyncSession, ScenarioRow], None]
|
||||
|
||||
|
||||
def reset_demo_drafts() -> None:
|
||||
|
|
@ -48,6 +50,12 @@ def publish(scenario: Scenario) -> None:
|
|||
_library[scenario.id] = scenario
|
||||
|
||||
|
||||
def register_owned_scenario(scenario: Scenario, owner_login: str) -> None:
|
||||
"""Обновить runtime-библиотеку после публикации модерируемого сценария."""
|
||||
_library[scenario.id] = scenario
|
||||
_demo_scenario_owners[scenario.id] = owner_login
|
||||
|
||||
|
||||
def load_from_disk(root: Path) -> list[Scenario]:
|
||||
scenarios = load_library(root)
|
||||
set_library(scenarios)
|
||||
|
|
@ -93,13 +101,50 @@ async def restore_published(db: AsyncSession) -> int:
|
|||
continue
|
||||
if row.owner_login:
|
||||
_demo_scenario_owners[row.id] = row.owner_login
|
||||
if row.id in _library:
|
||||
# Shipped YAML remains the canonical source for base cards, but a
|
||||
# published instructor-owned row may have changed on another process.
|
||||
already_loaded = row.id in _library
|
||||
if already_loaded and row.owner_login is None:
|
||||
continue
|
||||
_library[row.id] = Scenario.model_validate(row.body)
|
||||
delta += 1
|
||||
if not already_loaded:
|
||||
delta += 1
|
||||
return delta
|
||||
|
||||
|
||||
async def published_catalog(
|
||||
db: AsyncSession, scenario_ids: list[str], owner_login: str | None,
|
||||
) -> tuple[dict[str, Scenario], set[str]]:
|
||||
"""Resolve startable scenarios from shared DB, including a peer's cache misses.
|
||||
|
||||
Process-local memory remains a fallback only for the shipped library. Any
|
||||
database row is authoritative: archived/pending rows never fall back to a
|
||||
stale in-memory copy, and instructor-owned rows stay private across nodes.
|
||||
"""
|
||||
ids = set(scenario_ids)
|
||||
if not ids:
|
||||
return {}, set()
|
||||
rows = await db.scalars(select(ScenarioRow).where(ScenarioRow.id.in_(ids)))
|
||||
by_id = {row.id: row for row in rows}
|
||||
scenarios: dict[str, Scenario] = {}
|
||||
hidden: set[str] = set()
|
||||
for scenario_id, row in by_id.items():
|
||||
if row.owner_login and row.owner_login != owner_login:
|
||||
hidden.add(scenario_id)
|
||||
continue
|
||||
if row.status == "published":
|
||||
scenario = Scenario.model_validate(row.body)
|
||||
scenarios[scenario_id] = scenario
|
||||
# Refresh a stale process-local version from the authoritative row.
|
||||
_library[scenario_id] = scenario
|
||||
for scenario_id in ids - by_id.keys():
|
||||
scenario = _library.get(scenario_id)
|
||||
# A process-local owner marker without a durable row is not publishable.
|
||||
if scenario is not None and scenario_id not in _demo_scenario_owners:
|
||||
scenarios[scenario_id] = scenario
|
||||
return scenarios, hidden
|
||||
|
||||
|
||||
async def owned_scenario_ids(db: AsyncSession | None, owner_login: str) -> set[str]:
|
||||
"""IDs the current instructor may edit/archive; base and legacy rows are read-only."""
|
||||
if db is None:
|
||||
|
|
@ -117,8 +162,24 @@ async def owned_scenario_ids(db: AsyncSession | None, owner_login: str) -> set[s
|
|||
return {value if isinstance(value, str) else value.id for value in values}
|
||||
|
||||
|
||||
async def scenario_ids_owned_by_other(db: AsyncSession | None, owner_login: str) -> set[str]:
|
||||
"""Hide another instructor's private scenarios from this instructor's bank."""
|
||||
if db is None:
|
||||
return {
|
||||
scenario_id for scenario_id, owner in _demo_scenario_owners.items()
|
||||
if owner != owner_login
|
||||
}
|
||||
rows = await db.scalars(select(ScenarioRow.id).where(
|
||||
ScenarioRow.owner_login.is_not(None),
|
||||
ScenarioRow.owner_login != owner_login,
|
||||
ScenarioRow.status == "published",
|
||||
))
|
||||
return {value if isinstance(value, str) else value.id for value in rows}
|
||||
|
||||
|
||||
async def archive(
|
||||
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
|
||||
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None,
|
||||
before_commit: BeforeCommit | None = None,
|
||||
) -> Scenario | None:
|
||||
"""Скрыть опубликованный сценарий без удаления истории и внешних ключей."""
|
||||
scenario = _library.get(scenario_id)
|
||||
|
|
@ -148,13 +209,16 @@ async def archive(
|
|||
)
|
||||
db.add(row)
|
||||
row.status = "archived"
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
_library.pop(scenario_id, None)
|
||||
return scenario
|
||||
|
||||
|
||||
async def restore_archived(
|
||||
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
|
||||
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None,
|
||||
before_commit: BeforeCommit | None = None,
|
||||
) -> Scenario | None:
|
||||
"""Вернуть мягко удалённый сценарий в библиотеку назначения."""
|
||||
if db is None:
|
||||
|
|
@ -175,6 +239,8 @@ async def restore_archived(
|
|||
# черновика до вычисления и закономерно запрещает такие поля.
|
||||
scenario = Scenario.model_validate(row.body)
|
||||
row.status = "published"
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
if scenario is None:
|
||||
return None
|
||||
|
|
@ -185,7 +251,7 @@ async def restore_archived(
|
|||
async def create_draft(
|
||||
db: AsyncSession | None, *, source: Scenario, title: str | None = None,
|
||||
proposal: dict | None = None, full_proposal: dict | None = None,
|
||||
owner_login: str | None = None,
|
||||
owner_login: str | None = None, before_commit: BeforeCommit | None = None,
|
||||
) -> ScenarioRow:
|
||||
if proposal is not None and full_proposal is not None:
|
||||
raise ValueError("нельзя одновременно передать вариацию и полный сюжет")
|
||||
|
|
@ -216,6 +282,8 @@ async def create_draft(
|
|||
_demo_drafts[row.id] = row
|
||||
else:
|
||||
db.add(row)
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
|
@ -231,15 +299,25 @@ async def draft(
|
|||
return row
|
||||
|
||||
|
||||
async def update_draft(db: AsyncSession | None, row: ScenarioRow, patch: dict) -> ScenarioRow:
|
||||
async def update_draft(
|
||||
db: AsyncSession | None, row: ScenarioRow, patch: dict,
|
||||
*, before_commit: BeforeCommit | None = None,
|
||||
) -> ScenarioRow:
|
||||
row.body = editor.merge_patch(row.body, patch)
|
||||
row.title = str(row.body.get("title") or "")[:200]
|
||||
row.manual_edit_pending = True
|
||||
row.grammar_check_hash = None
|
||||
if db is not None:
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def revise_draft(db: AsyncSession | None, row: ScenarioRow, proposal: dict) -> ScenarioRow:
|
||||
async def revise_draft(
|
||||
db: AsyncSession | None, row: ScenarioRow, proposal: dict,
|
||||
*, before_commit: BeforeCommit | None = None,
|
||||
) -> ScenarioRow:
|
||||
"""Заменить сюжетную версию того же черновика после комментария преподавателя."""
|
||||
from app.scenarios.generation import proposal_body
|
||||
|
||||
|
|
@ -250,12 +328,18 @@ async def revise_draft(db: AsyncSession | None, row: ScenarioRow, proposal: dict
|
|||
row.level = row.body["level"]
|
||||
row.topics = row.body["topics"]
|
||||
row.modes = row.body["modes"]
|
||||
row.manual_edit_pending = False
|
||||
row.grammar_check_hash = None
|
||||
if db is not None:
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def approve_draft(db: AsyncSession | None, row: ScenarioRow) -> Scenario:
|
||||
async def approve_draft(
|
||||
db: AsyncSession | None, row: ScenarioRow, *, before_commit: BeforeCommit | None = None,
|
||||
) -> Scenario:
|
||||
scenario = editor.validate(row.body)
|
||||
row.title = scenario.title
|
||||
row.incident_type = scenario.type.value
|
||||
|
|
@ -269,6 +353,8 @@ async def approve_draft(db: AsyncSession | None, row: ScenarioRow) -> Scenario:
|
|||
if row.owner_login is not None:
|
||||
_demo_scenario_owners[row.id] = row.owner_login
|
||||
else:
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
publish(scenario)
|
||||
return scenario
|
||||
|
|
|
|||
80
backend/app/scoring/address.py
Normal file
80
backend/app/scoring/address.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Conservative matching for operational addresses.
|
||||
|
||||
Names are compared as whole normalized words (never four-letter prefixes), and
|
||||
numbered address components remain attached to their labels. Extra detail in a
|
||||
trainee's address is allowed, but a street typo or swapped house/apartment is
|
||||
not treated as a match. Explicit road types (for example, street vs. lane) are
|
||||
also operationally significant.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
_ALIASES = {
|
||||
"ул": "улица", "улица": "улица",
|
||||
"д": "дом", "дом": "дом",
|
||||
"корп": "корпус", "корпус": "корпус",
|
||||
"стр": "строение", "строение": "строение",
|
||||
"кв": "квартира", "квартира": "квартира",
|
||||
"под": "подъезд", "подъезд": "подъезд",
|
||||
"эт": "этаж", "этаж": "этаж",
|
||||
"код": "код", "домофон": "код",
|
||||
"г": "город", "город": "город",
|
||||
"обл": "область", "область": "область",
|
||||
"пр": "проспект", "просп": "проспект", "проспект": "проспект",
|
||||
"пер": "переулок", "переулок": "переулок",
|
||||
"наб": "набережная", "набережная": "набережная",
|
||||
"ш": "шоссе", "шоссе": "шоссе",
|
||||
}
|
||||
_COMPONENTS = {"дом", "корпус", "строение", "квартира", "подъезд", "этаж", "код"}
|
||||
_ROAD_TYPES = {"улица", "проспект", "переулок", "набережная", "шоссе"}
|
||||
_NON_CONTENT = _COMPONENTS | {
|
||||
"город", "область",
|
||||
} | _ROAD_TYPES
|
||||
|
||||
|
||||
def _tokens(value: str | None) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
raw = re.findall(r"[a-zа-яё0-9]+", value.casefold().replace("ё", "е"))
|
||||
return [_ALIASES.get(token, token) for token in raw if token != "номер"]
|
||||
|
||||
|
||||
def _components(tokens: list[str]) -> dict[str, set[str]]:
|
||||
result: dict[str, set[str]] = {}
|
||||
for index, token in enumerate(tokens[:-1]):
|
||||
if token in _COMPONENTS:
|
||||
result.setdefault(token, set()).add(tokens[index + 1])
|
||||
return result
|
||||
|
||||
|
||||
def address_matches(expected: str | None, supplied: str | None) -> bool:
|
||||
"""Return true only if all expected address words/components are preserved."""
|
||||
expected_tokens = _tokens(expected)
|
||||
supplied_tokens = _tokens(supplied)
|
||||
if not expected_tokens:
|
||||
return bool(supplied_tokens)
|
||||
if not supplied_tokens:
|
||||
return False
|
||||
|
||||
expected_content = {token for token in expected_tokens if token not in _NON_CONTENT}
|
||||
supplied_content = {token for token in supplied_tokens if token not in _NON_CONTENT}
|
||||
if not expected_content <= supplied_content:
|
||||
return False
|
||||
|
||||
expected_components = _components(expected_tokens)
|
||||
supplied_components = _components(supplied_tokens)
|
||||
expected_road_types = set(expected_tokens) & _ROAD_TYPES
|
||||
supplied_road_types = set(supplied_tokens) & _ROAD_TYPES
|
||||
# Тип объекта не является декоративным словом: «улица Ленина» и
|
||||
# «переулок Ленина» — разные адреса, даже при одинаковых остальных токенах.
|
||||
# Если тип явно указан в ответе, он должен совпасть с источником; краткая
|
||||
# форма без типа остаётся допустимой, как и прочие проверенные сокращения.
|
||||
if (
|
||||
expected_road_types
|
||||
and supplied_road_types
|
||||
and supplied_road_types != expected_road_types
|
||||
):
|
||||
return False
|
||||
return all(supplied_components.get(kind) == values
|
||||
for kind, values in expected_components.items())
|
||||
83
backend/app/scoring/ai_coach.py
Normal file
83
backend/app/scoring/ai_coach.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Optional local-model coaching based only on deterministic score findings.
|
||||
|
||||
The model may explain how to improve, but never changes metric values, points,
|
||||
or pass/fail. Only failed criterion keys supplied by the scorer are accepted.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from app.config import get_settings
|
||||
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url
|
||||
from app.domain.events import AICoaching, AIRecommendation, Metric
|
||||
|
||||
|
||||
async def coach(metrics: list[Metric]) -> AICoaching:
|
||||
failed = [item for item in metrics if not item.passed and item.weight > 0]
|
||||
if not failed:
|
||||
return AICoaching(status="not_needed")
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.assessment_feedback_enabled:
|
||||
return AICoaching(status="disabled")
|
||||
if (not settings.llm_model_control or not is_loopback_url(
|
||||
settings.llm_control_base_url,
|
||||
allow_docker_host=settings.allow_docker_host_models,
|
||||
)):
|
||||
return AICoaching(status="unavailable")
|
||||
|
||||
allowed = {item.key for item in failed}
|
||||
schema = {"type": "json_object", "schema": {
|
||||
"type": "object",
|
||||
"properties": {"recommendations": {
|
||||
"type": "array", "maxItems": 3,
|
||||
"items": {"type": "object",
|
||||
"properties": {
|
||||
"metric_key": {"type": "string", "enum": sorted(allowed)},
|
||||
"text": {"type": "string", "minLength": 12, "maxLength": 240},
|
||||
},
|
||||
"required": ["metric_key", "text"], "additionalProperties": False,
|
||||
},
|
||||
}},
|
||||
"required": ["recommendations"], "additionalProperties": False,
|
||||
}}
|
||||
evidence = [{"metric_key": item.key, "criterion": item.title,
|
||||
"observed": item.fact, "expected": item.norm}
|
||||
for item in failed[:12]]
|
||||
request = LlmRequest(
|
||||
model=settings.llm_model_control,
|
||||
messages=[{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Ты методист учебного центра 112. По результатам детерминированной оценки "
|
||||
"сформулируй до трёх коротких, конкретных рекомендаций курсанту: что "
|
||||
"потренировать и как. Не пересчитывай баллы и не оспаривай зачёт. "
|
||||
"Опирайся только на переданные наблюдения и нормативы; не придумывай "
|
||||
"новые факты, требования и числа. Каждая рекомендация должна ссылаться "
|
||||
"на один из переданных metric_key. Верни только JSON. /no_think"
|
||||
),
|
||||
}, {"role": "user", "content": json.dumps(evidence, ensure_ascii=False)}],
|
||||
temperature=0.0, max_tokens=360, response_format=schema, strip_reasoning=True,
|
||||
)
|
||||
client = LlmClient(base_url=settings.llm_control_base_url, timeout=6)
|
||||
try:
|
||||
raw = await client.complete(request, use_cache=True)
|
||||
payload = json.loads(raw)
|
||||
if set(payload) != {"recommendations"} or not isinstance(payload["recommendations"], list):
|
||||
raise ValueError("invalid coaching schema")
|
||||
recommendations: list[AIRecommendation] = []
|
||||
seen: set[str] = set()
|
||||
for item in payload["recommendations"]:
|
||||
if (not isinstance(item, dict) or set(item) != {"metric_key", "text"}
|
||||
or item["metric_key"] not in allowed or item["metric_key"] in seen):
|
||||
raise ValueError("recommendation references an unscored criterion")
|
||||
recommendation = AIRecommendation.model_validate(item)
|
||||
seen.add(recommendation.metric_key)
|
||||
recommendations.append(recommendation)
|
||||
if not recommendations:
|
||||
raise ValueError("model returned no recommendations")
|
||||
return AICoaching(status="ready", model=settings.llm_model_control,
|
||||
recommendations=recommendations)
|
||||
except (LlmUnavailable, ValueError, TypeError, KeyError, json.JSONDecodeError):
|
||||
return AICoaching(status="unavailable")
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
|
@ -9,7 +9,8 @@ from app.domain.events import Metric
|
|||
from app.domain.kio import KIO, missing_fields
|
||||
from app.domain.taxonomy import Finding, FindingSource
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scoring.gost import GostResult, _normalize_address
|
||||
from app.scoring.gost import GostResult
|
||||
from app.scoring.address import address_matches
|
||||
from app.scoring.taxonomy import METRIC_MAP, METRIC_WEIGHTS
|
||||
|
||||
|
||||
|
|
@ -49,8 +50,7 @@ def evaluate_card(scenario: Scenario, kio: KIO) -> GostResult:
|
|||
if truth.address:
|
||||
written = kio.address or " ".join(filter(None, (kio.street, kio.building)))
|
||||
add("address", "Адрес происшествия", written or "не заполнен", truth.address,
|
||||
bool(_normalize_address(truth.address))
|
||||
and _normalize_address(truth.address) <= _normalize_address(written),
|
||||
address_matches(truth.address, written),
|
||||
"эталон сценария")
|
||||
|
||||
if truth.victims is not None:
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ def radar(metrics: list[Metric]) -> list[CompetencyScore]:
|
|||
continue
|
||||
competency = mapping[1]
|
||||
total[competency] = total.get(competency, 0.0) + metric.weight
|
||||
if metric.passed:
|
||||
passed[competency] = passed.get(competency, 0.0) + metric.weight
|
||||
credit = metric.credit if metric.credit is not None else float(metric.passed)
|
||||
passed[competency] = passed.get(competency, 0.0) + metric.weight * credit
|
||||
|
||||
return [
|
||||
CompetencyScore(competency=competency.value, value=round(passed.get(competency, 0.0) / weight, 3))
|
||||
|
|
|
|||
|
|
@ -4,14 +4,17 @@
|
|||
перечислены поимённо и с реальными примерами (docs/spec/DATASET.md). Формулировки
|
||||
не переписаны: преподаватель, который эту памятку читал, должен узнать их в разборе.
|
||||
|
||||
Детерминированно считаются D1, D2, D3, D4 и D6. D5 — полнота комментария — мягкий
|
||||
критерий, его место у судьи (lct-13): «не принята: не обслуживаем» без указания,
|
||||
куда передана информация, формально неотличимо от полного комментария.
|
||||
Детерминированно считаются D1–D6. D5 проверяет структуру отдельного доклада и
|
||||
получателя сведений; пропуск по каждому комментарию виден отдельно. К каждой ручной
|
||||
отметке обязательны непустые поля «Основание» и «Сведения»; их наличие входит в
|
||||
числовую метрику `dds_reply`, но не подтверждает истинность текста. Это не
|
||||
привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу.
|
||||
"""
|
||||
|
||||
from app.domain.statuses import (
|
||||
COMMENT_REQUIRED,
|
||||
PRIMARY,
|
||||
REFUSAL_COMMENT_REQUIRED,
|
||||
SERVICE_STATUS_LABELS,
|
||||
ServiceStatus,
|
||||
StatusEntry,
|
||||
|
|
@ -33,16 +36,61 @@ def _finding(code: ErrorCode, summary: str, fact: str, norm: str | None = None)
|
|||
fact=fact,
|
||||
norm=norm,
|
||||
ref="памятка «Работа на АРМ-112», раздел «Статусы реагирования»",
|
||||
competency=Competency.CARD,
|
||||
competency=(
|
||||
Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_RECIPIENT_TERMS = (
|
||||
"бригад",
|
||||
"старш",
|
||||
"дежурн",
|
||||
"диспетчер",
|
||||
"оператор",
|
||||
"заявител",
|
||||
"пострадавш",
|
||||
"мвд",
|
||||
"полици",
|
||||
"мчс",
|
||||
"скорая",
|
||||
"медицинск",
|
||||
"аварийн",
|
||||
"служба 101",
|
||||
"служба 102",
|
||||
"служба 103",
|
||||
"служба 104",
|
||||
)
|
||||
|
||||
|
||||
def _has_recipient(text: str, service: str, crew: str | None) -> bool:
|
||||
value = text.casefold()
|
||||
if any(term in value for term in _RECIPIENT_TERMS):
|
||||
return True
|
||||
candidates = [service, crew or ""]
|
||||
return any(len(item.strip()) >= 3 and item.strip().casefold() in value for item in candidates)
|
||||
|
||||
|
||||
def _has_basis_and_information(text: str) -> bool:
|
||||
"""Require two explicit fields; this checks structure, not factual truth."""
|
||||
fields: dict[str, str] = {}
|
||||
for line in text.splitlines():
|
||||
label, separator, value = line.partition(":")
|
||||
if separator and label.strip().casefold() in {"основание", "сведения"}:
|
||||
fields[label.strip().casefold()] = value.strip()
|
||||
return bool(fields.get("основание") and fields.get("сведения"))
|
||||
|
||||
|
||||
def evaluate_dispatcher(
|
||||
*,
|
||||
entries: list[StatusEntry],
|
||||
services: list[str],
|
||||
crew_assignments: dict[str, str] | None = None,
|
||||
deadline_ms: int,
|
||||
elapsed_ms: int | None,
|
||||
reply_text: str = "",
|
||||
expected_decision: str = "accept",
|
||||
expected_decision_reason: str | None = None,
|
||||
) -> list[Finding]:
|
||||
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
|
||||
|
||||
|
|
@ -51,6 +99,7 @@ def evaluate_dispatcher(
|
|||
компетенция» неправомерен.
|
||||
"""
|
||||
findings: list[Finding] = []
|
||||
crew_assignments = crew_assignments or {}
|
||||
if not services:
|
||||
return findings
|
||||
|
||||
|
|
@ -58,8 +107,63 @@ def evaluate_dispatcher(
|
|||
marks = [entry for entry in entries if entry.service == service]
|
||||
latest = current(entries, service)
|
||||
|
||||
# D1 — первичного статуса нет вовсе.
|
||||
if not any(mark.status in PRIMARY for mark in marks):
|
||||
# D5 — мягкая проверка памятки: если диспетчер оставил комментарий,
|
||||
# в нём должен быть назван получатель сведений. Статусы и внешние
|
||||
# звонки не используются как выдуманное доказательство.
|
||||
missing_comment_statuses = [
|
||||
SERVICE_STATUS_LABELS[mark.status]
|
||||
for mark in marks
|
||||
if mark.status in COMMENT_REQUIRED and not mark.comment.strip()
|
||||
]
|
||||
if missing_comment_statuses:
|
||||
findings.append(_finding(
|
||||
ErrorCode.D5,
|
||||
f"{service}: к статусу не добавлены основание и сведения",
|
||||
fact="не заполнены комментарии: " + ", ".join(missing_comment_statuses),
|
||||
norm="к каждой ручной отметке добавить основание и содержание полученных сведений",
|
||||
))
|
||||
|
||||
incomplete_comment_statuses = [
|
||||
SERVICE_STATUS_LABELS[mark.status]
|
||||
for mark in marks
|
||||
if mark.status in COMMENT_REQUIRED
|
||||
and mark.comment.strip()
|
||||
and not _has_basis_and_information(mark.comment)
|
||||
]
|
||||
if incomplete_comment_statuses:
|
||||
findings.append(_finding(
|
||||
ErrorCode.D5,
|
||||
f"{service}: комментарии к статусам не разделяют основание и сведения",
|
||||
fact="неполные комментарии: " + ", ".join(incomplete_comment_statuses),
|
||||
norm="в каждом комментарии заполнить отдельные поля «Основание» и «Сведения»",
|
||||
))
|
||||
|
||||
comments_to_check = [
|
||||
(SERVICE_STATUS_LABELS[mark.status], mark.comment.strip())
|
||||
for mark in marks
|
||||
if mark.comment.strip()
|
||||
]
|
||||
if reply_text.strip():
|
||||
comments_to_check.append(("ответ ДДС", reply_text.strip()))
|
||||
missing_recipients = [
|
||||
f"{label}: {comment[:180]}"
|
||||
for label, comment in comments_to_check
|
||||
if not _has_recipient(comment, service, crew_assignments.get(service))
|
||||
]
|
||||
if missing_recipients:
|
||||
findings.append(_finding(
|
||||
ErrorCode.D5,
|
||||
f"{service}: отдельный комментарий не указывает получателя сведений",
|
||||
fact="; ".join(missing_recipients)[:500],
|
||||
norm=(
|
||||
"назвать, кому переданы сведения: бригаде, службе, "
|
||||
"заявителю или иному адресату"
|
||||
),
|
||||
))
|
||||
|
||||
# D1 — первичного статуса нет либо он проставлен позже норматива.
|
||||
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
|
||||
if primary is None:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D1,
|
||||
|
|
@ -74,9 +178,23 @@ def evaluate_dispatcher(
|
|||
)
|
||||
continue
|
||||
|
||||
if elapsed_ms is None or elapsed_ms > deadline_ms:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D1,
|
||||
f"{service}: первичный статус проставлен с нарушением срока",
|
||||
fact=(
|
||||
f"прошло {elapsed_ms // 1000} с"
|
||||
if elapsed_ms is not None
|
||||
else "время первичной отметки не зафиксировано"
|
||||
),
|
||||
norm=f"первичный статус в течение {deadline_ms // 1000} с",
|
||||
)
|
||||
)
|
||||
|
||||
for mark in marks:
|
||||
# D4 — отказ без комментария.
|
||||
if mark.status in COMMENT_REQUIRED and not mark.comment.strip():
|
||||
if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip():
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D4,
|
||||
|
|
@ -86,48 +204,89 @@ def evaluate_dispatcher(
|
|||
)
|
||||
)
|
||||
|
||||
# D3 — отказ от профильного происшествия. Служба в списке оповещения
|
||||
# по классификатору, значит происшествие входит в её компетенцию.
|
||||
if latest is ServiceStatus.DECLINED:
|
||||
# D3 — отказ от профильного происшествия, когда эталон сценария
|
||||
# требует принятия. Дубль/территориальное исключение задаются явно.
|
||||
if latest is ServiceStatus.DECLINED and expected_decision == "accept":
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D3,
|
||||
f"{service}: отказ от происшествия, которое в её компетенции",
|
||||
fact="служба есть в списке оповещения по ЕКП",
|
||||
norm="отказываться от профильного происшествия нельзя",
|
||||
norm="принять согласно эталону сценария",
|
||||
)
|
||||
)
|
||||
|
||||
# D2 — «Принята», но работ не было и отказа тоже: статус не отражает факт.
|
||||
if latest is ServiceStatus.ACCEPTED:
|
||||
elif latest is ServiceStatus.ACCEPTED and expected_decision == "decline":
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
f"{service}: «Принята», но о реагировании ничего не отмечено",
|
||||
fact="после приёма статусов не было",
|
||||
norm="статус должен соответствовать фактическому состоянию заявки",
|
||||
f"{service}: принято вопреки эталону сценария",
|
||||
fact="карточка принята службой",
|
||||
norm=expected_decision_reason or "отказать с указанной причиной",
|
||||
)
|
||||
)
|
||||
|
||||
# D6 — работы завершены, а ход работ не отмечен. В памятке это отдельный
|
||||
# разбор: по такому происшествию идут повторные звонки, и другие службы
|
||||
# не видят, что реагирование вообще началось.
|
||||
if latest is ServiceStatus.COMPLETED and not any(
|
||||
# D2 — «Принята» без назначения бригады/дальнейшего хода или ход без
|
||||
# зафиксированной бригады. Заказчик уточнил: необходимые бригады ДДС
|
||||
# выбирает вручную; канал получения докладов при этом не предписан.
|
||||
if latest is ServiceStatus.ACCEPTED and expected_decision == "accept":
|
||||
if service not in crew_assignments:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
f"{service}: «Принята» без назначения бригады и хода реагирования",
|
||||
fact="бригада не выбрана, после приёма статусов не было",
|
||||
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
||||
)
|
||||
)
|
||||
else:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
f"{service}: «Принята», но о реагировании ничего не отмечено",
|
||||
fact="после приёма статусов не было",
|
||||
norm="статус должен соответствовать фактическому состоянию заявки",
|
||||
)
|
||||
)
|
||||
elif latest is not ServiceStatus.DECLINED and any(
|
||||
mark.status in PROGRESS for mark in marks
|
||||
):
|
||||
) and service not in crew_assignments:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
f"{service}: ход реагирования без назначения бригады",
|
||||
fact="статусы хода работ проставлены, бригада не выбрана",
|
||||
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
||||
)
|
||||
)
|
||||
|
||||
# D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке
|
||||
# это приводит к повторным звонкам и скрывает от других служб факт реакции.
|
||||
# REFUSED — отдельный допустимый терминальный статус с обязательной причиной.
|
||||
missing_progress = [status for status in PROGRESS if status not in {m.status for m in marks}]
|
||||
if (latest not in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
|
||||
and (missing_progress or latest is not ServiceStatus.COMPLETED)):
|
||||
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D6,
|
||||
f"{service}: работы завершены без отметок хода",
|
||||
fact="начало реагирования и прибытие не отмечены",
|
||||
norm="статусы хода работ проставляются по факту",
|
||||
f"{service}: ход реагирования не доведён до конца",
|
||||
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"),
|
||||
norm=(
|
||||
"отметить по факту начало реагирования, прибытие, проведение работ "
|
||||
"и завершение; если работы не проводились — оформить отказ с причиной"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
|
||||
def dispatcher_metrics(
|
||||
state,
|
||||
deadline_ms: int,
|
||||
expected_decision: str = "accept",
|
||||
expected_decision_reason: str | None = None,
|
||||
) -> list[Metric]:
|
||||
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
|
||||
|
||||
Веса предварительные — до утверждения методистом. Телефон — возможный
|
||||
|
|
@ -156,19 +315,40 @@ def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
|
|||
f"≤ {deadline_ms // 1000} с")
|
||||
if primary is None:
|
||||
continue
|
||||
add("dds_decision", "профильное реагирование",
|
||||
primary.status is ServiceStatus.ACCEPTED,
|
||||
primary.status.value, "профильную заявку принять", 2.0)
|
||||
if primary.status is ServiceStatus.DECLINED:
|
||||
expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept"
|
||||
else ServiceStatus.DECLINED)
|
||||
expected_label = "Принята" if expected_decision == "accept" else "Не принята"
|
||||
add("dds_decision", "решение по эталону сценария",
|
||||
primary.status is expected_status,
|
||||
primary.status.value,
|
||||
expected_decision_reason or f"по эталону ожидается «{expected_label}»", 2.0)
|
||||
if primary.status is ServiceStatus.DECLINED or expected_decision == "decline":
|
||||
continue
|
||||
crew = state.crew_assignments.get(service)
|
||||
add("dds_crew", "назначение бригады", bool(crew),
|
||||
crew or "бригада не выбрана",
|
||||
"необходимую бригаду выбирает ДДС вручную", 2.0)
|
||||
expected = {
|
||||
ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING,
|
||||
}
|
||||
add("dds_progress", "ведение хода реагирования", expected <= statuses,
|
||||
", ".join(status.value for status in statuses) or "статусов нет",
|
||||
"начало реагирования, прибытие и работы отмечены по полученной информации", 2.0)
|
||||
refused = ServiceStatus.REFUSED in statuses
|
||||
add("dds_progress", "ведение хода реагирования",
|
||||
expected <= statuses or refused,
|
||||
("отказ от выполнения работ" if refused else
|
||||
", ".join(mark.status.value for mark in marks if mark.status in expected)
|
||||
or "статусов хода нет"),
|
||||
"отметить по факту ход работ либо оформить обоснованный отказ от их выполнения", 2.0)
|
||||
add("dds_completion", "закрытие работ",
|
||||
ServiceStatus.COMPLETED in statuses,
|
||||
"завершено" if ServiceStatus.COMPLETED in statuses else "не завершено",
|
||||
"по факту поставить статус «Работы завершены»")
|
||||
ServiceStatus.COMPLETED in statuses or refused,
|
||||
("завершено" if ServiceStatus.COMPLETED in statuses else
|
||||
"оформлен отказ от выполнения работ" if refused else "не завершено"),
|
||||
"зафиксировать фактическое завершение работ или отказ от их выполнения")
|
||||
notes = [mark.comment.strip() for mark in marks if mark.status in COMMENT_REQUIRED]
|
||||
notes_complete = bool(notes) and all(
|
||||
_has_basis_and_information(note) for note in notes
|
||||
)
|
||||
add("dds_reply", "основание статусов и сведения о ходе работ",
|
||||
notes_complete,
|
||||
"; ".join(notes) if notes else "комментарии к статусам не внесены",
|
||||
"к каждой ручной отметке добавить основание и содержание полученных сведений")
|
||||
return metrics
|
||||
|
|
|
|||
|
|
@ -49,7 +49,10 @@ def to_csv(report: SessionReport) -> bytes:
|
|||
row("Оценка", "", "Причина изменения", report.override_comment)
|
||||
|
||||
for number, item in enumerate(report.metrics, 1):
|
||||
row("Метрики", number, item.title, item.fact, f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; вес: {item.weight:g}; источник: {item.ref or ''}")
|
||||
credit = f"; оценочный вклад: {item.credit:.0%}" if item.credit is not None else ""
|
||||
row("Метрики", number, item.title, item.fact,
|
||||
f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; "
|
||||
f"вес: {item.weight:g}{credit}; источник: {item.ref or ''}")
|
||||
for number, item in enumerate(report.findings, 1):
|
||||
row("Ошибки", number, item.code.value, item.summary, f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}")
|
||||
for number, item in enumerate(report.competencies, 1):
|
||||
|
|
@ -99,7 +102,6 @@ def _font_paths() -> tuple[Path, Path | None]:
|
|||
def to_pdf(report: SessionReport) -> bytes:
|
||||
"""Собрать многостраничный PDF с кириллицей и переносом длинных текстов."""
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_LEFT
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
|
|
@ -167,7 +169,9 @@ def to_pdf(report: SessionReport) -> bytes:
|
|||
if not report.metrics:
|
||||
story.append(p("Нет данных", muted))
|
||||
for item in report.metrics:
|
||||
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'} (вес {item.weight:g})"))
|
||||
credit = f", оценочный вклад {item.credit:.0%}" if item.credit is not None else ""
|
||||
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'} "
|
||||
f"(вес {item.weight:g}{credit})"))
|
||||
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm}. {item.ref or ''}", muted))
|
||||
|
||||
section("Выявленные ошибки")
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
"""Детерминированный слой оценки — 60% веса, считается кодом.
|
||||
"""Метрики опроса и КИО — объяснимые правила, считаются кодом.
|
||||
|
||||
Воспроизводится стопроцентно: один и тот же ход занятия даёт один и тот же
|
||||
результат. Каждая метрика — «факт против норматива со ссылкой», а не балл:
|
||||
«опрос 94 с при нормативе 75 с (ГОСТ Р 22.7.03-2021)» можно предъявить
|
||||
и проверить руками (docs/product/DEBRIEF.md).
|
||||
результат. Каждая метрика — «факт против норматива со ссылкой». Для временных
|
||||
метрик к двоичному признаку нарушения добавлен прозрачный непрерывный вклад,
|
||||
описанный в docs/product/METHODOLOGY.md.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.domain import ekp
|
||||
|
|
@ -16,6 +15,7 @@ from app.domain.kio import KIO, missing_fields
|
|||
from app.domain.taxonomy import ERRORS, Competency, Finding, FindingSource
|
||||
from app.domain.timers import GOST_REF, NORMATIVES, TimerCode
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scoring.address import address_matches
|
||||
from app.scoring.taxonomy import METRIC_MAP, METRIC_WEIGHTS
|
||||
from app.session.timers import SessionTimers
|
||||
|
||||
|
|
@ -42,7 +42,8 @@ class GostResult:
|
|||
total = sum(metric.weight for metric in self.metrics)
|
||||
if not total:
|
||||
return 0.0
|
||||
passed = sum(metric.weight for metric in self.metrics if metric.passed)
|
||||
passed = sum(metric.weight * (metric.credit if metric.credit is not None
|
||||
else float(metric.passed)) for metric in self.metrics)
|
||||
return round(100 * passed / total, 1)
|
||||
|
||||
|
||||
|
|
@ -50,16 +51,6 @@ def _seconds(ms: int) -> str:
|
|||
return f"{round(ms / 1000)} с"
|
||||
|
||||
|
||||
def _normalize_address(text: str | None) -> set[str]:
|
||||
"""Слова адреса без служебных: «ул. Ленина д. 14» и «улица Ленина, 14»
|
||||
должны совпасть, иначе курсанта штрафуют за сокращение."""
|
||||
if not text:
|
||||
return set()
|
||||
noise = {"улица", "ул", "дом", "д", "проспект", "пр", "переулок", "пер", "г", "город", "москва"}
|
||||
words = re.findall(r"[\w-]+", text.lower().replace("ё", "е"))
|
||||
return {word for word in words if word not in noise}
|
||||
|
||||
|
||||
class _Builder:
|
||||
def __init__(self) -> None:
|
||||
self.result = GostResult()
|
||||
|
|
@ -271,11 +262,10 @@ def evaluate(
|
|||
return build.result
|
||||
if truth.address:
|
||||
written = kio.address or " ".join(filter(None, [kio.street, kio.building]))
|
||||
expected = _normalize_address(truth.address)
|
||||
build.add(
|
||||
"address", "Адрес",
|
||||
written or "не заполнен", truth.address,
|
||||
passed=bool(expected) and expected <= _normalize_address(written),
|
||||
passed=address_matches(truth.address, written),
|
||||
ref="ground_truth сценария",
|
||||
finding=f"Адрес в карточке «{written or 'пусто'}», верный — «{truth.address}»",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ def build(scenario: Scenario) -> ReferenceDialog:
|
|||
# звонке: эталон должен звучать так же, а не литературно.
|
||||
answer=REVEAL[mood].format(fact=fact.value) if fact else None,
|
||||
required=bool(fact and fact.id in required),
|
||||
hidden=bool(fact and fact.hidden),
|
||||
)
|
||||
)
|
||||
return ReferenceDialog(scenario_id=scenario.id, first_line=scenario.first_line, steps=steps)
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
|||
if state.exercise is Exercise.DDS and state.dds_scenarios
|
||||
else scenario.id),
|
||||
mode=state.mode,
|
||||
exercise=state.exercise,
|
||||
attempt=state.attempt,
|
||||
criteria=state.criteria,
|
||||
failed_metrics=failed_metrics,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
|
|||
"required_fields": (ErrorCode.E5, Competency.CARD),
|
||||
"dds_primary": (ErrorCode.D1, Competency.CARD),
|
||||
"dds_ack": (ErrorCode.D1, Competency.NORMS),
|
||||
"card_fill_time": (ErrorCode.E3, Competency.NORMS),
|
||||
"dds_work_time": (ErrorCode.E3, Competency.NORMS),
|
||||
"dds_decision": (ErrorCode.D3, Competency.ROUTING),
|
||||
"dds_crew": (ErrorCode.D2, Competency.ROUTING),
|
||||
"dds_contact": (ErrorCode.D6, Competency.COMMUNICATION),
|
||||
|
|
@ -30,6 +32,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
|
|||
"dds_completion": (ErrorCode.D6, Competency.CARD),
|
||||
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
|
||||
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
|
||||
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
|
||||
}
|
||||
|
||||
#: Вес метрики в детерминированной оценке.
|
||||
|
|
@ -53,9 +56,7 @@ METRIC_WEIGHTS: dict[str, float] = {
|
|||
"answer_time": 1.0,
|
||||
"callback": 1.0,
|
||||
"dds_chain": 2.0,
|
||||
"description_grammar": 1.0,
|
||||
"card_fill_time": 1.5,
|
||||
"dds_work_time": 1.5,
|
||||
}
|
||||
|
||||
#: Вес детерминированного слоя в итоговой оценке. Остальное — LLM-судья
|
||||
#: на мягкие критерии (E4), и не больше (docs/arch/BACKEND.md).
|
||||
DETERMINISTIC_WEIGHT = 0.6
|
||||
JUDGE_WEIGHT = 0.4
|
||||
|
|
|
|||
13
backend/app/scoring/timing.py
Normal file
13
backend/app/scoring/timing.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""Временная составляющая оценки по занятой методике.
|
||||
|
||||
Скорость даёт непрерывный вклад, а превышение норматива дополнительно
|
||||
отмечается как E3. Линейный множитель 1 − t/(2T) ограничен диапазоном 0–1:
|
||||
нулевое время даёт полный вклад, два норматива и более — нулевой.
|
||||
"""
|
||||
|
||||
|
||||
def time_credit(elapsed_ms: int | None, limit_ms: int) -> float:
|
||||
"""Доля веса временной метрики, 0–1; отсутствие доказанного времени — 0."""
|
||||
if elapsed_ms is None or elapsed_ms < 0 or limit_ms <= 0:
|
||||
return 0.0
|
||||
return round(max(0.0, min(1.0, 1.0 - elapsed_ms / (2 * limit_ms))), 4)
|
||||
|
|
@ -94,6 +94,7 @@ def dump_state(state: SessionState) -> dict:
|
|||
"level": state.level,
|
||||
"mode": state.mode.value,
|
||||
"owner_login": state.owner_login,
|
||||
"backend_fencing_epoch": state.backend_fencing_epoch,
|
||||
"exercise": state.exercise.value,
|
||||
"handoff_to_dds": state.handoff_to_dds,
|
||||
"required_fields": state.required_fields,
|
||||
|
|
@ -135,6 +136,9 @@ def dump_state(state: SessionState) -> dict:
|
|||
state.phone_pending.model_dump(mode="json") if state.phone_pending else None
|
||||
),
|
||||
"dds_scenarios": [item.model_dump(mode="json") for item in state.dds_scenarios],
|
||||
"pending_dds_scenarios": [item.model_dump(mode="json") for item in state.pending_dds_scenarios],
|
||||
"operator_kio": state.operator_kio.model_dump(mode="json") if state.operator_kio else None,
|
||||
"operator_scenario": state.operator_scenario.model_dump(mode="json") if state.operator_scenario else None,
|
||||
"dds_live_cards": [_dump_live_card(item, now) for item in state.dds_live_cards],
|
||||
"dds_active_card_id": (
|
||||
str(state.dds_active_card_id) if state.dds_active_card_id else None
|
||||
|
|
@ -163,6 +167,7 @@ def dump_state(state: SessionState) -> dict:
|
|||
"reply_log": [[at.isoformat(), text] for at, text in state.reply_log],
|
||||
"resolved_outcome": state.resolved_outcome,
|
||||
"resolve_comment": state.resolve_comment,
|
||||
"processed_station_commands": state.processed_station_commands[-512:],
|
||||
}
|
||||
# В actions/score могут быть datetime/UUID из расчёта; JSONB должен
|
||||
# получать только стандартные JSON-типы.
|
||||
|
|
@ -250,6 +255,7 @@ def load_state(payload: dict, saved_at: datetime) -> SessionState:
|
|||
level=payload["level"],
|
||||
mode=SessionMode(payload["mode"]),
|
||||
owner_login=payload.get("owner_login"),
|
||||
backend_fencing_epoch=int(payload.get("backend_fencing_epoch", 0)),
|
||||
exercise=Exercise(payload["exercise"]),
|
||||
handoff_to_dds=bool(payload.get("handoff_to_dds")),
|
||||
required_fields=list(payload.get("required_fields") or []),
|
||||
|
|
@ -296,6 +302,12 @@ def load_state(payload: dict, saved_at: datetime) -> SessionState:
|
|||
),
|
||||
dds_scenarios=[Scenario.model_validate(item)
|
||||
for item in payload.get("dds_scenarios", [])],
|
||||
pending_dds_scenarios=[Scenario.model_validate(item)
|
||||
for item in payload.get("pending_dds_scenarios", [])],
|
||||
operator_kio=(KIO.model_validate(payload["operator_kio"])
|
||||
if payload.get("operator_kio") else None),
|
||||
operator_scenario=(Scenario.model_validate(payload["operator_scenario"])
|
||||
if payload.get("operator_scenario") else None),
|
||||
dds_live_cards=[_restore_live_card(item, saved_at)
|
||||
for item in payload.get("dds_live_cards", [])],
|
||||
dds_active_card_id=(
|
||||
|
|
@ -329,6 +341,7 @@ def load_state(payload: dict, saved_at: datetime) -> SessionState:
|
|||
for at, text in payload.get("reply_log", [])],
|
||||
resolved_outcome=payload.get("resolved_outcome"),
|
||||
resolve_comment=payload.get("resolve_comment", ""),
|
||||
processed_station_commands=list(payload.get("processed_station_commands") or [])[-512:],
|
||||
)
|
||||
if state.dds_live_cards:
|
||||
# Legacy snapshots had no explicit active ID; newer snapshots may
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from app.domain import ekp
|
||||
from app.domain.kio import KIO, ResponseStatus
|
||||
|
|
@ -18,13 +19,27 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
|
|||
state.level = scenario.level.value
|
||||
state.required_fields = list(scenario.required_fields)
|
||||
truth = scenario.ground_truth
|
||||
address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "")
|
||||
caller_fact = next((fact.value for fact in scenario.facts if fact.id in {"f_caller", "f_applicant"}), "")
|
||||
caller_phone = next((
|
||||
match.group(0).strip()
|
||||
for match in re.finditer(r"(?<!\d)(?:\+?7|8)?(?:[\s().-]*\d){10,11}(?!\d)", caller_fact)
|
||||
if len(re.sub(r"\D", "", match.group(0))) in {10, 11}
|
||||
), None)
|
||||
address_fact = next(
|
||||
(fact.value for fact in scenario.facts if "address" in fact.id), ""
|
||||
)
|
||||
caller_fact = next(
|
||||
(
|
||||
fact.value
|
||||
for fact in scenario.facts
|
||||
if fact.id in {"f_caller", "f_applicant"}
|
||||
),
|
||||
"",
|
||||
)
|
||||
caller_phone = next(
|
||||
(
|
||||
match.group(0).strip()
|
||||
for match in re.finditer(
|
||||
r"(?<!\d)(?:\+?7|8)?(?:[\s().-]*\d){10,11}(?!\d)", caller_fact
|
||||
)
|
||||
if len(re.sub(r"\D", "", match.group(0))) in {10, 11}
|
||||
),
|
||||
None,
|
||||
)
|
||||
caller_names = re.findall(
|
||||
r"(?<![А-ЯЁа-яё])([А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ][а-яё-]+){1,2})(?![А-ЯЁа-яё])",
|
||||
caller_fact,
|
||||
|
|
@ -34,20 +49,48 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
|
|||
caller_name = caller_names[0] if len(caller_names) == 1 else None
|
||||
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
|
||||
service = truth.dds.value if truth.dds else None
|
||||
fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"}
|
||||
state.kio = KIO(
|
||||
registered_at=now_utc(), caller_number=caller_phone, caller_name=caller_name,
|
||||
caller_contact=caller_phone,
|
||||
address=truth.address or address_fact or None,
|
||||
floor=floor.group(1) if floor else None,
|
||||
incident_type=truth.incident_type, incident_code=truth.incident_code,
|
||||
incident_group=(ekp.incident(truth.incident_code).group
|
||||
if truth.incident_code and ekp.incident(truth.incident_code) else None),
|
||||
dds=truth.dds, signs=list(scenario.signs),
|
||||
notify=list(truth.notify) or ([fallback[service]] if service in fallback else []),
|
||||
victims_count=truth.victims,
|
||||
description="; ".join(fact.value for fact in scenario.facts[:3]) or scenario.first_line,
|
||||
)
|
||||
fallback = {
|
||||
"01": "Служба 101",
|
||||
"02": "МВД",
|
||||
"03": "Скорая помощь",
|
||||
"04": "Аварийная служба",
|
||||
}
|
||||
if scenario.student_card is not None:
|
||||
# A moderated learner-authored KIO is the card itself; do not flatten
|
||||
# it to title/description and silently discard its structured fields.
|
||||
state.kio = scenario.student_card.model_copy(
|
||||
deep=True,
|
||||
update={
|
||||
"card_id": uuid4(),
|
||||
"registered_at": now_utc(),
|
||||
"response_status": ResponseStatus.REGISTERED,
|
||||
"dispatch_order_at": None,
|
||||
"arrival_at": None,
|
||||
},
|
||||
)
|
||||
else:
|
||||
state.kio = KIO(
|
||||
registered_at=now_utc(),
|
||||
caller_number=caller_phone,
|
||||
caller_name=caller_name,
|
||||
caller_contact=caller_phone,
|
||||
address=truth.address or address_fact or None,
|
||||
floor=floor.group(1) if floor else None,
|
||||
incident_type=truth.incident_type,
|
||||
incident_code=truth.incident_code,
|
||||
incident_group=(
|
||||
ekp.incident(truth.incident_code).group
|
||||
if truth.incident_code and ekp.incident(truth.incident_code)
|
||||
else None
|
||||
),
|
||||
dds=truth.dds,
|
||||
signs=list(scenario.signs),
|
||||
notify=list(truth.notify)
|
||||
or ([fallback[service]] if service in fallback else []),
|
||||
victims_count=truth.victims,
|
||||
description="; ".join(fact.value for fact in scenario.facts[:3])
|
||||
or scenario.first_line,
|
||||
)
|
||||
state.dispatched_card = None
|
||||
if service:
|
||||
state.dispatch(service)
|
||||
|
|
@ -76,7 +119,9 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
|
|||
state.on_event("dds.dispatch")
|
||||
|
||||
|
||||
def _append_live_card(state: SessionState, scenario: Scenario, index: int) -> DdsLiveCard:
|
||||
def _append_live_card(
|
||||
state: SessionState, scenario: Scenario, index: int
|
||||
) -> DdsLiveCard:
|
||||
state.dds_card_index = index
|
||||
prepare_card(state, scenario)
|
||||
card = DdsLiveCard(
|
||||
|
|
@ -114,9 +159,14 @@ def deliver_due_cards(state: SessionState, now: datetime | None = None) -> int:
|
|||
|
||||
delivered = 0
|
||||
while state.dds_next_scenario_index < len(state.dds_scenarios):
|
||||
active_id = state.dds_active_card_id if any(
|
||||
item.card_id == state.dds_active_card_id for item in state.dds_live_cards
|
||||
) else None
|
||||
active_id = (
|
||||
state.dds_active_card_id
|
||||
if any(
|
||||
item.card_id == state.dds_active_card_id
|
||||
for item in state.dds_live_cards
|
||||
)
|
||||
else None
|
||||
)
|
||||
active_exists = active_id is not None
|
||||
waiting_count = len(state.dds_live_cards) - int(active_exists)
|
||||
if waiting_count >= state.dds_max_waiting:
|
||||
|
|
@ -163,3 +213,45 @@ def prepare_queue(
|
|||
state.dds_next_scenario_index = 0
|
||||
state.dds_next_arrival_at = now_utc()
|
||||
deliver_due_cards(state)
|
||||
|
||||
|
||||
def prepare_handoff_queue(
|
||||
state: SessionState,
|
||||
additional_scenarios: list[Scenario],
|
||||
arrival_interval_seconds: int = 0,
|
||||
max_waiting: int = 3,
|
||||
) -> None:
|
||||
"""Передать созданную курсантом карточку в ДДС перед готовыми карточками."""
|
||||
if state.dispatched_card is None or state.scenario is None:
|
||||
return
|
||||
now = now_utc()
|
||||
state.operator_kio = state.dispatched_card.model_copy(deep=True)
|
||||
state.operator_scenario = state.scenario.model_copy(deep=True)
|
||||
# Карточка курсанта становится первым живым объектом очереди. Её данные
|
||||
# не переписываются из эталона: именно их будет обрабатывать ДДС.
|
||||
dds_timers = SessionTimers(limits=dict(state.timers.limits))
|
||||
dds_timers.on_event("dds.dispatch")
|
||||
first = DdsLiveCard(
|
||||
original_index=0,
|
||||
scenario=state.operator_scenario,
|
||||
kio=state.operator_kio.model_copy(deep=True),
|
||||
dispatched_card=state.operator_kio.model_copy(deep=True),
|
||||
dispatched_at=state.dispatched_at or now,
|
||||
timers=dds_timers,
|
||||
)
|
||||
state.dds_live_cards = [first]
|
||||
state.dds_scenarios = [state.operator_scenario, *additional_scenarios]
|
||||
state.dds_arrival_interval_seconds = arrival_interval_seconds
|
||||
state.dds_max_waiting = max_waiting
|
||||
state.dds_next_scenario_index = 1
|
||||
state.dds_next_arrival_at = (
|
||||
(
|
||||
now + timedelta(seconds=arrival_interval_seconds)
|
||||
if additional_scenarios and arrival_interval_seconds
|
||||
else now
|
||||
)
|
||||
if additional_scenarios
|
||||
else None
|
||||
)
|
||||
state.activate_dds_card(first.card_id, capture=False)
|
||||
deliver_due_cards(state)
|
||||
|
|
|
|||
|
|
@ -9,16 +9,22 @@
|
|||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.events import Exercise, ScoreReady
|
||||
from app.domain.events import ErrorKind, ErrorEvent, Exercise, Metric, ScoreReady
|
||||
from app.domain.statuses import ServiceStatus, current
|
||||
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios import store
|
||||
from app.scoring.card import evaluate_card
|
||||
from app.scoring.competency import radar
|
||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||
from app.scoring.gost import GostResult, evaluate
|
||||
from app.scoring.grammar import assess
|
||||
from app.scoring.report import build as build_report
|
||||
from app.scoring.taxonomy import METRIC_WEIGHTS
|
||||
from app.scoring.timing import time_credit
|
||||
from app.scoring.weights import apply_weights
|
||||
from app.session.hub import hub
|
||||
from app.session.state import DdsCardRecord, now_utc
|
||||
|
|
@ -30,14 +36,67 @@ def score_current_dds(state) -> DdsCardRecord:
|
|||
"""Оценить активную карточку отдельно, до выдачи следующей."""
|
||||
number = state.dds_card_index + 1
|
||||
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
|
||||
dds_decision = state.scenario.dds_decision
|
||||
findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=state.managed_services(),
|
||||
crew_assignments=state.crew_assignments,
|
||||
deadline_ms=decision_limit_ms,
|
||||
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
||||
reply_text=state.reply_text,
|
||||
expected_decision=dds_decision.expected,
|
||||
expected_decision_reason=dds_decision.reason,
|
||||
)
|
||||
metrics = dispatcher_metrics(
|
||||
state, decision_limit_ms, dds_decision.expected, dds_decision.reason
|
||||
)
|
||||
metrics = dispatcher_metrics(state, decision_limit_ms)
|
||||
weighted = GostResult(metrics=metrics, findings=findings)
|
||||
work_limit_ms = state.timers.limits[TimerCode.DDS_WORK]
|
||||
work_timer = state.timers.timers.get(TimerCode.DDS_WORK)
|
||||
work_elapsed_ms = state.timers.measured_ms(TimerCode.DDS_WORK)
|
||||
if work_elapsed_ms is None and work_timer is not None and work_timer.started_at is not None:
|
||||
work_elapsed_ms = work_timer.current_ms(time.monotonic())
|
||||
terminal = bool(state.managed_services()) and all(
|
||||
current(state.status_log, service) in {
|
||||
ServiceStatus.COMPLETED,
|
||||
ServiceStatus.DECLINED,
|
||||
ServiceStatus.REFUSED,
|
||||
}
|
||||
for service in state.managed_services()
|
||||
)
|
||||
work_passed = terminal and work_elapsed_ms is not None and work_elapsed_ms <= work_limit_ms
|
||||
work_delta_ms = (work_elapsed_ms - work_limit_ms) if work_elapsed_ms is not None else None
|
||||
if work_elapsed_ms is None:
|
||||
work_fact = "время обработки не зафиксировано"
|
||||
elif work_delta_ms and work_delta_ms > 0:
|
||||
work_fact = f"{round(work_elapsed_ms / 1000)} с (+{round(work_delta_ms / 1000)} с сверх норматива)"
|
||||
elif work_delta_ms and work_delta_ms < 0:
|
||||
work_fact = (f"{round(work_elapsed_ms / 1000)} с (на "
|
||||
f"{round(abs(work_delta_ms) / 1000)} с быстрее норматива)")
|
||||
else:
|
||||
work_fact = f"{round(work_elapsed_ms / 1000)} с (точно в норматив)"
|
||||
if not terminal:
|
||||
work_fact = f"карточка не завершена; {work_fact}"
|
||||
weighted.metrics.append(Metric(
|
||||
key="dds_work_time",
|
||||
title="Отработка карточки ДДС",
|
||||
fact=work_fact,
|
||||
norm=f"завершить за {round(work_limit_ms / 1000)} с",
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
passed=work_passed,
|
||||
weight=METRIC_WEIGHTS["dds_work_time"],
|
||||
credit=time_credit(work_elapsed_ms, work_limit_ms) if terminal else 0.0,
|
||||
))
|
||||
if not work_passed:
|
||||
weighted.findings.append(Finding(
|
||||
code=ErrorCode.E3,
|
||||
source=FindingSource.TIMERS,
|
||||
summary="ДДС: норматив времени отработки карточки не выполнен",
|
||||
fact=work_fact,
|
||||
norm=f"завершить за {round(work_limit_ms / 1000)} с",
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
competency=Competency.NORMS,
|
||||
))
|
||||
apply_weights(weighted, {**state.scenario.score_weights, **state.criteria.score_weights})
|
||||
actions = [
|
||||
{"type": "card.status", "service": mark.service, "status": mark.status.value,
|
||||
|
|
@ -66,6 +125,15 @@ def score_current_dds(state) -> DdsCardRecord:
|
|||
actions=actions,
|
||||
duration_ms=(max(0, int((now_utc() - state.dispatched_at).total_seconds() * 1000))
|
||||
if state.dispatched_at else 0),
|
||||
title=state.scenario.title,
|
||||
address=state.dispatched_card.address,
|
||||
description=state.dispatched_card.description,
|
||||
incident_type=(state.dispatched_card.incident_type.value
|
||||
if state.dispatched_card.incident_type else None),
|
||||
victims_count=state.dispatched_card.victims_count,
|
||||
received_at=state.dispatched_at,
|
||||
managed_service=(state.managed_services()[0] if state.managed_services() else None),
|
||||
recipient_services=list(state.dispatched_card.notify),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -74,12 +142,52 @@ async def finish(session_id: UUID, state) -> None:
|
|||
path = await asyncio.to_thread(state.recorder.finalize)
|
||||
state.recording_path = str(path) if path else None
|
||||
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
|
||||
scenario = state.scenario or store.get(state.scenario_id)
|
||||
scenario = (state.operator_scenario if state.handoff_to_dds and state.operator_scenario
|
||||
else state.scenario or store.get(state.scenario_id))
|
||||
if scenario is None:
|
||||
return
|
||||
|
||||
cards: list[DdsCardRecord] = []
|
||||
if state.exercise is Exercise.CARD:
|
||||
result = evaluate_card(scenario, state.dispatched_card or state.kio)
|
||||
result = evaluate_card(
|
||||
scenario, state.operator_kio or state.dispatched_card or state.kio
|
||||
)
|
||||
limit_ms = state.timers.limits[TimerCode.CARD_FILL]
|
||||
elapsed_ms = state.timers.measured_ms(TimerCode.CARD_FILL)
|
||||
submitted = state.dispatched_card is not None
|
||||
if elapsed_ms is None:
|
||||
elapsed_fact = "время не зафиксировано"
|
||||
else:
|
||||
delta_ms = elapsed_ms - limit_ms
|
||||
elapsed_seconds = round(elapsed_ms / 1000)
|
||||
if delta_ms > 0:
|
||||
deviation = f"+{round(delta_ms / 1000)} с сверх норматива"
|
||||
elif delta_ms < 0:
|
||||
deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива"
|
||||
else:
|
||||
deviation = "точно в норматив"
|
||||
elapsed_fact = f"{elapsed_seconds} с ({deviation})"
|
||||
passed = submitted and elapsed_ms is not None and elapsed_ms <= limit_ms
|
||||
result.metrics.append(Metric(
|
||||
key="card_fill_time",
|
||||
title="Время заполнения карточки",
|
||||
fact=elapsed_fact if submitted else f"карточка не сдана; {elapsed_fact}",
|
||||
norm=f"сдать карточку за {round(limit_ms / 1000)} с",
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
passed=passed,
|
||||
weight=METRIC_WEIGHTS["card_fill_time"],
|
||||
credit=time_credit(elapsed_ms, limit_ms) if submitted else 0.0,
|
||||
))
|
||||
if not passed:
|
||||
result.findings.append(Finding(
|
||||
code=ErrorCode.E3,
|
||||
source=FindingSource.TIMERS,
|
||||
summary="Время заполнения карточки: норматив не выполнен",
|
||||
fact=elapsed_fact if submitted else f"карточка не сдана; {elapsed_fact}",
|
||||
norm=f"сдать карточку за {round(limit_ms / 1000)} с",
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
competency=Competency.NORMS,
|
||||
))
|
||||
elif state.exercise is Exercise.DDS:
|
||||
# Все карточки выданы одновременно: при досрочном завершении оцениваем
|
||||
# каждую, включая не открытую, потому что её норматив уже шёл.
|
||||
|
|
@ -107,27 +215,74 @@ async def finish(session_id: UUID, state) -> None:
|
|||
resolved_outcome=state.resolved_outcome,
|
||||
dispatched=state.dispatched_card is not None,
|
||||
)
|
||||
# Грамматика относится к свободному описанию оператора 112, а не к
|
||||
# заполнению карточки на стороне ДДС. Учитывается только по явной настройке
|
||||
# преподавателя и только когда есть текст для проверки.
|
||||
operator_kio = state.operator_kio if state.handoff_to_dds else None
|
||||
description = (operator_kio or state.kio).description or ""
|
||||
if (state.exercise is not Exercise.DDS and state.criteria.require_correct_grammar
|
||||
and description.strip()):
|
||||
grammar = await assess(description)
|
||||
result.metrics.append(Metric(
|
||||
key="description_grammar",
|
||||
title="Грамматика описания происшествия",
|
||||
fact="ошибок не обнаружено" if grammar.passed else "; ".join(grammar.errors),
|
||||
norm="грамматически корректное описание",
|
||||
ref="критерий занятия; правила русского языка",
|
||||
passed=grammar.passed,
|
||||
weight=1.0,
|
||||
))
|
||||
if not grammar.passed:
|
||||
result.findings.append(Finding(
|
||||
code=ErrorCode.E4,
|
||||
source=FindingSource.GRAMMAR,
|
||||
summary="Грамматическая ошибка в описании происшествия",
|
||||
fact="; ".join(grammar.errors),
|
||||
norm="грамматически корректное описание",
|
||||
ref="критерий занятия; правила русского языка",
|
||||
competency=Competency.COMMUNICATION,
|
||||
))
|
||||
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
||||
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
||||
# участвуют оба (docs/spec/DATASET.md#статусы-реагирования).
|
||||
if state.dispatched_card is not None and (
|
||||
state.exercise is Exercise.CALL or state.handoff_to_dds
|
||||
):
|
||||
if state.dispatched_card is not None and state.exercise is Exercise.CALL:
|
||||
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
|
||||
dispatcher_findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=state.managed_services(),
|
||||
crew_assignments=state.crew_assignments,
|
||||
deadline_ms=decision_limit_ms,
|
||||
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
||||
reply_text=state.reply_text,
|
||||
expected_decision=scenario.dds_decision.expected,
|
||||
expected_decision_reason=scenario.dds_decision.reason,
|
||||
)
|
||||
result.findings.extend(dispatcher_findings)
|
||||
result.metrics.extend(dispatcher_metrics(state, decision_limit_ms))
|
||||
result.metrics.extend(dispatcher_metrics(
|
||||
state, decision_limit_ms, scenario.dds_decision.expected,
|
||||
scenario.dds_decision.reason,
|
||||
))
|
||||
# DDS cards were weighted individually in score_current_dds using each
|
||||
# card's scenario defaults plus the lesson override. Reapplying the first
|
||||
# scenario's weights here would corrupt the other ticket cards.
|
||||
if state.exercise is not Exercise.DDS:
|
||||
apply_weights(result, {**scenario.score_weights, **state.criteria.score_weights})
|
||||
|
||||
if state.handoff_to_dds and state.dds_scenarios:
|
||||
# В связке КИО оценивается относительно эталона и весов упражнения
|
||||
# 112, а каждая карточка очереди уже взвешена собственным сценарием.
|
||||
cards = list(state.dds_completed)
|
||||
state.capture_active_dds()
|
||||
for live in sorted(state.dds_live_cards, key=lambda item: item.original_index):
|
||||
if any(item.card_id == live.card_id for item in cards):
|
||||
continue
|
||||
state.activate_dds_card(live.card_id, capture=False)
|
||||
cards.append(score_current_dds(state))
|
||||
state.dds_completed = cards
|
||||
for card in cards:
|
||||
result.metrics.extend(card.metrics)
|
||||
result.findings.extend(card.findings)
|
||||
|
||||
# Сводка числами: по ней считается дельта между попытками в профиле.
|
||||
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
|
||||
required = scenario.ground_truth.required_facts
|
||||
|
|
@ -140,6 +295,7 @@ async def finish(session_id: UUID, state) -> None:
|
|||
"score_auto": result.score,
|
||||
"summary": {
|
||||
"interview_ms": state.timers.measured_ms(TimerCode.INTERVIEW),
|
||||
"card_fill_ms": state.timers.measured_ms(TimerCode.CARD_FILL),
|
||||
"facts_got": len([fact for fact in required if fact in revealed]),
|
||||
"facts_required": len(required),
|
||||
"hints": len(state.hints_shown),
|
||||
|
|
@ -155,18 +311,28 @@ async def finish(session_id: UUID, state) -> None:
|
|||
{"card_id": str(card.card_id), "scenario_id": card.scenario_id,
|
||||
"score_auto": card.score_auto, "reply_text": card.reply_text,
|
||||
"actions": card.actions, "duration_ms": card.duration_ms,
|
||||
"title": card.title, "address": card.address,
|
||||
"description": card.description, "incident_type": card.incident_type,
|
||||
"victims_count": card.victims_count,
|
||||
"received_at": card.received_at.isoformat() if card.received_at else None,
|
||||
"managed_service": card.managed_service,
|
||||
"recipient_services": card.recipient_services,
|
||||
"metrics": [metric.model_dump(mode="json") for metric in card.metrics],
|
||||
"findings": [finding.model_dump(mode="json") for finding in card.findings]}
|
||||
for card in cards
|
||||
] if state.exercise is Exercise.DDS else [],
|
||||
] if state.exercise is Exercise.DDS or state.handoff_to_dds else [],
|
||||
}
|
||||
# Полный разбор хранится вместе с оценкой: PDF/CSV и история должны
|
||||
# переживать перезапуск backend, а не зависеть от объекта в hub._sessions.
|
||||
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
||||
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))
|
||||
|
||||
if hub.journal:
|
||||
await hub.journal.score(session_id, result.score, state.score)
|
||||
if hub.journal and not await hub.journal.score(session_id, result.score, state.score):
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL,
|
||||
message="Не удалось сохранить оценку и аудит; итог не выдан. Обратитесь к преподавателю.",
|
||||
))
|
||||
return
|
||||
|
||||
hub.to_observers(session_id, ScoreReady(session_id=session_id))
|
||||
await release_score(session_id, state)
|
||||
|
|
|
|||
|
|
@ -7,13 +7,23 @@
|
|||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from contextvars import ContextVar, Token
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.domain.events import CardReceived, Exercise, StationState, TimerTick
|
||||
from app.domain.events import (
|
||||
CardReceived,
|
||||
ErrorEvent,
|
||||
ErrorKind,
|
||||
Exercise,
|
||||
StationState,
|
||||
TimerTick,
|
||||
)
|
||||
from app.session.state import SessionState
|
||||
|
||||
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
|
||||
|
|
@ -21,6 +31,15 @@ from app.session.state import SessionState
|
|||
QUEUE_SIZE = 256
|
||||
|
||||
TICK_SECONDS = 1.0
|
||||
LEASE_FENCED_MESSAGE = "Занятие передано другому backend-узлу; переподключитесь."
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _current_task():
|
||||
try:
|
||||
return asyncio.current_task()
|
||||
except RuntimeError: # synchronous tests and tooling have no running loop
|
||||
return None
|
||||
|
||||
|
||||
class Journal(Protocol):
|
||||
|
|
@ -29,21 +48,26 @@ class Journal(Protocol):
|
|||
|
||||
async def start_lesson(
|
||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
|
||||
trainee_id: UUID | None = None,
|
||||
) -> tuple[int, UUID | None, str | None]: ...
|
||||
trainee_id: UUID | None = None, owner_login: str | None = None,
|
||||
backend_node_id: str | None = None,
|
||||
) -> tuple[int, UUID | None, str | None, int] | None: ...
|
||||
async def utterance(self, session_id: UUID, entry) -> None: ...
|
||||
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
|
||||
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...
|
||||
async def self_assessment(self, session_id: UUID, missed: list[str], comment: str, at) -> None: ...
|
||||
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None: ...
|
||||
async def self_assessment(
|
||||
self, session_id: UUID, missed: list[str], comment: str, at
|
||||
) -> bool: ...
|
||||
async def score(self, session_id: UUID, score_auto: float, report: dict) -> bool: ...
|
||||
async def score_snapshot(self, session_id: UUID, report: dict) -> None: ...
|
||||
async def score_override(
|
||||
self, session_id: UUID, score_final: float, author: str, comment: str,
|
||||
) -> None: ...
|
||||
) -> bool: ...
|
||||
async def session_started(self, session_id: UUID, at) -> None: ...
|
||||
async def session_ended(self, session_id: UUID, at, reason: str) -> None: ...
|
||||
async def checkpoint(self, state: SessionState) -> None: ...
|
||||
async def restore_active(self) -> list[SessionState]: ...
|
||||
async def renew(self, session_id: UUID) -> None: ...
|
||||
async def claim_expired(self, session_id: UUID | None = None) -> list[SessionState]: ...
|
||||
|
||||
|
||||
class SessionHub:
|
||||
|
|
@ -54,6 +78,9 @@ class SessionHub:
|
|||
self._trainees: dict[UUID, set[asyncio.Queue]] = {}
|
||||
self._stations: dict[UUID, set[asyncio.Queue]] = {}
|
||||
self._tickers: dict[UUID, asyncio.Task] = {}
|
||||
self._event_batch: ContextVar[dict | None] = ContextVar(
|
||||
f"session-event-batch-{id(self)}", default=None
|
||||
)
|
||||
|
||||
# ── реестр ──
|
||||
|
||||
|
|
@ -62,12 +89,47 @@ class SessionHub:
|
|||
return state
|
||||
|
||||
def get(self, session_id: UUID) -> SessionState | None:
|
||||
return self._sessions.get(session_id)
|
||||
state = self._sessions.get(session_id)
|
||||
return None if state is not None and state.lease_fenced else state
|
||||
|
||||
def is_lease_fenced(self, session_id: UUID) -> bool:
|
||||
state = self._sessions.get(session_id)
|
||||
return state is not None and state.lease_fenced
|
||||
|
||||
def active_sessions(self, owner_login: str) -> list[SessionState]:
|
||||
"""Живые занятия только преподавателя-владельца для группового обзора."""
|
||||
return [
|
||||
state for state in self._sessions.values()
|
||||
if not state.ended and not state.lease_fenced and state.owner_login == owner_login
|
||||
]
|
||||
|
||||
def history(
|
||||
self, *, owner_login: str | None = None, trainee_id: UUID | None = None,
|
||||
mode: str | None = None, since: datetime | None = None, limit: int = 100,
|
||||
) -> list[SessionState]:
|
||||
"""Volatile session history for the explicit no-database demo mode."""
|
||||
if since is not None and since.tzinfo is None:
|
||||
since = since.replace(tzinfo=UTC)
|
||||
states = [
|
||||
state for state in self._sessions.values()
|
||||
if not state.lease_fenced
|
||||
and (owner_login is None or state.owner_login == owner_login)
|
||||
and (trainee_id is None or state.trainee_id == trainee_id)
|
||||
and (mode is None or state.mode.value == mode)
|
||||
and (since is None or (state.started_at is not None and state.started_at >= since))
|
||||
]
|
||||
# Hub insertion order is creation order; completed lessons sort by
|
||||
# their finish time, while unanswered calls retain their start time.
|
||||
states.sort(
|
||||
key=lambda state: state.ended_at or state.started_at or datetime.min.replace(tzinfo=UTC),
|
||||
reverse=True,
|
||||
)
|
||||
return states[:max(0, limit)]
|
||||
|
||||
def has_active_scenario(self, scenario_id: str) -> bool:
|
||||
"""Архивирование контента не должно менять уже идущее занятие."""
|
||||
return any(
|
||||
not state.ended and (
|
||||
not state.ended and not state.lease_fenced and (
|
||||
state.scenario_id == scenario_id
|
||||
or any(item.id == scenario_id for item in state.dds_scenarios)
|
||||
)
|
||||
|
|
@ -80,9 +142,100 @@ class SessionHub:
|
|||
|
||||
async def checkpoint(self, session_id: UUID) -> None:
|
||||
"""Зафиксировать подтверждённое состояние, если журнал доступен."""
|
||||
state = self.get(session_id)
|
||||
state = self._sessions.get(session_id)
|
||||
if state is not None and state.lease_fenced:
|
||||
raise RuntimeError(LEASE_FENCED_MESSAGE)
|
||||
if state is not None and self.journal is not None:
|
||||
await self.journal.checkpoint(state)
|
||||
try:
|
||||
await self.journal.checkpoint(state)
|
||||
except Exception:
|
||||
self._discard_event_batch(session_id)
|
||||
await self.fence(state)
|
||||
raise
|
||||
self._flush_event_batch(session_id)
|
||||
|
||||
def begin_event_stream(self, session_id: UUID) -> Token:
|
||||
"""Stage controller output until each explicit checkpoint in its loop."""
|
||||
return self._event_batch.set({
|
||||
"session_id": session_id, "events": [], "committed": False,
|
||||
"persistent": True, "owner_task": _current_task(),
|
||||
})
|
||||
|
||||
async def end_event_stream(self, token: Token) -> None:
|
||||
batch = self._event_batch.get()
|
||||
try:
|
||||
if batch is not None and batch["events"]:
|
||||
session_id = batch["session_id"]
|
||||
batch["events"].clear()
|
||||
state = self._sessions.get(session_id)
|
||||
if self.journal is not None and state is not None and not state.lease_fenced:
|
||||
await self.fence(state)
|
||||
finally:
|
||||
self._event_batch.reset(token)
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def durable_transition(self, session_id: UUID):
|
||||
"""Do not publish state-changing events until its checkpoint commits."""
|
||||
batch = {
|
||||
"session_id": session_id, "events": [], "committed": False,
|
||||
"persistent": False, "owner_task": _current_task(),
|
||||
}
|
||||
token: Token = self._event_batch.set(batch)
|
||||
try:
|
||||
yield
|
||||
if not batch["committed"]:
|
||||
await self.checkpoint(session_id)
|
||||
else:
|
||||
self._flush_event_batch(session_id)
|
||||
except Exception:
|
||||
self._discard_event_batch(session_id)
|
||||
state = self._sessions.get(session_id)
|
||||
if self.journal is not None and state is not None and not state.lease_fenced:
|
||||
await self.fence(state)
|
||||
raise
|
||||
finally:
|
||||
self._event_batch.reset(token)
|
||||
|
||||
def _discard_event_batch(self, session_id: UUID) -> None:
|
||||
batch = self._event_batch.get()
|
||||
if batch is not None and batch["session_id"] == session_id:
|
||||
batch["events"].clear()
|
||||
|
||||
def _flush_event_batch(self, session_id: UUID) -> None:
|
||||
batch = self._event_batch.get()
|
||||
if batch is None or batch["session_id"] != session_id:
|
||||
return
|
||||
pending, batch["events"] = batch["events"], []
|
||||
batch["committed"] = not batch.get("persistent", False)
|
||||
for registry, target_session_id, event in pending:
|
||||
self._put(registry.get(target_session_id, set()), event)
|
||||
|
||||
def _send(self, registry: dict[UUID, set[asyncio.Queue]], session_id: UUID,
|
||||
event: BaseModel) -> None:
|
||||
batch = self._event_batch.get()
|
||||
if isinstance(event, ErrorEvent):
|
||||
self._put(registry.get(session_id, set()), event)
|
||||
elif (batch is not None and batch["session_id"] == session_id
|
||||
and batch["owner_task"] is _current_task()):
|
||||
batch["events"].append((registry, session_id, event))
|
||||
else:
|
||||
self._put(registry.get(session_id, set()), event)
|
||||
|
||||
async def fence(self, state: SessionState) -> None:
|
||||
"""Fail closed when durable ownership is lost or cannot be confirmed."""
|
||||
if state.lease_fenced:
|
||||
return
|
||||
state.lease_fenced = True
|
||||
self.stop_ticker(state.session_id)
|
||||
if state.voice is not None:
|
||||
try:
|
||||
await state.voice.close()
|
||||
except Exception as exc: # noqa: BLE001 — fencing must still close data channels
|
||||
log.error("не удалось закрыть голос при fencing занятия %s (%s)",
|
||||
state.session_id, type(exc).__name__)
|
||||
event = ErrorEvent(code=ErrorKind.INTERNAL, message=LEASE_FENCED_MESSAGE)
|
||||
self.broadcast(state.session_id, event)
|
||||
self.to_station(state.session_id, event)
|
||||
|
||||
# ── подписки ──
|
||||
|
||||
|
|
@ -115,13 +268,16 @@ class SessionHub:
|
|||
queues.discard(queue)
|
||||
|
||||
def to_observers(self, session_id: UUID, event: BaseModel) -> None:
|
||||
self._put(self._observers.get(session_id, set()), event)
|
||||
self._send(self._observers, session_id, event)
|
||||
|
||||
def to_trainee(self, session_id: UUID, event: BaseModel | bytes) -> None:
|
||||
self._put(self._trainees.get(session_id, set()), event)
|
||||
if isinstance(event, BaseModel):
|
||||
self._send(self._trainees, session_id, event)
|
||||
else:
|
||||
self._put(self._trainees.get(session_id, set()), event)
|
||||
|
||||
def to_station(self, session_id: UUID, event: BaseModel) -> None:
|
||||
self._put(self._stations.get(session_id, set()), event)
|
||||
self._send(self._stations, session_id, event)
|
||||
|
||||
def broadcast(self, session_id: UUID, event: BaseModel) -> None:
|
||||
self.to_trainee(session_id, event)
|
||||
|
|
@ -160,7 +316,7 @@ class SessionHub:
|
|||
state = self.get(session_id)
|
||||
if state is None or state.ended:
|
||||
return
|
||||
if state.exercise is Exercise.DDS:
|
||||
if state.exercise is Exercise.DDS or (state.handoff_to_dds and state.dds_scenarios):
|
||||
from app.session.dds import deliver_due_cards
|
||||
|
||||
active_before = state.dds_active_card_id
|
||||
|
|
|
|||
|
|
@ -5,45 +5,99 @@
|
|||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.db import repo
|
||||
from app.db.models import Score, SelfAssessment, Session, User, Utterance
|
||||
from app.db.models import AuditLog, Score, SelfAssessment, Session, User, Utterance
|
||||
from app.domain.events import Mood, Speaker, TranscriptEntry
|
||||
from app.session.checkpoint import dump_state, load_state
|
||||
from app.session.state import SessionState, now_utc
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
LEASE_SECONDS = 15
|
||||
|
||||
|
||||
class SessionLeaseLost(RuntimeError):
|
||||
"""This process no longer owns the durable session generation."""
|
||||
|
||||
|
||||
class DbJournal:
|
||||
def __init__(self, sessionmaker: async_sessionmaker) -> None:
|
||||
def __init__(self, sessionmaker: async_sessionmaker, node_id: str | None = None) -> None:
|
||||
self._sessionmaker = sessionmaker
|
||||
self._node_id = node_id
|
||||
self._epochs: dict[UUID, int] = {}
|
||||
|
||||
async def _write(self, action, *args, **kwargs) -> None:
|
||||
async def _fence(self, db, session_id: UUID, expected_epoch: int | None = None) -> None:
|
||||
"""Renew and fence this write in the same transaction as its mutation."""
|
||||
if self._node_id is None:
|
||||
return
|
||||
epoch = expected_epoch if expected_epoch is not None else self._epochs.get(session_id)
|
||||
if epoch is None:
|
||||
raise SessionLeaseLost(f"session {session_id} has no local fencing epoch")
|
||||
now = now_utc()
|
||||
result = await db.execute(
|
||||
update(Session)
|
||||
.where(
|
||||
Session.id == session_id,
|
||||
Session.backend_node_id == self._node_id,
|
||||
Session.backend_fencing_epoch == epoch,
|
||||
)
|
||||
.values(backend_lease_until=now + timedelta(seconds=LEASE_SECONDS))
|
||||
.returning(Session.id)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise SessionLeaseLost(f"session {session_id} owner epoch {epoch} was fenced")
|
||||
|
||||
async def _write(
|
||||
self, action, *args, _fence_session_id: UUID | None = None,
|
||||
_fence_epoch: int | None = None, _raise_errors: bool = False, **kwargs
|
||||
) -> None:
|
||||
"""Ошибка записи не роняет занятие, но и не проглатывается молча:
|
||||
занятие идёт дальше, в логе остаётся след."""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
if _fence_session_id is not None:
|
||||
await self._fence(db, _fence_session_id, _fence_epoch)
|
||||
await action(db, *args, **kwargs)
|
||||
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||
log.exception("журнал: запись не удалась")
|
||||
except SessionLeaseLost:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||
session_id = _fence_session_id or kwargs.get("session_id")
|
||||
log.error("журнал: запись не удалась для сессии %s (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
if _raise_errors:
|
||||
raise
|
||||
|
||||
async def start_lesson(
|
||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
|
||||
trainee_id: UUID | None = None, owner_login: str | None = None,
|
||||
) -> tuple[int, UUID | None, str | None]:
|
||||
backend_node_id: str | None = None,
|
||||
) -> tuple[int, UUID | None, str | None, int] | None:
|
||||
"""Завести сессию в журнале и вернуть номер попытки и ID курсанта.
|
||||
|
||||
Если база недоступна, занятие всё равно идёт: номер попытки
|
||||
деградирует до первого, и это видно в логе.
|
||||
Строка сессии и событие аудита фиксируются вместе. При сбое транзакции
|
||||
занятие не запускается без долговечной истории.
|
||||
"""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
node_id = backend_node_id or self._node_id
|
||||
|
||||
def audit_start(transaction, row):
|
||||
if row.backend_fencing_epoch <= 0:
|
||||
row.backend_fencing_epoch = 1
|
||||
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
|
||||
transaction.add(AuditLog(
|
||||
actor=owner_login or "system",
|
||||
role="instructor" if owner_login else "system",
|
||||
action="lesson.start",
|
||||
object_id=str(row.id),
|
||||
detail=f"{scenario_id}, mode {mode}",
|
||||
))
|
||||
|
||||
row = await repo.ensure_session(
|
||||
db,
|
||||
session_id=session_id,
|
||||
|
|
@ -52,7 +106,11 @@ class DbJournal:
|
|||
trainee_name=trainee_name,
|
||||
trainee_id=trainee_id,
|
||||
owner_login=owner_login,
|
||||
backend_node_id=node_id,
|
||||
before_commit=audit_start,
|
||||
)
|
||||
epoch = getattr(row, "backend_fencing_epoch", 0) or 1
|
||||
self._epochs[session_id] = epoch
|
||||
service = None
|
||||
if row.trainee_id is not None:
|
||||
service = await db.scalar(
|
||||
|
|
@ -60,12 +118,13 @@ class DbJournal:
|
|||
.where(User.trainee_id == row.trainee_id, User.blocked.is_(False))
|
||||
.limit(1)
|
||||
)
|
||||
return row.attempt, row.trainee_id, service
|
||||
return row.attempt, row.trainee_id, service, epoch
|
||||
except PermissionError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||
log.exception("журнал: сессию завести не удалось")
|
||||
return 1, trainee_id, None
|
||||
except Exception as exc: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||
log.error("журнал: не удалось завести сессию %s (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
return None
|
||||
|
||||
async def checkpoint(self, state: SessionState) -> None:
|
||||
"""Сохранить снимок после подтверждённого действия пользователя."""
|
||||
|
|
@ -78,25 +137,102 @@ class DbJournal:
|
|||
await db.execute(update(Session).where(Session.id == state.session_id).values(**values))
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
if state.backend_fencing_epoch > 0:
|
||||
self._epochs.setdefault(state.session_id, state.backend_fencing_epoch)
|
||||
await self._write(
|
||||
lambda db: action(db), _fence_session_id=state.session_id,
|
||||
_fence_epoch=state.backend_fencing_epoch or None,
|
||||
_raise_errors=True,
|
||||
)
|
||||
|
||||
async def restore_active(self) -> list[SessionState]:
|
||||
async def renew(self, session_id: UUID) -> None:
|
||||
"""Refresh an owned session lease; concurrent takeover is row-serialized."""
|
||||
async with self._sessionmaker() as db:
|
||||
await self._fence(db, session_id)
|
||||
await db.commit()
|
||||
|
||||
async def claim_expired(self, session_id: UUID | None = None) -> list[SessionState]:
|
||||
"""Atomically fence and restore expired owners on this backend node."""
|
||||
if self._node_id is None:
|
||||
return []
|
||||
now = now_utc()
|
||||
conditions = [
|
||||
Session.ended_at.is_(None),
|
||||
Session.live_state.is_not(None),
|
||||
Session.checkpoint_at.is_not(None),
|
||||
(Session.backend_node_id.is_(None) | (Session.backend_node_id != self._node_id)),
|
||||
(Session.backend_lease_until.is_(None) | (Session.backend_lease_until <= now)),
|
||||
]
|
||||
if session_id is not None:
|
||||
conditions.append(Session.id == session_id)
|
||||
async with self._sessionmaker() as db:
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(*conditions).with_for_update(skip_locked=True).limit(100)
|
||||
)).all()
|
||||
for row in rows:
|
||||
row.backend_node_id = self._node_id
|
||||
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
|
||||
row.backend_lease_until = now + timedelta(seconds=LEASE_SECONDS)
|
||||
if rows:
|
||||
await db.commit()
|
||||
if not rows:
|
||||
return []
|
||||
return await self.restore_active(bump_owned_epoch=False)
|
||||
|
||||
async def restore_active(self, *, bump_owned_epoch: bool = True) -> list[SessionState]:
|
||||
"""Восстановить только незавершённые сессии с валидным снимком."""
|
||||
restored: list[SessionState] = []
|
||||
async with self._sessionmaker() as db:
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(
|
||||
Session.ended_at.is_(None),
|
||||
Session.live_state.is_not(None),
|
||||
Session.checkpoint_at.is_not(None),
|
||||
)
|
||||
)).all()
|
||||
active_with_snapshot = (
|
||||
Session.ended_at.is_(None),
|
||||
Session.live_state.is_not(None),
|
||||
Session.checkpoint_at.is_not(None),
|
||||
)
|
||||
if self._node_id is not None:
|
||||
# Adopt legacy unassigned snapshots exactly once. Concurrent
|
||||
# nodes lock disjoint rows; subsequent restores are owner-only.
|
||||
unassigned = (await db.scalars(
|
||||
select(Session)
|
||||
.where(*active_with_snapshot, Session.backend_node_id.is_(None))
|
||||
.with_for_update(skip_locked=True)
|
||||
)).all()
|
||||
for row in unassigned:
|
||||
row.backend_node_id = self._node_id
|
||||
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
|
||||
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
|
||||
if unassigned:
|
||||
await db.commit()
|
||||
# A restarted process with the same stable node ID is a new
|
||||
# owner generation. Bump before exposing any restored state.
|
||||
owned = (await db.scalars(
|
||||
select(Session)
|
||||
.where(*active_with_snapshot, Session.backend_node_id == self._node_id)
|
||||
.with_for_update(skip_locked=True)
|
||||
)).all()
|
||||
if bump_owned_epoch:
|
||||
for row in owned:
|
||||
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
|
||||
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
|
||||
if owned:
|
||||
await db.commit()
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(
|
||||
*active_with_snapshot,
|
||||
Session.backend_node_id == self._node_id,
|
||||
)
|
||||
)).all()
|
||||
else:
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(*active_with_snapshot)
|
||||
)).all()
|
||||
for row in rows:
|
||||
try:
|
||||
state = load_state(row.live_state, row.checkpoint_at)
|
||||
if state.session_id != row.id:
|
||||
raise ValueError("ID снимка не совпадает с записью занятия")
|
||||
state.owner_login = row.owner_login
|
||||
state.backend_fencing_epoch = row.backend_fencing_epoch
|
||||
self._epochs[row.id] = row.backend_fencing_epoch
|
||||
# Реплики пишутся отдельно сразу после появления. Если
|
||||
# процесс умер между репликой и общим снимком, отдельный
|
||||
# журнал не даёт потерять последний фрагмент диалога.
|
||||
|
|
@ -117,8 +253,9 @@ class DbJournal:
|
|||
for item in utterances
|
||||
]
|
||||
restored.append(state)
|
||||
except Exception: # noqa: BLE001 — один снимок не блокирует весь стенд
|
||||
log.exception("журнал: снимок занятия %s повреждён", row.id)
|
||||
except Exception as exc: # noqa: BLE001 — один снимок не блокирует весь стенд
|
||||
log.error("журнал: снимок занятия %s повреждён (%s)",
|
||||
row.id, type(exc).__name__)
|
||||
return restored
|
||||
|
||||
async def utterance(self, session_id: UUID, entry) -> None:
|
||||
|
|
@ -130,53 +267,127 @@ class DbJournal:
|
|||
text=entry.text,
|
||||
at=entry.at,
|
||||
mood=entry.mood.value if entry.mood else None,
|
||||
_fence_session_id=session_id,
|
||||
)
|
||||
|
||||
async def hint(self, session_id: UUID, checklist_id: str, question: str, at: datetime) -> None:
|
||||
await self._write(
|
||||
repo.record_hint, session_id=session_id, checklist_id=checklist_id, question=question, at=at
|
||||
repo.record_hint, _fence_session_id=session_id, session_id=session_id,
|
||||
checklist_id=checklist_id, question=question, at=at
|
||||
)
|
||||
|
||||
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None:
|
||||
await self._write(repo.add_note, session_id=session_id, transcript_ref=ref, text=text, author=author)
|
||||
await self._write(
|
||||
repo.add_note, _fence_session_id=session_id, session_id=session_id,
|
||||
transcript_ref=ref, text=text, author=author
|
||||
)
|
||||
|
||||
async def self_assessment(
|
||||
self, session_id: UUID, missed: list[str], comment: str, at: datetime
|
||||
) -> None:
|
||||
async def action(db):
|
||||
db.add(
|
||||
SelfAssessment(
|
||||
) -> bool:
|
||||
"""Persist trainee reflection and its security audit together."""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
session = await db.get(Session, session_id)
|
||||
if session is None:
|
||||
return False
|
||||
actor = "system"
|
||||
role = "system"
|
||||
if session.trainee_id is not None:
|
||||
login = await db.scalar(
|
||||
select(User.login).where(User.trainee_id == session.trainee_id)
|
||||
)
|
||||
if login:
|
||||
actor, role = login, "trainee"
|
||||
else:
|
||||
actor, role = f"trainee:{session.trainee_id}", "trainee"
|
||||
db.add(SelfAssessment(
|
||||
session_id=session_id, missed=missed, comment=comment, submitted_at=at
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
))
|
||||
db.add(AuditLog(
|
||||
actor=actor,
|
||||
role=role,
|
||||
action="self_assessment.submit",
|
||||
object_id=str(session_id),
|
||||
detail=f"missed_count={len(missed)}; comment_chars={len(comment)}",
|
||||
))
|
||||
await self._fence(db, session_id)
|
||||
await db.commit()
|
||||
return True
|
||||
except SessionLeaseLost:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — do not accept an unaudited reflection
|
||||
log.error("самооценка и аудит сессии %s не сохранены (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
return False
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
|
||||
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None:
|
||||
async def action(db):
|
||||
db.add(Score(session_id=session_id, score_auto=score_auto, score_final=score_auto, report=report))
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
async def score(self, session_id: UUID, score_auto: float, report: dict) -> bool:
|
||||
"""Persist the initial result and its audit event atomically."""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
await self._fence(db, session_id)
|
||||
db.add(Score(
|
||||
session_id=session_id, score_auto=score_auto,
|
||||
score_final=score_auto, report=report,
|
||||
))
|
||||
db.add(AuditLog(
|
||||
actor="system", role="system", action="score.calculate",
|
||||
object_id=str(session_id), detail=f"score_auto={score_auto}",
|
||||
))
|
||||
await db.commit()
|
||||
return True
|
||||
except SessionLeaseLost:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — result is not complete until durable
|
||||
log.error("итоговая оценка и аудит сессии %s не сохранены (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
return False
|
||||
|
||||
async def score_override(
|
||||
self, session_id: UUID, score_final: float, author: str, comment: str
|
||||
) -> None:
|
||||
"""Сохранить решение преподавателя рядом с неизменной автооценкой."""
|
||||
async def action(db):
|
||||
await db.execute(
|
||||
update(Score)
|
||||
.where(Score.session_id == session_id)
|
||||
.values(
|
||||
score_final=score_final,
|
||||
overridden_by=author,
|
||||
override_comment=comment,
|
||||
) -> bool:
|
||||
"""Persist a live correction and its security audit as one transaction."""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
await self._fence(db, session_id)
|
||||
score = await db.scalar(
|
||||
select(Score)
|
||||
.where(Score.session_id == session_id)
|
||||
.with_for_update()
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
if score is None:
|
||||
return False
|
||||
score.score_final = score_final
|
||||
score.overridden_by = author
|
||||
score.override_comment = comment
|
||||
report = dict(score.report or {})
|
||||
archived = report.get("full_report")
|
||||
if isinstance(archived, dict):
|
||||
archived = dict(archived)
|
||||
archived.update({
|
||||
"score_auto": score.score_auto,
|
||||
"score_final": score_final,
|
||||
"overridden_by": author,
|
||||
"override_comment": comment,
|
||||
})
|
||||
report["full_report"] = archived
|
||||
score.report = report
|
||||
db.add(AuditLog(
|
||||
actor=author,
|
||||
role="instructor",
|
||||
action="score.override",
|
||||
object_id=str(session_id),
|
||||
detail=(f"{score.score_auto} → {score_final}; "
|
||||
f"comment_chars={len(comment)}"),
|
||||
))
|
||||
await db.commit()
|
||||
return True
|
||||
except SessionLeaseLost:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — do not confirm a correction without its audit
|
||||
log.error("корректировка оценки и аудит сессии %s не сохранены (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
return False
|
||||
|
||||
async def score_snapshot(self, session_id: UUID, report: dict) -> None:
|
||||
"""Обновить полный архивный разбор после самооценки курсанта."""
|
||||
|
|
@ -186,14 +397,14 @@ class DbJournal:
|
|||
)
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
await self._write(lambda db: action(db), _fence_session_id=session_id)
|
||||
|
||||
async def session_started(self, session_id: UUID, at: datetime) -> None:
|
||||
async def action(db):
|
||||
await db.execute(update(Session).where(Session.id == session_id).values(started_at=at))
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
await self._write(lambda db: action(db), _fence_session_id=session_id)
|
||||
|
||||
async def session_ended(self, session_id: UUID, at: datetime, reason: str) -> None:
|
||||
async def action(db):
|
||||
|
|
@ -209,4 +420,4 @@ class DbJournal:
|
|||
)
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
await self._write(lambda db: action(db), _fence_session_id=session_id)
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ from app.domain.events import (
|
|||
)
|
||||
from app.domain.kio import KIO, ResponseStatus, apply_patch
|
||||
from app.domain.statuses import (
|
||||
CardStatus,
|
||||
NEXT,
|
||||
CardStatus,
|
||||
DdsCardSummary,
|
||||
DdsQueueCard,
|
||||
PhoneCallPending,
|
||||
|
|
@ -62,11 +62,20 @@ class DdsCardRecord:
|
|||
findings: list[Finding]
|
||||
actions: list[dict[str, Any]]
|
||||
duration_ms: int
|
||||
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] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def score_auto(self) -> float:
|
||||
total = sum(metric.weight for metric in self.metrics)
|
||||
passed = sum(metric.weight for metric in self.metrics if metric.passed)
|
||||
passed = sum(metric.weight * (metric.credit if metric.credit is not None
|
||||
else float(metric.passed)) for metric in self.metrics)
|
||||
return round(100 * passed / total, 1) if total else 0.0
|
||||
|
||||
|
||||
|
|
@ -106,6 +115,10 @@ class SessionState:
|
|||
mode: SessionMode
|
||||
#: Преподаватель, создавший занятие; чужой пульт не может им управлять.
|
||||
owner_login: str | None = None
|
||||
#: Monotonic DB ownership generation; stale processes may not persist writes.
|
||||
backend_fencing_epoch: int = 0
|
||||
#: Runtime-only: set when this process loses or cannot confirm DB ownership.
|
||||
lease_fenced: bool = False
|
||||
exercise: Exercise = Exercise.CALL
|
||||
#: После заполнения КИО занятие продолжится на АРМ ДДС, а не завершится.
|
||||
handoff_to_dds: bool = False
|
||||
|
|
@ -169,6 +182,11 @@ class SessionState:
|
|||
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
|
||||
phone_pending: PhoneCallPending | None = None
|
||||
dds_scenarios: list[Scenario] = field(default_factory=list)
|
||||
pending_dds_scenarios: list[Scenario] = field(default_factory=list)
|
||||
#: Исходная часть упражнения 112→ДДС сохраняется отдельно от активной
|
||||
#: карточки ДДС, которая может переключаться по очереди.
|
||||
operator_kio: KIO | None = None
|
||||
operator_scenario: Scenario | None = None
|
||||
dds_live_cards: list[DdsLiveCard] = field(default_factory=list)
|
||||
dds_active_card_id: UUID | None = None
|
||||
dds_card_index: int = 0
|
||||
|
|
@ -183,6 +201,10 @@ class SessionState:
|
|||
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
|
||||
resolved_outcome: str | None = None
|
||||
resolve_comment: str = ""
|
||||
#: Recently committed DDS command IDs; included in the durable checkpoint so
|
||||
#: a lost WebSocket acknowledgement cannot apply an operation twice.
|
||||
processed_station_commands: list[str] = field(default_factory=list)
|
||||
text_revealed_facts: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def on_event(self, event_type: str) -> None:
|
||||
"""Единственная точка, где событие двигает таймеры."""
|
||||
|
|
@ -391,6 +413,11 @@ class SessionState:
|
|||
log=list(self.status_log),
|
||||
crew_options=(self.crew_options() if has_active_dds_card or not self.dds_scenarios else []),
|
||||
crew_selected=self.crew_selected if has_active_dds_card else None,
|
||||
zone_decision=(
|
||||
next((detail == "в зоне" for action, _at, detail in reversed(self.dds_log)
|
||||
if action == "zone.decision"), None)
|
||||
if has_active_dds_card else None
|
||||
),
|
||||
phone_reports=list(self.phone_reports) if has_active_dds_card else [],
|
||||
phone_lines=list(self.phone_lines) if has_active_dds_card else [],
|
||||
phone_pending=self.phone_pending if has_active_dds_card else None,
|
||||
|
|
@ -422,6 +449,7 @@ class SessionState:
|
|||
return CardReceived(
|
||||
card=self.dispatched_card,
|
||||
from_operator=("учебный сценарий" if self.exercise is Exercise.DDS
|
||||
or (self.handoff_to_dds and self.dds_card_index > 0)
|
||||
else self.trainee_name or "оператор 112"),
|
||||
at=self.dispatched_at or now_utc(),
|
||||
card_index=self.dds_card_index + 1,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ STARTS: dict[str, tuple[TimerCode, ...]] = {
|
|||
"call.incoming": (TimerCode.ANSWER,),
|
||||
"call.answer": (TimerCode.INTERVIEW,),
|
||||
"dds.dispatch": (TimerCode.DDS_ACK, TimerCode.CLOSE),
|
||||
"dds.open": (TimerCode.DDS_WORK,),
|
||||
"card.start": (TimerCode.CARD_FILL,),
|
||||
"card.received": (TimerCode.ZONE_CHECK,),
|
||||
"call.dropped": (TimerCode.CALLBACK,),
|
||||
"callback.dial": (TimerCode.CALLBACK,),
|
||||
|
|
@ -38,6 +40,10 @@ STOPS: dict[str, tuple[TimerCode, ...]] = {
|
|||
# карточки: норматив опроса не должен тикать после решения (lct-36).
|
||||
"call.resolve": (TimerCode.INTERVIEW,),
|
||||
"card.ack": (TimerCode.DDS_ACK,),
|
||||
"card.submit": (TimerCode.CARD_FILL,),
|
||||
"card.end": (TimerCode.CARD_FILL,),
|
||||
"dds.complete": (TimerCode.DDS_WORK,),
|
||||
"dds.finish": (TimerCode.DDS_WORK,),
|
||||
"zone.decision": (TimerCode.ZONE_CHECK,),
|
||||
"crew.arrived": (TimerCode.CLOSE,),
|
||||
"call.started": (TimerCode.CALLBACK,),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""Локальная WAV-запись обеих сторон учебного голосового вызова."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import wave
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -12,6 +14,10 @@ import numpy as np
|
|||
from app.config import get_settings
|
||||
|
||||
TARGET_RATE = 16_000
|
||||
JOURNAL_MAGIC = b"LCTREC01"
|
||||
JOURNAL_RECORD = struct.Struct("<QI") # sample offset, mono sample count
|
||||
MAX_JOURNAL_RECORD_SAMPLES = TARGET_RATE * 60
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def recording_path(session_id: UUID) -> Path:
|
||||
|
|
@ -35,9 +41,78 @@ class CallRecorder:
|
|||
def __init__(self, path: Path, *, clock=time.monotonic) -> None:
|
||||
self.path = path
|
||||
self._clock = clock
|
||||
self._started = clock()
|
||||
self._segments: list[_Segment] = []
|
||||
self._finalized = False
|
||||
self._journal_path = path.with_suffix(path.suffix + ".journal")
|
||||
self._journal = None
|
||||
self._journal_failed = False
|
||||
self._started = clock()
|
||||
self._last_sync = self._started
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._open_journal()
|
||||
|
||||
def _open_journal(self) -> None:
|
||||
"""Open or recover the append-only audio journal after process restart."""
|
||||
if self._journal_path.exists():
|
||||
valid_end = len(JOURNAL_MAGIC)
|
||||
with self._journal_path.open("r+b") as source:
|
||||
if source.read(len(JOURNAL_MAGIC)) != JOURNAL_MAGIC:
|
||||
raise ValueError("invalid call recording journal")
|
||||
while True:
|
||||
record = source.read(JOURNAL_RECORD.size)
|
||||
if not record:
|
||||
break
|
||||
if len(record) != JOURNAL_RECORD.size:
|
||||
break
|
||||
offset, count = JOURNAL_RECORD.unpack(record)
|
||||
if not count or count > MAX_JOURNAL_RECORD_SAMPLES:
|
||||
raise ValueError("invalid call recording journal record")
|
||||
payload = source.read(count * 2)
|
||||
if len(payload) != count * 2:
|
||||
break
|
||||
samples = np.frombuffer(payload, dtype="<i2").astype(np.int32)
|
||||
self._segments.append(_Segment(int(offset), samples))
|
||||
valid_end = source.tell()
|
||||
source.truncate(valid_end)
|
||||
os.fsync(source.fileno())
|
||||
else:
|
||||
self._journal_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(self._journal_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
try:
|
||||
os.write(fd, JOURNAL_MAGIC)
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
end = max((item.offset + item.samples.size for item in self._segments), default=0)
|
||||
if end:
|
||||
# Monotonic clocks do not survive a host reboot. Continue directly
|
||||
# after the last committed sample rather than using stale timestamps.
|
||||
self._started -= end / TARGET_RATE
|
||||
self._journal = self._journal_path.open("ab", buffering=0)
|
||||
os.chmod(self._journal_path, 0o600)
|
||||
|
||||
def _write_journal_record(self, offset: int, samples: np.ndarray, now: float) -> None:
|
||||
if self._journal is None or self._journal_failed:
|
||||
return
|
||||
payload = JOURNAL_RECORD.pack(offset, int(samples.size)) + samples.astype("<i2").tobytes()
|
||||
try:
|
||||
view = memoryview(payload)
|
||||
while view:
|
||||
written = self._journal.write(view)
|
||||
if not written:
|
||||
raise OSError("short audio journal write")
|
||||
view = view[written:]
|
||||
if now - self._last_sync >= 1.0:
|
||||
os.fsync(self._journal.fileno())
|
||||
self._last_sync = now
|
||||
except OSError:
|
||||
# Keep the live call working; finalize can still save the in-memory
|
||||
# audio. The warning is explicit because crash recovery is degraded.
|
||||
self._journal_failed = True
|
||||
log.error("журнал WAV недоступен (%s)", self._journal_path.name)
|
||||
self._journal.close()
|
||||
self._journal = None
|
||||
|
||||
def add_pcm(self, pcm: bytes, *, sample_rate: int) -> None:
|
||||
if self._finalized or not pcm or sample_rate <= 0 or len(pcm) % 2:
|
||||
|
|
@ -49,7 +124,9 @@ class CallRecorder:
|
|||
length = max(1, round(source.size * TARGET_RATE / sample_rate))
|
||||
points = np.linspace(0, source.size - 1, length)
|
||||
source = np.rint(np.interp(points, np.arange(source.size), source)).astype(np.int32)
|
||||
offset = max(0, round((self._clock() - self._started) * TARGET_RATE))
|
||||
now = self._clock()
|
||||
offset = max(0, round((now - self._started) * TARGET_RATE))
|
||||
self._write_journal_record(offset, source, now)
|
||||
self._segments.append(_Segment(offset=offset, samples=source))
|
||||
|
||||
def finalize(self) -> Path | None:
|
||||
|
|
@ -57,6 +134,7 @@ class CallRecorder:
|
|||
return self.path if self.path.is_file() else None
|
||||
self._finalized = True
|
||||
if not self._segments:
|
||||
self._close_journal(remove=True)
|
||||
return None
|
||||
total = max(item.offset + item.samples.size for item in self._segments)
|
||||
mixed = np.zeros(total, dtype=np.int32)
|
||||
|
|
@ -71,11 +149,33 @@ class CallRecorder:
|
|||
target.setsampwidth(2)
|
||||
target.setframerate(TARGET_RATE)
|
||||
target.writeframes(pcm)
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, self.path)
|
||||
self._close_journal(remove=True)
|
||||
return self.path
|
||||
|
||||
def _close_journal(self, *, remove: bool) -> None:
|
||||
if self._journal is not None:
|
||||
try:
|
||||
os.fsync(self._journal.fileno())
|
||||
except OSError as exc:
|
||||
log.warning("не удалось синхронизировать журнал WAV (%s)", type(exc).__name__)
|
||||
finally:
|
||||
self._journal.close()
|
||||
self._journal = None
|
||||
if remove:
|
||||
try:
|
||||
self._journal_path.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
log.warning("не удалось удалить журнал WAV (%s)", type(exc).__name__)
|
||||
|
||||
|
||||
def start_recording(session_id: UUID) -> CallRecorder | None:
|
||||
if not get_settings().record_calls:
|
||||
return None
|
||||
return CallRecorder(recording_path(session_id))
|
||||
try:
|
||||
return CallRecorder(recording_path(session_id))
|
||||
except (OSError, ValueError) as exc:
|
||||
# Recording failure must not drop an otherwise recoverable call.
|
||||
log.error("сессия %s: запись звонка недоступна (%s)", session_id, type(exc).__name__)
|
||||
return None
|
||||
|
|
|
|||
Loading…
Reference in a new issue