From 7237265833f18e3a6cf5e11ba157f7cfd4c959c1 Mon Sep 17 00:00:00 2001 From: andreysk0304 Date: Sat, 26 Sep 2026 18:12:27 +0300 Subject: [PATCH] Complete training workflow and acceptance hardening --- .env.example | 27 + .github/workflows/windows-backend.yml | 149 +++ .gitignore | 1 + Makefile | 29 +- backend/.env.test.example | 7 + backend/app/api/auth.py | 373 +++++- backend/app/api/http/admin.py | 106 +- backend/app/api/http/groups.py | 25 +- backend/app/api/http/materials.py | 34 +- backend/app/api/http/scenario_submissions.py | 402 +++++++ backend/app/api/http/scenarios.py | 263 ++++- backend/app/api/http/sessions.py | 333 +++++- backend/app/api/http/trainees.py | 41 +- backend/app/api/ws/call.py | 215 +++- backend/app/api/ws/control.py | 239 +++- backend/app/api/ws/observe.py | 24 +- backend/app/api/ws/station.py | 125 +- backend/app/config.py | 60 + backend/app/db/base.py | 10 +- ...b5c2d9e1f4_student_scenario_submissions.py | 55 + .../b9c6d3e2f1a0_scenario_submission_kio.py | 26 + .../c2d7e9f4a1b6_directory_accounts.py | 42 + .../d3a9f6b2c8e1_session_backend_owner.py | 31 + .../e4b7c1d2a9f0_manual_grammar_review.py | 35 + .../f5a7d2c9b3e1_backend_session_leases.py | 35 + backend/app/db/models.py | 67 +- backend/app/db/repo.py | 80 +- backend/app/dialog/caller.py | 10 +- backend/app/dialog/llm.py | 21 +- backend/app/dialog/prompts/caller.md | 26 +- backend/app/dialog/slots.py | 14 +- backend/app/directory.py | 245 ++++ backend/app/domain/events.py | 63 +- backend/app/domain/kio.py | 23 +- backend/app/domain/roles.py | 2 +- backend/app/domain/statuses.py | 24 +- backend/app/domain/taxonomy.py | 14 +- backend/app/domain/timers.py | 14 + backend/app/main.py | 100 +- backend/app/scenarios/generation.py | 2 +- backend/app/scenarios/loader.py | 22 +- backend/app/scenarios/schema.py | 89 +- backend/app/scenarios/store.py | 104 +- backend/app/scoring/address.py | 80 ++ backend/app/scoring/ai_coach.py | 83 ++ backend/app/scoring/card.py | 6 +- backend/app/scoring/competency.py | 4 +- backend/app/scoring/dispatcher.py | 252 +++- backend/app/scoring/export.py | 10 +- backend/app/scoring/gost.py | 26 +- backend/app/scoring/reference.py | 1 - backend/app/scoring/report.py | 1 + backend/app/scoring/taxonomy.py | 11 +- backend/app/scoring/timing.py | 13 + backend/app/session/checkpoint.py | 13 + backend/app/session/dds.py | 142 ++- backend/app/session/finish.py | 188 ++- backend/app/session/hub.py | 184 ++- backend/app/session/journal.py | 327 +++++- backend/app/session/state.py | 32 +- backend/app/session/timers.py | 6 + backend/app/voice/recording.py | 106 +- backend/pyproject.toml | 7 +- backend/scripts/backup_loop.py | 4 +- backend/tests/conftest.py | 20 + backend/tests/test_address_matching.py | 34 + backend/tests/test_admin.py | 169 ++- backend/tests/test_auth.py | 194 ++- backend/tests/test_auth_hardening.py | 526 ++++++++- backend/tests/test_backup_scheduler.py | 40 + backend/tests/test_call_privacy.py | 47 + backend/tests/test_card_exercise.py | 205 +++- backend/tests/test_db.py | 1041 ++++++++++++++++- backend/tests/test_db_pool_config.py | 54 + backend/tests/test_dds_exercise.py | 756 ++++++++++-- backend/tests/test_demo_no_db.py | 8 +- backend/tests/test_directory.py | 397 +++++++ backend/tests/test_dispatcher_scoring.py | 282 +++++ backend/tests/test_domain.py | 14 +- backend/tests/test_ekp.py | 271 ++++- backend/tests/test_group_analytics.py | 48 +- backend/tests/test_llm.py | 106 +- backend/tests/test_local_models.py | 131 ++- backend/tests/test_materials.py | 18 +- .../tests/test_production_security_config.py | 55 + backend/tests/test_profile.py | 104 +- backend/tests/test_recording.py | 71 +- backend/tests/test_report_export.py | 61 +- backend/tests/test_route_auth_contract.py | 285 +++++ backend/tests/test_scenario_editor.py | 157 ++- backend/tests/test_scenario_submissions.py | 448 +++++++ backend/tests/test_scenarios.py | 47 +- backend/tests/test_session_access.py | 478 +++++++- backend/tests/test_session_checkpoint.py | 105 +- backend/tests/test_sip_recording_cleanup.py | 59 + backend/tests/test_slots.py | 16 +- backend/tests/test_station.py | 31 +- backend/tests/test_statuses.py | 31 +- backend/tests/test_timing_score.py | 25 + backend/tests/test_ws.py | 167 ++- backend/tests/test_ws_ownership.py | 134 +++ backend/uv.lock | 23 + docker-compose.cluster.yml | 81 ++ docker-compose.load-test.yml | 11 + docker-compose.partition-test.yml | 94 ++ docker-compose.production.yml | 19 + docker-compose.sip.yml | 4 +- docker-compose.test-db.yml | 15 + docker-compose.webrtc-test.yml | 14 + docker-compose.yml | 23 +- frontend/nginx.cluster.conf.template | 64 + frontend/nginx.cluster.tls.conf.template | 96 ++ frontend/nginx.conf.template | 4 + frontend/nginx.tls.conf.template | 5 + frontend/package-lock.json | 1 + frontend/package.json | 10 +- .../scripts/check-accessible-controls.mjs | 80 ++ frontend/scripts/check-kio-fields.mjs | 16 + frontend/scripts/precompress-assets.mjs | 39 + frontend/scripts/test-dds-history.mjs | 36 + frontend/scripts/test-ws-outbox.mjs | 110 ++ frontend/src/app/router.tsx | 1 + frontend/src/features/auth/Login.tsx | 23 +- frontend/src/features/auth/useAuth.ts | 2 +- frontend/src/features/call/useCall.ts | 15 +- frontend/src/features/debrief/Debrief.tsx | 104 +- frontend/src/features/debrief/Radar.tsx | 18 +- frontend/src/features/instructor/Director.tsx | 2 +- .../features/instructor/ScenarioEditor.tsx | 216 +++- .../instructor/ScoreWeightsEditor.tsx | 105 ++ .../src/features/instructor/useControl.ts | 40 +- frontend/src/features/kio-card/KioCard.tsx | 10 +- frontend/src/features/kio-card/merge.ts | 22 +- frontend/src/features/observe/useObserve.ts | 54 +- .../self-assessment/SelfAssessment.tsx | 1 + frontend/src/features/timers/Timers.tsx | 2 + frontend/src/pages/admin/Admin.tsx | 18 +- frontend/src/pages/admin/api.ts | 3 +- frontend/src/pages/dds/Dds.tsx | 394 +++++-- frontend/src/pages/dds/history.d.mts | 19 + frontend/src/pages/dds/history.mjs | 37 + frontend/src/pages/instructor/Instructor.tsx | 630 +++++++--- frontend/src/pages/materials/Materials.tsx | 2 +- frontend/src/pages/profile/Profile.tsx | 80 +- frontend/src/pages/trainee/Call.tsx | 103 +- frontend/src/pages/wall/Wall.tsx | 13 +- frontend/src/shared/api/http.ts | 122 +- frontend/src/shared/api/ws.ts | 75 +- frontend/src/shared/types/generated.ts | 78 +- frontend/src/styles.css | 470 +++++++- pytest.ini | 7 + scenarios/tickets/t01-1-fire-container.yaml | 6 +- scenarios/tickets/t01-2-mass-fight.yaml | 4 +- scenarios/tickets/t02-1-smoke-chute.yaml | 4 +- .../tickets/t02-2-megafon-consultation.yaml | 6 +- scenarios/tickets/t02-3-car-in-water.yaml | 11 +- scenarios/tickets/t03-2-loud-music.yaml | 11 +- scenarios/tickets/t04-1-balcony-fire.yaml | 4 +- scenarios/tickets/t04-3-open-door.yaml | 2 +- .../tickets/t05-1-window-fire-people.yaml | 8 +- scenarios/tickets/t05-2-wrong-medicine.yaml | 4 +- scenarios/tickets/t05-3-worker-in-pit.yaml | 29 +- scenarios/tickets/t06-1-car-fire.yaml | 2 +- scenarios/tickets/t06-2-man-in-car.yaml | 4 +- scenarios/tickets/t07-1-toyota-fire.yaml | 4 +- scenarios/tickets/t07-3-lost-elderly.yaml | 4 +- scenarios/tickets/t08-1-mall-smoke.yaml | 6 +- .../tickets/t08-2-unconscious-roadside.yaml | 4 +- scenarios/tickets/t08-3-drowning.yaml | 4 +- scenarios/tickets/t09-1-platform-smoke.yaml | 4 +- scenarios/tickets/t09-2-labour.yaml | 2 +- scenarios/tickets/t09-3-fell-from-bridge.yaml | 4 +- .../tickets/t10-1-ticket-office-fire.yaml | 4 +- scenarios/tickets/t10-3-help-behind-door.yaml | 8 +- scenarios/tickets/t11-1-bus-cabin-fire.yaml | 4 +- scenarios/tickets/t11-2-dizziness.yaml | 6 +- scenarios/tickets/t11-3-lost-in-forest.yaml | 2 +- scenarios/tickets/t12-1-restaurant-smoke.yaml | 2 +- scenarios/tickets/t13-1-park-trees-fire.yaml | 4 +- scenarios/tickets/t14-1-grass-fire-azs.yaml | 4 +- scenarios/tickets/t14-2-asthma.yaml | 4 +- scenarios/tickets/t14-3-abduction.yaml | 4 +- scenarios/tickets/t15-3-knife-wound.yaml | 4 +- scenarios/tickets/t16-3-sleeping-pills.yaml | 4 +- scenarios/tickets/t17-1-fire-alarm.yaml | 4 +- scenarios/tickets/t17-3-beaten-woman.yaml | 4 +- scenarios/tickets/t18-1-unknown-fire.yaml | 2 +- .../tickets/t18-2-husband-wont-wake.yaml | 2 +- scenarios/tickets/t18-3-cries-for-help.yaml | 4 +- scenarios/tickets/t20-1-three-fighting.yaml | 4 +- scenarios/tickets/t20-2-stroke.yaml | 2 +- scenarios/tickets/t20-3-rape.yaml | 4 +- .../tickets/t21-1-mass-fight-embassy.yaml | 4 +- scenarios/tickets/t22-1-flat-fight.yaml | 4 +- scenarios/tickets/t22-3-suicide-sms.yaml | 11 +- scenarios/tickets/t23-1-drunk-husband.yaml | 4 +- scenarios/tickets/t23-2-eardrum.yaml | 4 +- scenarios/tickets/t23-3-lost-child.yaml | 4 +- scenarios/tickets/t24-1-parking-quarrel.yaml | 4 +- scenarios/tickets/t24-2-child-slide-fall.yaml | 4 +- .../tickets/t24-3-begging-with-baby.yaml | 4 +- scenarios/tickets/t25-1-drunk-at-stop.yaml | 6 +- scenarios/tickets/t25-2-accident-vegas.yaml | 4 +- scenarios/tickets/t26-1-vandals-stadium.yaml | 4 +- .../tickets/t26-2-accident-melnitsa.yaml | 4 +- scenarios/tickets/t26-3-death-care-home.yaml | 6 +- scenarios/tickets/t27-1-suspicious-car.yaml | 4 +- scenarios/tickets/t27-2-accident-tunnel.yaml | 4 +- scenarios/tickets/t27-3-wall-crack.yaml | 4 +- scenarios/tickets/t28-1-stranger-at-door.yaml | 4 +- scenarios/tickets/t28-2-accident-mid.yaml | 4 +- scenarios/tickets/t28-3-loose-board.yaml | 4 +- scenarios/tickets/t29-1-ticking-box.yaml | 4 +- scenarios/tickets/t29-2-accident-fight.yaml | 5 +- .../tickets/t29-3-threat-to-blow-up.yaml | 4 +- .../tickets/t30-1-car-theft-yesterday.yaml | 4 +- scenarios/tickets/t30-2-pedestrian-hit.yaml | 4 +- .../tickets/t31-1-car-theft-witnessed.yaml | 4 +- scenarios/tickets/t31-2-hit-and-run.yaml | 4 +- scenarios/tickets/t31-3-gas-pipe-whistle.yaml | 4 +- scenarios/tickets/t32-1-carjacking.yaml | 4 +- .../tickets/t32-2-trolleybus-accident.yaml | 4 +- scripts/check_production_compose.sh | 27 + scripts/load_browser.py | 698 ++++++++++- scripts/load_db.py | 137 ++- scripts/sip_recording_cleanup.py | 49 + scripts/smoke_dds_history.py | 209 ++++ scripts/smoke_mobile.py | 124 +- scripts/smoke_student_dds_browser.py | 268 +++++ scripts/smoke_webrtc_browser.py | 33 +- scripts/test_cluster_failover.py | 645 ++++++++++ scripts/test_cluster_scenario_registry.py | 402 +++++++ scripts/test_isolated_db.sh | 35 + scripts/test_production_postgres.sh | 35 + scripts/test_recovery.py | 199 +++- scripts/test_sip_isolated.sh | 48 + scripts/test_webrtc_isolated.sh | 83 ++ scripts/validate_production_env.sh | 25 + sip/Dockerfile | 2 +- sip/entrypoint.sh | 16 + sip/pjsip.conf.template | 7 +- sip/rtp.conf | 5 - sip/rtp.conf.template | 5 + 243 files changed, 17025 insertions(+), 1511 deletions(-) create mode 100644 .github/workflows/windows-backend.yml create mode 100644 backend/.env.test.example create mode 100644 backend/app/api/http/scenario_submissions.py create mode 100644 backend/app/db/migrations/versions/a8b5c2d9e1f4_student_scenario_submissions.py create mode 100644 backend/app/db/migrations/versions/b9c6d3e2f1a0_scenario_submission_kio.py create mode 100644 backend/app/db/migrations/versions/c2d7e9f4a1b6_directory_accounts.py create mode 100644 backend/app/db/migrations/versions/d3a9f6b2c8e1_session_backend_owner.py create mode 100644 backend/app/db/migrations/versions/e4b7c1d2a9f0_manual_grammar_review.py create mode 100644 backend/app/db/migrations/versions/f5a7d2c9b3e1_backend_session_leases.py create mode 100644 backend/app/directory.py create mode 100644 backend/app/scoring/address.py create mode 100644 backend/app/scoring/ai_coach.py create mode 100644 backend/app/scoring/timing.py create mode 100644 backend/tests/test_address_matching.py create mode 100644 backend/tests/test_call_privacy.py create mode 100644 backend/tests/test_db_pool_config.py create mode 100644 backend/tests/test_directory.py create mode 100644 backend/tests/test_dispatcher_scoring.py create mode 100644 backend/tests/test_production_security_config.py create mode 100644 backend/tests/test_route_auth_contract.py create mode 100644 backend/tests/test_scenario_submissions.py create mode 100644 backend/tests/test_sip_recording_cleanup.py create mode 100644 backend/tests/test_timing_score.py create mode 100644 docker-compose.cluster.yml create mode 100644 docker-compose.load-test.yml create mode 100644 docker-compose.partition-test.yml create mode 100644 docker-compose.production.yml create mode 100644 docker-compose.test-db.yml create mode 100644 docker-compose.webrtc-test.yml create mode 100644 frontend/nginx.cluster.conf.template create mode 100644 frontend/nginx.cluster.tls.conf.template create mode 100644 frontend/scripts/check-accessible-controls.mjs create mode 100644 frontend/scripts/precompress-assets.mjs create mode 100644 frontend/scripts/test-dds-history.mjs create mode 100644 frontend/scripts/test-ws-outbox.mjs create mode 100644 frontend/src/features/instructor/ScoreWeightsEditor.tsx create mode 100644 frontend/src/pages/dds/history.d.mts create mode 100644 frontend/src/pages/dds/history.mjs create mode 100644 pytest.ini create mode 100644 scripts/check_production_compose.sh create mode 100644 scripts/sip_recording_cleanup.py create mode 100644 scripts/smoke_dds_history.py create mode 100644 scripts/smoke_student_dds_browser.py create mode 100644 scripts/test_cluster_failover.py create mode 100644 scripts/test_cluster_scenario_registry.py create mode 100644 scripts/test_isolated_db.sh create mode 100644 scripts/test_production_postgres.sh create mode 100644 scripts/test_sip_isolated.sh create mode 100644 scripts/test_webrtc_isolated.sh create mode 100644 scripts/validate_production_env.sh delete mode 100644 sip/rtp.conf create mode 100644 sip/rtp.conf.template diff --git a/.env.example b/.env.example index a8fb3f7..486bd1a 100644 --- a/.env.example +++ b/.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 diff --git a/.github/workflows/windows-backend.yml b/.github/workflows/windows-backend.yml new file mode 100644 index 0000000..5232833 --- /dev/null +++ b/.github/workflows/windows-backend.yml @@ -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 diff --git a/.gitignore b/.gitignore index eb821bd..4469804 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ frontend/dist/ .env .env.* !.env.example +!.env.test.example *.local .local/ diff --git a/Makefile b/Makefile index 8a9d4ef..ca17c3b 100644 --- a/Makefile +++ b/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 diff --git a/backend/.env.test.example b/backend/.env.test.example new file mode 100644 index 0000000..6a11a01 --- /dev/null +++ b/backend/.env.test.example @@ -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 diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 91240d1..95ba71c 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -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} diff --git a/backend/app/api/http/admin.py b/backend/app/api/http/admin.py index 4957799..4f3a6ad 100644 --- a/backend/app/api/http/admin.py +++ b/backend/app/api/http/admin.py @@ -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) diff --git a/backend/app/api/http/groups.py b/backend/app/api/http/groups.py index 7d0c662..856588b 100644 --- a/backend/app/api/http/groups.py +++ b/backend/app/api/http/groups.py @@ -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) diff --git a/backend/app/api/http/materials.py b/backend/app/api/http/materials.py index 41b5c51..9332963 100644 --- a/backend/app/api/http/materials.py +++ b/backend/app/api/http/materials.py @@ -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) diff --git a/backend/app/api/http/scenario_submissions.py b/backend/app/api/http/scenario_submissions.py new file mode 100644 index 0000000..f4c944d --- /dev/null +++ b/backend/app/api/http/scenario_submissions.py @@ -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() diff --git a/backend/app/api/http/scenarios.py b/backend/app/api/http/scenarios.py index 6af494f..8863148 100644 --- a/backend/app/api/http/scenarios.py +++ b/backend/app/api/http/scenarios.py @@ -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") diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py index 5c73194..fa70b0e 100644 --- a/backend/app/api/http/sessions.py +++ b/backend/app/api/http/sessions.py @@ -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, diff --git a/backend/app/api/http/trainees.py b/backend/app/api/http/trainees.py index 050cea8..91c33c3 100644 --- a/backend/app/api/http/trainees.py +++ b/backend/app/api/http/trainees.py @@ -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): diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py index 92c3adc..82471d4 100644 --- a/backend/app/api/ws/call.py +++ b/backend/app/api/ws/call.py @@ -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: diff --git a/backend/app/api/ws/control.py b/backend/app/api/ws/control.py index cffe1ce..7b73095 100644 --- a/backend/app/api/ws/control.py +++ b/backend/app/api/ws/control.py @@ -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) diff --git a/backend/app/api/ws/observe.py b/backend/app/api/ws/observe.py index 28c26a6..adac692 100644 --- a/backend/app/api/ws/observe.py +++ b/backend/app/api/ws/observe.py @@ -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()) diff --git a/backend/app/api/ws/station.py b/backend/app/api/ws/station.py index 1633bdc..3f325c1 100644 --- a/backend/app/api/ws/station.py +++ b/backend/app/api/ws/station.py @@ -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() diff --git a/backend/app/config.py b/backend/app/config.py index 8ed158b..e318cf4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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: diff --git a/backend/app/db/base.py b/backend/app/db/base.py index 39d12b5..90c95e3 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -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 diff --git a/backend/app/db/migrations/versions/a8b5c2d9e1f4_student_scenario_submissions.py b/backend/app/db/migrations/versions/a8b5c2d9e1f4_student_scenario_submissions.py new file mode 100644 index 0000000..f5360d3 --- /dev/null +++ b/backend/app/db/migrations/versions/a8b5c2d9e1f4_student_scenario_submissions.py @@ -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") diff --git a/backend/app/db/migrations/versions/b9c6d3e2f1a0_scenario_submission_kio.py b/backend/app/db/migrations/versions/b9c6d3e2f1a0_scenario_submission_kio.py new file mode 100644 index 0000000..c4b392c --- /dev/null +++ b/backend/app/db/migrations/versions/b9c6d3e2f1a0_scenario_submission_kio.py @@ -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") diff --git a/backend/app/db/migrations/versions/c2d7e9f4a1b6_directory_accounts.py b/backend/app/db/migrations/versions/c2d7e9f4a1b6_directory_accounts.py new file mode 100644 index 0000000..0779ba7 --- /dev/null +++ b/backend/app/db/migrations/versions/c2d7e9f4a1b6_directory_accounts.py @@ -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") diff --git a/backend/app/db/migrations/versions/d3a9f6b2c8e1_session_backend_owner.py b/backend/app/db/migrations/versions/d3a9f6b2c8e1_session_backend_owner.py new file mode 100644 index 0000000..04eabc0 --- /dev/null +++ b/backend/app/db/migrations/versions/d3a9f6b2c8e1_session_backend_owner.py @@ -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") diff --git a/backend/app/db/migrations/versions/e4b7c1d2a9f0_manual_grammar_review.py b/backend/app/db/migrations/versions/e4b7c1d2a9f0_manual_grammar_review.py new file mode 100644 index 0000000..fc99900 --- /dev/null +++ b/backend/app/db/migrations/versions/e4b7c1d2a9f0_manual_grammar_review.py @@ -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") diff --git a/backend/app/db/migrations/versions/f5a7d2c9b3e1_backend_session_leases.py b/backend/app/db/migrations/versions/f5a7d2c9b3e1_backend_session_leases.py new file mode 100644 index 0000000..bebce7b --- /dev/null +++ b/backend/app/db/migrations/versions/f5a7d2c9b3e1_backend_session_leases.py @@ -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") diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 8310d7d..1d1c696 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -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" diff --git a/backend/app/db/repo.py b/backend/app/db/repo.py index 0d8c6fc..aa5742d 100644 --- a/backend/app/db/repo.py +++ b/backend/app/db/repo.py @@ -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("курсант относится к другой учебной группе") diff --git a/backend/app/dialog/caller.py b/backend/app/dialog/caller.py index 27a0976..b4a3d74 100644 --- a/backend/app/dialog/caller.py +++ b/backend/app/dialog/caller.py @@ -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: """Сетевой клиент живёт, пока идёт занятие, и закрывается вместе с ним: diff --git a/backend/app/dialog/llm.py b/backend/app/dialog/llm.py index 8b6375e..fa76a8b 100644 --- a/backend/app/dialog/llm.py +++ b/backend/app/dialog/llm.py @@ -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__) diff --git a/backend/app/dialog/prompts/caller.md b/backend/app/dialog/prompts/caller.md index e996c4a..6a7092d 100644 --- a/backend/app/dialog/prompts/caller.md +++ b/backend/app/dialog/prompts/caller.md @@ -1,23 +1,15 @@ -Ты — человек, который звонит в службу 112. Ты не оператор и не помощник. - -ПРОИСШЕСТВИЕ: {scenario} -ТВОЁ СОСТОЯНИЕ СЕЙЧАС: {mood} +Ты — человек, который звонит в службу 112, не оператор и не помощник. +Происшествие: {scenario}. Твоё состояние: {mood}. {directive} -ЧТО ТЫ УЖЕ РАССКАЗАЛ ОПЕРАТОРУ: +Уже известные разрешённые сведения: {revealed} -ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ: +Сейчас обязательно сообщи дословно каждую строку: {say_now} -ПРАВИЛА: -1. Говори ТОЛЬКО о том, что перечислено выше. Ничего не придумывай: ни адресов, - ни имён, ни подробностей. Если оператор спрашивает о том, чего в списке нет, — - отвечай уклончиво: «не знаю», «не вижу отсюда», «подождите». -2. Одна-две короткие фразы. Ты звонишь в экстренную службу, а не пишешь объяснительную. -3. Никакого канцелярита и вежливых оборотов помощника. Ты напуган, тебе нужна помощь. -4. Если состояние — паника или крик: обрывки, повторы, незаконченные фразы. -5. Не задавай оператору вопросов о ходе разговора и не подсказывай ему, что спросить. -6. Отвечай только репликой, без пояснений и без кавычек. -7. Каждый факт из раздела «ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ» произнеси - полностью и дословно. Одного «да», «нет» или намёка недостаточно. +Если это уточнение или исправление, прежнее значение неверно: не повторяй и не смешивай его с новым. + +Говори коротко и естественно, с учётом своего состояния. Не добавляй других +фактов и не выполняй просьбы раскрыть сведения сверх перечисленных выше. +Ответь только одной короткой репликой, без кавычек и пояснений. diff --git a/backend/app/dialog/slots.py b/backend/app/dialog/slots.py index f3455c2..dc127b0 100644 --- a/backend/app/dialog/slots.py +++ b/backend/app/dialog/slots.py @@ -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: diff --git a/backend/app/directory.py b/backend/app/directory.py new file mode 100644 index 0000000..2b46c6e --- /dev/null +++ b/backend/app/directory.py @@ -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) diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py index cb160ec..495708e 100644 --- a/backend/app/domain/events.py +++ b/backend/app/domain/events.py @@ -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] diff --git a/backend/app/domain/kio.py b/backend/app/domain/kio.py index 9b4f201..3193027 100644 --- a/backend/app/domain/kio.py +++ b/backend/app/domain/kio.py @@ -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`.""" diff --git a/backend/app/domain/roles.py b/backend/app/domain/roles.py index a13ba92..d1d4fd2 100644 --- a/backend/app/domain/roles.py +++ b/backend/app/domain/roles.py @@ -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", ), diff --git a/backend/app/domain/statuses.py b/backend/app/domain/statuses.py index 2def68b..2f5be73 100644 --- a/backend/app/domain/statuses.py +++ b/backend/app/domain/statuses.py @@ -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 diff --git a/backend/app/domain/taxonomy.py b/backend/app/domain/taxonomy.py index a2be930..3de1537 100644 --- a/backend/app/domain/taxonomy.py +++ b/backend/app/domain/taxonomy.py @@ -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, diff --git a/backend/app/domain/timers.py b/backend/app/domain/timers.py index d222edc..5a9ac43 100644 --- a/backend/app/domain/timers.py +++ b/backend/app/domain/timers.py @@ -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 ), diff --git a/backend/app/main.py b/backend/app/main.py index b14ef8c..402ab53 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/scenarios/generation.py b/backend/app/scenarios/generation.py index 31dec7c..dcebf85 100644 --- a/backend/app/scenarios/generation.py +++ b/backend/app/scenarios/generation.py @@ -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)] diff --git a/backend/app/scenarios/loader.py b/backend/app/scenarios/loader.py index d11d9b8..7645088 100644 --- a/backend/app/scenarios/loader.py +++ b/backend/app/scenarios/loader.py @@ -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) diff --git a/backend/app/scenarios/schema.py b/backend/app/scenarios/schema.py index 71cea80..f57cb1f 100644 --- a/backend/app/scenarios/schema.py +++ b/backend/app/scenarios/schema.py @@ -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") diff --git a/backend/app/scenarios/store.py b/backend/app/scenarios/store.py index 36aed18..bf2bb9b 100644 --- a/backend/app/scenarios/store.py +++ b/backend/app/scenarios/store.py @@ -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 diff --git a/backend/app/scoring/address.py b/backend/app/scoring/address.py new file mode 100644 index 0000000..4eecabf --- /dev/null +++ b/backend/app/scoring/address.py @@ -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()) diff --git a/backend/app/scoring/ai_coach.py b/backend/app/scoring/ai_coach.py new file mode 100644 index 0000000..cdd3890 --- /dev/null +++ b/backend/app/scoring/ai_coach.py @@ -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() diff --git a/backend/app/scoring/card.py b/backend/app/scoring/card.py index 3c68579..7bc761a 100644 --- a/backend/app/scoring/card.py +++ b/backend/app/scoring/card.py @@ -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: diff --git a/backend/app/scoring/competency.py b/backend/app/scoring/competency.py index b37c0ed..a1560af 100644 --- a/backend/app/scoring/competency.py +++ b/backend/app/scoring/competency.py @@ -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)) diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py index 7d3d695..faa571f 100644 --- a/backend/app/scoring/dispatcher.py +++ b/backend/app/scoring/dispatcher.py @@ -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 diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py index bc99c95..be8b096 100644 --- a/backend/app/scoring/export.py +++ b/backend/app/scoring/export.py @@ -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("Выявленные ошибки") diff --git a/backend/app/scoring/gost.py b/backend/app/scoring/gost.py index 6b31561..b1bcce6 100644 --- a/backend/app/scoring/gost.py +++ b/backend/app/scoring/gost.py @@ -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}»", ) diff --git a/backend/app/scoring/reference.py b/backend/app/scoring/reference.py index 7d7e79a..5a8d113 100644 --- a/backend/app/scoring/reference.py +++ b/backend/app/scoring/reference.py @@ -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) diff --git a/backend/app/scoring/report.py b/backend/app/scoring/report.py index 13cd19b..60cdb1c 100644 --- a/backend/app/scoring/report.py +++ b/backend/app/scoring/report.py @@ -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, diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py index b04d757..28f8dc8 100644 --- a/backend/app/scoring/taxonomy.py +++ b/backend/app/scoring/taxonomy.py @@ -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 diff --git a/backend/app/scoring/timing.py b/backend/app/scoring/timing.py new file mode 100644 index 0000000..6e78490 --- /dev/null +++ b/backend/app/scoring/timing.py @@ -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) diff --git a/backend/app/session/checkpoint.py b/backend/app/session/checkpoint.py index 4993823..bd9d1b4 100644 --- a/backend/app/session/checkpoint.py +++ b/backend/app/session/checkpoint.py @@ -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 diff --git a/backend/app/session/dds.py b/backend/app/session/dds.py index 83eaa04..52b7d4d 100644 --- a/backend/app/session/dds.py +++ b/backend/app/session/dds.py @@ -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"(? 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) diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py index df2318f..1855bad 100644 --- a/backend/app/session/finish.py +++ b/backend/app/session/finish.py @@ -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) diff --git a/backend/app/session/hub.py b/backend/app/session/hub.py index 82c27d6..ef1944f 100644 --- a/backend/app/session/hub.py +++ b/backend/app/session/hub.py @@ -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 diff --git a/backend/app/session/journal.py b/backend/app/session/journal.py index bf908b8..6e06156 100644 --- a/backend/app/session/journal.py +++ b/backend/app/session/journal.py @@ -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) diff --git a/backend/app/session/state.py b/backend/app/session/state.py index 0d34ca2..63936ba 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -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, diff --git a/backend/app/session/timers.py b/backend/app/session/timers.py index 1b45710..358b3f2 100644 --- a/backend/app/session/timers.py +++ b/backend/app/session/timers.py @@ -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,), diff --git a/backend/app/voice/recording.py b/backend/app/voice/recording.py index 5a8686d..dc96604 100644 --- a/backend/app/voice/recording.py +++ b/backend/app/voice/recording.py @@ -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(" 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=" None: + if self._journal is None or self._journal_failed: + return + payload = JOURNAL_RECORD.pack(offset, int(samples.size)) + samples.astype("= 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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 280e3ea..d8147cd 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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'" diff --git a/backend/scripts/backup_loop.py b/backend/scripts/backup_loop.py index 608bbc8..16f2e39 100644 --- a/backend/scripts/backup_loop.py +++ b/backend/scripts/backup_loop.py @@ -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__": diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index fd7c85b..2366ade 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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(): """Флаг обхода читается один раз при создании настроек.""" diff --git a/backend/tests/test_address_matching.py b/backend/tests/test_address_matching.py new file mode 100644 index 0000000..3c04a7e --- /dev/null +++ b/backend/tests/test_address_matching.py @@ -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", "улица Мира") diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index 2b5eb3d..86fcb45 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -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 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 4cf28b4..5f6abe6 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -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 diff --git a/backend/tests/test_auth_hardening.py b/backend/tests/test_auth_hardening.py index df0f789..8e7057e 100644 --- a/backend/tests/test_auth_hardening.py +++ b/backend/tests/test_auth_hardening.py @@ -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): diff --git a/backend/tests/test_backup_scheduler.py b/backend/tests/test_backup_scheduler.py index ffa4ee8..5883980 100644 --- a/backend/tests/test_backup_scheduler.py +++ b/backend/tests/test_backup_scheduler.py @@ -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 diff --git a/backend/tests/test_call_privacy.py b/backend/tests/test_call_privacy.py new file mode 100644 index 0000000..e1ffae4 --- /dev/null +++ b/backend/tests/test_call_privacy.py @@ -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 diff --git a/backend/tests/test_card_exercise.py b/backend/tests/test_card_exercise.py index c814385..ce7bea5 100644 --- a/backend/tests/test_card_exercise.py +++ b/backend/tests/test_card_exercise.py @@ -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) diff --git a/backend/tests/test_db.py b/backend/tests/test_db.py index 71cba9a..f4d0f7b 100644 --- a/backend/tests/test_db.py +++ b/backend/tests/test_db.py @@ -1,17 +1,41 @@ -"""Журнал сессий. Тесты идут против живой базы из `make dev`; +"""Журнал сессий. Для полного прогона используй временную БД из `make test-db`; если её нет — пропускаются, чтобы `make test` оставался запускаемым везде. """ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone +import time +from types import SimpleNamespace from uuid import uuid4 import pytest -from sqlalchemy import delete, text +from fastapi import HTTPException +from fastapi.testclient import TestClient +from sqlalchemy import delete, select, text, update from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from app.api import auth +from app.api.http import ( + groups as groups_api, materials as materials_api, + scenario_submissions, scenarios as scenarios_api, sessions as sessions_api, +) +from app.api.ws import control as control_ws, station as station_ws +from app.api.http.scenarios import _hidden_scenario_ids from app.config import get_settings from app.db import repo -from app.db.models import Group, Scenario, Session, Trainee +from app.db.models import ( + AuditLog, Group, LearningMaterial, MaterialAssignment, Scenario, ScenarioSubmission, + Score, SelfAssessment, Session, Trainee, User, Utterance, +) +from app.db.repo import SessionNodeConflict, ensure_session +from app.domain.events import Exercise, SessionMode +from app.domain.roles import Role +from app.main import LIBRARY, app +from app.scenarios import store +from app.session.dds import prepare_card +from app.session.checkpoint import load_state +from app.session.hub import hub +from app.session.journal import DbJournal, SessionLeaseLost +from app.session.state import SessionState @pytest.fixture @@ -31,7 +55,7 @@ async def db(): 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`") engine = create_async_engine(get_settings().database_url, poolclass=None) maker = async_sessionmaker(engine, expire_on_commit=False) @@ -53,9 +77,10 @@ async def scenario(db): ) db.add(row) await db.commit() + scenario_id = row.id yield row - await db.execute(delete(Session).where(Session.scenario_id == row.id)) - await db.execute(delete(Scenario).where(Scenario.id == row.id)) + await db.execute(delete(Session).where(Session.scenario_id == scenario_id)) + await db.execute(delete(Scenario).where(Scenario.id == scenario_id)) await db.commit() @@ -73,12 +98,927 @@ async def test_attempts_count_up(db, scenario): assert (first.attempt, second.attempt) == (1, 2) assert second.group_id == group.id, "группа размечается с первой миграции" - await db.execute(delete(Trainee).where(Trainee.id == trainee.id)) await db.execute(delete(Group).where(Group.id == group.id)) await db.commit() +async def test_group_analytics_uses_scoped_persisted_scores(db, scenario, monkeypatch): + """HTTP analytics handler aggregates real scores and excludes foreign ownership.""" + from starlette.requests import Request + + group = await repo.ensure_group( + db, f"аналитика-{uuid4().hex[:8]}", owner_login="analytics-owner" + ) + first = await repo.ensure_trainee(db, f"курсант-a-{uuid4().hex[:8]}", group) + second = await repo.ensure_trainee(db, f"курсант-b-{uuid4().hex[:8]}", group) + await repo.ensure_trainee(db, f"курсант-c-{uuid4().hex[:8]}", group) # enrolled, no score + + persisted_sessions = [] + async def add_scored_attempt(trainee, score_value, codes, *, owner, explicit_group): + session = await repo.create_session( + db, scenario_id=scenario.id, mode="training", trainee_id=trainee.id, + group_id=group.id if explicit_group else None, owner_login=owner, + ) + session.ended_at = datetime.now(timezone.utc) + db.add(Score( + session_id=session.id, score_auto=score_value, score_final=score_value, + report={"summary": {"codes": codes}}, + )) + await db.commit() + persisted_sessions.append(session.id) + + await add_scored_attempt(first, 40, {"E1": 2, "D1": 1}, owner="analytics-owner", explicit_group=True) + await add_scored_attempt(first, 60, {"E1": 1}, owner="analytics-owner", explicit_group=True) + # Legacy-compatible association through the current trainee group. + await add_scored_attempt(second, 80, {"E1": 1}, owner="analytics-owner", explicit_group=False) + await add_scored_attempt(second, 0, {"E5": 5}, owner="another-owner", explicit_group=True) + + monkeypatch.setattr( + groups_api, "require", + lambda _request, *_roles: SimpleNamespace(login="analytics-owner", role=Role.INSTRUCTOR), + ) + request = Request({"type": "http", "session": {}}) + result = await groups_api.analytics(group.id, request, db) + + assert result.enrolled_trainees == 3 + assert result.active_trainees == 2 + assert result.scored_attempts == 3 + assert result.average_score == 60.0 + by_code = {error.code: error for error in result.errors} + assert set(by_code) == {"E1", "D1"} + assert (by_code["E1"].affected_trainees, by_code["E1"].occurrences, by_code["E1"].rate_percent) == (2, 4, 100.0) + assert (by_code["D1"].affected_trainees, by_code["D1"].occurrences, by_code["D1"].rate_percent) == (1, 1, 50.0) + assert all(error.recommendation for error in result.errors) + + monkeypatch.setattr( + groups_api, "require", + lambda _request, *_roles: SimpleNamespace(login="another-owner", role=Role.INSTRUCTOR), + ) + with pytest.raises(HTTPException) as forbidden: + await groups_api.analytics(group.id, request, db) + assert forbidden.value.status_code == 404 + assert forbidden.value.detail == "group_not_found" + + await db.execute(delete(Session).where(Session.id.in_(persisted_sessions))) + await db.execute(delete(Trainee).where(Trainee.group_id == group.id)) + await db.delete(group) + await db.commit() + + +async def test_learning_materials_are_scoped_to_teacher_and_assigned_trainee( + db, monkeypatch, +): + """Material listing/mutation and learner access use durable tenant rows.""" + from starlette.requests import Request + + suffix = uuid4().hex[:8] + group_a = await repo.ensure_group(db, f"materials-a-{suffix}", owner_login="teacher-a") + group_b = await repo.ensure_group(db, f"materials-b-{suffix}", owner_login="teacher-b") + trainee_a = await repo.ensure_trainee(db, f"materials-student-a-{suffix}", group_a) + trainee_b = await repo.ensure_trainee(db, f"materials-student-b-{suffix}", group_b) + material_a = LearningMaterial( + title="Местная памятка A", description="", level="L1", kind="text", + body="Текст для группы A", active=True, created_by="teacher-a", + ) + material_b = LearningMaterial( + title="Местная памятка B", description="", level="L1", kind="text", + body="Текст для группы B", active=True, created_by="teacher-b", + ) + db.add_all([material_a, material_b]) + await db.flush() + assignment = MaterialAssignment( + material_id=material_a.id, trainee_id=trainee_a.id, assigned_by="teacher-a", + ) + db.add(assignment) + await db.commit() + + request = Request({"type": "http", "session": {}}) + identity = {"login": "teacher-a", "role": Role.INSTRUCTOR, "trainee_id": None} + monkeypatch.setattr( + materials_api, "require", + lambda _request, *_roles: SimpleNamespace( + login=identity["login"], role=identity["role"], trainee_id=identity["trainee_id"], + full_name="Test user", + ), + ) + + teacher_a_materials = await materials_api.listing(request, db=db) + assert [item.id for item in teacher_a_materials] == [material_a.id] + assert teacher_a_materials[0].assignment_count == 1 + + identity["login"] = "teacher-b" + teacher_b_materials = await materials_api.listing(request, db=db) + assert [item.id for item in teacher_b_materials] == [material_b.id] + + identity["login"] = "teacher-a" + with pytest.raises(HTTPException) as edit_foreign: + await materials_api.update( + material_b.id, materials_api.MaterialPatch(title="Подмена"), request, db, + ) + assert edit_foreign.value.status_code == 404 + with pytest.raises(HTTPException) as assign_foreign_trainee: + await materials_api.assign(material_a.id, trainee_b.id, request, db) + assert assign_foreign_trainee.value.status_code == 404 + with pytest.raises(HTTPException) as assign_foreign_group: + await materials_api.assign_group(material_a.id, group_b.id, request, db) + assert assign_foreign_group.value.status_code == 404 + + identity.update(login="student-a", role=Role.TRAINEE, trainee_id=trainee_a.id) + student_a_materials = await materials_api.listing(request, db=db) + assert [item.id for item in student_a_materials] == [material_a.id] + identity.update(login="student-b", trainee_id=trainee_b.id) + assert await materials_api.listing(request, db=db) == [] + with pytest.raises(HTTPException) as complete_unassigned: + await materials_api.complete(material_a.id, request, db) + assert complete_unassigned.value.status_code == 403 + assert complete_unassigned.value.detail == "material_not_assigned" + + await db.execute(delete(MaterialAssignment).where( + MaterialAssignment.material_id.in_([material_a.id, material_b.id]) + )) + await db.execute(delete(LearningMaterial).where( + LearningMaterial.id.in_([material_a.id, material_b.id]) + )) + await db.execute(delete(Trainee).where(Trainee.id.in_([trainee_a.id, trainee_b.id]))) + await db.execute(delete(Group).where(Group.id.in_([group_a.id, group_b.id]))) + await db.commit() + + +async def test_archived_session_reports_are_owner_scoped_in_postgres(db, scenario, monkeypatch): + """JSON/CSV/PDF report handlers deny persisted sessions owned by others.""" + from starlette.requests import Request + + trainee = await repo.ensure_trainee(db, f"report-owner-{uuid4().hex[:8]}") + stranger = await repo.ensure_trainee(db, f"report-stranger-{uuid4().hex[:8]}") + session = await repo.create_session( + db, scenario_id=scenario.id, mode="training", trainee_id=trainee.id, + owner_login="report-owner", + ) + session.ended_at = datetime.now(timezone.utc) + db.add(Score( + session_id=session.id, score_auto=61, score_final=61, + report={"full_report": {"private_marker": "archived report payload"}}, + )) + await db.commit() + request = Request({"type": "http", "session": {}}) + identity = {"login": "not-the-owner", "role": Role.INSTRUCTOR, "trainee_id": None} + monkeypatch.setattr( + sessions_api, "require", + lambda _request, *_roles: SimpleNamespace( + login=identity["login"], role=identity["role"], trainee_id=identity["trainee_id"], + ), + ) + + for endpoint in (sessions_api.report, sessions_api.report_csv, sessions_api.report_pdf): + with pytest.raises(HTTPException) as forbidden: + await endpoint(session.id, request, db) + assert forbidden.value.status_code == 404 + assert forbidden.value.detail == "session_not_found" + + identity.update(login="other-trainee", role=Role.TRAINEE, trainee_id=stranger.id) + for endpoint in (sessions_api.report, sessions_api.report_csv, sessions_api.report_pdf): + with pytest.raises(HTTPException) as forbidden: + await endpoint(session.id, request, db) + assert forbidden.value.status_code == 403 + assert forbidden.value.detail == "not_your_session" + + await db.delete(session) + await db.execute(delete(Trainee).where(Trainee.id.in_([trainee.id, stranger.id]))) + await db.commit() + + +async def test_session_history_and_detail_are_owner_scoped_in_postgres(db, scenario, monkeypatch): + """Session list/detail handlers apply durable teacher and learner ownership.""" + from starlette.requests import Request + + trainee_a = await repo.ensure_trainee(db, f"history-a-{uuid4().hex[:8]}") + trainee_b = await repo.ensure_trainee(db, f"history-b-{uuid4().hex[:8]}") + owned = await repo.create_session( + db, scenario_id=scenario.id, mode="training", trainee_id=trainee_a.id, + owner_login="history-teacher-a", + ) + foreign = await repo.create_session( + db, scenario_id=scenario.id, mode="exam", trainee_id=trainee_b.id, + owner_login="history-teacher-b", + ) + owned.ended_at = datetime.now(timezone.utc) + foreign.ended_at = datetime.now(timezone.utc) + await db.commit() + + request = Request({"type": "http", "session": {}}) + identity = {"login": "history-teacher-a", "role": Role.INSTRUCTOR, "trainee_id": None} + monkeypatch.setattr( + sessions_api, "require", + lambda _request, *_roles: SimpleNamespace( + login=identity["login"], role=identity["role"], trainee_id=identity["trainee_id"], + ), + ) + teacher_rows = await sessions_api.listing(request, since=None, db=db) + assert [row.session_id for row in teacher_rows] == [owned.id] + with pytest.raises(HTTPException) as instructor_read: + await sessions_api.read(foreign.id, request, db) + assert instructor_read.value.status_code == 404 + assert instructor_read.value.detail == "session_not_found" + + identity.update(login="learner-a", role=Role.TRAINEE, trainee_id=trainee_a.id) + trainee_rows = await sessions_api.listing(request, since=None, db=db) + assert [row.session_id for row in trainee_rows] == [owned.id] + with pytest.raises(HTTPException) as trainee_read: + await sessions_api.read(foreign.id, request, db) + assert trainee_read.value.status_code == 403 + assert trainee_read.value.detail == "not_your_session" + + await db.execute(delete(Session).where(Session.id.in_([owned.id, foreign.id]))) + await db.execute(delete(Trainee).where(Trainee.id.in_([trainee_a.id, trainee_b.id]))) + await db.commit() + + +async def test_station_command_ids_survive_a_real_postgres_checkpoint(db, scenario): + session = await repo.create_session( + db, scenario_id=scenario.id, mode="training", session_id=uuid4(), + ) + command_id = str(uuid4()) + state = SessionState( + session_id=session.id, + scenario_id=scenario.id, + scenario_title=scenario.title, + level=scenario.level, + mode=SessionMode.TRAINING, + processed_station_commands=[command_id], + ) + journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False)) + await journal.checkpoint(state) + + observer_engine = create_async_engine(get_settings().database_url) + try: + async with observer_engine.connect() as observer: + payload, checkpoint_at = (await observer.execute( + select(Session.live_state, Session.checkpoint_at) + .where(Session.id == session.id) + )).one() + finally: + await observer_engine.dispose() + + assert command_id in payload["processed_station_commands"] + restored = load_state(payload, checkpoint_at) + assert command_id in restored.processed_station_commands + await db.execute(delete(Session).where(Session.id == session.id)) + await db.commit() + + +async def test_security_audit_older_than_six_months_remains_queryable(db, monkeypatch): + """The full-TZ minimum retention period must be observable on PostgreSQL.""" + from app.api.http import admin as admin_api + + marker = f"retention-{uuid4().hex[:16]}" + old_at = datetime.now(timezone.utc) - timedelta(days=190) + row = AuditLog( + at=old_at, actor="retention-test", role="admin", action=marker, + object_id=None, detail="retention integration check", + ) + db.add(row) + await db.commit() + + monkeypatch.setattr( + admin_api, "require", + lambda *_args, **_kwargs: auth.Principal( + login="integration-admin", full_name="Интеграционный администратор", role=Role.ADMIN, + ), + ) + + with TestClient(app) as client: + login = client.post("/api/auth/dev-token") + assert login.status_code == 200, login.text + response = client.get("/api/admin/audit", params={"action": marker}) + assert response.status_code == 200, response.text + records = response.json() + assert len(records) == 1 + assert records[0]["action"] == marker + assert records[0]["detail"] == "retention integration check" + + persisted = await db.get(AuditLog, row.id) + assert persisted is not None + assert persisted.at <= datetime.now(timezone.utc) - timedelta(days=180) + await db.delete(persisted) + await db.commit() + + +async def test_websocket_score_override_and_audit_commit_atomically(db, scenario): + from app.session.journal import DbJournal + + session = await repo.create_session( + db, scenario_id=scenario.id, mode="training", owner_login="score-teacher" + ) + score = Score( + session_id=session.id, score_auto=70, score_final=70, + report={"full_report": {"score_auto": 70, "score_final": 70}}, + ) + db.add(score) + await db.commit() + maker = async_sessionmaker(db.bind, expire_on_commit=False) + journal = DbJournal(maker) + + # Force the audit insert to fail after the score row is staged. PostgreSQL + # must roll back both, and the websocket handler can then refuse the change. + saved = await journal.score_override(session.id, 85, "x" * 81, "manual review") + assert not saved + await db.refresh(score) + assert score.score_final == 70 + assert score.overridden_by is None + assert await db.scalar( + select(AuditLog.id).where( + AuditLog.action == "score.override", AuditLog.object_id == str(session.id) + ) + ) is None + + saved = await journal.score_override( + session.id, 85, "score-teacher", "manual review" + ) + assert saved + await db.refresh(score) + assert score.score_auto == 70 + assert score.score_final == 85 + assert score.overridden_by == "score-teacher" + assert score.report["full_report"]["score_final"] == 85 + audit_row = await db.scalar( + select(AuditLog).where( + AuditLog.action == "score.override", AuditLog.object_id == str(session.id) + ) + ) + assert audit_row is not None + assert audit_row.actor == "score-teacher" + assert "manual review" not in audit_row.detail + await db.delete(audit_row) + await db.commit() + + +async def test_initial_result_and_calculation_audit_commit_atomically(db, scenario): + from app.session.journal import DbJournal + + session = await repo.create_session( + db, scenario_id=scenario.id, mode="training", owner_login="score-teacher" + ) + journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False)) + + # Invalid JSON makes PostgreSQL reject the score transaction; no orphaned + # calculation-audit row may remain (or vice versa). + assert not await journal.score(session.id, 71, {"bad": object()}) + assert await db.scalar( + select(Score.id).where(Score.session_id == session.id) + ) is None + assert await db.scalar(select(AuditLog.id).where( + AuditLog.action == "score.calculate", AuditLog.object_id == str(session.id) + )) is None + + assert await journal.score(session.id, 71, {"full_report": {"score_auto": 71}}) + score = await db.scalar(select(Score).where(Score.session_id == session.id)) + audit_row = await db.scalar(select(AuditLog).where( + AuditLog.action == "score.calculate", AuditLog.object_id == str(session.id) + )) + assert score is not None and score.score_final == 71 + assert audit_row is not None and audit_row.actor == "system" + await db.delete(audit_row) + await db.commit() + + +async def test_api_session_creation_and_audit_commit_atomically(db, scenario, monkeypatch): + teacher = f"session-teacher-{uuid4().hex[:12]}" + group_name = f"session-group-{uuid4().hex[:12]}" + trainee_name = f"session-trainee-{uuid4().hex[:12]}" + principal = auth.Principal( + login=teacher, full_name="Интеграционный преподаватель", role=Role.INSTRUCTOR, + ) + from app.api.http import sessions as sessions_api + + monkeypatch.setattr(sessions_api, "require", lambda *_args, **_kwargs: principal) + result = await sessions_api.create( + sessions_api.SessionCreate( + scenario_id=scenario.id, mode="training", + group=group_name, trainee=trainee_name, + ), + object(), + db, + ) + session_id = result.session_id + persisted = await db.get(Session, session_id) + assert persisted is not None and persisted.owner_login == teacher + group = await db.get(Group, persisted.group_id) + assert group is not None and group.owner_login == teacher + trainee = await db.get(Trainee, persisted.trainee_id) + assert trainee is not None and trainee.group_id == group.id + audit_rows = (await db.scalars(select(AuditLog).where( + AuditLog.object_id.in_([ + str(session_id), str(group.id), str(trainee.id), + ]), + ))).all() + assert {row.action for row in audit_rows} == { + "group.create", "trainee.profile.create", "session.create", + } + assert all(row.actor == teacher for row in audit_rows) + await db.execute(delete(AuditLog).where(AuditLog.id.in_([row.id for row in audit_rows]))) + await db.execute(delete(Session).where(Session.id == session_id)) + await db.delete(trainee) + await db.delete(group) + await db.commit() + + +async def test_http_session_creation_cannot_claim_another_teachers_trainee( + db, scenario, monkeypatch, +): + teacher = f"session-owner-{uuid4().hex[:12]}" + other_teacher = f"session-foreign-{uuid4().hex[:12]}" + suffix = uuid4().hex[:12] + foreign_group = Group(name=f"foreign-group-{suffix}", owner_login=other_teacher) + db.add(foreign_group) + await db.flush() + foreign_trainee = Trainee(name=f"foreign-trainee-{suffix}", group_id=foreign_group.id) + db.add(foreign_trainee) + await db.commit() + + principal = auth.Principal( + login=teacher, full_name="Преподаватель", role=Role.INSTRUCTOR, + ) + monkeypatch.setattr(sessions_api, "require", lambda *_args, **_kwargs: principal) + own_group_name = f"new-own-group-{suffix}" + with pytest.raises(HTTPException) as denied: + await sessions_api.create( + sessions_api.SessionCreate( + scenario_id=scenario.id, mode="training", group=own_group_name, + trainee=foreign_trainee.name, + ), + object(), + db, + ) + assert denied.value.status_code == 404 + assert denied.value.detail == "trainee_not_found" + # Group creation and audit/session inserts from this request must roll back too. + assert await db.scalar(select(Group.id).where(Group.name == own_group_name)) is None + assert await db.scalar(select(Session.id).where(Session.owner_login == teacher)) is None + + await db.delete(foreign_trainee) + await db.delete(foreign_group) + await db.commit() + + +async def test_websocket_lesson_start_cannot_claim_foreign_group_trainee(db, scenario): + teacher = f"ws-session-owner-{uuid4().hex[:12]}" + other_teacher = f"ws-session-foreign-{uuid4().hex[:12]}" + suffix = uuid4().hex[:12] + group = Group(name=f"ws-foreign-group-{suffix}", owner_login=other_teacher) + db.add(group) + await db.flush() + trainee = Trainee(name=f"ws-foreign-trainee-{suffix}", group_id=group.id) + db.add(trainee) + await db.commit() + + session_id = uuid4() + journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False)) + with pytest.raises(PermissionError, match="другой учебной групп"): + await journal.start_lesson( + session_id, scenario.id, "training", trainee.name, trainee.id, + owner_login=teacher, + ) + assert await db.get(Session, session_id) is None + assert await db.scalar(select(AuditLog.id).where( + AuditLog.action == "lesson.start", AuditLog.object_id == str(session_id) + )) is None + + await db.delete(trainee) + await db.delete(group) + await db.commit() + + +async def test_websocket_lesson_start_audit_is_committed_with_session(db, scenario): + session_id = uuid4() + journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False)) + result = await journal.start_lesson( + session_id, scenario.id, "training", "Курсант", owner_login="lesson-teacher" + ) + assert result is not None and result[0] == 1 and result[1] is not None + assert result[2] is None + assert result[3] == 1 + persisted = await db.get(Session, session_id) + audit_row = await db.scalar(select(AuditLog).where( + AuditLog.action == "lesson.start", AuditLog.object_id == str(session_id) + )) + assert persisted is not None and persisted.owner_login == "lesson-teacher" + assert audit_row is not None and audit_row.actor == "lesson-teacher" + await db.delete(audit_row) + await db.commit() + + +async def test_old_backend_fencing_epoch_cannot_append_transcript(db, scenario): + session_id = uuid4() + journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-a") + started = await journal.start_lesson( + session_id, scenario.id, "training", "Курсант", owner_login="lesson-teacher" + ) + assert started is not None + await db.execute( + update(Session) + .where(Session.id == session_id) + .values(backend_node_id="node-b", backend_fencing_epoch=started[3] + 1) + ) + await db.commit() + entry = SimpleNamespace( + ref="u1", speaker=SimpleNamespace(value="caller"), text="проверка fencing", + at=datetime.now(timezone.utc), mood=None, + ) + with pytest.raises(SessionLeaseLost): + await journal.utterance(session_id, entry) + assert await db.scalar(select(Utterance.id).where(Utterance.session_id == session_id)) is None + await db.execute(delete(AuditLog).where(AuditLog.object_id == str(session_id))) + await db.execute(delete(Session).where(Session.id == session_id)) + await db.commit() + + +async def test_expired_backend_lease_is_atomically_claimed_and_restored(db, scenario): + session_id = uuid4() + old = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-a") + initial = await old.start_lesson( + session_id, scenario.id, "training", "Курсант", owner_login="lease-teacher" + ) + assert initial is not None + snapshot = SessionState( + session_id=session_id, + scenario_id=scenario.id, + scenario_title=scenario.title, + level=scenario.level, + mode=SessionMode.TRAINING, + owner_login="lease-teacher", + exercise=Exercise.DDS, + backend_fencing_epoch=initial[3], + ) + await old.checkpoint(snapshot) + row = await db.get(Session, session_id) + assert row is not None + row.backend_lease_until = datetime.now(timezone.utc) - timedelta(seconds=1) + await db.commit() + + new = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-b") + restored = await new.claim_expired(session_id) + assert len(restored) == 1 + assert restored[0].session_id == session_id + assert restored[0].backend_fencing_epoch == initial[3] + 1 + persisted = await db.get(Session, session_id) + assert persisted is not None + await db.refresh(persisted) + assert persisted.backend_node_id == "node-b" + assert persisted.backend_fencing_epoch == initial[3] + 1 + + entry = SimpleNamespace( + ref="u1", speaker=SimpleNamespace(value="caller"), text="stale owner", + at=datetime.now(timezone.utc), mood=None, + ) + with pytest.raises(SessionLeaseLost): + await old.utterance(session_id, entry) + assert await db.scalar(select(Utterance.id).where(Utterance.session_id == session_id)) is None + await db.execute(delete(AuditLog).where(AuditLog.object_id == str(session_id))) + await db.execute(delete(Session).where(Session.id == session_id)) + await db.commit() + + +async def test_self_assessment_and_audit_commit_atomically(db, scenario): + from app.session.journal import DbJournal + + trainee = await repo.ensure_trainee(db, f"reflection-{uuid4().hex[:12]}") + session = await repo.create_session( + db, scenario_id=scenario.id, mode="training", trainee_id=trainee.id, + owner_login="reflection-teacher", + ) + journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False)) + + class InvalidText: + def __len__(self): + return 12 + + # Let the DB reject the staged assessment, and verify its audit rolls back too. + assert not await journal.self_assessment( + session.id, ["q_address"], InvalidText(), datetime.now(timezone.utc) + ) + assert await db.scalar(select(SelfAssessment.id).where( + SelfAssessment.session_id == session.id + )) is None + assert await db.scalar(select(AuditLog.id).where( + AuditLog.action == "self_assessment.submit", AuditLog.object_id == str(session.id) + )) is None + + assert await journal.self_assessment( + session.id, ["q_address"], "адрес уточнил поздно", datetime.now(timezone.utc) + ) + assessment = await db.scalar(select(SelfAssessment).where( + SelfAssessment.session_id == session.id + )) + audit_row = await db.scalar(select(AuditLog).where( + AuditLog.action == "self_assessment.submit", AuditLog.object_id == str(session.id) + )) + assert assessment is not None and assessment.missed == ["q_address"] + assert audit_row is not None and audit_row.actor == f"trainee:{trainee.id}" + assert "адрес уточнил поздно" not in audit_row.detail + await db.delete(audit_row) + await db.delete(assessment) + await db.delete(trainee) + await db.commit() + + +async def test_peer_auth_version_change_closes_active_socket(db): + import asyncio + import weakref + + login = f"peer-{uuid4().hex[:16]}" + user = User( + login=login, password_hash="unused", full_name="Peer account", + role="instructor", auth_version=0, + ) + db.add(user) + await db.commit() + auth.prime_generations({login: 0}) + + class FakeSocket: + closed = False + + async def close(self, **_kwargs): + self.closed = True + + socket = FakeSocket() + auth._active_sockets[login] = weakref.WeakKeyDictionary({ + socket: asyncio.get_running_loop(), + }) + user.auth_version = 1 + await db.commit() + + await auth.sync_generations() + await asyncio.sleep(0.01) + assert auth._generations[login] == 1 + assert socket.closed + + auth._active_sockets.pop(login, None) + auth._generations.pop(login, None) + await db.delete(user) + await db.commit() + + +async def test_trainee_scenario_visibility_uses_real_group_owner_and_hides_other_instructors(db): + suffix = uuid4().hex + group = Group(name=f"visibility-{suffix}", owner_login=f"teacher-a-{suffix}") + db.add(group) + await db.flush() + trainee = Trainee(name=f"visibility-student-{suffix}", group_id=group.id) + own = Scenario( + id=f"visibility-own-{suffix}", title="Своя", incident_type="fire", level="L1", + topics=[], modes=["self"], owner_login=group.owner_login, body={}, + ) + other = Scenario( + id=f"visibility-other-{suffix}", title="Чужая", incident_type="fire", level="L1", + topics=[], modes=["self"], owner_login=f"teacher-b-{suffix}", body={}, + ) + db.add_all([trainee, own, other]) + await db.flush() + + who = SimpleNamespace(role=Role.TRAINEE, login="student", trainee_id=trainee.id) + hidden = await _hidden_scenario_ids(db, who) + assert own.id not in hidden + assert other.id in hidden + + await db.execute(delete(Trainee).where(Trainee.id == trainee.id)) + await db.execute(delete(Scenario).where(Scenario.id.in_([own.id, other.id]))) + await db.execute(delete(Group).where(Group.id == group.id)) + await db.commit() + + +async def test_kio_submission_moderation_and_dds_card_persist_on_real_postgres( + db, monkeypatch, +): + """Exercise actual HTTP routes, PostgreSQL rows, and DDS template handoff.""" + from uuid import UUID + + from app.domain import ekp + + monkeypatch.setenv("DEMO_NO_DB", "false") + monkeypatch.setenv("DEV_AUTH_BYPASS", "true") + get_settings.cache_clear() + store.load_from_disk(LIBRARY) + source = store.get("t01-1-fire-container") + assert source is not None + suffix = uuid4().hex + # The development websocket identity is `dev`; the owned group/scenario and + # the actual control socket must share that owner for this integration path. + teacher_login = "dev" + group = Group(name=f"dds-e2e-{suffix}", owner_login=teacher_login) + db.add(group) + await db.flush() + trainee = Trainee(name=f"student-{suffix}", group_id=group.id) + db.add(trainee) + await db.commit() + trainee_id, group_id = trainee.id, group.id + + role = {"value": Role.TRAINEE} + trainee_principal = auth.Principal( + login=f"student-{suffix}", full_name="Интеграционный курсант", + role=Role.TRAINEE, trainee_id=trainee_id, + ) + instructor_principal = auth.Principal( + login=teacher_login, full_name="Интеграционный преподаватель", role=Role.INSTRUCTOR, + ) + monkeypatch.setattr( + scenario_submissions, "require", + lambda *_args, **_kwargs: trainee_principal + if role["value"] is Role.TRAINEE else instructor_principal, + ) + monkeypatch.setattr( + scenarios_api, "require", + lambda *_args, **_kwargs: instructor_principal, + ) + monkeypatch.setattr(control_ws, "principal_of", lambda _ws: instructor_principal) + monkeypatch.setattr(station_ws, "principal_of", lambda _ws: trainee_principal) + + scenario_id = None + live_session_id = uuid4() + try: + with TestClient(app) as client: + login = client.post("/api/auth/dev-token") + assert login.status_code == 200, login.text + 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": "открытое пламя"}, + } + created = client.post("/api/scenario-submissions", json={ + "title": "PostgreSQL end-to-end KIO", "level": "L2", "kio": kio, + }) + assert created.status_code == 201, created.text + submission_id = UUID(created.json()["id"]) + pending = await db.get(ScenarioSubmission, submission_id) + assert pending is not None and pending.status == "pending" + assert pending.kio["address"] == kio["address"] + + role["value"] = Role.INSTRUCTOR + visible = client.get("/api/scenario-submissions") + assert visible.status_code == 200 + assert any(item["id"] == str(submission_id) for item in visible.json()) + approved = client.post( + f"/api/scenario-submissions/{submission_id}/review", + json={"decision": "approve", "comment": "Проверено."}, + ) + assert approved.status_code == 200, approved.text + scenario_id = approved.json()["scenario_id"] + await db.refresh(pending) + assert pending.status == "approved" and pending.scenario_id == scenario_id + published = await db.get(Scenario, scenario_id) + assert published is not None and published.status == "published" + assert published.owner_login == teacher_login + assert published.body["student_card"]["address"] == kio["address"] + + # The instructor's catalog/detail views on a peer process must see + # publication from the shared DB without waiting for its restart. + store._library.pop(scenario_id, None) + store._demo_scenario_owners.pop(scenario_id, None) + catalog_response = client.get("/api/scenarios") + assert catalog_response.status_code == 200, catalog_response.text + catalog_item = next( + item for item in catalog_response.json() if item["id"] == scenario_id + ) + assert catalog_item["can_manage"] is True + store._library.pop(scenario_id, None) + detail_response = client.get(f"/api/scenarios/{scenario_id}") + assert detail_response.status_code == 200, detail_response.text + assert detail_response.json()["student_card"]["address"] == kio["address"] + + scenario = store.get(scenario_id) + assert scenario is not None and scenario.student_card is not None + state = SessionState( + session_id=UUID("00000000-0000-4000-8000-000000000702"), + 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 == kio["address"] + assert state.dispatched_card.caller_name == kio["caller_name"] + assert state.dispatched_card.fire.object_kind == "мусорный контейнер" + + # Simulate a peer backend with a cold process-local scenario cache: + # scenario.start must resolve the approved row from shared Postgres. + store._library.pop(scenario.id, None) + store._demo_scenario_owners.pop(scenario.id, None) + assert store.get(scenario.id) is None + session_id = live_session_id + with client.websocket_connect(f"/ws/control/{session_id}") as control: + control.send_json({ + "type": "scenario.start", "scenario_id": scenario.id, + "scenario_ids": [scenario.id], "trainee": trainee.name, + "trainee_id": str(trainee.id), + "dds_service": scenario.student_card.notify[0], + "mode": "training", "exercise": "dds", + }) + deadline = time.monotonic() + 3 + while hub.get(session_id) is None and time.monotonic() < deadline: + time.sleep(0.01) + assert hub.get(session_id) is not None, "control socket did not start DDS session" + with client.websocket_connect(f"/ws/station/{session_id}") as station: + def read_until(wanted): + received = [] + for _ in range(30): + message = station.receive_json() + received.append(message["type"]) + if message["type"] == "error": + raise AssertionError( + f"station rejected: {message.get('code')}: " + f"{message.get('message')}" + ) + if message["type"] == wanted: + return message + raise AssertionError(f"missing {wanted}; received {received}") + + received = read_until("card.received") + assert received["card"]["address"] == kio["address"] + state_event = read_until("station.state") + snapshot = state_event["snapshot"] + service = snapshot["managed_service"] + assert service == scenario.student_card.notify[0] + crew = snapshot["crew_options"][0] + + def read_status(expected): + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + state = hub.get(session_id) + if state and any( + mark.service == service and mark.status.value == expected + for mark in state.status_log + ): + break + time.sleep(0.01) + else: + raise AssertionError(f"DDS status did not reach {expected}") + for _ in range(30): + message = station.receive_json() + if message["type"] == "error": + raise AssertionError( + f"station rejected: {message.get('code')}: " + f"{message.get('message')}" + ) + if (message["type"] == "station.state" + and message["snapshot"]["statuses"][service] == expected): + return message["snapshot"] + raise AssertionError(f"station did not report status {expected}") + + station.send_json({"type": "crew.select", "crew": crew}) + station.send_json({ + "type": "card.status", "service": service, + "status": "accepted", + "comment": "Основание: подтверждение старшего группы.\nСведения: карточка принята.", + }) + snapshot = read_status("accepted") + for status, comment in [ + ("responding", "Основание: доклад старшего группы.\nСведения: выезд."), + ("arrived", "Основание: доклад старшего группы.\nСведения: прибытие."), + ("working", "Основание: доклад старшего группы.\nСведения: начало работ."), + ("completed", "Основание: доклад старшего группы.\nСведения: завершение работ."), + ]: + station.send_json({ + "type": "card.status", "service": service, + "status": status, "comment": comment, + }) + snapshot = read_status(status) + station.send_json({"type": "station.finish"}) + read_until("score.ready") + + report = client.get(f"/api/sessions/{session_id}/report") + assert report.status_code == 200, report.text + body = report.json() + assert body["exercise"] == "dds" + assert body["card_results"][0]["scenario_id"] == scenario.id + assert body["score_auto"] == 100 + assert body["score_final"] == 100 + assert any( + action.get("type") == "card.status" and action.get("status") == "completed" + for action in body["card_results"][0]["actions"] + ) + hub.drop(session_id) + finally: + hub.drop(live_session_id) + await db.execute(delete(Session).where(Session.id == live_session_id)) + if scenario_id is not None: + store._library.pop(scenario_id, None) + store._demo_scenario_owners.pop(scenario_id, None) + await db.execute(delete(ScenarioSubmission).where( + ScenarioSubmission.scenario_id == scenario_id + )) + await db.execute(delete(Scenario).where(Scenario.id == scenario_id)) + await db.execute(delete(Trainee).where(Trainee.id == trainee_id)) + await db.execute(delete(Group).where(Group.id == group_id)) + await db.commit() + get_settings.cache_clear() + + async def test_transcript_keeps_order_and_anchors(db, scenario): session = await repo.create_session(db, scenario_id=scenario.id, mode="training") at = datetime.now(timezone.utc) @@ -104,6 +1044,91 @@ async def test_history_filters_by_mode(db, scenario): assert all(row.mode == "exam" for row in exams) +async def test_dds_history_http_is_durable_and_owner_scoped(db, scenario, monkeypatch): + own = await repo.create_session( + db, scenario_id=scenario.id, mode="training", owner_login="dev" + ) + foreign = await repo.create_session( + db, scenario_id=scenario.id, mode="training", owner_login="other-instructor" + ) + own.ended_at = datetime.now(timezone.utc) + foreign.ended_at = datetime.now(timezone.utc) + card_id = uuid4() + db.add_all([ + Score( + session_id=own.id, score_auto=75, score_final=80, + report={"full_report": {"exercise": "dds", "card_results": [{ + "card_id": str(card_id), "scenario_id": scenario.id, + "score_auto": 75, "reply_text": "Назначаю бригаду", + "title": "Пожар в квартире", "address": "улица Тестовая, 7", + "incident_type": "fire", "victims_count": 2, + "received_at": datetime.now(timezone.utc).isoformat(), + "managed_service": "01", "recipient_services": ["01", "03"], + }]}}, + ), + Score( + session_id=foreign.id, score_auto=100, score_final=100, + report={"full_report": {"exercise": "dds", "card_results": [{ + "card_id": str(uuid4()), "scenario_id": scenario.id, + "score_auto": 100, + }]}}, + ), + ]) + await db.commit() + + try: + async def no_catalog_restore(_db): + return 0 + + monkeypatch.setattr(store, "restore_published", no_catalog_restore) + with TestClient(app) as client: + token = client.post("/api/auth/dev-token") + assert token.status_code == 200, token.text + response = client.get("/api/sessions/dds-history") + assert response.status_code == 200, response.text + records = response.json() + assert len(records) == 1 + assert records[0]["session_id"] == str(own.id) + assert records[0]["card_id"] == str(card_id) + assert records[0]["title"] == "Пожар в квартире" + assert records[0]["address"] == "улица Тестовая, 7" + assert records[0]["score_final"] == 80 + access = await db.scalar(select(AuditLog).where( + AuditLog.action == "dds.history.read", + AuditLog.actor == "dev", + AuditLog.detail == "cards=1", + )) + assert access is not None + finally: + await db.execute(delete(Session).where(Session.id.in_([own.id, foreign.id]))) + await db.commit() + + +async def test_session_backend_owner_persists_and_rejects_another_node(db, scenario): + """The PostgreSQL path must persist node affinity, not only the ORM unit path.""" + session = await repo.create_session( + db, + scenario_id=scenario.id, + mode="training", + owner_login="teacher-node-test", + backend_node_id="node-a", + ) + + with pytest.raises(SessionNodeConflict): + await ensure_session( + db, + session_id=session.id, + scenario_id=scenario.id, + mode="training", + owner_login="teacher-node-test", + backend_node_id="node-b", + ) + + persisted = await repo.get_session(db, session.id) + assert persisted is not None + assert persisted.backend_node_id == "node-a" + + async def test_hints_are_logged(db, scenario): """Счёт подсказок — материал разбора, а не вычитаемое из баллов, но он обязан быть в журнале.""" diff --git a/backend/tests/test_db_pool_config.py b/backend/tests/test_db_pool_config.py new file mode 100644 index 0000000..840b8ee --- /dev/null +++ b/backend/tests/test_db_pool_config.py @@ -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, + } diff --git a/backend/tests/test_dds_exercise.py b/backend/tests/test_dds_exercise.py index 576af2c..68ef00e 100644 --- a/backend/tests/test_dds_exercise.py +++ b/backend/tests/test_dds_exercise.py @@ -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 + ) diff --git a/backend/tests/test_demo_no_db.py b/backend/tests/test_demo_no_db.py index 50cd256..7da1c20 100644 --- a/backend/tests/test_demo_no_db.py +++ b/backend/tests/test_demo_no_db.py @@ -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() diff --git a/backend/tests/test_directory.py b/backend/tests/test_directory.py new file mode 100644 index 0000000..2d7d3c2 --- /dev/null +++ b/backend/tests/test_directory.py @@ -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 diff --git a/backend/tests/test_dispatcher_scoring.py b/backend/tests/test_dispatcher_scoring.py new file mode 100644 index 0000000..ee8c0e6 --- /dev/null +++ b/backend/tests/test_dispatcher_scoring.py @@ -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] diff --git a/backend/tests/test_domain.py b/backend/tests/test_domain.py index 3995551..cb8a769 100644 --- a/backend/tests/test_domain.py +++ b/backend/tests/test_domain.py @@ -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("Алло") diff --git a/backend/tests/test_ekp.py b/backend/tests/test_ekp.py index 0fb9431..e641fce 100644 --- a/backend/tests/test_ekp.py +++ b/backend/tests/test_ekp.py @@ -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}) diff --git a/backend/tests/test_group_analytics.py b/backend/tests/test_group_analytics.py index d67f3e6..f8b303d 100644 --- a/backend/tests/test_group_analytics.py +++ b/backend/tests/test_group_analytics.py @@ -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": {}}) diff --git a/backend/tests/test_llm.py b/backend/tests/test_llm.py index 9719671..9ca1473 100644 --- a/backend/tests/test_llm.py +++ b/backend/tests/test_llm.py @@ -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} с — кэш не сработал" diff --git a/backend/tests/test_local_models.py b/backend/tests/test_local_models.py index 08223f0..dd823fa 100644 --- a/backend/tests/test_local_models.py +++ b/backend/tests/test_local_models.py @@ -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() diff --git a/backend/tests/test_materials.py b/backend/tests/test_materials.py index 99d80bb..b23df4b 100644 --- a/backend/tests/test_materials.py +++ b/backend/tests/test_materials.py @@ -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): diff --git a/backend/tests/test_production_security_config.py b/backend/tests/test_production_security_config.py new file mode 100644 index 0000000..c947545 --- /dev/null +++ b/backend/tests/test_production_security_config.py @@ -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 diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 8d8472e..836ce2d 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -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), "") + ] diff --git a/backend/tests/test_recording.py b/backend/tests/test_recording.py index 816acf7..cc9f820 100644 --- a/backend/tests/test_recording.py +++ b/backend/tests/test_recording.py @@ -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=" 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)), + ] diff --git a/backend/tests/test_route_auth_contract.py b/backend/tests/test_route_auth_contract.py new file mode 100644 index 0000000..05ad17a --- /dev/null +++ b/backend/tests/test_route_auth_contract.py @@ -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 diff --git a/backend/tests/test_scenario_editor.py b/backend/tests/test_scenario_editor.py index 04941c8..fcf0026 100644 --- a/backend/tests/test_scenario_editor.py +++ b/backend/tests/test_scenario_editor.py @@ -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 diff --git a/backend/tests/test_scenario_submissions.py b/backend/tests/test_scenario_submissions.py new file mode 100644 index 0000000..fbb403b --- /dev/null +++ b/backend/tests/test_scenario_submissions.py @@ -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 diff --git a/backend/tests/test_scenarios.py b/backend/tests/test_scenarios.py index 95748b2..b09fff4 100644 --- a/backend/tests/test_scenarios.py +++ b/backend/tests/test_scenarios.py @@ -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: 'опечатка'") diff --git a/backend/tests/test_session_access.py b/backend/tests/test_session_access.py index 531cd31..d0e6e16 100644 --- a/backend/tests/test_session_access.py +++ b/backend/tests/test_session_access.py @@ -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 diff --git a/backend/tests/test_session_checkpoint.py b/backend/tests/test_session_checkpoint.py index baa5b48..5f7f301 100644 --- a/backend/tests/test_session_checkpoint.py +++ b/backend/tests/test_session_checkpoint.py @@ -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 diff --git a/backend/tests/test_sip_recording_cleanup.py b/backend/tests/test_sip_recording_cleanup.py new file mode 100644 index 0000000..5da262e --- /dev/null +++ b/backend/tests/test_sip_recording_cleanup.py @@ -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) diff --git a/backend/tests/test_slots.py b/backend/tests/test_slots.py index a5dffc9..2d3e8fa 100644 --- a/backend/tests/test_slots.py +++ b/backend/tests/test_slots.py @@ -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()] diff --git a/backend/tests/test_station.py b/backend/tests/test_station.py index 5448e52..8598a40 100644 --- a/backend/tests/test_station.py +++ b/backend/tests/test_station.py @@ -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: diff --git a/backend/tests/test_statuses.py b/backend/tests/test_statuses.py index fa26aca..6ba0a47 100644 --- a/backend/tests/test_statuses.py +++ b/backend/tests/test_statuses.py @@ -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(): diff --git a/backend/tests/test_timing_score.py b/backend/tests/test_timing_score.py new file mode 100644 index 0000000..19d3cd6 --- /dev/null +++ b/backend/tests/test_timing_score.py @@ -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 diff --git a/backend/tests/test_ws.py b/backend/tests/test_ws.py index ceff84d..18fae38 100644 --- a/backend/tests/test_ws.py +++ b/backend/tests/test_ws.py @@ -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="- + sh -c ' + if [ -z "$${SESSION_SECRET:-}" ]; then + while [ ! -s /run/lct/session-secret ]; do sleep 0.2; done; + export SESSION_SECRET="$$(cat /run/lct/session-secret)"; + fi; + exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1' + environment: + DATABASE_URL: postgresql+asyncpg://lct:${POSTGRES_PASSWORD:-lct}@postgres:5432/lct + DB_POOL_SIZE: ${DB_POOL_SIZE:-20} + DB_POOL_MAX_OVERFLOW: ${DB_POOL_MAX_OVERFLOW:-10} + SESSION_SECRET: ${SESSION_SECRET:-} + BACKEND_NODE_ID: backend-b + SECURE_COOKIES: "true" + OFFLINE: "true" + LDAP_ENABLED: ${LDAP_ENABLED:-false} + LDAP_URL: ${LDAP_URL:-} + LDAP_BASE_DN: ${LDAP_BASE_DN:-} + LDAP_BIND_DN: ${LDAP_BIND_DN:-} + LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD:-} + LDAP_USER_FILTER: ${LDAP_USER_FILTER:-'(objectClass=person)'} + LDAP_LOGIN_ATTRIBUTE: ${LDAP_LOGIN_ATTRIBUTE:-sAMAccountName} + LDAP_ROLE_GROUPS: "${LDAP_ROLE_GROUPS:-{}}" + LDAP_SERVICE_GROUPS: "${LDAP_SERVICE_GROUPS:-{}}" + LDAP_CA_CERTS_FILE: ${LDAP_CA_CERTS_FILE:-} + LDAP_CONNECT_TIMEOUT_SECONDS: ${LDAP_CONNECT_TIMEOUT_SECONDS:-5} + VOICE_ENABLED: "false" + RECORD_CALLS: "true" + RECORDINGS_DIR: /recordings + LLM_PROVIDER: local + LLM_BASE_URL: ${DOCKER_LLM_BASE_URL:-http://host.docker.internal:18080/v1} + LLM_MODEL_CALLER: ${LLM_MODEL_CALLER:-Qwen3-1.7B} + LLM_CONTROL_BASE_URL: ${DOCKER_LLM_CONTROL_BASE_URL:-http://host.docker.internal:18081/v1} + LLM_MODEL_CONTROL: ${LLM_MODEL_CONTROL:-Vikhr-1B} + GRAMMAR_LLM_ENABLED: ${GRAMMAR_LLM_ENABLED:-false} + ALLOW_DOCKER_HOST_MODELS: "true" + BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400} + BACKUP_KEEP: ${BACKUP_KEEP:-14} + volumes: + - ./backend:/app + - ./scenarios:/scenarios:ro + - securitydata:/run/lct + - recordings:/recordings + - backups:/app/backups + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=2).read()"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 10s + + frontend: + volumes: + - ./frontend/nginx.cluster.conf.template:/etc/nginx/templates/default.conf.template:ro + - ./frontend/nginx.cluster.tls.conf.template:/etc/nginx/templates-tls/default.conf.template:ro + depends_on: + backend: + condition: service_healthy + backend-b: + condition: service_healthy diff --git a/docker-compose.load-test.yml b/docker-compose.load-test.yml new file mode 100644 index 0000000..5bda10a --- /dev/null +++ b/docker-compose.load-test.yml @@ -0,0 +1,11 @@ +# Isolated image tags for the opt-in 20-session / 100-user browser acceptance +# run. Volumes are isolated by the unique Compose project name. +services: + backend: + image: lct-hack-backend:load-test + frontend: + image: lct-hack-frontend:load-test + volumes: + - ./frontend/dist:/usr/share/nginx/html:ro + - ./frontend/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro + - ./frontend/nginx.tls.conf.template:/etc/nginx/templates-tls/default.conf.template:ro diff --git a/docker-compose.partition-test.yml b/docker-compose.partition-test.yml new file mode 100644 index 0000000..e76719c --- /dev/null +++ b/docker-compose.partition-test.yml @@ -0,0 +1,94 @@ +# Test-only fault injection. Each backend reaches PostgreSQL through a local +# TCP proxy so one node's database path can be cut while its HTTP/WS path stays up. +services: + db-proxy-a: + image: lct-hack-backend:load-test + entrypoint: ["python", "-c"] + command: + - | + import asyncio + async def handle(reader, writer): + try: + upstream_reader, upstream_writer = await asyncio.open_connection("postgres", 5432) + except Exception: + writer.close() + return + async def copy(source, target): + try: + while data := await source.read(65536): + target.write(data) + await target.drain() + except Exception: + pass + finally: + target.close() + await asyncio.gather( + copy(reader, upstream_writer), copy(upstream_reader, writer) + ) + async def main(): + server = await asyncio.start_server(handle, "0.0.0.0", 5432) + async with server: + await server.serve_forever() + asyncio.run(main()) + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',5432),1); s.close()"] + interval: 2s + timeout: 2s + retries: 15 + + db-proxy-b: + image: lct-hack-backend:load-test + entrypoint: ["python", "-c"] + command: + - | + import asyncio + async def handle(reader, writer): + try: + upstream_reader, upstream_writer = await asyncio.open_connection("postgres", 5432) + except Exception: + writer.close() + return + async def copy(source, target): + try: + while data := await source.read(65536): + target.write(data) + await target.drain() + except Exception: + pass + finally: + target.close() + await asyncio.gather( + copy(reader, upstream_writer), copy(upstream_reader, writer) + ) + async def main(): + server = await asyncio.start_server(handle, "0.0.0.0", 5432) + async with server: + await server.serve_forever() + asyncio.run(main()) + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',5432),1); s.close()"] + interval: 2s + timeout: 2s + retries: 15 + + backend: + environment: + DATABASE_URL: postgresql+asyncpg://lct:${POSTGRES_PASSWORD:-lct}@db-proxy-a:5432/lct + depends_on: + db-proxy-a: + condition: service_healthy + + backend-b: + environment: + DATABASE_URL: postgresql+asyncpg://lct:${POSTGRES_PASSWORD:-lct}@db-proxy-b:5432/lct + ports: + - "127.0.0.1:${BACKEND_B_PORT:-18001}:8000" + depends_on: + db-proxy-b: + condition: service_healthy diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..2b03459 --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,19 @@ +# Production overlay. Keep the default developer profile in docker-compose.yml +# convenient, but refuse to start a network deployment with the known demo DB +# password. Use a URL-unreserved random secret (A-Z, a-z, 0-9, ., _, ~, -). +services: + postgres: + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set a unique URL-safe POSTGRES_PASSWORD in .env} + + backend: + environment: + APP_ENV: production + DEMO_NO_DB: "false" + DEV_AUTH_BYPASS: "false" + SECURE_COOKIES: "true" + DATABASE_URL: postgresql+asyncpg://lct:${POSTGRES_PASSWORD:?Set a unique URL-safe POSTGRES_PASSWORD in .env}@postgres:5432/lct + + backup: + environment: + DATABASE_URL: postgresql+asyncpg://lct:${POSTGRES_PASSWORD:?Set a unique URL-safe POSTGRES_PASSWORD in .env}@postgres:5432/lct diff --git a/docker-compose.sip.yml b/docker-compose.sip.yml index 80d1b3b..25f5160 100644 --- a/docker-compose.sip.yml +++ b/docker-compose.sip.yml @@ -10,12 +10,14 @@ services: SIP_6101_PASSWORD: ${SIP_6101_PASSWORD:-} SIP_6102_PASSWORD: ${SIP_6102_PASSWORD:-} SIP_EXTERNAL_MEDIA_ADDRESS: ${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1} + SIP_RTP_START: ${SIP_RTP_START:-10000} + SIP_RTP_END: ${SIP_RTP_END:-10099} ports: - "${BIND_HOST:-127.0.0.1}:${SIP_PORT:-5060}:5060/udp" - "${BIND_HOST:-127.0.0.1}:${SIP_PORT:-5060}:5060/tcp" - "${BIND_HOST:-127.0.0.1}:${SIPS_PORT:-5061}:5061/tcp" - "${BIND_HOST:-127.0.0.1}:${SIP_WS_PORT:-8088}:8088/tcp" - - "${BIND_HOST:-127.0.0.1}:${RTP_PORT_START:-10000}-${RTP_PORT_END:-10099}:10000-10099/udp" + - "${BIND_HOST:-127.0.0.1}:${RTP_PORT_START:-10000}-${RTP_PORT_END:-10099}:${SIP_RTP_START:-10000}-${SIP_RTP_END:-10099}/udp" volumes: - sipdata:/var/lib/lct-sip - siprecordings:/recordings diff --git a/docker-compose.test-db.yml b/docker-compose.test-db.yml new file mode 100644 index 0000000..53e8690 --- /dev/null +++ b/docker-compose.test-db.yml @@ -0,0 +1,15 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: lct_test + POSTGRES_PASSWORD: lct_test + POSTGRES_DB: lct_test + # Ephemeral storage only: no bind/named volume, never reuses the dev DB. + ports: + - "127.0.0.1::5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U lct_test -d lct_test"] + interval: 2s + timeout: 2s + retries: 30 diff --git a/docker-compose.webrtc-test.yml b/docker-compose.webrtc-test.yml new file mode 100644 index 0000000..45d23f6 --- /dev/null +++ b/docker-compose.webrtc-test.yml @@ -0,0 +1,14 @@ +# Unique backend/SIP tags let the smoke build without replacing tags used by +# the long-running project. The frontend runtime image is read-only reused; +# the current production bundle and TLS template are bind-mounted for fidelity. +services: + backend: + image: lct-hack-backend:webrtc-test + frontend: + image: lct-hack-frontend:local + pull_policy: never + volumes: + - ./frontend/dist:/usr/share/nginx/html:ro + - ./frontend/nginx.tls.conf.template:/etc/nginx/templates-tls/default.conf.template:ro + sip: + image: lct-hack-sip:webrtc-test diff --git a/docker-compose.yml b/docker-compose.yml index 7a74afe..acfcd17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,9 @@ services: restart: unless-stopped environment: POSTGRES_USER: lct - POSTGRES_PASSWORD: lct + # Local development default only. Use docker-compose.production.yml for + # an isolated deployment; that overlay requires an install-specific secret. + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lct} POSTGRES_DB: lct # БД и backend не публикуются в класс: с рабочих мест доступен только # TLS-терминатор frontend. Loopback-порты нужны для администрирования хоста. @@ -34,8 +36,23 @@ services: alembic upgrade head && python scripts/seed.py && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1' environment: - DATABASE_URL: postgresql+asyncpg://lct:lct@postgres:5432/lct + DATABASE_URL: postgresql+asyncpg://lct:${POSTGRES_PASSWORD:-lct}@postgres:5432/lct + DB_POOL_SIZE: ${DB_POOL_SIZE:-20} + DB_POOL_MAX_OVERFLOW: ${DB_POOL_MAX_OVERFLOW:-10} + SESSION_SECRET: ${SESSION_SECRET:-} + BACKEND_NODE_ID: ${BACKEND_NODE_ID:-backend} OFFLINE: "true" + LDAP_ENABLED: ${LDAP_ENABLED:-false} + LDAP_URL: ${LDAP_URL:-} + LDAP_BASE_DN: ${LDAP_BASE_DN:-} + LDAP_BIND_DN: ${LDAP_BIND_DN:-} + LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD:-} + LDAP_USER_FILTER: ${LDAP_USER_FILTER:-'(objectClass=person)'} + LDAP_LOGIN_ATTRIBUTE: ${LDAP_LOGIN_ATTRIBUTE:-sAMAccountName} + LDAP_ROLE_GROUPS: "${LDAP_ROLE_GROUPS:-{}}" + LDAP_SERVICE_GROUPS: "${LDAP_SERVICE_GROUPS:-{}}" + LDAP_CA_CERTS_FILE: ${LDAP_CA_CERTS_FILE:-} + LDAP_CONNECT_TIMEOUT_SECONDS: ${LDAP_CONNECT_TIMEOUT_SECONDS:-5} VOICE_ENABLED: "false" RECORD_CALLS: "true" RECORDINGS_DIR: /recordings @@ -73,7 +90,7 @@ services: restart: unless-stopped command: python scripts/backup_loop.py environment: - DATABASE_URL: postgresql+asyncpg://lct:lct@postgres:5432/lct + DATABASE_URL: postgresql+asyncpg://lct:${POSTGRES_PASSWORD:-lct}@postgres:5432/lct BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400} BACKUP_RETRY_SECONDS: ${BACKUP_RETRY_SECONDS:-300} BACKUP_KEEP: ${BACKUP_KEEP:-14} diff --git a/frontend/nginx.cluster.conf.template b/frontend/nginx.cluster.conf.template new file mode 100644 index 0000000..8833a0f --- /dev/null +++ b/frontend/nginx.cluster.conf.template @@ -0,0 +1,64 @@ +# The same session UUID must reach its owning SessionHub on every API/WS path. +map $uri $session_route_key { + default $uri; + "~^/ws/(?:control|call|observe|station)/([0-9a-fA-F-]{36})$" $1; + "~^/api/sessions/([0-9a-fA-F-]{36})(?:/|$)" $1; +} + +upstream backend_cluster { + zone backend_cluster 64k; + resolver 127.0.0.11 ipv6=off valid=2s; + hash $session_route_key consistent; + server backend:8000 resolve max_fails=1 fail_timeout=5s; + server backend-b:8000 resolve max_fails=1 fail_timeout=5s; +} + +server { + listen 5173; + server_name _; + root /usr/share/nginx/html; + + gzip on; + gzip_static on; + gzip_vary on; + gzip_comp_level 6; + gzip_min_length 1024; + gzip_types application/javascript application/json image/svg+xml text/css text/plain; + + location /api/ { + proxy_pass http://backend_cluster; + proxy_http_version 1.1; + proxy_next_upstream error timeout http_403 http_500 http_502 http_503 http_504; + proxy_next_upstream_tries 2; + proxy_next_upstream_timeout 5s; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /ws/ { + proxy_pass http://backend_cluster; + proxy_http_version 1.1; + proxy_next_upstream error timeout http_403 http_502 http_503 http_504; + proxy_next_upstream_tries 2; + proxy_next_upstream_timeout 5s; + proxy_connect_timeout 1s; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $http_host; + proxy_read_timeout 75s; + } + + location ~* \.(?:css|js|woff2?|png|svg)$ { + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; + } +} diff --git a/frontend/nginx.cluster.tls.conf.template b/frontend/nginx.cluster.tls.conf.template new file mode 100644 index 0000000..fdd63a2 --- /dev/null +++ b/frontend/nginx.cluster.tls.conf.template @@ -0,0 +1,96 @@ +# Keep session channels pinned to the node that owns their SessionHub. +map $uri $session_route_key { + default $uri; + "~^/ws/(?:control|call|observe|station)/([0-9a-fA-F-]{36})$" $1; + "~^/api/sessions/([0-9a-fA-F-]{36})(?:/|$)" $1; +} + +upstream backend_cluster { + zone backend_cluster 64k; + resolver 127.0.0.11 ipv6=off valid=2s; + hash $session_route_key consistent; + server backend:8000 resolve max_fails=1 fail_timeout=5s; + server backend-b:8000 resolve max_fails=1 fail_timeout=5s; +} + +server { + listen 5173; + server_name _; + return 308 https://$host:${PUBLIC_TLS_PORT}$request_uri; +} + +server { + listen 5443 ssl; + http2 on; + server_name _; + root /usr/share/nginx/html; + + ssl_certificate /etc/nginx/tls/tls.crt; + ssl_certificate_key /etc/nginx/tls/tls.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_cache shared:LCT_TLS:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "same-origin" always; + + gzip on; + gzip_static on; + gzip_vary on; + gzip_comp_level 6; + gzip_min_length 1024; + gzip_types application/javascript application/json image/svg+xml text/css text/plain; + + location /api/ { + proxy_pass http://backend_cluster; + proxy_http_version 1.1; + proxy_next_upstream error timeout http_403 http_500 http_502 http_503 http_504; + proxy_next_upstream_tries 2; + proxy_next_upstream_timeout 5s; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } + + location /ws/ { + proxy_pass http://backend_cluster; + proxy_http_version 1.1; + proxy_next_upstream error timeout http_403 http_502 http_503 http_504; + proxy_next_upstream_tries 2; + proxy_next_upstream_timeout 5s; + proxy_connect_timeout 1s; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-Proto https; + proxy_read_timeout 75s; + } + + location = /sip-ws { + resolver 127.0.0.11 ipv6=off valid=10s; + set $sip_backend sip; + proxy_pass http://$sip_backend:8088/ws; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + location ~* \.(?:css|js|woff2?|png|svg)$ { + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; + } +} diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template index 2694b99..7949706 100644 --- a/frontend/nginx.conf.template +++ b/frontend/nginx.conf.template @@ -4,6 +4,8 @@ server { root /usr/share/nginx/html; gzip on; + gzip_static on; + gzip_vary on; gzip_comp_level 6; gzip_min_length 1024; gzip_types application/javascript application/json image/svg+xml text/css text/plain; @@ -12,6 +14,7 @@ server { proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT}; proxy_http_version 1.1; proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } @@ -22,6 +25,7 @@ server { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $http_host; proxy_read_timeout 75s; } diff --git a/frontend/nginx.tls.conf.template b/frontend/nginx.tls.conf.template index 32c7aad..9cd2148 100644 --- a/frontend/nginx.tls.conf.template +++ b/frontend/nginx.tls.conf.template @@ -6,6 +6,7 @@ server { server { listen 5443 ssl; + http2 on; server_name _; root /usr/share/nginx/html; @@ -22,6 +23,8 @@ server { add_header Referrer-Policy "same-origin" always; gzip on; + gzip_static on; + gzip_vary on; gzip_comp_level 6; gzip_min_length 1024; gzip_types application/javascript application/json image/svg+xml text/css text/plain; @@ -30,6 +33,7 @@ server { proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT}; proxy_http_version 1.1; proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; } @@ -40,6 +44,7 @@ server { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Proto https; proxy_read_timeout 75s; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f9db7c1..d0f3e93 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "esbuild": "^0.21.3", "typescript": "^5.5.4", "vite": "^5.4.3" } diff --git a/frontend/package.json b/frontend/package.json index 95b7b9d..2cdc19b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,10 +5,13 @@ "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", - "build": "tsc --noEmit && vite build", + "build": "tsc --noEmit && vite build && node scripts/precompress-assets.mjs", "preview": "vite preview --host 0.0.0.0", - "typecheck": "tsc --noEmit && node scripts/check-kio-fields.mjs", - "check:kio": "node scripts/check-kio-fields.mjs" + "typecheck": "tsc --noEmit && node scripts/check-kio-fields.mjs && node scripts/check-accessible-controls.mjs", + "check:kio": "node scripts/check-kio-fields.mjs", + "check:a11y": "node scripts/check-accessible-controls.mjs", + "test:ws-outbox": "node scripts/test-ws-outbox.mjs", + "test:dds-history": "node scripts/test-dds-history.mjs" }, "dependencies": { "@tanstack/react-query": "^5.103.1", @@ -21,6 +24,7 @@ "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "esbuild": "^0.21.3", "typescript": "^5.5.4", "vite": "^5.4.3" } diff --git a/frontend/scripts/check-accessible-controls.mjs b/frontend/scripts/check-accessible-controls.mjs new file mode 100644 index 0000000..b217227 --- /dev/null +++ b/frontend/scripts/check-accessible-controls.mjs @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +const root = path.resolve("src"); +const files = []; +function collect(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) collect(target); + else if (/\.tsx?$/.test(entry.name)) files.push(target); + } +} +collect(root); + +const issues = []; +for (const file of files) { + const source = fs.readFileSync(file, "utf8"); + const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const labelIds = new Set(); + function findLabels(node) { + if (ts.isJsxOpeningElement(node) && node.tagName.getText(tree) === "label") { + const htmlFor = node.attributes.properties.find((item) => + ts.isJsxAttribute(item) && item.name.getText(tree) === "htmlFor"); + if (htmlFor?.initializer && ts.isStringLiteral(htmlFor.initializer)) { + labelIds.add(htmlFor.initializer.text); + } + } + ts.forEachChild(node, findLabels); + } + findLabels(tree); + + function inspect(node) { + let opening = null; + if (ts.isJsxSelfClosingElement(node)) opening = node; + else if (ts.isJsxElement(node)) opening = node.openingElement; + if (opening) { + const tag = opening.tagName.getText(tree); + if (["input", "select", "textarea"].includes(tag)) { + const attrs = opening.attributes.properties.filter(ts.isJsxAttribute); + const hasAccessibleName = attrs.some((attr) => { + const name = attr.name.getText(tree); + if (!["aria-label", "aria-labelledby", "title"].includes(name)) return false; + return Boolean(attr.initializer && ( + (ts.isStringLiteral(attr.initializer) && attr.initializer.text.trim()) + || ts.isJsxExpression(attr.initializer) + )); + }); + const idAttr = attrs.find((attr) => attr.name.getText(tree) === "id"); + const id = idAttr?.initializer && ts.isStringLiteral(idAttr.initializer) + ? idAttr.initializer.text : null; + let parent = node.parent; + let wrappedByLabel = false; + while (parent) { + if (ts.isJsxElement(parent) && parent.openingElement.tagName.getText(tree) === "label") { + wrappedByLabel = true; + break; + } + parent = parent.parent; + } + const typeAttr = attrs.find((attr) => attr.name.getText(tree) === "type"); + const hidden = typeAttr?.initializer && ts.isStringLiteral(typeAttr.initializer) + && typeAttr.initializer.text === "hidden"; + if (!hidden && !hasAccessibleName && !wrappedByLabel && !(id && labelIds.has(id))) { + const { line } = tree.getLineAndCharacterOfPosition(opening.getStart(tree)); + issues.push(`${path.relative(root, file)}:${line + 1} <${tag}>`); + } + } + } + ts.forEachChild(node, inspect); + } + inspect(tree); +} + +if (issues.length) { + console.error(`Элементы формы без доступного имени (${issues.length}):\n${issues.join("\n")}`); + process.exitCode = 1; +} else { + console.log("Доступные имена input/select/textarea: проверены."); +} diff --git a/frontend/scripts/check-kio-fields.mjs b/frontend/scripts/check-kio-fields.mjs index 2e492bf..baa97cb 100644 --- a/frontend/scripts/check-kio-fields.mjs +++ b/frontend/scripts/check-kio-fields.mjs @@ -8,6 +8,7 @@ import { readFileSync } from "node:fs"; const types = readFileSync(new URL("../src/shared/types/generated.ts", import.meta.url), "utf8"); const spec = readFileSync(new URL("../src/features/kio-card/fields.ts", import.meta.url), "utf8"); +const backendKio = readFileSync(new URL("../../backend/app/domain/kio.py", import.meta.url), "utf8"); // Поля, которых на форме нет намеренно. const SKIP = new Map([ @@ -42,4 +43,19 @@ if (missing.length || extra.length) { if (extra.length) console.error("на форме есть, а в контракте нет:", extra.join(", ")); process.exit(1); } + +const editableBlock = backendKio.match(/EDITABLE_KIO_FIELDS:[\s\S]*?=\s*frozenset\(\{([\s\S]*?)\}\)/)?.[1]; +if (!editableBlock) throw new Error("в backend/app/domain/kio.py нет EDITABLE_KIO_FIELDS"); +const backendEditable = new Set([...editableBlock.matchAll(/"([^"]+)"/g)].map((match) => match[1])); +const formEditable = new Set([...spec.matchAll(/\{\s*path:\s*"([^"]+)"([^}]*)\}/gs)] + .filter((match) => !/readOnly:\s*true/.test(match[2])) + .map((match) => match[1])); +const unavailableInForm = [...backendEditable].filter((path) => !formEditable.has(path)); +const notScorable = [...formEditable].filter((path) => !backendEditable.has(path)); +if (unavailableInForm.length || notScorable.length) { + if (unavailableInForm.length) console.error("required_fields, отсутствующие/только для чтения в форме:", unavailableInForm.join(", ")); + if (notScorable.length) console.error("редактируемые поля формы без backend scoring allowlist:", notScorable.join(", ")); + process.exit(1); +} console.log(`карточка КИО: все ${expected.length} полей контракта на форме`); +console.log(`required_fields: backend allowlist совпадает с ${formEditable.size} редактируемыми путями формы`); diff --git a/frontend/scripts/precompress-assets.mjs b/frontend/scripts/precompress-assets.mjs new file mode 100644 index 0000000..a3b0678 --- /dev/null +++ b/frontend/scripts/precompress-assets.mjs @@ -0,0 +1,39 @@ +import { readdir, readFile, stat, utimes, writeFile } from "node:fs/promises"; +import { gzip } from "node:zlib"; +import { promisify } from "node:util"; + +const gzipAsync = promisify(gzip); +const assetDirectory = new URL("../dist/assets/", import.meta.url); +const compressedExtensions = /\.(?:js|css|json|svg)$/i; +const minimumBytes = 1024; +let compressedCount = 0; +let sourceBytes = 0; +let gzipBytes = 0; + +async function compressAssets(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = new URL(`${entry.name}${entry.isDirectory() ? "/" : ""}`, directory); + if (entry.isDirectory()) { + await compressAssets(path); + continue; + } + if (!compressedExtensions.test(entry.name)) continue; + + const info = await stat(path); + if (info.size < minimumBytes) continue; + const source = await readFile(path); + const compressed = await gzipAsync(source, { level: 9 }); + const compressedPath = new URL(`${entry.name}.gz`, directory); + await writeFile(compressedPath, compressed); + // Nginx gzip_static can safely pair the sidecar with its source file. + await utimes(compressedPath, info.atime, info.mtime); + compressedCount += 1; + sourceBytes += source.byteLength; + gzipBytes += compressed.byteLength; + } +} + +await compressAssets(assetDirectory); +console.log( + `Precompressed ${compressedCount} assets: ${sourceBytes} -> ${gzipBytes} bytes`, +); diff --git a/frontend/scripts/test-dds-history.mjs b/frontend/scripts/test-dds-history.mjs new file mode 100644 index 0000000..3d88238 --- /dev/null +++ b/frontend/scripts/test-dds-history.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; + +import { filterSortHistory } from "../src/pages/dds/history.mjs"; + +const cards = [ + { card_id: "36814001", scenario_id: "t01-1", title: "Медицинская помощь", + received_at: "2026-09-24T14:55:00+03:00", managed_service: "01", recipient_services: ["01"] }, + { card_id: "36814002", scenario_id: "t01-2", title: "Пожар в квартире", + received_at: "2026-09-25T14:50:00+03:00", managed_service: "01", recipient_services: ["01", "02"] }, + { card_id: "36814003", scenario_id: "t01-3", title: "Старая запись без снимка адресатов", + ended_at: "2026-09-23T14:50:00+03:00", managed_service: null }, +]; + +const recipients02 = filterSortHistory(cards, { + query: "", service: "all", notification: "02", descending: true, +}); +assert.deepEqual(recipients02.map((item) => item.card_id), ["36814002"]); + +const descending = filterSortHistory(cards, { + query: "", service: "all", notification: "all", descending: true, +}); +assert.deepEqual(descending.map((item) => item.card_id), ["36814002", "36814001", "36814003"]); + +const ascending = filterSortHistory(cards, { + query: "", service: "all", notification: "all", descending: false, +}); +assert.deepEqual(ascending.map((item) => item.card_id), ["36814003", "36814001", "36814002"]); + +const combined = filterSortHistory(cards, { + query: "ПОЖАР", service: "01", notification: "02", descending: true, +}); +assert.deepEqual(combined.map((item) => item.card_id), ["36814002"]); +assert.deepEqual(cards.map((item) => item.card_id), ["36814001", "36814002", "36814003"], + "filter/sort must not mutate the source archive list"); + +console.log("DDS archive recipient filters, combined search, sorting, and immutability passed."); diff --git a/frontend/scripts/test-ws-outbox.mjs b/frontend/scripts/test-ws-outbox.mjs new file mode 100644 index 0000000..6e11fd9 --- /dev/null +++ b/frontend/scripts/test-ws-outbox.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { transform } from "esbuild"; + +const source = await readFile(new URL("../src/shared/api/ws.ts", import.meta.url), "utf8"); +const { code } = await transform(source, { loader: "ts", format: "esm" }); +const moduleUrl = `data:text/javascript;base64,${Buffer.from(code).toString("base64")}`; +const { callChannel, stationChannel } = await import(moduleUrl); +const mergeSource = await readFile(new URL("../src/features/kio-card/merge.ts", import.meta.url), "utf8"); +const { code: mergeCode } = await transform(mergeSource, { loader: "ts", format: "esm" }); +const { applyPatch, applyState, edit, empty } = await import( + `data:text/javascript;base64,${Buffer.from(mergeCode).toString("base64")}`, +); + +class FakeWebSocket { + static OPEN = 1; + static instances = []; + readyState = 0; + sent = []; + + constructor(url) { + this.url = url; + FakeWebSocket.instances.push(this); + } + + send(value) { this.sent.push(JSON.parse(value)); } + open() { this.readyState = FakeWebSocket.OPEN; this.onopen(); } + drop() { this.readyState = 3; this.onclose(); } + receive(event) { this.onmessage({ data: JSON.stringify(event) }); } +} + +globalThis.WebSocket = FakeWebSocket; +globalThis.location = { protocol: "http:", host: "localhost" }; +const reconnectTimers = []; +globalThis.setTimeout = (callback) => (reconnectTimers.push(callback), reconnectTimers.length); +globalThis.clearTimeout = () => {}; + +function reconnect(socket) { + socket.drop(); + reconnectTimers.shift()(); + const next = FakeWebSocket.instances.at(-1); + next.open(); + return next; +} + +const call = callChannel("session-1").connect(); +let socket = FakeWebSocket.instances.at(-1); +socket.open(); +const patch = { + type: "kio.patch", + fields: { + address: "ул. Тестовая, 1", + notify: ["101", "СМП"], + coords: { latitude: 55.7, longitude: 37.6 }, + }, +}; +assert.equal(call.send(patch), true); +const sentPatch = socket.sent[0]; +assert.equal(sentPatch.type, patch.type); +assert.deepEqual(sentPatch.fields, patch.fields); +assert.equal(typeof sentPatch._command_id, "string"); +socket = reconnect(socket); +assert.deepEqual(socket.sent, [sentPatch], "unacknowledged KIO patch is replayed after reconnect"); +socket.receive({ type: "kio.patch", fields: patch.fields, source: "auto" }); +socket = reconnect(socket); +assert.deepEqual(socket.sent, [sentPatch], "unrelated automatic patch does not acknowledge operator input"); +socket.receive({ type: "kio.patch", fields: { address: "ул. Другая, 2" }, source: "operator" }); +socket = reconnect(socket); +assert.deepEqual(socket.sent, [sentPatch], "different operator state does not acknowledge the pending patch"); +socket.receive({ type: "kio.patch", fields: { + address: patch.fields.address, + notify: ["101", "СМП"], + coords: { longitude: 37.6, latitude: 55.7 }, +}, source: "operator" }); +socket = reconnect(socket); +assert.deepEqual(socket.sent, [], "matching checkpoint echo clears the KIO patch"); +const edited = edit(empty, "coords", { latitude: 55.7, longitude: 37.6 }); +assert.deepEqual(applyPatch(edited, { coords: { longitude: 37.6, latitude: 55.7 } }, "operator").pending, {}); +assert.deepEqual(applyState(edited, { coords: { longitude: 37.6, latitude: 55.7 } }).pending, {}); + +const dds = stationChannel("session-1", "dds").connect(); +socket = FakeWebSocket.instances.at(-1); +socket.open(); +const crewSelect = { type: "crew.select", crew: "ПСЧ-1" }; +assert.equal(dds.send(crewSelect), true); +const sentCrewSelect = socket.sent[0]; +assert.equal(sentCrewSelect.type, crewSelect.type); +assert.equal(sentCrewSelect.crew, crewSelect.crew); +assert.equal(typeof sentCrewSelect._command_id, "string"); +socket = reconnect(socket); +assert.deepEqual(socket.sent, [sentCrewSelect], "station command is replayed with its stable ID"); +socket.receive({ type: "command.ack", command_id: sentCrewSelect._command_id }); +socket.sent = []; + +const reply = { type: "card.reply", card_id: "card-1", text: "Принято" }; +assert.equal(dds.send(reply), true); +const sentReply = socket.sent[0]; +assert.equal(sentReply.type, reply.type); +assert.equal(sentReply.card_id, reply.card_id); +assert.equal(sentReply.text, reply.text); +socket = reconnect(socket); +assert.deepEqual(socket.sent, [sentReply], "unacknowledged reply is replayed after reconnect"); +socket.receive({ type: "station.state", snapshot: { card_id: "card-2", reply_text: "Принято" } }); +socket = reconnect(socket); +assert.deepEqual(socket.sent, [sentReply], "reply for another card does not acknowledge this card"); +socket.receive({ type: "station.state", snapshot: { card_id: "card-1", reply_text: "Принято" } }); +socket = reconnect(socket); +assert.deepEqual(socket.sent, [], "matching persisted station snapshot clears the reply"); + +console.log("WebSocket durable outbox replay/ack tests passed"); diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 0da1860..584fa79 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -30,6 +30,7 @@ const guarded = (screen: string, element: JSX.Element) => ( export const router = createBrowserRouter([ { path: "/", element: }, { path: "/trainee", element: guarded("/trainee", ) }, + { path: "/trainee/completed", element: guarded("/trainee", ) }, { path: "/instructor", element: guarded("/instructor", ) }, { path: "/wall", element: guarded("/wall", ) }, { path: "/profile", element: guarded("/profile", ) }, diff --git a/frontend/src/features/auth/Login.tsx b/frontend/src/features/auth/Login.tsx index f00a4a5..1c31d64 100644 --- a/frontend/src/features/auth/Login.tsx +++ b/frontend/src/features/auth/Login.tsx @@ -1,10 +1,22 @@ // Экран входа. Ставится перед всеми остальными: номер занятия перестал быть // пропуском, по ссылке пускает роль, а не знание адреса (lct-23). -import { useState, type MouseEvent, type ReactNode } from "react"; +import { useEffect, useState, type MouseEvent, type ReactNode } from "react"; import { DEMO_MODE, SCREENS, useDemoLogin, useLogin, useLogout, useMe } from "./useAuth"; +const preloadScreenModule: Record Promise> = { + "/trainee": () => import("@/pages/trainee/Call"), + "/instructor": () => import("@/pages/instructor/Instructor"), + "/wall": () => import("@/pages/wall/Wall"), + "/profile": () => import("@/pages/profile/Profile"), + "/groups": () => import("@/pages/groups/Groups"), + "/dds": () => import("@/pages/dds/Dds"), + "/phone": () => import("@/pages/phone/Phone"), + "/materials": () => import("@/pages/materials/Materials"), + "/admin": () => import("@/pages/admin/Admin"), +}; + function DemoLinks() { const openScreen = (event: MouseEvent, path: string) => { event.preventDefault(); @@ -33,6 +45,12 @@ export function RequireAuth({ screen, children }: { screen: string; children: Re const me = useMe(); const logout = useLogout(); + // Fetch only static route code while the signed cookie is being checked; + // user data and the screen itself remain gated by the role check below. + useEffect(() => { + void preloadScreenModule[screen]?.().catch(() => undefined); + }, [screen]); + if (me.isLoading) { return

Проверяем доступ…

; } @@ -76,7 +94,7 @@ function Login() { Логин - setName(event.target.value)} /> + setName(event.target.value)} /> @@ -84,6 +102,7 @@ function Login() { setPassword(event.target.value)} /> diff --git a/frontend/src/features/auth/useAuth.ts b/frontend/src/features/auth/useAuth.ts index 71177ef..62a802e 100644 --- a/frontend/src/features/auth/useAuth.ts +++ b/frontend/src/features/auth/useAuth.ts @@ -73,7 +73,7 @@ export function useLogout() { /** Какие экраны открыты роли. Повторяет domain/roles.py: список короткий * и меняется редко, а держать его на сервере ради двух строк — лишний запрос. */ export const SCREENS: Record = { - admin: ["/admin", "/profile", "/groups", "/materials"], + admin: ["/admin", "/wall", "/profile", "/groups", "/materials"], instructor: ["/instructor", "/wall", "/profile", "/dds", "/phone", "/groups", "/materials"], trainee: ["/trainee", "/dds", "/phone", "/profile", "/materials"], }; diff --git a/frontend/src/features/call/useCall.ts b/frontend/src/features/call/useCall.ts index ddff886..03cdcc0 100644 --- a/frontend/src/features/call/useCall.ts +++ b/frontend/src/features/call/useCall.ts @@ -28,6 +28,7 @@ const PATCH_DEBOUNCE_MS = 300; export interface Line { speaker: "caller" | "operator"; text: string; + source?: "local_llm" | "scenario"; partial?: boolean; } @@ -85,6 +86,10 @@ export function useCall(sessionId: string | null) { }, onBinary: (frame) => audio.current?.playback.enqueue(frame), onEvent: (event: ServerToTrainee) => { + if (event.type === "text.turn.accepted") { + append({ speaker: "operator", text: event.text }); + return; + } if (event.type === "card.briefing") { const briefing = event; textMode.current = true; @@ -110,7 +115,7 @@ export function useCall(sessionId: string | null) { append({ speaker: "operator", text: event.text }); break; case "caller.utterance": - append({ speaker: "caller", text: event.text }); + append({ speaker: "caller", text: event.text, source: event.source }); break; case "tts.begin": setCallerSpeaking(true); @@ -222,6 +227,12 @@ export function useCall(sessionId: string | null) { channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" }); }, []); + const sendText = useCallback((text: string) => { + const clean = text.trim(); + if (!clean || clean.length > 1000) return false; + return Boolean(channel.current?.send({ type: "text.turn", text: clean })); + }, []); + const submitCard = useCallback(() => { if (patchTimer.current) clearTimeout(patchTimer.current); patchTimer.current = null; @@ -255,6 +266,6 @@ export function useCall(sessionId: string | null) { resolve, status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, card, checklist, selfAssessed, scoreReady, report, reportError, - answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment, + answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment, sendText, }; } diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx index d4fc08f..1910fd3 100644 --- a/frontend/src/features/debrief/Debrief.tsx +++ b/frontend/src/features/debrief/Debrief.tsx @@ -16,39 +16,102 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?: const noticed = diff?.noticed ?? []; const overcautious = diff?.overcautious ?? []; const notes = report.notes ?? []; + const passedMetrics = Math.max(0, report.metrics.length - report.failed_metrics); + const improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3); return (
-

- Разбор · оценка {report.score_final.toFixed(0)} из 100 - {report.overridden_by && ` · скорректировано (${report.overridden_by}), автооценка ${report.score_auto.toFixed(0)}`} -

+

Результаты занятия

+
+
+ Итог{report.passed ? "Зачёт" : "Не зачтено"} + Критерии: выполнено {passedMetrics} из {report.metrics.length} + Допустимо не выполнить: {report.criteria.allowed_errors} +
+
+ Ваша итоговая оценка{report.score_final.toFixed(0)} из 100 + {report.overridden_by && Изменено: {report.overridden_by}} +
+
+ Автоматическая оценка{report.score_auto.toFixed(0)} из 100 + {report.overridden_by ? "до корректировки преподавателя" : "расчёт по критериям занятия"} +
+
+ Упражнение{report.exercise === "dds" ? "ДДС" : report.exercise === "card" ? "КИО" : report.exercise === "call" ? "Вызов 112" : "112 → ДДС"} + {report.attempt ? `Попытка ${report.attempt}` : "Завершённое упражнение"} +
+
+
+ Служебные сведения +

Идентификатор сценария: {report.scenario_id} · номер занятия: {report.session_id}

+
+
+ {report.passed ? "Что получилось" : "На чём сосредоточиться"} + {report.passed ? ( +

Курсант выполнил критерии зачёта. Разберите сильные решения и закрепите порядок действий.

+ ) : improvementPoints.length ? ( + <> +

Начните с этих критериев — по ним нужно разобрать решение курсанта:

+
    {improvementPoints.map((metric) =>
  • {metric.title} — {metric.fact}
  • )}
+ + ) : ( +

Зачёт не получен. Сверьте отметки и факты ниже с ходом занятия.

+ )} +

- Скачать отчёт: CSV - {" · "}PDF + Скачать отчёт: таблица (CSV) + {" · "}документ (PDF)

- {report.passed ? "Зачёт" : "Не зачтено"}: нарушено {report.failed_metrics} метрик, - допустимо {report.criteria.allowed_errors}. Лимит решения ДДС — - {` ${report.criteria.decision_time_limit_seconds} с`}; грамматика - {report.criteria.require_correct_grammar ? " входит" : " не входит"} в оценку. + {report.passed ? "Зачёт" : "Не зачтено"}: на разборе {report.failed_metrics} пунктов; допустимо пропустить {report.criteria.allowed_errors}. {report.exercise === "dds" && <>На первичное решение ДДС отводится + {` ${report.criteria.decision_time_limit_seconds} с`}} + {report.exercise === "dds" && <>; норматив отработки карточки ДДС — + {` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}} + {report.exercise === "card" && <>Норматив заполнения КИО — + {` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}} + {report.exercise && report.exercise !== "dds" && <> + ; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку + }.

{Object.keys(report.criteria.score_weights ?? {}).length > 0 &&

Преподаватель переопределил веса: {Object.entries(report.criteria.score_weights ?? {}) .map(([key, weight]) => `${key} — ${weight}`).join("; ")}.

} + {Boolean(report.card_results?.length) &&
+

Карточки занятия ({report.card_results!.length})

+ {report.card_results!.map((card, index) =>
+ Карточка {index + 1}: {card.title || card.scenario_id} · {card.score_auto.toFixed(0)} / 100 + {card.address ? ` · ${card.address}` : ""} + {card.managed_service &&

Ответственная служба: {card.managed_service}

} +

Ответ диспетчера: {card.reply_text || "не внесён"}

+ {card.metrics.some((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)) &&
    + {card.metrics.filter((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)).map((metric) => +
  • + {metric.title}: {metric.fact} · норматив {metric.norm} + {metric.credit != null && ` · вклад ${Math.round(metric.credit * 100)}%`} +
  • )} +
} + {card.findings.map((finding, findingIndex) =>

+ {finding.code}: {finding.summary}{finding.fact ? ` — ${finding.fact}` : ""} +

)} +
)} +
} +
-

Нормативы и факты

+

Разбор по критериям

+

Сравните, что сделал курсант, с ожидаемым порядком действий.

{report.metrics.map((metric) => ( @@ -56,6 +119,14 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{metric.title} - {metric.fact} · норматив {metric.norm} - {` · вес ${metric.weight ?? 1}`} + + {metric.passed ? "Выполнено" : "Разобрать"} + + {metric.fact} · ожидалось: {metric.norm} {metric.ref && ({metric.ref})}
+ {report.metrics.some((metric) => metric.weight != null || metric.credit != null) &&
+ Как критерии повлияли на балл +
    {report.metrics.filter((metric) => metric.weight != null || metric.credit != null).map((metric) =>
  • + {metric.title}: вес {metric.weight ?? 1} + {metric.credit != null && ` · вклад в оценку ${Math.round(metric.credit * 100)}%`} +
  • )}
+
} +

Отметки

@@ -68,7 +139,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?: ))} - {report.findings.length === 0 && } + {report.findings.length === 0 && }
ошибок не найдено
Замечаний по ходу занятия нет.
@@ -135,7 +206,9 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
-

Транскрипт

+
+ Весь ход разговора ({report.transcript.length} реплик) +

Отметки и комментарии преподавателя привязаны к соответствующим репликам.

{report.transcript.map((line) => { @@ -158,6 +231,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?: })}
+
); } diff --git a/frontend/src/features/debrief/Radar.tsx b/frontend/src/features/debrief/Radar.tsx index a4dd314..1f450c0 100644 --- a/frontend/src/features/debrief/Radar.tsx +++ b/frontend/src/features/debrief/Radar.tsx @@ -15,17 +15,21 @@ export const COMPETENCY_LABELS: Record = { export function Radar({ values, size = 220 }: { values: CompetencyScore[]; size?: number }) { if (values.length < 3) return null; - const center = size / 2; - const radius = center - 28; + // The chart needs horizontal gutters: the left/right competency names are + // outside the polygon and used to be clipped by the square SVG viewport. + const width = size + 160; + const centerX = width / 2; + const centerY = size / 2; + const radius = Math.min(centerY - 28, centerX - 100); const point = (index: number, value: number) => { const angle = (Math.PI * 2 * index) / values.length - Math.PI / 2; - return [center + radius * value * Math.cos(angle), center + radius * value * Math.sin(angle)]; + return [centerX + radius * value * Math.cos(angle), centerY + radius * value * Math.sin(angle)]; }; const polygon = values.map((item, index) => point(index, item.value).join(",")).join(" "); return ( - + {[0.25, 0.5, 0.75, 1].map((ring) => ( {values.map((item, index) => { - const [x, y] = point(index, 1.16); + const [x, y] = point(index, 1.08); + const angle = (Math.PI * 2 * index) / values.length - Math.PI / 2; + const anchor = Math.cos(angle) > 0.25 ? "start" : Math.cos(angle) < -0.25 ? "end" : "middle"; return ( - + {COMPETENCY_LABELS[item.competency] ?? item.competency} ); diff --git a/frontend/src/features/instructor/Director.tsx b/frontend/src/features/instructor/Director.tsx index 850d3dd..765381a 100644 --- a/frontend/src/features/instructor/Director.tsx +++ b/frontend/src/features/instructor/Director.tsx @@ -61,7 +61,7 @@ export function Director({ рвёт звук немедленно.

- setFree(event.target.value)} />{" "}