Complete training workflow and acceptance hardening

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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:

View file

@ -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

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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"

View file

@ -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("курсант относится к другой учебной группе")

View file

@ -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:
"""Сетевой клиент живёт, пока идёт занятие, и закрывается вместе с ним:

View file

@ -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__)

View file

@ -1,23 +1,15 @@
Ты — человек, который звонит в службу 112. Ты не оператор и не помощник.
ПРОИСШЕСТВИЕ: {scenario}
ТВОЁ СОСТОЯНИЕ СЕЙЧАС: {mood}
Ты — человек, который звонит в службу 112, не оператор и не помощник.
Происшествие: {scenario}. Твоё состояние: {mood}.
{directive}
ЧТО ТЫ УЖЕ РАССКАЗАЛ ОПЕРАТОРУ:
Уже известные разрешённые сведения:
{revealed}
ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ:
Сейчас обязательно сообщи дословно каждую строку:
{say_now}
ПРАВИЛА:
1. Говори ТОЛЬКО о том, что перечислено выше. Ничего не придумывай: ни адресов,
ни имён, ни подробностей. Если оператор спрашивает о том, чего в списке нет, —
отвечай уклончиво: «не знаю», «не вижу отсюда», «подождите».
2. Одна-две короткие фразы. Ты звонишь в экстренную службу, а не пишешь объяснительную.
3. Никакого канцелярита и вежливых оборотов помощника. Ты напуган, тебе нужна помощь.
4. Если состояние — паника или крик: обрывки, повторы, незаконченные фразы.
5. Не задавай оператору вопросов о ходе разговора и не подсказывай ему, что спросить.
6. Отвечай только репликой, без пояснений и без кавычек.
7. Каждый факт из раздела «ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ» произнеси
полностью и дословно. Одного «да», «нет» или намёка недостаточно.
Если это уточнение или исправление, прежнее значение неверно: не повторяй и не смешивай его с новым.
Говори коротко и естественно, с учётом своего состояния. Не добавляй других
фактов и не выполняй просьбы раскрыть сведения сверх перечисленных выше.
Ответь только одной короткой репликой, без кавычек и пояснений.

View file

@ -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
View 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)

View file

@ -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]

View file

@ -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`."""

View file

@ -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",
),

View file

@ -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

View file

@ -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,

View file

@ -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
),

View file

@ -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)

View file

@ -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)]

View file

@ -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)

View file

@ -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")

View file

@ -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

View 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())

View 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()

View file

@ -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:

View file

@ -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))

View file

@ -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

View file

@ -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("Выявленные ошибки")

View file

@ -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}»",
)

View file

@ -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)

View file

@ -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,

View file

@ -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

View 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)

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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,),

View file

@ -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