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

@ -1,7 +1,15 @@
# Скопировать в .env. Файл в .gitignore: ключи в репозиторий не едут.
BIND_HOST=127.0.0.1
POSTGRES_PORT=5432
# Development profile defaults to lct. Before production/offline deployment,
# generate a unique URL-safe value: python -c "import secrets; print(secrets.token_urlsafe(32))"
POSTGRES_PASSWORD=
BACKEND_PORT=8000
# Per-backend PostgreSQL connection pool (cluster default budget: 60 total).
DB_POOL_SIZE=20
DB_POOL_MAX_OVERFLOW=10
# Stable node identity; set a distinct value for each explicit backend node.
BACKEND_NODE_ID=backend-01
FRONTEND_PORT=5173
TLS_PORT=5443
TLS_CERT_DIR=./.local/tls
@ -53,4 +61,23 @@ OFFLINE=true
SECURE_COOKIES=false
# Необязательно для Compose: если пусто, стойкий случайный ключ создаётся в
# volume securitydata. На управляемом стенде можно задать свой 48+ байтный ключ.
# Compose generates this in its persistent securitydata volume if left blank.
# If managed explicitly, use at least 32 random characters.
SESSION_SECRET=
# Необязательный AD/LDAP: требует LDAPS или StartTLS и проверку сертификата.
# Для включения задайте ldap(s)://url, base DN, bind-учётку и явные группы.
# Ключи LDAP_ROLE_GROUPS — DN групп, значения: admin/instructor/trainee.
# LDAP_SERVICE_GROUPS связывает DN групп курсантов с названием службы ДДС.
LDAP_ENABLED=false
LDAP_URL=
LDAP_BASE_DN=
LDAP_BIND_DN=
LDAP_BIND_PASSWORD=
LDAP_USER_FILTER=(objectClass=person)
LDAP_LOGIN_ATTRIBUTE=sAMAccountName
LDAP_ROLE_GROUPS={}
LDAP_SERVICE_GROUPS={}
# Путь к доверенному внутреннему CA внутри контейнера или локального backend.
LDAP_CA_CERTS_FILE=
LDAP_CONNECT_TIMEOUT_SECONDS=5

149
.github/workflows/windows-backend.yml vendored Normal file
View file

@ -0,0 +1,149 @@
name: Windows backend
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
test-windows:
name: Backend and frontend checks (Windows x64)
runs-on: windows-2025
timeout-minutes: 30
env:
DATABASE_URL: postgresql+asyncpg://postgres:root@127.0.0.1:5432/lct_test
DEV_AUTH_BYPASS: "true"
steps:
- name: Check out source
uses: actions/checkout@v7
- name: Start the runner's PostgreSQL service
shell: pwsh
run: |
$service = Get-Service -Name 'postgresql-x64-17' -ErrorAction Stop
Set-Service -Name $service.Name -StartupType Manual
Start-Service -Name $service.Name
$pgIsReady = Join-Path $env:PGBIN 'pg_isready.exe'
$env:PGPASSWORD = 'root'
$ready = $false
for ($attempt = 0; $attempt -lt 30; $attempt++) {
& $pgIsReady -h 127.0.0.1 -p 5432 -U postgres
if ($LASTEXITCODE -eq 0) { $ready = $true; break }
Start-Sleep -Seconds 2
}
if (-not $ready) { throw 'PostgreSQL did not become ready on 127.0.0.1:5432' }
& (Join-Path $env:PGBIN 'createdb.exe') -h 127.0.0.1 -p 5432 -U postgres lct_test
if ($LASTEXITCODE -ne 0) { throw 'Could not create clean lct_test database' }
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.11"
- name: Set up uv
uses: astral-sh/setup-uv@v10
with:
enable-cache: true
cache-dependency-glob: backend/uv.lock
- name: Install locked backend dependencies
working-directory: backend
run: uv sync --locked --extra dev
- name: Apply migrations and seed the clean PostgreSQL database
working-directory: backend
run: |
uv run --locked --extra dev alembic upgrade head
if ($LASTEXITCODE -ne 0) { throw 'Alembic migrations failed' }
uv run --locked --extra dev python scripts/seed.py
if ($LASTEXITCODE -ne 0) { throw 'Scenario seed failed' }
- name: Prepare a least-privilege production startup probe account
shell: pwsh
run: |
$env:PGPASSWORD = 'root'
$psql = Join-Path $env:PGBIN 'psql.exe'
$password = 'Lct-Windows-CI-only-0123456789abcdef'
& $psql -h 127.0.0.1 -p 5432 -U postgres -d lct_test -v ON_ERROR_STOP=1 `
-c "CREATE ROLE lct_ci LOGIN PASSWORD '$password'"
if ($LASTEXITCODE -ne 0) { throw 'Could not create disposable startup-probe role' }
& $psql -h 127.0.0.1 -p 5432 -U postgres -d lct_test -v ON_ERROR_STOP=1 `
-c 'GRANT CONNECT ON DATABASE lct_test TO lct_ci; GRANT USAGE ON SCHEMA public TO lct_ci; GRANT SELECT ON TABLE users, scenarios, sessions, utterances TO lct_ci; GRANT UPDATE ON TABLE sessions TO lct_ci; GRANT INSERT ON TABLE audit_log TO lct_ci'
if ($LASTEXITCODE -ne 0) { throw 'Could not grant startup-probe database permissions' }
- name: Run backend tests with PostgreSQL integration enabled
working-directory: backend
run: uv run --locked --extra dev pytest -q
- name: Start production-configured app on Windows and probe health plus audited login
shell: pwsh
working-directory: backend
env:
APP_ENV: production
DATABASE_URL: postgresql+asyncpg://lct_ci:Lct-Windows-CI-only-0123456789abcdef@127.0.0.1:5432/lct_test
DEV_AUTH_BYPASS: "false"
DEMO_NO_DB: "false"
OFFLINE: "true"
LLM_PROVIDER: local
SECURE_COOKIES: "true"
SESSION_SECRET: windows-ci-smoke-only-session-secret-0123456789abcdef
PORT: "18088"
run: |
$server = Start-Process -FilePath 'uv' `
-ArgumentList @('run', '--locked', '--extra', 'dev', 'uvicorn', 'app.main:app', '--host', '127.0.0.1', '--port', $env:PORT, '--workers', '1') `
-WorkingDirectory (Get-Location).Path -PassThru
try {
$health = $null
for ($attempt = 0; $attempt -lt 60; $attempt++) {
if ($server.HasExited) { throw "Windows uvicorn exited with code $($server.ExitCode)" }
try {
$health = Invoke-RestMethod -Uri "http://127.0.0.1:$($env:PORT)/api/health" -TimeoutSec 2
break
} catch { Start-Sleep -Seconds 1 }
}
if ($null -eq $health -or $health.status -ne 'ok' -or $health.demo_no_db -ne $false -or $health.scenarios_loaded -lt 90) {
throw "Windows production startup health check failed: $($health | ConvertTo-Json -Compress)"
}
$responseFile = Join-Path $env:RUNNER_TEMP 'windows-login-smoke.json'
$httpCode = & curl.exe --silent --show-error --output $responseFile --write-out '%{http_code}' `
--header 'Content-Type: application/json' --data-raw '{"login":"windows-ci-unknown","password":"not-a-real-account"}' `
"http://127.0.0.1:$($env:PORT)/api/auth/login"
if ($LASTEXITCODE -ne 0 -or $httpCode -ne '401') { throw "Windows audited login probe returned HTTP $httpCode" }
$loginError = Get-Content -Raw $responseFile | ConvertFrom-Json
if ($loginError.detail -ne 'bad_credentials') { throw 'Windows login probe returned an unexpected public error' }
Write-Host "Windows production startup OK; scenarios=$($health.scenarios_loaded); unauthenticated login was safely rejected."
} finally {
if (-not $server.HasExited) {
& taskkill.exe /PID $server.Id /T /F | Out-Null
}
}
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install locked frontend dependencies
working-directory: frontend
run: npm ci
- name: Check frontend types and KIO fields
working-directory: frontend
run: npm run typecheck
- name: Test reliable WebSocket outbox
working-directory: frontend
run: npm run test:ws-outbox
- name: Test DDS archive recipient filters and date sorting
working-directory: frontend
run: npm run test:dds-history
- name: Build frontend
working-directory: frontend
run: npm run build

1
.gitignore vendored
View file

@ -23,6 +23,7 @@ frontend/dist/
.env
.env.*
!.env.example
!.env.test.example
*.local
.local/

View file

@ -27,6 +27,20 @@ tls: ## Поднять полный стенд по HTTPS/WSS с локальн
offline-tls: ## Поднять HTTPS/WSS из уже собранных образов без сети
$(COMPOSE) -f docker-compose.yml -f docker-compose.tls.yml up --pull never --no-build
production: ## Защищённый запуск из исходников: уникальный DB-пароль обязателен
bash scripts/validate_production_env.sh
$(COMPOSE) -f docker-compose.yml -f docker-compose.production.yml -f docker-compose.tls.yml up --build
offline-production: ## Защищённый запуск готовых образов без pull/build; требуется уникальный DB-пароль
bash scripts/validate_production_env.sh
$(COMPOSE) -f docker-compose.yml -f docker-compose.production.yml -f docker-compose.tls.yml up --pull never --no-build
production-config-check: ## Проверить, что production Compose требует и передаёт заданный DB-пароль
bash scripts/check_production_compose.sh
production-postgres-check: ## Проверить межконтейнерную PostgreSQL-аутентификацию на временной БД
bash scripts/test_production_postgres.sh
sip: ## Собрать и поднять локальный Asterisk SIP/VoIP
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml up --build -d sip
@ -45,6 +59,9 @@ webrtc-test: ## Проверить два браузерных SIP-клиент
--backend-url http://127.0.0.1:$(or $(BACKEND_PORT),8000) \
--database-url postgresql+asyncpg://lct:lct@127.0.0.1:$(or $(POSTGRES_PORT),5432)/lct $(args)
webrtc-test-isolated: ## Полный двухбраузерный WebRTC smoke на отдельной БД, портах и volume
bash scripts/test_webrtc_isolated.sh
down: ## Погасить стенд
$(COMPOSE) down
@ -60,6 +77,9 @@ types: ## domain/events.py → frontend/src/shared/types/generated.ts
test: ## Тесты бэкенда (голосовой контур пропускается)
cd backend && $(UV) run --extra dev pytest -q
test-db: ## Полный backend-прогон на новой временной PostgreSQL, без касания dev-базы
bash scripts/test_isolated_db.sh $(args)
test-voice: ## Тест голосового контура на настоящих моделях: задержка и перебивание
cd backend && $(UV) run --extra dev --extra voice pytest tests/test_voice_pipeline.py -q -s
@ -93,8 +113,11 @@ seed: ## Залить сценарии из /scenarios в БД
lesson: ## Запустить занятие и напечатать ссылки: make lesson s=<сценарий> m=<режим>
cd backend && $(UV) run --no-project --with websockets python scripts/start_lesson.py "$(s)" "$(m)"
test-llm: ## Живые проверки LLM по backend/.env.test (медленно: рассуждающая модель)
cd backend && $(UV) run --extra dev pytest tests/test_llm.py -m llm -q -s
test-llm-local: ## Живые проверки локального Qwen по backend/.env.test (без сети)
test -f backend/.env.test || (echo "Создайте backend/.env.test из backend/.env.test.example"; exit 2)
cd backend && $(UV) run --offline --extra dev pytest tests/test_llm.py -m llm -q -s
test-llm: test-llm-local ## Совместимое имя цели локальной проверки LLM
llm-check: ## Один запрос к активной локальной модели из backend/.env
cd backend && $(UV) run python scripts/llm_check.py
@ -120,4 +143,4 @@ demo: ## Поднять основной стенд ДДС: база, готов
demo-lite: ## Локальная демонстрация карточки/ДДС без Docker, БД, голоса и внешней сети
cd backend && UV_CACHE_DIR=/tmp/lct-uv-demo-cache OFFLINE=true VOICE_ENABLED=false DEV_AUTH_BYPASS=true DEMO_NO_DB=true $(UV) run --offline --no-sync uvicorn app.main:app --host 127.0.0.1 --port $(DEMO_PORT) --workers 1
.PHONY: help dev offline tls offline-tls sip offline-sip sip-credentials sip-test webrtc-test down back front types users users-docker backup test test-voice typecheck test-llm lesson llm-check latency migrate revision models local-models local-llm local-stt seed repl pregen demo demo-lite
.PHONY: help dev offline tls offline-tls production offline-production production-config-check production-postgres-check sip offline-sip sip-credentials sip-test webrtc-test webrtc-test-isolated down back front types users users-docker backup test test-db test-voice typecheck test-llm test-llm-local lesson llm-check latency migrate revision models local-models local-llm local-stt seed repl pregen demo demo-lite

View file

@ -0,0 +1,7 @@
# Copy to backend/.env.test for optional live inference tests.
# Start `make local-llm` first. This target refuses remote URLs and needs no API key.
LLM_PROVIDER=local
LLM_BASE_URL=http://127.0.0.1:18080/v1
LLM_API_KEY=
LLM_MODEL_CALLER=Qwen3-1.7B
OFFLINE=true

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

View file

@ -28,6 +28,7 @@ dependencies = [
# паролей, itsdangerous нужен SessionMiddleware из starlette.
"argon2-cffi>=23.1",
"itsdangerous>=2.1",
"ldap3>=2.9.1,<3",
]
[project.optional-dependencies]
@ -62,7 +63,7 @@ packages = ["app"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
# Живые запросы к LLM идут отдельно (`make test-llm`): они требуют сети,
# а рассуждающая модель отвечает десятками секунд.
markers = ["llm: живой запрос к провайдеру LLM"]
# Живые запросы идут отдельно (`make test-llm-local`) к loopback-модели;
# локальный инференс медленный и не должен запускаться в каждом unit-прогоне.
markers = ["llm: live запрос к локальной LLM"]
addopts = "-m 'not llm'"

View file

@ -73,7 +73,9 @@ def run_forever() -> None:
log.error("цикл резервного копирования не завершён: %s; повтор через %s с", exc, retry)
time.sleep(retry)
continue
time.sleep(interval)
# Re-evaluate the age of the completed copy at the top of the loop.
# Sleeping a full interval here would make the real gap
# (dump duration + interval) and could exceed the 24-hour requirement.
if __name__ == "__main__":

View file

@ -6,6 +6,8 @@
"""
import os
import socket
from urllib.parse import urlparse
os.environ.setdefault("DEV_AUTH_BYPASS", "true")
@ -14,6 +16,24 @@ import pytest # noqa: E402
from app.config import get_settings # noqa: E402
@pytest.fixture
def postgres_access():
"""Skip DB integration cases when the configured PostgreSQL is unreachable.
`/api/health` is a liveness endpoint and deliberately does not probe the
database. Use a short TCP check so sandbox/network-denied runs are reported
as unverified integration tests instead of misleading application failures.
"""
url = urlparse(get_settings().database_url)
if url.scheme not in {"postgres", "postgresql", "postgresql+asyncpg"}:
pytest.skip("PostgreSQL integration test requires a PostgreSQL DATABASE_URL")
try:
with socket.create_connection((url.hostname or "localhost", url.port or 5432), timeout=2):
pass
except OSError as exc:
pytest.skip(f"PostgreSQL unavailable ({exc})")
@pytest.fixture(autouse=True, scope="session")
def _dev_auth():
"""Флаг обхода читается один раз при создании настроек."""

View file

@ -0,0 +1,34 @@
from app.api.ws.station import _address_matches
from app.scoring.address import address_matches
def test_street_abbreviation_matches_but_similarly_named_street_does_not():
expected = "Дубнинская улица, дом 10"
assert address_matches(expected, "ул. Дубнинская, д. 10")
assert not _address_matches(expected, "Дубининская улица, дом 10")
def test_street_type_is_part_of_the_operational_address():
expected = "Москва, улица Ленина, дом 10"
assert address_matches(expected, "г. Москва, ул. Ленина, д. 10")
assert not address_matches(expected, "Москва, переулок Ленина, дом 10")
assert not address_matches(
expected, "Москва, улица Ленина и переулок Ленина, дом 10"
)
def test_house_and_apartment_numbers_cannot_be_swapped():
expected = "дом 10, квартира 20"
assert address_matches(expected, "д. 10, кв. 20, подъезд 3")
assert not address_matches(expected, "дом 20, квартира 10")
def test_corpus_and_building_numbers_keep_their_roles():
expected = "Москва, улица Мира, дом 5, корпус 1, квартира 20"
assert address_matches(expected, "г. Москва, ул. Мира, д. 5, корп. 1, кв. 20")
assert not address_matches(expected, "Москва, ул. Мира, дом 1, корп. 5, кв. 20")
def test_empty_or_partial_operational_address_is_not_a_match():
assert not address_matches("улица Мира, дом 5", "")
assert not address_matches("улица Мира, дом 5", "улица Мира")

View file

@ -5,6 +5,7 @@
"""
import uuid
from types import SimpleNamespace
from xml.etree import ElementTree as ET
import pytest
@ -47,12 +48,6 @@ def as_instructor(client):
def db_alive(client) -> bool:
"""Часть точек без Postgres работать не может, и это не повод падать:
на машине разработчика база может быть не поднята."""
return client.get("/api/health").status_code == 200
# ── границы роли ──
@ -61,6 +56,7 @@ def test_instructor_cannot_open_admin(as_instructor):
в административных функциях прямо."""
assert as_instructor.get("/api/admin/users").status_code == 403
assert as_instructor.get("/api/admin/audit").status_code == 403
assert as_instructor.get("/api/admin/audit.csv").status_code == 403
assert as_instructor.get("/api/admin/status").status_code == 403
assert as_instructor.get("/api/admin/diagnostics").status_code == 403
assert as_instructor.get("/api/admin/config.xml").status_code == 403
@ -68,6 +64,52 @@ def test_instructor_cannot_open_admin(as_instructor):
def test_anonymous_cannot_open_admin(client):
assert client.get("/api/admin/users").status_code == 401
assert client.get("/api/admin/audit.csv").status_code == 401
@pytest.mark.parametrize("role", [Role.INSTRUCTOR, Role.TRAINEE])
def test_non_admin_roles_cannot_reach_any_admin_endpoint(client, monkeypatch, role):
"""Exercise the complete current admin route surface with valid requests.
Stub only the DB dependency: every handler must reject the principal before
reading or mutating any admin data. Keep this endpoint inventory explicit
so a new admin route is added to the negative-role gate.
"""
import app.api.auth as auth_module
from app.api.http import admin as admin_api
monkeypatch.setattr(
auth_module,
"current",
lambda _request: Principal(login="not-admin", full_name="Пользователь", role=role),
)
async def empty_session():
yield object()
app.dependency_overrides[admin_api.get_session] = empty_session
calls = [
("GET", "/api/admin/config.xml", None),
("GET", "/api/admin/users", None),
("POST", "/api/admin/users", {
"login": "new.user", "full_name": "Новый пользователь",
"password": "long-enough-password", "role": "instructor",
}),
("PATCH", f"/api/admin/users/{uuid.uuid4()}", {"blocked": True}),
("GET", "/api/admin/audit", None),
("GET", "/api/admin/audit.csv", None),
("GET", "/api/admin/diagnostics", None),
("GET", "/api/admin/diagnostics.json", None),
("GET", "/api/admin/status", None),
("GET", "/api/admin/backups", None),
("POST", "/api/admin/backups", None),
]
try:
for method, path, payload in calls:
response = client.request(method, path, json=payload)
assert response.status_code == 403, (role, method, path, response.text)
finally:
app.dependency_overrides.pop(admin_api.get_session, None)
def test_admin_downloads_safe_xml_configuration(as_admin):
@ -78,6 +120,7 @@ def test_admin_downloads_safe_xml_configuration(as_admin):
root = ET.fromstring(response.content)
assert root.tag == "lctConfiguration"
assert root.find("./workstations/workstation[@role='admin']") is not None
assert root.find("./workstations/workstation[@role='admin']/screen[@path='/wall']") is not None
assert root.find("./timerLimits/timer[@code='dds_ack']") is not None
lowered = response.content.lower()
assert b"session_secret" not in lowered
@ -141,15 +184,56 @@ def test_audit_api_applies_actor_action_and_offset_filters(as_admin):
assert "audit_log.actor" in str(statement.whereclause)
def test_audit_csv_streams_full_filtered_log_and_neutralizes_formulas(as_admin):
from datetime import datetime, timezone
from app.main import app
from app.api.http import admin as admin_module
row = SimpleNamespace(
at=datetime(2026, 1, 2, tzinfo=timezone.utc), actor="=1+1", role="admin",
action="login.failed", object_id=None, detail='строка; "подробности"',
)
captured = {}
class FakeDb:
async def stream_scalars(self, statement):
captured["statement"] = statement
async def values():
yield row
return values()
async def fake_session():
yield FakeDb()
app.dependency_overrides[admin_module.get_session] = fake_session
try:
response = as_admin.get(
"/api/admin/audit.csv", params={"action": "login.failed", "actor": "=1+1"}
)
finally:
app.dependency_overrides.pop(admin_module.get_session, None)
assert response.status_code == 200, response.text
assert response.headers["content-disposition"].endswith('filename="lct-audit.csv"')
assert response.content.startswith(b"\xef\xbb\xbf")
text = response.content.decode("utf-8-sig")
assert ",\'=1+1," in text
assert '"строка; ""подробности"""' in text
statement = captured["statement"]
assert statement._limit_clause is None, "CSV must not truncate older audit rows"
assert "audit_log.action" in str(statement.whereclause)
assert "audit_log.actor" in str(statement.whereclause)
# ── учётные записи ──
def test_admin_creates_a_trainee_with_a_trainee_card(as_admin):
def test_admin_creates_a_trainee_with_a_trainee_card(as_admin, postgres_access):
"""У обучающегося должна появиться карточка курсанта: на ней висят
профиль, история и проверка «это твой разбор» (lct-23)."""
if not db_alive(as_admin):
pytest.skip("нет базы")
login = f"курсант-{uuid.uuid4().hex[:8]}"
response = as_admin.post(
"/api/admin/users",
@ -166,14 +250,15 @@ def test_admin_creates_a_trainee_with_a_trainee_card(as_admin):
assert body["role"] == "trainee"
assert body["service"] == "ДДС района"
audit_rows = as_admin.get("/api/admin/audit", params={"action": "user.create"}).json()
assert any(row["object_id"] == login and row["detail"] == "Обучающийся"
for row in audit_rows), "создание пользователя и audit row должны фиксироваться вместе"
listing = as_admin.get("/api/admin/users").json()
assert any(user["login"] == login for user in listing)
def test_duplicate_login_is_refused(as_admin):
if not db_alive(as_admin):
pytest.skip("нет базы")
def test_duplicate_login_is_refused(as_admin, postgres_access):
login = f"двойник-{uuid.uuid4().hex[:8]}"
payload = {
"login": login, "full_name": "Первый", "password": "длинный-пароль", "role": "instructor",
@ -182,6 +267,8 @@ def test_duplicate_login_is_refused(as_admin):
second = as_admin.post("/api/admin/users", json=payload)
assert second.status_code == 409
assert second.json()["detail"] == "login_taken"
audit_rows = as_admin.get("/api/admin/audit", params={"action": "user.create"}).json()
assert sum(row["object_id"] == login for row in audit_rows) == 1
def test_short_password_is_refused(as_admin):
@ -192,11 +279,8 @@ def test_short_password_is_refused(as_admin):
assert response.status_code == 422
def test_admin_cannot_block_himself(as_admin):
def test_admin_cannot_block_himself(as_admin, postgres_access):
"""Иначе стенд остаётся без администратора до похода в базу руками."""
if not db_alive(as_admin):
pytest.skip("нет базы")
created = as_admin.post(
"/api/admin/users",
json={
@ -215,20 +299,14 @@ def test_admin_cannot_block_himself(as_admin):
# ── состояние стенда ──
def test_status_names_every_component(as_admin):
if not db_alive(as_admin):
pytest.skip("нет базы")
def test_status_names_every_component(as_admin, postgres_access):
names = {item["name"] for item in as_admin.get("/api/admin/status").json()}
assert {"База данных", "Модели речи", "Эмбеддинги", "Провайдер LLM",
"Классификатор ЕКП", "Резервное копирование", "Секрет сессии",
"Нагрузка backend"} <= names
def test_admin_gets_live_metrics_and_downloadable_failure_report(as_admin):
if not db_alive(as_admin):
pytest.skip("нет базы")
def test_admin_gets_live_metrics_and_downloadable_failure_report(as_admin, postgres_access):
response = as_admin.get("/api/admin/diagnostics")
assert response.status_code == 200, response.text
body = response.json()
@ -256,11 +334,8 @@ def test_diagnostic_journal_redacts_credentials():
assert "never-show" not in event["message"]
def test_default_session_secret_is_reported_as_a_problem(as_admin):
def test_default_session_secret_is_reported_as_a_problem(as_admin, postgres_access):
"""На стенде это дыра, и увидеть её должен администратор, а не проверяющий."""
if not db_alive(as_admin):
pytest.skip("нет базы")
secret = next(
item for item in as_admin.get("/api/admin/status").json() if item["name"] == "Секрет сессии"
)
@ -304,6 +379,12 @@ def test_backup_failure_explains_what_is_missing(as_admin, monkeypatch):
"""Кнопка не должна молча ничего не делать: если снять копию нечем,
администратор видит, чего именно не хватает."""
from app.admin import backup as backup_service
from app.api.http import admin as admin_api
async def audit_is_available(*_args, **_kwargs):
return None
monkeypatch.setattr(admin_api, "audit_required", audit_is_available)
def broken():
raise backup_service.BackupError("нет ни pg_dump, ни docker")
@ -387,6 +468,34 @@ def test_backup_dsn_decodes_escaped_credentials_without_exposing_them(monkeypatc
raise AssertionError("invalid DATABASE_URL must be rejected")
def test_backup_endpoint_redacts_url_encoded_and_decoded_database_password(
as_admin, monkeypatch,
):
from app.api.http import admin as admin_api
from app.admin import backup as backup_service
from app.admin.backup import BackupError
dsn = "postgresql://backup:p%40ss%3Aword@db.example:5433/lct"
monkeypatch.setattr(
admin_api, "get_settings", lambda: SimpleNamespace(database_url=dsn)
)
async def audit_is_available(*_args, **_kwargs):
return None
def fail_with_decoded_password():
raise BackupError("connection failed for postgresql://backup:p@ss:word@db.example/lct")
monkeypatch.setattr(admin_api, "audit_required", audit_is_available)
monkeypatch.setattr(backup_service, "create", fail_with_decoded_password)
response = as_admin.post("/api/admin/backups")
assert response.status_code == 503
safe = response.json()["detail"]
assert "p@ss:word" not in safe
assert "p%40ss%3Aword" not in safe
assert "connection failed" in safe
def test_backup_directory_failure_is_retryable_backup_error(monkeypatch, tmp_path):
from app.admin import backup as backup_service

View file

@ -8,6 +8,8 @@
проверяют разграничение, для которого база не нужна.
"""
import time
from types import SimpleNamespace
from uuid import uuid4
import pytest
@ -46,10 +48,17 @@ def test_broken_hash_does_not_let_anyone_in():
assert not verify_password("не хеш вовсе", "что угодно")
def test_malformed_stored_hash_is_not_written_to_logs(caplog):
stored_hash = "private-stored-hash-marker"
assert not verify_password(stored_hash, "candidate-password")
assert stored_hash not in caplog.text
assert "InvalidHash" in caplog.text
# ── вход ──
def test_unknown_login_and_wrong_password_look_the_same(client):
def test_unknown_login_and_wrong_password_look_the_same(client, postgres_access):
"""Иначе форма входа превращается в список действующих учётных записей."""
first = client.post("/api/auth/login", json={"login": "нет-такого", "password": "x"})
assert first.status_code == 401
@ -78,9 +87,186 @@ def test_dev_token_gives_an_instructor(client):
assert client.get("/api/auth/me").json()["role"] == "instructor"
def test_logout_clears_the_session(client):
client.post("/api/auth/dev-token")
client.post("/api/auth/logout")
def test_directory_login_issues_the_mapped_role_and_identity(client, monkeypatch):
from app import directory
from app.api import auth
from app.config import get_settings
from app.directory import DirectoryIdentity
# This route test supplies its own account and sessionmaker below. Mark an
# empty auth-generation snapshot fresh as if startup had loaded the empty
# test directory; otherwise the production middleware correctly fails
# closed with 503 when the sandbox cannot reach PostgreSQL.
monkeypatch.setattr(auth, "_generations", {})
monkeypatch.setattr(auth, "_generations_synced_at", time.monotonic())
provisioned = {}
class EmptyDb:
async def scalar(self, query):
if "users.auth_version" in str(query) and "user" in provisioned:
return provisioned["user"].auth_version
return None
class DbContext:
async def __aenter__(self):
return EmptyDb()
async def __aexit__(self, *_args):
return None
settings = get_settings().model_copy(update={"ldap_enabled": True})
monkeypatch.setattr(auth, "get_settings", lambda: settings)
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: DbContext())
identity = DirectoryIdentity(
login="trainee.one",
full_name="Курсант Один",
role=Role.TRAINEE,
service="01",
subject="directory-guid-1",
)
async def authenticate(login, password):
assert login == "trainee.one"
assert password == "directory-password"
return identity
async def provision(_identity):
provisioned["user"] = SimpleNamespace(
login=identity.login,
full_name=identity.full_name,
role=identity.role.value,
service=identity.service,
trainee_id=uuid4(),
auth_version=0,
blocked=False,
)
return provisioned["user"]
async def audit(*_args, **_kwargs):
return None
monkeypatch.setattr(directory, "authenticate", authenticate)
monkeypatch.setattr(auth, "_directory_account", provision)
monkeypatch.setattr(auth, "audit", audit)
response = client.post(
"/api/auth/login",
json={"login": "trainee.one", "password": "directory-password"},
)
assert response.status_code == 200, response.text
assert response.json()["role"] == "trainee"
assert response.json()["service"] == "01"
assert client.get("/api/auth/me").json()["login"] == "trainee.one"
def test_directory_outage_does_not_fall_back_or_issue_a_session(client, monkeypatch):
from app import directory
from app.api import auth
from app.config import get_settings
from app.directory import DirectoryUnavailable
class EmptyDb:
async def scalar(self, _query):
return None
class DbContext:
async def __aenter__(self):
return EmptyDb()
async def __aexit__(self, *_args):
return None
monkeypatch.setattr(
auth,
"get_settings",
lambda: get_settings().model_copy(update={"ldap_enabled": True}),
)
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: DbContext())
async def unavailable(*_args):
raise DirectoryUnavailable("directory service unavailable")
async def audit(*_args, **_kwargs):
return None
monkeypatch.setattr(directory, "authenticate", unavailable)
monkeypatch.setattr(auth, "audit", audit)
response = client.post(
"/api/auth/login", json={"login": "trainee.one", "password": "anything"}
)
assert response.status_code == 503
assert response.json()["detail"] == "directory_unavailable"
assert client.get("/api/auth/me").status_code == 401
def test_blocked_directory_account_attempt_is_audited(client, monkeypatch):
from app import directory
from app.api import auth
from app.config import get_settings
from app.directory import DirectoryIdentity
class EmptyDb:
async def scalar(self, _query):
return None
class DbContext:
async def __aenter__(self):
return EmptyDb()
async def __aexit__(self, *_args):
return None
settings = get_settings().model_copy(update={"ldap_enabled": True})
monkeypatch.setattr(auth, "get_settings", lambda: settings)
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: DbContext())
identity = DirectoryIdentity(
login="trainee.one",
full_name="Курсант Один",
role=Role.TRAINEE,
service="01",
subject="directory-guid-blocked",
)
async def authenticate(*_args):
return identity
async def provision(_identity):
return SimpleNamespace(
login=identity.login,
full_name=identity.full_name,
role=identity.role.value,
service=identity.service,
trainee_id=uuid4(),
auth_version=0,
blocked=True,
)
audit_events = []
async def audit(*args):
audit_events.append(args)
monkeypatch.setattr(directory, "authenticate", authenticate)
monkeypatch.setattr(auth, "_directory_account", provision)
monkeypatch.setattr(auth, "audit", audit)
response = client.post(
"/api/auth/login",
json={"login": "trainee.one", "password": "directory-password"},
)
assert response.status_code == 403
assert response.json()["detail"] == "blocked"
assert any(event[2] == "login.blocked" for event in audit_events)
assert client.get("/api/auth/me").status_code == 401
def test_logout_clears_and_revokes_the_session(client, postgres_access):
assert client.post("/api/auth/dev-token").status_code == 200
stale_cookie = client.cookies.get("lct_session")
response = client.post("/api/auth/logout")
assert response.status_code == 200, response.text
assert client.get("/api/auth/me").status_code == 401
# Replaying a copied pre-logout cookie must not restore the authenticated session.
client.cookies.set("lct_session", stale_cookie)
assert client.get("/api/auth/me").status_code == 401

View file

@ -1,6 +1,10 @@
"""Regressions for stale cookies and privileged admin operations."""
import asyncio
import re
import weakref
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
@ -9,13 +13,78 @@ from fastapi import HTTPException
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
import app.api.auth as auth
from app.api import auth
from app.api.http import admin
from app.domain.roles import Role
from app.main import app
from app.session.hub import hub
@pytest.mark.parametrize(
"headers, scope, expected",
[
({"origin": "http://training.lan", "host": "training.lan"}, {"scheme": "ws"}, True),
(
{
"origin": "https://training.lan:5443",
"host": "backend:8000",
"x-forwarded-host": "training.lan:5443",
"x-forwarded-proto": "https",
},
{"scheme": "ws"},
True,
),
({"origin": "https://attacker.invalid", "host": "training.lan"}, {"scheme": "ws"}, False),
({"origin": "http://training.lan:5173", "host": "training.lan:8000"}, {"scheme": "ws"}, False),
(
{
"origin": "http://training.lan",
"host": "backend:8000",
"x-forwarded-host": "training.lan",
"x-forwarded-proto": "https",
},
{"scheme": "wss"},
False,
),
({"host": "training.lan"}, {"scheme": "ws"}, True),
({"origin": "not a URL", "host": "training.lan"}, {"scheme": "ws"}, False),
],
)
def test_websocket_origin_policy(headers, scope, expected):
assert auth.websocket_origin_allowed(SimpleNamespace(headers=headers, scope=scope)) is expected
def test_nginx_proxies_preserve_external_host_for_websocket_origin_validation():
project_root = Path(__file__).resolve().parents[2]
for config in ("nginx.conf.template", "nginx.tls.conf.template"):
text = (project_root / "frontend" / config).read_text(encoding="utf-8")
match = re.search(r"location /ws/ \{(.*?)^ \}", text, re.MULTILINE | re.DOTALL)
assert match is not None, f"{config}: missing WebSocket proxy block"
websocket_location = match.group(1)
assert "proxy_set_header X-Forwarded-Host $http_host;" in websocket_location
tls = (project_root / "frontend" / "nginx.tls.conf.template").read_text(encoding="utf-8")
match = re.search(r"location /ws/ \{(.*?)^ \}", tls, re.MULTILINE | re.DOTALL)
assert match is not None
tls_websocket_location = match.group(1)
assert "proxy_set_header X-Forwarded-Proto https;" in tls_websocket_location
def test_cluster_nginx_pins_all_session_channels_and_session_apis_to_one_hash_key():
project_root = Path(__file__).resolve().parents[2]
for config in ("nginx.cluster.conf.template", "nginx.cluster.tls.conf.template"):
text = (project_root / "frontend" / config).read_text(encoding="utf-8")
assert "hash $session_route_key consistent;" in text
assert "server backend:8000" in text and "server backend-b:8000" in text
assert re.search(
r"~\^/ws/\(\?:control\|call\|observe\|station\)/\(\[0-9a-fA-F-\]\{36\}\)",
text,
), f"{config}: all WebSocket channels must extract the same session UUID"
assert re.search(
r"~\^/api/sessions/\(\[0-9a-fA-F-\]\{36\}\)", text
), f"{config}: session REST endpoints must use the same routing key"
assert text.count("proxy_pass http://backend_cluster;") == 2
@pytest.fixture
def client():
# Each TestClient represents a fresh backend process. In particular,
@ -38,6 +107,43 @@ def test_account_change_revokes_http_and_new_websocket_handshakes(client):
assert client.get("/api/auth/me").status_code == 200
def test_generation_sync_preserves_synthetic_dev_account(monkeypatch):
class FakeResult:
def all(self):
return []
class FakeDb:
async def execute(self, _query):
return FakeResult()
class FakeSession:
async def __aenter__(self):
return FakeDb()
async def __aexit__(self, *_args):
return None
async def run():
auth.prime_generations({"dev": 7})
await auth.sync_generations()
assert auth._generations["dev"] == 7
auth._generations.pop("dev", None)
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=True))
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
asyncio.run(run())
def test_cross_origin_browser_websocket_is_rejected_before_handshake(client):
assert client.post("/api/auth/dev-token").status_code == 200
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect(
f"/ws/control/{uuid4()}", headers={"origin": "https://attacker.invalid"}
):
pytest.fail("cross-origin websocket must not be accepted")
assert exc.value.code == 1008
def test_account_change_closes_an_existing_websocket(client):
assert client.post("/api/auth/dev-token").status_code == 200
with client.websocket_connect(f"/ws/control/{uuid4()}") as socket:
@ -59,6 +165,334 @@ def test_cookie_survives_generation_cache_reload_when_account_is_unchanged(clien
assert client.get("/api/auth/me").status_code == 200
def test_login_is_not_issued_when_security_audit_cannot_be_written(client, monkeypatch):
from app.config import get_settings
user = SimpleNamespace(
login="audit-login", auth_provider="local", password_hash="hash",
blocked=False, role="instructor", full_name="Преподаватель",
service=None, trainee_id=None, auth_version=0,
)
class FakeDb:
async def scalar(self, _statement):
return user
class FakeSession:
async def __aenter__(self):
return FakeDb()
async def __aexit__(self, *_args):
return None
settings = get_settings().model_copy(update={"demo_no_db": False, "ldap_enabled": False})
monkeypatch.setattr(auth, "get_settings", lambda: settings)
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
monkeypatch.setattr(auth, "verify_password", lambda *_args: True)
async def audit_failure(*_args, **_kwargs):
return False
monkeypatch.setattr(auth, "audit", audit_failure)
response = client.post(
"/api/auth/login", json={"login": user.login, "password": "valid"}
)
assert response.status_code == 503
assert response.json()["detail"] == "audit_unavailable"
assert client.get("/api/auth/me").status_code == 401
@pytest.mark.asyncio
async def test_audit_storage_failure_does_not_log_user_supplied_detail(caplog, monkeypatch):
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
monkeypatch.setattr(auth, "get_settings", lambda: settings)
def broken_session():
raise RuntimeError("private-user-comment-must-not-reach-logs")
monkeypatch.setattr(auth, "get_sessionmaker", lambda: broken_session)
assert not await auth.audit(
"teacher", "instructor", "score.override", "session-id",
"sensitive comment must not be logged",
)
assert "private-user-comment-must-not-reach-logs" not in caplog.text
assert "sensitive comment" not in caplog.text
assert "RuntimeError" in caplog.text
def test_demo_logout_revokes_replayed_cookie(client, monkeypatch):
settings = auth.get_settings().model_copy(update={"demo_no_db": True})
monkeypatch.setattr(auth, "get_settings", lambda: settings)
assert client.post("/api/auth/dev-token").status_code == 200
stale_cookie = client.cookies.get("lct_session")
assert client.post("/api/auth/logout").status_code == 200
client.cookies.set("lct_session", stale_cookie)
assert client.get("/api/auth/me").status_code == 401
def test_peer_node_generation_sync_closes_revoked_websocket(monkeypatch):
login = "peer-revoked"
class FakeResult:
def all(self):
return [(login, 4)]
class FakeDb:
async def execute(self, _query):
return FakeResult()
class FakeSession:
async def __aenter__(self):
return FakeDb()
async def __aexit__(self, *_args):
return None
class FakeSocket:
closed = False
async def close(self, **_kwargs):
self.closed = True
async def run():
auth.prime_generations({login: 3})
socket = FakeSocket()
auth._active_sockets[login] = weakref.WeakKeyDictionary({
socket: asyncio.get_running_loop(),
})
await auth.sync_generations()
await asyncio.sleep(0.01)
assert auth._generations[login] == 4
assert socket.closed
auth._active_sockets.pop(login, None)
auth._generations.pop(login, None)
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
asyncio.run(run())
def test_revocation_does_not_log_error_if_socket_already_disconnected():
class DisconnectedSocket:
async def close(self, **_kwargs):
raise WebSocketDisconnect(code=1006)
asyncio.run(auth._close_revoked(DisconnectedSocket()))
def test_auth_middleware_rejects_cookie_with_old_database_epoch(monkeypatch):
login = "stale-cookie"
class FakeDb:
async def scalar(self, _query):
return 5
class FakeSession:
async def __aenter__(self):
return FakeDb()
async def __aexit__(self, *_args):
return None
observed = {}
class InnerApp:
async def __call__(self, scope, _receive, _send):
observed["session"] = dict(scope["session"])
async def run():
auth.prime_generations({login: 5})
cookie_session = {
"principal": {"login": login},
"auth_instance": auth._INSTANCE,
"auth_generation": 4,
}
scope = {"type": "http", "session": cookie_session}
async def unused_receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def unused_send(_message):
return None
middleware = auth.AuthVersionMiddleware(InnerApp())
await middleware(scope, unused_receive, unused_send)
assert observed["session"] == {}
auth._generations.pop(login, None)
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
monkeypatch.setattr(auth, "get_settings", lambda: settings)
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
asyncio.run(run())
def test_auth_middleware_fails_closed_when_generation_cache_is_stale_but_allows_logout(monkeypatch):
from app.config import get_settings
class BrokenSession:
async def __aenter__(self):
raise OSError("database unavailable")
async def __aexit__(self, *_args):
return None
class InnerApp:
def __init__(self):
self.called = False
async def __call__(self, _scope, _receive, _send):
self.called = True
async def run():
login = "db-outage-user"
auth.prime_generations({login: 0})
monkeypatch.setattr(
auth, "_generations_synced_at",
auth.time.monotonic() - auth.AUTH_GENERATION_MAX_AGE_SECONDS - 1,
)
principal = auth.Principal(
login=login, full_name="Учётная запись", role=Role.INSTRUCTOR
)
scope = {"type": "http", "path": "/api/admin/users", "session": {
"principal": principal.model_dump(mode="json"),
"auth_instance": auth._INSTANCE,
"auth_generation": 0,
}}
messages = []
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message):
messages.append(message)
protected = InnerApp()
await auth.AuthVersionMiddleware(protected)(scope, receive, send)
assert not protected.called
assert messages[0]["status"] == 503
logout_scope = {**scope, "path": "/api/auth/logout", "session": dict(scope["session"])}
logout = InnerApp()
await auth.AuthVersionMiddleware(logout)(logout_scope, receive, send)
assert logout.called, "logout must reach the route so it can clear the cookie"
settings = get_settings().model_copy(update={"demo_no_db": False, "dev_auth_bypass": False})
monkeypatch.setattr(auth, "get_settings", lambda: settings)
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: BrokenSession())
asyncio.run(run())
def test_auth_middleware_uses_fresh_generation_cache_without_per_request_database_query(monkeypatch):
from app.config import get_settings
class InnerApp:
def __init__(self):
self.called = False
async def __call__(self, _scope, _receive, _send):
self.called = True
async def run():
login = "cached-generation-user"
auth.prime_generations({login: 6})
principal = auth.Principal(
login=login, full_name="Учётная запись", role=Role.INSTRUCTOR
)
scope = {"type": "http", "path": "/api/admin/users", "session": {
"principal": principal.model_dump(mode="json"),
"auth_instance": auth._INSTANCE,
"auth_generation": 6,
}}
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def send(_message):
return None
protected = InnerApp()
await auth.AuthVersionMiddleware(protected)(scope, receive, send)
assert protected.called, "a fresh, matching epoch should reach role-protected route auth"
auth._generations.pop(login, None)
settings = get_settings().model_copy(update={"demo_no_db": False})
monkeypatch.setattr(auth, "get_settings", lambda: settings)
monkeypatch.setattr(
auth, "get_sessionmaker",
lambda: (_ for _ in ()).throw(AssertionError("middleware must use its synced cache")),
)
asyncio.run(run())
def test_stale_generation_sync_closes_existing_authenticated_websockets():
class FakeWebSocket:
def __init__(self):
self.closed_with = None
async def close(self, code, reason):
self.closed_with = (code, reason)
async def run():
login = "stale-cache-socket-user"
websocket = FakeWebSocket()
sockets = auth._active_sockets.setdefault(
login, weakref.WeakKeyDictionary()
)
sockets[websocket] = asyncio.get_running_loop()
try:
auth._close_unverified_sockets()
await asyncio.sleep(0)
await asyncio.sleep(0)
assert websocket.closed_with == (
1013, "Состояние доступа временно недоступно",
)
finally:
auth._active_sockets.pop(login, None)
asyncio.run(run())
def test_generation_watcher_fails_closed_after_database_sync_error(monkeypatch):
class FakeWebSocket:
def __init__(self):
self.closed_with = None
async def close(self, code, reason):
self.closed_with = (code, reason)
class StopWatcher(Exception):
pass
async def run():
login = "sync-error-socket-user"
websocket = FakeWebSocket()
auth._active_sockets.setdefault(
login, weakref.WeakKeyDictionary()
)[websocket] = asyncio.get_running_loop()
async def broken_sync():
raise OSError("database unavailable")
await_original_sleep = asyncio.sleep
async def stop_after_iteration(_seconds):
raise StopWatcher()
monkeypatch.setattr(auth, "sync_generations", broken_sync)
monkeypatch.setattr(auth.asyncio, "sleep", stop_after_iteration)
monkeypatch.setattr(
auth, "_generations_synced_at",
auth.time.monotonic() - auth.AUTH_GENERATION_MAX_AGE_SECONDS - 1,
)
try:
try:
await auth.watch_generations()
except StopWatcher:
pass
await await_original_sleep(0)
await await_original_sleep(0)
assert websocket.closed_with == (
1013, "Состояние доступа временно недоступно",
)
finally:
auth._active_sockets.pop(login, None)
asyncio.run(run())
class FakeDb:
def __init__(self, user):
self.user = user
@ -84,7 +518,8 @@ def fake_user(login="victim", role="instructor"):
return SimpleNamespace(
id=uuid4(), login=login, full_name="Проверка", role=role,
service=None, trainee_id=None, blocked=False,
password_hash="old", auth_version=0, created_at=datetime.now(timezone.utc),
password_hash="old", auth_provider="local", directory_subject=None,
auth_version=0, created_at=datetime.now(timezone.utc),
)
@ -101,13 +536,34 @@ async def test_admin_patch_revokes_cookie_after_commit(monkeypatch):
lambda login, version=None: calls.append((login, version, db.commits)),
)
async def no_audit(*_args, **_kwargs):
return None
monkeypatch.setattr(admin, "audit", no_audit)
await admin.patch_user(user.id, admin.UserPatch(blocked=True), object(), db)
assert user.blocked is True
assert calls == [("victim", 1, 1)]
assert db.added[-1].action == "user.update"
assert db.added[-1].object_id == "victim"
assert "заблокирован" in db.added[-1].detail
@pytest.mark.asyncio
async def test_admin_patch_never_revokes_or_reports_success_when_audit_commit_fails(monkeypatch):
user = fake_user()
class BrokenCommitDb(FakeDb):
async def commit(self):
raise RuntimeError("audit table unavailable")
db = BrokenCommitDb(user)
invalidations = []
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
login="admin", full_name="Администратор", role=Role.ADMIN,
))
monkeypatch.setattr(admin, "invalidate_login", lambda *args: invalidations.append(args))
with pytest.raises(RuntimeError, match="audit table unavailable"):
await admin.patch_user(user.id, admin.UserPatch(blocked=True), object(), db)
assert invalidations == [], "сессию отзываем только после атомарного commit"
assert db.added[-1].action == "user.update"
@pytest.mark.asyncio
@ -131,36 +587,43 @@ async def test_promotion_to_trainee_creates_profile(monkeypatch):
login="admin", full_name="Администратор", role=Role.ADMIN,
))
async def no_audit(*_args, **_kwargs):
return None
monkeypatch.setattr(admin, "audit", no_audit)
await admin.patch_user(user.id, admin.UserPatch(role=Role.TRAINEE), object(), db)
assert user.role == "trainee"
assert user.trainee_id is not None
assert db.commits == 1
assert db.added[-1].action == "user.update"
assert db.added[-1].detail == "роль trainee"
@pytest.mark.asyncio
async def test_backup_runs_off_event_loop_and_failure_is_audited(monkeypatch):
who = auth.Principal(login="admin", full_name="Администратор", role=Role.ADMIN)
monkeypatch.setattr(admin, "require", lambda _request, *_roles: who)
calls = []
threadpool_calls = []
audit_calls = []
outcome_calls = []
async def fake_threadpool(fn):
calls.append(fn)
threadpool_calls.append(fn)
return fn()
async def fake_audit_required(*args, **kwargs):
audit_calls.append((args, kwargs))
async def fake_audit(*args, **kwargs):
calls.append((args, kwargs))
outcome_calls.append((args, kwargs))
monkeypatch.setattr(admin, "run_in_threadpool", fake_threadpool)
monkeypatch.setattr(admin, "audit_required", fake_audit_required)
monkeypatch.setattr(admin, "audit", fake_audit)
monkeypatch.setattr(admin.backup_service, "create", lambda: {
"name": "example.sql", "size_bytes": 1, "at": datetime.now(timezone.utc),
})
assert (await admin.make_backup(object())).name == "example.sql"
assert calls[0] is admin.backup_service.create
assert threadpool_calls == [admin.backup_service.create]
assert [item[0][2] for item in audit_calls] == [
"backup.create.requested", "backup.create",
]
def broken():
raise admin.backup_service.BackupError("pg_dump failed")
@ -169,7 +632,40 @@ async def test_backup_runs_off_event_loop_and_failure_is_audited(monkeypatch):
with pytest.raises(HTTPException) as exc:
await admin.make_backup(object())
assert exc.value.status_code == 503
assert any(isinstance(item, tuple) and item[0][2] == "backup.failed" for item in calls)
assert outcome_calls[-1][0][2] == "backup.failed"
@pytest.mark.asyncio
async def test_backup_success_is_not_returned_when_audit_is_unavailable(monkeypatch):
who = auth.Principal(login="admin", full_name="Администратор", role=Role.ADMIN)
monkeypatch.setattr(admin, "require", lambda _request, *_roles: who)
created = []
async def fake_threadpool(fn):
return fn()
audit_actions = []
async def fail_after_backup(*args, **_kwargs):
audit_actions.append(args[2])
if args[2] == "backup.create":
raise HTTPException(status_code=503, detail="audit_unavailable")
def create_backup():
created.append("example.sql")
return {"name": "example.sql", "size_bytes": 1, "at": datetime.now(timezone.utc)}
monkeypatch.setattr(admin, "run_in_threadpool", fake_threadpool)
monkeypatch.setattr(admin, "audit_required", fail_after_backup)
monkeypatch.setattr(admin.backup_service, "create", create_backup)
with pytest.raises(HTTPException) as exc:
await admin.make_backup(object())
assert exc.value.status_code == 503
assert exc.value.detail == "audit_unavailable"
assert audit_actions == ["backup.create.requested", "backup.create"]
assert created == ["example.sql"] # artifact exists; the response does not misreport audit success
def test_backup_error_redacts_database_credentials(monkeypatch):

View file

@ -1,4 +1,5 @@
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from scripts import backup_loop
@ -18,3 +19,42 @@ def test_overdue_backup_is_due_immediately(monkeypatch):
now = datetime.now(timezone.utc)
monkeypatch.setattr(backup_loop, "listing", lambda: [{"at": now - timedelta(days=2)}])
assert backup_loop.seconds_until_due(now, 86_400) == 0
def test_scheduler_waits_only_remainder_after_slow_backup(monkeypatch):
settings = SimpleNamespace(
backup_interval_seconds=86_400,
backup_retry_seconds=300,
backup_keep=14,
)
copies = []
waits = []
def listing():
return copies
def create():
# Model pg_dump taking 20 minutes before finishing the scheduler cycle.
copies.append({
"name": "recent.sql",
"at": datetime.now(timezone.utc) - timedelta(minutes=20),
})
return {"name": "recent.sql", "size_bytes": 123}
def sleep(seconds):
waits.append(seconds)
raise RuntimeError("stop after observing next scheduled wait")
monkeypatch.setattr(backup_loop, "get_settings", lambda: settings)
monkeypatch.setattr(backup_loop, "listing", listing)
monkeypatch.setattr(backup_loop, "create", create)
monkeypatch.setattr(backup_loop, "prune", lambda keep: 0)
monkeypatch.setattr(backup_loop.time, "sleep", sleep)
try:
backup_loop.run_forever()
except RuntimeError as exc:
assert str(exc) == "stop after observing next scheduled wait"
assert len(waits) == 1
assert 85_190 <= waits[0] <= 85_200

View file

@ -0,0 +1,47 @@
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
import pytest
from app.api.ws import call
from app.dialog.slots import TurnResult
from app.domain.events import Exercise, TranscriptEntry
@pytest.mark.asyncio
async def test_text_dialogue_provider_error_does_not_log_prompt_or_provider_body(caplog, monkeypatch):
secret = "private-incident-address-from-provider-error"
class Caller:
async def reply(self, *_args):
raise RuntimeError(secret)
class Slots:
def hear(self, text):
return TurnResult(text=text)
def revealed_facts(self):
return []
monkeypatch.setattr(call.hub, "journal", None)
monkeypatch.setattr(call.hub, "to_trainee", lambda *_args: None)
monkeypatch.setattr(call.hub, "to_observers", lambda *_args: None)
state = SimpleNamespace(
ended=False,
exercise=Exercise.CARD,
dispatched_card=None,
caller=Caller(),
persona=object(),
scenario=SimpleNamespace(facts=[], checklist=[]),
slots=Slots(),
text_revealed_facts={},
append=lambda speaker, text: TranscriptEntry(
ref="transcript-ref", speaker=speaker, text=text, at=datetime.now(timezone.utc),
),
)
await call._handle(uuid4(), state, SimpleNamespace(type="text.turn", text="where is the incident"))
assert secret not in caplog.text
assert "RuntimeError" in caplog.text

View file

@ -1,17 +1,26 @@
"""Текстовая вводная 112: карточка без голоса, опроса и ДДС-оценки."""
"""Текстовое упражнение 112: переписка с заявителем без голоса."""
import time
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.api.http import sessions as sessions_http
from app.main import app
from app.api.ws.call import _text_turn
from app.scenarios import store
from app.scoring.grammar import basic_check
from app.session.hub import hub
@pytest.fixture
def client():
def client(monkeypatch):
async def audit_in_memory(*_args, **_kwargs):
return None
monkeypatch.setattr(sessions_http, "audit_required", audit_in_memory)
with TestClient(app) as test_client:
test_client.post("/api/auth/dev-token")
hub.journal = None
@ -36,13 +45,22 @@ def read_until(socket, wanted):
raise AssertionError(f"событие {wanted} не пришло")
def start(client, *, handoff_to_dds=False):
async def rules_only_grammar(text):
return basic_check(text)
def start(client, *, handoff_to_dds=False, criteria=None, scenario_ids=None):
session_id = uuid4()
context = client.websocket_connect(f"/ws/control/{session_id}")
control = context.__enter__()
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Иванов", "mode": "training", "exercise": "card",
"handoff_to_dds": handoff_to_dds})
payload = {"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Иванов", "mode": "training", "exercise": "card",
"handoff_to_dds": handoff_to_dds}
if scenario_ids is not None:
payload["scenario_ids"] = scenario_ids
if criteria is not None:
payload["criteria"] = criteria
control.send_json(payload)
wait_for(lambda: hub.get(session_id))
return session_id, context
@ -53,12 +71,13 @@ def test_card_briefing_is_text_only_and_replayed_on_late_join(client):
state = hub.get(session_id)
assert state.exercise.value == "card"
assert state.dispatched_card is None
assert state.slots is None and state.voice is None
assert state.voice is None
assert state.caller is not None and state.persona is not None
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
briefing = read_until(trainee, "card.briefing")
assert briefing["scenario_id"] == "fire-apartment-l2"
assert "Помогите" in briefing["text"]
assert "горит балкон" in briefing["text"]
assert "горит балкон" not in briefing["text"]
assert "ground_truth" not in briefing
assert briefing["card"]["address"] is None
assert "address" in briefing["required_fields"]
@ -70,7 +89,7 @@ def test_card_briefing_is_text_only_and_replayed_on_late_join(client):
control.__exit__(None, None, None)
def test_correct_card_scores_100_without_call_or_dds_metrics(client):
def test_correct_card_scores_100_including_grammar_without_call_or_dds_metrics(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
@ -79,7 +98,7 @@ def test_correct_card_scores_100_without_call_or_dds_metrics(client):
assert "dds" not in briefing["required_fields"]
trainee.send_json({"type": "kio.patch", "fields": {
"address": "улица Ленина, 14", "floor": "5", "incident_type": "fire",
"victims_count": 2, "description": "горит балкон",
"victims_count": 2, "description": "Горит балкон.",
"signs": ["жилой дом", "балкон", "открытое пламя"],
}})
wait_for(lambda: hub.get(session_id).kio.incident_code)
@ -93,9 +112,17 @@ def test_correct_card_scores_100_without_call_or_dds_metrics(client):
assert state.dispatched_card is not None
assert state.score["score_auto"] == 100.0
assert not state.score["findings"]
grammar_metric = next(item for item in state.score["metrics"]
if item["key"] == "description_grammar")
assert grammar_metric["passed"]
assert {item["key"] for item in state.score["metrics"]} == {
"incident_signs", "address", "victims_count", "required_fields"
"incident_signs", "address", "victims_count", "required_fields",
"description_grammar", "card_fill_time",
}
assert state.score["summary"]["card_fill_ms"] is not None
fill_metric = next(item for item in state.score["metrics"] if item["key"] == "card_fill_time")
assert fill_metric["passed"]
assert "норматива" in fill_metric["fact"]
with client.websocket_connect(f"/ws/call/{session_id}") as late:
assert read_until(late, "card.briefing")["card"]["address"] == "улица Ленина, 14"
assert read_until(late, "call.ended")["reason"] == "complete"
@ -104,6 +131,114 @@ def test_correct_card_scores_100_without_call_or_dds_metrics(client):
control.__exit__(None, None, None)
def test_text_exercise_allows_questions_and_returns_grounded_caller_reply(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
read_until(trainee, "card.briefing")
trainee.send_json({"type": "text.turn", "text": "Что горит?"})
accepted = read_until(trainee, "text.turn.accepted")
assert accepted["text"] == "Что горит?"
reply = read_until(trainee, "caller.utterance")
assert "балкон" in reply["text"] or "загорел" in reply["text"]
assert hub.get(session_id).text_revealed_facts["f_what_burns"] == "горит балкон, дым пошёл в квартиру"
finally:
control.__exit__(None, None, None)
def test_natural_request_for_precise_address_reveals_refinement_without_embedder(client):
scenario = store.get("t01-1-fire-container")
assert scenario is not None
address = next(fact for fact in scenario.facts if fact.id == "f_address")
assert address.refined == scenario.ground_truth.address
state = SimpleNamespace(
scenario=scenario,
slots=None,
text_revealed_facts={address.id: address.value},
)
turn = _text_turn(state, "а точнее можете назвать на ближайшем доме?")
assert turn.refined == ["f_address"]
assert turn.matched == ["q_address_check"]
assert state.text_revealed_facts[address.id] == address.refined
def test_card_fill_overrun_is_reported_as_e3_with_actual_and_norm(client):
session_id, control = start(client, criteria={"card_fill_time_limit_seconds": 60})
try:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
read_until(trainee, "card.briefing")
state = hub.get(session_id)
assert state.timers.limits[next(code for code in state.timers.limits
if code.value == "card_fill")] == 60_000
state.timers.timers[next(code for code in state.timers.timers
if code.value == "card_fill")].started_at = time.monotonic() - 61
trainee.send_json({"type": "card.submit"})
read_until(trainee, "call.ended")
read_until(trainee, "score.ready")
state = hub.get(session_id)
metric = next(item for item in state.score["metrics"] if item["key"] == "card_fill_time")
assert not metric["passed"]
assert "61 с" in metric["fact"] and "+1 с" in metric["fact"]
assert metric["norm"] == "сдать карточку за 60 с"
finding = next(item for item in state.score["findings"] if item["code"] == "E3")
assert "Время заполнения карточки" in finding["summary"]
finally:
control.__exit__(None, None, None)
def test_grammar_criterion_flags_incorrect_card_description(client, monkeypatch):
monkeypatch.setattr("app.session.finish.assess", rules_only_grammar)
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
read_until(trainee, "card.briefing")
trainee.send_json({"type": "kio.patch", "fields": {
"address": "улица Ленина, 14", "floor": "5", "incident_type": "fire",
"victims_count": 2, "description": "горит балкон",
"signs": ["жилой дом", "балкон", "открытое пламя"],
}})
wait_for(lambda: hub.get(session_id).kio.incident_code)
trainee.send_json({"type": "card.submit"})
read_until(trainee, "call.ended")
read_until(trainee, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
metric = next(item for item in score["metrics"] if item["key"] == "description_grammar")
assert not metric["passed"]
assert metric["fact"]
assert any(item["code"] == "E4" for item in score["findings"])
e4 = next(item for item in score["findings"] if item["code"] == "E4")
assert e4["source"] == "grammar"
assert score["score_auto"] < 100
finally:
control.__exit__(None, None, None)
def test_disabled_grammar_criterion_does_not_change_card_score(client, monkeypatch):
monkeypatch.setattr("app.session.finish.assess", rules_only_grammar)
session_id, control = start(client, criteria={"require_correct_grammar": False})
try:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
read_until(trainee, "card.briefing")
trainee.send_json({"type": "kio.patch", "fields": {
"address": "улица Ленина, 14", "floor": "5", "incident_type": "fire",
"victims_count": 2, "description": "горит балкон",
"signs": ["жилой дом", "балкон", "открытое пламя"],
}})
wait_for(lambda: hub.get(session_id).kio.incident_code)
trainee.send_json({"type": "card.submit"})
read_until(trainee, "call.ended")
read_until(trainee, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
assert score["score_auto"] == 100
assert not any(item["key"] == "description_grammar" for item in score["metrics"])
finally:
control.__exit__(None, None, None)
def test_incomplete_card_has_only_card_findings(client):
session_id, control = start(client)
try:
@ -155,7 +290,8 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
crew = next(item for item in snapshot["crew_options"] if item.startswith(service + " — "))
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
station.send_json({"type": "card.status", "service": service, "status": "accepted",
"comment": "Старший группы подтвердил приём карточки."})
read_until(station, "station.state")
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
@ -167,7 +303,8 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
"request": "Прошу подтвердить выезд и доложить о прибытии"})
assert read_until(station, "phone.report")["phase"] == "dispatched"
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": service, "status": "responding"})
station.send_json({"type": "card.status", "service": service, "status": "responding",
"comment": "Старший группы сообщил о начале реагирования."})
read_until(station, "station.state")
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
@ -180,3 +317,45 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
assert score["score_auto"] < 100
finally:
control.__exit__(None, None, None)
def test_handoff_queue_mixes_trainee_card_then_selected_prepared_card(client):
session_id, control = start(
client, handoff_to_dds=True,
scenario_ids=["fire-apartment-l2", "t01-1-fire-container"],
)
try:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
read_until(trainee, "card.briefing")
trainee.send_json({"type": "kio.patch", "fields": {
"address": "улица Ленина, 14", "incident_type": "fire",
"victims_count": 2, "description": "горит балкон",
"signs": ["жилой дом", "балкон", "открытое пламя"],
}})
wait_for(lambda: hub.get(session_id).kio.incident_code)
trainee.send_json({"type": "card.submit"})
read_until(trainee, "call.ended")
with client.websocket_connect(f"/ws/station/{session_id}") as station:
first = read_until(station, "card.received")
assert first["from_operator"] == "Иванов"
assert first["card"]["address"] == "улица Ленина, 14"
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["card_total"] == 2
assert {item["scenario_id"] for item in snapshot["queue_cards"]} == {
"fire-apartment-l2", "t01-1-fire-container"
}
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
state = wait_for(lambda: hub.get(session_id).score)
assert state["card_results"]
assert [item["scenario_id"] for item in state["card_results"]] == [
"fire-apartment-l2", "t01-1-fire-container"
]
assert {item["key"] for item in state["metrics"]} >= {
"address", "incident_signs", "dds_primary"
}
assert state["score_auto"] < 100
finally:
control.__exit__(None, None, None)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,54 @@
from types import SimpleNamespace
import pytest
from pydantic import ValidationError
from app.config import Settings
def test_database_pool_defaults_fit_two_backend_node_budget():
settings = Settings(_env_file=None)
assert settings.db_pool_size == 20
assert settings.db_pool_max_overflow == 10
# Two backend nodes use at most 60 application connections, leaving room
# under PostgreSQL's common 100-connection default for admin/backup work.
assert 2 * (settings.db_pool_size + settings.db_pool_max_overflow) == 60
@pytest.mark.parametrize("values", [{"db_pool_size": 0}, {"db_pool_max_overflow": -1}])
def test_database_pool_rejects_invalid_limits(values):
with pytest.raises(ValidationError):
Settings(_env_file=None, **values)
def test_engine_uses_configured_pool_limits(monkeypatch):
from app.db import base
observed = {}
sentinel = object()
def capture(url, **options):
observed["url"] = url
observed.update(options)
return sentinel
settings = SimpleNamespace(
database_url="postgresql+asyncpg://lct:test@localhost/lct",
db_pool_size=12,
db_pool_max_overflow=7,
)
monkeypatch.setattr(base, "get_settings", lambda: settings)
monkeypatch.setattr(base, "create_async_engine", capture)
base.reset()
try:
assert base.get_engine() is sentinel
finally:
base.reset()
assert observed == {
"url": settings.database_url,
"pool_pre_ping": True,
"pool_size": 12,
"max_overflow": 7,
"hide_parameters": True,
}

View file

@ -1,5 +1,6 @@
"""Готовая карточка → учебный звонок бригаде → числовая оценка ДДС."""
import asyncio
import time
from datetime import datetime
from uuid import uuid4
@ -7,17 +8,48 @@ from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.api.http import sessions as sessions_http
from app.api.ws.control import _start
from app.config import get_settings
from app.db.base import get_session
from app.domain.events import Exercise, ScenarioStart, SessionMode
from app.domain.timers import TimerCode
from app.main import app
from app.scenarios import store
from app.session.hub import hub
@pytest.fixture
def client():
with TestClient(app) as test_client:
test_client.post("/api/auth/dev-token")
hub.journal = None
yield test_client
def client(monkeypatch):
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
get_settings.cache_clear()
async def session_override():
# These tests exercise live in-memory sessions; no endpoint below needs
# persistence, but the report route still requires its DB dependency.
yield object()
async def audit_override(*_args, **_kwargs):
return None
monkeypatch.setitem(app.dependency_overrides, get_session, session_override)
async def optional_session_override():
yield None
monkeypatch.setitem(
app.dependency_overrides,
sessions_http.optional_session,
optional_session_override,
)
monkeypatch.setattr(sessions_http, "audit_required", audit_override)
monkeypatch.setattr("app.api.ws.control.audit", audit_override)
try:
with TestClient(app) as test_client:
test_client.post("/api/auth/dev-token")
hub.journal = None
yield test_client
finally:
get_settings.cache_clear()
def wait_for(predicate, timeout=3):
@ -31,23 +63,39 @@ def wait_for(predicate, timeout=3):
def read_until(socket, wanted):
received = []
for _ in range(20):
event = socket.receive_json()
received.append(event["type"])
if event["type"] == wanted:
return event
raise AssertionError(f"событие {wanted} не пришло")
raise AssertionError(f"событие {wanted} не пришло; получены: {received}")
def start(client, exercise="dds", criteria=None, dds_service=None, scenario_id="fire-apartment-l2"):
def start(
client,
exercise="dds",
criteria=None,
dds_service=None,
scenario_id="fire-apartment-l2",
random_scenario_ids=None,
):
session_id = uuid4()
context = client.websocket_connect(f"/ws/control/{session_id}")
control = context.__enter__()
payload = {"type": "scenario.start", "scenario_id": scenario_id,
"trainee": "Иванов", "mode": "training", "exercise": exercise}
payload = {
"type": "scenario.start",
"scenario_id": scenario_id,
"trainee": "Иванов",
"mode": "training",
"exercise": exercise,
}
if criteria is not None:
payload["criteria"] = criteria
if dds_service is not None:
payload["dds_service"] = dds_service
if random_scenario_ids is not None:
payload["random_scenario_ids"] = random_scenario_ids
control.send_json(payload)
wait_for(lambda: hub.get(session_id))
return session_id, context
@ -59,11 +107,18 @@ def complete_phone_call(station, state, expected_phase):
assert greeting["speaker"] == "crew"
read_until(station, "station.state")
if expected_phase == "dispatched":
station.send_json({"type": "phone.brief", "address": state.dispatched_card.address,
"incident": state.scenario_title,
"request": "Прошу подтвердить выезд и сообщить о прибытии"})
station.send_json(
{
"type": "phone.brief",
"address": state.dispatched_card.address,
"incident": state.scenario_title,
"request": "Прошу подтвердить выезд и сообщить о прибытии",
}
)
else:
station.send_json({"type": "phone.check", "text": "Сообщите текущую обстановку по карточке"})
station.send_json(
{"type": "phone.check", "text": "Сообщите текущую обстановку по карточке"}
)
assert read_until(station, "phone.line")["speaker"] == "dispatcher"
assert read_until(station, "phone.line")["speaker"] == "crew"
report = read_until(station, "phone.report")
@ -93,6 +148,183 @@ def test_dds_starts_with_prepared_card_without_call(client):
control.__exit__(None, None, None)
def test_repeated_ack_and_crew_selection_do_not_duplicate_dds_log(client):
session_id, control = start(client)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
crew = snapshot["crew_options"][0]
station.send_json({"type": "card.ack", "comment": "Основание: карточка передана диспетчеру."})
read_until(station, "station.state")
station.send_json({"type": "card.ack", "comment": "Основание: карточка передана диспетчеру."})
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
assert sum(action == "card.ack" for action, *_ in state.dds_log) == 1
station.send_json({"type": "crew.select", "crew": crew})
station.send_json({"type": "zone.decision", "in_zone": True})
read_until(station, "station.state")
assert sum(action == "crew.select" for action, *_ in state.dds_log) == 1
finally:
control.__exit__(None, None, None)
def test_zone_decision_is_one_shot_and_restored_in_station_snapshot(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
initial = read_until(station, "station.state")["snapshot"]
assert initial["zone_decision"] is None
station.send_json({"type": "zone.decision", "in_zone": True})
accepted = read_until(station, "station.state")["snapshot"]
assert accepted["zone_decision"] is True
state = hub.get(session_id)
assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1
station.send_json({"type": "zone.decision", "in_zone": True})
assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1
station.send_json({"type": "zone.decision", "in_zone": False})
error = read_until(station, "error")
assert "уже записано" in error["message"]
assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1
station.close()
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
restored = read_until(station, "station.state")["snapshot"]
assert restored["zone_decision"] is True
finally:
control.__exit__(None, None, None)
def test_server_randomly_selects_only_from_instructor_filtered_card_pool(
client, monkeypatch
):
pool = ["fire-apartment-l2", "t01-1-fire-container"]
monkeypatch.setattr(
"app.api.ws.control.secrets.choice", lambda scenarios: scenarios[-1]
)
session_id, control = start(client, random_scenario_ids=pool)
try:
state = hub.get(session_id)
assert state.scenario_id == "t01-1-fire-container"
assert state.dispatched_card is not None
assert [item.id for item in state.dds_scenarios] == pool[::-1]
assert state.dds_next_scenario_index == 2
assert state.dds_next_arrival_at is None
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
@pytest.mark.parametrize("scenario_id", [
"t01-3-child-other-region",
"t02-2-megafon-consultation",
"t07-2-headache-ryazan",
"t11-3-lost-in-forest",
"t12-2-heart-pain",
"t16-2-child-bicycle-volzhsky",
"t19-1-field-fire",
"t19-2-snake-bite",
"t27-3-wall-crack",
"t29-2-accident-fight",
])
def test_dds_rejects_non_card_outcomes_instead_of_making_fake_cards(
client, monkeypatch, scenario_id
):
scenario = store.get(scenario_id)
assert scenario is not None and scenario.outcome.value in {"consultation", "transfer_region"}
emitted = []
monkeypatch.setattr(
hub, "to_observers", lambda session_id, event: emitted.append(event)
)
session_id = uuid4()
event = ScenarioStart(
scenario_id=scenario.id,
scenario_ids=[scenario.id],
trainee="Иванов",
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
)
asyncio.run(_start(session_id, event))
assert hub.get(session_id) is None
assert emitted[-1].code.value == "scenario_invalid"
assert "готовые карточки" in emitted[-1].message
def test_instructor_live_registry_shows_owned_dds_session_and_deadline_state(client):
session_id, control = start(client)
try:
response = client.get("/api/sessions/active")
assert response.status_code == 200
rows = response.json()
row = next(item for item in rows if item["session_id"] == str(session_id))
assert row["exercise"] == "dds"
assert row["scenario_title"]
assert row["dds_card_total"] == 1
assert row["dds_open_cards"] == 1
assert row["dds_overdue_cards"] == 0
assert row["dds_snapshot"]["queue_cards"][0]["active"] is True
assert row["dds_snapshot"]["queue_cards"][0]["title"]
assert row["dds_snapshot"]["queue_cards"][0]["service_status"] == "added"
assert row["dds_snapshot"]["phone_reports"] == []
state = hub.get(session_id)
live_card = state.dds_live_cards[0]
live_card.timers.on_event("dds.open")
live_card.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 181
overdue_response = client.get("/api/sessions/active")
overdue_row = next(
item
for item in overdue_response.json()
if item["session_id"] == str(session_id)
)
assert overdue_row["dds_work_overdue_cards"] == 1
assert row["dds_statuses"]
finally:
control.__exit__(None, None, None)
def test_instructor_live_registry_includes_current_crew_report(client, monkeypatch):
from app.api import auth
async def keep_test_auth_state_fresh():
auth.prime_generations({})
auth.prime_generations({})
monkeypatch.setattr(auth, "sync_generations", keep_test_auth_state_fresh)
session_id, control = start(client)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
read_until(station, "station.state")
crew = state.crew_options()[0]
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
service = state.crew_service(crew)
station.send_json({
"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята.",
})
read_until(station, "station.state")
report = complete_phone_call(station, state, "dispatched")
rows = client.get("/api/sessions/active").json()
row = next(item for item in rows if item["session_id"] == str(session_id))
saved_report = row["dds_snapshot"]["phone_reports"][0]
assert saved_report["crew"] == report["crew"]
assert saved_report["phase"] == "dispatched"
assert saved_report["text"] == report["text"]
finally:
control.__exit__(None, None, None)
def test_ticket_dds_card_uses_source_caller_identity_and_phone(client):
session_id, control = start(client, scenario_id="t01-1-fire-container")
try:
@ -128,25 +360,47 @@ def test_dds_can_change_only_its_own_service_status(client):
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["services"] == ["МВД"]
assert "Служба 101" in snapshot["recipient_services"]
station.send_json({
"type": "card.status", "service": "Служба 101", "status": "accepted",
})
station.send_json(
{
"type": "card.status",
"service": "Служба 101",
"status": "accepted",
}
)
assert "только своей ДДС" in read_until(station, "error")["message"]
station.send_json({
"type": "card.status", "service": "МВД", "status": "accepted",
})
assert read_until(station, "station.state")["snapshot"]["statuses"]["МВД"] == "accepted"
station.send_json(
{
"type": "card.status",
"service": "МВД",
"status": "accepted",
"comment": "Основание: доклад старшего.\nСведения: карточка для нашей службы.",
}
)
assert (
read_until(station, "station.state")["snapshot"]["statuses"]["МВД"]
== "accepted"
)
finally:
control.__exit__(None, None, None)
def test_instructor_criteria_change_timer_and_success_threshold(client):
session_id, control = start(client, criteria={
"decision_time_limit_seconds": 45,
"allowed_errors": 50,
"require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5},
})
def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypatch):
from app.api import auth
async def keep_test_auth_state_fresh():
auth.prime_generations({})
auth.prime_generations({})
monkeypatch.setattr(auth, "sync_generations", keep_test_auth_state_fresh)
session_id, control = start(
client,
criteria={
"decision_time_limit_seconds": 45,
"allowed_errors": 50,
"require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5},
},
)
try:
state = hub.get(session_id)
assert state.criteria.decision_time_limit_seconds == 45
@ -155,11 +409,30 @@ def test_instructor_criteria_change_timer_and_success_threshold(client):
card = read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
station.send_json(
{"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята."}
)
read_until(station, "station.state")
station.send_json({"type": "card.reply", "card_id": card["card"]["card_id"],
"text": "Сообщение приняты, бригада направлено."})
station.send_json(
{
"type": "card.reply",
"card_id": card["card"]["card_id"],
"text": "Сообщение приняты, бригада направлено.",
}
)
read_until(station, "station.state")
# Simulate the acknowledgement being lost during reconnect. The
# buffered replacement may be replayed, but its journal is unique.
station.send_json(
{
"type": "card.reply",
"card_id": card["card"]["card_id"],
"text": "Сообщение приняты, бригада направлено.",
}
)
read_until(station, "station.state")
assert len(state.reply_log) == 1
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
@ -167,10 +440,20 @@ def test_instructor_criteria_change_timer_and_success_threshold(client):
ack = next(item for item in score["metrics"] if item["key"] == "dds_ack")
assert ack["norm"] == "≤ 45 с"
assert ack["weight"] == 3.5
assert not any(item["key"] in {"dds_reply", "dds_grammar"} for item in score["metrics"])
reply_metric = next(item for item in score["metrics"] if item["key"] == "dds_reply")
assert reply_metric["passed"]
assert not any(item["key"] == "dds_grammar" for item in score["metrics"])
assert (
next(item for item in score["metrics"] if item["key"] == "dds_work_time")[
"passed"
]
is False
)
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["criteria"] == {
"decision_time_limit_seconds": 45,
"card_fill_time_limit_seconds": 180,
"dds_card_work_time_limit_seconds": 180,
"allowed_errors": 50,
"require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5},
@ -180,6 +463,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client):
finally:
control.__exit__(None, None, None)
def test_dds_statuses_do_not_require_phone_reports(client):
session_id, control = start(client)
try:
@ -187,19 +471,100 @@ def test_dds_statuses_do_not_require_phone_reports(client):
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
station.send_json(
{"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята."}
)
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": service, "status": "responding"})
assert read_until(station, "station.state")["snapshot"]["statuses"][service] == "responding"
station.send_json(
{"type": "card.status", "service": service, "status": "responding",
"comment": "Основание: доклад старшего.\nСведения: начало реагирования подтверждено."}
)
assert (
read_until(station, "station.state")["snapshot"]["statuses"][service]
== "responding"
)
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
keys = {metric["key"] for metric in score["metrics"]}
assert "dds_primary" in keys and "dds_progress" in keys
assert "dds_contact" not in keys and "dds_crew" not in keys
crew_metric = next(
metric for metric in score["metrics"] if metric["key"] == "dds_crew"
)
assert not crew_metric["passed"]
assert "dds_contact" not in keys
assert "answer_time" not in keys and "interview_time" not in keys
assert 0 < score["score_auto"] < 100
assert all(not finding["code"].startswith("E") for finding in score["findings"])
assert any(
finding["code"] == "D2" and "бригады" in finding["summary"]
for finding in score["findings"]
)
assert any(
finding["code"] == "E3" and "времени отработки" in finding["summary"]
for finding in score["findings"]
)
finding_codes = {finding["code"] for finding in score["findings"]}
penalty_codes = {
"dds_primary": {"D1"},
"dds_ack": {"D1"},
"dds_decision": {"D2", "D3"},
"dds_crew": {"D2"},
"dds_progress": {"D6"},
"dds_completion": {"D6"},
"dds_reply": {"D5"},
"dds_work_time": {"E3"},
}
unexplained = [
metric["key"]
for metric in score["metrics"]
if not metric["passed"]
and not penalty_codes.get(metric["key"], set()).intersection(finding_codes)
]
assert not unexplained, f"проваленные метрики без кода и пояснения: {unexplained}"
finally:
control.__exit__(None, None, None)
def test_dds_d5_comment_is_explanatory_and_does_not_change_numeric_score(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
card = read_until(station, "card.received")["card"]
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
crew = next(
option
for option in snapshot["crew_options"]
if option.startswith(service + " — ")
)
station.send_json(
{"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята."}
)
read_until(station, "station.state")
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
for status in ("responding", "arrived", "working", "completed"):
station.send_json(
{"type": "card.status", "service": service, "status": status,
"comment": f"Основание: доклад ответственной службы.\nСведения: этап {status}."}
)
read_until(station, "station.state")
station.send_json(
{
"type": "card.reply",
"card_id": card["card_id"],
"text": "Все принято.",
}
)
read_until(station, "station.state")
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
assert score["score_auto"] == 100.0
d5 = next(finding for finding in score["findings"] if finding["code"] == "D5")
assert "получателя" in d5["summary"]
finally:
control.__exit__(None, None, None)
@ -211,8 +576,15 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
crew = next(option for option in snapshot["crew_options"] if option.startswith(service + " — "))
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
crew = next(
option
for option in snapshot["crew_options"]
if option.startswith(service + " — ")
)
station.send_json(
{"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
)
read_until(station, "station.state")
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
@ -221,18 +593,37 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
pending = read_until(station, "station.state")["snapshot"]
assert pending["phone_pending"]["phase"] == "dispatched"
assert not pending["phone_reports"]
station.send_json({"type": "card.status", "service": service, "status": "responding"})
assert read_until(station, "station.state")["snapshot"]["statuses"][service] == "responding"
station.send_json({"type": "phone.brief", "address": "другая улица, дом 99",
"incident": "Пожар в квартире",
"request": "Прошу направить бригаду"})
station.send_json(
{"type": "card.status", "service": service, "status": "responding",
"comment": "Основание: доклад старшего.\nСведения: начало реагирования подтверждено."}
)
assert (
read_until(station, "station.state")["snapshot"]["statuses"][service]
== "responding"
)
station.send_json(
{
"type": "phone.brief",
"address": "другая улица, дом 99",
"incident": "Пожар в квартире",
"request": "Прошу направить бригаду",
}
)
assert "адрес" in read_until(station, "error")["message"]
station.send_json({"type": "phone.brief", "address": "улица Ленина, 14",
"incident": "Ничего не произошло",
"request": "Прошу направить бригаду"})
station.send_json(
{
"type": "phone.brief",
"address": "улица Ленина, 14",
"incident": "Ничего не произошло",
"request": "Прошу направить бригаду",
}
)
assert "характер" in read_until(station, "error")["message"]
station.send_json({"type": "phone.hangup"})
assert read_until(station, "station.state")["snapshot"]["phone_pending"] is None
assert (
read_until(station, "station.state")["snapshot"]["phone_pending"]
is None
)
assert not hub.get(session_id).phone_reports
complete_phone_call(station, hub.get(session_id), "dispatched")
read_until(station, "station.state")
@ -240,7 +631,9 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
station.send_json({"type": "phone.dial"})
read_until(station, "phone.line")
read_until(station, "station.state")
station.send_json({"type": "phone.check", "text": "Здравствуйте, хорошая погода"})
station.send_json(
{"type": "phone.check", "text": "Здравствуйте, хорошая погода"}
)
assert "обстановку" in read_until(station, "error")["message"]
assert len(hub.get(session_id).phone_reports) == 1
finally:
@ -250,24 +643,43 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
def test_default_exercise_remains_call(client):
session_id = uuid4()
with client.websocket_connect(f"/ws/control/{session_id}") as control:
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Иванов", "mode": "training"})
control.send_json(
{
"type": "scenario.start",
"scenario_id": "fire-apartment-l2",
"trainee": "Иванов",
"mode": "training",
}
)
state = wait_for(lambda: hub.get(session_id))
assert state.exercise.value == "call"
assert state.dispatched_card is None
def test_complete_dds_workflow_scores_100_without_any_call(client):
def test_complete_dds_workflow_scores_100_with_assignment_without_call(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
for service in snapshot["services"]:
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
station.send_json(
{"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
)
read_until(station, "station.state")
crew = next(
option
for option in snapshot["crew_options"]
if option.startswith(service + " — ")
)
station.send_json({"type": "crew.select", "crew": crew})
snapshot = read_until(station, "station.state")["snapshot"]
for status in ("responding", "arrived", "working", "completed"):
station.send_json({"type": "card.status", "service": service, "status": status})
station.send_json(
{"type": "card.status", "service": service, "status": status,
"comment": f"Основание: доклад старшего.\nСведения: этап {status}."}
)
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["card"] == "completed"
station.send_json({"type": "station.finish"})
@ -276,8 +688,115 @@ def test_complete_dds_workflow_scores_100_without_any_call(client):
assert score["score_auto"] == 100.0
assert not score["findings"]
assert all(metric["key"].startswith("dds_") for metric in score["metrics"])
assert not any(metric["key"] in {"dds_contact", "dds_crew", "dds_reply", "dds_grammar"}
for metric in score["metrics"])
crew_metric = next(
metric for metric in score["metrics"] if metric["key"] == "dds_crew"
)
assert crew_metric["passed"]
assert next(
metric for metric in score["metrics"] if metric["key"] == "dds_work_time"
)["passed"]
assert not any(
metric["key"] in {"dds_contact", "dds_grammar"}
for metric in score["metrics"]
)
finally:
control.__exit__(None, None, None)
def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client):
session_id, control = start(
client, criteria={"dds_card_work_time_limit_seconds": 60}
)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
station.send_json(
{"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
)
snapshot = read_until(station, "station.state")["snapshot"]
state.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61
crew = next(
option
for option in snapshot["crew_options"]
if option.startswith(service + " — ")
)
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
for status in ("responding", "arrived", "working", "completed"):
station.send_json(
{"type": "card.status", "service": service, "status": status,
"comment": f"Основание: доклад старшего.\nСведения: этап {status}."}
)
read_until(station, "station.state")
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: state.score)
metric = next(
item for item in score["metrics"] if item["key"] == "dds_work_time"
)
assert not metric["passed"]
assert "61 с" in metric["fact"] and "+1 с" in metric["fact"]
finding = next(item for item in score["findings"] if item["code"] == "E3")
assert "времени отработки карточки" in finding["summary"]
finally:
control.__exit__(None, None, None)
def test_complete_dds_workflow_with_training_calls_and_status_updates(client):
session_id, control = start(client)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
crew = next(
option
for option in snapshot["crew_options"]
if option.startswith(service + " — ")
)
station.send_json(
{"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
)
read_until(station, "station.state")
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
complete_phone_call(station, state, "dispatched")
read_until(station, "station.state")
for status, phase in (
("responding", "arrived"),
("arrived", "working"),
("working", "completed"),
("completed", None),
):
station.send_json(
{"type": "card.status", "service": service, "status": status,
"comment": f"Основание: доклад старшего.\nСведения: этап {status}."}
)
read_until(station, "station.state")
if phase:
complete_phone_call(station, state, phase)
read_until(station, "station.state")
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: state.score)
assert score["score_auto"] == 100.0
assert [report.phase for report in state.phone_reports] == [
"dispatched",
"arrived",
"working",
"completed",
]
assert all(report.crew == crew for report in state.phone_reports)
assert not score["findings"]
finally:
control.__exit__(None, None, None)
@ -286,12 +805,17 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
session_id = uuid4()
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
control = control_ctx.__enter__()
control.send_json({
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
"trainee": "Иванов", "mode": "training", "exercise": "dds",
})
wait_for(lambda: hub.get(session_id))
control.send_json(
{
"type": "scenario.start",
"scenario_id": "fire-apartment-l2",
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
"trainee": "Иванов",
"mode": "training",
"exercise": "dds",
}
)
state = wait_for(lambda: hub.get(session_id))
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
first = read_until(station, "card.received")
@ -302,16 +826,30 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
first_row, second_row = snapshot["queue_cards"]
second_card_id = second_row["card_id"]
assert first_row["active"] is True and second_row["active"] is False
assert abs(
datetime.fromisoformat(first_row["received_at"]).timestamp()
- datetime.fromisoformat(second_row["received_at"]).timestamp()
) < 1
first_live = next(
item
for item in state.dds_live_cards
if str(item.card_id) == first_card_id
)
assert TimerCode.DDS_WORK not in first_live.timers.timers
assert (
abs(
datetime.fromisoformat(first_row["received_at"]).timestamp()
- datetime.fromisoformat(second_row["received_at"]).timestamp()
)
< 1
)
first_service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": first_service,
"status": "accepted"})
station.send_json(
{"type": "card.status", "service": first_service, "status": "accepted",
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
)
snapshot = read_until(station, "station.state")["snapshot"]
first_elapsed = next(item for item in snapshot["queue_cards"]
if item["card_id"] == first_card_id)["elapsed_ms"]
first_elapsed = next(
item
for item in snapshot["queue_cards"]
if item["card_id"] == first_card_id
)["elapsed_ms"]
time.sleep(0.03)
# Card switching must publish its own fresh station snapshot; do
@ -320,28 +858,48 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
station.send_json({"type": "card.open", "card_id": second_card_id})
second = read_until(station, "card.received")
assert second["card"]["card_id"] == second_card_id
second_live = next(
item
for item in state.dds_live_cards
if str(item.card_id) == second_card_id
)
assert TimerCode.DDS_WORK in second_live.timers.timers
assert second_live.timers.timers[TimerCode.DDS_WORK].started_at is not None
assert second["card"]["incident_type"] == "medical"
assert (second["card_index"], second["card_total"]) == (2, 2)
snapshot = read_until(station, "station.state")["snapshot"]
second_queue_row = next(item for item in snapshot["queue_cards"]
if item["card_id"] == second_card_id)
second_queue_row = next(
item
for item in snapshot["queue_cards"]
if item["card_id"] == second_card_id
)
assert second_queue_row["active"] is True
assert second_queue_row["elapsed_ms"] >= first_elapsed
first_queue_row = next(item for item in snapshot["queue_cards"]
if item["card_id"] == first_card_id)
first_queue_row = next(
item
for item in snapshot["queue_cards"]
if item["card_id"] == first_card_id
)
assert first_queue_row["service_status"] == "accepted"
assert first_queue_row["timer_stopped"] is True
second_service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": second_service,
"status": "accepted"})
station.send_json(
{"type": "card.status", "service": second_service, "status": "accepted",
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
)
read_until(station, "station.state")
station.send_json({"type": "card.open", "card_id": first_card_id})
assert read_until(station, "card.received")["card"]["card_id"] == first_card_id
assert (
read_until(station, "card.received")["card"]["card_id"] == first_card_id
)
restored = read_until(station, "station.state")["snapshot"]
assert restored["statuses"][first_service] == "accepted"
station.send_json({"type": "card.next", "card_id": first_card_id})
assert read_until(station, "card.received")["card"]["card_id"] == second_card_id
assert (
read_until(station, "card.received")["card"]["card_id"]
== second_card_id
)
after_close = read_until(station, "station.state")["snapshot"]
assert len(after_close["queue_cards"]) == 1
assert after_close["statuses"][second_service] == "accepted"
@ -354,9 +912,11 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2"
assert state.score["card_results"][1]["scenario_id"] == "t20-2-stroke"
assert "dds_primary" in {item["key"] for item in state.score["metrics"]}
assert not any(item["key"] in {"dds_reply", "dds_grammar"}
for item in state.score["metrics"])
assert all(item["code"].startswith("D") for item in state.score["findings"])
assert not any(
item["key"] == "dds_grammar"
for item in state.score["metrics"]
)
assert any(item["code"] == "E3" for item in state.score["findings"])
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["scenario_id"] == "fire-apartment-l2"
assert len(report["card_results"]) == 2
@ -372,11 +932,16 @@ def test_instructor_end_grades_all_concurrently_issued_cards(client):
session_id = uuid4()
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
control = control_ctx.__enter__()
control.send_json({
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
"trainee": "Иванов", "mode": "training", "exercise": "dds",
})
control.send_json(
{
"type": "scenario.start",
"scenario_id": "fire-apartment-l2",
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
"trainee": "Иванов",
"mode": "training",
"exercise": "dds",
}
)
wait_for(lambda: hub.get(session_id))
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
@ -388,7 +953,8 @@ def test_instructor_end_grades_all_concurrently_issued_cards(client):
assert state.ended
assert len(state.score["card_results"]) == 2
assert [item["scenario_id"] for item in state.score["card_results"]] == [
"fire-apartment-l2", "t20-2-stroke",
"fire-apartment-l2",
"t20-2-stroke",
]
finally:
control_ctx.__exit__(None, None, None)
@ -410,8 +976,12 @@ def test_each_dds_card_uses_its_own_scenario_weights():
first.score_weights = {"dds_primary": 7.0}
second.score_weights = {"dds_primary": 2.0}
state = SessionState(
session_id=uuid4(), scenario_id=base.id, scenario_title=base.title,
level=base.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
session_id=uuid4(),
scenario_id=base.id,
scenario_title=base.title,
level=base.level.value,
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
dds_scenarios=[first, second],
)
prepare_card(state, first)
@ -419,5 +989,11 @@ def test_each_dds_card_uses_its_own_scenario_weights():
state.dds_card_index = 1
prepare_card(state, second)
second_record = score_current_dds(state)
assert next(item.weight for item in first_record.metrics if item.key == "dds_primary") == 7.0
assert next(item.weight for item in second_record.metrics if item.key == "dds_primary") == 2.0
assert (
next(item.weight for item in first_record.metrics if item.key == "dds_primary")
== 7.0
)
assert (
next(item.weight for item in second_record.metrics if item.key == "dds_primary")
== 2.0
)

View file

@ -53,8 +53,9 @@ def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch)
{"id": str(DEMO_TRAINEE_ID), "name": "Демо-курсант", "group": None,
"service": "Служба 101"}
]
# БД-зависимые экраны получают быстрый и явный отказ, не ждут TCP timeout.
assert client.get("/api/sessions").json()["detail"] == "database_disabled_demo"
# Пустая volatile-история доступна в демо без PostgreSQL.
assert client.get("/api/sessions").status_code == 200
assert client.get("/api/sessions").json() == []
session_id = uuid4()
with client.websocket_connect(f"/ws/control/{session_id}") as control:
@ -65,6 +66,9 @@ def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch)
})
state = _wait_for(lambda: hub.get(session_id))
assert state.trainee_id == DEMO_TRAINEE_ID
listed = client.get("/api/sessions").json()
assert len(listed) == 1 and listed[0]["session_id"] == str(session_id)
assert listed[0]["scenario_id"] == "fire-apartment-l2"
who = client.post("/api/auth/login", json={
"login": "demo-trainee", "password": "demo"
}).json()

View file

@ -0,0 +1,397 @@
from __future__ import annotations
from types import SimpleNamespace
from uuid import UUID
import pytest
from app.config import Settings
from app.db.models import AuditLog, Trainee, User
from app.directory import (
DirectoryDenied,
DirectoryUnavailable,
_authenticate_sync,
map_groups,
)
from app.domain.roles import Role
def test_directory_role_and_service_mappings_are_explicit_and_unambiguous():
roles = {"CN=LCT Trainees,DC=training,DC=lan": "trainee"}
services = {"CN=DDS 01,DC=training,DC=lan": "01"}
assert map_groups(
["cn=dds 01,dc=training,dc=lan", "cn=lct trainees,dc=training,dc=lan"],
roles,
services,
) == (Role.TRAINEE, "01")
with pytest.raises(DirectoryDenied):
map_groups([], roles, services)
with pytest.raises(DirectoryDenied):
map_groups(
[
"CN=LCT Trainees,DC=training,DC=lan",
"CN=Other Trainees,DC=training,DC=lan",
],
{
"CN=LCT Trainees,DC=training,DC=lan": "trainee",
"CN=Other Trainees,DC=training,DC=lan": "instructor",
},
{},
)
with pytest.raises(DirectoryDenied):
map_groups(
[
"CN=LCT Trainees,DC=training,DC=lan",
"CN=DDS 01,DC=training,DC=lan",
"CN=DDS 02,DC=training,DC=lan",
],
roles,
{
"CN=DDS 01,DC=training,DC=lan": "01",
"CN=DDS 02,DC=training,DC=lan": "02",
},
)
def test_directory_role_mapping_rejects_unknown_privilege_names():
with pytest.raises(DirectoryUnavailable, match="invalid application role"):
map_groups(
["CN=LCT Admins,DC=training,DC=lan"],
{"CN=LCT Admins,DC=training,DC=lan": "superuser"},
{},
)
@pytest.mark.parametrize(
"url, expected_tls",
[
("ldaps://dc.training.lan:636", "ldaps"),
("ldap://dc.training.lan:389", "starttls"),
],
)
def test_directory_search_then_user_bind_uses_tls_and_escapes_login(
monkeypatch, url, expected_tls
):
import ldap3
calls = []
class Attribute:
def __init__(self, value=None, values=None):
self.value = value
self.values = values or []
entry = SimpleNamespace(
entry_dn="CN=Training User,OU=People,DC=training,DC=lan",
sAMAccountName=Attribute("Training.User"),
displayName=Attribute("Учебный пользователь"),
memberOf=Attribute(values=["CN=LCT Trainees,DC=training,DC=lan"]),
objectGUID=Attribute(bytes(range(16))),
entryUUID=Attribute(None),
)
class FakeServer:
def __init__(self, host, **kwargs):
calls.append(("server", host, kwargs))
class FakeConnection:
def __init__(self, server, **kwargs):
calls.append(("connection", kwargs))
self.entries = [entry]
self.bound = False
self.result = {"result": 0}
def open(self):
calls.append(("open",))
return True
def start_tls(self):
calls.append(("start_tls",))
return True
def bind(self):
calls.append(("service_bind",))
self.bound = True
return True
def search(self, **kwargs):
calls.append(("search", kwargs))
return True
def rebind(self, user, password):
calls.append(("user_bind", user, password))
self.bound = password == "correct-password"
self.result = {"result": 0 if self.bound else 49}
return self.bound
def unbind(self):
calls.append(("unbind",))
monkeypatch.setattr(ldap3, "Server", FakeServer)
monkeypatch.setattr(ldap3, "Connection", FakeConnection)
monkeypatch.setattr(
ldap3, "Tls", lambda **kwargs: calls.append(("tls", kwargs)) or object()
)
settings = Settings(
ldap_enabled=True,
ldap_url=url,
ldap_base_dn="DC=training,DC=lan",
ldap_bind_dn="CN=Reader,DC=training,DC=lan",
ldap_bind_password="service-secret",
ldap_role_groups={"CN=LCT Trainees,DC=training,DC=lan": "trainee"},
)
result = _authenticate_sync("Training.*(User", "correct-password", settings)
assert result.login == "training.user"
assert result.role is Role.TRAINEE
assert result.subject == "03020100-0504-0706-0809-0a0b0c0d0e0f"
search_call = next(call for call in calls if call[0] == "search")
assert r"Training.\2a\28User" in search_call[1]["search_filter"]
user_bind = next(call for call in calls if call[0] == "user_bind")
assert user_bind[1] == entry.entry_dn
if expected_tls == "starttls":
assert calls.index(("start_tls",)) < calls.index(("service_bind",))
else:
server_call = next(call for call in calls if call[0] == "server")
assert server_call[2]["use_ssl"] is True
assert not any(call[0] == "start_tls" for call in calls)
def test_invalid_directory_password_is_denied(monkeypatch):
import ldap3
class Attribute:
def __init__(self, value=None, values=None):
self.value = value
self.values = values or []
entry = SimpleNamespace(
entry_dn="CN=Training User,DC=training,DC=lan",
sAMAccountName=Attribute("trainee"),
displayName=Attribute("Trainee"),
memberOf=Attribute(values=["CN=LCT Trainees,DC=training,DC=lan"]),
objectGUID=Attribute("stable-guid"),
entryUUID=Attribute(None),
)
class FakeConnection:
def __init__(self, *args, **kwargs):
self.entries = [entry]
self.bound = False
self.result = {"result": 0}
def open(self):
return True
def bind(self):
self.bound = True
return True
def search(self, **kwargs):
return True
def rebind(self, user, password):
self.bound = False
self.result = {"result": 49}
return False
def unbind(self):
pass
monkeypatch.setattr(ldap3, "Server", lambda *args, **kwargs: object())
monkeypatch.setattr(ldap3, "Connection", FakeConnection)
monkeypatch.setattr(ldap3, "Tls", lambda **kwargs: object())
settings = Settings(
ldap_enabled=True,
ldap_url="ldaps://dc.training.lan",
ldap_base_dn="DC=training,DC=lan",
ldap_bind_dn="CN=Reader,DC=training,DC=lan",
ldap_bind_password="service-secret",
ldap_role_groups={"CN=LCT Trainees,DC=training,DC=lan": "trainee"},
)
with pytest.raises(DirectoryDenied, match="invalid directory credentials"):
_authenticate_sync("trainee", "wrong-password", settings)
def test_directory_account_without_stable_identifier_is_rejected(monkeypatch):
import ldap3
class Attribute:
def __init__(self, value=None, values=None):
self.value = value
self.values = values or []
entry = SimpleNamespace(
entry_dn="CN=Training User,DC=training,DC=lan",
sAMAccountName=Attribute("trainee"),
displayName=Attribute("Trainee"),
memberOf=Attribute(values=["CN=LCT Trainees,DC=training,DC=lan"]),
objectGUID=Attribute(None),
entryUUID=Attribute(None),
)
class FakeConnection:
def __init__(self, *args, **kwargs):
self.entries = [entry]
def open(self):
return True
def bind(self):
return True
def search(self, **kwargs):
return True
def unbind(self):
pass
monkeypatch.setattr(ldap3, "Server", lambda *args, **kwargs: object())
monkeypatch.setattr(ldap3, "Connection", FakeConnection)
monkeypatch.setattr(ldap3, "Tls", lambda **kwargs: object())
settings = Settings(
ldap_enabled=True,
ldap_url="ldaps://dc.training.lan",
ldap_base_dn="DC=training,DC=lan",
ldap_bind_dn="CN=Reader,DC=training,DC=lan",
ldap_bind_password="service-secret",
ldap_role_groups={"CN=LCT Trainees,DC=training,DC=lan": "trainee"},
)
with pytest.raises(DirectoryUnavailable, match="objectGUID or entryUUID"):
_authenticate_sync("trainee", "correct-password", settings)
@pytest.mark.asyncio
async def test_directory_account_is_jit_provisioned_and_role_sync_revokes_sessions(
monkeypatch,
):
from app.api import auth
class FakeDb:
user = None
trainee = None
audits = []
async def scalar(self, _query):
return self.user
def add(self, row):
if isinstance(row, Trainee):
row.id = UUID("00000000-0000-4000-8000-000000000321")
self.trainee = row
elif isinstance(row, User):
self.user = row
elif isinstance(row, AuditLog):
self.audits.append(row)
async def get(self, model, _key):
return self.trainee if model is Trainee else None
async def flush(self):
pass
async def commit(self):
pass
async def rollback(self):
pass
async def refresh(self, _row):
pass
class Context:
def __init__(self, db):
self.db = db
async def __aenter__(self):
return self.db
async def __aexit__(self, *_args):
return None
fake_db = FakeDb()
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: Context(fake_db))
identity = SimpleNamespace(
login="trainee.one",
full_name="Курсант Один",
role=Role.TRAINEE,
service="01",
subject="stable-object-guid",
)
user = await auth._directory_account(identity)
assert user.auth_provider == "ldap"
assert user.directory_subject == "stable-object-guid"
assert user.role == "trainee"
assert user.service == "01"
assert user.trainee_id == UUID("00000000-0000-4000-8000-000000000321")
assert user.password_hash != "correct-password"
assert [row.action for row in fake_db.audits] == ["user.provision.ldap"]
auth._generations[user.login] = user.auth_version
identity = SimpleNamespace(
**{**vars(identity), "full_name": "Курсант Одинов", "service": "02"}
)
updated = await auth._directory_account(identity)
assert updated.auth_version == 1
assert updated.full_name == "Курсант Одинов"
assert updated.service == "02"
assert [row.action for row in fake_db.audits] == [
"user.provision.ldap", "user.sync.ldap",
]
assert auth._generations[user.login] == 0 # persistent value is loaded at login
@pytest.mark.asyncio
async def test_blocked_directory_account_is_returned_without_directory_sync(monkeypatch):
from app.api import auth
user = User(
id=UUID("00000000-0000-4000-8000-000000000987"),
login="trainee.one",
full_name="Старое имя",
role="trainee",
service="01",
trainee_id=None,
blocked=True,
password_hash="unused",
auth_provider="ldap",
directory_subject="stable-object-guid",
auth_version=4,
)
class FakeDb:
commits = 0
async def scalar(self, _query):
return user
async def commit(self):
self.commits += 1
class Context:
def __init__(self, db):
self.db = db
async def __aenter__(self):
return self.db
async def __aexit__(self, *_args):
return None
fake_db = FakeDb()
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: Context(fake_db))
identity = SimpleNamespace(
login="trainee.one",
full_name="Новое имя из каталога",
role=Role.ADMIN,
service=None,
subject="stable-object-guid",
)
returned = await auth._directory_account(identity)
assert returned is user
assert user.full_name == "Старое имя"
assert user.role == "trainee"
assert user.auth_version == 4
assert fake_db.commits == 0

View file

@ -0,0 +1,282 @@
from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
from pydantic import ValidationError
from app.domain.statuses import ServiceStatus, StatusEntry
from app.domain.taxonomy import ErrorCode
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
def test_late_primary_status_has_d1_finding_even_when_status_exists():
findings = evaluate_dispatcher(
entries=[StatusEntry(
service="Служба 101",
status=ServiceStatus.ACCEPTED,
at=datetime.now(timezone.utc),
)],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=31_000,
)
d1 = next(finding for finding in findings if finding.code is ErrorCode.D1)
assert "31 с" in d1.fact
def test_primary_status_within_deadline_does_not_have_d1_finding():
findings = evaluate_dispatcher(
entries=[StatusEntry(
service="Служба 101",
status=ServiceStatus.ACCEPTED,
at=datetime.now(timezone.utc),
)],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=30_000,
)
assert ErrorCode.D1 not in [finding.code for finding in findings]
def test_d5_flags_comment_without_recipient_but_not_a_named_crew():
at = datetime.now(timezone.utc)
base = StatusEntry(
service="Служба 101",
status=ServiceStatus.ACCEPTED,
at=at,
comment="Основание: не обслуживаем.\nСведения: информация передана.",
)
findings = evaluate_dispatcher(
entries=[base],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=1_000,
)
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
assert "получателя" in d5.summary
assert "передана" in d5.fact
complete = evaluate_dispatcher(
entries=[base.model_copy(update={"comment": "Основание: принято.\nСведения: карточка передана бригаде 12."})],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=1_000,
)
assert ErrorCode.D5 not in [finding.code for finding in complete]
def test_recipient_in_one_status_comment_does_not_mask_another_status_comment():
at = datetime.now(timezone.utc)
entries = [
StatusEntry(
service="Служба 101", status=ServiceStatus.ACCEPTED,
at=at, comment="Основание: карточка принята.\nСведения: переданы бригаде 12.",
),
StatusEntry(
service="Служба 101", status=ServiceStatus.RESPONDING,
at=at, comment="Основание: доклад.\nСведения: подтверждено начало движения.",
),
]
findings = evaluate_dispatcher(
entries=entries,
services=["Служба 101"],
crew_assignments={"Служба 101": "Бригада 12"},
deadline_ms=30_000,
elapsed_ms=1_000,
)
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
assert "Начало реагирования" in d5.fact
assert "Бригаде 12 переданы сведения" not in d5.fact
def test_missing_manual_status_comment_has_a_d5_finding():
findings = evaluate_dispatcher(
entries=[StatusEntry(
service="Служба 101",
status=ServiceStatus.ACCEPTED,
at=datetime.now(timezone.utc),
)],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=1_000,
)
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
assert "не заполнены комментарии" in d5.fact
def test_each_manual_status_comment_is_part_of_the_numeric_reply_metric():
at = datetime.now(timezone.utc)
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"],
status_log=[
StatusEntry(
service="Служба 101", status=ServiceStatus.ACCEPTED,
at=at, comment="Основание: карточка принята.\nСведения: принято в работу.",
),
StatusEntry(
service="Служба 101", status=ServiceStatus.RESPONDING,
at=at, comment="",
),
],
crew_assignments={"Служба 101": "Бригада 1"},
dispatched_at=at,
)
reply = next(
metric for metric in dispatcher_metrics(state, 30_000)
if metric.key == "dds_reply"
)
assert not reply.passed
assert "к каждой ручной отметке" in reply.norm
findings = evaluate_dispatcher(
entries=state.status_log,
services=["Служба 101"],
crew_assignments=state.crew_assignments,
deadline_ms=30_000,
elapsed_ms=0,
)
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
assert "Начало реагирования" in d5.fact
def test_d5_does_not_treat_address_house_number_as_recipient():
entry = StatusEntry(
service="Служба 101",
status=ServiceStatus.ACCEPTED,
at=datetime.now(timezone.utc),
comment="Основание: доклад.\nСведения: в доме 101 проведён осмотр.",
)
findings = evaluate_dispatcher(
entries=[entry],
services=["Служба 101"],
deadline_ms=30_000,
elapsed_ms=1_000,
)
assert ErrorCode.D5 in [finding.code for finding in findings]
def test_scenario_may_define_a_valid_decline_for_duplicate_or_territory():
at = datetime.now(timezone.utc)
entries = [StatusEntry(
service="Служба 101", status=ServiceStatus.DECLINED, at=at,
comment="Основание: дубль.\nСведения: передано в дежурную часть.",
)]
findings = evaluate_dispatcher(
entries=entries, services=["Служба 101"], deadline_ms=30_000,
elapsed_ms=1_000, expected_decision="decline",
expected_decision_reason="дублирующая карточка",
)
assert ErrorCode.D3 not in [finding.code for finding in findings]
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"], status_log=entries,
crew_assignments={}, dispatched_at=at,
)
metrics = dispatcher_metrics(
state, 30_000, expected_decision="decline",
expected_decision_reason="дублирующая карточка",
)
decision = next(metric for metric in metrics if metric.key == "dds_decision")
assert decision.passed
assert "дублирующая карточка" in decision.norm
def test_nonempty_unstructured_comment_fails_reply_metric_but_structured_passes():
at = datetime.now(timezone.utc)
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"],
status_log=[StatusEntry(
service="Служба 101", status=ServiceStatus.ACCEPTED,
at=at, comment="бригада на связи",
)],
crew_assignments={"Служба 101": "Бригада 12"},
dispatched_at=at,
)
reply = next(m for m in dispatcher_metrics(state, 30_000) if m.key == "dds_reply")
assert not reply.passed
findings = evaluate_dispatcher(
entries=state.status_log, services=["Служба 101"],
crew_assignments=state.crew_assignments,
deadline_ms=30_000, elapsed_ms=0,
)
assert any("не разделяют основание и сведения" in f.summary for f in findings)
state.status_log[0] = state.status_log[0].model_copy(update={
"comment": "Основание: доклад старшего.\nСведения: бригада на связи.",
})
reply = next(m for m in dispatcher_metrics(state, 30_000) if m.key == "dds_reply")
assert reply.passed
def test_accepting_card_with_scenario_expected_decline_is_explained():
findings = evaluate_dispatcher(
entries=[StatusEntry(
service="Служба 101", status=ServiceStatus.ACCEPTED,
at=datetime.now(timezone.utc),
)],
services=["Служба 101"], deadline_ms=30_000, elapsed_ms=1_000,
expected_decision="decline", expected_decision_reason="не наша территория",
)
decision_finding = next(
finding for finding in findings
if finding.code is ErrorCode.D2 and "вопреки эталону" in finding.summary
)
assert decision_finding.norm == "не наша территория"
def test_scenario_decline_expectation_requires_an_explicit_reason():
from app.scenarios.schema import DdsDecision
with pytest.raises(ValidationError, match="reason обязателен"):
DdsDecision(expected="decline")
assert DdsDecision(expected="decline", reason="дубль").expected == "decline"
def test_incomplete_work_path_has_d6_for_failed_progress_metrics():
at = datetime.now(timezone.utc)
entries = [
StatusEntry(service="Служба 101", status=ServiceStatus.ACCEPTED, at=at),
StatusEntry(service="Служба 101", status=ServiceStatus.RESPONDING, at=at),
]
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"], status_log=entries,
crew_assignments={"Служба 101": "Бригада 1"}, dispatched_at=at,
)
metrics = dispatcher_metrics(state, 30_000)
assert not next(item for item in metrics if item.key == "dds_progress").passed
assert not next(item for item in metrics if item.key == "dds_completion").passed
findings = evaluate_dispatcher(
entries=entries, services=["Служба 101"],
crew_assignments={"Служба 101": "Бригада 1"},
deadline_ms=30_000, elapsed_ms=0,
)
d6 = next(finding for finding in findings if finding.code is ErrorCode.D6)
assert "Прибытие" in d6.fact and "Проведение работ" in d6.fact
def test_reasoned_refusal_after_acceptance_is_a_valid_terminal_path():
at = datetime.now(timezone.utc)
entries = [
StatusEntry(service="Служба 101", status=ServiceStatus.ACCEPTED, at=at),
StatusEntry(
service="Служба 101", status=ServiceStatus.REFUSED, at=at,
comment="Бригаде переданы сведения, выезд не выполнялся по причине угрозы.",
),
]
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"], status_log=entries,
crew_assignments={"Служба 101": "Бригада 1"}, dispatched_at=at,
)
metrics = dispatcher_metrics(state, 30_000)
assert next(item for item in metrics if item.key == "dds_progress").passed
assert next(item for item in metrics if item.key == "dds_completion").passed
findings = evaluate_dispatcher(
entries=entries, services=["Служба 101"],
crew_assignments={"Служба 101": "Бригада 1"},
deadline_ms=30_000, elapsed_ms=0,
)
assert ErrorCode.D6 not in [finding.code for finding in findings]

View file

@ -1,6 +1,6 @@
"""Тесты контракта. Домен — общий шов, ломать его молча нельзя."""
from datetime import datetime, timezone
from datetime import UTC, datetime
from uuid import uuid4
import pytest
@ -27,6 +27,16 @@ def test_interview_normative_is_75_seconds():
assert NORMATIVES[TimerCode.INTERVIEW].limit_ms == 75_000
def test_card_fill_normative_defaults_to_three_minutes_and_is_configurable():
from app.domain.events import LessonCriteria
assert NORMATIVES[TimerCode.CARD_FILL].limit_ms == 180_000
assert LessonCriteria().card_fill_time_limit_seconds == 180
assert LessonCriteria(card_fill_time_limit_seconds=240).card_fill_time_limit_seconds == 240
with pytest.raises(ValidationError):
LessonCriteria(card_fill_time_limit_seconds=20)
@pytest.mark.parametrize(
"elapsed_ms,expected",
[(0, TimerState.OK), (59_000, TimerState.OK), (70_000, TimerState.WARN), (94_000, TimerState.VIOLATED)],
@ -65,7 +75,7 @@ def test_server_events_round_trip_through_json():
"type": "caller.utterance",
"utterance_id": str(uuid4()),
"text": "Алло! Помогите! Горим!",
"at": datetime.now(timezone.utc).isoformat(),
"at": datetime.now(UTC).isoformat(),
"mood": "panic",
}
assert adapter.validate_python(payload).text.startswith("Алло")

View file

@ -4,9 +4,9 @@
производен и не выбирается руками, сценарий размечается признаками.
"""
import pytest
from pathlib import Path
import pytest
from app.domain import ekp
from app.domain.kio import KIO, derive_incident
from app.scenarios.loader import load_library
@ -19,43 +19,240 @@ def test_reference_loads_whole_book():
assert reference.version == "046.24"
assert len(reference.incidents) == 1283
assert len(reference.groups) == 23
assert all(incident.type for incident in reference.incidents), "код без итогового типа"
assert all(incident.type for incident in reference.incidents), (
"код без итогового типа"
)
def test_all_customer_ticket_cards_are_complete_and_classified():
cards = [scenario for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.ticket is not None]
cards = [
scenario
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.ticket is not None
]
assert len(cards) == 96
assert {scenario.ticket for scenario in cards} == set(range(1, 33))
for ticket in range(1, 33):
assert {scenario.position for scenario in cards if scenario.ticket == ticket} == {1, 2, 3}
assert all(scenario.facts and scenario.signs and scenario.ground_truth.address
and scenario.ground_truth.incident_code
and ekp.incident(scenario.ground_truth.incident_code)
for scenario in cards)
unknown_victim_counts = {scenario.id for scenario in cards
if scenario.ground_truth.victims is None}
assert unknown_victim_counts == {
"t02-2-megafon-consultation", "t02-3-car-in-water", "t03-2-loud-music",
"t04-1-balcony-fire", "t04-3-open-door", "t12-3-men-on-bridge-rail",
"t07-3-lost-elderly", "t08-1-mall-smoke", "t14-1-grass-fire-azs", "t17-1-fire-alarm",
"t12-1-restaurant-smoke", "t30-3-gas-smell-house", "t31-3-gas-pipe-whistle",
"t11-3-lost-in-forest", "t16-1-smoke-column",
"t18-1-unknown-fire", "t24-1-parking-quarrel", "t25-1-drunk-at-stop",
"t23-3-lost-child", "t27-1-suspicious-car", "t28-1-stranger-at-door", "t29-1-ticking-box",
"t29-3-threat-to-blow-up", "t30-1-car-theft-yesterday",
"t31-1-car-theft-witnessed", "t32-1-carjacking", "t32-3-street-lights",
assert {
scenario.position for scenario in cards if scenario.ticket == ticket
} == {1, 2, 3}
assert all(scenario.facts and scenario.ground_truth.address for scenario in cards)
unclassified_ids = {
"t02-3-car-in-water", # источник не уточняет, был ли человек в воде
"t03-2-loud-music", # время, нужное для признака тишины, не дано
"t05-3-worker-in-pit", # падение в котлован не означает обрушение/коммуникации
"t22-3-suicide-sms", # намерение в СМС не подтверждает попытку/приготовление
"t26-3-death-care-home", # точный код для смерти в центре не подтверждён
"t32-1-carjacking", # срок угона для кода не дан
"t32-3-street-lights", # время суток для признака не дано
}
assert {scenario.id for scenario in cards if not scenario.signs} == unclassified_ids
assert all(
scenario.ground_truth.incident_code is None and not scenario.ground_truth.notify
for scenario in cards
if scenario.id in unclassified_ids
)
assert all(
scenario.signs
and scenario.ground_truth.incident_code
and ekp.incident(scenario.ground_truth.incident_code)
for scenario in cards
if scenario.id not in unclassified_ids
)
unknown_victim_counts = {
scenario.id for scenario in cards if scenario.ground_truth.victims is None
}
assert unknown_victim_counts == {
"t02-2-megafon-consultation",
"t02-3-car-in-water",
"t03-2-loud-music",
"t04-1-balcony-fire",
"t04-3-open-door",
"t12-3-men-on-bridge-rail",
"t07-3-lost-elderly",
"t08-1-mall-smoke",
"t14-1-grass-fire-azs",
"t17-1-fire-alarm",
"t12-1-restaurant-smoke",
"t30-3-gas-smell-house",
"t31-3-gas-pipe-whistle",
"t11-3-lost-in-forest",
"t16-1-smoke-column",
"t18-1-unknown-fire",
"t24-1-parking-quarrel",
"t25-1-drunk-at-stop",
"t23-3-lost-child",
"t27-1-suspicious-car",
"t28-1-stranger-at-door",
"t29-1-ticking-box",
"t29-3-threat-to-blow-up",
"t30-1-car-theft-yesterday",
"t31-1-car-theft-witnessed",
"t32-1-carjacking",
"t32-3-street-lights",
}
def test_ticket_two_preserves_moscow_and_does_not_invent_missing_victim_data():
root = Path(__file__).resolve().parents[2] / "scenarios"
cards = {
scenario.id: scenario
for scenario in load_library(root)
if scenario.id
in {
"t02-1-smoke-chute",
"t02-2-megafon-consultation",
"t02-3-car-in-water",
}
}
assert len(cards) == 3
for scenario in cards.values():
assert scenario.ground_truth.address.startswith("Москва,")
address_fact = next(fact for fact in scenario.facts if fact.id == "f_address")
assert address_fact.value.startswith("Москва,")
consultation = cards["t02-2-megafon-consultation"]
victim_fact = next(fact for fact in consultation.facts if fact.id == "f_victims")
assert victim_fact.value == "в исходном билете сведения о пострадавших не указаны"
assert consultation.ground_truth.victims is None
def test_ticket_14_refined_address_retains_azs_and_approach_landmarks():
scenario = next(
scenario
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.id == "t14-1-grass-fire-azs"
)
address = next(fact for fact in scenario.facts if fact.id == "f_address")
for landmark in ("АЗС", "Роснефть", "не доезжая до Расторгуевского шоссе"):
assert landmark in address.value
assert landmark in address.refined
assert landmark in scenario.ground_truth.address
assert "25 км по столбам в сторону Москвы" in address.refined
assert "владение 2" in scenario.ground_truth.address
def test_ticket_05_02_does_not_invent_callers_age():
scenario = next(
scenario
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.id == "t05-2-wrong-medicine"
)
who = next(fact for fact in scenario.facts if fact.id == "f_who")
assert who.value == "Иванова Ирина Петровна, дата рождения 10.03.1975"
assert "на вид" not in who.value
def test_ticket_25_01_refined_address_keeps_stop_and_side_landmarks():
scenario = next(
scenario
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.id == "t25-1-drunk-at-stop"
)
address = next(fact for fact in scenario.facts if fact.id == "f_address")
assert "62" in address.refined
assert "Штурвальная" in address.refined
assert "на стороне улицы Фабрициуса, дом 18" in address.refined
def test_ticket_address_code_is_not_guessed_to_be_an_intercom():
root = Path(__file__).resolve().parents[2] / "scenarios"
expected_codes = {
"t04-3-open-door": "45В",
"t05-2-wrong-medicine": "142",
"t17-1-fire-alarm": "2215",
"t18-2-husband-wont-wake": "5В",
"t22-1-flat-fight": "2В",
"t23-1-drunk-husband": "80В",
"t28-1-stranger-at-door": "100",
"t29-3-threat-to-blow-up": "67",
"t31-3-gas-pipe-whistle": "5В",
}
cards = {
scenario.id: scenario
for scenario in load_library(root)
if scenario.id in expected_codes
}
assert cards.keys() == expected_codes.keys()
for scenario_id, code in expected_codes.items():
address = cards[scenario_id].ground_truth.address
assert address.endswith(f"код {code}")
assert "домофон" not in address.casefold()
def test_ticket_09_02_retains_the_source_motorway_designation_in_caller_facts():
scenario = next(
scenario
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.id == "t09-2-labour"
)
address_fact = next(fact for fact in scenario.facts if fact.id == "f_address")
assert "Горьковского шоссе (М7)" in address_fact.value
assert "Горьковского шоссе (М7)" in scenario.ground_truth.address
def test_ticket_18_01_retains_both_source_motorway_designations_in_caller_facts():
scenario = next(
scenario
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.id == "t18-1-unknown-fire"
)
address_fact = next(fact for fact in scenario.facts if fact.id == "f_address")
assert "М3" in address_fact.value
assert "М1" in address_fact.value
assert "М3" in scenario.ground_truth.address
assert "М1" in scenario.ground_truth.address
def test_ticket_8_2_preserves_both_highway_designations_from_source():
scenario = next(
item
for item in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if item.id == "t08-2-unconscious-roadside"
)
address_fact = next(fact for fact in scenario.facts if fact.id == "f_address")
for designation in ("М3", "М1"):
assert designation in address_fact.value
assert designation in scenario.ground_truth.address
def test_ticket_five_preserves_source_observation_without_inventing_age():
cards = {
item.id: item
for item in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if item.id in {"t05-1-window-fire-people", "t05-2-wrong-medicine"}
}
assert len(cards) == 2
fire_facts = {fact.id: fact.value for fact in cards["t05-1-window-fire-people"].facts}
assert fire_facts["f_observer"] == "заявитель наблюдает за пожаром с улицы"
medicine_facts = {fact.id: fact.value for fact in cards["t05-2-wrong-medicine"].facts}
assert medicine_facts["f_who"] == "Иванова Ирина Петровна, дата рождения 10.03.1975"
def test_ticket_seven_preserves_ambiguous_address_code_verbatim():
scenario = next(
item
for item in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if item.id == "t07-3-lost-elderly"
)
assert scenario.ground_truth.address.endswith("код 5В")
assert "домофон" not in scenario.ground_truth.address
def test_unknown_ticket_victim_count_is_not_scored():
from app.scoring.card import evaluate_card
cards = [scenario for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.ticket is not None and scenario.ground_truth.victims is None]
cards = [
scenario
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.ticket is not None and scenario.ground_truth.victims is None
]
assert len(cards) == 27
assert all("victims_count" not in {metric.key for metric in evaluate_card(scenario, KIO()).metrics}
for scenario in cards)
assert all(
"victims_count"
not in {metric.key for metric in evaluate_card(scenario, KIO()).metrics}
for scenario in cards
)
def test_signs_give_the_code_from_the_book():
@ -89,7 +286,10 @@ def test_category_filters_all_three_incident_choices_and_card_derivation():
assert fire.signs[2] in ekp.signs_at_level(3, fire.signs[:2], group=fire.group)
card = derive_incident(KIO(incident_group=fire.group, signs=fire.signs))
assert card.incident_code == fire.code
assert derive_incident(KIO(incident_group=other_group, signs=fire.signs)).incident_code is None
assert (
derive_incident(KIO(incident_group=other_group, signs=fire.signs)).incident_code
is None
)
def test_service_rows_hidden_from_operator_are_not_offered():
@ -156,9 +356,14 @@ def test_victims_bring_the_ambulance():
в поле `victims_count` поднимают скорую (docs/spec/DATASET.md)."""
from app.domain.kio import KIO, derive_incident
quiet = derive_incident(KIO(signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"]))
quiet = derive_incident(
KIO(signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"])
)
hurt = derive_incident(
KIO(signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"], victims_count=5)
KIO(
signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"],
victims_count=5,
)
)
assert "СМП" not in quiet.notify
assert "СМП" in hurt.notify
@ -169,7 +374,10 @@ def test_gasified_object_brings_mosgaz():
from app.domain.kio import KIO, FireDetails, derive_incident
card = derive_incident(
KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True))
KIO(
signs=["жилой дом", "балкон", "открытое пламя"],
fire=FireDetails(gasified=True),
)
)
assert "МОСГАЗ" in card.notify
@ -178,7 +386,10 @@ def test_removed_modifier_recalculates_notify_without_stale_service():
from app.domain.kio import KIO, FireDetails, apply_patch, derive_incident
card = derive_incident(
KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True))
KIO(
signs=["жилой дом", "балкон", "открытое пламя"],
fire=FireDetails(gasified=True),
)
)
assert "МОСГАЗ" in card.notify
updated = apply_patch(card, {"fire.gasified": False})

View file

@ -1,6 +1,7 @@
"""Групповая сводка считает людей, а не число их повторных попыток."""
from uuid import uuid4
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
@ -9,6 +10,7 @@ from starlette.requests import Request
from app.api import auth
from app.api.http import groups as group_api
from app.api.auth import Principal
from app.db.models import AuditLog
from app.domain.roles import Role
from app.scoring.group import ScoredAttempt, summarize
@ -47,25 +49,53 @@ def test_unknown_codes_do_not_break_group_summary():
assert [item["code"] for item in result["errors"]] == ["E5"]
@pytest.mark.asyncio
async def test_group_insight_fails_closed_if_audit_cannot_be_saved(monkeypatch):
who = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
monkeypatch.setattr(group_api, "require", lambda _request, *_roles: who)
async def fake_analytics(*_args, **_kwargs):
return SimpleNamespace(scored_attempts=1, model_dump=lambda **_kwargs: {})
async def fake_insight(_data):
return {"summary": "Повторить уточнение адреса", "priorities": []}
async def unavailable_audit(*_args, **_kwargs):
raise HTTPException(status_code=503, detail="audit_unavailable")
monkeypatch.setattr(group_api, "_analytics", fake_analytics)
monkeypatch.setattr(group_api, "generate_group_insight", fake_insight)
monkeypatch.setattr(group_api, "audit_required", unavailable_audit)
with pytest.raises(HTTPException) as exc:
await group_api.ai_insight(uuid4(), object(), object())
assert exc.value.status_code == 503
assert exc.value.detail == "audit_unavailable"
@pytest.mark.asyncio
async def test_group_creation_requires_staff_and_returns_new_group(monkeypatch):
class FakeDb:
def add(self, group):
group.id = uuid4()
def __init__(self):
self.added = []
self.commits = 0
def add(self, row):
self.added.append(row)
async def commit(self):
pass
async def no_audit(*args):
pass
monkeypatch.setattr(group_api, "audit", no_audit)
self.commits += 1
instructor = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
request = Request({"type": "http", "session": {}})
auth._issue_session(request, instructor)
created = await group_api.create(group_api.GroupCreate(name=" Группа 1 "), request, FakeDb())
db = FakeDb()
created = await group_api.create(group_api.GroupCreate(name=" Группа 1 "), request, db)
assert created.name == "Группа 1"
assert created.id
assert db.commits == 1
audit_row = next(row for row in db.added if isinstance(row, AuditLog))
assert audit_row.action == "group.create" and audit_row.object_id == str(created.id)
trainee = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE)
forbidden = Request({"type": "http", "session": {}})

View file

@ -1,10 +1,8 @@
"""Живые проверки LLM: клиент, кэш и звонящий своими словами.
"""Живые проверки локальной LLM: клиент, кэш и ответы звонящего.
Читают `backend/.env.test` — бесплатная модель через OpenRouter. Без этого файла
или без сети тест пропускается: обычные тесты в сеть не ходят вовсе.
Запускать отдельно (`make test-llm`): бесплатная рассуждающая модель отвечает
десятки секунд, и в общем прогоне ей не место.
Читают `backend/.env.test` с `LLM_PROVIDER=local` и loopback URL. Запускайте
после `make local-llm` отдельной командой `make test-llm-local`. Сеть не нужна;
основной pytest намеренно исключает медленный инференс.
"""
import os
@ -15,37 +13,97 @@ import pytest
ENV_TEST = Path(__file__).resolve().parents[1] / ".env.test"
def _load_test_env() -> bool:
if not ENV_TEST.exists():
return False
def _read_test_env() -> dict[str, str]:
values: dict[str, str] = {}
for line in ENV_TEST.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
os.environ[key.strip()] = value.strip()
from app.config import get_settings
get_settings.cache_clear()
return True
values[key.strip()] = value.strip()
return values
HAS_TEST_ENV = ENV_TEST.is_file()
pytestmark = [
pytest.mark.llm,
pytest.mark.skipif(not _load_test_env(), reason="нет backend/.env.test — живые проверки LLM пропущены"),
pytest.mark.skipif(not HAS_TEST_ENV, reason="нет backend/.env.test — живые проверки LLM пропущены"),
]
@pytest.fixture(autouse=True)
def local_llm_test_environment():
"""Изолировать live-конфиг: collection обычных тестов не меняет env."""
if not HAS_TEST_ENV:
yield
return
values = _read_test_env()
original = {key: os.environ.get(key) for key in values}
for key, value in values.items():
os.environ[key] = value
from app.config import get_settings
from app.dialog.llm import is_loopback_url
get_settings.cache_clear()
try:
settings = get_settings()
if (
settings.llm_provider != "local"
or not is_loopback_url(settings.llm_base_url)
or settings.llm_api_key
):
raise pytest.UsageError(
"test-llm-local требует LLM_PROVIDER=local, loopback URL и пустой LLM_API_KEY"
)
yield
finally:
for key, value in original.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
get_settings.cache_clear()
@pytest.fixture
async def client():
from app.dialog.llm import LlmClient
# Бесплатная рассуждающая модель думает по минуте: в живой проверке
# это допустимо, в занятии — нет, там таймаут 8 секунд и откат на заготовки.
# Локальная модель может быть медленной на слабом CPU; в занятии таймаут
# короче, и при отказе используются проверенные заготовки.
llm = LlmClient(timeout=180)
yield llm
await llm.aclose()
@pytest.fixture
async def database_client(postgres_access):
"""Подключить проверку кэша к PostgreSQL, когда тестовый стенд её дал.
Живые inference smoke должны работать и без БД, но проверка устойчивого
кэша имеет смысл только в отдельной DB-интеграционной цели.
"""
from sqlalchemy import select
from app.db.base import get_sessionmaker
from app.db.models import LlmCache
from app.dialog.llm import LlmClient
sessionmaker = get_sessionmaker()
try:
async with sessionmaker() as db:
await db.scalar(select(LlmCache.context_hash).limit(1))
except Exception as exc: # noqa: BLE001 — кэш необязателен для обычного inference smoke
if os.environ.get("DATABASE_URL"):
raise
pytest.skip(f"таблица кэша LLM недоступна: {type(exc).__name__}")
db_client = LlmClient(sessionmaker=sessionmaker, timeout=180)
yield db_client
await db_client.aclose()
async def test_provider_answers(client):
from app.dialog.llm import LlmRequest, LlmUnavailable
@ -58,7 +116,7 @@ async def test_provider_answers(client):
try:
text = await client.complete(request, use_cache=False)
except LlmUnavailable as exc:
pytest.skip(f"провайдер недоступен: {exc}")
pytest.fail(f"локальная модель недоступна: {exc}")
assert text, "пустой ответ модели"
@ -81,14 +139,14 @@ async def test_caller_speaks_only_revealed_facts(client):
# Оператор спрашивает не об адресе — адрес прозвучать не должен.
reply = await caller.reply(slots.hear("Что у вас случилось?"), persona, slots)
except LlmUnavailable as exc:
pytest.skip(f"провайдер недоступен: {exc}")
pytest.fail(f"локальная модель недоступна: {exc}")
assert caller.fallbacks == 0, "ответила не модель, а заготовка"
assert "Ленина" not in reply.text, f"звонящий выдал адрес без вопроса: «{reply.text}»"
assert len(reply.text) < 300, "звонящий пишет объяснительную вместо крика"
async def test_same_context_comes_from_cache(client):
async def test_same_context_comes_from_cache(database_client):
"""Кэш по хешу контекста: та же реплика на том же месте занятия звучит
одинаково у каждой группы и не стоит второго запроса."""
from app.dialog.llm import LlmRequest, LlmUnavailable
@ -100,17 +158,15 @@ async def test_same_context_comes_from_cache(client):
max_tokens=400,
)
try:
first = await client.complete(request)
first = await database_client.complete(request)
except LlmUnavailable as exc:
pytest.skip(f"провайдер недоступен: {exc}")
pytest.fail(f"локальная модель недоступна: {exc}")
import time
started = time.monotonic()
second = await client.complete(request)
second = await database_client.complete(request)
elapsed = time.monotonic() - started
if client._sessionmaker is None:
pytest.skip("кэш выключен: база недоступна")
assert second == first, "кэш вернул другой ответ"
assert elapsed < 1.0, f"второй запрос занял {elapsed:.2f} с — кэш не сработал"

View file

@ -14,8 +14,10 @@ from app.dialog.persona import PersonaState
from app.scoring.grammar import assess, basic_check
from app.dialog.slots import SlotMachine
from app.voice.models import WhisperRecognizer
from scripts import local_llms
from scripts import local_stt
from tests.test_slots import SCENARIO, StemEmbedder
from tests.test_refinement import SCENARIO as REFINED_SCENARIO
@pytest.fixture(autouse=True)
@ -42,10 +44,11 @@ def test_offline_model_address_must_be_literal_loopback():
@pytest.mark.asyncio
async def test_local_llm_uses_loopback_without_api_key(monkeypatch):
@pytest.mark.parametrize("api_key", ["", "leftover-cloud-key"])
async def test_local_llm_never_sends_an_api_key(monkeypatch, api_key):
monkeypatch.setenv("OFFLINE", "true")
monkeypatch.setenv("LLM_PROVIDER", "local")
monkeypatch.setenv("LLM_API_KEY", "")
monkeypatch.setenv("LLM_API_KEY", api_key)
requests = []
def answer(request):
@ -114,6 +117,58 @@ async def test_malformed_local_answer_falls_back_instead_of_crashing(monkeypatch
await client.aclose()
@pytest.mark.asyncio
async def test_llm_error_does_not_expose_provider_body(monkeypatch):
monkeypatch.setenv("OFFLINE", "true")
monkeypatch.setenv("LLM_PROVIDER", "local")
client = LlmClient(transport=httpx.MockTransport(
lambda _: httpx.Response(500, text="private incident address: 17 Example Street")
))
try:
with pytest.raises(LlmUnavailable) as raised:
await client.complete(
LlmRequest(messages=[{"role": "user", "content": "redacted prompt"}],
model="Qwen3-1.7B"),
use_cache=False,
)
assert "HTTP 500" in str(raised.value)
assert "Example Street" not in str(raised.value)
assert "redacted prompt" not in str(raised.value)
finally:
await client.aclose()
@pytest.mark.asyncio
async def test_llm_cache_write_failure_does_not_log_prompt_or_response(caplog):
class FakeDb:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
def add(self, _row):
return None
async def commit(self):
raise RuntimeError("sensitive prompt echoed by database driver")
request = LlmRequest(
messages=[{"role": "user", "content": "private caller address"}],
model="Qwen3-1.7B",
)
client = LlmClient(sessionmaker=FakeDb)
try:
await client._to_cache("hash", request, "private caller response")
finally:
await client.aclose()
assert "sensitive prompt" not in caplog.text
assert "private caller address" not in caplog.text
assert "private caller response" not in caplog.text
assert "RuntimeError" in caplog.text
@pytest.mark.asyncio
async def test_qwen_disabled_thinking_closing_marker_is_not_spoken(monkeypatch):
monkeypatch.setenv("OFFLINE", "true")
@ -243,6 +298,37 @@ async def test_rejected_qwen_turn_does_not_poison_next_turn():
assert all("99" not in message["content"] for message in client.requests[1].messages)
@pytest.mark.asyncio
async def test_address_correction_discards_old_value_from_qwen_context():
old_address = "улица Станционная, дом 28"
new_address = "Королёв, улица Станционная, дом 28"
class FakeClient:
def __init__(self):
self.requests = []
self.answers = iter([old_address, new_address])
async def complete(self, request):
self.requests.append(request)
return next(self.answers)
client = FakeClient()
caller = LlmCaller(client, "Qwen3-1.7B")
slots = SlotMachine(REFINED_SCENARIO, StemEmbedder(), floor=0.5)
persona = PersonaState(REFINED_SCENARIO.persona)
first = await caller.reply(slots.hear("Назовите адрес"), persona, slots)
assert first.source == "local_llm"
refined_turn = slots.hear("Это точно Москва город?")
second = await caller.reply(refined_turn, persona, slots)
assert refined_turn.refined == ["f_address"]
assert second.source == "local_llm"
assert second.text == new_address
assert len(client.requests[1].messages) == 2 # system + current user turn; no stale dialogue history
assert client.requests[1].messages[-1]["content"] == "Это точно Москва город?"
def test_whisper_cpp_uses_loopback_wav_only():
requests = []
@ -274,3 +360,44 @@ def test_whisper_cpp_command_is_local_and_uses_downloaded_weight(tmp_path, monke
assert "127.0.0.1" in argv
assert "18082" in argv
assert "ggml-small-q5_1.bin" in " ".join(argv)
def test_windows_llama_runner_uses_explicit_exe_and_ignores_bundled_macos(
tmp_path, monkeypatch,
):
mac_binary = tmp_path / "models" / "bin" / "llama-b10934" / "llama-server"
mac_binary.parent.mkdir(parents=True)
mac_binary.write_bytes(b"Mach-O test fixture")
windows_binary = tmp_path / "llama-server.exe"
windows_binary.write_bytes(b"Windows test fixture")
monkeypatch.setattr(local_llms, "ROOT", tmp_path)
monkeypatch.setattr(local_llms.sys, "platform", "win32")
monkeypatch.setattr(local_llms.shutil, "which", lambda _name: None)
monkeypatch.setenv("LLAMA_SERVER_BIN", str(windows_binary))
assert local_llms.binary_path() == str(windows_binary)
monkeypatch.delenv("LLAMA_SERVER_BIN")
with pytest.raises(RuntimeError, match="llama-server"):
local_llms.binary_path()
def test_windows_whisper_runner_uses_explicit_exe_and_ignores_bundled_macos(
tmp_path, monkeypatch,
):
mac_binary = (
tmp_path / "models" / "bin" / "whisper.cpp-1.9.4" / "build" / "bin"
/ "whisper-server"
)
mac_binary.parent.mkdir(parents=True)
mac_binary.write_bytes(b"Mach-O test fixture")
windows_binary = tmp_path / "whisper-server.exe"
windows_binary.write_bytes(b"Windows test fixture")
monkeypatch.setattr(local_stt, "ROOT", tmp_path)
monkeypatch.setattr(local_stt.sys, "platform", "win32")
monkeypatch.setattr(local_stt.shutil, "which", lambda _name: None)
monkeypatch.setenv("WHISPER_SERVER_BIN", str(windows_binary))
assert local_stt.binary_path() == str(windows_binary)
monkeypatch.delenv("WHISPER_SERVER_BIN")
with pytest.raises(RuntimeError, match="whisper-server"):
local_stt.binary_path()

View file

@ -141,16 +141,24 @@ def test_unassigned_trainee_cannot_download_resource(client):
def test_archive_hides_material_from_trainee_but_keeps_record(client):
_instructor(client)
seeded = client.get("/api/materials").json()[0]
archived = client.delete(f"/api/materials/{seeded['id']}")
created = client.post("/api/materials", json={
"title": "Архивируемая памятка",
"kind": "text",
"body": "Уникальный тестовый материал для проверки архивации.",
})
assert created.status_code == 201, created.text
material_id = created.json()["id"]
archived = client.delete(f"/api/materials/{material_id}")
assert archived.status_code == 200
assert archived.json()["active"] is False
assert client.get("/api/materials").json() == []
assert all(item["id"] != material_id for item in client.get("/api/materials").json())
archived_list = client.get("/api/materials?include_archived=true").json()
assert len(archived_list) == 1 and archived_list[0]["active"] is False
archived_item = next(item for item in archived_list if item["id"] == material_id)
assert archived_item["active"] is False
_trainee(client)
assert client.get("/api/materials").json() == []
assert all(item["id"] != material_id for item in client.get("/api/materials").json())
def test_trainee_starts_assigned_practice_in_self_mode(client):

View file

@ -0,0 +1,55 @@
import pytest
from fastapi.testclient import TestClient
from app import main
from app.config import Settings
def prod_settings(**overrides) -> Settings:
values = {
"app_env": "production",
"database_url": "postgresql+asyncpg://lct:postgres-secret-with-more-than-32-characters@localhost:5432/lct",
"session_secret": "a-unique-secret-that-is-at-least-32-characters-long",
"secure_cookies": True,
"dev_auth_bypass": False,
"offline": True,
"llm_provider": "local",
**overrides,
}
return Settings(_env_file=None, **values)
def test_production_accepts_unique_secret_https_cookie_and_password_auth():
prod_settings().validate_deployment_security()
@pytest.mark.parametrize(
("overrides", "message"),
[
({"session_secret": "dev-secret-поменять-на-стенде"}, "SESSION_SECRET"),
({"session_secret": "short"}, "SESSION_SECRET"),
({"database_url": "postgresql+asyncpg://lct:short@localhost:5432/lct"}, "PostgreSQL password"),
({"database_url": "postgresql+asyncpg://lct:has%40unsafe%40characters-over-32@localhost:5432/lct"}, "PostgreSQL password"),
({"secure_cookies": False}, "SECURE_COOKIES"),
({"dev_auth_bypass": True}, "DEV_AUTH_BYPASS"),
({"demo_no_db": True}, "DEMO_NO_DB"),
({"offline": False}, "OFFLINE"),
({"llm_provider": "openai"}, "LLM_PROVIDER"),
],
)
def test_production_rejects_insecure_authentication_defaults(overrides, message):
with pytest.raises(ValueError, match=message):
prod_settings(**overrides).validate_deployment_security()
def test_development_keeps_local_http_and_dev_token_available():
settings = Settings(_env_file=None, app_env="development")
settings.validate_deployment_security()
def test_production_app_startup_fails_before_serving_with_default_secret(monkeypatch):
settings = prod_settings(session_secret="dev-secret-поменять-на-стенде")
monkeypatch.setattr(main, "get_settings", lambda: settings)
with pytest.raises(RuntimeError, match="SESSION_SECRET"):
with TestClient(main.app):
pass

View file

@ -4,12 +4,18 @@
профиль читается по HTTP. Без Postgres пропускается.
"""
import asyncio
import time
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.config import get_settings
from app.api.auth import Principal
from app.api.http import trainees as trainees_api
from app.domain.roles import Role
from app.main import app
from app.session.hub import hub
@ -25,7 +31,7 @@ def client():
try:
socket.create_connection((url.hostname or "localhost", url.port or 5432), timeout=2).close()
except OSError as exc:
pytest.skip(f"Postgres недоступен ({exc}) — подними `make dev`")
pytest.skip(f"Postgres недоступен ({exc}) — запусти `make test-db`")
with TestClient(app) as test_client:
# Сокеты закрыты ролями (lct-23): тесты входят так же,
# как `make lesson`, — через dev-token за флагом.
@ -86,6 +92,102 @@ def test_profile_shows_attempts_and_delta(client):
if delta["facts_got"] is not None:
assert delta["facts_got"] >= 0, "во второй попытке фактов добыто не меньше"
# Личный совет должен отражать последнюю оценённую попытку, а не копить
# нарушения за всю историю и не раскрывать неизвестные коды таксономии.
latest_scored = next(item for item in reversed(profile["attempts"]) if item["score"] is not None)
latest_codes = latest_scored["codes"]
from app.scoring.group import RECOMMENDATIONS
recommendations = profile["recommendations"]
assert len(recommendations) <= 5
expected_codes = {
code for code, count in latest_codes.items()
if code in RECOMMENDATIONS and isinstance(count, int) and count > 0
}
assert {item["code"] for item in recommendations} == expected_codes
for item in recommendations:
assert item["occurrences"] == latest_codes[item["code"]]
assert item["title"] and item["recommendation"]
def test_profile_of_unknown_trainee_is_404(client):
assert client.get(f"/api/trainees/{uuid4()}/profile").status_code == 404
def test_personal_recommendations_are_explainable_and_limited_to_known_codes():
from app.api.http.trainees import _personal_recommendations
result = _personal_recommendations({"D6": 1, "E1": 3, "unknown": 99, "D2": 0})
assert [item.code for item in result] == ["E1", "D6"]
assert result[0].title == "Пропущенный факт"
assert result[0].recommendation
assert result[0].occurrences == 3
def test_profile_read_is_audited_without_copying_profile_data(monkeypatch):
trainee_id = uuid4()
trainee = SimpleNamespace(id=trainee_id, name="Курсант Петров", group_id=None)
who = Principal(
login="trainee-login", full_name=trainee.name, role=Role.TRAINEE,
trainee_id=trainee_id,
)
class Rows:
def __iter__(self):
return iter(())
class Database:
async def get(self, model, key):
assert key == trainee_id
return trainee
async def execute(self, _statement):
return Rows()
events = []
async def capture(actor, role, action, object_id=None, detail=""):
events.append((actor, role, action, object_id, detail))
monkeypatch.setattr(trainees_api, "require", lambda *_args, **_kwargs: who)
monkeypatch.setattr(trainees_api, "audit_required", capture)
result = asyncio.run(trainees_api.profile(trainee_id, object(), Database()))
assert result.trainee.name == "Курсант Петров"
assert events == [("trainee-login", "trainee", "trainee.profile.read", str(trainee_id), "")]
def test_certificate_export_is_audited_before_response(monkeypatch):
trainee_id = uuid4()
trainee = SimpleNamespace(id=trainee_id, name="Курсант Петров", group_id=None)
who = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
class Result:
def one(self):
return 1, 90.0, datetime(2026, 9, 26, tzinfo=timezone.utc)
class Database:
async def scalar(self, _statement):
return uuid4()
async def get(self, model, key):
assert key == trainee_id
return trainee
async def execute(self, _statement):
return Result()
events = []
async def capture(actor, role, action, object_id=None, detail=""):
events.append((actor, role, action, object_id, detail))
monkeypatch.setattr(trainees_api, "require", lambda *_args, **_kwargs: who)
monkeypatch.setattr(trainees_api, "audit_required", capture)
monkeypatch.setattr(trainees_api, "certificate_pdf", lambda **_kwargs: b"%PDF-test")
response = asyncio.run(trainees_api.certificate(trainee_id, object(), Database()))
assert response.body == b"%PDF-test"
assert events == [
("teacher", "instructor", "trainee.certificate.export.pdf", str(trainee_id), "")
]

View file

@ -1,7 +1,8 @@
"""WAV-запись вызова: формат, микширование и защищённая выдача."""
import os
import wave
from datetime import datetime, timezone
from datetime import UTC, datetime
from types import SimpleNamespace
from uuid import uuid4
@ -25,6 +26,9 @@ def test_recorder_mixes_16_and_24_khz_into_atomic_wav(tmp_path):
assert recorder.finalize() == path
assert recorder.finalize() == path
assert not path.with_suffix(".wav.tmp").exists()
assert not path.with_suffix(".wav.journal").exists()
if os.name == "posix": # Windows exposes a different permission model.
assert os.stat(path).st_mode & 0o777 == 0o600
with wave.open(str(path), "rb") as source:
assert source.getnchannels() == 1
assert source.getsampwidth() == 2
@ -34,6 +38,34 @@ def test_recorder_mixes_16_and_24_khz_into_atomic_wav(tmp_path):
assert samples.max() >= 2000
def test_recorder_recovers_audio_journal_after_process_restart(tmp_path):
path = tmp_path / "interrupted.wav"
clock_value = [10.0]
clock = lambda: clock_value[0]
first_process = CallRecorder(path, clock=clock)
first_process.add_pcm((1000).to_bytes(2, "little", signed=True) * 320, sample_rate=16_000)
journal = path.with_suffix(".wav.journal")
# Simulate power loss halfway through a journal record. The next process
# must keep all complete audio and discard only the torn tail.
first_process._journal.close()
with journal.open("ab") as partial:
partial.write(b"\x40\x01\x00\x00\x00\x00\x00\x00\x40\x01\x00\x00\x02\x00")
clock_value[0] = 50.0 # monotonic origin changed across host restart
recovered = CallRecorder(path, clock=clock)
recovered.add_pcm((2000).to_bytes(2, "little", signed=True) * 320, sample_rate=16_000)
recovered.finalize()
with wave.open(str(path), "rb") as source:
samples = np.frombuffer(source.readframes(source.getnframes()), dtype="<i2")
assert source.getframerate() == 16_000
assert samples.size == 640
assert np.all(samples[:320] == 1000)
assert np.all(samples[320:] == 2000)
assert not journal.exists()
def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypatch):
session_id = uuid4()
path = tmp_path / f"{session_id}.wav"
@ -46,11 +78,17 @@ def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypa
async def fake_session(db, requested):
assert requested == session_id
return SimpleNamespace(
trainee_id=uuid4(), owner_login="dev", ended_at=datetime.now(timezone.utc),
trainee_id=uuid4(), owner_login="dev", ended_at=datetime.now(UTC),
)
monkeypatch.setattr(sessions.repo, "get_session", fake_session)
monkeypatch.setattr(sessions, "recording_path", lambda requested: path)
audit_events = []
async def record_access(actor, role, action, object_id=None, detail=""):
audit_events.append((actor, role, action, object_id, detail))
monkeypatch.setattr(sessions, "audit_required", record_access)
with TestClient(app) as client:
assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 401
client.post("/api/auth/dev-token")
@ -58,6 +96,9 @@ def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypa
assert response.status_code == 200
assert response.headers["content-type"] == "audio/wav"
assert response.content.startswith(b"RIFF")
assert audit_events == [
("dev", "instructor", "recording.read", str(session_id), "")
]
monkeypatch.setattr(
sessions,
@ -67,3 +108,29 @@ def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypa
),
)
assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 403
assert len(audit_events) == 1
def test_recording_is_not_returned_when_access_audit_is_unavailable(tmp_path, monkeypatch):
from fastapi import HTTPException
session_id = uuid4()
path = tmp_path / f"{session_id}.wav"
path.write_bytes(b"not returned")
async def fake_session(db, requested):
return SimpleNamespace(
trainee_id=uuid4(), owner_login="dev", ended_at=datetime.now(UTC),
)
async def audit_unavailable(*_args, **_kwargs):
raise HTTPException(status_code=503, detail="audit_unavailable")
monkeypatch.setattr(sessions.repo, "get_session", fake_session)
monkeypatch.setattr(sessions, "recording_path", lambda requested: path)
monkeypatch.setattr(sessions, "audit_required", audit_unavailable)
with TestClient(app) as client:
client.post("/api/auth/dev-token")
response = client.get(f"/api/sessions/{session_id}/recording.wav")
assert response.status_code == 503
assert response.json() == {"detail": "audit_unavailable"}

View file

@ -3,16 +3,17 @@
import asyncio
import csv
import io
from datetime import datetime, timezone
from datetime import UTC, datetime
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app.api.auth import Principal
from app.api.http import sessions
from app.config import get_settings
from app.domain.events import SessionReport
from app.domain.roles import Role
from app.main import app
@ -20,7 +21,7 @@ from app.scoring.export import _cell, certificate_pdf, to_csv, to_pdf
def sample_report(*, long: bool = False) -> SessionReport:
at = datetime(2026, 9, 21, 12, 0, tzinfo=timezone.utc)
at = datetime(2026, 9, 21, 12, 0, tzinfo=UTC)
long_text = "Заявитель сообщает о дыме в учебном помещении. " * (240 if long else 1)
return SessionReport.model_validate({
"session_id": str(uuid4()),
@ -114,18 +115,32 @@ def test_certificate_pdf_contains_saved_result(tmp_path):
@pytest.fixture
def client(monkeypatch):
report = sample_report()
state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4(), owner_login="dev")
owner_login = "demo-instructor" if get_settings().demo_no_db else "dev"
state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4(), owner_login=owner_login)
audit_events = []
async def capture_audit(actor, role, action, object_id=None, detail=""):
audit_events.append((actor, role, action, object_id))
state.audit_events = audit_events
monkeypatch.setattr(sessions, "_live", lambda session_id: (state, object()))
monkeypatch.setattr(sessions, "build_report", lambda session_id, state, scenario: report)
monkeypatch.setattr(sessions, "audit_required", capture_audit)
with TestClient(app) as test_client:
# These endpoint tests exercise the in-memory live-report path. Durable
# report readiness is covered by the isolated PostgreSQL integration suite.
monkeypatch.setattr(sessions.hub, "journal", None)
test_client.post("/api/auth/dev-token")
yield test_client, state, report
def test_export_routes_return_downloads_with_json_report_rights(client):
browser, state, report = client
json_response = browser.get(f"/api/sessions/{report.session_id}/report")
assert json_response.status_code == 200, json_response.text
csv_response = browser.get(f"/api/sessions/{report.session_id}/report.csv")
assert csv_response.status_code == 200
assert csv_response.status_code == 200, csv_response.text
assert csv_response.headers["content-type"].startswith("text/csv")
assert csv_response.content.startswith(b"\xef\xbb\xbf")
assert "attachment" in csv_response.headers["content-disposition"]
@ -134,6 +149,11 @@ def test_export_routes_return_downloads_with_json_report_rights(client):
assert pdf_response.status_code == 200
assert pdf_response.headers["content-type"] == "application/pdf"
assert pdf_response.content.startswith(b"%PDF-")
assert state.audit_events == [
("dev", "instructor", "report.read", str(report.session_id)),
("dev", "instructor", "report.export.csv", str(report.session_id)),
("dev", "instructor", "report.export.pdf", str(report.session_id)),
]
state.score = None
assert browser.get(f"/api/sessions/{report.session_id}/report.csv").status_code == 409
@ -141,13 +161,28 @@ def test_export_routes_return_downloads_with_json_report_rights(client):
def test_trainee_cannot_export_another_persons_report(client, monkeypatch):
browser, state, report = client
browser, _state, report = client
monkeypatch.setattr(
sessions, "require",
lambda request: Principal(login="trainee", full_name="Учебный", role=Role.TRAINEE, trainee_id=uuid4()),
)
for suffix in ("csv", "pdf"):
assert browser.get(f"/api/sessions/{report.session_id}/report.{suffix}").status_code == 403
assert not client[1].audit_events
def test_report_export_fails_closed_when_access_audit_is_unavailable(client, monkeypatch):
from fastapi import HTTPException
browser, _state, report = client
async def unavailable(*_args, **_kwargs):
raise HTTPException(status_code=503, detail="audit_unavailable")
monkeypatch.setattr(sessions, "audit_required", unavailable)
response = browser.get(f"/api/sessions/{report.session_id}/report.pdf")
assert response.status_code == 503
assert response.json() == {"detail": "audit_unavailable"}
def test_archived_report_survives_missing_live_session(monkeypatch):
@ -220,6 +255,12 @@ def test_archived_score_override_updates_json_exports_and_audit(monkeypatch):
sessions, "require",
lambda request, *roles: Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR),
)
audit_events = []
async def capture_audit(actor, role, action, object_id=None, detail=""):
audit_events.append((actor, role, action, object_id))
monkeypatch.setattr(sessions, "audit_required", capture_audit)
corrected = asyncio.run(sessions.override(
archived.session_id,
@ -237,7 +278,8 @@ def test_archived_score_override_updates_json_exports_and_audit(monkeypatch):
assert corrected.override_comment == "проверена запись переговоров"
audit = db.added[0]
assert audit.action == "score.override" and audit.actor == "teacher"
assert "84.5" in audit.detail and "проверена запись переговоров" in audit.detail
assert "84.5" in audit.detail and "comment_chars=" in audit.detail
assert "проверена запись переговоров" not in audit.detail
report = asyncio.run(sessions.report(archived.session_id, object(), db))
assert report.score_final == 84.5 and report.score_auto == 70.0
@ -246,3 +288,8 @@ def test_archived_score_override_updates_json_exports_and_audit(monkeypatch):
assert "проверена запись переговоров" in csv_response.body.decode("utf-8-sig")
pdf_response = asyncio.run(sessions.report_pdf(archived.session_id, object(), db))
assert pdf_response.body.startswith(b"%PDF-")
assert audit_events == [
("teacher", "instructor", "report.read", str(archived.session_id)),
("teacher", "instructor", "report.export.csv", str(archived.session_id)),
("teacher", "instructor", "report.export.pdf", str(archived.session_id)),
]

View file

@ -0,0 +1,285 @@
"""Fail closed if a new API endpoint forgets its authentication gate.
This is a structural guard, not a substitute for the per-role and owner-scope
HTTP/WebSocket integration tests. Public endpoints are kept in a small explicit
allowlist so that adding a route cannot silently make it public.
"""
import ast
from pathlib import Path
API_ROOT = Path(__file__).parents[1] / "app" / "api"
# Public by design: credential entry/session bootstrap and the non-sensitive
# incident classification dictionary. dev-token has its own fail-closed flag
# and remains hidden in production.
PUBLIC_HTTP_ROUTES = {
("auth.py", "post", "/login"),
("auth.py", "post", "/dev-token"),
("http/ekp.py", "get", "/groups"),
("http/ekp.py", "get", "/signs"),
}
# Routes that centralize ownership + authentication checks in a shared helper.
DELEGATED_HTTP_AUTH = {
("http/sessions.py", "get", "/{session_id}/report"): "_report_data",
("http/sessions.py", "get", "/{session_id}/report.csv"): "_report_data",
("http/sessions.py", "get", "/{session_id}/report.pdf"): "_report_data",
}
# Each tuple is the exact positional Role allowlist passed to a route's
# require(request, ...). An empty tuple means any authenticated principal, with
# resource ownership checked in the handler. The outer tuple preserves routes
# that intentionally apply more than one gate (e.g. authentication then role).
HTTP_ROLE_GATE_POLICY = {
("http/admin.py", "get", "/config.xml"): (("ADMIN",),),
("http/admin.py", "get", "/users"): (("ADMIN",),),
("http/admin.py", "post", "/users"): (("ADMIN",),),
("http/admin.py", "patch", "/users/{user_id}"): (("ADMIN",),),
("http/admin.py", "get", "/audit"): (("ADMIN",),),
("http/admin.py", "get", "/audit.csv"): (("ADMIN",),),
("http/admin.py", "get", "/diagnostics"): (("ADMIN",),),
("http/admin.py", "get", "/diagnostics.json"): (("ADMIN",),),
("http/admin.py", "get", "/status"): (("ADMIN",),),
("http/admin.py", "get", "/backups"): (("ADMIN",),),
("http/admin.py", "post", "/backups"): (("ADMIN",),),
("http/groups.py", "get", ""): (("ADMIN", "INSTRUCTOR"),),
("http/groups.py", "post", ""): (("ADMIN", "INSTRUCTOR"),),
("http/groups.py", "patch", "/{group_id}/owner"): (("ADMIN",),),
("http/groups.py", "put", "/{group_id}/trainees/{trainee_id}"): (("ADMIN", "INSTRUCTOR"),),
("http/groups.py", "get", "/{group_id}/analytics"): (("ADMIN", "INSTRUCTOR"),),
("http/groups.py", "post", "/{group_id}/analytics/insight"): (("INSTRUCTOR",),),
("http/materials.py", "get", ""): ((), ("ADMIN", "INSTRUCTOR")),
("http/materials.py", "post", ""): (("INSTRUCTOR",),),
("http/materials.py", "patch", "/{material_id}"): (("INSTRUCTOR",),),
("http/materials.py", "delete", "/{material_id}"): (("INSTRUCTOR",),),
("http/materials.py", "put", "/{material_id}/assign/{trainee_id}"): (("INSTRUCTOR",),),
("http/materials.py", "put", "/{material_id}/assign-group/{group_id}"): (("INSTRUCTOR",),),
("http/materials.py", "delete", "/{material_id}/assign/{trainee_id}"): (("INSTRUCTOR",),),
("http/materials.py", "post", "/{material_id}/complete"): (("TRAINEE",),),
("http/materials.py", "post", "/{material_id}/start"): (("TRAINEE",),),
("http/materials.py", "get", "/{material_id}/download"): ((),),
("http/scenario_submissions.py", "post", ""): (("TRAINEE",),),
("http/scenario_submissions.py", "get", ""): (("ADMIN", "INSTRUCTOR", "TRAINEE"),),
("http/scenario_submissions.py", "post", "/{submission_id}/review"): (("ADMIN", "INSTRUCTOR"),),
("http/scenarios.py", "post", "/drafts/from-template"): (("INSTRUCTOR",),),
("http/scenarios.py", "post", "/drafts/generate"): (("INSTRUCTOR",),),
("http/scenarios.py", "post", "/drafts/generate-from-description"): (("INSTRUCTOR",),),
("http/scenarios.py", "get", "/drafts/{scenario_id}"): (("INSTRUCTOR",),),
("http/scenarios.py", "patch", "/drafts/{scenario_id}"): (("INSTRUCTOR",),),
("http/scenarios.py", "post", "/drafts/{scenario_id}/revise"): (("INSTRUCTOR",),),
("http/scenarios.py", "post", "/drafts/{scenario_id}/validate"): (("INSTRUCTOR",),),
("http/scenarios.py", "post", "/drafts/{scenario_id}/grammar-check"): (("INSTRUCTOR",),),
("http/scenarios.py", "post", "/drafts/{scenario_id}/approve"): (("INSTRUCTOR",),),
("http/scenarios.py", "get", ""): (("ADMIN", "INSTRUCTOR", "TRAINEE"),),
("http/scenarios.py", "delete", "/{scenario_id}"): (("INSTRUCTOR",),),
("http/scenarios.py", "post", "/{scenario_id}/restore"): (("INSTRUCTOR",),),
("http/scenarios.py", "get", "/{scenario_id}"): (("ADMIN", "INSTRUCTOR", "TRAINEE"),),
("http/sessions.py", "get", "/dds-history"): (("INSTRUCTOR", "TRAINEE"),),
("http/sessions.py", "get", "/active"): (("INSTRUCTOR",),),
("http/sessions.py", "post", ""): (("INSTRUCTOR",),),
("http/sessions.py", "get", "/{session_id}"): ((),),
("http/sessions.py", "get", "/{session_id}/checklist"): ((),),
("http/sessions.py", "get", "/{session_id}/recording.wav"): (("INSTRUCTOR", "TRAINEE"),),
("http/sessions.py", "patch", "/{session_id}/report"): (("INSTRUCTOR",),),
("http/sessions.py", "get", ""): ((),),
("http/trainees.py", "get", "/{trainee_id}/certificate.pdf"): ((),),
("http/trainees.py", "get", ""): (("ADMIN", "INSTRUCTOR"),),
("http/trainees.py", "get", "/{trainee_id}/profile"): ((),),
}
AUTH_SESSION_HTTP_ROUTES = {
("auth.py", "post", "/logout"),
("auth.py", "get", "/me"),
}
WEBSOCKET_ROLE_POLICY = {
("call.py", "/ws/call/{session_id}"): {"INSTRUCTOR", "TRAINEE"},
("control.py", "/ws/control/{session_id}"): {"INSTRUCTOR"},
("observe.py", "/ws/observe/{session_id}"): {"ADMIN", "INSTRUCTOR"},
("station.py", "/ws/station/{session_id}"): {"INSTRUCTOR", "TRAINEE"},
}
def _route_declaration(node: ast.FunctionDef | ast.AsyncFunctionDef):
for decorator in node.decorator_list:
if not isinstance(decorator, ast.Call) or not isinstance(decorator.func, ast.Attribute):
continue
method = decorator.func.attr.lower()
if method not in {"get", "post", "put", "patch", "delete", "websocket"}:
continue
path = decorator.args[0] if decorator.args else None
if isinstance(path, ast.Constant) and isinstance(path.value, str):
return method, path.value
return None
def _called_names(node: ast.AST) -> set[str]:
return {
call.func.id if isinstance(call.func, ast.Name) else call.func.attr
for call in ast.walk(node)
if isinstance(call, ast.Call)
and (isinstance(call.func, ast.Name) or isinstance(call.func, ast.Attribute))
}
def _required_role_gates(node: ast.AST) -> tuple[tuple[str, ...], ...]:
gates = []
for call in ast.walk(node):
if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Name) or call.func.id != "require":
continue
roles = tuple(sorted(
argument.attr
for argument in call.args[1:]
if isinstance(argument, ast.Attribute)
and isinstance(argument.value, ast.Name)
and argument.value.id == "Role"
))
gates.append(roles)
return tuple(sorted(gates))
def test_every_http_route_has_an_authentication_gate_or_explicit_public_reason():
discovered_public: set[tuple[str, str, str]] = set()
missing: list[str] = []
discovered_delegated: set[tuple[str, str, str]] = set()
sources = [*API_ROOT.glob("*.py"), *(API_ROOT / "http").glob("*.py")]
for source in sources:
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
relative = source.relative_to(API_ROOT).as_posix()
for node in tree.body:
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
route = _route_declaration(node)
if route is None:
continue
method, path = route
key = (relative, method, path)
calls = _called_names(node)
if key in PUBLIC_HTTP_ROUTES:
discovered_public.add(key)
continue
delegated_helper = DELEGATED_HTTP_AUTH.get(key)
if delegated_helper and delegated_helper in calls:
discovered_delegated.add(key)
elif not calls.intersection({"require", "current"}):
missing.append(f"{relative}:{node.name} ({method.upper()} {path})")
assert discovered_public == PUBLIC_HTTP_ROUTES, (
"Public endpoint allowlist drifted; review each newly removed/renamed route "
f"and keep the allowlist exact. Missing: {PUBLIC_HTTP_ROUTES - discovered_public}; "
f"unexpected: {discovered_public - PUBLIC_HTTP_ROUTES}"
)
assert discovered_delegated == set(DELEGATED_HTTP_AUTH), (
"Delegated-auth routes drifted; re-check their shared guard: "
f"missing {set(DELEGATED_HTTP_AUTH) - discovered_delegated}"
)
assert not missing, "HTTP routes without require/current authentication gate: " + "; ".join(missing)
for (relative, _, _), helper_name in DELEGATED_HTTP_AUTH.items():
tree = ast.parse((API_ROOT / relative).read_text(encoding="utf-8"))
helper = next(
node for node in tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == helper_name
)
assert "require" in _called_names(helper), (
f"delegated helper {relative}:{helper_name} must enforce authentication itself"
)
def test_every_websocket_route_checks_a_principal_before_serving():
missing: list[str] = []
for source in (API_ROOT / "ws").glob("*.py"):
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
for node in tree.body:
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
route = _route_declaration(node)
if route is None or route[0] != "websocket":
continue
if "principal_of" not in _called_names(node):
missing.append(f"{source.name}:{node.name} ({route[1]})")
assert not missing, "WebSocket routes without principal check: " + "; ".join(missing)
def test_http_routes_match_the_reviewed_role_gate_matrix():
found: dict[tuple[str, str, str], tuple[tuple[str, ...], ...]] = {}
session_guards: set[tuple[str, str, str]] = set()
discovered_routes: set[tuple[str, str, str]] = set()
for source in [*API_ROOT.glob("*.py"), *(API_ROOT / "http").glob("*.py")]:
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
relative = source.relative_to(API_ROOT).as_posix()
for node in tree.body:
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
route = _route_declaration(node)
if route is None:
continue
method, path = route
key = (relative, method, path)
discovered_routes.add(key)
if key in HTTP_ROLE_GATE_POLICY:
found[key] = _required_role_gates(node)
elif key in AUTH_SESSION_HTTP_ROUTES:
if "current" in _called_names(node):
session_guards.add(key)
reviewed_routes = (
set(HTTP_ROLE_GATE_POLICY)
| set(DELEGATED_HTTP_AUTH)
| set(PUBLIC_HTTP_ROUTES)
| AUTH_SESSION_HTTP_ROUTES
)
assert discovered_routes == reviewed_routes, (
"Every HTTP route must be categorized in the reviewed matrix; "
f"unreviewed={discovered_routes - reviewed_routes}, stale={reviewed_routes - discovered_routes}"
)
assert set(found) == set(HTTP_ROLE_GATE_POLICY), (
"The HTTP role matrix must enumerate every protected route; "
f"missing={set(HTTP_ROLE_GATE_POLICY) - set(found)}, "
f"unexpected={set(found) - set(HTTP_ROLE_GATE_POLICY)}"
)
differences = {
key: (HTTP_ROLE_GATE_POLICY[key], found[key])
for key in HTTP_ROLE_GATE_POLICY
if HTTP_ROLE_GATE_POLICY[key] != found[key]
}
assert not differences, f"HTTP route role-gate drift: {differences}"
assert session_guards == AUTH_SESSION_HTTP_ROUTES
def test_websocket_routes_match_the_reviewed_role_matrix():
found: dict[tuple[str, str], set[str]] = {}
for source in (API_ROOT / "ws").glob("*.py"):
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
for node in tree.body:
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
route = _route_declaration(node)
if route is None or route[0] != "websocket":
continue
roles = {
item.attr for item in ast.walk(node)
if isinstance(item, ast.Attribute)
and isinstance(item.value, ast.Name)
and item.value.id == "Role"
}
found[(source.name, route[1])] = roles
assert found == WEBSOCKET_ROLE_POLICY, f"WebSocket role policy drift: {found}"
def test_dev_token_remains_runtime_gated():
source = (API_ROOT / "auth.py").read_text(encoding="utf-8")
tree = ast.parse(source)
target = next(
node for node in tree.body
if isinstance(node, ast.AsyncFunctionDef) and node.name == "dev_token"
)
calls_and_names = {node.id for node in ast.walk(target) if isinstance(node, ast.Name)}
attributes = {node.attr for node in ast.walk(target) if isinstance(node, ast.Attribute)}
assert "dev_auth_bypass" in calls_and_names | attributes
assert "demo_no_db" in calls_and_names | attributes

View file

@ -8,13 +8,19 @@ from fastapi.testclient import TestClient
from app.api.http import scenarios as scenarios_api
from app.config import get_settings
from app.dialog.llm import LlmUnavailable
from app.db.models import AuditLog
from app.main import app
from app.scenarios import store
from app.scenarios import generation, store
from app.scenarios.editor import merge_patch, template_copy, validate
from app.scenarios import generation
from app.scenarios.generation import (GenerationError, correction_target,
full_proposal_body, parse_full_proposal,
parse_proposal, proposal_body, style_fallback)
from app.scenarios.generation import (
GenerationError,
correction_target,
full_proposal_body,
parse_full_proposal,
parse_proposal,
proposal_body,
style_fallback,
)
from app.scenarios.loader import ScenarioError, load_file
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
@ -25,23 +31,39 @@ class FakeSession:
def __init__(self):
self.rows = {}
self.audit_rows = []
self.commit_audit_counts = []
def add(self, row):
self.rows[row.id] = row
if isinstance(row, AuditLog):
self.audit_rows.append(row)
else:
self.rows[row.id] = row
async def get(self, model, key):
return self.rows.get(key)
async def commit(self):
pass
self.commit_audit_counts.append(len(self.audit_rows))
async def scalars(self, query):
expression = query.column_descriptions[0]["expr"]
if getattr(expression, "key", None) == "id":
owner_login = query.compile().params.get("owner_login_1")
owner_filter = next(
clause for clause in query.whereclause.clauses
if getattr(getattr(clause, "left", None), "key", None) == "owner_login"
and getattr(getattr(clause, "right", None), "value", None) is not None
)
owner_login = owner_filter.right.value
owner_operator = owner_filter.operator.__name__
return [
row.id for row in self.rows.values()
if row.status == "published" and row.owner_login == owner_login
if row.status == "published"
and (
row.owner_login == owner_login
if owner_operator == "eq"
else row.owner_login is not None and row.owner_login != owner_login
)
]
return [row for row in self.rows.values() if row.status == "published"]
@ -60,10 +82,18 @@ def client(monkeypatch):
monkeypatch.setattr(scenarios_api, "audit", no_audit)
monkeypatch.setattr(store, "restore_published", no_restore)
with TestClient(app) as test_client:
test_client.fake_db = db
yield test_client
app.dependency_overrides.clear()
def assert_atomic_audit(client, action: str, object_id: str) -> None:
row = client.fake_db.audit_rows[-1]
assert row.action == action
assert row.object_id == object_id
assert client.fake_db.commit_audit_counts[-1] == len(client.fake_db.audit_rows)
def test_template_copy_is_local_independent_and_valid():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
body = template_copy(source, "draft-example")
@ -87,6 +117,20 @@ def test_editor_rejects_derived_truth_and_missing_fact():
validate(broken)
def test_editor_can_save_explicit_scenario_decline_with_required_reason():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
body = template_copy(source, "draft-decline")
declined = merge_patch(body, {
"dds_decision": {
"expected": "decline",
"reason": "Повторный вызов уже отрабатывается по первой карточке.",
},
})
assert validate(declined).dds_decision.expected == "decline"
with pytest.raises(ScenarioError, match="reason обязателен"):
validate(merge_patch(body, {"dds_decision": {"expected": "decline"}}))
def test_ai_proposal_changes_only_story_and_keeps_reference():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
proposal = parse_proposal('''```json
@ -191,10 +235,10 @@ async def test_ai_generation_retries_copied_facts_with_strict_schema(monkeypatch
class FakeClient:
def __init__(self, **kwargs):
self.answers = iter([
'{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
'"facts":{"f_smoke":"дым идёт в подъезд, на площадке ничего не видно"}}',
'{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
'"facts":{"f_smoke":"лестница уже заполнена густым дымом"}}',
('{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
'"facts":{"f_smoke":"дым идёт в подъезд, на площадке ничего не видно"}}'),
('{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
'"facts":{"f_smoke":"лестница уже заполнена густым дымом"}}'),
])
async def complete(self, request, **kwargs):
@ -317,7 +361,13 @@ async def test_description_generation_retries_fact_that_is_a_question(monkeypatc
assert proposal["facts"]["f_people"].endswith("Пострадавших: 1")
def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client, monkeypatch):
from app.scoring.grammar import GrammarAssessment
async def fake_assess(_text):
return GrammarAssessment(True, (), "rules")
monkeypatch.setattr(scenarios_api, "assess", fake_assess)
source = next(s for s in store.all_scenarios() if s.id == "fire-apartment-l2")
assert client.post("/api/auth/dev-token").status_code == 200
response = client.post(
@ -329,6 +379,7 @@ def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
draft_id = draft["id"]
assert draft["generation"] == "template_copy"
assert draft["status"] == "draft"
assert_atomic_audit(client, "scenario.draft.create", draft_id)
assert store.get(draft_id) is None
assert client.get(f"/api/scenarios/{draft_id}").status_code == 404
@ -338,10 +389,14 @@ def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
)
assert changed.status_code == 200, changed.text
assert changed.json()["body"]["first_line"] == "Соседи! В доме дым!"
assert_atomic_audit(client, "scenario.draft.update", draft_id)
check = client.post(f"/api/scenarios/drafts/{draft_id}/validate")
assert check.status_code == 200 and check.json()["valid"]
grammar = client.post(f"/api/scenarios/drafts/{draft_id}/grammar-check")
assert grammar.status_code == 200 and grammar.json()["valid"]
approved = client.post(f"/api/scenarios/drafts/{draft_id}/approve")
assert approved.status_code == 200, approved.text
assert_atomic_audit(client, "scenario.approve", draft_id)
assert approved.json()["status"] == "published"
assert store.get(draft_id).first_line == "Соседи! В доме дым!"
assert client.get(f"/api/scenarios/{draft_id}").status_code == 200
@ -365,6 +420,7 @@ def test_ai_draft_requires_instructor_review_before_publication(client, monkeypa
draft = response.json()
assert draft["generation"] == "ai_variant"
assert draft["id"].startswith("ai-")
assert_atomic_audit(client, "scenario.draft.ai_generate", draft["id"])
assert draft["body"]["first_line"] == "Помогите, у нас горит балкон!"
assert store.get(draft["id"]) is None
assert client.get(f"/api/scenarios/{draft['id']}").status_code == 404
@ -438,9 +494,73 @@ def test_instructor_revises_same_ai_draft_by_comment(client, monkeypatch):
assert body["facts"][0]["value"] == "улица Ленина, 14, квартира 47, 5-й этаж"
assert next(item["value"] for item in body["facts"] if item["id"] == "f_smoke").startswith("чёрный")
assert comments[-1] == "Сделай дым чёрным и закрой им площадку"
assert_atomic_audit(client, "scenario.draft.ai_revise", draft_id)
audit_row = client.fake_db.audit_rows[-1]
assert audit_row.detail == "instruction_chars=38"
assert "чёрным" not in audit_row.detail
assert client.post(f"/api/scenarios/drafts/{draft_id}/validate").json()["valid"]
def test_manual_grammar_check_covers_caller_line_and_fact_values(client, monkeypatch):
from app.scoring.grammar import GrammarAssessment
checked = []
async def fake_assess(text):
checked.append(text)
return GrammarAssessment(True, (), "rules")
monkeypatch.setattr(scenarios_api, "assess", fake_assess)
client.post("/api/auth/dev-token")
created = client.post("/api/scenarios/drafts/from-template", json={
"source_id": "fire-apartment-l2",
})
assert created.status_code == 201, created.text
draft_id = created.json()["id"]
changed = client.patch(
f"/api/scenarios/drafts/{draft_id}",
json={"first_line": "Помогите! Горит балкон."},
)
assert changed.status_code == 200, changed.text
assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 409
response = client.post(f"/api/scenarios/drafts/{draft_id}/grammar-check")
assert response.status_code == 200, response.text
result = response.json()
assert result["valid"] is True
assert result["checks"][0]["field"] == "first_line"
assert len(result["checks"]) == 1 + len(changed.json()["body"]["facts"])
assert checked == [changed.json()["body"]["first_line"], *[
fact["value"] for fact in changed.json()["body"]["facts"]
]]
assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 200
def test_failed_grammar_check_does_not_authorize_manual_draft(client, monkeypatch):
from app.scoring.grammar import GrammarAssessment
async def fake_assess(_text):
return GrammarAssessment(False, ("тестовая языковая ошибка",), "rules")
monkeypatch.setattr(scenarios_api, "assess", fake_assess)
client.post("/api/auth/dev-token")
created = client.post("/api/scenarios/drafts/from-template", json={
"source_id": "fire-apartment-l2",
})
draft_id = created.json()["id"]
assert client.patch(f"/api/scenarios/drafts/{draft_id}",
json={"first_line": "пожар"}).status_code == 200
result = client.post(f"/api/scenarios/drafts/{draft_id}/grammar-check")
assert result.status_code == 200
assert result.json()["valid"] is False
assert result.json()["checks"][0]["errors"] == ["тестовая языковая ошибка"]
assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 409
def test_ai_editor_works_in_demo_lite_without_database(monkeypatch):
monkeypatch.setenv("DEMO_NO_DB", "true")
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
@ -517,11 +637,13 @@ def test_instructor_archives_and_restores_scenario_without_deleting_history(clie
scenario_id = created.json()["id"]
approved = client.post(f"/api/scenarios/drafts/{scenario_id}/approve")
assert approved.status_code == 200, approved.text
assert_atomic_audit(client, "scenario.approve", scenario_id)
original = store.get(scenario_id)
assert original is not None
archived = client.delete(f"/api/scenarios/{scenario_id}")
assert archived.status_code == 200, archived.text
assert_atomic_audit(client, "scenario.archive", scenario_id)
assert archived.json()["status"] == "archived"
assert store.get(scenario_id) is None
assert scenario_id not in {item["id"] for item in client.get("/api/scenarios").json()}
@ -529,6 +651,7 @@ def test_instructor_archives_and_restores_scenario_without_deleting_history(clie
restored = client.post(f"/api/scenarios/{scenario_id}/restore")
assert restored.status_code == 200, restored.text
assert_atomic_audit(client, "scenario.restore", scenario_id)
assert restored.json()["status"] == "published"
assert store.get(scenario_id).title == original.title
assert scenario_id in {item["id"] for item in client.get("/api/scenarios").json()}
@ -544,7 +667,6 @@ def test_instructor_cannot_read_or_edit_another_instructors_draft(client, monkey
return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
monkeypatch.setattr(scenarios_api, "require", instructor)
monkeypatch.setattr(scenarios_api, "current", instructor)
created = client.post(
"/api/scenarios/drafts/from-template",
json={"source_id": "fire-apartment-l2", "title": "Личный черновик"},
@ -570,7 +692,6 @@ def test_instructor_cannot_archive_another_instructors_published_scenario(client
return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
monkeypatch.setattr(scenarios_api, "require", instructor)
monkeypatch.setattr(scenarios_api, "current", instructor)
created = client.post(
"/api/scenarios/drafts/from-template",
json={"source_id": "fire-apartment-l2", "title": "Публикация автора"},
@ -582,8 +703,8 @@ def test_instructor_cannot_archive_another_instructors_published_scenario(client
assert scenario["can_manage"] is True
identity["login"] = "teacher-two"
scenario = next(item for item in client.get("/api/scenarios").json() if item["id"] == scenario_id)
assert scenario["can_manage"] is False
assert scenario_id not in {item["id"] for item in client.get("/api/scenarios").json()}
assert client.get(f"/api/scenarios/{scenario_id}").status_code == 404
assert client.delete(f"/api/scenarios/{scenario_id}").status_code == 404

View file

@ -0,0 +1,448 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
from uuid import UUID
import pytest
from fastapi.testclient import TestClient
from app.api import auth
from app.api.http import scenario_submissions
from app.api.http.scenario_submissions import reset_demo_submissions
from app.config import get_settings
from app.db.models import AuditLog, Group, Scenario, ScenarioSubmission, Trainee
from app.domain import ekp
from app.domain.events import Exercise, SessionMode
from app.main import app
from app.scenarios import store
from app.session.dds import prepare_card
from app.session.state import SessionState
@pytest.fixture
def client(monkeypatch):
monkeypatch.setenv("DEMO_NO_DB", "true")
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
get_settings.cache_clear()
store.reset_demo_drafts()
reset_demo_submissions()
try:
with TestClient(app) as test_client:
yield test_client
finally:
get_settings.cache_clear()
def _login(client: TestClient, role: str) -> None:
response = client.post("/api/auth/dev-token", params={"role": role})
assert response.status_code == 200, response.text
def _student_kio(
description="В мастерской виден дым из повреждённого оборудования.",
address="Москва, учебная улица, дом 10",
):
source = store.get("t01-1-fire-container")
assert source is not None
return {
"caller_name": "Учебный заявитель",
"caller_contact": "+7 900 000-00-00",
"address": address,
"description": description,
"incident_group": ekp.incident(source.ground_truth.incident_code).group,
"signs": source.signs,
"incident_type": "fire",
"dds": source.ground_truth.dds.value,
"victims_count": 0,
"fire": {"object_kind": "оборудование", "fire_nature": "задымление"},
}
def test_trainee_scenario_catalog_and_detail_only_expose_safe_self_practice_fields(client):
_login(client, "trainee")
listed = client.get("/api/scenarios").json()
item = next(row for row in listed if row["id"] == "t01-1-fire-container")
assert set(item) == {"id", "title", "level", "modes"}
assert "self" in item["modes"]
detail = client.get("/api/scenarios/t01-1-fire-container")
assert detail.status_code == 200
assert set(detail.json()) == {"id", "title", "level", "modes"}
assert client.get("/api/scenarios/t01-1-fire-container").json().get("ground_truth") is None
def test_student_submission_is_moderated_then_enters_dds_bank(client):
_login(client, "trainee")
created = client.post(
"/api/scenario-submissions",
json={
"title": "Пожар в мастерской",
"level": "L2",
"kio": _student_kio(
"В мастерской на первом этаже виден дым, люди вышли наружу.",
"Москва, улица Примерная, дом 12",
),
},
)
assert created.status_code == 201, created.text
submission_id = created.json()["id"]
assert created.json()["status"] == "pending"
assert created.json()["scenario_id"] is None
assert client.get("/api/scenarios").status_code == 200
assert not any("student-created" in item.get("topics", [])
for item in client.get("/api/scenarios").json())
# Курсанту разрешено видеть своё предложение, но не публиковать его.
own = client.get("/api/scenario-submissions").json()
assert [item["id"] for item in own] == [submission_id]
denied = client.post(
f"/api/scenario-submissions/{submission_id}/review",
json={"decision": "approve"},
)
assert denied.status_code == 403
client.post("/api/auth/logout")
_login(client, "instructor")
pending = client.get("/api/scenario-submissions").json()
assert pending[0]["title"] == "Пожар в мастерской"
approved = client.post(
f"/api/scenario-submissions/{submission_id}/review",
json={"decision": "approve", "comment": "Факты проверены."},
)
assert approved.status_code == 200, approved.text
body = approved.json()
assert body["status"] == "approved"
assert body["scenario_id"].startswith("student-")
scenario = store.get(body["scenario_id"])
assert scenario is not None
assert scenario.ground_truth.address == "Москва, улица Примерная, дом 12"
assert scenario.ground_truth.victims == 0
assert "student-created" in scenario.topics
listed = client.get("/api/scenarios").json()
published = next(item for item in listed if item["id"] == scenario.id)
assert published["source"] == "trainee"
assert published["outcome"] == "card"
assert published["dds"] == "01"
repeated = client.post(
f"/api/scenario-submissions/{submission_id}/review",
json={"decision": "approve"},
)
assert repeated.status_code == 409
def test_submitted_kio_is_kept_intact_and_becomes_the_dds_card_after_approval(client):
source = store.get("t01-1-fire-container")
assert source is not None
_login(client, "trainee")
created = client.post(
"/api/scenario-submissions",
json={
"title": "КИО курсанта: контейнер во дворе",
"level": "L2",
"kio": {
"caller_name": "Учебный заявитель",
"caller_contact": "+7 900 000-00-00",
"address": "Москва, учебная улица, дом 10",
"description": "Во дворе открыто горит мусорный контейнер.",
"incident_group": ekp.incident(source.ground_truth.incident_code).group,
"signs": source.signs,
"incident_type": "fire",
"dds": source.ground_truth.dds.value,
"victims_count": 0,
"fire": {
"object_kind": "мусорный контейнер",
"fire_nature": "открытое пламя",
},
},
},
)
assert created.status_code == 201, created.text
body = created.json()
assert body["status"] == "pending"
assert body["kio"]["caller_number"] is None
assert body["kio"]["caller_name"] == "Учебный заявитель"
assert not any("student-created" in item.get("topics", [])
for item in client.get("/api/scenarios").json())
client.post("/api/auth/logout")
_login(client, "instructor")
approved = client.post(
f"/api/scenario-submissions/{body['id']}/review",
json={"decision": "approve", "comment": "КИО проверена."},
)
assert approved.status_code == 200, approved.text
scenario = store.get(approved.json()["scenario_id"])
assert scenario is not None
assert scenario.student_card is not None
assert scenario.student_card.address == "Москва, учебная улица, дом 10"
assert scenario.student_card.caller_name == "Учебный заявитель"
assert scenario.student_card.victims_count == 0
assert scenario.student_card.fire.object_kind == "мусорный контейнер"
assert scenario.student_card.signs == source.signs
assert "moderated-kio" in scenario.topics
state = SessionState(
session_id=UUID("00000000-0000-4000-8000-000000000701"),
scenario_id=scenario.id,
scenario_title=scenario.title,
level=scenario.level.value,
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
)
prepare_card(state, scenario)
assert state.dispatched_card is not None
assert state.dispatched_card.address == scenario.student_card.address
assert state.dispatched_card.caller_name == "Учебный заявитель"
assert state.dispatched_card.fire.object_kind == "мусорный контейнер"
assert any(
item["id"] == scenario.id and item["source"] == "trainee"
for item in client.get("/api/scenarios").json()
)
def test_database_submission_path_persists_kio_and_publishes_on_approval(
client,
monkeypatch,
):
source = store.get("t01-1-fire-container")
assert source is not None
trainee_id = UUID("00000000-0000-4000-8000-000000000112")
group_id = UUID("00000000-0000-4000-8000-000000000113")
class FakeDb:
submission = None
scenario_row = None
def __init__(self):
self.audit_rows = []
self.commit_rows = []
async def get(self, model, _key):
if model is Trainee:
return SimpleNamespace(id=trainee_id, group_id=group_id)
if model is Group:
return SimpleNamespace(id=group_id, owner_login="demo-instructor")
raise AssertionError(f"unexpected model: {model}")
def add(self, row):
if isinstance(row, ScenarioSubmission):
self.submission = row
row.status = "pending"
row.created_at = datetime.now(UTC)
elif isinstance(row, Scenario):
self.scenario_row = row
elif isinstance(row, AuditLog):
self.audit_rows.append(row)
else:
raise AssertionError(f"unexpected row: {type(row)}")
async def commit(self):
self.commit_rows.append(tuple(self.audit_rows))
async def scalar(self, _query):
return self.submission
fake_db = FakeDb()
async def session_override():
yield fake_db
monkeypatch.setitem(
app.dependency_overrides,
scenario_submissions.submission_session,
session_override,
)
_login(client, "trainee")
created = client.post(
"/api/scenario-submissions",
json={
"title": "КИО в DB-пути",
"level": "L2",
"kio": {
"caller_name": "Учебный заявитель",
"address": "Москва, тестовая улица, дом 3",
"description": "Во дворе открыто горит мусорный контейнер.",
"incident_group": ekp.incident(source.ground_truth.incident_code).group,
"signs": source.signs,
"incident_type": "fire",
"dds": source.ground_truth.dds.value,
"victims_count": 0,
"fire": {"object_kind": "мусорный контейнер"},
},
},
)
assert created.status_code == 201, created.text
assert fake_db.submission.kio["address"] == "Москва, тестовая улица, дом 3"
assert created.json()["status"] == "pending"
assert fake_db.commit_rows[0][0].action == "scenario.submission.create"
client.post("/api/auth/logout")
_login(client, "instructor")
approved = client.post(
f"/api/scenario-submissions/{created.json()['id']}/review",
json={"decision": "approve", "comment": "Проверено."},
)
assert approved.status_code == 200, approved.text
assert fake_db.scenario_row.owner_login == "demo-instructor"
assert fake_db.commit_rows[1][-1].action == "scenario.submission.approve"
persisted_kio = fake_db.scenario_row.body["student_card"]
assert persisted_kio["address"] == "Москва, тестовая улица, дом 3"
assert approved.json()["status"] == "approved"
def test_rejection_requires_comment_and_returns_proposal_to_student(client, monkeypatch):
audit_rows = []
async def capture_audit(*args):
audit_rows.append(args)
monkeypatch.setattr(scenario_submissions, "audit", capture_audit)
_login(client, "trainee")
response = client.post(
"/api/scenario-submissions",
json={
"title": "Обстановка на объекте",
"level": "L1",
"kio": _student_kio(
"В помещении обнаружено повреждение инженерного оборудования."
),
},
)
submission_id = response.json()["id"]
client.post("/api/auth/logout")
_login(client, "instructor")
missing_reason = client.post(
f"/api/scenario-submissions/{submission_id}/review", json={"decision": "reject"}
)
assert missing_reason.status_code == 422
rejected = client.post(
f"/api/scenario-submissions/{submission_id}/review",
json={
"decision": "reject",
"comment": "Уточните место и наблюдаемые признаки.",
},
)
assert rejected.status_code == 200
assert rejected.json()["status"] == "rejected"
assert audit_rows[-1][-1] == "comment_chars=38"
assert "Уточните место" not in audit_rows[-1][-1]
client.post("/api/auth/logout")
_login(client, "trainee")
own = client.get("/api/scenario-submissions").json()
assert own[0]["status"] == "rejected"
assert own[0]["review_comment"] == "Уточните место и наблюдаемые признаки."
assert not any("student-created" in item.get("topics", [])
for item in client.get("/api/scenarios").json())
def test_submission_validation_rejects_short_description(client):
_login(client, "trainee")
response = client.post(
"/api/scenario-submissions",
json={
"title": "Короткая заявка",
"level": "L1",
"kio": _student_kio("дым"),
},
)
assert response.status_code == 422
def test_submission_requires_structured_kio_not_legacy_free_text(client):
_login(client, "trainee")
response = client.post(
"/api/scenario-submissions",
json={
"title": "Только текст",
"level": "L1",
"incident_type": "fire",
"description": "В мастерской обнаружены дым и повреждение оборудования.",
"address": "Москва, учебная улица, дом 10",
},
)
assert response.status_code == 422
def test_submission_rejects_whitespace_only_title(client):
_login(client, "trainee")
response = client.post(
"/api/scenario-submissions",
json={"title": " ", "level": "L1", "kio": _student_kio()},
)
assert response.status_code == 422
def test_submission_uses_street_and_building_when_address_is_blank(client):
_login(client, "trainee")
kio = _student_kio()
kio.update({"address": " ", "street": "Учебная улица", "building": "12"})
response = client.post(
"/api/scenario-submissions",
json={"title": "Проверка адреса", "level": "L1", "kio": kio},
)
assert response.status_code == 201, response.text
assert response.json()["address"] == "Учебная улица 12"
@pytest.mark.parametrize(
("group_id", "group_owner", "detail"),
[
(None, None, "trainee_group_required_for_review"),
(
UUID("00000000-0000-4000-8000-000000000001"),
None,
"instructor_group_required_for_review",
),
],
)
def test_submission_requires_a_group_with_a_moderating_instructor(
client,
monkeypatch,
group_id,
group_owner,
detail,
):
trainee_id = UUID("00000000-0000-4000-8000-000000000112")
monkeypatch.setattr(
scenario_submissions,
"require",
lambda *_args, **_kwargs: auth.Principal(
login="student",
full_name="Курсант",
role=auth.Role.TRAINEE,
trainee_id=trainee_id,
),
)
class FakeDb:
async def get(self, model, _key):
if model.__name__ == "Trainee":
return SimpleNamespace(group_id=group_id)
return SimpleNamespace(owner_login=group_owner)
def add(self, _row):
raise AssertionError("proposal must not be stored without a moderator")
async def session_override():
yield FakeDb()
app.dependency_overrides[scenario_submissions.submission_session] = session_override
try:
response = client.post(
"/api/scenario-submissions",
json={
"title": "Пожар в мастерской",
"level": "L1",
"kio": _student_kio(
"В мастерской обнаружены дым и повреждение оборудования."
),
},
)
finally:
app.dependency_overrides.pop(scenario_submissions.submission_session, None)
assert response.status_code == 409
assert response.json()["detail"] == detail

View file

@ -68,12 +68,19 @@ def test_broken_yaml_names_the_file(tmp_path):
load_file(path, tmp_path)
def test_hidden_fact_without_approach_is_rejected(tmp_path):
body = VALID.replace(
@pytest.mark.parametrize("replace_text", [
(
' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr } }',
' - { id: f_addr, value: "Ленина, 1", hidden: true, reveal_on: { question: q_addr } }',
)
with pytest.raises(ScenarioError, match="hidden требует"):
),
(
' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr } }',
' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr, approach: "проявил эмпатию" } }',
),
])
def test_llm_controlled_fact_disclosure_is_rejected(tmp_path, replace_text):
body = VALID.replace(*replace_text)
with pytest.raises(ScenarioError, match="Extra inputs are not permitted"):
load_file(write(tmp_path, body), tmp_path)
@ -83,6 +90,38 @@ def test_checklist_pointing_at_missing_fact_is_rejected(tmp_path):
load_file(write(tmp_path, body), tmp_path)
def test_duplicate_fact_ids_are_rejected_before_they_can_change_ground_truth(tmp_path):
fact = ' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr } }'
body = VALID.replace(fact, fact + '\n - { id: f_addr, value: "Ленина, 2", reveal_on: { question: q_addr } }')
with pytest.raises(ScenarioError, match="id фактов должны быть уникальны"):
load_file(write(tmp_path, body), tmp_path)
def test_duplicate_local_checklist_ids_are_not_silently_merged(tmp_path):
item = ' - { id: q_addr, question: "Адрес?", fact: f_addr }'
body = VALID.replace("checklist:\n" + item, "checklist:\n" + item + "\n" + item)
with pytest.raises(ScenarioError, match="повторяются id пунктов чек-листа"):
load_file(write(tmp_path, body), tmp_path)
@pytest.mark.parametrize(("fields", "message"), [
('[address, coordinates]', "отсутствуют в форме КИО"),
('[card_id]', "заполняются системой"),
('[registered_at]', "заполняются системой"),
('[address, address]', "повторяются поля"),
])
def test_required_fields_must_be_unique_and_fillable_in_kio_form(tmp_path, fields, message):
body = VALID + f"\nrequired_fields: {fields}\n"
with pytest.raises(ScenarioError, match=message):
load_file(write(tmp_path, body), tmp_path)
def test_non_card_outcomes_cannot_require_kio_fields(tmp_path):
body = VALID + "\noutcome: consultation\nrequired_fields: [address]\n"
with pytest.raises(ScenarioError, match="required_fields должны быть пустыми"):
load_file(write(tmp_path, body), tmp_path)
def test_typo_in_field_name_is_rejected(tmp_path):
"""Схема строгая: опечатка должна падать на старте, а не игнорироваться."""
body = VALID.replace("level: L1", "level: L1\nfirst_lines: 'опечатка'")

View file

@ -1,21 +1,52 @@
"""HTTP-ссылки на занятие не дают курсанту чужую карточку или чек-лист."""
from datetime import UTC, datetime
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import HTTPException, Request
from app.db import repo
from app.db.models import Session, Utterance
from app.db.repo import SessionNodeConflict, ensure_session
from app.api.auth import Principal
from app.api.http import sessions
from app.domain.events import Exercise
from app.api.ws import call as call_ws
from app.api.ws import observe as observe_ws
from app.api.ws import station as station_ws
from app.domain.events import Exercise, SessionMode
from app.domain.roles import Role
from app.session.checkpoint import dump_state
from app.session.hub import SessionHub
from app.session.state import SessionState
from app.session.journal import DbJournal
def request() -> Request:
return Request({"type": "http", "method": "GET", "path": "/", "headers": []})
@pytest.mark.asyncio
async def test_journal_write_failure_does_not_log_user_text(caplog):
private_text = "private caller address and medical detail"
class FakeDb:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def fail_write(_db, text):
raise RuntimeError(text)
journal = DbJournal(lambda: FakeDb())
await journal._write(fail_write, private_text)
assert private_text not in caplog.text
assert "RuntimeError" in caplog.text
@pytest.mark.asyncio
async def test_trainee_cannot_read_foreign_session(monkeypatch):
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
@ -44,6 +75,69 @@ async def test_instructor_cannot_read_foreign_session(monkeypatch):
assert error.value.status_code == 404
@pytest.mark.asyncio
@pytest.mark.parametrize("endpoint", [sessions.report, sessions.report_csv, sessions.report_pdf])
async def test_instructor_cannot_read_or_export_foreign_archived_report(monkeypatch, endpoint):
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _request: who)
monkeypatch.setattr(sessions.hub, "get", lambda _session_id: None)
async def row(_db, _session_id):
return SimpleNamespace(owner_login="teacher-b", trainee_id=uuid4())
monkeypatch.setattr(sessions.repo, "get_session", row)
with pytest.raises(HTTPException) as error:
await endpoint(uuid4(), request(), db=object())
assert error.value.status_code == 404
@pytest.mark.asyncio
async def test_trainee_cannot_export_foreign_archived_report(monkeypatch):
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
monkeypatch.setattr(sessions, "require", lambda _request: who)
monkeypatch.setattr(sessions.hub, "get", lambda _session_id: None)
async def row(_db, _session_id):
return SimpleNamespace(owner_login="teacher-a", trainee_id=uuid4())
monkeypatch.setattr(sessions.repo, "get_session", row)
with pytest.raises(HTTPException) as error:
await sessions.report_pdf(uuid4(), request(), db=object())
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_instructor_cannot_download_foreign_recording(monkeypatch):
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _request, *_roles: who)
monkeypatch.setattr(sessions, "get_settings", lambda: SimpleNamespace(demo_no_db=False))
async def row(_db, _session_id):
return SimpleNamespace(
owner_login="teacher-b", trainee_id=uuid4(), ended_at=None,
)
monkeypatch.setattr(sessions.repo, "get_session", row)
with pytest.raises(HTTPException) as error:
await sessions.recording(uuid4(), request(), db=object())
assert error.value.status_code == 404
@pytest.mark.asyncio
async def test_instructor_cannot_override_foreign_archived_score(monkeypatch):
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _request, *_roles: who)
async def row(_db, _session_id):
return SimpleNamespace(owner_login="teacher-b")
monkeypatch.setattr(sessions.repo, "get_session", row)
body = sessions.ScoreOverride(score_final=80, comment="Проверка")
with pytest.raises(HTTPException) as error:
await sessions.override(uuid4(), body, request(), db=object())
assert error.value.status_code == 404
@pytest.mark.asyncio
async def test_instructor_history_is_scoped_to_owner(monkeypatch):
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
@ -59,6 +153,251 @@ async def test_instructor_history_is_scoped_to_owner(monkeypatch):
assert seen["owner_login"] == "teacher-a"
@pytest.mark.asyncio
async def test_demo_history_lists_only_owned_sessions_and_applies_filters(monkeypatch):
owner_trainee = uuid4()
other_trainee = uuid4()
older = datetime(2026, 9, 20, tzinfo=UTC)
newer = datetime(2026, 9, 25, tzinfo=UTC)
demo_hub = SessionHub()
for session_id, owner, trainee_id, mode, started, ended in [
(uuid4(), "teacher-a", owner_trainee, SessionMode.TRAINING, older, newer),
(uuid4(), "teacher-b", owner_trainee, SessionMode.TRAINING, newer, newer),
(uuid4(), "teacher-a", other_trainee, SessionMode.EXAM, newer, newer),
]:
demo_hub.register(SimpleNamespace(
session_id=session_id, scenario_id="ticket-demo", mode=mode, attempt=1,
trainee_id=trainee_id, owner_login=owner, lease_fenced=False,
started_at=started, ended_at=ended, end_reason=None,
))
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _request: who)
monkeypatch.setattr(sessions, "hub", demo_hub)
monkeypatch.setattr(sessions, "get_settings", lambda: SimpleNamespace(demo_no_db=True))
rows = await sessions.listing(
request(), trainee=owner_trainee, mode=SessionMode.TRAINING,
since=None, limit=100, db=None,
)
assert len(rows) == 1
assert rows[0].trainee_id == owner_trainee
assert rows[0].mode is SessionMode.TRAINING
assert rows[0].ended_at == newer
# Demo memory has no group membership records and must not ignore a group filter.
assert await sessions.listing(request(), group=uuid4(), since=None, limit=100, db=None) == []
trainee = Principal(
login="learner", full_name="Курсант", role=Role.TRAINEE, trainee_id=owner_trainee,
)
monkeypatch.setattr(sessions, "require", lambda _request: trainee)
trainee_rows = await sessions.listing(
request(), trainee=other_trainee, since=None, limit=100, db=None,
)
assert trainee_rows
assert {row.trainee_id for row in trainee_rows} == {owner_trainee}
unlinked = Principal(login="unlinked", full_name="Без профиля", role=Role.TRAINEE)
monkeypatch.setattr(sessions, "require", lambda _request: unlinked)
with pytest.raises(HTTPException) as error:
await sessions.listing(request(), since=None, limit=100, db=None)
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_dds_history_returns_archived_cards_only_for_trainee(monkeypatch):
trainee_id = uuid4()
session_id = uuid4()
card_id = uuid4()
ended_at = datetime.now(UTC)
who = Principal(
login="trainee-a", full_name="Курсант A", role=Role.TRAINEE,
trainee_id=trainee_id,
)
monkeypatch.setattr(sessions, "require", lambda *_args, **_kwargs: who)
audit_events = []
async def capture_audit(actor, role, action, object_id=None, detail=""):
audit_events.append((actor, role, action, object_id, detail))
monkeypatch.setattr(sessions, "audit_required", capture_audit)
class Rows:
def all(self):
return [(
SimpleNamespace(id=session_id, ended_at=ended_at),
SimpleNamespace(score_final=82.5, report={"full_report": {
"exercise": "dds",
"card_results": [{
"card_id": str(card_id), "scenario_id": "fire-apartment",
"score_auto": 80, "reply_text": "Бригада направлена",
"title": "Пожар", "address": "улица Лесная, 4",
"incident_type": "fire", "victims_count": 1,
"managed_service": "01", "recipient_services": ["01", "03"],
}],
}}),
)]
class Database:
statement = None
async def execute(self, statement):
self.statement = statement
return Rows()
db = Database()
result = await sessions.dds_history(request(), limit=200, db=db)
sql = str(db.statement.compile(compile_kwargs={"literal_binds": True}))
assert "sessions.trainee_id" in sql
assert trainee_id.hex in sql
assert len(result) == 1
assert result[0].session_id == session_id
assert result[0].card_id == card_id
assert result[0].address == "улица Лесная, 4"
assert result[0].score_final == 82.5
assert audit_events == [("trainee-a", "trainee", "dds.history.read", None, "cards=1")]
@pytest.mark.asyncio
async def test_dds_history_rejects_admin_role(monkeypatch):
who = Principal(login="admin", full_name="Администратор", role=Role.ADMIN)
def require(*_args, **_kwargs):
raise HTTPException(status_code=403, detail="forbidden")
monkeypatch.setattr(sessions, "require", require)
with pytest.raises(HTTPException) as error:
await sessions.dds_history(request(), limit=200, db=None)
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_live_registry_is_scoped_to_current_instructor(monkeypatch):
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _request, _role: who)
seen = {}
def active_sessions(owner_login):
seen["owner_login"] = owner_login
return []
monkeypatch.setattr(sessions.hub, "active_sessions", active_sessions)
assert await sessions.active(request(), db=None) == []
assert seen["owner_login"] == "teacher-a"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("ws_module", "handler_name"),
[
(observe_ws, "observe"),
(call_ws, "call"),
(station_ws, "station"),
],
)
async def test_instructor_cannot_join_foreign_live_session(monkeypatch, ws_module, handler_name):
session_id = uuid4()
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
state = SimpleNamespace(owner_login="teacher-b")
monkeypatch.setattr(ws_module, "websocket_origin_allowed", lambda _ws: True)
monkeypatch.setattr(ws_module, "principal_of", lambda _ws: who)
monkeypatch.setattr(ws_module.hub, "get", lambda _session_id: state)
class Socket:
def __init__(self):
self.accepted = False
self.closed = False
self.messages = []
async def accept(self):
self.accepted = True
async def send_text(self, message):
self.messages.append(message)
async def close(self):
self.closed = True
socket = Socket()
await getattr(ws_module, handler_name)(socket, session_id)
assert socket.accepted and socket.closed
assert len(socket.messages) == 1
assert '"code":"session_not_found"' in socket.messages[0]
assert "teacher-b" not in socket.messages[0]
@pytest.mark.asyncio
async def test_live_registry_includes_owned_checkpoints_from_other_nodes(monkeypatch):
owner = "teacher-a"
who = Principal(login=owner, full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _request, _role: who)
monkeypatch.setattr(sessions.hub, "active_sessions", lambda _owner: [])
state = SessionState(
session_id=uuid4(),
scenario_id="remote-case",
scenario_title="Удалённое занятие",
level="L2",
mode=SessionMode.TRAINING,
owner_login=owner,
exercise=Exercise.DDS,
trainee_name="Курсант",
)
state.started_at = datetime.now(UTC)
row = SimpleNamespace(
id=state.session_id,
owner_login=owner,
ended_at=None,
live_state=dump_state(state),
checkpoint_at=datetime.now(UTC),
)
class Rows:
def all(self):
return [row]
class FakeDb:
async def scalars(self, _query):
return Rows()
result = await sessions.active(request(), db=FakeDb())
assert len(result) == 1
assert result[0].session_id == state.session_id
assert result[0].trainee_name == "Курсант"
assert result[0].scenario_id == "remote-case"
@pytest.mark.asyncio
async def test_new_http_session_is_assigned_to_backend_node_at_creation(monkeypatch):
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _request, _role: who)
monkeypatch.setattr(sessions, "get_settings", lambda: SimpleNamespace(backend_node_id="node-a"))
monkeypatch.setattr(sessions.repo, "ensure_group", lambda *_args, **_kwargs: None)
seen = {}
async def create_session(_db, **kwargs):
seen.update(kwargs)
now = datetime.now(UTC)
return SimpleNamespace(
id=uuid4(), scenario_id=kwargs["scenario_id"], mode=kwargs["mode"],
attempt=1, trainee_id=None, group_id=None,
started_at=None, ended_at=None, end_reason=None, created_at=now,
)
async def audit(*_args, **_kwargs):
return None
monkeypatch.setattr(sessions.repo, "create_session", create_session)
monkeypatch.setattr(sessions, "audit", audit)
result = await sessions.create(
sessions.SessionCreate(scenario_id="case", mode=SessionMode.TRAINING),
request(), db=object(),
)
assert result.scenario_id == "case"
assert seen["backend_node_id"] == "node-a"
@pytest.mark.asyncio
async def test_trainee_cannot_read_foreign_checklist(monkeypatch):
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
@ -91,3 +430,140 @@ async def test_trainee_without_profile_cannot_list_everyones_sessions(monkeypatc
with pytest.raises(HTTPException) as error:
await sessions.listing(request(), db=object())
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_existing_session_keeps_its_backend_owner():
existing = SimpleNamespace(
owner_login="teacher-a", backend_node_id="node-a"
)
class FakeDb:
async def scalar(self, _query):
return existing
with pytest.raises(SessionNodeConflict):
await ensure_session(
FakeDb(),
session_id=uuid4(),
scenario_id="case",
mode="training",
owner_login="teacher-a",
backend_node_id="node-b",
)
@pytest.mark.asyncio
async def test_unassigned_existing_session_is_claimed_once():
existing = SimpleNamespace(owner_login="teacher-a", backend_node_id=None)
class FakeDb:
commits = 0
async def scalar(self, _query):
return existing
async def commit(self):
self.commits += 1
db = FakeDb()
result = await ensure_session(
db,
session_id=uuid4(),
scenario_id="case",
mode="training",
owner_login="teacher-a",
backend_node_id="node-a",
)
assert result.backend_node_id == "node-a"
assert db.commits == 1
@pytest.mark.asyncio
async def test_journal_assigns_new_lesson_to_its_backend_node(monkeypatch):
seen = {}
trainee_id = uuid4()
row = SimpleNamespace(attempt=3, trainee_id=trainee_id)
async def ensure(_db, **kwargs):
seen.update(kwargs)
return row
monkeypatch.setattr(repo, "ensure_session", ensure)
class FakeDb:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def scalar(self, _query):
return "01"
journal = DbJournal(lambda: FakeDb(), node_id="node-a")
result = await journal.start_lesson(
uuid4(), "case", "training", "Курсант", trainee_id,
owner_login="teacher-a",
)
assert result == (3, trainee_id, "01", 1)
assert seen["backend_node_id"] == "node-a"
assert callable(seen["before_commit"]), "session creation must carry its audit into commit"
@pytest.mark.asyncio
async def test_journal_restores_checkpoint_owned_by_this_node():
owner = "teacher-a"
state = SessionState(
session_id=uuid4(),
scenario_id="case",
scenario_title="Удалённая сессия",
level="L1",
mode=SessionMode.TRAINING,
owner_login=owner,
exercise=Exercise.DDS,
)
state.started_at = datetime.now(UTC)
row = SimpleNamespace(
id=state.session_id,
owner_login=owner,
backend_node_id="node-a",
backend_fencing_epoch=0,
backend_lease_until=None,
live_state=dump_state(state),
checkpoint_at=datetime.now(UTC),
)
class Rows:
def __init__(self, values):
self.values = values
def all(self):
return self.values
class FakeDb:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def execute(self, _statement):
return None
async def commit(self):
return None
async def scalars(self, statement):
entity = statement.column_descriptions[0]["entity"]
if entity is Utterance:
return Rows([])
if "backend_node_id IS NULL" in str(statement):
return Rows([])
return Rows([row])
journal = DbJournal(lambda: FakeDb(), node_id="node-a")
restored = await journal.restore_active()
assert len(restored) == 1
assert restored[0].session_id == state.session_id
assert restored[0].owner_login == owner

View file

@ -1,18 +1,20 @@
"""Промежуточное состояние занятия переживает смену backend-процесса."""
import asyncio
from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4
import pytest
from app.domain.events import Exercise, LessonCriteria, SessionMode
from app.domain.events import CommandAck, CallStarted, Exercise, LessonCriteria, SessionMode
from app.domain.kio import KIO
from app.domain.statuses import PhoneCallPending, ServiceStatus
from app.domain.timers import TimerCode
from app.scenarios.loader import load_file
from app.scoring.grammar import basic_check
from app.session.checkpoint import dump_state, load_state
from app.session.dds import deliver_due_cards, prepare_queue
from app.session.dds import deliver_due_cards, prepare_handoff_queue, prepare_queue
from app.session.hub import LEASE_FENCED_MESSAGE, SessionHub
from app.session.state import SessionState, now_utc
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
@ -41,7 +43,7 @@ def dds_state() -> SessionState:
state.timers.limits[TimerCode.DDS_ACK] = 45_000
prepare_queue(state, state.dds_scenarios)
service = state.notified_services()[0]
state.set_service_status(service, ServiceStatus.ACCEPTED, author="диспетчер")
state.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу", author="диспетчер")
state.crew_selected = state.crew_options()[0]
state.crew_assignments[service] = state.crew_selected
state.phone_pending = PhoneCallPending(
@ -56,6 +58,7 @@ def dds_state() -> SessionState:
def test_active_dds_session_round_trips_without_losing_work():
before = dds_state()
before.processed_station_commands = ["2a831a63-dbb0-4d9f-af5b-21a617520001"]
payload = dump_state(before)
restored = load_state(
payload,
@ -72,6 +75,7 @@ def test_active_dds_session_round_trips_without_losing_work():
assert restored.phone_pending == before.phone_pending
assert restored.reply_text == before.reply_text
assert restored.reply_grammar == before.reply_grammar
assert restored.processed_station_commands == before.processed_station_commands
assert restored.dds_scenarios[0].id == before.scenario_id
# Время простоя backend входит в норматив, а не обнуляет таймер.
timer = next(item for item in restored.timers.snapshot() if item.code is TimerCode.DDS_ACK)
@ -104,7 +108,7 @@ def test_concurrent_dds_queue_round_trips_with_each_timer_and_status():
prepare_queue(state, state.dds_scenarios)
first_id = state.dispatched_card.card_id
first_service = state.managed_services()[0]
state.set_service_status(first_service, ServiceStatus.ACCEPTED)
state.set_service_status(first_service, ServiceStatus.ACCEPTED, "Принято в работу")
state.on_event("card.ack")
second_id = state.dds_live_cards[1].card_id
assert state.activate_dds_card(second_id)
@ -149,7 +153,7 @@ def test_delivering_next_dds_card_does_not_clear_previous_card_state():
prepare_queue(state, scenarios, arrival_interval_seconds=60, max_waiting=1)
first_id = state.dds_live_cards[0].card_id
service = state.managed_services()[0]
state.set_service_status(service, ServiceStatus.ACCEPTED)
state.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу")
state.capture_active_dds()
assert deliver_due_cards(state, now_utc() + timedelta(seconds=61)) == 1
@ -165,3 +169,92 @@ def test_delivering_next_dds_card_does_not_clear_previous_card_state():
assert len(restored.dds_live_cards) == 2
assert restored.dds_next_scenario_index == 2
assert restored.dds_next_arrival_at is not None
def test_mixed_handoff_checkpoint_preserves_operator_card_and_generated_queue():
first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
second = load_file(LIBRARY / "tickets" / "t01-1-fire-container.yaml", LIBRARY)
state = SessionState(
session_id=uuid4(), scenario_id=first.id, scenario_title=first.title,
level=first.level.value, mode=SessionMode.TRAINING,
exercise=Exercise.CARD, handoff_to_dds=True, scenario=first,
pending_dds_scenarios=[second],
)
state.kio = KIO(address="улица Ленина, 14", description="горит балкон")
state.dispatch()
prepare_handoff_queue(state, state.pending_dds_scenarios)
restored = load_state(dump_state(state), now_utc())
assert restored.operator_kio.address == "улица Ленина, 14"
assert restored.operator_scenario.id == first.id
assert restored.dds_scenarios[0].id == first.id
assert restored.dds_scenarios[1].id == second.id
assert len(restored.dds_live_cards) == 2
assert restored.dds_active_card_id == state.dds_active_card_id
def test_checkpoint_storage_failure_fences_and_notifies_all_data_channels():
class BrokenJournal:
async def checkpoint(self, _state):
raise OSError("simulated database partition")
local_hub = SessionHub(journal=BrokenJournal())
state = dds_state()
local_hub.register(state)
with local_hub.observer(state.session_id) as observers, \
local_hub.trainee(state.session_id) as trainee, \
local_hub.station(state.session_id) as station:
async def failing_transition():
async with local_hub.durable_transition(state.session_id):
local_hub.to_trainee(
state.session_id, CallStarted(started_at=now_utc())
)
assert trainee.empty(), "success event escaped before durable checkpoint"
with pytest.raises(OSError, match="partition"):
asyncio.run(failing_transition())
assert state.lease_fenced
assert local_hub.get(state.session_id) is None
for queue in (observers, trainee, station):
event = queue.get_nowait()
assert event.message == LEASE_FENCED_MESSAGE
assert queue.empty(), "uncommitted success event leaked during fencing"
def test_durable_transition_publishes_event_only_after_checkpoint_commit():
class CommitJournal:
committed = False
async def checkpoint(self, _state):
await asyncio.sleep(0)
self.committed = True
journal = CommitJournal()
local_hub = SessionHub(journal=journal)
state = dds_state()
local_hub.register(state)
command_id = uuid4()
with local_hub.trainee(state.session_id) as trainee, \
local_hub.station(state.session_id) as station:
async def transition():
async with local_hub.durable_transition(state.session_id):
local_hub.to_trainee(
state.session_id, CallStarted(started_at=now_utc())
)
local_hub.to_station(
state.session_id, CommandAck(command_id=command_id)
)
assert trainee.empty()
assert station.empty()
assert journal.committed
asyncio.run(transition())
event = trainee.get_nowait()
assert isinstance(event, CallStarted)
ack = station.get_nowait()
assert isinstance(ack, CommandAck)
assert ack.command_id == command_id

View file

@ -0,0 +1,59 @@
import subprocess
import sys
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parents[2] / "scripts"
sys.path.insert(0, str(SCRIPTS))
from sip_recording_cleanup import remove_smoke_recordings # noqa: E402
def test_cleanup_removes_only_new_wav_basenames_from_compose_volume(tmp_path):
calls = []
def run(command, **kwargs):
calls.append((command, kwargs))
return subprocess.CompletedProcess(command, 0, stdout="")
assert remove_smoke_recordings(
tmp_path,
{"20260926-120000-6101-6102-abc.wav", "../keep.wav", "old.txt"},
runner=run,
)
command, options = calls[0]
assert command[-4:] == [
"rm", "-f", "--", "/recordings/20260926-120000-6101-6102-abc.wav",
]
assert "../keep.wav" not in command
assert "old.txt" not in command
assert options["cwd"] == tmp_path
assert len(calls) == 2
assert calls[1][0][-8:] == [
"find", "/recordings", "-maxdepth", "1", "-type", "f", "-name", "*.wav",
]
def test_cleanup_does_not_run_compose_for_empty_or_unsafe_names(tmp_path):
def unexpected_run(*_args, **_kwargs):
raise AssertionError("no deletion command should be needed")
assert remove_smoke_recordings(tmp_path, {"../outside.wav", "not-a-recording.txt"}, runner=unexpected_run)
def test_cleanup_reports_compose_failure(tmp_path):
def fail(command, **_kwargs):
return subprocess.CompletedProcess(command, 1)
assert not remove_smoke_recordings(tmp_path, {"new.wav"}, runner=fail)
def test_cleanup_fails_if_exact_smoke_file_still_exists(tmp_path):
calls = []
def run(command, **_kwargs):
calls.append(command)
output = "/recordings/new.wav\n" if len(calls) == 2 else ""
return subprocess.CompletedProcess(command, 0, stdout=output)
assert not remove_smoke_recordings(tmp_path, {"new.wav"}, runner=run)

View file

@ -56,8 +56,7 @@ SCENARIO = Scenario.model_validate(
{
"id": "f_secret",
"value": "муж курил на балконе",
"hidden": True,
"reveal_on": {"approach": "объяснил, что вину никто не ищет"},
"reveal_on": {"question": "q_cause"},
},
],
"checklist": [
@ -100,14 +99,13 @@ def test_two_questions_in_one_line_both_count(slots):
assert set(turn.revealed) == {"f_address", "f_people"}
def test_hidden_fact_is_not_given_for_a_direct_question(slots):
"""Скрывающий звонящий уклоняется от прямого вопроса: факт раскрывается
только подходом, иначе механика L3 превращается в обычный чек-лист."""
turn = slots.hear("Из-за чего начался пожар?")
assert "f_secret" not in turn.revealed
assert "q_cause" in slots.asked, "вопрос задан — это должно быть видно в разборе"
def test_fact_is_revealed_only_by_its_matching_question(slots):
unrelated = slots.hear("Где находится квартира?")
assert "f_secret" not in unrelated.revealed
assert slots.reveal_by_approach("f_secret")
cause = slots.hear("Из-за чего начался пожар?")
assert cause.revealed == ["f_secret"]
assert "q_cause" in slots.asked
assert "f_secret" in [fact.id for fact in slots.revealed_facts()]

View file

@ -6,12 +6,17 @@ from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.api.http import sessions as sessions_http
from app.main import app
from app.session.hub import hub
@pytest.fixture
def client():
def client(monkeypatch):
async def audit_in_memory(*_args, **_kwargs):
return None
monkeypatch.setattr(sessions_http, "audit_required", audit_in_memory)
with TestClient(app) as test_client:
# Сокеты закрыты ролями (lct-23): тесты входят так же,
# как `make lesson`, — через dev-token за флагом.
@ -97,7 +102,7 @@ def test_acknowledgement_stops_the_four_second_norm(client):
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "dds.dispatch", "service": "01"})
read_until(station, "card.received")
station.send_json({"type": "card.ack"})
station.send_json({"type": "card.ack", "comment": "Подтверждение приёма зафиксировано по докладу старшего группы."})
measured = wait_for(lambda: state.timers.measured_ms(TimerCode.DDS_ACK) is not None)
assert measured
finally:
@ -173,8 +178,25 @@ def test_dispatcher_sets_a_status_and_the_card_follows(client):
try:
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": "Служба 101", "status": "accepted"})
error = read_until(station, "error")
assert "комментар" in error["message"]
assert hub.get(session_id).status_log == []
command_id = str(uuid4())
accepted = {
"type": "card.status", "service": "Служба 101", "status": "accepted",
"comment": "Старший группы подтвердил приём карточки.",
"_command_id": command_id,
}
station.send_json(accepted)
state = read_until(station, "station.state")
assert state["snapshot"]["statuses"]["Служба 101"] == "accepted"
assert read_until(station, "command.ack")["command_id"] == command_id
# Simulate a retry after the client lost the ACK: the committed ID
# returns another ACK but does not append a second status action.
station.send_json(accepted)
assert read_until(station, "command.ack")["command_id"] == command_id
runtime = hub.get(session_id)
assert sum(item.service == "Служба 101" for item in runtime.status_log) == 1
assert "responding" in state["snapshot"]["available"]["Служба 101"]
assert "accepted" not in state["snapshot"]["available"]["Служба 101"]
finally:
@ -186,7 +208,8 @@ def test_out_of_order_status_is_rejected_with_a_reason(client):
session_id, station, contexts = dispatched(client)
try:
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": "Служба 101", "status": "arrived"})
station.send_json({"type": "card.status", "service": "Служба 101", "status": "arrived",
"comment": "Бригада доложила старшему о прибытии."})
error = read_until(station, "error")
assert "Принята" in error["message"]
assert hub.get(session_id).status_log == []
@ -211,7 +234,7 @@ def test_ack_button_still_works_and_counts_as_accepted(client):
session_id, station, contexts = dispatched(client)
try:
read_until(station, "station.state")
station.send_json({"type": "card.ack"})
station.send_json({"type": "card.ack", "comment": "Старший группы подтвердил приём карточки."})
state = read_until(station, "station.state")
assert state["snapshot"]["statuses"]["Служба 101"] == "accepted"
finally:

View file

@ -34,22 +34,22 @@ def mark(service: str, status: ServiceStatus, comment: str = "", minute: int = 0
def test_only_primary_statuses_are_available_at_first():
check([], "Служба 101", ServiceStatus.ACCEPTED, "")
check([], "Служба 101", ServiceStatus.ACCEPTED, "Принято в работу")
check([], "Служба 101", ServiceStatus.DECLINED, "не обслуживаем, передано в УК")
with pytest.raises(StatusError):
check([], "Служба 101", ServiceStatus.ARRIVED, "")
def test_accepted_opens_the_rest():
log = [mark("Служба 101", ServiceStatus.ACCEPTED)]
check(log, "Служба 101", ServiceStatus.RESPONDING, "")
check(log, "Служба 101", ServiceStatus.COMPLETED, "")
log = [mark("Служба 101", ServiceStatus.ACCEPTED, "Принято в работу")]
check(log, "Служба 101", ServiceStatus.RESPONDING, "Бригада выехала")
check(log, "Служба 101", ServiceStatus.COMPLETED, "Работы завершены")
def test_declined_leads_only_back_to_accepted():
"""Служба может передумать, но не может отказаться дважды по-разному."""
log = [mark("Служба 101", ServiceStatus.DECLINED, "не наш адрес")]
check(log, "Служба 101", ServiceStatus.ACCEPTED, "")
check(log, "Служба 101", ServiceStatus.ACCEPTED, "Повторно принято")
with pytest.raises(StatusError):
check(log, "Служба 101", ServiceStatus.RESPONDING, "")
@ -113,9 +113,10 @@ def test_alarming_statuses_are_the_three_from_the_memo():
# ── ошибки диспетчера ──
def _codes(entries, services=SERVICES, elapsed=45_000):
def _codes(entries, services=SERVICES, elapsed=45_000, crew_assignments=None):
findings = evaluate_dispatcher(
entries=entries, services=services, deadline_ms=30_000, elapsed_ms=elapsed
entries=entries, services=services, crew_assignments=crew_assignments,
deadline_ms=30_000, elapsed_ms=elapsed
)
return [finding.code for finding in findings]
@ -156,13 +157,17 @@ def test_clean_work_has_no_findings():
log = []
for service in SERVICES:
log += [
mark(service, ServiceStatus.ACCEPTED),
mark(service, ServiceStatus.RESPONDING, minute=1),
mark(service, ServiceStatus.ARRIVED, minute=4),
mark(service, ServiceStatus.WORKING, minute=5),
mark(service, ServiceStatus.COMPLETED, minute=20),
mark(service, ServiceStatus.ACCEPTED, "Основание: доклад бригады.\nСведения: принято."),
mark(service, ServiceStatus.RESPONDING, "Основание: доклад бригады.\nСведения: выезд.", minute=1),
mark(service, ServiceStatus.ARRIVED, "Основание: доклад бригады.\nСведения: прибытие.", minute=4),
mark(service, ServiceStatus.WORKING, "Основание: доклад бригады.\nСведения: начало работ.", minute=5),
mark(service, ServiceStatus.COMPLETED, "Основание: доклад бригады.\nСведения: завершение работ.", minute=20),
]
assert _codes(log) == []
assert _codes(
log,
elapsed=10_000,
crew_assignments={service: "дежурная бригада" for service in SERVICES},
) == []
def test_every_finding_carries_its_reason():

View file

@ -0,0 +1,25 @@
import pytest
from app.domain.events import Metric
from app.scoring.competency import radar
from app.scoring.gost import GostResult
from app.scoring.timing import time_credit
@pytest.mark.parametrize(
("elapsed_ms", "expected"),
[(0, 1.0), (90_000, 0.75), (180_000, 0.5), (360_000, 0.0), (400_000, 0.0),
(None, 0.0)],
)
def test_time_credit_reduces_linearly_against_the_norm(elapsed_ms, expected):
assert time_credit(elapsed_ms, 180_000) == expected
def test_fractional_timing_credit_affects_total_and_competency_scores():
metric = Metric(
key="card_fill_time", title="Время заполнения карточки", fact="90 с",
norm="180 с", passed=True, weight=2.0, credit=0.75,
)
result = GostResult(metrics=[metric])
assert result.score == 75.0
assert radar([metric])[0].value == 0.75

View file

@ -4,21 +4,28 @@
Проверяются пункты приёмки карточки lct-05, а не отдельные функции.
"""
import asyncio
import contextlib
import time
import wave
from uuid import uuid4
import numpy as np
import pytest
from fastapi.testclient import TestClient
from app.api.http import sessions as sessions_http
from app.main import app
from app.scenarios import store
from app.session.hub import hub
@pytest.fixture
def client():
def client(monkeypatch):
async def audit_in_memory(*_args, **_kwargs):
return None
monkeypatch.setattr(sessions_http, "audit_required", audit_in_memory)
with TestClient(app) as test_client:
# Сокеты закрыты ролями (lct-23): тесты входят так же,
# как `make lesson`, — через dev-token за флагом.
@ -225,6 +232,37 @@ def test_instructor_cannot_touch_the_card(client):
assert "fields" not in fields and "kio" not in fields
def test_control_contract_contains_only_handled_commands():
"""Не рекламировать команду WebSocket, у которой нет ветки обработчика."""
import ast
from pathlib import Path
from app.domain.events import InstructorToServer
from typing import get_args
union = get_args(get_args(InstructorToServer)[0])
commands = {model.model_fields["type"].default for model in union}
source = Path(__file__).parents[1] / "app" / "api" / "ws" / "control.py"
tree = ast.parse(source.read_text(encoding="utf-8"))
control_matches = [
node for node in ast.walk(tree)
if isinstance(node, ast.Match)
and isinstance(node.subject, ast.Attribute)
and isinstance(node.subject.value, ast.Name)
and node.subject.value.id == "event"
and node.subject.attr == "type"
]
assert len(control_matches) == 1, "не удалось однозначно найти dispatch control-событий"
handled = {
case.pattern.value.value
for case in control_matches[0].cases
if isinstance(case.pattern, ast.MatchValue)
and isinstance(case.pattern.value, ast.Constant)
and isinstance(case.pattern.value.value, str)
}
assert commands == handled
def test_call_socket_refuses_session_that_was_not_started(client):
with client.websocket_connect(f"/ws/call/{uuid4()}") as trainee:
message = trainee.receive_json()
@ -261,10 +299,31 @@ def test_answered_call_is_saved_as_wav_on_hangup(client, monkeypatch, tmp_path):
from app.api.ws import call as call_api
from app.voice.recording import CallRecorder
# Exercise the production audio callback without loading models: caller
# audio uses the same send_audio path whether it came from TTS or a test.
caller_pcm = (2000).to_bytes(2, "little", signed=True) * 480
class FakeVoice:
def __init__(self, *, send_audio, **_kwargs):
self.send_audio = send_audio
self.speak_count = 0
def speak(self, *_args):
self.speak_count += 1
asyncio.get_running_loop().call_later(0.01, self.send_audio, caller_pcm)
def feed(self, _pcm):
return None
async def close(self):
return None
monkeypatch.setattr(
call_api, "start_recording",
lambda session_id: CallRecorder(tmp_path / f"{session_id}.wav"),
)
monkeypatch.setattr(call_api, "get_voice_models", lambda: object())
monkeypatch.setattr(call_api, "VoiceSession", FakeVoice)
with lesson(client) as (session_id, _):
state = hub.get(session_id)
path = tmp_path / f"{session_id}.wav"
@ -272,7 +331,34 @@ def test_answered_call_is_saved_as_wav_on_hangup(client, monkeypatch, tmp_path):
read_until(trainee, "call.incoming")
trainee.send_json({"type": "call.answer"})
read_until(trainee, "call.started")
wait_for(lambda: state.recorder and len(state.recorder._segments) >= 1)
for _ in range(10):
outgoing = trainee.receive()
if outgoing.get("bytes") is not None:
assert outgoing["bytes"] == caller_pcm
break
else:
raise AssertionError("TTS-реплика звонящего не пришла по WebSocket")
first_started_at = state.started_at
trainee.send_json({"type": "call.answer"})
read_until(trainee, "call.started")
assert state.started_at == first_started_at, "повторное подключение перезапустило таймер"
assert state.voice.speak_count == 1, "повторное подключение заново проиграло вводную"
trainee.send_bytes((1000).to_bytes(2, "little", signed=True) * 320)
wait_for(lambda: len(state.recorder._segments) >= 2)
# Drop process-local runtime objects but leave the durable call journal,
# as if the backend process had been killed and restored from PostgreSQL.
state.recorder._journal.close()
state.recorder = None
state.voice = None
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
read_until(trainee, "call.incoming")
read_until(trainee, "call.started")
assert len(state.recorder._segments) == 2
assert state.voice.speak_count == 0, "после восстановления повторилась первая реплика"
trainee.send_bytes((3000).to_bytes(2, "little", signed=True) * 320)
wait_for(lambda: len(state.recorder._segments) >= 3)
trainee.send_json({"type": "call.hangup"})
read_until(trainee, "call.ended")
wait_for(path.is_file)
@ -280,7 +366,12 @@ def test_answered_call_is_saved_as_wav_on_hangup(client, monkeypatch, tmp_path):
assert state.recording_path == str(path)
with wave.open(str(path), "rb") as source:
assert source.getframerate() == 16_000
assert source.getnframes() >= 320
samples = source.readframes(source.getnframes())
assert source.getnframes() >= 640
decoded = np.frombuffer(samples, dtype="<i2")
assert 1000 in decoded, "в записи потерян микрофонный звук оператора"
assert 2000 in decoded, "в записи потерян звук звонящего"
assert 3000 in decoded, "после восстановления потерян микрофонный звук оператора"
def test_score_waits_for_self_assessment(client):
@ -378,18 +469,42 @@ def test_report_shows_missed_questions_and_self_assessment_gap(client):
assert report["hints_used"], "использованные подсказки попадают в разбор"
def test_instructor_correction_keeps_the_automatic_score(client):
with lesson(client) as (session_id, _):
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "call.hangup"})
wait_for(lambda: hub.get(session_id).score is not None)
def test_instructor_correction_keeps_the_automatic_score(client, postgres_access):
from app.db.base import get_sessionmaker
from app.session.journal import DbJournal
auto = client.get(f"/api/sessions/{session_id}/report").json()["score_auto"]
corrected = client.patch(
f"/api/sessions/{session_id}/report",
json={"score_final": 80.0, "comment": "связь рвалась не по вине курсанта"},
).json()
# Unlike the websocket-only cases above, this regression exercises the
# durable HTTP correction endpoint against a real PostgreSQL score row.
journal = DbJournal(get_sessionmaker())
try:
with lesson(client) as (session_id, _):
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "call.hangup"})
wait_for(lambda: hub.get(session_id).score is not None)
# The websocket-only fixture deliberately disables journalling;
# persist its computed result before testing the HTTP override.
state = hub.get(session_id)
client.portal.call(
journal.start_lesson,
session_id,
state.scenario_id,
state.mode.value,
state.trainee_name,
state.trainee_id,
"dev",
)
client.portal.call(journal.score, session_id, state.score["score_auto"], state.score)
auto = client.get(f"/api/sessions/{session_id}/report").json()["score_auto"]
response = client.patch(
f"/api/sessions/{session_id}/report",
json={"score_final": 80.0, "comment": "связь рвалась не по вине курсанта"},
)
assert response.status_code == 200, response.text
corrected = response.json()
finally:
hub.journal = None
assert corrected["score_final"] == 80.0
assert corrected["score_auto"] == auto, "автооценка должна сохраниться рядом"
@ -423,6 +538,32 @@ def test_ws_score_override_rejects_other_session_and_invalid_value(client):
assert state.score["overridden_by"] == "dev"
def test_ws_score_override_is_not_applied_when_atomic_persistence_fails(client):
class FailedJournal:
async def score_override(self, *_args):
return False
async def checkpoint(self, *_args):
return None
with lesson(client) as (session_id, control):
state = hub.get(session_id)
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "call.hangup"})
wait_for(lambda: state.score is not None)
auto = state.score["score_auto"]
hub.journal = FailedJournal()
control.send_json({
"type": "score.override", "session_id": str(session_id),
"verdict": "85", "comment": "manual review",
})
time.sleep(0.1)
assert state.score["score_auto"] == auto
assert "score_final" not in state.score
def test_soft_directive_changes_how_the_caller_sounds(client):
from app.domain.events import Mood

Some files were not shown because too many files have changed in this diff Show more