Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
27
.env.example
27
.env.example
|
|
@ -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
149
.github/workflows/windows-backend.yml
vendored
Normal 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
1
.gitignore
vendored
|
|
@ -23,6 +23,7 @@ frontend/dist/
|
|||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test.example
|
||||
*.local
|
||||
.local/
|
||||
|
||||
|
|
|
|||
29
Makefile
29
Makefile
|
|
@ -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
|
||||
|
|
|
|||
7
backend/.env.test.example
Normal file
7
backend/.env.test.example
Normal 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
|
||||
|
|
@ -19,7 +19,9 @@ import asyncio
|
|||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import weakref
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
|
|
@ -27,10 +29,13 @@ from argon2.exceptions import VerifyMismatchError
|
|||
from fastapi import APIRouter, HTTPException, Request, WebSocket
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_sessionmaker
|
||||
from app.db.models import AuditLog, User
|
||||
from app.db.models import AuditLog, Trainee, User
|
||||
from app.directory import DirectoryDenied, DirectoryIdentity, DirectoryUnavailable
|
||||
from app.domain.roles import Role
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -46,20 +51,46 @@ _INSTANCE = hashlib.sha256(
|
|||
).hexdigest()
|
||||
_generations: dict[str, int] = {}
|
||||
_active_sockets: dict[str, weakref.WeakKeyDictionary] = {}
|
||||
AUTH_GENERATION_SYNC_SECONDS = 1.0
|
||||
AUTH_GENERATION_MAX_AGE_SECONDS = 2.0
|
||||
_generations_synced_at: float | None = None
|
||||
|
||||
|
||||
async def _close_revoked(ws: WebSocket) -> None:
|
||||
try:
|
||||
await ws.close(code=1008, reason="Учётная запись изменена: войдите снова")
|
||||
except (RuntimeError, OSError):
|
||||
except (RuntimeError, OSError, WebSocketDisconnect):
|
||||
# The peer may already have disconnected; revocation still stands.
|
||||
pass
|
||||
|
||||
|
||||
async def _close_auth_state_unavailable(ws: WebSocket) -> None:
|
||||
try:
|
||||
await ws.close(code=1013, reason="Состояние доступа временно недоступно")
|
||||
except (RuntimeError, OSError, WebSocketDisconnect):
|
||||
pass
|
||||
|
||||
|
||||
def _close_unverified_sockets() -> None:
|
||||
for sockets in list(_active_sockets.values()):
|
||||
for ws, loop in list(sockets.items()):
|
||||
try:
|
||||
if not loop.is_closed():
|
||||
loop.call_soon_threadsafe(
|
||||
lambda socket=ws: asyncio.create_task(
|
||||
_close_auth_state_unavailable(socket)
|
||||
)
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
def prime_generations(values: dict[str, int]) -> None:
|
||||
"""Загрузить версии полномочий из БД при старте нового процесса."""
|
||||
global _generations_synced_at
|
||||
_generations.clear()
|
||||
_generations.update(values)
|
||||
_generations_synced_at = time.monotonic()
|
||||
|
||||
|
||||
async def load_generations() -> None:
|
||||
|
|
@ -68,6 +99,106 @@ async def load_generations() -> None:
|
|||
prime_generations({login: version for login, version in rows})
|
||||
|
||||
|
||||
async def sync_generations() -> None:
|
||||
"""Refresh shared account epochs and close sockets revoked on peer nodes."""
|
||||
async with get_sessionmaker()() as db:
|
||||
rows = (await db.execute(select(User.login, User.auth_version))).all()
|
||||
current = {login: version for login, version in rows}
|
||||
for login, version in current.items():
|
||||
previous = _generations.get(login)
|
||||
if previous is None:
|
||||
_generations[login] = version
|
||||
elif previous != version:
|
||||
invalidate_login(login, version)
|
||||
# Account deletion is not exposed by the application. Still close active
|
||||
# sockets if an operator removes one directly from the shared directory DB.
|
||||
# The local-only dev-token principal is synthetic, never stored in users;
|
||||
# a DB watcher must not revoke its in-memory generation during test/demo
|
||||
# flows that deliberately exercise account invalidation.
|
||||
synthetic = {"dev"} if get_settings().dev_auth_bypass else set()
|
||||
for login in _generations.keys() - current.keys() - synthetic:
|
||||
invalidate_login(login)
|
||||
_generations.pop(login, None)
|
||||
global _generations_synced_at
|
||||
_generations_synced_at = time.monotonic()
|
||||
|
||||
|
||||
async def watch_generations() -> None:
|
||||
"""Poll PostgreSQL once per node so remote logout/role changes close WS."""
|
||||
while True:
|
||||
try:
|
||||
async with asyncio.timeout(AUTH_GENERATION_MAX_AGE_SECONDS):
|
||||
await sync_generations()
|
||||
except Exception as exc: # noqa: BLE001 — retry; authenticated requests fail closed
|
||||
log.error("не удалось синхронизировать версии полномочий (%s)",
|
||||
type(exc).__name__)
|
||||
if (
|
||||
_generations_synced_at is None
|
||||
or time.monotonic() - _generations_synced_at > AUTH_GENERATION_MAX_AGE_SECONDS
|
||||
):
|
||||
_close_unverified_sockets()
|
||||
await asyncio.sleep(AUTH_GENERATION_SYNC_SECONDS)
|
||||
|
||||
|
||||
class AuthVersionMiddleware:
|
||||
"""Check signed-cookie epochs against the fresh, DB-synchronized node cache."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] not in {"http", "websocket"}:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
if scope["type"] == "http" and scope.get("path") in {
|
||||
"/api/health", "/api/auth/logout",
|
||||
}:
|
||||
# Liveness must remain observable and logout must always be able to
|
||||
# clear the browser cookie even while PostgreSQL is unreachable.
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
settings = get_settings()
|
||||
session = scope.get("session")
|
||||
data = session.get("principal") if isinstance(session, dict) else None
|
||||
login = data.get("login") if isinstance(data, dict) else None
|
||||
if (
|
||||
settings.demo_no_db
|
||||
or not isinstance(login, str)
|
||||
or login == "dev" and settings.dev_auth_bypass
|
||||
or session.get("auth_instance") != _INSTANCE
|
||||
):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
synced_at = _generations_synced_at
|
||||
if (
|
||||
synced_at is None
|
||||
or time.monotonic() - synced_at > AUTH_GENERATION_MAX_AGE_SECONDS
|
||||
):
|
||||
if scope["type"] == "websocket":
|
||||
await send({"type": "websocket.close", "code": 1013})
|
||||
else:
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": 503,
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
})
|
||||
await send({
|
||||
"type": "http.response.body",
|
||||
"body": b'{"detail":"auth_state_unavailable"}',
|
||||
})
|
||||
return
|
||||
|
||||
cookie_version = session.get("auth_generation")
|
||||
version = _generations.get(login)
|
||||
if version is None or cookie_version != version:
|
||||
if session is not None:
|
||||
session.clear()
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
def invalidate_login(login: str, version: int | None = None) -> None:
|
||||
"""Revoke previously issued cookies after account/role/password changes."""
|
||||
_generations[login] = version if version is not None else _generations.get(login, 0) + 1
|
||||
|
|
@ -112,8 +243,9 @@ def verify_password(password_hash: str, password: str) -> bool:
|
|||
return _hasher.verify(password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
except Exception: # noqa: BLE001 — битый хеш не должен пускать в систему
|
||||
log.exception("проверка пароля не удалась")
|
||||
except Exception as exc: # noqa: BLE001 — битый хеш не должен пускать в систему
|
||||
# Do not echo malformed stored hash material in diagnostic tracebacks.
|
||||
log.error("проверка пароля не удалась (%s)", type(exc).__name__)
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -167,6 +299,48 @@ def principal_of(websocket: WebSocket) -> Principal | None:
|
|||
return who
|
||||
|
||||
|
||||
def websocket_origin_allowed(websocket: WebSocket) -> bool:
|
||||
"""Reject browser cross-site WebSocket handshakes (CSWSH).
|
||||
|
||||
Non-browser clients may omit Origin. Browser Origins must exactly match
|
||||
the external host and scheme; the bundled reverse proxies forward the
|
||||
original Host and scheme explicitly for this check.
|
||||
"""
|
||||
origin = websocket.headers.get("origin")
|
||||
if origin is None:
|
||||
return True
|
||||
try:
|
||||
parsed_origin = urlsplit(origin)
|
||||
host = websocket.headers.get("x-forwarded-host") or websocket.headers.get("host")
|
||||
scheme = (
|
||||
websocket.headers.get("x-forwarded-proto")
|
||||
or {"ws": "http", "wss": "https"}.get(websocket.scope.get("scheme", ""), "")
|
||||
).casefold()
|
||||
if not host or scheme not in {"http", "https"}:
|
||||
return False
|
||||
expected = urlsplit(f"{scheme}://{host}")
|
||||
if parsed_origin.scheme.casefold() != scheme:
|
||||
return False
|
||||
if (
|
||||
parsed_origin.username
|
||||
or parsed_origin.password
|
||||
or not parsed_origin.hostname
|
||||
or not expected.hostname
|
||||
):
|
||||
return False
|
||||
origin_port = parsed_origin.port or (443 if scheme == "https" else 80)
|
||||
expected_port = expected.port or (443 if scheme == "https" else 80)
|
||||
return (
|
||||
parsed_origin.hostname.casefold() == expected.hostname.casefold()
|
||||
and origin_port == expected_port
|
||||
and parsed_origin.path in {"", "/"}
|
||||
and not parsed_origin.query
|
||||
and not parsed_origin.fragment
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def require(request: Request, *roles: Role) -> Principal:
|
||||
"""Принципал нужной роли или отказ. Единственная точка проверки на HTTP."""
|
||||
who = current(request)
|
||||
|
|
@ -179,21 +353,134 @@ def require(request: Request, *roles: Role) -> Principal:
|
|||
|
||||
async def audit(
|
||||
actor: str, role: str, action: str, object_id: str | None = None, detail: str = ""
|
||||
) -> None:
|
||||
"""Запись в журнал. Аудит не должен ронять действие: если база недоступна,
|
||||
занятие продолжается, а пропуск виден в логе."""
|
||||
) -> bool:
|
||||
"""Best-effort audit write for actions that cannot be rolled back."""
|
||||
if get_settings().demo_no_db:
|
||||
return # в явном demo-режиме запись и долговременный аудит недоступны
|
||||
return True # явный demo-режим не обещает долговременное хранение
|
||||
try:
|
||||
async with get_sessionmaker()() as db:
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor=actor, role=role, action=action, object_id=object_id, detail=detail[:2000]
|
||||
)
|
||||
)
|
||||
add_audit_entry(db, actor, role, action, object_id, detail)
|
||||
await db.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("аудит: запись %s не удалась", action)
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# SQL traces can include audit detail and user-provided text.
|
||||
log.error("аудит: запись %s не удалась (%s)", action, type(exc).__name__)
|
||||
return False
|
||||
|
||||
|
||||
async def audit_required(
|
||||
actor: str, role: str, action: str, object_id: str | None = None, detail: str = ""
|
||||
) -> None:
|
||||
"""Fail closed for authentication decisions that must be auditable."""
|
||||
written = await audit(actor, role, action, object_id, detail)
|
||||
# `is False` preserves simple third-party/test audit hooks that return None.
|
||||
if written is False:
|
||||
raise HTTPException(status_code=503, detail="audit_unavailable")
|
||||
|
||||
|
||||
def add_audit_entry(
|
||||
db, actor: str, role: str, action: str, object_id: str | None = None, detail: str = ""
|
||||
) -> None:
|
||||
"""Добавить audit row к текущей транзакции, не коммитя отдельно.
|
||||
|
||||
Для административных операций, где изменение без audit trail недопустимо,
|
||||
вызывающий код коммитит предметную запись и журнал одним commit.
|
||||
"""
|
||||
db.add(AuditLog(
|
||||
actor=actor,
|
||||
role=role,
|
||||
action=action,
|
||||
object_id=object_id,
|
||||
detail=detail[:2000],
|
||||
))
|
||||
|
||||
|
||||
async def _directory_account(identity: DirectoryIdentity) -> User:
|
||||
"""Just-in-time provision and sync one explicitly group-mapped account."""
|
||||
async with get_sessionmaker()() as db:
|
||||
by_login = await db.scalar(select(User).where(User.login == identity.login))
|
||||
by_subject = await db.scalar(
|
||||
select(User).where(User.directory_subject == identity.subject)
|
||||
)
|
||||
if by_login is not None and by_login.auth_provider != "ldap":
|
||||
raise DirectoryDenied("directory login conflicts with a local account")
|
||||
if by_login is not None and by_subject is not None and by_login.id != by_subject.id:
|
||||
raise DirectoryDenied("directory identity conflicts with an existing account")
|
||||
user = by_subject or by_login
|
||||
if user is not None and user.blocked:
|
||||
# Let the login endpoint record the blocked attempt with the same
|
||||
# audit path used for local accounts. Do not sync any account fields.
|
||||
return user
|
||||
if user is not None and user.directory_subject not in {None, identity.subject}:
|
||||
raise DirectoryDenied("directory login is bound to another identity")
|
||||
|
||||
if user is None:
|
||||
trainee_id = None
|
||||
if identity.role is Role.TRAINEE:
|
||||
trainee = Trainee(name=identity.full_name)
|
||||
db.add(trainee)
|
||||
await db.flush()
|
||||
trainee_id = trainee.id
|
||||
user = User(
|
||||
login=identity.login,
|
||||
password_hash=hash_password(secrets.token_urlsafe(48)),
|
||||
full_name=identity.full_name,
|
||||
role=identity.role.value,
|
||||
service=identity.service,
|
||||
trainee_id=trainee_id,
|
||||
auth_provider="ldap",
|
||||
directory_subject=identity.subject,
|
||||
auth_version=0,
|
||||
blocked=False,
|
||||
)
|
||||
db.add(user)
|
||||
add_audit_entry(
|
||||
db,
|
||||
"system",
|
||||
"system",
|
||||
"user.provision.ldap",
|
||||
identity.login,
|
||||
f"role={identity.role.value}; service_assigned={identity.service is not None}",
|
||||
)
|
||||
else:
|
||||
if user.role != identity.role.value and identity.role is Role.TRAINEE and user.trainee_id is None:
|
||||
trainee = Trainee(name=identity.full_name)
|
||||
db.add(trainee)
|
||||
await db.flush()
|
||||
user.trainee_id = trainee.id
|
||||
changed = (
|
||||
user.full_name != identity.full_name
|
||||
or user.role != identity.role.value
|
||||
or user.service != identity.service
|
||||
or user.directory_subject != identity.subject
|
||||
)
|
||||
user.full_name = identity.full_name
|
||||
user.role = identity.role.value
|
||||
user.service = identity.service
|
||||
user.directory_subject = identity.subject
|
||||
if user.trainee_id is not None:
|
||||
trainee = await db.get(Trainee, user.trainee_id)
|
||||
if trainee is not None:
|
||||
trainee.name = identity.full_name
|
||||
if changed:
|
||||
user.auth_version += 1
|
||||
add_audit_entry(
|
||||
db,
|
||||
"system",
|
||||
"system",
|
||||
"user.sync.ldap",
|
||||
identity.login,
|
||||
f"role={user.role}; service_assigned={user.service is not None}",
|
||||
)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
# Concurrent first login or duplicate directory subject is denied;
|
||||
# the caller can retry after the account mapping is unambiguous.
|
||||
raise DirectoryDenied("directory account provisioning conflict") from exc
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
|
|
@ -211,19 +498,35 @@ async def login(payload: LoginIn, request: Request) -> dict:
|
|||
async with get_sessionmaker()() as db:
|
||||
user = await db.scalar(select(User).where(User.login == payload.login))
|
||||
|
||||
# Одинаковый ответ на неизвестный логин и неверный пароль: иначе форма
|
||||
# входа превращается в список действующих учётных записей.
|
||||
if user is None or not verify_password(user.password_hash, payload.password):
|
||||
settings = get_settings()
|
||||
if user is None or user.auth_provider == "ldap":
|
||||
if not settings.ldap_enabled:
|
||||
raise HTTPException(status_code=401, detail="bad_credentials")
|
||||
from app.directory import authenticate
|
||||
|
||||
try:
|
||||
identity = await authenticate(payload.login, payload.password)
|
||||
if identity is None:
|
||||
raise DirectoryDenied("unknown directory account")
|
||||
user = await _directory_account(identity)
|
||||
except DirectoryDenied as exc:
|
||||
await audit_required(payload.login[:80], "unknown", "login.failed")
|
||||
raise HTTPException(status_code=401, detail="bad_credentials") from exc
|
||||
except DirectoryUnavailable as exc:
|
||||
log.error("local directory unavailable: %s", exc)
|
||||
raise HTTPException(status_code=503, detail="directory_unavailable") from exc
|
||||
elif not verify_password(user.password_hash, payload.password):
|
||||
# Не записываем пароль, IP либо факт существования учётной записи.
|
||||
# Логин нужен администратору для расследования перебора; ограничиваем
|
||||
# длину до размера поля AuditLog.actor.
|
||||
await audit(payload.login[:80], "unknown", "login.failed")
|
||||
await audit_required(payload.login[:80], "unknown", "login.failed")
|
||||
raise HTTPException(status_code=401, detail="bad_credentials")
|
||||
if user.blocked:
|
||||
await audit(user.login, user.role, "login.blocked")
|
||||
await audit_required(user.login, user.role, "login.blocked")
|
||||
raise HTTPException(status_code=403, detail="blocked")
|
||||
|
||||
_generations[user.login] = user.auth_version
|
||||
if _generations.get(user.login) != user.auth_version:
|
||||
invalidate_login(user.login, user.auth_version)
|
||||
|
||||
who = Principal(
|
||||
login=user.login,
|
||||
|
|
@ -232,17 +535,41 @@ async def login(payload: LoginIn, request: Request) -> dict:
|
|||
service=user.service,
|
||||
trainee_id=user.trainee_id,
|
||||
)
|
||||
await audit_required(who.login, who.role.value, "login")
|
||||
_issue_session(request, who)
|
||||
await audit(who.login, who.role.value, "login")
|
||||
return who.model_dump(mode="json")
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request) -> dict:
|
||||
who = current(request)
|
||||
if who is None:
|
||||
request.session.clear()
|
||||
return {"ok": True}
|
||||
|
||||
# Drop the browser cookie even if durable revocation is unavailable.
|
||||
request.session.clear()
|
||||
if who:
|
||||
if get_settings().demo_no_db:
|
||||
invalidate_login(who.login)
|
||||
await audit(who.login, who.role.value, "logout")
|
||||
return {"ok": True}
|
||||
|
||||
try:
|
||||
async with get_sessionmaker()() as db:
|
||||
user = await db.scalar(select(User).where(User.login == who.login).with_for_update())
|
||||
if user is not None:
|
||||
user.auth_version += 1
|
||||
version = user.auth_version
|
||||
else:
|
||||
version = _generations.get(who.login, 0) + 1
|
||||
add_audit_entry(db, who.login, who.role.value, "logout")
|
||||
await db.commit()
|
||||
except Exception as exc: # noqa: BLE001 — fail closed for revocation/audit
|
||||
log.error("выход: отзыв cookie и аудит не удалось сохранить (%s)",
|
||||
type(exc).__name__)
|
||||
invalidate_login(who.login)
|
||||
raise HTTPException(status_code=503, detail="audit_unavailable") from exc
|
||||
invalidate_login(who.login, version)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,23 +9,29 @@
|
|||
но с проверкой».
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import quote, quote_plus
|
||||
from uuid import UUID
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.admin import backup as backup_service
|
||||
from app.api.auth import audit, hash_password, invalidate_login, require
|
||||
from app.api.auth import (
|
||||
add_audit_entry, audit, audit_required, hash_password, invalidate_login, require,
|
||||
)
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_session
|
||||
from app.db.models import AuditLog, Session as SessionRow, Trainee, User
|
||||
|
|
@ -97,6 +103,7 @@ class UserOut(BaseModel):
|
|||
login: str
|
||||
full_name: str
|
||||
role: Role
|
||||
auth_provider: str
|
||||
service: str | None
|
||||
blocked: bool
|
||||
created_at: datetime
|
||||
|
|
@ -126,6 +133,7 @@ def _out(user: User) -> UserOut:
|
|||
login=user.login,
|
||||
full_name=user.full_name,
|
||||
role=Role(user.role),
|
||||
auth_provider=user.auth_provider,
|
||||
service=user.service,
|
||||
blocked=user.blocked,
|
||||
created_at=user.created_at,
|
||||
|
|
@ -161,13 +169,14 @@ async def create_user(
|
|||
user.trainee_id = trainee.id
|
||||
|
||||
db.add(user)
|
||||
add_audit_entry(db, who.login, who.role.value, "user.create", body.login,
|
||||
ROLE_LABELS[body.role])
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail="login_taken") from exc
|
||||
|
||||
await audit(who.login, who.role.value, "user.create", body.login, ROLE_LABELS[body.role])
|
||||
return _out(user)
|
||||
|
||||
|
||||
|
|
@ -180,6 +189,11 @@ async def patch_user(
|
|||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="user_not_found")
|
||||
|
||||
if user.auth_provider == "ldap" and any(
|
||||
value is not None for value in (body.role, body.service, body.password)
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="directory_managed_account")
|
||||
|
||||
changed: list[str] = []
|
||||
if body.role is not None:
|
||||
if user.login == who.login and body.role is not Role.ADMIN:
|
||||
|
|
@ -207,9 +221,10 @@ async def patch_user(
|
|||
if not changed:
|
||||
return _out(user)
|
||||
user.auth_version += 1
|
||||
add_audit_entry(db, who.login, who.role.value, "user.update", user.login,
|
||||
", ".join(changed))
|
||||
await db.commit()
|
||||
invalidate_login(user.login, user.auth_version)
|
||||
await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed))
|
||||
return _out(user)
|
||||
|
||||
|
||||
|
|
@ -222,6 +237,25 @@ class AuditOut(BaseModel):
|
|||
detail: str
|
||||
|
||||
|
||||
def _csv_value(value: object) -> str:
|
||||
"""Prevent spreadsheet formula execution in user-controlled audit fields."""
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value)
|
||||
probe = text.lstrip(" \t\r\n\ufeff\u200b")
|
||||
if probe.startswith(("=", "+", "-", "@")) or text.startswith(("\t", "\r", "\n")):
|
||||
return "'" + text
|
||||
return text
|
||||
|
||||
|
||||
def _csv_row(values: tuple[object, ...]) -> str:
|
||||
output = io.StringIO(newline="")
|
||||
csv.writer(output, lineterminator="\r\n").writerow(
|
||||
[_csv_value(value) for value in values]
|
||||
)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
@router.get("/audit", response_model=list[AuditOut])
|
||||
async def audit_log(
|
||||
request: Request,
|
||||
|
|
@ -254,6 +288,37 @@ async def audit_log(
|
|||
]
|
||||
|
||||
|
||||
@router.get("/audit.csv")
|
||||
async def audit_csv(
|
||||
request: Request,
|
||||
action: str | None = None,
|
||||
actor: str | None = None,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> StreamingResponse:
|
||||
"""Stream the complete filtered security log for offline review/archive."""
|
||||
require(request, Role.ADMIN)
|
||||
query = select(AuditLog).order_by(AuditLog.at.asc(), AuditLog.id.asc())
|
||||
if action:
|
||||
query = query.where(AuditLog.action == action)
|
||||
if actor:
|
||||
query = query.where(AuditLog.actor == actor)
|
||||
|
||||
async def rows():
|
||||
yield "\ufeff" + _csv_row(("Когда UTC", "Пользователь", "Роль", "Действие", "Объект", "Подробности"))
|
||||
result = await db.stream_scalars(query)
|
||||
async for row in result:
|
||||
yield _csv_row((
|
||||
row.at.isoformat(), row.actor, row.role, row.action,
|
||||
row.object_id, row.detail,
|
||||
))
|
||||
|
||||
return StreamingResponse(
|
||||
rows(),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="lct-audit.csv"'},
|
||||
)
|
||||
|
||||
|
||||
class ServiceState(BaseModel):
|
||||
name: str
|
||||
ok: bool
|
||||
|
|
@ -504,9 +569,25 @@ def _safe_backup_error(exc: backup_service.BackupError) -> str:
|
|||
dsn = get_settings().database_url
|
||||
if dsn:
|
||||
message = message.replace(dsn, "[DATABASE_URL скрыт]")
|
||||
match = re.search(r"://[^:]+:([^@]+)@", dsn)
|
||||
if match and match.group(1):
|
||||
message = message.replace(match.group(1), "[пароль скрыт]")
|
||||
try:
|
||||
password = make_url(dsn).password
|
||||
except Exception: # malformed DSN is handled by backup setup separately
|
||||
password = None
|
||||
if password:
|
||||
# Driver errors may echo the DSN either as configured (percent
|
||||
# encoded) or after the URL parser decoded credentials. Redact all
|
||||
# common representations; checking only the raw password misses
|
||||
# secrets containing @, :, spaces, or other escaped characters.
|
||||
encoded = {quote(password, safe=""), quote_plus(password, safe="")}
|
||||
variants = {
|
||||
password,
|
||||
*encoded,
|
||||
*(re.sub(r"%[0-9A-F]{2}", lambda match: match.group(0).lower(), item)
|
||||
for item in encoded),
|
||||
}
|
||||
for secret in sorted(variants, key=len, reverse=True):
|
||||
if secret:
|
||||
message = message.replace(secret, "[пароль скрыт]")
|
||||
return message
|
||||
|
||||
|
||||
|
|
@ -515,12 +596,21 @@ async def make_backup(request: Request) -> BackupOut:
|
|||
"""Копия прямо сейчас. Расписание — отдельно, в `scripts/backup.py`:
|
||||
кнопка нужна перед занятием, расписание — чтобы о нём не вспоминали."""
|
||||
who = require(request, Role.ADMIN)
|
||||
# Record intent before the irreversible filesystem operation. If the DB
|
||||
# audit store fails after pg_dump finishes, the attempt is still visible.
|
||||
await audit_required(who.login, who.role.value, "backup.create.requested")
|
||||
try:
|
||||
# pg_dump may run for two minutes; never block the event loop for it.
|
||||
created = await run_in_threadpool(backup_service.create)
|
||||
except backup_service.BackupError as exc:
|
||||
detail = _safe_backup_error(exc)
|
||||
# The durable requested event above preserves the attempt even if the
|
||||
# outcome write also fails. Keep the concrete storage error visible to
|
||||
# the operator instead of replacing it with an audit-store error.
|
||||
await audit(who.login, who.role.value, "backup.failed", detail=detail)
|
||||
raise HTTPException(status_code=503, detail=detail) from exc
|
||||
await audit(who.login, who.role.value, "backup.create", created["name"])
|
||||
# A completed backup must not be reported as successful when its security
|
||||
# audit could not be persisted. The file remains visible in the backup list
|
||||
# so an administrator can reconcile it after the audit store recovers.
|
||||
await audit_required(who.login, who.role.value, "backup.create", created["name"])
|
||||
return BackupOut(**created)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Сводка ошибок и рекомендаций учебной группы для преподавателя."""
|
||||
|
||||
from uuid import UUID
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
|
@ -8,7 +8,7 @@ from sqlalchemy import and_, func, or_, select
|
|||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import audit, require
|
||||
from app.api.auth import add_audit_entry, audit_required, require
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Group, Score, Session, Trainee, User
|
||||
from app.domain.roles import Role
|
||||
|
|
@ -113,14 +113,16 @@ async def create(
|
|||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=422, detail="group_name_required")
|
||||
group = Group(name=name, owner_login=who.login if who.role is Role.INSTRUCTOR else None)
|
||||
group = Group(
|
||||
id=uuid4(), name=name, owner_login=who.login if who.role is Role.INSTRUCTOR else None
|
||||
)
|
||||
db.add(group)
|
||||
add_audit_entry(db, who.login, who.role.value, "group.create", str(group.id), group.name)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail="group_exists") from exc
|
||||
await audit(who.login, who.role.value, "group.create", str(group.id), group.name)
|
||||
return GroupOut(id=group.id, name=group.name)
|
||||
|
||||
|
||||
|
|
@ -142,14 +144,11 @@ async def transfer_ownership(
|
|||
raise HTTPException(status_code=422, detail="active_instructor_required")
|
||||
previous_owner = group.owner_login
|
||||
group.owner_login = body.owner_login
|
||||
await db.commit()
|
||||
await audit(
|
||||
who.login,
|
||||
who.role.value,
|
||||
"group.transfer",
|
||||
str(group.id),
|
||||
add_audit_entry(
|
||||
db, who.login, who.role.value, "group.transfer", str(group.id),
|
||||
f"{previous_owner or 'admin'} -> {body.owner_login or 'admin'}",
|
||||
)
|
||||
await db.commit()
|
||||
return GroupOut(id=group.id, name=group.name)
|
||||
|
||||
|
||||
|
|
@ -174,8 +173,8 @@ async def assign_trainee(
|
|||
if current_group is None or current_group.owner_login != who.login:
|
||||
raise HTTPException(status_code=409, detail="trainee_in_other_instructor_group")
|
||||
trainee.group_id = group_id
|
||||
add_audit_entry(db, who.login, who.role.value, "group.assign", str(group.id), str(trainee_id))
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "group.assign", str(group.id), str(trainee_id))
|
||||
return GroupOut(id=group.id, name=group.name)
|
||||
|
||||
|
||||
|
|
@ -204,5 +203,7 @@ async def ai_insight(
|
|||
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
|
||||
except InsightInvalid as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "group.ai_insight", str(group_id), "aggregated_only")
|
||||
await audit_required(
|
||||
who.login, who.role.value, "group.ai_insight", str(group_id), "aggregated_only"
|
||||
)
|
||||
return GroupInsightOut(**insight)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from pydantic import BaseModel, Field, model_validator
|
|||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import DEMO_TRAINEE_ID, audit, require
|
||||
from app.api.auth import DEMO_TRAINEE_ID, add_audit_entry, audit, require
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Group, LearningMaterial, MaterialAssignment, Trainee
|
||||
|
|
@ -264,8 +264,8 @@ async def create(
|
|||
_demo_materials[row.id] = row
|
||||
else:
|
||||
db.add(row)
|
||||
add_audit_entry(db, who.login, who.role.value, "material.create", str(row.id), row.title)
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "material.create", str(row.id), row.title)
|
||||
return _out(row)
|
||||
|
||||
|
||||
|
|
@ -301,8 +301,8 @@ async def update(
|
|||
setattr(row, key, value.strip() if isinstance(value, str) else value)
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
if db is not None:
|
||||
add_audit_entry(db, who.login, who.role.value, "material.update", str(row.id))
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "material.update", str(row.id))
|
||||
return _out(row)
|
||||
|
||||
|
||||
|
|
@ -320,8 +320,8 @@ async def archive(
|
|||
row.active = False
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
if db is not None:
|
||||
add_audit_entry(db, who.login, who.role.value, "material.archive", str(row.id))
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "material.archive", str(row.id))
|
||||
return _out(row)
|
||||
|
||||
|
||||
|
|
@ -361,9 +361,13 @@ async def assign(
|
|||
material_id=material_id, trainee_id=trainee_id, assigned_by=who.login
|
||||
)
|
||||
db.add(assignment)
|
||||
await db.commit()
|
||||
await db.refresh(assignment)
|
||||
await audit(who.login, who.role.value, "material.assign", str(row.id), str(trainee_id))
|
||||
add_audit_entry(
|
||||
db, who.login, who.role.value, "material.assign", str(row.id), str(trainee_id)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(assignment)
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "material.assign", str(row.id), str(trainee_id))
|
||||
return _out(row, assignment=assignment)
|
||||
|
||||
|
||||
|
|
@ -394,8 +398,10 @@ async def assign_group(
|
|||
db.add(MaterialAssignment(
|
||||
material_id=material_id, trainee_id=trainee_id, assigned_by=who.login
|
||||
))
|
||||
add_audit_entry(
|
||||
db, who.login, who.role.value, "material.assign_group", str(row.id), str(group_id)
|
||||
)
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "material.assign_group", str(row.id), str(group_id))
|
||||
return {"material_id": str(row.id), "assigned": len(trainee_ids)}
|
||||
|
||||
|
||||
|
|
@ -422,9 +428,13 @@ async def unassign(
|
|||
raise HTTPException(status_code=404, detail="assignment_not_found")
|
||||
if assignment is not None:
|
||||
await db.delete(assignment)
|
||||
await db.commit()
|
||||
removed = assignment is not None
|
||||
await audit(who.login, who.role.value, "material.unassign", str(material_id), str(trainee_id))
|
||||
add_audit_entry(
|
||||
db, who.login, who.role.value, "material.unassign", str(material_id), str(trainee_id)
|
||||
)
|
||||
await db.commit()
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "material.unassign", str(material_id), str(trainee_id))
|
||||
return {"removed": removed}
|
||||
|
||||
|
||||
|
|
@ -454,8 +464,10 @@ async def complete(
|
|||
assignment["completed_at"] = completed_at
|
||||
else:
|
||||
assignment.completed_at = completed_at
|
||||
add_audit_entry(db, who.login, who.role.value, "material.complete", str(material_id))
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "material.complete", str(material_id))
|
||||
if isinstance(assignment, dict):
|
||||
await audit(who.login, who.role.value, "material.complete", str(material_id))
|
||||
return _out(row, assignment=assignment)
|
||||
|
||||
|
||||
|
|
|
|||
402
backend/app/api/http/scenario_submissions.py
Normal file
402
backend/app/api/http/scenario_submissions.py
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
"""Student-authored case outlines and instructor moderation."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import Principal, add_audit_entry, audit, require
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Group, ScenarioSubmission, Trainee
|
||||
from app.db.models import Scenario as ScenarioRow
|
||||
from app.domain.classifiers import IncidentType, Level
|
||||
from app.domain.kio import KIO, derive_incident
|
||||
from app.domain.roles import Role
|
||||
from app.scenarios import store
|
||||
from app.scenarios.editor import validate
|
||||
from app.scenarios.loader import ScenarioError
|
||||
|
||||
router = APIRouter(prefix="/api/scenario-submissions", tags=["scenario submissions"])
|
||||
_demo_submissions: dict[UUID, dict] = {}
|
||||
|
||||
|
||||
def _card_address(card: KIO) -> str:
|
||||
explicit = (card.address or "").strip()
|
||||
fallback = " ".join(filter(None, (card.street, card.building))).strip()
|
||||
return explicit or fallback
|
||||
|
||||
|
||||
async def submission_session() -> AsyncIterator[AsyncSession | None]:
|
||||
if get_settings().demo_no_db:
|
||||
yield None
|
||||
else:
|
||||
async for db in get_session():
|
||||
yield db
|
||||
|
||||
|
||||
class SubmissionIn(BaseModel):
|
||||
title: str = Field(min_length=3, max_length=200)
|
||||
level: Level
|
||||
kio: KIO
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def normalize_title(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if len(normalized) < 3:
|
||||
raise ValueError("title must contain at least three non-space characters")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_kio(self):
|
||||
card = derive_incident(self.kio)
|
||||
if card.incident_type is None or len((card.description or "").strip()) < 20:
|
||||
raise ValueError("KIO needs incident type and a meaningful description")
|
||||
if not _card_address(card):
|
||||
raise ValueError("KIO needs a usable address")
|
||||
if card.incident_group is None or not card.signs:
|
||||
raise ValueError("KIO needs a classifier group and signs")
|
||||
if not card.notify:
|
||||
raise ValueError("KIO needs at least one derived DDS recipient")
|
||||
data = card.model_dump()
|
||||
data.update(
|
||||
{
|
||||
"card_id": uuid4(),
|
||||
"registered_at": None,
|
||||
"response_status": "registered",
|
||||
"caller_number": None,
|
||||
"incident_code": None,
|
||||
"notify": [],
|
||||
"dispatch_order_at": None,
|
||||
"arrival_at": None,
|
||||
}
|
||||
)
|
||||
object.__setattr__(self, "kio", derive_incident(KIO.model_validate(data)))
|
||||
return self
|
||||
|
||||
|
||||
class ReviewIn(BaseModel):
|
||||
decision: Literal["approve", "reject"]
|
||||
comment: str = Field(default="", max_length=1000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def rejection_needs_reason(self):
|
||||
if self.decision == "reject" and not self.comment.strip():
|
||||
raise ValueError("comment is required when rejecting a proposal")
|
||||
return self
|
||||
|
||||
|
||||
def _out(row, author_name: str | None = None) -> dict:
|
||||
def get(name, default=None):
|
||||
if isinstance(row, dict):
|
||||
return row.get(name, default)
|
||||
return getattr(row, name, default)
|
||||
|
||||
return {
|
||||
"id": str(get("id")),
|
||||
"author_name": author_name or get("author_name", "Курсант"),
|
||||
"title": get("title"),
|
||||
"incident_type": get("incident_type"),
|
||||
"level": get("level"),
|
||||
"description": get("description"),
|
||||
"address": get("address", ""),
|
||||
"victims": get("victims"),
|
||||
"kio": get("kio"),
|
||||
"status": get("status"),
|
||||
"review_comment": get("review_comment", ""),
|
||||
"scenario_id": get("scenario_id"),
|
||||
"created_at": get("created_at"),
|
||||
"reviewed_at": get("reviewed_at"),
|
||||
}
|
||||
|
||||
|
||||
def _scenario_for(row) -> object:
|
||||
sid = f"student-{row['id'].hex if isinstance(row, dict) else row.id.hex}"
|
||||
title = row["title"] if isinstance(row, dict) else row.title
|
||||
level = row["level"] if isinstance(row, dict) else row.level
|
||||
kio_data = row.get("kio") if isinstance(row, dict) else row.kio
|
||||
if kio_data:
|
||||
card = derive_incident(KIO.model_validate(kio_data))
|
||||
if card.incident_type is None:
|
||||
raise ScenarioError("КИО не содержит тип происшествия")
|
||||
address = _card_address(card) or None
|
||||
facts = [{"id": "event", "value": card.description or title}]
|
||||
if address:
|
||||
facts.append({"id": "address", "value": address})
|
||||
caller = "; ".join(
|
||||
filter(None, (card.caller_name, card.caller_contact, card.phone_on_scene))
|
||||
)
|
||||
if caller:
|
||||
facts.append({"id": "f_caller", "value": caller})
|
||||
raw = {
|
||||
"id": sid,
|
||||
"title": title.strip(),
|
||||
"type": card.incident_type.value,
|
||||
"level": level,
|
||||
"topics": ["student-created", "moderated-kio"],
|
||||
"modes": ["training", "exam"],
|
||||
"persona": {"base": "Утверждённая преподавателем учебная карточка КИО."},
|
||||
"first_line": card.description or title,
|
||||
"signs": card.signs,
|
||||
"facts": facts,
|
||||
"checklist": [
|
||||
{"id": "q_event", "question": "Что произошло?", "fact": "event"}
|
||||
],
|
||||
"required_fields": ["address", "description"],
|
||||
"outcome": "card",
|
||||
"dds_decision": {"expected": "accept"},
|
||||
"ground_truth": {
|
||||
**({"address": address} if address else {}),
|
||||
**(
|
||||
{"victims": card.victims_count}
|
||||
if card.victims_count is not None
|
||||
else {}
|
||||
),
|
||||
},
|
||||
"student_card": card.model_dump(mode="json"),
|
||||
}
|
||||
return validate(raw)
|
||||
|
||||
incident_type = row["incident_type"] if isinstance(row, dict) else row.incident_type
|
||||
description = row["description"] if isinstance(row, dict) else row.description
|
||||
address = row.get("address", "") if isinstance(row, dict) else row.address
|
||||
victims = row.get("victims") if isinstance(row, dict) else row.victims
|
||||
facts = [{"id": "event", "value": description.strip()}]
|
||||
if address and address.strip():
|
||||
facts.append({"id": "address", "value": address.strip()})
|
||||
raw = {
|
||||
"id": sid,
|
||||
"title": title.strip(),
|
||||
"type": incident_type,
|
||||
"level": level,
|
||||
"topics": ["student-created"],
|
||||
"modes": ["training", "exam"],
|
||||
"persona": {
|
||||
"base": "Авторский учебный сюжет курсанта, проверенный преподавателем."
|
||||
},
|
||||
"first_line": description.strip(),
|
||||
"facts": facts,
|
||||
"checklist": [{"id": "q_event", "question": "Что произошло?", "fact": "event"}],
|
||||
"outcome": "card",
|
||||
"ground_truth": {
|
||||
**({"address": address.strip()} if address and address.strip() else {}),
|
||||
**({"victims": victims} if victims is not None else {}),
|
||||
},
|
||||
}
|
||||
return validate(raw)
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_submission(
|
||||
body: SubmissionIn,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(submission_session),
|
||||
) -> dict:
|
||||
who: Principal = require(request, Role.TRAINEE)
|
||||
if who.trainee_id is None:
|
||||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
now = datetime.now(UTC)
|
||||
card = body.kio
|
||||
incident_type = card.incident_type
|
||||
description = card.description or ""
|
||||
address = _card_address(card)
|
||||
victims = card.victims_count
|
||||
if db is None:
|
||||
row = {
|
||||
"id": uuid4(),
|
||||
"author_trainee_id": who.trainee_id,
|
||||
"author_name": who.full_name,
|
||||
"group_id": None,
|
||||
"title": body.title,
|
||||
"level": body.level.value,
|
||||
"kio": card.model_dump(mode="json"),
|
||||
"incident_type": incident_type.value,
|
||||
"description": description,
|
||||
"address": address,
|
||||
"victims": victims,
|
||||
"status": "pending",
|
||||
"review_comment": "",
|
||||
"reviewed_by": None,
|
||||
"scenario_id": None,
|
||||
"created_at": now,
|
||||
"reviewed_at": None,
|
||||
}
|
||||
_demo_submissions[row["id"]] = row
|
||||
else:
|
||||
trainee = await db.get(Trainee, who.trainee_id)
|
||||
if trainee is None:
|
||||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
if trainee.group_id is None:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="trainee_group_required_for_review"
|
||||
)
|
||||
group = await db.get(Group, trainee.group_id)
|
||||
if group is None or group.owner_login is None:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="instructor_group_required_for_review"
|
||||
)
|
||||
row = ScenarioSubmission(
|
||||
id=uuid4(),
|
||||
author_trainee_id=trainee.id,
|
||||
group_id=trainee.group_id,
|
||||
title=body.title.strip(),
|
||||
incident_type=incident_type.value,
|
||||
level=body.level.value,
|
||||
description=description,
|
||||
address=address,
|
||||
victims=victims,
|
||||
kio=card.model_dump(mode="json"),
|
||||
)
|
||||
db.add(row)
|
||||
add_audit_entry(
|
||||
db, who.login, who.role.value, "scenario.submission.create", str(row.id)
|
||||
)
|
||||
await db.commit()
|
||||
if isinstance(row, dict):
|
||||
await audit(who.login, who.role.value, "scenario.submission.create", str(row["id"]))
|
||||
return _out(row, who.full_name)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_submissions(
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(submission_session),
|
||||
) -> list[dict]:
|
||||
who: Principal = require(request, Role.TRAINEE, Role.INSTRUCTOR, Role.ADMIN)
|
||||
if db is None:
|
||||
if who.role is Role.TRAINEE:
|
||||
rows = [
|
||||
row
|
||||
for row in _demo_submissions.values()
|
||||
if row["author_trainee_id"] == who.trainee_id
|
||||
]
|
||||
else:
|
||||
rows = list(_demo_submissions.values())
|
||||
rows.sort(key=lambda item: item["created_at"], reverse=True)
|
||||
return [_out(row) for row in rows]
|
||||
|
||||
query = select(ScenarioSubmission, Trainee.name).join(
|
||||
Trainee, Trainee.id == ScenarioSubmission.author_trainee_id
|
||||
)
|
||||
if who.role is Role.TRAINEE:
|
||||
if who.trainee_id is None:
|
||||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
query = query.where(ScenarioSubmission.author_trainee_id == who.trainee_id)
|
||||
elif who.role is Role.INSTRUCTOR:
|
||||
owned_groups = select(Group.id).where(Group.owner_login == who.login)
|
||||
query = query.where(ScenarioSubmission.group_id.in_(owned_groups))
|
||||
rows = (
|
||||
await db.execute(query.order_by(ScenarioSubmission.created_at.desc()))
|
||||
).all()
|
||||
return [_out(row, name) for row, name in rows]
|
||||
|
||||
|
||||
async def _reviewable(
|
||||
db: AsyncSession, submission_id: UUID, who: Principal
|
||||
) -> ScenarioSubmission | None:
|
||||
# Serialize concurrent teacher decisions. Under PostgreSQL READ COMMITTED,
|
||||
# a second reviewer waits and then observes the committed non-pending status,
|
||||
# instead of racing to publish the same scenario twice.
|
||||
query = (
|
||||
select(ScenarioSubmission)
|
||||
.where(ScenarioSubmission.id == submission_id)
|
||||
.with_for_update()
|
||||
)
|
||||
if who.role is Role.INSTRUCTOR:
|
||||
owned_groups = select(Group.id).where(Group.owner_login == who.login)
|
||||
query = query.where(ScenarioSubmission.group_id.in_(owned_groups))
|
||||
return await db.scalar(query)
|
||||
|
||||
|
||||
@router.post("/{submission_id}/review")
|
||||
async def review_submission(
|
||||
submission_id: UUID,
|
||||
body: ReviewIn,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(submission_session),
|
||||
) -> dict:
|
||||
who: Principal = require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
if db is None:
|
||||
row = _demo_submissions.get(submission_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="submission_not_found")
|
||||
if row["status"] != "pending":
|
||||
raise HTTPException(status_code=409, detail="submission_already_reviewed")
|
||||
else:
|
||||
row = await _reviewable(db, submission_id, who)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="submission_not_found")
|
||||
if row.status != "pending":
|
||||
raise HTTPException(status_code=409, detail="submission_already_reviewed")
|
||||
|
||||
scenario = None
|
||||
if body.decision == "approve":
|
||||
try:
|
||||
scenario = _scenario_for(row)
|
||||
except ScenarioError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"scenario_invalid: {exc}"
|
||||
) from exc
|
||||
|
||||
now = datetime.now(UTC)
|
||||
if isinstance(row, dict):
|
||||
row["status"] = "approved" if scenario else "rejected"
|
||||
row["review_comment"] = body.comment.strip()
|
||||
row["reviewed_by"] = who.login
|
||||
row["reviewed_at"] = now
|
||||
if scenario is not None:
|
||||
row["scenario_id"] = scenario.id
|
||||
else:
|
||||
row.status = "approved" if scenario else "rejected"
|
||||
row.review_comment = body.comment.strip()
|
||||
row.reviewed_by = who.login
|
||||
row.reviewed_at = now
|
||||
if scenario is not None:
|
||||
db.add(
|
||||
ScenarioRow(
|
||||
id=scenario.id,
|
||||
title=scenario.title,
|
||||
incident_type=scenario.type.value,
|
||||
level=scenario.level.value,
|
||||
topics=scenario.topics,
|
||||
modes=scenario.modes,
|
||||
status="published",
|
||||
owner_login=who.login,
|
||||
body=scenario.model_dump(mode="json"),
|
||||
)
|
||||
)
|
||||
row.scenario_id = scenario.id
|
||||
add_audit_entry(
|
||||
db,
|
||||
who.login,
|
||||
who.role.value,
|
||||
f"scenario.submission.{body.decision}",
|
||||
str(submission_id),
|
||||
f"comment_chars={len(body.comment.strip())}" if body.comment else "",
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
if scenario is not None:
|
||||
store.register_owned_scenario(scenario, who.login)
|
||||
if isinstance(row, dict):
|
||||
await audit(
|
||||
who.login,
|
||||
who.role.value,
|
||||
f"scenario.submission.{body.decision}",
|
||||
str(submission_id),
|
||||
f"comment_chars={len(body.comment.strip())}" if body.comment else "",
|
||||
)
|
||||
result = _out(row)
|
||||
if scenario is not None:
|
||||
result["scenario_id"] = scenario.id
|
||||
return result
|
||||
|
||||
|
||||
def reset_demo_submissions() -> None:
|
||||
_demo_submissions.clear()
|
||||
|
|
@ -1,37 +1,58 @@
|
|||
"""Библиотека сценариев по HTTP.
|
||||
|
||||
`GET /api/scenarios/{id}` **не отдаёт** `facts` и `ground_truth`: иначе курсант
|
||||
откроет DevTools и прочитает адрес до того, как его спросит.
|
||||
|
||||
`checklist` скрыт по той же причине и даже более веской: чек-лист — это
|
||||
содержимое подсказок. Отдать его целиком значит выдать в контрольном режиме
|
||||
то, чего там не должно быть вовсе, и обойти выдачу по одному пункту
|
||||
(docs/product/MODES.md#подсказка-по-запросу). Подсказки идут только событием
|
||||
`hint.shown` из живой сессии, эталонные вопросы — только в разборе.
|
||||
Курсантский каталог и карточка отдают только заголовок, сложность и доступные
|
||||
режимы: классификатор, факты, личность звонящего и чек-лист не должны быть
|
||||
доступны заранее через DevTools. Инструктор и администратор получают редакторскую
|
||||
карточку. Подсказки в сессии выдаются по одному пункту через `hint.shown`,
|
||||
эталонные вопросы — только в разборе (docs/product/MODES.md#подсказка-по-запросу).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import audit, require
|
||||
from app.domain import ekp
|
||||
from app.db.base import get_session
|
||||
from app.api.auth import add_audit_entry, audit, require
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Group, Trainee
|
||||
from app.dialog.llm import LlmUnavailable
|
||||
from app.domain import ekp
|
||||
from app.domain.roles import Role
|
||||
from app.scenarios import store
|
||||
from app.scenarios.editor import validate
|
||||
from app.scenarios.generation import GenerationError, generate, generate_from_description
|
||||
from app.dialog.llm import LlmUnavailable
|
||||
from app.scenarios.generation import (
|
||||
GenerationError,
|
||||
generate,
|
||||
generate_from_description,
|
||||
)
|
||||
from app.scenarios.loader import ScenarioError
|
||||
from app.scoring.grammar import assess
|
||||
from app.session.hub import hub
|
||||
|
||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
|
||||
|
||||
HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"}
|
||||
|
||||
async def _hidden_scenario_ids(db: AsyncSession | None, who) -> set[str]:
|
||||
"""Scenario drafts are private to their instructor and that instructor's class."""
|
||||
if who.role is Role.ADMIN:
|
||||
return set()
|
||||
owner_login = who.login
|
||||
if who.role is Role.TRAINEE:
|
||||
if db is None or who.trainee_id is None:
|
||||
owner_login = ""
|
||||
else:
|
||||
owner_login = await db.scalar(
|
||||
select(Group.owner_login)
|
||||
.join(Trainee, Trainee.group_id == Group.id)
|
||||
.where(Trainee.id == who.trainee_id)
|
||||
) or ""
|
||||
return await store.scenario_ids_owned_by_other(db, owner_login)
|
||||
|
||||
|
||||
async def scenario_session() -> AsyncIterator[AsyncSession | None]:
|
||||
|
|
@ -77,6 +98,25 @@ def _draft_out(row) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def _draft_grammar_hash(scenario) -> str:
|
||||
"""Stable fingerprint of the caller dialogue fields covered by grammar QA."""
|
||||
payload = {
|
||||
"first_line": scenario.first_line,
|
||||
"facts": [
|
||||
{"id": fact.id, "value": fact.value, "refined": fact.refined}
|
||||
for fact in scenario.facts
|
||||
],
|
||||
}
|
||||
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _audit_before_commit(actor: str, role: str, action: str, detail: str = ""):
|
||||
return lambda transaction, row: add_audit_entry(
|
||||
transaction, actor, role, action, str(row.id), detail
|
||||
)
|
||||
|
||||
|
||||
@router.post("/drafts/from-template", status_code=201)
|
||||
async def create_template_draft(
|
||||
body: TemplateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
|
|
@ -85,8 +125,17 @@ async def create_template_draft(
|
|||
source = store.get(body.source_id)
|
||||
if source is None:
|
||||
raise HTTPException(status_code=404, detail="published_source_not_found")
|
||||
row = await store.create_draft(db, source=source, title=body.title, owner_login=who.login)
|
||||
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
|
||||
row = await store.create_draft(
|
||||
db,
|
||||
source=source,
|
||||
title=body.title,
|
||||
owner_login=who.login,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.draft.create", f"template:{source.id}"
|
||||
),
|
||||
)
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
|
|
@ -100,13 +149,19 @@ async def create_ai_draft(
|
|||
raise HTTPException(status_code=404, detail="published_source_not_found")
|
||||
try:
|
||||
proposal = await generate(source, body.instruction.strip(), require_fact_change=False)
|
||||
row = await store.create_draft(db, source=source, proposal=proposal, owner_login=who.login)
|
||||
row = await store.create_draft(
|
||||
db, source=source, proposal=proposal, owner_login=who.login,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.draft.ai_generate", f"source:{source.id}",
|
||||
),
|
||||
)
|
||||
except LlmUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
|
||||
except GenerationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_generate", row.id,
|
||||
f"source:{source.id}")
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_generate", row.id,
|
||||
f"source:{source.id}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
|
|
@ -123,14 +178,19 @@ async def create_full_ai_draft(
|
|||
try:
|
||||
proposal = await generate_from_description(source, body.description.strip())
|
||||
row = await store.create_draft(
|
||||
db, source=source, full_proposal=proposal, owner_login=who.login
|
||||
db, source=source, full_proposal=proposal, owner_login=who.login,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.draft.ai_generate_full",
|
||||
f"class_source:{source.id}",
|
||||
),
|
||||
)
|
||||
except LlmUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
|
||||
except GenerationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_generate_full", row.id,
|
||||
f"class_source:{source.id}")
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_generate_full", row.id,
|
||||
f"class_source:{source.id}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
|
|
@ -157,10 +217,16 @@ async def patch_draft(
|
|||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||
try:
|
||||
row = await store.update_draft(db, row, body)
|
||||
row = await store.update_draft(
|
||||
db, row, body,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.draft.update"
|
||||
),
|
||||
)
|
||||
except ScenarioError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "scenario.draft.update", row.id)
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.draft.update", row.id)
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
|
|
@ -178,13 +244,22 @@ async def revise_ai_draft(
|
|||
try:
|
||||
source = validate(row.body)
|
||||
proposal = await generate(source, body.comment.strip(), require_fact_change=False)
|
||||
row = await store.revise_draft(db, row, proposal)
|
||||
row = await store.revise_draft(
|
||||
db, row, proposal,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.draft.ai_revise",
|
||||
f"instruction_chars={len(body.comment.strip())}",
|
||||
),
|
||||
)
|
||||
except LlmUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
|
||||
except (GenerationError, ScenarioError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_revise", row.id,
|
||||
body.comment.strip()[:500])
|
||||
# Editorial instructions can contain names, addresses, or other sensitive
|
||||
# details. Keep only non-content metadata in the durable admin audit log.
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_revise", row.id,
|
||||
f"instruction_chars={len(body.comment.strip())}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
|
|
@ -207,6 +282,45 @@ async def validate_draft(
|
|||
}
|
||||
|
||||
|
||||
@router.post("/drafts/{scenario_id}/grammar-check")
|
||||
async def check_draft_grammar(
|
||||
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
) -> dict:
|
||||
"""Явная языковая проверка после ручного редактирования сценария.
|
||||
|
||||
Это только диагностический результат: проверяются реплика звонящего и
|
||||
текстовые значения фактов, но содержимое не исправляется и не публикуется.
|
||||
"""
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
row = await store.draft(db, scenario_id, owner_login=who.login)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||
try:
|
||||
scenario = validate(row.body)
|
||||
except ScenarioError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"сначала исправьте структуру: {exc}") from exc
|
||||
|
||||
fields = [("first_line", scenario.first_line)]
|
||||
for fact in scenario.facts:
|
||||
fields.append((f"facts.{fact.id}.value", fact.value))
|
||||
if fact.refined:
|
||||
fields.append((f"facts.{fact.id}.refined", fact.refined))
|
||||
checks = []
|
||||
for field, value in fields:
|
||||
result = await assess(value)
|
||||
checks.append({
|
||||
"field": field,
|
||||
"passed": result.passed,
|
||||
"errors": list(result.errors),
|
||||
"source": result.source,
|
||||
})
|
||||
passed = all(item["passed"] for item in checks)
|
||||
row.grammar_check_hash = _draft_grammar_hash(scenario) if passed else None
|
||||
if db is not None:
|
||||
await db.commit()
|
||||
return {"valid": passed, "checks": checks}
|
||||
|
||||
|
||||
@router.post("/drafts/{scenario_id}/approve")
|
||||
async def approve_draft(
|
||||
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
|
|
@ -216,10 +330,23 @@ async def approve_draft(
|
|||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||
try:
|
||||
scenario = await store.approve_draft(db, row)
|
||||
current = validate(row.body)
|
||||
if (row.manual_edit_pending
|
||||
and row.grammar_check_hash != _draft_grammar_hash(current)):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="после ручных правок требуется успешная проверка грамматики",
|
||||
)
|
||||
scenario = await store.approve_draft(
|
||||
db, row,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.approve"
|
||||
),
|
||||
)
|
||||
except ScenarioError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "scenario.approve", scenario.id)
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.approve", scenario.id)
|
||||
return {"id": scenario.id, "status": "published", "title": scenario.title}
|
||||
|
||||
|
||||
|
|
@ -228,15 +355,36 @@ async def listing(
|
|||
request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
) -> list[dict]:
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
|
||||
if db is not None:
|
||||
# Published student scenarios may have been approved on a peer backend.
|
||||
# Refresh this process-local catalog from the shared authoritative DB.
|
||||
await store.restore_published(db)
|
||||
owned_ids = (
|
||||
await store.owned_scenario_ids(db, who.login)
|
||||
if who is not None and who.role is Role.INSTRUCTOR
|
||||
else set()
|
||||
)
|
||||
return [
|
||||
{
|
||||
hidden_ids = await _hidden_scenario_ids(db, who)
|
||||
result = []
|
||||
for scenario in store.all_scenarios():
|
||||
if scenario.id in hidden_ids:
|
||||
continue
|
||||
if who.role is Role.TRAINEE:
|
||||
# A trainee may select a scenario for self-practice, but the catalog
|
||||
# must not reveal dispatch codes, answer hints, or instructor-only metadata.
|
||||
if "self" not in scenario.modes:
|
||||
continue
|
||||
result.append({
|
||||
"id": scenario.id,
|
||||
"title": scenario.title,
|
||||
"level": scenario.level.value,
|
||||
"modes": scenario.modes,
|
||||
})
|
||||
continue
|
||||
result.append({
|
||||
"id": scenario.id,
|
||||
"title": scenario.title,
|
||||
"outcome": scenario.outcome.value,
|
||||
"type": scenario.type.value,
|
||||
"level": scenario.level.value,
|
||||
"topics": scenario.topics,
|
||||
|
|
@ -253,9 +401,9 @@ async def listing(
|
|||
if scenario.ground_truth.incident_code
|
||||
and ekp.incident(scenario.ground_truth.incident_code) else None),
|
||||
"can_manage": scenario.id in owned_ids,
|
||||
}
|
||||
for scenario in store.all_scenarios()
|
||||
]
|
||||
"source": "trainee" if "student-created" in scenario.topics else "system",
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{scenario_id}")
|
||||
|
|
@ -267,10 +415,16 @@ async def archive_scenario(
|
|||
who = require(request, Role.INSTRUCTOR)
|
||||
if hub.has_active_scenario(scenario_id):
|
||||
raise HTTPException(status_code=409, detail="scenario_is_used_by_active_session")
|
||||
scenario = await store.archive(db, scenario_id, owner_login=who.login)
|
||||
scenario = await store.archive(
|
||||
db, scenario_id, owner_login=who.login,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.archive"
|
||||
),
|
||||
)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||
await audit(who.login, who.role.value, "scenario.archive", scenario_id)
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.archive", scenario_id)
|
||||
return {"id": scenario_id, "status": "archived", "title": scenario.title}
|
||||
|
||||
|
||||
|
|
@ -280,23 +434,42 @@ async def restore_scenario(
|
|||
db: AsyncSession | None = Depends(scenario_session),
|
||||
) -> dict:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
scenario = await store.restore_archived(db, scenario_id, owner_login=who.login)
|
||||
scenario = await store.restore_archived(
|
||||
db, scenario_id, owner_login=who.login,
|
||||
before_commit=_audit_before_commit(
|
||||
who.login, who.role.value, "scenario.restore"
|
||||
),
|
||||
)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="archived_scenario_not_found")
|
||||
await audit(who.login, who.role.value, "scenario.restore", scenario_id)
|
||||
if db is None:
|
||||
await audit(who.login, who.role.value, "scenario.restore", scenario_id)
|
||||
return {"id": scenario_id, "status": "published", "title": scenario.title}
|
||||
|
||||
|
||||
@router.get("/{scenario_id}")
|
||||
async def read(scenario_id: str, request: Request) -> dict:
|
||||
async def read(
|
||||
scenario_id: str,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(scenario_session),
|
||||
) -> dict:
|
||||
# Training content is local but not public: anonymous clients must not be
|
||||
# able to enumerate cards or inspect even the trainee-safe scenario body.
|
||||
require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
|
||||
if scenario_id in await _hidden_scenario_ids(db, who):
|
||||
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||
if db is not None:
|
||||
await store.restore_published(db)
|
||||
scenario = store.get(scenario_id)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||
payload = scenario.model_dump(mode="json")
|
||||
for key in HIDDEN_FROM_TRAINEE:
|
||||
payload.pop(key, None)
|
||||
payload["required_fields"] = scenario.required_fields
|
||||
return payload
|
||||
if who.role is Role.TRAINEE:
|
||||
if "self" not in scenario.modes:
|
||||
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||
return {
|
||||
"id": scenario.id,
|
||||
"title": scenario.title,
|
||||
"level": scenario.level.value,
|
||||
"modes": scenario.modes,
|
||||
}
|
||||
return scenario.model_dump(mode="json")
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@
|
|||
задним числом не надо (docs/arch/CONTRACT.md#http-api).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
|
|
@ -13,20 +16,32 @@ from pydantic import BaseModel, Field, field_validator
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import audit, require
|
||||
from app.api.auth import add_audit_entry, audit, audit_required, require
|
||||
from app.config import get_settings
|
||||
from app.db import repo
|
||||
from app.db.base import get_session
|
||||
from app.db.models import AuditLog, Score
|
||||
from app.db.models import AuditLog, Group, Score, Session, Trainee
|
||||
from app.domain.events import Exercise, SessionMode, SessionReport
|
||||
from app.scenarios import store
|
||||
from app.scoring.report import build as build_report
|
||||
from app.scoring.export import to_csv, to_pdf
|
||||
from app.domain.roles import Role
|
||||
from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios import store
|
||||
from app.scoring.export import to_csv, to_pdf
|
||||
from app.scoring.report import build as build_report
|
||||
from app.session.checkpoint import load_state
|
||||
from app.session.hub import hub
|
||||
from app.voice.recording import recording_path
|
||||
|
||||
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def optional_session() -> AsyncIterator[AsyncSession | None]:
|
||||
if get_settings().demo_no_db:
|
||||
yield None
|
||||
else:
|
||||
async for db in get_session():
|
||||
yield db
|
||||
|
||||
|
||||
class SessionCreate(BaseModel):
|
||||
|
|
@ -48,6 +63,43 @@ class SessionOut(BaseModel):
|
|||
end_reason: str | None = None
|
||||
|
||||
|
||||
class DdsHistoryOut(BaseModel):
|
||||
"""Одна завершённая карточка из отчёта занятия; только в границах владельца."""
|
||||
|
||||
session_id: UUID
|
||||
ended_at: datetime
|
||||
card_id: UUID
|
||||
scenario_id: str
|
||||
score_auto: float
|
||||
score_final: float
|
||||
reply_text: str = ""
|
||||
title: str | None = None
|
||||
address: str | None = None
|
||||
description: str | None = None
|
||||
incident_type: str | None = None
|
||||
victims_count: int | None = None
|
||||
received_at: datetime | None = None
|
||||
managed_service: str | None = None
|
||||
recipient_services: list[str] = []
|
||||
|
||||
|
||||
class ActiveSessionOut(BaseModel):
|
||||
session_id: UUID
|
||||
trainee_name: str | None
|
||||
scenario_id: str
|
||||
scenario_title: str
|
||||
mode: SessionMode
|
||||
exercise: Exercise
|
||||
started_at: datetime | None
|
||||
elapsed_seconds: int
|
||||
dds_card_total: int
|
||||
dds_open_cards: int
|
||||
dds_overdue_cards: int
|
||||
dds_work_overdue_cards: int
|
||||
dds_statuses: dict[str, str]
|
||||
dds_snapshot: StationSnapshot | None = None
|
||||
|
||||
|
||||
def _out(session) -> SessionOut:
|
||||
return SessionOut(
|
||||
session_id=session.id,
|
||||
|
|
@ -62,14 +114,204 @@ def _out(session) -> SessionOut:
|
|||
)
|
||||
|
||||
|
||||
@router.get("/dds-history", response_model=list[DdsHistoryOut])
|
||||
async def dds_history(
|
||||
request: Request,
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
db: AsyncSession | None = Depends(optional_session),
|
||||
) -> list[DdsHistoryOut]:
|
||||
"""Durable completed-card registry, limited to the current trainee/instructor."""
|
||||
who = require(request, Role.TRAINEE, Role.INSTRUCTOR)
|
||||
if db is None:
|
||||
await audit_required(
|
||||
who.login, who.role.value, "dds.history.read", detail="cards=0"
|
||||
)
|
||||
return []
|
||||
statement = (
|
||||
select(Session, Score)
|
||||
.join(Score, Score.session_id == Session.id)
|
||||
.where(Session.ended_at.is_not(None))
|
||||
.order_by(Session.ended_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if who.role is Role.TRAINEE:
|
||||
if who.trainee_id is None:
|
||||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
statement = statement.where(Session.trainee_id == who.trainee_id)
|
||||
else:
|
||||
statement = statement.where(Session.owner_login == who.login)
|
||||
|
||||
rows = (await db.execute(statement)).all()
|
||||
result: list[DdsHistoryOut] = []
|
||||
for session, score in rows:
|
||||
report = score.report or {}
|
||||
full_report = report.get("full_report") or report
|
||||
if full_report.get("exercise") != Exercise.DDS.value:
|
||||
continue
|
||||
for card in full_report.get("card_results", []):
|
||||
try:
|
||||
result.append(DdsHistoryOut(
|
||||
session_id=session.id,
|
||||
ended_at=session.ended_at,
|
||||
card_id=card["card_id"],
|
||||
scenario_id=card["scenario_id"],
|
||||
score_auto=card["score_auto"],
|
||||
score_final=score.score_final,
|
||||
reply_text=card.get("reply_text", ""),
|
||||
title=card.get("title"),
|
||||
address=card.get("address"),
|
||||
description=card.get("description"),
|
||||
incident_type=card.get("incident_type"),
|
||||
victims_count=card.get("victims_count"),
|
||||
received_at=card.get("received_at"),
|
||||
managed_service=card.get("managed_service"),
|
||||
recipient_services=card.get("recipient_services", []),
|
||||
))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
log.warning("Пропущена некорректная карточка ДДС в отчёте сессии %s", session.id)
|
||||
if len(result) >= limit:
|
||||
await audit_required(
|
||||
who.login, who.role.value, "dds.history.read",
|
||||
detail=f"cards={len(result)}",
|
||||
)
|
||||
return result
|
||||
await audit_required(
|
||||
who.login, who.role.value, "dds.history.read", detail=f"cards={len(result)}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/active", response_model=list[ActiveSessionOut])
|
||||
async def active(
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(optional_session),
|
||||
) -> list[ActiveSessionOut]:
|
||||
"""Компактный live-реестр сессий преподавателя; детали остаются в /ws/observe."""
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
now = datetime.now(UTC)
|
||||
result: list[ActiveSessionOut] = []
|
||||
states = {
|
||||
state.session_id: state
|
||||
for state in hub.active_sessions(who.login)
|
||||
}
|
||||
if db is not None:
|
||||
rows = (
|
||||
await db.scalars(
|
||||
select(Session).where(
|
||||
Session.owner_login == who.login,
|
||||
Session.ended_at.is_(None),
|
||||
Session.live_state.is_not(None),
|
||||
Session.checkpoint_at.is_not(None),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
for row in rows:
|
||||
local = hub.get(row.id)
|
||||
if local is not None:
|
||||
if local.owner_login == who.login and not local.ended:
|
||||
states[row.id] = local
|
||||
else:
|
||||
states.pop(row.id, None)
|
||||
continue
|
||||
try:
|
||||
state = load_state(row.live_state, row.checkpoint_at)
|
||||
except Exception as exc: # noqa: BLE001 — один плохой checkpoint не ломает весь реестр
|
||||
log.error("Не удалось прочитать checkpoint сессии %s (%s)",
|
||||
row.id, type(exc).__name__)
|
||||
continue
|
||||
state.owner_login = row.owner_login
|
||||
if not state.ended:
|
||||
states[state.session_id] = state
|
||||
|
||||
for state in states.values():
|
||||
elapsed = (max(0, int((now - state.started_at).total_seconds()))
|
||||
if state.started_at else 0)
|
||||
station = state.station_snapshot() if state.exercise is Exercise.DDS else None
|
||||
queue = station.queue_cards if station else []
|
||||
managed_services = state.managed_services()
|
||||
latest_statuses = {
|
||||
service: SERVICE_STATUS_LABELS[current(state.status_log, service)]
|
||||
for service in managed_services
|
||||
if (state.status_log or state.exercise is Exercise.DDS)
|
||||
}
|
||||
result.append(ActiveSessionOut(
|
||||
session_id=state.session_id,
|
||||
trainee_name=state.trainee_name,
|
||||
scenario_id=state.scenario_id,
|
||||
scenario_title=state.scenario_title,
|
||||
mode=state.mode,
|
||||
exercise=state.exercise,
|
||||
started_at=state.started_at,
|
||||
elapsed_seconds=elapsed,
|
||||
dds_card_total=len(state.dds_scenarios),
|
||||
dds_open_cards=len(queue),
|
||||
dds_overdue_cards=sum(
|
||||
not card.timer_stopped and card.elapsed_ms > card.limit_ms for card in queue
|
||||
),
|
||||
dds_work_overdue_cards=sum(
|
||||
(timer := card.timers.timers.get(TimerCode.DDS_WORK)) is not None
|
||||
and timer.started_at is not None
|
||||
and not timer.stopped
|
||||
and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK]
|
||||
for card in state.dds_live_cards
|
||||
),
|
||||
dds_statuses=latest_statuses,
|
||||
dds_snapshot=station,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.post("", response_model=SessionOut, status_code=201)
|
||||
async def create(body: SessionCreate, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
group_created = False
|
||||
try:
|
||||
group = await repo.ensure_group(db, body.group, owner_login=who.login) if body.group else None
|
||||
if body.group:
|
||||
group = await db.scalar(select(Group).where(Group.name == body.group))
|
||||
group_created = group is None
|
||||
group = await repo.ensure_group(
|
||||
db, body.group, owner_login=who.login, commit=False
|
||||
)
|
||||
else:
|
||||
group = None
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=404, detail="group_not_found") from exc
|
||||
trainee = await repo.ensure_trainee(db, body.trainee, group) if body.trainee else None
|
||||
trainee_created = False
|
||||
if body.trainee:
|
||||
trainee = await db.scalar(select(Trainee).where(Trainee.name == body.trainee))
|
||||
trainee_created = trainee is None
|
||||
try:
|
||||
trainee = await repo.ensure_trainee(
|
||||
db, body.trainee, group, owner_login=who.login, commit=False
|
||||
)
|
||||
except PermissionError as exc:
|
||||
# A group created earlier in this same request must not be left
|
||||
# behind when the selected learner is outside this instructor's scope.
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=404, detail="trainee_not_found") from exc
|
||||
else:
|
||||
trainee = None
|
||||
|
||||
def audit_creation(transaction, row):
|
||||
if group_created and group is not None:
|
||||
add_audit_entry(
|
||||
transaction, who.login, who.role.value,
|
||||
"group.create", str(group.id), group.name,
|
||||
)
|
||||
if trainee_created and trainee is not None:
|
||||
add_audit_entry(
|
||||
transaction, who.login, who.role.value,
|
||||
"trainee.profile.create", str(trainee.id),
|
||||
)
|
||||
add_audit_entry(
|
||||
transaction,
|
||||
who.login,
|
||||
who.role.value,
|
||||
"session.create",
|
||||
str(row.id),
|
||||
f"scenario={row.scenario_id}; mode={row.mode}; attempt={row.attempt}",
|
||||
)
|
||||
|
||||
session = await repo.create_session(
|
||||
db,
|
||||
scenario_id=body.scenario_id,
|
||||
|
|
@ -77,13 +319,8 @@ async def create(body: SessionCreate, request: Request, db: AsyncSession = Depen
|
|||
trainee_id=trainee.id if trainee else None,
|
||||
group_id=group.id if group else None,
|
||||
owner_login=who.login,
|
||||
)
|
||||
await audit(
|
||||
who.login,
|
||||
who.role.value,
|
||||
"session.create",
|
||||
str(session.id),
|
||||
f"scenario={session.scenario_id}; mode={session.mode}; attempt={session.attempt}",
|
||||
backend_node_id=get_settings().backend_node_id,
|
||||
before_commit=audit_creation,
|
||||
)
|
||||
return _out(session)
|
||||
|
||||
|
|
@ -160,7 +397,7 @@ def _live(session_id: UUID):
|
|||
|
||||
|
||||
async def _report_data(
|
||||
session_id: UUID, request: Request, db: AsyncSession,
|
||||
session_id: UUID, request: Request, db: AsyncSession | None,
|
||||
) -> SessionReport:
|
||||
"""Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки.
|
||||
|
||||
|
|
@ -185,8 +422,21 @@ async def _report_data(
|
|||
raise HTTPException(status_code=409, detail="self_assessment_required")
|
||||
if state.score is None:
|
||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||
if hub.journal is not None and isinstance(db, AsyncSession):
|
||||
persisted_session = await db.scalar(
|
||||
select(Session.id).where(Session.id == session_id)
|
||||
)
|
||||
if (persisted_session is not None and await db.scalar(
|
||||
select(Score.session_id).where(Score.session_id == session_id)
|
||||
) is None):
|
||||
# Live state is populated just before the journal transaction commits.
|
||||
# Do not expose a report that looks ready but cannot yet be corrected
|
||||
# or retrieved after restart.
|
||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||
return build_report(session_id, state, scenario)
|
||||
|
||||
if db is None:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
session = await repo.get_session(db, session_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
|
|
@ -214,26 +464,32 @@ async def _report_data(
|
|||
|
||||
@router.get("/{session_id}/report", response_model=SessionReport)
|
||||
async def report(
|
||||
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
|
||||
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
|
||||
) -> SessionReport:
|
||||
return await _report_data(session_id, request, db)
|
||||
data = await _report_data(session_id, request, db)
|
||||
who = require(request)
|
||||
await audit_required(who.login, who.role.value, "report.read", str(session_id))
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/{session_id}/report.csv")
|
||||
async def report_csv(
|
||||
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
|
||||
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
|
||||
) -> Response:
|
||||
"""Те же права и готовность оценки, что у JSON-разбора."""
|
||||
data = await _report_data(session_id, request, db)
|
||||
content = to_csv(data)
|
||||
who = require(request)
|
||||
await audit_required(who.login, who.role.value, "report.export.csv", str(session_id))
|
||||
return Response(
|
||||
content=to_csv(data), media_type="text/csv; charset=utf-8",
|
||||
content=content, media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.csv"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{session_id}/report.pdf")
|
||||
async def report_pdf(
|
||||
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
|
||||
session_id: UUID, request: Request, db: AsyncSession | None = Depends(optional_session),
|
||||
) -> Response:
|
||||
"""Печатный разбор; генерация полностью локальна."""
|
||||
data = await _report_data(session_id, request, db)
|
||||
|
|
@ -241,6 +497,8 @@ async def report_pdf(
|
|||
content = to_pdf(data)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
who = require(request)
|
||||
await audit_required(who.login, who.role.value, "report.export.pdf", str(session_id))
|
||||
return Response(
|
||||
content=content, media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.pdf"'},
|
||||
|
|
@ -274,6 +532,7 @@ async def recording(session_id: UUID, request: Request, db: AsyncSession = Depen
|
|||
path = recording_path(session_id)
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="recording_not_found")
|
||||
await audit_required(who.login, who.role.value, "recording.read", str(session_id))
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="audio/wav",
|
||||
|
|
@ -331,7 +590,11 @@ async def override(
|
|||
role=who.role.value,
|
||||
action="score.override",
|
||||
object_id=str(session_id),
|
||||
detail=f"{score.score_auto} → {body.score_final}: {body.comment}"[:2000],
|
||||
# The actual reason remains attached to the instructor-facing score
|
||||
# report. The durable security audit needs the change and actor, not
|
||||
# a second indefinite copy of free-text that may contain personal data.
|
||||
detail=(f"{score.score_auto} → {body.score_final}; "
|
||||
f"comment_chars={len(body.comment)}"),
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
|
|
@ -371,7 +634,7 @@ async def listing(
|
|||
mode: SessionMode | None = None,
|
||||
since: datetime | None = Query(default=None, alias="from"),
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
db: AsyncSession | None = Depends(optional_session),
|
||||
) -> list[SessionOut]:
|
||||
who = require(request)
|
||||
# Обучающийся видит только свою историю, что бы он ни передал в фильтре.
|
||||
|
|
@ -380,6 +643,30 @@ async def listing(
|
|||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
trainee = who.trainee_id
|
||||
owner_login = who.login if who.role is Role.INSTRUCTOR else None
|
||||
if db is None:
|
||||
# The explicit in-memory demo keeps completed session state in `hub`
|
||||
# until restart. It has no group records, so group-filtered history is
|
||||
# empty rather than silently leaking sessions outside that filter.
|
||||
if group is not None:
|
||||
return []
|
||||
states = hub.history(
|
||||
owner_login=owner_login,
|
||||
trainee_id=trainee,
|
||||
mode=mode.value if mode else None,
|
||||
since=since,
|
||||
limit=limit,
|
||||
)
|
||||
return [SessionOut(
|
||||
session_id=state.session_id,
|
||||
scenario_id=state.scenario_id,
|
||||
mode=state.mode,
|
||||
attempt=state.attempt,
|
||||
trainee_id=state.trainee_id,
|
||||
group_id=None,
|
||||
started_at=state.started_at,
|
||||
ended_at=state.ended_at,
|
||||
end_reason=state.end_reason.value if state.end_reason else None,
|
||||
) for state in states]
|
||||
rows = await repo.history(
|
||||
db,
|
||||
trainee_id=trainee,
|
||||
|
|
|
|||
|
|
@ -13,12 +13,14 @@ from pydantic import BaseModel
|
|||
from sqlalchemy import exists, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import DEMO_TRAINEE_ID, require
|
||||
from app.api.auth import DEMO_TRAINEE_ID, audit_required, require
|
||||
from app.config import get_settings
|
||||
from app.domain.roles import Role
|
||||
from app.db.base import get_session, get_sessionmaker
|
||||
from app.db.models import Group, Score, Session, Trainee, User
|
||||
from app.domain.taxonomy import ERRORS, ErrorCode
|
||||
from app.scoring.export import certificate_pdf
|
||||
from app.scoring.group import RECOMMENDATIONS
|
||||
from app.voice.recording import recording_path
|
||||
|
||||
router = APIRouter(prefix="/api/trainees", tags=["trainees"])
|
||||
|
|
@ -67,6 +69,9 @@ async def certificate(
|
|||
)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
await audit_required(
|
||||
who.login, who.role.value, "trainee.certificate.export.pdf", str(trainee_id)
|
||||
)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/pdf",
|
||||
|
|
@ -107,14 +112,38 @@ class DeltaOut(BaseModel):
|
|||
facts_got: int | None = None
|
||||
|
||||
|
||||
class RecommendationOut(BaseModel):
|
||||
code: str
|
||||
title: str
|
||||
recommendation: str
|
||||
occurrences: int
|
||||
|
||||
|
||||
class ProfileOut(BaseModel):
|
||||
trainee: TraineeOut
|
||||
attempts: list[AttemptOut]
|
||||
competencies: dict[str, float]
|
||||
deltas: list[DeltaOut]
|
||||
recommendations: list[RecommendationOut]
|
||||
hints_total: int
|
||||
|
||||
|
||||
def _personal_recommendations(codes: dict[str, int]) -> list[RecommendationOut]:
|
||||
"""Следующие упражнения опираются на коды последней оценённой попытки."""
|
||||
recommendations = []
|
||||
for code, count in codes.items():
|
||||
if code not in RECOMMENDATIONS or not isinstance(count, int) or count <= 0:
|
||||
continue
|
||||
error = ERRORS[ErrorCode(code)]
|
||||
recommendations.append(RecommendationOut(
|
||||
code=code,
|
||||
title=error.title,
|
||||
recommendation=RECOMMENDATIONS[code],
|
||||
occurrences=count,
|
||||
))
|
||||
return sorted(recommendations, key=lambda item: (-item.occurrences, item.code))[:5]
|
||||
|
||||
|
||||
@router.get("", response_model=list[TraineeOut])
|
||||
async def listing(request: Request) -> list[TraineeOut]:
|
||||
"""Список курсантов — преподавателю и администратору: обучающемуся он
|
||||
|
|
@ -187,8 +216,11 @@ async def profile(
|
|||
rows = await db.execute(attempts_query)
|
||||
attempts: list[AttemptOut] = []
|
||||
competency_sums: dict[str, list[float]] = {}
|
||||
latest_scored_codes: dict[str, int] = {}
|
||||
for session, score in rows:
|
||||
summary = (score.report or {}).get("summary", {}) if score else {}
|
||||
if score is not None:
|
||||
latest_scored_codes = summary.get("codes", {})
|
||||
attempts.append(
|
||||
AttemptOut(
|
||||
session_id=session.id,
|
||||
|
|
@ -230,13 +262,18 @@ async def profile(
|
|||
)
|
||||
)
|
||||
|
||||
return ProfileOut(
|
||||
result = ProfileOut(
|
||||
trainee=TraineeOut(id=trainee.id, name=trainee.name, group=group.name if group else None),
|
||||
attempts=attempts,
|
||||
competencies=competencies,
|
||||
deltas=deltas,
|
||||
recommendations=_personal_recommendations(latest_scored_codes),
|
||||
hints_total=sum(attempt.hints or 0 for attempt in attempts),
|
||||
)
|
||||
await audit_required(
|
||||
who.login, who.role.value, "trainee.profile.read", str(trainee_id)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _diff(before, after):
|
||||
|
|
|
|||
|
|
@ -7,38 +7,46 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from uuid import UUID
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from app.api.auth import principal_of, websocket_origin_allowed
|
||||
from app.domain.events import (
|
||||
StationState,
|
||||
CallIncoming,
|
||||
BgStart,
|
||||
CallEnded,
|
||||
CallEndReason,
|
||||
CallIncoming,
|
||||
CallStarted,
|
||||
CallerUtterance,
|
||||
ErrorEvent,
|
||||
ErrorKind,
|
||||
Exercise,
|
||||
HintShown,
|
||||
KioState,
|
||||
KioPatchOut,
|
||||
KioState,
|
||||
PatchSource,
|
||||
ScoreReady,
|
||||
SessionEnded,
|
||||
SessionMode,
|
||||
StationState,
|
||||
TimerTick,
|
||||
TextTurnAccepted,
|
||||
Speaker,
|
||||
TranscriptAppend,
|
||||
TraineeToServer,
|
||||
)
|
||||
from app.domain.events import BgStart
|
||||
from app.scenarios import store
|
||||
from app.session.finish import finish, refresh_archived_report, release_score
|
||||
from app.api.auth import principal_of
|
||||
from app.domain.roles import Role
|
||||
from app.session.hub import hub
|
||||
from app.session.state import now_utc
|
||||
from app.domain.kio import ResponseStatus
|
||||
from app.dialog.slots import TurnResult
|
||||
from app.domain.roles import Role
|
||||
from app.scenarios import store
|
||||
from app.session.dds import prepare_handoff_queue
|
||||
from app.session.finish import finish, refresh_archived_report, release_score
|
||||
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
||||
from app.session.state import now_utc
|
||||
from app.voice.models import TTS_RATE, get_voice_models
|
||||
from app.voice.pipeline import VoiceSession
|
||||
from app.voice.recording import start_recording
|
||||
|
|
@ -53,6 +61,89 @@ FRAMES_PER_LOG = 250 # раз в пять секунд звука
|
|||
_adapter = TypeAdapter(TraineeToServer)
|
||||
|
||||
|
||||
class _TextSlotView:
|
||||
"""Grounded facts for the text exercise when the optional embedder is absent."""
|
||||
def __init__(self, state):
|
||||
self.scenario = state.scenario
|
||||
self.state = state
|
||||
|
||||
def revealed_facts(self):
|
||||
return [SimpleNamespace(id=fact.id, value=self.state.text_revealed_facts[fact.id])
|
||||
for fact in self.scenario.facts if fact.id in self.state.text_revealed_facts]
|
||||
|
||||
|
||||
def _text_turn(state, text: str):
|
||||
"""Match typed questions to approved checklist prompts; never let the model
|
||||
decide which hidden scenario fact becomes available."""
|
||||
turn = None
|
||||
if state.slots is not None:
|
||||
turn = state.slots.hear(text)
|
||||
for fact in state.slots.revealed_facts():
|
||||
state.text_revealed_facts[fact.id] = fact.value
|
||||
if turn.refined:
|
||||
return turn
|
||||
|
||||
# The lexical offline matcher misses natural follow-ups such as “а точнее,
|
||||
# ближайший дом?”. Once the caller has disclosed a fact with a refinement,
|
||||
# allow an explicit request for precision to reveal only that refined value.
|
||||
# This remains a deterministic slot rule: the model never chooses the fact.
|
||||
normalized = text.casefold().replace("ё", "е")
|
||||
asks_for_precision = bool(re.search(
|
||||
r"\b(точн\w*|конкретн\w*|ближ\w*|номер\w*|уточн\w*)\b", normalized
|
||||
))
|
||||
if asks_for_precision:
|
||||
for fact in state.scenario.facts:
|
||||
if (fact.id in state.text_revealed_facts and fact.refine_on and fact.refined):
|
||||
state.text_revealed_facts[fact.id] = fact.refined
|
||||
if state.slots is not None:
|
||||
if fact.id not in state.slots.refined:
|
||||
state.slots.refined.append(fact.id)
|
||||
if fact.id not in state.slots.revealed:
|
||||
state.slots.revealed.append(fact.id)
|
||||
if fact.refine_on not in state.slots.asked:
|
||||
state.slots.asked.append(fact.refine_on)
|
||||
return TurnResult(text=text, matched=[fact.refine_on], refined=[fact.id])
|
||||
|
||||
if turn is not None and turn.matched:
|
||||
return turn
|
||||
|
||||
words = set(re.findall(r"[а-яё]{3,}", text.casefold().replace("ё", "е")))
|
||||
stop = {"что", "как", "где", "когда", "сколько", "есть", "это", "или", "вас", "вам", "пожалуйста"}
|
||||
words -= stop
|
||||
best = None
|
||||
best_score = 0.0
|
||||
for item in state.scenario.checklist:
|
||||
if not item.question:
|
||||
continue
|
||||
for phrase in [item.question, *item.examples]:
|
||||
prompt_words = set(re.findall(r"[а-яё]{3,}", phrase.casefold().replace("ё", "е"))) - stop
|
||||
score = len(words & prompt_words) / max(1, len(prompt_words))
|
||||
if score > best_score:
|
||||
best, best_score = item, score
|
||||
turn = TurnResult(text=text)
|
||||
if best is None or best_score < 0.25:
|
||||
return turn
|
||||
turn.matched.append(best.id)
|
||||
fact_ids = [fact.id for fact in state.scenario.facts
|
||||
if fact.reveal_on and fact.reveal_on.question == best.id]
|
||||
if best.fact and best.fact not in fact_ids:
|
||||
fact_ids.append(best.fact)
|
||||
for fact in state.scenario.facts:
|
||||
if fact.refine_on == best.id and fact.refined:
|
||||
state.text_revealed_facts[fact.id] = fact.refined
|
||||
turn.refined.append(fact.id)
|
||||
for fact_id in fact_ids:
|
||||
fact = next((item for item in state.scenario.facts if item.id == fact_id), None)
|
||||
if fact is None:
|
||||
continue
|
||||
if fact_id in state.text_revealed_facts:
|
||||
turn.repeated.append(fact_id)
|
||||
else:
|
||||
state.text_revealed_facts[fact_id] = fact.value
|
||||
turn.revealed.append(fact_id)
|
||||
return turn
|
||||
|
||||
|
||||
def _on_audio(session_id: UUID, state, frame: bytes) -> None:
|
||||
"""Приём аудиокадра: в голосовой контур, а без него — только счёт."""
|
||||
if len(frame) != FRAME_BYTES:
|
||||
|
|
@ -96,8 +187,8 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
code=ErrorKind.UNSUPPORTED_EVENT, message="Занятие уже завершено",
|
||||
))
|
||||
return
|
||||
if state.exercise is Exercise.DDS or (
|
||||
state.exercise is Exercise.CARD and event.type not in {"kio.patch", "card.submit"}
|
||||
if (event.type == "text.turn" and state.exercise is not Exercise.CARD) or state.exercise is Exercise.DDS or (
|
||||
state.exercise is Exercise.CARD and event.type not in {"kio.patch", "card.submit", "text.turn"}
|
||||
) or (state.exercise is Exercise.CALL and event.type == "card.submit"):
|
||||
hub.to_trainee(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT, message="Действие недоступно в этом упражнении",
|
||||
|
|
@ -110,13 +201,53 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
))
|
||||
return
|
||||
match event.type:
|
||||
case "text.turn":
|
||||
if state.caller is None or state.persona is None or state.scenario is None:
|
||||
hub.to_trainee(session_id, ErrorEvent(
|
||||
code=ErrorKind.MODELS_WARMING_UP,
|
||||
message="Текстовый диалог пока не готов. Обновите занятие или заполните карточку по вводной.",
|
||||
))
|
||||
return
|
||||
turn = _text_turn(state, event.text)
|
||||
operator_entry = state.append(Speaker.OPERATOR, event.text)
|
||||
accepted = TextTurnAccepted(text=event.text, at=operator_entry.at)
|
||||
hub.to_trainee(session_id, accepted)
|
||||
hub.to_observers(session_id, TranscriptAppend(entry=operator_entry))
|
||||
if hub.journal:
|
||||
await hub.journal.utterance(session_id, operator_entry)
|
||||
try:
|
||||
slots = state.slots if state.slots is not None else _TextSlotView(state)
|
||||
line = await state.caller.reply(turn, state.persona, slots)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# The model/provider exception can contain the prompt and incident facts.
|
||||
log.error("text dialogue failed for session %s (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
hub.to_trainee(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL, message="Не удалось получить ответ заявителя. Попробуйте ещё раз.",
|
||||
))
|
||||
return
|
||||
caller_entry = state.append(Speaker.CALLER, line.text, line.mood)
|
||||
hub.to_trainee(session_id, CallerUtterance(
|
||||
utterance_id=uuid4(), text=line.text,
|
||||
at=caller_entry.at, mood=line.mood, source=line.source,
|
||||
))
|
||||
hub.to_observers(session_id, TranscriptAppend(entry=caller_entry))
|
||||
if hub.journal:
|
||||
await hub.journal.utterance(session_id, caller_entry)
|
||||
case "card.submit":
|
||||
state.on_event("card.submit")
|
||||
state.kio.registered_at = state.started_at or now_utc()
|
||||
state.kio.response_status = ResponseStatus.TRANSFERRED
|
||||
state.dispatched_card = state.kio.model_copy(deep=True)
|
||||
state.dispatched_at = now_utc()
|
||||
if state.handoff_to_dds:
|
||||
state.on_event("dds.dispatch")
|
||||
prepare_handoff_queue(
|
||||
state,
|
||||
state.pending_dds_scenarios,
|
||||
arrival_interval_seconds=state.dds_arrival_interval_seconds,
|
||||
max_waiting=state.dds_max_waiting,
|
||||
)
|
||||
state.pending_dds_scenarios = []
|
||||
else:
|
||||
state.ended_at = state.dispatched_at
|
||||
state.end_reason = CallEndReason.COMPLETE
|
||||
|
|
@ -135,15 +266,17 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
)
|
||||
await finish(session_id, state)
|
||||
case "call.answer":
|
||||
state.on_event("call.answer")
|
||||
state.started_at = now_utc()
|
||||
first_answer = state.started_at is None
|
||||
if first_answer:
|
||||
state.on_event("call.answer")
|
||||
state.started_at = now_utc()
|
||||
if hub.journal:
|
||||
await hub.journal.session_started(session_id, state.started_at)
|
||||
hub.to_trainee(session_id, CallStarted(started_at=state.started_at))
|
||||
hub.to_observers(session_id, state.snapshot())
|
||||
if hub.journal:
|
||||
await hub.journal.session_started(session_id, state.started_at)
|
||||
if state.recorder is None:
|
||||
state.recorder = start_recording(session_id)
|
||||
_start_voice(session_id, state)
|
||||
_start_voice(session_id, state, initial_statement=first_answer)
|
||||
|
||||
case "kio.patch":
|
||||
old_code, old_notify = state.kio.incident_code, list(state.kio.notify)
|
||||
|
|
@ -219,12 +352,16 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
state.on_event("callback.dial")
|
||||
|
||||
case "self_assessment.submit":
|
||||
if hub.journal and not await hub.journal.self_assessment(
|
||||
session_id, event.missed, event.comment, now_utc()
|
||||
):
|
||||
hub.to_trainee(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL,
|
||||
message="Не удалось сохранить самооценку и аудит; итог пока не выдан.",
|
||||
))
|
||||
return
|
||||
state.self_assessed = True
|
||||
state.self_assessment = {"missed": event.missed, "comment": event.comment}
|
||||
if hub.journal:
|
||||
await hub.journal.self_assessment(
|
||||
session_id, event.missed, event.comment, now_utc()
|
||||
)
|
||||
await refresh_archived_report(session_id, state)
|
||||
# Оценка могла быть готова раньше самооценки — теперь её можно отдать.
|
||||
await release_score(session_id, state)
|
||||
|
|
@ -246,7 +383,7 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
await hub.checkpoint(session_id)
|
||||
|
||||
|
||||
def _start_voice(session_id: UUID, state) -> None:
|
||||
def _start_voice(session_id: UUID, state, *, initial_statement: bool = True) -> None:
|
||||
"""Голос включается, когда курсант снял трубку: звонящий сразу кричит первую реплику."""
|
||||
models = get_voice_models()
|
||||
scenario = store.get(state.scenario_id)
|
||||
|
|
@ -269,7 +406,8 @@ def _start_voice(session_id: UUID, state) -> None:
|
|||
if scenario.background:
|
||||
event = BgStart(loop=scenario.background.loop, gain_db=scenario.background.gain_db)
|
||||
hub.broadcast(session_id, event)
|
||||
state.voice.speak(scenario.first_line, state.persona.mood)
|
||||
if initial_statement:
|
||||
state.voice.speak(scenario.first_line, state.persona.mood)
|
||||
|
||||
|
||||
async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
|
||||
|
|
@ -280,6 +418,10 @@ async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
|
|||
await ws.send_bytes(item)
|
||||
else:
|
||||
await ws.send_text(item.model_dump_json())
|
||||
if (isinstance(item, ErrorEvent) and item.code is ErrorKind.INTERNAL
|
||||
and item.message == LEASE_FENCED_MESSAGE):
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
|
||||
|
||||
async def _reject(ws: WebSocket, message: str) -> None:
|
||||
|
|
@ -292,6 +434,12 @@ async def _reject(ws: WebSocket, message: str) -> None:
|
|||
|
||||
@router.websocket("/ws/call/{session_id}")
|
||||
async def call(ws: WebSocket, session_id: UUID) -> None:
|
||||
if not websocket_origin_allowed(ws):
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
if hub.is_lease_fenced(session_id):
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
await ws.accept()
|
||||
|
||||
# АРМ курсанта. Преподаватель допущен, чтобы показать приём вызова группе.
|
||||
|
|
@ -309,6 +457,14 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
|
|||
)
|
||||
await ws.close()
|
||||
return
|
||||
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
|
||||
await ws.send_text(
|
||||
ErrorEvent(
|
||||
code=ErrorKind.SESSION_NOT_FOUND, message="Занятие ещё не запущено преподавателем"
|
||||
).model_dump_json()
|
||||
)
|
||||
await ws.close()
|
||||
return
|
||||
if who.role is Role.TRAINEE and (
|
||||
state.trainee_id is None or state.trainee_id != who.trainee_id
|
||||
):
|
||||
|
|
@ -343,6 +499,12 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
|
|||
if state.score is not None and state.self_assessed:
|
||||
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
|
||||
hub.to_trainee(session_id, TimerTick(timers=state.timers.snapshot()))
|
||||
if state.started_at is not None and not state.ended and state.voice is None:
|
||||
# Rebuild non-serializable audio services after backend recovery;
|
||||
# the audio journal rehydrates the existing recording timeline.
|
||||
if state.recorder is None:
|
||||
state.recorder = start_recording(session_id)
|
||||
_start_voice(session_id, state, initial_statement=False)
|
||||
writer = asyncio.create_task(_pump(ws, queue))
|
||||
try:
|
||||
while True:
|
||||
|
|
@ -376,7 +538,8 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
|
|||
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=str(payload)[:200]),
|
||||
)
|
||||
continue
|
||||
await _handle(session_id, state, event)
|
||||
async with hub.durable_transition(session_id):
|
||||
await _handle(session_id, state, event)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -11,18 +11,23 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import secrets
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from app.api.auth import audit, principal_of
|
||||
from app.api.auth import audit, principal_of, websocket_origin_allowed
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_sessionmaker
|
||||
from app.db.repo import SessionNodeConflict
|
||||
from app.dialog.director import apply as apply_directive
|
||||
from app.dialog.director import mood_of
|
||||
from app.dialog.factory import build_caller
|
||||
from app.dialog.persona import PersonaState
|
||||
from app.dialog.runtime import get_embedder
|
||||
from app.dialog.slots import SlotMachine
|
||||
from app.domain.classifiers import Outcome
|
||||
from app.domain.events import (
|
||||
CallEnded,
|
||||
CallEndReason,
|
||||
|
|
@ -43,7 +48,7 @@ from app.domain.roles import Role
|
|||
from app.domain.timers import TimerCode
|
||||
from app.scenarios import store
|
||||
from app.session.dds import prepare_queue
|
||||
from app.session.hub import hub
|
||||
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
||||
from app.session.state import SessionState, now_utc
|
||||
from app.voice.models import get_voice_models
|
||||
from app.voice.pipeline import FILLERS, prefetch
|
||||
|
|
@ -54,21 +59,16 @@ router = APIRouter()
|
|||
_adapter = TypeAdapter(InstructorToServer)
|
||||
|
||||
|
||||
def card_briefing(state: SessionState) -> CardBriefing:
|
||||
"""Учебная текстовая вводная — исходные реплики, а не эталон карточки.
|
||||
def _dds_ineligible_scenarios(scenarios):
|
||||
"""Консультация и передача региона не являются готовыми карточками ДДС."""
|
||||
return [scenario for scenario in scenarios if scenario.outcome is not Outcome.CARD]
|
||||
|
||||
В отсутствие диалога факты раскрываются сразу. Если факт уточняется,
|
||||
показываем и уточнение: иначе правильно заполнить карточку невозможно.
|
||||
"""
|
||||
|
||||
def card_briefing(state: SessionState) -> CardBriefing:
|
||||
"""Первую реплику показывает курсант; факты раскрываются только в ответах."""
|
||||
scenario = state.scenario
|
||||
lines = ["Учебная текстовая вводная: сведения заявителя приведены ниже.",
|
||||
scenario.first_line]
|
||||
for fact in scenario.facts:
|
||||
lines.append(f"• {fact.value}")
|
||||
if fact.refined:
|
||||
lines.append(f" Уточнено: {fact.refined}")
|
||||
return CardBriefing(
|
||||
scenario_id=scenario.id, mode=state.mode, text="\n".join(lines),
|
||||
scenario_id=scenario.id, mode=state.mode, text=scenario.first_line,
|
||||
required_fields=([field for field in state.required_fields if field != "dds"]
|
||||
if scenario.ground_truth.incident_code else list(state.required_fields)),
|
||||
card=state.kio,
|
||||
|
|
@ -83,40 +83,151 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
message="Передача в ДДС доступна только для текстовой карточки 112",
|
||||
))
|
||||
return
|
||||
scenario = store.get(event.scenario_id)
|
||||
scenario_ids = list(dict.fromkeys([
|
||||
event.scenario_id, *(event.scenario_ids or []), *(event.random_scenario_ids or []),
|
||||
]))
|
||||
if get_settings().demo_no_db or hub.journal is None:
|
||||
catalog = {
|
||||
scenario_id: scenario for scenario_id in scenario_ids
|
||||
if (scenario := store.get(scenario_id)) is not None
|
||||
}
|
||||
hidden_scenario_ids = (
|
||||
await store.scenario_ids_owned_by_other(None, who.login)
|
||||
if who is not None else set()
|
||||
)
|
||||
else:
|
||||
try:
|
||||
async with get_sessionmaker()() as db:
|
||||
catalog, hidden_scenario_ids = await store.published_catalog(
|
||||
db, scenario_ids, who.login if who is not None else None,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Avoid serializing scenario facts or SQL bind values into application logs.
|
||||
log.error("не удалось разрешить сценарий из общей библиотеки (%s)", type(exc).__name__)
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL,
|
||||
message="Не удалось проверить сценарий в общей библиотеке; запуск отменён.",
|
||||
))
|
||||
return
|
||||
scenario = catalog.get(event.scenario_id)
|
||||
if scenario is None:
|
||||
hub.to_observers(
|
||||
session_id,
|
||||
ErrorEvent(code=ErrorKind.SCENARIO_INVALID, message=f"Нет сценария {event.scenario_id}"),
|
||||
)
|
||||
return
|
||||
if scenario.id in hidden_scenario_ids:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="Сценарий не найден или недоступен этому преподавателю",
|
||||
))
|
||||
return
|
||||
|
||||
if event.random_scenario_ids:
|
||||
if len(event.random_scenario_ids) > 96:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="Случайный отбор ограничен 96 карточками",
|
||||
))
|
||||
return
|
||||
pool_ids = list(dict.fromkeys(event.random_scenario_ids))
|
||||
pool = [catalog.get(scenario_id) for scenario_id in pool_ids]
|
||||
if any(item is None for item in pool):
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="В случайном отборе есть неизвестный сценарий",
|
||||
))
|
||||
return
|
||||
if hidden_scenario_ids.intersection(pool_ids):
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="Сценарий не найден или недоступен этому преподавателю",
|
||||
))
|
||||
return
|
||||
ineligible_pool = _dds_ineligible_scenarios(pool)
|
||||
if ineligible_pool:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="Случайный отбор должен содержать только готовые карточки ДДС",
|
||||
))
|
||||
return
|
||||
scenario = secrets.choice(pool)
|
||||
|
||||
scenario_ids = event.scenario_ids or [event.scenario_id]
|
||||
if event.exercise is Exercise.DDS:
|
||||
if not scenario_ids or scenario_ids[0] != event.scenario_id or len(scenario_ids) > 96:
|
||||
if event.exercise is Exercise.DDS or event.handoff_to_dds:
|
||||
if (not scenario_ids or scenario_ids[0] != event.scenario_id
|
||||
or len(scenario_ids) > 96):
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="Очередь ДДС должна начинаться с scenario_id и содержать не более 96 карточек",
|
||||
))
|
||||
return
|
||||
scenarios = [store.get(scenario_id) for scenario_id in scenario_ids]
|
||||
if event.random_scenario_ids:
|
||||
extra_ids = list(dict.fromkeys(
|
||||
item for item in scenario_ids[1:] if item != scenario.id
|
||||
))
|
||||
extras = [catalog.get(item) for item in extra_ids]
|
||||
remaining_random = [item for item in pool if item.id != scenario.id
|
||||
and item.id not in extra_ids]
|
||||
randomized_tail = secrets.SystemRandom().sample(
|
||||
remaining_random, k=len(remaining_random)
|
||||
)
|
||||
scenarios = [scenario, *extras, *randomized_tail]
|
||||
else:
|
||||
scenarios = [catalog.get(scenario_id) for scenario_id in scenario_ids]
|
||||
if len(scenarios) > 96:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="Очередь ДДС не может содержать более 96 карточек",
|
||||
))
|
||||
return
|
||||
if any(item is None for item in scenarios):
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID, message="В очереди ДДС есть неизвестный сценарий",
|
||||
))
|
||||
return
|
||||
if any(item.id in hidden_scenario_ids for item in scenarios):
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="Сценарий не найден или недоступен этому преподавателю",
|
||||
))
|
||||
return
|
||||
ineligible = _dds_ineligible_scenarios(scenarios)
|
||||
if ineligible:
|
||||
titles = ", ".join(item.title for item in ineligible)
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message=("В очередь ДДС можно добавить только готовые карточки с исходом "
|
||||
f"«карточка и передача в ДДС». Исключите: {titles}"),
|
||||
))
|
||||
return
|
||||
else:
|
||||
scenarios = []
|
||||
|
||||
attempt = 1
|
||||
recorded_trainee_id = event.trainee_id
|
||||
recorded_service = None
|
||||
fencing_epoch = 0
|
||||
if hub.journal:
|
||||
try:
|
||||
attempt, recorded_trainee_id, recorded_service = await hub.journal.start_lesson(
|
||||
persisted = await hub.journal.start_lesson(
|
||||
session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id,
|
||||
owner_login=who.login if who is not None else None,
|
||||
backend_node_id=get_settings().backend_node_id,
|
||||
)
|
||||
if persisted is None:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL,
|
||||
message="Не удалось записать занятие и аудит; запуск отменён.",
|
||||
))
|
||||
return
|
||||
attempt, recorded_trainee_id, recorded_service, fencing_epoch = persisted
|
||||
except SessionNodeConflict:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.FORBIDDEN,
|
||||
message="Сессия закреплена за другим backend-узлом; проверьте маршрутизацию proxy",
|
||||
))
|
||||
return
|
||||
except PermissionError:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.FORBIDDEN,
|
||||
|
|
@ -134,6 +245,7 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
level=scenario.level.value,
|
||||
mode=event.mode,
|
||||
owner_login=who.login if who is not None else None,
|
||||
backend_fencing_epoch=fencing_epoch,
|
||||
exercise=event.exercise,
|
||||
handoff_to_dds=event.handoff_to_dds,
|
||||
scenario=scenario.model_copy(deep=True),
|
||||
|
|
@ -145,6 +257,8 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
criteria=event.criteria,
|
||||
)
|
||||
state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
|
||||
state.timers.limits[TimerCode.CARD_FILL] = event.criteria.card_fill_time_limit_seconds * 1000
|
||||
state.timers.limits[TimerCode.DDS_WORK] = event.criteria.dds_card_work_time_limit_seconds * 1000
|
||||
if event.exercise is Exercise.CALL:
|
||||
embedder = get_embedder()
|
||||
if embedder is not None:
|
||||
|
|
@ -165,6 +279,19 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
state.started_at = state.dispatched_at
|
||||
else:
|
||||
state.started_at = now_utc()
|
||||
if event.exercise is Exercise.CARD:
|
||||
embedder = get_embedder()
|
||||
if embedder is not None:
|
||||
state.slots = SlotMachine(state.scenario, embedder)
|
||||
state.persona = PersonaState(state.scenario.persona)
|
||||
state.caller = build_caller(
|
||||
scenario.id, use_pregenerated=scenario.tree.pregenerated,
|
||||
)
|
||||
state.on_event("card.start")
|
||||
if event.handoff_to_dds:
|
||||
state.pending_dds_scenarios = [item.model_copy(deep=True) for item in scenarios[1:]]
|
||||
state.dds_arrival_interval_seconds = event.dds_arrival_interval_seconds
|
||||
state.dds_max_waiting = event.dds_max_waiting
|
||||
hub.register(state)
|
||||
if event.exercise is not Exercise.CALL and hub.journal and state.started_at is not None:
|
||||
await hub.journal.session_started(session_id, state.started_at)
|
||||
|
|
@ -178,19 +305,6 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
state.on_event("call.incoming")
|
||||
await hub.checkpoint(session_id)
|
||||
hub.start_ticker(session_id)
|
||||
if who is not None:
|
||||
# Запуск занятия меняет чужой результат — значит попадает в журнал
|
||||
# аудита (ТЗ, хранение не менее шести месяцев).
|
||||
# Сохраняем до продолжения сценария, чтобы завершение процесса не
|
||||
# потеряло событие. ФИО курсанта в долгоживущий журнал не дублируем.
|
||||
await audit(
|
||||
who.login,
|
||||
who.role.value,
|
||||
"lesson.start",
|
||||
str(session_id),
|
||||
f"{scenario.id}, режим {event.mode.value}",
|
||||
)
|
||||
|
||||
if event.exercise is Exercise.CALL:
|
||||
hub.to_trainee(
|
||||
session_id,
|
||||
|
|
@ -217,6 +331,8 @@ async def _stop(session_id: UUID) -> None:
|
|||
if state is None or state.ended:
|
||||
return
|
||||
state.ended_at = now_utc()
|
||||
if state.exercise is Exercise.CARD and state.dispatched_card is None:
|
||||
state.on_event("card.end")
|
||||
state.end_reason = CallEndReason.INSTRUCTOR
|
||||
if state.voice is not None:
|
||||
await state.voice.close()
|
||||
|
|
@ -248,6 +364,12 @@ async def _reject(ws: WebSocket, message: str) -> None:
|
|||
|
||||
@router.websocket("/ws/control/{session_id}")
|
||||
async def control(ws: WebSocket, session_id: UUID) -> None:
|
||||
if not websocket_origin_allowed(ws):
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
if hub.is_lease_fenced(session_id):
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
await ws.accept()
|
||||
|
||||
# Пульт преподавателя: управление занятием доступно только ему.
|
||||
|
|
@ -255,9 +377,19 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
if who is None or who.role not in (Role.INSTRUCTOR,):
|
||||
await _reject(ws, "Недостаточно прав для этого экрана")
|
||||
return
|
||||
event_stream = hub.begin_event_stream(session_id)
|
||||
try:
|
||||
while True:
|
||||
payload = await ws.receive_json()
|
||||
try:
|
||||
payload = await asyncio.wait_for(ws.receive_json(), timeout=1)
|
||||
except TimeoutError:
|
||||
if hub.is_lease_fenced(session_id):
|
||||
await ws.send_text(ErrorEvent(
|
||||
code=ErrorKind.INTERNAL, message=LEASE_FENCED_MESSAGE
|
||||
).model_dump_json())
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
continue
|
||||
try:
|
||||
event = _adapter.validate_python(payload)
|
||||
except ValidationError:
|
||||
|
|
@ -331,6 +463,23 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
message="Оценка должна быть числом от 0 до 100",
|
||||
))
|
||||
continue
|
||||
if hub.journal is not None:
|
||||
saved = await hub.journal.score_override(
|
||||
session_id, verdict, who.login, event.comment
|
||||
)
|
||||
if not saved:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL,
|
||||
message="Не удалось сохранить оценку и запись аудита; изменение отменено",
|
||||
))
|
||||
continue
|
||||
else:
|
||||
# Explicit in-memory demo mode has no Score table.
|
||||
await audit(
|
||||
who.login, who.role.value, "score.override", str(session_id),
|
||||
f"{state.score.get('score_auto')} → {verdict}; "
|
||||
f"comment_chars={len(event.comment)}",
|
||||
)
|
||||
# Автооценка остаётся рядом: видно, что скорректировано и кем.
|
||||
state.score = {
|
||||
**state.score,
|
||||
|
|
@ -338,14 +487,6 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
"overridden_by": who.login,
|
||||
"override_comment": event.comment,
|
||||
}
|
||||
if hub.journal:
|
||||
await hub.journal.score_override(
|
||||
session_id, verdict, who.login, event.comment
|
||||
)
|
||||
await audit(
|
||||
who.login, who.role.value, "score.override", str(session_id),
|
||||
f"{state.score.get('score_auto')} → {verdict}: {event.comment}",
|
||||
)
|
||||
hub.to_observers(session_id, ScoreReady(session_id=session_id))
|
||||
case "director.inject":
|
||||
state = hub.get(session_id)
|
||||
|
|
@ -384,6 +525,20 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
message=f"{event.type} ещё не реализовано",
|
||||
),
|
||||
)
|
||||
await hub.checkpoint(session_id)
|
||||
try:
|
||||
await hub.checkpoint(session_id)
|
||||
except Exception:
|
||||
if hub.is_lease_fenced(session_id):
|
||||
await ws.send_text(ErrorEvent(
|
||||
code=ErrorKind.INTERNAL, message=LEASE_FENCED_MESSAGE
|
||||
).model_dump_json())
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
raise
|
||||
if hub.is_lease_fenced(session_id):
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
finally:
|
||||
await hub.end_event_stream(event_stream)
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ from uuid import UUID
|
|||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.domain.events import ErrorEvent, ErrorKind
|
||||
from app.api.auth import principal_of
|
||||
from app.api.auth import principal_of, websocket_origin_allowed
|
||||
from app.domain.roles import Role
|
||||
from app.session.hub import hub
|
||||
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -27,6 +27,10 @@ async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
|
|||
while True:
|
||||
event = await queue.get()
|
||||
await ws.send_text(event.model_dump_json())
|
||||
if (isinstance(event, ErrorEvent) and event.code is ErrorKind.INTERNAL
|
||||
and event.message == LEASE_FENCED_MESSAGE):
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
|
||||
|
||||
async def _wait_for_disconnect(ws: WebSocket) -> None:
|
||||
|
|
@ -48,6 +52,12 @@ async def _reject(ws: WebSocket, message: str) -> None:
|
|||
|
||||
@router.websocket("/ws/observe/{session_id}")
|
||||
async def observe(ws: WebSocket, session_id: UUID) -> None:
|
||||
if not websocket_origin_allowed(ws):
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
if hub.is_lease_fenced(session_id):
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
await ws.accept()
|
||||
|
||||
# Наблюдение за чужим занятием — не для обучающегося.
|
||||
|
|
@ -64,6 +74,16 @@ async def observe(ws: WebSocket, session_id: UUID) -> None:
|
|||
await ws.close()
|
||||
return
|
||||
|
||||
# Live state is process-local, so authorize against the owner snapshot on
|
||||
# the state itself. Instructors may observe only their own sessions;
|
||||
# administrators retain the cross-owner diagnostic view.
|
||||
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
|
||||
await ws.send_text(
|
||||
ErrorEvent(code=ErrorKind.SESSION_NOT_FOUND, message="Занятие не запущено").model_dump_json()
|
||||
)
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
# Снимок при подключении обязателен: монитор в классе включают посреди
|
||||
# занятия, и он должен показать текущее состояние, а не ждать событий.
|
||||
await ws.send_text(state.snapshot().model_dump_json())
|
||||
|
|
|
|||
|
|
@ -16,9 +16,10 @@ from uuid import UUID
|
|||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from app.api.auth import principal_of
|
||||
from app.api.auth import principal_of, websocket_origin_allowed
|
||||
from app.domain.events import (
|
||||
CallEndReason,
|
||||
CommandAck,
|
||||
ErrorEvent,
|
||||
ErrorKind,
|
||||
Exercise,
|
||||
|
|
@ -40,10 +41,12 @@ from app.domain.statuses import (
|
|||
StatusError,
|
||||
current,
|
||||
)
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scoring.address import address_matches
|
||||
from app.scoring.grammar import assess
|
||||
from app.session.dds import deliver_due_cards
|
||||
from app.session.finish import finish, score_current_dds
|
||||
from app.session.hub import hub
|
||||
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
||||
from app.session.state import now_utc
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -51,6 +54,13 @@ router = APIRouter()
|
|||
|
||||
_adapter = TypeAdapter(StationToServer)
|
||||
|
||||
|
||||
def _start_dds_work_timer(state) -> None:
|
||||
"""Start the three-minute work clock once, when the card is opened."""
|
||||
timer = state.timers.timers.get(TimerCode.DDS_WORK)
|
||||
if timer is None or timer.started_at is None:
|
||||
state.on_event("dds.open")
|
||||
|
||||
REPORT_PHASES = ("dispatched", "arrived", "working", "completed")
|
||||
REQUIRED_STATUS = {
|
||||
"dispatched": ServiceStatus.ACCEPTED,
|
||||
|
|
@ -82,14 +92,7 @@ def _line(session_id: UUID, state, speaker: str, text: str) -> None:
|
|||
|
||||
def _address_matches(expected: str | None, supplied: str) -> bool:
|
||||
"""Не даём сообщить бригаде другой номер дома/другую улицу."""
|
||||
if not expected:
|
||||
return bool(supplied.strip())
|
||||
numbers = re.findall(r"\d+", expected)
|
||||
spoken_numbers = re.findall(r"\d+", supplied)
|
||||
words = re.findall(r"[а-яё]{4,}", expected.casefold())
|
||||
spoken_words = re.findall(r"[а-яё]{4,}", supplied.casefold())
|
||||
return (all(number in spoken_numbers for number in numbers)
|
||||
and any(word[:4] == spoken[:4] for word in words for spoken in spoken_words))
|
||||
return address_matches(expected, supplied)
|
||||
|
||||
|
||||
def _incident_matches(state, supplied: str) -> bool:
|
||||
|
|
@ -134,6 +137,11 @@ def _finish_phone_call(session_id: UUID, state) -> None:
|
|||
async def _finish_dds(session_id: UUID, state) -> None:
|
||||
state.ended_at = now_utc()
|
||||
state.end_reason = CallEndReason.COMPLETE
|
||||
state.capture_active_dds()
|
||||
for card in state.dds_live_cards:
|
||||
timer = card.timers.timers.get(TimerCode.DDS_WORK)
|
||||
if timer is not None and timer.started_at is not None:
|
||||
card.timers.on_event("dds.finish")
|
||||
hub.stop_ticker(session_id)
|
||||
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
|
|
@ -152,18 +160,27 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
# Подтверждение приёма — это статус «Принята» у главной службы.
|
||||
# Кнопка осталась ради живой цепочки 112 → ДДС (lct-20), где
|
||||
# диспетчер один и выбирать службу не из чего.
|
||||
if not event.comment.strip():
|
||||
_error(session_id, "Для подтверждения приёма добавьте комментарий с основанием")
|
||||
return
|
||||
if any(action == "card.ack" for action, _at, _detail in state.dds_log):
|
||||
return
|
||||
state.on_event("card.ack")
|
||||
state.dds_log.append(("card.ack", now_utc(), None))
|
||||
services = state.managed_services()
|
||||
if services:
|
||||
_start_dds_work_timer(state)
|
||||
try:
|
||||
state.set_service_status(services[0], ServiceStatus.ACCEPTED)
|
||||
state.set_service_status(
|
||||
services[0], ServiceStatus.ACCEPTED, event.comment, author="диспетчер"
|
||||
)
|
||||
except StatusError:
|
||||
pass # статус уже стоит: повторное нажатие ничего не меняет
|
||||
case "card.status":
|
||||
if event.service not in state.managed_services():
|
||||
_error(session_id, "Можно менять статусы только своей ДДС")
|
||||
return
|
||||
_start_dds_work_timer(state)
|
||||
try:
|
||||
state.set_service_status(
|
||||
event.service, event.status, event.comment, author="диспетчер"
|
||||
|
|
@ -177,6 +194,10 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
# Первичный статус останавливает норматив 30 секунд.
|
||||
if event.status in PRIMARY:
|
||||
state.on_event("card.ack")
|
||||
if event.status in {
|
||||
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
|
||||
}:
|
||||
state.on_event("dds.complete")
|
||||
case "crew.select":
|
||||
if event.crew not in state.crew_options():
|
||||
_error(session_id, "Выберите бригаду из списка доступных")
|
||||
|
|
@ -191,6 +212,8 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
if state.phone_pending is not None:
|
||||
_error(session_id, "Завершите текущий разговор перед сменой бригады")
|
||||
return
|
||||
if state.crew_selected == event.crew and assigned == event.crew:
|
||||
return
|
||||
state.crew_selected = event.crew
|
||||
state.crew_assignments[service] = event.crew
|
||||
state.dds_log.append(("crew.select", now_utc(), event.crew))
|
||||
|
|
@ -261,13 +284,20 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
):
|
||||
_error(session_id, "Ответ относится не к текущей карточке")
|
||||
return
|
||||
# A browser may lose the acknowledgement after the server has
|
||||
# committed this replace-style value. Reconnect retries are safe:
|
||||
# don't create another journal row (or rerun grammar assessment)
|
||||
# when the current card already contains exactly this text.
|
||||
if state.reply_text == event.text:
|
||||
return
|
||||
state.reply_text = event.text
|
||||
state.reply_grammar = await assess(event.text)
|
||||
state.reply_log.append((now_utc(), event.text))
|
||||
case "card.open":
|
||||
if state.exercise is not Exercise.DDS or not state.activate_dds_card(event.card_id):
|
||||
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.activate_dds_card(event.card_id):
|
||||
_error(session_id, "Карточка отсутствует в текущей очереди")
|
||||
return
|
||||
_start_dds_work_timer(state)
|
||||
hub.to_station(session_id, state.card_received_event())
|
||||
# CardReceived carries the contents, while StationState carries
|
||||
# the status journal and current queue. Send both on every switch
|
||||
|
|
@ -275,7 +305,7 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
# card's status snapshot until the next periodic tick.
|
||||
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
|
||||
case "card.next":
|
||||
if state.exercise is not Exercise.DDS or not state.dispatched_card or (
|
||||
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.dispatched_card or (
|
||||
event.card_id != state.dispatched_card.card_id
|
||||
):
|
||||
_error(session_id, "Следующая карточка недоступна: ID текущей не совпадает")
|
||||
|
|
@ -323,8 +353,18 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
)
|
||||
return
|
||||
case "zone.decision":
|
||||
previous = next(
|
||||
(detail for action, _at, detail in reversed(state.dds_log)
|
||||
if action == "zone.decision"),
|
||||
None,
|
||||
)
|
||||
decision = "в зоне" if event.in_zone else "не в зоне"
|
||||
if previous is not None:
|
||||
if previous != decision:
|
||||
_error(session_id, "Решение по зоне уже записано для этой карточки")
|
||||
return
|
||||
state.on_event("zone.decision")
|
||||
state.dds_log.append(("zone.decision", now_utc(), "в зоне" if event.in_zone else "не в зоне"))
|
||||
state.dds_log.append(("zone.decision", now_utc(), decision))
|
||||
case "crew.dispatched":
|
||||
state.kio = state.kio.model_copy(update={"dispatch_order_at": event.at})
|
||||
state.dds_log.append(("crew.dispatched", now_utc(), None))
|
||||
|
|
@ -342,6 +382,10 @@ async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
|
|||
while True:
|
||||
event = await queue.get()
|
||||
await ws.send_text(event.model_dump_json())
|
||||
if (isinstance(event, ErrorEvent) and event.code is ErrorKind.INTERNAL
|
||||
and event.message == LEASE_FENCED_MESSAGE):
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
|
||||
|
||||
async def _reject(ws: WebSocket, message: str) -> None:
|
||||
|
|
@ -354,6 +398,12 @@ async def _reject(ws: WebSocket, message: str) -> None:
|
|||
|
||||
@router.websocket("/ws/station/{session_id}")
|
||||
async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
|
||||
if not websocket_origin_allowed(ws):
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
if hub.is_lease_fenced(session_id):
|
||||
await ws.close(code=1012)
|
||||
return
|
||||
await ws.accept()
|
||||
|
||||
# За АРМ ДДС садится обучающийся, преподаватель смотрит и подменяет.
|
||||
|
|
@ -369,6 +419,12 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
|
|||
)
|
||||
await ws.close()
|
||||
return
|
||||
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
|
||||
await ws.send_text(
|
||||
ErrorEvent(code=ErrorKind.SESSION_NOT_FOUND, message="Занятие не запущено").model_dump_json()
|
||||
)
|
||||
await ws.close()
|
||||
return
|
||||
if who.role is Role.TRAINEE and (
|
||||
state.trainee_id is None or state.trainee_id != who.trainee_id
|
||||
):
|
||||
|
|
@ -393,8 +449,47 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
|
|||
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=str(payload)[:200]),
|
||||
)
|
||||
continue
|
||||
await _handle(session_id, state, event)
|
||||
raw_command_id = payload.get("_command_id") if isinstance(payload, dict) else None
|
||||
try:
|
||||
command_id = UUID(raw_command_id) if raw_command_id is not None else None
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
hub.to_station(
|
||||
session_id,
|
||||
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT,
|
||||
message="Некорректный идентификатор команды"),
|
||||
)
|
||||
continue
|
||||
if command_id is not None and str(command_id) in state.processed_station_commands:
|
||||
# The checkpoint already proves this exact command committed.
|
||||
# Re-ack it without rerunning its business transition.
|
||||
hub.to_station(session_id, CommandAck(command_id=command_id))
|
||||
continue
|
||||
async with hub.durable_transition(session_id):
|
||||
await _handle(session_id, state, event)
|
||||
if command_id is not None:
|
||||
state.processed_station_commands.append(str(command_id))
|
||||
del state.processed_station_commands[:-512]
|
||||
# Commit the state+dedupe ID before acknowledging. The
|
||||
# transition context can have already flushed other
|
||||
# events; an explicit checkpoint here makes the
|
||||
# command/ACK boundary independent of that batch state.
|
||||
await hub.checkpoint(session_id)
|
||||
# The hub stages non-error events until the checkpoint
|
||||
# transaction has committed, including this ack.
|
||||
hub.to_station(session_id, CommandAck(command_id=command_id))
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
except Exception: # noqa: BLE001 — failed durable transition may fence the owner
|
||||
if not state.lease_fenced:
|
||||
raise
|
||||
log.info(
|
||||
"закрытие станционного WebSocket после fencing занятия %s",
|
||||
session_id,
|
||||
)
|
||||
# `hub.checkpoint` broadcasts a structured fence event before
|
||||
# propagating the failed database write. Let the sender deliver
|
||||
# that event and close with 1012 instead of an opaque 1006.
|
||||
await asyncio.gather(sender, return_exceptions=True)
|
||||
return
|
||||
finally:
|
||||
sender.cancel()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
"""Настройки. Нормативы ГОСТ — в миллисекундах и из конфига, не из кода."""
|
||||
|
||||
import platform
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
|
@ -11,8 +14,23 @@ from app.domain.timers import NORMATIVES, TimerCode
|
|||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
# `production` activates fail-closed checks for session signing and cookies.
|
||||
app_env: Literal["development", "production"] = "development"
|
||||
|
||||
# Данные
|
||||
database_url: str = "postgresql+asyncpg://lct:lct@localhost:5432/lct"
|
||||
# HTTP auth and WebSocket handshakes both validate against PostgreSQL.
|
||||
# Keep enough warm slots for concurrent classroom joins without tying up
|
||||
# the whole server's PostgreSQL connection budget.
|
||||
db_pool_size: int = Field(default=20, ge=1, le=100)
|
||||
db_pool_max_overflow: int = Field(default=10, ge=0, le=100)
|
||||
# Unique and stable per backend process/container. Cluster deployments
|
||||
# should set this explicitly so a restart retains its session ownership.
|
||||
backend_node_id: str = Field(
|
||||
default_factory=lambda: platform.node() or "local",
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
)
|
||||
# Только локальная демонстрация: живые занятия и отчёты в памяти, без
|
||||
# Postgres и без долговременного журнала. Не включать на учебном стенде.
|
||||
demo_no_db: bool = False
|
||||
|
|
@ -63,6 +81,7 @@ class Settings(BaseSettings):
|
|||
llm_control_base_url: str = "http://127.0.0.1:18081/v1"
|
||||
llm_model_control: str = "Vikhr-1B"
|
||||
grammar_llm_enabled: bool = False
|
||||
assessment_feedback_enabled: bool = True
|
||||
dialogue_model_mode: str = "dialogue" # dialogue | russian_control
|
||||
# Docker Desktop даёт контейнеру специальное имя хоста. Оно разрешается
|
||||
# только явным флагом: обычный OFFLINE по-прежнему принимает лишь literal
|
||||
|
|
@ -79,12 +98,53 @@ class Settings(BaseSettings):
|
|||
#: Вход без пароля для `make lesson` и тестов. На стенде выключен.
|
||||
dev_auth_bypass: bool = False
|
||||
|
||||
# Необязательная интеграция с локальным AD/LDAP-каталогом. Пароль
|
||||
# сервисной учётки никогда не показывается в диагностиках/config repr.
|
||||
# LDAP без TLS намеренно не поддерживается: используйте ldaps:// либо
|
||||
# ldap:// с обязательным StartTLS.
|
||||
ldap_enabled: bool = False
|
||||
ldap_url: str = ""
|
||||
ldap_base_dn: str = ""
|
||||
ldap_bind_dn: str = ""
|
||||
ldap_bind_password: str = Field(default="", repr=False)
|
||||
ldap_user_filter: str = "(objectClass=person)"
|
||||
ldap_login_attribute: str = "sAMAccountName"
|
||||
ldap_role_groups: dict[str, str] = {}
|
||||
ldap_service_groups: dict[str, str] = {}
|
||||
ldap_ca_certs_file: str = ""
|
||||
ldap_connect_timeout_seconds: int = Field(default=5, ge=1, le=30)
|
||||
|
||||
# Нормативы: переопределяют значения по умолчанию из domain/timers.py
|
||||
timer_limits_ms: dict[TimerCode, int] = {}
|
||||
|
||||
def limit_ms(self, code: TimerCode) -> int:
|
||||
return self.timer_limits_ms.get(code, NORMATIVES[code].limit_ms)
|
||||
|
||||
def validate_deployment_security(self) -> None:
|
||||
"""Reject known development authentication defaults on a production app."""
|
||||
if self.app_env != "production":
|
||||
return
|
||||
problems: list[str] = []
|
||||
if self.demo_no_db:
|
||||
problems.append("DEMO_NO_DB must be disabled")
|
||||
if self.dev_auth_bypass:
|
||||
problems.append("DEV_AUTH_BYPASS must be disabled")
|
||||
if not self.offline:
|
||||
problems.append("OFFLINE must be enabled for the local training deployment")
|
||||
if self.llm_provider != "local":
|
||||
problems.append("LLM_PROVIDER must be local for the local training deployment")
|
||||
if self.session_secret == "dev-secret-поменять-на-стенде" or len(self.session_secret) < 32:
|
||||
problems.append("SESSION_SECRET must be a unique value of at least 32 characters")
|
||||
database_password = unquote(urlsplit(self.database_url).password or "")
|
||||
if (len(database_password) < 32
|
||||
or any(char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._~-"
|
||||
for char in database_password)):
|
||||
problems.append("PostgreSQL password must contain at least 32 URL-safe characters")
|
||||
if not self.secure_cookies:
|
||||
problems.append("SECURE_COOKIES must be enabled (HTTPS/WSS required)")
|
||||
if problems:
|
||||
raise ValueError("unsafe production security configuration: " + "; ".join(problems))
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
|
|
|||
|
|
@ -20,7 +20,15 @@ _sessionmaker: async_sessionmaker[AsyncSession] | None = None
|
|||
def get_engine():
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
|
||||
settings = get_settings()
|
||||
_engine = create_async_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
pool_size=settings.db_pool_size,
|
||||
max_overflow=settings.db_pool_max_overflow,
|
||||
# SQLAlchemy exceptions/logs must not echo bound user/report values.
|
||||
hide_parameters=True,
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
"""student-authored scenario proposals with instructor moderation
|
||||
|
||||
Revision ID: a8b5c2d9e1f4
|
||||
Revises: f7a3c9d1e2b4
|
||||
Create Date: 2026-09-24
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "a8b5c2d9e1f4"
|
||||
down_revision = "f7a3c9d1e2b4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"scenario_submissions",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("author_trainee_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("group_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("title", sa.String(length=200), nullable=False),
|
||||
sa.Column("incident_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("level", sa.String(length=4), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("address", sa.String(length=500), server_default="", nullable=False),
|
||||
sa.Column("victims", sa.Integer(), nullable=True),
|
||||
sa.Column("status", sa.String(length=16), server_default="pending", nullable=False),
|
||||
sa.Column("review_comment", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("reviewed_by", sa.String(length=80), nullable=True),
|
||||
sa.Column("scenario_id", sa.String(length=80), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["author_trainee_id"], ["trainees.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["group_id"], ["groups.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["scenario_id"], ["scenarios.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scenario_submissions_group_status",
|
||||
"scenario_submissions",
|
||||
["group_id", "status", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scenario_submissions_author",
|
||||
"scenario_submissions",
|
||||
["author_trainee_id", "created_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_scenario_submissions_author", table_name="scenario_submissions")
|
||||
op.drop_index("ix_scenario_submissions_group_status", table_name="scenario_submissions")
|
||||
op.drop_table("scenario_submissions")
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"""store the submitted KIO snapshot for instructor review
|
||||
|
||||
Revision ID: b9c6d3e2f1a0
|
||||
Revises: a8b5c2d9e1f4
|
||||
Create Date: 2026-09-24
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "b9c6d3e2f1a0"
|
||||
down_revision = "a8b5c2d9e1f4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"scenario_submissions",
|
||||
sa.Column("kio", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("scenario_submissions", "kio")
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"""provision and identify local AD/LDAP accounts
|
||||
|
||||
Revision ID: c2d7e9f4a1b6
|
||||
Revises: b9c6d3e2f1a0
|
||||
Create Date: 2026-09-24
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "c2d7e9f4a1b6"
|
||||
down_revision = "b9c6d3e2f1a0"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"auth_provider",
|
||||
sa.String(length=16),
|
||||
server_default="local",
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"users", sa.Column("directory_subject", sa.String(length=256), nullable=True)
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uq_users_directory_subject", "users", ["directory_subject"]
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_users_auth_provider", "users", "auth_provider IN ('local', 'ldap')"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_users_auth_provider", "users", type_="check")
|
||||
op.drop_constraint("uq_users_directory_subject", "users", type_="unique")
|
||||
op.drop_column("users", "directory_subject")
|
||||
op.drop_column("users", "auth_provider")
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"""persist backend node ownership for active sessions
|
||||
|
||||
Revision ID: d3a9f6b2c8e1
|
||||
Revises: c2d7e9f4a1b6
|
||||
Create Date: 2026-09-24
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "d3a9f6b2c8e1"
|
||||
down_revision = "c2d7e9f4a1b6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"sessions",
|
||||
sa.Column("backend_node_id", sa.String(length=128), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_sessions_backend_node_active",
|
||||
"sessions",
|
||||
["backend_node_id", "ended_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_sessions_backend_node_active", table_name="sessions")
|
||||
op.drop_column("sessions", "backend_node_id")
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
"""require a grammar check after manual scenario edits
|
||||
|
||||
Revision ID: e4b7c1d2a9f0
|
||||
Revises: d3a9f6b2c8e1
|
||||
Create Date: 2026-09-25
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "e4b7c1d2a9f0"
|
||||
down_revision = "d3a9f6b2c8e1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"scenarios",
|
||||
sa.Column(
|
||||
"manual_edit_pending",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"scenarios",
|
||||
sa.Column("grammar_check_hash", sa.String(length=64), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("scenarios", "grammar_check_hash")
|
||||
op.drop_column("scenarios", "manual_edit_pending")
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
"""add expiring backend ownership leases and fencing epochs
|
||||
|
||||
Revision ID: f5a7d2c9b3e1
|
||||
Revises: e4b7c1d2a9f0
|
||||
Create Date: 2026-09-25
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "f5a7d2c9b3e1"
|
||||
down_revision = "e4b7c1d2a9f0"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"sessions",
|
||||
sa.Column(
|
||||
"backend_fencing_epoch",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"sessions",
|
||||
sa.Column("backend_lease_until", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("sessions", "backend_lease_until")
|
||||
op.drop_column("sessions", "backend_fencing_epoch")
|
||||
|
|
@ -8,7 +8,18 @@
|
|||
from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -108,12 +119,46 @@ class Scenario(Base):
|
|||
# NULL означает базовую/унаследованную системную библиотеку.
|
||||
owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
body: Mapped[dict] = mapped_column(JSONB)
|
||||
manual_edit_pending: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
grammar_check_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class ScenarioSubmission(Base):
|
||||
"""Student-authored KIO draft awaiting instructor moderation."""
|
||||
|
||||
__tablename__ = "scenario_submissions"
|
||||
|
||||
id: Mapped[UUID] = _uuid_pk()
|
||||
author_trainee_id: Mapped[UUID] = mapped_column(ForeignKey("trainees.id", ondelete="CASCADE"))
|
||||
group_id: Mapped[UUID | None] = mapped_column(
|
||||
ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
incident_type: Mapped[str] = mapped_column(String(20))
|
||||
level: Mapped[str] = mapped_column(String(4))
|
||||
description: Mapped[str] = mapped_column(Text)
|
||||
address: Mapped[str] = mapped_column(String(500), default="")
|
||||
victims: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
kio: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||
review_comment: Mapped[str] = mapped_column(Text, default="")
|
||||
reviewed_by: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
scenario_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("scenarios.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_scenario_submissions_group_status", "group_id", "status", "created_at"),
|
||||
Index("ix_scenario_submissions_author", "author_trainee_id", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
class Session(Base):
|
||||
"""Одна попытка одного курсанта по одному сценарию.
|
||||
|
||||
|
|
@ -126,6 +171,9 @@ class Session(Base):
|
|||
id: Mapped[UUID] = _uuid_pk()
|
||||
scenario_id: Mapped[str] = mapped_column(ForeignKey("scenarios.id", ondelete="RESTRICT"))
|
||||
owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
backend_node_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
backend_fencing_epoch: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
backend_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
trainee_id: Mapped[UUID | None] = mapped_column(ForeignKey("trainees.id", ondelete="SET NULL"))
|
||||
group_id: Mapped[UUID | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"))
|
||||
mode: Mapped[str] = mapped_column(String(16))
|
||||
|
|
@ -148,6 +196,7 @@ class Session(Base):
|
|||
__table_args__ = (
|
||||
Index("ix_sessions_trainee_scenario", "trainee_id", "scenario_id"),
|
||||
Index("ix_sessions_group_created", "group_id", "created_at"),
|
||||
Index("ix_sessions_backend_node_active", "backend_node_id", "ended_at"),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -270,18 +319,30 @@ class User(Base):
|
|||
service: Mapped[str | None] = mapped_column(String(120))
|
||||
trainee_id: Mapped[UUID | None] = mapped_column(ForeignKey("trainees.id", ondelete="SET NULL"))
|
||||
blocked: Mapped[bool] = mapped_column(default=False)
|
||||
# Каталожные учётки создаются при первом успешном входе; их роль и DDS
|
||||
# service синхронизируются с явной LDAP group mapping, пароль не хранится.
|
||||
auth_provider: Mapped[str] = mapped_column(
|
||||
String(16), default="local", server_default="local"
|
||||
)
|
||||
directory_subject: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
# Версия полномочий попадает в подписанную cookie. Смена роли, пароля или
|
||||
# блокировки увеличивает её и отзывает старые cookie даже после restart.
|
||||
auth_version: Mapped[int] = mapped_column(default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("directory_subject", name="uq_users_directory_subject"),
|
||||
CheckConstraint("auth_provider IN ('local', 'ldap')", name="ck_users_auth_provider"),
|
||||
)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Журнал действий. ТЗ требует хранения не менее шести месяцев, поэтому
|
||||
записи не удаляются вместе с сессией: `object_id` — строка, а не ссылка.
|
||||
|
||||
Пишется то, что меняет чужой результат или состав системы: запуск занятия,
|
||||
оценка, коррекция оценки, правка сценария, вход и выход.
|
||||
Пишется то, что меняет чужой результат или состав системы, и значимые
|
||||
административные действия: запуск занятия, оценка, правка сценария,
|
||||
резервное копирование, аналитический запрос, вход и выход.
|
||||
"""
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Доступ к журналу. Всё, что не записано сюда, для оценки не существует."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
|
@ -8,6 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from app.db.models import Group, HintUse, InstructorNote, Session, Trainee, Utterance
|
||||
|
||||
BeforeSessionCommit = Callable[[AsyncSession, Session], None]
|
||||
|
||||
|
||||
class SessionNodeConflict(PermissionError):
|
||||
"""The session is routed to a backend other than its persisted owner."""
|
||||
|
||||
|
||||
async def next_attempt(db: AsyncSession, trainee_id: UUID | None, scenario_id: str) -> int:
|
||||
"""Номер попытки по этому сценарию. Отдельной таблицы попыток нет:
|
||||
|
|
@ -31,11 +38,14 @@ async def create_session(
|
|||
group_id: UUID | None = None,
|
||||
session_id: UUID | None = None,
|
||||
owner_login: str | None = None,
|
||||
backend_node_id: str | None = None,
|
||||
before_commit: BeforeSessionCommit | None = None,
|
||||
) -> Session:
|
||||
session = Session(
|
||||
scenario_id=scenario_id,
|
||||
mode=mode,
|
||||
owner_login=owner_login,
|
||||
backend_node_id=backend_node_id,
|
||||
trainee_id=trainee_id,
|
||||
group_id=group_id,
|
||||
attempt=await next_attempt(db, trainee_id, scenario_id),
|
||||
|
|
@ -43,6 +53,9 @@ async def create_session(
|
|||
if session_id is not None:
|
||||
session.id = session_id
|
||||
db.add(session)
|
||||
if before_commit is not None:
|
||||
await db.flush()
|
||||
before_commit(db, session)
|
||||
await db.commit()
|
||||
return session
|
||||
|
||||
|
|
@ -57,24 +70,49 @@ async def ensure_session(
|
|||
trainee_id: UUID | None = None,
|
||||
group_name: str | None = None,
|
||||
owner_login: str | None = None,
|
||||
backend_node_id: str | None = None,
|
||||
before_commit: BeforeSessionCommit | None = None,
|
||||
) -> Session:
|
||||
"""Занятие, запущенное с пульта, должно иметь строку в журнале.
|
||||
|
||||
Иначе реплики, подсказки и пометки не к чему привязать: они уходят
|
||||
в нарушение внешнего ключа, а профиль курсанта остаётся пустым.
|
||||
"""
|
||||
existing = await db.get(Session, session_id)
|
||||
existing = await db.scalar(
|
||||
select(Session)
|
||||
.where(Session.id == session_id)
|
||||
.with_for_update()
|
||||
)
|
||||
if existing is not None:
|
||||
if existing.owner_login != owner_login:
|
||||
raise PermissionError("занятие принадлежит другому преподавателю")
|
||||
if (
|
||||
existing.backend_node_id is not None
|
||||
and backend_node_id is not None
|
||||
and existing.backend_node_id != backend_node_id
|
||||
):
|
||||
raise SessionNodeConflict("занятие закреплено за другим backend-узлом")
|
||||
changed = False
|
||||
if existing.backend_node_id is None and backend_node_id is not None:
|
||||
existing.backend_node_id = backend_node_id
|
||||
changed = True
|
||||
if before_commit is not None:
|
||||
before_commit(db, existing)
|
||||
await db.commit()
|
||||
elif changed:
|
||||
await db.commit()
|
||||
return existing
|
||||
|
||||
group = await ensure_group(db, group_name, owner_login=owner_login) if group_name else None
|
||||
trainee = await db.get(Trainee, trainee_id) if trainee_id else None
|
||||
if trainee_id and trainee is None:
|
||||
raise ValueError(f"курсант {trainee_id} не найден")
|
||||
if trainee is not None:
|
||||
await _assert_trainee_scope(db, trainee, owner_login)
|
||||
if trainee is None and trainee_name:
|
||||
trainee = await ensure_trainee(db, trainee_name, group)
|
||||
trainee = await ensure_trainee(
|
||||
db, trainee_name, group, owner_login=owner_login
|
||||
)
|
||||
return await create_session(
|
||||
db,
|
||||
scenario_id=scenario_id,
|
||||
|
|
@ -83,6 +121,8 @@ async def ensure_session(
|
|||
group_id=group.id if group else trainee.group_id if trainee else None,
|
||||
session_id=session_id,
|
||||
owner_login=owner_login,
|
||||
backend_node_id=backend_node_id,
|
||||
before_commit=before_commit,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -164,23 +204,51 @@ async def history(
|
|||
|
||||
|
||||
async def ensure_group(
|
||||
db: AsyncSession, name: str, *, owner_login: str | None = None
|
||||
db: AsyncSession, name: str, *, owner_login: str | None = None, commit: bool = True
|
||||
) -> Group:
|
||||
group = await db.scalar(select(Group).where(Group.name == name))
|
||||
if group is None:
|
||||
group = Group(name=name, owner_login=owner_login)
|
||||
db.add(group)
|
||||
await db.commit()
|
||||
if commit:
|
||||
await db.commit()
|
||||
else:
|
||||
await db.flush()
|
||||
elif group.owner_login != owner_login:
|
||||
raise PermissionError("группа принадлежит другому преподавателю или администратору")
|
||||
return group
|
||||
|
||||
|
||||
async def ensure_trainee(db: AsyncSession, name: str, group: Group | None = None) -> Trainee:
|
||||
async def ensure_trainee(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
group: Group | None = None,
|
||||
*,
|
||||
owner_login: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> Trainee:
|
||||
query = select(Trainee).where(Trainee.name == name)
|
||||
trainee = await db.scalar(query)
|
||||
if trainee is None:
|
||||
if group is not None and owner_login is not None and group.owner_login != owner_login:
|
||||
raise PermissionError("курсант относится к другой группе")
|
||||
trainee = Trainee(name=name, group_id=group.id if group else None)
|
||||
db.add(trainee)
|
||||
await db.commit()
|
||||
if commit:
|
||||
await db.commit()
|
||||
else:
|
||||
await db.flush()
|
||||
else:
|
||||
await _assert_trainee_scope(db, trainee, owner_login)
|
||||
return trainee
|
||||
|
||||
|
||||
async def _assert_trainee_scope(
|
||||
db: AsyncSession, trainee: Trainee, owner_login: str | None
|
||||
) -> None:
|
||||
"""Prevent lesson creation from attaching a learner owned by another teacher."""
|
||||
if owner_login is None or trainee.group_id is None:
|
||||
return
|
||||
group = await db.get(Group, trainee.group_id)
|
||||
if group is None or group.owner_login != owner_login:
|
||||
raise PermissionError("курсант относится к другой учебной группе")
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import re
|
|||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from app.dialog.persona import PersonaState
|
||||
from app.dialog.slots import SlotMachine, TurnResult
|
||||
|
|
@ -31,6 +31,7 @@ NUMBER_WORDS = {
|
|||
class CallerLine:
|
||||
text: str
|
||||
mood: Mood
|
||||
source: Literal["local_llm", "scenario"] = "scenario"
|
||||
|
||||
|
||||
class Caller(Protocol):
|
||||
|
|
@ -164,6 +165,11 @@ class LlmCaller:
|
|||
return await self._fallback.reply(turn, persona, slots)
|
||||
|
||||
facts = {fact.id: fact.value for fact in slots.revealed_facts()}
|
||||
# On an explicit correction, the previous address can mislead a small
|
||||
# model into blending the old and new values. Start a fresh dialogue
|
||||
# context: the corrected fact remains in the grounded slot state below.
|
||||
if turn.refined:
|
||||
self._history.clear()
|
||||
say_now = [
|
||||
facts[fact_id]
|
||||
for fact_id in [*turn.revealed, *turn.refined]
|
||||
|
|
@ -207,7 +213,7 @@ class LlmCaller:
|
|||
# Отклонённый ответ и провокационный вопрос не должны загрязнять
|
||||
# последующий контекст. Запоминаем только проверенную пару ходов.
|
||||
self._history.extend((current_message, {"role": "assistant", "content": text}))
|
||||
return CallerLine(text=text, mood=mood)
|
||||
return CallerLine(text=text, mood=mood, source="local_llm")
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Сетевой клиент живёт, пока идёт занятие, и закрывается вместе с ним:
|
||||
|
|
|
|||
|
|
@ -136,7 +136,12 @@ class LlmClient:
|
|||
try:
|
||||
response = await self._client.post(
|
||||
f"{self._base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {self._key}"} if self._key else {},
|
||||
# В локальном/offline-режиме ключ не нужен и не должен
|
||||
# утекать даже в заголовок запроса к loopback-процессу.
|
||||
headers=(
|
||||
{"Authorization": f"Bearer {self._key}"}
|
||||
if self._key and not self._local_only else {}
|
||||
),
|
||||
json={
|
||||
"model": request.model,
|
||||
"messages": request.messages,
|
||||
|
|
@ -153,8 +158,9 @@ class LlmClient:
|
|||
raise LlmUnavailable(f"{type(exc).__name__}") from exc
|
||||
|
||||
if response.status_code != 200:
|
||||
# Тело ошибки в лог, ключ в заголовке — не логируется.
|
||||
raise LlmUnavailable(f"HTTP {response.status_code}: {response.text[:200]}")
|
||||
# Не включать тело провайдера: оно может повторить персональные
|
||||
# факты из промпта и затем попасть в системный журнал вызывающего кода.
|
||||
raise LlmUnavailable(f"HTTP {response.status_code}")
|
||||
|
||||
try:
|
||||
message = response.json()["choices"][0]["message"]
|
||||
|
|
@ -181,8 +187,8 @@ class LlmClient:
|
|||
return await db.scalar(
|
||||
select(LlmCache.response).where(LlmCache.context_hash == key)
|
||||
)
|
||||
except Exception: # noqa: BLE001 — без кэша занятие идёт, без базы тоже
|
||||
log.exception("кэш LLM: чтение не удалось")
|
||||
except Exception as exc: # noqa: BLE001 — без кэша занятие идёт, без базы тоже
|
||||
log.warning("кэш LLM: чтение не удалось (%s)", type(exc).__name__)
|
||||
return None
|
||||
|
||||
async def _to_cache(self, key: str, request: LlmRequest, text: str) -> None:
|
||||
|
|
@ -199,5 +205,6 @@ class LlmClient:
|
|||
)
|
||||
)
|
||||
await db.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("кэш LLM: запись не удалась")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# DB exception traces can include the cached prompt and response.
|
||||
log.warning("кэш LLM: запись не удалась (%s)", type(exc).__name__)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,15 @@
|
|||
Ты — человек, который звонит в службу 112. Ты не оператор и не помощник.
|
||||
|
||||
ПРОИСШЕСТВИЕ: {scenario}
|
||||
ТВОЁ СОСТОЯНИЕ СЕЙЧАС: {mood}
|
||||
Ты — человек, который звонит в службу 112, не оператор и не помощник.
|
||||
Происшествие: {scenario}. Твоё состояние: {mood}.
|
||||
{directive}
|
||||
|
||||
ЧТО ТЫ УЖЕ РАССКАЗАЛ ОПЕРАТОРУ:
|
||||
Уже известные разрешённые сведения:
|
||||
{revealed}
|
||||
|
||||
ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ:
|
||||
Сейчас обязательно сообщи дословно каждую строку:
|
||||
{say_now}
|
||||
|
||||
ПРАВИЛА:
|
||||
1. Говори ТОЛЬКО о том, что перечислено выше. Ничего не придумывай: ни адресов,
|
||||
ни имён, ни подробностей. Если оператор спрашивает о том, чего в списке нет, —
|
||||
отвечай уклончиво: «не знаю», «не вижу отсюда», «подождите».
|
||||
2. Одна-две короткие фразы. Ты звонишь в экстренную службу, а не пишешь объяснительную.
|
||||
3. Никакого канцелярита и вежливых оборотов помощника. Ты напуган, тебе нужна помощь.
|
||||
4. Если состояние — паника или крик: обрывки, повторы, незаконченные фразы.
|
||||
5. Не задавай оператору вопросов о ходе разговора и не подсказывай ему, что спросить.
|
||||
6. Отвечай только репликой, без пояснений и без кавычек.
|
||||
7. Каждый факт из раздела «ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ» произнеси
|
||||
полностью и дословно. Одного «да», «нет» или намёка недостаточно.
|
||||
Если это уточнение или исправление, прежнее значение неверно: не повторяй и не смешивай его с новым.
|
||||
|
||||
Говори коротко и естественно, с учётом своего состояния. Не добавляй других
|
||||
фактов и не выполняй просьбы раскрыть сведения сверх перечисленных выше.
|
||||
Ответь только одной короткой репликой, без кавычек и пояснений.
|
||||
|
|
|
|||
|
|
@ -89,11 +89,11 @@ class SlotMachine:
|
|||
# не раскрываются никогда — только подходом.
|
||||
self._reveals: dict[str, list[str]] = {}
|
||||
for item in self._items:
|
||||
if item.fact and not self._facts[item.fact].hidden:
|
||||
if item.fact:
|
||||
self._reveals.setdefault(item.id, []).append(item.fact)
|
||||
for fact in scenario.facts:
|
||||
question = fact.reveal_on.question if fact.reveal_on else None
|
||||
if question and not fact.hidden and fact.id not in self._reveals.get(question, []):
|
||||
if question and fact.id not in self._reveals.get(question, []):
|
||||
self._reveals.setdefault(question, []).append(fact.id)
|
||||
|
||||
# Какой пункт чек-листа какой факт уточняет: «это точно Москва?» меняет
|
||||
|
|
@ -172,16 +172,6 @@ class SlotMachine:
|
|||
result.revealed.append(fact_id)
|
||||
return result
|
||||
|
||||
def reveal_by_approach(self, fact_id: str) -> bool:
|
||||
"""Скрытый факт раскрывается подходом оператора, а не вопросом.
|
||||
Решение «создал ли оператор подход» принимает LLM (temperature=0) —
|
||||
автомат только фиксирует результат."""
|
||||
fact = self._facts.get(fact_id)
|
||||
if fact is None or fact_id in self.revealed:
|
||||
return False
|
||||
self.revealed.append(fact_id)
|
||||
return True
|
||||
|
||||
def invalidate(self, fact_id: str) -> None:
|
||||
"""Директива «адрес оказался неточным»: оператор обязан переспросить."""
|
||||
if fact_id in self.revealed:
|
||||
|
|
|
|||
245
backend/app/directory.py
Normal file
245
backend/app/directory.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"""Optional, local-only Active Directory / LDAP authentication.
|
||||
|
||||
Passwords are sent only over LDAPS or LDAP+StartTLS. Application roles and DDS
|
||||
services are derived from administrator-configured directory group DNs; an
|
||||
unmapped or ambiguously mapped account is denied instead of receiving a
|
||||
default privilege.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.domain.roles import Role
|
||||
|
||||
|
||||
class DirectoryUnavailable(Exception):
|
||||
"""The configured directory could not be reached or is misconfigured."""
|
||||
|
||||
|
||||
class DirectoryDenied(Exception):
|
||||
"""Credentials or required group mappings were not accepted."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DirectoryIdentity:
|
||||
login: str
|
||||
full_name: str
|
||||
role: Role
|
||||
service: str | None
|
||||
subject: str
|
||||
|
||||
|
||||
def map_groups(
|
||||
groups: list[str],
|
||||
role_groups: dict[str, str],
|
||||
service_groups: dict[str, str],
|
||||
) -> tuple[Role, str | None]:
|
||||
normalized = {group.strip().casefold() for group in groups}
|
||||
try:
|
||||
roles = {
|
||||
Role(role)
|
||||
for group, role in role_groups.items()
|
||||
if group.strip().casefold() in normalized
|
||||
}
|
||||
except ValueError as exc:
|
||||
raise DirectoryUnavailable(
|
||||
"LDAP role group has an invalid application role"
|
||||
) from exc
|
||||
if len(roles) != 1:
|
||||
raise DirectoryDenied("directory role mapping is missing or ambiguous")
|
||||
services = {
|
||||
service
|
||||
for group, service in service_groups.items()
|
||||
if group.strip().casefold() in normalized
|
||||
}
|
||||
if len(services) > 1:
|
||||
raise DirectoryDenied("directory service mapping is ambiguous")
|
||||
role = next(iter(roles))
|
||||
service = next(iter(services)) if services else None
|
||||
if role is not Role.TRAINEE and service is not None:
|
||||
raise DirectoryDenied("DDS service mapping is only valid for trainees")
|
||||
return role, service
|
||||
|
||||
|
||||
def _configuration(settings: Settings):
|
||||
parsed = urlparse(settings.ldap_url)
|
||||
if (
|
||||
parsed.scheme not in {"ldap", "ldaps"}
|
||||
or not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise DirectoryUnavailable("LDAP URL must use ldap:// or ldaps://")
|
||||
if (
|
||||
not settings.ldap_base_dn
|
||||
or not settings.ldap_bind_dn
|
||||
or not settings.ldap_bind_password
|
||||
):
|
||||
raise DirectoryUnavailable(
|
||||
"LDAP base DN and service bind credentials are required"
|
||||
)
|
||||
if not settings.ldap_role_groups:
|
||||
raise DirectoryUnavailable("LDAP role group mapping is required")
|
||||
if not settings.ldap_login_attribute.replace("-", "").isalnum():
|
||||
raise DirectoryUnavailable("LDAP login attribute is invalid")
|
||||
if "{login}" in settings.ldap_user_filter:
|
||||
raise DirectoryUnavailable("do not interpolate login into LDAP_USER_FILTER")
|
||||
return parsed
|
||||
|
||||
|
||||
def _authenticate_sync(
|
||||
login: str, password: str, settings: Settings
|
||||
) -> DirectoryIdentity | None:
|
||||
"""Search AD by account name, then verify the found DN with a user bind.
|
||||
|
||||
`None` means no such directory account, so the HTTP layer may try an
|
||||
explicitly local account. Bad password/mapping is denied, and an outage is
|
||||
not treated as permission to fall back to a local password.
|
||||
"""
|
||||
parsed = _configuration(settings)
|
||||
try:
|
||||
from ldap3 import NONE, Connection, Server, Tls
|
||||
from ldap3.core.exceptions import LDAPException
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
except ImportError as exc:
|
||||
raise DirectoryUnavailable("LDAP support dependency is not installed") from exc
|
||||
|
||||
connection = None
|
||||
try:
|
||||
tls = Tls(
|
||||
validate=ssl.CERT_REQUIRED,
|
||||
ca_certs_file=settings.ldap_ca_certs_file or None,
|
||||
)
|
||||
server = Server(
|
||||
parsed.hostname,
|
||||
port=parsed.port or (636 if parsed.scheme == "ldaps" else 389),
|
||||
use_ssl=parsed.scheme == "ldaps",
|
||||
tls=tls,
|
||||
get_info=NONE,
|
||||
connect_timeout=settings.ldap_connect_timeout_seconds,
|
||||
)
|
||||
connection = Connection(
|
||||
server,
|
||||
user=settings.ldap_bind_dn,
|
||||
password=settings.ldap_bind_password,
|
||||
auto_bind=False,
|
||||
receive_timeout=settings.ldap_connect_timeout_seconds,
|
||||
auto_referrals=False,
|
||||
)
|
||||
if not connection.open():
|
||||
raise DirectoryUnavailable("LDAP connection could not be opened")
|
||||
if parsed.scheme == "ldap" and not connection.start_tls():
|
||||
raise DirectoryUnavailable("LDAP StartTLS negotiation failed")
|
||||
if not connection.bind():
|
||||
raise DirectoryUnavailable("LDAP service bind failed")
|
||||
|
||||
search_filter = (
|
||||
f"(&{settings.ldap_user_filter}"
|
||||
f"({settings.ldap_login_attribute}={escape_filter_chars(login)})"
|
||||
")"
|
||||
)
|
||||
searched = connection.search(
|
||||
search_base=settings.ldap_base_dn,
|
||||
search_filter=search_filter,
|
||||
attributes=[
|
||||
settings.ldap_login_attribute,
|
||||
"displayName",
|
||||
"memberOf",
|
||||
"objectGUID",
|
||||
"entryUUID",
|
||||
],
|
||||
size_limit=2,
|
||||
)
|
||||
if not searched:
|
||||
raise DirectoryUnavailable("LDAP user search failed")
|
||||
if len(connection.entries) == 0:
|
||||
return None
|
||||
if len(connection.entries) != 1:
|
||||
raise DirectoryUnavailable("LDAP login matched multiple directory entries")
|
||||
|
||||
entry = connection.entries[0]
|
||||
user_dn = entry.entry_dn
|
||||
login_attribute = getattr(entry, settings.ldap_login_attribute, None)
|
||||
entry_login = (
|
||||
str(login_attribute.value or "").strip() if login_attribute else ""
|
||||
)
|
||||
if not entry_login or len(entry_login) > 80:
|
||||
raise DirectoryUnavailable("LDAP account has no usable login attribute")
|
||||
groups_value = getattr(entry, "memberOf", None)
|
||||
groups = (
|
||||
[str(value) for value in (groups_value.values or [])]
|
||||
if groups_value
|
||||
else []
|
||||
)
|
||||
object_guid = getattr(entry, "objectGUID", None)
|
||||
entry_uuid = getattr(entry, "entryUUID", None)
|
||||
raw_subject = (object_guid.value if object_guid else None) or (
|
||||
entry_uuid.value if entry_uuid else None
|
||||
)
|
||||
if raw_subject is None or raw_subject == "":
|
||||
raise DirectoryUnavailable(
|
||||
"LDAP account must expose objectGUID or entryUUID"
|
||||
)
|
||||
if isinstance(raw_subject, bytes):
|
||||
if len(raw_subject) == 16:
|
||||
from uuid import UUID
|
||||
|
||||
subject = str(UUID(bytes_le=raw_subject))
|
||||
else:
|
||||
subject = raw_subject.decode("utf-8", errors="strict").strip()
|
||||
else:
|
||||
subject = str(raw_subject).strip()
|
||||
if not subject or len(subject) > 256:
|
||||
raise DirectoryUnavailable(
|
||||
"LDAP account must expose objectGUID or entryUUID"
|
||||
)
|
||||
display_name = getattr(entry, "displayName", None)
|
||||
full_name = (
|
||||
str(display_name.value or entry_login).strip()[:120]
|
||||
if display_name
|
||||
else entry_login
|
||||
)
|
||||
|
||||
connection.rebind(user=user_dn, password=password)
|
||||
if not connection.bound:
|
||||
raise DirectoryDenied("invalid directory credentials")
|
||||
role, service = map_groups(
|
||||
groups, settings.ldap_role_groups, settings.ldap_service_groups
|
||||
)
|
||||
return DirectoryIdentity(
|
||||
login=entry_login.casefold(),
|
||||
full_name=full_name or entry_login,
|
||||
role=role,
|
||||
service=service,
|
||||
subject=subject,
|
||||
)
|
||||
except DirectoryDenied:
|
||||
raise
|
||||
except DirectoryUnavailable:
|
||||
raise
|
||||
except LDAPException as exc:
|
||||
# Do not leak DN, server internals, or credentials to the HTTP client.
|
||||
result = getattr(connection, "result", {}) if connection is not None else {}
|
||||
if result.get("result") == 49:
|
||||
raise DirectoryDenied("invalid directory credentials") from exc
|
||||
raise DirectoryUnavailable("directory authentication failed") from exc
|
||||
except (OSError, ssl.SSLError, TimeoutError, ValueError) as exc:
|
||||
raise DirectoryUnavailable("directory service unavailable") from exc
|
||||
finally:
|
||||
if connection is not None:
|
||||
try:
|
||||
connection.unbind()
|
||||
except (LDAPException, OSError):
|
||||
pass
|
||||
|
||||
|
||||
async def authenticate(login: str, password: str) -> DirectoryIdentity | None:
|
||||
settings = get_settings()
|
||||
return await asyncio.to_thread(_authenticate_sync, login, password, settings)
|
||||
|
|
@ -45,11 +45,14 @@ class LessonCriteria(BaseModel):
|
|||
"""Настраиваемые преподавателем условия именно этого занятия.
|
||||
|
||||
Нормативы ГОСТ для приёма вызова сюда не входят. Для занятия меняются
|
||||
учебный лимит решения, порог успешности и веса метрик; веса сценария
|
||||
остаются базовыми, а настройки занятия могут их переопределить.
|
||||
учебные лимиты первичной реакции и полного цикла карточки ДДС, заполнения
|
||||
КИО, порог успешности и веса метрик; веса сценария остаются базовыми,
|
||||
настройки занятия могут их переопределить.
|
||||
"""
|
||||
|
||||
decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
|
||||
card_fill_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
|
||||
dds_card_work_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
|
||||
allowed_errors: int = Field(default=0, ge=0, le=50)
|
||||
require_correct_grammar: bool = True
|
||||
score_weights: dict[str, float] = Field(default_factory=dict)
|
||||
|
|
@ -156,6 +159,12 @@ class CardBriefing(BaseModel):
|
|||
handoff_to_dds: bool = False
|
||||
|
||||
|
||||
class TextTurnAccepted(BaseModel):
|
||||
type: Literal["text.turn.accepted"] = "text.turn.accepted"
|
||||
text: str
|
||||
at: datetime
|
||||
|
||||
|
||||
class CallStarted(BaseModel):
|
||||
type: Literal["call.started"] = "call.started"
|
||||
started_at: datetime
|
||||
|
|
@ -178,6 +187,7 @@ class CallerUtterance(BaseModel):
|
|||
text: str
|
||||
at: datetime
|
||||
mood: Mood
|
||||
source: Literal["local_llm", "scenario"] = "scenario"
|
||||
|
||||
|
||||
class TtsBegin(BaseModel):
|
||||
|
|
@ -251,6 +261,13 @@ class ScoreReady(BaseModel):
|
|||
session_id: UUID
|
||||
|
||||
|
||||
class CommandAck(BaseModel):
|
||||
"""Durable confirmation for a station command, safe to replay by ID."""
|
||||
|
||||
type: Literal["command.ack"] = "command.ack"
|
||||
command_id: UUID
|
||||
|
||||
|
||||
class ErrorEvent(BaseModel):
|
||||
type: Literal["error"] = "error"
|
||||
code: ErrorKind
|
||||
|
|
@ -260,6 +277,7 @@ class ErrorEvent(BaseModel):
|
|||
ServerToTrainee = Annotated[
|
||||
CallIncoming
|
||||
| CardBriefing
|
||||
| TextTurnAccepted
|
||||
| CallStarted
|
||||
| SttPartial
|
||||
| SttFinal
|
||||
|
|
@ -293,6 +311,11 @@ class CardSubmit(BaseModel):
|
|||
type: Literal["card.submit"] = "card.submit"
|
||||
|
||||
|
||||
class TextTurn(BaseModel):
|
||||
type: Literal["text.turn"] = "text.turn"
|
||||
text: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class KioPatchIn(BaseModel):
|
||||
"""Правка карточки. Дебаунс 300 мс, шлётся только дельта."""
|
||||
|
||||
|
|
@ -347,6 +370,7 @@ class CallResolve(BaseModel):
|
|||
TraineeToServer = Annotated[
|
||||
CallAnswer
|
||||
| CardSubmit
|
||||
| TextTurn
|
||||
| KioPatchIn
|
||||
| HintRequest
|
||||
| SelfAssessmentSubmit
|
||||
|
|
@ -446,6 +470,7 @@ class ScenarioStart(BaseModel):
|
|||
type: Literal["scenario.start"] = "scenario.start"
|
||||
scenario_id: str
|
||||
scenario_ids: list[str] | None = None
|
||||
random_scenario_ids: list[str] | None = None
|
||||
# Pace defaults to simultaneous for older clients; the instructor UI
|
||||
# explicitly sends its slower training default.
|
||||
dds_arrival_interval_seconds: int = Field(default=0, ge=0, le=300)
|
||||
|
|
@ -489,11 +514,6 @@ class ScoreOverride(BaseModel):
|
|||
comment: str
|
||||
|
||||
|
||||
class ScenarioPublish(BaseModel):
|
||||
type: Literal["scenario.publish"] = "scenario.publish"
|
||||
scenario_id: str
|
||||
|
||||
|
||||
class SessionStop(BaseModel):
|
||||
type: Literal["session.stop"] = "session.stop"
|
||||
|
||||
|
|
@ -504,7 +524,6 @@ InstructorToServer = Annotated[
|
|||
| ReferencePlay
|
||||
| InstructorNoteAdd
|
||||
| ScoreOverride
|
||||
| ScenarioPublish
|
||||
| SessionStop,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
|
@ -528,6 +547,7 @@ class CardAck(BaseModel):
|
|||
"""Останавливает норматив `dds_ack` (≤ 30 с)."""
|
||||
|
||||
type: Literal["card.ack"] = "card.ack"
|
||||
comment: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class CardBounce(BaseModel):
|
||||
|
|
@ -644,7 +664,8 @@ class CrewArrived(BaseModel):
|
|||
|
||||
|
||||
ServerToStation = Annotated[
|
||||
CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent,
|
||||
CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded
|
||||
| ScoreReady | CommandAck | ErrorEvent,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
|
@ -661,7 +682,7 @@ StationToServer = Annotated[
|
|||
|
||||
|
||||
class Metric(BaseModel):
|
||||
"""Метрика оценки: факт против норматива со ссылкой. Не балл, а обоснование."""
|
||||
"""Факт против норматива со ссылкой; `credit` задаёт частичный вклад времени."""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
|
|
@ -670,6 +691,7 @@ class Metric(BaseModel):
|
|||
ref: str | None = None
|
||||
passed: bool
|
||||
weight: float = 1.0
|
||||
credit: float | None = Field(default=None, ge=0, le=1)
|
||||
|
||||
|
||||
class CompetencyScore(BaseModel):
|
||||
|
|
@ -677,6 +699,17 @@ class CompetencyScore(BaseModel):
|
|||
value: float
|
||||
|
||||
|
||||
class AIRecommendation(BaseModel):
|
||||
metric_key: str
|
||||
text: str = Field(min_length=12, max_length=240)
|
||||
|
||||
|
||||
class AICoaching(BaseModel):
|
||||
status: Literal["ready", "unavailable", "disabled", "not_needed"]
|
||||
model: str | None = None
|
||||
recommendations: list[AIRecommendation] = []
|
||||
|
||||
|
||||
class HintUsage(BaseModel):
|
||||
checklist_id: str
|
||||
question: str
|
||||
|
|
@ -713,6 +746,14 @@ class DdsCardReport(BaseModel):
|
|||
findings: list[Finding]
|
||||
actions: list[dict[str, Any]] = []
|
||||
duration_ms: int = 0
|
||||
title: str | None = None
|
||||
address: str | None = None
|
||||
description: str | None = None
|
||||
incident_type: str | None = None
|
||||
victims_count: int | None = None
|
||||
received_at: datetime | None = None
|
||||
managed_service: str | None = None
|
||||
recipient_services: list[str] = []
|
||||
|
||||
|
||||
class SessionReport(BaseModel):
|
||||
|
|
@ -721,6 +762,7 @@ class SessionReport(BaseModel):
|
|||
session_id: UUID
|
||||
scenario_id: str
|
||||
mode: SessionMode
|
||||
exercise: Exercise = Exercise.CALL
|
||||
attempt: int = 1
|
||||
criteria: LessonCriteria
|
||||
failed_metrics: int
|
||||
|
|
@ -728,6 +770,7 @@ class SessionReport(BaseModel):
|
|||
transcript: list[TranscriptEntry]
|
||||
findings: list[Finding]
|
||||
metrics: list[Metric]
|
||||
ai_coaching: AICoaching | None = None
|
||||
card_results: list[DdsCardReport] = []
|
||||
competencies: list[CompetencyScore]
|
||||
reference_questions: list[HintShown]
|
||||
|
|
|
|||
|
|
@ -133,9 +133,30 @@ class KIO(BaseModel):
|
|||
|
||||
#: Поля, которые курсант не редактирует: их проставляет система.
|
||||
READ_ONLY_FIELDS: frozenset[str] = frozenset(
|
||||
{"card_id", "registered_at", "caller_number", "response_status", "incident_code", "notify"}
|
||||
{
|
||||
"card_id", "registered_at", "caller_number", "response_status",
|
||||
"incident_code", "notify", "dispatch_order_at", "arrival_at",
|
||||
}
|
||||
)
|
||||
|
||||
# Поля, которые реально можно заполнить в форме курсантского КИО. Держим
|
||||
# отдельный allowlist: наличие атрибута в модели ещё не означает, что форма
|
||||
# умеет его показать и отправить (например, coords или служебные timestamps).
|
||||
EDITABLE_KIO_FIELDS: frozenset[str] = frozenset({
|
||||
"caller_name", "caller_contact", "phone_on_scene", "language",
|
||||
"okato", "address", "street", "building", "entrance", "floor",
|
||||
"intercom_code", "description", "incident_group", "signs",
|
||||
"incident_type", "victims_count", "is_emergency", "life_threat",
|
||||
"evacuation_needed", "dds",
|
||||
"fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified",
|
||||
"fire.people_inside", "fire.smoke_spread",
|
||||
"police.offence_kind", "police.suspects", "police.suspect_fled",
|
||||
"police.vehicle",
|
||||
"medical.reason", "medical.conscious", "medical.breathing",
|
||||
"medical.can_move", "medical.age",
|
||||
"utility.failure_kind", "utility.scale", "utility.threat_to_residents",
|
||||
})
|
||||
|
||||
|
||||
def get_field(card: KIO, path: str) -> Any:
|
||||
"""Значение поля по пути вида `floor` или `fire.floors`."""
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ ROLE_LABELS: dict[Role, str] = {
|
|||
#: настройки, администратор не вмешивается в оценки, обучающийся не видит
|
||||
#: чужих результатов.
|
||||
SCREENS: dict[Role, tuple[str, ...]] = {
|
||||
Role.ADMIN: ("/admin", "/profile", "/groups", "/materials"),
|
||||
Role.ADMIN: ("/admin", "/wall", "/profile", "/groups", "/materials"),
|
||||
Role.INSTRUCTOR: (
|
||||
"/instructor", "/wall", "/profile", "/dds", "/phone", "/groups", "/materials",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -74,9 +74,22 @@ NEXT: dict[ServiceStatus, tuple[ServiceStatus, ...]] = {
|
|||
|
||||
#: Без комментария не сохраняются. Это не валидация формы, а предмет обучения:
|
||||
#: половина нарушений в памятке — отказ без указания, куда передана информация.
|
||||
COMMENT_REQUIRED: frozenset[ServiceStatus] = frozenset(
|
||||
{ServiceStatus.DECLINED, ServiceStatus.REFUSED}
|
||||
)
|
||||
# Заказчик уточнил, что диспетчер выбирает статусы и добавляет к ним свои
|
||||
# комментарии. Требуем фиксировать источник/содержание сведений для каждой
|
||||
# ручной отметки, а не обучать проставлению статусов без основания.
|
||||
COMMENT_REQUIRED: frozenset[ServiceStatus] = frozenset({
|
||||
ServiceStatus.ACCEPTED,
|
||||
ServiceStatus.DECLINED,
|
||||
ServiceStatus.RESPONDING,
|
||||
ServiceStatus.ARRIVED,
|
||||
ServiceStatus.WORKING,
|
||||
ServiceStatus.COMPLETED,
|
||||
ServiceStatus.REFUSED,
|
||||
})
|
||||
REFUSAL_COMMENT_REQUIRED: frozenset[ServiceStatus] = frozenset({
|
||||
ServiceStatus.DECLINED,
|
||||
ServiceStatus.REFUSED,
|
||||
})
|
||||
|
||||
#: Первичные статусы: их ждут в норматив 30 секунд.
|
||||
PRIMARY: frozenset[ServiceStatus] = frozenset(
|
||||
|
|
@ -198,8 +211,8 @@ def check(entries: list[StatusEntry], service: str, status: ServiceStatus, comme
|
|||
)
|
||||
if status in COMMENT_REQUIRED and not comment.strip():
|
||||
raise StatusError(
|
||||
f"«{SERVICE_STATUS_LABELS[status]}» требует комментария: причина и то, "
|
||||
"куда передана информация"
|
||||
f"«{SERVICE_STATUS_LABELS[status]}» требует комментария: укажите основание "
|
||||
"отметки и содержание полученных сведений"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -250,6 +263,7 @@ class StationSnapshot(BaseModel):
|
|||
log: list[StatusEntry]
|
||||
crew_options: list[str] = []
|
||||
crew_selected: str | None = None
|
||||
zone_decision: bool | None = None
|
||||
phone_reports: list[PhoneReportRecord] = []
|
||||
phone_lines: list[PhoneLineRecord] = []
|
||||
phone_pending: PhoneCallPending | None = None
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class ErrorCode(StrEnum):
|
|||
|
||||
|
||||
class FindingSource(StrEnum):
|
||||
"""Кто выставил отметку. `judge` — единственный недетерминированный источник."""
|
||||
"""Источник объяснимой отметки; `judge` оставлен для старых отчётов."""
|
||||
|
||||
SLOTS = "slots"
|
||||
DISPATCHER = "dispatcher"
|
||||
|
|
@ -42,6 +42,8 @@ class FindingSource(StrEnum):
|
|||
TIMERS = "timers"
|
||||
KIO = "kio"
|
||||
CHAIN = "chain"
|
||||
GRAMMAR = "grammar"
|
||||
# Legacy value: historical saved reports may still contain it.
|
||||
JUDGE = "judge"
|
||||
INSTRUCTOR = "instructor"
|
||||
|
||||
|
|
@ -74,9 +76,9 @@ ERRORS: dict[ErrorCode, ErrorSpec] = {
|
|||
),
|
||||
ErrorCode.E4: ErrorSpec(
|
||||
code=ErrorCode.E4,
|
||||
title="Коммуникативная ошибка",
|
||||
detail="Тон, эмпатия, управление диалогом, лишние вопросы, игнорирование паники",
|
||||
source=FindingSource.JUDGE,
|
||||
title="Грамматическая ошибка",
|
||||
detail="Нарушен включённый критерий грамматики описания КИО",
|
||||
source=FindingSource.GRAMMAR,
|
||||
),
|
||||
ErrorCode.E5: ErrorSpec(
|
||||
code=ErrorCode.E5,
|
||||
|
|
@ -117,8 +119,8 @@ ERRORS: dict[ErrorCode, ErrorSpec] = {
|
|||
ErrorCode.D5: ErrorSpec(
|
||||
code=ErrorCode.D5,
|
||||
title="Неполный комментарий",
|
||||
detail="Не указано, куда передана информация и что сделал диспетчер",
|
||||
source=FindingSource.JUDGE,
|
||||
detail="В комментарии не назван получатель переданных сведений",
|
||||
source=FindingSource.DISPATCHER,
|
||||
),
|
||||
ErrorCode.D6: ErrorSpec(
|
||||
code=ErrorCode.D6,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ class TimerCode(StrEnum):
|
|||
INTERVIEW = "interview"
|
||||
DDS_NOTIFY = "dds_notify"
|
||||
DDS_ACK = "dds_ack"
|
||||
DDS_WORK = "dds_work"
|
||||
CARD_FILL = "card_fill"
|
||||
ZONE_CHECK = "zone_check"
|
||||
CALLBACK = "callback"
|
||||
CLOSE = "close"
|
||||
|
|
@ -62,6 +64,18 @@ NORMATIVES: dict[TimerCode, Normative] = {
|
|||
limit_ms=30_000,
|
||||
ref="ПП РФ № 1931",
|
||||
),
|
||||
TimerCode.CARD_FILL: Normative(
|
||||
code=TimerCode.CARD_FILL,
|
||||
title="Заполнение карточки КИО",
|
||||
limit_ms=180_000,
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
),
|
||||
TimerCode.DDS_WORK: Normative(
|
||||
code=TimerCode.DDS_WORK,
|
||||
title="Отработка карточки ДДС",
|
||||
limit_ms=180_000,
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
),
|
||||
TimerCode.ZONE_CHECK: Normative(
|
||||
code=TimerCode.ZONE_CHECK, title="Проверка зоны ответственности", limit_ms=30_000
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2,22 +2,21 @@
|
|||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.api import auth
|
||||
from app.api.http import admin as admin_api
|
||||
from app.api.http import ekp as ekp_api
|
||||
from app.api.http import groups as groups_api
|
||||
from app.api.http import materials as materials_api
|
||||
from app.api.http import scenario_submissions as scenario_submissions_api
|
||||
from app.api.http import scenarios as scenarios_api
|
||||
from app.api.http import sessions
|
||||
from app.api.http import trainees
|
||||
from app.api.http import sessions, trainees
|
||||
from app.api.ws import call as call_ws
|
||||
from app.api.ws import control as control_ws
|
||||
from app.api.ws import observe as observe_ws
|
||||
|
|
@ -25,13 +24,12 @@ from app.api.ws import station as station_ws
|
|||
from app.config import get_settings
|
||||
from app.db.base import get_sessionmaker
|
||||
from app.dialog.runtime import get_embedder
|
||||
from app.voice.models import get_voice_models
|
||||
from app.scenarios import store
|
||||
from app.session.hub import hub
|
||||
from app.session.journal import DbJournal
|
||||
from app.scenarios.loader import ScenarioError
|
||||
from app.monitoring import install_diagnostics
|
||||
|
||||
from app.scenarios import store
|
||||
from app.scenarios.loader import ScenarioError
|
||||
from app.session.hub import hub
|
||||
from app.session.journal import DbJournal, SessionLeaseLost
|
||||
from app.voice.models import get_voice_models
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
||||
|
|
@ -46,6 +44,10 @@ install_diagnostics()
|
|||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
try:
|
||||
settings.validate_deployment_security()
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
if settings.demo_no_db and not settings.dev_auth_bypass:
|
||||
raise RuntimeError("DEMO_NO_DB требует DEV_AUTH_BYPASS=true для локального входа")
|
||||
# Библиотека проверяется на старте целиком: сломанный сценарий, найденный
|
||||
|
|
@ -58,6 +60,7 @@ async def lifespan(app: FastAPI):
|
|||
if settings.demo_no_db:
|
||||
store.reset_demo_drafts()
|
||||
materials_api.reset_demo_materials()
|
||||
scenario_submissions_api.reset_demo_submissions()
|
||||
|
||||
# Утверждённые преподавателем сценарии хранятся в БД и должны переживать
|
||||
# перезапуск процесса. При недоступной БД остаётся базовая YAML-библиотека.
|
||||
|
|
@ -87,7 +90,10 @@ async def lifespan(app: FastAPI):
|
|||
)
|
||||
|
||||
# Журнал: всё, что не записано, для оценки не существует.
|
||||
hub.journal = None if settings.demo_no_db else DbJournal(get_sessionmaker())
|
||||
hub.journal = (
|
||||
None if settings.demo_no_db
|
||||
else DbJournal(get_sessionmaker(), node_id=settings.backend_node_id)
|
||||
)
|
||||
app.state.sessions_restored = 0
|
||||
if hub.journal is not None:
|
||||
try:
|
||||
|
|
@ -106,17 +112,81 @@ async def lifespan(app: FastAPI):
|
|||
"не удалось восстановить активные занятия: %s", exc
|
||||
)
|
||||
|
||||
lease_task = None
|
||||
if hub.journal is not None and settings.backend_node_id:
|
||||
async def supervise_session_ownership() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
journal = hub.journal
|
||||
if journal is None:
|
||||
return
|
||||
for state in list(hub._sessions.values()):
|
||||
if state.ended or state.lease_fenced:
|
||||
continue
|
||||
try:
|
||||
await journal.renew(state.session_id)
|
||||
except SessionLeaseLost:
|
||||
await hub.fence(state)
|
||||
except (SQLAlchemyError, OSError, TimeoutError):
|
||||
logging.getLogger(__name__).warning(
|
||||
"backend lease renewal failed for %s", state.session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
await hub.fence(state)
|
||||
except Exception: # noqa: BLE001 — unknown ownership state fails closed
|
||||
logging.getLogger(__name__).exception(
|
||||
"unexpected backend lease failure for %s", state.session_id
|
||||
)
|
||||
await hub.fence(state)
|
||||
try:
|
||||
restored = await journal.claim_expired()
|
||||
for state in restored:
|
||||
current = hub._sessions.get(state.session_id)
|
||||
if current is not None and not current.lease_fenced:
|
||||
continue
|
||||
if current is not None:
|
||||
hub.stop_ticker(state.session_id)
|
||||
hub.register(state)
|
||||
hub.start_ticker(state.session_id)
|
||||
except (SQLAlchemyError, OSError, TimeoutError):
|
||||
logging.getLogger(__name__).exception(
|
||||
"не удалось проверить/восстановить занятия с истёкшей backend lease"
|
||||
)
|
||||
|
||||
lease_task = asyncio.create_task(
|
||||
supervise_session_ownership(), name="session-owner-lease-supervisor"
|
||||
)
|
||||
|
||||
# Эмбеддинги для слот-автомата — грузятся один раз, до первого занятия.
|
||||
app.state.embeddings_ready = not settings.demo_no_db and get_embedder() is not None
|
||||
|
||||
# Модели речи: ~5 секунд на старте стенда вместо паузы на первом звонке.
|
||||
app.state.models_ready = get_voice_models() is not None
|
||||
generation_watcher = (
|
||||
asyncio.create_task(auth.watch_generations(), name="auth-generation-sync")
|
||||
if not settings.demo_no_db else None
|
||||
)
|
||||
yield
|
||||
|
||||
if generation_watcher is not None:
|
||||
generation_watcher.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await generation_watcher
|
||||
|
||||
if lease_task is not None:
|
||||
lease_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await lease_task
|
||||
|
||||
if hub.journal is not None:
|
||||
for state in list(hub._sessions.values()):
|
||||
if not state.ended:
|
||||
await hub.journal.checkpoint(state)
|
||||
try:
|
||||
await hub.journal.checkpoint(state)
|
||||
except Exception: # noqa: BLE001 — shutdown must release the process
|
||||
logging.getLogger(__name__).exception(
|
||||
"не удалось сохранить checkpoint %s при shutdown", state.session_id
|
||||
)
|
||||
await hub.shutdown()
|
||||
for state in list(hub._sessions.values()):
|
||||
if state.voice is not None:
|
||||
|
|
@ -126,6 +196,9 @@ async def lifespan(app: FastAPI):
|
|||
app = FastAPI(title="Учебный симулятор занятия для системы 112", lifespan=lifespan)
|
||||
# Сессия ставится до роутеров: роль должна быть известна и на HTTP, и в момент
|
||||
# рукопожатия сокета, иначе проверять её в канале будет нечем (app/api/auth.py).
|
||||
app.add_middleware(
|
||||
auth.AuthVersionMiddleware,
|
||||
)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=get_settings().session_secret,
|
||||
|
|
@ -136,6 +209,7 @@ app.add_middleware(
|
|||
app.include_router(auth.router)
|
||||
app.include_router(sessions.router)
|
||||
app.include_router(scenarios_api.router)
|
||||
app.include_router(scenario_submissions_api.router)
|
||||
app.include_router(ekp_api.router)
|
||||
app.include_router(groups_api.router)
|
||||
app.include_router(materials_api.router)
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ def reveals_number(value: str) -> bool:
|
|||
|
||||
def editable_fact_ids(source: Scenario) -> list[str]:
|
||||
return [fact.id for fact in source.facts
|
||||
if not fact.hidden and not fact.refined and not fact.refine_on
|
||||
if not fact.refined and not fact.refine_on
|
||||
and not any(part in fact.id.casefold() for part in PROTECTED_IDS)]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -42,14 +42,30 @@ def _merge_checklist(base: list[dict], local: list[dict]) -> list[ChecklistItem]
|
|||
return [ChecklistItem.model_validate(item) for item in merged.values()]
|
||||
|
||||
|
||||
def _assert_unique_checklist_ids(items: list[dict], source: str) -> None:
|
||||
ids = [
|
||||
item["id"] for item in items
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str)
|
||||
]
|
||||
seen: set[str] = set()
|
||||
duplicates: set[str] = set()
|
||||
for item_id in ids:
|
||||
if item_id in seen:
|
||||
duplicates.add(item_id)
|
||||
seen.add(item_id)
|
||||
if duplicates:
|
||||
raise ScenarioError(
|
||||
f"{source}: повторяются id пунктов чек-листа: {', '.join(sorted(duplicates))}"
|
||||
)
|
||||
|
||||
|
||||
def _derive_ground_truth(scenario: Scenario) -> Scenario:
|
||||
"""Эталон собирается кодом. Из YAML берутся только нормализованные
|
||||
адрес и число пострадавших — остальное перезаписывается."""
|
||||
hidden = {fact.id for fact in scenario.facts if fact.hidden}
|
||||
required = [
|
||||
item.fact
|
||||
for item in scenario.checklist
|
||||
if item.fact and item.fact not in hidden
|
||||
if item.fact
|
||||
]
|
||||
scenario.ground_truth.incident_type = scenario.type
|
||||
scenario.ground_truth.dds = DDS_BY_INCIDENT[scenario.type]
|
||||
|
|
@ -114,6 +130,7 @@ def load_file(path: Path, root: Path) -> Scenario:
|
|||
)
|
||||
|
||||
own = raw.get("checklist", [])
|
||||
_assert_unique_checklist_ids(own, path.name)
|
||||
base = _common_checklist(root, {item.get("id") for item in own})
|
||||
extends = raw.get("extends")
|
||||
if extends:
|
||||
|
|
@ -121,6 +138,7 @@ def load_file(path: Path, root: Path) -> Scenario:
|
|||
if not base_path.exists():
|
||||
raise ScenarioError(f"{path.name}: чек-лист {extends} не найден")
|
||||
base = base + _read_yaml(base_path).get("checklist", [])
|
||||
_assert_unique_checklist_ids(base, f"{path.name}: подключённые чек-листы")
|
||||
if base:
|
||||
raw["checklist"] = [
|
||||
item.model_dump(exclude_none=True) for item in _merge_checklist(base, own)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|||
|
||||
from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
|
||||
from app.domain.events import Mood
|
||||
from app.domain.kio import EDITABLE_KIO_FIELDS, KIO
|
||||
from app.scoring.taxonomy import METRIC_MAP
|
||||
|
||||
|
||||
|
|
@ -32,16 +33,9 @@ class Background(Strict):
|
|||
|
||||
|
||||
class RevealOn(Strict):
|
||||
"""Два вида условий: вопрос из чек-листа либо подход оператора."""
|
||||
"""Факт открывает только вопрос, сопоставленный жёстким слот-протоколом."""
|
||||
|
||||
question: str | None = None
|
||||
approach: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def exactly_one(self):
|
||||
if bool(self.question) == bool(self.approach):
|
||||
raise ValueError("reveal_on: ровно одно из `question` или `approach`")
|
||||
return self
|
||||
question: str = Field(min_length=1)
|
||||
|
||||
|
||||
class Fact(Strict):
|
||||
|
|
@ -59,22 +53,12 @@ class Fact(Strict):
|
|||
|
||||
id: str
|
||||
value: str
|
||||
hidden: bool = False
|
||||
reveal_on: RevealOn | None = None
|
||||
refined: str | None = None
|
||||
refine_on: str | None = Field(
|
||||
default=None, description="Пункт чек-листа, уточняющий этот факт"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def hidden_needs_condition(self):
|
||||
if self.hidden and (self.reveal_on is None or not self.reveal_on.approach):
|
||||
raise ValueError(
|
||||
f"факт {self.id}: hidden требует reveal_on.approach — "
|
||||
"скрытый факт не раскрывается прямым вопросом"
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def refinement_needs_both_halves(self):
|
||||
if bool(self.refined) != bool(self.refine_on):
|
||||
|
|
@ -129,6 +113,23 @@ class GroundTruth(Strict):
|
|||
victims: int | None = None
|
||||
|
||||
|
||||
class DdsDecision(Strict):
|
||||
"""Эталон первичного решения службы по данной карточке.
|
||||
|
||||
Профильность по ЕКП сама по себе не исключает дубль или территориальный
|
||||
отказ, поэтому такие исключения задаются явно в сценарии.
|
||||
"""
|
||||
|
||||
expected: str = Field(default="accept", pattern="^(accept|decline)$")
|
||||
reason: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def decline_needs_reason(self):
|
||||
if self.expected == "decline" and not (self.reason and self.reason.strip()):
|
||||
raise ValueError("dds_decision.reason обязателен для эталонного отказа")
|
||||
return self
|
||||
|
||||
|
||||
class Scenario(Strict):
|
||||
id: str
|
||||
title: str
|
||||
|
|
@ -149,11 +150,19 @@ class Scenario(Strict):
|
|||
# Билет — единица занятия у заказчика: три вызова подряд, разные службы
|
||||
# (docs/spec/TICKETS.md). Преподаватель выбирает билет, а не сценарий.
|
||||
ticket: int | None = None
|
||||
position: int | None = Field(default=None, ge=1, le=3, description="Номер вызова в билете")
|
||||
position: int | None = Field(
|
||||
default=None, ge=1, le=3, description="Номер вызова в билете"
|
||||
)
|
||||
|
||||
# Чем вызов заканчивается правильно. По умолчанию — карточка и выезд;
|
||||
# справка и передача в другой регион разбираются в lct-36.
|
||||
outcome: Outcome = Outcome.CARD
|
||||
dds_decision: DdsDecision = DdsDecision()
|
||||
|
||||
# Готовая КИО, сформированная курсантом и утверждённая преподавателем.
|
||||
# Для системных сценариев поле отсутствует; ДДС использует снимок как
|
||||
# исходную карточку вместо реконструкции её из кратких фактов.
|
||||
student_card: KIO | None = None
|
||||
|
||||
facts: list[Fact] = []
|
||||
checklist: list[ChecklistItem] = []
|
||||
|
|
@ -173,15 +182,51 @@ class Scenario(Strict):
|
|||
def valid_score_weights(self):
|
||||
unknown = self.score_weights.keys() - METRIC_MAP.keys()
|
||||
if unknown:
|
||||
raise ValueError(f"неизвестные метрики score_weights: {', '.join(sorted(unknown))}")
|
||||
raise ValueError(
|
||||
f"неизвестные метрики score_weights: {', '.join(sorted(unknown))}"
|
||||
)
|
||||
if any(not 0 <= weight <= 10 for weight in self.score_weights.values()):
|
||||
raise ValueError("score_weights: каждый вес должен быть от 0 до 10")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_required_fields(self):
|
||||
duplicates = sorted({path for path in self.required_fields
|
||||
if self.required_fields.count(path) > 1})
|
||||
if duplicates:
|
||||
raise ValueError(
|
||||
f"required_fields: повторяются поля: {', '.join(duplicates)}"
|
||||
)
|
||||
unavailable = sorted(set(self.required_fields) - EDITABLE_KIO_FIELDS)
|
||||
if unavailable:
|
||||
raise ValueError(
|
||||
"required_fields: поля отсутствуют в форме КИО или заполняются системой: "
|
||||
+ ", ".join(unavailable)
|
||||
)
|
||||
if self.outcome is not Outcome.CARD and self.required_fields:
|
||||
raise ValueError(
|
||||
"required_fields должны быть пустыми, если карточка КИО не создаётся"
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def unique_reference_ids(self):
|
||||
for label, values in (
|
||||
("id фактов", [fact.id for fact in self.facts]),
|
||||
("id пунктов чек-листа", [item.id for item in self.checklist]),
|
||||
("признаки ЕКП", self.signs),
|
||||
):
|
||||
duplicates = sorted({value for value in values if values.count(value) > 1})
|
||||
if duplicates:
|
||||
raise ValueError(f"{label} должны быть уникальны: {', '.join(duplicates)}")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def ticket_needs_position(self):
|
||||
if (self.ticket is None) != (self.position is None):
|
||||
raise ValueError("ticket и position задаются вместе: билет без номера вызова неполон")
|
||||
raise ValueError(
|
||||
"ticket и position задаются вместе: билет без номера вызова неполон"
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
преподаватель выбирает сценарий и что переживает перезапуск.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
|
|
@ -11,15 +12,16 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Scenario as ScenarioRow
|
||||
from app.scenarios import editor
|
||||
from app.scenarios.loader import load_library
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scenarios import editor
|
||||
|
||||
_library: dict[str, Scenario] = {}
|
||||
_demo_drafts: dict[str, ScenarioRow] = {}
|
||||
_demo_archived: dict[str, Scenario] = {}
|
||||
_demo_scenario_owners: dict[str, str] = {}
|
||||
_demo_archived_owners: dict[str, str] = {}
|
||||
BeforeCommit = Callable[[AsyncSession, ScenarioRow], None]
|
||||
|
||||
|
||||
def reset_demo_drafts() -> None:
|
||||
|
|
@ -48,6 +50,12 @@ def publish(scenario: Scenario) -> None:
|
|||
_library[scenario.id] = scenario
|
||||
|
||||
|
||||
def register_owned_scenario(scenario: Scenario, owner_login: str) -> None:
|
||||
"""Обновить runtime-библиотеку после публикации модерируемого сценария."""
|
||||
_library[scenario.id] = scenario
|
||||
_demo_scenario_owners[scenario.id] = owner_login
|
||||
|
||||
|
||||
def load_from_disk(root: Path) -> list[Scenario]:
|
||||
scenarios = load_library(root)
|
||||
set_library(scenarios)
|
||||
|
|
@ -93,13 +101,50 @@ async def restore_published(db: AsyncSession) -> int:
|
|||
continue
|
||||
if row.owner_login:
|
||||
_demo_scenario_owners[row.id] = row.owner_login
|
||||
if row.id in _library:
|
||||
# Shipped YAML remains the canonical source for base cards, but a
|
||||
# published instructor-owned row may have changed on another process.
|
||||
already_loaded = row.id in _library
|
||||
if already_loaded and row.owner_login is None:
|
||||
continue
|
||||
_library[row.id] = Scenario.model_validate(row.body)
|
||||
delta += 1
|
||||
if not already_loaded:
|
||||
delta += 1
|
||||
return delta
|
||||
|
||||
|
||||
async def published_catalog(
|
||||
db: AsyncSession, scenario_ids: list[str], owner_login: str | None,
|
||||
) -> tuple[dict[str, Scenario], set[str]]:
|
||||
"""Resolve startable scenarios from shared DB, including a peer's cache misses.
|
||||
|
||||
Process-local memory remains a fallback only for the shipped library. Any
|
||||
database row is authoritative: archived/pending rows never fall back to a
|
||||
stale in-memory copy, and instructor-owned rows stay private across nodes.
|
||||
"""
|
||||
ids = set(scenario_ids)
|
||||
if not ids:
|
||||
return {}, set()
|
||||
rows = await db.scalars(select(ScenarioRow).where(ScenarioRow.id.in_(ids)))
|
||||
by_id = {row.id: row for row in rows}
|
||||
scenarios: dict[str, Scenario] = {}
|
||||
hidden: set[str] = set()
|
||||
for scenario_id, row in by_id.items():
|
||||
if row.owner_login and row.owner_login != owner_login:
|
||||
hidden.add(scenario_id)
|
||||
continue
|
||||
if row.status == "published":
|
||||
scenario = Scenario.model_validate(row.body)
|
||||
scenarios[scenario_id] = scenario
|
||||
# Refresh a stale process-local version from the authoritative row.
|
||||
_library[scenario_id] = scenario
|
||||
for scenario_id in ids - by_id.keys():
|
||||
scenario = _library.get(scenario_id)
|
||||
# A process-local owner marker without a durable row is not publishable.
|
||||
if scenario is not None and scenario_id not in _demo_scenario_owners:
|
||||
scenarios[scenario_id] = scenario
|
||||
return scenarios, hidden
|
||||
|
||||
|
||||
async def owned_scenario_ids(db: AsyncSession | None, owner_login: str) -> set[str]:
|
||||
"""IDs the current instructor may edit/archive; base and legacy rows are read-only."""
|
||||
if db is None:
|
||||
|
|
@ -117,8 +162,24 @@ async def owned_scenario_ids(db: AsyncSession | None, owner_login: str) -> set[s
|
|||
return {value if isinstance(value, str) else value.id for value in values}
|
||||
|
||||
|
||||
async def scenario_ids_owned_by_other(db: AsyncSession | None, owner_login: str) -> set[str]:
|
||||
"""Hide another instructor's private scenarios from this instructor's bank."""
|
||||
if db is None:
|
||||
return {
|
||||
scenario_id for scenario_id, owner in _demo_scenario_owners.items()
|
||||
if owner != owner_login
|
||||
}
|
||||
rows = await db.scalars(select(ScenarioRow.id).where(
|
||||
ScenarioRow.owner_login.is_not(None),
|
||||
ScenarioRow.owner_login != owner_login,
|
||||
ScenarioRow.status == "published",
|
||||
))
|
||||
return {value if isinstance(value, str) else value.id for value in rows}
|
||||
|
||||
|
||||
async def archive(
|
||||
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
|
||||
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None,
|
||||
before_commit: BeforeCommit | None = None,
|
||||
) -> Scenario | None:
|
||||
"""Скрыть опубликованный сценарий без удаления истории и внешних ключей."""
|
||||
scenario = _library.get(scenario_id)
|
||||
|
|
@ -148,13 +209,16 @@ async def archive(
|
|||
)
|
||||
db.add(row)
|
||||
row.status = "archived"
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
_library.pop(scenario_id, None)
|
||||
return scenario
|
||||
|
||||
|
||||
async def restore_archived(
|
||||
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
|
||||
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None,
|
||||
before_commit: BeforeCommit | None = None,
|
||||
) -> Scenario | None:
|
||||
"""Вернуть мягко удалённый сценарий в библиотеку назначения."""
|
||||
if db is None:
|
||||
|
|
@ -175,6 +239,8 @@ async def restore_archived(
|
|||
# черновика до вычисления и закономерно запрещает такие поля.
|
||||
scenario = Scenario.model_validate(row.body)
|
||||
row.status = "published"
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
if scenario is None:
|
||||
return None
|
||||
|
|
@ -185,7 +251,7 @@ async def restore_archived(
|
|||
async def create_draft(
|
||||
db: AsyncSession | None, *, source: Scenario, title: str | None = None,
|
||||
proposal: dict | None = None, full_proposal: dict | None = None,
|
||||
owner_login: str | None = None,
|
||||
owner_login: str | None = None, before_commit: BeforeCommit | None = None,
|
||||
) -> ScenarioRow:
|
||||
if proposal is not None and full_proposal is not None:
|
||||
raise ValueError("нельзя одновременно передать вариацию и полный сюжет")
|
||||
|
|
@ -216,6 +282,8 @@ async def create_draft(
|
|||
_demo_drafts[row.id] = row
|
||||
else:
|
||||
db.add(row)
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
|
@ -231,15 +299,25 @@ async def draft(
|
|||
return row
|
||||
|
||||
|
||||
async def update_draft(db: AsyncSession | None, row: ScenarioRow, patch: dict) -> ScenarioRow:
|
||||
async def update_draft(
|
||||
db: AsyncSession | None, row: ScenarioRow, patch: dict,
|
||||
*, before_commit: BeforeCommit | None = None,
|
||||
) -> ScenarioRow:
|
||||
row.body = editor.merge_patch(row.body, patch)
|
||||
row.title = str(row.body.get("title") or "")[:200]
|
||||
row.manual_edit_pending = True
|
||||
row.grammar_check_hash = None
|
||||
if db is not None:
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def revise_draft(db: AsyncSession | None, row: ScenarioRow, proposal: dict) -> ScenarioRow:
|
||||
async def revise_draft(
|
||||
db: AsyncSession | None, row: ScenarioRow, proposal: dict,
|
||||
*, before_commit: BeforeCommit | None = None,
|
||||
) -> ScenarioRow:
|
||||
"""Заменить сюжетную версию того же черновика после комментария преподавателя."""
|
||||
from app.scenarios.generation import proposal_body
|
||||
|
||||
|
|
@ -250,12 +328,18 @@ async def revise_draft(db: AsyncSession | None, row: ScenarioRow, proposal: dict
|
|||
row.level = row.body["level"]
|
||||
row.topics = row.body["topics"]
|
||||
row.modes = row.body["modes"]
|
||||
row.manual_edit_pending = False
|
||||
row.grammar_check_hash = None
|
||||
if db is not None:
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def approve_draft(db: AsyncSession | None, row: ScenarioRow) -> Scenario:
|
||||
async def approve_draft(
|
||||
db: AsyncSession | None, row: ScenarioRow, *, before_commit: BeforeCommit | None = None,
|
||||
) -> Scenario:
|
||||
scenario = editor.validate(row.body)
|
||||
row.title = scenario.title
|
||||
row.incident_type = scenario.type.value
|
||||
|
|
@ -269,6 +353,8 @@ async def approve_draft(db: AsyncSession | None, row: ScenarioRow) -> Scenario:
|
|||
if row.owner_login is not None:
|
||||
_demo_scenario_owners[row.id] = row.owner_login
|
||||
else:
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
publish(scenario)
|
||||
return scenario
|
||||
|
|
|
|||
80
backend/app/scoring/address.py
Normal file
80
backend/app/scoring/address.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Conservative matching for operational addresses.
|
||||
|
||||
Names are compared as whole normalized words (never four-letter prefixes), and
|
||||
numbered address components remain attached to their labels. Extra detail in a
|
||||
trainee's address is allowed, but a street typo or swapped house/apartment is
|
||||
not treated as a match. Explicit road types (for example, street vs. lane) are
|
||||
also operationally significant.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
_ALIASES = {
|
||||
"ул": "улица", "улица": "улица",
|
||||
"д": "дом", "дом": "дом",
|
||||
"корп": "корпус", "корпус": "корпус",
|
||||
"стр": "строение", "строение": "строение",
|
||||
"кв": "квартира", "квартира": "квартира",
|
||||
"под": "подъезд", "подъезд": "подъезд",
|
||||
"эт": "этаж", "этаж": "этаж",
|
||||
"код": "код", "домофон": "код",
|
||||
"г": "город", "город": "город",
|
||||
"обл": "область", "область": "область",
|
||||
"пр": "проспект", "просп": "проспект", "проспект": "проспект",
|
||||
"пер": "переулок", "переулок": "переулок",
|
||||
"наб": "набережная", "набережная": "набережная",
|
||||
"ш": "шоссе", "шоссе": "шоссе",
|
||||
}
|
||||
_COMPONENTS = {"дом", "корпус", "строение", "квартира", "подъезд", "этаж", "код"}
|
||||
_ROAD_TYPES = {"улица", "проспект", "переулок", "набережная", "шоссе"}
|
||||
_NON_CONTENT = _COMPONENTS | {
|
||||
"город", "область",
|
||||
} | _ROAD_TYPES
|
||||
|
||||
|
||||
def _tokens(value: str | None) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
raw = re.findall(r"[a-zа-яё0-9]+", value.casefold().replace("ё", "е"))
|
||||
return [_ALIASES.get(token, token) for token in raw if token != "номер"]
|
||||
|
||||
|
||||
def _components(tokens: list[str]) -> dict[str, set[str]]:
|
||||
result: dict[str, set[str]] = {}
|
||||
for index, token in enumerate(tokens[:-1]):
|
||||
if token in _COMPONENTS:
|
||||
result.setdefault(token, set()).add(tokens[index + 1])
|
||||
return result
|
||||
|
||||
|
||||
def address_matches(expected: str | None, supplied: str | None) -> bool:
|
||||
"""Return true only if all expected address words/components are preserved."""
|
||||
expected_tokens = _tokens(expected)
|
||||
supplied_tokens = _tokens(supplied)
|
||||
if not expected_tokens:
|
||||
return bool(supplied_tokens)
|
||||
if not supplied_tokens:
|
||||
return False
|
||||
|
||||
expected_content = {token for token in expected_tokens if token not in _NON_CONTENT}
|
||||
supplied_content = {token for token in supplied_tokens if token not in _NON_CONTENT}
|
||||
if not expected_content <= supplied_content:
|
||||
return False
|
||||
|
||||
expected_components = _components(expected_tokens)
|
||||
supplied_components = _components(supplied_tokens)
|
||||
expected_road_types = set(expected_tokens) & _ROAD_TYPES
|
||||
supplied_road_types = set(supplied_tokens) & _ROAD_TYPES
|
||||
# Тип объекта не является декоративным словом: «улица Ленина» и
|
||||
# «переулок Ленина» — разные адреса, даже при одинаковых остальных токенах.
|
||||
# Если тип явно указан в ответе, он должен совпасть с источником; краткая
|
||||
# форма без типа остаётся допустимой, как и прочие проверенные сокращения.
|
||||
if (
|
||||
expected_road_types
|
||||
and supplied_road_types
|
||||
and supplied_road_types != expected_road_types
|
||||
):
|
||||
return False
|
||||
return all(supplied_components.get(kind) == values
|
||||
for kind, values in expected_components.items())
|
||||
83
backend/app/scoring/ai_coach.py
Normal file
83
backend/app/scoring/ai_coach.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Optional local-model coaching based only on deterministic score findings.
|
||||
|
||||
The model may explain how to improve, but never changes metric values, points,
|
||||
or pass/fail. Only failed criterion keys supplied by the scorer are accepted.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from app.config import get_settings
|
||||
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url
|
||||
from app.domain.events import AICoaching, AIRecommendation, Metric
|
||||
|
||||
|
||||
async def coach(metrics: list[Metric]) -> AICoaching:
|
||||
failed = [item for item in metrics if not item.passed and item.weight > 0]
|
||||
if not failed:
|
||||
return AICoaching(status="not_needed")
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.assessment_feedback_enabled:
|
||||
return AICoaching(status="disabled")
|
||||
if (not settings.llm_model_control or not is_loopback_url(
|
||||
settings.llm_control_base_url,
|
||||
allow_docker_host=settings.allow_docker_host_models,
|
||||
)):
|
||||
return AICoaching(status="unavailable")
|
||||
|
||||
allowed = {item.key for item in failed}
|
||||
schema = {"type": "json_object", "schema": {
|
||||
"type": "object",
|
||||
"properties": {"recommendations": {
|
||||
"type": "array", "maxItems": 3,
|
||||
"items": {"type": "object",
|
||||
"properties": {
|
||||
"metric_key": {"type": "string", "enum": sorted(allowed)},
|
||||
"text": {"type": "string", "minLength": 12, "maxLength": 240},
|
||||
},
|
||||
"required": ["metric_key", "text"], "additionalProperties": False,
|
||||
},
|
||||
}},
|
||||
"required": ["recommendations"], "additionalProperties": False,
|
||||
}}
|
||||
evidence = [{"metric_key": item.key, "criterion": item.title,
|
||||
"observed": item.fact, "expected": item.norm}
|
||||
for item in failed[:12]]
|
||||
request = LlmRequest(
|
||||
model=settings.llm_model_control,
|
||||
messages=[{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Ты методист учебного центра 112. По результатам детерминированной оценки "
|
||||
"сформулируй до трёх коротких, конкретных рекомендаций курсанту: что "
|
||||
"потренировать и как. Не пересчитывай баллы и не оспаривай зачёт. "
|
||||
"Опирайся только на переданные наблюдения и нормативы; не придумывай "
|
||||
"новые факты, требования и числа. Каждая рекомендация должна ссылаться "
|
||||
"на один из переданных metric_key. Верни только JSON. /no_think"
|
||||
),
|
||||
}, {"role": "user", "content": json.dumps(evidence, ensure_ascii=False)}],
|
||||
temperature=0.0, max_tokens=360, response_format=schema, strip_reasoning=True,
|
||||
)
|
||||
client = LlmClient(base_url=settings.llm_control_base_url, timeout=6)
|
||||
try:
|
||||
raw = await client.complete(request, use_cache=True)
|
||||
payload = json.loads(raw)
|
||||
if set(payload) != {"recommendations"} or not isinstance(payload["recommendations"], list):
|
||||
raise ValueError("invalid coaching schema")
|
||||
recommendations: list[AIRecommendation] = []
|
||||
seen: set[str] = set()
|
||||
for item in payload["recommendations"]:
|
||||
if (not isinstance(item, dict) or set(item) != {"metric_key", "text"}
|
||||
or item["metric_key"] not in allowed or item["metric_key"] in seen):
|
||||
raise ValueError("recommendation references an unscored criterion")
|
||||
recommendation = AIRecommendation.model_validate(item)
|
||||
seen.add(recommendation.metric_key)
|
||||
recommendations.append(recommendation)
|
||||
if not recommendations:
|
||||
raise ValueError("model returned no recommendations")
|
||||
return AICoaching(status="ready", model=settings.llm_model_control,
|
||||
recommendations=recommendations)
|
||||
except (LlmUnavailable, ValueError, TypeError, KeyError, json.JSONDecodeError):
|
||||
return AICoaching(status="unavailable")
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
|
@ -9,7 +9,8 @@ from app.domain.events import Metric
|
|||
from app.domain.kio import KIO, missing_fields
|
||||
from app.domain.taxonomy import Finding, FindingSource
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scoring.gost import GostResult, _normalize_address
|
||||
from app.scoring.gost import GostResult
|
||||
from app.scoring.address import address_matches
|
||||
from app.scoring.taxonomy import METRIC_MAP, METRIC_WEIGHTS
|
||||
|
||||
|
||||
|
|
@ -49,8 +50,7 @@ def evaluate_card(scenario: Scenario, kio: KIO) -> GostResult:
|
|||
if truth.address:
|
||||
written = kio.address or " ".join(filter(None, (kio.street, kio.building)))
|
||||
add("address", "Адрес происшествия", written or "не заполнен", truth.address,
|
||||
bool(_normalize_address(truth.address))
|
||||
and _normalize_address(truth.address) <= _normalize_address(written),
|
||||
address_matches(truth.address, written),
|
||||
"эталон сценария")
|
||||
|
||||
if truth.victims is not None:
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ def radar(metrics: list[Metric]) -> list[CompetencyScore]:
|
|||
continue
|
||||
competency = mapping[1]
|
||||
total[competency] = total.get(competency, 0.0) + metric.weight
|
||||
if metric.passed:
|
||||
passed[competency] = passed.get(competency, 0.0) + metric.weight
|
||||
credit = metric.credit if metric.credit is not None else float(metric.passed)
|
||||
passed[competency] = passed.get(competency, 0.0) + metric.weight * credit
|
||||
|
||||
return [
|
||||
CompetencyScore(competency=competency.value, value=round(passed.get(competency, 0.0) / weight, 3))
|
||||
|
|
|
|||
|
|
@ -4,14 +4,17 @@
|
|||
перечислены поимённо и с реальными примерами (docs/spec/DATASET.md). Формулировки
|
||||
не переписаны: преподаватель, который эту памятку читал, должен узнать их в разборе.
|
||||
|
||||
Детерминированно считаются D1, D2, D3, D4 и D6. D5 — полнота комментария — мягкий
|
||||
критерий, его место у судьи (lct-13): «не принята: не обслуживаем» без указания,
|
||||
куда передана информация, формально неотличимо от полного комментария.
|
||||
Детерминированно считаются D1–D6. D5 проверяет структуру отдельного доклада и
|
||||
получателя сведений; пропуск по каждому комментарию виден отдельно. К каждой ручной
|
||||
отметке обязательны непустые поля «Основание» и «Сведения»; их наличие входит в
|
||||
числовую метрику `dds_reply`, но не подтверждает истинность текста. Это не
|
||||
привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу.
|
||||
"""
|
||||
|
||||
from app.domain.statuses import (
|
||||
COMMENT_REQUIRED,
|
||||
PRIMARY,
|
||||
REFUSAL_COMMENT_REQUIRED,
|
||||
SERVICE_STATUS_LABELS,
|
||||
ServiceStatus,
|
||||
StatusEntry,
|
||||
|
|
@ -33,16 +36,61 @@ def _finding(code: ErrorCode, summary: str, fact: str, norm: str | None = None)
|
|||
fact=fact,
|
||||
norm=norm,
|
||||
ref="памятка «Работа на АРМ-112», раздел «Статусы реагирования»",
|
||||
competency=Competency.CARD,
|
||||
competency=(
|
||||
Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_RECIPIENT_TERMS = (
|
||||
"бригад",
|
||||
"старш",
|
||||
"дежурн",
|
||||
"диспетчер",
|
||||
"оператор",
|
||||
"заявител",
|
||||
"пострадавш",
|
||||
"мвд",
|
||||
"полици",
|
||||
"мчс",
|
||||
"скорая",
|
||||
"медицинск",
|
||||
"аварийн",
|
||||
"служба 101",
|
||||
"служба 102",
|
||||
"служба 103",
|
||||
"служба 104",
|
||||
)
|
||||
|
||||
|
||||
def _has_recipient(text: str, service: str, crew: str | None) -> bool:
|
||||
value = text.casefold()
|
||||
if any(term in value for term in _RECIPIENT_TERMS):
|
||||
return True
|
||||
candidates = [service, crew or ""]
|
||||
return any(len(item.strip()) >= 3 and item.strip().casefold() in value for item in candidates)
|
||||
|
||||
|
||||
def _has_basis_and_information(text: str) -> bool:
|
||||
"""Require two explicit fields; this checks structure, not factual truth."""
|
||||
fields: dict[str, str] = {}
|
||||
for line in text.splitlines():
|
||||
label, separator, value = line.partition(":")
|
||||
if separator and label.strip().casefold() in {"основание", "сведения"}:
|
||||
fields[label.strip().casefold()] = value.strip()
|
||||
return bool(fields.get("основание") and fields.get("сведения"))
|
||||
|
||||
|
||||
def evaluate_dispatcher(
|
||||
*,
|
||||
entries: list[StatusEntry],
|
||||
services: list[str],
|
||||
crew_assignments: dict[str, str] | None = None,
|
||||
deadline_ms: int,
|
||||
elapsed_ms: int | None,
|
||||
reply_text: str = "",
|
||||
expected_decision: str = "accept",
|
||||
expected_decision_reason: str | None = None,
|
||||
) -> list[Finding]:
|
||||
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
|
||||
|
||||
|
|
@ -51,6 +99,7 @@ def evaluate_dispatcher(
|
|||
компетенция» неправомерен.
|
||||
"""
|
||||
findings: list[Finding] = []
|
||||
crew_assignments = crew_assignments or {}
|
||||
if not services:
|
||||
return findings
|
||||
|
||||
|
|
@ -58,8 +107,63 @@ def evaluate_dispatcher(
|
|||
marks = [entry for entry in entries if entry.service == service]
|
||||
latest = current(entries, service)
|
||||
|
||||
# D1 — первичного статуса нет вовсе.
|
||||
if not any(mark.status in PRIMARY for mark in marks):
|
||||
# D5 — мягкая проверка памятки: если диспетчер оставил комментарий,
|
||||
# в нём должен быть назван получатель сведений. Статусы и внешние
|
||||
# звонки не используются как выдуманное доказательство.
|
||||
missing_comment_statuses = [
|
||||
SERVICE_STATUS_LABELS[mark.status]
|
||||
for mark in marks
|
||||
if mark.status in COMMENT_REQUIRED and not mark.comment.strip()
|
||||
]
|
||||
if missing_comment_statuses:
|
||||
findings.append(_finding(
|
||||
ErrorCode.D5,
|
||||
f"{service}: к статусу не добавлены основание и сведения",
|
||||
fact="не заполнены комментарии: " + ", ".join(missing_comment_statuses),
|
||||
norm="к каждой ручной отметке добавить основание и содержание полученных сведений",
|
||||
))
|
||||
|
||||
incomplete_comment_statuses = [
|
||||
SERVICE_STATUS_LABELS[mark.status]
|
||||
for mark in marks
|
||||
if mark.status in COMMENT_REQUIRED
|
||||
and mark.comment.strip()
|
||||
and not _has_basis_and_information(mark.comment)
|
||||
]
|
||||
if incomplete_comment_statuses:
|
||||
findings.append(_finding(
|
||||
ErrorCode.D5,
|
||||
f"{service}: комментарии к статусам не разделяют основание и сведения",
|
||||
fact="неполные комментарии: " + ", ".join(incomplete_comment_statuses),
|
||||
norm="в каждом комментарии заполнить отдельные поля «Основание» и «Сведения»",
|
||||
))
|
||||
|
||||
comments_to_check = [
|
||||
(SERVICE_STATUS_LABELS[mark.status], mark.comment.strip())
|
||||
for mark in marks
|
||||
if mark.comment.strip()
|
||||
]
|
||||
if reply_text.strip():
|
||||
comments_to_check.append(("ответ ДДС", reply_text.strip()))
|
||||
missing_recipients = [
|
||||
f"{label}: {comment[:180]}"
|
||||
for label, comment in comments_to_check
|
||||
if not _has_recipient(comment, service, crew_assignments.get(service))
|
||||
]
|
||||
if missing_recipients:
|
||||
findings.append(_finding(
|
||||
ErrorCode.D5,
|
||||
f"{service}: отдельный комментарий не указывает получателя сведений",
|
||||
fact="; ".join(missing_recipients)[:500],
|
||||
norm=(
|
||||
"назвать, кому переданы сведения: бригаде, службе, "
|
||||
"заявителю или иному адресату"
|
||||
),
|
||||
))
|
||||
|
||||
# D1 — первичного статуса нет либо он проставлен позже норматива.
|
||||
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
|
||||
if primary is None:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D1,
|
||||
|
|
@ -74,9 +178,23 @@ def evaluate_dispatcher(
|
|||
)
|
||||
continue
|
||||
|
||||
if elapsed_ms is None or elapsed_ms > deadline_ms:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D1,
|
||||
f"{service}: первичный статус проставлен с нарушением срока",
|
||||
fact=(
|
||||
f"прошло {elapsed_ms // 1000} с"
|
||||
if elapsed_ms is not None
|
||||
else "время первичной отметки не зафиксировано"
|
||||
),
|
||||
norm=f"первичный статус в течение {deadline_ms // 1000} с",
|
||||
)
|
||||
)
|
||||
|
||||
for mark in marks:
|
||||
# D4 — отказ без комментария.
|
||||
if mark.status in COMMENT_REQUIRED and not mark.comment.strip():
|
||||
if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip():
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D4,
|
||||
|
|
@ -86,48 +204,89 @@ def evaluate_dispatcher(
|
|||
)
|
||||
)
|
||||
|
||||
# D3 — отказ от профильного происшествия. Служба в списке оповещения
|
||||
# по классификатору, значит происшествие входит в её компетенцию.
|
||||
if latest is ServiceStatus.DECLINED:
|
||||
# D3 — отказ от профильного происшествия, когда эталон сценария
|
||||
# требует принятия. Дубль/территориальное исключение задаются явно.
|
||||
if latest is ServiceStatus.DECLINED and expected_decision == "accept":
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D3,
|
||||
f"{service}: отказ от происшествия, которое в её компетенции",
|
||||
fact="служба есть в списке оповещения по ЕКП",
|
||||
norm="отказываться от профильного происшествия нельзя",
|
||||
norm="принять согласно эталону сценария",
|
||||
)
|
||||
)
|
||||
|
||||
# D2 — «Принята», но работ не было и отказа тоже: статус не отражает факт.
|
||||
if latest is ServiceStatus.ACCEPTED:
|
||||
elif latest is ServiceStatus.ACCEPTED and expected_decision == "decline":
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
f"{service}: «Принята», но о реагировании ничего не отмечено",
|
||||
fact="после приёма статусов не было",
|
||||
norm="статус должен соответствовать фактическому состоянию заявки",
|
||||
f"{service}: принято вопреки эталону сценария",
|
||||
fact="карточка принята службой",
|
||||
norm=expected_decision_reason or "отказать с указанной причиной",
|
||||
)
|
||||
)
|
||||
|
||||
# D6 — работы завершены, а ход работ не отмечен. В памятке это отдельный
|
||||
# разбор: по такому происшествию идут повторные звонки, и другие службы
|
||||
# не видят, что реагирование вообще началось.
|
||||
if latest is ServiceStatus.COMPLETED and not any(
|
||||
# D2 — «Принята» без назначения бригады/дальнейшего хода или ход без
|
||||
# зафиксированной бригады. Заказчик уточнил: необходимые бригады ДДС
|
||||
# выбирает вручную; канал получения докладов при этом не предписан.
|
||||
if latest is ServiceStatus.ACCEPTED and expected_decision == "accept":
|
||||
if service not in crew_assignments:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
f"{service}: «Принята» без назначения бригады и хода реагирования",
|
||||
fact="бригада не выбрана, после приёма статусов не было",
|
||||
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
||||
)
|
||||
)
|
||||
else:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
f"{service}: «Принята», но о реагировании ничего не отмечено",
|
||||
fact="после приёма статусов не было",
|
||||
norm="статус должен соответствовать фактическому состоянию заявки",
|
||||
)
|
||||
)
|
||||
elif latest is not ServiceStatus.DECLINED and any(
|
||||
mark.status in PROGRESS for mark in marks
|
||||
):
|
||||
) and service not in crew_assignments:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
f"{service}: ход реагирования без назначения бригады",
|
||||
fact="статусы хода работ проставлены, бригада не выбрана",
|
||||
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
||||
)
|
||||
)
|
||||
|
||||
# D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке
|
||||
# это приводит к повторным звонкам и скрывает от других служб факт реакции.
|
||||
# REFUSED — отдельный допустимый терминальный статус с обязательной причиной.
|
||||
missing_progress = [status for status in PROGRESS if status not in {m.status for m in marks}]
|
||||
if (latest not in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
|
||||
and (missing_progress or latest is not ServiceStatus.COMPLETED)):
|
||||
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D6,
|
||||
f"{service}: работы завершены без отметок хода",
|
||||
fact="начало реагирования и прибытие не отмечены",
|
||||
norm="статусы хода работ проставляются по факту",
|
||||
f"{service}: ход реагирования не доведён до конца",
|
||||
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"),
|
||||
norm=(
|
||||
"отметить по факту начало реагирования, прибытие, проведение работ "
|
||||
"и завершение; если работы не проводились — оформить отказ с причиной"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
|
||||
def dispatcher_metrics(
|
||||
state,
|
||||
deadline_ms: int,
|
||||
expected_decision: str = "accept",
|
||||
expected_decision_reason: str | None = None,
|
||||
) -> list[Metric]:
|
||||
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
|
||||
|
||||
Веса предварительные — до утверждения методистом. Телефон — возможный
|
||||
|
|
@ -156,19 +315,40 @@ def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
|
|||
f"≤ {deadline_ms // 1000} с")
|
||||
if primary is None:
|
||||
continue
|
||||
add("dds_decision", "профильное реагирование",
|
||||
primary.status is ServiceStatus.ACCEPTED,
|
||||
primary.status.value, "профильную заявку принять", 2.0)
|
||||
if primary.status is ServiceStatus.DECLINED:
|
||||
expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept"
|
||||
else ServiceStatus.DECLINED)
|
||||
expected_label = "Принята" if expected_decision == "accept" else "Не принята"
|
||||
add("dds_decision", "решение по эталону сценария",
|
||||
primary.status is expected_status,
|
||||
primary.status.value,
|
||||
expected_decision_reason or f"по эталону ожидается «{expected_label}»", 2.0)
|
||||
if primary.status is ServiceStatus.DECLINED or expected_decision == "decline":
|
||||
continue
|
||||
crew = state.crew_assignments.get(service)
|
||||
add("dds_crew", "назначение бригады", bool(crew),
|
||||
crew or "бригада не выбрана",
|
||||
"необходимую бригаду выбирает ДДС вручную", 2.0)
|
||||
expected = {
|
||||
ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING,
|
||||
}
|
||||
add("dds_progress", "ведение хода реагирования", expected <= statuses,
|
||||
", ".join(status.value for status in statuses) or "статусов нет",
|
||||
"начало реагирования, прибытие и работы отмечены по полученной информации", 2.0)
|
||||
refused = ServiceStatus.REFUSED in statuses
|
||||
add("dds_progress", "ведение хода реагирования",
|
||||
expected <= statuses or refused,
|
||||
("отказ от выполнения работ" if refused else
|
||||
", ".join(mark.status.value for mark in marks if mark.status in expected)
|
||||
or "статусов хода нет"),
|
||||
"отметить по факту ход работ либо оформить обоснованный отказ от их выполнения", 2.0)
|
||||
add("dds_completion", "закрытие работ",
|
||||
ServiceStatus.COMPLETED in statuses,
|
||||
"завершено" if ServiceStatus.COMPLETED in statuses else "не завершено",
|
||||
"по факту поставить статус «Работы завершены»")
|
||||
ServiceStatus.COMPLETED in statuses or refused,
|
||||
("завершено" if ServiceStatus.COMPLETED in statuses else
|
||||
"оформлен отказ от выполнения работ" if refused else "не завершено"),
|
||||
"зафиксировать фактическое завершение работ или отказ от их выполнения")
|
||||
notes = [mark.comment.strip() for mark in marks if mark.status in COMMENT_REQUIRED]
|
||||
notes_complete = bool(notes) and all(
|
||||
_has_basis_and_information(note) for note in notes
|
||||
)
|
||||
add("dds_reply", "основание статусов и сведения о ходе работ",
|
||||
notes_complete,
|
||||
"; ".join(notes) if notes else "комментарии к статусам не внесены",
|
||||
"к каждой ручной отметке добавить основание и содержание полученных сведений")
|
||||
return metrics
|
||||
|
|
|
|||
|
|
@ -49,7 +49,10 @@ def to_csv(report: SessionReport) -> bytes:
|
|||
row("Оценка", "", "Причина изменения", report.override_comment)
|
||||
|
||||
for number, item in enumerate(report.metrics, 1):
|
||||
row("Метрики", number, item.title, item.fact, f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; вес: {item.weight:g}; источник: {item.ref or ''}")
|
||||
credit = f"; оценочный вклад: {item.credit:.0%}" if item.credit is not None else ""
|
||||
row("Метрики", number, item.title, item.fact,
|
||||
f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; "
|
||||
f"вес: {item.weight:g}{credit}; источник: {item.ref or ''}")
|
||||
for number, item in enumerate(report.findings, 1):
|
||||
row("Ошибки", number, item.code.value, item.summary, f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}")
|
||||
for number, item in enumerate(report.competencies, 1):
|
||||
|
|
@ -99,7 +102,6 @@ def _font_paths() -> tuple[Path, Path | None]:
|
|||
def to_pdf(report: SessionReport) -> bytes:
|
||||
"""Собрать многостраничный PDF с кириллицей и переносом длинных текстов."""
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_LEFT
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
|
|
@ -167,7 +169,9 @@ def to_pdf(report: SessionReport) -> bytes:
|
|||
if not report.metrics:
|
||||
story.append(p("Нет данных", muted))
|
||||
for item in report.metrics:
|
||||
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'} (вес {item.weight:g})"))
|
||||
credit = f", оценочный вклад {item.credit:.0%}" if item.credit is not None else ""
|
||||
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'} "
|
||||
f"(вес {item.weight:g}{credit})"))
|
||||
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm}. {item.ref or ''}", muted))
|
||||
|
||||
section("Выявленные ошибки")
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
"""Детерминированный слой оценки — 60% веса, считается кодом.
|
||||
"""Метрики опроса и КИО — объяснимые правила, считаются кодом.
|
||||
|
||||
Воспроизводится стопроцентно: один и тот же ход занятия даёт один и тот же
|
||||
результат. Каждая метрика — «факт против норматива со ссылкой», а не балл:
|
||||
«опрос 94 с при нормативе 75 с (ГОСТ Р 22.7.03-2021)» можно предъявить
|
||||
и проверить руками (docs/product/DEBRIEF.md).
|
||||
результат. Каждая метрика — «факт против норматива со ссылкой». Для временных
|
||||
метрик к двоичному признаку нарушения добавлен прозрачный непрерывный вклад,
|
||||
описанный в docs/product/METHODOLOGY.md.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.domain import ekp
|
||||
|
|
@ -16,6 +15,7 @@ from app.domain.kio import KIO, missing_fields
|
|||
from app.domain.taxonomy import ERRORS, Competency, Finding, FindingSource
|
||||
from app.domain.timers import GOST_REF, NORMATIVES, TimerCode
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scoring.address import address_matches
|
||||
from app.scoring.taxonomy import METRIC_MAP, METRIC_WEIGHTS
|
||||
from app.session.timers import SessionTimers
|
||||
|
||||
|
|
@ -42,7 +42,8 @@ class GostResult:
|
|||
total = sum(metric.weight for metric in self.metrics)
|
||||
if not total:
|
||||
return 0.0
|
||||
passed = sum(metric.weight for metric in self.metrics if metric.passed)
|
||||
passed = sum(metric.weight * (metric.credit if metric.credit is not None
|
||||
else float(metric.passed)) for metric in self.metrics)
|
||||
return round(100 * passed / total, 1)
|
||||
|
||||
|
||||
|
|
@ -50,16 +51,6 @@ def _seconds(ms: int) -> str:
|
|||
return f"{round(ms / 1000)} с"
|
||||
|
||||
|
||||
def _normalize_address(text: str | None) -> set[str]:
|
||||
"""Слова адреса без служебных: «ул. Ленина д. 14» и «улица Ленина, 14»
|
||||
должны совпасть, иначе курсанта штрафуют за сокращение."""
|
||||
if not text:
|
||||
return set()
|
||||
noise = {"улица", "ул", "дом", "д", "проспект", "пр", "переулок", "пер", "г", "город", "москва"}
|
||||
words = re.findall(r"[\w-]+", text.lower().replace("ё", "е"))
|
||||
return {word for word in words if word not in noise}
|
||||
|
||||
|
||||
class _Builder:
|
||||
def __init__(self) -> None:
|
||||
self.result = GostResult()
|
||||
|
|
@ -271,11 +262,10 @@ def evaluate(
|
|||
return build.result
|
||||
if truth.address:
|
||||
written = kio.address or " ".join(filter(None, [kio.street, kio.building]))
|
||||
expected = _normalize_address(truth.address)
|
||||
build.add(
|
||||
"address", "Адрес",
|
||||
written or "не заполнен", truth.address,
|
||||
passed=bool(expected) and expected <= _normalize_address(written),
|
||||
passed=address_matches(truth.address, written),
|
||||
ref="ground_truth сценария",
|
||||
finding=f"Адрес в карточке «{written or 'пусто'}», верный — «{truth.address}»",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ def build(scenario: Scenario) -> ReferenceDialog:
|
|||
# звонке: эталон должен звучать так же, а не литературно.
|
||||
answer=REVEAL[mood].format(fact=fact.value) if fact else None,
|
||||
required=bool(fact and fact.id in required),
|
||||
hidden=bool(fact and fact.hidden),
|
||||
)
|
||||
)
|
||||
return ReferenceDialog(scenario_id=scenario.id, first_line=scenario.first_line, steps=steps)
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
|||
if state.exercise is Exercise.DDS and state.dds_scenarios
|
||||
else scenario.id),
|
||||
mode=state.mode,
|
||||
exercise=state.exercise,
|
||||
attempt=state.attempt,
|
||||
criteria=state.criteria,
|
||||
failed_metrics=failed_metrics,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
|
|||
"required_fields": (ErrorCode.E5, Competency.CARD),
|
||||
"dds_primary": (ErrorCode.D1, Competency.CARD),
|
||||
"dds_ack": (ErrorCode.D1, Competency.NORMS),
|
||||
"card_fill_time": (ErrorCode.E3, Competency.NORMS),
|
||||
"dds_work_time": (ErrorCode.E3, Competency.NORMS),
|
||||
"dds_decision": (ErrorCode.D3, Competency.ROUTING),
|
||||
"dds_crew": (ErrorCode.D2, Competency.ROUTING),
|
||||
"dds_contact": (ErrorCode.D6, Competency.COMMUNICATION),
|
||||
|
|
@ -30,6 +32,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
|
|||
"dds_completion": (ErrorCode.D6, Competency.CARD),
|
||||
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
|
||||
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
|
||||
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
|
||||
}
|
||||
|
||||
#: Вес метрики в детерминированной оценке.
|
||||
|
|
@ -53,9 +56,7 @@ METRIC_WEIGHTS: dict[str, float] = {
|
|||
"answer_time": 1.0,
|
||||
"callback": 1.0,
|
||||
"dds_chain": 2.0,
|
||||
"description_grammar": 1.0,
|
||||
"card_fill_time": 1.5,
|
||||
"dds_work_time": 1.5,
|
||||
}
|
||||
|
||||
#: Вес детерминированного слоя в итоговой оценке. Остальное — LLM-судья
|
||||
#: на мягкие критерии (E4), и не больше (docs/arch/BACKEND.md).
|
||||
DETERMINISTIC_WEIGHT = 0.6
|
||||
JUDGE_WEIGHT = 0.4
|
||||
|
|
|
|||
13
backend/app/scoring/timing.py
Normal file
13
backend/app/scoring/timing.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""Временная составляющая оценки по занятой методике.
|
||||
|
||||
Скорость даёт непрерывный вклад, а превышение норматива дополнительно
|
||||
отмечается как E3. Линейный множитель 1 − t/(2T) ограничен диапазоном 0–1:
|
||||
нулевое время даёт полный вклад, два норматива и более — нулевой.
|
||||
"""
|
||||
|
||||
|
||||
def time_credit(elapsed_ms: int | None, limit_ms: int) -> float:
|
||||
"""Доля веса временной метрики, 0–1; отсутствие доказанного времени — 0."""
|
||||
if elapsed_ms is None or elapsed_ms < 0 or limit_ms <= 0:
|
||||
return 0.0
|
||||
return round(max(0.0, min(1.0, 1.0 - elapsed_ms / (2 * limit_ms))), 4)
|
||||
|
|
@ -94,6 +94,7 @@ def dump_state(state: SessionState) -> dict:
|
|||
"level": state.level,
|
||||
"mode": state.mode.value,
|
||||
"owner_login": state.owner_login,
|
||||
"backend_fencing_epoch": state.backend_fencing_epoch,
|
||||
"exercise": state.exercise.value,
|
||||
"handoff_to_dds": state.handoff_to_dds,
|
||||
"required_fields": state.required_fields,
|
||||
|
|
@ -135,6 +136,9 @@ def dump_state(state: SessionState) -> dict:
|
|||
state.phone_pending.model_dump(mode="json") if state.phone_pending else None
|
||||
),
|
||||
"dds_scenarios": [item.model_dump(mode="json") for item in state.dds_scenarios],
|
||||
"pending_dds_scenarios": [item.model_dump(mode="json") for item in state.pending_dds_scenarios],
|
||||
"operator_kio": state.operator_kio.model_dump(mode="json") if state.operator_kio else None,
|
||||
"operator_scenario": state.operator_scenario.model_dump(mode="json") if state.operator_scenario else None,
|
||||
"dds_live_cards": [_dump_live_card(item, now) for item in state.dds_live_cards],
|
||||
"dds_active_card_id": (
|
||||
str(state.dds_active_card_id) if state.dds_active_card_id else None
|
||||
|
|
@ -163,6 +167,7 @@ def dump_state(state: SessionState) -> dict:
|
|||
"reply_log": [[at.isoformat(), text] for at, text in state.reply_log],
|
||||
"resolved_outcome": state.resolved_outcome,
|
||||
"resolve_comment": state.resolve_comment,
|
||||
"processed_station_commands": state.processed_station_commands[-512:],
|
||||
}
|
||||
# В actions/score могут быть datetime/UUID из расчёта; JSONB должен
|
||||
# получать только стандартные JSON-типы.
|
||||
|
|
@ -250,6 +255,7 @@ def load_state(payload: dict, saved_at: datetime) -> SessionState:
|
|||
level=payload["level"],
|
||||
mode=SessionMode(payload["mode"]),
|
||||
owner_login=payload.get("owner_login"),
|
||||
backend_fencing_epoch=int(payload.get("backend_fencing_epoch", 0)),
|
||||
exercise=Exercise(payload["exercise"]),
|
||||
handoff_to_dds=bool(payload.get("handoff_to_dds")),
|
||||
required_fields=list(payload.get("required_fields") or []),
|
||||
|
|
@ -296,6 +302,12 @@ def load_state(payload: dict, saved_at: datetime) -> SessionState:
|
|||
),
|
||||
dds_scenarios=[Scenario.model_validate(item)
|
||||
for item in payload.get("dds_scenarios", [])],
|
||||
pending_dds_scenarios=[Scenario.model_validate(item)
|
||||
for item in payload.get("pending_dds_scenarios", [])],
|
||||
operator_kio=(KIO.model_validate(payload["operator_kio"])
|
||||
if payload.get("operator_kio") else None),
|
||||
operator_scenario=(Scenario.model_validate(payload["operator_scenario"])
|
||||
if payload.get("operator_scenario") else None),
|
||||
dds_live_cards=[_restore_live_card(item, saved_at)
|
||||
for item in payload.get("dds_live_cards", [])],
|
||||
dds_active_card_id=(
|
||||
|
|
@ -329,6 +341,7 @@ def load_state(payload: dict, saved_at: datetime) -> SessionState:
|
|||
for at, text in payload.get("reply_log", [])],
|
||||
resolved_outcome=payload.get("resolved_outcome"),
|
||||
resolve_comment=payload.get("resolve_comment", ""),
|
||||
processed_station_commands=list(payload.get("processed_station_commands") or [])[-512:],
|
||||
)
|
||||
if state.dds_live_cards:
|
||||
# Legacy snapshots had no explicit active ID; newer snapshots may
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from app.domain import ekp
|
||||
from app.domain.kio import KIO, ResponseStatus
|
||||
|
|
@ -18,13 +19,27 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
|
|||
state.level = scenario.level.value
|
||||
state.required_fields = list(scenario.required_fields)
|
||||
truth = scenario.ground_truth
|
||||
address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "")
|
||||
caller_fact = next((fact.value for fact in scenario.facts if fact.id in {"f_caller", "f_applicant"}), "")
|
||||
caller_phone = next((
|
||||
match.group(0).strip()
|
||||
for match in re.finditer(r"(?<!\d)(?:\+?7|8)?(?:[\s().-]*\d){10,11}(?!\d)", caller_fact)
|
||||
if len(re.sub(r"\D", "", match.group(0))) in {10, 11}
|
||||
), None)
|
||||
address_fact = next(
|
||||
(fact.value for fact in scenario.facts if "address" in fact.id), ""
|
||||
)
|
||||
caller_fact = next(
|
||||
(
|
||||
fact.value
|
||||
for fact in scenario.facts
|
||||
if fact.id in {"f_caller", "f_applicant"}
|
||||
),
|
||||
"",
|
||||
)
|
||||
caller_phone = next(
|
||||
(
|
||||
match.group(0).strip()
|
||||
for match in re.finditer(
|
||||
r"(?<!\d)(?:\+?7|8)?(?:[\s().-]*\d){10,11}(?!\d)", caller_fact
|
||||
)
|
||||
if len(re.sub(r"\D", "", match.group(0))) in {10, 11}
|
||||
),
|
||||
None,
|
||||
)
|
||||
caller_names = re.findall(
|
||||
r"(?<![А-ЯЁа-яё])([А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ][а-яё-]+){1,2})(?![А-ЯЁа-яё])",
|
||||
caller_fact,
|
||||
|
|
@ -34,20 +49,48 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
|
|||
caller_name = caller_names[0] if len(caller_names) == 1 else None
|
||||
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
|
||||
service = truth.dds.value if truth.dds else None
|
||||
fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"}
|
||||
state.kio = KIO(
|
||||
registered_at=now_utc(), caller_number=caller_phone, caller_name=caller_name,
|
||||
caller_contact=caller_phone,
|
||||
address=truth.address or address_fact or None,
|
||||
floor=floor.group(1) if floor else None,
|
||||
incident_type=truth.incident_type, incident_code=truth.incident_code,
|
||||
incident_group=(ekp.incident(truth.incident_code).group
|
||||
if truth.incident_code and ekp.incident(truth.incident_code) else None),
|
||||
dds=truth.dds, signs=list(scenario.signs),
|
||||
notify=list(truth.notify) or ([fallback[service]] if service in fallback else []),
|
||||
victims_count=truth.victims,
|
||||
description="; ".join(fact.value for fact in scenario.facts[:3]) or scenario.first_line,
|
||||
)
|
||||
fallback = {
|
||||
"01": "Служба 101",
|
||||
"02": "МВД",
|
||||
"03": "Скорая помощь",
|
||||
"04": "Аварийная служба",
|
||||
}
|
||||
if scenario.student_card is not None:
|
||||
# A moderated learner-authored KIO is the card itself; do not flatten
|
||||
# it to title/description and silently discard its structured fields.
|
||||
state.kio = scenario.student_card.model_copy(
|
||||
deep=True,
|
||||
update={
|
||||
"card_id": uuid4(),
|
||||
"registered_at": now_utc(),
|
||||
"response_status": ResponseStatus.REGISTERED,
|
||||
"dispatch_order_at": None,
|
||||
"arrival_at": None,
|
||||
},
|
||||
)
|
||||
else:
|
||||
state.kio = KIO(
|
||||
registered_at=now_utc(),
|
||||
caller_number=caller_phone,
|
||||
caller_name=caller_name,
|
||||
caller_contact=caller_phone,
|
||||
address=truth.address or address_fact or None,
|
||||
floor=floor.group(1) if floor else None,
|
||||
incident_type=truth.incident_type,
|
||||
incident_code=truth.incident_code,
|
||||
incident_group=(
|
||||
ekp.incident(truth.incident_code).group
|
||||
if truth.incident_code and ekp.incident(truth.incident_code)
|
||||
else None
|
||||
),
|
||||
dds=truth.dds,
|
||||
signs=list(scenario.signs),
|
||||
notify=list(truth.notify)
|
||||
or ([fallback[service]] if service in fallback else []),
|
||||
victims_count=truth.victims,
|
||||
description="; ".join(fact.value for fact in scenario.facts[:3])
|
||||
or scenario.first_line,
|
||||
)
|
||||
state.dispatched_card = None
|
||||
if service:
|
||||
state.dispatch(service)
|
||||
|
|
@ -76,7 +119,9 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
|
|||
state.on_event("dds.dispatch")
|
||||
|
||||
|
||||
def _append_live_card(state: SessionState, scenario: Scenario, index: int) -> DdsLiveCard:
|
||||
def _append_live_card(
|
||||
state: SessionState, scenario: Scenario, index: int
|
||||
) -> DdsLiveCard:
|
||||
state.dds_card_index = index
|
||||
prepare_card(state, scenario)
|
||||
card = DdsLiveCard(
|
||||
|
|
@ -114,9 +159,14 @@ def deliver_due_cards(state: SessionState, now: datetime | None = None) -> int:
|
|||
|
||||
delivered = 0
|
||||
while state.dds_next_scenario_index < len(state.dds_scenarios):
|
||||
active_id = state.dds_active_card_id if any(
|
||||
item.card_id == state.dds_active_card_id for item in state.dds_live_cards
|
||||
) else None
|
||||
active_id = (
|
||||
state.dds_active_card_id
|
||||
if any(
|
||||
item.card_id == state.dds_active_card_id
|
||||
for item in state.dds_live_cards
|
||||
)
|
||||
else None
|
||||
)
|
||||
active_exists = active_id is not None
|
||||
waiting_count = len(state.dds_live_cards) - int(active_exists)
|
||||
if waiting_count >= state.dds_max_waiting:
|
||||
|
|
@ -163,3 +213,45 @@ def prepare_queue(
|
|||
state.dds_next_scenario_index = 0
|
||||
state.dds_next_arrival_at = now_utc()
|
||||
deliver_due_cards(state)
|
||||
|
||||
|
||||
def prepare_handoff_queue(
|
||||
state: SessionState,
|
||||
additional_scenarios: list[Scenario],
|
||||
arrival_interval_seconds: int = 0,
|
||||
max_waiting: int = 3,
|
||||
) -> None:
|
||||
"""Передать созданную курсантом карточку в ДДС перед готовыми карточками."""
|
||||
if state.dispatched_card is None or state.scenario is None:
|
||||
return
|
||||
now = now_utc()
|
||||
state.operator_kio = state.dispatched_card.model_copy(deep=True)
|
||||
state.operator_scenario = state.scenario.model_copy(deep=True)
|
||||
# Карточка курсанта становится первым живым объектом очереди. Её данные
|
||||
# не переписываются из эталона: именно их будет обрабатывать ДДС.
|
||||
dds_timers = SessionTimers(limits=dict(state.timers.limits))
|
||||
dds_timers.on_event("dds.dispatch")
|
||||
first = DdsLiveCard(
|
||||
original_index=0,
|
||||
scenario=state.operator_scenario,
|
||||
kio=state.operator_kio.model_copy(deep=True),
|
||||
dispatched_card=state.operator_kio.model_copy(deep=True),
|
||||
dispatched_at=state.dispatched_at or now,
|
||||
timers=dds_timers,
|
||||
)
|
||||
state.dds_live_cards = [first]
|
||||
state.dds_scenarios = [state.operator_scenario, *additional_scenarios]
|
||||
state.dds_arrival_interval_seconds = arrival_interval_seconds
|
||||
state.dds_max_waiting = max_waiting
|
||||
state.dds_next_scenario_index = 1
|
||||
state.dds_next_arrival_at = (
|
||||
(
|
||||
now + timedelta(seconds=arrival_interval_seconds)
|
||||
if additional_scenarios and arrival_interval_seconds
|
||||
else now
|
||||
)
|
||||
if additional_scenarios
|
||||
else None
|
||||
)
|
||||
state.activate_dds_card(first.card_id, capture=False)
|
||||
deliver_due_cards(state)
|
||||
|
|
|
|||
|
|
@ -9,16 +9,22 @@
|
|||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.events import Exercise, ScoreReady
|
||||
from app.domain.events import ErrorKind, ErrorEvent, Exercise, Metric, ScoreReady
|
||||
from app.domain.statuses import ServiceStatus, current
|
||||
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios import store
|
||||
from app.scoring.card import evaluate_card
|
||||
from app.scoring.competency import radar
|
||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||
from app.scoring.gost import GostResult, evaluate
|
||||
from app.scoring.grammar import assess
|
||||
from app.scoring.report import build as build_report
|
||||
from app.scoring.taxonomy import METRIC_WEIGHTS
|
||||
from app.scoring.timing import time_credit
|
||||
from app.scoring.weights import apply_weights
|
||||
from app.session.hub import hub
|
||||
from app.session.state import DdsCardRecord, now_utc
|
||||
|
|
@ -30,14 +36,67 @@ def score_current_dds(state) -> DdsCardRecord:
|
|||
"""Оценить активную карточку отдельно, до выдачи следующей."""
|
||||
number = state.dds_card_index + 1
|
||||
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
|
||||
dds_decision = state.scenario.dds_decision
|
||||
findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=state.managed_services(),
|
||||
crew_assignments=state.crew_assignments,
|
||||
deadline_ms=decision_limit_ms,
|
||||
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
||||
reply_text=state.reply_text,
|
||||
expected_decision=dds_decision.expected,
|
||||
expected_decision_reason=dds_decision.reason,
|
||||
)
|
||||
metrics = dispatcher_metrics(
|
||||
state, decision_limit_ms, dds_decision.expected, dds_decision.reason
|
||||
)
|
||||
metrics = dispatcher_metrics(state, decision_limit_ms)
|
||||
weighted = GostResult(metrics=metrics, findings=findings)
|
||||
work_limit_ms = state.timers.limits[TimerCode.DDS_WORK]
|
||||
work_timer = state.timers.timers.get(TimerCode.DDS_WORK)
|
||||
work_elapsed_ms = state.timers.measured_ms(TimerCode.DDS_WORK)
|
||||
if work_elapsed_ms is None and work_timer is not None and work_timer.started_at is not None:
|
||||
work_elapsed_ms = work_timer.current_ms(time.monotonic())
|
||||
terminal = bool(state.managed_services()) and all(
|
||||
current(state.status_log, service) in {
|
||||
ServiceStatus.COMPLETED,
|
||||
ServiceStatus.DECLINED,
|
||||
ServiceStatus.REFUSED,
|
||||
}
|
||||
for service in state.managed_services()
|
||||
)
|
||||
work_passed = terminal and work_elapsed_ms is not None and work_elapsed_ms <= work_limit_ms
|
||||
work_delta_ms = (work_elapsed_ms - work_limit_ms) if work_elapsed_ms is not None else None
|
||||
if work_elapsed_ms is None:
|
||||
work_fact = "время обработки не зафиксировано"
|
||||
elif work_delta_ms and work_delta_ms > 0:
|
||||
work_fact = f"{round(work_elapsed_ms / 1000)} с (+{round(work_delta_ms / 1000)} с сверх норматива)"
|
||||
elif work_delta_ms and work_delta_ms < 0:
|
||||
work_fact = (f"{round(work_elapsed_ms / 1000)} с (на "
|
||||
f"{round(abs(work_delta_ms) / 1000)} с быстрее норматива)")
|
||||
else:
|
||||
work_fact = f"{round(work_elapsed_ms / 1000)} с (точно в норматив)"
|
||||
if not terminal:
|
||||
work_fact = f"карточка не завершена; {work_fact}"
|
||||
weighted.metrics.append(Metric(
|
||||
key="dds_work_time",
|
||||
title="Отработка карточки ДДС",
|
||||
fact=work_fact,
|
||||
norm=f"завершить за {round(work_limit_ms / 1000)} с",
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
passed=work_passed,
|
||||
weight=METRIC_WEIGHTS["dds_work_time"],
|
||||
credit=time_credit(work_elapsed_ms, work_limit_ms) if terminal else 0.0,
|
||||
))
|
||||
if not work_passed:
|
||||
weighted.findings.append(Finding(
|
||||
code=ErrorCode.E3,
|
||||
source=FindingSource.TIMERS,
|
||||
summary="ДДС: норматив времени отработки карточки не выполнен",
|
||||
fact=work_fact,
|
||||
norm=f"завершить за {round(work_limit_ms / 1000)} с",
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
competency=Competency.NORMS,
|
||||
))
|
||||
apply_weights(weighted, {**state.scenario.score_weights, **state.criteria.score_weights})
|
||||
actions = [
|
||||
{"type": "card.status", "service": mark.service, "status": mark.status.value,
|
||||
|
|
@ -66,6 +125,15 @@ def score_current_dds(state) -> DdsCardRecord:
|
|||
actions=actions,
|
||||
duration_ms=(max(0, int((now_utc() - state.dispatched_at).total_seconds() * 1000))
|
||||
if state.dispatched_at else 0),
|
||||
title=state.scenario.title,
|
||||
address=state.dispatched_card.address,
|
||||
description=state.dispatched_card.description,
|
||||
incident_type=(state.dispatched_card.incident_type.value
|
||||
if state.dispatched_card.incident_type else None),
|
||||
victims_count=state.dispatched_card.victims_count,
|
||||
received_at=state.dispatched_at,
|
||||
managed_service=(state.managed_services()[0] if state.managed_services() else None),
|
||||
recipient_services=list(state.dispatched_card.notify),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -74,12 +142,52 @@ async def finish(session_id: UUID, state) -> None:
|
|||
path = await asyncio.to_thread(state.recorder.finalize)
|
||||
state.recording_path = str(path) if path else None
|
||||
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
|
||||
scenario = state.scenario or store.get(state.scenario_id)
|
||||
scenario = (state.operator_scenario if state.handoff_to_dds and state.operator_scenario
|
||||
else state.scenario or store.get(state.scenario_id))
|
||||
if scenario is None:
|
||||
return
|
||||
|
||||
cards: list[DdsCardRecord] = []
|
||||
if state.exercise is Exercise.CARD:
|
||||
result = evaluate_card(scenario, state.dispatched_card or state.kio)
|
||||
result = evaluate_card(
|
||||
scenario, state.operator_kio or state.dispatched_card or state.kio
|
||||
)
|
||||
limit_ms = state.timers.limits[TimerCode.CARD_FILL]
|
||||
elapsed_ms = state.timers.measured_ms(TimerCode.CARD_FILL)
|
||||
submitted = state.dispatched_card is not None
|
||||
if elapsed_ms is None:
|
||||
elapsed_fact = "время не зафиксировано"
|
||||
else:
|
||||
delta_ms = elapsed_ms - limit_ms
|
||||
elapsed_seconds = round(elapsed_ms / 1000)
|
||||
if delta_ms > 0:
|
||||
deviation = f"+{round(delta_ms / 1000)} с сверх норматива"
|
||||
elif delta_ms < 0:
|
||||
deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива"
|
||||
else:
|
||||
deviation = "точно в норматив"
|
||||
elapsed_fact = f"{elapsed_seconds} с ({deviation})"
|
||||
passed = submitted and elapsed_ms is not None and elapsed_ms <= limit_ms
|
||||
result.metrics.append(Metric(
|
||||
key="card_fill_time",
|
||||
title="Время заполнения карточки",
|
||||
fact=elapsed_fact if submitted else f"карточка не сдана; {elapsed_fact}",
|
||||
norm=f"сдать карточку за {round(limit_ms / 1000)} с",
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
passed=passed,
|
||||
weight=METRIC_WEIGHTS["card_fill_time"],
|
||||
credit=time_credit(elapsed_ms, limit_ms) if submitted else 0.0,
|
||||
))
|
||||
if not passed:
|
||||
result.findings.append(Finding(
|
||||
code=ErrorCode.E3,
|
||||
source=FindingSource.TIMERS,
|
||||
summary="Время заполнения карточки: норматив не выполнен",
|
||||
fact=elapsed_fact if submitted else f"карточка не сдана; {elapsed_fact}",
|
||||
norm=f"сдать карточку за {round(limit_ms / 1000)} с",
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
competency=Competency.NORMS,
|
||||
))
|
||||
elif state.exercise is Exercise.DDS:
|
||||
# Все карточки выданы одновременно: при досрочном завершении оцениваем
|
||||
# каждую, включая не открытую, потому что её норматив уже шёл.
|
||||
|
|
@ -107,27 +215,74 @@ async def finish(session_id: UUID, state) -> None:
|
|||
resolved_outcome=state.resolved_outcome,
|
||||
dispatched=state.dispatched_card is not None,
|
||||
)
|
||||
# Грамматика относится к свободному описанию оператора 112, а не к
|
||||
# заполнению карточки на стороне ДДС. Учитывается только по явной настройке
|
||||
# преподавателя и только когда есть текст для проверки.
|
||||
operator_kio = state.operator_kio if state.handoff_to_dds else None
|
||||
description = (operator_kio or state.kio).description or ""
|
||||
if (state.exercise is not Exercise.DDS and state.criteria.require_correct_grammar
|
||||
and description.strip()):
|
||||
grammar = await assess(description)
|
||||
result.metrics.append(Metric(
|
||||
key="description_grammar",
|
||||
title="Грамматика описания происшествия",
|
||||
fact="ошибок не обнаружено" if grammar.passed else "; ".join(grammar.errors),
|
||||
norm="грамматически корректное описание",
|
||||
ref="критерий занятия; правила русского языка",
|
||||
passed=grammar.passed,
|
||||
weight=1.0,
|
||||
))
|
||||
if not grammar.passed:
|
||||
result.findings.append(Finding(
|
||||
code=ErrorCode.E4,
|
||||
source=FindingSource.GRAMMAR,
|
||||
summary="Грамматическая ошибка в описании происшествия",
|
||||
fact="; ".join(grammar.errors),
|
||||
norm="грамматически корректное описание",
|
||||
ref="критерий занятия; правила русского языка",
|
||||
competency=Competency.COMMUNICATION,
|
||||
))
|
||||
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
||||
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
||||
# участвуют оба (docs/spec/DATASET.md#статусы-реагирования).
|
||||
if state.dispatched_card is not None and (
|
||||
state.exercise is Exercise.CALL or state.handoff_to_dds
|
||||
):
|
||||
if state.dispatched_card is not None and state.exercise is Exercise.CALL:
|
||||
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
|
||||
dispatcher_findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=state.managed_services(),
|
||||
crew_assignments=state.crew_assignments,
|
||||
deadline_ms=decision_limit_ms,
|
||||
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
||||
reply_text=state.reply_text,
|
||||
expected_decision=scenario.dds_decision.expected,
|
||||
expected_decision_reason=scenario.dds_decision.reason,
|
||||
)
|
||||
result.findings.extend(dispatcher_findings)
|
||||
result.metrics.extend(dispatcher_metrics(state, decision_limit_ms))
|
||||
result.metrics.extend(dispatcher_metrics(
|
||||
state, decision_limit_ms, scenario.dds_decision.expected,
|
||||
scenario.dds_decision.reason,
|
||||
))
|
||||
# DDS cards were weighted individually in score_current_dds using each
|
||||
# card's scenario defaults plus the lesson override. Reapplying the first
|
||||
# scenario's weights here would corrupt the other ticket cards.
|
||||
if state.exercise is not Exercise.DDS:
|
||||
apply_weights(result, {**scenario.score_weights, **state.criteria.score_weights})
|
||||
|
||||
if state.handoff_to_dds and state.dds_scenarios:
|
||||
# В связке КИО оценивается относительно эталона и весов упражнения
|
||||
# 112, а каждая карточка очереди уже взвешена собственным сценарием.
|
||||
cards = list(state.dds_completed)
|
||||
state.capture_active_dds()
|
||||
for live in sorted(state.dds_live_cards, key=lambda item: item.original_index):
|
||||
if any(item.card_id == live.card_id for item in cards):
|
||||
continue
|
||||
state.activate_dds_card(live.card_id, capture=False)
|
||||
cards.append(score_current_dds(state))
|
||||
state.dds_completed = cards
|
||||
for card in cards:
|
||||
result.metrics.extend(card.metrics)
|
||||
result.findings.extend(card.findings)
|
||||
|
||||
# Сводка числами: по ней считается дельта между попытками в профиле.
|
||||
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
|
||||
required = scenario.ground_truth.required_facts
|
||||
|
|
@ -140,6 +295,7 @@ async def finish(session_id: UUID, state) -> None:
|
|||
"score_auto": result.score,
|
||||
"summary": {
|
||||
"interview_ms": state.timers.measured_ms(TimerCode.INTERVIEW),
|
||||
"card_fill_ms": state.timers.measured_ms(TimerCode.CARD_FILL),
|
||||
"facts_got": len([fact for fact in required if fact in revealed]),
|
||||
"facts_required": len(required),
|
||||
"hints": len(state.hints_shown),
|
||||
|
|
@ -155,18 +311,28 @@ async def finish(session_id: UUID, state) -> None:
|
|||
{"card_id": str(card.card_id), "scenario_id": card.scenario_id,
|
||||
"score_auto": card.score_auto, "reply_text": card.reply_text,
|
||||
"actions": card.actions, "duration_ms": card.duration_ms,
|
||||
"title": card.title, "address": card.address,
|
||||
"description": card.description, "incident_type": card.incident_type,
|
||||
"victims_count": card.victims_count,
|
||||
"received_at": card.received_at.isoformat() if card.received_at else None,
|
||||
"managed_service": card.managed_service,
|
||||
"recipient_services": card.recipient_services,
|
||||
"metrics": [metric.model_dump(mode="json") for metric in card.metrics],
|
||||
"findings": [finding.model_dump(mode="json") for finding in card.findings]}
|
||||
for card in cards
|
||||
] if state.exercise is Exercise.DDS else [],
|
||||
] if state.exercise is Exercise.DDS or state.handoff_to_dds else [],
|
||||
}
|
||||
# Полный разбор хранится вместе с оценкой: PDF/CSV и история должны
|
||||
# переживать перезапуск backend, а не зависеть от объекта в hub._sessions.
|
||||
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
||||
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))
|
||||
|
||||
if hub.journal:
|
||||
await hub.journal.score(session_id, result.score, state.score)
|
||||
if hub.journal and not await hub.journal.score(session_id, result.score, state.score):
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL,
|
||||
message="Не удалось сохранить оценку и аудит; итог не выдан. Обратитесь к преподавателю.",
|
||||
))
|
||||
return
|
||||
|
||||
hub.to_observers(session_id, ScoreReady(session_id=session_id))
|
||||
await release_score(session_id, state)
|
||||
|
|
|
|||
|
|
@ -7,13 +7,23 @@
|
|||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from contextvars import ContextVar, Token
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.domain.events import CardReceived, Exercise, StationState, TimerTick
|
||||
from app.domain.events import (
|
||||
CardReceived,
|
||||
ErrorEvent,
|
||||
ErrorKind,
|
||||
Exercise,
|
||||
StationState,
|
||||
TimerTick,
|
||||
)
|
||||
from app.session.state import SessionState
|
||||
|
||||
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
|
||||
|
|
@ -21,6 +31,15 @@ from app.session.state import SessionState
|
|||
QUEUE_SIZE = 256
|
||||
|
||||
TICK_SECONDS = 1.0
|
||||
LEASE_FENCED_MESSAGE = "Занятие передано другому backend-узлу; переподключитесь."
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _current_task():
|
||||
try:
|
||||
return asyncio.current_task()
|
||||
except RuntimeError: # synchronous tests and tooling have no running loop
|
||||
return None
|
||||
|
||||
|
||||
class Journal(Protocol):
|
||||
|
|
@ -29,21 +48,26 @@ class Journal(Protocol):
|
|||
|
||||
async def start_lesson(
|
||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
|
||||
trainee_id: UUID | None = None,
|
||||
) -> tuple[int, UUID | None, str | None]: ...
|
||||
trainee_id: UUID | None = None, owner_login: str | None = None,
|
||||
backend_node_id: str | None = None,
|
||||
) -> tuple[int, UUID | None, str | None, int] | None: ...
|
||||
async def utterance(self, session_id: UUID, entry) -> None: ...
|
||||
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
|
||||
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...
|
||||
async def self_assessment(self, session_id: UUID, missed: list[str], comment: str, at) -> None: ...
|
||||
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None: ...
|
||||
async def self_assessment(
|
||||
self, session_id: UUID, missed: list[str], comment: str, at
|
||||
) -> bool: ...
|
||||
async def score(self, session_id: UUID, score_auto: float, report: dict) -> bool: ...
|
||||
async def score_snapshot(self, session_id: UUID, report: dict) -> None: ...
|
||||
async def score_override(
|
||||
self, session_id: UUID, score_final: float, author: str, comment: str,
|
||||
) -> None: ...
|
||||
) -> bool: ...
|
||||
async def session_started(self, session_id: UUID, at) -> None: ...
|
||||
async def session_ended(self, session_id: UUID, at, reason: str) -> None: ...
|
||||
async def checkpoint(self, state: SessionState) -> None: ...
|
||||
async def restore_active(self) -> list[SessionState]: ...
|
||||
async def renew(self, session_id: UUID) -> None: ...
|
||||
async def claim_expired(self, session_id: UUID | None = None) -> list[SessionState]: ...
|
||||
|
||||
|
||||
class SessionHub:
|
||||
|
|
@ -54,6 +78,9 @@ class SessionHub:
|
|||
self._trainees: dict[UUID, set[asyncio.Queue]] = {}
|
||||
self._stations: dict[UUID, set[asyncio.Queue]] = {}
|
||||
self._tickers: dict[UUID, asyncio.Task] = {}
|
||||
self._event_batch: ContextVar[dict | None] = ContextVar(
|
||||
f"session-event-batch-{id(self)}", default=None
|
||||
)
|
||||
|
||||
# ── реестр ──
|
||||
|
||||
|
|
@ -62,12 +89,47 @@ class SessionHub:
|
|||
return state
|
||||
|
||||
def get(self, session_id: UUID) -> SessionState | None:
|
||||
return self._sessions.get(session_id)
|
||||
state = self._sessions.get(session_id)
|
||||
return None if state is not None and state.lease_fenced else state
|
||||
|
||||
def is_lease_fenced(self, session_id: UUID) -> bool:
|
||||
state = self._sessions.get(session_id)
|
||||
return state is not None and state.lease_fenced
|
||||
|
||||
def active_sessions(self, owner_login: str) -> list[SessionState]:
|
||||
"""Живые занятия только преподавателя-владельца для группового обзора."""
|
||||
return [
|
||||
state for state in self._sessions.values()
|
||||
if not state.ended and not state.lease_fenced and state.owner_login == owner_login
|
||||
]
|
||||
|
||||
def history(
|
||||
self, *, owner_login: str | None = None, trainee_id: UUID | None = None,
|
||||
mode: str | None = None, since: datetime | None = None, limit: int = 100,
|
||||
) -> list[SessionState]:
|
||||
"""Volatile session history for the explicit no-database demo mode."""
|
||||
if since is not None and since.tzinfo is None:
|
||||
since = since.replace(tzinfo=UTC)
|
||||
states = [
|
||||
state for state in self._sessions.values()
|
||||
if not state.lease_fenced
|
||||
and (owner_login is None or state.owner_login == owner_login)
|
||||
and (trainee_id is None or state.trainee_id == trainee_id)
|
||||
and (mode is None or state.mode.value == mode)
|
||||
and (since is None or (state.started_at is not None and state.started_at >= since))
|
||||
]
|
||||
# Hub insertion order is creation order; completed lessons sort by
|
||||
# their finish time, while unanswered calls retain their start time.
|
||||
states.sort(
|
||||
key=lambda state: state.ended_at or state.started_at or datetime.min.replace(tzinfo=UTC),
|
||||
reverse=True,
|
||||
)
|
||||
return states[:max(0, limit)]
|
||||
|
||||
def has_active_scenario(self, scenario_id: str) -> bool:
|
||||
"""Архивирование контента не должно менять уже идущее занятие."""
|
||||
return any(
|
||||
not state.ended and (
|
||||
not state.ended and not state.lease_fenced and (
|
||||
state.scenario_id == scenario_id
|
||||
or any(item.id == scenario_id for item in state.dds_scenarios)
|
||||
)
|
||||
|
|
@ -80,9 +142,100 @@ class SessionHub:
|
|||
|
||||
async def checkpoint(self, session_id: UUID) -> None:
|
||||
"""Зафиксировать подтверждённое состояние, если журнал доступен."""
|
||||
state = self.get(session_id)
|
||||
state = self._sessions.get(session_id)
|
||||
if state is not None and state.lease_fenced:
|
||||
raise RuntimeError(LEASE_FENCED_MESSAGE)
|
||||
if state is not None and self.journal is not None:
|
||||
await self.journal.checkpoint(state)
|
||||
try:
|
||||
await self.journal.checkpoint(state)
|
||||
except Exception:
|
||||
self._discard_event_batch(session_id)
|
||||
await self.fence(state)
|
||||
raise
|
||||
self._flush_event_batch(session_id)
|
||||
|
||||
def begin_event_stream(self, session_id: UUID) -> Token:
|
||||
"""Stage controller output until each explicit checkpoint in its loop."""
|
||||
return self._event_batch.set({
|
||||
"session_id": session_id, "events": [], "committed": False,
|
||||
"persistent": True, "owner_task": _current_task(),
|
||||
})
|
||||
|
||||
async def end_event_stream(self, token: Token) -> None:
|
||||
batch = self._event_batch.get()
|
||||
try:
|
||||
if batch is not None and batch["events"]:
|
||||
session_id = batch["session_id"]
|
||||
batch["events"].clear()
|
||||
state = self._sessions.get(session_id)
|
||||
if self.journal is not None and state is not None and not state.lease_fenced:
|
||||
await self.fence(state)
|
||||
finally:
|
||||
self._event_batch.reset(token)
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def durable_transition(self, session_id: UUID):
|
||||
"""Do not publish state-changing events until its checkpoint commits."""
|
||||
batch = {
|
||||
"session_id": session_id, "events": [], "committed": False,
|
||||
"persistent": False, "owner_task": _current_task(),
|
||||
}
|
||||
token: Token = self._event_batch.set(batch)
|
||||
try:
|
||||
yield
|
||||
if not batch["committed"]:
|
||||
await self.checkpoint(session_id)
|
||||
else:
|
||||
self._flush_event_batch(session_id)
|
||||
except Exception:
|
||||
self._discard_event_batch(session_id)
|
||||
state = self._sessions.get(session_id)
|
||||
if self.journal is not None and state is not None and not state.lease_fenced:
|
||||
await self.fence(state)
|
||||
raise
|
||||
finally:
|
||||
self._event_batch.reset(token)
|
||||
|
||||
def _discard_event_batch(self, session_id: UUID) -> None:
|
||||
batch = self._event_batch.get()
|
||||
if batch is not None and batch["session_id"] == session_id:
|
||||
batch["events"].clear()
|
||||
|
||||
def _flush_event_batch(self, session_id: UUID) -> None:
|
||||
batch = self._event_batch.get()
|
||||
if batch is None or batch["session_id"] != session_id:
|
||||
return
|
||||
pending, batch["events"] = batch["events"], []
|
||||
batch["committed"] = not batch.get("persistent", False)
|
||||
for registry, target_session_id, event in pending:
|
||||
self._put(registry.get(target_session_id, set()), event)
|
||||
|
||||
def _send(self, registry: dict[UUID, set[asyncio.Queue]], session_id: UUID,
|
||||
event: BaseModel) -> None:
|
||||
batch = self._event_batch.get()
|
||||
if isinstance(event, ErrorEvent):
|
||||
self._put(registry.get(session_id, set()), event)
|
||||
elif (batch is not None and batch["session_id"] == session_id
|
||||
and batch["owner_task"] is _current_task()):
|
||||
batch["events"].append((registry, session_id, event))
|
||||
else:
|
||||
self._put(registry.get(session_id, set()), event)
|
||||
|
||||
async def fence(self, state: SessionState) -> None:
|
||||
"""Fail closed when durable ownership is lost or cannot be confirmed."""
|
||||
if state.lease_fenced:
|
||||
return
|
||||
state.lease_fenced = True
|
||||
self.stop_ticker(state.session_id)
|
||||
if state.voice is not None:
|
||||
try:
|
||||
await state.voice.close()
|
||||
except Exception as exc: # noqa: BLE001 — fencing must still close data channels
|
||||
log.error("не удалось закрыть голос при fencing занятия %s (%s)",
|
||||
state.session_id, type(exc).__name__)
|
||||
event = ErrorEvent(code=ErrorKind.INTERNAL, message=LEASE_FENCED_MESSAGE)
|
||||
self.broadcast(state.session_id, event)
|
||||
self.to_station(state.session_id, event)
|
||||
|
||||
# ── подписки ──
|
||||
|
||||
|
|
@ -115,13 +268,16 @@ class SessionHub:
|
|||
queues.discard(queue)
|
||||
|
||||
def to_observers(self, session_id: UUID, event: BaseModel) -> None:
|
||||
self._put(self._observers.get(session_id, set()), event)
|
||||
self._send(self._observers, session_id, event)
|
||||
|
||||
def to_trainee(self, session_id: UUID, event: BaseModel | bytes) -> None:
|
||||
self._put(self._trainees.get(session_id, set()), event)
|
||||
if isinstance(event, BaseModel):
|
||||
self._send(self._trainees, session_id, event)
|
||||
else:
|
||||
self._put(self._trainees.get(session_id, set()), event)
|
||||
|
||||
def to_station(self, session_id: UUID, event: BaseModel) -> None:
|
||||
self._put(self._stations.get(session_id, set()), event)
|
||||
self._send(self._stations, session_id, event)
|
||||
|
||||
def broadcast(self, session_id: UUID, event: BaseModel) -> None:
|
||||
self.to_trainee(session_id, event)
|
||||
|
|
@ -160,7 +316,7 @@ class SessionHub:
|
|||
state = self.get(session_id)
|
||||
if state is None or state.ended:
|
||||
return
|
||||
if state.exercise is Exercise.DDS:
|
||||
if state.exercise is Exercise.DDS or (state.handoff_to_dds and state.dds_scenarios):
|
||||
from app.session.dds import deliver_due_cards
|
||||
|
||||
active_before = state.dds_active_card_id
|
||||
|
|
|
|||
|
|
@ -5,45 +5,99 @@
|
|||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.db import repo
|
||||
from app.db.models import Score, SelfAssessment, Session, User, Utterance
|
||||
from app.db.models import AuditLog, Score, SelfAssessment, Session, User, Utterance
|
||||
from app.domain.events import Mood, Speaker, TranscriptEntry
|
||||
from app.session.checkpoint import dump_state, load_state
|
||||
from app.session.state import SessionState, now_utc
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
LEASE_SECONDS = 15
|
||||
|
||||
|
||||
class SessionLeaseLost(RuntimeError):
|
||||
"""This process no longer owns the durable session generation."""
|
||||
|
||||
|
||||
class DbJournal:
|
||||
def __init__(self, sessionmaker: async_sessionmaker) -> None:
|
||||
def __init__(self, sessionmaker: async_sessionmaker, node_id: str | None = None) -> None:
|
||||
self._sessionmaker = sessionmaker
|
||||
self._node_id = node_id
|
||||
self._epochs: dict[UUID, int] = {}
|
||||
|
||||
async def _write(self, action, *args, **kwargs) -> None:
|
||||
async def _fence(self, db, session_id: UUID, expected_epoch: int | None = None) -> None:
|
||||
"""Renew and fence this write in the same transaction as its mutation."""
|
||||
if self._node_id is None:
|
||||
return
|
||||
epoch = expected_epoch if expected_epoch is not None else self._epochs.get(session_id)
|
||||
if epoch is None:
|
||||
raise SessionLeaseLost(f"session {session_id} has no local fencing epoch")
|
||||
now = now_utc()
|
||||
result = await db.execute(
|
||||
update(Session)
|
||||
.where(
|
||||
Session.id == session_id,
|
||||
Session.backend_node_id == self._node_id,
|
||||
Session.backend_fencing_epoch == epoch,
|
||||
)
|
||||
.values(backend_lease_until=now + timedelta(seconds=LEASE_SECONDS))
|
||||
.returning(Session.id)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise SessionLeaseLost(f"session {session_id} owner epoch {epoch} was fenced")
|
||||
|
||||
async def _write(
|
||||
self, action, *args, _fence_session_id: UUID | None = None,
|
||||
_fence_epoch: int | None = None, _raise_errors: bool = False, **kwargs
|
||||
) -> None:
|
||||
"""Ошибка записи не роняет занятие, но и не проглатывается молча:
|
||||
занятие идёт дальше, в логе остаётся след."""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
if _fence_session_id is not None:
|
||||
await self._fence(db, _fence_session_id, _fence_epoch)
|
||||
await action(db, *args, **kwargs)
|
||||
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||
log.exception("журнал: запись не удалась")
|
||||
except SessionLeaseLost:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||
session_id = _fence_session_id or kwargs.get("session_id")
|
||||
log.error("журнал: запись не удалась для сессии %s (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
if _raise_errors:
|
||||
raise
|
||||
|
||||
async def start_lesson(
|
||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
|
||||
trainee_id: UUID | None = None, owner_login: str | None = None,
|
||||
) -> tuple[int, UUID | None, str | None]:
|
||||
backend_node_id: str | None = None,
|
||||
) -> tuple[int, UUID | None, str | None, int] | None:
|
||||
"""Завести сессию в журнале и вернуть номер попытки и ID курсанта.
|
||||
|
||||
Если база недоступна, занятие всё равно идёт: номер попытки
|
||||
деградирует до первого, и это видно в логе.
|
||||
Строка сессии и событие аудита фиксируются вместе. При сбое транзакции
|
||||
занятие не запускается без долговечной истории.
|
||||
"""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
node_id = backend_node_id or self._node_id
|
||||
|
||||
def audit_start(transaction, row):
|
||||
if row.backend_fencing_epoch <= 0:
|
||||
row.backend_fencing_epoch = 1
|
||||
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
|
||||
transaction.add(AuditLog(
|
||||
actor=owner_login or "system",
|
||||
role="instructor" if owner_login else "system",
|
||||
action="lesson.start",
|
||||
object_id=str(row.id),
|
||||
detail=f"{scenario_id}, mode {mode}",
|
||||
))
|
||||
|
||||
row = await repo.ensure_session(
|
||||
db,
|
||||
session_id=session_id,
|
||||
|
|
@ -52,7 +106,11 @@ class DbJournal:
|
|||
trainee_name=trainee_name,
|
||||
trainee_id=trainee_id,
|
||||
owner_login=owner_login,
|
||||
backend_node_id=node_id,
|
||||
before_commit=audit_start,
|
||||
)
|
||||
epoch = getattr(row, "backend_fencing_epoch", 0) or 1
|
||||
self._epochs[session_id] = epoch
|
||||
service = None
|
||||
if row.trainee_id is not None:
|
||||
service = await db.scalar(
|
||||
|
|
@ -60,12 +118,13 @@ class DbJournal:
|
|||
.where(User.trainee_id == row.trainee_id, User.blocked.is_(False))
|
||||
.limit(1)
|
||||
)
|
||||
return row.attempt, row.trainee_id, service
|
||||
return row.attempt, row.trainee_id, service, epoch
|
||||
except PermissionError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||
log.exception("журнал: сессию завести не удалось")
|
||||
return 1, trainee_id, None
|
||||
except Exception as exc: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||
log.error("журнал: не удалось завести сессию %s (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
return None
|
||||
|
||||
async def checkpoint(self, state: SessionState) -> None:
|
||||
"""Сохранить снимок после подтверждённого действия пользователя."""
|
||||
|
|
@ -78,25 +137,102 @@ class DbJournal:
|
|||
await db.execute(update(Session).where(Session.id == state.session_id).values(**values))
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
if state.backend_fencing_epoch > 0:
|
||||
self._epochs.setdefault(state.session_id, state.backend_fencing_epoch)
|
||||
await self._write(
|
||||
lambda db: action(db), _fence_session_id=state.session_id,
|
||||
_fence_epoch=state.backend_fencing_epoch or None,
|
||||
_raise_errors=True,
|
||||
)
|
||||
|
||||
async def restore_active(self) -> list[SessionState]:
|
||||
async def renew(self, session_id: UUID) -> None:
|
||||
"""Refresh an owned session lease; concurrent takeover is row-serialized."""
|
||||
async with self._sessionmaker() as db:
|
||||
await self._fence(db, session_id)
|
||||
await db.commit()
|
||||
|
||||
async def claim_expired(self, session_id: UUID | None = None) -> list[SessionState]:
|
||||
"""Atomically fence and restore expired owners on this backend node."""
|
||||
if self._node_id is None:
|
||||
return []
|
||||
now = now_utc()
|
||||
conditions = [
|
||||
Session.ended_at.is_(None),
|
||||
Session.live_state.is_not(None),
|
||||
Session.checkpoint_at.is_not(None),
|
||||
(Session.backend_node_id.is_(None) | (Session.backend_node_id != self._node_id)),
|
||||
(Session.backend_lease_until.is_(None) | (Session.backend_lease_until <= now)),
|
||||
]
|
||||
if session_id is not None:
|
||||
conditions.append(Session.id == session_id)
|
||||
async with self._sessionmaker() as db:
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(*conditions).with_for_update(skip_locked=True).limit(100)
|
||||
)).all()
|
||||
for row in rows:
|
||||
row.backend_node_id = self._node_id
|
||||
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
|
||||
row.backend_lease_until = now + timedelta(seconds=LEASE_SECONDS)
|
||||
if rows:
|
||||
await db.commit()
|
||||
if not rows:
|
||||
return []
|
||||
return await self.restore_active(bump_owned_epoch=False)
|
||||
|
||||
async def restore_active(self, *, bump_owned_epoch: bool = True) -> list[SessionState]:
|
||||
"""Восстановить только незавершённые сессии с валидным снимком."""
|
||||
restored: list[SessionState] = []
|
||||
async with self._sessionmaker() as db:
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(
|
||||
Session.ended_at.is_(None),
|
||||
Session.live_state.is_not(None),
|
||||
Session.checkpoint_at.is_not(None),
|
||||
)
|
||||
)).all()
|
||||
active_with_snapshot = (
|
||||
Session.ended_at.is_(None),
|
||||
Session.live_state.is_not(None),
|
||||
Session.checkpoint_at.is_not(None),
|
||||
)
|
||||
if self._node_id is not None:
|
||||
# Adopt legacy unassigned snapshots exactly once. Concurrent
|
||||
# nodes lock disjoint rows; subsequent restores are owner-only.
|
||||
unassigned = (await db.scalars(
|
||||
select(Session)
|
||||
.where(*active_with_snapshot, Session.backend_node_id.is_(None))
|
||||
.with_for_update(skip_locked=True)
|
||||
)).all()
|
||||
for row in unassigned:
|
||||
row.backend_node_id = self._node_id
|
||||
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
|
||||
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
|
||||
if unassigned:
|
||||
await db.commit()
|
||||
# A restarted process with the same stable node ID is a new
|
||||
# owner generation. Bump before exposing any restored state.
|
||||
owned = (await db.scalars(
|
||||
select(Session)
|
||||
.where(*active_with_snapshot, Session.backend_node_id == self._node_id)
|
||||
.with_for_update(skip_locked=True)
|
||||
)).all()
|
||||
if bump_owned_epoch:
|
||||
for row in owned:
|
||||
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
|
||||
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
|
||||
if owned:
|
||||
await db.commit()
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(
|
||||
*active_with_snapshot,
|
||||
Session.backend_node_id == self._node_id,
|
||||
)
|
||||
)).all()
|
||||
else:
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(*active_with_snapshot)
|
||||
)).all()
|
||||
for row in rows:
|
||||
try:
|
||||
state = load_state(row.live_state, row.checkpoint_at)
|
||||
if state.session_id != row.id:
|
||||
raise ValueError("ID снимка не совпадает с записью занятия")
|
||||
state.owner_login = row.owner_login
|
||||
state.backend_fencing_epoch = row.backend_fencing_epoch
|
||||
self._epochs[row.id] = row.backend_fencing_epoch
|
||||
# Реплики пишутся отдельно сразу после появления. Если
|
||||
# процесс умер между репликой и общим снимком, отдельный
|
||||
# журнал не даёт потерять последний фрагмент диалога.
|
||||
|
|
@ -117,8 +253,9 @@ class DbJournal:
|
|||
for item in utterances
|
||||
]
|
||||
restored.append(state)
|
||||
except Exception: # noqa: BLE001 — один снимок не блокирует весь стенд
|
||||
log.exception("журнал: снимок занятия %s повреждён", row.id)
|
||||
except Exception as exc: # noqa: BLE001 — один снимок не блокирует весь стенд
|
||||
log.error("журнал: снимок занятия %s повреждён (%s)",
|
||||
row.id, type(exc).__name__)
|
||||
return restored
|
||||
|
||||
async def utterance(self, session_id: UUID, entry) -> None:
|
||||
|
|
@ -130,53 +267,127 @@ class DbJournal:
|
|||
text=entry.text,
|
||||
at=entry.at,
|
||||
mood=entry.mood.value if entry.mood else None,
|
||||
_fence_session_id=session_id,
|
||||
)
|
||||
|
||||
async def hint(self, session_id: UUID, checklist_id: str, question: str, at: datetime) -> None:
|
||||
await self._write(
|
||||
repo.record_hint, session_id=session_id, checklist_id=checklist_id, question=question, at=at
|
||||
repo.record_hint, _fence_session_id=session_id, session_id=session_id,
|
||||
checklist_id=checklist_id, question=question, at=at
|
||||
)
|
||||
|
||||
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None:
|
||||
await self._write(repo.add_note, session_id=session_id, transcript_ref=ref, text=text, author=author)
|
||||
await self._write(
|
||||
repo.add_note, _fence_session_id=session_id, session_id=session_id,
|
||||
transcript_ref=ref, text=text, author=author
|
||||
)
|
||||
|
||||
async def self_assessment(
|
||||
self, session_id: UUID, missed: list[str], comment: str, at: datetime
|
||||
) -> None:
|
||||
async def action(db):
|
||||
db.add(
|
||||
SelfAssessment(
|
||||
) -> bool:
|
||||
"""Persist trainee reflection and its security audit together."""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
session = await db.get(Session, session_id)
|
||||
if session is None:
|
||||
return False
|
||||
actor = "system"
|
||||
role = "system"
|
||||
if session.trainee_id is not None:
|
||||
login = await db.scalar(
|
||||
select(User.login).where(User.trainee_id == session.trainee_id)
|
||||
)
|
||||
if login:
|
||||
actor, role = login, "trainee"
|
||||
else:
|
||||
actor, role = f"trainee:{session.trainee_id}", "trainee"
|
||||
db.add(SelfAssessment(
|
||||
session_id=session_id, missed=missed, comment=comment, submitted_at=at
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
))
|
||||
db.add(AuditLog(
|
||||
actor=actor,
|
||||
role=role,
|
||||
action="self_assessment.submit",
|
||||
object_id=str(session_id),
|
||||
detail=f"missed_count={len(missed)}; comment_chars={len(comment)}",
|
||||
))
|
||||
await self._fence(db, session_id)
|
||||
await db.commit()
|
||||
return True
|
||||
except SessionLeaseLost:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — do not accept an unaudited reflection
|
||||
log.error("самооценка и аудит сессии %s не сохранены (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
return False
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
|
||||
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None:
|
||||
async def action(db):
|
||||
db.add(Score(session_id=session_id, score_auto=score_auto, score_final=score_auto, report=report))
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
async def score(self, session_id: UUID, score_auto: float, report: dict) -> bool:
|
||||
"""Persist the initial result and its audit event atomically."""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
await self._fence(db, session_id)
|
||||
db.add(Score(
|
||||
session_id=session_id, score_auto=score_auto,
|
||||
score_final=score_auto, report=report,
|
||||
))
|
||||
db.add(AuditLog(
|
||||
actor="system", role="system", action="score.calculate",
|
||||
object_id=str(session_id), detail=f"score_auto={score_auto}",
|
||||
))
|
||||
await db.commit()
|
||||
return True
|
||||
except SessionLeaseLost:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — result is not complete until durable
|
||||
log.error("итоговая оценка и аудит сессии %s не сохранены (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
return False
|
||||
|
||||
async def score_override(
|
||||
self, session_id: UUID, score_final: float, author: str, comment: str
|
||||
) -> None:
|
||||
"""Сохранить решение преподавателя рядом с неизменной автооценкой."""
|
||||
async def action(db):
|
||||
await db.execute(
|
||||
update(Score)
|
||||
.where(Score.session_id == session_id)
|
||||
.values(
|
||||
score_final=score_final,
|
||||
overridden_by=author,
|
||||
override_comment=comment,
|
||||
) -> bool:
|
||||
"""Persist a live correction and its security audit as one transaction."""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
await self._fence(db, session_id)
|
||||
score = await db.scalar(
|
||||
select(Score)
|
||||
.where(Score.session_id == session_id)
|
||||
.with_for_update()
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
if score is None:
|
||||
return False
|
||||
score.score_final = score_final
|
||||
score.overridden_by = author
|
||||
score.override_comment = comment
|
||||
report = dict(score.report or {})
|
||||
archived = report.get("full_report")
|
||||
if isinstance(archived, dict):
|
||||
archived = dict(archived)
|
||||
archived.update({
|
||||
"score_auto": score.score_auto,
|
||||
"score_final": score_final,
|
||||
"overridden_by": author,
|
||||
"override_comment": comment,
|
||||
})
|
||||
report["full_report"] = archived
|
||||
score.report = report
|
||||
db.add(AuditLog(
|
||||
actor=author,
|
||||
role="instructor",
|
||||
action="score.override",
|
||||
object_id=str(session_id),
|
||||
detail=(f"{score.score_auto} → {score_final}; "
|
||||
f"comment_chars={len(comment)}"),
|
||||
))
|
||||
await db.commit()
|
||||
return True
|
||||
except SessionLeaseLost:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — do not confirm a correction without its audit
|
||||
log.error("корректировка оценки и аудит сессии %s не сохранены (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
return False
|
||||
|
||||
async def score_snapshot(self, session_id: UUID, report: dict) -> None:
|
||||
"""Обновить полный архивный разбор после самооценки курсанта."""
|
||||
|
|
@ -186,14 +397,14 @@ class DbJournal:
|
|||
)
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
await self._write(lambda db: action(db), _fence_session_id=session_id)
|
||||
|
||||
async def session_started(self, session_id: UUID, at: datetime) -> None:
|
||||
async def action(db):
|
||||
await db.execute(update(Session).where(Session.id == session_id).values(started_at=at))
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
await self._write(lambda db: action(db), _fence_session_id=session_id)
|
||||
|
||||
async def session_ended(self, session_id: UUID, at: datetime, reason: str) -> None:
|
||||
async def action(db):
|
||||
|
|
@ -209,4 +420,4 @@ class DbJournal:
|
|||
)
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
await self._write(lambda db: action(db), _fence_session_id=session_id)
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ from app.domain.events import (
|
|||
)
|
||||
from app.domain.kio import KIO, ResponseStatus, apply_patch
|
||||
from app.domain.statuses import (
|
||||
CardStatus,
|
||||
NEXT,
|
||||
CardStatus,
|
||||
DdsCardSummary,
|
||||
DdsQueueCard,
|
||||
PhoneCallPending,
|
||||
|
|
@ -62,11 +62,20 @@ class DdsCardRecord:
|
|||
findings: list[Finding]
|
||||
actions: list[dict[str, Any]]
|
||||
duration_ms: int
|
||||
title: str | None = None
|
||||
address: str | None = None
|
||||
description: str | None = None
|
||||
incident_type: str | None = None
|
||||
victims_count: int | None = None
|
||||
received_at: datetime | None = None
|
||||
managed_service: str | None = None
|
||||
recipient_services: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def score_auto(self) -> float:
|
||||
total = sum(metric.weight for metric in self.metrics)
|
||||
passed = sum(metric.weight for metric in self.metrics if metric.passed)
|
||||
passed = sum(metric.weight * (metric.credit if metric.credit is not None
|
||||
else float(metric.passed)) for metric in self.metrics)
|
||||
return round(100 * passed / total, 1) if total else 0.0
|
||||
|
||||
|
||||
|
|
@ -106,6 +115,10 @@ class SessionState:
|
|||
mode: SessionMode
|
||||
#: Преподаватель, создавший занятие; чужой пульт не может им управлять.
|
||||
owner_login: str | None = None
|
||||
#: Monotonic DB ownership generation; stale processes may not persist writes.
|
||||
backend_fencing_epoch: int = 0
|
||||
#: Runtime-only: set when this process loses or cannot confirm DB ownership.
|
||||
lease_fenced: bool = False
|
||||
exercise: Exercise = Exercise.CALL
|
||||
#: После заполнения КИО занятие продолжится на АРМ ДДС, а не завершится.
|
||||
handoff_to_dds: bool = False
|
||||
|
|
@ -169,6 +182,11 @@ class SessionState:
|
|||
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
|
||||
phone_pending: PhoneCallPending | None = None
|
||||
dds_scenarios: list[Scenario] = field(default_factory=list)
|
||||
pending_dds_scenarios: list[Scenario] = field(default_factory=list)
|
||||
#: Исходная часть упражнения 112→ДДС сохраняется отдельно от активной
|
||||
#: карточки ДДС, которая может переключаться по очереди.
|
||||
operator_kio: KIO | None = None
|
||||
operator_scenario: Scenario | None = None
|
||||
dds_live_cards: list[DdsLiveCard] = field(default_factory=list)
|
||||
dds_active_card_id: UUID | None = None
|
||||
dds_card_index: int = 0
|
||||
|
|
@ -183,6 +201,10 @@ class SessionState:
|
|||
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
|
||||
resolved_outcome: str | None = None
|
||||
resolve_comment: str = ""
|
||||
#: Recently committed DDS command IDs; included in the durable checkpoint so
|
||||
#: a lost WebSocket acknowledgement cannot apply an operation twice.
|
||||
processed_station_commands: list[str] = field(default_factory=list)
|
||||
text_revealed_facts: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def on_event(self, event_type: str) -> None:
|
||||
"""Единственная точка, где событие двигает таймеры."""
|
||||
|
|
@ -391,6 +413,11 @@ class SessionState:
|
|||
log=list(self.status_log),
|
||||
crew_options=(self.crew_options() if has_active_dds_card or not self.dds_scenarios else []),
|
||||
crew_selected=self.crew_selected if has_active_dds_card else None,
|
||||
zone_decision=(
|
||||
next((detail == "в зоне" for action, _at, detail in reversed(self.dds_log)
|
||||
if action == "zone.decision"), None)
|
||||
if has_active_dds_card else None
|
||||
),
|
||||
phone_reports=list(self.phone_reports) if has_active_dds_card else [],
|
||||
phone_lines=list(self.phone_lines) if has_active_dds_card else [],
|
||||
phone_pending=self.phone_pending if has_active_dds_card else None,
|
||||
|
|
@ -422,6 +449,7 @@ class SessionState:
|
|||
return CardReceived(
|
||||
card=self.dispatched_card,
|
||||
from_operator=("учебный сценарий" if self.exercise is Exercise.DDS
|
||||
or (self.handoff_to_dds and self.dds_card_index > 0)
|
||||
else self.trainee_name or "оператор 112"),
|
||||
at=self.dispatched_at or now_utc(),
|
||||
card_index=self.dds_card_index + 1,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ STARTS: dict[str, tuple[TimerCode, ...]] = {
|
|||
"call.incoming": (TimerCode.ANSWER,),
|
||||
"call.answer": (TimerCode.INTERVIEW,),
|
||||
"dds.dispatch": (TimerCode.DDS_ACK, TimerCode.CLOSE),
|
||||
"dds.open": (TimerCode.DDS_WORK,),
|
||||
"card.start": (TimerCode.CARD_FILL,),
|
||||
"card.received": (TimerCode.ZONE_CHECK,),
|
||||
"call.dropped": (TimerCode.CALLBACK,),
|
||||
"callback.dial": (TimerCode.CALLBACK,),
|
||||
|
|
@ -38,6 +40,10 @@ STOPS: dict[str, tuple[TimerCode, ...]] = {
|
|||
# карточки: норматив опроса не должен тикать после решения (lct-36).
|
||||
"call.resolve": (TimerCode.INTERVIEW,),
|
||||
"card.ack": (TimerCode.DDS_ACK,),
|
||||
"card.submit": (TimerCode.CARD_FILL,),
|
||||
"card.end": (TimerCode.CARD_FILL,),
|
||||
"dds.complete": (TimerCode.DDS_WORK,),
|
||||
"dds.finish": (TimerCode.DDS_WORK,),
|
||||
"zone.decision": (TimerCode.ZONE_CHECK,),
|
||||
"crew.arrived": (TimerCode.CLOSE,),
|
||||
"call.started": (TimerCode.CALLBACK,),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""Локальная WAV-запись обеих сторон учебного голосового вызова."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import wave
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -12,6 +14,10 @@ import numpy as np
|
|||
from app.config import get_settings
|
||||
|
||||
TARGET_RATE = 16_000
|
||||
JOURNAL_MAGIC = b"LCTREC01"
|
||||
JOURNAL_RECORD = struct.Struct("<QI") # sample offset, mono sample count
|
||||
MAX_JOURNAL_RECORD_SAMPLES = TARGET_RATE * 60
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def recording_path(session_id: UUID) -> Path:
|
||||
|
|
@ -35,9 +41,78 @@ class CallRecorder:
|
|||
def __init__(self, path: Path, *, clock=time.monotonic) -> None:
|
||||
self.path = path
|
||||
self._clock = clock
|
||||
self._started = clock()
|
||||
self._segments: list[_Segment] = []
|
||||
self._finalized = False
|
||||
self._journal_path = path.with_suffix(path.suffix + ".journal")
|
||||
self._journal = None
|
||||
self._journal_failed = False
|
||||
self._started = clock()
|
||||
self._last_sync = self._started
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._open_journal()
|
||||
|
||||
def _open_journal(self) -> None:
|
||||
"""Open or recover the append-only audio journal after process restart."""
|
||||
if self._journal_path.exists():
|
||||
valid_end = len(JOURNAL_MAGIC)
|
||||
with self._journal_path.open("r+b") as source:
|
||||
if source.read(len(JOURNAL_MAGIC)) != JOURNAL_MAGIC:
|
||||
raise ValueError("invalid call recording journal")
|
||||
while True:
|
||||
record = source.read(JOURNAL_RECORD.size)
|
||||
if not record:
|
||||
break
|
||||
if len(record) != JOURNAL_RECORD.size:
|
||||
break
|
||||
offset, count = JOURNAL_RECORD.unpack(record)
|
||||
if not count or count > MAX_JOURNAL_RECORD_SAMPLES:
|
||||
raise ValueError("invalid call recording journal record")
|
||||
payload = source.read(count * 2)
|
||||
if len(payload) != count * 2:
|
||||
break
|
||||
samples = np.frombuffer(payload, dtype="<i2").astype(np.int32)
|
||||
self._segments.append(_Segment(int(offset), samples))
|
||||
valid_end = source.tell()
|
||||
source.truncate(valid_end)
|
||||
os.fsync(source.fileno())
|
||||
else:
|
||||
self._journal_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(self._journal_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
try:
|
||||
os.write(fd, JOURNAL_MAGIC)
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
end = max((item.offset + item.samples.size for item in self._segments), default=0)
|
||||
if end:
|
||||
# Monotonic clocks do not survive a host reboot. Continue directly
|
||||
# after the last committed sample rather than using stale timestamps.
|
||||
self._started -= end / TARGET_RATE
|
||||
self._journal = self._journal_path.open("ab", buffering=0)
|
||||
os.chmod(self._journal_path, 0o600)
|
||||
|
||||
def _write_journal_record(self, offset: int, samples: np.ndarray, now: float) -> None:
|
||||
if self._journal is None or self._journal_failed:
|
||||
return
|
||||
payload = JOURNAL_RECORD.pack(offset, int(samples.size)) + samples.astype("<i2").tobytes()
|
||||
try:
|
||||
view = memoryview(payload)
|
||||
while view:
|
||||
written = self._journal.write(view)
|
||||
if not written:
|
||||
raise OSError("short audio journal write")
|
||||
view = view[written:]
|
||||
if now - self._last_sync >= 1.0:
|
||||
os.fsync(self._journal.fileno())
|
||||
self._last_sync = now
|
||||
except OSError:
|
||||
# Keep the live call working; finalize can still save the in-memory
|
||||
# audio. The warning is explicit because crash recovery is degraded.
|
||||
self._journal_failed = True
|
||||
log.error("журнал WAV недоступен (%s)", self._journal_path.name)
|
||||
self._journal.close()
|
||||
self._journal = None
|
||||
|
||||
def add_pcm(self, pcm: bytes, *, sample_rate: int) -> None:
|
||||
if self._finalized or not pcm or sample_rate <= 0 or len(pcm) % 2:
|
||||
|
|
@ -49,7 +124,9 @@ class CallRecorder:
|
|||
length = max(1, round(source.size * TARGET_RATE / sample_rate))
|
||||
points = np.linspace(0, source.size - 1, length)
|
||||
source = np.rint(np.interp(points, np.arange(source.size), source)).astype(np.int32)
|
||||
offset = max(0, round((self._clock() - self._started) * TARGET_RATE))
|
||||
now = self._clock()
|
||||
offset = max(0, round((now - self._started) * TARGET_RATE))
|
||||
self._write_journal_record(offset, source, now)
|
||||
self._segments.append(_Segment(offset=offset, samples=source))
|
||||
|
||||
def finalize(self) -> Path | None:
|
||||
|
|
@ -57,6 +134,7 @@ class CallRecorder:
|
|||
return self.path if self.path.is_file() else None
|
||||
self._finalized = True
|
||||
if not self._segments:
|
||||
self._close_journal(remove=True)
|
||||
return None
|
||||
total = max(item.offset + item.samples.size for item in self._segments)
|
||||
mixed = np.zeros(total, dtype=np.int32)
|
||||
|
|
@ -71,11 +149,33 @@ class CallRecorder:
|
|||
target.setsampwidth(2)
|
||||
target.setframerate(TARGET_RATE)
|
||||
target.writeframes(pcm)
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, self.path)
|
||||
self._close_journal(remove=True)
|
||||
return self.path
|
||||
|
||||
def _close_journal(self, *, remove: bool) -> None:
|
||||
if self._journal is not None:
|
||||
try:
|
||||
os.fsync(self._journal.fileno())
|
||||
except OSError as exc:
|
||||
log.warning("не удалось синхронизировать журнал WAV (%s)", type(exc).__name__)
|
||||
finally:
|
||||
self._journal.close()
|
||||
self._journal = None
|
||||
if remove:
|
||||
try:
|
||||
self._journal_path.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
log.warning("не удалось удалить журнал WAV (%s)", type(exc).__name__)
|
||||
|
||||
|
||||
def start_recording(session_id: UUID) -> CallRecorder | None:
|
||||
if not get_settings().record_calls:
|
||||
return None
|
||||
return CallRecorder(recording_path(session_id))
|
||||
try:
|
||||
return CallRecorder(recording_path(session_id))
|
||||
except (OSError, ValueError) as exc:
|
||||
# Recording failure must not drop an otherwise recoverable call.
|
||||
log.error("сессия %s: запись звонка недоступна (%s)", session_id, type(exc).__name__)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
"""Флаг обхода читается один раз при создании настроек."""
|
||||
|
|
|
|||
34
backend/tests/test_address_matching.py
Normal file
34
backend/tests/test_address_matching.py
Normal 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", "улица Мира")
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
47
backend/tests/test_call_privacy.py
Normal file
47
backend/tests/test_call_privacy.py
Normal 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
|
||||
|
|
@ -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
54
backend/tests/test_db_pool_config.py
Normal file
54
backend/tests/test_db_pool_config.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
397
backend/tests/test_directory.py
Normal file
397
backend/tests/test_directory.py
Normal 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
|
||||
282
backend/tests/test_dispatcher_scoring.py
Normal file
282
backend/tests/test_dispatcher_scoring.py
Normal 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]
|
||||
|
|
@ -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("Алло")
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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": {}})
|
||||
|
|
|
|||
|
|
@ -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} с — кэш не сработал"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
55
backend/tests/test_production_security_config.py
Normal file
55
backend/tests/test_production_security_config.py
Normal 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
|
||||
|
|
@ -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), "")
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
]
|
||||
|
|
|
|||
285
backend/tests/test_route_auth_contract.py
Normal file
285
backend/tests/test_route_auth_contract.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
448
backend/tests/test_scenario_submissions.py
Normal file
448
backend/tests/test_scenario_submissions.py
Normal 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
|
||||
|
|
@ -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: 'опечатка'")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
59
backend/tests/test_sip_recording_cleanup.py
Normal file
59
backend/tests/test_sip_recording_cleanup.py
Normal 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)
|
||||
|
|
@ -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()]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
25
backend/tests/test_timing_score.py
Normal file
25
backend/tests/test_timing_score.py
Normal 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
|
||||
|
|
@ -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
Loading…
Reference in a new issue