From 896455381ab60d658959466a0d678b0395fc12fa Mon Sep 17 00:00:00 2001 From: Max Ronzhin Date: Thu, 23 Jul 2026 01:04:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=BD=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=81=D0=B8=D1=8F=20VidConf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 105 + .github/workflows/ci.yml | 97 + .gitignore | 43 + README.md | 313 + VERSION | 1 + backend/.python-version | 1 + backend/Dockerfile | 40 + backend/README.md | 356 + backend/alembic.ini | 149 + backend/alembic/README | 1 + backend/alembic/env.py | 93 + backend/alembic/script.py.mako | 28 + ...e0_auth_email_verification_and_livekit_.py | 51 + .../versions/1e2e34a0cb06_initial_schema.py | 136 + ...b8_booking_access_link_and_participants.py | 50 + .../504791847d4f_chat_guest_authors.py | 69 + .../5970bf64fc43_settings_notifications.py | 108 + ...140_audio_tracks_and_phrase_attribution.py | 109 + .../alembic/versions/9d37822e4513_teams.py | 49 + ...1e12784_conference_invitees_and_avatars.py | 76 + .../f418dd65e7b1_dynamic_conferences.py | 366 + backend/api/__init__.py | 0 backend/api/admin.py | 464 ++ backend/api/auth.py | 188 + backend/api/chat.py | 152 + backend/api/conferences.py | 255 + backend/api/deps.py | 72 + backend/api/health.py | 40 + backend/api/livekit_webhook.py | 63 + backend/api/metrics.py | 125 + backend/api/teams.py | 31 + backend/api/users.py | 145 + backend/core/__init__.py | 0 backend/core/config.py | 138 + backend/core/db.py | 24 + backend/core/plugins/__init__.py | 12 + backend/core/plugins/config.py | 84 + backend/core/plugins/factory.py | 47 + backend/core/plugins/faster_whisper.py | 141 + backend/core/plugins/null.py | 36 + backend/core/plugins/qwen_local.py | 203 + backend/core/plugins/summarizer.py | 15 + backend/core/plugins/transcriber.py | 25 + backend/core/rate_limit.py | 36 + backend/core/redis.py | 9 + backend/core/security.py | 71 + backend/core/summarization/__init__.py | 5 + backend/core/summarization/chunking.py | 156 + backend/core/summarization/llm_client.py | 155 + backend/core/summarization/tokens.py | 51 + backend/main.py | 97 + backend/models/__init__.py | 39 + backend/models/audio_track.py | 65 + backend/models/base.py | 7 + backend/models/chat.py | 60 + backend/models/conference.py | 89 + backend/models/email_delivery.py | 59 + backend/models/email_verification.py | 30 + backend/models/guest.py | 28 + backend/models/instance_setting.py | 27 + backend/models/invitee.py | 61 + backend/models/participant.py | 44 + backend/models/phrase.py | 36 + backend/models/session.py | 55 + backend/models/team.py | 24 + backend/models/user.py | 40 + backend/models/webhook_event.py | 25 + backend/pyproject.toml | 115 + backend/repositories/__init__.py | 0 backend/repositories/admin.py | 133 + backend/repositories/chat.py | 48 + backend/repositories/conferences.py | 543 ++ backend/repositories/users.py | 59 + backend/schemas/__init__.py | 1 + backend/schemas/admin.py | 158 + backend/schemas/auth.py | 113 + backend/schemas/chat.py | 83 + backend/schemas/conferences.py | 212 + backend/scripts/__init__.py | 0 backend/scripts/apply_preset_settings.py | 120 + backend/scripts/seed.py | 51 + backend/services/__init__.py | 0 backend/services/ai_levels.py | 98 + backend/services/ai_tiers.py | 137 + backend/services/auth.py | 239 + backend/services/avatars.py | 123 + backend/services/chat.py | 218 + backend/services/conference_access.py | 78 + backend/services/conference_ids.py | 37 + backend/services/conferences.py | 670 ++ backend/services/egress.py | 58 + backend/services/email.py | 202 + backend/services/email_templates.py | 161 + backend/services/ics.py | 148 + backend/services/instance_settings.py | 390 + backend/services/invitations_producer.py | 40 + backend/services/livekit_tokens.py | 43 + backend/services/pipeline_producer.py | 70 + backend/services/profile.py | 70 + backend/services/recurrence.py | 165 + backend/services/webhook_handlers.py | 357 + backend/tests/__init__.py | 0 backend/tests/conftest.py | 233 + .../tests/fixtures/livekit/egress_ended.json | 16 + .../fixtures/livekit/egress_ended_failed.json | 13 + .../fixtures/livekit/participant_joined.json | 14 + .../fixtures/livekit/participant_left.json | 14 + .../tests/fixtures/livekit/room_finished.json | 9 + .../tests/fixtures/livekit/room_started.json | 9 + .../fixtures/livekit/track_published.json | 19 + .../livekit/track_published_video.json | 19 + backend/tests/test_admin_api.py | 608 ++ backend/tests/test_admin_teams.py | 299 + backend/tests/test_ai_levels.py | 183 + backend/tests/test_auth.py | 434 ++ backend/tests/test_build_phrases.py | 125 + backend/tests/test_celery_routes.py | 51 + backend/tests/test_chat_ws.py | 526 ++ backend/tests/test_chunking.py | 176 + backend/tests/test_conference_invitees.py | 92 + backend/tests/test_conference_service.py | 450 ++ backend/tests/test_conferences_api.py | 1202 +++ backend/tests/test_health.py | 38 + backend/tests/test_ics.py | 267 + backend/tests/test_instance_settings.py | 652 ++ backend/tests/test_invitations_task.py | 443 ++ backend/tests/test_livekit_webhook.py | 669 ++ backend/tests/test_llm_client.py | 206 + backend/tests/test_maintenance.py | 523 ++ backend/tests/test_metrics_api.py | 123 + backend/tests/test_notify_task.py | 448 ++ backend/tests/test_pipeline.py | 559 ++ backend/tests/test_plugins_factory.py | 189 + backend/tests/test_qwen_local.py | 243 + backend/tests/test_rbac.py | 86 + backend/tests/test_recurrence.py | 296 + backend/tests/test_smtp_backend.py | 170 + backend/tests/test_summarize_task.py | 518 ++ backend/tests/test_summarizer_transcript.py | 48 + backend/tests/test_task_producers.py | 92 + backend/tests/test_teams_api.py | 56 + backend/tests/test_tokens.py | 54 + backend/tests/test_users_api.py | 395 + backend/uv.lock | 2398 ++++++ config/.gitkeep | 0 config/plugins.yaml | 35 + deploy/.gitkeep | 0 deploy/coturn/turnserver.conf | 38 + deploy/docker-compose.yml | 696 ++ deploy/egress/egress.yaml | 34 + deploy/livekit/livekit.yaml | 60 + deploy/llm/download-model.sh | 99 + deploy/monitoring/alerts.yml | 59 + .../grafana/dashboards/pipelines.json | 150 + .../provisioning/dashboards/dashboards.yml | 15 + .../provisioning/datasources/datasource.yml | 12 + deploy/monitoring/prometheus.yml | 45 + deploy/nginx/nginx.conf | 120 + deploy/whisper/download-model.py | 54 + design/DESIGN_SYSTEM.md | 791 ++ design/favicon.svg | 14 + design/logo.svg | 28 + design/mockups/admin.html | 232 + design/mockups/auth.html | 330 + design/mockups/calendar.html | 507 ++ design/mockups/dark/README.md | 188 + design/mockups/dark/admin.html | 303 + design/mockups/dark/calendar.html | 558 ++ design/mockups/dark/join.html | 253 + design/mockups/dark/lobby.html | 403 + design/mockups/dark/login.html | 401 + design/mockups/dark/my-conferences.html | 403 + design/mockups/email-summary.html | 245 + design/mockups/join.html | 193 + design/mockups/lobby.html | 414 + design/mockups/my-conferences.html | 354 + design/mockups/room.html | 336 + design/tailwind.md | 236 + design/tokens.css | 348 + design/tools/contrast.py | 164 + docs/README.md | 152 + docs/api/.gitkeep | 0 docs/api/README.md | 184 + docs/api/admin.md | 578 ++ docs/api/auth.md | 513 ++ docs/api/chat.md | 272 + docs/api/conferences.md | 540 ++ docs/api/teams.md | 111 + docs/api/users.md | 308 + docs/architecture/README.md | 535 ++ docs/architecture/adr/.gitkeep | 0 docs/architecture/adr/000-template.md | 54 + .../adr/001-dynamic-conferences-pivot.md | 161 + ...-phrase-attribution-session-participant.md | 42 + .../adr/003-conference-invitees.md | 70 + docs/architecture/adr/004-ai-tier-matrix.md | 114 + .../adr/005-password-reset-deferred.md | 37 + docs/architecture/conference-room-ui.md | 312 + docs/architecture/frontend-themes.md | 224 + docs/db/.gitkeep | 0 docs/db/schema.md | 664 ++ docs/deploy/.gitkeep | 0 docs/deploy/capacity.md | 268 + docs/deploy/dev-setup.md | 194 + docs/deploy/env.md | 526 ++ docs/deploy/hardware-profiles.md | 64 + docs/deploy/install.md | 134 + docs/deploy/llm-setup.md | 121 + docs/deploy/monitoring.md | 90 + docs/deploy/quality-tiers.md | 294 + docs/deploy/scaling.md | 146 + docs/plugins/.gitkeep | 0 docs/plugins/contracts.md | 321 + docs/plugins/summarizer.md | 491 ++ docs/plugins/transcriber.md | 389 + frontend/.dockerignore | 17 + frontend/.gitignore | 24 + frontend/Dockerfile | 31 + frontend/README.md | 328 + frontend/components.json | 25 + frontend/eslint.config.js | 29 + frontend/index.html | 32 + frontend/package-lock.json | 6766 +++++++++++++++++ frontend/package.json | 53 + frontend/public/favicon.svg | 12 + frontend/public/icons.svg | 24 + frontend/src/App.tsx | 79 + frontend/src/api/admin.ts | 260 + frontend/src/api/auth.ts | 81 + frontend/src/api/client.ts | 143 + frontend/src/api/conferences.ts | 237 + frontend/src/api/health.ts | 26 + frontend/src/api/users.ts | 80 + frontend/src/auth/AuthProvider.tsx | 72 + frontend/src/auth/RequireAdmin.tsx | 18 + frontend/src/auth/RequireAuth.tsx | 27 + frontend/src/auth/authContext.ts | 26 + frontend/src/auth/authStore.ts | 41 + frontend/src/auth/useAuth.ts | 9 + .../components/admin/AdminConferencesTab.tsx | 450 ++ .../src/components/admin/AdminSettingsTab.tsx | 278 + .../src/components/admin/AdminTeamsTab.tsx | 241 + .../admin/AdminUserCreateDialog.tsx | 148 + .../admin/AdminUserProfileDialog.tsx | 219 + .../src/components/admin/AdminUsersTab.tsx | 233 + frontend/src/components/auth/AuthLayout.tsx | 51 + .../calendar/ConferenceCalendar.tsx | 245 + .../calendar/ConferenceFormCard.tsx | 529 ++ .../calendar/ConferenceOccurrenceDialog.tsx | 73 + .../calendar/ParticipantsPicker.tsx | 184 + .../src/components/layout/ShellTopbar.tsx | 117 + frontend/src/components/room/ChatPanel.tsx | 120 + .../components/room/DeviceSettingsDialog.tsx | 110 + .../components/room/RoomParticipantTile.tsx | 164 + frontend/src/components/room/RoomStage.tsx | 228 + frontend/src/components/room/RoomToolbar.tsx | 215 + frontend/src/components/room/RoomTopbar.tsx | 62 + frontend/src/components/room/stageFocus.ts | 133 + frontend/src/components/ui/Avatar.tsx | 31 + .../src/components/ui/ConferenceHoverCard.tsx | 103 + frontend/src/components/ui/CopyPill.tsx | 55 + frontend/src/components/ui/LogoMark.tsx | 67 + frontend/src/components/ui/Select.tsx | 233 + frontend/src/components/ui/ThemeToggle.tsx | 34 + frontend/src/components/ui/ToastProvider.tsx | 63 + frontend/src/components/ui/button.tsx | 58 + frontend/src/hooks/useChat.ts | 177 + frontend/src/hooks/useFullscreen.ts | 48 + frontend/src/hooks/useRoomPiP.ts | 183 + frontend/src/hooks/useTheme.ts | 77 + frontend/src/index.css | 15 + frontend/src/lib/localTime.ts | 50 + frontend/src/lib/parseJoinQuery.ts | 81 + frontend/src/lib/pluralize.ts | 17 + frontend/src/lib/recurrenceFormat.ts | 144 + frontend/src/lib/utils.ts | 6 + frontend/src/main.tsx | 67 + frontend/src/pages/AdminPage.tsx | 73 + frontend/src/pages/CalendarPage.tsx | 117 + frontend/src/pages/JoinPage.tsx | 414 + frontend/src/pages/LobbyPage.tsx | 118 + frontend/src/pages/LoginPage.tsx | 122 + frontend/src/pages/MyConferencesPage.tsx | 232 + frontend/src/pages/ProfilePage.tsx | 276 + frontend/src/pages/RegisterPage.tsx | 193 + frontend/src/pages/RoomPage.tsx | 250 + frontend/src/pages/VerifyEmailPage.tsx | 93 + frontend/src/styles/admin.css | 150 + frontend/src/styles/auth.css | 270 + frontend/src/styles/calendar.css | 273 + frontend/src/styles/join.css | 80 + frontend/src/styles/lobby.css | 210 + frontend/src/styles/my-conferences.css | 85 + frontend/src/styles/profile.css | 25 + frontend/src/styles/room.css | 482 ++ frontend/src/styles/select.css | 126 + frontend/src/styles/shell.css | 455 ++ frontend/src/styles/tokens.css | 315 + .../types/document-picture-in-picture.d.ts | 18 + frontend/tsconfig.app.json | 29 + frontend/tsconfig.json | 12 + frontend/tsconfig.node.json | 23 + frontend/vite.config.ts | 24 + install.sh | 450 ++ workers/README.md | 386 + workers/__init__.py | 1 + workers/celery_app.py | 94 + workers/db.py | 38 + workers/livekit_client.py | 38 + workers/summarizer/__init__.py | 6 + .../eval/corpus/01-short-standup.txt | 22 + .../eval/corpus/02-long-multitopic.txt | 60 + .../eval/corpus/03-dialogue-1on1.txt | 28 + .../eval/corpus/04-multispeaker-guest.txt | 34 + .../eval/corpus/05-metrics-heavy.txt | 31 + .../eval/results/min/01-short-standup.md | 21 + .../eval/results/min/02-long-multitopic.md | 49 + .../eval/results/min/03-dialogue-1on1.md | 22 + .../eval/results/min/04-multispeaker-guest.md | 21 + .../eval/results/min/05-metrics-heavy.md | 39 + .../eval/results/min/_run_meta.json | 49 + workers/summarizer/eval/run_tiers.py | 328 + workers/summarizer/prompts/summary_map_ru.txt | 33 + .../summarizer/prompts/summary_reduce_ru.txt | 36 + workers/summarizer/transcript.py | 44 + workers/tasks/__init__.py | 1 + workers/tasks/dispatch.py | 72 + workers/tasks/invitations.py | 286 + workers/tasks/maintenance.py | 212 + workers/tasks/notify.py | 325 + workers/tasks/pipeline.py | 250 + workers/tasks/summarize.py | 216 + workers/transcription/README.md | 254 + workers/transcription/__init__.py | 1 + workers/transcription/phrases.py | 164 + 335 files changed, 61527 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 VERSION create mode 100644 backend/.python-version create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/149d70424ae0_auth_email_verification_and_livekit_.py create mode 100644 backend/alembic/versions/1e2e34a0cb06_initial_schema.py create mode 100644 backend/alembic/versions/299053c6f7b8_booking_access_link_and_participants.py create mode 100644 backend/alembic/versions/504791847d4f_chat_guest_authors.py create mode 100644 backend/alembic/versions/5970bf64fc43_settings_notifications.py create mode 100644 backend/alembic/versions/88aa676ac140_audio_tracks_and_phrase_attribution.py create mode 100644 backend/alembic/versions/9d37822e4513_teams.py create mode 100644 backend/alembic/versions/d87681e12784_conference_invitees_and_avatars.py create mode 100644 backend/alembic/versions/f418dd65e7b1_dynamic_conferences.py create mode 100644 backend/api/__init__.py create mode 100644 backend/api/admin.py create mode 100644 backend/api/auth.py create mode 100644 backend/api/chat.py create mode 100644 backend/api/conferences.py create mode 100644 backend/api/deps.py create mode 100644 backend/api/health.py create mode 100644 backend/api/livekit_webhook.py create mode 100644 backend/api/metrics.py create mode 100644 backend/api/teams.py create mode 100644 backend/api/users.py create mode 100644 backend/core/__init__.py create mode 100644 backend/core/config.py create mode 100644 backend/core/db.py create mode 100644 backend/core/plugins/__init__.py create mode 100644 backend/core/plugins/config.py create mode 100644 backend/core/plugins/factory.py create mode 100644 backend/core/plugins/faster_whisper.py create mode 100644 backend/core/plugins/null.py create mode 100644 backend/core/plugins/qwen_local.py create mode 100644 backend/core/plugins/summarizer.py create mode 100644 backend/core/plugins/transcriber.py create mode 100644 backend/core/rate_limit.py create mode 100644 backend/core/redis.py create mode 100644 backend/core/security.py create mode 100644 backend/core/summarization/__init__.py create mode 100644 backend/core/summarization/chunking.py create mode 100644 backend/core/summarization/llm_client.py create mode 100644 backend/core/summarization/tokens.py create mode 100644 backend/main.py create mode 100644 backend/models/__init__.py create mode 100644 backend/models/audio_track.py create mode 100644 backend/models/base.py create mode 100644 backend/models/chat.py create mode 100644 backend/models/conference.py create mode 100644 backend/models/email_delivery.py create mode 100644 backend/models/email_verification.py create mode 100644 backend/models/guest.py create mode 100644 backend/models/instance_setting.py create mode 100644 backend/models/invitee.py create mode 100644 backend/models/participant.py create mode 100644 backend/models/phrase.py create mode 100644 backend/models/session.py create mode 100644 backend/models/team.py create mode 100644 backend/models/user.py create mode 100644 backend/models/webhook_event.py create mode 100644 backend/pyproject.toml create mode 100644 backend/repositories/__init__.py create mode 100644 backend/repositories/admin.py create mode 100644 backend/repositories/chat.py create mode 100644 backend/repositories/conferences.py create mode 100644 backend/repositories/users.py create mode 100644 backend/schemas/__init__.py create mode 100644 backend/schemas/admin.py create mode 100644 backend/schemas/auth.py create mode 100644 backend/schemas/chat.py create mode 100644 backend/schemas/conferences.py create mode 100644 backend/scripts/__init__.py create mode 100644 backend/scripts/apply_preset_settings.py create mode 100644 backend/scripts/seed.py create mode 100644 backend/services/__init__.py create mode 100644 backend/services/ai_levels.py create mode 100644 backend/services/ai_tiers.py create mode 100644 backend/services/auth.py create mode 100644 backend/services/avatars.py create mode 100644 backend/services/chat.py create mode 100644 backend/services/conference_access.py create mode 100644 backend/services/conference_ids.py create mode 100644 backend/services/conferences.py create mode 100644 backend/services/egress.py create mode 100644 backend/services/email.py create mode 100644 backend/services/email_templates.py create mode 100644 backend/services/ics.py create mode 100644 backend/services/instance_settings.py create mode 100644 backend/services/invitations_producer.py create mode 100644 backend/services/livekit_tokens.py create mode 100644 backend/services/pipeline_producer.py create mode 100644 backend/services/profile.py create mode 100644 backend/services/recurrence.py create mode 100644 backend/services/webhook_handlers.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/fixtures/livekit/egress_ended.json create mode 100644 backend/tests/fixtures/livekit/egress_ended_failed.json create mode 100644 backend/tests/fixtures/livekit/participant_joined.json create mode 100644 backend/tests/fixtures/livekit/participant_left.json create mode 100644 backend/tests/fixtures/livekit/room_finished.json create mode 100644 backend/tests/fixtures/livekit/room_started.json create mode 100644 backend/tests/fixtures/livekit/track_published.json create mode 100644 backend/tests/fixtures/livekit/track_published_video.json create mode 100644 backend/tests/test_admin_api.py create mode 100644 backend/tests/test_admin_teams.py create mode 100644 backend/tests/test_ai_levels.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_build_phrases.py create mode 100644 backend/tests/test_celery_routes.py create mode 100644 backend/tests/test_chat_ws.py create mode 100644 backend/tests/test_chunking.py create mode 100644 backend/tests/test_conference_invitees.py create mode 100644 backend/tests/test_conference_service.py create mode 100644 backend/tests/test_conferences_api.py create mode 100644 backend/tests/test_health.py create mode 100644 backend/tests/test_ics.py create mode 100644 backend/tests/test_instance_settings.py create mode 100644 backend/tests/test_invitations_task.py create mode 100644 backend/tests/test_livekit_webhook.py create mode 100644 backend/tests/test_llm_client.py create mode 100644 backend/tests/test_maintenance.py create mode 100644 backend/tests/test_metrics_api.py create mode 100644 backend/tests/test_notify_task.py create mode 100644 backend/tests/test_pipeline.py create mode 100644 backend/tests/test_plugins_factory.py create mode 100644 backend/tests/test_qwen_local.py create mode 100644 backend/tests/test_rbac.py create mode 100644 backend/tests/test_recurrence.py create mode 100644 backend/tests/test_smtp_backend.py create mode 100644 backend/tests/test_summarize_task.py create mode 100644 backend/tests/test_summarizer_transcript.py create mode 100644 backend/tests/test_task_producers.py create mode 100644 backend/tests/test_teams_api.py create mode 100644 backend/tests/test_tokens.py create mode 100644 backend/tests/test_users_api.py create mode 100644 backend/uv.lock create mode 100644 config/.gitkeep create mode 100644 config/plugins.yaml create mode 100644 deploy/.gitkeep create mode 100644 deploy/coturn/turnserver.conf create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/egress/egress.yaml create mode 100644 deploy/livekit/livekit.yaml create mode 100755 deploy/llm/download-model.sh create mode 100644 deploy/monitoring/alerts.yml create mode 100644 deploy/monitoring/grafana/dashboards/pipelines.json create mode 100644 deploy/monitoring/grafana/provisioning/dashboards/dashboards.yml create mode 100644 deploy/monitoring/grafana/provisioning/datasources/datasource.yml create mode 100644 deploy/monitoring/prometheus.yml create mode 100644 deploy/nginx/nginx.conf create mode 100644 deploy/whisper/download-model.py create mode 100644 design/DESIGN_SYSTEM.md create mode 100644 design/favicon.svg create mode 100644 design/logo.svg create mode 100644 design/mockups/admin.html create mode 100644 design/mockups/auth.html create mode 100644 design/mockups/calendar.html create mode 100644 design/mockups/dark/README.md create mode 100644 design/mockups/dark/admin.html create mode 100644 design/mockups/dark/calendar.html create mode 100644 design/mockups/dark/join.html create mode 100644 design/mockups/dark/lobby.html create mode 100644 design/mockups/dark/login.html create mode 100644 design/mockups/dark/my-conferences.html create mode 100644 design/mockups/email-summary.html create mode 100644 design/mockups/join.html create mode 100644 design/mockups/lobby.html create mode 100644 design/mockups/my-conferences.html create mode 100644 design/mockups/room.html create mode 100644 design/tailwind.md create mode 100644 design/tokens.css create mode 100644 design/tools/contrast.py create mode 100644 docs/README.md create mode 100644 docs/api/.gitkeep create mode 100644 docs/api/README.md create mode 100644 docs/api/admin.md create mode 100644 docs/api/auth.md create mode 100644 docs/api/chat.md create mode 100644 docs/api/conferences.md create mode 100644 docs/api/teams.md create mode 100644 docs/api/users.md create mode 100644 docs/architecture/README.md create mode 100644 docs/architecture/adr/.gitkeep create mode 100644 docs/architecture/adr/000-template.md create mode 100644 docs/architecture/adr/001-dynamic-conferences-pivot.md create mode 100644 docs/architecture/adr/002-phrase-attribution-session-participant.md create mode 100644 docs/architecture/adr/003-conference-invitees.md create mode 100644 docs/architecture/adr/004-ai-tier-matrix.md create mode 100644 docs/architecture/adr/005-password-reset-deferred.md create mode 100644 docs/architecture/conference-room-ui.md create mode 100644 docs/architecture/frontend-themes.md create mode 100644 docs/db/.gitkeep create mode 100644 docs/db/schema.md create mode 100644 docs/deploy/.gitkeep create mode 100644 docs/deploy/capacity.md create mode 100644 docs/deploy/dev-setup.md create mode 100644 docs/deploy/env.md create mode 100644 docs/deploy/hardware-profiles.md create mode 100644 docs/deploy/install.md create mode 100644 docs/deploy/llm-setup.md create mode 100644 docs/deploy/monitoring.md create mode 100644 docs/deploy/quality-tiers.md create mode 100644 docs/deploy/scaling.md create mode 100644 docs/plugins/.gitkeep create mode 100644 docs/plugins/contracts.md create mode 100644 docs/plugins/summarizer.md create mode 100644 docs/plugins/transcriber.md create mode 100644 frontend/.dockerignore create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/components.json create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/admin.ts create mode 100644 frontend/src/api/auth.ts create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/conferences.ts create mode 100644 frontend/src/api/health.ts create mode 100644 frontend/src/api/users.ts create mode 100644 frontend/src/auth/AuthProvider.tsx create mode 100644 frontend/src/auth/RequireAdmin.tsx create mode 100644 frontend/src/auth/RequireAuth.tsx create mode 100644 frontend/src/auth/authContext.ts create mode 100644 frontend/src/auth/authStore.ts create mode 100644 frontend/src/auth/useAuth.ts create mode 100644 frontend/src/components/admin/AdminConferencesTab.tsx create mode 100644 frontend/src/components/admin/AdminSettingsTab.tsx create mode 100644 frontend/src/components/admin/AdminTeamsTab.tsx create mode 100644 frontend/src/components/admin/AdminUserCreateDialog.tsx create mode 100644 frontend/src/components/admin/AdminUserProfileDialog.tsx create mode 100644 frontend/src/components/admin/AdminUsersTab.tsx create mode 100644 frontend/src/components/auth/AuthLayout.tsx create mode 100644 frontend/src/components/calendar/ConferenceCalendar.tsx create mode 100644 frontend/src/components/calendar/ConferenceFormCard.tsx create mode 100644 frontend/src/components/calendar/ConferenceOccurrenceDialog.tsx create mode 100644 frontend/src/components/calendar/ParticipantsPicker.tsx create mode 100644 frontend/src/components/layout/ShellTopbar.tsx create mode 100644 frontend/src/components/room/ChatPanel.tsx create mode 100644 frontend/src/components/room/DeviceSettingsDialog.tsx create mode 100644 frontend/src/components/room/RoomParticipantTile.tsx create mode 100644 frontend/src/components/room/RoomStage.tsx create mode 100644 frontend/src/components/room/RoomToolbar.tsx create mode 100644 frontend/src/components/room/RoomTopbar.tsx create mode 100644 frontend/src/components/room/stageFocus.ts create mode 100644 frontend/src/components/ui/Avatar.tsx create mode 100644 frontend/src/components/ui/ConferenceHoverCard.tsx create mode 100644 frontend/src/components/ui/CopyPill.tsx create mode 100644 frontend/src/components/ui/LogoMark.tsx create mode 100644 frontend/src/components/ui/Select.tsx create mode 100644 frontend/src/components/ui/ThemeToggle.tsx create mode 100644 frontend/src/components/ui/ToastProvider.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/hooks/useChat.ts create mode 100644 frontend/src/hooks/useFullscreen.ts create mode 100644 frontend/src/hooks/useRoomPiP.ts create mode 100644 frontend/src/hooks/useTheme.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/localTime.ts create mode 100644 frontend/src/lib/parseJoinQuery.ts create mode 100644 frontend/src/lib/pluralize.ts create mode 100644 frontend/src/lib/recurrenceFormat.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/AdminPage.tsx create mode 100644 frontend/src/pages/CalendarPage.tsx create mode 100644 frontend/src/pages/JoinPage.tsx create mode 100644 frontend/src/pages/LobbyPage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/MyConferencesPage.tsx create mode 100644 frontend/src/pages/ProfilePage.tsx create mode 100644 frontend/src/pages/RegisterPage.tsx create mode 100644 frontend/src/pages/RoomPage.tsx create mode 100644 frontend/src/pages/VerifyEmailPage.tsx create mode 100644 frontend/src/styles/admin.css create mode 100644 frontend/src/styles/auth.css create mode 100644 frontend/src/styles/calendar.css create mode 100644 frontend/src/styles/join.css create mode 100644 frontend/src/styles/lobby.css create mode 100644 frontend/src/styles/my-conferences.css create mode 100644 frontend/src/styles/profile.css create mode 100644 frontend/src/styles/room.css create mode 100644 frontend/src/styles/select.css create mode 100644 frontend/src/styles/shell.css create mode 100644 frontend/src/styles/tokens.css create mode 100644 frontend/src/types/document-picture-in-picture.d.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100755 install.sh create mode 100644 workers/README.md create mode 100644 workers/__init__.py create mode 100644 workers/celery_app.py create mode 100644 workers/db.py create mode 100644 workers/livekit_client.py create mode 100644 workers/summarizer/__init__.py create mode 100644 workers/summarizer/eval/corpus/01-short-standup.txt create mode 100644 workers/summarizer/eval/corpus/02-long-multitopic.txt create mode 100644 workers/summarizer/eval/corpus/03-dialogue-1on1.txt create mode 100644 workers/summarizer/eval/corpus/04-multispeaker-guest.txt create mode 100644 workers/summarizer/eval/corpus/05-metrics-heavy.txt create mode 100644 workers/summarizer/eval/results/min/01-short-standup.md create mode 100644 workers/summarizer/eval/results/min/02-long-multitopic.md create mode 100644 workers/summarizer/eval/results/min/03-dialogue-1on1.md create mode 100644 workers/summarizer/eval/results/min/04-multispeaker-guest.md create mode 100644 workers/summarizer/eval/results/min/05-metrics-heavy.md create mode 100644 workers/summarizer/eval/results/min/_run_meta.json create mode 100644 workers/summarizer/eval/run_tiers.py create mode 100644 workers/summarizer/prompts/summary_map_ru.txt create mode 100644 workers/summarizer/prompts/summary_reduce_ru.txt create mode 100644 workers/summarizer/transcript.py create mode 100644 workers/tasks/__init__.py create mode 100644 workers/tasks/dispatch.py create mode 100644 workers/tasks/invitations.py create mode 100644 workers/tasks/maintenance.py create mode 100644 workers/tasks/notify.py create mode 100644 workers/tasks/pipeline.py create mode 100644 workers/tasks/summarize.py create mode 100644 workers/transcription/README.md create mode 100644 workers/transcription/__init__.py create mode 100644 workers/transcription/phrases.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9480e95 --- /dev/null +++ b/.env.example @@ -0,0 +1,105 @@ +# --- Database --- +POSTGRES_USER=vidconf +POSTGRES_PASSWORD=vidconf +POSTGRES_DB=vidconf +DATABASE_URL=postgresql+asyncpg://vidconf:vidconf@localhost:5432/vidconf + +# --- Redis --- +REDIS_URL=redis://localhost:6379/0 + +# --- Seed admin user --- +SEED_ADMIN_EMAIL=admin@vidconf.example +SEED_ADMIN_PASSWORD=change-me + +# --- Plugins --- +PLUGINS_CONFIG_PATH=config/plugins.yaml + +# --- Auth (JWT + email-подтверждение) --- +JWT_SECRET=change-me-generate-a-long-random-secret +ACCESS_TOKEN_TTL_MINUTES=15 +REFRESH_TOKEN_TTL_DAYS=14 +EMAIL_VERIFICATION_TTL_HOURS=24 +FRONTEND_URL=http://localhost:5173 +# false только для dev по http://localhost (Safari не сохраняет +# Secure-cookie без HTTPS); в проде обязательно true. +AUTH_COOKIE_SECURE=false + +# --- LiveKit (dev placeholders, replace in real .env) --- +LIVEKIT_API_KEY=devkey +LIVEKIT_API_SECRET=change-me-livekit-secret +LIVEKIT_URL=ws://localhost:7880 +# URL, который получает браузер клиента для подключения к LiveKit SFU. +LIVEKIT_PUBLIC_URL=ws://localhost:7880 + +# --- Coturn (dev placeholders) --- +TURN_REALM=vidconf.local +TURN_STATIC_AUTH_SECRET=change-me-turn-secret + +# --- Транскрибация (профиль compose `transcribe`: egress + worker-transcriber) --- +# Общий путь тома `recordings` (egress пишет .ogg-треки, worker-transcriber +# читает их для faster-whisper). Внутри docker-сети менять не нужно — +# значение по умолчанию (см. deploy/docker-compose.yml) уже совпадает +# с точкой монтирования тома в обоих сервисах. +RECORDINGS_DIR=/recordings + +# --- Email (рассылка саммари + .ics-приглашения) --- +# `console` — дефолт для dev (письмо только логируется, ссылка подтверждения +# email берётся из логов); `smtp` — реальная отправка через aiosmtplib. +# Переключатель — только .env (секреты SMTP никогда не попадают в БД/админку). +EMAIL_BACKEND=console +SMTP_HOST=localhost +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_START_TLS=true +SMTP_USE_TLS=false +SMTP_FROM=VidConf +SMTP_TIMEOUT_S=30 + +# --- Версия инстанса (релиз v0.0.1) --- +# install.sh копирует значение из корневого файла VERSION при каждой +# установке/обновлении — руками менять не нужно. +VIDCONF_VERSION=0.0.1 + +# --- Профили compose — install.sh выставляет по выбранному +# пресету инсталлятора (1/2 — media; 3 — +transcribe,llm; 4 — те же, +# WHISPER_MODEL/LLM_MODEL_* другого уровня; 5 — +transcribe-gpu,llm-gpu). --- +COMPOSE_PROFILES=media + +# --- Настройки модулей инстанса под пресет --- +# install.sh пишет их по выбранному пресету, backend +# читает их при первом бутстрапе instance_settings (lifespan) вместо +# дефолтного «всё enabled: true» из config/plugins.yaml; те же значения +# использует scripts.apply_preset_settings при повторном применении. Пусто — +# бутстрап берёт дефолты plugins.yaml (как раньше, для dev без install.sh). --- +BOOTSTRAP_CHAT_ENABLED= +BOOTSTRAP_TRANSCRIPTION_ENABLED= +BOOTSTRAP_AI_LEVEL= + +# --- Обнаруженное железо (install.sh, ADR-004) — читает +# backend/services/ai_levels.py для детекта доступности уровней AI в +# админке; НЕ влияет на то, что реально поднимает docker compose +# (это делает COMPOSE_PROFILES выше) — используется только для +# рекомендации/причин недоступности. +HW_CPUS= +HW_RAM_MB= +HW_GPU_NAME= +HW_VRAM_MB= + +# --- Транскрибация: модель faster-whisper уровня AI (профили `transcribe`/ +# `transcribe-gpu`) — small (min) / medium (medium) / large-v3 (max, только +# GPU), см. ADR-004. Пишет install.sh по выбранному пресету. --- +WHISPER_MODEL=small + +# --- Суммаризация: модель Qwen3.5 уровня AI (профили `llm`/`llm-gpu`) — +# см. ADR-004 и deploy/llm/download-model.sh. Пишет install.sh по выбранному +# пресету; значения ниже — дефолт уровня `min`. --- +LLM_MODEL_FILE=qwen3.5-4b-instruct-q4_k_m.gguf +LLM_MODEL_URL=https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/Qwen3.5-4B-Q4_K_M.gguf +LLM_MODEL_MIN_SIZE=2000000000 +LLM_TOKENIZER_FILE=qwen3.5-4b-instruct.tokenizer.json +LLM_TOKENIZER_URL=https://huggingface.co/Qwen/Qwen3.5-4B/resolve/main/tokenizer.json + +# --- Мониторинг (профиль compose `monitoring`) --- +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD=change-me-grafana diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8451c84 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: vidconf + POSTGRES_PASSWORD: vidconf + POSTGRES_DB: vidconf + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U vidconf -d vidconf" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgresql+asyncpg://vidconf:vidconf@localhost:5432/vidconf + REDIS_URL: redis://localhost:6379/0 + # Путь относительно working-directory (backend/) — config/ лежит в корне репозитория. + PLUGINS_CONFIG_PATH: ../config/plugins.yaml + SEED_ADMIN_EMAIL: admin@vidconf.example + SEED_ADMIN_PASSWORD: change-me + LIVEKIT_API_KEY: devkey + LIVEKIT_API_SECRET: change-me-livekit-secret + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v8.3.2 + with: + enable-cache: true + + - name: Set up Python + run: uv python install + + - name: Install dependencies + run: uv sync --frozen + + - name: Lint (ruff check) + run: uv run ruff check . + + - name: Lint (ruff format) + run: uv run ruff format --check . + + - name: Type check (mypy) + run: uv run mypy . + + - name: Run migrations + run: uv run alembic upgrade head + + - name: Tests + run: uv run pytest -q + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v7 + + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6885173 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Python +.venv/ +venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +.coverage +htmlcov/ +# uv +.uv/ +uv.lock.bak + +# Node / frontend +node_modules/ +dist/ +.vite/ +frontend/coverage/ + +# Env / secrets +.env +.env.* +!.env.example + +# Models +*.gguf + +# Editors / OS +.DS_Store +.idea/ +.vscode/ + +# Docker +deploy/**/*.local.yml + +# Артефакты Celery beat +celerybeat-schedule*.db +celerybeat-schedule + +# Загруженные медиа-файлы (аватары) — dev-каталог backend/media +backend/media/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c6c35c --- /dev/null +++ b/README.md @@ -0,0 +1,313 @@ +# VidConf — самоуправляемые видеоконференции с AI-транскрибацией и суммаризацией + +Открытая платформа видеоконференций с встроенной AI-транскрибацией и суммаризацией. Построена на LiveKit SFU, Python FastAPI backend и современном React frontend. + +## Функциональность + +- **Видеоконференции:** Потоки аудио/видео отдельно для каждого участника через LiveKit SFU +- **Транскрибация:** Автоматическое преобразование речи в текст (faster-whisper, уровни CPU-only или GPU, подключаемо) +- **Суммаризация:** AI-резюме сессий (Qwen3.5-4B/9B/35B-A3B через llama.cpp, 3 уровня качества, подключаемо) +- **Динамические конференции:** Создание мгновенно из лобби или планово из календаря, вход по ссылке/номеру, закрепление с повторением +- **Чат:** Текстовое общение в конференции с сохранением сообщений +- **Экспорт:** Трансцрибции и резюме конференций через email + iCalendar (.ics) +- **Мультипользовательский режим:** Доступ на основе ролей (админ/пользователь), проверка почты +- **Самоуправляемо:** Docker Compose стек с PostgreSQL, Redis, Nginx (инсталлятор `install.sh` с автодетектом железа) + +## Стек технологий + +- **Backend:** Python 3.12, FastAPI, SQLAlchemy 2.0 async, Alembic, Celery + Redis +- **БД:** PostgreSQL 16 (с расширением btree_gist для EXCLUDE constraints) +- **Медиа:** LiveKit SFU + Coturn (TURN) +- **AI (подключаемо, 3 уровня качества):** faster-whisper (small/medium/large-v3), Qwen3.5 (4B/9B/35B-A3B) Q4_K_M через llama.cpp +- **Frontend:** React + TypeScript + Vite, Tailwind CSS, shadcn/ui, FullCalendar, LiveKit JS SDK +- **Развёртывание:** Docker Compose (5 пресетов инсталлятора), Nginx (TLS), Ansible; Prometheus + Grafana для мониторинга + +## Структура проекта + +``` +├── backend/ FastAPI приложение (api/, core/, models/, repositories/, services/) +├── workers/ Celery воркеры (transcriber, summarizer, notifier) +├── frontend/ React SPA (TypeScript, Vite, Tailwind) +├── deploy/ Docker Compose, Nginx, Ansible, конфиги LiveKit/Coturn +├── design/ Дизайн-система (DESIGN_SYSTEM.md, tokens.css, mockups/) +├── docs/ Архитектура, API, плагины, развёртывание, БД +│ ├── architecture/ ADR (записи архитектурных решений) +│ ├── api/ Документация API endpoint'ов +│ ├── plugins/ Контракты плагинов и руководство расширений +│ ├── deploy/ Гайды развёртывания и профили оборудования +│ └── db/ Схема БД и миграции +├── config/ Конфигурация plugins.yaml +└── README.md Этот файл +``` + +## Быстрый старт + +### Требования + +- Docker + Docker Compose v2 +- `uv` (инструменты Python): `brew install uv` +- Node 20+ (frontend) + +### 1. Клонирование и настройка + +```bash +git clone https://github.com/your-org/vidconf.git +cd vidconf +cp .env.example .env +``` + +### 2. Запуск (три варианта) + +**Вариант A: Автоматический инсталлятор (рекомендуется)** + +```bash +./install.sh # интерактивный опросник: автодетект железа + # и рекомендация пресета +./install.sh --preset 1 # MVP-ядро: лобби, конференции, календарь, + # закреплённые, гости (без чата и AI) +./install.sh --preset 2 # пресет 1 + чат конференции (без AI) +./install.sh --preset 3 # пресет 2 + AI «мин» — CPU: faster-whisper + # small + Qwen (8 vCPU / 16 ГБ RAM / 100 ГБ) +./install.sh --preset 4 # пресет 2 + AI «средний» — CPU: faster-whisper + # medium + Qwen (12–16 vCPU / 32 ГБ RAM / 150 ГБ) +./install.sh --preset 5 # пресет 2 + AI «макс» — GPU NVIDIA ≥16 ГБ VRAM + # ОБЯЗАТЕЛЕН: whisper large-v3 + Qwen MoE + # (16+ vCPU / 64 ГБ RAM / 250 ГБ) +./install.sh --preset 3 --yes # без подтверждений (скрипты/CI) +``` + +Повторный запуск инсталлятора идемпотентен: смена пресета докачивает модели +и переключает compose-профили на месте, секреты и правки `.env` сохраняются. +Точные требования пресетов по железу — `docs/architecture/adr/004-ai-tier-matrix.md`. + +**Вариант B: Вручную (базовый стек без AI)** +```bash +docker compose -f deploy/docker-compose.yml up -d +``` + +Это запустит PostgreSQL, Redis, backend (FastAPI) и Nginx. API доступен по адресу: +- Прямой доступ: `http://localhost:8000/api/health` +- Через Nginx: `http://localhost/api/health` + +**Вариант C: Вручную с видеоконференциями и AI** +```bash +docker compose -f deploy/docker-compose.yml \ + --profile media --profile transcribe --profile llm up -d +``` + +### 3. Настройка БД + +```bash +cd backend +uv run alembic upgrade head # Запуск миграций +uv run python -m scripts.seed # Идемпотентный сид: единственный админ-пользователь +``` + +### 4. Запуск frontend + +```bash +cd frontend +npm install +npm run dev +``` + +Frontend запущен на `http://localhost:5173` (dev сервер). + +### 5. (Опционально) Включение медиа-стека + +Для использования видеоконференций с LiveKit + Coturn: + +```bash +docker compose -f deploy/docker-compose.yml --profile media up -d +``` + +Полные детали настройки см. в [docs/deploy/dev-setup.md](docs/deploy/dev-setup.md). + +## Тестирование и качество кода + +```bash +# Backend +cd backend +uv run ruff check . && uv run ruff format --check . # Lint + проверка форматирования +uv run mypy . # Проверка типов +uv run pytest -q # Unit тесты + +# Frontend +cd frontend +npm run lint # ESLint +npm run build # Проверка сборки +``` + +## Конфигурация + +### Переменные окружения (.env) + +Скопируйте `.env.example` в `.env` и настройте (полный справочник переменных — [docs/deploy/env.md](docs/deploy/env.md)): + +``` +POSTGRES_USER=vidconf +POSTGRES_PASSWORD=vidconf +POSTGRES_DB=vidconf + +REDIS_URL=redis://localhost:6379/0 + +JWT_SECRET=change-me-generate-a-long-random-secret + +SEED_ADMIN_EMAIL=admin@vidconf.example +SEED_ADMIN_PASSWORD=change-me +``` + +### Конфигурация плагинов (config/plugins.yaml) + +```yaml +transcriber: + enabled: true + provider: "null" # или "faster_whisper_cpu"/"faster_whisper_gpu" + model: null + language: ru + +summarizer: + enabled: true + provider: "null" # или "qwen_local" + model: null + chunk_minutes: 20 + +chat: + enabled: true +``` + +Архитектуру плагинов и как добавлять пользовательские реализации см. в [docs/plugins/contracts.md](docs/plugins/contracts.md). + +## Схема БД + +VidConf использует 14 таблиц: +- **users** — зарегистрированные пользователи с доступом на основе ролей +- **teams** — организационные единицы +- **email_verification_tokens** — одноразовые токены подтверждения почты +- **conferences** — динамические конференции с номером, slug, владельцем, жизненным циклом +- **conference_invitees** — приглашённые на конференцию (зарегистрированные пользователи или внешние email) +- **guest_access** — гостевой доступ (display_name, email, разовые ссылки) +- **conference_sessions** — отдельные сессии конференции с состоянием pipeline (recording→transcribing→summarizing→notified) +- **conference_participants** — отслеживание участия (время присоединения/отключения) +- **session_audio_tracks** — аудиотреки per-участника с записями +- **phrases** — текстовые сегменты транскрибации (выход faster-whisper) +- **chat_messages** — текстовые сообщения в конференции (WebSocket, Redis pub/sub) +- **email_deliveries** — журнал отправленных писем (саммари, приглашения) +- **instance_settings** — настройки инстанса (уровень AI, чат, таймзона и т.д.) +- **livekit_webhook_events** — журнал webhook-событий LiveKit для идемпотентности + +Все временные метки хранятся в UTC; клиент преобразует в локальный часовой пояс. Полную ER-диаграмму и решения по проектированию см. в [docs/db/schema.md](docs/db/schema.md). + +## Архитектура + +Ключевые архитектурные принципы: +- **Система плагинов:** Реализации Transcriber/Summarizer подключаемы через паттерн Strategy + Factory +- **Идемпотентный pipeline:** Пост-конференционная обработка (транскрибирование → суммаризация → уведомление) использует state machine в enum `pipeline_status` +- **Без диаризации:** Per-track аудио в SFU исключает необходимость диаризации спикеров +- **Гости как полноценные участники:** гостевой доступ (без регистрации) участвует в пайплайне наравне с зарегистрированными пользователями +- **Всё в UTC:** Времена всегда UTC в хранилище; преобразование часовых поясов на клиенте + +ADR и обоснования дизайна см. в [docs/architecture/](docs/architecture/). + +## Документация API + +Основные endpoint'ы: +- `GET /api/health` — проверка здоровья +- `POST /api/v1/auth/register` — регистрация пользователя +- `POST /api/v1/auth/token` — вход, выдача access + refresh токенов +- `POST /api/v1/conferences` — создать конференцию (мгновенную или плановую) +- `GET /api/v1/conferences/my` — мои конференции (закреплённые + предстоящие) +- `GET /api/v1/conferences/resolve` — найти конференцию по номеру или ссылке +- `POST /api/v1/conferences/{id}/join` — войти в конференцию +- `GET /api/v1/admin/conferences` — список всех конференций (админ) + +Полную спецификацию API см. в [docs/api/](docs/api/). + +## Дизайн-система + +UI следует строгим рекомендациям дизайна, определённым в [design/DESIGN_SYSTEM.md](design/DESIGN_SYSTEM.md): +- **Светлая тема** для оболочки приложения (лобби-хаб, календарь, админка, профиль) +- **Тёмная тема** для комнаты конференции +- **Пастельная палитра:** мятный акцент `#D4F2E3`, смягченные статус-индикаторы +- **Логотип:** стилизованный объектив камеры (`design/logo.svg`, `design/favicon.svg`) +- **Макеты** в `design/mockups/` (lobby.html, calendar.html, my-conferences.html, join.html и др.) +- **Проверка контраста:** скрипт `design/tools/contrast.py` (WCAG 2.1, 24/24 пар PASS) + +Все компоненты используют Tailwind CSS + shadcn/ui. + +## Развёртывание + +### Пресеты инсталлятора + +VidConf использует единый инсталлятор `install.sh` с 5 пресетами и автодетектом железа: + +1. **MVP-ядро** — лобби, конференции, календарь, закреплённые, гости +2. **+чат** — текстовое общение +3. **+AI min (CPU)** — faster-whisper small + Qwen3.5-4B +4. **+AI medium (CPU/GPU опционально)** — faster-whisper medium + Qwen3.5-9B +5. **+AI max (GPU обязателен)** — faster-whisper large-v3 + Qwen3.5-35B-A3B + +```bash +./install.sh # интерактивный опросник с рекомендацией +./install.sh --preset 3 # неинтерактивно (пресет 3 = AI min) +./install.sh --preset 3 --yes # без подтверждений (для CI/скриптов) +``` + +Требования к оборудованию и полное описание см. в [docs/deploy/install.md](docs/deploy/install.md) и [docs/architecture/adr/004-ai-tier-matrix.md](docs/architecture/adr/004-ai-tier-matrix.md). + +### Docker Compose профили (низкоуровневый контроль) + +Если вы хотите настраивать стек вручную без инсталлятора: + +```bash +# Минимум (только backend, БД, Redis) +docker compose -f deploy/docker-compose.yml up -d + +# + видеоконференции (LiveKit + Coturn) +docker compose -f deploy/docker-compose.yml --profile media up -d + +# + AI transcription/summarization (CPU) +docker compose -f deploy/docker-compose.yml --profile media --profile transcribe --profile llm up -d + +# + мониторинг (Prometheus + Grafana) +docker compose -f deploy/docker-compose.yml --profile monitoring up -d +``` + +## Вклад в проект + +**Код без документации не мёржится.** Каждое изменение функциональности должно включать: +1. Обновлённый код +2. Тесты (unit + интеграционные) +3. Миграции БД (если меняется схема) +4. Документацию API/плагинов +5. ADR или ссылку на существующий ADR + +Процесс разработки: +1. Напишите тесты сначала (TDD для бизнес-логики) +2. Реализуйте функцию +3. Обновите документацию в `docs/` +4. Запустите проверки качества: + ```bash + cd backend && ruff check . && mypy . && pytest -q + cd frontend && npm run lint && npm run build + ``` +5. Создайте коммит с ясным сообщением и ссылками на документацию + +## Лицензия + +[Добавьте вашу лицензию здесь] + +## Поддержка + +- Ошибки и баги: GitHub Issues +- Документация: `docs/` +- Чат и реал-тайм: Комната конференции в самом приложении + +## Благодарности + +Построено с использованием: +- [LiveKit](https://livekit.io/) — SFU +- [faster-whisper](https://github.com/guillaumekln/faster-whisper) — транскрибация +- [Qwen](https://github.com/QwenLM/Qwen) — LLM +- [FastAPI](https://fastapi.tiangolo.com/) — backend фреймворк +- [React](https://react.dev/) — frontend diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..8acdd82 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.1 diff --git a/backend/.python-version b/backend/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/backend/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4bbb729 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,40 @@ +FROM python:3.12-slim AS base + +# uv binary, pinned via digest-less tag (see astral-sh/uv releases) +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ + +WORKDIR /app + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + PYTHONUNBUFFERED=1 + +# Экстра-группа `gpu` (ADR-004: cuBLAS/cuDNN9 для FasterWhisperGPU, +# `core/plugins/faster_whisper.py`) — ставится ТОЛЬКО в GPU-образе воркера +# транскрибации (deploy/docker-compose.yml, сервис `worker-transcriber-gpu`, +# `build.args.WITH_GPU_EXTRA: "true"`, профиль `transcribe-gpu`); базовый +# CPU-образ backend/worker/worker-transcriber собирается с дефолтом "false" +# — не тянет нативные CUDA-библиотеки туда, где GPU нет. +ARG WITH_GPU_EXTRA=false + +# Install dependencies first (better layer caching), then copy source. +COPY pyproject.toml uv.lock ./ +RUN if [ "$WITH_GPU_EXTRA" = "true" ]; then \ + uv sync --frozen --no-dev --no-install-project --extra gpu; \ + else \ + uv sync --frozen --no-dev --no-install-project; \ + fi + +COPY . . +RUN if [ "$WITH_GPU_EXTRA" = "true" ]; then \ + uv sync --frozen --no-dev --extra gpu; \ + else \ + uv sync --frozen --no-dev; \ + fi + +EXPOSE 8000 + +HEALTHCHECK --interval=10s --timeout=5s --retries=10 --start-period=15s \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1 + +CMD ["uv", "run", "uvicorn", "main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..a69aca7 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,356 @@ +# Backend — FastAPI приложение + +HTTP API сервер для VidConf: аутентификация (JWT), динамические конференции +(создание, календарь, вход по ссылке/номеру, гостевой доступ), чат в +реальном времени, администрирование (конференции/пользователи/команды/ +настройки инстанса), контракты плагинов (Transcriber/Summarizer) и интеграция +с LiveKit (токены, webhook-приёмник). + +## Структура + +``` +backend/ +├── api/ HTTP endpoint'ы +│ ├── __init__.py +│ ├── deps.py JWT-зависимости для аутентификации +│ ├── auth.py Регистрация, подтверждение email, вход, refresh, выход +│ ├── users.py Профиль, аватары, поиск пользователей (GET/PATCH /users/me, +│ │ POST/DELETE /users/me/avatar, POST /users/me/password, GET /users?q=) +│ ├── teams.py Справочник команд (GET /teams) +│ ├── admin.py Администрирование конференций, пользователей, команд, настроек инстанса +│ ├── health.py GET /api/health +│ ├── metrics.py GET /metrics (Prometheus) +│ ├── conferences.py Динамические конференции (create, my, calendar, resolve, +│ │ join, guest-join, GET/PATCH/DELETE /{id}) +│ ├── chat.py WS-эндпоинт чата (auth по LiveKit-токену, история, broadcast через Redis pub/sub) +│ └── livekit_webhook.py Webhook-приёмник событий LiveKit +├── core/ Основные модули +│ ├── config.py Конфиг из .env (Settings) +│ ├── security.py Hash/verify пароля (argon2), JWT токены +│ ├── db.py Управление сеансами БД (async) +│ ├── redis.py Redis клиент +│ ├── rate_limit.py Rate limiting для публичных endpoint'ов +│ ├── summarization/ Чанкинг транскрипта, LLM-клиент, подсчёт токенов +│ └── plugins/ Контракты Transcriber/Summarizer, factory, реализации +│ ├── transcriber.py Контракт Transcriber +│ ├── summarizer.py Контракт Summarizer +│ ├── config.py Pydantic-конфиг плагинов +│ ├── factory.py Регистрация и создание провайдеров +│ ├── null.py NullTranscriber, NullSummarizer (no-op) +│ ├── faster_whisper.py FasterWhisperCPU, FasterWhisperGPU +│ └── qwen_local.py QwenLocal (map-reduce суммаризация через llama.cpp) +├── models/ SQLAlchemy ORM (14 таблиц, см. docs/db/schema.md) +│ ├── base.py Base class +│ ├── user.py User +│ ├── team.py Team (справочник команд) +│ ├── email_verification.py EmailVerificationToken +│ ├── conference.py Conference (номер, slug, владелец, recurrence) +│ ├── invitee.py ConferenceInvitee (приглашённые: user_id ИЛИ email) +│ ├── guest.py GuestAccess (display_name, email) +│ ├── session.py ConferenceSession (один запуск конференции, pipeline_status) +│ ├── participant.py ConferenceParticipant (user_id ИЛИ guest_id, session_id) +│ ├── audio_track.py SessionAudioTrack (аудиодорожка участника) +│ ├── phrase.py Phrase (текстовые сегменты транскрибации, participant_id, session_id) +│ ├── chat.py ChatMessage (автор: пользователь ИЛИ гость, author_name, session_id) +│ ├── email_delivery.py EmailDelivery (журнал отправленных писем) +│ ├── instance_setting.py InstanceSetting (key-value настройки инстанса, JSONB) +│ └── webhook_event.py LivekitWebhookEvent +├── repositories/ Async слой доступа к данным +│ ├── users.py +│ ├── conferences.py +│ ├── chat.py ChatMessageRepository (последние N сообщений, добавление) +│ └── admin.py +├── services/ Бизнес-логика +│ ├── auth.py Аутентификация (регистрация, вход) +│ ├── conferences.py CRUD и валидация конференций, жизненный цикл, участники, .ics-приглашения +│ ├── conference_access.py Проверка доступа (пароль, статус ended) +│ ├── conference_ids.py Генерация номера (9 цифр) и slug (base64url) +│ ├── recurrence.py RecurrenceRule, развёртка occurrences +│ ├── avatars.py Загрузка, валидация (magic bytes), удаление аватаров +│ ├── profile.py Обновление профиля (ФИО, команда) +│ ├── invitations_producer.py Постановка .ics-приглашений в очередь Celery +│ ├── chat.py ChatService (auth по LiveKit-токену, history, persist+publish) +│ ├── livekit_tokens.py Генерация LiveKit JWT-токенов +│ ├── webhook_handlers.py Обработчики webhook-событий от LiveKit +│ ├── egress.py Запуск/остановка записи аудио через LiveKit Egress +│ ├── email.py / email_templates.py Отправка писем (саммари, приглашения) +│ ├── ics.py Генерация .ics-приглашений +│ ├── instance_settings.py Эффективная конфигурация инстанса (уровень AI, чат и т.д.) +│ ├── ai_levels.py / ai_tiers.py Матрица уровней AI (min/medium/max), детект доступности +│ └── pipeline_producer.py Постановка задач пайплайна пост-обработки в Celery +├── schemas/ Pydantic-схемы для запросов/ответов +│ ├── auth.py Auth, профиль пользователя +│ ├── admin.py Админка (конференции, пользователи, команды, настройки) +│ ├── conferences.py ConferenceCreateIn/UpdateIn, ConferenceOut, JoinOut, ResolveOut, OccurrenceOut, InviteeIn/Out +│ └── chat.py ChatAuthIn, ChatMessageIn/Out, ChatHistoryOut, ChatErrorOut +├── alembic/ Миграции БД +│ ├── versions/ 9 миграций от initial schema до teams/avatars/settings +│ └── env.py +├── scripts/ Утилиты +│ ├── __init__.py +│ ├── seed.py Идемпотентный сид: единственный админ-пользователь +│ └── apply_preset_settings.py Применить настройки инстанса под пресет install.sh +├── tests/ Тесты (pytest, см. полный список файлов в каталоге) +│ ├── conftest.py +│ ├── test_health.py +│ ├── test_auth.py / test_rbac.py / test_tokens.py +│ ├── test_conferences_api.py / test_conference_service.py / test_recurrence.py +│ ├── test_chat_ws.py +│ ├── test_admin_api.py / test_admin_teams.py / test_teams_api.py / test_users_api.py +│ ├── test_plugins_factory.py / test_qwen_local.py / test_llm_client.py +│ ├── test_pipeline.py / test_build_phrases.py / test_summarize_task.py / test_notify_task.py +│ └── ... +├── main.py Точка входа FastAPI приложения +└── README.md Этот файл +``` + +## Быстрый старт + +### Требования + +- Python 3.12+ +- `uv`: `brew install uv` +- PostgreSQL 16 + Redis (через docker-compose или локально) + +### 1. Установка зависимостей + +```bash +cd backend +uv sync +``` + +### 2. Запуск миграций + +```bash +uv run alembic upgrade head +``` + +Создаёт 14 таблиц и включает расширение PostgreSQL `btree_gist` (установлено, но +на текущей схеме не используется ни одним constraint'ом). + +### 3. Загрузка тестовых данных (опционально) + +```bash +uv run python -m scripts.seed +``` + +Идемпотентный сид: создаёт единственного администратора (`SEED_ADMIN_EMAIL` / +`SEED_ADMIN_PASSWORD`), если его ещё нет. Конференции создаются пользователями +динамически, предустановленных данных не требуется. + +### 4. Запуск сервера + +```bash +uv run uvicorn main:app --reload --host 0.0.0.0 --port 8000 +``` + +API доступен по адресу `http://localhost:8000`. + +Документация: +- Swagger: `http://localhost:8000/docs` +- ReDoc: `http://localhost:8000/redoc` + +## Тестирование + +```bash +# Запуск всех тестов +uv run pytest -q + +# Запуск с покрытием +uv run pytest --cov=. --cov-report=html + +# Запуск конкретного теста +uv run pytest tests/test_health.py -v +``` + +## Качество кода + +```bash +# Linting +uv run ruff check . + +# Проверка форматирования +uv run ruff format --check . + +# Автоматическое форматирование +uv run ruff format . + +# Проверка типов +uv run mypy . +``` + +Все проверки должны пройти перед мёржем. + +## Конфигурация + +### Переменные окружения (`.env`) + +Полный список переменных (БД, Redis, JWT, LiveKit, Coturn, email/SMTP, уровни +AI, обнаруженное железо для install.sh и т.д.) и их назначение — см. +[docs/deploy/env.md](../docs/deploy/env.md). Модель `Settings` в +`core/config.py` — единственный источник дефолтов. + +### Конфигурация плагинов (`config/plugins.yaml`) + +```yaml +transcriber: + enabled: true + provider: "null" + model: null + language: ru + +summarizer: + enabled: true + provider: "null" + model: null + chunk_minutes: 20 + +chat: + enabled: true +``` + +Подробнее о плагинах см. [docs/plugins/contracts.md](../docs/plugins/contracts.md). + +## База данных + +### Схема + +**14 ORM моделей:** +- `users` — зарегистрированные пользователи +- `teams` — справочник команд +- `email_verification_tokens` — одноразовые токены верификации email +- `conferences` — постоянные сущности конференций (номер, slug, владелец, recurrence) +- `conference_invitees` — приглашённые на конференцию (пользователь или внешний email) +- `guest_access` — гости, представившиеся при входе (display_name, email) +- `conference_sessions` — один запуск конференции (pipeline_status, summary_data) +- `conference_participants` — отслеживание присутствия в сеансе (user_id ИЛИ guest_id, session_id) +- `session_audio_tracks` — аудиодорожки участников сеанса +- `phrases` — текстовые сегменты транскрибации (participant_id, session_id) +- `chat_messages` — сообщения в сеансе (session_id, автор — пользователь или гость) +- `email_deliveries` — журнал отправленных писем +- `instance_settings` — key-value настройки инстанса (JSONB) +- `livekit_webhook_events` — журнал webhook-событий для идемпотентности + +Полную ER-диаграмму и обоснования дизайна см. [docs/db/schema.md](../docs/db/schema.md). + +### Миграции + +Alembic управляет схемой: + +```bash +# Проверить текущую версию +uv run alembic current + +# Обновить до последней версии +uv run alembic upgrade head + +# Создать миграцию после изменений модели +uv run alembic revision --autogenerate -m "description" +``` + +**Правило:** Модель + миграция коммитятся вместе; никогда только модель. + +### Временные метки + +Все `DateTime(timezone=True)` сохраняются как UTC в PostgreSQL. Клиент преобразует в локальный часовой пояс. + +## API + +Полная спецификация: [docs/api/README.md](../docs/api/README.md). + +- `GET /api/health` — проверка здоровья (БД, Redis) +- `GET /metrics` — метрики Prometheus +- `/api/v1/auth/*` — регистрация, подтверждение email, вход, refresh, выход +- `/api/v1/users/*` — профиль, аватар, смена пароля, поиск пользователей +- `/api/v1/teams` — справочник команд +- `/api/v1/conferences/*` — создание, календарь, вход по ссылке/номеру, гостевой вход, изменение/удаление +- `WS /api/v1/conferences/{id}/chat` — текстовый чат в реальном времени +- `/api/v1/admin/*` — администрирование конференций, пользователей, команд, настроек +- `/api/v1/livekit/webhook` — приёмник webhook-событий LiveKit + +## Плагины + +Контракты плагинов в `backend/core/plugins/`: + +**Transcriber:** +```python +class Transcriber(ABC): + provider: ClassVar[str] + def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]: ... +``` + +**Summarizer:** +```python +class Summarizer(ABC): + provider: ClassVar[str] + def summarize(self, transcript: str) -> str: ... +``` + +**Реализации:** +- `NullTranscriber`, `NullSummarizer` — no-op (`provider: "null"`, дефолт) +- `FasterWhisperCPU`, `FasterWhisperGPU` — [docs/plugins/transcriber.md](../docs/plugins/transcriber.md) +- `QwenLocal` — map-reduce суммаризация через llama.cpp — [docs/plugins/summarizer.md](../docs/plugins/summarizer.md) + +**Как добавить новый плагин:** +1. Создайте класс, наследующий Transcriber/Summarizer +2. Декоратор: `@register_transcriber` или `@register_summarizer` +3. Укажите провайдера в `config/plugins.yaml` + +Все контракты и фабрика: [docs/plugins/contracts.md](../docs/plugins/contracts.md). + +## Celery воркеры + +Пост-обработка (транскрибирование, суммаризация, email-уведомления и +приглашения) выполняется в Celery-воркерах. См. [workers/README.md](../workers/README.md). + +## Решение проблем + +**ImportError:** +```bash +uv sync --all-extras +``` + +**Ошибка подключения к БД:** +```bash +docker compose -f ../deploy/docker-compose.yml ps +echo $DATABASE_URL +``` + +**Миграции не выполняются:** +```bash +uv run alembic current +uv run alembic downgrade base +uv run alembic upgrade head +``` + +**Ошибки типов:** +- Python 3.12+: `python --version` +- Пересборка: `uv sync --refresh` + +## Процесс разработки + +1. **Ветка:** `git checkout -b feature/my-feature` +2. **Напишите тесты:** `tests/test_*.py` +3. **Реализуйте** в соответствующем модуле +4. **Качество:** `pytest -q && ruff check . && mypy .` +5. **Коммит:** включите резюме тестов + +Пример: +``` +feat: добавить реестр Transcriber плагинов + +- Реализовать декоратор @register_transcriber +- Добавить NullTranscriber no-op реализацию +- Добавить test_plugins_factory.py + +Тесты: пройдены +``` + +## Ссылки + +- [Корневой README](../README.md) — обзор проекта +- [Схема БД](../docs/db/schema.md) — ER диаграмма & дизайн +- [Контракты плагинов](../docs/plugins/contracts.md) — гайд расширений плагинов +- [API справка](../docs/api/README.md) — endpoint'ы +- [Архитектура](../docs/architecture/README.md) — дизайн системы +- [Dev Setup](../docs/deploy/dev-setup.md) — локальное окружение diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..df80d65 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..f59cf76 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,93 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context +from core.config import get_settings +from models import Base + +# это объект конфига Alembic, который предоставляет +# доступ к значениям в используемом .ini файле. +config = context.config + +# Интерпретировать конфиг файл для Python логирования. +# Эта строка устанавливает логгеры в основном. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Использовать Settings приложения (переменные окружения / .env) вместо alembic.ini +# для URL БД. +config.set_main_option("sqlalchemy.url", get_settings().database_url) + +# добавить объект MetaData вашей модели здесь +# для поддержки 'autogenerate' +target_metadata = Base.metadata + +# другие значения конфига, определённые потребностями env.py, +# можно получить: +# my_important_option = config.get_main_option("my_important_option") +# ... и т.д. + + +def run_migrations_offline() -> None: + """Запустить миграции в 'offline' режиме. + + Это настраивает контекст только с URL + и без Engine, хотя Engine также приемлем + здесь. Пропуская создание Engine + нам даже не нужен доступный DBAPI. + + Вызовы context.execute() здесь выдают заданную строку в + вывод скрипта. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """В этом сценарии нам нужно создать Engine + и связать подключение с контекстом. + + """ + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Запустить миграции в 'online' режиме.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/149d70424ae0_auth_email_verification_and_livekit_.py b/backend/alembic/versions/149d70424ae0_auth_email_verification_and_livekit_.py new file mode 100644 index 0000000..cf0aa8b --- /dev/null +++ b/backend/alembic/versions/149d70424ae0_auth_email_verification_and_livekit_.py @@ -0,0 +1,51 @@ +"""auth email verification and livekit webhook events + +Revision ID: 149d70424ae0 +Revises: 1e2e34a0cb06 +Create Date: 2026-07-15 03:54:52.548246 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '149d70424ae0' +down_revision: Union[str, Sequence[str], None] = '1e2e34a0cb06' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('livekit_webhook_events', + sa.Column('event_id', sa.String(length=255), nullable=False), + sa.Column('event_type', sa.String(length=64), nullable=False), + sa.Column('received_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('event_id') + ) + op.create_table('email_verification_tokens', + sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('token_hash', sa.Text(), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token_hash') + ) + op.create_index('ix_email_verification_tokens_user_id', 'email_verification_tokens', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_email_verification_tokens_user_id', table_name='email_verification_tokens') + op.drop_table('email_verification_tokens') + op.drop_table('livekit_webhook_events') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/1e2e34a0cb06_initial_schema.py b/backend/alembic/versions/1e2e34a0cb06_initial_schema.py new file mode 100644 index 0000000..17fdb6c --- /dev/null +++ b/backend/alembic/versions/1e2e34a0cb06_initial_schema.py @@ -0,0 +1,136 @@ +"""initial schema + +Revision ID: 1e2e34a0cb06 +Revises: +Create Date: 2026-07-15 01:48:41.232692 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '1e2e34a0cb06' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Required for the `room_bookings` EXCLUDE USING gist constraint, which + # mixes an equality operator (room_id) with a range overlap operator + # (period) — btree_gist supplies the GiST operator class for `=` on + # non-range types such as uuid. + op.execute("CREATE EXTENSION IF NOT EXISTS btree_gist") + + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('rooms', + sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('is_pinned', sa.Boolean(), server_default='false', nullable=False), + sa.Column('permanent_link', sa.String(length=64), nullable=False), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('permanent_link') + ) + op.create_table('users', + sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('name_user', sa.String(length=255), nullable=False), + sa.Column('password_hash', sa.Text(), nullable=False), + sa.Column('role', sa.String(length=16), server_default='user', nullable=False), + sa.Column('email_verified', sa.Boolean(), server_default='false', nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint("role IN ('admin', 'user')", name='ck_users_role'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email') + ) + op.create_table('room_bookings', + sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False), + sa.Column('room_id', sa.UUID(), nullable=False), + sa.Column('organizer_id', sa.UUID(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=True), + sa.Column('period', postgresql.TSTZRANGE(), nullable=False), + sa.Column('is_closed', sa.Boolean(), server_default='false', nullable=False), + sa.Column('password_hash', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + postgresql.ExcludeConstraint((sa.column('room_id'), '='), (sa.column('period'), '&&'), using='gist', name='excl_room_bookings_overlap'), + sa.CheckConstraint('NOT isempty(period)', name='ck_room_bookings_period_not_empty'), + sa.ForeignKeyConstraint(['organizer_id'], ['users.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('conferences', + sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False), + sa.Column('room_id', sa.UUID(), nullable=False), + sa.Column('booking_id', sa.UUID(), nullable=True), + sa.Column('title', sa.String(length=255), nullable=True), + sa.Column('t_start', sa.DateTime(timezone=True), nullable=False), + sa.Column('t_end', sa.DateTime(timezone=True), nullable=True), + sa.Column('summary_data', sa.Text(), nullable=True), + sa.Column('pipeline_status', sa.Enum('recording', 'transcribing', 'summarizing', 'notified', 'failed', name='pipeline_status'), server_default='recording', nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['room_bookings.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_conferences_pipeline_status', 'conferences', ['pipeline_status'], unique=False) + op.create_index('ix_conferences_room_id_t_start', 'conferences', ['room_id', 't_start'], unique=False) + op.create_table('chat_messages', + sa.Column('id', sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column('conference_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('text', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_chat_messages_conference_id_created_at', 'chat_messages', ['conference_id', 'created_at'], unique=False) + op.create_table('conference_participants', + sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False), + sa.Column('conference_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('joined_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('left_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_conference_participants_conference_id', 'conference_participants', ['conference_id'], unique=False) + op.create_table('phrases', + sa.Column('id', sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('conference_id', sa.UUID(), nullable=False), + sa.Column('data', sa.Text(), nullable=False), + sa.Column('t_start', sa.DateTime(timezone=True), nullable=False), + sa.Column('t_end', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_phrases_conference_id_t_start', 'phrases', ['conference_id', 't_start'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_phrases_conference_id_t_start', table_name='phrases') + op.drop_table('phrases') + op.drop_index('ix_conference_participants_conference_id', table_name='conference_participants') + op.drop_table('conference_participants') + op.drop_index('ix_chat_messages_conference_id_created_at', table_name='chat_messages') + op.drop_table('chat_messages') + op.drop_index('ix_conferences_room_id_t_start', table_name='conferences') + op.drop_index('ix_conferences_pipeline_status', table_name='conferences') + op.drop_table('conferences') + op.drop_table('room_bookings') + op.drop_table('users') + op.drop_table('rooms') + # ### end Alembic commands ### + postgresql.ENUM(name="pipeline_status").drop(op.get_bind(), checkfirst=True) diff --git a/backend/alembic/versions/299053c6f7b8_booking_access_link_and_participants.py b/backend/alembic/versions/299053c6f7b8_booking_access_link_and_participants.py new file mode 100644 index 0000000..9a43a65 --- /dev/null +++ b/backend/alembic/versions/299053c6f7b8_booking_access_link_and_participants.py @@ -0,0 +1,50 @@ +"""booking access link and participants + +Revision ID: 299053c6f7b8 +Revises: 149d70424ae0 +Create Date: 2026-07-15 16:52:55.554576 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '299053c6f7b8' +down_revision: Union[str, Sequence[str], None] = '149d70424ae0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + 'booking_participants', + sa.Column('booking_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['room_bookings.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('booking_id', 'user_id'), + ) + + # Колонка добавляется nullable, чтобы не упасть на уже существующих + # строках; backfill генерирует уникальный slug каждой существующей + # брони, после чего ограничение NOT NULL накладывается отдельным шагом. + op.add_column('room_bookings', sa.Column('access_link', sa.String(length=43), nullable=True)) + # gen_random_bytes живёт в pgcrypto (в отличие от gen_random_uuid, + # встроенного в ядро с PG13) — расширение включается здесь же. + op.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto") + op.execute("UPDATE room_bookings SET access_link = encode(gen_random_bytes(16), 'hex')") + op.alter_column('room_bookings', 'access_link', nullable=False) + op.create_unique_constraint( + 'uq_room_bookings_access_link', 'room_bookings', ['access_link'] + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_constraint('uq_room_bookings_access_link', 'room_bookings', type_='unique') + op.drop_column('room_bookings', 'access_link') + op.drop_table('booking_participants') diff --git a/backend/alembic/versions/504791847d4f_chat_guest_authors.py b/backend/alembic/versions/504791847d4f_chat_guest_authors.py new file mode 100644 index 0000000..4abf472 --- /dev/null +++ b/backend/alembic/versions/504791847d4f_chat_guest_authors.py @@ -0,0 +1,69 @@ +"""chat guest authors + +Разрешить сообщения чата (`chat_messages`) от гостей и хранить снапшот имени +автора: +- `user_id` становится nullable — автором может быть гость; +- `guest_access_id` — необязательная ссылка на `guest_access`, `ON DELETE + CASCADE` (удаление гостевой записи удаляет и его сообщения чата); +- `author_name` — снапшот отображаемого имени из LiveKit-токена на момент + отправки; добавляется nullable, backfill из `users.name_user` для уже + существующих строк (все они с `user_id`, т.к. гостевого автора раньше не + было), затем ужесточается до `NOT NULL`; +- `ck_chat_messages_author` — ровно один из `user_id`/`guest_access_id` + обязателен (как у `ConferenceParticipant`, ADR-001, п.6). + +Revision ID: 504791847d4f +Revises: 9d37822e4513 +Create Date: 2026-07-18 18:45:20.921653 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '504791847d4f' +down_revision: Union[str, Sequence[str], None] = '9d37822e4513' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column('chat_messages', sa.Column('guest_access_id', sa.UUID(), nullable=True)) + op.add_column('chat_messages', sa.Column('author_name', sa.String(length=255), nullable=True)) + op.alter_column('chat_messages', 'user_id', existing_type=sa.UUID(), nullable=True) + + op.create_foreign_key( + 'fk_chat_messages_guest_access_id_guest_access', + 'chat_messages', 'guest_access', ['guest_access_id'], ['id'], ondelete='CASCADE', + ) + + # Backfill: до этой миграции автор всегда был зарегистрированным + # пользователем — берём снапшот его текущего имени. + op.execute( + """ + UPDATE chat_messages cm + SET author_name = u.name_user + FROM users u + WHERE cm.user_id = u.id + """ + ) + op.alter_column('chat_messages', 'author_name', existing_type=sa.String(length=255), nullable=False) + + op.create_check_constraint( + 'ck_chat_messages_author', + 'chat_messages', + 'user_id IS NOT NULL OR guest_access_id IS NOT NULL', + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_constraint('ck_chat_messages_author', 'chat_messages', type_='check') + op.drop_constraint('fk_chat_messages_guest_access_id_guest_access', 'chat_messages', type_='foreignkey') + op.alter_column('chat_messages', 'user_id', existing_type=sa.UUID(), nullable=False) + op.drop_column('chat_messages', 'author_name') + op.drop_column('chat_messages', 'guest_access_id') diff --git a/backend/alembic/versions/5970bf64fc43_settings_notifications.py b/backend/alembic/versions/5970bf64fc43_settings_notifications.py new file mode 100644 index 0000000..b7994e5 --- /dev/null +++ b/backend/alembic/versions/5970bf64fc43_settings_notifications.py @@ -0,0 +1,108 @@ +"""settings and notifications + +Настройки инстанса и журнал почтовых рассылок: +- `instance_settings` — key-value настройки инстанса (JSONB), бутстрап из + `config/plugins.yaml` в lifespan backend (`services/instance_settings.py`); +- `email_deliveries` — идемпотентность рассылки саммари (уникальный частичный + индекс по `(session_id, recipient_email)` при `kind='summary'`) и журнал + приглашений (`kind='invitation'`, без unique — переслать обновление + расписания обязано дублировать письмо); +- `conferences.summary_recipients` — переопределение рассылки для конкретной + конференции (`NULL` = дефолт инстанса), `conferences.ics_sequence` — + счётчик изменений расписания для VEVENT `SEQUENCE`; +- `users.is_blocked` — блокировка администратором, проверяется немедленно в + `api/deps.py::_user_from_token`. + +Revision ID: 5970bf64fc43 +Revises: 88aa676ac140 +Create Date: 2026-07-17 23:24:46.699304 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = '5970bf64fc43' +down_revision: Union[str, Sequence[str], None] = '88aa676ac140' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + 'instance_settings', + sa.Column('key', sa.String(), nullable=False), + sa.Column('value', postgresql.JSONB(), nullable=False), + sa.Column( + 'updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), + nullable=False, + ), + sa.PrimaryKeyConstraint('key'), + ) + + op.create_table( + 'email_deliveries', + sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False), + sa.Column('session_id', sa.UUID(), nullable=True), + sa.Column('conference_id', sa.UUID(), nullable=True), + sa.Column('recipient_email', sa.String(length=320), nullable=False), + sa.Column('kind', sa.String(length=16), nullable=False), + sa.Column( + 'sent_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), + nullable=False, + ), + sa.CheckConstraint("kind IN ('summary', 'invitation')", name='ck_email_deliveries_kind'), + sa.CheckConstraint( + '(kind = \'summary\') = (session_id IS NOT NULL)', + name='ck_email_deliveries_summary_has_session', + ), + sa.CheckConstraint( + '(kind = \'invitation\') = (conference_id IS NOT NULL)', + name='ck_email_deliveries_invitation_has_conference', + ), + sa.ForeignKeyConstraint(['session_id'], ['conference_sessions.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index( + 'uq_email_deliveries_summary', 'email_deliveries', ['session_id', 'recipient_email'], + unique=True, postgresql_where=sa.text("kind = 'summary'"), + ) + op.create_index( + 'ix_email_deliveries_conference', 'email_deliveries', ['conference_id'], + ) + + op.add_column('conferences', sa.Column('summary_recipients', sa.String(length=16), nullable=True)) + op.add_column( + 'conferences', + sa.Column('ics_sequence', sa.Integer(), server_default='0', nullable=False), + ) + op.create_check_constraint( + 'ck_conferences_summary_recipients', + 'conferences', + "summary_recipients IN ('all', 'owner')", + ) + + op.add_column( + 'users', sa.Column('is_blocked', sa.Boolean(), server_default='false', nullable=False) + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column('users', 'is_blocked') + + op.drop_constraint('ck_conferences_summary_recipients', 'conferences', type_='check') + op.drop_column('conferences', 'ics_sequence') + op.drop_column('conferences', 'summary_recipients') + + op.drop_index('ix_email_deliveries_conference', table_name='email_deliveries') + op.drop_index('uq_email_deliveries_summary', table_name='email_deliveries') + op.drop_table('email_deliveries') + + op.drop_table('instance_settings') diff --git a/backend/alembic/versions/88aa676ac140_audio_tracks_and_phrase_attribution.py b/backend/alembic/versions/88aa676ac140_audio_tracks_and_phrase_attribution.py new file mode 100644 index 0000000..ec67a81 --- /dev/null +++ b/backend/alembic/versions/88aa676ac140_audio_tracks_and_phrase_attribution.py @@ -0,0 +1,109 @@ +"""session_audio_tracks + атрибуция phrases к участнику (ADR-002) + +Схема БД для записи аудиодорожек сеанса и атрибуции фраз: +- новая таблица `session_audio_tracks` — одна аудиодорожка сеанса, записанная + LiveKit Track Egress (per-track, трек = спикер, диаризация не нужна); +- `phrases`: `user_id` -> `participant_id` (FK `conference_participants.id`, + ON DELETE CASCADE) — атрибуция фразы к окну присутствия участника сеанса + (пользователя ИЛИ гостя), а не напрямую к `users` + (`docs/architecture/adr/002-phrase-attribution-session-participant.md`). + +Продакшен-данных нет (см. ADR-002, контекст) — простая замена колонки без +backfill; единственная строка `phrases`, оставшаяся в дев-БД от ручного +тестирования, удаляется явно (см. `_clear_dev_phrases`), т.к. её +`user_id` не сопоставим ни с одним `conference_participants.id`. Downgrade +симметричен и данные `phrases` не восстанавливает (как и в f418dd65e7b1). + +Revision ID: 88aa676ac140 +Revises: f418dd65e7b1 +Create Date: 2026-07-17 10:30:10.456704 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql +from sqlalchemy.engine import Connection + +# revision identifiers, used by Alembic. +revision: str = '88aa676ac140' +down_revision: Union[str, Sequence[str], None] = 'f418dd65e7b1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _clear_dev_phrases(connection: Connection) -> None: + """Удалить строки `phrases`, оставшиеся от ручного тестирования до ADR-002. + + Продакшен-данных нет (см. докстринг ревизии) — на пустой таблице это no-op. + """ + connection.execute(sa.text("DELETE FROM phrases")) + + +def upgrade() -> None: + """Upgrade schema.""" + bind = op.get_bind() + + op.create_table( + 'session_audio_tracks', + sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False), + sa.Column('session_id', sa.UUID(), nullable=False), + sa.Column('participant_id', sa.UUID(), nullable=False), + sa.Column('track_sid', sa.String(length=64), nullable=False), + sa.Column('egress_id', sa.String(length=64), nullable=True), + sa.Column('file_path', sa.Text(), nullable=True), + sa.Column( + 'status', + sa.Enum('recording', 'recorded', 'transcribed', 'failed', name='audio_track_status'), + server_default='recording', + nullable=False, + ), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('ended_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('segments', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.ForeignKeyConstraint( + ['participant_id'], ['conference_participants.id'], ondelete='CASCADE' + ), + sa.ForeignKeyConstraint(['session_id'], ['conference_sessions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('session_id', 'track_sid', name='uq_session_track'), + ) + op.create_index( + 'ix_session_audio_tracks_session_id', 'session_audio_tracks', ['session_id'] + ) + op.create_index( + 'ix_session_audio_tracks_session_id_status', + 'session_audio_tracks', + ['session_id', 'status'], + ) + + _clear_dev_phrases(bind) + op.drop_constraint('phrases_user_id_fkey', 'phrases', type_='foreignkey') + op.drop_column('phrases', 'user_id') + op.add_column('phrases', sa.Column('participant_id', sa.UUID(), nullable=False)) + op.create_foreign_key( + 'phrases_participant_id_fkey', + 'phrases', 'conference_participants', + ['participant_id'], ['id'], ondelete='CASCADE', + ) + + +def downgrade() -> None: + """Downgrade schema. + + Разрушительная миграция для `phrases` (см. докстринг ревизии выше) — + downgrade восстанавливает структуру колонки, но не исходные данные. + """ + bind = op.get_bind() + + _clear_dev_phrases(bind) + op.drop_constraint('phrases_participant_id_fkey', 'phrases', type_='foreignkey') + op.drop_column('phrases', 'participant_id') + op.add_column('phrases', sa.Column('user_id', sa.UUID(), nullable=False)) + op.create_foreign_key('phrases_user_id_fkey', 'phrases', 'users', ['user_id'], ['id']) + + op.drop_index('ix_session_audio_tracks_session_id_status', table_name='session_audio_tracks') + op.drop_index('ix_session_audio_tracks_session_id', table_name='session_audio_tracks') + op.drop_table('session_audio_tracks') + postgresql.ENUM(name='audio_track_status').drop(bind, checkfirst=True) diff --git a/backend/alembic/versions/9d37822e4513_teams.py b/backend/alembic/versions/9d37822e4513_teams.py new file mode 100644 index 0000000..2e25fbf --- /dev/null +++ b/backend/alembic/versions/9d37822e4513_teams.py @@ -0,0 +1,49 @@ +"""teams + +Справочник команд и привязка пользователя к команде: +- `teams` — id/name (уникально)/created_at; +- `users.team_id` — необязательная ссылка на команду, `ON DELETE SET NULL` + (удаление команды не удаляет пользователей, только снимает привязку). + +Revision ID: 9d37822e4513 +Revises: 5970bf64fc43 +Create Date: 2026-07-18 03:00:27.636900 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9d37822e4513' +down_revision: Union[str, Sequence[str], None] = '5970bf64fc43' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + 'teams', + sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column( + 'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), + nullable=False, + ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name'), + ) + op.add_column('users', sa.Column('team_id', sa.UUID(), nullable=True)) + op.create_foreign_key( + 'fk_users_team_id_teams', 'users', 'teams', ['team_id'], ['id'], ondelete='SET NULL' + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_constraint('fk_users_team_id_teams', 'users', type_='foreignkey') + op.drop_column('users', 'team_id') + op.drop_table('teams') diff --git a/backend/alembic/versions/d87681e12784_conference_invitees_and_avatars.py b/backend/alembic/versions/d87681e12784_conference_invitees_and_avatars.py new file mode 100644 index 0000000..23237ff --- /dev/null +++ b/backend/alembic/versions/d87681e12784_conference_invitees_and_avatars.py @@ -0,0 +1,76 @@ +"""conference invitees and avatars + +Реализует раздел «Модель данных» ADR-003 +(`docs/architecture/adr/003-conference-invitees.md`): +- новая таблица `conference_invitees` — приглашённые НА КОНФЕРЕНЦИЮ + (зарегистрированный `user_id` ИЛИ внешний `email`, ровно одна identity); + организатор в таблице не хранится (выводится из `conferences.owner_id`); +- `users.avatar_path` — путь к загруженному аватару, + `NULL` — заглушка с инициалами на фронте. + +Revision ID: d87681e12784 +Revises: 504791847d4f +Create Date: 2026-07-19 12:03:06.294730 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd87681e12784' +down_revision: Union[str, Sequence[str], None] = '504791847d4f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + 'conference_invitees', + sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False), + sa.Column('conference_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=True), + sa.Column('email', sa.String(length=255), nullable=True), + sa.Column( + 'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), + nullable=False, + ), + sa.CheckConstraint( + '(user_id IS NOT NULL)::int + (email IS NOT NULL)::int = 1', + name='ck_conference_invitees_single_identity', + ), + sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + # Частичные уникальные индексы (ADR-003, п.1): дубль по зарегистрированному + # пользователю или по email (регистронезависимо — `lower(email)`, email + # приложение хранит уже в lower-case, индекс — доп. страховка). + op.create_index( + 'uq_conference_invitees_user', + 'conference_invitees', + ['conference_id', 'user_id'], + unique=True, + postgresql_where=sa.text('user_id IS NOT NULL'), + ) + op.execute( + """ + CREATE UNIQUE INDEX uq_conference_invitees_email + ON conference_invitees (conference_id, lower(email)) + WHERE email IS NOT NULL + """ + ) + + op.add_column('users', sa.Column('avatar_path', sa.String(length=512), nullable=True)) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column('users', 'avatar_path') + + op.execute('DROP INDEX IF EXISTS uq_conference_invitees_email') + op.drop_index('uq_conference_invitees_user', table_name='conference_invitees') + op.drop_table('conference_invitees') diff --git a/backend/alembic/versions/f418dd65e7b1_dynamic_conferences.py b/backend/alembic/versions/f418dd65e7b1_dynamic_conferences.py new file mode 100644 index 0000000..35563bc --- /dev/null +++ b/backend/alembic/versions/f418dd65e7b1_dynamic_conferences.py @@ -0,0 +1,366 @@ +"""dynamic conferences (ADR-001) + +Реализует раздел «Модель данных» ADR-001 (`docs/architecture/adr/001-dynamic-conferences-pivot.md`): +- переименование `conferences` (сеанс) в `conference_sessions`; +- новая сущность `conferences` (конференция: номер, ссылка, владелец, статус, + закрепление/закрытость, расписание, recurrence); +- backfill новых `conferences` из существующих `rooms` (по одной на комнату, + на которую ссылается хотя бы один сеанс) и перевязка `conference_sessions`; +- переименование `conference_id` -> `session_id` в `phrases`/`chat_messages`/ + `conference_participants`; +- `conference_participants`: `user_id` NULLABLE + `guest_id` + CHECK «ровно + одно из двух заполнено»; +- новая таблица `guest_access`; +- удаление `booking_participants`, `room_bookings` (вместе с ней уходит + EXCLUDE-constraint — см. ADR-001, п.5) и `rooms`. + +Продакшен-данных нет (см. контекст ADR-001) — backfill рассчитан на +непустую тестовую/дев БД, но не падает и на пустой (см. `_backfill_conferences_from_rooms`). + +Revision ID: f418dd65e7b1 +Revises: 299053c6f7b8 +Create Date: 2026-07-16 12:00:00.000000 + +""" +import secrets +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql +from sqlalchemy.engine import Connection + +# revision identifiers, used by Alembic. +revision: str = 'f418dd65e7b1' +down_revision: Union[str, Sequence[str], None] = '299053c6f7b8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _generate_conference_number(used: set[str]) -> str: + """Сгенерировать 9-значный номер конференции, уникальный в рамках backfill. + + Логика идентична `services/conference_ids.py::generate_number` — миграции + не импортируют прикладной код (он может измениться со временем и + сломать применение старых ревизий), поэтому продублирована здесь. + """ + while True: + first_digit = str(secrets.randbelow(9) + 1) + rest_digits = "".join(str(secrets.randbelow(10)) for _ in range(8)) + number = first_digit + rest_digits + if number not in used: + used.add(number) + return number + + +def _backfill_conferences_from_rooms(connection: Connection) -> None: + """Создать по одной `conferences`-записи на каждую `room`, встречающуюся в сеансах. + + `slug` = `permanent_link` комнаты (сохраняет действующие постоянные + ссылки), `status='ended'` (это уже прожитая история, а не активная + конференция), `owner_id` не определён (комнаты были общими). На пустой + БД (нет строк `rooms`) цикл просто не выполняется. + """ + rooms = connection.execute( + sa.text( + """ + SELECT DISTINCT r.id, r.name, r.permanent_link, r.is_pinned, r.created_at + FROM rooms r + WHERE EXISTS (SELECT 1 FROM conference_sessions cs WHERE cs.room_id = r.id) + """ + ) + ).mappings().all() + + used_numbers: set[str] = set() + for room in rooms: + connection.execute( + sa.text( + """ + INSERT INTO conferences + (id, number, slug, title, status, is_pinned, created_at) + VALUES + (gen_random_uuid(), :number, :slug, :title, 'ended', :is_pinned, :created_at) + """ + ), + { + "number": _generate_conference_number(used_numbers), + "slug": room["permanent_link"], + "title": room["name"], + "is_pinned": room["is_pinned"], + "created_at": room["created_at"], + }, + ) + + +def upgrade() -> None: + """Upgrade schema.""" + bind = op.get_bind() + + # 1. conferences (сеанс) -> conference_sessions. FK-constraints и данные + # сохраняются автоматически (Postgres переносит их вместе с таблицей, + # constraint-имена, унаследованные от старого имени таблицы, не переименовываются — + # это косметика, на работу не влияет). Индексы на удаляемой ниже колонке + # `room_id` пересоздаём под новую модель. + op.rename_table('conferences', 'conference_sessions') + op.drop_index('ix_conferences_room_id_t_start', table_name='conference_sessions') + op.drop_index('ix_conferences_pipeline_status', table_name='conference_sessions') + op.create_index( + 'ix_conference_sessions_pipeline_status', 'conference_sessions', ['pipeline_status'] + ) + + # 2. Новая сущность Conference + её ENUM жизненного цикла (ADR-001, п.2). + # Тип создаётся автоматически вместе с таблицей (create_type=True по + # умолчанию) — отдельный `.create()` здесь не нужен и приводит к + # DuplicateObjectError при повторном создании тем же вызовом create_table. + conference_status = postgresql.ENUM('scheduled', 'active', 'ended', name='conference_status') + op.create_table( + 'conferences', + sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False), + sa.Column('number', sa.String(length=9), nullable=False), + sa.Column('slug', sa.String(length=22), nullable=False), + sa.Column('title', sa.String(length=255), nullable=True), + sa.Column('owner_id', sa.UUID(), nullable=True), + sa.Column('status', conference_status, server_default='scheduled', nullable=False), + sa.Column('is_pinned', sa.Boolean(), server_default='false', nullable=False), + sa.Column('is_closed', sa.Boolean(), server_default='false', nullable=False), + sa.Column('password_hash', sa.Text(), nullable=True), + sa.Column('scheduled_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('duration_minutes', sa.Integer(), nullable=True), + sa.Column('recurrence', postgresql.JSONB(), nullable=True), + sa.Column('ended_at', sa.DateTime(timezone=True), nullable=True), + sa.Column( + 'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), + nullable=False, + ), + sa.CheckConstraint( + 'is_closed = false OR password_hash IS NOT NULL', + name='ck_conferences_closed_requires_password', + ), + sa.CheckConstraint( + 'recurrence IS NULL OR is_pinned = true', + name='ck_conferences_recurrence_requires_pinned', + ), + sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('number'), + sa.UniqueConstraint('slug'), + ) + + # 3. Backfill: одна конференция на каждую room, на которую ссылались сеансы. + _backfill_conferences_from_rooms(bind) + + # 4. Перевязка: conference_sessions получает conference_id, найденный через + # исходный room_id (join по slug == permanent_link, который backfill + # сохранил равным исходной ссылке комнаты), после чего room_id/booking_id уходят. + op.add_column('conference_sessions', sa.Column('conference_id', sa.UUID(), nullable=True)) + op.execute( + """ + UPDATE conference_sessions cs + SET conference_id = c.id + FROM rooms r + JOIN conferences c ON c.slug = r.permanent_link + WHERE cs.room_id = r.id + """ + ) + op.alter_column('conference_sessions', 'conference_id', nullable=False) + op.create_foreign_key( + 'conference_sessions_conference_id_fkey', + 'conference_sessions', 'conferences', + ['conference_id'], ['id'], ondelete='CASCADE', + ) + op.create_index( + 'ix_conference_sessions_conference_id_t_start', + 'conference_sessions', ['conference_id', 't_start'], + ) + op.drop_constraint('conferences_room_id_fkey', 'conference_sessions', type_='foreignkey') + op.drop_constraint('conferences_booking_id_fkey', 'conference_sessions', type_='foreignkey') + op.drop_column('conference_sessions', 'room_id') + op.drop_column('conference_sessions', 'booking_id') + + # 5. conference_id -> session_id в phrases/chat_messages/conference_participants. + # FK на conference_sessions(id) сохраняется автоматически (см. п.1); индексы + # переименовываются вслед за колонкой. + op.alter_column('phrases', 'conference_id', new_column_name='session_id') + op.execute('ALTER INDEX ix_phrases_conference_id_t_start RENAME TO ix_phrases_session_id_t_start') + + op.alter_column('chat_messages', 'conference_id', new_column_name='session_id') + op.execute( + 'ALTER INDEX ix_chat_messages_conference_id_created_at ' + 'RENAME TO ix_chat_messages_session_id_created_at' + ) + + op.alter_column('conference_participants', 'conference_id', new_column_name='session_id') + op.execute( + 'ALTER INDEX ix_conference_participants_conference_id ' + 'RENAME TO ix_conference_participants_session_id' + ) + + # 6. guest_access — создаётся до правки conference_participants, т.к. её + # новый guest_id ссылается на эту таблицу (порядок из-за FK-зависимости + # отличается от порядка перечисления в ADR-001, итоговая схема та же). + op.create_table( + 'guest_access', + sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False), + sa.Column('conference_id', sa.UUID(), nullable=False), + sa.Column('display_name', sa.String(length=255), nullable=False), + sa.Column('email', sa.String(length=320), nullable=True), + sa.Column( + 'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), + nullable=False, + ), + sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + + # 7. conference_participants: user_id nullable, guest_id, CHECK «ровно одно из двух». + op.alter_column('conference_participants', 'user_id', nullable=True) + op.add_column('conference_participants', sa.Column('guest_id', sa.UUID(), nullable=True)) + op.create_foreign_key( + 'conference_participants_guest_id_fkey', + 'conference_participants', 'guest_access', + ['guest_id'], ['id'], + ) + op.create_check_constraint( + 'ck_conference_participants_exactly_one_identity', + 'conference_participants', + '(user_id IS NOT NULL)::int + (guest_id IS NOT NULL)::int = 1', + ) + + # 8. Комнаты и бронирование уходят вместе с EXCLUDE-constraint'ом + # (ADR-001, п.5); список допущенных участников брони не переносится — + # закрытая конференция теперь защищена только паролем. + op.drop_table('booking_participants') + op.drop_table('room_bookings') + op.drop_table('rooms') + + +def downgrade() -> None: + """Downgrade schema. + + Разрушительная миграция (ADR-001: «продакшен-данных нет — допустима + структурная миграция»): downgrade восстанавливает схему, но не исходные + данные комнат/броней/номеров — они не хранились обратимо после backfill. + """ + op.create_table( + 'rooms', + sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('is_pinned', sa.Boolean(), server_default='false', nullable=False), + sa.Column('permanent_link', sa.String(length=64), nullable=False), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column( + 'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), + nullable=False, + ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('permanent_link'), + ) + op.create_table( + 'room_bookings', + sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False), + sa.Column('room_id', sa.UUID(), nullable=False), + sa.Column('organizer_id', sa.UUID(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=True), + sa.Column('period', postgresql.TSTZRANGE(), nullable=False), + sa.Column('is_closed', sa.Boolean(), server_default='false', nullable=False), + sa.Column('password_hash', sa.Text(), nullable=True), + sa.Column('access_link', sa.String(length=43), nullable=False), + sa.Column( + 'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), + nullable=False, + ), + postgresql.ExcludeConstraint( + (sa.column('room_id'), '='), (sa.column('period'), '&&'), + using='gist', name='excl_room_bookings_overlap', + ), + sa.CheckConstraint('NOT isempty(period)', name='ck_room_bookings_period_not_empty'), + sa.ForeignKeyConstraint(['organizer_id'], ['users.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('access_link'), + ) + op.create_table( + 'booking_participants', + sa.Column('booking_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['room_bookings.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('booking_id', 'user_id'), + ) + + op.drop_constraint( + 'ck_conference_participants_exactly_one_identity', 'conference_participants', + type_='check', + ) + op.drop_constraint( + 'conference_participants_guest_id_fkey', 'conference_participants', type_='foreignkey' + ) + op.drop_column('conference_participants', 'guest_id') + op.alter_column('conference_participants', 'user_id', nullable=False) + + op.drop_table('guest_access') + + op.execute( + 'ALTER INDEX ix_conference_participants_session_id ' + 'RENAME TO ix_conference_participants_conference_id' + ) + op.alter_column('conference_participants', 'session_id', new_column_name='conference_id') + + op.execute( + 'ALTER INDEX ix_chat_messages_session_id_created_at ' + 'RENAME TO ix_chat_messages_conference_id_created_at' + ) + op.alter_column('chat_messages', 'session_id', new_column_name='conference_id') + + op.execute('ALTER INDEX ix_phrases_session_id_t_start RENAME TO ix_phrases_conference_id_t_start') + op.alter_column('phrases', 'session_id', new_column_name='conference_id') + + op.add_column('conference_sessions', sa.Column('room_id', sa.UUID(), nullable=True)) + op.add_column('conference_sessions', sa.Column('booking_id', sa.UUID(), nullable=True)) + + # Реконструируем rooms из conferences, на которые ссылаются сеансы — + # операция, обратная backfill'у в upgrade(). `is_active` не хранилось + # раздельно от новой модели — восстанавливаем как `true`. + op.execute( + """ + INSERT INTO rooms (id, name, permanent_link, is_pinned, is_active, created_at) + SELECT gen_random_uuid(), COALESCE(c.title, c.slug), c.slug, c.is_pinned, true, c.created_at + FROM conferences c + WHERE EXISTS (SELECT 1 FROM conference_sessions cs WHERE cs.conference_id = c.id) + """ + ) + op.execute( + """ + UPDATE conference_sessions cs + SET room_id = r.id + FROM conferences c + JOIN rooms r ON r.permanent_link = c.slug + WHERE cs.conference_id = c.id + """ + ) + op.create_foreign_key( + 'conferences_room_id_fkey', 'conference_sessions', 'rooms', ['room_id'], ['id'], + ondelete='CASCADE', + ) + op.create_foreign_key( + 'conferences_booking_id_fkey', 'conference_sessions', 'room_bookings', ['booking_id'], + ['id'], ondelete='SET NULL', + ) + op.drop_index('ix_conference_sessions_conference_id_t_start', table_name='conference_sessions') + op.drop_constraint( + 'conference_sessions_conference_id_fkey', 'conference_sessions', type_='foreignkey' + ) + op.drop_column('conference_sessions', 'conference_id') + op.alter_column('conference_sessions', 'room_id', nullable=False) + + op.drop_table('conferences') + postgresql.ENUM(name='conference_status').drop(op.get_bind(), checkfirst=True) + + op.drop_index('ix_conference_sessions_pipeline_status', table_name='conference_sessions') + op.create_index( + 'ix_conferences_room_id_t_start', 'conference_sessions', ['room_id', 't_start'] + ) + op.create_index( + 'ix_conferences_pipeline_status', 'conference_sessions', ['pipeline_status'] + ) + op.rename_table('conference_sessions', 'conferences') diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/admin.py b/backend/api/admin.py new file mode 100644 index 0000000..5e3aeb1 --- /dev/null +++ b/backend/api/admin.py @@ -0,0 +1,464 @@ +"""Роутер администрирования: конференции, пользователи, команды, настройки. + +Все эндпоинты требуют роль `admin` (`Depends(require_admin)`, 403 иначе). +Правки конференций/удаление переиспользуют `ConferenceService` (тот же +бизнес-слой, что и обычный роутер конференций) — админ проходит проверку +владения как «или владелец, или админ» (см. `ConferenceService._ensure_owner_or_admin`). +Рассылка приглашений и вся отправка писем — только в Celery-задачах; здесь +лишь постановка в очередь и немедленный ответ `202`. +Справочник команд (`teams`) — простой CRUD без бизнес-правил, кроме +уникальности названия; привязка пользователя к команде — `users.team_id` +(`ON DELETE SET NULL`). +""" + +import uuid +from pathlib import Path +from typing import Annotated + +import anyio +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status +from sqlalchemy.ext.asyncio import AsyncSession + +from api.deps import require_admin + +# Алиас обязателен: ниже в этом модуле уже есть роутер-хендлер `get_settings` +# (`GET /admin/settings`) — без переименования он затирает имя импортированной +# функции в globals модуля (последнее связывание имени побеждает). +from core.config import get_settings as get_app_settings +from core.db import get_session +from core.plugins.config import InstanceConfig +from core.security import hash_password +from models.conference import Conference +from models.user import User +from repositories.admin import AdminConferenceRepository, AdminUserRepository, TeamRepository +from repositories.users import UserRepository +from schemas.admin import ( + AdminConferenceListOut, + AdminConferenceOut, + AdminUserCreateIn, + AdminUserListOut, + AdminUserOut, + AdminUserUpdateIn, + InvitationsSendIn, + SettingsOut, + TeamCreateIn, + TeamListOut, + TeamOut, + TeamUpdateIn, +) +from schemas.conferences import ConferenceUpdateIn +from services.ai_levels import detect_ai_levels +from services.avatars import AvatarInvalidTypeError, AvatarTooLargeError, avatar_url +from services.conferences import ( + ConferenceActiveError, + ConferenceNotFoundError, + ConferenceService, + InvalidConferenceStateError, + NotConferenceOwnerError, +) +from services.instance_settings import ( + InstanceSettingsService, + InvalidAiLevelError, + InvalidEmailDomainError, + InvalidTimezoneError, + SettingsUpdateIn, +) +from services.invitations_producer import enqueue_invitations +from services.pipeline_producer import transcription_queue_served +from services.profile import resolve_team_name, set_avatar + +router = APIRouter(prefix="/api/v1/admin", tags=["admin"]) + +DEFAULT_LIMIT = 50 +MAX_LIMIT = 200 + + +# --- Конференции ------------------------------------------------------------------ + + +@router.get("/conferences", response_model=AdminConferenceListOut) +async def list_conferences( + admin: Annotated[User, Depends(require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], + status_filter: Annotated[str | None, Query(alias="status")] = None, + q: Annotated[str | None, Query()] = None, + limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT, + offset: Annotated[int, Query(ge=0)] = 0, +) -> AdminConferenceListOut: + """Список всех конференций инстанса с фильтром по статусу и текстовым поиском.""" + rows, total = await AdminConferenceRepository(session).list_paginated( + status=status_filter, q=q, limit=limit, offset=offset + ) + service = ConferenceService(session) + items = [ + _to_admin_conference_out( + service, conference, viewer_id=admin.id, owner_name=owner_name, owner_email=owner_email + ) + for conference, owner_name, owner_email in rows + ] + return AdminConferenceListOut(items=items, total=total) + + +@router.patch("/conferences/{conference_id}", response_model=AdminConferenceOut) +async def update_conference( + conference_id: uuid.UUID, + data: ConferenceUpdateIn, + admin: Annotated[User, Depends(require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> AdminConferenceOut: + """Изменить любую конференцию инстанса (реюз `ConferenceService.update`).""" + service = ConferenceService(session) + try: + conference = await service.update(conference_id, actor=admin, data=data) + except ConferenceNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found" + ) from exc + except NotConferenceOwnerError as exc: # недостижимо для admin, оставлено для полноты + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc + except InvalidConferenceStateError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + owner_name, owner_email = await _load_owner(session, conference) + return _to_admin_conference_out( + service, conference, viewer_id=admin.id, owner_name=owner_name, owner_email=owner_email + ) + + +@router.delete("/conferences/{conference_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_conference( + conference_id: uuid.UUID, + admin: Annotated[User, Depends(require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> None: + """Удалить любую конференцию инстанса (реюз `ConferenceService.delete`, 409 для активной).""" + service = ConferenceService(session) + try: + await service.delete(conference_id, actor=admin) + except ConferenceNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found" + ) from exc + except ConferenceActiveError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="conference_active" + ) from exc + + +@router.post( + "/conferences/{conference_id}/invitations", + status_code=status.HTTP_202_ACCEPTED, +) +async def send_conference_invitations( + conference_id: uuid.UUID, + admin: Annotated[User, Depends(require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], + data: InvitationsSendIn = InvitationsSendIn(), +) -> None: + """Поставить в очередь ручную рассылку .ics-приглашений (отправка — только в Celery).""" + conference = await session.get(Conference, conference_id) + if conference is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found") + enqueue_invitations(conference_id, emails=data.emails) + + +# --- Пользователи ------------------------------------------------------------------ + + +@router.get("/users", response_model=AdminUserListOut) +async def list_users( + admin: Annotated[User, Depends(require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], + q: Annotated[str | None, Query()] = None, + limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT, + offset: Annotated[int, Query(ge=0)] = 0, +) -> AdminUserListOut: + """Список всех пользователей инстанса с текстовым поиском по email/имени.""" + rows, total = await AdminUserRepository(session).list_paginated(q=q, limit=limit, offset=offset) + media_root = _media_root() + items = [ + _to_admin_user_out(user, team_name=team_name, media_root=media_root) + for user, team_name in rows + ] + return AdminUserListOut(items=items, total=total) + + +@router.post("/users", response_model=AdminUserOut, status_code=status.HTTP_201_CREATED) +async def create_user( + data: AdminUserCreateIn, + admin: Annotated[User, Depends(require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> AdminUserOut: + """Создать пользователя от имени администратора. + + В отличие от самостоятельной регистрации (`POST /auth/register`), + email сразу считается подтверждённым (`email_verified=True`) — письмо с + подтверждением не отправляется; роль по умолчанию — `user`. Дубль email — + 409 `email_already_registered` (тот же код, что у публичной регистрации); + несуществующая команда — 404 `team_not_found`. + """ + repo = UserRepository(session) + if await repo.get_by_email(data.email) is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="email_already_registered") + if data.team_id is not None: + team = await TeamRepository(session).get(data.team_id) + if team is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="team_not_found") + + user = await repo.create( + email=data.email, + name_user=data.name_user, + password_hash=hash_password(data.password), + team_id=data.team_id, + ) + user.email_verified = True + await session.commit() + team_name = await resolve_team_name(session, user.team_id) + return _to_admin_user_out(user, team_name=team_name, media_root=_media_root()) + + +@router.get("/users/{user_id}", response_model=AdminUserOut) +async def get_user( + user_id: uuid.UUID, + admin: Annotated[User, Depends(require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> AdminUserOut: + """Карточка профиля пользователя — те же данные, что в своём профиле.""" + row = await AdminUserRepository(session).get_with_team(user_id) + if row is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") + user, team_name = row + return _to_admin_user_out(user, team_name=team_name, media_root=_media_root()) + + +@router.patch("/users/{user_id}", response_model=AdminUserOut) +async def update_user( + user_id: uuid.UUID, + data: AdminUserUpdateIn, + admin: Annotated[User, Depends(require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> AdminUserOut: + """Изменить роль/блокировку/ФИО/команду пользователя. + + Запрет самоизменения (409) распространяется только на `role`/`is_blocked` — + своё ФИО/команду админ менять может (та же карточка). + """ + if user_id == admin.id and (data.role is not None or data.is_blocked is not None): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="cannot_modify_self") + + user = await session.get(User, user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") + + if data.role is not None: + user.role = data.role + if data.is_blocked is not None: + user.is_blocked = data.is_blocked + if data.name_user is not None: + user.name_user = data.name_user + if "team_id" in data.model_fields_set: + # Явная передача (в т.ч. `null`) — назначить/снять команду; отсутствие + # поля в запросе значение не трогает (тот же паттерн, что + # `summary_recipients` в `services/conferences.py`). + if data.team_id is not None: + team = await TeamRepository(session).get(data.team_id) + if team is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="team_not_found") + user.team_id = data.team_id + await session.commit() + team_name = await resolve_team_name(session, user.team_id) + return _to_admin_user_out(user, team_name=team_name, media_root=_media_root()) + + +@router.post("/users/{user_id}/avatar", response_model=AdminUserOut) +async def upload_user_avatar( + user_id: uuid.UUID, + admin: Annotated[User, Depends(require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], + file: Annotated[UploadFile, File()], +) -> AdminUserOut: + """Загрузить аватар любому пользователю (та же валидация, что `POST /users/me/avatar`).""" + user = await session.get(User, user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") + try: + await set_avatar(_media_root(), user, file) + except AvatarTooLargeError as exc: + raise HTTPException( + status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="avatar_too_large" + ) from exc + except AvatarInvalidTypeError as exc: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="avatar_invalid_type" + ) from exc + await session.commit() + team_name = await resolve_team_name(session, user.team_id) + return _to_admin_user_out(user, team_name=team_name, media_root=_media_root()) + + +# --- Команды ------------------------------------------------------------------------ + + +@router.get("/teams", response_model=TeamListOut) +async def list_teams( + admin: Annotated[User, Depends(require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> TeamListOut: + """Список всех команд, отсортированный по названию.""" + items, total = await TeamRepository(session).list_all() + return TeamListOut(items=[TeamOut.model_validate(team) for team in items], total=total) + + +@router.post("/teams", response_model=TeamOut, status_code=status.HTTP_201_CREATED) +async def create_team( + data: TeamCreateIn, + admin: Annotated[User, Depends(require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> TeamOut: + """Создать команду; дубль названия (регистрозависимо) — 409.""" + repo = TeamRepository(session) + if await repo.get_by_name(data.name) is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="team_name_taken") + team = await repo.create(data.name) + await session.commit() + return TeamOut.model_validate(team) + + +@router.patch("/teams/{team_id}", response_model=TeamOut) +async def update_team( + team_id: uuid.UUID, + data: TeamUpdateIn, + admin: Annotated[User, Depends(require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> TeamOut: + """Переименовать команду; нет команды — 404, дубль названия — 409.""" + repo = TeamRepository(session) + team = await repo.get(team_id) + if team is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="team_not_found") + + existing = await repo.get_by_name(data.name) + if existing is not None and existing.id != team_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="team_name_taken") + + team.name = data.name + await session.commit() + return TeamOut.model_validate(team) + + +@router.delete("/teams/{team_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_team( + team_id: uuid.UUID, + admin: Annotated[User, Depends(require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> None: + """Удалить команду (у пользователей `team_id` обнулится, ON DELETE SET NULL); нет — 404.""" + repo = TeamRepository(session) + team = await repo.get(team_id) + if team is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="team_not_found") + await repo.delete(team) + await session.commit() + + +# --- Настройки инстанса ------------------------------------------------------------- + + +@router.get("/settings", response_model=SettingsOut) +async def get_settings( + admin: Annotated[User, Depends(require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> SettingsOut: + """Текущие эффективные настройки инстанса. + + `transcription_queue_served` вычисляется блокирующим вызовом Celery + (`app.control.inspect`, ждёт ответа брокера/воркеров) — выносится в поток + через `anyio.to_thread.run_sync`, чтобы не блокировать event loop. + """ + cfg = await InstanceSettingsService(session).get() + queue_served = await anyio.to_thread.run_sync(transcription_queue_served) + return _to_settings_out(cfg, transcription_queue_served=queue_served) + + +@router.put("/settings", response_model=SettingsOut) +async def update_settings( + data: SettingsUpdateIn, + admin: Annotated[User, Depends(require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> SettingsOut: + """Частично обновить настройки инстанса; недоступный уровень AI/таймзона/домен — 400.""" + service = InstanceSettingsService(session) + try: + cfg = await service.update(data) + except (InvalidAiLevelError, InvalidTimezoneError, InvalidEmailDomainError) as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + queue_served = await anyio.to_thread.run_sync(transcription_queue_served) + return _to_settings_out(cfg, transcription_queue_served=queue_served) + + +def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -> SettingsOut: + """Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI.""" + return SettingsOut( + chat_enabled=cfg.chat.enabled, + transcription_enabled=cfg.transcriber.enabled, + ai_level=cfg.ai_level, + ai_levels=detect_ai_levels(cfg), + transcription_queue_served=transcription_queue_served, + summary_recipients=cfg.summary_recipients, + display_timezone=cfg.display_timezone, + registration_team_choice=cfg.registration_team_choice, + registration_email_domain_enabled=cfg.registration_email_domain_enabled, + registration_email_domain=cfg.registration_email_domain, + ) + + +def _to_admin_conference_out( + service: ConferenceService, + conference: Conference, + *, + viewer_id: uuid.UUID, + owner_name: str | None, + owner_email: str | None, +) -> AdminConferenceOut: + """Дополнить `ConferenceOut` данными владельца для админ-таблицы конференций. + + `participants` намеренно не заполняется — та же логика, что у `/my` + (список не раздувает состав, ADR-003, п.5); `organizer_name` переиспользует + уже загруженное здесь имя владельца (`owner_name`) — повторного запроса не нужно. + """ + base = service.to_out(conference, viewer_id=viewer_id, organizer_name=owner_name) + return AdminConferenceOut(**base.model_dump(), owner_name=owner_name, owner_email=owner_email) + + +async def _load_owner( + session: AsyncSession, conference: Conference +) -> tuple[str | None, str | None]: + """Имя/email владельца конференции (`None`/`None`, если владельца нет — ADR-001).""" + if conference.owner_id is None: + return None, None + owner = await session.get(User, conference.owner_id) + if owner is None: + return None, None + return owner.name_user, owner.email + + +def _media_root() -> Path: + """Каталог загруженных медиа-файлов (см. `core/config.py::Settings.media_root`).""" + return Path(get_app_settings().media_root) + + +def _to_admin_user_out(user: User, *, team_name: str | None, media_root: Path) -> AdminUserOut: + """Собрать `AdminUserOut` — та же карточка, что и `UserProfileOut`, + модерация.""" + return AdminUserOut( + id=user.id, + email=user.email, + name_user=user.name_user, + role=user.role, + is_blocked=user.is_blocked, + email_verified=user.email_verified, + created_at=user.created_at, + team_id=user.team_id, + avatar_url=avatar_url(media_root, user.avatar_path), + team_name=team_name, + ) diff --git a/backend/api/auth.py b/backend/api/auth.py new file mode 100644 index 0000000..2e5ddf8 --- /dev/null +++ b/backend/api/auth.py @@ -0,0 +1,188 @@ +"""Роутер аутентификации: регистрация, подтверждение email, JWT access/refresh, logout.""" + +from typing import Annotated + +from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.ext.asyncio import AsyncSession + +from core.config import get_settings +from core.db import get_session +from core.redis import redis_client +from models.user import User +from repositories.admin import TeamRepository +from schemas.auth import ( + RegisterIn, + RegistrationOptionsOut, + RegistrationTeamOptionOut, + TokenOut, + UserOut, + VerifyEmailIn, +) +from services.auth import ( + AuthService, + EmailAlreadyRegisteredError, + EmailNotVerifiedError, + InvalidCredentialsError, + InvalidEmailDomainError, + InvalidRefreshTokenError, + InvalidTeamSelectionError, + InvalidVerificationTokenError, +) +from services.email import create_email_backend +from services.instance_settings import InstanceSettingsService + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) + +REFRESH_COOKIE_NAME = "refresh_token" +REFRESH_COOKIE_PATH = "/api/v1/auth" + + +def get_auth_service(session: Annotated[AsyncSession, Depends(get_session)]) -> AuthService: + """Собрать `AuthService` с реальными зависимостями (БД, Redis, email-бэкенд из настроек).""" + return AuthService( + session=session, redis=redis_client, email_backend=create_email_backend(get_settings()) + ) + + +@router.get("/registration-options", response_model=RegistrationOptionsOut) +async def registration_options( + session: Annotated[AsyncSession, Depends(get_session)], +) -> RegistrationOptionsOut: + """Публичные опции карточки регистрации: выбор команды и верификация домена email. + + Список команд отдаётся только при включённой настройке инстанса + `registration_team_choice` — иначе пустой массив (справочник команд не + раскрывается, пока выбор выключен). `email_domain` — эталонный домен при + включённой настройке `registration_email_domain`, иначе `None`. + """ + cfg = await InstanceSettingsService(session).get() + teams: list[RegistrationTeamOptionOut] = [] + if cfg.registration_team_choice: + items, _ = await TeamRepository(session).list_all() + teams = [RegistrationTeamOptionOut(id=team.id, name=team.name) for team in items] + email_domain = cfg.registration_email_domain if cfg.registration_email_domain_enabled else None + return RegistrationOptionsOut( + team_choice_enabled=cfg.registration_team_choice, teams=teams, email_domain=email_domain + ) + + +@router.post("/register", status_code=status.HTTP_201_CREATED, response_model=UserOut) +async def register( + data: RegisterIn, service: Annotated[AuthService, Depends(get_auth_service)] +) -> User: + """Зарегистрировать нового пользователя и отправить письмо для подтверждения email.""" + try: + return await service.register( + email=data.email, + name_user=data.name_user, + password=data.password, + team_id=data.team_id, + ) + except EmailAlreadyRegisteredError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="email_already_registered" + ) from exc + except InvalidTeamSelectionError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_team_selection" + ) from exc + except InvalidEmailDomainError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_email_domain" + ) from exc + + +@router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT) +async def verify_email( + data: VerifyEmailIn, service: Annotated[AuthService, Depends(get_auth_service)] +) -> None: + """Подтвердить email по токену, полученному в письме.""" + try: + await service.verify_email(data.token) + except InvalidVerificationTokenError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_or_expired_token" + ) from exc + + +@router.post("/token", response_model=TokenOut) +async def login( + response: Response, + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + service: Annotated[AuthService, Depends(get_auth_service)], +) -> TokenOut: + """OAuth2 password flow: вход по email (передаётся как `username`) и паролю.""" + try: + pair = await service.login(email=form_data.username, password=form_data.password) + except InvalidCredentialsError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_credentials" + ) from exc + except EmailNotVerifiedError as exc: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="email_not_verified" + ) from exc + + _set_refresh_cookie(response, pair.refresh_token) + return TokenOut(access_token=pair.access_token) + + +@router.post("/refresh", response_model=TokenOut) +async def refresh( + response: Response, + service: Annotated[AuthService, Depends(get_auth_service)], + refresh_token: Annotated[str | None, Cookie(alias=REFRESH_COOKIE_NAME)] = None, +) -> TokenOut: + """Ротировать refresh-токен из cookie и выдать новый access-токен.""" + if refresh_token is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="missing_refresh_token" + ) + try: + pair = await service.refresh(refresh_token) + except InvalidRefreshTokenError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_refresh_token" + ) from exc + + _set_refresh_cookie(response, pair.refresh_token) + return TokenOut(access_token=pair.access_token) + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout( + response: Response, + service: Annotated[AuthService, Depends(get_auth_service)], + refresh_token: Annotated[str | None, Cookie(alias=REFRESH_COOKIE_NAME)] = None, +) -> None: + """Отозвать refresh-токен (удалить из Redis) и погасить cookie.""" + if refresh_token is not None: + await service.logout(refresh_token) + settings = get_settings() + response.delete_cookie( + REFRESH_COOKIE_NAME, + path=REFRESH_COOKIE_PATH, + secure=settings.auth_cookie_secure, + httponly=True, + samesite="strict", + ) + + +def _set_refresh_cookie(response: Response, refresh_token: str) -> None: + """Установить httpOnly SameSite=Strict cookie с refresh-токеном. + + Флаг `Secure` управляется настройкой `auth_cookie_secure` — в dev по + `http://localhost` его нужно отключать (см. `core/config.py`), т.к. + Safari (в отличие от Chrome) не сохраняет Secure-cookie без HTTPS. + """ + settings = get_settings() + response.set_cookie( + key=REFRESH_COOKIE_NAME, + value=refresh_token, + httponly=True, + secure=settings.auth_cookie_secure, + samesite="strict", + path=REFRESH_COOKIE_PATH, + max_age=settings.refresh_token_ttl_days * 24 * 3600, + ) diff --git a/backend/api/chat.py b/backend/api/chat.py new file mode 100644 index 0000000..6d4bcc9 --- /dev/null +++ b/backend/api/chat.py @@ -0,0 +1,152 @@ +"""WS-роутер текстового чата конференции: `WS /api/v1/conferences/{id}/chat`. + +Протокол: `connect` -> `accept()` -> клиент шлёт `{"type":"auth","token":...}` +первым сообщением (таймаут 10 с; токен не query-параметр — не палим его в +логах nginx) -> сервер проверяет тоггл `chat.enabled` и LiveKit-токен -> +история последних 50 сообщений открытой сессии -> двунаправленный обмен +`{"type":"message","text":...}` через Redis pub/sub (echo отправителю тоже). +""" + +import asyncio +import logging +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect +from pydantic import ValidationError +from redis.asyncio.client import PubSub +from sqlalchemy.ext.asyncio import AsyncSession + +from core.db import get_session +from core.redis import redis_client +from models.conference import Conference +from schemas.chat import ( + ChatAuthIn, + ChatErrorOut, + ChatHistoryOut, + ChatMessageEventOut, + ChatMessageIn, + ChatMessageOut, +) +from services.chat import ChatAuthError, ChatIdentity, ChatService, InvalidTokenError, chat_channel + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/conferences", tags=["chat"]) + +# Таймаут ожидания первого (auth) сообщения клиента. +AUTH_TIMEOUT_SECONDS = 10.0 + + +@router.websocket("/{conference_id}/chat") +async def chat_websocket( + websocket: WebSocket, + conference_id: uuid.UUID, + session: Annotated[AsyncSession, Depends(get_session)], +) -> None: + """WS-эндпоинт текстового чата конференции — единая аутентификация LiveKit-токеном.""" + await websocket.accept() + service = ChatService(session) + + try: + identity = await _authenticate(websocket, service) + conference = await service.ensure_chat_open(conference_id, identity=identity) + except ChatAuthError as exc: + await _close_quietly(websocket, exc.close_code) + return + + pubsub = redis_client.pubsub() + channel = chat_channel(conference.id) + # Подписка ДО чтения истории: сообщение, + # опубликованное другим клиентом в окне между SELECT истории и + # subscribe, иначе теряется для подключающегося клиента — Redis начинает + # буферизовать входящие publish для этого соединения сразу после + # subscribe, до первого вызова `get_message`. На стыке возможен дубликат + # (то же сообщение и в history, и в первом pub/sub-сообщении) — безопаснее + # дедуплицировать по `id`, чем потерять сообщение. + await pubsub.subscribe(channel) + try: + history = await service.history(conference) + await websocket.send_json(ChatHistoryOut(messages=history).model_dump(mode="json")) + seen_ids = {item.id for item in history} + + async with asyncio.TaskGroup() as tg: + tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, seen_ids)) + tg.create_task(_pump_websocket_to_service(websocket, service, conference, identity)) + except* WebSocketDisconnect: + # Штатное закрытие соединения клиентом — не ошибка. + pass + except* ChatAuthError as eg: + # Допуск был проверен только при коннекте — за время жизни + # долгоживущего WS (LiveKit-токен TTL 6 часов) конференция могла + # завершиться; `persist_and_publish` бросает `ChatUnavailableError` + # при попытке создать сессию пайплайна для уже мёртвой + # конференции — закрываем с тем же кодом, что и при отказе + # на коннекте. + # `except*` всегда связывает `ExceptionGroup` (PEP 654) — на рантайме + # `eg.exceptions[0]` гарантированно `ChatAuthError`; mypy после + # нескольких подряд идущих `except*` моделирует тип `eg` неточно + # (union с "голым" `ChatAuthError`, не имеющим `.exceptions`). + await _close_quietly(websocket, eg.exceptions[0].close_code) # type: ignore[union-attr] + finally: + # Всегда отписываемся и закрываем pubsub-соединение, иначе при частых + # обрывах соединений копятся забытые подписки на стороне Redis. + await pubsub.unsubscribe(channel) + # `PubSub.aclose` в redis-py не аннотирован (untyped def) несмотря на + # `py.typed` пакета — узкий игнор именно этого вызова. + await pubsub.aclose() # type: ignore[no-untyped-call] + + +async def _authenticate(websocket: WebSocket, service: ChatService) -> ChatIdentity: + """Дождаться первого (auth) сообщения клиента с таймаутом и проверить LiveKit-токен.""" + try: + raw = await asyncio.wait_for(websocket.receive_text(), timeout=AUTH_TIMEOUT_SECONDS) + except (TimeoutError, WebSocketDisconnect) as exc: + raise InvalidTokenError from exc + try: + envelope = ChatAuthIn.model_validate_json(raw) + except ValidationError as exc: + raise InvalidTokenError from exc + return await service.authenticate(envelope.token) + + +async def _pump_pubsub_to_websocket( + websocket: WebSocket, pubsub: PubSub, seen_ids: set[int] +) -> None: + """Читать сообщения Redis pub/sub канала чата и пересылать их подключённому клиенту. + + `seen_ids` — id сообщений, уже отправленных клиенту в `history` (на + стыке подписки и SELECT истории возможен дубликат, см. докстринг + `chat_websocket`) — такие сообщения не пересылаются повторно. + """ + while True: + raw = await pubsub.get_message(ignore_subscribe_messages=True, timeout=None) + if raw is None: + continue + message = ChatMessageOut.model_validate_json(raw["data"]) + if message.id in seen_ids: + continue + seen_ids.add(message.id) + await websocket.send_json(ChatMessageEventOut(message=message).model_dump(mode="json")) + + +async def _pump_websocket_to_service( + websocket: WebSocket, service: ChatService, conference: Conference, identity: ChatIdentity +) -> None: + """Читать текстовые сообщения клиента, валидировать и сохранять+публиковать их.""" + while True: + raw = await websocket.receive_text() + try: + envelope = ChatMessageIn.model_validate_json(raw) + except ValidationError: + await websocket.send_json(ChatErrorOut(code="invalid_message").model_dump(mode="json")) + continue + await service.persist_and_publish(conference, identity=identity, text=envelope.text) + + +async def _close_quietly(websocket: WebSocket, code: int) -> None: + """Закрыть WS с заданным кодом, не роняя обработчик, если клиент уже отвалился.""" + try: + await websocket.close(code=code) + except Exception: # noqa: BLE001 — соединение уже могло быть разорвано клиентом + logger.debug("chat websocket: close(%s) на уже разорванном соединении", code) diff --git a/backend/api/conferences.py b/backend/api/conferences.py new file mode 100644 index 0000000..1d75a42 --- /dev/null +++ b/backend/api/conferences.py @@ -0,0 +1,255 @@ +"""Роутер конференций: создание, «Мои конференции», календарь, резолв, вход, правки (ADR-001).""" + +import uuid +from datetime import UTC, datetime, timedelta +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from sqlalchemy.ext.asyncio import AsyncSession + +from api.deps import get_current_user +from core.db import get_session +from core.rate_limit import enforce_rate_limit +from models.user import User +from schemas.conferences import ( + ConferenceCreateIn, + ConferenceOut, + ConferenceUpdateIn, + GuestJoinIn, + JoinIn, + JoinOut, + OccurrenceOut, + ResolveOut, +) +from services.conference_access import ( + ConferenceEndedError, + InvalidPasswordError, + PasswordRequiredError, +) +from services.conferences import ( + ConferenceActiveError, + ConferenceNotFoundError, + ConferenceService, + InvalidConferenceStateError, + InviteeUserNotFoundError, + NotConferenceOwnerError, +) + +router = APIRouter(prefix="/api/v1/conferences", tags=["conferences"]) + +# Максимальная ширина диапазона `from`/`to` для GET /calendar — защита от +# случайного запроса на годы вперёд (календарь UI показывает недели/месяцы). +MAX_CALENDAR_RANGE = timedelta(days=62) + + +@router.post("", status_code=status.HTTP_201_CREATED, response_model=ConferenceOut) +async def create_conference( + data: ConferenceCreateIn, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> ConferenceOut: + """Создать конференцию: без `scheduled_at` — мгновенная (создатель входит сразу же).""" + service = ConferenceService(session) + try: + conference, join = await service.create(owner=user, data=data) + except InviteeUserNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="invitee_user_not_found" + ) from exc + return await service.to_detail_out(conference, viewer=user, join=join) + + +@router.get("/my", response_model=list[ConferenceOut]) +async def list_my_conferences( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> list[ConferenceOut]: + """Закреплённые конференции + предстоящие разовые владельца.""" + service = ConferenceService(session) + return await service.list_my(owner=user) + + +@router.get("/calendar", response_model=list[OccurrenceOut]) +async def get_calendar( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], + from_: Annotated[datetime, Query(alias="from")], + to: Annotated[datetime, Query()], +) -> list[OccurrenceOut]: + """Развёртка вхождений закреплённых (с повторением) и разовых плановых конференций владельца.""" + t_from = _require_utc(from_) + t_to = _require_utc(to) + if t_to <= t_from or t_to - t_from > MAX_CALENDAR_RANGE: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="invalid_range" + ) + service = ConferenceService(session) + return await service.list_calendar(owner=user, t_from=t_from, t_to=t_to) + + +@router.get("/resolve", response_model=ResolveOut) +async def resolve_conference( + request: Request, + session: Annotated[AsyncSession, Depends(get_session)], + q: Annotated[str, Query(min_length=1)], +) -> ResolveOut: + """Найти конференцию по номеру или ссылке — без auth; rate limit; единообразный 404. + + Для завершённой конференции (`status=ended`) отдаём минимальный ответ — + только `id`/`title`/`status`, без `is_closed`/`requires_password` (ADR-001, + п.4, уточнение резолва): вход в неё невозможен в любом случае (410 у + join/guest-join), а признак закрытости неактуален для мёртвой конференции. + """ + await enforce_rate_limit(f"resolve:{_client_ip(request)}") + service = ConferenceService(session) + conference = await service.resolve(q) + if conference is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not_found") + if conference.status == "ended": + return ResolveOut(id=conference.id, title=conference.title, status=conference.status) + return ResolveOut( + id=conference.id, + title=conference.title, + status=conference.status, + is_closed=conference.is_closed, + requires_password=conference.is_closed, + ) + + +@router.post("/{conference_id}/join", response_model=JoinOut) +async def join_conference( + conference_id: uuid.UUID, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], + data: JoinIn = JoinIn(), +) -> JoinOut: + """Войти в конференцию зарегистрированным пользователем.""" + service = ConferenceService(session) + try: + return await service.join_as_user(conference_id, user=user, password=data.password) + except ConferenceNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found" + ) from exc + except ConferenceEndedError as exc: + raise HTTPException(status_code=status.HTTP_410_GONE, detail="conference_ended") from exc + except PasswordRequiredError as exc: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="password_required" + ) from exc + except InvalidPasswordError as exc: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password" + ) from exc + + +@router.post("/{conference_id}/guest-join", response_model=JoinOut) +async def guest_join_conference( + conference_id: uuid.UUID, + request: Request, + data: GuestJoinIn, + session: Annotated[AsyncSession, Depends(get_session)], +) -> JoinOut: + """Войти гостем: представиться (имя обязательно, email факультативен) — без auth, rate limit.""" + await enforce_rate_limit(f"guest_join:{_client_ip(request)}") + service = ConferenceService(session) + try: + return await service.join_as_guest(conference_id, data=data) + except ConferenceNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found" + ) from exc + except ConferenceEndedError as exc: + raise HTTPException(status_code=status.HTTP_410_GONE, detail="conference_ended") from exc + except PasswordRequiredError as exc: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="password_required" + ) from exc + except InvalidPasswordError as exc: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password" + ) from exc + + +@router.get("/{conference_id}", response_model=ConferenceOut) +async def get_conference( + conference_id: uuid.UUID, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> ConferenceOut: + """Детальная карточка конференции (с полным составом участников); владелец или администратор.""" + service = ConferenceService(session) + try: + conference = await service.get_detail(conference_id, actor=user) + except ConferenceNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found" + ) from exc + except NotConferenceOwnerError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc + return await service.to_detail_out(conference, viewer=user) + + +@router.patch("/{conference_id}", response_model=ConferenceOut) +async def update_conference( + conference_id: uuid.UUID, + data: ConferenceUpdateIn, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> ConferenceOut: + """Изменить конференцию: разрешено владельцу или администратору.""" + service = ConferenceService(session) + try: + conference = await service.update(conference_id, actor=user, data=data) + except ConferenceNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found" + ) from exc + except NotConferenceOwnerError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc + except InvalidConferenceStateError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + except InviteeUserNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="invitee_user_not_found" + ) from exc + return await service.to_detail_out(conference, viewer=user) + + +@router.delete("/{conference_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_conference( + conference_id: uuid.UUID, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> None: + """Удалить конференцию: запрещено для активной (409), разрешено владельцу/администратору.""" + service = ConferenceService(session) + try: + await service.delete(conference_id, actor=user) + except ConferenceNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found" + ) from exc + except NotConferenceOwnerError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc + except ConferenceActiveError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="conference_active" + ) from exc + + +def _require_utc(value: datetime) -> datetime: + """Требовать явную таймзону и привести значение к UTC (в БД и API — только UTC).""" + if value.tzinfo is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="datetime_must_be_timezone_aware", + ) + return value.astimezone(UTC) + + +def _client_ip(request: Request) -> str: + """IP-адрес клиента для rate limit (без auth — ключ по IP, а не по пользователю).""" + return request.client.host if request.client else "unknown" diff --git a/backend/api/deps.py b/backend/api/deps.py new file mode 100644 index 0000000..7063136 --- /dev/null +++ b/backend/api/deps.py @@ -0,0 +1,72 @@ +"""Зависимости FastAPI для аутентификации (RBAC): user / guest / admin.""" + +import uuid +from typing import Annotated + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.ext.asyncio import AsyncSession + +from core.db import get_session +from core.security import decode_token +from models.user import User +from repositories.users import UserRepository + +# `auto_error=False`, чтобы отсутствие заголовка не приводило к автоматической +# ошибке — guest (отсутствие JWT) обрабатывается явно в get_current_user_optional. +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token", auto_error=False) + + +async def get_current_user( + token: Annotated[str | None, Depends(oauth2_scheme)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> User: + """Вернуть текущего пользователя по access-токену; 401 если не аутентифицирован.""" + user = await _user_from_token(token, session) + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="not_authenticated") + return user + + +async def get_current_user_optional( + token: Annotated[str | None, Depends(oauth2_scheme)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> User | None: + """Вернуть текущего пользователя либо `None` для guest (без ошибки). + + Роль guest в системе — это отсутствие JWT, а не отдельное enum-значение в БД. + """ + return await _user_from_token(token, session) + + +async def require_admin(user: Annotated[User, Depends(get_current_user)]) -> User: + """Требовать роль `admin`; иначе 403.""" + if user.role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin_required") + return user + + +async def _user_from_token(token: str | None, session: AsyncSession) -> User | None: + """Общая логика резолва пользователя из access-токена (или None при любой проблеме). + + Заблокированный администратором пользователь (`is_blocked`) трактуется + так же, как отсутствие пользователя — блокировка действует немедленно, + не дожидаясь истечения уже выданного access-токена. + """ + if token is None: + return None + try: + payload = decode_token(token) + except jwt.PyJWTError: + return None + if payload.get("type") != "access": + return None + try: + user_id = uuid.UUID(str(payload.get("sub"))) + except (ValueError, TypeError): + return None + user = await UserRepository(session).get_by_id(user_id) + if user is not None and user.is_blocked: + return None + return user diff --git a/backend/api/health.py b/backend/api/health.py new file mode 100644 index 0000000..18f2c64 --- /dev/null +++ b/backend/api/health.py @@ -0,0 +1,40 @@ +"""Endpoint для проверки здоровья.""" + +from fastapi import APIRouter, Depends +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from core.config import get_settings +from core.db import get_session +from core.redis import redis_client + +router = APIRouter() + + +@router.get("/api/health") +async def health(session: AsyncSession = Depends(get_session)) -> dict[str, bool | str]: + """Отчет о статусе приложения и связи с БД/Redis. + + Поле `version` — версия инстанса (`VIDCONF_VERSION` из `.env`, + пишет `install.sh` из корневого файла `VERSION`); футер админки + берёт его отсюда, а не из версии сборки фронтенда. + """ + db_ok = False + try: + await session.execute(text("SELECT 1")) + db_ok = True + except Exception: # noqa: BLE001 + db_ok = False + + redis_ok = False + try: + redis_ok = bool(await redis_client.ping()) + except Exception: # noqa: BLE001 + redis_ok = False + + return { + "status": "ok", + "db": db_ok, + "redis": redis_ok, + "version": get_settings().vidconf_version, + } diff --git a/backend/api/livekit_webhook.py b/backend/api/livekit_webhook.py new file mode 100644 index 0000000..c57eeaf --- /dev/null +++ b/backend/api/livekit_webhook.py @@ -0,0 +1,63 @@ +"""Приёмник webhook-событий LiveKit (без JWT — верификация подписью LiveKit).""" + +import logging +from typing import Annotated, Any, cast + +from fastapi import APIRouter, Depends, Header, HTTPException, Request, status +from livekit import api +from sqlalchemy import CursorResult +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncSession + +from core.config import get_settings +from core.db import get_session +from models.webhook_event import LivekitWebhookEvent +from services.webhook_handlers import WebhookDispatcher + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/livekit", tags=["livekit"]) + + +@router.post("/webhook") +async def receive_webhook( + request: Request, + session: Annotated[AsyncSession, Depends(get_session)], + authorization: Annotated[str | None, Header()] = None, +) -> dict[str, str]: + """Принять, верифицировать и обработать webhook-событие LiveKit. + + Дедупликация по `event.id`: `INSERT ... ON CONFLICT DO NOTHING` в + `livekit_webhook_events` в одной транзакции с эффектами обработчика — + при конфликте (дубль) эффекты пропускаются, но ответ всё равно 200. + """ + settings = get_settings() + raw_body = await request.body() + + receiver = api.WebhookReceiver( + api.TokenVerifier(settings.livekit_api_key, settings.livekit_api_secret) + ) + try: + event = receiver.receive(raw_body.decode(), authorization or "") + except Exception as exc: # noqa: BLE001 — SDK кидает generic Exception на невалидную подпись + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_signature" + ) from exc + + insert_result = cast( + CursorResult[Any], + await session.execute( + pg_insert(LivekitWebhookEvent) + .values(event_id=event.id, event_type=event.event) + .on_conflict_do_nothing(index_elements=["event_id"]) + ), + ) + if insert_result.rowcount == 0: + # Дубль уже обработанного события — пропускаем эффекты, но отвечаем 200. + await session.commit() + return {"status": "duplicate"} + + dispatcher = WebhookDispatcher(session) + await dispatcher.dispatch(event) + await session.commit() + return {"status": "ok"} diff --git a/backend/api/metrics.py b/backend/api/metrics.py new file mode 100644 index 0000000..a001fa8 --- /dev/null +++ b/backend/api/metrics.py @@ -0,0 +1,125 @@ +"""Метрики Prometheus: латентность HTTP + gauge'и пайплайна и очередей. + +`GET /metrics` — без авторизации (снаружи закрывается на уровне nginx, вне +периметра backend, см. `docs/deploy/scaling.md`/monitoring-часть devops): +Prometheus-серверы традиционно ходят напрямую в контейнер по внутренней +сети, а не через публичный `/api/`-гейтвей. + +Gauge'и `vidconf_pipeline_sessions`/`vidconf_celery_queue_depth` намеренно +НЕ обновляются фоновой задачей — значения пересчитываются прямо в обработчике +запроса при каждом scrape (см. докстринг `metrics_endpoint`), поэтому их +асинхронные источники (БД, Redis) можно опросить обычным `await` вместо +реализации синхронного `prometheus_client.registry.Collector`. +""" + +import time +from collections.abc import Awaitable, Callable + +from fastapi import APIRouter, Depends, Request, Response +from prometheus_client import CONTENT_TYPE_LATEST, Gauge, Histogram, generate_latest +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.routing import Match + +from core.db import get_session +from core.redis import redis_client +from models.session import PIPELINE_STATUSES +from repositories.conferences import ConferenceSessionRepository + +router = APIRouter() + +# --- Латентность HTTP-запросов по маршрутам -------------------------------- + +HTTP_REQUEST_DURATION_SECONDS = Histogram( + "vidconf_http_request_duration_seconds", + "Латентность HTTP-запросов backend по маршрутам", + labelnames=("method", "path", "status"), +) + + +async def prometheus_latency_middleware( + request: Request, call_next: Callable[[Request], Awaitable[Response]] +) -> Response: + """Замерить латентность запроса и записать в `HTTP_REQUEST_DURATION_SECONDS`. + + Метка `path` — шаблон маршрута (`/api/v1/conferences/{conference_id}`), а + не сырой URL: иначе каждый UUID/slug в пути породил бы собственную серию + меток (неограниченная кардинальность). Шаблон резолвится постфактум + поиском совпавшего маршрута среди `request.app.routes` (FastAPI/Starlette + не кладёт его в `request.scope` до входа в сам эндпоинт, а `call_next` + оборачивает вызов целиком) — тот же приём, что использует + `starlette.routing.Router` внутри себя для диспетчеризации. + """ + start = time.perf_counter() + response = await call_next(request) + duration = time.perf_counter() - start + path_template = _match_route_path(request) + HTTP_REQUEST_DURATION_SECONDS.labels( + method=request.method, path=path_template, status=str(response.status_code) + ).observe(duration) + return response + + +def _match_route_path(request: Request) -> str: + """Найти шаблон пути совпавшего маршрута; сырой `request.url.path`, если не найден (404).""" + for route in request.app.routes: + match, _ = route.matches(request.scope) + if match == Match.FULL: + return getattr(route, "path", request.url.path) + return request.url.path + + +# --- Gauge числа сеансов по статусу пайплайна ------------------------------- + +PIPELINE_SESSIONS = Gauge( + "vidconf_pipeline_sessions", + "Число сеансов конференций в каждом статусе пайплайна пост-обработки", + labelnames=("status",), +) + + +async def _refresh_pipeline_sessions_gauge(session: AsyncSession) -> None: + """Пересчитать `vidconf_pipeline_sessions` по всем статусам `pipeline_status`.""" + counts = await ConferenceSessionRepository(session).count_by_pipeline_status() + for status in PIPELINE_STATUSES: + PIPELINE_SESSIONS.labels(status=status).set(counts.get(status, 0)) + + +# --- Gauge глубины очередей Celery (Redis) ---------------------------------- + +CELERY_QUEUES = ("transcription", "summarize", "notify", "celery") +"""Очереди, за которыми следим (`workers/celery_app.py::app.conf.task_routes`, +`docs/deploy/scaling.md`): выделенные `transcription`/`summarize`/`notify` + +дефолтная `celery` (обслуживающие задачи без явного маршрута).""" + +CELERY_QUEUE_DEPTH = Gauge( + "vidconf_celery_queue_depth", + "Число задач, ожидающих обработки в очереди Celery (redis LLEN)", + labelnames=("queue",), +) + + +async def _refresh_celery_queue_depth_gauge() -> None: + """Пересчитать `vidconf_celery_queue_depth` по всем отслеживаемым очередям. + + Список Redis, лежащий за очередью Celery, называется так же, как сама + очередь (транспорт `kombu` с брокером `redis` кладёт задачи в список по + имени очереди) — `LLEN` даёт точную глубину backlog'а на момент scrape. + """ + for queue in CELERY_QUEUES: + depth = await redis_client.llen(queue) + CELERY_QUEUE_DEPTH.labels(queue=queue).set(depth) + + +@router.get("/metrics") +async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Response: + """Отдать метрики Prometheus в формате text exposition. + + Gauge'и пересчитываются прямо здесь (а не по расписанию/периодическим + коллектором) — значение в ответе всегда актуально на момент scrape, + ценой одного SELECT (группировка по `pipeline_status`) и `LLEN` на + каждую из 4 отслеживаемых очередей per запрос — Prometheus скрейпит + редко (обычно раз в 15–30с), нагрузка пренебрежимо мала. + """ + await _refresh_pipeline_sessions_gauge(session) + await _refresh_celery_queue_depth_gauge() + return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST) diff --git a/backend/api/teams.py b/backend/api/teams.py new file mode 100644 index 0000000..5000d03 --- /dev/null +++ b/backend/api/teams.py @@ -0,0 +1,31 @@ +"""Роутер справочника команд для аутентифицированных пользователей. + +Отдельно от `/admin/teams` (админ-only CRUD): здесь только чтение полного +списка — нужно странице профиля (выбор команды). В отличие от +`GET /auth/registration-options`, список НЕ гасится тумблером +`registration_team_choice` (та настройка — только про публичную форму +регистрации, не про профиль уже аутентифицированного пользователя). +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from api.deps import get_current_user +from core.db import get_session +from models.user import User +from repositories.admin import TeamRepository +from schemas.admin import TeamOut + +router = APIRouter(prefix="/api/v1/teams", tags=["teams"]) + + +@router.get("", response_model=list[TeamOut]) +async def list_teams( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> list[TeamOut]: + """Полный справочник команд, отсортированный по названию (выбор команды в профиле).""" + items, _ = await TeamRepository(session).list_all() + return [TeamOut.model_validate(team) for team in items] diff --git a/backend/api/users.py b/backend/api/users.py new file mode 100644 index 0000000..33a60d9 --- /dev/null +++ b/backend/api/users.py @@ -0,0 +1,145 @@ +"""Роутер профиля текущего пользователя, аватара и списка пользователей.""" + +from pathlib import Path +from typing import Annotated + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status +from sqlalchemy.ext.asyncio import AsyncSession + +from api.deps import get_current_user +from core.config import get_settings +from core.db import get_session +from core.security import hash_password, verify_password +from models.user import User +from repositories.users import UserRepository +from schemas.auth import PasswordChangeIn, ProfileUpdateIn, UserListItemOut, UserProfileOut +from services.avatars import AvatarInvalidTypeError, AvatarTooLargeError, avatar_url +from services.profile import ( + TeamNotFoundError, + clear_avatar, + resolve_team_name, + update_profile_fields, +) +from services.profile import set_avatar as _set_avatar + +router = APIRouter(prefix="/api/v1/users", tags=["users"]) + +# Число совпадений, возвращаемых поиском по `q` (пикер участников). +SEARCH_LIMIT = 20 + + +@router.get("/me", response_model=UserProfileOut) +async def read_current_user( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> UserProfileOut: + """Вернуть профиль текущего аутентифицированного пользователя.""" + return await _to_profile_out(session, user) + + +@router.patch("/me", response_model=UserProfileOut) +async def update_current_user( + data: ProfileUpdateIn, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> UserProfileOut: + """Изменить ФИО и/или команду текущего пользователя; email — read-only.""" + try: + await update_profile_fields( + session, + user, + name_user=data.name_user, + team_id=data.team_id, + team_id_is_set="team_id" in data.model_fields_set, + ) + except TeamNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="team_not_found") from exc + await session.commit() + return await _to_profile_out(session, user) + + +@router.post("/me/avatar", response_model=UserProfileOut) +async def upload_current_user_avatar( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], + file: Annotated[UploadFile, File()], +) -> UserProfileOut: + """Загрузить аватар текущего пользователя (jpeg/png/webp, до 2 МБ).""" + try: + await _set_avatar(_media_root(), user, file) + except AvatarTooLargeError as exc: + raise HTTPException( + status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="avatar_too_large" + ) from exc + except AvatarInvalidTypeError as exc: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="avatar_invalid_type" + ) from exc + await session.commit() + return await _to_profile_out(session, user) + + +@router.delete("/me/avatar", status_code=status.HTTP_204_NO_CONTENT) +async def delete_current_user_avatar( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> None: + """Удалить аватар текущего пользователя.""" + clear_avatar(_media_root(), user) + await session.commit() + + +@router.post("/me/password", status_code=status.HTTP_204_NO_CONTENT) +async def change_current_user_password( + data: PasswordChangeIn, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> None: + """Сменить пароль текущего пользователя. + + Refresh-сессии сознательно НЕ отзываются — отзыв всех сессий появится + вместе со сбросом пароля по email (v0.1.0, см. ADR-005 + `docs/architecture/adr/005-password-reset-deferred.md`). + """ + if not verify_password(data.current_password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_current_password" + ) + user.password_hash = hash_password(data.new_password) + await session.commit() + + +@router.get("", response_model=list[UserListItemOut]) +async def list_users( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], + q: Annotated[str | None, Query()] = None, +) -> list[UserListItemOut]: + """Пикер участников конференции: без `q` — полный список; с `q` — поиск имя/email.""" + media_root = _media_root() + users = await UserRepository(session).search(q=q, limit=SEARCH_LIMIT) + return [ + UserListItemOut( + id=u.id, display_name=u.name_user, avatar_url=avatar_url(media_root, u.avatar_path) + ) + for u in users + ] + + +def _media_root() -> Path: + """Каталог загруженных медиа-файлов (см. `core/config.py::Settings.media_root`).""" + return Path(get_settings().media_root) + + +async def _to_profile_out(session: AsyncSession, user: User) -> UserProfileOut: + """Собрать `UserProfileOut` — общая сборка для своего профиля и карточки в админке.""" + team_name = await resolve_team_name(session, user.team_id) + return UserProfileOut( + id=user.id, + email=user.email, + name_user=user.name_user, + role=user.role, + avatar_url=avatar_url(_media_root(), user.avatar_path), + team_id=user.team_id, + team_name=team_name, + ) diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/config.py b/backend/core/config.py new file mode 100644 index 0000000..ee4ca6c --- /dev/null +++ b/backend/core/config.py @@ -0,0 +1,138 @@ +"""Конфигурация приложения, загруженная из переменных окружения / файла .env.""" + +from functools import lru_cache + +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from core.plugins.config import AiLevel + + +class Settings(BaseSettings): + """Центральные параметры приложения. + + Значения читаются из переменных окружения (или файла `.env`). + """ + + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + database_url: str = "postgresql+asyncpg://vidconf:vidconf@localhost:5432/vidconf" + redis_url: str = "redis://localhost:6379/0" + plugins_config_path: str = "../config/plugins.yaml" + + # --- Версия инстанса (релиз v0.0.1) --- + # install.sh копирует значение из файла `VERSION` (корень репозитория) в + # `.env` при каждой установке/обновлении — здесь только чтение готового + # значения. Отдаётся в `GET /api/health` (футер админки, Блок 4). + vidconf_version: str = "0.0.0" + + # Домен не должен попадать в список special-use/reserved (RFC 6761, + # напр. `.local`/`.test`): email-validator (`EmailStr`) их отклоняет, а + # раньше это ловилось и на выходе — старый дефолт `admin@vidconf.local` + # ронял `GET /users/me` 500 `ResponseValidationError`, пока `UserOut.email` + # был `EmailStr`. `.example` (RFC 2606) email-validator пропускает. + seed_admin_email: str = "admin@vidconf.example" + seed_admin_password: str = "change-me" + + # --- Auth (JWT + email-подтверждение) --- + jwt_secret: str = "dev-only-insecure-secret-change-me" + access_token_ttl_minutes: int = 15 + refresh_token_ttl_days: int = 14 + email_verification_ttl_hours: int = 24 + frontend_url: str = "http://localhost:5173" + # Флаг Secure для refresh-cookie. false нужен только для dev по + # http://localhost (Safari, в отличие от Chrome, не сохраняет + # Secure-cookie без HTTPS); в проде обязательно true. + auth_cookie_secure: bool = True + + # --- LiveKit --- + livekit_api_key: str = "devkey" + livekit_api_secret: str = "change-me-livekit-secret" + livekit_public_url: str = "ws://localhost:7880" + # Внутренний server-to-server URL для вызовов LiveKit RoomService (Celery + # maintenance-задача); в отличие от `livekit_public_url` не проксируется + # через nginx/TLS для браузера. LiveKit SDK сам нормализует ws:// в http://. + livekit_url: str = "ws://localhost:7880" + + # --- Пайплайн транскрибации --- + # Общий volume между LiveKit Egress и celery-воркером `transcription` + # (см. `deploy/docker-compose.yml`); в тестах переопределяется на `tmp_path`. + recordings_dir: str = "/recordings" + + # --- Email (SMTP-бэкенд) --- + # `console` — дефолт для dev (письмо только логируется); `smtp` — реальная + # отправка через aiosmtplib. Секреты SMTP — только в `.env` (инвариант №6), + # переключатель бэкенда — тоже переменная окружения, а не настройка в БД + # (`instance_settings`). + email_backend: str = "console" + smtp_host: str = "localhost" + smtp_port: int = 587 + smtp_username: str | None = None + smtp_password: str | None = None + smtp_start_tls: bool = True + smtp_use_tls: bool = False + smtp_from: str = "VidConf " + smtp_timeout_s: int = 30 + + # --- Медиа (аватары пользователей) --- + # Каталог, куда сохраняются загруженные файлы (аватары — `avatars/{user_id}.{ext}`); + # раздаётся статикой по `/media` (`main.py`, dev) либо через nginx `location /media/` + # в проде (`deploy/nginx/nginx.conf`, volume `media`). Относительный путь по + # умолчанию — рабочая директория backend (аналог `recordings_dir`, но без + # требования root для локального запуска вне Docker). + media_root: str = "media" + + # --- Автодетект железа: install.sh определяет `nproc`/`free -m`/ + # `nvidia-smi` и пишет в `.env`; читает `services/ai_levels.py` для детекта + # доступности уровней AI (ADR-004) без torch/nvidia-smi внутри процесса + # backend/воркеров. `None` — install.sh не запускался (dev-окружение) либо + # GPU не обнаружен (`hw_gpu_name`/`hw_vram_mb`). + hw_cpus: int | None = None + hw_ram_mb: int | None = None + hw_gpu_name: str | None = None + hw_vram_mb: int | None = None + + # --- Матрица «пресет → настройки» инсталлятора: install.sh пишет эти три + # переменные в `.env` по выбранному пресету (1–5), lifespan backend + # передаёт их бутстрапу `instance_settings` (`services/instance_settings.py`, + # `bootstrap_overrides_from_settings`) как overrides дефолтов + # `plugins.yaml` — БЕЗ этого механизма бутстрап всегда включал чат и + # AI-модули независимо от пресета. `None` — install.sh не запускался + # (dev-окружение) либо переменная не установлена для этого пресета: + # бутстрап тогда использует дефолты `plugins.yaml` как раньше. + bootstrap_chat_enabled: bool | None = None + bootstrap_transcription_enabled: bool | None = None + bootstrap_ai_level: AiLevel | None = None + + @field_validator( + "hw_cpus", + "hw_ram_mb", + "hw_gpu_name", + "hw_vram_mb", + "bootstrap_chat_enabled", + "bootstrap_transcription_enabled", + "bootstrap_ai_level", + mode="before", + ) + @classmethod + def _empty_hw_string_to_none(cls, value: object) -> object: + """Пустая строка env (`KEY=`, а не отсутствие переменной) → `None`. + + `docker-compose` подставляет `env_file` дословно: `HW_VRAM_MB=` в `.env` + (пишет `install.sh` на любой машине без NVIDIA GPU, пресеты 1–4; + `.env.example` — все четыре `HW_*` пустыми по умолчанию) превращается в + переменную окружения со значением `""`, а не в отсутствующую переменную — + без этой нормализации pydantic не парсит `""` как `int` и роняет + `Settings()` уже на импорте модуля (`main.py`, `workers/celery_app.py`), + не давая контейнеру стартовать. Та же проблема для `BOOTSTRAP_*` + (`.env.example` — пустыми по умолчанию, install.sh заполняет по пресету). + """ + if value == "": + return None + return value + + +@lru_cache +def get_settings() -> Settings: + """Вернуть кэшированный экземпляр `Settings`.""" + return Settings() diff --git a/backend/core/db.py b/backend/core/db.py new file mode 100644 index 0000000..bca636c --- /dev/null +++ b/backend/core/db.py @@ -0,0 +1,24 @@ +"""Настройка асинхронного движка SQLAlchemy и сеанса.""" + +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from core.config import get_settings + +settings = get_settings() + +engine: AsyncEngine = create_async_engine(settings.database_url, pool_pre_ping=True) + +async_session_maker = async_sessionmaker(engine, expire_on_commit=False) + + +async def get_session() -> AsyncGenerator[AsyncSession, None]: + """Зависимость FastAPI, возвращающая `AsyncSession`.""" + async with async_session_maker() as session: + yield session diff --git a/backend/core/plugins/__init__.py b/backend/core/plugins/__init__.py new file mode 100644 index 0000000..fa66d4e --- /dev/null +++ b/backend/core/plugins/__init__.py @@ -0,0 +1,12 @@ +"""Пакет плагинов Transcriber/Summarizer (Strategy + Factory). + +Импорт конкретных реализаций здесь регистрирует их в `core.plugins.factory` +через декораторы `@register_transcriber`/`@register_summarizer` (побочный +эффект импорта модуля). Новая реализация = новый класс + импорт в этом +файле + строка в `config/plugins.yaml` — ядро (`factory.py`, контракты) не +трогаем. +""" + +from core.plugins import faster_whisper as faster_whisper # noqa: F401 +from core.plugins import null as null # noqa: F401 +from core.plugins import qwen_local as qwen_local # noqa: F401 diff --git a/backend/core/plugins/config.py b/backend/core/plugins/config.py new file mode 100644 index 0000000..8469a02 --- /dev/null +++ b/backend/core/plugins/config.py @@ -0,0 +1,84 @@ +"""Модели Pydantic для описания `config/plugins.yaml` и его загрузчика.""" + +from pathlib import Path +from typing import Any, Literal + +import yaml +from pydantic import BaseModel, Field + + +class TranscriberConfig(BaseModel): + """Конфигурация активного плагина transcriber.""" + + enabled: bool = True + provider: str = Field(default="null", min_length=1) + model: str | None = None + language: str = "ru" + options: dict[str, Any] = Field(default_factory=dict) + + +class SummarizerConfig(BaseModel): + """Конфигурация активного плагина summarizer.""" + + enabled: bool = True + provider: str = Field(default="null", min_length=1) + model: str | None = None + chunk_minutes: int = 20 + options: dict[str, Any] = Field(default_factory=dict) + + +class ChatConfig(BaseModel): + """Конфигурация переключателя функции чата.""" + + enabled: bool = True + + +class PluginsConfig(BaseModel): + """Корневая модель конфигурации для `config/plugins.yaml`.""" + + transcriber: TranscriberConfig = Field(default_factory=TranscriberConfig) + summarizer: SummarizerConfig = Field(default_factory=SummarizerConfig) + chat: ChatConfig = Field(default_factory=ChatConfig) + + +def load_plugins_config(path: str | Path) -> PluginsConfig: + """Загрузить и валидировать `PluginsConfig` из YAML файла.""" + raw = yaml.safe_load(Path(path).read_text()) or {} + return PluginsConfig.model_validate(raw) + + +# --- Настройки инстанса: БД поверх дефолтов `plugins.yaml`. --- +# Ключи `instance_settings` зеркалят секции ниже (`transcriber`, `summarizer`, +# `chat`, `ai_level`, `summary_recipients`, `display_timezone`) — см. +# `services/instance_settings.py`. + +AiLevel = Literal["min", "medium", "max"] +"""Уровень AI-модуля инстанса (модели/требования — ADR-004, +`docs/architecture/adr/004-ai-tier-matrix.md`, `services/ai_tiers.TIERS`). +Доступность каждого уровня на конкретном инстансе зависит от обнаруженного +железа и скачанных моделей — см. `services/ai_levels.py::detect_ai_levels`.""" + +SummaryRecipientsMode = Literal["all", "owner"] +"""Режим рассылки саммари по умолчанию: всем участникам либо только +владельцу конференции (переопределяется на уровне `conferences.summary_recipients`).""" + + +class InstanceConfig(BaseModel): + """Эффективная конфигурация инстанса (значения `instance_settings` поверх дефолтов + `plugins.yaml`, см. `services/instance_settings.py::load_effective_config`).""" + + transcriber: TranscriberConfig + summarizer: SummarizerConfig + chat: ChatConfig + ai_level: AiLevel = "min" + summary_recipients: SummaryRecipientsMode = "all" + display_timezone: str = "Europe/Moscow" + # Разрешить выбор команды на форме регистрации (справочник `teams`) + # — см. `services/instance_settings.py`. + registration_team_choice: bool = False + # Верификация регистрирующихся по домену email: при включении + # `POST /auth/register` принимает только + # email с доменом `registration_email_domain` — см. + # `services/instance_settings.py`. + registration_email_domain_enabled: bool = False + registration_email_domain: str | None = None diff --git a/backend/core/plugins/factory.py b/backend/core/plugins/factory.py new file mode 100644 index 0000000..ceeb542 --- /dev/null +++ b/backend/core/plugins/factory.py @@ -0,0 +1,47 @@ +"""Factory + реестр для реализаций плагинов Transcriber/Summarizer.""" + +from core.plugins.config import SummarizerConfig, TranscriberConfig +from core.plugins.summarizer import Summarizer +from core.plugins.transcriber import Transcriber + + +class PluginError(Exception): + """Базовая ошибка при сбое реестра/factory плагинов.""" + + +class UnknownProviderError(PluginError): + """Вызывается, когда запрошенный `provider` плагина не зарегистрирован.""" + + +_TRANSCRIBERS: dict[str, type[Transcriber]] = {} +_SUMMARIZERS: dict[str, type[Summarizer]] = {} + + +def register_transcriber[T: type[Transcriber]](cls: T) -> T: + """Зарегистрировать подкласс `Transcriber` под его ключом `provider`.""" + _TRANSCRIBERS[cls.provider] = cls + return cls + + +def register_summarizer[S: type[Summarizer]](cls: S) -> S: + """Зарегистрировать подкласс `Summarizer` под его ключом `provider`.""" + _SUMMARIZERS[cls.provider] = cls + return cls + + +def create_transcriber(cfg: TranscriberConfig) -> Transcriber: + """Инстанцировать `Transcriber`, зарегистрированный для `cfg.provider`.""" + try: + cls = _TRANSCRIBERS[cfg.provider] + except KeyError as exc: + raise UnknownProviderError(f"Неизвестный провайдер transcriber: {cfg.provider!r}") from exc + return cls(model=cfg.model, language=cfg.language, **cfg.options) # type: ignore[call-arg] + + +def create_summarizer(cfg: SummarizerConfig) -> Summarizer: + """Инстанцировать `Summarizer`, зарегистрированный для `cfg.provider`.""" + try: + cls = _SUMMARIZERS[cfg.provider] + except KeyError as exc: + raise UnknownProviderError(f"Неизвестный провайдер summarizer: {cfg.provider!r}") from exc + return cls(model=cfg.model, chunk_minutes=cfg.chunk_minutes, **cfg.options) # type: ignore[call-arg] diff --git a/backend/core/plugins/faster_whisper.py b/backend/core/plugins/faster_whisper.py new file mode 100644 index 0000000..ab27287 --- /dev/null +++ b/backend/core/plugins/faster_whisper.py @@ -0,0 +1,141 @@ +"""Плагины `Transcriber` на основе faster-whisper: CPU (`min`) и GPU (`medium`/`max`). + +Оба плагина используют встроенный в faster-whisper Silero VAD (`vad_filter=True`) +и дополнительно отбрасывают сегменты короче `MIN_SEGMENT_DURATION_S` — +типичные галлюцинации Whisper на тишине/шуме (ТЗ §1.4). Общая логика +(ленивая загрузка модели-синглтона процесса, вызов `transcribe` с VAD, +фильтрация коротких сегментов) вынесена в `_FasterWhisperBase`; CPU/GPU-варианты +отличаются только параметрами устройства/квантизации (ADR-004, +`docs/architecture/adr/004-ai-tier-matrix.md`). +""" + +from typing import TYPE_CHECKING, ClassVar + +from core.plugins.factory import register_transcriber +from core.plugins.transcriber import Segment, Transcriber + +if TYPE_CHECKING: + # Импорт только для проверки типов: рантайм-импорт — ленивый, см. `_get_model`, + # чтобы API-процесс, где транскрибация не используется, не тянул тяжёлую + # зависимость (ctranslate2 и т.п.) в память. + from faster_whisper import WhisperModel + +MIN_SEGMENT_DURATION_S = 0.3 +"""Минимальная длительность сегмента (сек); короче — отбрасывается как +вероятная галлюцинация Whisper на тишине/шуме.""" + +VAD_MIN_SILENCE_DURATION_MS = 500 +"""Порог Silero VAD (мс) для разбиения на речевые куски внутри трека.""" + + +class _FasterWhisperBase(Transcriber): + """Общая логика плагинов faster-whisper: синглтон модели процесса + VAD-транскрибация. + + Модель-синглтон принадлежит конкретному подклассу (`FasterWhisperCPU`, + `FasterWhisperGPU`), а не общему базовому классу: присваивание + `cls._model = ...` в `_get_model` всегда происходит через `type(self)`, + поэтому у каждого подкласса — свой атрибут класса, и CPU/GPU-плагины не + делят один кэшированный инстанс модели, даже если оба сконфигурированы в + одном процессе. + """ + + MIN_SEGMENT_S: ClassVar[float] = MIN_SEGMENT_DURATION_S + _model: "ClassVar[WhisperModel | None]" = None + + # Задаются наследниками в `__init__` (device — фиксированно классом, + # compute_type — либо фиксированно, либо конструкторская опция). + device: str + compute_type: str + + def __init__( + self, + model: str, + language: str = "ru", + download_root: str | None = None, + ) -> None: + self.model_name = model + self.language = language + self.download_root = download_root + + def _get_model(self) -> "WhisperModel": + """Лениво создать (или переиспользовать) синглтон `WhisperModel` конкретного подкласса.""" + cls = type(self) + if cls._model is None: + from faster_whisper import WhisperModel # ленивый импорт тяжёлой зависимости + + cls._model = WhisperModel( + self.model_name, + device=self.device, + compute_type=self.compute_type, + download_root=self.download_root, + ) + return cls._model + + def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]: + """Транскрибировать аудиофайл трека, отбросив короткие сегменты-галлюцинации. + + VAD (Silero, встроен в faster-whisper) включён с порогом тишины + `VAD_MIN_SILENCE_DURATION_MS`; дополнительно отбрасываются сегменты + короче `MIN_SEGMENT_DURATION_S`. + """ + model = self._get_model() + raw_segments, _info = model.transcribe( + audio_path, + language=language, + vad_filter=True, + vad_parameters={"min_silence_duration_ms": VAD_MIN_SILENCE_DURATION_MS}, + ) + return [ + Segment(start=segment.start, end=segment.end, text=segment.text) + for segment in raw_segments + if (segment.end - segment.start) >= MIN_SEGMENT_DURATION_S + ] + + +@register_transcriber +class FasterWhisperCPU(_FasterWhisperBase): + """Транскрибер faster-whisper (CTranslate2) на CPU с int8-квантизацией (уровень `min`). + + Модель — синглтон на процесс: создаётся лениво при первом вызове + `transcribe` и переиспользуется всеми последующими вызовами в рамках + одного процесса воркера (процесс запускается + в Celery-очереди `transcription` с `--pool=solo --concurrency=1`, поэтому + гонок за атрибут класса не возникает). + """ + + provider: ClassVar[str] = "faster_whisper_cpu" + _model: "ClassVar[WhisperModel | None]" = None + + def __init__( + self, + model: str | None = None, + language: str = "ru", + download_root: str | None = None, + ) -> None: + super().__init__(model=model or "small", language=language, download_root=download_root) + self.device = "cpu" + self.compute_type = "int8" + + +@register_transcriber +class FasterWhisperGPU(_FasterWhisperBase): + """Транскрибер faster-whisper на GPU (CUDA, уровни `medium`/`max`, ADR-004). + + `compute_type` — конструкторская опция (дефолт `float16`, как в матрице + ADR-004); для экономии VRAM конфиг уровня может задать `int8_float16` + (options плагина в `TIERS`/`plugins.yaml`). + """ + + provider: ClassVar[str] = "faster_whisper_gpu" + _model: "ClassVar[WhisperModel | None]" = None + + def __init__( + self, + model: str | None = None, + language: str = "ru", + download_root: str | None = None, + compute_type: str = "float16", + ) -> None: + super().__init__(model=model or "medium", language=language, download_root=download_root) + self.device = "cuda" + self.compute_type = compute_type diff --git a/backend/core/plugins/null.py b/backend/core/plugins/null.py new file mode 100644 index 0000000..32f2274 --- /dev/null +++ b/backend/core/plugins/null.py @@ -0,0 +1,36 @@ +"""No-op реализации Transcriber/Summarizer, используемые как безопасный default.""" + +from typing import Any, ClassVar + +from core.plugins.factory import register_summarizer, register_transcriber +from core.plugins.summarizer import Summarizer +from core.plugins.transcriber import Segment, Transcriber + + +@register_transcriber +class NullTranscriber(Transcriber): + """Transcriber, который не выдаёт сегменты; используется когда транскрибация отключена.""" + + provider: ClassVar[str] = "null" + + def __init__(self, model: str | None = None, language: str = "ru", **options: Any) -> None: + self.model = model + self.language = language + self.options = options + + def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]: + return [] + + +@register_summarizer +class NullSummarizer(Summarizer): + """Summarizer, который выдаёт пустое резюме; используется когда суммаризация отключена.""" + + provider: ClassVar[str] = "null" + + def __init__(self, model: str | None = None, **options: Any) -> None: + self.model = model + self.options = options + + def summarize(self, transcript: str) -> str: + return "" diff --git a/backend/core/plugins/qwen_local.py b/backend/core/plugins/qwen_local.py new file mode 100644 index 0000000..2cdcd16 --- /dev/null +++ b/backend/core/plugins/qwen_local.py @@ -0,0 +1,203 @@ +"""Плагин `Summarizer` на локальной модели семейства Qwen через сервер llama.cpp. + +Конкретная модель/квант не зашиты в плагине — их задаёт конфигурация +(`config/plugins.yaml` либо `TierSpec` в `services/ai_tiers.py`, ADR-004); +дефолт конструктора (`qwen2.5-3b-instruct-q4_k_m`) — только фолбэк на случай +прямого создания плагина без конфига. + +Map-reduce целиком инкапсулирован в плагине (контракт `Summarizer.summarize` +не меняется — ТЗ §1.3): транскрипт делится на чанки +чистой функцией `chunk_transcript`, каждый чанк резюмируется отдельным +вызовом LLM (map), частичные резюме объединяются одним reduce-вызовом; если +частичные резюме суммарно не влезают в бюджет токенов запроса — reduce +выполняется иерархически, группами, пока не останется одно резюме. +`max_tokens_map`/`max_tokens_reduce` — раздельные per-tier лимиты генерации +(ADR-004: reduce всегда ≥ map — 1024 токенов на reduce не хватает). + +Тексты промптов (`workers/summarizer/prompts/summary_map_ru.txt`, +`summary_reduce_ru.txt`) утверждены и не меняются в коде — загружаются +лениво из файлов. Подстановка плейсхолдера — через `str.replace`, а не +`str.format`: промпты содержат разметку формата вывода (`[что решено] — +ответственный: [имя]` и т.п.) с квадратными, но потенциально и фигурными +скобками в будущих правках текста — `str.format` на них падает с +`KeyError`/`IndexError`, тогда как `str.replace` нечувствителен к остальному +содержимому файла. +""" + +from pathlib import Path +from typing import Any, ClassVar + +from core.plugins.factory import register_summarizer +from core.plugins.summarizer import Summarizer +from core.summarization.chunking import chunk_transcript +from core.summarization.llm_client import OpenAICompatClient +from core.summarization.tokens import QwenTokenCounter + +_DEFAULT_PROMPTS_DIR = "workers/summarizer/prompts" +_MAP_PROMPT_FILE = "summary_map_ru.txt" +_REDUCE_PROMPT_FILE = "summary_reduce_ru.txt" +_MAP_PLACEHOLDER = "{transcript_chunk}" +_REDUCE_PLACEHOLDER = "{partial_summaries}" + +_REDUCE_BUDGET_TOKENS = 6000 +"""Бюджет токенов на один reduce-вызов (частичные резюме + шаблон промпта); +меньше `max_chunk_tokens` чанкера — запас под текст самого reduce-промпта и +вывод модели в общем контексте (CTX_SIZE=16384).""" + + +@register_summarizer +class QwenLocal(Summarizer): + """Summarizer на Qwen2.5-3B-Instruct через OpenAI-совместимый сервер llama.cpp.""" + + provider: ClassVar[str] = "qwen_local" + + def __init__( + self, + model: str | None = None, + chunk_minutes: int = 20, + base_url: str = "http://llm:8080/v1", + tokenizer_path: str = "/models/qwen/tokenizer.json", + prompts_dir: str = _DEFAULT_PROMPTS_DIR, + temperature: float = 0.2, + max_tokens: int = 1024, + max_tokens_map: int | None = None, + max_tokens_reduce: int | None = None, + **options: Any, + ) -> None: + self.model = model or "qwen2.5-3b-instruct-q4_k_m" + self.chunk_minutes = chunk_minutes + self.base_url = base_url + self.tokenizer_path = tokenizer_path + self.prompts_dir = prompts_dir + self.temperature = temperature + self.max_tokens = max_tokens + # Раздельные лимиты map/reduce (ADR-004, per-tier параметры генерации); + # без явного значения оба используют общий `max_tokens` — обратная + # совместимость со старым форматом конфигурации. + self.max_tokens_map = max_tokens_map if max_tokens_map is not None else max_tokens + self.max_tokens_reduce = max_tokens_reduce if max_tokens_reduce is not None else max_tokens + self.options = options + + self._count_tokens = QwenTokenCounter(tokenizer_path) + self._client: OpenAICompatClient | None = None + self._map_prompt: str | None = None + self._reduce_prompt: str | None = None + + def _get_client(self) -> OpenAICompatClient: + """Лениво создать HTTP-клиент LLM (переиспользуется в рамках инстанса плагина).""" + if self._client is None: + self._client = OpenAICompatClient( + base_url=self.base_url, + model=self.model, + temperature=self.temperature, + max_tokens=self.max_tokens, + **self.options, + ) + return self._client + + def close(self) -> None: + """Закрыть HTTP-клиент LLM, если он был лениво создан (освободить пул соединений). + + Безопасно вызывать многократно и до первого использования — если + клиент ни разу не создавался, ничего не делает. + """ + if self._client is not None: + self._client.close() + self._client = None + + def _load_prompt(self, filename: str) -> str: + """Прочитать текст промпта из `prompts_dir` (без изменений, как есть на диске).""" + return (Path(self.prompts_dir) / filename).read_text(encoding="utf-8") + + def _map_prompt_template(self) -> str: + if self._map_prompt is None: + self._map_prompt = self._load_prompt(_MAP_PROMPT_FILE) + return self._map_prompt + + def _reduce_prompt_template(self) -> str: + if self._reduce_prompt is None: + self._reduce_prompt = self._load_prompt(_REDUCE_PROMPT_FILE) + return self._reduce_prompt + + def _map_chunk(self, chunk: str) -> str: + """Выполнить map-вызов LLM для одного чанка транскрипта.""" + prompt = self._map_prompt_template().replace(_MAP_PLACEHOLDER, chunk) + return self._get_client().complete(prompt, max_tokens=self.max_tokens_map) + + def _reduce_once(self, summaries: list[str]) -> str: + """Выполнить один reduce-вызов LLM над группой частичных резюме.""" + joined = "\n\n".join(summaries) + prompt = self._reduce_prompt_template().replace(_REDUCE_PLACEHOLDER, joined) + return self._get_client().complete(prompt, max_tokens=self.max_tokens_reduce) + + def _group_by_token_budget(self, summaries: list[str], budget: int) -> list[list[str]]: + """Жадно сгруппировать резюме так, чтобы каждая группа влезала в `budget` токенов.""" + groups: list[list[str]] = [] + current: list[str] = [] + current_tokens = 0 + for summary in summaries: + tokens = self._count_tokens(summary) + if current and current_tokens + tokens > budget: + groups.append(current) + current = [] + current_tokens = 0 + current.append(summary) + current_tokens += tokens + if current: + groups.append(current) + return groups + + def _reduce(self, partial_summaries: list[str]) -> str: + """Свести частичные резюме к одному, иерархически группами при переполнении бюджета. + + Группировка по токенам (`_group_by_token_budget`) не гарантирует + прогресс, если отдельные частичные резюме сами не помещаются в + `_REDUCE_BUDGET_TOKENS` (например, при неудачно большом `max_tokens` + в конфиге плагина) — тогда она вырождается в список синглтон-групп, + и список резюме не сокращается. В этом случае принудительно сводим + резюме попарно: длина списка минимум делится пополам на каждой + итерации, что гарантирует завершение цикла за конечное число шагов. + """ + summaries = partial_summaries + while len(summaries) > 1: + joined_tokens = self._count_tokens("\n\n".join(summaries)) + if joined_tokens <= _REDUCE_BUDGET_TOKENS: + return self._reduce_once(summaries) + + groups = self._group_by_token_budget(summaries, _REDUCE_BUDGET_TOKENS) + if len(groups) >= len(summaries): + # Группировка по бюджету не уменьшила число групп (каждое + # резюме — уже отдельная группа) — гарантируем прогресс + # принудительным объединением попарно. + groups = [summaries[i : i + 2] for i in range(0, len(summaries), 2)] + summaries = [self._reduce_once(group) for group in groups] + return summaries[0] + + def summarize(self, transcript: str) -> str: + """Построить резюме транскрипта: map по чанкам, затем reduce до одного текста. + + Пустой транскрипт (пустой список чанков) — пустая строка без вызовов + LLM. Единственный чанк — map-результат уже соответствует формату + reduce-вывода, дополнительный reduce-вызов не требуется. + + HTTP-клиент LLM (если он был создан) закрывается по завершении вызова + независимо от исхода — плагин инстанцируется на одну задачу + суммаризации (см. `create_summarizer` в фабрике), поэтому держать + пул соединений открытым дольше одного вызова `summarize` не нужно. + """ + try: + chunks = chunk_transcript( + transcript, + self._count_tokens, + target_chunk_minutes=self.chunk_minutes, + ) + if not chunks: + return "" + + partial_summaries = [self._map_chunk(chunk) for chunk in chunks] + if len(partial_summaries) == 1: + return partial_summaries[0] + + return self._reduce(partial_summaries) + finally: + self.close() diff --git a/backend/core/plugins/summarizer.py b/backend/core/plugins/summarizer.py new file mode 100644 index 0000000..631a908 --- /dev/null +++ b/backend/core/plugins/summarizer.py @@ -0,0 +1,15 @@ +"""Контракт плагина Summarizer.""" + +from abc import ABC, abstractmethod +from typing import ClassVar + + +class Summarizer(ABC): + """Интерфейс Strategy для реализаций суммаризации текста.""" + + provider: ClassVar[str] + + @abstractmethod + def summarize(self, transcript: str) -> str: + """Создать резюме переданной трансцрибции.""" + ... diff --git a/backend/core/plugins/transcriber.py b/backend/core/plugins/transcriber.py new file mode 100644 index 0000000..e719e3f --- /dev/null +++ b/backend/core/plugins/transcriber.py @@ -0,0 +1,25 @@ +"""Контракт плагина Transcriber.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import ClassVar + + +@dataclass(frozen=True, slots=True) +class Segment: + """Один транскрибированный сегмент трека.""" + + start: float # секунды от начала трека + end: float + text: str + + +class Transcriber(ABC): + """Интерфейс Strategy для реализаций преобразования речи в текст.""" + + provider: ClassVar[str] + + @abstractmethod + def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]: + """Транскрибировать аудиофайл по пути `audio_path` в список сегментов.""" + ... diff --git a/backend/core/rate_limit.py b/backend/core/rate_limit.py new file mode 100644 index 0000000..e1b1d00 --- /dev/null +++ b/backend/core/rate_limit.py @@ -0,0 +1,36 @@ +"""Rate limit на основе Redis `INCR`+`EXPIRE` для публичных (без auth) эндпоинтов. + +Используется резолвом конференций и гостевым входом (`api/conferences.py`) — +эндпоинтами без аутентификации, уязвимыми к перебору номера/ссылки конференции +(см. ADR-001, п.4 — оценка энтропии и рекомендуемый лимит 10 запросов/мин на IP). +""" + +from fastapi import HTTPException, status + +from core.redis import redis_client + +RATE_LIMIT_MAX_REQUESTS = 10 +RATE_LIMIT_WINDOW_SECONDS = 60 + + +async def enforce_rate_limit( + key: str, + *, + max_requests: int = RATE_LIMIT_MAX_REQUESTS, + window_seconds: int = RATE_LIMIT_WINDOW_SECONDS, +) -> None: + """Увеличить счётчик запросов по ключу; бросить 429, если лимит превышен. + + `INCR` атомарно создаёт ключ со значением 1, если его ещё не было; TTL + выставляется только при первом инкременте в окне (когда счётчик стал + равен 1) — иначе окно продлевалось бы при каждом запросе и лимит + никогда бы не истекал. + """ + redis_key = f"rate_limit:{key}" + current = await redis_client.incr(redis_key) + if current == 1: + await redis_client.expire(redis_key, window_seconds) + if current > max_requests: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="rate_limit_exceeded" + ) diff --git a/backend/core/redis.py b/backend/core/redis.py new file mode 100644 index 0000000..c709fdc --- /dev/null +++ b/backend/core/redis.py @@ -0,0 +1,9 @@ +"""Настройка асинхронного Redis клиента.""" + +from redis.asyncio import Redis + +from core.config import get_settings + +settings = get_settings() + +redis_client: Redis = Redis.from_url(settings.redis_url, decode_responses=True) diff --git a/backend/core/security.py b/backend/core/security.py new file mode 100644 index 0000000..7c7e6b0 --- /dev/null +++ b/backend/core/security.py @@ -0,0 +1,71 @@ +"""Хэширование паролей (argon2) и выпуск/проверка JWT (access + refresh).""" + +import uuid +from datetime import UTC, datetime, timedelta +from typing import Any + +import jwt +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + +from core.config import get_settings + +JWT_ALGORITHM = "HS256" + +_hasher = PasswordHasher() + + +def hash_password(password: str) -> str: + """Захэшировать пароль алгоритмом argon2 для хранения в БД.""" + return _hasher.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + """Сверить пароль с сохранённым argon2-хэшем; пароль/хэш никогда не логируются.""" + try: + return _hasher.verify(password_hash, password) + except VerifyMismatchError: + return False + + +def create_access_token(user_id: uuid.UUID, role: str) -> str: + """Выпустить access-токен: `sub`=user_id, `role`=роль, TTL из настроек.""" + settings = get_settings() + now = datetime.now(UTC) + payload = { + "sub": str(user_id), + "role": role, + "type": "access", + "iat": now, + "exp": now + timedelta(minutes=settings.access_token_ttl_minutes), + } + return jwt.encode(payload, settings.jwt_secret, algorithm=JWT_ALGORITHM) + + +def create_refresh_token(user_id: uuid.UUID) -> tuple[str, str]: + """Выпустить refresh-токен с уникальным `jti`. + + Возвращает пару (token, jti); сохранение jti в Redis — ответственность + вызывающего кода (`services.auth.AuthService`). + """ + settings = get_settings() + jti = str(uuid.uuid4()) + now = datetime.now(UTC) + payload = { + "sub": str(user_id), + "jti": jti, + "type": "refresh", + "iat": now, + "exp": now + timedelta(days=settings.refresh_token_ttl_days), + } + token = jwt.encode(payload, settings.jwt_secret, algorithm=JWT_ALGORITHM) + return token, jti + + +def decode_token(token: str) -> dict[str, Any]: + """Декодировать и верифицировать JWT (сигнатура + срок действия). + + Бросает `jwt.PyJWTError` (или подкласс) при невалидном/просроченном токене. + """ + settings = get_settings() + return jwt.decode(token, settings.jwt_secret, algorithms=[JWT_ALGORITHM]) diff --git a/backend/core/summarization/__init__.py b/backend/core/summarization/__init__.py new file mode 100644 index 0000000..17c1542 --- /dev/null +++ b/backend/core/summarization/__init__.py @@ -0,0 +1,5 @@ +"""Пакет чистых функций суммаризации: чанкинг транскрипта и подсчёт токенов. + +Плагин `QwenLocal` использует эти функции как строительные +блоки map-reduce; сам пакет не знает про LLM и HTTP. +""" diff --git a/backend/core/summarization/chunking.py b/backend/core/summarization/chunking.py new file mode 100644 index 0000000..8dd3351 --- /dev/null +++ b/backend/core/summarization/chunking.py @@ -0,0 +1,156 @@ +"""Чанкинг транскрипта для map-стадии суммаризации (ТЗ §1.3). + +Транскрипт — строка с одной фразой на строку в формате `[Имя MM:SS] текст` +(или `[Имя ЧЧ:MM:SS] текст` от часа, см. `workers/summarizer/transcript.py`). +Каждая строка уже атомарна и соответствует одной реконструированной фразе +(`build_phrases`) — граница фразы там уже равна смене спикера, +поэтому `chunk_transcript` закрывает чанк исключительно на границах строк и +никогда не режет фразу пополам (кроме аварийного случая монолога, см. ниже). + +Правила закрытия чанка (проверяются перед добавлением очередной фразы): +- добавление фразы сделало бы охваченный чанком промежуток времени + (от начала чанка до начала этой фразы) >= `target_chunk_minutes`; +- ЛИБО добавление фразы превысило бы `max_chunk_tokens` для чанка. +В любом из этих случаев уже накопленный чанк закрывается, а фраза уходит в +новый чанк. + +Аварийный случай — монолог: единственная фраза сама по себе превышает +`max_chunk_tokens` (например, 40-минутный монолог одного участника). Такую +фразу нельзя оставить целой и нельзя просто выбросить — она делится по +границам предложений на несколько частей меньше лимита, каждая из которых +получает повторённую исходную метку `[Имя MM:SS]`, и добавляется в список +чанков как самостоятельный чанк. +""" + +import re +from collections.abc import Callable + +_LABEL_RE = re.compile(r"^\[(?P.+) (?P