From 4c4b91064f154b3333ff12f5d6f25be1362e94ca Mon Sep 17 00:00:00 2001 From: andreysk0304 Date: Thu, 24 Sep 2026 01:10:49 +0300 Subject: [PATCH] Complete DDS training workflow and delivery package --- .env.example | 53 +- .gitignore | 7 + Makefile | 43 +- backend/Dockerfile | 2 +- backend/app/admin/backup.py | 150 ++++- backend/app/api/auth.py | 40 +- backend/app/api/http/admin.py | 244 ++++++- backend/app/api/http/groups.py | 181 +++-- backend/app/api/http/materials.py | 549 +++++++++++++++ backend/app/api/http/scenarios.py | 178 ++++- backend/app/api/http/sessions.py | 232 ++++++- backend/app/api/http/trainees.py | 111 ++- backend/app/api/ws/call.py | 19 +- backend/app/api/ws/control.py | 121 +++- backend/app/api/ws/station.py | 244 +++++-- backend/app/config.py | 39 +- .../a4c9d1e2f3b4_learning_materials.py | 63 ++ .../b5e0f2a3c4d5_active_session_checkpoint.py | 37 + .../c6f1a3b4d5e6_persistent_auth_version.py | 26 + .../versions/d7e2f4a5b6c7_scenario_owners.py | 31 + .../f7a3c9d1e2b4_instructor_owned_groups.py | 25 + backend/app/db/models.py | 61 +- backend/app/db/repo.py | 19 +- backend/app/dialog/caller.py | 71 +- backend/app/dialog/director.py | 17 +- backend/app/dialog/factory.py | 43 +- backend/app/dialog/llm.py | 88 ++- backend/app/dialog/prompts/caller.md | 2 + backend/app/domain/events.py | 84 ++- backend/app/domain/kio.py | 1 + backend/app/domain/roles.py | 8 +- backend/app/domain/statuses.py | 42 ++ backend/app/main.py | 40 +- backend/app/monitoring.py | 167 +++++ backend/app/scenarios/editor.py | 43 ++ backend/app/scenarios/generation.py | 634 ++++++++++++++++++ backend/app/scenarios/store.py | 184 ++++- backend/app/scoring/dispatcher.py | 31 +- backend/app/scoring/export.py | 87 ++- backend/app/scoring/grammar.py | 102 +++ backend/app/scoring/group_insight.py | 77 +++ backend/app/scoring/report.py | 7 +- backend/app/scoring/taxonomy.py | 1 + backend/app/session/checkpoint.py | 358 ++++++++++ backend/app/session/dds.py | 132 +++- backend/app/session/finish.py | 83 +-- backend/app/session/hub.py | 38 +- backend/app/session/journal.py | 96 ++- backend/app/session/state.py | 259 ++++++- backend/app/voice/models.py | 63 +- backend/app/voice/recording.py | 81 +++ backend/conftest.py | 1 + backend/pyproject.toml | 1 + backend/scripts/backup.py | 3 +- backend/scripts/backup_loop.py | 80 +++ backend/scripts/download_local_models.py | 131 ++++ backend/scripts/llm_check.py | 81 +-- backend/scripts/local_llms.py | 118 ++++ backend/scripts/local_stt.py | 84 +++ backend/scripts/models.py | 42 +- backend/scripts/repl.py | 5 +- backend/scripts/smoke_voice.py | 40 ++ backend/scripts/users.py | 2 +- backend/tests/test_admin.py | 234 ++++++- backend/tests/test_auth.py | 11 + backend/tests/test_auth_hardening.py | 19 +- backend/tests/test_backup_scheduler.py | 20 + backend/tests/test_card_exercise.py | 8 +- backend/tests/test_dds_exercise.py | 299 +++++++-- backend/tests/test_demo_no_db.py | 3 +- backend/tests/test_ekp.py | 24 +- backend/tests/test_group_insight.py | 67 ++ backend/tests/test_local_models.py | 276 ++++++++ backend/tests/test_materials.py | 168 +++++ backend/tests/test_recording.py | 69 ++ backend/tests/test_report_export.py | 131 +++- backend/tests/test_scenario_editor.py | 472 ++++++++++++- backend/tests/test_score_weights.py | 11 +- backend/tests/test_session_access.py | 29 + backend/tests/test_session_checkpoint.py | 167 +++++ backend/tests/test_station.py | 17 +- backend/tests/test_ws.py | 27 + backend/uv.lock | 2 + docker-compose.sip.yml | 28 + docker-compose.tls.yml | 16 + docker-compose.yml | 82 ++- frontend/Dockerfile | 16 +- .../15-local-certificate.sh | 26 + frontend/nginx.conf.template | 38 ++ frontend/nginx.tls.conf.template | 71 ++ frontend/package-lock.json | 32 +- frontend/package.json | 1 + frontend/src/app/router.tsx | 26 +- frontend/src/features/auth/Login.tsx | 1 + frontend/src/features/auth/useAuth.ts | 6 +- frontend/src/features/call/useCall.ts | 15 +- frontend/src/features/debrief/Debrief.tsx | 11 + frontend/src/features/health/StandStatus.tsx | 2 +- frontend/src/features/instructor/Director.tsx | 2 +- .../features/instructor/ScenarioEditor.tsx | 156 ++++- .../src/features/instructor/useControl.ts | 8 +- frontend/src/features/kio-card/KioCard.tsx | 3 +- frontend/src/features/kio-card/fields.ts | 1 + frontend/src/features/observe/useObserve.ts | 6 + frontend/src/features/sip/SipPhone.tsx | 57 ++ frontend/src/features/sip/useSipPhone.ts | 171 +++++ frontend/src/pages/admin/Admin.tsx | 88 ++- frontend/src/pages/admin/api.ts | 44 +- frontend/src/pages/dds/Dds.tsx | 189 ++++-- frontend/src/pages/groups/Groups.tsx | 20 +- frontend/src/pages/instructor/Instructor.tsx | 217 +++++- frontend/src/pages/materials/Materials.tsx | 229 +++++++ frontend/src/pages/phone/Phone.tsx | 9 + frontend/src/pages/profile/Profile.tsx | 27 +- frontend/src/shared/api/http.ts | 155 ++++- frontend/src/shared/api/ws.ts | 69 +- frontend/src/shared/types/generated.ts | 100 ++- frontend/src/styles.css | 159 ++++- frontend/vite.config.ts | 11 +- scenarios/tickets/t01-1-fire-container.yaml | 2 +- scenarios/tickets/t01-2-mass-fight.yaml | 4 +- .../tickets/t01-3-child-other-region.yaml | 2 +- scenarios/tickets/t02-1-smoke-chute.yaml | 8 +- .../tickets/t02-2-megafon-consultation.yaml | 8 +- scenarios/tickets/t02-3-car-in-water.yaml | 14 +- .../tickets/t03-1-fire-private-house.yaml | 10 +- scenarios/tickets/t03-2-loud-music.yaml | 12 +- .../tickets/t03-3-log-blocked-driver.yaml | 8 +- scenarios/tickets/t04-1-balcony-fire.yaml | 2 +- .../tickets/t04-2-unconscious-parking.yaml | 6 +- scenarios/tickets/t04-3-open-door.yaml | 8 +- .../tickets/t05-1-window-fire-people.yaml | 4 +- scenarios/tickets/t05-2-wrong-medicine.yaml | 4 +- scenarios/tickets/t05-3-worker-in-pit.yaml | 6 +- scenarios/tickets/t06-1-car-fire.yaml | 4 +- scenarios/tickets/t06-2-man-in-car.yaml | 6 +- scenarios/tickets/t06-3-men-on-ice.yaml | 8 +- 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 | 6 +- scenarios/tickets/t09-2-labour.yaml | 6 +- scenarios/tickets/t09-3-fell-from-bridge.yaml | 4 +- .../tickets/t10-1-ticket-office-fire.yaml | 2 +- .../tickets/t10-2-elderly-crossroads.yaml | 4 +- scenarios/tickets/t10-3-help-behind-door.yaml | 4 +- scenarios/tickets/t11-1-bus-cabin-fire.yaml | 6 +- scenarios/tickets/t11-2-dizziness.yaml | 4 +- scenarios/tickets/t11-3-lost-in-forest.yaml | 8 +- scenarios/tickets/t12-1-restaurant-smoke.yaml | 10 +- scenarios/tickets/t12-2-heart-pain.yaml | 8 +- .../tickets/t12-3-men-on-bridge-rail.yaml | 14 +- scenarios/tickets/t13-1-park-trees-fire.yaml | 2 +- scenarios/tickets/t13-2-birth-in-car.yaml | 11 +- scenarios/tickets/t13-3-man-on-roadside.yaml | 6 +- scenarios/tickets/t14-1-grass-fire-azs.yaml | 8 +- scenarios/tickets/t14-2-asthma.yaml | 6 +- scenarios/tickets/t14-3-abduction.yaml | 2 +- .../tickets/t15-2-dived-head-injury.yaml | 2 +- scenarios/tickets/t15-3-knife-wound.yaml | 8 +- scenarios/tickets/t16-1-smoke-column.yaml | 10 +- .../tickets/t16-2-child-bicycle-volzhsky.yaml | 4 +- scenarios/tickets/t16-3-sleeping-pills.yaml | 11 +- scenarios/tickets/t17-1-fire-alarm.yaml | 8 +- scenarios/tickets/t17-2-seizure-in-shop.yaml | 9 +- scenarios/tickets/t17-3-beaten-woman.yaml | 4 +- scenarios/tickets/t18-1-unknown-fire.yaml | 8 +- .../tickets/t18-2-husband-wont-wake.yaml | 4 +- scenarios/tickets/t18-3-cries-for-help.yaml | 6 +- scenarios/tickets/t19-1-field-fire.yaml | 4 +- scenarios/tickets/t19-2-snake-bite.yaml | 2 +- scenarios/tickets/t19-3-train-injury.yaml | 2 +- scenarios/tickets/t20-1-three-fighting.yaml | 6 +- scenarios/tickets/t20-2-stroke.yaml | 4 +- scenarios/tickets/t20-3-rape.yaml | 6 +- .../tickets/t21-1-mass-fight-embassy.yaml | 4 +- scenarios/tickets/t21-2-axe-wound.yaml | 6 +- scenarios/tickets/t22-1-flat-fight.yaml | 6 +- scenarios/tickets/t22-2-child-stomach.yaml | 15 +- scenarios/tickets/t22-3-suicide-sms.yaml | 6 +- scenarios/tickets/t23-1-drunk-husband.yaml | 6 +- scenarios/tickets/t23-2-eardrum.yaml | 4 +- scenarios/tickets/t23-3-lost-child.yaml | 4 +- scenarios/tickets/t24-1-parking-quarrel.yaml | 8 +- scenarios/tickets/t24-2-child-slide-fall.yaml | 4 +- .../tickets/t24-3-begging-with-baby.yaml | 2 +- scenarios/tickets/t25-1-drunk-at-stop.yaml | 9 +- scenarios/tickets/t25-2-accident-vegas.yaml | 6 +- scenarios/tickets/t25-3-body-found.yaml | 6 +- .../tickets/t26-2-accident-melnitsa.yaml | 6 +- scenarios/tickets/t26-3-death-care-home.yaml | 6 +- scenarios/tickets/t27-1-suspicious-car.yaml | 6 +- scenarios/tickets/t27-2-accident-tunnel.yaml | 8 +- scenarios/tickets/t27-3-wall-crack.yaml | 8 +- scenarios/tickets/t28-1-stranger-at-door.yaml | 6 +- scenarios/tickets/t28-2-accident-mid.yaml | 4 +- scenarios/tickets/t28-3-loose-board.yaml | 8 +- scenarios/tickets/t29-1-ticking-box.yaml | 6 +- scenarios/tickets/t29-2-accident-fight.yaml | 4 +- .../tickets/t29-3-threat-to-blow-up.yaml | 10 +- .../tickets/t30-1-car-theft-yesterday.yaml | 10 +- scenarios/tickets/t30-3-gas-smell-house.yaml | 6 +- .../tickets/t31-1-car-theft-witnessed.yaml | 2 +- scenarios/tickets/t31-2-hit-and-run.yaml | 2 +- scenarios/tickets/t31-3-gas-pipe-whistle.yaml | 8 +- scenarios/tickets/t32-1-carjacking.yaml | 10 +- .../tickets/t32-2-trolleybus-accident.yaml | 2 +- scenarios/tickets/t32-3-street-lights.yaml | 14 +- scripts/demo.sh | 49 +- scripts/load_browser.py | 226 +++++++ scripts/load_db.py | 98 +++ scripts/smoke_active_session_recovery.py | 197 ++++++ scripts/smoke_mobile.py | 343 ++++++++++ scripts/smoke_persisted_report.py | 218 ++++++ scripts/smoke_recording.py | 121 ++++ scripts/smoke_sip.py | 286 ++++++++ scripts/smoke_sip_websocket.py | 134 ++++ scripts/smoke_webrtc_browser.py | 200 ++++++ scripts/test_recovery.py | 298 ++++++++ sip/.dockerignore | 2 + sip/Dockerfile | 22 + sip/entrypoint.sh | 50 ++ sip/extensions.conf | 28 + sip/http.conf | 8 + sip/modules.conf | 7 + sip/pjsip.conf.template | 156 +++++ sip/rtp.conf | 5 + 229 files changed, 12000 insertions(+), 1055 deletions(-) create mode 100644 backend/app/api/http/materials.py create mode 100644 backend/app/db/migrations/versions/a4c9d1e2f3b4_learning_materials.py create mode 100644 backend/app/db/migrations/versions/b5e0f2a3c4d5_active_session_checkpoint.py create mode 100644 backend/app/db/migrations/versions/c6f1a3b4d5e6_persistent_auth_version.py create mode 100644 backend/app/db/migrations/versions/d7e2f4a5b6c7_scenario_owners.py create mode 100644 backend/app/db/migrations/versions/f7a3c9d1e2b4_instructor_owned_groups.py create mode 100644 backend/app/monitoring.py create mode 100644 backend/app/scenarios/generation.py create mode 100644 backend/app/scoring/grammar.py create mode 100644 backend/app/scoring/group_insight.py create mode 100644 backend/app/session/checkpoint.py create mode 100644 backend/app/voice/recording.py create mode 100644 backend/scripts/backup_loop.py create mode 100644 backend/scripts/download_local_models.py create mode 100644 backend/scripts/local_llms.py create mode 100644 backend/scripts/local_stt.py create mode 100644 backend/scripts/smoke_voice.py create mode 100644 backend/tests/test_backup_scheduler.py create mode 100644 backend/tests/test_group_insight.py create mode 100644 backend/tests/test_local_models.py create mode 100644 backend/tests/test_materials.py create mode 100644 backend/tests/test_recording.py create mode 100644 backend/tests/test_session_checkpoint.py create mode 100644 docker-compose.sip.yml create mode 100644 docker-compose.tls.yml create mode 100755 frontend/docker-entrypoint.d/15-local-certificate.sh create mode 100644 frontend/nginx.conf.template create mode 100644 frontend/nginx.tls.conf.template create mode 100644 frontend/src/features/sip/SipPhone.tsx create mode 100644 frontend/src/features/sip/useSipPhone.ts create mode 100644 frontend/src/pages/materials/Materials.tsx create mode 100644 frontend/src/pages/phone/Phone.tsx create mode 100644 scripts/load_browser.py create mode 100644 scripts/load_db.py create mode 100644 scripts/smoke_active_session_recovery.py create mode 100644 scripts/smoke_mobile.py create mode 100644 scripts/smoke_persisted_report.py create mode 100644 scripts/smoke_recording.py create mode 100644 scripts/smoke_sip.py create mode 100644 scripts/smoke_sip_websocket.py create mode 100644 scripts/smoke_webrtc_browser.py create mode 100644 scripts/test_recovery.py create mode 100644 sip/.dockerignore create mode 100644 sip/Dockerfile create mode 100644 sip/entrypoint.sh create mode 100644 sip/extensions.conf create mode 100644 sip/http.conf create mode 100644 sip/modules.conf create mode 100644 sip/pjsip.conf.template create mode 100644 sip/rtp.conf diff --git a/.env.example b/.env.example index ca65c76..a8fb3f7 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,56 @@ # Скопировать в .env. Файл в .gitignore: ключи в репозиторий не едут. +BIND_HOST=127.0.0.1 +POSTGRES_PORT=5432 +BACKEND_PORT=8000 +FRONTEND_PORT=5173 +TLS_PORT=5443 +TLS_CERT_DIR=./.local/tls +SIP_PORT=5060 +SIPS_PORT=5061 +SIP_WS_PORT=8088 +RTP_PORT_START=10000 +RTP_PORT_END=10099 +SIP_TLS_DIR=./.local/sip-tls +# Если пусто, Asterisk создаст стойкие случайные пароли в volume sipdata. +SIP_6001_PASSWORD= +SIP_6002_PASSWORD= +SIP_6003_PASSWORD= +SIP_6101_PASSWORD= +SIP_6102_PASSWORD= +# IP/DNS сервера, который WebRTC-клиенты видят в локальной сети. При запуске +# только на той же машине оставьте 127.0.0.1; RTP-порты должны отображаться 1:1. +SIP_EXTERNAL_MEDIA_ADDRESS=127.0.0.1 DATABASE_URL=postgresql+asyncpg://lct:lct@localhost:5432/lct -# LLM: провайдер меняется значением, не кодом (docs/arch/STACK.md) -LLM_PROVIDER=gigachat +# Два локальных OpenAI-совместимых сервера (например, llama-server). +# Адреса должны быть loopback: OFFLINE=true не допускает внешний API. +LLM_PROVIDER=local +LLM_BASE_URL=http://127.0.0.1:18080/v1 +# Адреса тех же процессов из backend-контейнера Docker Desktop (macOS/Windows). +DOCKER_LLM_BASE_URL=http://host.docker.internal:18080/v1 LLM_API_KEY= -LLM_MODEL_CALLER= -LLM_MODEL_JUDGE= +LLM_MODEL_CALLER=Qwen3-1.7B +LLM_CONTROL_BASE_URL=http://127.0.0.1:18081/v1 +DOCKER_LLM_CONTROL_BASE_URL=http://host.docker.internal:18081/v1 +LLM_MODEL_CONTROL=Vikhr-1B +GRAMMAR_LLM_ENABLED=true +DIALOGUE_MODEL_MODE=dialogue JUDGE_TEMPERATURE=0 # Голосовой контур MODELS_DIR=models -STT_MODEL=gigaam-v3-rnnt +STT_MODEL=whisper-small +WHISPER_MODEL_PATH=models/whisper-small +WHISPER_SERVER_URL=http://127.0.0.1:18082 +VOICE_ENABLED=false +RECORD_CALLS=true +RECORDINGS_DIR=recordings +BACKUP_INTERVAL_SECONDS=86400 +BACKUP_RETRY_SECONDS=300 +BACKUP_KEEP=14 ENDPOINTING_MS=600 -OFFLINE=false +OFFLINE=true +SECURE_COOKIES=false +# Необязательно для Compose: если пусто, стойкий случайный ключ создаётся в +# volume securitydata. На управляемом стенде можно задать свой 48+ байтный ключ. +SESSION_SECRET= diff --git a/.gitignore b/.gitignore index 919f7a7..eb821bd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ backend/models/ # Резервные копии базы (make backup) backend/backups/ +backend/recordings/ +recordings/ # Python __pycache__/ @@ -22,9 +24,14 @@ frontend/dist/ .env.* !.env.example *.local +.local/ # Python .pytest_cache/ +# Temporary SQLite files created by local in-memory demo/test runs +:memory:.ses +backend/:memory:.ses + # Артефакты Windows рядом со скачанными файлами *:Zone.Identifier diff --git a/Makefile b/Makefile index 544ce03..8a9d4ef 100644 --- a/Makefile +++ b/Makefile @@ -21,10 +21,34 @@ dev: ## Поднять стенд: postgres + backend --reload + frontend offline: ## Поднять карточки/ДДС из заранее собранных локальных образов, без скачивания $(COMPOSE) up --pull never --no-build +tls: ## Поднять полный стенд по HTTPS/WSS с локальным сертификатом + $(COMPOSE) -f docker-compose.yml -f docker-compose.tls.yml up --build + +offline-tls: ## Поднять HTTPS/WSS из уже собранных образов без сети + $(COMPOSE) -f docker-compose.yml -f docker-compose.tls.yml up --pull never --no-build + +sip: ## Собрать и поднять локальный Asterisk SIP/VoIP + $(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml up --build -d sip + +offline-sip: ## Поднять SIP из уже собранного образа без сети + $(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml up --pull never --no-build -d sip + +sip-credentials: ## Показать локальные пароли софтфонов 6001–6003 и WebRTC 6101–6102 + $(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml exec -T sip cat /var/lib/lct-sip/credentials.env + +sip-test: ## Проверить регистрацию, звонок, RTP и задержку (args="--rtp-host-offset 1000") + python3 scripts/smoke_sip.py --port $(or $(SIP_PORT),5060) $(args) + +webrtc-test: ## Проверить два браузерных SIP-клиента, аудио и запись Asterisk + python3 scripts/smoke_webrtc_browser.py \ + --frontend-url https://127.0.0.1:$(or $(TLS_PORT),5443) \ + --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) + down: ## Погасить стенд $(COMPOSE) down -back: ## Бэкенд нативно, с голосовым контуром (порт 8000: сначала docker compose stop backend) +back: ## Бэкенд нативно, без голоса по умолчанию (порт 8000: сначала docker compose stop backend) cd backend && $(UV) run --extra voice uvicorn app.main:app --reload --port 8000 --workers 1 front: ## Только фронтенд, локально @@ -42,9 +66,18 @@ test-voice: ## Тест голосового контура на настоящ typecheck: ## Проверить фронтенд компилятором npm --prefix frontend run typecheck -models: ## Скачать модели в backend/models/: эмбеддинги, GigaAM, Silero VAD; Silero TTS — вручную +models: ## Скачать эмбеддинги и Silero VAD; Silero TTS — вручную cd backend && $(UV) run python scripts/models.py +local-models: ## Скачать и проверить Qwen3 1.7B, Vikhr 1B, Whisper small (нужна сеть один раз) + cd backend && $(UV) run --offline --no-sync python scripts/download_local_models.py + +local-llm: ## Запустить Qwen3 на localhost:18080 (mode=both для Qwen3 и Vikhr) + cd backend && python3 scripts/local_llms.py --mode $(or $(mode),dialogue) + +local-stt: ## Запустить локальный whisper.cpp server на localhost:18082 + cd backend && python3 scripts/local_stt.py + backup: ## Резервная копия базы в backend/backups/ (для cron: раз в сутки) cd backend && $(UV) run python scripts/backup.py @@ -63,7 +96,7 @@ lesson: ## Запустить занятие и напечатать ссылк test-llm: ## Живые проверки LLM по backend/.env.test (медленно: рассуждающая модель) cd backend && $(UV) run --extra dev pytest tests/test_llm.py -m llm -q -s -llm-check: ## Один запрос к LLM: проверить ключ и адрес из backend/.env +llm-check: ## Один запрос к активной локальной модели из backend/.env cd backend && $(UV) run python scripts/llm_check.py latency: ## Замер задержки голосового контура по этапам — запускать на демо-машине @@ -81,10 +114,10 @@ repl: ## Текстовый диалог со звонящим без голос pregen: ## Офлайн-таблицы реплик: make pregen [s=<сценарий>] [t=<билет>] [force=1] cd backend && s="$(s)" t="$(t)" force="$(force)" $(UV) run python scripts/pregenerate.py -demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт +demo: ## Поднять основной стенд ДДС: база, готовые карточки, бэкенд и фронт ./scripts/demo.sh 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 down back front types users users-docker backup test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo demo-lite +.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 diff --git a/backend/Dockerfile b/backend/Dockerfile index d30b636..bcda384 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,7 +7,7 @@ ENV PYTHONUNBUFFERED=1 # Экспорт PDF требует локальный TTF с кириллицей; базовый slim-образ его не # гарантирует. Шрифт запекается в образ при подготовке офлайн-комплекта. -RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core \ +RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core postgresql-client \ && rm -rf /var/lib/apt/lists/* # Зависимости — из pyproject.toml, а не отдельным списком: ручной список разошёлся diff --git a/backend/app/admin/backup.py b/backend/app/admin/backup.py index c1c5c8f..7d75a14 100644 --- a/backend/app/admin/backup.py +++ b/backend/app/admin/backup.py @@ -7,13 +7,16 @@ честно говорим, чего не хватает. """ +import base64 import logging import os -import re import shutil import subprocess +import tempfile from datetime import datetime, timezone +from getpass import getuser from pathlib import Path +from urllib.parse import unquote, urlsplit from app.config import get_settings @@ -23,51 +26,122 @@ DIR = Path(__file__).resolve().parents[2] / "backups" #: Контейнер базы в docker compose проекта. CONTAINER = os.environ.get("POSTGRES_CONTAINER", "lct-hack-postgres-1") TIMEOUT_S = 120 +# pg_dump 17 добавляет эту настройку в plain SQL, а целевая PostgreSQL 16 из +# Compose её ещё не знает. Остальной plain dump обратно совместим; удаляем +# только служебный SET, не данные и не DDL. +INCOMPATIBLE_SETTINGS = (b"SET transaction_timeout = 0;\n",) class BackupError(RuntimeError): """Копию снять нечем или не удалось. Сообщение показывается администратору.""" +def _restrict_file_permissions(fd: int, path: Path, platform_name: str | None = None) -> None: + """Закрыть резервную копию от остальных локальных пользователей. + + chmod(0o600) не задаёт ACL на Windows, поэтому там icacls удаляет + наследуемые разрешения и оставляет полный доступ владельцу процесса и + системной учётной записи. При ошибке копию не публикуем. + """ + platform_name = platform_name or os.name + if platform_name == "nt": + username = getuser() + domain = os.environ.get("USERDOMAIN") + if domain and "\\" not in username: + username = f"{domain}\\{username}" + system_root = Path(os.environ.get("SystemRoot", "C:\\Windows")) + icacls = system_root / "System32" / "icacls.exe" + try: + result = subprocess.run( + [str(icacls), str(path), "/inheritance:r", "/grant:r", + f"{username}:(F)", "*S-1-5-18:(F)"], + capture_output=True, + text=True, + timeout=15, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise BackupError("не удалось задать ACL резервной копии через icacls") from exc + if result.returncode != 0: + raise BackupError("Windows не применила закрытый ACL к резервной копии") + return + if hasattr(os, "fchmod"): + try: + os.fchmod(fd, 0o600) + except OSError as exc: + raise BackupError("не удалось ограничить права резервной копии") from exc + else: + try: + os.chmod(path, 0o600) + except OSError as exc: + raise BackupError("не удалось ограничить права резервной копии") from exc + + def _dsn() -> tuple[str, str, str, str, str]: """Разобрать DATABASE_URL на части для pg_dump.""" url = get_settings().database_url - match = re.match(r".*://([^:]+):([^@]+)@([^:/]+):(\d+)/(\w+)", url) - if not match: - raise BackupError(f"не разобрать DATABASE_URL: {url}") - return match.groups() # user, password, host, port, database + try: + parsed = urlsplit(url) + user = unquote(parsed.username or "") + password = unquote(parsed.password or "") + host = parsed.hostname or "" + port = parsed.port + database = unquote(parsed.path.lstrip("/")) + except ValueError as exc: + # Не включать исходный URL в диагностику: он содержит пароль. + raise BackupError("не удалось разобрать DATABASE_URL") from exc + if not all((user, password, host, port, database)) or "/" in database: + raise BackupError("не удалось разобрать DATABASE_URL") + return user, password, host, str(port), database def listing() -> list[dict]: """Готовые копии, новые первыми.""" - if not DIR.exists(): + items = [] + try: + with os.scandir(DIR) as entries: + for entry in entries: + if not entry.name.endswith(".sql") or not entry.is_file(follow_symlinks=False): + continue + stat = entry.stat(follow_symlinks=False) + items.append({ + "name": entry.name, + "size_bytes": stat.st_size, + "at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), + }) + except FileNotFoundError: return [] - items = [ - { - "name": path.name, - "size_bytes": path.stat().st_size, - "at": datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc), - } - for path in DIR.glob("*.sql") - ] + except OSError as exc: + raise BackupError(f"не удалось прочитать каталог резервных копий: {exc}") from exc return sorted(items, key=lambda item: item["at"], reverse=True) def create() -> dict: """Снять копию. Возвращает описание файла, как в `listing()`.""" user, password, host, port, database = _dsn() - DIR.mkdir(parents=True, exist_ok=True) - stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + try: + DIR.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise BackupError(f"недоступен каталог резервных копий: {exc}") from exc + stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f") target = DIR / f"lct-{stamp}.sql" if shutil.which("pg_dump"): command = ["pg_dump", "-h", host, "-p", port, "-U", user, "-d", database] env = {**os.environ, "PGPASSWORD": password} + stdin_data = None elif shutil.which("docker"): # В контейнере база доступна как localhost, а не по хосту из DSN. - command = ["docker", "exec", "-e", f"PGPASSWORD={password}", CONTAINER, - "pg_dump", "-U", user, "-d", database] + # Пароль передаём через stdin, а не аргументом `docker exec -e`, + # поскольку аргументы процесса могут быть видны другим пользователям. + script = ( + "IFS= read -r encoded_password || exit 1; " + 'PGPASSWORD="$(printf %s "$encoded_password" | base64 -d)" || exit 1; ' + 'export PGPASSWORD; exec pg_dump -U "$1" -d "$2"' + ) + command = ["docker", "exec", "-i", CONTAINER, "sh", "-c", script, + "lct-pg-dump", user, database] env = dict(os.environ) + stdin_data = base64.b64encode(password.encode("utf-8")) + b"\n" else: raise BackupError( "нечем снять копию: нет ни pg_dump, ни docker. " @@ -75,7 +149,9 @@ def create() -> dict: ) try: - result = subprocess.run(command, capture_output=True, timeout=TIMEOUT_S, env=env) + result = subprocess.run( + command, capture_output=True, timeout=TIMEOUT_S, env=env, input=stdin_data + ) except subprocess.TimeoutExpired as exc: raise BackupError(f"pg_dump не уложился в {TIMEOUT_S} с") from exc except OSError as exc: @@ -84,10 +160,38 @@ def create() -> dict: if result.returncode != 0: raise BackupError(result.stderr.decode(errors="replace")[:300] or "pg_dump завершился с ошибкой") - target.write_bytes(result.stdout) - log.info("резервная копия: %s, %d КБ", target.name, target.stat().st_size // 1024) + dump = result.stdout + for setting in INCOMPATIBLE_SETTINGS: + dump = dump.replace(setting, b"") + if not dump.startswith(b"--\n-- PostgreSQL database dump"): + raise BackupError("pg_dump вернул неожиданный или пустой формат") + # Публикуем только полностью записанный dump. При аварийном завершении + # процесса список копий не должен показывать обрезанный файл как готовый. + try: + fd, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=DIR) + except OSError as exc: + raise BackupError(f"не удалось создать временный файл копии: {exc}") from exc + temporary = Path(temporary_name) + try: + with os.fdopen(fd, "wb") as stream: + _restrict_file_permissions(stream.fileno(), temporary) + stream.write(dump) + stream.flush() + os.fsync(stream.fileno()) + temporary.replace(target) + except Exception as exc: + try: + temporary.unlink(missing_ok=True) + except OSError: + log.exception("не удалось удалить временную копию %s", temporary.name) + if isinstance(exc, OSError): + raise BackupError(f"не удалось записать резервную копию: {exc}") from exc + raise + size_bytes = len(dump) + created_at = datetime.now(timezone.utc) + log.info("резервная копия: %s, %d КБ", target.name, size_bytes // 1024) return { "name": target.name, - "size_bytes": target.stat().st_size, - "at": datetime.fromtimestamp(target.stat().st_mtime, tz=timezone.utc), + "size_bytes": size_bytes, + "at": created_at, } diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 0f8e2df..91240d1 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -16,6 +16,7 @@ """ import asyncio +import hashlib import logging import secrets import weakref @@ -24,7 +25,7 @@ from uuid import UUID from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError from fastapi import APIRouter, HTTPException, Request, WebSocket -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy import select from app.config import get_settings @@ -37,10 +38,12 @@ router = APIRouter(prefix="/api/auth", tags=["auth"]) DEMO_TRAINEE_ID = UUID("00000000-0000-4000-8000-000000000112") _hasher = PasswordHasher() -# Cookie is signed, but carries a role snapshot. A changed account must not -# keep its old privileges for the full 12-hour cookie lifetime. Compose runs -# one worker; the process marker also invalidates all cookies after restart. -_INSTANCE = secrets.token_urlsafe(32) +# Cookie is signed and may safely survive a backend restart. Marker changes +# only together with SESSION_SECRET; account edits are revoked independently +# through the persistent auth_version loaded below. +_INSTANCE = hashlib.sha256( + f"lct-auth-instance:{get_settings().session_secret}".encode() +).hexdigest() _generations: dict[str, int] = {} _active_sockets: dict[str, weakref.WeakKeyDictionary] = {} @@ -53,9 +56,21 @@ async def _close_revoked(ws: WebSocket) -> None: pass -def invalidate_login(login: str) -> None: +def prime_generations(values: dict[str, int]) -> None: + """Загрузить версии полномочий из БД при старте нового процесса.""" + _generations.clear() + _generations.update(values) + + +async def load_generations() -> None: + async with get_sessionmaker()() as db: + rows = (await db.execute(select(User.login, User.auth_version))).all() + prime_generations({login: version for login, version in rows}) + + +def invalidate_login(login: str, version: int | None = None) -> None: """Revoke previously issued cookies after account/role/password changes.""" - _generations[login] = _generations.get(login, 0) + 1 + _generations[login] = version if version is not None else _generations.get(login, 0) + 1 for ws, loop in list(_active_sockets.get(login, {}).items()): try: if not loop.is_closed(): @@ -117,8 +132,8 @@ class Principal(BaseModel): class LoginIn(BaseModel): - login: str - password: str + login: str = Field(min_length=3, max_length=80) + password: str = Field(min_length=1, max_length=1024) def _demo_local(request: Request) -> None: @@ -199,10 +214,17 @@ async def login(payload: LoginIn, request: Request) -> dict: # Одинаковый ответ на неизвестный логин и неверный пароль: иначе форма # входа превращается в список действующих учётных записей. if user is None or not verify_password(user.password_hash, payload.password): + # Не записываем пароль, IP либо факт существования учётной записи. + # Логин нужен администратору для расследования перебора; ограничиваем + # длину до размера поля AuditLog.actor. + await audit(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") raise HTTPException(status_code=403, detail="blocked") + _generations[user.login] = user.auth_version + who = Principal( login=user.login, full_name=user.full_name, diff --git a/backend/app/api/http/admin.py b/backend/app/api/http/admin.py index e8be34c..4957799 100644 --- a/backend/app/api/http/admin.py +++ b/backend/app/api/http/admin.py @@ -11,10 +11,13 @@ import logging import re -from datetime import datetime +from datetime import datetime, timedelta, timezone from uuid import UUID +from xml.etree import ElementTree as ET -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError @@ -27,13 +30,68 @@ from app.config import get_settings from app.db.base import get_session from app.db.models import AuditLog, Session as SessionRow, Trainee, User from app.domain import ekp -from app.domain.roles import ROLE_LABELS, Role +from app.domain.roles import ROLE_LABELS, SCREENS, Role +from app.domain.timers import NORMATIVES +from app.dialog.llm import is_loopback_url +from app.monitoring import recent_events, sample_metrics from app.session.hub import hub log = logging.getLogger(__name__) router = APIRouter(prefix="/api/admin", tags=["admin"]) +def _configuration_xml() -> bytes: + """Безопасный переносимый снимок конфигурации без паролей и ключей.""" + settings = get_settings() + root = ET.Element("lctConfiguration", {"version": "1"}) + ET.SubElement(root, "platform", { + "offline": str(settings.offline).lower(), + "voiceEnabled": str(settings.voice_enabled).lower(), + "secureCookies": str(settings.secure_cookies).lower(), + }) + models = ET.SubElement(root, "localModels") + ET.SubElement(models, "dialogue", {"name": settings.llm_model_caller}) + ET.SubElement(models, "russianControl", { + "name": settings.llm_model_control, + "grammarEnabled": str(settings.grammar_llm_enabled).lower(), + }) + ET.SubElement(models, "speechToText", { + "name": settings.stt_model, + "enabled": str(settings.voice_enabled).lower(), + }) + workstations = ET.SubElement(root, "workstations") + for role, screens in SCREENS.items(): + workstation = ET.SubElement(workstations, "workstation", { + "role": role.value, "label": ROLE_LABELS[role], + }) + for path in screens: + ET.SubElement(workstation, "screen", {"path": path}) + timers = ET.SubElement(root, "timerLimits") + for code, normative in NORMATIVES.items(): + ET.SubElement(timers, "timer", { + "code": code.value, + "milliseconds": str(settings.limit_ms(code)), + "defaultMilliseconds": str(normative.limit_ms), + }) + reference = ekp.reference() + ET.SubElement(root, "ekp", { + "version": reference.version, + "incidents": str(len(reference.incidents)), + }) + ET.indent(root, space=" ") + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +@router.get("/config.xml") +async def configuration_xml(request: Request) -> Response: + require(request, Role.ADMIN) + return Response( + content=_configuration_xml(), + media_type="application/xml", + headers={"Content-Disposition": 'attachment; filename="lct-workstations.xml"'}, + ) + + class UserOut(BaseModel): id: UUID login: str @@ -47,7 +105,7 @@ class UserOut(BaseModel): class UserCreate(BaseModel): login: str = Field(min_length=3, max_length=80) full_name: str = Field(min_length=1, max_length=120) - password: str = Field(min_length=8, description="Короткий пароль не заводится") + password: str = Field(min_length=8, max_length=1024, description="Пароль должен быть от 8 до 1024 символов") role: Role service: str | None = None @@ -59,7 +117,7 @@ class UserPatch(BaseModel): role: Role | None = None service: str | None = None blocked: bool | None = None - password: str | None = Field(default=None, min_length=8) + password: str | None = Field(default=None, min_length=8, max_length=1024) def _out(user: User) -> UserOut: @@ -148,8 +206,9 @@ async def patch_user( if not changed: return _out(user) + user.auth_version += 1 await db.commit() - invalidate_login(user.login) + invalidate_login(user.login, user.auth_version) await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed)) return _out(user) @@ -169,12 +228,18 @@ async def audit_log( action: str | None = None, actor: str | None = None, limit: int = 200, + offset: int = 0, db: AsyncSession = Depends(get_session), ) -> list[AuditOut]: """Журнал действий. Администратор его читает, но не правит: точки удаления или изменения записи здесь нет — ТЗ требует хранения, а не управления.""" require(request, Role.ADMIN) - query = select(AuditLog).order_by(AuditLog.at.desc()).limit(max(1, min(limit, 1000))) + query = ( + select(AuditLog) + .order_by(AuditLog.at.desc(), AuditLog.id.desc()) + .limit(max(1, min(limit, 1001))) + .offset(max(0, min(offset, 10_000_000))) + ) if action: query = query.where(AuditLog.action == action) if actor: @@ -195,6 +260,104 @@ class ServiceState(BaseModel): detail: str +class RuntimeMetrics(BaseModel): + at: datetime + uptime_seconds: float + cpu_percent: float + load_1m_percent: float | None + cpu_cores: int + rss_bytes: int | None + memory_total_bytes: int | None + memory_available_bytes: int | None + disk_total_bytes: int + disk_free_bytes: int + threads: int + active_sessions: int + restored_sessions: int + completed_sessions_24h: int + + +class DiagnosticEvent(BaseModel): + at: datetime + level: str + source: str + message: str + + +class FailureEvent(BaseModel): + at: datetime + actor: str + action: str + object_id: str | None + detail: str + + +class DiagnosticReport(BaseModel): + generated_at: datetime + metrics: RuntimeMetrics + recent_system_events: list[DiagnosticEvent] + failed_actions_24h: list[FailureEvent] + + +async def _runtime_metrics(db: AsyncSession) -> RuntimeMetrics: + from app.main import app + + raw = sample_metrics() + since = datetime.now(timezone.utc) - timedelta(hours=24) + completed = await db.scalar( + select(func.count()).select_from(SessionRow).where(SessionRow.ended_at >= since) + ) + return RuntimeMetrics( + **raw, + active_sessions=sum(not item.ended for item in hub._sessions.values()), # noqa: SLF001 + restored_sessions=getattr(app.state, "sessions_restored", 0), + completed_sessions_24h=completed or 0, + ) + + +async def _diagnostic_report(db: AsyncSession) -> DiagnosticReport: + since = datetime.now(timezone.utc) - timedelta(hours=24) + rows = await db.scalars( + select(AuditLog) + .where(AuditLog.at >= since, AuditLog.action.like("%.failed")) + .order_by(AuditLog.at.desc()) + .limit(200) + ) + return DiagnosticReport( + generated_at=datetime.now(timezone.utc), + metrics=await _runtime_metrics(db), + recent_system_events=[DiagnosticEvent(**item) for item in recent_events(limit=100)], + failed_actions_24h=[ + FailureEvent( + at=row.at, actor=row.actor, action=row.action, + object_id=row.object_id, detail=row.detail, + ) + for row in rows + ], + ) + + +@router.get("/diagnostics", response_model=DiagnosticReport) +async def diagnostics( + request: Request, db: AsyncSession = Depends(get_session) +) -> DiagnosticReport: + """Live load plus a bounded, redacted incident report for the admin.""" + require(request, Role.ADMIN) + return await _diagnostic_report(db) + + +@router.get("/diagnostics.json") +async def download_diagnostics( + request: Request, db: AsyncSession = Depends(get_session) +) -> JSONResponse: + require(request, Role.ADMIN) + report = await _diagnostic_report(db) + return JSONResponse( + jsonable_encoder(report), + headers={"Content-Disposition": 'attachment; filename="lct-diagnostics.json"'}, + ) + + @router.get("/status", response_model=list[ServiceState]) async def status(request: Request, db: AsyncSession = Depends(get_session)) -> list[ServiceState]: """Состояние компонентов стенда — то, что администратор смотрит до занятия, @@ -220,6 +383,21 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l detail="распознавание и синтез готовы" if models_ready else "не загружены: занятие пойдёт без голоса", ) ) + metrics = sample_metrics() + disk_free = metrics["disk_free_bytes"] + disk_total = max(metrics["disk_total_bytes"], 1) + disk_ok = disk_free >= 1024 ** 3 and disk_free / disk_total >= 0.05 + load = metrics["load_1m_percent"] + states.append(ServiceState( + name="Нагрузка backend", + ok=disk_ok and (load is None or load < 100), + detail=( + f"CPU процесса {metrics['cpu_percent']:.1f}%; " + + (f"нагрузка хоста {load:.1f}%; " if load is not None else "") + + f"RAM процесса {(metrics['rss_bytes'] or 0) / 1024 ** 2:.0f} МБ; " + + f"свободно на диске {disk_free / 1024 ** 3:.1f} ГБ" + ), + )) states.append( ServiceState( name="Эмбеддинги", @@ -228,11 +406,20 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l else "нет модели: подсказки идут по порядку чек-листа", ) ) + llm_configured = ( + is_loopback_url( + settings.llm_base_url, + allow_docker_host=settings.allow_docker_host_models, + ) + if settings.offline or settings.llm_provider == "local" + else bool(settings.llm_api_key and settings.llm_base_url) + ) states.append( ServiceState( name="Провайдер LLM", - ok=bool(settings.llm_api_key and settings.llm_base_url), - detail=settings.llm_base_url or "не настроен: звонящий читает офлайн-таблицу", + ok=llm_configured, + detail=(f"локальный адрес разрешён: {settings.llm_base_url}" + if llm_configured else "не настроен: звонящий читает офлайн-таблицу"), ) ) @@ -248,9 +435,39 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l ServiceState( name="Живых занятий", ok=True, - detail=str(len(hub._sessions)), # noqa: SLF001 — реестр в памяти процесса + detail=( + f"активно {sum(not item.ended for item in hub._sessions.values())}; " + f"восстановлено после запуска {getattr(app.state, 'sessions_restored', 0)}" + ), # noqa: SLF001 — реестр в памяти процесса ) ) + try: + copies = backup_service.listing() + except backup_service.BackupError as exc: + states.append(ServiceState( + name="Резервное копирование", + ok=False, + detail=f"ошибка чтения копий: {exc}", + )) + copies = [] + if copies: + latest = copies[0] + age_seconds = max(0.0, (datetime.now(timezone.utc) - latest["at"]).total_seconds()) + allowed_age = max(60, settings.backup_interval_seconds) + max( + 60, settings.backup_retry_seconds + ) + states.append(ServiceState( + name="Резервное копирование", + ok=age_seconds <= allowed_age, + detail=(f"последняя копия {latest['name']}, " + f"{age_seconds / 3600:.1f} ч назад; хранится {len(copies)}"), + )) + else: + states.append(ServiceState( + name="Резервное копирование", + ok=False, + detail="успешных копий ещё нет", + )) # Секрет сессии по умолчанию — не ошибка запуска, но на стенде это дыра, # и увидеть её должен администратор, а не проверяющий. default_secret = settings.session_secret.startswith("dev-secret") @@ -273,7 +490,12 @@ class BackupOut(BaseModel): @router.get("/backups", response_model=list[BackupOut]) async def backups(request: Request) -> list[BackupOut]: require(request, Role.ADMIN) - return [BackupOut(**item) for item in await run_in_threadpool(backup_service.listing)] + try: + copies = await run_in_threadpool(backup_service.listing) + except backup_service.BackupError as exc: + detail = _safe_backup_error(exc) + raise HTTPException(status_code=503, detail=detail) from exc + return [BackupOut(**item) for item in copies] def _safe_backup_error(exc: backup_service.BackupError) -> str: diff --git a/backend/app/api/http/groups.py b/backend/app/api/http/groups.py index 55b8f07..7d0c662 100644 --- a/backend/app/api/http/groups.py +++ b/backend/app/api/http/groups.py @@ -10,9 +10,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.auth import audit, require from app.db.base import get_session -from app.db.models import Group, Score, Session, Trainee +from app.db.models import Group, Score, Session, Trainee, User from app.domain.roles import Role from app.scoring.group import ScoredAttempt, summarize +from app.scoring.group_insight import InsightInvalid, generate_group_insight +from app.dialog.llm import LlmUnavailable router = APIRouter(prefix="/api/groups", tags=["groups"]) @@ -26,6 +28,10 @@ class GroupCreate(BaseModel): name: str = Field(min_length=1, max_length=120) +class GroupOwnerPatch(BaseModel): + owner_login: str | None + + class GroupErrorOut(BaseModel): code: str title: str @@ -44,62 +50,23 @@ class GroupAnalyticsOut(BaseModel): errors: list[GroupErrorOut] -@router.get("", response_model=list[GroupOut]) -async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[GroupOut]: - require(request, Role.INSTRUCTOR, Role.ADMIN) - groups = await db.scalars(select(Group).order_by(Group.name)) - return [GroupOut(id=group.id, name=group.name) for group in groups] +class GroupInsightOut(BaseModel): + summary: str + priorities: list[str] + source: str = "local_qwen" + personal_data_sent: bool = False -@router.post("", response_model=GroupOut, status_code=201) -async def create( - body: GroupCreate, request: Request, db: AsyncSession = Depends(get_session) -) -> GroupOut: - who = require(request, Role.INSTRUCTOR, Role.ADMIN) - name = body.name.strip() - if not name: - raise HTTPException(status_code=422, detail="group_name_required") - group = Group(name=name) - db.add(group) - 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) - - -@router.put("/{group_id}/trainees/{trainee_id}", response_model=GroupOut) -async def assign_trainee( - group_id: UUID, trainee_id: UUID, request: Request, - db: AsyncSession = Depends(get_session), -) -> GroupOut: - who = require(request, Role.INSTRUCTOR, Role.ADMIN) - group = await db.get(Group, group_id) - if group is None: - raise HTTPException(status_code=404, detail="group_not_found") - trainee = await db.get(Trainee, trainee_id) - if trainee is None: - raise HTTPException(status_code=404, detail="trainee_not_found") - trainee.group_id = group_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) - - -@router.get("/{group_id}/analytics", response_model=GroupAnalyticsOut) -async def analytics( - group_id: UUID, request: Request, db: AsyncSession = Depends(get_session) +async def _analytics( + group_id: UUID, db: AsyncSession, *, owner_login: str | None = None ) -> GroupAnalyticsOut: - require(request, Role.INSTRUCTOR, Role.ADMIN) group = await db.get(Group, group_id) - if group is None: + if group is None or (owner_login is not None and group.owner_login != owner_login): raise HTTPException(status_code=404, detail="group_not_found") enrolled = await db.scalar( select(func.count()).select_from(Trainee).where(Trainee.group_id == group_id) ) - rows = await db.execute( + attempts_query = ( select(Session.trainee_id, Score.score_final, Score.report) .join(Score, Score.session_id == Session.id) .join(Trainee, Trainee.id == Session.trainee_id, isouter=True) @@ -111,6 +78,9 @@ async def analytics( Session.ended_at.is_not(None), ) ) + if owner_login is not None: + attempts_query = attempts_query.where(Session.owner_login == owner_login) + rows = await db.execute(attempts_query) attempts = [ ScoredAttempt( trainee_id=trainee_id, @@ -123,3 +93,116 @@ async def analytics( group=GroupOut(id=group.id, name=group.name), **summarize(attempts, int(enrolled or 0)), ) + + +@router.get("", response_model=list[GroupOut]) +async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[GroupOut]: + who = require(request, Role.INSTRUCTOR, Role.ADMIN) + query = select(Group).order_by(Group.name) + if who.role is Role.INSTRUCTOR: + query = query.where(Group.owner_login == who.login) + groups = await db.scalars(query) + return [GroupOut(id=group.id, name=group.name) for group in groups] + + +@router.post("", response_model=GroupOut, status_code=201) +async def create( + body: GroupCreate, request: Request, db: AsyncSession = Depends(get_session) +) -> GroupOut: + who = require(request, Role.INSTRUCTOR, Role.ADMIN) + 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) + db.add(group) + 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) + + +@router.patch("/{group_id}/owner", response_model=GroupOut) +async def transfer_ownership( + group_id: UUID, + body: GroupOwnerPatch, + request: Request, + db: AsyncSession = Depends(get_session), +) -> GroupOut: + """Администратор безопасно закрепляет legacy-группу за преподавателем.""" + who = require(request, Role.ADMIN) + group = await db.get(Group, group_id) + if group is None: + raise HTTPException(status_code=404, detail="group_not_found") + if body.owner_login is not None: + user = await db.scalar(select(User).where(User.login == body.owner_login)) + if user is None or user.role != Role.INSTRUCTOR.value or user.blocked: + 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), + f"{previous_owner or 'admin'} -> {body.owner_login or 'admin'}", + ) + return GroupOut(id=group.id, name=group.name) + + +@router.put("/{group_id}/trainees/{trainee_id}", response_model=GroupOut) +async def assign_trainee( + group_id: UUID, trainee_id: UUID, request: Request, + db: AsyncSession = Depends(get_session), +) -> GroupOut: + who = require(request, Role.INSTRUCTOR, Role.ADMIN) + group = await db.get(Group, group_id) + if group is None or (who.role is Role.INSTRUCTOR and group.owner_login != who.login): + raise HTTPException(status_code=404, detail="group_not_found") + trainee = await db.get(Trainee, trainee_id) + if trainee is None: + raise HTTPException(status_code=404, detail="trainee_not_found") + if ( + who.role is Role.INSTRUCTOR + and trainee.group_id is not None + and trainee.group_id != group_id + ): + current_group = await db.get(Group, trainee.group_id) + 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 + 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) + + +@router.get("/{group_id}/analytics", response_model=GroupAnalyticsOut) +async def analytics( + group_id: UUID, request: Request, db: AsyncSession = Depends(get_session) +) -> GroupAnalyticsOut: + who = require(request, Role.INSTRUCTOR, Role.ADMIN) + return await _analytics( + group_id, db, owner_login=who.login if who.role is Role.INSTRUCTOR else None + ) + + +@router.post("/{group_id}/analytics/insight", response_model=GroupInsightOut) +async def ai_insight( + group_id: UUID, request: Request, db: AsyncSession = Depends(get_session) +) -> GroupInsightOut: + """Сформировать по запросу преподавателя локальный обезличенный инсайт.""" + who = require(request, Role.INSTRUCTOR) + data = await _analytics(group_id, db, owner_login=who.login) + if data.scored_attempts == 0: + raise HTTPException(status_code=409, detail="no_scored_attempts") + try: + insight = await generate_group_insight(data.model_dump(mode="json")) + except LlmUnavailable as exc: + 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") + return GroupInsightOut(**insight) diff --git a/backend/app/api/http/materials.py b/backend/app/api/http/materials.py new file mode 100644 index 0000000..41b5c51 --- /dev/null +++ b/backend/app/api/http/materials.py @@ -0,0 +1,549 @@ +"""Учебные материалы, локальные вложения и назначения курсантам. + +Файлы хранятся в PostgreSQL и отдаются только как attachment: учебный контур +не зависит от внешнего файлового сервиса и не исполняет загруженный HTML. +""" + +import base64 +import binascii +import hashlib +from collections.abc import AsyncIterator +from datetime import datetime, timezone +from typing import Literal +from urllib.parse import quote +from uuid import UUID, uuid4 + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +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.config import get_settings +from app.db.base import get_session +from app.db.models import Group, LearningMaterial, MaterialAssignment, Trainee +from app.domain.events import Exercise, ScenarioStart, SessionMode +from app.domain.roles import Role +from app.scenarios import store +from app.session.hub import hub + +router = APIRouter(prefix="/api/materials", tags=["materials"]) +MAX_FILE_BYTES = 5 * 1024 * 1024 +LEVELS = {"L1", "L2", "L3"} + +_DEMO_GUIDE_ID = UUID("00000000-0000-4000-8000-000000000901") +_demo_materials: dict[UUID, LearningMaterial] = {} +_demo_assignments: dict[tuple[UUID, UUID], dict] = {} + + +def reset_demo_materials() -> None: + """Демонстрационный справочник воспроизводим после каждого старта.""" + _demo_materials.clear() + _demo_assignments.clear() + now = datetime.now(timezone.utc) + _demo_materials[_DEMO_GUIDE_ID] = LearningMaterial( + id=_DEMO_GUIDE_ID, + title="Памятка диспетчера ДДС", + description="Короткий алгоритм работы с готовой карточкой происшествия.", + level="L1", + kind="text", + body=( + "1. Подтвердите получение карточки.\n" + "2. Проверьте зону ответственности и список оповещения.\n" + "3. Назначьте бригаду и передайте адрес, событие и задачу.\n" + "4. Фиксируйте выезд, прибытие, локализацию и завершение работ." + ), + scenario_id="t01-1-fire-container", + file_name=None, + media_type=None, + file_data=None, + file_sha256=None, + active=True, + created_by="system", + created_at=now, + updated_at=now, + ) + _demo_assignments[(_DEMO_GUIDE_ID, DEMO_TRAINEE_ID)] = { + "assigned_by": "system", "assigned_at": now, "completed_at": None, + } + + +async def material_session() -> AsyncIterator[AsyncSession | None]: + if get_settings().demo_no_db: + yield None + else: + async for db in get_session(): + yield db + + +class MaterialCreate(BaseModel): + title: str = Field(min_length=3, max_length=200) + description: str = Field(default="", max_length=2000) + level: Literal["L1", "L2", "L3"] = "L1" + kind: Literal["text", "file"] = "text" + body: str = Field(default="", max_length=30_000) + scenario_id: str | None = Field(default=None, max_length=80) + file_name: str | None = Field(default=None, max_length=240) + media_type: str | None = Field(default=None, max_length=120) + content_base64: str | None = None + + @model_validator(mode="after") + def valid_content(self): + if self.kind == "text" and not self.body.strip(): + raise ValueError("текст материала пуст") + if self.kind == "file" and (not self.file_name or not self.content_base64): + raise ValueError("для файла нужны имя и содержимое") + return self + + +class MaterialPatch(BaseModel): + title: str | None = Field(default=None, min_length=3, max_length=200) + description: str | None = Field(default=None, max_length=2000) + level: Literal["L1", "L2", "L3"] | None = None + body: str | None = Field(default=None, max_length=30_000) + scenario_id: str | None = Field(default=None, max_length=80) + active: bool | None = None + + +class MaterialOut(BaseModel): + id: UUID + title: str + description: str + level: str + kind: str + body: str + scenario_id: str | None + file_name: str | None + media_type: str | None + file_size: int + file_sha256: str | None + active: bool + created_by: str + created_at: datetime + assigned_at: datetime | None = None + completed_at: datetime | None = None + assignment_count: int = 0 + + +def _out( + row: LearningMaterial, *, assignment: MaterialAssignment | dict | None = None, + assignment_count: int = 0, +) -> MaterialOut: + if isinstance(assignment, dict): + assigned_at = assignment.get("assigned_at") + completed_at = assignment.get("completed_at") + else: + assigned_at = assignment.assigned_at if assignment else None + completed_at = assignment.completed_at if assignment else None + return MaterialOut( + id=row.id, + title=row.title, + description=row.description, + level=row.level, + kind=row.kind, + body=row.body, + scenario_id=row.scenario_id, + file_name=row.file_name, + media_type=row.media_type, + file_size=len(row.file_data or b""), + file_sha256=row.file_sha256, + active=row.active, + created_by=row.created_by, + created_at=row.created_at, + assigned_at=assigned_at, + completed_at=completed_at, + assignment_count=assignment_count, + ) + + +def _decode_file(payload: MaterialCreate) -> bytes | None: + if payload.kind != "file": + return None + try: + data = base64.b64decode(payload.content_base64 or "", validate=True) + except (binascii.Error, ValueError) as exc: + raise HTTPException(status_code=422, detail="invalid_file_base64") from exc + if not data: + raise HTTPException(status_code=422, detail="empty_file") + if len(data) > MAX_FILE_BYTES: + raise HTTPException(status_code=413, detail="file_too_large_5mb") + return data + + +def _validate_scenario(scenario_id: str | None) -> None: + if scenario_id and store.get(scenario_id) is None: + raise HTTPException(status_code=422, detail="scenario_not_found") + + +def _safe_filename(value: str) -> str: + """Убрать и POSIX-, и Windows-путь; в БД остаётся только имя файла.""" + return value.replace("\\", "/").rsplit("/", 1)[-1] or "resource.bin" + + +@router.get("", response_model=list[MaterialOut]) +async def listing( + request: Request, + include_archived: bool = False, + db: AsyncSession | None = Depends(material_session), +) -> list[MaterialOut]: + who = require(request) + if db is None: + if who.role is Role.TRAINEE: + if who.trainee_id is None: + raise HTTPException(status_code=403, detail="trainee_profile_required") + result = [] + for material_id, row in _demo_materials.items(): + assignment = _demo_assignments.get((material_id, who.trainee_id)) + if assignment and row.active: + result.append(_out(row, assignment=assignment)) + return result + return [ + _out(row, assignment_count=sum(mid == row.id for mid, _ in _demo_assignments)) + for row in _demo_materials.values() + if (include_archived or row.active) + and (who.role is not Role.INSTRUCTOR or row.created_by in {who.login, "system"}) + ] + + if who.role is Role.TRAINEE: + if who.trainee_id is None: + raise HTTPException(status_code=403, detail="trainee_profile_required") + rows = await db.execute( + select(LearningMaterial, MaterialAssignment) + .join(MaterialAssignment, MaterialAssignment.material_id == LearningMaterial.id) + .where( + MaterialAssignment.trainee_id == who.trainee_id, + LearningMaterial.active.is_(True), + ) + .order_by(MaterialAssignment.assigned_at.desc()) + ) + return [_out(row, assignment=assignment) for row, assignment in rows] + + require(request, Role.INSTRUCTOR, Role.ADMIN) + statement = ( + select(LearningMaterial, func.count(MaterialAssignment.id)) + .outerjoin(MaterialAssignment, MaterialAssignment.material_id == LearningMaterial.id) + .group_by(LearningMaterial.id) + .order_by(LearningMaterial.active.desc(), LearningMaterial.updated_at.desc()) + ) + if who.role is Role.INSTRUCTOR: + statement = statement.where(LearningMaterial.created_by == who.login) + if not include_archived: + statement = statement.where(LearningMaterial.active.is_(True)) + rows = await db.execute(statement) + return [_out(row, assignment_count=count) for row, count in rows] + + +@router.post("", response_model=MaterialOut, status_code=201) +async def create( + payload: MaterialCreate, + request: Request, + db: AsyncSession | None = Depends(material_session), +) -> MaterialOut: + who = require(request, Role.INSTRUCTOR) + _validate_scenario(payload.scenario_id) + data = _decode_file(payload) + now = datetime.now(timezone.utc) + row = LearningMaterial( + id=uuid4(), + title=payload.title.strip(), + description=payload.description.strip(), + level=payload.level, + kind=payload.kind, + body=payload.body.strip(), + scenario_id=payload.scenario_id, + file_name=_safe_filename(payload.file_name) if payload.file_name else None, + media_type=(payload.media_type or "application/octet-stream") if data else None, + file_data=data, + file_sha256=hashlib.sha256(data).hexdigest() if data else None, + active=True, + created_by=who.login, + created_at=now, + updated_at=now, + ) + if db is None: + _demo_materials[row.id] = row + else: + db.add(row) + await db.commit() + await audit(who.login, who.role.value, "material.create", str(row.id), row.title) + return _out(row) + + +async def _material(db: AsyncSession | None, material_id: UUID) -> LearningMaterial | None: + return _demo_materials.get(material_id) if db is None else await db.get(LearningMaterial, material_id) + + +def _require_owner(row: LearningMaterial, login: str) -> None: + """Only the instructor who authored a resource may manage it.""" + if row.created_by != login: + raise HTTPException(status_code=404, detail="material_not_found") + + +@router.patch("/{material_id}", response_model=MaterialOut) +async def update( + material_id: UUID, + payload: MaterialPatch, + request: Request, + db: AsyncSession | None = Depends(material_session), +) -> MaterialOut: + who = require(request, Role.INSTRUCTOR) + row = await _material(db, material_id) + if row is None: + raise HTTPException(status_code=404, detail="material_not_found") + _require_owner(row, who.login) + patch = payload.model_dump(exclude_unset=True) + if "scenario_id" in patch: + patch["scenario_id"] = patch["scenario_id"] or None + _validate_scenario(patch["scenario_id"]) + if row.kind == "text" and "body" in patch and not (patch["body"] or "").strip(): + raise HTTPException(status_code=422, detail="empty_material_body") + for key, value in patch.items(): + setattr(row, key, value.strip() if isinstance(value, str) else value) + row.updated_at = datetime.now(timezone.utc) + if db is not None: + await db.commit() + await audit(who.login, who.role.value, "material.update", str(row.id)) + return _out(row) + + +@router.delete("/{material_id}", response_model=MaterialOut) +async def archive( + material_id: UUID, + request: Request, + db: AsyncSession | None = Depends(material_session), +) -> MaterialOut: + who = require(request, Role.INSTRUCTOR) + row = await _material(db, material_id) + if row is None: + raise HTTPException(status_code=404, detail="material_not_found") + _require_owner(row, who.login) + row.active = False + row.updated_at = datetime.now(timezone.utc) + if db is not None: + await db.commit() + await audit(who.login, who.role.value, "material.archive", str(row.id)) + return _out(row) + + +@router.put("/{material_id}/assign/{trainee_id}", response_model=MaterialOut) +async def assign( + material_id: UUID, + trainee_id: UUID, + request: Request, + db: AsyncSession | None = Depends(material_session), +) -> MaterialOut: + who = require(request, Role.INSTRUCTOR) + row = await _material(db, material_id) + if row is None or not row.active: + raise HTTPException(status_code=404, detail="material_not_found") + _require_owner(row, who.login) + if db is None: + if trainee_id != DEMO_TRAINEE_ID: + raise HTTPException(status_code=404, detail="trainee_not_found") + assignment = _demo_assignments.setdefault( + (material_id, trainee_id), + {"assigned_by": who.login, "assigned_at": datetime.now(timezone.utc), "completed_at": None}, + ) + else: + trainee = await db.get(Trainee, trainee_id) + if trainee is None: + raise HTTPException(status_code=404, detail="trainee_not_found") + if trainee.group_id is not None: + group = await db.get(Group, trainee.group_id) + if group is None or group.owner_login != who.login: + raise HTTPException(status_code=404, detail="trainee_not_found") + assignment = await db.scalar(select(MaterialAssignment).where( + MaterialAssignment.material_id == material_id, + MaterialAssignment.trainee_id == trainee_id, + )) + if assignment is None: + assignment = MaterialAssignment( + 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)) + return _out(row, assignment=assignment) + + +@router.put("/{material_id}/assign-group/{group_id}") +async def assign_group( + material_id: UUID, + group_id: UUID, + request: Request, + db: AsyncSession | None = Depends(material_session), +) -> dict: + who = require(request, Role.INSTRUCTOR) + row = await _material(db, material_id) + if row is None or not row.active: + raise HTTPException(status_code=404, detail="material_not_found") + _require_owner(row, who.login) + if db is None: + raise HTTPException(status_code=409, detail="groups_unavailable_in_demo") + group = await db.get(Group, group_id) + if group is None or group.owner_login != who.login: + raise HTTPException(status_code=404, detail="group_not_found") + trainee_ids = list(await db.scalars(select(Trainee.id).where(Trainee.group_id == group_id))) + existing = set(await db.scalars(select(MaterialAssignment.trainee_id).where( + MaterialAssignment.material_id == material_id, + MaterialAssignment.trainee_id.in_(trainee_ids), + ))) if trainee_ids else set() + for trainee_id in trainee_ids: + if trainee_id not in existing: + db.add(MaterialAssignment( + material_id=material_id, trainee_id=trainee_id, assigned_by=who.login + )) + 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)} + + +@router.delete("/{material_id}/assign/{trainee_id}") +async def unassign( + material_id: UUID, + trainee_id: UUID, + request: Request, + db: AsyncSession | None = Depends(material_session), +) -> dict: + who = require(request, Role.INSTRUCTOR) + row = await _material(db, material_id) + if row is None: + raise HTTPException(status_code=404, detail="material_not_found") + _require_owner(row, who.login) + if db is None: + removed = _demo_assignments.pop((material_id, trainee_id), None) is not None + else: + assignment = await db.scalar(select(MaterialAssignment).where( + MaterialAssignment.material_id == material_id, + MaterialAssignment.trainee_id == trainee_id, + )) + if assignment is not None and assignment.assigned_by != who.login: + 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)) + return {"removed": removed} + + +@router.post("/{material_id}/complete", response_model=MaterialOut) +async def complete( + material_id: UUID, + request: Request, + db: AsyncSession | None = Depends(material_session), +) -> MaterialOut: + who = require(request, Role.TRAINEE) + if who.trainee_id is None: + raise HTTPException(status_code=403, detail="trainee_profile_required") + row = await _material(db, material_id) + if row is None or not row.active: + raise HTTPException(status_code=404, detail="material_not_found") + if db is None: + assignment = _demo_assignments.get((material_id, who.trainee_id)) + else: + assignment = await db.scalar(select(MaterialAssignment).where( + MaterialAssignment.material_id == material_id, + MaterialAssignment.trainee_id == who.trainee_id, + )) + if assignment is None: + raise HTTPException(status_code=403, detail="material_not_assigned") + completed_at = datetime.now(timezone.utc) + if isinstance(assignment, dict): + assignment["completed_at"] = completed_at + else: + assignment.completed_at = completed_at + await db.commit() + await audit(who.login, who.role.value, "material.complete", str(material_id)) + return _out(row, assignment=assignment) + + +@router.post("/{material_id}/start") +async def start_assigned_practice( + material_id: UUID, + request: Request, + db: AsyncSession | None = Depends(material_session), +) -> dict: + """Курсант сам выбирает назначенный модуль и запускает текстовую практику.""" + who = require(request, Role.TRAINEE) + if who.trainee_id is None: + raise HTTPException(status_code=403, detail="trainee_profile_required") + row = await _material(db, material_id) + if row is None or not row.active: + raise HTTPException(status_code=404, detail="material_not_found") + if db is None: + assignment = _demo_assignments.get((material_id, who.trainee_id)) + else: + assignment = await db.scalar(select(MaterialAssignment).where( + MaterialAssignment.material_id == material_id, + MaterialAssignment.trainee_id == who.trainee_id, + )) + if assignment is None: + raise HTTPException(status_code=403, detail="material_not_assigned") + if not row.scenario_id: + raise HTTPException(status_code=409, detail="material_has_no_practice") + scenario = store.get(row.scenario_id) + if scenario is None: + raise HTTPException(status_code=409, detail="scenario_not_found") + # Явное назначение преподавателя — и есть разрешение на самостоятельный + # модуль. Оно не открывает курсанту остальные сценарии библиотеки. + + from app.api.ws.control import _start + + session_id = uuid4() + await _start(session_id, ScenarioStart( + scenario_id=scenario.id, + trainee=who.full_name, + trainee_id=who.trainee_id, + mode=SessionMode.SELF, + exercise=Exercise.CARD, + ), who) + if hub.get(session_id) is None: + raise HTTPException(status_code=409, detail="practice_start_failed") + return { + "session_id": str(session_id), + "scenario_id": scenario.id, + "mode": SessionMode.SELF.value, + "exercise": Exercise.CARD.value, + "path": f"/trainee?session={session_id}", + } + + +@router.get("/{material_id}/download") +async def download( + material_id: UUID, + request: Request, + db: AsyncSession | None = Depends(material_session), +) -> Response: + who = require(request) + row = await _material(db, material_id) + if row is None or not row.active or row.kind != "file" or row.file_data is None: + raise HTTPException(status_code=404, detail="file_not_found") + if who.role is Role.TRAINEE: + if who.trainee_id is None: + raise HTTPException(status_code=403, detail="trainee_profile_required") + if db is None: + allowed = (material_id, who.trainee_id) in _demo_assignments + else: + allowed = await db.scalar(select(MaterialAssignment.id).where( + MaterialAssignment.material_id == material_id, + MaterialAssignment.trainee_id == who.trainee_id, + )) is not None + if not allowed: + raise HTTPException(status_code=403, detail="material_not_assigned") + elif who.role is Role.INSTRUCTOR: + _require_owner(row, who.login) + elif who.role not in {Role.INSTRUCTOR, Role.ADMIN}: + raise HTTPException(status_code=403, detail="forbidden") + filename = _safe_filename(row.file_name or "resource.bin") + disposition = f"attachment; filename=resource; filename*=UTF-8''{quote(filename)}" + return Response( + content=row.file_data, + media_type=row.media_type or "application/octet-stream", + headers={ + "Content-Disposition": disposition, + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'none'", + }, + ) diff --git a/backend/app/api/http/scenarios.py b/backend/app/api/http/scenarios.py index 0bb8a14..6af494f 100644 --- a/backend/app/api/http/scenarios.py +++ b/backend/app/api/http/scenarios.py @@ -10,6 +10,7 @@ `hint.shown` из живой сессии, эталонные вопросы — только в разборе. """ +from collections.abc import AsyncIterator from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request @@ -19,49 +20,126 @@ 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.config import get_settings 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.loader import ScenarioError +from app.session.hub import hub router = APIRouter(prefix="/api/scenarios", tags=["scenarios"]) HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"} +async def scenario_session() -> AsyncIterator[AsyncSession | None]: + """Только редактор в demo-lite использует временное хранилище без БД.""" + if get_settings().demo_no_db: + yield None + else: + async for db in get_session(): + yield db + + class TemplateDraftIn(BaseModel): source_id: str = Field(min_length=1) title: str | None = Field(default=None, min_length=1, max_length=200) +class GenerateDraftIn(BaseModel): + source_id: str = Field(min_length=1) + instruction: str = Field(min_length=10, max_length=1000) + + +class GenerateFullDraftIn(BaseModel): + source_id: str = Field(min_length=1) + description: str = Field(min_length=20, max_length=1500) + + +class ReviseDraftIn(BaseModel): + comment: str = Field(min_length=10, max_length=1000) + + def _draft_out(row) -> dict: + if row.id.startswith("ai-full-"): + generation = "ai_full" + elif row.id.startswith("ai-"): + generation = "ai_variant" + else: + generation = "template_copy" return { "id": row.id, "status": row.status, - "generation": "template_copy", + "generation": generation, "body": row.body, } @router.post("/drafts/from-template", status_code=201) async def create_template_draft( - body: TemplateDraftIn, request: Request, db: AsyncSession = Depends(get_session) + body: TemplateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_session) ) -> dict: who = require(request, Role.INSTRUCTOR) 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) + 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}") return _draft_out(row) +@router.post("/drafts/generate", status_code=201) +async def create_ai_draft( + body: GenerateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_session) +) -> dict: + who = require(request, Role.INSTRUCTOR) + source = store.get(body.source_id) + if source is None: + 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) + 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}") + return _draft_out(row) + + +@router.post("/drafts/generate-from-description", status_code=201) +async def create_full_ai_draft( + body: GenerateFullDraftIn, request: Request, + db: AsyncSession | None = Depends(scenario_session), +) -> dict: + """Новый сюжет и мягкий эталон внутри выбранного класса ЕКП.""" + who = require(request, Role.INSTRUCTOR) + source = store.get(body.source_id) + if source is None: + raise HTTPException(status_code=404, detail="published_source_not_found") + 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 + ) + 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}") + return _draft_out(row) + + @router.get("/drafts/{scenario_id}") async def read_draft( - scenario_id: str, request: Request, db: AsyncSession = Depends(get_session) + scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session) ) -> dict: - require(request, Role.INSTRUCTOR) - row = await store.draft(db, scenario_id) + 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") return _draft_out(row) @@ -72,10 +150,10 @@ async def patch_draft( scenario_id: str, body: dict[str, Any], request: Request, - db: AsyncSession = Depends(get_session), + db: AsyncSession | None = Depends(scenario_session), ) -> dict: who = require(request, Role.INSTRUCTOR) - row = await store.draft(db, scenario_id) + 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: @@ -86,12 +164,36 @@ async def patch_draft( return _draft_out(row) +@router.post("/drafts/{scenario_id}/revise") +async def revise_ai_draft( + scenario_id: str, + body: ReviseDraftIn, + 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: + source = validate(row.body) + proposal = await generate(source, body.comment.strip(), require_fact_change=False) + row = await store.revise_draft(db, row, proposal) + 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]) + return _draft_out(row) + + @router.post("/drafts/{scenario_id}/validate") async def validate_draft( - scenario_id: str, request: Request, db: AsyncSession = Depends(get_session) + scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session) ) -> dict: - require(request, Role.INSTRUCTOR) - row = await store.draft(db, scenario_id) + 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: @@ -107,10 +209,10 @@ async def validate_draft( @router.post("/drafts/{scenario_id}/approve") async def approve_draft( - scenario_id: str, request: Request, db: AsyncSession = Depends(get_session) + scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session) ) -> dict: who = require(request, Role.INSTRUCTOR) - row = await store.draft(db, scenario_id) + 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: @@ -122,7 +224,15 @@ async def approve_draft( @router.get("") -async def listing() -> list[dict]: +async def listing( + request: Request, db: AsyncSession | None = Depends(scenario_session) +) -> list[dict]: + who = require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE) + owned_ids = ( + await store.owned_scenario_ids(db, who.login) + if who is not None and who.role is Role.INSTRUCTOR + else set() + ) return [ { "id": scenario.id, @@ -131,19 +241,57 @@ async def listing() -> list[dict]: "level": scenario.level.value, "topics": scenario.topics, "modes": scenario.modes, + # Преподаватель должен видеть не только название карточки, но и + # зафиксированный путь классификатора. ИИ меняет сюжет внутри + # этого пути, а не незаметно подменяет код происшествия. + "signs": scenario.signs, + "incident_code": scenario.ground_truth.incident_code, "dds": scenario.ground_truth.dds.value if scenario.ground_truth.dds else None, "ticket": scenario.ticket, "position": scenario.position, "ekp_group": (ekp.incident(scenario.ground_truth.incident_code).group 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() ] +@router.delete("/{scenario_id}") +async def archive_scenario( + scenario_id: str, request: Request, + db: AsyncSession | None = Depends(scenario_session), +) -> dict: + """Мягкое удаление: история занятий остаётся целой, сценарий можно вернуть.""" + 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) + if scenario is None: + raise HTTPException(status_code=404, detail="scenario_not_found") + await audit(who.login, who.role.value, "scenario.archive", scenario_id) + return {"id": scenario_id, "status": "archived", "title": scenario.title} + + +@router.post("/{scenario_id}/restore") +async def restore_scenario( + scenario_id: str, request: Request, + db: AsyncSession | None = Depends(scenario_session), +) -> dict: + who = require(request, Role.INSTRUCTOR) + scenario = await store.restore_archived(db, scenario_id, owner_login=who.login) + 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) + return {"id": scenario_id, "status": "published", "title": scenario.title} + + @router.get("/{scenario_id}") -async def read(scenario_id: str) -> dict: +async def read(scenario_id: str, request: Request) -> 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) scenario = store.get(scenario_id) if scenario is None: raise HTTPException(status_code=404, detail="scenario_not_found") diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py index ffdd88a..5c73194 100644 --- a/backend/app/api/http/sessions.py +++ b/backend/app/api/http/sessions.py @@ -8,18 +8,23 @@ from datetime import datetime from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response -from pydantic import BaseModel, Field +from fastapi.responses import FileResponse +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.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.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.session.hub import hub +from app.voice.recording import recording_path router = APIRouter(prefix="/api/sessions", tags=["sessions"]) @@ -59,8 +64,11 @@ def _out(session) -> SessionOut: @router.post("", response_model=SessionOut, status_code=201) async def create(body: SessionCreate, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut: - require(request, Role.INSTRUCTOR) - group = await repo.ensure_group(db, body.group) if body.group else None + who = require(request, Role.INSTRUCTOR) + try: + group = await repo.ensure_group(db, body.group, owner_login=who.login) if body.group else 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 session = await repo.create_session( db, @@ -68,6 +76,14 @@ async def create(body: SessionCreate, request: Request, db: AsyncSession = Depen mode=body.mode.value, 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}", ) return _out(session) @@ -80,6 +96,8 @@ async def read(session_id: UUID, request: Request, db: AsyncSession = Depends(ge raise HTTPException(status_code=404, detail="session_not_found") if who.role is Role.TRAINEE and session.trainee_id != who.trainee_id: raise HTTPException(status_code=403, detail="not_your_session") + if who.role is Role.INSTRUCTOR and session.owner_login != who.login: + raise HTTPException(status_code=404, detail="session_not_found") return _out(session) @@ -102,6 +120,8 @@ async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut raise HTTPException(status_code=404, detail="session_not_found") if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id: raise HTTPException(status_code=403, detail="not_your_session") + if who.role is Role.INSTRUCTOR and state.owner_login != who.login: + raise HTTPException(status_code=404, detail="session_not_found") if not state.ended: raise HTTPException(status_code=409, detail="call_not_ended") scenario = state.scenario or store.get(state.scenario_id) @@ -118,7 +138,15 @@ class ScoreOverride(BaseModel): """Коррекция оценки преподавателем. Автооценка сохраняется рядом.""" score_final: float = Field(ge=0, le=100) - comment: str = "" + comment: str = Field(min_length=1, max_length=2000) + + @field_validator("comment") + @classmethod + def comment_must_not_be_blank(cls, comment: str) -> str: + cleaned = comment.strip() + if not cleaned: + raise ValueError("обоснование корректировки обязательно") + return cleaned def _live(session_id: UUID): @@ -131,8 +159,9 @@ def _live(session_id: UUID): return state, scenario -@router.get("/{session_id}/report", response_model=SessionReport) -async def report(session_id: UUID, request: Request) -> SessionReport: +async def _report_data( + session_id: UUID, request: Request, db: AsyncSession, +) -> SessionReport: """Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки. Обучающийся открывает только свой разбор: ТЗ запрещает доступ к чужим @@ -140,20 +169,62 @@ async def report(session_id: UUID, request: Request) -> SessionReport: занятия, а не по номеру в ссылке. """ who = require(request) - state, scenario = _live(session_id) - if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id: + try: + state, scenario = _live(session_id) + except HTTPException as exc: + if exc.status_code != 404: + raise + state = None + scenario = None + if state is not None and scenario is not None: + if who.role is Role.INSTRUCTOR and state.owner_login != who.login: + raise HTTPException(status_code=404, detail="session_not_found") + if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id: + raise HTTPException(status_code=403, detail="not_your_session") + if who.role is Role.TRAINEE and state.exercise is Exercise.CALL and not state.self_assessed: + raise HTTPException(status_code=409, detail="self_assessment_required") + if state.score is None: + raise HTTPException(status_code=409, detail="score_not_ready") + return build_report(session_id, state, scenario) + + session = await repo.get_session(db, session_id) + if session is None: + raise HTTPException(status_code=404, detail="session_not_found") + if who.role is Role.TRAINEE and session.trainee_id != who.trainee_id: raise HTTPException(status_code=403, detail="not_your_session") - if who.role is Role.TRAINEE and state.exercise is Exercise.CALL and not state.self_assessed: - raise HTTPException(status_code=409, detail="self_assessment_required") - if state.score is None: + if who.role is Role.INSTRUCTOR and session.owner_login != who.login: + raise HTTPException(status_code=404, detail="session_not_found") + score = await db.scalar(select(Score).where(Score.session_id == session_id)) + if score is None: raise HTTPException(status_code=409, detail="score_not_ready") - return build_report(session_id, state, scenario) + archived = (score.report or {}).get("full_report") + if archived is None: + raise HTTPException(status_code=409, detail="report_not_archived") + data = SessionReport.model_validate(archived) + if (who.role is Role.TRAINEE and data.reference_questions + and data.self_assessment is None): + raise HTTPException(status_code=409, detail="self_assessment_required") + return data.model_copy(update={ + "score_auto": score.score_auto, + "score_final": score.score_final, + "overridden_by": score.overridden_by, + "override_comment": score.override_comment, + }) + + +@router.get("/{session_id}/report", response_model=SessionReport) +async def report( + session_id: UUID, request: Request, db: AsyncSession = Depends(get_session), +) -> SessionReport: + return await _report_data(session_id, request, db) @router.get("/{session_id}/report.csv") -async def report_csv(session_id: UUID, request: Request) -> Response: +async def report_csv( + session_id: UUID, request: Request, db: AsyncSession = Depends(get_session), +) -> Response: """Те же права и готовность оценки, что у JSON-разбора.""" - data = await report(session_id, request) + data = await _report_data(session_id, request, db) return Response( content=to_csv(data), media_type="text/csv; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.csv"'}, @@ -161,9 +232,11 @@ async def report_csv(session_id: UUID, request: Request) -> Response: @router.get("/{session_id}/report.pdf") -async def report_pdf(session_id: UUID, request: Request) -> Response: +async def report_pdf( + session_id: UUID, request: Request, db: AsyncSession = Depends(get_session), +) -> Response: """Печатный разбор; генерация полностью локальна.""" - data = await report(session_id, request) + data = await _report_data(session_id, request, db) try: content = to_pdf(data) except RuntimeError as exc: @@ -174,31 +247,120 @@ async def report_pdf(session_id: UUID, request: Request) -> Response: ) +@router.get("/{session_id}/recording.wav") +async def recording(session_id: UUID, request: Request, db: AsyncSession = Depends(get_session)): + """Запись учебного звонка: преподавателю либо владельцу занятия.""" + who = require(request, Role.INSTRUCTOR, Role.TRAINEE) + if get_settings().demo_no_db: + state = hub.get(session_id) + if state is None: + raise HTTPException(status_code=404, detail="session_not_found") + owner_id = state.trainee_id + owner_login = state.owner_login + ended = state.ended + else: + row = await repo.get_session(db, session_id) + if row is None: + raise HTTPException(status_code=404, detail="session_not_found") + owner_id = row.trainee_id + owner_login = row.owner_login + ended = row.ended_at is not None + if who.role is Role.TRAINEE and (owner_id is None or owner_id != who.trainee_id): + raise HTTPException(status_code=403, detail="not_your_recording") + if who.role is Role.INSTRUCTOR and owner_login != who.login: + raise HTTPException(status_code=404, detail="recording_not_found") + if not ended: + raise HTTPException(status_code=409, detail="recording_not_ready") + path = recording_path(session_id) + if not path.is_file(): + raise HTTPException(status_code=404, detail="recording_not_found") + return FileResponse( + path, + media_type="audio/wav", + filename=f"session-{session_id}-recording.wav", + ) + + @router.patch("/{session_id}/report", response_model=SessionReport) -async def override(session_id: UUID, body: ScoreOverride, request: Request) -> SessionReport: +async def override( + session_id: UUID, + body: ScoreOverride, + request: Request, + db: AsyncSession = Depends(get_session), +) -> SessionReport: """Тренажёр готовит материал, преподаватель имеет последнее слово. Администратору сюда нельзя: ТЗ запрещает ему вмешиваться в оценки прямо. """ who = require(request, Role.INSTRUCTOR) - state, scenario = _live(session_id) - if state.score is None: + session = await repo.get_session(db, session_id) + if session is None: + raise HTTPException(status_code=404, detail="session_not_found") + if session.owner_login != who.login: + raise HTTPException(status_code=404, detail="session_not_found") + state = hub.get(session_id) + live_ready = state is not None and state.score is not None + scenario = (state.scenario or store.get(state.scenario_id)) if live_ready else None + if live_ready and scenario is None: + raise HTTPException(status_code=409, detail="scenario_not_found") + score = await db.scalar(select(Score).where(Score.session_id == session_id)) + if score is None: raise HTTPException(status_code=409, detail="score_not_ready") - state.score = { - **state.score, - "score_final": body.score_final, - "overridden_by": who.login, - "override_comment": body.comment, - } - if hub.journal: - await hub.journal.score_override( - session_id, body.score_final, who.login, body.comment - ) - await audit( - who.login, who.role.value, "score.override", str(session_id), - f"{state.score.get('score_auto')} → {body.score_final}: {body.comment}", - ) - return build_report(session_id, state, scenario) + archived = (score.report or {}).get("full_report") + if not live_ready and archived is None: + raise HTTPException(status_code=409, detail="report_not_archived") + + # Persist score and audit entry in the same request transaction: the API + # must not report success if either durable record failed to commit. + score.score_final = body.score_final + score.overridden_by = who.login + score.override_comment = body.comment + report_payload = dict(score.report or {}) + if archived is not None: + archived_payload = dict(archived) + archived_payload.update({ + "score_auto": score.score_auto, + "score_final": body.score_final, + "overridden_by": who.login, + "override_comment": body.comment, + }) + report_payload["full_report"] = archived_payload + score.report = report_payload + db.add(AuditLog( + actor=who.login, + role=who.role.value, + action="score.override", + object_id=str(session_id), + detail=f"{score.score_auto} → {body.score_final}: {body.comment}"[:2000], + )) + await db.commit() + + if live_ready: + assert state is not None and state.score is not None and scenario is not None + state.score = { + **state.score, + "score_final": body.score_final, + "overridden_by": who.login, + "override_comment": body.comment, + } + state.score["full_report"] = { + **state.score.get("full_report", {}), + "score_auto": score.score_auto, + "score_final": body.score_final, + "overridden_by": who.login, + "override_comment": body.comment, + } + result = build_report(session_id, state, scenario) + else: + # Historical/archived sessions no longer have a live object in the hub. + # Keep score correction available from the instructor's report history. + result = SessionReport.model_validate(archived).model_copy(update={ + "score_auto": score.score_auto, + "score_final": body.score_final, + "overridden_by": who.login, + "override_comment": body.comment, + }) + return result @router.get("", response_model=list[SessionOut]) @@ -217,11 +379,13 @@ async def listing( if who.trainee_id is None: 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 rows = await repo.history( db, trainee_id=trainee, group_id=group, mode=mode.value if mode else None, + owner_login=owner_login, since=since, limit=limit, ) diff --git a/backend/app/api/http/trainees.py b/backend/app/api/http/trainees.py index 882afa0..050cea8 100644 --- a/backend/app/api/http/trainees.py +++ b/backend/app/api/http/trainees.py @@ -8,24 +8,77 @@ from datetime import datetime from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel -from sqlalchemy import select +from sqlalchemy import exists, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.auth import DEMO_TRAINEE_ID, 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 +from app.db.models import Group, Score, Session, Trainee, User +from app.scoring.export import certificate_pdf +from app.voice.recording import recording_path router = APIRouter(prefix="/api/trainees", tags=["trainees"]) +@router.get("/{trainee_id}/certificate.pdf") +async def certificate( + trainee_id: UUID, request: Request, db: AsyncSession = Depends(get_session) +) -> Response: + who = require(request) + if who.role is Role.TRAINEE and who.trainee_id != trainee_id: + raise HTTPException(status_code=403, detail="not_your_certificate") + if who.role is Role.INSTRUCTOR: + owns_trainee = await db.scalar( + select(Session.id) + .where(Session.trainee_id == trainee_id, Session.owner_login == who.login) + .limit(1) + ) + if owns_trainee is None: + raise HTTPException(status_code=404, detail="trainee_not_found") + trainee = await db.get(Trainee, trainee_id) + if trainee is None: + raise HTTPException(status_code=404, detail="trainee_not_found") + group = await db.get(Group, trainee.group_id) if trainee.group_id else None + if who.role is Role.INSTRUCTOR and group is not None and group.owner_login != who.login: + group = None + scores_query = ( + select(func.count(Score.id), func.avg(Score.score_final), func.max(Score.created_at)) + .join(Session, Session.id == Score.session_id) + .where(Session.trainee_id == trainee_id) + ) + if who.role is Role.INSTRUCTOR: + scores_query = scores_query.where(Session.owner_login == who.login) + result = await db.execute(scores_query) + attempts, average_score, completed_at = result.one() + if not attempts: + raise HTTPException(status_code=409, detail="no_scored_attempts") + try: + content = certificate_pdf( + trainee_name=trainee.name, + trainee_id=trainee.id, + group_name=group.name if group else None, + attempts=int(attempts), + average_score=float(average_score), + issued_at=completed_at.date().isoformat(), + ) + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + return Response( + content=content, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="trainee-{trainee_id}-certificate.pdf"'}, + ) + + class TraineeOut(BaseModel): id: UUID name: str group: str | None = None + service: str | None = None class AttemptOut(BaseModel): @@ -40,6 +93,7 @@ class AttemptOut(BaseModel): facts_required: int | None = None hints: int | None = None codes: dict[str, int] = {} + recording_available: bool = False class DeltaOut(BaseModel): @@ -65,14 +119,39 @@ class ProfileOut(BaseModel): async def listing(request: Request) -> list[TraineeOut]: """Список курсантов — преподавателю и администратору: обучающемуся он не нужен, а чужие фамилии из него видны.""" - require(request, Role.INSTRUCTOR, Role.ADMIN) + who = require(request, Role.INSTRUCTOR, Role.ADMIN) if get_settings().demo_no_db: - return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант")] + return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант", service="Служба 101")] async with get_sessionmaker()() as db: - rows = await db.execute( - select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True) + query = ( + select(Trainee, Group.name, Group.owner_login, User.service) + .join(Group, Group.id == Trainee.group_id, isouter=True) + .join(User, User.trainee_id == Trainee.id, isouter=True) ) - return [TraineeOut(id=trainee.id, name=trainee.name, group=group) for trainee, group in rows] + if who.role is Role.INSTRUCTOR: + has_owned_attempt = exists( + select(Session.id).where( + Session.trainee_id == Trainee.id, + Session.owner_login == who.login, + ) + ) + query = query.where( + or_( + Trainee.group_id.is_(None), + Group.owner_login == who.login, + has_owned_attempt, + ) + ) + rows = await db.execute(query) + return [ + TraineeOut( + id=trainee.id, + name=trainee.name, + group=(group if who.role is not Role.INSTRUCTOR or group_owner == who.login else None), + service=service, + ) + for trainee, group, group_owner, service in rows + ] @router.get("/{trainee_id}/profile", response_model=ProfileOut) @@ -82,17 +161,30 @@ async def profile( who = require(request) if who.role is Role.TRAINEE and who.trainee_id != trainee_id: raise HTTPException(status_code=403, detail="not_your_profile") + if who.role is Role.INSTRUCTOR: + owns_trainee = await db.scalar( + select(Session.id) + .where(Session.trainee_id == trainee_id, Session.owner_login == who.login) + .limit(1) + ) + if owns_trainee is None: + raise HTTPException(status_code=404, detail="trainee_not_found") trainee = await db.get(Trainee, trainee_id) if trainee is None: raise HTTPException(status_code=404, detail="trainee_not_found") group = await db.get(Group, trainee.group_id) if trainee.group_id else None + if who.role is Role.INSTRUCTOR and group is not None and group.owner_login != who.login: + group = None - rows = await db.execute( + attempts_query = ( select(Session, Score) .join(Score, Score.session_id == Session.id, isouter=True) .where(Session.trainee_id == trainee_id) .order_by(Session.created_at) ) + if who.role is Role.INSTRUCTOR: + attempts_query = attempts_query.where(Session.owner_login == who.login) + rows = await db.execute(attempts_query) attempts: list[AttemptOut] = [] competency_sums: dict[str, list[float]] = {} for session, score in rows: @@ -110,6 +202,7 @@ async def profile( facts_required=summary.get("facts_required"), hints=summary.get("hints"), codes=summary.get("codes", {}), + recording_available=recording_path(session.id).is_file(), ) ) for item in (score.report or {}).get("competencies", []) if score else []: diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py index 6fb6d7a..92c3adc 100644 --- a/backend/app/api/ws/call.py +++ b/backend/app/api/ws/call.py @@ -33,14 +33,15 @@ from app.domain.events import ( ) from app.domain.events import BgStart from app.scenarios import store -from app.session.finish import finish, release_score +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.voice.models import get_voice_models +from app.voice.models import TTS_RATE, get_voice_models from app.voice.pipeline import VoiceSession +from app.voice.recording import start_recording log = logging.getLogger(__name__) router = APIRouter() @@ -62,6 +63,8 @@ def _on_audio(session_id: UUID, state, frame: bytes) -> None: session_id, len(frame), FRAME_BYTES) return state.audio_frames += 1 + if state.recorder is not None: + state.recorder.add_pcm(frame, sample_rate=16_000) if state.voice is not None: state.voice.feed(frame) if state.audio_frames % FRAMES_PER_LOG == 0: @@ -138,6 +141,8 @@ async def _handle(session_id: UUID, state, event) -> None: 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) case "kio.patch": @@ -220,6 +225,7 @@ async def _handle(session_id: UUID, state, event) -> None: 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) @@ -237,6 +243,8 @@ async def _handle(session_id: UUID, state, event) -> None: ) await finish(session_id, state) + await hub.checkpoint(session_id) + def _start_voice(session_id: UUID, state) -> None: """Голос включается, когда курсант снял трубку: звонящий сразу кричит первую реплику.""" @@ -244,13 +252,18 @@ def _start_voice(session_id: UUID, state) -> None: scenario = store.get(state.scenario_id) if models is None or scenario is None or state.voice is not None: return + def send_audio(pcm: bytes) -> None: + if state.recorder is not None: + state.recorder.add_pcm(pcm, sample_rate=TTS_RATE) + hub.to_trainee(session_id, pcm) + state.voice = VoiceSession( session_id=session_id, state=state, models=models, send_event=lambda event: hub.to_trainee(session_id, event), send_observer=lambda event: hub.to_observers(session_id, event), - send_audio=lambda pcm: hub.to_trainee(session_id, pcm), + send_audio=send_audio, journal=hub.journal, ) if scenario.background: diff --git a/backend/app/api/ws/control.py b/backend/app/api/ws/control.py index 62576a2..cffe1ce 100644 --- a/backend/app/api/ws/control.py +++ b/backend/app/api/ws/control.py @@ -8,43 +8,43 @@ преподавателю, уходит на его же сокет `observe`. """ +import asyncio import logging import math -from uuid import UUID, uuid4 +from uuid import UUID from fastapi import APIRouter, WebSocket, WebSocketDisconnect from pydantic import TypeAdapter, ValidationError -from app.domain.events import ( - CallEndReason, - CallEnded, - ErrorEvent, - ErrorKind, - ScoreReady, - CallIncoming, - CardBriefing, - Exercise, - StationState, - InstructorToServer, - InstructorNoteShown, - ModeSet, - ReferenceStarted, - SessionEnded, -) -import asyncio - -from app.dialog.factory import build_caller +from app.api.auth import audit, principal_of 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.scenarios import store -from app.api.auth import audit, principal_of +from app.domain.events import ( + CallEnded, + CallEndReason, + CallIncoming, + CardBriefing, + ErrorEvent, + ErrorKind, + Exercise, + InstructorNoteShown, + InstructorToServer, + ModeSet, + ReferenceStarted, + ScoreReady, + SessionEnded, + StationState, +) 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.state import SessionState, now_utc -from app.session.dds import prepare_card from app.voice.models import get_voice_models from app.voice.pipeline import FILLERS, prefetch @@ -93,10 +93,10 @@ async def _start(session_id: UUID, event, who=None) -> None: 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) > 10: + 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 и содержать не более 10 карточек", + message="Очередь ДДС должна начинаться с scenario_id и содержать не более 96 карточек", )) return scenarios = [store.get(scenario_id) for scenario_id in scenario_ids] @@ -110,10 +110,19 @@ async def _start(session_id: UUID, event, who=None) -> None: attempt = 1 recorded_trainee_id = event.trainee_id + recorded_service = None if hub.journal: - attempt, recorded_trainee_id = await hub.journal.start_lesson( - session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id - ) + try: + attempt, recorded_trainee_id, recorded_service = 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, + ) + except PermissionError: + hub.to_observers(session_id, ErrorEvent( + code=ErrorKind.FORBIDDEN, + message="Занятие создано другим преподавателем", + )) + return # Занятие собирается целиком и только потом регистрируется: иначе # наблюдатель, подключившийся в эту щель, увидит полусобранное состояние @@ -124,23 +133,35 @@ async def _start(session_id: UUID, event, who=None) -> None: scenario_title=scenario.title, level=scenario.level.value, mode=event.mode, + owner_login=who.login if who is not None else None, exercise=event.exercise, handoff_to_dds=event.handoff_to_dds, scenario=scenario.model_copy(deep=True), required_fields=scenario.required_fields, trainee_name=event.trainee, trainee_id=recorded_trainee_id, + dds_service=recorded_service or event.dds_service, attempt=attempt, + criteria=event.criteria, ) + state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000 if event.exercise is Exercise.CALL: 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) + state.caller = build_caller( + scenario.id, + use_pregenerated=scenario.tree.pregenerated, + ) elif event.exercise is Exercise.DDS: state.dds_scenarios = [item.model_copy(deep=True) for item in scenarios] - prepare_card(state, state.dds_scenarios[0]) + prepare_queue( + state, + state.dds_scenarios, + arrival_interval_seconds=event.dds_arrival_interval_seconds, + max_waiting=event.dds_max_waiting, + ) state.started_at = state.dispatched_at else: state.started_at = now_utc() @@ -155,13 +176,19 @@ async def _start(session_id: UUID, event, who=None) -> None: if models is not None: asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()])) state.on_event("call.incoming") + await hub.checkpoint(session_id) hub.start_ticker(session_id) if who is not None: # Запуск занятия меняет чужой результат — значит попадает в журнал # аудита (ТЗ, хранение не менее шести месяцев). - asyncio.create_task( - audit(who.login, who.role.value, "lesson.start", str(session_id), - f"{scenario.id}, режим {event.mode.value}, курсант {event.trainee}") + # Сохраняем до продолжения сценария, чтобы завершение процесса не + # потеряло событие. ФИО курсанта в долгоживущий журнал не дублируем. + await audit( + who.login, + who.role.value, + "lesson.start", + str(session_id), + f"{scenario.id}, режим {event.mode.value}", ) if event.exercise is Exercise.CALL: @@ -191,18 +218,22 @@ async def _stop(session_id: UUID) -> None: return state.ended_at = now_utc() state.end_reason = CallEndReason.INSTRUCTOR + if state.voice is not None: + await state.voice.close() hub.stop_ticker(session_id) hub.to_observers(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR)) - if state.exercise in {Exercise.DDS, Exercise.CARD}: - from app.session.finish import finish + from app.session.finish import finish + if state.exercise in {Exercise.DDS, Exercise.CARD}: if state.exercise is Exercise.DDS or state.handoff_to_dds and state.dispatched_card is not None: hub.to_station(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR)) else: hub.to_trainee(session_id, CallEnded(reason=CallEndReason.INSTRUCTOR)) - await finish(session_id, state) if state.exercise is Exercise.DDS or state.handoff_to_dds and state.dispatched_card is not None: hub.to_station(session_id, ScoreReady(session_id=session_id)) + else: + hub.to_trainee(session_id, CallEnded(reason=CallEndReason.INSTRUCTOR)) + await finish(session_id, state) if hub.journal: await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value) @@ -236,6 +267,21 @@ async def control(ws: WebSocket, session_id: UUID) -> None: ) continue + existing_state = hub.get(session_id) + if event.type == "scenario.start": + if existing_state is not None: + hub.to_observers(session_id, ErrorEvent( + code=ErrorKind.FORBIDDEN, + message="Для нового занятия создайте новый номер сессии", + )) + continue + elif existing_state is None or existing_state.owner_login != who.login: + hub.to_observers(session_id, ErrorEvent( + code=ErrorKind.FORBIDDEN, + message="Это занятие принадлежит другому преподавателю", + )) + continue + match event.type: case "scenario.start": await _start(session_id, event, who) @@ -315,7 +361,7 @@ async def control(ws: WebSocket, session_id: UUID) -> None: if result.needs_network: hub.to_observers(session_id, ErrorEvent( code=ErrorKind.DIRECTIVE_NEEDS_NETWORK, - message="Свободный текст требует LLM: офлайн доступны только кнопки", + message="Свободный текст требует локально запущенной модели; без неё доступны кнопки", )) continue state.directives.append(event.directive) @@ -338,5 +384,6 @@ async def control(ws: WebSocket, session_id: UUID) -> None: message=f"{event.type} ещё не реализовано", ), ) + await hub.checkpoint(session_id) except WebSocketDisconnect: return diff --git a/backend/app/api/ws/station.py b/backend/app/api/ws/station.py index 831cd66..1633bdc 100644 --- a/backend/app/api/ws/station.py +++ b/backend/app/api/ws/station.py @@ -10,23 +10,41 @@ import asyncio import logging +import re from uuid import UUID from fastapi import APIRouter, WebSocket, WebSocketDisconnect from pydantic import TypeAdapter, ValidationError -from app.domain.events import ( - CallEndReason, ErrorEvent, ErrorKind, Exercise, PhoneReport, ScoreReady, - SessionEnded, StationState, StationToServer, -) -from app.domain.statuses import PRIMARY, PhoneReportRecord, ServiceStatus, StatusError, current from app.api.auth import principal_of +from app.domain.events import ( + CallEndReason, + ErrorEvent, + ErrorKind, + Exercise, + PhoneLine, + PhoneReport, + ScoreReady, + SessionEnded, + StationState, + StationToServer, +) from app.domain.roles import Role +from app.domain.statuses import ( + PRIMARY, + SERVICE_STATUS_LABELS, + PhoneCallPending, + PhoneLineRecord, + PhoneReportRecord, + ServiceStatus, + StatusError, + current, +) +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.state import now_utc -from app.session.finish import finish -from app.session.finish import score_current_dds -from app.session.dds import prepare_card log = logging.getLogger(__name__) router = APIRouter() @@ -34,24 +52,85 @@ router = APIRouter() _adapter = TypeAdapter(StationToServer) REPORT_PHASES = ("dispatched", "arrived", "working", "completed") -REPORT_TEXT = { - "dispatched": "Бригада выехала к месту происшествия.", - "arrived": "Бригада прибыла на место происшествия.", - "working": "Бригада приступила к проведению работ.", - "completed": "Работы завершены, бригада освобождена.", +REQUIRED_STATUS = { + "dispatched": ServiceStatus.ACCEPTED, + "arrived": ServiceStatus.RESPONDING, + "working": ServiceStatus.ARRIVED, + "completed": ServiceStatus.WORKING, } -REPORT_FOR_STATUS = { - ServiceStatus.RESPONDING: "dispatched", - ServiceStatus.ARRIVED: "arrived", - ServiceStatus.WORKING: "working", - ServiceStatus.COMPLETED: "completed", +STATUS_AT_OR_AFTER = { + "dispatched": {ServiceStatus.ACCEPTED, ServiceStatus.RESPONDING, + ServiceStatus.ARRIVED, ServiceStatus.WORKING}, + "arrived": {ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING}, + "working": {ServiceStatus.ARRIVED, ServiceStatus.WORKING}, + "completed": {ServiceStatus.WORKING, ServiceStatus.COMPLETED}, } - - def _error(session_id: UUID, message: str) -> None: hub.to_station(session_id, ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=message)) +def _line(session_id: UUID, state, speaker: str, text: str) -> None: + call = state.phone_pending + if call is None: + return + line = PhoneLineRecord(service=call.service, crew=call.crew, + speaker=speaker, text=text, at=now_utc()) + state.phone_lines.append(line) + state.dds_log.append((f"phone.line.{speaker}", line.at, f"{call.crew}: {text}")) + hub.to_station(session_id, PhoneLine(**line.model_dump())) + + +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)) + + +def _incident_matches(state, supplied: str) -> bool: + text = supplied.casefold() + # Описание часто начинается с адреса: его нельзя считать совпадением + # характера происшествия. Берём только название сценария и признаки ЕКП. + source = " ".join((state.scenario_title, " ".join(state.kio.signs))) + anchors = {word[:4] for word in re.findall(r"[а-яё]{5,}", source.casefold())} + return len(supplied.strip()) >= 8 and any(anchor in text for anchor in anchors) + + +def _has_purpose(text: str, stems: tuple[str, ...]) -> bool: + normalized = text.casefold() + return len(text.strip()) >= 8 and any(stem in normalized for stem in stems) + + +def _report_text(phase: str, crew: str, address: str) -> str: + match phase: + case "dispatched": + return f"{crew}: вызов по адресу {address} принят, выезжаем. О прибытии доложу." + case "arrived": + return f"{crew}: прибыли по адресу {address}. Уточняем обстановку на месте." + case "working": + return f"{crew}: обстановка уточнена, приступили к работам. Сообщим о завершении." + case _: + return f"{crew}: работы завершены. Дальнейшая помощь от нашей бригады не требуется." + + +def _finish_phone_call(session_id: UUID, state) -> None: + call = state.phone_pending + assert call is not None + text = _report_text(call.phase, call.crew, state.dispatched_card.address or "из карточки") + _line(session_id, state, "crew", text) + report = PhoneReportRecord(service=call.service, crew=call.crew, + phase=call.phase, text=text, at=now_utc()) + state.phone_reports.append(report) + state.dds_log.append(("phone.report", now_utc(), f"{call.crew}: {call.phase}")) + state.phone_pending = None + hub.to_station(session_id, PhoneReport(**report.model_dump())) + + async def _finish_dds(session_id: UUID, state) -> None: state.ended_at = now_utc() state.end_reason = CallEndReason.COMPLETE @@ -75,22 +154,15 @@ async def _handle(session_id: UUID, state, event) -> None: # диспетчер один и выбирать службу не из чего. state.on_event("card.ack") state.dds_log.append(("card.ack", now_utc(), None)) - services = state.notified_services() + services = state.managed_services() if services: try: state.set_service_status(services[0], ServiceStatus.ACCEPTED) except StatusError: pass # статус уже стоит: повторное нажатие ничего не меняет case "card.status": - if event.service not in state.notified_services(): - _error(session_id, "Служба отсутствует в списке оповещения карточки") - return - phase = REPORT_FOR_STATUS.get(event.status) - if (state.exercise is Exercise.DDS or state.handoff_to_dds) and phase is not None and not any( - report.service == event.service and report.phase == phase - for report in state.phone_reports - ): - _error(session_id, f"Статус «{event.status.value}» требует доклада бригады") + if event.service not in state.managed_services(): + _error(session_id, "Можно менять статусы только своей ДДС") return try: state.set_service_status( @@ -116,33 +188,73 @@ async def _handle(session_id: UUID, state, event) -> None: ): _error(session_id, "После первого доклада бригаду этой службы менять нельзя") return + if state.phone_pending is not None: + _error(session_id, "Завершите текущий разговор перед сменой бригады") + return state.crew_selected = event.crew state.crew_assignments[service] = event.crew state.dds_log.append(("crew.select", now_utc(), event.crew)) case "phone.dial": + if state.phone_pending is not None: + _error(session_id, "Разговор уже идёт: передайте сведения или завершите звонок") + return crew = state.crew_selected service = state.crew_service(crew) if crew else None if service is None: _error(session_id, "Сначала выберите бригаду") return - if current(state.status_log, service) not in { - ServiceStatus.ACCEPTED, ServiceStatus.RESPONDING, - ServiceStatus.ARRIVED, ServiceStatus.WORKING, - }: - _error(session_id, "Сначала примите карточку этой службы") - return previous = [report for report in state.phone_reports if report.service == service] if len(previous) >= len(REPORT_PHASES): _error(session_id, "Все доклады этой бригады уже получены") return phase = REPORT_PHASES[len(previous)] - report = PhoneReportRecord( - service=service, crew=crew, phase=phase, - text=REPORT_TEXT[phase], at=now_utc(), - ) - state.phone_reports.append(report) + required = REQUIRED_STATUS[phase] + if current(state.status_log, service) not in STATUS_AT_OR_AFTER[phase]: + _error(session_id, f"Перед звонком нужен статус «{SERVICE_STATUS_LABELS[required]}» этой службы") + return + state.phone_pending = PhoneCallPending(service=service, crew=crew, phase=phase) state.dds_log.append(("phone.dial", now_utc(), crew)) - hub.to_station(session_id, PhoneReport(**report.model_dump())) + greeting = (f"{crew}, старший группы на связи. Назовите адрес, характер происшествия " + "и что требуется от бригады." + if phase == "dispatched" else + f"{crew}, старший группы на связи. Слушаю ваш запрос по карточке.") + _line(session_id, state, "crew", greeting) + case "phone.brief": + call = state.phone_pending + if call is None or call.phase != "dispatched": + _error(session_id, "Сначала соединитесь со старшим группы для передачи вызова") + return + if not _address_matches(state.dispatched_card.address, event.address): + _error(session_id, "Проверьте адрес: улица и номер дома должны совпадать с карточкой") + return + if not _incident_matches(state, event.incident): + _error(session_id, "Уточните характер происшествия по данным карточки") + return + if not _has_purpose(event.request, ("выезд", "выех", "направ", "прибыт", + "реагир", "помощ", "подтверд", "долож")): + _error(session_id, "Сформулируйте задачу: выезд, помощь или доклад бригады") + return + _line(session_id, state, "dispatcher", f"Адрес: {event.address.strip()}. " + f"Происшествие: {event.incident.strip()}. {event.request.strip()}") + _finish_phone_call(session_id, state) + case "phone.check": + call = state.phone_pending + if call is None or call.phase == "dispatched": + _error(session_id, "Сначала передайте вызов, затем запросите обстановку") + return + if not _has_purpose(event.text, ("обстанов", "статус", "прибыл", "доех", + "выех", "работ", "заверш", "мест", + "ход", "сообщ", "долож", "уточн")): + _error(session_id, "Спросите обстановку, прибытие или ход работ по карточке") + return + _line(session_id, state, "dispatcher", event.text.strip()) + _finish_phone_call(session_id, state) + case "phone.hangup": + if state.phone_pending is None: + _error(session_id, "Нет активного разговора") + return + state.dds_log.append(("phone.hangup", now_utc(), state.phone_pending.crew)) + state.phone_pending = None case "card.reply": 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 @@ -150,7 +262,18 @@ async def _handle(session_id: UUID, state, event) -> None: _error(session_id, "Ответ относится не к текущей карточке") 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): + _error(session_id, "Карточка отсутствует в текущей очереди") + return + 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 + # so the newly opened card cannot briefly inherit the previous + # 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 ( event.card_id != state.dispatched_card.card_id @@ -161,11 +284,32 @@ async def _handle(session_id: UUID, state, event) -> None: _error(session_id, "Эта карточка уже завершена") return state.dds_completed.append(score_current_dds(state)) - if state.dds_card_index + 1 < len(state.dds_scenarios): - state.dds_card_index += 1 - prepare_card(state, state.dds_scenarios[state.dds_card_index]) + completed_index = state.dds_card_index + state.dds_live_cards = [ + item for item in state.dds_live_cards if item.card_id != event.card_id + ] + remaining = sorted(state.dds_live_cards, key=lambda item: item.original_index) + if remaining: + following = next( + (item for item in remaining if item.original_index > completed_index), + remaining[0], + ) + state.activate_dds_card(following.card_id, capture=False) hub.to_station(session_id, state.card_received_event()) else: + # Keep the lesson alive if selected cards have not arrived yet. + # The next delivery may become the active card immediately or + # after its configured interval; no completed card is reused. + state.dds_active_card_id = None + state.dispatched_card = None + state.dispatched_at = None + state.dds_card_index = completed_index + active_before_delivery = state.dds_active_card_id + deliver_due_cards(state) + if state.dds_active_card_id and state.dds_active_card_id != active_before_delivery: + hub.to_station(session_id, state.card_received_event()) + hub.to_station(session_id, StationState(snapshot=state.station_snapshot())) + if not state.dds_live_cards and len(state.dds_completed) >= len(state.dds_scenarios): await _finish_dds(session_id, state) case "station.finish": if state.exercise is not Exercise.DDS and not state.handoff_to_dds: @@ -173,9 +317,11 @@ async def _handle(session_id: UUID, state, event) -> None: return await _finish_dds(session_id, state) case "card.bounce": - # Карточка вернулась: в разборе это E6 с конкретной причиной. - state.bounced_fields = list(event.missing_fields) - state.dds_log.append(("card.bounce", now_utc(), event.comment)) + _error( + session_id, + "ДДС не проверяет заполнение карточки: замечания передаёт служба контроля 112", + ) + return case "zone.decision": state.on_event("zone.decision") state.dds_log.append(("zone.decision", now_utc(), "в зоне" if event.in_zone else "не в зоне")) @@ -186,6 +332,8 @@ async def _handle(session_id: UUID, state, event) -> None: state.on_event("crew.arrived") state.kio = state.kio.model_copy(update={"arrival_at": event.at}) state.dds_log.append(("crew.arrived", now_utc(), None)) + state.capture_active_dds() + await hub.checkpoint(session_id) hub.to_station(session_id, StationState(snapshot=state.station_snapshot())) hub.to_observers(session_id, state.snapshot()) diff --git a/backend/app/config.py b/backend/app/config.py index f7143c1..8ed158b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,36 +19,63 @@ class Settings(BaseSettings): # Голосовой контур models_dir: str = "models" - stt_model: str = "gigaam-v3-rnnt" + stt_model: str = "whisper-small" + # Локальный файл GGML Whisper small для whisper.cpp. Имя модели из сети + # не принимается: учебный контур не скачивает веса при запуске. + whisper_model_path: str = "models/whisper-small" + whisper_server_url: str = "http://127.0.0.1:18082" endpointing_ms: int = 600 mic_sample_rate: int = 16_000 tts_sample_rate: int = 24_000 offline: bool = False - # Голосовой контур: грузит ~5 с моделей при старте. В тестах выключен. - voice_enabled: bool = True + # STT/голос не входят в MVP ДДС; включаются лишь для отдельного эксперимента. + voice_enabled: bool = False + # Запись хранится локально и отдаётся только через авторизованный API. + record_calls: bool = True + recordings_dir: str = "recordings" + # Автокопирование полного стенда. Интервал по умолчанию ровно сутки; + # небольшой grace нужен только для отображения просрочки в мониторинге. + backup_interval_seconds: int = 86_400 + backup_retry_seconds: int = 300 + backup_keep: int = 14 # LLM. Провайдер меняется значением, не кодом (docs/arch/STACK.md). # Имена COMPAT_MODEL_* принимаются тоже — так их выставляет командный сниппет. - llm_provider: str = "openai_compatible" + llm_provider: str = "local" llm_base_url: str = Field( - default="", validation_alias=AliasChoices("llm_base_url", "compat_model_url") + default="http://127.0.0.1:18080/v1", + validation_alias=AliasChoices("llm_base_url", "compat_model_url"), ) llm_api_key: str = Field( default="", validation_alias=AliasChoices("llm_api_key", "compat_model_api_key"), repr=False, ) llm_model_caller: str = Field( - default="", validation_alias=AliasChoices("llm_model_caller", "compat_model_name") + default="Qwen3-1.7B", validation_alias=AliasChoices("llm_model_caller", "compat_model_name") ) llm_model_judge: str = Field( default="", validation_alias=AliasChoices("llm_model_judge", "compat_model_name") ) judge_temperature: float = 0.0 + # Vikhr — отдельный русскоязычный контрольный профиль диалога. Никакая + # модель не решает, какие факты раскрыть, как маршрутизировать карточку или + # какой балл поставить: это делает протокол и оценка в коде. + llm_control_base_url: str = "http://127.0.0.1:18081/v1" + llm_model_control: str = "Vikhr-1B" + grammar_llm_enabled: bool = False + dialogue_model_mode: str = "dialogue" # dialogue | russian_control + # Docker Desktop даёт контейнеру специальное имя хоста. Оно разрешается + # только явным флагом: обычный OFFLINE по-прежнему принимает лишь literal + # loopback и не может незаметно уйти во внешний API. + allow_docker_host_models: bool = False # Вход и роли (lct-23). `session_secret` обязателен на рабочем стенде: # значение по умолчанию годится только для разработки, и администратор # видит в состоянии сервисов, что оно не заменено. session_secret: str = "dev-secret-поменять-на-стенде" + # В TLS-профиле cookie уходит только по HTTPS/WSS. Для нативной разработки + # на loopback флаг выключен, иначе браузер не сможет войти по HTTP. + secure_cookies: bool = False #: Вход без пароля для `make lesson` и тестов. На стенде выключен. dev_auth_bypass: bool = False diff --git a/backend/app/db/migrations/versions/a4c9d1e2f3b4_learning_materials.py b/backend/app/db/migrations/versions/a4c9d1e2f3b4_learning_materials.py new file mode 100644 index 0000000..d2375b5 --- /dev/null +++ b/backend/app/db/migrations/versions/a4c9d1e2f3b4_learning_materials.py @@ -0,0 +1,63 @@ +"""учебные материалы и назначения + +Revision ID: a4c9d1e2f3b4 +Revises: e6e2ab171dd7 +Create Date: 2026-09-23 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "a4c9d1e2f3b4" +down_revision = "e6e2ab171dd7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "learning_materials", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column("level", sa.String(length=4), nullable=False), + sa.Column("kind", sa.String(length=16), nullable=False), + sa.Column("body", sa.Text(), nullable=False), + sa.Column("scenario_id", sa.String(length=80), nullable=True), + sa.Column("file_name", sa.String(length=240), nullable=True), + sa.Column("media_type", sa.String(length=120), nullable=True), + sa.Column("file_data", sa.LargeBinary(), nullable=True), + sa.Column("file_sha256", sa.String(length=64), nullable=True), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("created_by", sa.String(length=80), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["scenario_id"], ["scenarios.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "material_assignments", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("material_id", sa.Uuid(), nullable=False), + sa.Column("trainee_id", sa.Uuid(), nullable=False), + sa.Column("assigned_by", sa.String(length=80), nullable=False), + sa.Column("assigned_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["material_id"], ["learning_materials.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["trainee_id"], ["trainees.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("material_id", "trainee_id", name="uq_material_assignment"), + ) + op.create_index( + "ix_material_assignments_trainee", + "material_assignments", + ["trainee_id", "assigned_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_material_assignments_trainee", table_name="material_assignments") + op.drop_table("material_assignments") + op.drop_table("learning_materials") diff --git a/backend/app/db/migrations/versions/b5e0f2a3c4d5_active_session_checkpoint.py b/backend/app/db/migrations/versions/b5e0f2a3c4d5_active_session_checkpoint.py new file mode 100644 index 0000000..5e03f44 --- /dev/null +++ b/backend/app/db/migrations/versions/b5e0f2a3c4d5_active_session_checkpoint.py @@ -0,0 +1,37 @@ +"""снимки активных занятий для восстановления + +Revision ID: b5e0f2a3c4d5 +Revises: a4c9d1e2f3b4 +Create Date: 2026-09-23 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision = "b5e0f2a3c4d5" +down_revision = "a4c9d1e2f3b4" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("sessions", sa.Column("live_state", postgresql.JSONB(), nullable=True)) + op.add_column( + "sessions", + sa.Column("checkpoint_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index( + "ix_sessions_active_checkpoint", + "sessions", + ["checkpoint_at"], + unique=False, + postgresql_where=sa.text("ended_at IS NULL AND live_state IS NOT NULL"), + ) + + +def downgrade() -> None: + op.drop_index("ix_sessions_active_checkpoint", table_name="sessions") + op.drop_column("sessions", "checkpoint_at") + op.drop_column("sessions", "live_state") diff --git a/backend/app/db/migrations/versions/c6f1a3b4d5e6_persistent_auth_version.py b/backend/app/db/migrations/versions/c6f1a3b4d5e6_persistent_auth_version.py new file mode 100644 index 0000000..b984863 --- /dev/null +++ b/backend/app/db/migrations/versions/c6f1a3b4d5e6_persistent_auth_version.py @@ -0,0 +1,26 @@ +"""сохраняемая версия полномочий пользователя + +Revision ID: c6f1a3b4d5e6 +Revises: b5e0f2a3c4d5 +Create Date: 2026-09-23 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "c6f1a3b4d5e6" +down_revision = "b5e0f2a3c4d5" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("auth_version", sa.Integer(), server_default="0", nullable=False), + ) + + +def downgrade() -> None: + op.drop_column("users", "auth_version") diff --git a/backend/app/db/migrations/versions/d7e2f4a5b6c7_scenario_owners.py b/backend/app/db/migrations/versions/d7e2f4a5b6c7_scenario_owners.py new file mode 100644 index 0000000..6322c78 --- /dev/null +++ b/backend/app/db/migrations/versions/d7e2f4a5b6c7_scenario_owners.py @@ -0,0 +1,31 @@ +"""ограничение авторского контента и занятий владельцем-преподавателем + +Revision ID: d7e2f4a5b6c7 +Revises: c6f1a3b4d5e6 +Create Date: 2026-09-23 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "d7e2f4a5b6c7" +down_revision = "c6f1a3b4d5e6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "scenarios", + sa.Column("owner_login", sa.String(length=80), nullable=True), + ) + op.add_column( + "sessions", + sa.Column("owner_login", sa.String(length=80), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("sessions", "owner_login") + op.drop_column("scenarios", "owner_login") diff --git a/backend/app/db/migrations/versions/f7a3c9d1e2b4_instructor_owned_groups.py b/backend/app/db/migrations/versions/f7a3c9d1e2b4_instructor_owned_groups.py new file mode 100644 index 0000000..196a892 --- /dev/null +++ b/backend/app/db/migrations/versions/f7a3c9d1e2b4_instructor_owned_groups.py @@ -0,0 +1,25 @@ +"""закрепить учебные группы за преподавателем + +Revision ID: f7a3c9d1e2b4 +Revises: d7e2f4a5b6c7 +Create Date: 2026-09-23 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "f7a3c9d1e2b4" +down_revision = "d7e2f4a5b6c7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Existing groups are not auto-assigned: historical membership/analytics may + # span instructors, so they remain admin-managed until explicitly migrated. + op.add_column("groups", sa.Column("owner_login", sa.String(length=80), nullable=True)) + + +def downgrade() -> None: + op.drop_column("groups", "owner_login") diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 60880aa..8310d7d 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -8,7 +8,7 @@ from datetime import datetime from uuid import UUID, uuid4 -from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func +from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, String, Text, UniqueConstraint, func from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -26,6 +26,8 @@ class Group(Base): id: Mapped[UUID] = _uuid_pk() name: Mapped[str] = mapped_column(String(120), unique=True) + # NULL — legacy/admin-managed group; instructor-created groups are private. + owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) trainees: Mapped[list["Trainee"]] = relationship(back_populates="group") @@ -43,6 +45,52 @@ class Trainee(Base): sessions: Mapped[list["Session"]] = relationship(back_populates="trainee") +class LearningMaterial(Base): + """Локальный учебный материал или загруженный преподавателем ресурс.""" + + __tablename__ = "learning_materials" + + id: Mapped[UUID] = _uuid_pk() + title: Mapped[str] = mapped_column(String(200)) + description: Mapped[str] = mapped_column(Text, default="") + level: Mapped[str] = mapped_column(String(4), default="L1") + kind: Mapped[str] = mapped_column(String(16), default="text") + body: Mapped[str] = mapped_column(Text, default="") + scenario_id: Mapped[str | None] = mapped_column( + ForeignKey("scenarios.id", ondelete="SET NULL"), nullable=True + ) + file_name: Mapped[str | None] = mapped_column(String(240)) + media_type: Mapped[str | None] = mapped_column(String(120)) + file_data: Mapped[bytes | None] = mapped_column(LargeBinary) + file_sha256: Mapped[str | None] = mapped_column(String(64)) + active: Mapped[bool] = mapped_column(default=True) + created_by: Mapped[str] = mapped_column(String(80)) + 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 MaterialAssignment(Base): + """Назначение материала курсанту и факт изучения.""" + + __tablename__ = "material_assignments" + + id: Mapped[UUID] = _uuid_pk() + material_id: Mapped[UUID] = mapped_column( + ForeignKey("learning_materials.id", ondelete="CASCADE") + ) + trainee_id: Mapped[UUID] = mapped_column(ForeignKey("trainees.id", ondelete="CASCADE")) + assigned_by: Mapped[str] = mapped_column(String(80)) + assigned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + __table_args__ = ( + UniqueConstraint("material_id", "trainee_id", name="uq_material_assignment"), + Index("ix_material_assignments_trainee", "trainee_id", "assigned_at"), + ) + + class Scenario(Base): """Сценарий целиком лежит в `body`: библиотека — контент, а не схема. Отдельными колонками вынесено только то, по чему идёт выборка в списке.""" @@ -56,6 +104,9 @@ class Scenario(Base): topics: Mapped[list] = mapped_column(JSONB, default=list) modes: Mapped[list] = mapped_column(JSONB, default=list) status: Mapped[str] = mapped_column(String(16), default="published") + # У черновика и утверждённого авторского сценария ровно один владелец; + # NULL означает базовую/унаследованную системную библиотеку. + owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True) body: Mapped[dict] = mapped_column(JSONB) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( @@ -74,6 +125,7 @@ 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) 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)) @@ -82,6 +134,10 @@ class Session(Base): ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) end_reason: Mapped[str | None] = mapped_column(String(16)) kio: Mapped[dict] = mapped_column(JSONB, default=dict) + # Последний подтверждённый снимок незавершённого занятия. Он очищается при + # завершении и позволяет новому backend-процессу продолжить карточку/ДДС. + live_state: Mapped[dict | None] = mapped_column(JSONB) + checkpoint_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) trainee: Mapped["Trainee | None"] = relationship(back_populates="sessions") @@ -214,6 +270,9 @@ 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) + # Версия полномочий попадает в подписанную cookie. Смена роли, пароля или + # блокировки увеличивает её и отзывает старые cookie даже после restart. + auth_version: Mapped[int] = mapped_column(default=0) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/db/repo.py b/backend/app/db/repo.py index 4d921c1..0d8c6fc 100644 --- a/backend/app/db/repo.py +++ b/backend/app/db/repo.py @@ -30,10 +30,12 @@ async def create_session( trainee_id: UUID | None = None, group_id: UUID | None = None, session_id: UUID | None = None, + owner_login: str | None = None, ) -> Session: session = Session( scenario_id=scenario_id, mode=mode, + owner_login=owner_login, trainee_id=trainee_id, group_id=group_id, attempt=await next_attempt(db, trainee_id, scenario_id), @@ -54,6 +56,7 @@ async def ensure_session( trainee_name: str | None = None, trainee_id: UUID | None = None, group_name: str | None = None, + owner_login: str | None = None, ) -> Session: """Занятие, запущенное с пульта, должно иметь строку в журнале. @@ -62,9 +65,11 @@ async def ensure_session( """ existing = await db.get(Session, session_id) if existing is not None: + if existing.owner_login != owner_login: + raise PermissionError("занятие принадлежит другому преподавателю") return existing - group = await ensure_group(db, group_name) if group_name else None + 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} не найден") @@ -77,6 +82,7 @@ async def ensure_session( trainee_id=trainee.id if trainee else None, group_id=group.id if group else trainee.group_id if trainee else None, session_id=session_id, + owner_login=owner_login, ) @@ -137,6 +143,7 @@ async def history( trainee_id: UUID | None = None, group_id: UUID | None = None, mode: str | None = None, + owner_login: str | None = None, since: datetime | None = None, limit: int = 100, ) -> list[Session]: @@ -149,17 +156,23 @@ async def history( query = query.where(Session.group_id == group_id) if mode is not None: query = query.where(Session.mode == mode) + if owner_login is not None: + query = query.where(Session.owner_login == owner_login) if since is not None: query = query.where(Session.created_at >= since) return list(await db.scalars(query)) -async def ensure_group(db: AsyncSession, name: str) -> Group: +async def ensure_group( + db: AsyncSession, name: str, *, owner_login: str | None = None +) -> Group: group = await db.scalar(select(Group).where(Group.name == name)) if group is None: - group = Group(name=name) + group = Group(name=name, owner_login=owner_login) db.add(group) await db.commit() + elif group.owner_login != owner_login: + raise PermissionError("группа принадлежит другому преподавателю или администратору") return group diff --git a/backend/app/dialog/caller.py b/backend/app/dialog/caller.py index 73d50e9..27a0976 100644 --- a/backend/app/dialog/caller.py +++ b/backend/app/dialog/caller.py @@ -9,6 +9,7 @@ """ import logging +import re from dataclasses import dataclass from functools import lru_cache from pathlib import Path @@ -20,6 +21,11 @@ from app.domain.events import Mood log = logging.getLogger(__name__) +NUMBER_WORDS = { + "один", "одна", "одно", "двое", "два", "две", "трое", "три", "четверо", "четыре", + "пять", "шесть", "семь", "восемь", "девять", "десять", +} + @dataclass class CallerLine: @@ -152,6 +158,11 @@ class LlmCaller: persona.on_repeat() mood = persona.remember() + # Непонятная реплика не передаёт модели право импровизировать фактами. + # Отбор фактов и вопросная карта — только слот-автомат, не LLM. + if not (turn.revealed or turn.refined or turn.repeated): + return await self._fallback.reply(turn, persona, slots) + facts = {fact.id: fact.value for fact in slots.revealed_facts()} say_now = [ facts[fact_id] @@ -161,7 +172,7 @@ class LlmCaller: repeated = [facts[fact_id] for fact_id in turn.repeated if fact_id in facts] system = _prompt("caller.md").format( - scenario=slots.scenario.title, + scenario="учебное происшествие", mood=MOOD_WORDS.get(mood, mood.value), directive=_directive_line(persona), revealed="\n".join(f"- {value}" for value in facts.values()) or "- пока ничего", @@ -173,13 +184,15 @@ class LlmCaller: repeated="; ".join(repeated), repeats=persona.repeats ) - self._history.append({"role": "user", "content": turn.text}) + current_message = {"role": "user", "content": turn.text} try: text = await self._client.complete( LlmRequest( - messages=[{"role": "system", "content": system}, *self._history[-6:]], + messages=[{"role": "system", "content": system}, + *self._history[-6:], current_message], model=self._model, temperature=self._temperature, + max_tokens=160, ) ) except LlmUnavailable as exc: @@ -187,7 +200,13 @@ class LlmCaller: log.warning("звонящий на заготовках: %s", exc) return await self._fallback.reply(turn, persona, slots) - self._history.append({"role": "assistant", "content": text}) + if not _allowed_reply(text, facts, say_now + repeated, slots): + self.fallbacks += 1 + log.warning("ответ модели нарушил протокол раскрытия фактов — использована заготовка") + return await self._fallback.reply(turn, persona, slots) + # Отклонённый ответ и провокационный вопрос не должны загрязнять + # последующий контекст. Запоминаем только проверенную пару ходов. + self._history.extend((current_message, {"role": "assistant", "content": text})) return CallerLine(text=text, mood=mood) async def aclose(self) -> None: @@ -196,6 +215,48 @@ class LlmCaller: await self._client.aclose() +def _allowed_reply(text: str, allowed: dict[str, str], required_now: list[str], slots: SlotMachine) -> bool: + """Консервативная граница для текста модели; протокол 112 всё равно в коде. + + Невозможно доказать истинность произвольной русской фразы регулярками, + поэтому сомнительный ответ заменяется детерминированной репликой. + """ + if not text or len(text) > 300 or "\n" in text or "" in text.lower(): + return False + normalized = text.casefold().replace("ё", "е") + # Модель не вправе назвать числовой адрес или телефон, которого нет в + # раскрытых фактах, даже если оператор предположил его в своей реплике. + allowed_digits = set(re.findall(r"\d+", " ".join(allowed.values()))) + if any(number not in allowed_digits for number in re.findall(r"\d+", normalized)): + return False + allowed_words = set(re.findall(r"[а-яё]+", " ".join(allowed.values()).casefold().replace("ё", "е"))) + spoken_words = set(re.findall(r"[а-яё]+", normalized)) + if (spoken_words & NUMBER_WORDS) - allowed_words: + return False + allowed_stems = {word[:4] for word in allowed_words if len(word) >= 4} + spoken_stems = {word[:4] for word in spoken_words if len(word) >= 4} + for fact in slots.scenario.facts: + if fact.id in allowed: + continue + for value in (fact.value, fact.refined): + if not value: + continue + if len(value) >= 7 and value.casefold().replace("ё", "е") in normalized: + return False + # Полная фраза — не единственный способ слить скрытый факт: + # «муж курил» раскрывает причину, даже без «на балконе». + hidden_stems = {word[:4] for word in re.findall( + r"[а-яё]{4,}", value.casefold().replace("ё", "е") + )} - allowed_stems + if hidden_stems & spoken_stems: + return False + # Новый обязательный факт нельзя опустить ради красивой реплики. + for value in required_now: + if value.casefold().replace("ё", "е") not in normalized: + return False + return True + + MOOD_WORDS = { Mood.PANIC: "паника, ты кричишь", Mood.AGGRESSIVE: "злость, ты срываешься на оператора", @@ -210,6 +271,8 @@ def _directive_line(persona: PersonaState) -> str: if persona.directive in SOFT: return f"ПРЕПОДАВАТЕЛЬ ВЕДЁТ СИТУАЦИЮ: {SOFT[persona.directive].lower()}." + if persona.directive: + return f"ПРЕПОДАВАТЕЛЬ ПРОСИТ ИЗМЕНИТЬ ПОДАЧУ: {persona.directive}. Факты не меняй." return "" diff --git a/backend/app/dialog/director.py b/backend/app/dialog/director.py index 4501bf9..3908b5e 100644 --- a/backend/app/dialog/director.py +++ b/backend/app/dialog/director.py @@ -41,7 +41,7 @@ class DirectiveResult: say: str | None = None #: Оборвать связь: звук на полуслове, дальше обратный дозвон. drop_line: bool = False - #: Директива требует сети (свободный текст без LLM). + #: Директива требует локально работающую модель (без неё — только кнопки). needs_network: bool = False @@ -72,14 +72,15 @@ def apply(state, directive: str) -> DirectiveResult: state.slots.invalidate(fact.id) return DirectiveResult(applied=True, say=HARD_LINES[directive]) - # Свободный текст уходит в контекст персоны — но подставить его в реплику - # может только LLM. Офлайн-дерево предгенерировано, произвольную фразу - # взять неоткуда (docs/arch/CONTRACT.md). + # Свободный текст разрешён лишь в режиме локальной модели. Он управляет + # интонацией, но не раскрывает факты и не меняет правила 112. + from app.dialog.caller import LlmCaller + + if not isinstance(state.caller, LlmCaller) or len(directive) > 500: + return DirectiveResult(applied=False, needs_network=True) if state.persona is not None: - state.persona.directive = None - if state.directives is not None: - state.directives.append(directive) - return DirectiveResult(applied=False, needs_network=True) + state.persona.directive = directive + return DirectiveResult(applied=True) def mood_of(state) -> Mood: diff --git a/backend/app/dialog/factory.py b/backend/app/dialog/factory.py index 4fce413..9dd4943 100644 --- a/backend/app/dialog/factory.py +++ b/backend/app/dialog/factory.py @@ -1,4 +1,4 @@ -"""Кто играет звонящего: LLM, если есть ключ, иначе заготовки. +"""Кто играет звонящего: локальная LLM, таблица или заготовки. Провайдер и модель меняются значением в конфиге, а не кодом. Заготовки — не запасной костыль, а рабочий режим: занятие идёт и без сети. @@ -9,30 +9,45 @@ import logging from app.config import get_settings from app.dialog.caller import Caller, LlmCaller, TemplateCaller from app.dialog.llm import LlmClient +from app.dialog.llm import is_loopback_url from app.dialog.tree import TreeCaller, has_table from app.db.base import get_sessionmaker log = logging.getLogger(__name__) -def build_caller(scenario_id: str | None = None, sessionmaker=None) -> Caller: +def build_caller( + scenario_id: str | None = None, + sessionmaker=None, + *, + use_pregenerated: bool = False, +) -> Caller: settings = get_settings() - # Офлайн и «нет ключа» — это один и тот же путь: предгенерированная таблица, - # а не локальная модель. Формулировки в ней от облачной модели, а задержка - # нулевая (docs/arch/STACK.md). - if (settings.offline or not settings.llm_api_key) and scenario_id and has_table(scenario_id): + local = settings.llm_provider == "local" + mode = settings.dialogue_model_mode + if mode not in {"dialogue", "russian_control"}: + raise ValueError(f"неизвестный DIALOGUE_MODEL_MODE: {mode}") + model = settings.llm_model_control if mode == "russian_control" else settings.llm_model_caller + base_url = settings.llm_control_base_url if mode == "russian_control" else settings.llm_base_url + model_allowed = settings.llm_provider != "disabled" and bool(model and base_url and ( + is_loopback_url(base_url, allow_docker_host=settings.allow_docker_host_models) + if settings.offline or local else settings.llm_api_key + )) + + # Локальный loopback не считается внешней сетью. Если сервер модели не + # отвечает, LlmCaller откатится на проверенные заготовки этой же реплики. + if model_allowed: + client = LlmClient(sessionmaker=sessionmaker or _safe_sessionmaker(), base_url=base_url) + log.info("звонящий: режим %s, модель %s, адрес %s", mode, model, base_url) + return LlmCaller(client, model=model) + + if use_pregenerated and scenario_id and has_table(scenario_id): log.info("звонящий по предгенерированной таблице сценария %s", scenario_id) return TreeCaller(scenario_id) - if not settings.llm_api_key or settings.offline: - reason = "офлайн-режим" if settings.offline else "нет ключа LLM" - log.info("звонящий отвечает заготовками: %s (таблицы нет — make pregen)", reason) - return TemplateCaller() - - client = LlmClient(sessionmaker=sessionmaker or _safe_sessionmaker()) - log.info("звонящий на модели %s", settings.llm_model_caller) - return LlmCaller(client, model=settings.llm_model_caller) + log.info("звонящий отвечает фактами сценария: модель отключена или предгенерация не разрешена") + return TemplateCaller() def _safe_sessionmaker(): diff --git a/backend/app/dialog/llm.py b/backend/app/dialog/llm.py index a752e19..8b6375e 100644 --- a/backend/app/dialog/llm.py +++ b/backend/app/dialog/llm.py @@ -1,4 +1,4 @@ -"""Клиент облачной LLM за интерфейсом: провайдер меняется значением в конфиге. +"""Клиент совместимого API для локальных или внешних моделей. Кэш ответов по хешу контекста лежит в Postgres, а не в Redis: база уже поднята, лишняя движущаяся часть на стенде не нужна (docs/arch/STACK.md). Кэш работает @@ -9,7 +9,10 @@ import hashlib import json import logging +import re from dataclasses import dataclass +from ipaddress import ip_address +from urllib.parse import urlsplit import httpx from sqlalchemy import select @@ -26,6 +29,48 @@ class LlmUnavailable(RuntimeError): занятие продолжается — молчащий звонящий хуже шаблонной фразы.""" +def is_loopback_url(value: str, *, allow_docker_host: bool = False) -> bool: + """В офлайн-режиме модели разрешены лишь на той же машине. + + Не доверяем доменам или hosts-записям: они могут указывать наружу. + """ + try: + url = urlsplit(value) + host = url.hostname or "" + try: + local_host = ip_address(host).is_loopback + except ValueError: + local_host = allow_docker_host and host == "host.docker.internal" + return (url.scheme == "http" and local_host and url.port is not None + and not url.username and not url.password) + except (ValueError, TypeError): + return False + + +def _spoken_content(raw: str, *, strip_reasoning: bool = False) -> str: + """Убрать только пустой служебный хвост Qwen3, не рассуждения модели. + + llama.cpp с выключенным thinking иногда возвращает в `content` один + закрывающий `` перед самой репликой. Внутренний текст размышлений + мы намеренно не вырезаем: если он есть, ответ небезопасен и идёт fallback. + """ + text = raw.strip() + if strip_reasoning: + closing = list(re.finditer(r"\s*", text, re.IGNORECASE)) + if closing: + text = text[closing[-1].end():].strip() + elif text.startswith("<|"): + start = text.find("{") + if start >= 0: + text = text[start:].strip() + text = re.sub(r"^(?:\s*)+", "", text, flags=re.IGNORECASE).strip() + if re.search(r" str: payload = json.dumps( - {"m": self.model, "t": self.temperature, "msgs": self.messages}, + {"m": self.model, "t": self.temperature, "msgs": self.messages, + "format": self.response_format, "strip_reasoning": self.strip_reasoning}, ensure_ascii=False, sort_keys=True, ) @@ -50,36 +100,43 @@ class LlmClient: *, sessionmaker: async_sessionmaker | None = None, transport: httpx.AsyncBaseTransport | None = None, + base_url: str | None = None, # Ответ дольше этого бессмысленен: бюджет хода — 1.5 с, а звонящий # с заготовками ответит сразу. timeout: float = 8.0, ) -> None: settings = get_settings() - self._base_url = settings.llm_base_url.rstrip("/") + self._base_url = (base_url or settings.llm_base_url).rstrip("/") self._key = settings.llm_api_key + self._local_only = settings.offline or settings.llm_provider == "local" + self._allow_docker_host = settings.allow_docker_host_models self._sessionmaker = sessionmaker - self._client = httpx.AsyncClient(timeout=timeout, transport=transport) + self._client = httpx.AsyncClient(timeout=timeout, transport=transport, trust_env=False) @property def configured(self) -> bool: + if self._local_only: + return is_loopback_url( + self._base_url, allow_docker_host=self._allow_docker_host + ) return bool(self._key and self._base_url) async def complete(self, request: LlmRequest, *, use_cache: bool = True) -> str: """Ответ модели. Кэш по хешу контекста: та же реплика на том же месте занятия звучит одинаково у каждой группы.""" if not self.configured: - raise LlmUnavailable("не задан ключ или адрес провайдера") + raise LlmUnavailable("локальный адрес модели недопустим или провайдер не настроен") key = request.cache_key() if use_cache: cached = await self._from_cache(key) if cached is not None: - return cached + return _spoken_content(cached, strip_reasoning=request.strip_reasoning) try: response = await self._client.post( f"{self._base_url}/chat/completions", - headers={"Authorization": f"Bearer {self._key}"}, + headers={"Authorization": f"Bearer {self._key}"} if self._key else {}, json={ "model": request.model, "messages": request.messages, @@ -88,6 +145,8 @@ class LlmClient: # Рассуждение в ответе не нужно: оно только раздувает трафик. # Провайдеры, которые про это поле не знают, его игнорируют. "reasoning": {"exclude": True}, + **({"response_format": request.response_format} + if request.response_format is not None else {}), }, ) except httpx.HTTPError as exc: @@ -97,13 +156,14 @@ class LlmClient: # Тело ошибки в лог, ключ в заголовке — не логируется. raise LlmUnavailable(f"HTTP {response.status_code}: {response.text[:200]}") - message = response.json()["choices"][0]["message"] - text = (message.get("content") or "").strip() - if not text: - # У рассуждающих моделей при нехватке бюджета весь ответ уходит - # в размышление, а content приходит пустым. Для занятия это отказ: - # звонящий откатится на заготовку, а не промолчит. - raise LlmUnavailable("пустой ответ модели: весь бюджет токенов ушёл в рассуждение") + try: + message = response.json()["choices"][0]["message"] + content = message.get("content") + if content is not None and not isinstance(content, str): + raise TypeError("content не строка") + text = _spoken_content(content or "", strip_reasoning=request.strip_reasoning) + except (ValueError, KeyError, IndexError, TypeError, AttributeError) as exc: + raise LlmUnavailable("некорректный ответ локальной модели") from exc if use_cache and text: await self._to_cache(key, request, text) return text diff --git a/backend/app/dialog/prompts/caller.md b/backend/app/dialog/prompts/caller.md index 30614c2..e996c4a 100644 --- a/backend/app/dialog/prompts/caller.md +++ b/backend/app/dialog/prompts/caller.md @@ -19,3 +19,5 @@ 4. Если состояние — паника или крик: обрывки, повторы, незаконченные фразы. 5. Не задавай оператору вопросов о ходе разговора и не подсказывай ему, что спросить. 6. Отвечай только репликой, без пояснений и без кавычек. +7. Каждый факт из раздела «ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ» произнеси + полностью и дословно. Одного «да», «нет» или намёка недостаточно. diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py index 62ae457..cb160ec 100644 --- a/backend/app/domain/events.py +++ b/backend/app/domain/events.py @@ -15,14 +15,15 @@ from enum import StrEnum from typing import Annotated, Any, Literal from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator -from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome +from app.domain.classifiers import DDSCode, Level, Outcome from app.domain.kio import KIO, Coords from app.domain.roles import Role from app.domain.statuses import ServiceStatus, StationSnapshot from app.domain.taxonomy import Finding from app.domain.timers import TimerSnapshot +from app.scoring.taxonomy import METRIC_MAP class SessionMode(StrEnum): @@ -40,6 +41,30 @@ class Exercise(StrEnum): CARD = "card" +class LessonCriteria(BaseModel): + """Настраиваемые преподавателем условия именно этого занятия. + + Нормативы ГОСТ для приёма вызова сюда не входят. Для занятия меняются + учебный лимит решения, порог успешности и веса метрик; веса сценария + остаются базовыми, а настройки занятия могут их переопределить. + """ + + decision_time_limit_seconds: int = Field(default=30, ge=5, le=300) + allowed_errors: int = Field(default=0, ge=0, le=50) + require_correct_grammar: bool = True + score_weights: dict[str, float] = Field(default_factory=dict) + + @field_validator("score_weights") + @classmethod + def validate_score_weights(cls, weights: dict[str, float]) -> dict[str, float]: + unknown = weights.keys() - METRIC_MAP.keys() + if unknown: + raise ValueError(f"неизвестные метрики весов: {', '.join(sorted(unknown))}") + if any(not 0 <= weight <= 10 for weight in weights.values()): + raise ValueError("каждый вес метрики должен быть от 0 до 10") + return weights + + class Speaker(StrEnum): CALLER = "caller" OPERATOR = "operator" @@ -346,6 +371,7 @@ class SessionSnapshot(BaseModel): level: Level mode: SessionMode exercise: Exercise = Exercise.CALL + criteria: LessonCriteria = Field(default_factory=LessonCriteria) trainee_name: str | None = None started_at: datetime | None = None kio: KIO @@ -420,12 +446,20 @@ class ScenarioStart(BaseModel): type: Literal["scenario.start"] = "scenario.start" scenario_id: str 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) + dds_max_waiting: int = Field(default=3, ge=1, le=10) trainee: str trainee_id: UUID | None = None + # Служба обучающегося определяет, чьи статусы он ведёт. В рабочем режиме + # сервер заменяет это значение данными учётной записи; поле нужно demo без БД. + dds_service: str | None = None group_id: str | None = None mode: SessionMode exercise: Exercise = Exercise.CALL handoff_to_dds: bool = False + criteria: LessonCriteria = Field(default_factory=LessonCriteria) class DirectorInject(BaseModel): @@ -527,6 +561,35 @@ class PhoneDial(BaseModel): type: Literal["phone.dial"] = "phone.dial" +class PhoneBrief(BaseModel): + """Что диспетчер передал старшему группы при первом исходящем звонке.""" + + type: Literal["phone.brief"] = "phone.brief" + address: str = Field(min_length=3, max_length=300) + incident: str = Field(min_length=3, max_length=500) + request: str = Field(default="", max_length=500) + + +class PhoneCheck(BaseModel): + """Запрос диспетчера об обстановке после предыдущего доклада.""" + + type: Literal["phone.check"] = "phone.check" + text: str = Field(min_length=8, max_length=500) + + +class PhoneHangup(BaseModel): + type: Literal["phone.hangup"] = "phone.hangup" + + +class PhoneLine(BaseModel): + type: Literal["phone.line"] = "phone.line" + service: str + crew: str + speaker: Literal["dispatcher", "crew"] + text: str + at: datetime + + class PhoneReport(BaseModel): type: Literal["phone.report"] = "phone.report" service: str @@ -551,6 +614,13 @@ class CardNext(BaseModel): card_id: UUID +class CardOpen(BaseModel): + """Открыть одну из уже выданных карточек, не меняя её таймер.""" + + type: Literal["card.open"] = "card.open" + card_id: UUID + + class StationState(BaseModel): """Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше.""" @@ -574,13 +644,14 @@ class CrewArrived(BaseModel): ServerToStation = Annotated[ - CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent, + CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent, Field(discriminator="type"), ] StationToServer = Annotated[ - CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish - | CardReply | CardNext + CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck + | PhoneHangup | StationFinish + | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived, Field(discriminator="type"), ] @@ -651,6 +722,9 @@ class SessionReport(BaseModel): scenario_id: str mode: SessionMode attempt: int = 1 + criteria: LessonCriteria + failed_metrics: int + passed: bool transcript: list[TranscriptEntry] findings: list[Finding] metrics: list[Metric] diff --git a/backend/app/domain/kio.py b/backend/app/domain/kio.py index 527ef79..9b4f201 100644 --- a/backend/app/domain/kio.py +++ b/backend/app/domain/kio.py @@ -90,6 +90,7 @@ class KIO(BaseModel): caller_number: str | None = None caller_name: str | None = None caller_contact: str | None = None + phone_on_scene: str | None = None language: str = "ru" # Место diff --git a/backend/app/domain/roles.py b/backend/app/domain/roles.py index 19a68e7..a13ba92 100644 --- a/backend/app/domain/roles.py +++ b/backend/app/domain/roles.py @@ -23,7 +23,9 @@ ROLE_LABELS: dict[Role, str] = { #: настройки, администратор не вмешивается в оценки, обучающийся не видит #: чужих результатов. SCREENS: dict[Role, tuple[str, ...]] = { - Role.ADMIN: ("/admin", "/profile"), - Role.INSTRUCTOR: ("/instructor", "/wall", "/profile", "/dds"), - Role.TRAINEE: ("/trainee", "/profile"), + Role.ADMIN: ("/admin", "/profile", "/groups", "/materials"), + Role.INSTRUCTOR: ( + "/instructor", "/wall", "/profile", "/dds", "/phone", "/groups", "/materials", + ), + Role.TRAINEE: ("/trainee", "/dds", "/phone", "/profile", "/materials"), } diff --git a/backend/app/domain/statuses.py b/backend/app/domain/statuses.py index 3993afe..2def68b 100644 --- a/backend/app/domain/statuses.py +++ b/backend/app/domain/statuses.py @@ -135,12 +135,46 @@ class PhoneReportRecord(BaseModel): at: datetime +class PhoneLineRecord(BaseModel): + service: str + crew: str + speaker: str # dispatcher | crew + text: str + at: datetime + + +class PhoneCallPending(BaseModel): + service: str + crew: str + phase: str + + class DdsCardSummary(BaseModel): card_id: UUID scenario_id: str score_auto: float +class DdsQueueCard(BaseModel): + """Строка общей очереди ДДС; таймер каждой карточки идёт независимо.""" + + card_id: UUID + scenario_id: str + title: str + address: str | None = None + description: str | None = None + incident_type: str | None = None + victims_count: int | None = None + received_at: datetime + managed_service: str | None = None + service_status: ServiceStatus + card_status: CardStatus + elapsed_ms: int + limit_ms: int + timer_stopped: bool + active: bool + + class StatusError(ValueError): """Переход запрещён автоматом или нет обязательного комментария.""" @@ -208,6 +242,8 @@ class StationSnapshot(BaseModel): """ services: list[str] + recipient_services: list[str] = [] + managed_service: str | None = None statuses: dict[str, ServiceStatus] available: dict[str, list[ServiceStatus]] card: CardStatus @@ -215,8 +251,14 @@ class StationSnapshot(BaseModel): crew_options: list[str] = [] crew_selected: str | None = None phone_reports: list[PhoneReportRecord] = [] + phone_lines: list[PhoneLineRecord] = [] + phone_pending: PhoneCallPending | None = None card_id: UUID | None = None card_index: int = 1 card_total: int = 1 reply_text: str = "" completed_cards: list[DdsCardSummary] = [] + queue_cards: list[DdsQueueCard] = [] + pending_cards_count: int = 0 + next_arrival_in_seconds: int | None = None + max_waiting_cards: int = 3 diff --git a/backend/app/main.py b/backend/app/main.py index 5c7b692..b14ef8c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ 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 scenarios as scenarios_api from app.api.http import sessions from app.api.http import trainees @@ -29,6 +30,7 @@ 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 LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" @@ -38,6 +40,7 @@ LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" # а до лога доходят одни предупреждения. Чужие библиотеки — от WARNING. logging.basicConfig(level=logging.WARNING, format="%(levelname)-8s %(name)s: %(message)s") logging.getLogger("app").setLevel(logging.INFO) +install_diagnostics() @asynccontextmanager @@ -52,6 +55,9 @@ async def lifespan(app: FastAPI): except ScenarioError as exc: raise RuntimeError(f"библиотека сценариев не прошла проверку: {exc}") from exc app.state.scenarios_loaded = len(loaded) + if settings.demo_no_db: + store.reset_demo_drafts() + materials_api.reset_demo_materials() # Утверждённые преподавателем сценарии хранятся в БД и должны переживать # перезапуск процесса. При недоступной БД остаётся базовая YAML-библиотека. @@ -71,8 +77,34 @@ async def lifespan(app: FastAPI): "DEMO_NO_DB: занятия и оценки живут только до перезапуска; журнал БД выключен" ) + if not settings.demo_no_db: + try: + async with asyncio.timeout(2): + await auth.load_generations() + except (SQLAlchemyError, OSError, TimeoutError) as exc: + logging.getLogger(__name__).warning( + "не удалось загрузить версии полномочий: %s", exc + ) + # Журнал: всё, что не записано, для оценки не существует. hub.journal = None if settings.demo_no_db else DbJournal(get_sessionmaker()) + app.state.sessions_restored = 0 + if hub.journal is not None: + try: + async with asyncio.timeout(3): + restored = await hub.journal.restore_active() + for state in restored: + hub.register(state) + hub.start_ticker(state.session_id) + app.state.sessions_restored = len(restored) + if restored: + logging.getLogger(__name__).info( + "восстановлено активных занятий: %d", len(restored) + ) + except (SQLAlchemyError, OSError, TimeoutError) as exc: + logging.getLogger(__name__).warning( + "не удалось восстановить активные занятия: %s", exc + ) # Эмбеддинги для слот-автомата — грузятся один раз, до первого занятия. app.state.embeddings_ready = not settings.demo_no_db and get_embedder() is not None @@ -81,6 +113,10 @@ async def lifespan(app: FastAPI): app.state.models_ready = get_voice_models() is not None yield + if hub.journal is not None: + for state in list(hub._sessions.values()): + if not state.ended: + await hub.journal.checkpoint(state) await hub.shutdown() for state in list(hub._sessions.values()): if state.voice is not None: @@ -94,7 +130,7 @@ app.add_middleware( SessionMiddleware, secret_key=get_settings().session_secret, session_cookie="lct_session", - https_only=False, # учебный класс живёт по http во внутренней сети + https_only=get_settings().secure_cookies, max_age=12 * 60 * 60, # смена занятий, не месяц ) app.include_router(auth.router) @@ -102,6 +138,7 @@ app.include_router(sessions.router) app.include_router(scenarios_api.router) app.include_router(ekp_api.router) app.include_router(groups_api.router) +app.include_router(materials_api.router) app.include_router(admin_api.router) app.include_router(trainees.router) app.include_router(call_ws.router) @@ -119,6 +156,7 @@ async def health() -> dict: "models_ready": getattr(app.state, "models_ready", False), "scenarios_loaded": getattr(app.state, "scenarios_loaded", 0), "embeddings_ready": getattr(app.state, "embeddings_ready", False), + "sessions_restored": getattr(app.state, "sessions_restored", 0), "offline": settings.offline, "demo_no_db": settings.demo_no_db, } diff --git a/backend/app/monitoring.py b/backend/app/monitoring.py new file mode 100644 index 0000000..1fd02b9 --- /dev/null +++ b/backend/app/monitoring.py @@ -0,0 +1,167 @@ +"""Small, dependency-free runtime diagnostics for the local stand. + +The product has to run on Windows as well as Linux. Keep the collector in +the standard library: unavailable host counters are returned as ``None`` +instead of making the administrator screen fail. +""" + +from __future__ import annotations + +import logging +import os +import re +import shutil +import sys +import threading +import time +from collections import deque +from datetime import datetime, timezone +from pathlib import Path + + +STARTED_MONOTONIC = time.monotonic() +_events: deque[dict] = deque(maxlen=500) +_installed = False +_sample_lock = threading.Lock() +_last_wall = time.monotonic() +_last_cpu = time.process_time() + +_CREDENTIALS = re.compile(r"(://[^:/\s]+:)([^@/\s]+)(@)") +_TOKENS = re.compile( + r"(?i)\b(session_secret|llm_api_key|authorization|password)\b\s*[:=]\s*([^\s,;]+)" +) + + +def _safe(message: str) -> str: + message = _CREDENTIALS.sub(r"\1[скрыто]\3", message) + message = _TOKENS.sub(r"\1=[скрыто]", message) + return message.replace("\x00", "")[:2000] + + +class DiagnosticHandler(logging.Handler): + """Bounded warning/error journal safe enough to expose to an admin.""" + + def emit(self, record: logging.LogRecord) -> None: + try: + _events.append({ + "at": datetime.fromtimestamp(record.created, timezone.utc), + "level": record.levelname, + "source": record.name[:120], + "message": _safe(record.getMessage()), + }) + except Exception: # logging must never take the service down + pass + + +def install_diagnostics() -> None: + global _installed + if _installed: + return + handler = DiagnosticHandler(level=logging.WARNING) + logging.getLogger().addHandler(handler) + _installed = True + + +def recent_events(*, limit: int = 100) -> list[dict]: + return list(_events)[-max(1, min(limit, 500)):][::-1] + + +def _rss_bytes() -> int | None: + try: + if sys.platform.startswith("linux"): + resident_pages = int(Path("/proc/self/statm").read_text().split()[1]) + return resident_pages * int(os.sysconf("SC_PAGE_SIZE")) + if os.name == "nt": + import ctypes + from ctypes import wintypes + + class Counters(ctypes.Structure): + _fields_ = [ + ("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD), + ("PeakWorkingSetSize", ctypes.c_size_t), + ("WorkingSetSize", ctypes.c_size_t), + ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), + ("QuotaPagedPoolUsage", ctypes.c_size_t), + ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), + ("QuotaNonPagedPoolUsage", ctypes.c_size_t), + ("PagefileUsage", ctypes.c_size_t), + ("PeakPagefileUsage", ctypes.c_size_t), + ] + + counters = Counters() + counters.cb = ctypes.sizeof(counters) + ctypes.windll.psapi.GetProcessMemoryInfo( # type: ignore[attr-defined] + ctypes.windll.kernel32.GetCurrentProcess(), ctypes.byref(counters), counters.cb + ) + return int(counters.WorkingSetSize) + import resource + + value = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + return int(value if sys.platform == "darwin" else value * 1024) + except (OSError, ValueError, AttributeError, ImportError, IndexError): + return None + + +def _memory() -> tuple[int | None, int | None]: + try: + if sys.platform.startswith("linux"): + fields: dict[str, int] = {} + for line in Path("/proc/meminfo").read_text().splitlines(): + name, value = line.split(":", 1) + fields[name] = int(value.strip().split()[0]) * 1024 + return fields.get("MemTotal"), fields.get("MemAvailable") + if os.name == "nt": + import ctypes + + class MemoryStatus(ctypes.Structure): + _fields_ = [ + ("length", ctypes.c_ulong), ("memory_load", ctypes.c_ulong), + ("total_physical", ctypes.c_ulonglong), + ("available_physical", ctypes.c_ulonglong), + ("total_page_file", ctypes.c_ulonglong), + ("available_page_file", ctypes.c_ulonglong), + ("total_virtual", ctypes.c_ulonglong), + ("available_virtual", ctypes.c_ulonglong), + ("available_extended_virtual", ctypes.c_ulonglong), + ] + + status = MemoryStatus() + status.length = ctypes.sizeof(status) + ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)) # type: ignore[attr-defined] + return int(status.total_physical), int(status.available_physical) + page = int(os.sysconf("SC_PAGE_SIZE")) + return int(os.sysconf("SC_PHYS_PAGES")) * page, None + except (OSError, ValueError, AttributeError, ImportError): + return None, None + + +def sample_metrics() -> dict: + """Return a cheap point-in-time sample; the first CPU sample is zero.""" + global _last_wall, _last_cpu + now = time.monotonic() + cpu_now = time.process_time() + with _sample_lock: + wall_delta = max(now - _last_wall, 0.001) + cpu_percent = max(0.0, (cpu_now - _last_cpu) / wall_delta * 100.0) + _last_wall, _last_cpu = now, cpu_now + + cores = os.cpu_count() or 1 + try: + load_1m_percent = os.getloadavg()[0] / cores * 100.0 + except (AttributeError, OSError): + load_1m_percent = None + memory_total, memory_available = _memory() + disk = shutil.disk_usage(Path.cwd()) + return { + "at": datetime.now(timezone.utc), + "uptime_seconds": max(0.0, now - STARTED_MONOTONIC), + "cpu_percent": round(cpu_percent, 2), + "load_1m_percent": round(load_1m_percent, 2) if load_1m_percent is not None else None, + "cpu_cores": cores, + "rss_bytes": _rss_bytes(), + "memory_total_bytes": memory_total, + "memory_available_bytes": memory_available, + "disk_total_bytes": disk.total, + "disk_free_bytes": disk.free, + "threads": threading.active_count(), + } diff --git a/backend/app/scenarios/editor.py b/backend/app/scenarios/editor.py index 90b8796..7456776 100644 --- a/backend/app/scenarios/editor.py +++ b/backend/app/scenarios/editor.py @@ -5,6 +5,7 @@ """ from copy import deepcopy +import re from pydantic import ValidationError @@ -12,6 +13,31 @@ from app.scenarios.loader import ScenarioError, _derive_ground_truth from app.scenarios.schema import Scenario DERIVED_TRUTH = {"incident_type", "dds", "incident_code", "notify", "required_facts"} +PLACEHOLDER = re.compile(r"\{\{?\s*[a-zA-Z_][\w.-]*\s*\}?\}") + + +def classifier_sign_gaps(signs: list[str], narrative: list[str]) -> list[str]: + """Найти признаки ЕКП, которые не подтверждены текстом сценария. + + Сравниваются трёхбуквенные основы, поэтому русские окончания + (``жилой дом`` / ``жилого дома``) не создают ложного расхождения. Это + намеренно строгая, детерминированная граница: модель не может заявить + один объект, а в карточку незаметно положить код другого. + """ + def stems(value: str) -> set[str]: + return { + word[:3] + for word in re.findall(r"[0-9a-zа-яё]+", value.casefold()) + if len(word) >= 3 + } + + present = stems(" ".join(narrative)) + gaps: list[str] = [] + for sign in signs: + required = stems(sign) + if required and not required <= present: + gaps.append(sign) + return gaps def template_copy(source: Scenario, draft_id: str, title: str | None = None) -> dict: @@ -92,9 +118,26 @@ def validate(body: dict) -> Scenario: if item.fact and item.fact not in known: raise ScenarioError(f"пункт {item.id} ссылается на факт {item.fact}, которого нет") for fact in scenario.facts: + if PLACEHOLDER.search(fact.value) or (fact.refined and PLACEHOLDER.search(fact.refined)): + raise ScenarioError( + f"факт {fact.id}: остался плейсхолдер вместо конкретного ответа заявителя" + ) question_id = fact.reveal_on.question if fact.reveal_on else None if question_id and question_id not in question_ids: raise ScenarioError(f"факт {fact.id} раскрывается несуществующим вопросом {question_id}") if fact.refine_on and fact.refine_on not in question_ids: raise ScenarioError(f"факт {fact.id} уточняется несуществующим вопросом {fact.refine_on}") + # Для старых карточек и безопасных вариаций классификатор уже утверждён + # заказчиком. Новый свободный сюжет обязан доказать свой путь ЕКП текстом. + if scenario.id.startswith("ai-full-"): + gaps = classifier_sign_gaps( + scenario.signs, + [scenario.title, scenario.first_line] + + [fact.value for fact in scenario.facts] + + [fact.refined for fact in scenario.facts if fact.refined], + ) + if gaps: + raise ScenarioError( + "signs: сюжет не подтверждает путь ЕКП: " + ", ".join(gaps) + ) return _derive_ground_truth(scenario) diff --git a/backend/app/scenarios/generation.py b/backend/app/scenarios/generation.py new file mode 100644 index 0000000..31dec7c --- /dev/null +++ b/backend/app/scenarios/generation.py @@ -0,0 +1,634 @@ +"""Локальная ИИ-вариация утверждённого сценария для проверки преподавателем. + +Модель пишет только сюжетные поля. Адрес, число пострадавших, классификацию, +список оповещения и протокол оценки она менять не может. Публикация — отдельное +действие преподавателя после просмотра черновика и эталона. +""" + +import json +import re + +from app.config import get_settings +from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url +from app.scenarios.editor import classifier_sign_gaps, template_copy, validate +from app.scenarios.loader import ScenarioError +from app.scenarios.schema import Scenario + + +class GenerationError(ValueError): + pass + + +PROTECTED_IDS = ("address", "people", "victim", "floor", "location", "region") +PLACEHOLDER = re.compile(r"\{\{?\s*[a-zA-Z_][\w.-]*\s*\}?\}") +NUMBER_WORD = re.compile( + r"\b(?:ноль|один|одна|одно|одного|одну|два|две|двое|двух|три|трое|тр[её]х|" + r"четыре|четверо|четыр[её]х|пять|пятеро|пяти|шесть|шестеро|шести|семь|" + r"семеро|семи|восемь|восьмеро|восьми|девять|девятеро|девяти|десять|" + r"десятеро|десяти)\b", + re.IGNORECASE, +) + + +def reveals_number(value: str) -> bool: + return bool(re.search(r"\d", value) or NUMBER_WORD.search(value)) + + +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 + and not any(part in fact.id.casefold() for part in PROTECTED_IDS)] + + +def correction_target(source: Scenario, instruction: str) -> str | None: + """Указать маленькой модели конкретный факт для правки при повторе.""" + eligible = [fact for fact in source.facts if fact.id in editable_fact_ids(source)] + if not eligible: + return None + def stems(text: str) -> set[str]: + return {word[:4] for word in re.findall(r"[а-яё]{3,}", text.casefold())} + + words = stems(instruction) + questions = {item.fact: item.question or "" for item in source.checklist if item.fact} + + def relevance(fact) -> int: + haystack = stems(f"{fact.value} {questions.get(fact.id, '')}") + return len(words & haystack) + + _, best = max(enumerate(eligible), key=lambda pair: (relevance(pair[1]), pair[0])) + return best.id if relevance(best) > 0 else None + + +def style_fallback(source: Scenario, instruction: str) -> dict | None: + """Безопасно сократить реплику, если 1.7B дважды вернула копию.""" + if not re.search(r"\b(?:короч|кратк)", instruction.casefold()): + return None + chunks = re.findall(r"[^.!?…]+[.!?…]+|[^.!?…]+$", source.first_line) + useful: list[str] = [] + for chunk in chunks: + value = chunk.strip() + letters = re.sub(r"[^а-яё]", "", value.casefold()) + if not letters or letters in {"алло", "ааа", "аа", "а"}: + continue + useful.append(value) + if not useful: + return None + shortened = " ".join(useful[:2])[:300].strip() + if shortened == source.first_line.strip(): + return None + return {"title": source.title, "first_line": shortened, "facts": {}} + + +def parse_proposal( + raw: str, source: Scenario, *, require_fact_change: bool = True, + instruction: str = "", restrict_first_line_facts: bool = False, +) -> dict: + """Никакой произвольный JSON модели не попадает в схему сценария.""" + text = raw.strip() + fenced = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE) + if fenced: + text = fenced.group(1) + try: + proposal = json.loads(text) + except (ValueError, TypeError) as exc: + raise GenerationError("модель не вернула корректный JSON") from exc + if not isinstance(proposal, dict) or set(proposal) != {"title", "first_line", "facts"}: + raise GenerationError("модель вернула поля вне разрешённого формата") + title, first_line, facts = (proposal[key] for key in ("title", "first_line", "facts")) + if not isinstance(title, str) or not 5 <= len(title.strip()) <= 200: + raise GenerationError("название варианта пустое или слишком длинное") + if not isinstance(first_line, str) or not 8 <= len(first_line.strip()) <= 300: + raise GenerationError("первая реплика пустая или слишком длинная") + if reveals_number(first_line): + raise GenerationError("первая реплика раскрывает числовые детали до опроса") + if instruction and restrict_first_line_facts: + stop = {"есть", "если", "меня", "очень", "этот", "только", "сейчас", "помогите"} + + def stems(value: str) -> set[str]: + return {word[:3] for word in re.findall(r"[а-яё]{3,}", value.casefold()) + if word not in stop} + + fact_stems = set().union(*(stems(fact.value) for fact in source.facts)) + allowed_stems = stems(f"{source.first_line} {instruction}") + leaked = (stems(first_line) & fact_stems) - allowed_stems + if leaked: + raise GenerationError( + "первая реплика добавляет не запрошенные обстоятельства карточки" + ) + if not isinstance(facts, dict): + raise GenerationError("факты должны быть объектом") + allowed = set(editable_fact_ids(source)) + if set(facts) - allowed: + raise GenerationError("модель попыталась изменить защищённые или неизвестные факты") + for fact_id, value in facts.items(): + if not isinstance(value, str) or not 3 <= len(value.strip()) <= 300: + raise GenerationError(f"факт {fact_id}: значение пустое или слишком длинное") + if PLACEHOLDER.search(value): + raise GenerationError(f"факт {fact_id}: модель вернула плейсхолдер вместо ответа") + if "?" in value: + raise GenerationError(f"факт {fact_id}: модель вернула вопрос вместо ответа") + source_values = {fact.id: fact.value for fact in source.facts} + fact_changed = any(value.strip() != source_values[fact_id] for fact_id, value in facts.items()) + story_changed = title.strip() != source.title or first_line.strip() != source.first_line + if require_fact_change and not fact_changed: + raise GenerationError("модель не предложила новых обстоятельств происшествия") + if not fact_changed and not story_changed: + raise GenerationError("модель не изменила черновик по комментарию") + return {"title": title.strip(), "first_line": first_line.strip(), "facts": facts} + + +def proposal_body(source: Scenario, draft_id: str, proposal: dict) -> dict: + body = template_copy(source, draft_id, proposal["title"]) + by_id = proposal["facts"] + for fact in body["facts"]: + if fact["id"] in by_id: + fact["value"] = by_id[fact["id"]].strip() + body["first_line"] = proposal["first_line"] + # Схема, связи фактов и эталон проверяются до сохранения черновика. + validate(body) + return body + + +def parse_full_proposal(raw: str, source: Scenario) -> dict: + """Проверить новый сюжет, где модель заполняет все ответы и мягкий эталон. + + Тип, признаки ЕКП, маршрутизация и чек-лист остаются структурным каркасом, + но сами обстоятельства, адрес и число пострадавших создаются заново. + """ + text = raw.strip() + fenced = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE) + if fenced: + text = fenced.group(1) + try: + proposal = json.loads(text) + except (ValueError, TypeError) as exc: + raise GenerationError("модель не вернула корректный JSON нового сюжета") from exc + if not isinstance(proposal, dict) or set(proposal) != { + "title", "first_line", "facts", "ground_truth" + }: + raise GenerationError("новый сюжет содержит поля вне разрешённого формата") + title = proposal["title"] + first_line = proposal["first_line"] + facts = proposal["facts"] + truth = proposal["ground_truth"] + if not isinstance(title, str) or not 5 <= len(title.strip()) <= 200: + raise GenerationError("название нового сюжета пустое или слишком длинное") + if not isinstance(first_line, str) or not 8 <= len(first_line.strip()) <= 300: + raise GenerationError("первая реплика нового сюжета пустая или слишком длинная") + if reveals_number(first_line): + raise GenerationError("первая реплика раскрывает числовые детали до опроса") + expected = {fact.id for fact in source.facts} + if not isinstance(facts, dict) or set(facts) != expected: + raise GenerationError("новый сюжет должен заполнить каждый факт каркаса ровно один раз") + for fact_id, value in facts.items(): + if not isinstance(value, str) or not 3 <= len(value.strip()) <= 300: + raise GenerationError(f"факт {fact_id}: значение пустое или слишком длинное") + if PLACEHOLDER.search(value): + raise GenerationError(f"факт {fact_id}: модель вернула плейсхолдер вместо ответа") + if "?" in value: + raise GenerationError(f"факт {fact_id}: модель вернула вопрос вместо ответа") + if not isinstance(truth, dict) or set(truth) != {"address", "victims"}: + raise GenerationError("ground_truth нового сюжета должен содержать address и victims") + address, victims = truth["address"], truth["victims"] + if not isinstance(address, str) or not 5 <= len(address.strip()) <= 200: + raise GenerationError("эталонный адрес пустой или слишком длинный") + if not isinstance(victims, int) or isinstance(victims, bool) or not 0 <= victims <= 20: + raise GenerationError("число пострадавших должно быть целым от 0 до 20") + address_id = next((fact.id for fact in source.facts if "address" in fact.id.casefold()), None) + if address_id is None: + raise GenerationError("в выбранном каркасе нет адресного факта") + + def normalized(value: str) -> str: + return re.sub(r"\s+", " ", value.casefold().replace("ё", "е")).strip(" .,") + + if not normalized(facts[address_id]).startswith(normalized(address)): + raise GenerationError("эталонный адрес не совпадает с началом адресного факта") + evidence = " ".join(facts.values()).casefold().replace("ё", "е") + count_words = { + 0: ("ноль",), 1: ("один", "одна", "одного"), + 2: ("два", "двое", "двух"), 3: ("три", "трое", "трех"), + 4: ("четыре", "четверо", "четырех"), 5: ("пять", "пятеро", "пяти"), + 6: ("шесть", "шестеро", "шести"), 7: ("семь", "семеро", "семи"), + 8: ("восемь", "восьмеро", "восьми"), 9: ("девять", "девятеро", "девяти"), + 10: ("десять", "десятеро", "десяти"), + } + numeric = re.search(rf"пострадавш\w*\s*[:—-]?\s*{victims}(?:\D|$)", evidence) + worded = any(re.search( + rf"(?:пострадавш\w*.{{0,25}}\b{word}\b|\b{word}\b.{{0,25}}пострада\w*)", evidence + ) for word in count_words.get(victims, ())) + none_hurt = victims == 0 and re.search( + r"пострадавш(?:их|ие|их лиц)\s+нет|никто\s+не\s+пострадал|" + r"все\s+(?:люди\s+)?(?:успели\s+)?(?:выйти|эвакуироваться)", evidence + ) + if not (numeric or worded or none_hurt): + raise GenerationError("в фактах нет явного подтверждения числа пострадавших") + old_values = {fact.id: fact.value.strip() for fact in source.facts} + changed = sum(value.strip() != old_values[fact_id] for fact_id, value in facts.items()) + if changed < max(1, (len(facts) + 1) // 2): + raise GenerationError("новый сюжет слишком близко копирует исходный каркас") + if normalized(address) in normalized(first_line): + raise GenerationError("первая реплика раскрывает адрес до опроса") + return { + "title": title.strip(), "first_line": first_line.strip(), + "facts": {key: value.strip() for key, value in facts.items()}, + "ground_truth": {"address": address.strip(), "victims": victims}, + } + + +def full_proposal_body(source: Scenario, draft_id: str, proposal: dict) -> dict: + """Собрать самостоятельный сценарий из проверенного нового сюжета.""" + body = template_copy(source, draft_id, proposal["title"]) + for fact in body["facts"]: + fact["value"] = proposal["facts"][fact["id"]] + # Старое уточнение относится к старому сюжету и не переносится. + fact["refined"] = None + fact["refine_on"] = None + body["first_line"] = proposal["first_line"] + body["ground_truth"] = dict(proposal["ground_truth"]) + validate(body) + return body + + +async def generate_from_description(source: Scenario, description: str) -> dict: + """Создать все обстоятельства и мягкий эталон внутри выбранной категории ЕКП. + + Qwen 1.7B заметно надёжнее отвечает на один вопрос за раз. Поэтому вместо + одного длинного JSON, где малая модель копировала вопросы и старую карточку, + используются короткие строгие запросы. Итог всё равно проходит единую + сквозную проверку непротиворечивости. + """ + settings = get_settings() + if (not is_loopback_url( + settings.llm_base_url, + allow_docker_host=settings.allow_docker_host_models, + ) or not settings.llm_model_caller): + raise LlmUnavailable("для генерации нужна разрешённая локальная модель") + questions = {item.fact: item.question for item in source.checklist if item.fact} + address_id = next((fact.id for fact in source.facts if "address" in fact.id.casefold()), None) + if address_id is None: + raise GenerationError("в выбранном каркасе нет адресного факта") + + def victim_relevance(fact_id: str) -> int: + text = f"{fact_id} {questions.get(fact_id, '')}".casefold() + return sum(word in text for word in ( + "people", "victim", "injur", "who", "люд", "пострад", "кто", "кому" + )) + + victim_id = max((fact.id for fact in source.facts), key=victim_relevance) + client = LlmClient(base_url=settings.llm_base_url, timeout=45) + + def clean_answer(value: object) -> str: + """Нормализовать пробелы и ограничить ответ без порчи `ул.`/`д.`.""" + text = re.sub(r"\s+", " ", str(value)).strip() + for marker in (", но", ". В данном", ". Однако", ". Необходимо", + ". Рекомендуется", ". Следует"): + text = text.split(marker, 1)[0] + return text[:300].strip() + + def validate_value(data: dict, *, extra_key: str | None = None) -> None: + value = clean_answer(data.get("value", "")) + if not 3 <= len(value) <= 300: + raise GenerationError("значение пустое или слишком длинное") + if PLACEHOLDER.search(value): + raise GenerationError("модель вернула плейсхолдер вместо ответа") + if "?" in value: + raise GenerationError("модель вернула вопрос вместо ответа") + if extra_key is not None and extra_key not in data: + raise GenerationError(f"нет обязательного поля {extra_key}") + + def validate_headline(data: dict) -> None: + title = clean_answer(data.get("title", "")) + first_line = clean_answer(data.get("first_line", "")) + if not 5 <= len(title) <= 200: + raise GenerationError("название пустое или слишком длинное") + if not 8 <= len(first_line) <= 300: + raise GenerationError("первая реплика пустая или слишком длинная") + if reveals_number(first_line): + raise GenerationError("первая реплика раскрывает числовые детали") + + def validate_victims(data: dict) -> None: + validate_value(data, extra_key="victims") + victims = data["victims"] + if not isinstance(victims, int) or isinstance(victims, bool) or not 0 <= victims <= 20: + raise GenerationError("victims должен быть целым от 0 до 20") + + def victim_answer(count: int) -> str: + """Число модели сохраняем, формулировку делаем воспроизводимой и грамотной.""" + if count == 0: + return "Никто не пострадал. Пострадавших: 0" + if count == 1: + return "Пострадал один человек. Пострадавших: 1" + noun = "человека" if count % 10 in (2, 3, 4) and count % 100 not in (12, 13, 14) else "человек" + return f"Пострадали {count} {noun}. Пострадавших: {count}" + + async def ask( + system: str, payload: dict, schema: dict, *, max_tokens: int = 180, + validator=None, + ) -> dict: + last_error: Exception | None = None + raw = "" + for attempt in range(3): + messages = [ + {"role": "system", "content": system}, + {"role": "user", "content": json.dumps(payload, ensure_ascii=False)}, + ] + if attempt: + messages += [ + {"role": "assistant", "content": raw}, + {"role": "user", "content": ( + f"Ответ отклонён: {last_error}. Верни исправленный JSON. " + "Нужен конкретный ответ, не повторяй вопрос." + )}, + ] + try: + raw = await client.complete(LlmRequest( + messages=messages, model=settings.llm_model_caller, + temperature=0.25, max_tokens=max_tokens, + response_format={"type": "json_object", "schema": schema}, + strip_reasoning=True, + ), use_cache=False) + data = json.loads(raw) + if not isinstance(data, dict) or set(data) != set(schema["required"]): + raise GenerationError("неверный набор полей") + if validator is not None: + validator(data) + return data + except (ValueError, TypeError, GenerationError, LlmUnavailable) as exc: + last_error = exc + if isinstance(exc, LlmUnavailable) and not any(marker in str(exc) for marker in ( + "служебные токены", "пустой ответ модели" + )): + raise + raise GenerationError(f"локальная модель не заполнила часть сценария: {last_error}") + + value_schema = { + "type": "object", "properties": {"value": {"type": "string"}}, + "required": ["value"], "additionalProperties": False, + } + try: + # Заголовочный запрос не получает числовые детали даже словами: малая + # модель склонна повторять их в первой реплике, раскрывая ответ до + # обязательного вопроса оператора. Полное описание остаётся у запросов + # фактов ниже, поэтому эталон числа пострадавших не теряется. + headline_description = re.sub(r"\d+", "", description) + headline_description = re.sub(r"\s+", " ", NUMBER_WORD.sub("", headline_description)).strip() + headline = await ask( + "Создай название и первую эмоциональную реплику заявителя для учебного вызова 112. " + "Верни JSON title и first_line. first_line без цифр, адреса и служебных пояснений. " + "classifier_signs — обязательный путь классификатора ЕКП: сюжет не должен ему " + "противоречить. Если свободное description конфликтует с classifier_signs, " + "приоритет всегда у classifier_signs.", + {"incident_type": source.type.value, "classifier_signs": source.signs, + "level": source.level.value, "description": headline_description}, + {"type": "object", "properties": { + "title": {"type": "string"}, "first_line": {"type": "string"}, + }, "required": ["title", "first_line"], "additionalProperties": False}, + max_tokens=220, + validator=validate_headline, + ) + facts: dict[str, str] = {} + address = "" + victims: int | None = None + for fact in source.facts: + question = questions.get(fact.id, fact.id) + base_payload = { + "description": description, "incident_type": source.type.value, + "classifier_signs": source.signs, + "question": question, "already_known_answers": facts, + } + if fact.id == address_id: + data = await ask( + "Придумай конкретный вымышленный московский адрес для учебного сценария. " + "Верни JSON value и address. value должно начинаться дословно с address, " + "после него можно добавить квартиру, этаж или ориентир. Это ответ, не вопрос. " + "Адрес и тип объекта не должны противоречить classifier_signs.", + base_payload, + {"type": "object", "properties": { + "value": {"type": "string"}, "address": {"type": "string"}, + }, "required": ["value", "address"], "additionalProperties": False}, + validator=lambda data: validate_value(data, extra_key="address"), + ) + facts[fact.id] = clean_answer(data["value"]) + # Эталон выводится из факта, а не из второго пересказа модели: + # малая Qwen иногда меняет пунктуацию или номер между полями. + address = facts[fact.id][:200].rstrip(" ,.;") + elif fact.id == victim_id: + data = await ask( + "Ответь на вопрос заявителя конкретным фактом по описанию. Верни JSON value " + "и victims. victims — число пострадавших от 0 до 20. value обязательно " + "заканчивается точной фразой `Пострадавших: N`, где N равно victims. " + "Не пиши вопрос и не считай всех присутствующих пострадавшими. " + "Ответ не должен противоречить classifier_signs.", + base_payload, + {"type": "object", "properties": { + "value": {"type": "string"}, + "victims": {"type": "integer", "minimum": 0, "maximum": 20}, + }, "required": ["value", "victims"], "additionalProperties": False}, + validator=validate_victims, + ) + victims = data["victims"] if isinstance(data["victims"], int) else None + if victims is not None: + facts[fact.id] = victim_answer(victims) + else: + data = await ask( + "Ответь по-русски конкретным обстоятельством заявителя на question, используя " + "description, classifier_signs и already_known_answers. classifier_signs — " + "обязательный путь ЕКП и важнее конфликтующих деталей description. Верни JSON " + "только с ключом value. value — ответ, не вопрос; не используй знак вопроса " + "и плейсхолдеры.", + base_payload, value_schema, validator=validate_value, + ) + facts[fact.id] = clean_answer(data["value"]) + if victims is None: + raise GenerationError("модель не указала число пострадавших") + gaps = classifier_sign_gaps( + source.signs, + [str(headline["title"]), str(headline["first_line"]), *facts.values()], + ) + if gaps: + target = next( + fact for fact in source.facts + if fact.id not in {address_id, victim_id} + ) + + def validate_alignment(data: dict) -> None: + validate_value(data) + candidate = dict(facts) + candidate[target.id] = clean_answer(data["value"]) + missing = classifier_sign_gaps( + source.signs, + [str(headline["title"]), str(headline["first_line"]), *candidate.values()], + ) + if missing: + raise GenerationError( + "ответ не подтверждает признаки ЕКП: " + ", ".join(missing) + ) + + aligned = await ask( + "Перепиши один ответ заявителя так, чтобы он оставался конкретным ответом на " + "question и дословно, осмысленно подтверждал все missing_classifier_signs. " + "Это обязательные признаки ЕКП, их нельзя заменять другим объектом или событием. " + "Верни JSON только с ключом value, без вопроса и служебных пояснений.", + { + "description": description, + "question": questions.get(target.id, target.id), + "current_answer": facts[target.id], + "missing_classifier_signs": gaps, + "all_classifier_signs": source.signs, + "already_known_answers": facts, + }, + value_schema, + max_tokens=220, + validator=validate_alignment, + ) + facts[target.id] = clean_answer(aligned["value"]) + first_line = str(headline["first_line"]).strip() + if first_line and first_line[-1] not in ".!?…": + first_line += "!" + proposal = { + "title": headline["title"], "first_line": first_line, + "facts": facts, "ground_truth": {"address": address, "victims": victims}, + } + proposal = parse_full_proposal(json.dumps(proposal, ensure_ascii=False), source) + full_proposal_body(source, "ai-full-validation", proposal) + return proposal + finally: + await client.aclose() + + +async def generate( + source: Scenario, instruction: str, *, require_fact_change: bool = True +) -> dict: + settings = get_settings() + if (not is_loopback_url( + settings.llm_base_url, + allow_docker_host=settings.allow_docker_host_models, + ) or not settings.llm_model_caller): + raise LlmUnavailable("для генерации нужна разрешённая локальная модель") + all_editable = editable_fact_ids(source) + target = correction_target(source, instruction) + style_only = target is None and not require_fact_change + # Для 1.7B один комментарий = один изменяемый факт. Это не даёт модели + # «заодно» испортить газ, пока преподаватель просит сделать плотнее дым. + editable = [] if style_only else ([target] if target else all_editable) + fact_questions = {item.fact: item.question for item in source.checklist + if item.fact and item.question} + context = { + "incident_type": source.type.value, + "source_title": source.title, + "editable_facts": { + fact.id: {"question": fact_questions.get(fact.id, fact.id), "old_answer": fact.value} + for fact in source.facts if fact.id in editable + }, + } + if not require_fact_change: + context["current_first_line"] = source.first_line + change_rule = ( + "Измени обстоятельства хотя бы одного факта: дословная копия недопустима. " + if require_fact_change else + "Измени черновик по комментарию: допустимо менять только заголовок или first_line; " + "полностью прежняя версия недопустима. " + ) + if style_only: + change_rule += ( + "Это стилистическая правка: не добавляй в first_line новые обстоятельства, " + "которых нет в current_first_line или комментарии. facts оставь пустым. " + ) + system = ( + "Сочини НОВУЮ вариацию учебного вызова 112 на русском языке. " + "Верни ровно JSON с тремя ключами: title, first_line, facts. " + "facts содержит только id из editable_facts. " + change_rule + + "Значения facts — НОВЫЕ ответы " + "заявителя на question, не повторение вопроса. Не добавляй ключи. " + "first_line — прямая речь заявителя без цифр и адреса. " + "Тип происшествия, адрес и люди не меняются; их не включай в facts. " + "Не придумывай службы, коды и регламенты." + ) + request = LlmRequest( + messages=[{"role": "system", "content": system}, + {"role": "user", "content": json.dumps( + {"source": context, "request": instruction}, ensure_ascii=False)}], + model=settings.llm_model_caller, temperature=0.7, max_tokens=500, + response_format={"type": "json_object", "schema": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "first_line": {"type": "string"}, + "facts": {"type": "object", "properties": { + fact_id: {"type": "string"} for fact_id in editable + }, "additionalProperties": False}, + }, + "required": ["title", "first_line", "facts"], + "additionalProperties": False, + }}, + strip_reasoning=True, + ) + client = LlmClient(base_url=settings.llm_base_url, timeout=45) + + async def complete_safely(candidate: LlmRequest) -> str: + last_error: LlmUnavailable | None = None + for _ in range(2): + try: + return await client.complete(candidate, use_cache=False) + except LlmUnavailable as exc: + last_error = exc + if not any(marker in str(exc) for marker in ( + "служебные токены", "пустой ответ модели" + )): + raise + assert last_error is not None + raise last_error + + try: + raw = await complete_safely(request) + try: + proposal = parse_proposal( + raw, source, require_fact_change=require_fact_change, instruction=instruction, + restrict_first_line_facts=style_only, + ) + except GenerationError as first_error: + feedback = ( + f"Предыдущий ответ отклонён: {first_error}. " + "Верни исправленный JSON той же схемы. " + "Значения facts должны быть ответами заявителя, не вопросами. " + "Запрещены плейсхолдеры вида {fact_id}: пиши конкретный русский текст. " + ) + if target: + old = next(fact.value for fact in source.facts if fact.id == target) + feedback += ( + f"Обязательно замени факт {target}. Старый ответ: {old}. " + f"Новый ответ должен учитывать запрос: {instruction}. " + "Не повторяй старый ответ дословно." + ) + retry = LlmRequest( + messages=[*request.messages, {"role": "assistant", "content": raw}, + {"role": "user", "content": feedback}], + model=request.model, temperature=0.8, max_tokens=request.max_tokens, + response_format=request.response_format, + strip_reasoning=True, + ) + raw = await complete_safely(retry) + try: + proposal = parse_proposal( + raw, source, require_fact_change=require_fact_change, instruction=instruction, + restrict_first_line_facts=style_only, + ) + except GenerationError: + fallback = style_fallback(source, instruction) if not require_fact_change else None + if fallback is None: + raise + # Даже детерминированный fallback проходит те же запреты утечек. + proposal = parse_proposal( + json.dumps(fallback, ensure_ascii=False), source, + require_fact_change=False, instruction=instruction, + restrict_first_line_facts=style_only, + ) + finally: + await client.aclose() + # Проверка на фиксированном ID гарантирует, что генерация не выдаст + # черновик, который потом невозможно утвердить из-за сломанной схемы. + try: + proposal_body(source, "ai-validation", proposal) + except ScenarioError as exc: + raise GenerationError(f"сгенерированный сценарий не прошёл проверку: {exc}") from exc + return proposal diff --git a/backend/app/scenarios/store.py b/backend/app/scenarios/store.py index 9a32270..36aed18 100644 --- a/backend/app/scenarios/store.py +++ b/backend/app/scenarios/store.py @@ -16,6 +16,18 @@ 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] = {} + + +def reset_demo_drafts() -> None: + """Черновики и архив demo-lite живут только в текущем процессе.""" + _demo_drafts.clear() + _demo_archived.clear() + _demo_scenario_owners.clear() + _demo_archived_owners.clear() def set_library(scenarios: list[Scenario]) -> None: @@ -56,6 +68,7 @@ async def seed(db: AsyncSession, scenarios: list[Scenario]) -> int: row.topics = scenario.topics row.modes = scenario.modes row.body = payload + row.owner_login = None await db.commit() return len(scenarios) @@ -66,21 +79,128 @@ async def restore_published(db: AsyncSession) -> int: Вызвать после ``load_from_disk``. Базовую библиотеку не перезаписываем: её источником остаются проверенные YAML из репозитория. """ - rows = await db.scalars(select(ScenarioRow).where(ScenarioRow.status == "published")) - restored = 0 + rows = await db.scalars(select(ScenarioRow).where( + ScenarioRow.status.in_(["published", "archived"]) + )) + delta = 0 for row in rows: + if row.status == "archived": + if row.owner_login: + _demo_scenario_owners.pop(row.id, None) + _demo_archived_owners[row.id] = row.owner_login + if _library.pop(row.id, None) is not None: + delta -= 1 + continue + if row.owner_login: + _demo_scenario_owners[row.id] = row.owner_login if row.id in _library: continue _library[row.id] = Scenario.model_validate(row.body) - restored += 1 - return restored + delta += 1 + return delta + + +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: + return { + scenario_id for scenario_id, owner in _demo_scenario_owners.items() + if owner == owner_login and scenario_id in _library + } + rows = await db.scalars(select(ScenarioRow.id).where( + ScenarioRow.owner_login == owner_login, + ScenarioRow.status == "published", + )) + values = set(rows) + # Test doubles and alternate SQLAlchemy result wrappers may return rows + # for a scalar selection; normalize them without leaking owner data. + return {value if isinstance(value, str) else value.id for value in values} + + +async def archive( + db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None +) -> Scenario | None: + """Скрыть опубликованный сценарий без удаления истории и внешних ключей.""" + scenario = _library.get(scenario_id) + if scenario is None: + return None + if db is None: + owner = _demo_scenario_owners.get(scenario_id) + if owner_login is not None and owner != owner_login: + return None + _demo_archived[scenario_id] = scenario + if owner is not None: + _demo_archived_owners[scenario_id] = owner + _demo_scenario_owners.pop(scenario_id, None) + else: + row = await db.get(ScenarioRow, scenario_id) + if owner_login is not None and (row is None or row.owner_login != owner_login): + return None + if row is None: + row = ScenarioRow( + id=scenario.id, + title=scenario.title, + incident_type=scenario.type.value, + level=scenario.level.value, + topics=scenario.topics, + modes=scenario.modes, + body=scenario.model_dump(mode="json"), + ) + db.add(row) + row.status = "archived" + 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 +) -> Scenario | None: + """Вернуть мягко удалённый сценарий в библиотеку назначения.""" + if db is None: + owner = _demo_archived_owners.get(scenario_id) + if owner_login is not None and owner != owner_login: + return None + scenario = _demo_archived.pop(scenario_id, None) + if scenario is not None and owner is not None: + _demo_scenario_owners[scenario_id] = owner + _demo_archived_owners.pop(scenario_id, None) + else: + row = await db.get(ScenarioRow, scenario_id) + if (row is None or row.status != "archived" + or owner_login is not None and row.owner_login != owner_login): + return None + # В опубликованном body уже лежат вычисленные поля ground_truth (ЕКП, + # ДДС, список оповещения). Редакторская validate предназначена для + # черновика до вычисления и закономерно запрещает такие поля. + scenario = Scenario.model_validate(row.body) + row.status = "published" + await db.commit() + if scenario is None: + return None + publish(scenario) + return scenario async def create_draft( - db: AsyncSession, *, source: Scenario, title: str | None = None + db: AsyncSession | None, *, source: Scenario, title: str | None = None, + proposal: dict | None = None, full_proposal: dict | None = None, + owner_login: str | None = None, ) -> ScenarioRow: - draft_id = f"draft-{uuid4().hex}" - body = editor.template_copy(source, draft_id, title) + if proposal is not None and full_proposal is not None: + raise ValueError("нельзя одновременно передать вариацию и полный сюжет") + prefix = "ai-full" if full_proposal is not None else "ai" if proposal is not None else "draft" + draft_id = f"{prefix}-{uuid4().hex}" + if full_proposal is not None: + from app.scenarios.generation import full_proposal_body + + body = full_proposal_body(source, draft_id, full_proposal) + elif proposal is None: + body = editor.template_copy(source, draft_id, title) + else: + from app.scenarios.generation import proposal_body + + body = proposal_body(source, draft_id, proposal) row = ScenarioRow( id=draft_id, title=body["title"], @@ -89,26 +209,53 @@ async def create_draft( topics=body["topics"], modes=body["modes"], status="draft", + owner_login=owner_login, body=body, ) - db.add(row) - await db.commit() + if db is None: + _demo_drafts[row.id] = row + else: + db.add(row) + await db.commit() return row -async def draft(db: AsyncSession, scenario_id: str) -> ScenarioRow | None: - row = await db.get(ScenarioRow, scenario_id) - return row if row is not None and row.status == "draft" else None +async def draft( + db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None +) -> ScenarioRow | None: + row = _demo_drafts.get(scenario_id) if db is None else await db.get(ScenarioRow, scenario_id) + if row is None or row.status != "draft": + return None + if owner_login is not None and row.owner_login != owner_login: + return None + return row -async def update_draft(db: AsyncSession, row: ScenarioRow, patch: dict) -> ScenarioRow: +async def update_draft(db: AsyncSession | None, row: ScenarioRow, patch: dict) -> ScenarioRow: row.body = editor.merge_patch(row.body, patch) row.title = str(row.body.get("title") or "")[:200] - await db.commit() + if db is not None: + await db.commit() return row -async def approve_draft(db: AsyncSession, row: ScenarioRow) -> Scenario: +async def revise_draft(db: AsyncSession | None, row: ScenarioRow, proposal: dict) -> ScenarioRow: + """Заменить сюжетную версию того же черновика после комментария преподавателя.""" + from app.scenarios.generation import proposal_body + + source = editor.validate(row.body) + row.body = proposal_body(source, row.id, proposal) + row.title = row.body["title"] + row.incident_type = row.body["type"] + row.level = row.body["level"] + row.topics = row.body["topics"] + row.modes = row.body["modes"] + if db is not None: + await db.commit() + return row + + +async def approve_draft(db: AsyncSession | None, row: ScenarioRow) -> Scenario: scenario = editor.validate(row.body) row.title = scenario.title row.incident_type = scenario.type.value @@ -117,6 +264,11 @@ async def approve_draft(db: AsyncSession, row: ScenarioRow) -> Scenario: row.modes = scenario.modes row.body = scenario.model_dump(mode="json") row.status = "published" - await db.commit() + if db is None: + _demo_drafts.pop(row.id, None) + if row.owner_login is not None: + _demo_scenario_owners[row.id] = row.owner_login + else: + await db.commit() publish(scenario) return scenario diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py index f5a801b..7d3d695 100644 --- a/backend/app/scoring/dispatcher.py +++ b/backend/app/scoring/dispatcher.py @@ -130,16 +130,13 @@ def evaluate_dispatcher( def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]: """Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму. - Веса предварительные — до утверждения методистом. Неподтверждённые - телефонные действия нельзя засчитывать по одной кнопке статуса. + Веса предварительные — до утверждения методистом. Телефон — возможный + источник информации, но не обязательный шлюз статуса. """ metrics: list[Metric] = [] - for service in state.notified_services(): + for service in state.managed_services(): marks = [mark for mark in state.status_log if mark.service == service] statuses = {mark.status for mark in marks} - reports = {report.phase for report in state.phone_reports if report.service == service} - selected_crew = state.crew_assignments.get(service) - selected = selected_crew is not None def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0): metrics.append(Metric( @@ -153,7 +150,7 @@ def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]: if primary and state.dispatched_at else None) add("dds_primary", "решение по карточке", primary is not None, primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0) - add("dds_ack", "первичная отметка за 30 с", + add("dds_ack", f"первичная отметка за {deadline_ms // 1000} с", elapsed is not None and elapsed <= deadline_ms, f"{elapsed / 1000:.1f} с" if elapsed is not None else "не отмечено", f"≤ {deadline_ms // 1000} с") @@ -164,22 +161,14 @@ def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]: primary.status.value, "профильную заявку принять", 2.0) if primary.status is ServiceStatus.DECLINED: continue - add("dds_crew", "ручной выбор бригады", selected, - selected_crew or "бригада не выбрана", "выбрать конкретную бригаду", 1.5) - add("dds_contact", "связь с бригадой", bool(reports), - ", ".join(sorted(reports)) or "докладов нет", "получить доклад о выезде", 1.5) expected = { - "dispatched": ServiceStatus.RESPONDING, - "arrived": ServiceStatus.ARRIVED, - "working": ServiceStatus.WORKING, + ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING, } - aligned = all(status in statuses for phase, status in expected.items() if phase in reports) - all_progress = all(phase in reports and status in statuses for phase, status in expected.items()) - add("dds_progress", "статусы по докладам", aligned and all_progress, + add("dds_progress", "ведение хода реагирования", expected <= statuses, ", ".join(status.value for status in statuses) or "статусов нет", - "выезд, прибытие и работы отмечены после докладов", 2.0) - add("dds_completion", "закрытие работ по докладу", - "completed" in reports and ServiceStatus.COMPLETED in statuses, + "начало реагирования, прибытие и работы отмечены по полученной информации", 2.0) + add("dds_completion", "закрытие работ", + ServiceStatus.COMPLETED in statuses, "завершено" if ServiceStatus.COMPLETED in statuses else "не завершено", - "доклад о завершении и статус «Работы завершены»") + "по факту поставить статус «Работы завершены»") return metrics diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py index 2774586..bc99c95 100644 --- a/backend/app/scoring/export.py +++ b/backend/app/scoring/export.py @@ -37,13 +37,19 @@ def to_csv(report: SessionReport) -> bytes: row("Занятие", "", "Сценарий", report.scenario_id) row("Занятие", "", "Режим", report.mode.value) row("Занятие", "", "Попытка", report.attempt) + row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds) + row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors) + row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет") + row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные") + row("Критерии", "", "Нарушенных метрик", report.failed_metrics) + row("Критерии", "", "Зачёт", "да" if report.passed else "нет") row("Оценка", "", "Автоматическая", report.score_auto) row("Оценка", "", "Итоговая", report.score_final) row("Оценка", "", "Изменена преподавателем", report.overridden_by) 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.ref or ''}") + row("Метрики", number, item.title, item.fact, f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; вес: {item.weight:g}; источник: {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): @@ -138,6 +144,12 @@ def to_pdf(report: SessionReport) -> bytes: ("Сценарий", report.scenario_id), ("Режим", report.mode.value), ("Попытка", report.attempt), + ("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"), + ("Допустимо ошибок", report.criteria.allowed_errors), + ("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"), + ("Переопределения весов", report.criteria.score_weights or "стандартные"), + ("Нарушенных метрик", report.failed_metrics), + ("Результат", "зачёт" if report.passed else "не зачтено"), ("Автоматическая оценка", report.score_auto), ("Итоговая оценка", report.score_final), ): @@ -155,7 +167,7 @@ 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 'нарушено'}")) + story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'} (вес {item.weight:g})")) story.append(p(f"Факт: {item.fact}. Норматив: {item.norm}. {item.ref or ''}", muted)) section("Выявленные ошибки") @@ -226,3 +238,74 @@ def to_pdf(report: SessionReport) -> bytes: document.build(story, onFirstPage=footer, onLaterPages=footer) return output.getvalue() + + +def certificate_pdf( + *, trainee_name: str, trainee_id: object, group_name: str | None, + attempts: int, average_score: float, issued_at: object, +) -> bytes: + """Одностраничный сертификат по фактически сохранённым результатам.""" + from reportlab.lib import colors + from reportlab.lib.enums import TA_CENTER + from reportlab.lib.pagesizes import A4, landscape + from reportlab.lib.styles import ParagraphStyle + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer + + regular_path, bold_path = _font_paths() + regular_name = "LCTCertificateRegular" + bold_name = "LCTCertificateBold" + if regular_name not in pdfmetrics.getRegisteredFontNames(): + regular = TTFont(regular_name, str(regular_path)) + if not all(ord(char) in regular.face.charToGlyph for char in "Сертификат Ёж"): + raise RuntimeError(f"Шрифт не поддерживает кириллицу: {regular_path}") + pdfmetrics.registerFont(regular) + pdfmetrics.registerFont(TTFont(bold_name, str(bold_path or regular_path))) + + title = ParagraphStyle( + "certificate-title", fontName=bold_name, fontSize=28, leading=34, + alignment=TA_CENTER, textColor=colors.HexColor("#193b56"), spaceAfter=20, + ) + body = ParagraphStyle( + "certificate-body", fontName=regular_name, fontSize=14, leading=21, + alignment=TA_CENTER, textColor=colors.HexColor("#18202a"), spaceAfter=10, + ) + name_style = ParagraphStyle( + "certificate-name", parent=body, fontName=bold_name, fontSize=22, leading=28, + textColor=colors.HexColor("#a24816"), spaceAfter=18, + ) + small = ParagraphStyle( + "certificate-small", parent=body, fontSize=9, leading=13, + textColor=colors.HexColor("#5d6873"), + ) + safe_name = escape(trainee_name) + safe_group = escape(group_name or "без группы") + safe_date = escape(str(issued_at)) + story = [ + Spacer(1, 34), + Paragraph("СЕРТИФИКАТ", title), + Paragraph("о прохождении учебных занятий в локальном тренажёре системы-112 и ДДС", body), + Spacer(1, 12), + Paragraph(safe_name, name_style), + Paragraph( + f"Выполнено занятий: {attempts} · средняя итоговая оценка: " + f"{average_score:.1f} из 100", body, + ), + Paragraph(f"Учебная группа: {safe_group}", body), + Spacer(1, 28), + Paragraph(f"Дата формирования: {safe_date}", small), + Paragraph(f"Идентификатор курсанта: {escape(str(trainee_id))}", small), + Paragraph( + "Документ сформирован локально по сохранённым результатам и не заменяет " + "квалификационный документ установленного образца.", small, + ), + ] + output = io.BytesIO() + document = SimpleDocTemplate( + output, pagesize=landscape(A4), leftMargin=60, rightMargin=60, + topMargin=45, bottomMargin=45, title="Сертификат о прохождении обучения", + author="Учебный симулятор 112", + ) + document.build(story) + return output.getvalue() diff --git a/backend/app/scoring/grammar.py b/backend/app/scoring/grammar.py new file mode 100644 index 0000000..6c1a98f --- /dev/null +++ b/backend/app/scoring/grammar.py @@ -0,0 +1,102 @@ +"""Проверка письменного ответа ДДС: локальные правила и необязательный Vikhr.""" + +import json +import re +from dataclasses import dataclass + +from app.config import get_settings +from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url + + +@dataclass(frozen=True) +class GrammarAssessment: + passed: bool + errors: tuple[str, ...] + source: str + + +def basic_check(text: str) -> GrammarAssessment: + """Воспроизводимый минимум работает даже без модели.""" + value = text.strip() + errors: list[str] = [] + if not value: + errors.append("ответ отсутствует") + else: + first_letter = next((char for char in value if char.isalpha()), "") + if first_letter and first_letter != first_letter.upper(): + errors.append("предложение начинается со строчной буквы") + if value[-1:] not in ".!?…": + errors.append("нет завершающего знака препинания") + if re.search(r"\s{2,}", value): + errors.append("повторяющиеся пробелы") + if re.search(r"[A-Za-z]", value): + errors.append("латинские буквы в русском ответе") + if re.search(r"([!?.,])\1{2,}", value): + errors.append("избыточно повторяющиеся знаки препинания") + agreement_errors = ( + (r"\bсообщение\s+принят(?:ы|а)\b", "нарушено согласование: «сообщение принято»"), + (r"\bбригада\s+направлен(?:о|ы)\b", "нарушено согласование: «бригада направлена»"), + (r"\bинформация\s+передан(?:о|ы)\b", "нарушено согласование: «информация передана»"), + (r"\bработы\s+завершен(?:а|о)\b", "нарушено согласование: «работы завершены»"), + ) + for pattern, message in agreement_errors: + if re.search(pattern, value, re.IGNORECASE): + errors.append(message) + return GrammarAssessment(not errors, tuple(errors), "rules") + + +async def assess(text: str) -> GrammarAssessment: + """Vikhr проверяет мягкую грамматику, правила остаются страховочной сеткой.""" + baseline = basic_check(text) + settings = get_settings() + if (not settings.grammar_llm_enabled or not text.strip() + or not is_loopback_url( + settings.llm_control_base_url, + allow_docker_host=settings.allow_docker_host_models, + ) + or not settings.llm_model_control): + return baseline + + schema = {"type": "json_object", "schema": { + "type": "object", + "properties": { + "passed": {"type": "boolean"}, + "comment": {"type": "string"}, + }, + "required": ["passed", "comment"], + "additionalProperties": False, + }} + request = LlmRequest( + messages=[{ + "role": "system", + "content": ( + "Ты строгий корректор русского языка. Проверяй только грамматику, " + "орфографию и пунктуацию короткого служебного сообщения; не оценивай " + "факты и действия. Пример ошибки: «Сообщение приняты» => passed=false, " + "comment=«Нарушено согласование: сообщение принято». Пример нормы: " + "«Сообщение принято, бригада направлена.» => passed=true, comment=«». " + "Найди даже одну языковую ошибку и верни JSON по заданной схеме." + ), + }, {"role": "user", "content": text}], + model=settings.llm_model_control, + temperature=0.0, + max_tokens=160, + response_format=schema, + ) + client = LlmClient(base_url=settings.llm_control_base_url, timeout=5) + try: + raw = await client.complete(request, use_cache=True) + payload = json.loads(raw) + if set(payload) != {"passed", "comment"} or not isinstance(payload["passed"], bool): + raise ValueError("неверная схема") + comment = payload["comment"] + if not isinstance(comment, str) or len(comment) > 500: + raise ValueError("неверный комментарий") + errors = list(baseline.errors) + if not payload["passed"]: + errors.append(comment.strip() or "локальная модель обнаружила языковые ошибки") + return GrammarAssessment(not errors, tuple(dict.fromkeys(errors)), "vikhr") + except (LlmUnavailable, ValueError, TypeError, KeyError, json.JSONDecodeError): + return baseline + finally: + await client.aclose() diff --git a/backend/app/scoring/group_insight.py b/backend/app/scoring/group_insight.py new file mode 100644 index 0000000..0b9d1b1 --- /dev/null +++ b/backend/app/scoring/group_insight.py @@ -0,0 +1,77 @@ +"""Локальный Qwen-инсайт по обезличенной агрегированной аналитике группы.""" + +import json + +from app.config import get_settings +from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url + + +class InsightInvalid(ValueError): + pass + + +async def generate_group_insight(data: dict) -> dict: + settings = get_settings() + if (not settings.llm_model_caller or not is_loopback_url( + settings.llm_base_url, allow_docker_host=settings.allow_docker_host_models + )): + raise LlmUnavailable("локальная модель аналитики не настроена") + schema = {"type": "json_object", "schema": { + "type": "object", + "properties": { + "summary": {"type": "string", "minLength": 20, "maxLength": 700}, + "priorities": { + "type": "array", "minItems": 1, "maxItems": 3, + "items": {"type": "string", "minLength": 8, "maxLength": 240}, + }, + }, + "required": ["summary", "priorities"], + "additionalProperties": False, + }} + prompt = { + "active_trainees": data["active_trainees"], + "scored_attempts": data["scored_attempts"], + "average_score": data["average_score"], + "typical_errors": [{ + "code": item["code"], + "title": item["title"], + "rate_percent": item["rate_percent"], + "rule_recommendation": item["recommendation"], + } for item in data["errors"][:8]], + } + request = LlmRequest( + model=settings.llm_model_caller, + messages=[{ + "role": "system", + "content": ( + "Ты методист учебного центра системы-112. По обезличенной агрегированной " + "статистике сформулируй краткий русский вывод и от одной до трёх конкретных " + "тем следующего занятия. Не придумывай персональные данные, новые числа, " + "диагнозы или нормативы. Верни только JSON по заданной схеме. /no_think" + ), + }, {"role": "user", "content": json.dumps(prompt, ensure_ascii=False)}], + temperature=0.0, + max_tokens=420, + response_format=schema, + strip_reasoning=True, + ) + client = LlmClient(base_url=settings.llm_base_url, timeout=25) + try: + raw = await client.complete(request, use_cache=True) + finally: + await client.aclose() + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + raise InsightInvalid("модель вернула не JSON") from exc + if set(payload) != {"summary", "priorities"}: + raise InsightInvalid("неверный набор полей инсайта") + summary = payload["summary"] + priorities = payload["priorities"] + if not isinstance(summary, str) or not 20 <= len(summary.strip()) <= 700: + raise InsightInvalid("неверная длина вывода") + if (not isinstance(priorities, list) or not 1 <= len(priorities) <= 3 + or any(not isinstance(item, str) or not 8 <= len(item.strip()) <= 240 + for item in priorities)): + raise InsightInvalid("неверные приоритеты") + return {"summary": summary.strip(), "priorities": [item.strip() for item in priorities]} diff --git a/backend/app/scoring/report.py b/backend/app/scoring/report.py index a1af75b..13cd19b 100644 --- a/backend/app/scoring/report.py +++ b/backend/app/scoring/report.py @@ -46,6 +46,8 @@ def assess_difference( def build(session_id: UUID, state, scenario: Scenario) -> SessionReport: score = state.score or {} + metrics = [Metric.model_validate(item) for item in score.get("metrics", [])] + failed_metrics = sum(not item.passed and item.weight > 0 for item in metrics) # Без модели эмбеддингов распознать *какие* факты прозвучали нельзя. # Но если оператор не произнёс ни слова, все обязательные вопросы точно # пропущены — это доказуемый частный случай и он нужен офлайн-разбору. @@ -78,9 +80,12 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport: else scenario.id), mode=state.mode, attempt=state.attempt, + criteria=state.criteria, + failed_metrics=failed_metrics, + passed=failed_metrics <= state.criteria.allowed_errors, transcript=list(state.transcript), findings=[Finding.model_validate(item) for item in score.get("findings", [])], - metrics=[Metric.model_validate(item) for item in score.get("metrics", [])], + metrics=metrics, card_results=[DdsCardReport.model_validate(item) for item in score.get("card_results", [])], competencies=[CompetencyScore.model_validate(item) for item in score.get("competencies", [])], diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py index 85d63dc..b04d757 100644 --- a/backend/app/scoring/taxonomy.py +++ b/backend/app/scoring/taxonomy.py @@ -29,6 +29,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = { "dds_progress": (ErrorCode.D6, Competency.CARD), "dds_completion": (ErrorCode.D6, Competency.CARD), "dds_reply": (ErrorCode.D5, Competency.COMMUNICATION), + "dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION), } #: Вес метрики в детерминированной оценке. diff --git a/backend/app/session/checkpoint.py b/backend/app/session/checkpoint.py new file mode 100644 index 0000000..4993823 --- /dev/null +++ b/backend/app/session/checkpoint.py @@ -0,0 +1,358 @@ +"""Переносимый JSON-снимок незавершённого занятия. + +Снимок хранится в PostgreSQL после каждого подтверждённого действия. Он не +содержит сокеты, аудиобуферы или объекты моделей: после перезапуска процесса +они создаются заново, а учебные данные, таймеры и состояние ДДС восстанавливаются. +""" + +import time +from datetime import UTC, datetime +from uuid import UUID + +from fastapi.encoders import jsonable_encoder + +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.events import ( + CallEndReason, + Exercise, + LessonCriteria, + Metric, + SessionMode, + TranscriptEntry, +) +from app.domain.kio import KIO +from app.domain.statuses import ( + PhoneCallPending, + PhoneLineRecord, + PhoneReportRecord, + StatusEntry, +) +from app.domain.taxonomy import Finding +from app.domain.timers import TimerCode +from app.scenarios.schema import Scenario +from app.scoring.grammar import GrammarAssessment +from app.session.state import DdsCardRecord, DdsLiveCard, SessionState, now_utc +from app.session.timers import SessionTimers, Timer + +CHECKPOINT_VERSION = 1 + + +def _grammar(value: GrammarAssessment | None) -> dict | None: + if value is None: + return None + return {"passed": value.passed, "errors": list(value.errors), "source": value.source} + + +def _dump_timers(timers: SessionTimers, now: float) -> dict: + return { + "limits": {code.value: limit for code, limit in timers.limits.items()}, + "items": { + code.value: { + "elapsed_ms": timer.current_ms(now), + "attempt": timer.attempt, + "stopped": timer.stopped, + } + for code, timer in timers.timers.items() + }, + } + + +def _dump_live_card(item: DdsLiveCard, now: float) -> dict: + return { + "original_index": item.original_index, + "scenario": item.scenario.model_dump(mode="json"), + "kio": item.kio.model_dump(mode="json"), + "dispatched_card": item.dispatched_card.model_dump(mode="json"), + "dispatched_at": item.dispatched_at.isoformat(), + "timers": _dump_timers(item.timers, now), + "bounced_fields": item.bounced_fields, + "dds_log": [[action, at.isoformat(), detail] for action, at, detail in item.dds_log], + "status_log": [entry.model_dump(mode="json") for entry in item.status_log], + "crew_selected": item.crew_selected, + "crew_assignments": item.crew_assignments, + "phone_reports": [entry.model_dump(mode="json") for entry in item.phone_reports], + "phone_lines": [entry.model_dump(mode="json") for entry in item.phone_lines], + "phone_pending": item.phone_pending.model_dump(mode="json") if item.phone_pending else None, + "reply_text": item.reply_text, + "reply_grammar": _grammar(item.reply_grammar), + "reply_log": [[at.isoformat(), text] for at, text in item.reply_log], + } + + +def dump_state(state: SessionState) -> dict: + """Сериализовать только данные, необходимые для точного продолжения.""" + state.capture_active_dds() + now = time.monotonic() + payload = { + "version": CHECKPOINT_VERSION, + "session_id": str(state.session_id), + "scenario_id": state.scenario_id, + "scenario_title": state.scenario_title, + "level": state.level, + "mode": state.mode.value, + "owner_login": state.owner_login, + "exercise": state.exercise.value, + "handoff_to_dds": state.handoff_to_dds, + "required_fields": state.required_fields, + "trainee_name": state.trainee_name, + "trainee_id": str(state.trainee_id) if state.trainee_id else None, + "dds_service": state.dds_service, + "attempt": state.attempt, + "criteria": state.criteria.model_dump(mode="json"), + "kio": state.kio.model_dump(mode="json"), + "transcript": [entry.model_dump(mode="json") for entry in state.transcript], + "timers": _dump_timers(state.timers, now), + "hints_shown": state.hints_shown, + "hints_log": [[item, at.isoformat()] for item, at in state.hints_log], + "notes": state.notes, + "directives": state.directives, + "scenario": state.scenario.model_dump(mode="json") if state.scenario else None, + "audio_frames": state.audio_frames, + "bad_frames": state.bad_frames, + "self_assessed": state.self_assessed, + "self_assessment": state.self_assessment, + "score": state.score, + "started_at": state.started_at.isoformat() if state.started_at else None, + "ended_at": state.ended_at.isoformat() if state.ended_at else None, + "end_reason": state.end_reason.value if state.end_reason else None, + "dispatched_card": ( + state.dispatched_card.model_dump(mode="json") if state.dispatched_card else None + ), + "dispatched_at": state.dispatched_at.isoformat() if state.dispatched_at else None, + "bounced_fields": state.bounced_fields, + "dds_log": [ + [action, at.isoformat(), detail] for action, at, detail in state.dds_log + ], + "status_log": [item.model_dump(mode="json") for item in state.status_log], + "crew_selected": state.crew_selected, + "crew_assignments": state.crew_assignments, + "phone_reports": [item.model_dump(mode="json") for item in state.phone_reports], + "phone_lines": [item.model_dump(mode="json") for item in state.phone_lines], + "phone_pending": ( + 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], + "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 + ), + "dds_card_index": state.dds_card_index, + "dds_arrival_interval_seconds": state.dds_arrival_interval_seconds, + "dds_max_waiting": state.dds_max_waiting, + "dds_next_scenario_index": state.dds_next_scenario_index, + "dds_next_arrival_at": ( + state.dds_next_arrival_at.isoformat() if state.dds_next_arrival_at else None + ), + "dds_completed": [ + { + "card_id": str(item.card_id), + "scenario_id": item.scenario_id, + "reply_text": item.reply_text, + "metrics": [metric.model_dump(mode="json") for metric in item.metrics], + "findings": [finding.model_dump(mode="json") for finding in item.findings], + "actions": item.actions, + "duration_ms": item.duration_ms, + } + for item in state.dds_completed + ], + "reply_text": state.reply_text, + "reply_grammar": _grammar(state.reply_grammar), + "reply_log": [[at.isoformat(), text] for at, text in state.reply_log], + "resolved_outcome": state.resolved_outcome, + "resolve_comment": state.resolve_comment, + } + # В actions/score могут быть datetime/UUID из расчёта; JSONB должен + # получать только стандартные JSON-типы. + return jsonable_encoder(payload) + + +def _dt(value: str | None) -> datetime | None: + return datetime.fromisoformat(value) if value else None + + +def _restore_timers(payload: dict, saved_at: datetime) -> SessionTimers: + raw = payload.get("timers") or {} + limits = { + TimerCode(code): int(limit) + for code, limit in (raw.get("limits") or {}).items() + } + restored = SessionTimers(limits=limits or SessionTimers().limits) + now_mono = time.monotonic() + now_wall = datetime.now(UTC) + if saved_at.tzinfo is None: + saved_at = saved_at.replace(tzinfo=UTC) + downtime_ms = max(0, int((now_wall - saved_at).total_seconds() * 1000)) + for raw_code, item in (raw.get("items") or {}).items(): + code = TimerCode(raw_code) + stopped = bool(item.get("stopped")) + elapsed = max(0, int(item.get("elapsed_ms", 0))) + total = elapsed if stopped else elapsed + downtime_ms + restored.timers[code] = Timer( + code=code, + started_at=None if stopped else now_mono - total / 1000, + elapsed_ms=elapsed if stopped else 0, + attempt=max(1, int(item.get("attempt", 1))), + stopped=stopped, + ) + return restored + + +def _restore_grammar(value: dict | None) -> GrammarAssessment | None: + if not value: + return None + return GrammarAssessment( + passed=bool(value["passed"]), + errors=tuple(value.get("errors") or []), + source=value["source"], + ) + + +def _restore_live_card(item: dict, saved_at: datetime) -> DdsLiveCard: + return DdsLiveCard( + original_index=int(item["original_index"]), + scenario=Scenario.model_validate(item["scenario"]), + kio=KIO.model_validate(item["kio"]), + dispatched_card=KIO.model_validate(item["dispatched_card"]), + dispatched_at=datetime.fromisoformat(item["dispatched_at"]), + timers=_restore_timers({"timers": item["timers"]}, saved_at), + bounced_fields=list(item.get("bounced_fields") or []), + dds_log=[(action, datetime.fromisoformat(at), detail) + for action, at, detail in item.get("dds_log", [])], + status_log=[StatusEntry.model_validate(entry) + for entry in item.get("status_log", [])], + crew_selected=item.get("crew_selected"), + crew_assignments=dict(item.get("crew_assignments") or {}), + phone_reports=[PhoneReportRecord.model_validate(entry) + for entry in item.get("phone_reports", [])], + phone_lines=[PhoneLineRecord.model_validate(entry) + for entry in item.get("phone_lines", [])], + phone_pending=(PhoneCallPending.model_validate(item["phone_pending"]) + if item.get("phone_pending") else None), + reply_text=item.get("reply_text", ""), + reply_grammar=_restore_grammar(item.get("reply_grammar")), + reply_log=[(datetime.fromisoformat(at), text) + for at, text in item.get("reply_log", [])], + ) + + +def load_state(payload: dict, saved_at: datetime) -> SessionState: + """Восстановить состояние; неизвестная версия отклоняется явно.""" + if payload.get("version") != CHECKPOINT_VERSION: + raise ValueError("неподдерживаемая версия снимка занятия") + scenario = Scenario.model_validate(payload["scenario"]) if payload.get("scenario") else None + state = SessionState( + session_id=UUID(payload["session_id"]), + scenario_id=payload["scenario_id"], + scenario_title=payload["scenario_title"], + level=payload["level"], + mode=SessionMode(payload["mode"]), + owner_login=payload.get("owner_login"), + exercise=Exercise(payload["exercise"]), + handoff_to_dds=bool(payload.get("handoff_to_dds")), + required_fields=list(payload.get("required_fields") or []), + trainee_name=payload.get("trainee_name"), + trainee_id=UUID(payload["trainee_id"]) if payload.get("trainee_id") else None, + dds_service=payload.get("dds_service"), + attempt=int(payload.get("attempt", 1)), + criteria=LessonCriteria.model_validate(payload.get("criteria") or {}), + kio=KIO.model_validate(payload.get("kio") or {}), + transcript=[TranscriptEntry.model_validate(item) for item in payload.get("transcript", [])], + timers=_restore_timers(payload, saved_at), + hints_shown=list(payload.get("hints_shown") or []), + hints_log=[(item, datetime.fromisoformat(at)) + for item, at in payload.get("hints_log", [])], + notes=list(payload.get("notes") or []), + directives=list(payload.get("directives") or []), + scenario=scenario, + audio_frames=int(payload.get("audio_frames", 0)), + bad_frames=int(payload.get("bad_frames", 0)), + self_assessed=bool(payload.get("self_assessed")), + self_assessment=payload.get("self_assessment"), + score=payload.get("score"), + started_at=_dt(payload.get("started_at")), + ended_at=_dt(payload.get("ended_at")), + end_reason=(CallEndReason(payload["end_reason"]) if payload.get("end_reason") else None), + dispatched_card=( + KIO.model_validate(payload["dispatched_card"]) + if payload.get("dispatched_card") else None + ), + dispatched_at=_dt(payload.get("dispatched_at")), + bounced_fields=list(payload.get("bounced_fields") or []), + dds_log=[(action, datetime.fromisoformat(at), detail) + for action, at, detail in payload.get("dds_log", [])], + status_log=[StatusEntry.model_validate(item) for item in payload.get("status_log", [])], + crew_selected=payload.get("crew_selected"), + crew_assignments=dict(payload.get("crew_assignments") or {}), + phone_reports=[PhoneReportRecord.model_validate(item) + for item in payload.get("phone_reports", [])], + phone_lines=[PhoneLineRecord.model_validate(item) + for item in payload.get("phone_lines", [])], + phone_pending=( + PhoneCallPending.model_validate(payload["phone_pending"]) + if payload.get("phone_pending") else None + ), + dds_scenarios=[Scenario.model_validate(item) + for item in payload.get("dds_scenarios", [])], + dds_live_cards=[_restore_live_card(item, saved_at) + for item in payload.get("dds_live_cards", [])], + dds_active_card_id=( + UUID(payload["dds_active_card_id"]) + if payload.get("dds_active_card_id") else None + ), + dds_card_index=int(payload.get("dds_card_index", 0)), + dds_arrival_interval_seconds=int(payload.get("dds_arrival_interval_seconds", 0)), + dds_max_waiting=int(payload.get("dds_max_waiting", 3)), + dds_next_scenario_index=int(payload.get( + "dds_next_scenario_index", + max((item["original_index"] for item in payload.get("dds_live_cards", [])), default=-1) + 1, + )), + dds_next_arrival_at=_dt(payload.get("dds_next_arrival_at")), + dds_completed=[ + DdsCardRecord( + card_id=UUID(item["card_id"]), + scenario_id=item["scenario_id"], + reply_text=item.get("reply_text", ""), + metrics=[Metric.model_validate(metric) for metric in item.get("metrics", [])], + findings=[Finding.model_validate(finding) + for finding in item.get("findings", [])], + actions=list(item.get("actions") or []), + duration_ms=int(item.get("duration_ms", 0)), + ) + for item in payload.get("dds_completed", []) + ], + reply_text=payload.get("reply_text", ""), + reply_grammar=_restore_grammar(payload.get("reply_grammar")), + reply_log=[(datetime.fromisoformat(at), text) + for at, text in payload.get("reply_log", [])], + resolved_outcome=payload.get("resolved_outcome"), + resolve_comment=payload.get("resolve_comment", ""), + ) + if state.dds_live_cards: + # Legacy snapshots had no explicit active ID; newer snapshots may + # intentionally be between cards while waiting for the next arrival. + active_id = state.dds_active_card_id + if active_id is None and "dds_active_card_id" not in payload: + active_id = state.dds_live_cards[0].card_id + if active_id is not None: + state.activate_dds_card(active_id, capture=False) + if (state.dds_scenarios + and state.dds_next_scenario_index < len(state.dds_scenarios) + and state.dds_next_arrival_at is None): + # Old checkpoints had no delivery schedule; resume any remaining + # selected scenarios immediately rather than strand the session. + state.dds_next_arrival_at = now_utc() + # Голосовые runtime-объекты не сериализуются. Их безопасно собрать заново; + # карточка и оценка при этом остаются прежними. + if state.exercise is Exercise.CALL and state.scenario is not None: + state.persona = PersonaState(state.scenario.persona) + state.caller = build_caller( + state.scenario.id, + use_pregenerated=state.scenario.tree.pregenerated, + ) + embedder = get_embedder() + if embedder is not None: + state.slots = SlotMachine(state.scenario, embedder) + return state diff --git a/backend/app/session/dds.py b/backend/app/session/dds.py index fb4d23f..83eaa04 100644 --- a/backend/app/session/dds.py +++ b/backend/app/session/dds.py @@ -1,11 +1,12 @@ -"""Выдача очередной учебной карточки ДДС в рамках одного занятия.""" +"""Одновременная выдача учебных карточек ДДС в рамках одного занятия.""" import re +from datetime import datetime, timedelta from app.domain import ekp from app.domain.kio import KIO, ResponseStatus from app.scenarios.schema import Scenario -from app.session.state import SessionState, now_utc +from app.session.state import DdsLiveCard, SessionState, now_utc from app.session.timers import SessionTimers @@ -18,11 +19,25 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None: 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: state.dispatched_card = state.kio.model_copy(deep=True) state.dispatched_at = now_utc() - state.status_log.clear() - state.dds_log.clear() - state.bounced_fields.clear() + # A live DДС card keeps references to the current working collections. + # Never clear them in place when preparing the next card: that would also + # erase the previous card's history. Replace the working collections so + # every queued card retains an isolated snapshot. + state.status_log = [] + state.dds_log = [] + state.bounced_fields = [] state.crew_selected = None - state.crew_assignments.clear() - state.phone_reports.clear() + state.crew_assignments = {} + state.phone_reports = [] + state.phone_lines = [] + state.phone_pending = None state.reply_text = "" - state.reply_log.clear() - state.timers = SessionTimers() + state.reply_grammar = None + state.reply_log = [] + # Настроенный преподавателем лимит копируется в независимый таймер карточки. + state.timers = SessionTimers(limits=dict(state.timers.limits)) state.on_event("dds.dispatch") + + +def _append_live_card(state: SessionState, scenario: Scenario, index: int) -> DdsLiveCard: + state.dds_card_index = index + prepare_card(state, scenario) + card = DdsLiveCard( + original_index=index, + scenario=state.scenario, + kio=state.kio, + dispatched_card=state.dispatched_card, + dispatched_at=state.dispatched_at, + timers=state.timers, + bounced_fields=state.bounced_fields, + dds_log=state.dds_log, + status_log=state.status_log, + crew_selected=state.crew_selected, + crew_assignments=state.crew_assignments, + phone_reports=state.phone_reports, + phone_lines=state.phone_lines, + phone_pending=state.phone_pending, + reply_text=state.reply_text, + reply_grammar=state.reply_grammar, + reply_log=state.reply_log, + ) + state.dds_live_cards.append(card) + return card + + +def deliver_due_cards(state: SessionState, now: datetime | None = None) -> int: + """Выдать всё, чему настал срок и для чего есть место в ожидании. + + Активная карточка не считается ожидающей. Каждый вызов prepare_card + запускает собственный 30-секундный таймер ровно при поступлении. + """ + now = now or now_utc() + if state.dds_next_arrival_at is None or state.dds_next_arrival_at > now: + return 0 + + 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_exists = active_id is not None + waiting_count = len(state.dds_live_cards) - int(active_exists) + if waiting_count >= state.dds_max_waiting: + break + + index = state.dds_next_scenario_index + card = _append_live_card(state, state.dds_scenarios[index], index) + state.dds_next_scenario_index += 1 + delivered += 1 + if not active_exists: + state.activate_dds_card(card.card_id, capture=False) + else: + # prepare_card builds the new snapshot through SessionState's + # working fields; restore the trainee's active card immediately. + state.activate_dds_card(active_id, capture=False) + + if state.dds_next_scenario_index >= len(state.dds_scenarios): + state.dds_next_arrival_at = None + break + if state.dds_arrival_interval_seconds: + # Pace from actual delivery time. If the cap was full, the next + # card becomes due one full interval after this release. + state.dds_next_arrival_at = card.dispatched_at + timedelta( + seconds=state.dds_arrival_interval_seconds + ) + break + # Zero interval means fill active + available waiting slots now. + state.dds_next_arrival_at = now + + return delivered + + +def prepare_queue( + state: SessionState, + scenarios: list[Scenario], + arrival_interval_seconds: int = 0, + max_waiting: int = 3, +) -> None: + """Начать поступление очереди с первой карточки; остальные идут по темпу.""" + state.dds_live_cards.clear() + state.dds_scenarios = scenarios + state.dds_arrival_interval_seconds = arrival_interval_seconds + state.dds_max_waiting = max_waiting + state.dds_next_scenario_index = 0 + state.dds_next_arrival_at = now_utc() + deliver_due_cards(state) diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py index d2feb24..df2318f 100644 --- a/backend/app/session/finish.py +++ b/backend/app/session/finish.py @@ -7,17 +7,18 @@ событие сразу: им ждать нечего. """ +import asyncio import logging from uuid import UUID -from app.domain.events import Exercise, Metric, ScoreReady -from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource -from app.domain.timers import NORMATIVES, TimerCode +from app.domain.events import Exercise, ScoreReady +from app.domain.timers import TimerCode from app.scenarios import store -from app.scoring.competency import radar 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.report import build as build_report from app.scoring.weights import apply_weights from app.session.hub import hub from app.session.state import DdsCardRecord, now_utc @@ -28,34 +29,16 @@ log = logging.getLogger(__name__) def score_current_dds(state) -> DdsCardRecord: """Оценить активную карточку отдельно, до выдачи следующей.""" number = state.dds_card_index + 1 + decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK] findings = evaluate_dispatcher( entries=state.status_log, - services=state.notified_services(), - deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms, + services=state.managed_services(), + deadline_ms=decision_limit_ms, elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK), ) - metrics = dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms) - reply = state.reply_text.strip() - if reply or len(state.dds_scenarios) > 1: - passed = len(reply) >= 20 and len(reply.split()) >= 3 - metrics.append(Metric( - key="dds_reply", title="Свободный ответ по карточке", - fact=reply or "ответ отсутствует", - norm="содержательный текст не короче 20 символов и 3 слов", - ref="ТЗ: действия с карточками, ввод текста", - passed=passed, weight=1.5, - )) - if not passed: - findings.append(Finding( - code=ErrorCode.D5, source=FindingSource.DISPATCHER, - summary="Свободный ответ отсутствует или слишком краток", - fact=reply or "ответ отсутствует", - norm="не менее 20 символов и 3 слов; грамматика отдельно не проверялась", - ref="ТЗ: действия с карточками, ввод текста", - competency=Competency.COMMUNICATION, - )) + metrics = dispatcher_metrics(state, decision_limit_ms) weighted = GostResult(metrics=metrics, findings=findings) - apply_weights(weighted, state.scenario.score_weights) + apply_weights(weighted, {**state.scenario.score_weights, **state.criteria.score_weights}) actions = [ {"type": "card.status", "service": mark.service, "status": mark.status.value, "comment": mark.comment, "at": mark.at.isoformat()} @@ -87,6 +70,9 @@ def score_current_dds(state) -> DdsCardRecord: async def finish(session_id: UUID, state) -> None: + if state.recorder is not 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) if scenario is None: @@ -95,22 +81,20 @@ async def finish(session_id: UUID, state) -> None: if state.exercise is Exercise.CARD: result = evaluate_card(scenario, state.dispatched_card or state.kio) elif state.exercise is Exercise.DDS: - # В ДДС могут последовательно пройти несколько карточек. Уже закрытые - # сохранены отдельными результатами; активную оцениваем лишь однажды. + # Все карточки выданы одновременно: при досрочном завершении оцениваем + # каждую, включая не открытую, потому что её норматив уже шёл. result = GostResult() cards = list(state.dds_completed) - if state.dispatched_card and not any( - item.card_id == state.dispatched_card.card_id for item in cards - ): + 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) - result.unavailable.append( - "Грамматика свободного ответа: автоматическая проверка не настроена; " - "оценивались только наличие и минимальная полнота текста" - ) else: result = evaluate( scenario=scenario, @@ -129,16 +113,20 @@ async def finish(session_id: UUID, state) -> None: if state.dispatched_card is not None and ( state.exercise is Exercise.CALL or state.handoff_to_dds ): + decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK] dispatcher_findings = evaluate_dispatcher( entries=state.status_log, - services=state.notified_services(), - deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms, + services=state.managed_services(), + deadline_ms=decision_limit_ms, elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK), ) result.findings.extend(dispatcher_findings) - result.metrics.extend(dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms)) + result.metrics.extend(dispatcher_metrics(state, decision_limit_ms)) + # 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) + apply_weights(result, {**scenario.score_weights, **state.criteria.score_weights}) # Сводка числами: по ней считается дельта между попытками в профиле. # Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам. @@ -172,6 +160,9 @@ async def finish(session_id: UUID, state) -> None: for card in cards ] if state.exercise is Exercise.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: @@ -181,6 +172,18 @@ async def finish(session_id: UUID, state) -> None: await release_score(session_id, state) +async def refresh_archived_report(session_id: UUID, state) -> None: + """Дополнить сохранённый разбор самооценкой, пришедшей после hangup.""" + if state.score is None: + return + scenario = state.scenario or store.get(state.scenario_id) + if scenario is None: + return + state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json") + if hub.journal: + await hub.journal.score_snapshot(session_id, state.score) + + async def release_score(session_id: UUID, state) -> None: """Отдать оценку курсанту, когда самооценка сдана.""" if state.score is not None and (state.self_assessed or state.exercise is Exercise.CARD): diff --git a/backend/app/session/hub.py b/backend/app/session/hub.py index 37ca43b..82c27d6 100644 --- a/backend/app/session/hub.py +++ b/backend/app/session/hub.py @@ -13,7 +13,7 @@ from uuid import UUID from pydantic import BaseModel -from app.domain.events import TimerTick +from app.domain.events import CardReceived, Exercise, StationState, TimerTick from app.session.state import SessionState #: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие: @@ -30,14 +30,20 @@ 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]: ... + ) -> tuple[int, UUID | None, str | 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 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: ... 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]: ... class SessionHub: @@ -58,10 +64,26 @@ class SessionHub: def get(self, session_id: UUID) -> SessionState | None: return self._sessions.get(session_id) + def has_active_scenario(self, scenario_id: str) -> bool: + """Архивирование контента не должно менять уже идущее занятие.""" + return any( + not state.ended and ( + state.scenario_id == scenario_id + or any(item.id == scenario_id for item in state.dds_scenarios) + ) + for state in self._sessions.values() + ) + def drop(self, session_id: UUID) -> None: self._sessions.pop(session_id, None) self.stop_ticker(session_id) + async def checkpoint(self, session_id: UUID) -> None: + """Зафиксировать подтверждённое состояние, если журнал доступен.""" + state = self.get(session_id) + if state is not None and self.journal is not None: + await self.journal.checkpoint(state) + # ── подписки ── @contextlib.contextmanager @@ -138,6 +160,18 @@ class SessionHub: state = self.get(session_id) if state is None or state.ended: return + if state.exercise is Exercise.DDS: + from app.session.dds import deliver_due_cards + + active_before = state.dds_active_card_id + delivered = deliver_due_cards(state) + if state.dds_active_card_id != active_before and state.dds_active_card_id: + self.to_station(session_id, state.card_received_event()) + if delivered: + await self.checkpoint(session_id) + # Keep the pending count and countdown live even while + # the active dispatcher card is being handled. + self.to_station(session_id, StationState(snapshot=state.station_snapshot())) self.broadcast(session_id, TimerTick(timers=state.timers.snapshot())) except asyncio.CancelledError: raise diff --git a/backend/app/session/journal.py b/backend/app/session/journal.py index fca473c..bf908b8 100644 --- a/backend/app/session/journal.py +++ b/backend/app/session/journal.py @@ -8,11 +8,14 @@ import logging from datetime import datetime from uuid import UUID -from sqlalchemy import update +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 +from app.db.models import 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__) @@ -32,8 +35,8 @@ class DbJournal: 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]: + trainee_id: UUID | None = None, owner_login: str | None = None, + ) -> tuple[int, UUID | None, str | None]: """Завести сессию в журнале и вернуть номер попытки и ID курсанта. Если база недоступна, занятие всё равно идёт: номер попытки @@ -48,11 +51,75 @@ class DbJournal: mode=mode, trainee_name=trainee_name, trainee_id=trainee_id, + owner_login=owner_login, ) - return row.attempt, row.trainee_id + service = None + if row.trainee_id is not None: + service = await db.scalar( + select(User.service) + .where(User.trainee_id == row.trainee_id, User.blocked.is_(False)) + .limit(1) + ) + return row.attempt, row.trainee_id, service + except PermissionError: + raise except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию log.exception("журнал: сессию завести не удалось") - return 1, trainee_id + return 1, trainee_id, None + + async def checkpoint(self, state: SessionState) -> None: + """Сохранить снимок после подтверждённого действия пользователя.""" + async def action(db): + values = ( + {"live_state": None, "checkpoint_at": None} + if state.ended + else {"live_state": dump_state(state), "checkpoint_at": now_utc()} + ) + await db.execute(update(Session).where(Session.id == state.session_id).values(**values)) + await db.commit() + + await self._write(lambda db: action(db)) + + async def restore_active(self) -> 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() + 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 + # Реплики пишутся отдельно сразу после появления. Если + # процесс умер между репликой и общим снимком, отдельный + # журнал не даёт потерять последний фрагмент диалога. + utterances = (await db.scalars( + select(Utterance) + .where(Utterance.session_id == row.id) + .order_by(Utterance.at, Utterance.ref) + )).all() + if utterances: + state.transcript = [ + TranscriptEntry( + ref=item.ref, + speaker=Speaker(item.speaker), + text=item.text, + at=item.at, + mood=Mood(item.mood) if item.mood else None, + ) + for item in utterances + ] + restored.append(state) + except Exception: # noqa: BLE001 — один снимок не блокирует весь стенд + log.exception("журнал: снимок занятия %s повреждён", row.id) + return restored async def utterance(self, session_id: UUID, entry) -> None: await self._write( @@ -111,6 +178,16 @@ class DbJournal: await self._write(lambda db: action(db)) + async def score_snapshot(self, session_id: UUID, report: dict) -> None: + """Обновить полный архивный разбор после самооценки курсанта.""" + async def action(db): + await db.execute( + update(Score).where(Score.session_id == session_id).values(report=report) + ) + await db.commit() + + await self._write(lambda db: action(db)) + 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)) @@ -123,7 +200,12 @@ class DbJournal: await db.execute( update(Session) .where(Session.id == session_id) - .values(ended_at=at, end_reason=reason) + .values( + ended_at=at, + end_reason=reason, + live_state=None, + checkpoint_at=None, + ) ) await db.commit() diff --git a/backend/app/session/state.py b/backend/app/session/state.py index df0ef70..0d34ca2 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -5,45 +5,52 @@ пустой экран (docs/arch/STACK.md). """ +import time from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from uuid import UUID -from app.domain.events import ( - CallEndReason, - Exercise, - Mood, - SessionMode, - SessionSnapshot, - Metric, - Speaker, - TranscriptEntry, -) from app.dialog.caller import TemplateCaller from app.dialog.persona import PersonaState from app.dialog.slots import SlotMachine -from app.scenarios.schema import Scenario +from app.domain.events import ( + CallEndReason, + Exercise, + LessonCriteria, + Metric, + Mood, + SessionMode, + SessionSnapshot, + Speaker, + TranscriptEntry, +) +from app.domain.kio import KIO, ResponseStatus, apply_patch from app.domain.statuses import ( + CardStatus, NEXT, + DdsCardSummary, + DdsQueueCard, + PhoneCallPending, + PhoneLineRecord, + PhoneReportRecord, ServiceStatus, StationSnapshot, StatusEntry, - PhoneReportRecord, - DdsCardSummary, card_status, check, current, ) -from app.domain.timers import NORMATIVES, TimerCode -from app.domain.kio import KIO, ResponseStatus, apply_patch -from app.session.timers import SessionTimers from app.domain.taxonomy import Finding +from app.domain.timers import TimerCode +from app.scenarios.schema import Scenario +from app.scoring.grammar import GrammarAssessment +from app.session.timers import SessionTimers def now_utc() -> datetime: """Часы серверные. Метрика, посчитанная по часам браузера, недоказуема.""" - return datetime.now(timezone.utc) + return datetime.now(UTC) @dataclass @@ -63,6 +70,33 @@ class DdsCardRecord: return round(100 * passed / total, 1) if total else 0.0 +@dataclass +class DdsLiveCard: + """Изолированное живое состояние одной одновременно выданной карточки.""" + + original_index: int + scenario: Scenario + kio: KIO + dispatched_card: KIO + dispatched_at: datetime + timers: SessionTimers + bounced_fields: list[str] = field(default_factory=list) + dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list) + status_log: list[StatusEntry] = field(default_factory=list) + crew_selected: str | None = None + crew_assignments: dict[str, str] = field(default_factory=dict) + phone_reports: list[PhoneReportRecord] = field(default_factory=list) + phone_lines: list[PhoneLineRecord] = field(default_factory=list) + phone_pending: PhoneCallPending | None = None + reply_text: str = "" + reply_grammar: GrammarAssessment | None = None + reply_log: list[tuple[datetime, str]] = field(default_factory=list) + + @property + def card_id(self) -> UUID: + return self.dispatched_card.card_id + + @dataclass class SessionState: session_id: UUID @@ -70,6 +104,8 @@ class SessionState: scenario_title: str level: str mode: SessionMode + #: Преподаватель, создавший занятие; чужой пульт не может им управлять. + owner_login: str | None = None exercise: Exercise = Exercise.CALL #: После заполнения КИО занятие продолжится на АРМ ДДС, а не завершится. handoff_to_dds: bool = False @@ -78,7 +114,11 @@ class SessionState: #: Чьё это занятие. Проставляется при запуске, когда курсант известен #: по учётной записи: по нему разбор закрывается от чужих (lct-23). trainee_id: UUID | None = None + #: Конкретная ДДС обучающегося. Она меняет статусы только своей службы; + #: остальные адресаты карточки показываются информационно. + dds_service: str | None = None attempt: int = 1 + criteria: LessonCriteria = field(default_factory=LessonCriteria) kio: KIO = field(default_factory=KIO) transcript: list[TranscriptEntry] = field(default_factory=list) @@ -100,6 +140,8 @@ class SessionState: # Голосовой контур звонка. Нет — если голос выключен или моделей нет: # тогда кадры микрофона только считаются. voice: object | None = None + recorder: object | None = None + recording_path: str | None = None # Аудио курсанта. До голосового контура (lct-06) кадры только считаются — # этого достаточно, чтобы доказать, что звук доходит от микрофона до сервера. @@ -124,10 +166,19 @@ class SessionState: crew_selected: str | None = None crew_assignments: dict[str, str] = field(default_factory=dict) phone_reports: list[PhoneReportRecord] = field(default_factory=list) + phone_lines: list[PhoneLineRecord] = field(default_factory=list) + phone_pending: PhoneCallPending | None = None dds_scenarios: list[Scenario] = field(default_factory=list) + dds_live_cards: list[DdsLiveCard] = field(default_factory=list) + dds_active_card_id: UUID | None = None dds_card_index: int = 0 dds_completed: list[DdsCardRecord] = field(default_factory=list) + dds_arrival_interval_seconds: int = 0 + dds_max_waiting: int = 3 + dds_next_scenario_index: int = 0 + dds_next_arrival_at: datetime | None = None reply_text: str = "" + reply_grammar: GrammarAssessment | None = None reply_log: list[tuple[datetime, str]] = field(default_factory=list) #: Чем курсант закрыл вызов, если не карточкой (lct-36). resolved_outcome: str | None = None @@ -168,12 +219,106 @@ class SessionState: card = self.dispatched_card or self.kio return list(card.notify) + def _managed_services_for(self, recipients: list[str]) -> list[str]: + """Служба, от имени которой работает этот обучающийся. + + Старые занятия и demo без профиля безопасно закрепляются за первым + адресатом. Обобщённая учебная служба «ДДС района» соответствует + территориальному органу, а не всем получателям карточки сразу. + """ + if not recipients: + return [] + wanted = (self.dds_service or "").strip().casefold() + if wanted: + exact = next((item for item in recipients if item.casefold() == wanted), None) + if exact: + return [exact] + contained = next( + (item for item in recipients + if wanted in item.casefold() or item.casefold() in wanted), + None, + ) + if contained: + return [contained] + if "район" in wanted: + district = next( + (item for item in recipients if "территориальн" in item.casefold()), None + ) + if district: + return [district] + return [] + return [recipients[0]] + + def managed_services(self) -> list[str]: + return self._managed_services_for(self.notified_services()) + + def capture_active_dds(self) -> None: + """Сохранить изменяемые поля активной карточки перед переключением.""" + if not self.dispatched_card: + return + card = next( + (item for item in self.dds_live_cards + if item.card_id == self.dispatched_card.card_id), + None, + ) + if card is None: + return + card.scenario = self.scenario + card.kio = self.kio + card.dispatched_card = self.dispatched_card + card.dispatched_at = self.dispatched_at or card.dispatched_at + card.timers = self.timers + card.bounced_fields = self.bounced_fields + card.dds_log = self.dds_log + card.status_log = self.status_log + card.crew_selected = self.crew_selected + card.crew_assignments = self.crew_assignments + card.phone_reports = self.phone_reports + card.phone_lines = self.phone_lines + card.phone_pending = self.phone_pending + card.reply_text = self.reply_text + card.reply_grammar = self.reply_grammar + card.reply_log = self.reply_log + self.dds_active_card_id = card.card_id + + def activate_dds_card(self, card_id: UUID, *, capture: bool = True) -> bool: + """Переключить рабочее место на карточку, не останавливая остальные.""" + if capture: + self.capture_active_dds() + card = next((item for item in self.dds_live_cards if item.card_id == card_id), None) + if card is None: + return False + scenario = card.scenario + self.scenario = scenario + self.scenario_id = scenario.id + self.scenario_title = scenario.title + self.level = scenario.level.value + self.required_fields = list(scenario.required_fields) + self.kio = card.kio + self.dispatched_card = card.dispatched_card + self.dispatched_at = card.dispatched_at + self.timers = card.timers + self.bounced_fields = card.bounced_fields + self.dds_log = card.dds_log + self.status_log = card.status_log + self.crew_selected = card.crew_selected + self.crew_assignments = card.crew_assignments + self.phone_reports = card.phone_reports + self.phone_lines = card.phone_lines + self.phone_pending = card.phone_pending + self.reply_text = card.reply_text + self.reply_grammar = card.reply_grammar + self.reply_log = card.reply_log + self.dds_card_index = card.original_index + self.dds_active_card_id = card.card_id + return True + def crew_options(self) -> list[str]: return [f"{service} — бригада №{number}" - for service in self.notified_services() for number in (1, 2)] + for service in self.managed_services() for number in (1, 2)] def crew_service(self, crew: str) -> str | None: - return next((service for service in self.notified_services() + return next((service for service in self.managed_services() if crew in (f"{service} — бригада №1", f"{service} — бригада №2")), None) def set_service_status( @@ -190,31 +335,84 @@ class SessionState: def station_snapshot(self) -> StationSnapshot: """Что видит диспетчер: службы, их статусы и доступные переходы.""" - services = self.notified_services() + self.capture_active_dds() + has_active_dds_card = any( + item.card_id == self.dds_active_card_id for item in self.dds_live_cards + ) + recipients = self.notified_services() if has_active_dds_card or not self.dds_scenarios else [] + services = self._managed_services_for(recipients) statuses = {service: current(self.status_log, service) for service in services} elapsed = self.timers.measured_ms(TimerCode.DDS_ACK) - limit = NORMATIVES[TimerCode.DDS_ACK].limit_ms - overdue = self.dispatched_at is not None and ( + limit = self.timers.limits[TimerCode.DDS_ACK] + overdue = has_active_dds_card and self.dispatched_at is not None and ( elapsed is None and (now_utc() - self.dispatched_at).total_seconds() * 1000 > limit ) + now_mono = time.monotonic() + queue_cards: list[DdsQueueCard] = [] + for item in sorted(self.dds_live_cards, key=lambda queued: queued.original_index): + recipients_for_card = list(item.dispatched_card.notify) + managed = self._managed_services_for(recipients_for_card) + service = managed[0] if managed else None + timer = item.timers.timers.get(TimerCode.DDS_ACK) + elapsed_ms = timer.current_ms(now_mono) if timer else 0 + stopped = timer.stopped if timer else False + limit_ms = item.timers.limits[TimerCode.DDS_ACK] + queue_cards.append(DdsQueueCard( + card_id=item.card_id, + scenario_id=item.scenario.id, + title=item.scenario.title, + address=item.dispatched_card.address, + description=item.dispatched_card.description, + incident_type=(item.dispatched_card.incident_type.value + if item.dispatched_card.incident_type else None), + victims_count=item.dispatched_card.victims_count, + received_at=item.dispatched_at, + managed_service=service, + service_status=(current(item.status_log, service) + if service else ServiceStatus.ADDED), + card_status=card_status( + item.status_log, managed, + notify_deadline_passed=not stopped and elapsed_ms > limit_ms, + ), + elapsed_ms=elapsed_ms, + limit_ms=limit_ms, + timer_stopped=stopped, + active=item.card_id == self.dds_active_card_id, + )) return StationSnapshot( services=services, + recipient_services=recipients, + managed_service=services[0] if services else None, statuses=statuses, available={service: list(NEXT[status]) for service, status in statuses.items()}, - card=card_status(self.status_log, services, notify_deadline_passed=overdue), + card=(card_status(self.status_log, services, notify_deadline_passed=overdue) + if has_active_dds_card or not self.dds_scenarios else CardStatus.REGISTERED), log=list(self.status_log), - crew_options=self.crew_options(), - crew_selected=self.crew_selected, - phone_reports=list(self.phone_reports), - card_id=self.dispatched_card.card_id if self.dispatched_card else None, - card_index=self.dds_card_index + 1, + 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, + 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, + card_id=(self.dispatched_card.card_id if self.dispatched_card and has_active_dds_card else None), + card_index=(self.dds_card_index + 1 if has_active_dds_card + else min(self.dds_next_scenario_index + 1, len(self.dds_scenarios))), card_total=len(self.dds_scenarios) or 1, - reply_text=self.reply_text, + reply_text=self.reply_text if has_active_dds_card else "", completed_cards=[DdsCardSummary(card_id=item.card_id, scenario_id=item.scenario_id, score_auto=item.score_auto) for item in self.dds_completed], + queue_cards=queue_cards, + pending_cards_count=max( + 0, len(self.dds_scenarios) - len(self.dds_completed) + - (1 if has_active_dds_card else 0), + ), + next_arrival_in_seconds=( + max(0, int((self.dds_next_arrival_at - now_utc()).total_seconds() + 0.999)) + if self.dds_next_arrival_at is not None else None + ), + max_waiting_cards=self.dds_max_waiting, ) def card_received_event(self): @@ -244,6 +442,7 @@ class SessionState: level=self.level, mode=self.mode, exercise=self.exercise, + criteria=self.criteria, trainee_name=self.trainee_name, started_at=self.started_at, kio=self.kio, diff --git a/backend/app/voice/models.py b/backend/app/voice/models.py index 1d34a82..b00c9d3 100644 --- a/backend/app/voice/models.py +++ b/backend/app/voice/models.py @@ -6,14 +6,18 @@ onnxruntime и torch отпускают GIL на время вычислений """ import asyncio +import io import logging +import wave from concurrent.futures import ThreadPoolExecutor from functools import lru_cache from pathlib import Path import numpy as np +import httpx from app.config import get_settings +from app.dialog.llm import is_loopback_url from app.voice.text import normalize log = logging.getLogger(__name__) @@ -51,6 +55,47 @@ class Recognizer: self.transcribe((rng.normal(0, 0.05, 16_000)).astype(np.float32)) +class WhisperRecognizer: + """Локальный whisper.cpp: GGML Whisper small, без облака и Python-зависимости STT. + + VAD уже отделил реплику. Сервер держит модель в памяти, а Python отправляет + ему только 16-кГц WAV через loopback; адрес из конфигурации проверяется. + """ + + def __init__(self, server_url: str, client: httpx.Client | None = None) -> None: + if not is_loopback_url(server_url): + raise ValueError("Whisper server должен слушать только loopback") + self._url = server_url.rstrip("/") + self._client = client or httpx.Client(trust_env=False, timeout=30.0) + + def transcribe(self, audio: np.ndarray) -> str: + pcm = (np.clip(audio, -1, 1) * 32767).astype(" None: + # GET / отвечает локальная страница server.cpp. Не запускаем тяжёлый + # инференс на тишине: сервер загрузил модель до готовности порта. + response = self._client.get(self._url + "/", timeout=2.0) + response.raise_for_status() + + class Synthesizer: """Silero v5: около десятой доли длительности фразы на синтез (docs/LATENCY.md).""" @@ -80,7 +125,8 @@ class Synthesizer: class VoiceModels: - def __init__(self, recognizer: Recognizer, synthesizer: Synthesizer, vad_path: Path) -> None: + def __init__(self, recognizer: Recognizer | WhisperRecognizer, synthesizer: Synthesizer, + vad_path: Path) -> None: self.recognizer = recognizer self.synthesizer = synthesizer self.vad_path = vad_path @@ -100,8 +146,14 @@ def get_voice_models() -> VoiceModels | None: if not settings.voice_enabled: return None models = ROOT / settings.models_dir + stt_path = (ROOT / settings.whisper_model_path / "ggml-small-q5_1.bin" + if settings.stt_model == "whisper-small" + else models / "gigaam-v3-onnx" / "v3_rnnt_encoder.int8.onnx") + if settings.stt_model not in {"whisper-small", "gigaam-v3-rnnt"}: + log.error("неизвестный STT_MODEL: %s", settings.stt_model) + return None required = [ - models / "gigaam-v3-onnx" / "v3_rnnt_encoder.int8.onnx", + stt_path, models / "silero-vad" / "silero_vad.onnx", models / "silero-tts" / "v5_ru.pt", ] @@ -110,12 +162,13 @@ def get_voice_models() -> VoiceModels | None: log.warning("голосовой контур выключен, нет моделей: %s — make models", ", ".join(missing)) return None try: - recognizer = Recognizer(models / "gigaam-v3-onnx") + recognizer = (WhisperRecognizer(settings.whisper_server_url) if settings.stt_model == "whisper-small" + else Recognizer(models / "gigaam-v3-onnx")) synthesizer = Synthesizer(models / "silero-tts" / "v5_ru.pt") # Прогрев здесь, на старте стенда, а не на первой реплике курсанта. recognizer.warmup() synthesizer.warmup() return VoiceModels(recognizer, synthesizer, models / "silero-vad" / "silero_vad.onnx") - except ImportError as exc: - log.warning("голосовой контур выключен: не установлены зависимости (%s) — uv sync --extra voice", exc) + except (ImportError, httpx.HTTPError, ValueError) as exc: + log.warning("голосовой контур выключен: модели/сервер не готовы (%s)", exc) return None diff --git a/backend/app/voice/recording.py b/backend/app/voice/recording.py new file mode 100644 index 0000000..5a8686d --- /dev/null +++ b/backend/app/voice/recording.py @@ -0,0 +1,81 @@ +"""Локальная WAV-запись обеих сторон учебного голосового вызова.""" + +import os +import time +import wave +from dataclasses import dataclass +from pathlib import Path +from uuid import UUID + +import numpy as np + +from app.config import get_settings + +TARGET_RATE = 16_000 + + +def recording_path(session_id: UUID) -> Path: + return Path(get_settings().recordings_dir).resolve() / f"{session_id}.wav" + + +@dataclass(frozen=True) +class _Segment: + offset: int + samples: np.ndarray + + +class CallRecorder: + """Смешивает PCM16 разных частот на монотаймлайн 16 кГц. + + Вход курсанта приходит по 16 кГц, TTS звонящего — по 24 кГц. Метка + monotonic сохраняет паузы и взаимное расположение реплик; системные часы + и изменение времени на хосте на запись не влияют. + """ + + 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 + + def add_pcm(self, pcm: bytes, *, sample_rate: int) -> None: + if self._finalized or not pcm or sample_rate <= 0 or len(pcm) % 2: + return + source = np.frombuffer(pcm, dtype=" Path | None: + if self._finalized: + return self.path if self.path.is_file() else None + self._finalized = True + if not self._segments: + return None + total = max(item.offset + item.samples.size for item in self._segments) + mixed = np.zeros(total, dtype=np.int32) + for item in self._segments: + mixed[item.offset:item.offset + item.samples.size] += item.samples + pcm = np.clip(mixed, -32768, 32767).astype(" CallRecorder | None: + if not get_settings().record_calls: + return None + return CallRecorder(recording_path(session_id)) diff --git a/backend/conftest.py b/backend/conftest.py index 1d019a5..21a015c 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -13,3 +13,4 @@ os.environ.setdefault("VOICE_ENABLED", "false") # ждал бы ответа провайдера и зависел от его настроения. # Живые проверки LLM — в test_llm.py, он читает backend/.env.test. os.environ.setdefault("LLM_API_KEY", "") +os.environ.setdefault("LLM_PROVIDER", "disabled") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e78b037..280e3ea 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "alembic>=1.13", "asyncpg>=0.29", "httpx>=0.27", + "certifi>=2024", "pyyaml>=6.0", "reportlab>=4.0", "structlog>=24.1", diff --git a/backend/scripts/backup.py b/backend/scripts/backup.py index 6ec7511..294069f 100644 --- a/backend/scripts/backup.py +++ b/backend/scripts/backup.py @@ -8,6 +8,7 @@ 0 3 * * * cd … && make backup в crontab демо-машины """ +import os import sys from pathlib import Path @@ -18,7 +19,7 @@ from app.admin.backup import BackupError, create, listing # noqa: E402 #: Сколько копий держать. Место на демо-машине не бесконечно, а копия за #: позапрошлую неделю не нужна никому. -KEEP = 14 +KEEP = max(1, int(os.environ.get("BACKUP_KEEP", "14"))) def main() -> int: diff --git a/backend/scripts/backup_loop.py b/backend/scripts/backup_loop.py new file mode 100644 index 0000000..608bbc8 --- /dev/null +++ b/backend/scripts/backup_loop.py @@ -0,0 +1,80 @@ +"""Автоматическая резервная копия PostgreSQL не реже раза в сутки. + +Процесс живёт отдельным Compose-сервисом. Он делает копию сразу, если свежей +нет, затем ориентируется на фактическое время последнего успешного файла. +После ошибки повторяет попытку чаще, но не удаляет существующие копии. +""" + +from __future__ import annotations + +import logging +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.admin.backup import BackupError, DIR, create, listing # noqa: E402 +from app.config import get_settings # noqa: E402 + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s backup-scheduler %(message)s", +) +log = logging.getLogger("backup-scheduler") + + +def seconds_until_due(now: datetime, interval: int) -> float: + copies = listing() + if not copies: + return 0.0 + age = (now - copies[0]["at"]).total_seconds() + return max(0.0, interval - age) + + +def prune(keep: int) -> int: + old = listing()[keep:] + deleted = 0 + for item in old: + try: + (DIR / item["name"]).unlink(missing_ok=True) + deleted += 1 + except OSError as exc: + # A locked/readonly old file must not take down the daily scheduler. + log.error("не удалось удалить старую копию %s: %s", item["name"], exc) + return deleted + + +def run_forever() -> None: + settings = get_settings() + interval = max(60, settings.backup_interval_seconds) + retry = max(10, min(settings.backup_retry_seconds, interval)) + keep = max(1, settings.backup_keep) + log.info("запущен: интервал=%s с, хранить=%s", interval, keep) + + while True: + try: + wait = seconds_until_due(datetime.now(timezone.utc), interval) + except BackupError as exc: + log.error("не удалось проверить резервные копии: %s; повтор через %s с", exc, retry) + time.sleep(retry) + continue + if wait > 0: + time.sleep(wait) + continue + try: + made = create() + deleted = prune(keep) + log.info("готово: %s (%s байт), удалено старых=%s", + made["name"], made["size_bytes"], deleted) + except BackupError as exc: + log.error("цикл резервного копирования не завершён: %s; повтор через %s с", exc, retry) + time.sleep(retry) + continue + time.sleep(interval) + + +if __name__ == "__main__": + run_forever() diff --git a/backend/scripts/download_local_models.py b/backend/scripts/download_local_models.py new file mode 100644 index 0000000..673fccf --- /dev/null +++ b/backend/scripts/download_local_models.py @@ -0,0 +1,131 @@ +"""Скачать ровно проверенные файлы трёх локальных моделей. + +Файлы лежат в backend/models/ (не в Git). Скрипт одинаково работает на +Windows, macOS и Linux; при повторном запуске продолжает неполный файл. +После переноса каталога models/ интернет для занятия не нужен. +""" + +import hashlib +import argparse +import shutil +import ssl +import sys +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +import certifi + +ROOT = Path(__file__).resolve().parents[1] +MODELS = ROOT / "models" + + +@dataclass(frozen=True) +class Artifact: + repo: str + name: str + target: str + size: int + sha256: str + + +ARTIFACTS = ( + Artifact("Qwen/Qwen3-1.7B-GGUF", "Qwen3-1.7B-Q8_0.gguf", + "qwen3-1.7b/Qwen3-1.7B-Q8_0.gguf", 1834426016, + "061b54daade076b5d3362dac252678d17da8c68f07560be70818cace6590cb1a"), + Artifact("Vikhrmodels/Vikhr-Llama-3.2-1B-instruct-GGUF", + "Vikhr-Llama-3.2-1B-Q4_K_M.gguf", + "vikhr-1b/Vikhr-Llama-3.2-1B-Q4_K_M.gguf", 807694432, + "a85c0ef4b6476ff5fcf2c961226234ab7008d17087c2152416e61a9b7e74b32d"), + Artifact("ggerganov/whisper.cpp", "ggml-small-q5_1.bin", + "whisper-small/ggml-small-q5_1.bin", 190085487, + "ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb"), +) + + +def digest(path: Path) -> str: + sha = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1 << 20): + sha.update(chunk) + return sha.hexdigest() + + +def verify(item: Artifact) -> None: + """Проверить перенесённый вес без сети и без изменения файла.""" + target = MODELS / item.target + if not target.is_file(): + raise RuntimeError(f"нет локального файла: {target}") + if target.stat().st_size != item.size: + raise RuntimeError( + f"неверный размер {item.target}: {target.stat().st_size}, ожидался {item.size} байт" + ) + actual = digest(target) + if actual != item.sha256: + raise RuntimeError(f"SHA-256 не совпал для {item.target}: {actual}") + print(f"проверено SHA-256: {item.target}", flush=True) + + +def download(item: Artifact) -> None: + target = MODELS / item.target + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists(): + try: + verify(item) + except RuntimeError: + pass + else: + print(f"готово: {item.target}", flush=True) + return + partial = target.with_name(target.name + ".part") + offset = partial.stat().st_size if partial.exists() else 0 + if offset > item.size: + raise RuntimeError(f"некорректный частичный файл: {partial}") + free = shutil.disk_usage(MODELS if MODELS.exists() else ROOT).free + if free < item.size - offset + 256 * 1024 * 1024: + raise RuntimeError(f"мало свободного места для {item.target}: осталось {free // (1 << 20)} МиБ") + url = f"https://huggingface.co/{item.repo}/resolve/main/{item.name}" + request = urllib.request.Request(url, headers={"Range": f"bytes={offset}-"} if offset else {}) + print(f"скачиваю {item.target} ({item.size // (1 << 20)} МиБ)", flush=True) + with urllib.request.urlopen( + request, timeout=120, context=ssl.create_default_context(cafile=certifi.where()) + ) as response: + # Сервер может проигнорировать Range: тогда начинаем заново, не + # приписываем полный файл к частичному. + resumed = offset > 0 and response.status == 206 + with partial.open("ab" if resumed else "wb") as out: + count = offset if resumed else 0 + while chunk := response.read(1 << 20): + out.write(chunk) + count += len(chunk) + if count % (64 << 20) < len(chunk) or count == item.size: + print(f" {count // (1 << 20)}/{item.size // (1 << 20)} МиБ", flush=True) + if partial.stat().st_size != item.size or digest(partial) != item.sha256: + raise RuntimeError(f"контроль размера/SHA-256 не пройден: {partial}") + partial.replace(target) + print(f"проверено SHA-256: {item.target}", flush=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--verify-only", action="store_true", + help="проверить уже загруженные/перенесённые веса без сети и без изменения файлов", + ) + args = parser.parse_args() + try: + for item in ARTIFACTS: + verify(item) if args.verify_only else download(item) + except (OSError, RuntimeError) as exc: + action = "проверка прервана" if args.verify_only else "загрузка прервана" + print(f"{action}: {exc}", file=sys.stderr) + return 1 + if args.verify_only: + print("Все три локальные модели прошли офлайн-проверку; файлы не изменялись.") + else: + print("Все три локальные модели проверены. Для голоса нужны также whisper-server, VAD и TTS.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/llm_check.py b/backend/scripts/llm_check.py index 0ff93ef..113b6e2 100644 --- a/backend/scripts/llm_check.py +++ b/backend/scripts/llm_check.py @@ -1,76 +1,43 @@ -"""make llm-check: один запрос к LLM, чтобы убедиться, что ключ и адрес рабочие. +"""Проверка активного диалогового профиля через тот же клиент, что и занятие. -Ключ берётся из backend/.env и никуда не печатается. Провайдер отвечает с машины -разработки напрямую; таймаут почти всегда означает не отказ провайдера, а окружение — -VPN-туннель или песочница, через которые российские адреса не проходят. Поэтому -проверять отсюда, а не из обёрток, и с той машины, на которой пойдёт занятие. +В локальном режиме ключ не нужен; OFFLINE=true запрещает запрос наружу. """ +import asyncio import sys import time from pathlib import Path -import httpx - ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from app.config import get_settings # noqa: E402 +from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable # noqa: E402 -def main() -> int: +async def check() -> int: settings = get_settings() - if not settings.llm_api_key: - print("в backend/.env нет LLM_API_KEY") - return 1 - - print(f"адрес: {settings.llm_base_url}") - print(f"модель: {settings.llm_model_caller}") - - body = { - "model": settings.llm_model_caller, - "messages": [ - {"role": "system", "content": "Ты звонящий в службу 112, у тебя горит балкон. " - "Ответь одной короткой фразой, в панике."}, - {"role": "user", "content": "Служба 112, что у вас случилось?"}, - ], - "max_tokens": 60, - "temperature": 0.8, - } - + control = settings.dialogue_model_mode == "russian_control" + model = settings.llm_model_control if control else settings.llm_model_caller + address = settings.llm_control_base_url if control else settings.llm_base_url + print(f"адрес: {address}\nмодель: {model}") + client = LlmClient(base_url=address, timeout=30) started = time.monotonic() try: - response = httpx.post( - f"{settings.llm_base_url}/chat/completions", - headers={"Authorization": f"Bearer {settings.llm_api_key}"}, - json=body, - timeout=60, - ) - except httpx.HTTPError as exc: - print(f"\nсеть: {type(exc).__name__} — до провайдера не достучались.") - print("Это чаще про окружение, чем про провайдера: проверь, что российские") - print("адреса идут мимо VPN, и повтори с самой машины стенда.") - return 2 - - elapsed = time.monotonic() - started - print(f"\nHTTP {response.status_code}, {elapsed:.2f} с") - - if response.status_code == 200: - data = response.json() - print("ответ модели:", data["choices"][0]["message"]["content"].strip()) - usage = data.get("usage", {}) - print("токены:", usage.get("prompt_tokens"), "→", usage.get("completion_tokens")) - print("\nКЛЮЧ РАБОТАЕТ") - return 0 - - print("ответ сервера:", response.text[:400]) - if response.status_code in (401, 403): - print("\nСеть в порядке, но ключ не принят: возможно, его нужно менять " - "на временный токен — сверься с консолью Cloud.ru.") - elif response.status_code == 404: - print("\nКлюч принят, но такой модели нет: проверь LLM_MODEL_CALLER.") - return 3 + reply = await client.complete(LlmRequest( + model=model, + messages=[{"role": "user", "content": "Ответь одним словом по-русски: работает /no_think"}], + temperature=0, + max_tokens=32, + ), use_cache=False) + except LlmUnavailable as exc: + print(f"модель недоступна: {exc}") + return 1 + finally: + await client.aclose() + print(f"ответ за {time.monotonic() - started:.2f} с: {reply}") + return 0 if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(asyncio.run(check())) diff --git a/backend/scripts/local_llms.py b/backend/scripts/local_llms.py new file mode 100644 index 0000000..d9e31cb --- /dev/null +++ b/backend/scripts/local_llms.py @@ -0,0 +1,118 @@ +"""Запуск двух локальных GGUF-серверов через llama.cpp на Windows/macOS/Linux. + +Никаких загрузок при старте: GGUF предварительно кладутся в models/ через +download_local_models.py. Путь к llama-server задаётся LLAMA_SERVER_BIN либо +берётся из PATH. Ctrl+C останавливает дочерние процессы. +""" + +import argparse +import os +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PROFILES = { + "dialogue": ("qwen3-1.7b/Qwen3-1.7B-Q8_0.gguf", "Qwen3-1.7B", 18080), + "russian_control": ("vikhr-1b/Vikhr-Llama-3.2-1B-Q4_K_M.gguf", "Vikhr-1B", 18081), +} + + +def binary_path() -> str: + bundled_mac = ROOT / "models" / "bin" / "llama-b10934" / "llama-server" + binary = (os.environ.get("LLAMA_SERVER_BIN") or shutil.which("llama-server") + or shutil.which("llama-server.exe") + # The checked-out helper is Mach-O arm64. Do not select it on a + # Windows machine merely because the whole models/ folder was + # copied there for its platform-neutral GGUF weights. + or (str(bundled_mac) if sys.platform == "darwin" and bundled_mac.is_file() else None)) + if not binary: + raise RuntimeError("нет llama-server; установите бинарник llama.cpp и задайте LLAMA_SERVER_BIN") + if not Path(binary).is_file(): + raise RuntimeError(f"llama-server не найден: {binary}") + return binary + + +def command(binary: str, profile: str, threads: int) -> list[str]: + relative, alias, port = PROFILES[profile] + model = ROOT / "models" / relative + if not model.is_file(): + raise RuntimeError(f"нет модели: {model} — запустите make local-models") + result = [binary, "-m", str(model), "--alias", alias, + "--host", "127.0.0.1", "--port", str(port), + "--ctx-size", "2048", "--threads", str(threads), "--parallel", "1", + "--cors-origins", "localhost"] + if profile == "dialogue": + result += ["--reasoning-budget", "0"] + return result + + +def ready(port: int) -> bool: + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=1) as response: + return response.status == 200 + except (OSError, urllib.error.HTTPError): + return False + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--mode", choices=["dialogue", "russian_control", "both"], default="dialogue") + parser.add_argument("--threads", type=int, default=4) + args = parser.parse_args() + if args.threads < 1: + parser.error("--threads должен быть положительным") + selected = list(PROFILES) if args.mode == "both" else [args.mode] + try: + binary = binary_path() + commands = [command(binary, profile, args.threads) for profile in selected] + except RuntimeError as exc: + print(exc, file=sys.stderr) + return 2 + processes: list[subprocess.Popen] = [] + try: + for profile, argv in zip(selected, commands): + port = PROFILES[profile][2] + if ready(port): + raise RuntimeError(f"порт {port} уже занят сервером — не запускаю дубликат") + print(f"запускаю {profile} на 127.0.0.1:{port}", flush=True) + # Ctrl+C должен достаться управляющему процессу один раз: он сам + # остановит дочерний сервер. Иначе llama.cpp получает двойной + # SIGINT и на Metal иногда падает во время освобождения памяти. + flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0 + processes.append(subprocess.Popen( + argv, cwd=ROOT, creationflags=flags, start_new_session=os.name != "nt" + )) + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + if any(process.poll() is not None for process in processes): + raise RuntimeError("один из серверов модели завершился до готовности") + if all(ready(PROFILES[profile][2]) for profile in selected): + print("локальные модели готовы; Ctrl+C остановит их", flush=True) + while all(process.poll() is None for process in processes): + time.sleep(0.5) + raise RuntimeError("один из серверов модели неожиданно завершился") + time.sleep(0.5) + raise RuntimeError("модели не стали готовы за 120 секунд") + except KeyboardInterrupt: + return 0 + except RuntimeError as exc: + print(exc, file=sys.stderr) + return 1 + finally: + for process in processes: + if process.poll() is None: + process.terminate() + for process in processes: + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/local_stt.py b/backend/scripts/local_stt.py new file mode 100644 index 0000000..0190f9d --- /dev/null +++ b/backend/scripts/local_stt.py @@ -0,0 +1,84 @@ +"""Локальный whisper.cpp server для Whisper small (macOS / Windows / Linux). + +Сначала скачайте GGML-вес через download_local_models.py. Исполняемый файл +задаётся WHISPER_SERVER_BIN или ищется в PATH / локальной сборке. Сеть не нужна. +""" + +import os +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MODEL = ROOT / "models" / "whisper-small" / "ggml-small-q5_1.bin" +PORT = 18082 + + +def binary_path() -> str: + built = ROOT / "models" / "bin" / "whisper.cpp-1.9.4" / "build" / "bin" / "whisper-server" + binary = (os.environ.get("WHISPER_SERVER_BIN") or shutil.which("whisper-server") + or shutil.which("whisper-server.exe") + # The in-tree build is macOS arm64, not a portable executable. + or (str(built) if sys.platform == "darwin" and built.is_file() else None)) + if not binary or not Path(binary).is_file(): + raise RuntimeError("нет whisper-server; установите бинарник whisper.cpp и задайте WHISPER_SERVER_BIN") + return binary + + +def command(binary: str, threads: int = 4) -> list[str]: + if not MODEL.is_file(): + raise RuntimeError(f"нет модели: {MODEL} — запустите make local-models") + return [binary, "-m", str(MODEL), "--host", "127.0.0.1", "--port", str(PORT), + "--language", "ru", "--threads", str(threads), "--no-fallback"] + + +def ready() -> bool: + try: + with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1) as response: + return response.status == 200 + except (OSError, urllib.error.HTTPError): + return False + + +def main() -> int: + try: + if ready(): + raise RuntimeError(f"порт {PORT} уже занят сервером") + argv = command(binary_path()) + except RuntimeError as exc: + print(exc, file=sys.stderr) + return 2 + flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0 + process = subprocess.Popen(argv, cwd=ROOT, creationflags=flags, start_new_session=os.name != "nt") + try: + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError("whisper-server завершился до готовности") + if ready(): + print(f"Whisper small готов на 127.0.0.1:{PORT}; Ctrl+C остановит сервер", flush=True) + while process.poll() is None: + time.sleep(0.5) + raise RuntimeError("whisper-server неожиданно завершился") + time.sleep(0.5) + raise RuntimeError("Whisper small не стал готов за 120 секунд") + except KeyboardInterrupt: + return 0 + except RuntimeError as exc: + print(exc, file=sys.stderr) + return 1 + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/models.py b/backend/scripts/models.py index f7cbf3c..cce292b 100644 --- a/backend/scripts/models.py +++ b/backend/scripts/models.py @@ -1,15 +1,21 @@ -"""make models: веса моделей в backend/models/. +"""make models: вспомогательные веса голоса в backend/models/. -Эмбеддинги, распознавание (GigaAM v3, int8) и VAD качаются с Hugging Face. +Эмбеддинги и VAD качаются с Hugging Face. Whisper small, Qwen3 и Vikhr +скачиваются отдельным `make local-models` с проверкой SHA-256. Старый GigaAM +доступен через `--legacy-gigaam`, но в выбранный стек больше не входит. Синтез (Silero TTS v5) лежит на models.silero.ai — российском хосте, который не отвечает из-под VPN: его скрипт не качает, а проверяет и говорит, что делать. Докачка продолжается с места обрыва: сеть на стенде бывает медленной. """ +import argparse +import ssl import sys import urllib.request from pathlib import Path +import certifi + ROOT = Path(__file__).resolve().parents[1] MODELS = ROOT / "models" @@ -20,16 +26,16 @@ FILES = { "e5-small/config.json": E5 + "config.json", "e5-small/tokenizer.json": E5 + "tokenizer.json", "e5-small/model_quantized.onnx": E5 + "onnx/model_quantized.onnx", - # RNNT — основная модель, CTC — запасная на случай, если RNNT не загрузится - # (по скорости CTC не выигрывает — docs/LATENCY.md). + "silero-vad/config.json": VAD + "config.json", + "silero-vad/silero_vad.onnx": VAD + "silero_vad.onnx", +} +LEGACY_GIGAAM_FILES = { "gigaam-v3-onnx/config.json": GIGAAM + "config.json", "gigaam-v3-onnx/v3_vocab.txt": GIGAAM + "v3_vocab.txt", "gigaam-v3-onnx/v3_rnnt_encoder.int8.onnx": GIGAAM + "v3_rnnt_encoder.int8.onnx", "gigaam-v3-onnx/v3_rnnt_decoder.int8.onnx": GIGAAM + "v3_rnnt_decoder.int8.onnx", "gigaam-v3-onnx/v3_rnnt_joint.int8.onnx": GIGAAM + "v3_rnnt_joint.int8.onnx", "gigaam-v3-onnx/v3_ctc.int8.onnx": GIGAAM + "v3_ctc.int8.onnx", - "silero-vad/config.json": VAD + "config.json", - "silero-vad/silero_vad.onnx": VAD + "silero_vad.onnx", } SILERO_TTS = "silero-tts/v5_ru.pt" @@ -40,19 +46,27 @@ def fetch(url: str, target: Path) -> None: partial = target.with_suffix(target.suffix + ".part") offset = partial.stat().st_size if partial.exists() else 0 request = urllib.request.Request(url, headers={"Range": f"bytes={offset}-"} if offset else {}) - with urllib.request.urlopen(request, timeout=60) as response, partial.open("ab") as out: - total = offset + int(response.headers.get("Content-Length", 0)) - while chunk := response.read(1 << 20): - out.write(chunk) - done = out.tell() - if total: - print(f"\r {target.name}: {done // (1 << 20)} из {total // (1 << 20)} МБ", end="", flush=True) + with urllib.request.urlopen( + request, timeout=60, context=ssl.create_default_context(cafile=certifi.where()) + ) as response: + resumed = offset > 0 and response.status == 206 + with partial.open("ab" if resumed else "wb") as out: + total = (offset if resumed else 0) + int(response.headers.get("Content-Length", 0)) + while chunk := response.read(1 << 20): + out.write(chunk) + done = out.tell() + if total: + print(f"\r {target.name}: {done // (1 << 20)} из {total // (1 << 20)} МБ", end="", flush=True) partial.rename(target) print() def main() -> None: - for relative, url in FILES.items(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--legacy-gigaam", action="store_true") + args = parser.parse_args() + files = {**FILES, **(LEGACY_GIGAAM_FILES if args.legacy_gigaam else {})} + for relative, url in files.items(): target = MODELS / relative if target.exists(): print(f" {relative}: уже есть") diff --git a/backend/scripts/repl.py b/backend/scripts/repl.py index 7450e56..8807a4f 100644 --- a/backend/scripts/repl.py +++ b/backend/scripts/repl.py @@ -54,7 +54,10 @@ async def main() -> None: slots = SlotMachine(scenario, embedder) persona = PersonaState(scenario.persona) - caller = build_caller(scenario.id) + caller = build_caller( + scenario.id, + use_pregenerated=scenario.tree.pregenerated, + ) print(f"── {scenario.title} ({scenario.level.value}) ──") print("Вы — оператор 112. Команды: /подсказка /факты /итог /выход\n") diff --git a/backend/scripts/smoke_voice.py b/backend/scripts/smoke_voice.py new file mode 100644 index 0000000..5b537c6 --- /dev/null +++ b/backend/scripts/smoke_voice.py @@ -0,0 +1,40 @@ +"""Проверка локального TTS → Whisper small без микрофона и внешней сети. + +Это не замер сквозной задержки звонка: TTS создаёт синтетический образец, а +загрузка моделей оплачивается отдельно. На Windows выполнить ту же команду. +""" + +import time + +import numpy as np +from scipy.signal import resample_poly + +from app.voice.models import Synthesizer, WhisperRecognizer +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PHRASE = "Пожар на улице Ленина, дом четырнадцать. Нужна помощь." + + +def main() -> None: + started = time.monotonic() + tts = Synthesizer(ROOT / "models" / "silero-tts" / "v5_ru.pt") + print(f"TTS загружен за {time.monotonic() - started:.2f} с", flush=True) + started = time.monotonic() + pcm = tts.synthesize(PHRASE) + audio = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768 + audio = resample_poly(audio, 2, 3).astype(np.float32) + print(f"TTS: {time.monotonic() - started:.2f} с, звук {len(audio) / 16000:.2f} с", flush=True) + + started = time.monotonic() + stt = WhisperRecognizer("http://127.0.0.1:18082") + stt.warmup() + print(f"Whisper server готов за {time.monotonic() - started:.2f} с", flush=True) + started = time.monotonic() + answer = stt.transcribe(audio) + print(f"Whisper: {time.monotonic() - started:.2f} с", flush=True) + print(f"ожидалось: {PHRASE}\nполучено: {answer}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/users.py b/backend/scripts/users.py index 32dcba7..f255b37 100644 --- a/backend/scripts/users.py +++ b/backend/scripts/users.py @@ -30,7 +30,7 @@ from app.domain.roles import Role # noqa: E402 PEOPLE = [ ("admin", "Администратор стенда", Role.ADMIN, None), ("teacher", "Преподаватель", Role.INSTRUCTOR, None), - ("trainee", "Курсант", Role.TRAINEE, "ДДС района"), + ("trainee", "Курсант", Role.TRAINEE, "Служба 101"), ] diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index 2116953..2b5eb3d 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -5,6 +5,7 @@ """ import uuid +from xml.etree import ElementTree as ET import pytest from fastapi.testclient import TestClient @@ -61,12 +62,29 @@ 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/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 def test_anonymous_cannot_open_admin(client): assert client.get("/api/admin/users").status_code == 401 +def test_admin_downloads_safe_xml_configuration(as_admin): + response = as_admin.get("/api/admin/config.xml") + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/xml") + assert "attachment" in response.headers["content-disposition"] + root = ET.fromstring(response.content) + assert root.tag == "lctConfiguration" + assert root.find("./workstations/workstation[@role='admin']") 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 + assert b"llm_api_key" not in lowered + assert b"database_url" not in lowered + + def test_admin_has_no_way_to_change_a_score(): """Запрет выражен отсутствием точки, а не проверкой внутри неё: в модуле администратора нет ни одного маршрута, трогающего оценки.""" @@ -90,6 +108,39 @@ def test_admin_cannot_write_to_the_audit_log(): ] +def test_audit_api_applies_actor_action_and_offset_filters(as_admin): + """The admin UI must be able to reach older records, not just page zero.""" + from app.api.http import admin as admin_module + + class FakeDb: + statement = None + + async def scalars(self, statement): + self.statement = statement + return [] + + fake_db = FakeDb() + + async def fake_session(): + yield fake_db + + app.dependency_overrides[admin_module.get_session] = fake_session + try: + response = as_admin.get( + "/api/admin/audit", + params={"action": "login.failed", "actor": "alice", "limit": 201, "offset": 400}, + ) + finally: + app.dependency_overrides.pop(admin_module.get_session, None) + + assert response.status_code == 200, response.text + statement = fake_db.statement + assert statement._limit_clause.value == 201 + assert statement._offset_clause.value == 400 + assert "audit_log.action" in str(statement.whereclause) + assert "audit_log.actor" in str(statement.whereclause) + + # ── учётные записи ── @@ -170,7 +221,39 @@ def test_status_names_every_component(as_admin): names = {item["name"] for item in as_admin.get("/api/admin/status").json()} assert {"База данных", "Модели речи", "Эмбеддинги", "Провайдер LLM", - "Классификатор ЕКП", "Секрет сессии"} <= names + "Классификатор ЕКП", "Резервное копирование", "Секрет сессии", + "Нагрузка backend"} <= names + + +def test_admin_gets_live_metrics_and_downloadable_failure_report(as_admin): + if not db_alive(as_admin): + pytest.skip("нет базы") + + response = as_admin.get("/api/admin/diagnostics") + assert response.status_code == 200, response.text + body = response.json() + assert body["metrics"]["cpu_cores"] >= 1 + assert body["metrics"]["disk_total_bytes"] > body["metrics"]["disk_free_bytes"] + assert body["metrics"]["active_sessions"] >= 0 + assert isinstance(body["recent_system_events"], list) + assert isinstance(body["failed_actions_24h"], list) + + download = as_admin.get("/api/admin/diagnostics.json") + assert download.status_code == 200 + assert "attachment" in download.headers["content-disposition"] + + +def test_diagnostic_journal_redacts_credentials(): + import logging + + from app.monitoring import recent_events + + logging.getLogger("app.redaction-test").warning( + "failure postgresql://user:secret@db/lct SESSION_SECRET=never-show" + ) + event = recent_events(limit=1)[0] + assert "user:secret@" not in event["message"] + assert "never-show" not in event["message"] def test_default_session_secret_is_reported_as_a_problem(as_admin): @@ -191,6 +274,32 @@ def test_backup_listing_is_open_to_admin(as_admin): assert as_admin.get("/api/admin/backups").status_code == 200 +def test_backup_listing_failure_does_not_break_admin_status(as_admin, monkeypatch): + from app.admin import backup as backup_service + + def broken(): + raise backup_service.BackupError("каталог временно недоступен") + + monkeypatch.setattr(backup_service, "listing", broken) + response = as_admin.get("/api/admin/status") + assert response.status_code == 200, response.text + backup = next(item for item in response.json() if item["name"] == "Резервное копирование") + assert not backup["ok"] + assert "каталог временно недоступен" in backup["detail"] + + +def test_backup_list_returns_503_on_storage_error(as_admin, monkeypatch): + from app.admin import backup as backup_service + + def broken(): + raise backup_service.BackupError("каталог временно недоступен") + + monkeypatch.setattr(backup_service, "listing", broken) + response = as_admin.get("/api/admin/backups") + assert response.status_code == 503 + assert "каталог временно недоступен" in response.json()["detail"] + + def test_backup_failure_explains_what_is_missing(as_admin, monkeypatch): """Кнопка не должна молча ничего не делать: если снять копию нечем, администратор видит, чего именно не хватает.""" @@ -203,3 +312,126 @@ def test_backup_failure_explains_what_is_missing(as_admin, monkeypatch): response = as_admin.post("/api/admin/backups") assert response.status_code == 503 assert "pg_dump" in response.json()["detail"] + + +def test_pg17_transaction_timeout_is_removed_from_pg16_backup(monkeypatch, tmp_path): + """Образ backend может содержать клиент новее сервера из Compose.""" + from app.admin import backup as backup_service + + monkeypatch.setattr(backup_service, "DIR", tmp_path) + monkeypatch.setattr(backup_service, "_dsn", lambda: ("u", "p", "db", "5432", "lct")) + monkeypatch.setattr(backup_service.shutil, "which", lambda name: "/usr/bin/pg_dump") + + class Result: + returncode = 0 + stderr = b"" + stdout = (b"--\n-- PostgreSQL database dump\n--\n\n" + b"SET transaction_timeout = 0;\nCREATE TABLE demo(id integer);\n") + + monkeypatch.setattr(backup_service.subprocess, "run", lambda *args, **kwargs: Result()) + made = backup_service.create() + content = (tmp_path / made["name"]).read_bytes() + assert b"transaction_timeout" not in content + assert b"CREATE TABLE demo" in content + + +def test_docker_backup_does_not_put_database_password_in_process_arguments(monkeypatch, tmp_path): + import base64 + from types import SimpleNamespace + + from app.admin import backup as backup_service + + password = "secret p@ss:word" + monkeypatch.setattr(backup_service, "DIR", tmp_path) + monkeypatch.setattr(backup_service, "_dsn", lambda: ("backup", password, "db", "5432", "lct")) + monkeypatch.setattr( + backup_service.shutil, "which", lambda name: "/usr/bin/docker" if name == "docker" else None + ) + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + captured["input"] = kwargs["input"] + return SimpleNamespace( + returncode=0, + stderr=b"", + stdout=b"--\n-- PostgreSQL database dump\nCREATE TABLE demo(id integer);\n", + ) + + monkeypatch.setattr(backup_service.subprocess, "run", fake_run) + backup_service.create() + + assert password not in " ".join(captured["command"]) + assert captured["input"] == base64.b64encode(password.encode()) + b"\n" + + +def test_backup_dsn_decodes_escaped_credentials_without_exposing_them(monkeypatch): + from types import SimpleNamespace + from app.admin import backup as backup_service + + monkeypatch.setattr( + backup_service, "get_settings", + lambda: SimpleNamespace(database_url="postgresql://backup:p%40ss%3Aword@db.example:5433/lct"), + ) + assert backup_service._dsn() == ("backup", "p@ss:word", "db.example", "5433", "lct") + + monkeypatch.setattr( + backup_service, "get_settings", + lambda: SimpleNamespace(database_url="not-a-valid-url-with-secret"), + ) + try: + backup_service._dsn() + except backup_service.BackupError as exc: + assert "secret" not in str(exc) + else: + raise AssertionError("invalid DATABASE_URL must be rejected") + + +def test_backup_directory_failure_is_retryable_backup_error(monkeypatch, tmp_path): + from app.admin import backup as backup_service + + blocker = tmp_path / "not-a-directory" + blocker.write_text("occupied") + monkeypatch.setattr(backup_service, "DIR", blocker / "backups") + monkeypatch.setattr(backup_service, "_dsn", lambda: ("u", "p", "db", "5432", "lct")) + + with pytest.raises(backup_service.BackupError, match="каталог резервных копий"): + backup_service.create() + + +def test_backup_listing_io_failure_is_retryable_error(monkeypatch, tmp_path): + from app.admin import backup as backup_service + + monkeypatch.setattr(backup_service, "DIR", tmp_path) + + def denied(_path): + raise PermissionError("access denied") + + monkeypatch.setattr(backup_service.os, "scandir", denied) + with pytest.raises(backup_service.BackupError, match="прочитать каталог"): + backup_service.listing() + + +def test_windows_backup_permissions_use_explicit_private_acl(monkeypatch, tmp_path): + from types import SimpleNamespace + from app.admin import backup as backup_service + + monkeypatch.setattr(backup_service, "getuser", lambda: "operator") + monkeypatch.setenv("USERDOMAIN", "TRAINING") + monkeypatch.setenv("SystemRoot", str(tmp_path)) + called = {} + + def fake_run(command, **kwargs): + called["command"] = command + called["kwargs"] = kwargs + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(backup_service.subprocess, "run", fake_run) + backup_service._restrict_file_permissions(-1, tmp_path / "copy.sql", platform_name="nt") + + command = called["command"] + assert command[0].endswith("icacls.exe") + assert "/inheritance:r" in command + assert "TRAINING\\operator:(F)" in command + assert "*S-1-5-18:(F)" in command + assert called["kwargs"]["timeout"] == 15 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 49a6826..4cf28b4 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -56,6 +56,17 @@ def test_unknown_login_and_wrong_password_look_the_same(client): assert first.json()["detail"] == "bad_credentials" +def test_login_rejects_values_outside_database_and_hash_bounds(client): + too_long_login = client.post( + "/api/auth/login", json={"login": "a" * 81, "password": "not-used"} + ) + too_long_password = client.post( + "/api/auth/login", json={"login": "operator", "password": "x" * 1025} + ) + assert too_long_login.status_code == 422 + assert too_long_password.status_code == 422 + + def test_me_requires_authentication(client): 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 4a079cf..df0f789 100644 --- a/backend/tests/test_auth_hardening.py +++ b/backend/tests/test_auth_hardening.py @@ -18,6 +18,10 @@ from app.session.hub import hub @pytest.fixture def client(): + # Each TestClient represents a fresh backend process. In particular, + # revocations exercised by a previous test must not leak into the next + # process' in-memory cache. + auth.prime_generations({}) with TestClient(app) as test_client: hub.journal = None yield test_client @@ -49,6 +53,12 @@ def test_cookie_from_previous_process_is_rejected(client, monkeypatch): assert client.get("/api/auth/me").status_code == 401 +def test_cookie_survives_generation_cache_reload_when_account_is_unchanged(client): + assert client.post("/api/auth/dev-token").status_code == 200 + auth.prime_generations({"dev": 0}) + assert client.get("/api/auth/me").status_code == 200 + + class FakeDb: def __init__(self, user): self.user = user @@ -74,7 +84,7 @@ 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", created_at=datetime.now(timezone.utc), + password_hash="old", auth_version=0, created_at=datetime.now(timezone.utc), ) @@ -86,7 +96,10 @@ async def test_admin_patch_revokes_cookie_after_commit(monkeypatch): monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal( login="admin", full_name="Администратор", role=Role.ADMIN, )) - monkeypatch.setattr(admin, "invalidate_login", lambda login: calls.append((login, db.commits))) + monkeypatch.setattr( + admin, "invalidate_login", + lambda login, version=None: calls.append((login, version, db.commits)), + ) async def no_audit(*_args, **_kwargs): return None @@ -94,7 +107,7 @@ async def test_admin_patch_revokes_cookie_after_commit(monkeypatch): 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)] + assert calls == [("victim", 1, 1)] @pytest.mark.asyncio diff --git a/backend/tests/test_backup_scheduler.py b/backend/tests/test_backup_scheduler.py new file mode 100644 index 0000000..ffa4ee8 --- /dev/null +++ b/backend/tests/test_backup_scheduler.py @@ -0,0 +1,20 @@ +from datetime import datetime, timedelta, timezone + +from scripts import backup_loop + + +def test_no_backup_is_due_immediately(monkeypatch): + monkeypatch.setattr(backup_loop, "listing", lambda: []) + assert backup_loop.seconds_until_due(datetime.now(timezone.utc), 86_400) == 0 + + +def test_fresh_backup_waits_only_for_remaining_interval(monkeypatch): + now = datetime.now(timezone.utc) + monkeypatch.setattr(backup_loop, "listing", lambda: [{"at": now - timedelta(hours=6)}]) + assert backup_loop.seconds_until_due(now, 86_400) == 18 * 3600 + + +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 diff --git a/backend/tests/test_card_exercise.py b/backend/tests/test_card_exercise.py index a8ca82a..c814385 100644 --- a/backend/tests/test_card_exercise.py +++ b/backend/tests/test_card_exercise.py @@ -160,6 +160,11 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client): station.send_json({"type": "crew.select", "crew": crew}) read_until(station, "station.state") station.send_json({"type": "phone.dial"}) + assert read_until(station, "phone.line")["speaker"] == "crew" + read_until(station, "station.state") + station.send_json({"type": "phone.brief", "address": "улица Ленина, 14", + "incident": "Пожар в квартире", + "request": "Прошу подтвердить выезд и доложить о прибытии"}) assert read_until(station, "phone.report")["phase"] == "dispatched" read_until(station, "station.state") station.send_json({"type": "card.status", "service": service, "status": "responding"}) @@ -170,7 +175,8 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client): score = wait_for(lambda: hub.get(session_id).score) assert hub.get(session_id).ended keys = {item["key"] for item in score["metrics"]} - assert {"address", "incident_signs", "dds_primary", "dds_contact"} <= keys + assert {"address", "incident_signs", "dds_primary", "dds_progress"} <= keys + assert "dds_contact" not in keys assert score["score_auto"] < 100 finally: control.__exit__(None, None, None) diff --git a/backend/tests/test_dds_exercise.py b/backend/tests/test_dds_exercise.py index c8fab6f..576af2c 100644 --- a/backend/tests/test_dds_exercise.py +++ b/backend/tests/test_dds_exercise.py @@ -1,13 +1,14 @@ """Готовая карточка → учебный звонок бригаде → числовая оценка ДДС.""" import time +from datetime import datetime from uuid import uuid4 import pytest from fastapi.testclient import TestClient -from app.main import app from app.domain.timers import TimerCode +from app.main import app from app.session.hub import hub @@ -37,16 +38,39 @@ def read_until(socket, wanted): raise AssertionError(f"событие {wanted} не пришло") -def start(client, exercise="dds"): +def start(client, exercise="dds", criteria=None, dds_service=None, scenario_id="fire-apartment-l2"): 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": 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 + control.send_json(payload) wait_for(lambda: hub.get(session_id)) return session_id, context +def complete_phone_call(station, state, expected_phase): + station.send_json({"type": "phone.dial"}) + greeting = read_until(station, "phone.line") + 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": "Прошу подтвердить выезд и сообщить о прибытии"}) + else: + 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") + assert report["phase"] == expected_phase + return report + + def test_dds_starts_with_prepared_card_without_call(client): session_id, control = start(client) try: @@ -62,10 +86,125 @@ def test_dds_starts_with_prepared_card_without_call(client): assert card["card"]["address"] == "улица Ленина, 14" snapshot = read_until(station, "station.state")["snapshot"] assert snapshot["crew_options"] + assert len(snapshot["services"]) == 1 + assert snapshot["managed_service"] == snapshot["services"][0] + assert set(snapshot["services"]) < set(snapshot["recipient_services"]) finally: control.__exit__(None, None, None) -def test_dds_call_reports_gate_status_and_affect_numeric_score(client): + +def test_ticket_dds_card_uses_source_caller_identity_and_phone(client): + session_id, control = start(client, scenario_id="t01-1-fire-container") + try: + state = hub.get(session_id) + assert state.kio.caller_name == "Сидоров Иван Сергеевич" + assert state.kio.caller_number == "916-126-34-71" + assert state.kio.caller_contact == "916-126-34-71" + with client.websocket_connect(f"/ws/station/{session_id}") as station: + card = read_until(station, "card.received")["card"] + assert card["caller_name"] == "Сидоров Иван Сергеевич" + assert card["caller_number"] == "916-126-34-71" + assert card["caller_contact"] == "916-126-34-71" + finally: + control.__exit__(None, None, None) + + +def test_ticket_with_multiple_witnesses_does_not_guess_caller_name(client): + session_id, control = start(client, scenario_id="t20-1-three-fighting") + try: + state = hub.get(session_id) + assert state.kio.caller_name is None + assert state.kio.caller_number == "916-123-98-78" + assert state.kio.caller_contact == "916-123-98-78" + finally: + control.__exit__(None, None, None) + + +def test_dds_can_change_only_its_own_service_status(client): + session_id, control = start(client, dds_service="МВД") + try: + with client.websocket_connect(f"/ws/station/{session_id}") as station: + read_until(station, "card.received") + 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", + }) + 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" + 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}, + }) + try: + state = hub.get(session_id) + assert state.criteria.decision_time_limit_seconds == 45 + assert state.timers.limits[TimerCode.DDS_ACK] == 45_000 + with client.websocket_connect(f"/ws/station/{session_id}") as station: + 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"}) + read_until(station, "station.state") + station.send_json({"type": "card.reply", "card_id": card["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) + 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"]) + report = client.get(f"/api/sessions/{session_id}/report").json() + assert report["criteria"] == { + "decision_time_limit_seconds": 45, + "allowed_errors": 50, + "require_correct_grammar": False, + "score_weights": {"dds_ack": 3.5}, + } + assert report["failed_metrics"] > 0 + assert report["passed"] is True + finally: + control.__exit__(None, None, None) + +def test_dds_statuses_do_not_require_phone_reports(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"] + service = snapshot["services"][0] + station.send_json({"type": "card.status", "service": service, "status": "accepted"}) + 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": "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 + 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"]) + finally: + control.__exit__(None, None, None) + + +def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client): session_id, control = start(client) try: with client.websocket_connect(f"/ws/station/{session_id}") as station: @@ -75,25 +214,35 @@ def test_dds_call_reports_gate_status_and_affect_numeric_score(client): crew = next(option for option in snapshot["crew_options"] if option.startswith(service + " — ")) station.send_json({"type": "card.status", "service": service, "status": "accepted"}) read_until(station, "station.state") - station.send_json({"type": "card.status", "service": service, "status": "responding"}) - assert "доклада" in read_until(station, "error")["message"] station.send_json({"type": "crew.select", "crew": crew}) - selected = read_until(station, "station.state")["snapshot"] - assert selected["crew_selected"] == crew - station.send_json({"type": "phone.dial"}) - report = read_until(station, "phone.report") - assert report["phase"] == "dispatched" and report["crew"] == crew - assert read_until(station, "station.state")["snapshot"]["phone_reports"] - station.send_json({"type": "card.status", "service": service, "status": "responding"}) 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) - keys = {metric["key"] for metric in score["metrics"]} - assert "dds_primary" in keys and "dds_contact" 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"]) + station.send_json({"type": "phone.dial"}) + assert read_until(station, "phone.line")["speaker"] == "crew" + 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": "Прошу направить бригаду"}) + assert "адрес" in read_until(station, "error")["message"] + 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 not hub.get(session_id).phone_reports + complete_phone_call(station, hub.get(session_id), "dispatched") + read_until(station, "station.state") + assert len(hub.get(session_id).phone_reports) == 1 + station.send_json({"type": "phone.dial"}) + read_until(station, "phone.line") + read_until(station, "station.state") + station.send_json({"type": "phone.check", "text": "Здравствуйте, хорошая погода"}) + assert "обстановку" in read_until(station, "error")["message"] + assert len(hub.get(session_id).phone_reports) == 1 finally: control.__exit__(None, None, None) @@ -108,26 +257,16 @@ def test_default_exercise_remains_call(client): assert state.dispatched_card is None -def test_complete_dds_workflow_scores_without_call_penalties(client): +def test_complete_dds_workflow_scores_100_without_any_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"]: - crew = next(option for option in snapshot["crew_options"] - if option.startswith(service + " — ")) station.send_json({"type": "card.status", "service": service, "status": "accepted"}) read_until(station, "station.state") - station.send_json({"type": "crew.select", "crew": crew}) - read_until(station, "station.state") - for expected_phase, status in ( - ("dispatched", "responding"), ("arrived", "arrived"), - ("working", "working"), ("completed", "completed"), - ): - station.send_json({"type": "phone.dial"}) - assert read_until(station, "phone.report")["phase"] == expected_phase - read_until(station, "station.state") + for status in ("responding", "arrived", "working", "completed"): station.send_json({"type": "card.status", "service": service, "status": status}) snapshot = read_until(station, "station.state")["snapshot"] assert snapshot["card"] == "completed" @@ -137,11 +276,13 @@ def test_complete_dds_workflow_scores_without_call_penalties(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"]) finally: control.__exit__(None, None, None) -def test_sequential_dds_cards_keep_separate_state_and_scores(client): +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__() @@ -157,37 +298,53 @@ def test_sequential_dds_cards_keep_separate_state_and_scores(client): first_card_id = first["card"]["card_id"] assert (first["card_index"], first["card_total"]) == (1, 2) snapshot = read_until(station, "station.state")["snapshot"] - service = snapshot["services"][0] - station.send_json({"type": "card.status", "service": service, "status": "accepted"}) - read_until(station, "station.state") - station.send_json({"type": "card.reply", "card_id": first_card_id, - "text": "Сообщение принято, дежурная бригада направлена на место."}) - assert read_until(station, "station.state")["snapshot"]["reply_text"].startswith("Сообщение") - station.send_json({"type": "card.next", "card_id": first_card_id}) + assert len(snapshot["queue_cards"]) == 2 + 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_service = snapshot["services"][0] + station.send_json({"type": "card.status", "service": first_service, + "status": "accepted"}) + 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"] + + time.sleep(0.03) + # Card switching must publish its own fresh station snapshot; do + # not let the one-second ticker mask a missing update. + hub.stop_ticker(session_id) + station.send_json({"type": "card.open", "card_id": second_card_id}) second = read_until(station, "card.received") - second_card_id = second["card"]["card_id"] - assert second_card_id != first_card_id + assert second["card"]["card_id"] == second_card_id assert second["card"]["incident_type"] == "medical" assert (second["card_index"], second["card_total"]) == (2, 2) snapshot = read_until(station, "station.state")["snapshot"] - assert snapshot["reply_text"] == "" - assert snapshot["statuses"].get(service) == "added" or service not in snapshot["statuses"] - assert len(snapshot["completed_cards"]) == 1 - assert snapshot["completed_cards"][0]["card_id"] == first_card_id - station.send_json({"type": "card.reply", "card_id": first_card_id, - "text": "Запоздалый ответ к прошлой карточке"}) - assert "не к текущей" in read_until(station, "error")["message"] - assert hub.get(session_id).reply_text == "" - station.send_json({"type": "card.next", "card_id": first_card_id}) - assert "ID" in read_until(station, "error")["message"] - assert hub.get(session_id).dds_card_index == 1 + 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) + assert first_queue_row["service_status"] == "accepted" + assert first_queue_row["timer_stopped"] is True - with client.websocket_connect(f"/ws/station/{session_id}") as station: - assert read_until(station, "card.received")["card"]["card_id"] == second_card_id - assert read_until(station, "station.state")["snapshot"]["card_index"] == 2 - station.send_json({"type": "card.reply", "card_id": second_card_id, - "text": "Сообщение принято, бригада направлена на место происшествия."}) + second_service = snapshot["services"][0] + station.send_json({"type": "card.status", "service": second_service, + "status": "accepted"}) 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 + 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 + after_close = read_until(station, "station.state")["snapshot"] + assert len(after_close["queue_cards"]) == 1 + assert after_close["statuses"][second_service] == "accepted" station.send_json({"type": "card.next", "card_id": second_card_id}) read_until(station, "score.ready") @@ -196,20 +353,22 @@ def test_sequential_dds_cards_keep_separate_state_and_scores(client): assert len(state.score["card_results"]) == 2 assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2" assert state.score["card_results"][1]["scenario_id"] == "t20-2-stroke" - assert {item["key"] for item in state.score["metrics"]} >= {"dds_reply", "dds_primary"} + 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"]) report = client.get(f"/api/sessions/{session_id}/report").json() assert report["scenario_id"] == "fire-apartment-l2" assert len(report["card_results"]) == 2 assert report["missed_checklist"] == [] and report["reference_questions"] == [] first_actions = report["card_results"][0]["actions"] - assert {item["type"] for item in first_actions} >= {"card.status", "card.reply"} + assert {item["type"] for item in first_actions} >= {"card.status"} assert report["card_results"][0]["duration_ms"] >= 0 finally: control_ctx.__exit__(None, None, None) -def test_instructor_can_end_multi_card_early_without_grading_future_cards(client): +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__() @@ -227,8 +386,10 @@ def test_instructor_can_end_multi_card_early_without_grading_future_cards(client read_until(station, "score.ready") state = hub.get(session_id) assert state.ended - assert len(state.score["card_results"]) == 1 - assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2" + assert len(state.score["card_results"]) == 2 + assert [item["scenario_id"] for item in state.score["card_results"]] == [ + "fire-apartment-l2", "t20-2-stroke", + ] finally: control_ctx.__exit__(None, None, None) @@ -246,8 +407,8 @@ def test_each_dds_card_uses_its_own_scenario_weights(): base = load_file(root / "fire-apartment-l2.yaml", root) first = base.model_copy(deep=True) second = base.model_copy(deep=True) - first.score_weights = {"dds_reply": 7.0} - second.score_weights = {"dds_reply": 2.0} + 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, @@ -258,5 +419,5 @@ 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_reply") == 7.0 - assert next(item.weight for item in second_record.metrics if item.key == "dds_reply") == 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 e213e91..50cd256 100644 --- a/backend/tests/test_demo_no_db.py +++ b/backend/tests/test_demo_no_db.py @@ -50,7 +50,8 @@ def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch) assert client.post("/api/auth/dev-token?role=trainee").json()["trainee_id"] == str(DEMO_TRAINEE_ID) assert client.post("/api/auth/dev-token").json()["role"] == "instructor" assert client.get("/api/trainees").json() == [ - {"id": str(DEMO_TRAINEE_ID), "name": "Демо-курсант", "group": None} + {"id": str(DEMO_TRAINEE_ID), "name": "Демо-курсант", "group": None, + "service": "Служба 101"} ] # БД-зависимые экраны получают быстрый и явный отказ, не ждут TCP timeout. assert client.get("/api/sessions").json()["detail"] == "database_disabled_demo" diff --git a/backend/tests/test_ekp.py b/backend/tests/test_ekp.py index 70c11f7..0fb9431 100644 --- a/backend/tests/test_ekp.py +++ b/backend/tests/test_ekp.py @@ -30,10 +30,32 @@ def test_all_customer_ticket_cards_are_complete_and_classified(): 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.victims is not None 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", + } + + +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] + assert len(cards) == 27 + 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(): diff --git a/backend/tests/test_group_insight.py b/backend/tests/test_group_insight.py new file mode 100644 index 0000000..f8d9fcc --- /dev/null +++ b/backend/tests/test_group_insight.py @@ -0,0 +1,67 @@ +"""ИИ-инсайт получает только агрегаты и обязан вернуть строгий JSON.""" + +import json + +import pytest + +from app.config import get_settings +from app.scoring import group_insight + + +@pytest.fixture(autouse=True) +def local_settings(monkeypatch): + monkeypatch.setenv("LLM_PROVIDER", "local") + monkeypatch.setenv("LLM_BASE_URL", "http://127.0.0.1:18080/v1") + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +@pytest.mark.asyncio +async def test_insight_uses_only_aggregates_and_strict_schema(monkeypatch): + seen = [] + + class FakeClient: + def __init__(self, **_kwargs): pass + async def complete(self, request, **_kwargs): + seen.append(request) + return json.dumps({ + "summary": "Группа чаще всего теряет обязательные сведения при опросе.", + "priorities": ["Отработать уточнение адреса и этажа."], + }, ensure_ascii=False) + async def aclose(self): pass + + monkeypatch.setattr(group_insight, "LlmClient", FakeClient) + result = await group_insight.generate_group_insight({ + "active_trainees": 4, + "scored_attempts": 7, + "average_score": 76.2, + "errors": [{ + "code": "E2", "title": "Пропущен обязательный факт", + "rate_percent": 50.0, "recommendation": "Повторить алгоритм опроса", + }], + }) + assert result["priorities"] == ["Отработать уточнение адреса и этажа."] + request = seen[0] + assert request.temperature == 0 + assert request.response_format["schema"]["additionalProperties"] is False + payload = json.loads(request.messages[1]["content"]) + assert set(payload) == {"active_trainees", "scored_attempts", "average_score", "typical_errors"} + assert "trainee_id" not in json.dumps(payload) + assert "group" not in json.dumps(payload) + + +@pytest.mark.asyncio +async def test_insight_rejects_wrong_shape(monkeypatch): + class FakeClient: + def __init__(self, **_kwargs): pass + async def complete(self, *_args, **_kwargs): + return '{"summary":"слишком кратко","priorities":[],"secret":"x"}' + async def aclose(self): pass + + monkeypatch.setattr(group_insight, "LlmClient", FakeClient) + with pytest.raises(group_insight.InsightInvalid): + await group_insight.generate_group_insight({ + "active_trainees": 1, "scored_attempts": 1, "average_score": 50, + "errors": [], + }) diff --git a/backend/tests/test_local_models.py b/backend/tests/test_local_models.py new file mode 100644 index 0000000..08223f0 --- /dev/null +++ b/backend/tests/test_local_models.py @@ -0,0 +1,276 @@ +"""Локальная маршрутизация моделей не должна зависеть от сети или весов.""" + +import json + +import httpx +import numpy as np +import pytest + +from app.config import get_settings +from app.dialog.caller import LlmCaller, _allowed_reply +from app.dialog.factory import build_caller +from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, _spoken_content, is_loopback_url +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_stt +from tests.test_slots import SCENARIO, StemEmbedder + + +@pytest.fixture(autouse=True) +def clear_settings(): + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +def test_offline_model_address_must_be_literal_loopback(): + assert is_loopback_url("http://127.0.0.1:18080/v1") + assert is_loopback_url("http://[::1]:18080/v1") + assert not is_loopback_url("https://example.com/v1") + assert not is_loopback_url("http://localhost.evil.test:18080/v1") + assert not is_loopback_url("http://127.0.0.1.evil.test:18080/v1") + assert not is_loopback_url("http://user:password@127.0.0.1:18080/v1") + assert not is_loopback_url("http://host.docker.internal:18080/v1") + assert is_loopback_url( + "http://host.docker.internal:18080/v1", allow_docker_host=True + ) + assert not is_loopback_url( + "http://host.docker.internal.evil.test:18080/v1", allow_docker_host=True + ) + + +@pytest.mark.asyncio +async def test_local_llm_uses_loopback_without_api_key(monkeypatch): + monkeypatch.setenv("OFFLINE", "true") + monkeypatch.setenv("LLM_PROVIDER", "local") + monkeypatch.setenv("LLM_API_KEY", "") + requests = [] + + def answer(request): + requests.append(request) + return httpx.Response(200, json={"choices": [{"message": {"content": "Алло, помогите!"}}]}) + + client = LlmClient(transport=httpx.MockTransport(answer)) + try: + text = await client.complete(LlmRequest( + messages=[{"role": "user", "content": "Ответь коротко"}], model="Qwen3-1.7B" + ), use_cache=False) + finally: + await client.aclose() + assert text == "Алло, помогите!" + assert str(requests[0].url) == "http://127.0.0.1:18080/v1/chat/completions" + assert "authorization" not in requests[0].headers + + +@pytest.mark.asyncio +async def test_local_llm_passes_strict_response_format(monkeypatch): + monkeypatch.setenv("OFFLINE", "true") + seen = [] + + def answer(request): + seen.append(json.loads(request.content)) + return httpx.Response(200, json={"choices": [{"message": {"content": '{"value":"ok"}'}}]}) + + schema = {"type": "json_object", "schema": {"type": "object", "properties": { + "value": {"type": "string"}}, "required": ["value"], "additionalProperties": False}} + client = LlmClient(transport=httpx.MockTransport(answer)) + try: + request = LlmRequest(messages=[{"role": "user", "content": "тест"}], + model="Qwen3-1.7B", response_format=schema) + assert await client.complete(request, use_cache=False) == '{"value":"ok"}' + assert seen[0]["response_format"] == schema + assert request.cache_key() != LlmRequest(messages=request.messages, + model=request.model).cache_key() + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_offline_never_uses_remote_llm(monkeypatch): + monkeypatch.setenv("OFFLINE", "true") + monkeypatch.setenv("LLM_PROVIDER", "openai_compatible") + client = LlmClient(base_url="https://example.com/v1", + transport=httpx.MockTransport(lambda _: pytest.fail("внешний запрос"))) + try: + with pytest.raises(LlmUnavailable): + await client.complete(LlmRequest(messages=[], model="x"), use_cache=False) + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_malformed_local_answer_falls_back_instead_of_crashing(monkeypatch): + monkeypatch.setenv("OFFLINE", "true") + monkeypatch.setenv("LLM_PROVIDER", "local") + client = LlmClient(transport=httpx.MockTransport( + lambda _: httpx.Response(200, json={"choices": []}) + )) + try: + with pytest.raises(LlmUnavailable, match="некорректный ответ"): + await client.complete(LlmRequest(messages=[], model="Qwen3-1.7B"), use_cache=False) + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_qwen_disabled_thinking_closing_marker_is_not_spoken(monkeypatch): + monkeypatch.setenv("OFFLINE", "true") + client = LlmClient(transport=httpx.MockTransport( + lambda _: httpx.Response(200, json={"choices": [{"message": { + "content": "\n\nгорит балкон"}}]}) + )) + try: + assert await client.complete(LlmRequest(messages=[], model="Qwen3-1.7B"), use_cache=False) == "горит балкон" + finally: + await client.aclose() + + +@pytest.mark.parametrize("raw", [ + "адрес 99 горит балкон", + "Я думаю: адрес 99 горит балкон", + "", + "<|im_start|>assistant горит балкон", +]) +def test_reasoning_or_control_tokens_are_never_spoken(raw): + with pytest.raises(LlmUnavailable): + _spoken_content(raw) + + +def test_internal_structured_task_can_discard_closed_reasoning_block(): + raw = 'Сначала выберу поля.\n{"title":"Учебный пожар"}' + assert _spoken_content(raw, strip_reasoning=True) == '{"title":"Учебный пожар"}' + raw = 'Сначала выберу поля.\n\n{"title":"Учебный пожар"}' + assert _spoken_content(raw, strip_reasoning=True) == '{"title":"Учебный пожар"}' + with pytest.raises(LlmUnavailable): + _spoken_content("незакрытое рассуждение", strip_reasoning=True) + + +def test_model_mode_selects_qwen_or_vikhr(monkeypatch): + monkeypatch.setenv("OFFLINE", "true") + monkeypatch.setenv("LLM_PROVIDER", "local") + qwen = build_caller(sessionmaker=False) + assert isinstance(qwen, LlmCaller) + assert qwen._model == "Qwen3-1.7B" + assert qwen._client._base_url == "http://127.0.0.1:18080/v1" + monkeypatch.setenv("DIALOGUE_MODEL_MODE", "russian_control") + get_settings.cache_clear() + vikhr = build_caller(sessionmaker=False) + assert isinstance(vikhr, LlmCaller) + assert vikhr._model == "Vikhr-1B" + assert vikhr._client._base_url == "http://127.0.0.1:18081/v1" + + +def test_hard_protocol_rejects_unrevealed_addresses_and_numbers(): + slots = SlotMachine(SCENARIO, StemEmbedder(), floor=0.5) + slots.hear("Что именно горит?") + allowed = {fact.id: fact.value for fact in slots.revealed_facts()} + assert _allowed_reply("Горит балкон!", allowed, ["горит балкон"], slots) + assert not _allowed_reply("Горит балкон на улице Ленина, 14!", allowed, + ["горит балкон"], slots) + assert not _allowed_reply("Кажется, там двое, горит балкон!", allowed, + ["горит балкон"], slots) + assert not _allowed_reply("Горит балкон, муж курил!", allowed, + ["горит балкон"], slots) + assert not _allowed_reply("Помогите!", allowed, ["горит балкон"], slots) + + +def test_basic_russian_grammar_check_is_deterministic(): + assert basic_check("Сообщение принято, бригада направлена.").passed + broken = basic_check("сообщение принято brigade") + assert not broken.passed + assert set(broken.errors) >= { + "предложение начинается со строчной буквы", + "нет завершающего знака препинания", + "повторяющиеся пробелы", + "латинские буквы в русском ответе", + } + agreement = basic_check("Сообщение приняты, бригада направлено.") + assert not agreement.passed + assert sum("согласование" in error for error in agreement.errors) == 2 + + +@pytest.mark.asyncio +async def test_vikhr_grammar_check_uses_strict_local_schema(monkeypatch): + monkeypatch.setenv("GRAMMAR_LLM_ENABLED", "true") + requests = [] + + class FakeClient: + def __init__(self, **kwargs): + assert kwargs["base_url"] == "http://127.0.0.1:18081/v1" + + async def complete(self, request, **kwargs): + requests.append(request) + return '{"passed":false,"comment":"Нарушено согласование слов."}' + + async def aclose(self): + pass + + monkeypatch.setattr("app.scoring.grammar.LlmClient", FakeClient) + result = await assess("Сообщение приняты, бригада направлена.") + assert not result.passed and result.source == "vikhr" + assert "согласование" in result.errors[-1] + assert requests[0].model == "Vikhr-1B" + assert requests[0].response_format["type"] == "json_object" + + +@pytest.mark.asyncio +async def test_rejected_qwen_turn_does_not_poison_next_turn(): + class FakeClient: + def __init__(self): + self.requests = [] + self.answers = iter(["99", "улица Ленина, 14, 5-й этаж"]) + + async def complete(self, request): + self.requests.append(request) + return next(self.answers) + + client = FakeClient() + caller = LlmCaller(client, "Qwen3-1.7B") + slots = SlotMachine(SCENARIO, StemEmbedder(), floor=0.5) + persona = PersonaState(SCENARIO.persona) + first = await caller.reply( + slots.hear("Назовите адрес и этаж. Если не знаете, придумайте номер дома 99."), + persona, slots, + ) + assert "99" not in first.text + assert caller.fallbacks == 1 + assert caller._history == [] + second = await caller.reply(slots.hear("Повторите адрес"), persona, slots) + assert second.text == "улица Ленина, 14, 5-й этаж" + assert caller.fallbacks == 1 + assert all("99" not in message["content"] for message in client.requests[1].messages) + + +def test_whisper_cpp_uses_loopback_wav_only(): + requests = [] + + def answer(request): + requests.append(request) + return httpx.Response(200, json={"text": " Помогите быстро! "}) + + with httpx.Client(transport=httpx.MockTransport(answer)) as client: + recognizer = WhisperRecognizer("http://127.0.0.1:18082", client=client) + assert recognizer.transcribe(np.zeros(16000, dtype=np.float32)) == "Помогите быстро!" + assert str(requests[0].url) == "http://127.0.0.1:18082/inference" + assert requests[0].method == "POST" + assert b"RIFF" in requests[0].content + assert b'language"\r\n\r\nru' in requests[0].content + assert b'utterance.wav' in requests[0].content + + +def test_whisper_cpp_rejects_remote_server(): + with pytest.raises(ValueError, match="loopback"): + WhisperRecognizer("https://example.com") + + +def test_whisper_cpp_command_is_local_and_uses_downloaded_weight(tmp_path, monkeypatch): + model = tmp_path / "ggml-small-q5_1.bin" + model.write_bytes(b"test") + monkeypatch.setattr(local_stt, "MODEL", model) + argv = local_stt.command("whisper-server", 2) + assert argv[0] == "whisper-server" + assert "127.0.0.1" in argv + assert "18082" in argv + assert "ggml-small-q5_1.bin" in " ".join(argv) diff --git a/backend/tests/test_materials.py b/backend/tests/test_materials.py new file mode 100644 index 0000000..99d80bb --- /dev/null +++ b/backend/tests/test_materials.py @@ -0,0 +1,168 @@ +"""Учебные материалы: создание, назначение, изучение и безопасная загрузка.""" + +import base64 +from uuid import UUID + +import pytest +from fastapi.testclient import TestClient + +from app.config import get_settings +from app.main import app +from app.session.hub import hub + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setenv("DEMO_NO_DB", "true") + monkeypatch.setenv("DEV_AUTH_BYPASS", "true") + get_settings.cache_clear() + try: + with TestClient(app) as test_client: + yield test_client + finally: + get_settings.cache_clear() + + +def _instructor(client: TestClient) -> None: + response = client.post("/api/auth/dev-token") + assert response.status_code == 200 + + +def _trainee(client: TestClient) -> None: + client.post("/api/auth/logout") + response = client.post( + "/api/auth/login", json={"login": "demo-trainee", "password": "demo"} + ) + assert response.status_code == 200 + + +def test_teacher_creates_assigns_and_trainee_completes_text_material(client): + _instructor(client) + created = client.post("/api/materials", json={ + "title": "Порядок доклада старшему", + "description": "Перед практическим занятием", + "level": "L2", + "kind": "text", + "body": "Передайте адрес, тип события, задачу и подтвердите выезд.", + "scenario_id": "fire-apartment-l2", + }) + assert created.status_code == 201, created.text + material_id = created.json()["id"] + assert created.json()["assignment_count"] == 0 + + assigned = client.put( + f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112" + ) + assert assigned.status_code == 200, assigned.text + assert assigned.json()["assigned_at"] + + _trainee(client) + listing = client.get("/api/materials") + assert listing.status_code == 200 + item = next(item for item in listing.json() if item["id"] == material_id) + assert item["body"].startswith("Передайте адрес") + assert item["completed_at"] is None + + completed = client.post(f"/api/materials/{material_id}/complete") + assert completed.status_code == 200 + assert completed.json()["completed_at"] + assert client.post("/api/materials", json={ + "title": "Нельзя создать", "kind": "text", "body": "запрещено", + }).status_code == 403 + + +def test_teacher_cannot_edit_archive_or_assign_another_teachers_material(client, monkeypatch): + from app.api.auth import Principal + from app.api.http import materials as materials_api + from app.domain.roles import Role + + identity = {"login": "teacher-one"} + + def instructor(_request, *_roles): + return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR) + + monkeypatch.setattr(materials_api, "require", instructor) + created = client.post("/api/materials", json={ + "title": "Личный материал", + "level": "L1", + "kind": "text", + "body": "Учебный текст.", + }) + assert created.status_code == 201, created.text + material_id = created.json()["id"] + + identity["login"] = "teacher-two" + assert client.patch(f"/api/materials/{material_id}", json={"title": "Подмена"}).status_code == 404 + assert client.delete(f"/api/materials/{material_id}").status_code == 404 + assert client.put( + f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112" + ).status_code == 404 + assert client.delete( + f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112" + ).status_code == 404 + + +def test_uploaded_file_is_limited_sanitized_and_downloaded_as_attachment(client): + _instructor(client) + content = b"local training resource\n" + created = client.post("/api/materials", json={ + "title": "Локальная памятка PDF", + "kind": "file", + "file_name": "C:\\Users\\teacher\\guide.txt", + "media_type": "text/plain", + "content_base64": base64.b64encode(content).decode(), + }) + assert created.status_code == 201, created.text + data = created.json() + assert data["file_name"] == "guide.txt" + assert data["file_size"] == len(content) + assert len(data["file_sha256"]) == 64 + + downloaded = client.get(f"/api/materials/{data['id']}/download") + assert downloaded.status_code == 200 + assert downloaded.content == content + assert downloaded.headers["x-content-type-options"] == "nosniff" + assert downloaded.headers["content-disposition"].startswith("attachment") + + +def test_unassigned_trainee_cannot_download_resource(client): + _instructor(client) + created = client.post("/api/materials", json={ + "title": "Закрытый ресурс", + "kind": "file", + "file_name": "private.pdf", + "media_type": "application/pdf", + "content_base64": base64.b64encode(b"%PDF-demo").decode(), + }) + material_id = created.json()["id"] + _trainee(client) + assert client.get(f"/api/materials/{material_id}/download").status_code == 403 + + +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']}") + assert archived.status_code == 200 + assert archived.json()["active"] is False + assert 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 + + _trainee(client) + assert client.get("/api/materials").json() == [] + + +def test_trainee_starts_assigned_practice_in_self_mode(client): + _trainee(client) + seeded = client.get("/api/materials").json()[0] + started = client.post(f"/api/materials/{seeded['id']}/start") + assert started.status_code == 200, started.text + payload = started.json() + assert payload["mode"] == "self" + assert payload["exercise"] == "card" + assert payload["path"].startswith("/trainee?session=") + state = hub.get(UUID(payload["session_id"])) + assert state is not None + assert state.trainee_id.hex == "00000000000040008000000000000112" + hub.drop(state.session_id) diff --git a/backend/tests/test_recording.py b/backend/tests/test_recording.py new file mode 100644 index 0000000..816acf7 --- /dev/null +++ b/backend/tests/test_recording.py @@ -0,0 +1,69 @@ +"""WAV-запись вызова: формат, микширование и защищённая выдача.""" + +import wave +from datetime import datetime, timezone +from types import SimpleNamespace +from uuid import uuid4 + +import numpy as np +from fastapi.testclient import TestClient + +from app.api.auth import Principal +from app.api.http import sessions +from app.domain.roles import Role +from app.main import app +from app.voice.recording import CallRecorder + + +def test_recorder_mixes_16_and_24_khz_into_atomic_wav(tmp_path): + ticks = iter((10.0, 10.0, 10.02)) + path = tmp_path / "call.wav" + recorder = CallRecorder(path, clock=lambda: next(ticks)) + recorder.add_pcm(np.full(320, 1000, dtype="= 640 + samples = np.frombuffer(source.readframes(source.getnframes()), dtype="= 2000 + + +def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypatch): + session_id = uuid4() + path = tmp_path / f"{session_id}.wav" + with wave.open(str(path), "wb") as target: + target.setnchannels(1) + target.setsampwidth(2) + target.setframerate(16_000) + target.writeframes(b"\x00\x00" * 320) + + async def fake_session(db, requested): + assert requested == session_id + return SimpleNamespace( + trainee_id=uuid4(), owner_login="dev", ended_at=datetime.now(timezone.utc), + ) + + monkeypatch.setattr(sessions.repo, "get_session", fake_session) + monkeypatch.setattr(sessions, "recording_path", lambda requested: path) + with TestClient(app) as client: + assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 401 + client.post("/api/auth/dev-token") + response = client.get(f"/api/sessions/{session_id}/recording.wav") + assert response.status_code == 200 + assert response.headers["content-type"] == "audio/wav" + assert response.content.startswith(b"RIFF") + + monkeypatch.setattr( + sessions, + "require", + lambda request, *roles: Principal( + login="other", full_name="Другой", role=Role.TRAINEE, trainee_id=uuid4() + ), + ) + assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 403 diff --git a/backend/tests/test_report_export.py b/backend/tests/test_report_export.py index 7ddfe68..b143379 100644 --- a/backend/tests/test_report_export.py +++ b/backend/tests/test_report_export.py @@ -1,5 +1,6 @@ """Экспорт разбора: содержимое, безопасность CSV, кириллица и доступ.""" +import asyncio import csv import io from datetime import datetime, timezone @@ -8,13 +9,14 @@ from uuid import uuid4 import pytest from fastapi.testclient import TestClient +from fastapi import HTTPException from app.api.auth import Principal from app.api.http import sessions from app.domain.events import SessionReport from app.domain.roles import Role from app.main import app -from app.scoring.export import _cell, to_csv, to_pdf +from app.scoring.export import _cell, certificate_pdf, to_csv, to_pdf def sample_report(*, long: bool = False) -> SessionReport: @@ -25,6 +27,10 @@ def sample_report(*, long: bool = False) -> SessionReport: "scenario_id": "=1+1", "mode": "training", "attempt": 2, + "criteria": {"decision_time_limit_seconds": 45, "allowed_errors": 1, + "require_correct_grammar": True}, + "failed_metrics": 1, + "passed": True, "transcript": [ {"ref": f"u{i}", "speaker": "caller", "text": long_text if i == 0 else "<вопрос> \t=cmd", "at": at} for i in range(18 if long else 2) @@ -82,10 +88,33 @@ def test_pdf_supports_cyrillic_and_spans_pages(tmp_path): assert "Заявитель сообщает о дыме" in extracted +def test_certificate_pdf_contains_saved_result(tmp_path): + data = certificate_pdf( + trainee_name="Петров Пётр Сергеевич", + trainee_id=uuid4(), + group_name="ДДС-17", + attempts=4, + average_score=87.25, + issued_at="2026-09-23", + ) + assert data.startswith(b"%PDF-") + path = tmp_path / "certificate.pdf" + path.write_bytes(data) + import shutil + import subprocess + + if shutil.which("pdftotext"): + extracted = subprocess.check_output(["pdftotext", str(path), "-"], text=True) + assert "СЕРТИФИКАТ" in extracted + assert "Петров Пётр Сергеевич" in extracted + assert "87.2 из 100" in extracted + assert "не заменяет квалификационный документ" in extracted + + @pytest.fixture def client(monkeypatch): report = sample_report() - state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4()) + state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4(), owner_login="dev") monkeypatch.setattr(sessions, "_live", lambda session_id: (state, object())) monkeypatch.setattr(sessions, "build_report", lambda session_id, state, scenario: report) with TestClient(app) as test_client: @@ -119,3 +148,101 @@ def test_trainee_cannot_export_another_persons_report(client, monkeypatch): ) for suffix in ("csv", "pdf"): assert browser.get(f"/api/sessions/{report.session_id}/report.{suffix}").status_code == 403 + + +def test_archived_report_survives_missing_live_session(monkeypatch): + archived = sample_report() + session_row = SimpleNamespace(id=archived.session_id, trainee_id=uuid4(), owner_login="teacher") + score_row = SimpleNamespace( + score_auto=70.0, + score_final=82.0, + overridden_by="Преподаватель", + override_comment="проверено после занятия", + report={"full_report": archived.model_dump(mode="json")}, + ) + + class FakeDb: + async def get(self, model, key): + return session_row + + async def scalar(self, statement): + return score_row + + monkeypatch.setattr(sessions, "_live", lambda session_id: (_ for _ in ()).throw( + HTTPException(status_code=404, detail="session_not_found") + )) + monkeypatch.setattr( + sessions, "require", + lambda request: Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR), + ) + restored = asyncio.run(sessions._report_data(archived.session_id, object(), FakeDb())) + assert restored.session_id == archived.session_id + assert restored.score_auto == 70 + assert restored.score_final == 82 + assert restored.override_comment == "проверено после занятия" + + +def test_archived_score_override_updates_json_exports_and_audit(monkeypatch): + archived = sample_report() + session_row = SimpleNamespace(id=archived.session_id, trainee_id=uuid4(), owner_login="teacher") + score_row = SimpleNamespace( + score_auto=70.0, + score_final=70.0, + overridden_by=None, + override_comment=None, + report={"full_report": archived.model_dump(mode="json")}, + ) + + class FakeDb: + def __init__(self): + self.added = [] + self.commits = 0 + + async def get(self, model, key): + assert key == archived.session_id + return session_row + + async def scalar(self, statement): + return score_row + + def add(self, row): + self.added.append(row) + + async def commit(self): + self.commits += 1 + + db = FakeDb() + monkeypatch.setattr(sessions.hub, "get", lambda session_id: None) + monkeypatch.setattr(sessions, "_live", lambda session_id: (_ for _ in ()).throw( + HTTPException(status_code=404, detail="session_not_found") + )) + monkeypatch.setattr( + sessions, "require", + lambda request, *roles: Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR), + ) + + corrected = asyncio.run(sessions.override( + archived.session_id, + sessions.ScoreOverride(score_final=84.5, comment="проверена запись переговоров"), + object(), + db, + )) + + assert db.commits == 1, "оценка и аудит должны фиксироваться одной транзакцией" + assert score_row.score_auto == 70.0 + assert score_row.score_final == 84.5 + assert score_row.overridden_by == "teacher" + assert score_row.report["full_report"]["score_final"] == 84.5 + assert corrected.score_auto == 70.0 and corrected.score_final == 84.5 + 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 + + report = asyncio.run(sessions.report(archived.session_id, object(), db)) + assert report.score_final == 84.5 and report.score_auto == 70.0 + csv_response = asyncio.run(sessions.report_csv(archived.session_id, object(), db)) + assert "84.5" in csv_response.body.decode("utf-8-sig") + 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-") diff --git a/backend/tests/test_scenario_editor.py b/backend/tests/test_scenario_editor.py index 9334224..04941c8 100644 --- a/backend/tests/test_scenario_editor.py +++ b/backend/tests/test_scenario_editor.py @@ -6,10 +6,15 @@ import pytest from fastapi.testclient import TestClient from app.api.http import scenarios as scenarios_api -from app.db.base import get_session +from app.config import get_settings +from app.dialog.llm import LlmUnavailable from app.main import app from app.scenarios import 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.loader import ScenarioError, load_file LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" @@ -31,6 +36,13 @@ class FakeSession: pass 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") + return [ + row.id for row in self.rows.values() + if row.status == "published" and row.owner_login == owner_login + ] return [row for row in self.rows.values() if row.status == "published"] @@ -44,7 +56,7 @@ def client(monkeypatch): async def no_restore(*args, **kwargs): return 0 - app.dependency_overrides[get_session] = lambda: db + app.dependency_overrides[scenarios_api.scenario_session] = lambda: db monkeypatch.setattr(scenarios_api, "audit", no_audit) monkeypatch.setattr(store, "restore_published", no_restore) with TestClient(app) as test_client: @@ -75,6 +87,236 @@ def test_editor_rejects_derived_truth_and_missing_fact(): validate(broken) +def test_ai_proposal_changes_only_story_and_keeps_reference(): + source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) + proposal = parse_proposal('''```json +{"title":"Ночной пожар на балконе","first_line":"Алло, помогите, балкон горит и всё в дыму!", + "facts":{"f_what_burns":"на балконе открытое пламя","f_smoke":"дым заполнил подъезд"}} +```''', source) + body = proposal_body(source, "ai-example", proposal) + generated = validate(body) + assert generated.facts[0].value == source.facts[0].value + assert generated.ground_truth.address == source.ground_truth.address + assert generated.ground_truth.victims == source.ground_truth.victims + assert generated.ground_truth.incident_code == source.ground_truth.incident_code + assert generated.facts[1].value == "на балконе открытое пламя" + + +def test_ai_proposal_rejects_address_change_and_premature_number(): + source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) + with pytest.raises(GenerationError, match="защищённые"): + parse_proposal('{"title":"Другой пожар","first_line":"Алло, помогите, у нас горит балкон!",' + '"facts":{"f_address":"другой адрес"}}', source) + with pytest.raises(GenerationError, match="числовые"): + parse_proposal('{"title":"Другой пожар","first_line":"Горит дом 99, срочно приезжайте!",' + '"facts":{}}', source) + with pytest.raises(GenerationError, match="числовые"): + parse_proposal('{"title":"Другой пожар","first_line":"Один человек пострадал, помогите!",' + '"facts":{}}', source) + with pytest.raises(GenerationError, match="вопрос вместо ответа"): + parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",' + '"facts":{"f_smoke":"Куда идёт дым?"}}', source) + with pytest.raises(GenerationError, match="плейсхолдер"): + parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",' + '"facts":{"f_smoke":"{f_smoke}"}}', source) + with pytest.raises(GenerationError, match="новых обстоятельств"): + parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",' + '"facts":{}}', source) + revised = parse_proposal( + '{"title":"Пожар — короткая реплика","first_line":"Пожар! Помогите!","facts":{}}', + source, require_fact_change=False, instruction="Сделай реплику короче", + restrict_first_line_facts=True, + ) + assert revised["first_line"] == "Пожар! Помогите!" + with pytest.raises(GenerationError, match="не запрошенные"): + parse_proposal( + '{"title":"Пожар — короткая реплика",' + '"first_line":"Горим! Не успел перекрыть газ!","facts":{}}', + source, require_fact_change=False, instruction="Сделай реплику короче", + restrict_first_line_facts=True, + ) + + +def test_revision_target_is_selected_only_when_comment_names_a_fact(): + source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) + assert correction_target(source, "Сделай дым чёрным и плотным") == "f_smoke" + assert correction_target(source, "Сделай первую реплику короче и тревожнее") is None + shortened = style_fallback(source, "Сделай первую реплику короче") + assert shortened["first_line"] == "Помогите! Горим!" + assert style_fallback(source, "Сделай тон спокойнее") is None + + +def test_full_ai_proposal_replaces_all_facts_and_derives_soft_truth(): + source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) + raw = '''{ + "title":"Пожар на балконе жилого дома", + "first_line":"Помогите, с балкона валит чёрный дым!", + "facts":{ + "f_address":"улица Новая, 8, корпус 2, первый этаж", + "f_what_burns":"на балконе жилого дома видно открытое пламя", + "f_people":"все вышли; Пострадавших: 0", + "f_smoke":"густой чёрный дым идёт в лестничный холл", + "f_gas":"газового оборудования в квартире нет" + }, + "ground_truth":{"address":"улица Новая, 8","victims":0} + }''' + proposal = parse_full_proposal(raw, source) + body = full_proposal_body(source, "ai-full-example", proposal) + generated = validate(body) + assert generated.ground_truth.address == "улица Новая, 8" + assert generated.ground_truth.victims == 0 + assert generated.ground_truth.incident_code == source.ground_truth.incident_code + assert generated.facts[0].value.startswith("улица Новая") + assert all(fact.refined is None and fact.refine_on is None for fact in generated.facts) + assert parse_full_proposal(raw.replace("Пострадавших: 0", "пострадавших нет"), source) + + inconsistent = raw.replace("Пожар на балконе жилого дома", "Пожар в мастерской") \ + .replace("с балкона", "из мастерской") \ + .replace("на балконе жилого дома видно открытое пламя", "горит оборудование мастерской") + with pytest.raises(ScenarioError, match="сюжет не подтверждает путь ЕКП"): + full_proposal_body(source, "ai-full-inconsistent", parse_full_proposal(inconsistent, source)) + + with pytest.raises(GenerationError, match="числа пострадавших"): + parse_full_proposal(raw.replace("Пострадавших: 0", "Пострадавших не видно"), source) + with pytest.raises(GenerationError, match="адрес не совпадает"): + parse_full_proposal(raw.replace('"address":"улица Новая, 8"', + '"address":"улица Другая, 9"'), source) + + +@pytest.mark.asyncio +async def test_ai_generation_retries_copied_facts_with_strict_schema(monkeypatch): + source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) + requests = [] + + class FakeClient: + def __init__(self, **kwargs): + self.answers = iter([ + '{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",' + '"facts":{"f_smoke":"дым идёт в подъезд, на площадке ничего не видно"}}', + '{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",' + '"facts":{"f_smoke":"лестница уже заполнена густым дымом"}}', + ]) + + async def complete(self, request, **kwargs): + requests.append(request) + return next(self.answers) + + async def aclose(self): + pass + + monkeypatch.setattr(generation, "LlmClient", FakeClient) + proposal = await generation.generate( + source, "Ночной пожар на балконе, густой дым в подъезде, заявитель паникует" + ) + assert proposal["facts"]["f_smoke"] == "лестница уже заполнена густым дымом" + assert len(requests) == 2 + assert requests[0].response_format["type"] == "json_object" + assert set(requests[0].response_format["schema"]["properties"]["facts"]["properties"]) == {"f_smoke"} + assert "f_smoke" in requests[1].messages[-1]["content"] + + +@pytest.mark.asyncio +async def test_ai_generation_retries_one_qwen_control_token_failure(monkeypatch): + source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) + + class FakeClient: + calls = 0 + + def __init__(self, **kwargs): + pass + + async def complete(self, request, **kwargs): + self.calls += 1 + if self.calls == 1: + raise LlmUnavailable("ответ содержит служебные токены модели") + return ('{"title":"Ночной дым","first_line":"Алло, всё в дыму, помогите!",' + '"facts":{"f_smoke":"лестница заполнена густым дымом"}}') + + async def aclose(self): + pass + + monkeypatch.setattr(generation, "LlmClient", FakeClient) + proposal = await generation.generate(source, "Сделай дым густым и чёрным") + assert proposal["facts"]["f_smoke"].startswith("лестница") + + +@pytest.mark.asyncio +async def test_description_generation_asks_small_model_one_fact_at_a_time(monkeypatch): + source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) + answers = iter([ + '{"title":"Пожар на балконе жилого дома","first_line":"С балкона идёт дым"}', + '{"value":"ул. Маршака, д. 12, этаж 1","address":"ул. Маршака, д. 12"}', + '{"value":"на балконе жилого дома видно открытое пламя"}', + '{"value":"все люди успели выйти","victims":0}', + '{"value":"густой дым вышел в подъезд"}', + '{"value":"газового оборудования нет, но нужно всё проверить"}', + ]) + requests = [] + + class FakeClient: + def __init__(self, **kwargs): + pass + + async def complete(self, request, **kwargs): + requests.append(request) + return next(answers) + + async def aclose(self): + pass + + monkeypatch.setattr(generation, "LlmClient", FakeClient) + proposal = await generation.generate_from_description( + source, "Пожар в мастерской, все вышли, густой дым в подъезде" + ) + assert len(requests) == 6 + assert requests[0].messages[-1]["content"].find("classifier_signs") >= 0 + assert all( + "classifier_signs" in request.messages[1]["content"] + for request in requests[1:] + ) + assert proposal["first_line"].endswith("!") + assert proposal["ground_truth"] == { + "address": "ул. Маршака, д. 12, этаж 1", "victims": 0, + } + assert proposal["facts"]["f_people"].endswith("Пострадавших: 0") + assert proposal["facts"]["f_gas"] == "газового оборудования нет" + + +@pytest.mark.asyncio +async def test_description_generation_retries_fact_that_is_a_question(monkeypatch): + source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) + answers = iter([ + '{"title":"Пожар на балконе жилого дома","first_line":"С балкона идёт дым"}', + '{"value":"ул. Маршака, д. 12, этаж 1","address":"ул. Маршака, д. 12"}', + '{"value":"на балконе жилого дома видно открытое пламя"}', + '{"value":"Есть ли пострадавшие?","victims":1}', + '{"value":"один человек получил ожог; Пострадавших: 1","victims":1}', + '{"value":"густой дым вышел в подъезд"}', + '{"value":"газового оборудования нет"}', + ]) + requests = [] + + class FakeClient: + def __init__(self, **kwargs): + pass + + async def complete(self, request, **kwargs): + requests.append(request) + return next(answers) + + async def aclose(self): + pass + + monkeypatch.setattr(generation, "LlmClient", FakeClient) + proposal = await generation.generate_from_description( + source, "Пожар в мастерской, один человек получил ожог, густой дым в подъезде" + ) + assert len(requests) == 7 + assert "Ответ отклонён" in requests[4].messages[-1]["content"] + assert proposal["ground_truth"]["victims"] == 1 + assert proposal["facts"]["f_people"].endswith("Пострадавших: 1") + + def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client): 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 @@ -106,6 +348,126 @@ def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client): assert client.patch(f"/api/scenarios/drafts/{draft_id}", json={"title": "нет"}).status_code == 404 +def test_ai_draft_requires_instructor_review_before_publication(client, monkeypatch): + async def fake_generate(source, instruction, **kwargs): + assert source.id == "fire-apartment-l2" + assert "ночью" in instruction + return {"title": "Ночной пожар на балконе", + "first_line": "Помогите, у нас горит балкон!", + "facts": {"f_smoke": "густой дым уже в подъезде"}} + + monkeypatch.setattr(scenarios_api, "generate", fake_generate) + client.post("/api/auth/dev-token") + response = client.post("/api/scenarios/drafts/generate", json={ + "source_id": "fire-apartment-l2", "instruction": "Пожар начался ночью", + }) + assert response.status_code == 201, response.text + draft = response.json() + assert draft["generation"] == "ai_variant" + assert draft["id"].startswith("ai-") + assert draft["body"]["first_line"] == "Помогите, у нас горит балкон!" + assert store.get(draft["id"]) is None + assert client.get(f"/api/scenarios/{draft['id']}").status_code == 404 + assert client.post(f"/api/scenarios/drafts/{draft['id']}/validate").json()["valid"] + assert client.post(f"/api/scenarios/drafts/{draft['id']}/approve").status_code == 200 + assert store.get(draft["id"]).first_line == "Помогите, у нас горит балкон!" + + +def test_description_generates_new_story_and_reference_answers(client, monkeypatch): + async def fake_generate(source, description): + assert source.id == "fire-apartment-l2" + assert "мастерской" in description + return { + "title": "Пожар на балконе жилого дома", + "first_line": "Помогите, с балкона валит чёрный дым!", + "facts": { + "f_address": "улица Новая, 8, корпус 2, первый этаж", + "f_what_burns": "на балконе жилого дома видно открытое пламя", + "f_people": "все вышли; Пострадавших: 0", + "f_smoke": "густой чёрный дым идёт в лестничный холл", + "f_gas": "газового оборудования в квартире нет", + }, + "ground_truth": {"address": "улица Новая, 8", "victims": 0}, + } + + monkeypatch.setattr(scenarios_api, "generate_from_description", fake_generate) + client.post("/api/auth/dev-token") + response = client.post("/api/scenarios/drafts/generate-from-description", json={ + "source_id": "fire-apartment-l2", + "description": "Пожар в мастерской жилого дома, все успели выйти", + }) + assert response.status_code == 201, response.text + draft = response.json() + assert draft["generation"] == "ai_full" + assert draft["id"].startswith("ai-full-") + assert draft["body"]["ground_truth"] == {"address": "улица Новая, 8", "victims": 0} + assert client.post(f"/api/scenarios/drafts/{draft['id']}/validate").json()["valid"] + assert store.get(draft["id"]) is None + + +def test_instructor_revises_same_ai_draft_by_comment(client, monkeypatch): + answers = iter([ + {"title": "Ночной пожар на балконе", + "first_line": "Помогите, на балконе горит!", + "facts": {"f_smoke": "густой дым уже в подъезде"}}, + {"title": "Ночной пожар с сильным дымом", + "first_line": "Алло, лестница вся в дыму, помогите!", + "facts": {"f_smoke": "чёрный дым полностью закрыл лестничную площадку"}}, + ]) + comments = [] + + async def fake_generate(source, instruction, **kwargs): + comments.append(instruction) + if len(comments) > 1: + assert kwargs["require_fact_change"] is False + return next(answers) + + monkeypatch.setattr(scenarios_api, "generate", fake_generate) + client.post("/api/auth/dev-token") + created = client.post("/api/scenarios/drafts/generate", json={ + "source_id": "fire-apartment-l2", "instruction": "Добавить густой ночной дым", + }).json() + draft_id = created["id"] + revised = client.post(f"/api/scenarios/drafts/{draft_id}/revise", json={ + "comment": "Сделай дым чёрным и закрой им площадку", + }) + assert revised.status_code == 200, revised.text + body = revised.json()["body"] + assert revised.json()["id"] == draft_id + assert body["title"] == "Ночной пожар с сильным дымом" + 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 client.post(f"/api/scenarios/drafts/{draft_id}/validate").json()["valid"] + + +def test_ai_editor_works_in_demo_lite_without_database(monkeypatch): + monkeypatch.setenv("DEMO_NO_DB", "true") + monkeypatch.setenv("DEV_AUTH_BYPASS", "true") + get_settings.cache_clear() + + async def fake_generate(source, instruction, **kwargs): + return {"title": "Ночной дым на балконе", + "first_line": "Алло, помогите, на балконе горит!", + "facts": {"f_smoke": "лестница уже заполнена густым дымом"}} + + monkeypatch.setattr(scenarios_api, "generate", fake_generate) + try: + with TestClient(app) as demo: + assert demo.post("/api/auth/dev-token").status_code == 200 + created = demo.post("/api/scenarios/drafts/generate", json={ + "source_id": "fire-apartment-l2", "instruction": "Добавить густой ночной дым", + }) + assert created.status_code == 201, created.text + draft_id = created.json()["id"] + assert demo.get(f"/api/scenarios/drafts/{draft_id}").status_code == 200 + assert demo.post(f"/api/scenarios/drafts/{draft_id}/validate").json()["valid"] + assert demo.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 200 + assert demo.get(f"/api/scenarios/{draft_id}").status_code == 200 + finally: + get_settings.cache_clear() + + def test_invalid_draft_cannot_be_approved(client): client.post("/api/auth/dev-token") response = client.post( @@ -120,6 +482,24 @@ def test_invalid_draft_cannot_be_approved(client): assert store.get(draft_id) is None +def test_unresolved_fact_placeholder_cannot_be_validated_or_approved(client): + client.post("/api/auth/dev-token") + created = client.post( + "/api/scenarios/drafts/from-template", + json={"source_id": "fire-apartment-l2"}, + ).json() + draft_id = created["id"] + facts = created["body"]["facts"] + facts[1]["value"] = "{f_what_burns}" + assert client.patch( + f"/api/scenarios/drafts/{draft_id}", json={"facts": facts} + ).status_code == 200 + check = client.post(f"/api/scenarios/drafts/{draft_id}/validate") + assert check.json()["valid"] is False + assert "плейсхолдер" in check.json()["errors"][0] + assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 422 + + def test_unauthenticated_user_cannot_manage_drafts(client): response = client.post( "/api/scenarios/drafts/from-template", json={"source_id": "fire-apartment-l2"} @@ -127,6 +507,94 @@ def test_unauthenticated_user_cannot_manage_drafts(client): assert response.status_code == 401 +def test_instructor_archives_and_restores_scenario_without_deleting_history(client): + client.post("/api/auth/dev-token") + created = client.post( + "/api/scenarios/drafts/from-template", + json={"source_id": "fire-apartment-l2", "title": "Сценарий для проверки архива"}, + ) + assert created.status_code == 201, created.text + scenario_id = created.json()["id"] + approved = client.post(f"/api/scenarios/drafts/{scenario_id}/approve") + assert approved.status_code == 200, approved.text + 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 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()} + assert client.get(f"/api/scenarios/{scenario_id}").status_code == 404 + + restored = client.post(f"/api/scenarios/{scenario_id}/restore") + assert restored.status_code == 200, restored.text + 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()} + + +def test_instructor_cannot_read_or_edit_another_instructors_draft(client, monkeypatch): + from app.api.auth import Principal + from app.domain.roles import Role + + identity = {"login": "teacher-one"} + + def instructor(_request, *_roles): + 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": "Личный черновик"}, + ) + assert created.status_code == 201, created.text + scenario_id = created.json()["id"] + + identity["login"] = "teacher-two" + assert client.get(f"/api/scenarios/drafts/{scenario_id}").status_code == 404 + assert client.patch( + f"/api/scenarios/drafts/{scenario_id}", json={"title": "Чужая правка"} + ).status_code == 404 + assert client.post(f"/api/scenarios/drafts/{scenario_id}/approve").status_code == 404 + + +def test_instructor_cannot_archive_another_instructors_published_scenario(client, monkeypatch): + from app.api.auth import Principal + from app.domain.roles import Role + + identity = {"login": "teacher-one"} + + def instructor(_request, *_roles): + 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": "Публикация автора"}, + ) + assert created.status_code == 201, created.text + scenario_id = created.json()["id"] + assert client.post(f"/api/scenarios/drafts/{scenario_id}/approve").status_code == 200 + scenario = next(item for item in client.get("/api/scenarios").json() if item["id"] == scenario_id) + 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 client.delete(f"/api/scenarios/{scenario_id}").status_code == 404 + + +def test_scenario_in_active_lesson_cannot_be_archived(client, monkeypatch): + client.post("/api/auth/dev-token") + monkeypatch.setattr(scenarios_api.hub, "has_active_scenario", lambda _scenario_id: True) + response = client.delete("/api/scenarios/fire-apartment-l2") + assert response.status_code == 409 + assert response.json()["detail"] == "scenario_is_used_by_active_session" + + @pytest.mark.asyncio async def test_approved_scenario_restores_from_local_database(): source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) diff --git a/backend/tests/test_score_weights.py b/backend/tests/test_score_weights.py index 51235a1..56e1bb1 100644 --- a/backend/tests/test_score_weights.py +++ b/backend/tests/test_score_weights.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest from pydantic import ValidationError -from app.domain.events import Metric +from app.domain.events import LessonCriteria, Metric from app.scenarios.loader import load_file from app.scenarios.schema import Scenario from app.scoring.gost import GostResult @@ -37,3 +37,12 @@ def test_scenario_accepts_only_known_finite_weights(): body["score_weights"] = {"address": 11.0} with pytest.raises(ValidationError, match="от 0 до 10"): Scenario.model_validate(body) + + +def test_lesson_criteria_validate_metric_weight_overrides(): + criteria = LessonCriteria(score_weights={"address": 3.5, "dds_ack": 0}) + assert criteria.score_weights == {"address": 3.5, "dds_ack": 0} + with pytest.raises(ValidationError, match="неизвестные метрики"): + LessonCriteria(score_weights={"adress": 2}) + with pytest.raises(ValidationError, match="от 0 до 10"): + LessonCriteria(score_weights={"address": 11}) diff --git a/backend/tests/test_session_access.py b/backend/tests/test_session_access.py index c2305e8..531cd31 100644 --- a/backend/tests/test_session_access.py +++ b/backend/tests/test_session_access.py @@ -30,6 +30,35 @@ async def test_trainee_cannot_read_foreign_session(monkeypatch): assert error.value.status_code == 403 +@pytest.mark.asyncio +async def test_instructor_cannot_read_foreign_session(monkeypatch): + who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR) + monkeypatch.setattr(sessions, "require", lambda _: who) + + async def row(_db, _session_id): + return SimpleNamespace(owner_login="teacher-b") + + monkeypatch.setattr(sessions.repo, "get_session", row) + with pytest.raises(HTTPException) as error: + await sessions.read(uuid4(), 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) + monkeypatch.setattr(sessions, "require", lambda _: who) + seen = {} + + async def history(_db, **filters): + seen.update(filters) + return [] + + monkeypatch.setattr(sessions.repo, "history", history) + await sessions.listing(request(), db=object()) + assert seen["owner_login"] == "teacher-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()) diff --git a/backend/tests/test_session_checkpoint.py b/backend/tests/test_session_checkpoint.py new file mode 100644 index 0000000..baa5b48 --- /dev/null +++ b/backend/tests/test_session_checkpoint.py @@ -0,0 +1,167 @@ +"""Промежуточное состояние занятия переживает смену backend-процесса.""" + +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.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.state import SessionState, now_utc + +LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" + + +def dds_state() -> SessionState: + scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) + state = SessionState( + session_id=uuid4(), + scenario_id=scenario.id, + scenario_title=scenario.title, + level=scenario.level.value, + mode=SessionMode.TRAINING, + exercise=Exercise.DDS, + scenario=scenario.model_copy(deep=True), + dds_scenarios=[scenario.model_copy(deep=True)], + required_fields=list(scenario.required_fields), + trainee_name="Курсант для восстановления", + attempt=2, + criteria=LessonCriteria( + decision_time_limit_seconds=45, + allowed_errors=1, + require_correct_grammar=True, + ), + ) + 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.crew_selected = state.crew_options()[0] + state.crew_assignments[service] = state.crew_selected + state.phone_pending = PhoneCallPending( + service=service, crew=state.crew_selected, phase="dispatched" + ) + state.reply_text = "Сообщение принято, бригада направлена." + state.reply_grammar = basic_check(state.reply_text) + state.reply_log.append((now_utc(), state.reply_text)) + state.dds_log.append(("crew.select", now_utc(), state.crew_selected)) + return state + + +def test_active_dds_session_round_trips_without_losing_work(): + before = dds_state() + payload = dump_state(before) + restored = load_state( + payload, + datetime.now(UTC) - timedelta(seconds=2), + ) + + assert restored.session_id == before.session_id + assert restored.exercise is Exercise.DDS + assert restored.criteria.decision_time_limit_seconds == 45 + assert restored.dispatched_card == before.dispatched_card + assert restored.status_log == before.status_log + assert restored.crew_selected == before.crew_selected + assert restored.crew_assignments == before.crew_assignments + assert restored.phone_pending == before.phone_pending + assert restored.reply_text == before.reply_text + assert restored.reply_grammar == before.reply_grammar + 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) + assert timer.elapsed_ms >= 1_900 + assert timer.limit_ms == 45_000 + + +def test_checkpoint_rejects_unknown_format_version(): + payload = dump_state(dds_state()) + payload["version"] = 999 + with pytest.raises(ValueError, match="версия"): + load_state(payload, datetime.now(UTC)) + + +def test_concurrent_dds_queue_round_trips_with_each_timer_and_status(): + first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) + second = first.model_copy( + deep=True, + update={"id": "checkpoint-second", "title": "Вторая карточка восстановления"}, + ) + state = SessionState( + session_id=uuid4(), + scenario_id=first.id, + scenario_title=first.title, + level=first.level.value, + mode=SessionMode.TRAINING, + exercise=Exercise.DDS, + dds_scenarios=[first, second], + ) + 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.on_event("card.ack") + second_id = state.dds_live_cards[1].card_id + assert state.activate_dds_card(second_id) + + restored = load_state( + dump_state(state), + datetime.now(UTC) - timedelta(seconds=2), + ) + + assert restored.dds_active_card_id == second_id + assert restored.dispatched_card.card_id == second_id + assert len(restored.dds_live_cards) == 2 + first_restored = next(item for item in restored.dds_live_cards if item.card_id == first_id) + second_restored = next(item for item in restored.dds_live_cards if item.card_id == second_id) + assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED + assert first_restored.timers.measured_ms(TimerCode.DDS_ACK) is not None + second_timer = next( + item for item in second_restored.timers.snapshot() if item.code is TimerCode.DDS_ACK + ) + assert second_timer.elapsed_ms >= 1_900 + assert second_timer.stopped is False + + +def test_delivering_next_dds_card_does_not_clear_previous_card_state(): + first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) + scenarios = [ + first.model_copy( + deep=True, + update={"id": f"scheduled-checkpoint-{index}", "title": f"Карточка {index}"}, + ) + for index in range(3) + ] + state = SessionState( + session_id=uuid4(), + scenario_id=scenarios[0].id, + scenario_title=scenarios[0].title, + level=scenarios[0].level.value, + mode=SessionMode.TRAINING, + exercise=Exercise.DDS, + dds_scenarios=scenarios, + ) + 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.capture_active_dds() + + assert deliver_due_cards(state, now_utc() + timedelta(seconds=61)) == 1 + first = next(card for card in state.dds_live_cards if card.card_id == first_id) + assert first.status_log[-1].status is ServiceStatus.ACCEPTED + second = next(card for card in state.dds_live_cards if card.original_index == 1) + assert state.activate_dds_card(second.card_id) + + restored = load_state(dump_state(state), now_utc()) + first_restored = next(card for card in restored.dds_live_cards if card.card_id == first_id) + assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED + assert restored.dds_active_card_id == second.card_id + assert len(restored.dds_live_cards) == 2 + assert restored.dds_next_scenario_index == 2 + assert restored.dds_next_arrival_at is not None diff --git a/backend/tests/test_station.py b/backend/tests/test_station.py index bd72550..5448e52 100644 --- a/backend/tests/test_station.py +++ b/backend/tests/test_station.py @@ -104,9 +104,8 @@ def test_acknowledgement_stops_the_four_second_norm(client): control.__exit__(None, None, None) -def test_bounced_card_becomes_e6_with_the_reason(client): - """Неполнота КИО перестаёт быть процентом в отчёте и становится - сорванным выездом с конкретной причиной.""" +def test_dds_cannot_return_card_for_112_quality_control(client): + """ДДС ставит статусы своей службы, а качество КИО контролирует 112.""" session_id, control, _ = lesson(client) try: state = hub.get(session_id) @@ -117,14 +116,15 @@ def test_bounced_card_becomes_e6_with_the_reason(client): read_until(station, "card.received") station.send_json({"type": "card.bounce", "missing_fields": ["floor", "victims_count"], "comment": "куда ехать без этажа"}) - wait_for(lambda: state.bounced_fields) + error = read_until(station, "error") + assert "служба контроля 112" in error["message"] + assert state.bounced_fields == [] trainee.send_json({"type": "call.hangup"}) wait_for(lambda: state.score is not None) response = client.get(f"/api/sessions/{session_id}/report").json() chain = [finding for finding in response["findings"] if finding["code"] == "E6"] - assert chain, "возврат карточки должен попасть в разбор" - assert "floor" in chain[0]["summary"] and "выезд сорван" in chain[0]["summary"] + assert chain == [] finally: control.__exit__(None, None, None) @@ -160,8 +160,9 @@ def test_station_sees_the_notify_list_from_the_classifier(client): session_id, station, contexts = dispatched(client) try: state = read_until(station, "station.state") - assert "Служба 101" in state["snapshot"]["services"] - assert len(state["snapshot"]["services"]) > 1 + assert state["snapshot"]["services"] == ["Служба 101"] + assert "Служба 101" in state["snapshot"]["recipient_services"] + assert len(state["snapshot"]["recipient_services"]) > 1 assert state["snapshot"]["statuses"]["Служба 101"] == "added" finally: close_all(contexts) diff --git a/backend/tests/test_ws.py b/backend/tests/test_ws.py index 8ad4d32..ceff84d 100644 --- a/backend/tests/test_ws.py +++ b/backend/tests/test_ws.py @@ -6,6 +6,7 @@ import contextlib import time +import wave from uuid import uuid4 import pytest @@ -256,6 +257,32 @@ def test_events_still_work_between_audio_frames(client): assert state.audio_frames == 2 +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 + + monkeypatch.setattr( + call_api, "start_recording", + lambda session_id: CallRecorder(tmp_path / f"{session_id}.wav"), + ) + with lesson(client) as (session_id, _): + state = hub.get(session_id) + path = tmp_path / f"{session_id}.wav" + with client.websocket_connect(f"/ws/call/{session_id}") as trainee: + read_until(trainee, "call.incoming") + trainee.send_json({"type": "call.answer"}) + read_until(trainee, "call.started") + trainee.send_bytes((1000).to_bytes(2, "little", signed=True) * 320) + trainee.send_json({"type": "call.hangup"}) + read_until(trainee, "call.ended") + wait_for(path.is_file) + + assert state.recording_path == str(path) + with wave.open(str(path), "rb") as source: + assert source.getframerate() == 16_000 + assert source.getnframes() >= 320 + + def test_score_waits_for_self_assessment(client): """Курсант сначала сверяет своё ощущение с объективной картиной: расхождение самооценки с автооценкой — отдельный материал для преподавателя.""" diff --git a/backend/uv.lock b/backend/uv.lock index 333fb9e..d7dc167 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -397,6 +397,7 @@ dependencies = [ { name = "alembic" }, { name = "argon2-cffi" }, { name = "asyncpg" }, + { name = "certifi" }, { name = "fastapi" }, { name = "httpx" }, { name = "itsdangerous" }, @@ -430,6 +431,7 @@ requires-dist = [ { name = "alembic", specifier = ">=1.13" }, { name = "argon2-cffi", specifier = ">=23.1" }, { name = "asyncpg", specifier = ">=0.29" }, + { name = "certifi", specifier = ">=2024" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.27" }, { name = "itsdangerous", specifier = ">=2.1" }, diff --git a/docker-compose.sip.yml b/docker-compose.sip.yml new file mode 100644 index 0000000..80d1b3b --- /dev/null +++ b/docker-compose.sip.yml @@ -0,0 +1,28 @@ +services: + sip: + build: ./sip + image: lct-hack-sip:local + restart: unless-stopped + environment: + SIP_6001_PASSWORD: ${SIP_6001_PASSWORD:-} + SIP_6002_PASSWORD: ${SIP_6002_PASSWORD:-} + SIP_6003_PASSWORD: ${SIP_6003_PASSWORD:-} + SIP_6101_PASSWORD: ${SIP_6101_PASSWORD:-} + SIP_6102_PASSWORD: ${SIP_6102_PASSWORD:-} + SIP_EXTERNAL_MEDIA_ADDRESS: ${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1} + 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" + volumes: + - sipdata:/var/lib/lct-sip + - siprecordings:/recordings + - type: bind + source: ${SIP_TLS_DIR:-./.local/sip-tls} + target: /tls + +volumes: + sipdata: + siprecordings: diff --git a/docker-compose.tls.yml b/docker-compose.tls.yml new file mode 100644 index 0000000..4b1f114 --- /dev/null +++ b/docker-compose.tls.yml @@ -0,0 +1,16 @@ +services: + backend: + environment: + SECURE_COOKIES: "true" + + frontend: + environment: + NGINX_ENVSUBST_TEMPLATE_DIR: /etc/nginx/templates-tls + PUBLIC_TLS_PORT: ${TLS_PORT:-5443} + TLS_BOOTSTRAP: "true" + ports: + - "${BIND_HOST:-127.0.0.1}:${TLS_PORT:-5443}:5443" + volumes: + - type: bind + source: ${TLS_CERT_DIR:-./.local/tls} + target: /etc/nginx/tls diff --git a/docker-compose.yml b/docker-compose.yml index 9ecba32..7a74afe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,14 @@ services: postgres: image: postgres:16-alpine + restart: unless-stopped environment: POSTGRES_USER: lct POSTGRES_PASSWORD: lct POSTGRES_DB: lct - ports: ["5432:5432"] + # БД и backend не публикуются в класс: с рабочих мест доступен только + # TLS-терминатор frontend. Loopback-порты нужны для администрирования хоста. + ports: ["127.0.0.1:${POSTGRES_PORT:-5432}:5432"] volumes: ["pgdata:/var/lib/postgresql/data"] healthcheck: test: ["CMD-SHELL", "pg_isready -U lct"] @@ -16,17 +19,67 @@ services: backend: build: ./backend image: lct-hack-backend:local + restart: unless-stopped # Один воркер принципиально: состояние живой сессии и реестр наблюдателей # живут в памяти процесса (docs/arch/STACK.md). - command: sh -c "alembic upgrade head && python scripts/seed.py && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1" + command: >- + sh -c ' + if [ -z "$${SESSION_SECRET:-}" ]; then + if [ ! -s /run/lct/session-secret ]; then + python -c "import secrets; print(secrets.token_urlsafe(48))" > /run/lct/session-secret; + chmod 600 /run/lct/session-secret; + fi; + export SESSION_SECRET="$$(cat /run/lct/session-secret)"; + fi; + 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 OFFLINE: "true" 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 - ports: ["8000:8000"] + - securitydata:/run/lct + - recordings:/recordings + - backups:/app/backups + ports: ["127.0.0.1:${BACKEND_PORT:-8000}:8000"] + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + postgres: + 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 + + backup: + build: ./backend + image: lct-hack-backend:local + restart: unless-stopped + command: python scripts/backup_loop.py + environment: + DATABASE_URL: postgresql+asyncpg://lct:lct@postgres:5432/lct + BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400} + BACKUP_RETRY_SECONDS: ${BACKUP_RETRY_SECONDS:-300} + BACKUP_KEEP: ${BACKUP_KEEP:-14} + volumes: + - ./backend:/app + - backups:/app/backups depends_on: postgres: condition: service_healthy @@ -34,15 +87,22 @@ services: frontend: build: ./frontend image: lct-hack-frontend:local - command: npm run dev -- --host 0.0.0.0 - volumes: - - ./frontend:/app - - /app/node_modules + restart: unless-stopped environment: - VITE_BACKEND_URL: http://backend:8000 - VITE_WS_BACKEND_URL: ws://backend:8000 - ports: ["5173:5173"] - depends_on: [backend] + BACKEND_HOST: backend + BACKEND_PORT: 8000 + ports: ["${BIND_HOST:-127.0.0.1}:${FRONTEND_PORT:-5173}:5173"] + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD", "nginx", "-t"] + interval: 10s + timeout: 3s + retries: 3 volumes: pgdata: + securitydata: + recordings: + backups: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 20692fd..390c9cc 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,9 +1,21 @@ -FROM node:20-slim +FROM node:20-slim AS build WORKDIR /app COPY package.json package-lock.json* .npmrc ./ RUN npm install COPY . . +RUN npm run build + +FROM nginx:1.27-alpine + +RUN apk add --no-cache openssl +COPY nginx.conf.template /etc/nginx/templates/default.conf.template +COPY nginx.tls.conf.template /etc/nginx/templates-tls/default.conf.template +COPY docker-entrypoint.d/15-local-certificate.sh /docker-entrypoint.d/15-local-certificate.sh +COPY --from=build /app/dist /usr/share/nginx/html + +ENV BACKEND_HOST=backend +ENV BACKEND_PORT=8000 EXPOSE 5173 -CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] +EXPOSE 5443 diff --git a/frontend/docker-entrypoint.d/15-local-certificate.sh b/frontend/docker-entrypoint.d/15-local-certificate.sh new file mode 100755 index 0000000..0b78063 --- /dev/null +++ b/frontend/docker-entrypoint.d/15-local-certificate.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu + +if [ "${TLS_BOOTSTRAP:-false}" != "true" ]; then + exit 0 +fi + +certificate_dir=/etc/nginx/tls +certificate_file="$certificate_dir/tls.crt" +private_key_file="$certificate_dir/tls.key" +mkdir -p "$certificate_dir" + +if [ -s "$certificate_file" ] && [ -s "$private_key_file" ]; then + exit 0 +fi + +openssl req -x509 -nodes -newkey rsa:3072 -sha256 -days 365 \ + -keyout "$private_key_file" \ + -out "$certificate_file" \ + -subj "/CN=localhost/O=LCT local training stand" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \ + -addext "keyUsage=digitalSignature,keyEncipherment" \ + -addext "extendedKeyUsage=serverAuth" 2>/dev/null +chmod 600 "$private_key_file" +chmod 644 "$certificate_file" +echo "TLS: создан локальный bootstrap-сертификат $certificate_file" >&2 diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template new file mode 100644 index 0000000..2694b99 --- /dev/null +++ b/frontend/nginx.conf.template @@ -0,0 +1,38 @@ +server { + listen 5173; + server_name _; + root /usr/share/nginx/html; + + gzip 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_HOST}:${BACKEND_PORT}; + proxy_http_version 1.1; + proxy_set_header Host $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_HOST}:${BACKEND_PORT}; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $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.tls.conf.template b/frontend/nginx.tls.conf.template new file mode 100644 index 0000000..32c7aad --- /dev/null +++ b/frontend/nginx.tls.conf.template @@ -0,0 +1,71 @@ +server { + listen 5173; + server_name _; + return 308 https://$host:${PUBLIC_TLS_PORT}$request_uri; +} + +server { + listen 5443 ssl; + 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_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_HOST}:${BACKEND_PORT}; + proxy_http_version 1.1; + proxy_set_header Host $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_HOST}:${BACKEND_PORT}; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_read_timeout 75s; + } + + # SIP over WebSocket для встроенного WebRTC-телефона. Переменная и + # Docker DNS позволяют обычному TLS-стенду стартовать даже до sip-сервиса. + 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/package-lock.json b/frontend/package-lock.json index 3e38b7f..f9db7c1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@tanstack/react-query": "^5.103.1", + "jssip": "^3.13.8", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.2" @@ -1340,7 +1341,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1410,6 +1410,15 @@ "node": ">=6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1467,6 +1476,17 @@ "node": ">=6" } }, + "node_modules/jssip": { + "version": "3.13.8", + "resolved": "https://registry.npmjs.org/jssip/-/jssip-3.13.8.tgz", + "integrity": "sha512-ErxEdy13vXR5izGo42fpXOU64UmC2VF7Shg4oYG7R2NzZF0FTPeYikffkIIQa9w+/11YvTlgezG1nSaycWYXPA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "events": "^3.3.0", + "sdp-transform": "^2.14.1" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1493,7 +1513,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -1683,6 +1702,15 @@ "loose-envify": "^1.1.0" } }, + "node_modules/sdp-transform": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", + "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "license": "MIT", + "bin": { + "sdp-verify": "checker.js" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 05e6e2e..95b7b9d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.103.1", + "jssip": "^3.13.8", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.2" diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index c9c583e..0da1860 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -1,22 +1,30 @@ +import { lazy, Suspense } from "react"; import { createBrowserRouter, Navigate } from "react-router-dom"; -import { Call } from "@/pages/trainee/Call"; -import { Instructor } from "@/pages/instructor/Instructor"; -import { Dds } from "@/pages/dds/Dds"; -import { Admin } from "@/pages/admin/Admin"; -import { Profile } from "@/pages/profile/Profile"; -import { Groups } from "@/pages/groups/Groups"; -import { Wall } from "@/pages/wall/Wall"; import { RequireAuth } from "@/features/auth/Login"; import { DEMO_MODE } from "@/features/auth/useAuth"; +const Call = lazy(() => import("@/pages/trainee/Call").then((module) => ({ default: module.Call }))); +const Instructor = lazy(() => import("@/pages/instructor/Instructor").then((module) => ({ default: module.Instructor }))); +const Dds = lazy(() => import("@/pages/dds/Dds").then((module) => ({ default: module.Dds }))); +const Admin = lazy(() => import("@/pages/admin/Admin").then((module) => ({ default: module.Admin }))); +const Profile = lazy(() => import("@/pages/profile/Profile").then((module) => ({ default: module.Profile }))); +const Groups = lazy(() => import("@/pages/groups/Groups").then((module) => ({ default: module.Groups }))); +const Wall = lazy(() => import("@/pages/wall/Wall").then((module) => ({ default: module.Wall }))); +const Phone = lazy(() => import("@/pages/phone/Phone").then((module) => ({ default: module.Phone }))); +const Materials = lazy(() => import("@/pages/materials/Materials").then((module) => ({ default: module.Materials }))); + // Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md). // session_id живёт в URL: /instructor?session=..., монитор открывают ссылкой. // Каждый экран за проверкой роли: курсант не открывает пульт даже по прямой // ссылке. Сервер проверяет то же самое на сокетах — здесь только чтобы человек // увидел понятное объяснение вместо пустого экрана (lct-23). const guarded = (screen: string, element: JSX.Element) => ( - {element} + +

Загружаем рабочее место…

}> + {element} +
+
); export const router = createBrowserRouter([ @@ -27,5 +35,7 @@ export const router = createBrowserRouter([ { path: "/profile", element: guarded("/profile", ) }, { path: "/groups", element: guarded("/groups", ) }, { path: "/dds", element: guarded("/dds", ) }, + { path: "/phone", element: guarded("/phone", ) }, + { path: "/materials", element: guarded("/materials", ) }, { path: "/admin", element: guarded("/admin", ) }, ]); diff --git a/frontend/src/features/auth/Login.tsx b/frontend/src/features/auth/Login.tsx index df664e7..f00a4a5 100644 --- a/frontend/src/features/auth/Login.tsx +++ b/frontend/src/features/auth/Login.tsx @@ -23,6 +23,7 @@ function DemoLinks() { openScreen(event, "/instructor")}>Преподаватель openScreen(event, "/trainee")}>Оператор 112 openScreen(event, "/dds")}>ДДС + openScreen(event, "/materials")}>Материалы ; diff --git a/frontend/src/features/auth/useAuth.ts b/frontend/src/features/auth/useAuth.ts index 7915de0..71177ef 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"], - instructor: ["/instructor", "/wall", "/profile", "/dds", "/groups"], - trainee: ["/trainee", "/dds", "/profile"], + admin: ["/admin", "/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 fabab06..ddff886 100644 --- a/frontend/src/features/call/useCall.ts +++ b/frontend/src/features/call/useCall.ts @@ -69,7 +69,20 @@ export function useCall(sessionId: string | null) { useEffect(() => { if (!sessionId) return; const ch = callChannel(sessionId, { - onStatus: setStatus, + onStatus: (next) => { + setStatus(next); + if (next === "open") { + // Браузер не подтверждает доставку WebSocket-кадра. Всё, что ещё + // отмечено pending и не пришло эхом от сервера, повторяем после + // reconnect: kio.patch идемпотентен и не создаёт двойных действий. + queueMicrotask(() => setCard((current) => { + if (Object.keys(current.pending).length) { + channel.current?.send({ type: "kio.patch", fields: current.pending }); + } + return current; + })); + } + }, onBinary: (frame) => audio.current?.playback.enqueue(frame), onEvent: (event: ServerToTrainee) => { if (event.type === "card.briefing") { diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx index fbca07e..d4fc08f 100644 --- a/frontend/src/features/debrief/Debrief.tsx +++ b/frontend/src/features/debrief/Debrief.tsx @@ -27,6 +27,16 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?: Скачать отчёт: CSV {" · "}PDF

+

+ {report.passed ? "Зачёт" : "Не зачтено"}: нарушено {report.failed_metrics} метрик, + допустимо {report.criteria.allowed_errors}. Лимит решения ДДС — + {` ${report.criteria.decision_time_limit_seconds} с`}; грамматика + {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("; ")}. +

}
@@ -38,6 +48,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?: {metric.title} {metric.fact} · норматив {metric.norm} + {` · вес ${metric.weight ?? 1}`} {metric.ref && ({metric.ref})} diff --git a/frontend/src/features/health/StandStatus.tsx b/frontend/src/features/health/StandStatus.tsx index 896ae82..72a6576 100644 --- a/frontend/src/features/health/StandStatus.tsx +++ b/frontend/src/features/health/StandStatus.tsx @@ -12,7 +12,7 @@ export function StandStatus() { if (!health.data) return null; if (health.data.demo_no_db) { - return
Локальная демка: карточки 112 и ДДС работают без БД и голоса; история занятий не сохраняется.
; + return
Локальная демка: карточки 112, ДДС и редактор работают без БД и голоса; история и черновики исчезнут после перезапуска.
; } const troubles: string[] = []; diff --git a/frontend/src/features/instructor/Director.tsx b/frontend/src/features/instructor/Director.tsx index 6183b0d..850d3dd 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)} />{" "}

+
+ + + +
+
+ {!archiveCandidate ? : <> + Сценарий исчезнет из назначения, но история занятий сохранится. + + + } + {effectiveSource && !effectiveSource.can_manage && + Базовые сценарии и сценарии других преподавателей доступны только для чтения. + } + {lastArchived && } +
{draft && ( <> -

Черновик {draft.id}. Факты и эталон доступны только преподавателю до утверждения. Поле score_weights позволяет задать веса метрик (0–10), например {"{\"address\": 3, \"dds_ack\": 2}"}.

+

Черновик {draft.id} ({generationLabel(draft.generation)}). Факты и эталон доступны только преподавателю до утверждения. Поле score_weights позволяет задать веса метрик (0–10), например {"{\"address\": 3, \"dds_ack\": 2}"}.

+
+ Предпросмотр: {stringField(draft.body, "title")} +

Первая реплика: «{stringField(draft.body, "first_line")}»

+

Сюжетные факты — проверьте, что они согласуются друг с другом и с эталоном:

+
    {Array.isArray(draft.body.facts) && draft.body.facts.map((item, index) => + isObject(item) ?
  • {String(item.id)}: {String(item.value ?? "")}
  • : null + )}
+
+
+ + +