Compare commits

...

31 Commits

Author SHA1 Message Date
Florent Chehab fb4f49b5fc fixup! (summary) add multi-tenant support and v2 tasks / API 2026-03-23 17:08:17 +01:00
Florent Chehab 0e390dd1cd (summary) add multi-tenant support and v2 tasks / API
Add multitenancy support to Summary sub-app. The V1 routes / tasks
behave like before, with the default tenant being "meet".

V2 routes / tasks support being called frm any tenant, and don't have
meet related logic.
V2 tasks are created in separate queues to avoid mix / match,i
2026-03-23 16:30:58 +01:00
lebaudantoine 4f2c4bfaf9 ️(summary) improve accessibility of transcription download link
Update the link label to use explicit text "Download your recording"
instead of generic "following this link."

This ensures blind users understand the purpose of the link
and the behavior of opening a new window.
2026-03-20 10:17:48 +01:00
lebaudantoine dacf705329 🍱(frontend) update logo to the latest version
Replace the previous logo with the most up-to-date brand
assets to ensure consistency across the application and emails.
2026-03-20 09:54:12 +01:00
lebaudantoine 8296738347 ️(backend) improve logo access in screen recording email notification
Update the logo alternative text to include the brand name instead
of a generic "logo image" description.

Use a more descriptive value such as "Logo LaSuite Meet"
to better convey the content to screen reader users.

It closes #1092
2026-03-20 09:54:08 +01:00
Florent Chehab 04be495351 💄(custom-background) add upload indicator with preview
When uploading an image, depending on the available network
there might be a bit of wait while the image is being uploaded.
In this commit we add a preview (grayscale + spinner) to have
UI feedback that the upload is in progress.
2026-03-19 17:27:51 +01:00
Florent Chehab 43185605eb 💄(spinner) enforce spinner height
For some reason the ProgressBar adds a bit of height to the spinner
which makes it hard to center.
2026-03-19 17:27:21 +01:00
Florent Chehab cf3fb208e2 🐛(frontend) auto-select new custom background when not logged in
When not logged in and selecting a new custom personal background
the constant id was causing the new custom background not to be auto
selected.
2026-03-19 17:27:21 +01:00
Florent Chehab 4ca230eb12 🐛(frontend) disable personal custom background while deleting
Prevent users selecting a personal custom background while deleting one.
2026-03-19 17:27:20 +01:00
lebaudantoine 4b5e0cb2a3 ️(frontend) improve button descriptions for More tools actions #1184
The "Transcribe" and "Record" buttons had unclear and misleading
descriptions, both using the verb "record," which caused confusion,
especially for screen reader users.

Update descriptions to clearly reflect each action:
- Transcribe: generate a written transcript of the conversation
- Record: save the meeting as a video

This improves accessibility (RGAA 11.9) and reduces the risk of
users triggering the wrong action.

Closes #1173
2026-03-19 14:48:02 +01:00
lebaudantoine 45f374610f ️(frontend) fix more tools heading hierarchy
Side panel title is an H1, but the hierarchy skips directly to H3.
Fix the heading structure. It closes #1178.
2026-03-19 11:56:16 +01:00
lebaudantoine b419a2bfd2 ️(frontend) fix sidepanel accessibility aria-label
The aria-label only announced the presence of a sidepanel without
including its title.

Append the sidepanel title to improve accessibility and context
for screen readers.

Closes #1176.
2026-03-19 11:21:40 +01:00
lebaudantoine ee8d96bee7 🔖(minor) bump release to 1.11.0 2026-03-19 00:31:32 +01:00
lebaudantoine c65ff2d75d 🩹(frontend) disable subtitle settings when feature is unavailable
Hide or disable settings related to the subtitle feature when
the feature flag is not enabled.
2026-03-18 23:25:20 +01:00
Cyril ea1c90d8ca (feat) add default color option for captions
Add "Default" option (white text on black background) for font and background.
2026-03-18 20:44:25 +01:00
Cyril 3e963e3e6d ♻️(refactor) apply caption color customization to subtitles
Use captionFontColor and captionBackgroundColor in Transcription component.
2026-03-18 20:44:25 +01:00
Cyril 82769128a1 (feat) add caption font color and background settings
Select font color and background color in Accessibility > Captions.
2026-03-18 20:44:25 +01:00
Cyril 05f4ce6b2e 🌐(i18n) add captions color settings translations
EN, FR, DE, NL for Accessibility > Captions > Font color / Background color.
2026-03-18 20:44:25 +01:00
Cyril 508984ecfa ️(frontend) add caption font color and background to access store
Persist captionFontColor and captionBackgroundColor in user preferences.
2026-03-18 20:44:25 +01:00
lebaudantoine a05507f73d 👷(ci) run summary test suite
Configure the CI to run the summary tests' suite.
2026-03-18 20:19:52 +01:00
lebaudantoine a27def119c (summary) add tests for the task endpoint
Ensure the API correctly validates and accepts the parameters
required to register a task in the queue.
2026-03-18 20:19:52 +01:00
lebaudantoine fce94f38ce 🧑‍💻(summary) add make command to run summary test suite
Introduce a dedicated make target to execute tests for the
summary component of the stack.
2026-03-18 20:19:52 +01:00
lebaudantoine 83bd9c5ce3 (summary) add minimal test suite for health endpoint
Based on cameldev's work, introduce basic tests to ensure the
health endpoint responds correctly.
2026-03-18 20:19:52 +01:00
Florent Chehab bfbfade99a 🐛(frontend) prevent black background image
Prevent showing a black background when the image is not
accessible anymore.

This happens after the user logs out or logs in.
Or the auth is revoked.
2026-03-18 18:49:59 +01:00
Florent Chehab 16daf7b8d3 (frontend) add custom virtual background feature
Add a custom virtual background feature.

If the backend supports uploading files, backgrounds are stored
in the backend for the user.
Otherwise, only one background image can be selected.
2026-03-18 18:49:59 +01:00
lebaudantoine fcad79d662 📌(agents) unpin OpenSSL and related dependencies
The base image now includes OpenSSL 3.5.5, which resolves
CVE-2025-15467.

Remove explicit pinning of OpenSSL and its dependencies.
2026-03-18 11:34:44 +01:00
lebaudantoine 2424ce17ec 👷(ci) re-enable Trivy scan in CI for image builds
Restore Trivy scanning jobs for built images after the situation
was clarified, ensuring vulnerability checks are active again.
2026-03-18 10:26:23 +01:00
camillem 4715905334 docs: fix typo and markdown 2026-03-18 10:24:09 +01:00
renovate[bot] 7c05aedcfe ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] 2026-03-17 18:33:39 +01:00
lebaudantoine 7347fc7c86 🚨(doc) fix changelog linting
Merge an outside contribution, didn't notice it broke the changelog fix it.
2026-03-17 16:45:04 +01:00
Hadrien Blanc ada7d9a666 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor
The getImageData call was using PROCESSING_WIDTH for both dimensions
instead of PROCESSING_WIDTH and PROCESSING_HEIGHT. This caused the
source image data to be 256x256 instead of the expected 256x144, leading
to buffer overflow when writing to the segmentation mask and potential
visual artifacts in Firefox background effects.
2026-03-17 16:41:40 +01:00
84 changed files with 3070 additions and 613 deletions
+33 -33
View File
@@ -48,12 +48,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '--target backend-production -f Dockerfile'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -93,12 +93,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -139,12 +139,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -185,13 +185,13 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/summary/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
@@ -233,14 +233,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/agents/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
# docker-context: './src/agents'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@v6
+39
View File
@@ -297,6 +297,45 @@ jobs:
- name: Run tests
run: uv run pytest -n 2
test-summary:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/summary
env:
V1_TENANT_ID: 'test-tenant'
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet"
AWS_S3_SECRET_ACCESS_KEY: "password"
WHISPERX_BASE_URL: "https://configure-your-url.com"
WHISPERX_ASR_MODEL: "large-v2"
WHISPERX_API_KEY: "test-whisperx-secret"
WHISPERX_DEFAULT_LANGUAGE: "fr"
LLM_BASE_URL: "https://configure-your-url.com"
LLM_API_KEY: "test-llm-secret"
LLM_MODEL: "test-llm-model"
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Run summary tests
run: ~/.local/bin/pytest
lint-front:
runs-on: ubuntu-latest
permissions:
+24 -2
View File
@@ -8,6 +8,23 @@ and this project adheres to
## [Unreleased]
### Changed
- ♿️(frontend) fix sidepanel accessibility aria-label #1182
- ♿️(frontend) fix more tools heading hierarchy #1181
- ♿️(fronted) improve button descriptions for More tools actions #1184
- 💄(spinner) enforce spinner height #1183
- 💄(custom-background) add upload indicator with preview #1183
- ♿️(backend) improve logo accessibility in recording email notification #1092
- ♿️(summary) improve accessibility of transcription download link #1187
### Fixed
- 🐛(frontend) disable personal custom background while deleting #1183
- 🐛(frontend) auto-select new custom background when not logged in #1183
## [1.11.0] - 2026-03-19
### Added
- ✨(helm) support celery with our Django backend #1124
@@ -15,20 +32,24 @@ and this project adheres to
- ✨(backend) add authenticated user rate throttling on request-entry #1129
- ✨(backend) expose `is_active` field for Application in Django admin #1133
- ✨(file-upload) disable by default & limit count by user #1141
- ✨(frontend) custom background #1067
### Changed
- ♿️(frontend) Caption text size setting for accessibility #1062
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
- ♿(frontend) improve chat toast a11y for screen readers #1109
- ♿(frontend) improve ui and aria labels for help article links #1108
-(frontend) improve chat toast a11y for screen readers #1109
-(frontend) improve ui and aria labels for help article links #1108
- 🌐(frontend) improve German translation #1125
- 🔨(python-env) migrate meet main app to UV #1120
- ♻️(backend) align Application model field with `is_active` convention #1133
- 🔐(backend) avoids revealing the inactive status of an application #1135
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
- 🔒️(backend) avoid information exposure through exception messages #1144
- ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] #1151
- 📌(agents) unpin OpenSSL and related dependencies #1167
- ♿️(frontend) add caption font and background color customization #1122
### Fixed
@@ -36,6 +57,7 @@ and this project adheres to
- 🩹(backend) add page_size to pagination for room endpoints #1131
- 🐛(backend) refactor lobby throttling to use participant id #1129
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
- 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor #1116
## [1.10.0] - 2026-03-05
+6
View File
@@ -191,6 +191,7 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
test: ## run project tests
@$(MAKE) test-back-parallel
@$(MAKE) test-summary
.PHONY: test
test-back: ## run back-end tests
@@ -203,6 +204,11 @@ test-back-parallel: ## run all back-end tests in parallel
bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel
test-summary: ## run summary tests
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest-summary $${args:-${1}}
.PHONY: test-summary
makemigrations: ## run django makemigrations for the Meet project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
+1
View File
@@ -101,6 +101,7 @@ k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
app-summary-dev \
python -m pytest "$@"
+3 -4
View File
@@ -61,11 +61,10 @@ services:
`docker compose up -d`
```
Your keycloak instance is now available on https://doc.yourdomain.tld
Your keycloak instance is now available on https://id.yourdomain.tld
> [!CAUTION]
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags).
```
## Creating an OIDC Client for Meet Application
@@ -76,7 +75,7 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
3. Enter the name of the realm - `meet`.
4. Click "Create".
#### Step 2: Create a New Client
### Step 2: Create a New Client
1. Navigate to the "Clients" tab.
2. Click on the "Create client" button.
@@ -86,7 +85,7 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
1. Click "Save".
#### Step 3: Get Client Credentials
### Step 3: Get Client Credentials
1. Go to the "Credentials" tab.
2. Copy the client ID (`meet` in this example) and the client secret.
+1 -1
View File
@@ -27,7 +27,7 @@ AWS_S3_DOMAIN_REPLACE=http://localhost:9000
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:8083
MEDIA_BASE_URL=http://localhost:3000
FILE_UPLOAD_ENABLED=True
# OIDC
-2
View File
@@ -4,8 +4,6 @@ FROM python:3.13-slim AS base
RUN apt-get update && apt-get install -y \
libglib2.0-0 \
libgobject-2.0-0 \
"openssl=3.5.4-1~deb13u2" \
"libssl3t64=3.5.4-1~deb13u2" \
&& rm -rf /var/lib/apt/lists/*
FROM base AS builder
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.10.0"
version = "1.11.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
+2 -2
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.10.0"
version = "1.11.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -52,7 +52,7 @@ dependencies = [
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.3.2",
"pydantic==2.12.4",
"PyJWT==2.11.0",
"PyJWT==2.12.0",
"python-frontmatter==1.1.0",
"python-magic==0.4.27",
"requests==2.32.5",
+5 -5
View File
@@ -1157,7 +1157,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.10.0"
version = "1.11.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1251,7 +1251,7 @@ requires-dist = [
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.2" },
{ name = "pydantic", specifier = "==2.12.4" },
{ name = "pyjwt", specifier = "==2.11.0" },
{ name = "pyjwt", specifier = "==2.12.0" },
{ name = "python-frontmatter", specifier = "==1.1.0" },
{ name = "python-magic", specifier = "==0.4.27" },
{ name = "redis", specifier = "==5.2.1" },
@@ -1754,11 +1754,11 @@ wheels = [
[[package]]
name = "pyjwt"
version = "2.11.0"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", size = 102511, upload-time = "2026-03-12T17:15:30.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
{ url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" },
]
[[package]]
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.10.0",
"version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.10.0",
"version": "1.11.0",
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.34",
"@fontsource/material-icons-outlined": "5.2.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.10.0",
"version": "1.11.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

+13 -1
View File
@@ -15,7 +15,19 @@ export const fetchApi = async <T = Record<string, unknown>>(
...options?.headers,
},
})
const result = await response.json()
let result: T
if (response.status === 204) {
result = undefined as T
} else {
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
result = undefined as T
} else {
result = (await response.json()) as T
}
}
if (!response.ok) {
throw new ApiError(response.status, result)
}
+1
View File
@@ -5,4 +5,5 @@ export const keys = {
requestEntry: 'requestEntry',
waitingParticipants: 'waitingParticipants',
roomCreationCallback: 'roomCreationCallback',
files: 'files',
}
+7
View File
@@ -30,6 +30,13 @@ export interface ApiConfig {
expiration_days?: number
max_duration?: number
}
background_image: {
upload_is_enabled: boolean
max_size: number
max_count_by_user: number
allowed_extensions: string[]
allowed_mimetypes: string[]
}
subtitle: {
enabled: boolean
}
@@ -0,0 +1,93 @@
import { fetchApi } from '@/api/fetchApi'
import { useMutation } from '@tanstack/react-query'
import { ApiFileItem } from '@/features/files/api/types.ts'
import { keys } from '@/api/queryKeys.ts'
import { queryClient } from '@/api/queryClient.ts'
/**
* Upload a file, using XHR so we can report on progress through a handler.
*
* @param url The URL to PUT the file to.
* @param file The file to upload.
* @param progressHandler A handler that receives progress updates as a single integer `0 <= x <= 100`.
*/
export const uploadFile = (
url: string,
file: File,
progressHandler: (progress: number) => void
) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('PUT', url)
xhr.setRequestHeader('X-amz-acl', 'private')
xhr.setRequestHeader('Content-Type', file.type)
xhr.addEventListener('error', reject)
xhr.addEventListener('abort', reject)
xhr.addEventListener('readystatechange', () => {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
// Make sure to always set the progress to 100% when the upload is done.
// Because 'progress' event listener is not called when the file size is 0.
progressHandler(100)
return resolve(true)
}
reject(new Error(`Failed to perform the upload on ${url}.`))
}
})
xhr.upload.addEventListener('progress', (progressEvent) => {
if (progressEvent.lengthComputable) {
progressHandler(
Math.floor((progressEvent.loaded / progressEvent.total) * 100)
)
}
})
xhr.send(file)
})
/**
* Asynchronously creates a new file and uploads it to the server.
*
* @param {object} params - The parameters for the file creation and upload process.
* @param {File} params.file - The file object to be uploaded.
* @param {function} params.onProgress - A callback function that receives the upload progress as a number (0 to 100).
* @returns {Promise<ApiFileItem>} A promise that resolves when the file has been successfully uploaded and the server process is completed.
*/
export const createFile = async ({
file,
onProgress,
}: {
file: File
onProgress: (progress: number) => void
}): Promise<ApiFileItem> => {
const res = await fetchApi<ApiFileItem>(`/files/`, {
method: 'POST',
body: JSON.stringify({ filename: file.name, type: 'background_image' }),
})
if (res.upload_state !== 'pending') {
throw new Error('State should be pending right after creation')
}
const policy = res.policy
await uploadFile(policy, file, onProgress)
const createdFile = await fetchApi<ApiFileItem>(
`/files/${res.id}/upload-ended/`,
{
method: 'POST',
}
)
// We invalidate the files query to make sure the new file is immediately available.
await queryClient.invalidateQueries({
queryKey: [keys.files],
})
return createdFile
}
export const useCreateFile = () => {
return useMutation({
mutationFn: createFile,
})
}
@@ -0,0 +1,33 @@
import { fetchApi } from '@/api/fetchApi'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys.ts'
/**
* Deletes a file specified by its unique identifier.
*
* @param {Object} params - The parameters required for deleting the file.
* @param {string} params.fileId - The unique identifier of the file to be deleted.
* @returns {Promise<void>} A promise that resolves when the file is successfully deleted.
*/
export const deleteFile = async ({
fileId,
}: {
fileId: string
}): Promise<void> => {
await fetchApi<void>(`/files/${fileId}/`, {
method: 'DELETE',
})
}
export const useDeleteFile = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteFile,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: [keys.files],
})
},
})
}
@@ -0,0 +1,70 @@
import { fetchApi } from '@/api/fetchApi'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys'
import {
ApiFileItem,
ApiFileType,
ApiFileUploadState,
} from '@/features/files/api/types.ts'
import { useUser } from '@/features/auth'
import { useConfig } from '@/api/useConfig.ts'
type ListFilesResponse = {
count: number
next: string | null
previous: string | null
results: ApiFileItem[]
}
type ListFilesFilters = {
is_creator_me?: boolean
type?: ApiFileType
upload_state?: ApiFileUploadState
is_deleted?: boolean
}
export type ListFilesParams = {
filters?: ListFilesFilters
pagination: {
page: number
pageSize: number
}
}
export const listMyFiles = async ({
filters = {},
pagination: { page, pageSize },
}: ListFilesParams): Promise<ListFilesResponse> => {
const query = new URLSearchParams()
query.append('page', page.toString())
query.append('page_size', pageSize.toString())
if (filters?.is_creator_me ?? true) {
query.append('is_creator_me', 'true')
}
if (filters?.type) {
query.append('type', filters.type)
}
if (filters?.upload_state) {
query.append('upload_state', filters.upload_state)
}
if (typeof filters?.is_deleted === 'boolean') {
query.append('is_deleted', filters.is_deleted ? 'true' : 'false')
}
return fetchApi<ListFilesResponse>(`/files?${query.toString()}`, {
method: 'GET',
})
}
export const useListMyFiles = (params: Parameters<typeof listMyFiles>[0]) => {
const { isLoggedIn } = useUser()
const { data: appConfig } = useConfig()
return useQuery({
queryKey: [keys.files, params],
queryFn: () => listMyFiles(params),
refetchOnMount: 'always',
placeholderData: keepPreviousData,
enabled:
isLoggedIn && appConfig?.background_image?.upload_is_enabled === true,
})
}
@@ -0,0 +1,34 @@
export type ApiFileCreator = {
id: string // UUID
full_name: string | null
short_name: string | null
}
export type ApiFileType = 'background_image'
export type ApiFileUploadState = 'pending' | 'ready'
export type ApiFileItem = {
id: string // UUID
created_at: string // ISO datetime string
updated_at: string // ISO datetime string
title: string
type: ApiFileType
creator: ApiFileCreator
deleted_at: string | null
hard_deleted_at: string | null
filename: string
upload_state: ApiFileUploadState
mimetype: string // e.g. "image/png"
size: number // file size in bytes
description: string | null
} & (
| {
upload_state: 'ready'
url: string
}
| {
upload_state: 'pending'
policy: string
url: null
}
)
@@ -215,8 +215,8 @@ export const Conference = ({
audio={userConfig.audioEnabled}
video={
userConfig.videoEnabled && {
processor: BackgroundProcessorFactory.deserializeProcessor(
userConfig.processorSerialized
processor: BackgroundProcessorFactory.fromProcessorConfig(
userConfig.processorConfig
),
}
}
@@ -4,15 +4,15 @@ import { css } from '@/styled-system/css'
import { Screen } from '@/layout/Screen'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
createLocalVideoTrack,
createLocalAudioTrack,
createLocalVideoTrack,
LocalAudioTrack,
LocalVideoTrack,
Track,
} from 'livekit-client'
import { H } from '@/primitives/H'
import { Field } from '@/primitives/Field'
import { Button, Dialog, Text, Form } from '@/primitives'
import { Button, Dialog, Form, Text } from '@/primitives'
import { VStack } from '@/styled-system/jsx'
import { Heading } from 'react-aria-components'
import { RiImageCircleAiFill } from '@remixicon/react'
@@ -44,8 +44,7 @@ const onError = (e: Error) => console.error('ERROR', e)
const Effects = ({
videoTrack,
onSubmit,
}: Pick<EffectsConfigurationProps, 'videoTrack' | 'onSubmit'>) => {
}: Pick<EffectsConfigurationProps, 'videoTrack'>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' })
const [isDialogOpen, setIsDialogOpen] = useState(false)
const openDialog = () => setIsDialogOpen(true)
@@ -81,7 +80,7 @@ const Effects = ({
>
{t('subTitle')}
</Text>
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
<EffectsConfiguration videoTrack={videoTrack} />
</Dialog>
<Button
variant="whiteCircle"
@@ -111,7 +110,7 @@ export const Join = ({
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorSerialized,
processorConfig,
username,
},
saveAudioInputEnabled,
@@ -120,7 +119,6 @@ export const Join = ({
saveAudioInputDeviceId,
saveVideoInputDeviceId,
saveUsername,
saveProcessorSerialized,
} = usePersistentUserChoices()
const initialUserChoices = useRef<LocalUserChoices | null>(null)
@@ -132,7 +130,7 @@ export const Join = ({
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorSerialized,
processorConfig,
username,
}
}
@@ -146,8 +144,8 @@ export const Join = ({
video: !!initialUserChoices.current &&
initialUserChoices.current?.videoEnabled && {
deviceId: initialUserChoices.current.videoDeviceId,
processor: BackgroundProcessorFactory.deserializeProcessor(
initialUserChoices.current.processorSerialized
processor: BackgroundProcessorFactory.fromProcessorConfig(
initialUserChoices.current.processorConfig
),
},
},
@@ -186,9 +184,7 @@ export const Join = ({
const track = await createLocalVideoTrack({
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.deserializeProcessor(
processorSerialized
),
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
})
setDynamicVideoTrack(track)
} catch (error) {
@@ -207,7 +203,7 @@ export const Join = ({
}, [
videoEnabled,
videoDeviceId,
processorSerialized,
processorConfig,
previewVideoTrack,
dynamicVideoTrack,
])
@@ -690,12 +686,7 @@ export const Join = ({
zIndex: '1',
})}
>
<Effects
videoTrack={videoTrack}
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
<Effects videoTrack={videoTrack} />
</div>
</div>
</div>
@@ -149,11 +149,12 @@ export const SidePanel = () => {
activeSubPanelId,
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
const title = t(`heading.${activeSubPanelId || activePanelId}`)
return (
<StyledSidePanel
title={t(`heading.${activeSubPanelId || activePanelId}`)}
ariaLabel={t('ariaLabel')}
title={title}
ariaLabel={t('ariaLabel', { title })}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
@@ -65,7 +65,7 @@ const ToolButton = ({
<div>
<Text
margin={false}
as="h3"
as="h2"
className={css({
display: 'flex',
gap: 0.25,
@@ -11,11 +11,7 @@ import {
TIMEOUT_TICK,
timerWorkerScript,
} from './TimerWorker'
import {
BackgroundProcessorInterface,
BackgroundOptions,
ProcessorType,
} from '.'
import { BackgroundProcessorInterface, ProcessorConfig, ProcessorType } from '.'
const PROCESSING_WIDTH = 256
const PROCESSING_HEIGHT = 144
@@ -33,7 +29,7 @@ const DEFAULT_BLUR = '10'
* MediaStreamTrackProcessor.
*/
export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
options: BackgroundOptions
options: ProcessorConfig
name: string
processedTrack?: MediaStreamTrack | undefined
@@ -64,15 +60,10 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
type: ProcessorType
virtualBackgroundImage?: HTMLImageElement
constructor(opts: BackgroundOptions) {
constructor(opts: ProcessorConfig) {
this.name = 'blur'
this.options = opts
if (this.options.blurRadius) {
this.type = ProcessorType.BLUR
} else {
this.type = ProcessorType.VIRTUAL
}
this.type = opts.type
}
static get isSupported() {
@@ -107,6 +98,12 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
}
_initVirtualBackgroundImage() {
if (this.options.type !== 'virtual') {
throw new Error(
'Virtual background is only supported for virtual background'
)
}
const needsUpdate =
this.options.imagePath &&
this.virtualBackgroundImage &&
@@ -118,7 +115,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
}
}
async update(opts: BackgroundOptions): Promise<void> {
async update(opts: ProcessorConfig): Promise<void> {
this.options = opts
this._initVirtualBackgroundImage()
}
@@ -187,7 +184,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
0,
0,
PROCESSING_WIDTH,
PROCESSING_WIDTH
PROCESSING_HEIGHT
)
}
@@ -212,6 +209,9 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
* TODO: future improvement with WebGL.
*/
async blur() {
if (this.options.type !== 'blur') {
throw new Error('Blurring is only supported for blur background')
}
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
for (let i = 0; i < mask.length; ++i) {
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
@@ -293,7 +293,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
await this.sizeSource()
await this.segment()
if (this.options.blurRadius) {
if (this.options.type === 'blur') {
await this.blur()
} else {
await this.drawVirtualBackground()
@@ -354,15 +354,4 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
this.timerWorker?.terminate()
this.imageSegmenter?.close()
}
clone() {
return new BackgroundCustomProcessor(this.options)
}
serialize() {
return {
type: this.type,
options: this.options,
}
}
}
@@ -332,15 +332,4 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
this.timerWorker?.terminate()
this.faceLandmarker?.close()
}
clone() {
return new FaceLandmarksProcessor(this.options)
}
serialize() {
return {
type: this.type,
options: this.options,
}
}
}
@@ -1,28 +1,23 @@
import { ProcessorOptions, Track } from 'livekit-client'
import {
BackgroundBlur,
BackgroundTransformer,
ProcessorWrapper,
VirtualBackground,
} from '@livekit/track-processors'
import {
BackgroundOptions,
BackgroundProcessorInterface,
ProcessorType,
} from '.'
import { ProcessorConfig, BackgroundProcessorInterface, ProcessorType } from '.'
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
processor: ProcessorWrapper<BackgroundOptions>
opts: BackgroundOptions
processor: ProcessorWrapper<{ imagePath?: string; blurRadius?: number }>
opts: ProcessorConfig
processorType: ProcessorType
constructor(opts: BackgroundOptions) {
constructor(opts: ProcessorConfig) {
this.opts = opts
if (opts.imagePath) {
if (opts.type === 'virtual') {
this.processorType = ProcessorType.VIRTUAL
this.processor = VirtualBackground(opts.imagePath)
} else if (opts.blurRadius !== undefined) {
} else if (opts.type === 'blur') {
this.processorType = ProcessorType.BLUR
this.processor = BackgroundBlur(opts.blurRadius)
} else {
@@ -44,24 +39,23 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
return this.processor.destroy()
}
async update(opts: BackgroundOptions): Promise<void> {
const newProcessorType = opts.imagePath
? ProcessorType.VIRTUAL
: ProcessorType.BLUR
async update(opts: ProcessorConfig): Promise<void> {
this.opts = opts
const newProcessorType =
opts.type === 'virtual' ? ProcessorType.VIRTUAL : ProcessorType.BLUR
let processedOpts = opts
if (newProcessorType !== this.processorType) {
this.processorType = newProcessorType
if (newProcessorType === ProcessorType.VIRTUAL) {
this.processor.name = 'virtual-background'
processedOpts = { ...opts, blurRadius: undefined }
} else {
this.processor.name = 'background-blur'
processedOpts = { ...opts, imagePath: undefined }
}
}
await this.processor.updateTransformerOptions(processedOpts)
this.opts = processedOpts
await this.processor.updateTransformerOptions(
opts as { imagePath?: string; blurRadius?: number }
)
}
get name() {
@@ -73,17 +67,6 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
}
get options() {
return (this.processor.transformer as BackgroundTransformer).options
}
clone() {
return new UnifiedBackgroundTrackProcessor(this.options || this.opts)
}
serialize() {
return {
type: this.processorType,
options: this.options,
}
return this.opts
}
}
@@ -2,23 +2,7 @@ import { ProcessorWrapper } from '@livekit/track-processors'
import { Track, TrackProcessor } from 'livekit-client'
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
export type BackgroundOptions = {
blurRadius?: number
imagePath?: string
}
export interface ProcessorSerialized {
type: ProcessorType
options: BackgroundOptions
}
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
update(opts: BackgroundOptions): Promise<void>
options: BackgroundOptions
clone(): BackgroundProcessorInterface
serialize(): ProcessorSerialized
}
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
export enum ProcessorType {
BLUR = 'blur',
@@ -26,6 +10,16 @@ export enum ProcessorType {
FACE_LANDMARKS = 'faceLandmarks',
}
export type ProcessorConfig =
| { type: ProcessorType.BLUR; blurRadius: number }
| { type: ProcessorType.VIRTUAL; imagePath: string; fileId?: string }
| ({ type: ProcessorType.FACE_LANDMARKS } & FaceLandmarksOptions)
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
update(opts: ProcessorConfig): Promise<void>
options: ProcessorConfig
}
export class BackgroundProcessorFactory {
static hasModernApiSupport() {
return ProcessorWrapper.hasModernApiSupport
@@ -36,28 +30,27 @@ export class BackgroundProcessorFactory {
}
static getProcessor(
type: ProcessorType,
opts: BackgroundOptions
config: ProcessorConfig
): BackgroundProcessorInterface | undefined {
const isBlur = type === ProcessorType.BLUR
const isVirtual = type === ProcessorType.VIRTUAL
const isBlur = config.type === ProcessorType.BLUR
const isVirtual = config.type === ProcessorType.VIRTUAL
if (!isBlur && !isVirtual) return undefined
if (ProcessorWrapper.isSupported) {
return new UnifiedBackgroundTrackProcessor(opts)
return new UnifiedBackgroundTrackProcessor(config)
}
if (BackgroundCustomProcessor.isSupported) {
return new BackgroundCustomProcessor(opts)
return new BackgroundCustomProcessor(config)
}
return undefined
}
static deserializeProcessor(data?: ProcessorSerialized) {
if (data?.type) {
return BackgroundProcessorFactory.getProcessor(data?.type, data?.options)
static fromProcessorConfig(data?: ProcessorConfig) {
if (data) {
return BackgroundProcessorFactory.getProcessor(data)
}
return undefined
}
@@ -82,8 +82,8 @@ export const VideoDeviceControl = ({
*
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
*/
const processor = BackgroundProcessorFactory.deserializeProcessor(
userChoices.processorSerialized
const processor = BackgroundProcessorFactory.fromProcessorConfig(
userChoices.processorConfig
)
const toggle = trackProps.toggle as (
@@ -2,14 +2,12 @@ import { useLocalParticipant } from '@livekit/components-react'
import { LocalVideoTrack } from 'livekit-client'
import { css } from '@/styled-system/css'
import { EffectsConfiguration } from './EffectsConfiguration'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
import { TrackSource } from '@livekit/protocol'
export const Effects = () => {
const { cameraTrack } = useLocalParticipant()
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
const { saveProcessorSerialized } = usePersistentUserChoices()
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
@@ -24,9 +22,6 @@ export const Effects = () => {
isDisabled={!canPublishCamera}
videoTrack={localCameraTrack}
layout="vertical"
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
</div>
)
File diff suppressed because it is too large Load Diff
@@ -27,7 +27,7 @@ export const FunnyEffects = ({
showFrench: false,
}
}
return processor.serialize().options
return { ...processor.options }
}
const options = getOptions()
@@ -1,7 +1,7 @@
import { useSnapshot } from 'valtio'
import { userChoicesStore } from '@/stores/userChoices'
import type { VideoResolution } from '@/stores/userChoices'
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
import { ProcessorConfig } from '@/features/rooms/livekit/components/blur'
import type { VideoQuality } from 'livekit-client'
export function usePersistentUserChoices() {
@@ -36,10 +36,8 @@ export function usePersistentUserChoices() {
saveNoiseReductionEnabled: (enabled: boolean) => {
userChoicesStore.noiseReductionEnabled = enabled
},
saveProcessorSerialized: (
processorSerialized: ProcessorSerialized | undefined
) => {
userChoicesStore.processorSerialized = processorSerialized
saveProcessorConfig: (processorConfig: ProcessorConfig | undefined) => {
userChoicesStore.processorConfig = processorConfig
},
}
}
@@ -31,7 +31,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
const {
userChoices: {
videoDeviceId,
processorSerialized,
processorConfig,
videoPublishResolution,
videoSubscribeQuality,
},
@@ -78,7 +78,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
resolution: VideoPresets[key].resolution,
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
})
}
}
@@ -6,8 +6,11 @@ import { useSnapshot } from 'valtio'
import {
accessibilityStore,
type CaptionTextSize,
type CaptionColor,
CAPTION_TEXT_SIZE_OPTIONS,
CAPTION_COLOR_OPTIONS,
} from '@/stores/accessibility'
import { useAreSubtitlesAvailable } from '../hooks/useAreSubtitlesAvailable'
export const CaptionsSettings = () => {
const { t } = useTranslation('settings', {
@@ -24,6 +27,27 @@ export const CaptionsSettings = () => {
[t]
)
const captionFontColorItems = useMemo(
() =>
CAPTION_COLOR_OPTIONS.map((color) => ({
value: color,
label: t(`fontColor.options.${color}`),
})),
[t]
)
const captionBackgroundColorItems = useMemo(
() =>
CAPTION_COLOR_OPTIONS.map((color) => ({
value: color,
label: t(`backgroundColor.options.${color}`),
})),
[t]
)
const areSubtitlesAvailable = useAreSubtitlesAvailable()
if (!areSubtitlesAvailable) return null
return (
<li>
<H
@@ -34,16 +58,44 @@ export const CaptionsSettings = () => {
>
{t('heading')}
</H>
<Field
type="select"
label={t('textSize.label')}
items={captionTextSizeItems}
selectedKey={snap.captionTextSize}
onSelectionChange={(key) => {
accessibilityStore.captionTextSize = key as CaptionTextSize
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
})}
>
<Field
type="select"
label={t('textSize.label')}
items={captionTextSizeItems}
selectedKey={snap.captionTextSize}
onSelectionChange={(key) => {
accessibilityStore.captionTextSize = key as CaptionTextSize
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
<Field
type="select"
label={t('fontColor.label')}
items={captionFontColorItems}
selectedKey={snap.captionFontColor}
onSelectionChange={(key) => {
accessibilityStore.captionFontColor = key as CaptionColor
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
<Field
type="select"
label={t('backgroundColor.label')}
items={captionBackgroundColorItems}
selectedKey={snap.captionBackgroundColor}
onSelectionChange={(key) => {
accessibilityStore.captionBackgroundColor = key as CaptionColor
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
</div>
</li>
)
}
@@ -12,6 +12,8 @@ import { useSnapshot } from 'valtio'
import {
accessibilityStore,
CAPTION_TEXT_SIZE_OPTIONS,
CAPTION_FONT_COLOR_VALUES,
CAPTION_BACKGROUND_COLOR_VALUES,
type CaptionTextSize,
} from '@/stores/accessibility'
@@ -92,10 +94,14 @@ const useTranscriptionState = () => {
}
const Transcription = ({ row }: { row: TranscriptionRow }) => {
const { captionTextSize } = useSnapshot(accessibilityStore)
const { captionTextSize, captionFontColor, captionBackgroundColor } =
useSnapshot(accessibilityStore)
const participantColor = getParticipantColor(row.participant)
const participantName = getParticipantName(row.participant)
const { fontSize, lineHeight } = CAPTION_FONT_SIZES[captionTextSize]
const fontColor = CAPTION_FONT_COLOR_VALUES[captionFontColor]
const backgroundColor =
CAPTION_BACKGROUND_COLOR_VALUES[captionBackgroundColor]
const getDisplayText = (row: TranscriptionRow): string => {
return row.segments
@@ -128,9 +134,9 @@ const Transcription = ({ row }: { row: TranscriptionRow }) => {
/>
<div
className={css({
color: 'white',
width: '100%',
})}
style={{ color: fontColor }}
>
<Text variant="h3" margin={false}>
{participantName}
@@ -138,8 +144,10 @@ const Transcription = ({ row }: { row: TranscriptionRow }) => {
<p
className={css({
fontWeight: '400',
borderRadius: '4px',
padding: '0.125rem 0.25rem',
})}
style={{ fontSize, lineHeight }}
style={{ fontSize, lineHeight, backgroundColor }}
>
{displayText}
</p>
+36 -13
View File
@@ -273,17 +273,40 @@
"title": "Virtueller Hintergrund",
"selectedLabel": "Hintergrund angewendet:",
"apply": "Ersetze deinen Hintergrund:",
"descriptions": {
"0": "Gerilltes Holzmöbel",
"1": "Besprechungsraum",
"2": "Loft mit schwarzer Glaswand",
"3": "Esszimmer",
"4": "Holzregale",
"5": "Holztreppe",
"6": "Graue Bibliothek",
"7": "Kaffeetheke"
"personal": {
"title": "Meine Hintergründe",
"selectFileTooltip": "Wähle ein Bild aus, das als persönlicher Hintergrund verwendet werden soll",
"notLoggedInWarning": "Du bist nicht angemeldet, der persönliche Hintergrund wird nicht von einem Meeting zum anderen gespeichert.",
"warningUploadDisabled": "Persönliche Hintergründe werden derzeit nicht von einem Meeting zum anderen gespeichert.",
"uploadLimitReached": "Du kannst keine weiteren persönlichen Hintergründe hinzufügen.",
"uploadInProgress": "Datei wird hochgeladen…",
"errors": {
"close": "Schließen",
"file_too_large": {
"title": "Datei zu groß",
"description": "Die Datei ist zu groß. Bitte wähle eine Datei mit weniger als {{maxSize, number}} MB."
},
"invalid_file_type": {
"title": "Ungültiger Dateityp",
"description": "Der Dateityp wird nicht unterstützt. Bitte wähle eine {{allowedExtension}}-Datei."
}
}
},
"presets": {
"title": "Vorschläge",
"descriptions": {
"0": "Gerilltes Holzmöbel",
"1": "Besprechungsraum",
"2": "Loft mit schwarzer Glaswand",
"3": "Esszimmer",
"4": "Holzregale",
"5": "Holztreppe",
"6": "Graue Bibliothek",
"7": "Kaffeetheke"
}
}
},
"faceLandmarks": {
"title": "Visuelle Effekte",
"glasses": {
@@ -297,7 +320,7 @@
}
},
"sidePanel": {
"ariaLabel": "Seitenleiste",
"ariaLabel": "Seitenleiste - {{title}}",
"backToTools": "Zurück zu den Meeting-Tools",
"heading": {
"participants": "Teilnehmende",
@@ -332,11 +355,11 @@
"tools": {
"transcript": {
"title": "Transkribieren",
"body": "Das Gespräch aufzeichnen."
"body": "Wandelt Meetings in Text um."
},
"screenRecording": {
"title": "Aufzeichnen",
"body": "Das Meeting aufzeichnen."
"title": "Aufnehmen",
"body": "Speichert Meetings als Video."
}
}
},
+28
View File
@@ -126,6 +126,34 @@
"medium": "Mittel",
"large": "Groß"
}
},
"fontColor": {
"label": "Schriftfarbe",
"options": {
"default": "Standard",
"white": "Weiß",
"black": "Schwarz",
"blue": "Blau",
"green": "Grün",
"red": "Rot",
"yellow": "Gelb",
"cyan": "Cyan",
"magenta": "Magenta"
}
},
"backgroundColor": {
"label": "Hintergrundfarbe",
"options": {
"default": "Standard",
"white": "Weiß",
"black": "Schwarz",
"blue": "Blau",
"green": "Grün",
"red": "Rot",
"yellow": "Gelb",
"cyan": "Cyan",
"magenta": "Magenta"
}
}
}
},
+35 -13
View File
@@ -270,18 +270,40 @@
}
},
"virtual": {
"title": "Virtual background",
"title": "Virtual backgrounds",
"selectedLabel": "Background applied:",
"apply": "Replace your background:",
"descriptions": {
"0": "Fluted wooden furniture",
"1": "Meeting room",
"2": "Loft with black glass partition",
"3": "Dining room",
"4": "Wooden shelves",
"5": "Wooden staircase",
"6": "Gray library",
"7": "Coffee counter"
"personal": {
"title": "My backgrounds",
"selectFileTooltip": "Select an image file to use as a personal background",
"notLoggedInWarning": "You are not logged-in, personal backgrounds won't be saved from one meeting to the other.",
"warningUploadDisabled": "Personal backgrounds are currently not saved from one meeting to the other.",
"uploadLimitReached": "You cannot upload more personal backgrounds.",
"uploadInProgress": "Image is being uploaded…",
"errors": {
"close": "Close",
"file_too_large": {
"title": "File too large",
"description": "The file is too large. Please choose a file smaller than {{maxSize, number}} MB."
},
"invalid_file_type": {
"title": "Invalid file type",
"description": "The file type is not supported. Please choose a {{allowedExtension, list(type: 'disjunction')}} file."
}
}
},
"presets": {
"title": "Suggestions",
"descriptions": {
"0": "Fluted wooden furniture",
"1": "Meeting room",
"2": "Loft with black glass partition",
"3": "Dining room",
"4": "Wooden shelves",
"5": "Wooden staircase",
"6": "Gray library",
"7": "Coffee counter"
}
}
},
"faceLandmarks": {
@@ -297,7 +319,7 @@
}
},
"sidePanel": {
"ariaLabel": "Sidepanel",
"ariaLabel": "Sidepanel - {{title}}",
"backToTools": "Back to meeting tools",
"heading": {
"participants": "Participants",
@@ -332,11 +354,11 @@
"tools": {
"transcript": {
"title": "Transcribe",
"body": "Record the conversation."
"body": "Turn meetings into text."
},
"screenRecording": {
"title": "Record",
"body": "Record the meeting."
"body": "Save meetings as video."
}
}
},
+28
View File
@@ -126,6 +126,34 @@
"medium": "Medium",
"large": "Large"
}
},
"fontColor": {
"label": "Font color",
"options": {
"default": "Default",
"white": "White",
"black": "Black",
"blue": "Blue",
"green": "Green",
"red": "Red",
"yellow": "Yellow",
"cyan": "Cyan",
"magenta": "Magenta"
}
},
"backgroundColor": {
"label": "Background color",
"options": {
"default": "Default",
"white": "White",
"black": "Black",
"blue": "Blue",
"green": "Green",
"red": "Red",
"yellow": "Yellow",
"cyan": "Cyan",
"magenta": "Magenta"
}
}
}
},
+35 -13
View File
@@ -270,18 +270,40 @@
}
},
"virtual": {
"title": "Arrière-plan virtuel",
"title": "Arrière-plans virtuels",
"selectedLabel": "Arrière-plan appliqué :",
"apply": "Remplacer votre arrière plan :",
"descriptions": {
"0": "Meuble cannelé en bois",
"1": "Salle de réunion",
"2": "Loft avec verrière noire",
"3": "Salle à manger",
"4": "Étagères en bois",
"5": "Escalier en bois",
"6": "Bibliothèque grise",
"7": "Comptoir de café"
"personal": {
"title": "Mes arrière-plans",
"selectFileTooltip": "Sélectionnez une image à utiliser comme arrière-plan",
"notLoggedInWarning": "Vous n'êtes pas connecté, l'arrière-plan personnel ne sera pas sauvegardé d'une réunion à l'autre.",
"warningUploadDisabled": "Les arrière-plans personnels ne sont actuellement pas sauvegardés d'une réunion à l'autre.",
"uploadLimitReached": "Vous ne pouvez pas ajouter plus d'arrière-plans personnels.",
"uploadInProgress": "Image en cours d'envoi…",
"errors": {
"close": "Fermer",
"file_too_large": {
"title": "Fichier trop volumineux",
"description": "Le fichier est trop volumineux. Veuillez choisir un fichier de moins de {{maxSize, number}} Mo."
},
"invalid_file_type": {
"title": "Type de fichier non valide",
"description": "Le type de fichier n'est pas pris en charge. Veuillez choisir un fichier {{allowedExtension}}."
}
}
},
"presets": {
"title": "Suggestions",
"descriptions": {
"0": "Meuble cannelé en bois",
"1": "Salle de réunion",
"2": "Loft avec verrière noire",
"3": "Salle à manger",
"4": "Étagères en bois",
"5": "Escalier en bois",
"6": "Bibliothèque grise",
"7": "Comptoir de café"
}
}
},
"faceLandmarks": {
@@ -297,7 +319,7 @@
}
},
"sidePanel": {
"ariaLabel": "Panneau latéral",
"ariaLabel": "Panneau latéral - {{title}}",
"backToTools": "Retour aux outils de réunion",
"heading": {
"participants": "Participants",
@@ -332,11 +354,11 @@
"tools": {
"transcript": {
"title": "Transcrire",
"body": "Enregistrer la conversation."
"body": "Transcrire la réunion."
},
"screenRecording": {
"title": "Enregistrer",
"body": "Enregistrer la réunion."
"body": "Enregistrer la réunion en vidéo."
}
}
},
+28
View File
@@ -126,6 +126,34 @@
"medium": "Moyen",
"large": "Grand"
}
},
"fontColor": {
"label": "Couleur du texte",
"options": {
"default": "Par défaut",
"white": "Blanc",
"black": "Noir",
"blue": "Bleu",
"green": "Vert",
"red": "Rouge",
"yellow": "Jaune",
"cyan": "Cyan",
"magenta": "Magenta"
}
},
"backgroundColor": {
"label": "Couleur de fond",
"options": {
"default": "Par défaut",
"white": "Blanc",
"black": "Noir",
"blue": "Bleu",
"green": "Vert",
"red": "Rouge",
"yellow": "Jaune",
"cyan": "Cyan",
"magenta": "Magenta"
}
}
}
},
+34 -12
View File
@@ -273,15 +273,37 @@
"title": "Virtuele achtergrond",
"selectedLabel": "Achtergrond toegepast:",
"apply": "Vervang je achtergrond:",
"descriptions": {
"0": "Geprofileerd houten meubel",
"1": "Vergaderruimte",
"2": "Loft met zwarte glaswand",
"3": "Eetkamer",
"4": "Houten planken",
"5": "Houten trap",
"6": "Grijze bibliotheek",
"7": "Koffiebar"
"personal": {
"title": "Mijn achtergronden",
"selectFileTooltip": "Selecteer een afbeeldingsbestand om te gebruiken als persoonlijke achtergrond",
"notLoggedInWarning": "U bent niet ingelogd, persoonlijke achtergronden worden niet opgeslagen van de ene vergadering naar de andere.",
"warningUploadDisabled": "Persoonlijke achtergronden worden momenteel niet opgeslagen van de ene vergadering naar de andere.",
"uploadLimitReached": "U kunt geen persoonlijke achtergronden meer uploaden.",
"uploadInProgress": "Afbeelding wordt geüpload…",
"errors": {
"close": "Sluiten",
"file_too_large": {
"title": "Bestand te groot",
"description": "Het bestand is te groot. Kies een bestand kleiner dan {{maxSize, number}} MB."
},
"invalid_file_type": {
"title": "Ongeldig bestandstype",
"description": "Het bestandstype wordt niet ondersteund. Kies een {{allowedExtension, list(type: 'disjunction')}} bestand."
}
}
},
"presets": {
"title": "Suggesties",
"descriptions": {
"0": "Geprofileerd houten meubel",
"1": "Vergaderruimte",
"2": "Loft met zwarte glaswand",
"3": "Eetkamer",
"4": "Houten planken",
"5": "Houten trap",
"6": "Grijze bibliotheek",
"7": "Koffiebar"
}
}
},
"faceLandmarks": {
@@ -297,7 +319,7 @@
}
},
"sidePanel": {
"ariaLabel": "Zijbalk",
"ariaLabel": "Zijbalk - {{title}}",
"backToTools": "Terug naar vergadertools",
"heading": {
"participants": "Deelnemers",
@@ -332,11 +354,11 @@
"tools": {
"transcript": {
"title": "Transcriberen",
"body": "Het gesprek opnemen."
"body": "Zet vergaderingen om in tekst."
},
"screenRecording": {
"title": "Opnemen",
"body": "De vergadering opnemen."
"body": "Sla vergaderingen op als video."
}
}
},
+28
View File
@@ -126,6 +126,34 @@
"medium": "Gemiddeld",
"large": "Groot"
}
},
"fontColor": {
"label": "Tekstkleur",
"options": {
"default": "Standaard",
"white": "Wit",
"black": "Zwart",
"blue": "Blauw",
"green": "Groen",
"red": "Rood",
"yellow": "Geel",
"cyan": "Cyaan",
"magenta": "Magenta"
}
},
"backgroundColor": {
"label": "Achtergrondkleur",
"options": {
"default": "Standaard",
"white": "Wit",
"black": "Zwart",
"blue": "Blauw",
"green": "Groen",
"red": "Rood",
"yellow": "Geel",
"cyan": "Cyaan",
"magenta": "Magenta"
}
}
}
},
+5 -1
View File
@@ -20,7 +20,11 @@ export const Spinner = ({
const r = 14 - strokeWidth
const c = 2 * r * Math.PI
return (
<ProgressBar aria-label="Loading..." value={30}>
<ProgressBar
aria-label="Loading..."
value={30}
style={{ height: size, width: size }}
>
{({ percentage }) => (
<div
className={css({
+4
View File
@@ -36,6 +36,10 @@ export const text = cva({
textStyle: 'body',
fontWeight: 'bold',
},
bodyXsMedium: {
textStyle: 'body',
fontWeight: 'medium',
},
body: {
textStyle: 'body',
},
+66 -2
View File
@@ -10,14 +10,65 @@ export const CAPTION_TEXT_SIZE_OPTIONS: CaptionTextSize[] = [
'large',
]
export type CaptionColor =
| 'default'
| 'white'
| 'black'
| 'blue'
| 'green'
| 'red'
| 'yellow'
| 'cyan'
| 'magenta'
export const CAPTION_COLOR_OPTIONS: CaptionColor[] = [
'default',
'white',
'black',
'blue',
'green',
'red',
'yellow',
'cyan',
'magenta',
]
export const CAPTION_FONT_COLOR_VALUES: Record<CaptionColor, string> = {
default: '#FFFFFF',
white: '#FFFFFF',
black: '#000000',
blue: '#0000FF',
green: '#00FF00',
red: '#FF0000',
yellow: '#FFFF00',
cyan: '#00FFFF',
magenta: '#FF00FF',
}
export const CAPTION_BACKGROUND_COLOR_VALUES: Record<CaptionColor, string> = {
default: 'rgba(0, 0, 0, 0.75)',
black: 'rgba(0, 0, 0, 0.75)',
white: 'rgba(255, 255, 255, 0.75)',
blue: 'rgba(0, 0, 255, 0.75)',
green: 'rgba(0, 255, 0, 0.75)',
red: 'rgba(255, 0, 0, 0.75)',
yellow: 'rgba(255, 255, 0, 0.75)',
cyan: 'rgba(0, 255, 255, 0.75)',
magenta: 'rgba(255, 0, 255, 0.75)',
}
type AccessibilityState = {
announceReactions: boolean
captionTextSize: CaptionTextSize
captionFontColor: CaptionColor
captionBackgroundColor: CaptionColor
}
const DEFAULT_STATE: AccessibilityState = {
announceReactions: false,
captionTextSize: 'medium',
captionFontColor: 'default',
captionBackgroundColor: 'default',
}
function getAccessibilityState(): AccessibilityState {
@@ -25,10 +76,21 @@ function getAccessibilityState(): AccessibilityState {
const stored = localStorage.getItem(STORAGE_KEYS.ACCESSIBILITY)
if (stored) {
const parsed = JSON.parse(stored)
const validCaptionSizes = CAPTION_TEXT_SIZE_OPTIONS
const captionTextSize = validCaptionSizes.includes(parsed.captionTextSize)
const captionTextSize = CAPTION_TEXT_SIZE_OPTIONS.includes(
parsed.captionTextSize
)
? parsed.captionTextSize
: DEFAULT_STATE.captionTextSize
const captionFontColor = CAPTION_COLOR_OPTIONS.includes(
parsed.captionFontColor
)
? parsed.captionFontColor
: DEFAULT_STATE.captionFontColor
const captionBackgroundColor = CAPTION_COLOR_OPTIONS.includes(
parsed.captionBackgroundColor
)
? parsed.captionBackgroundColor
: DEFAULT_STATE.captionBackgroundColor
return {
...DEFAULT_STATE,
...parsed,
@@ -37,6 +99,8 @@ function getAccessibilityState(): AccessibilityState {
? parsed.announceReactions
: DEFAULT_STATE.announceReactions,
captionTextSize,
captionFontColor,
captionBackgroundColor,
}
}
+33 -4
View File
@@ -1,16 +1,19 @@
import { proxy, subscribe } from 'valtio'
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
import {
ProcessorConfig,
ProcessorType,
} from '@/features/rooms/livekit/components/blur'
import {
loadUserChoices,
saveUserChoices,
LocalUserChoices as LocalUserChoicesLK,
saveUserChoices,
} from '@livekit/components-core'
import { VideoQuality } from 'livekit-client'
export type VideoResolution = 'h720' | 'h360' | 'h180'
export type LocalUserChoices = LocalUserChoicesLK & {
processorSerialized?: ProcessorSerialized
processorConfig?: ProcessorConfig
noiseReductionEnabled?: boolean
audioOutputDeviceId?: string
videoPublishResolution?: VideoResolution
@@ -28,7 +31,33 @@ function getUserChoicesState(): LocalUserChoices {
}
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
subscribe(userChoicesStore, () => {
saveUserChoices(userChoicesStore, false)
})
// we run some logic on store loading to check if the processor config is still valid
if (userChoicesStore.processorConfig?.type === ProcessorType.VIRTUAL) {
if (userChoicesStore.processorConfig.imagePath.startsWith('blob:')) {
// this happens when a not authenticated user had changed their background image
// we restore clear the processor config to avoid displaying a black screen.
userChoicesStore.processorConfig = undefined
} else if (userChoicesStore.processorConfig.fileId) {
// Checking if the image is still available / accessible
await fetch(userChoicesStore.processorConfig.imagePath, {
// We bypass the cache to ensure we have access
cache: 'reload',
})
.then((response) => {
// if we cannot fetch the image (likely a 401 from the backend because
// the user is not logged in anymore, etc.),
// we clear the processor config to avoid displaying a black screen.
// This can happen when the user logs out for instance, etc.
if (!response.ok) {
userChoicesStore.processorConfig = undefined
}
})
.catch(() => {
userChoicesStore.processorConfig = undefined
})
}
}
+11
View File
@@ -95,3 +95,14 @@ html:has(.lk-video-conference) {
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
.hoverGroup .hoverGroupChild {
opacity: 0;
pointer-events: none;
}
.hoverGroup:hover .hoverGroupChild,
.hoverGroup:focus-within .hoverGroupChild {
opacity: 1;
pointer-events: auto;
}
+8
View File
@@ -14,6 +14,14 @@ export default defineConfig(({ mode }) => {
port: parseInt(env.VITE_PORT) || 3000,
host: env.VITE_HOST ?? 'localhost',
allowedHosts: ['.nip.io'],
// In a local dev setup, we proxy the media server ourselves to avoid CORS issues
proxy: {
'/media': {
target: 'http://localhost:8083',
changeOrigin: true,
secure: false
}
}
},
}
})
@@ -168,6 +168,7 @@ summary:
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
AUTHORIZED_TENANTS: '[{"id": "dictaphone", "api_key": "dictaphone_token", "webhook_url": "http://dictaphone-backend.dictaphone.svc.cluster.local/api/v1.0/files/ai-webhook/", "webhook_api_key": "token_summary"}]'
WHISPERX_API_KEY: your-secret-value
WHISPERX_BASE_URL: https://configure-your-url.com
WHISPERX_ASR_MODEL: large-v2
@@ -205,6 +206,7 @@ celeryTranscribe:
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
AUTHORIZED_TENANTS: '[{"id": "dictaphone", "api_key": "dictaphone_token", "webhook_url": "http://dictaphone-backend.dictaphone.svc.cluster.local/api/v1.0/files/ai-webhook/", "webhook_api_key": "token_summary"}]'
WHISPERX_API_KEY: your-secret-value
WHISPERX_BASE_URL: https://configure-your-url.com
WHISPERX_ASR_MODEL: large-v2
@@ -231,7 +233,7 @@ celeryTranscribe:
- "--pool=solo"
- "--loglevel=info"
- "-Q"
- "transcribe-queue"
- "transcribe-queue,transcribe-queue-v2"
celerySummarize:
replicas: 1
@@ -243,6 +245,7 @@ celerySummarize:
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
AUTHORIZED_TENANTS: '[{"id": "dictaphone", "api_key": "dictaphone_token", "webhook_url": "http://dictaphone-backend.dictaphone.svc.cluster.local/api/v1.0/files/ai-webhook/", "webhook_api_key": "token_summary"}]'
WHISPERX_API_KEY: your-secret-value
WHISPERX_BASE_URL: https://configure-your-url.com
WHISPERX_ASR_MODEL: large-v2
@@ -269,7 +272,46 @@ celerySummarize:
- "--pool=solo"
- "--loglevel=info"
- "-Q"
- "summarize-queue"
- "summarize-queue,summarize-queue-v2"
celerySummaryBackend:
replicas: 1
envVars:
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
AUTHORIZED_TENANTS: '[{"id": "dictaphone", "api_key": "dictaphone_token", "webhook_url": "http://dictaphone-backend.dictaphone.svc.cluster.local/api/v1.0/files/ai-webhook/", "webhook_api_key": "token_summary"}]'
WHISPERX_API_KEY: your-secret-value
WHISPERX_BASE_URL: https://configure-your-url.com
WHISPERX_ASR_MODEL: large-v2
WHISPERX_DEFAULT_LANGUAGE: fr
LLM_BASE_URL: https://configure-your-url.com
LLM_API_KEY: your-secret-value
LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
WEBHOOK_API_TOKEN: password
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
TASK_TRACKER_REDIS_URL: redis://default:pass@redis-master:6379/1
image:
repository: localhost:5001/meet-summary
pullPolicy: Always
tag: "latest"
command:
- "celery"
- "-A"
- "summary.core.celery_worker"
- "worker"
- "--pool=solo"
- "--loglevel=info"
- "-Q"
- "call-webhook-queue-v2"
agents:
replicas: 1
+10
View File
@@ -202,6 +202,16 @@ Requires top level scope
{{ include "meet.fullname" . }}-celery-summarize
{{- end }}
{{/*
Full name for the Celery Summary Backend
Requires top level scope
*/}}
{{- define "meet.celerySummaryBackend.fullname" -}}
{{ include "meet.fullname" . }}-celery-summary-backend
{{- end }}
{{/*
Full name for the agents
@@ -0,0 +1,153 @@
{{- $envVars := include "meet.common.env" (list . .Values.celerySummaryBackend) -}}
{{- $fullName := include "meet.celerySummaryBackend.fullname" . -}}
{{- $component := "celery-summary-backend" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $fullName }}
annotations:
{{- with .Values.celerySummaryBackend.dpAnnotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
spec:
replicas: {{ .Values.celerySummaryBackend.replicas }}
selector:
matchLabels:
{{- include "meet.common.selectorLabels" (list . $component) | nindent 6 }}
template:
metadata:
annotations:
{{- with .Values.celerySummaryBackend.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "meet.common.selectorLabels" (list . $component) | nindent 8 }}
spec:
{{- if $.Values.image.credentials }}
imagePullSecrets:
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
{{- end }}
shareProcessNamespace: {{ .Values.celerySummaryBackend.shareProcessNamespace }}
containers:
{{- with .Values.celerySummaryBackend.sidecars }}
{{- toYaml . | nindent 8 }}
{{- end }}
- name: {{ .Chart.Name }}
image: "{{ (.Values.celerySummaryBackend.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.celerySummaryBackend.image | default dict).tag | default .Values.image.tag }}"
imagePullPolicy: {{ (.Values.celerySummaryBackend.image | default dict).pullPolicy | default .Values.image.pullPolicy }}
{{- with .Values.celerySummaryBackend.command }}
command:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.celerySummaryBackend.args }}
args:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- if $envVars }}
{{- $envVars | indent 12 }}
{{- end }}
{{- with .Values.celerySummaryBackend.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.celerySummaryBackend.service.targetPort }}
protocol: TCP
{{- if .Values.celerySummaryBackend.probes.liveness }}
livenessProbe:
{{- include "meet.probes.abstract" (merge .Values.celerySummaryBackend.probes.liveness (dict "targetPort" .Values.celerySummaryBackend.service.targetPort )) | nindent 12 }}
{{- end }}
{{- if .Values.celerySummaryBackend.probes.readiness }}
readinessProbe:
{{- include "meet.probes.abstract" (merge .Values.celerySummaryBackend.probes.readiness (dict "targetPort" .Values.celerySummaryBackend.service.targetPort )) | nindent 12 }}
{{- end }}
{{- if .Values.celerySummaryBackend.probes.startup }}
startupProbe:
{{- include "meet.probes.abstract" (merge .Values.celerySummaryBackend.probes.startup (dict "targetPort" .Values.celerySummaryBackend.service.targetPort )) | nindent 12 }}
{{- end }}
{{- with .Values.celerySummaryBackend.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
{{- range $index, $value := .Values.mountFiles }}
- name: "files-{{ $index }}"
mountPath: {{ $value.path }}
subPath: content
{{- end }}
{{- range $name, $volume := .Values.celerySummaryBackend.persistence }}
- name: "{{ $name }}"
mountPath: "{{ $volume.mountPath }}"
{{- end }}
{{- range .Values.celerySummaryBackend.extraVolumeMounts }}
- name: {{ .name }}
mountPath: {{ .mountPath }}
subPath: {{ .subPath | default "" }}
readOnly: {{ .readOnly }}
{{- end }}
{{- with .Values.celerySummaryBackend.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.celerySummaryBackend.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.celerySummaryBackend.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
volumes:
{{- range $index, $value := .Values.mountFiles }}
- name: "files-{{ $index }}"
configMap:
name: "{{ include "meet.fullname" $ }}-files-{{ $index }}"
{{- end }}
{{- range $name, $volume := .Values.celerySummaryBackend.persistence }}
- name: "{{ $name }}"
{{- if eq $volume.type "emptyDir" }}
emptyDir: {}
{{- else }}
persistentVolumeClaim:
claimName: "{{ $fullName }}-{{ $name }}"
{{- end }}
{{- end }}
{{- range .Values.celerySummaryBackend.extraVolumes }}
- name: {{ .name }}
{{- if .existingClaim }}
persistentVolumeClaim:
claimName: {{ .existingClaim }}
{{- else if .hostPath }}
hostPath:
{{ toYaml .hostPath | nindent 12 }}
{{- else if .csi }}
csi:
{{- toYaml .csi | nindent 12 }}
{{- else if .configMap }}
configMap:
{{- toYaml .configMap | nindent 12 }}
{{- else if .emptyDir }}
emptyDir:
{{- toYaml .emptyDir | nindent 12 }}
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}
---
{{ if .Values.celerySummaryBackend.pdb.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace | quote }}
spec:
maxUnavailable: 1
selector:
matchLabels:
{{- include "meet.common.selectorLabels" (list . $component) | nindent 6 }}
{{ end }}
+112 -2
View File
@@ -738,7 +738,7 @@ celeryTranscribe:
- "--pool=solo"
- "--loglevel=info"
- "-Q"
- "transcribe-queue"
- "transcribe-queue,transcribe-queue-v2"
## @param celeryTranscribe.args Override the celeryTranscribe container args
args: []
@@ -847,7 +847,7 @@ celerySummarize:
- "--pool=solo"
- "--loglevel=info"
- "-Q"
- "summarize-queue"
- "summarize-queue,summarize-queue-v2"
## @param celerySummarize.args Override the celerySummarize container args
args: []
@@ -933,6 +933,116 @@ celerySummarize:
pdb:
enabled: false
## @section celerySummaryBackend
celerySummaryBackend:
## @param celerySummaryBackend.image.repository Repository to use to pull meet's celerySummaryBackend container image
## @param celerySummaryBackend.image.tag meet's celerySummaryBackend container tag
## @param celerySummaryBackend.image.pullPolicy celerySummaryBackend container image pull policy
image:
repository: lasuite/meet-summary
pullPolicy: IfNotPresent
tag: "latest"
## @param celerySummaryBackend.dpAnnotations Annotations to add to the celerySummaryBackend Deployment
dpAnnotations: {}
## @param celerySummaryBackend.command Override the celerySummaryBackend container command
command:
- "celery"
- "-A"
- "summary.core.celery_worker"
- "worker"
- "--pool=solo"
- "--loglevel=info"
- "-Q"
- "call-webhook-queue-v2"
## @param celerySummaryBackend.args Override the celerySummaryBackend container args
args: []
## @param celerySummaryBackend.replicas Amount of celerySummaryBackend replicas
replicas: 1
## @param celerySummaryBackend.shareProcessNamespace Enable share process namespace between containers
shareProcessNamespace: false
## @param celerySummaryBackend.sidecars Add sidecars containers to celerySummaryBackend deployment
sidecars: []
## @param celerySummaryBackend.migrateJobAnnotations Annotations for the migrate job
migrateJobAnnotations: {}
## @param celerySummaryBackend.securityContext Configure celerySummaryBackend Pod security context
securityContext: null
## @param celerySummaryBackend.envVars Configure celerySummaryBackend container environment variables
## @extra celerySummaryBackend.envVars.BY_VALUE Example environment variable by setting value directly
## @extra celerySummaryBackend.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
## @extra celerySummaryBackend.envVars.FROM_CONFIGMAP.configMapKeyRef.key Key within a ConfigMap when configuring env vars from a ConfigMap
## @extra celerySummaryBackend.envVars.FROM_SECRET.secretKeyRef.name Name of a Secret when configuring env vars from a Secret
## @extra celerySummaryBackend.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret
## @skip celerySummaryBackend.envVars
envVars:
<<: *commonEnvVars
## @param celerySummaryBackend.podAnnotations Annotations to add to the celerySummaryBackend Pod
podAnnotations: {}
## @param celerySummaryBackend.service.type celerySummaryBackend Service type
## @param celerySummaryBackend.service.port celerySummaryBackend Service listening port
## @param celerySummaryBackend.service.targetPort celerySummaryBackend container listening port
## @param celerySummaryBackend.service.annotations Annotations to add to the celerySummaryBackend Service
service:
type: ClusterIP
port: 80
targetPort: 8000
annotations: {}
## @param celerySummaryBackend.probes Configure celerySummaryBackend probes
## @param celerySummaryBackend.probes.liveness.path [nullable] Configure path for celerySummaryBackend HTTP liveness probe
## @param celerySummaryBackend.probes.liveness.targetPort [nullable] Configure port for celerySummaryBackend HTTP liveness probe
## @param celerySummaryBackend.probes.liveness.initialDelaySeconds [nullable] Configure initial delay for celerySummaryBackend liveness probe
## @param celerySummaryBackend.probes.liveness.initialDelaySeconds [nullable] Configure timeout for celerySummaryBackend liveness probe
## @param celerySummaryBackend.probes.startup.path [nullable] Configure path for celerySummaryBackend HTTP startup probe
## @param celerySummaryBackend.probes.startup.targetPort [nullable] Configure port for celerySummaryBackend HTTP startup probe
## @param celerySummaryBackend.probes.startup.initialDelaySeconds [nullable] Configure initial delay for celerySummaryBackend startup probe
## @param celerySummaryBackend.probes.startup.initialDelaySeconds [nullable] Configure timeout for celerySummaryBackend startup probe
## @param celerySummaryBackend.probes.readiness.path [nullable] Configure path for celerySummaryBackend HTTP readiness probe
## @param celerySummaryBackend.probes.readiness.targetPort [nullable] Configure port for celerySummaryBackend HTTP readiness probe
## @param celerySummaryBackend.probes.readiness.initialDelaySeconds [nullable] Configure initial delay for celerySummaryBackend readiness probe
## @param celerySummaryBackend.probes.readiness.initialDelaySeconds [nullable] Configure timeout for celerySummaryBackend readiness probe
probes: {}
## @param celerySummaryBackend.resources Resource requirements for the celerySummaryBackend container
resources: {}
## @param celerySummaryBackend.nodeSelector Node selector for the celerySummaryBackend Pod
nodeSelector: {}
## @param celerySummaryBackend.tolerations Tolerations for the celerySummaryBackend Pod
tolerations: []
## @param celerySummaryBackend.affinity Affinity for the celerySummaryBackend Pod
affinity: {}
## @param celerySummaryBackend.persistence Additional volumes to create and mount on the celerySummaryBackend. Used for debugging purposes
## @extra celerySummaryBackend.persistence.volume-name.size Size of the additional volume
## @extra celerySummaryBackend.persistence.volume-name.type Type of the additional volume, persistentVolumeClaim or emptyDir
## @extra celerySummaryBackend.persistence.volume-name.mountPath Path where the volume should be mounted to
persistence: {}
## @param celerySummaryBackend.extraVolumeMounts Additional volumes to mount on the celerySummaryBackend.
extraVolumeMounts: []
## @param celerySummaryBackend.extraVolumes Additional volumes to mount on the celerySummaryBackend.
extraVolumes: []
## @param celerySummaryBackend.pdb.enabled Enable pdb on celerySummaryBackend
pdb:
enabled: false
## @section agents
agents:
+1 -1
View File
@@ -9,7 +9,7 @@
align="center"
src="{{logo_img}}"
width="320px"
alt="{%trans 'Logo email' %}"
alt="{%trans 'Logo email' %} {{brandname}}"
/>
</mj-column>
</mj-section>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.10.0",
"version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.10.0",
"version": "1.11.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.10.0",
"version": "1.11.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.10.0",
"version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.10.0",
"version": "1.11.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.10.0",
"version": "1.11.0",
"author": "",
"license": "ISC",
"description": "",
+10 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.10.0"
version = "1.11.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
@@ -21,8 +21,16 @@ dependencies = [
[project.optional-dependencies]
dev = [
"ruff==0.14.4",
"pytest==9.0.2",
"responses>=0.25.8",
]
[tool.pytest.ini_options]
markers = [
"api: Test the API",
]
testpaths = ["tests"]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
@@ -49,6 +57,7 @@ select = [
"T20", # flake8-print
"W", # pycodestyle warning
]
ignore= ["PLR2004"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = [
+7 -4
View File
@@ -2,8 +2,11 @@
from fastapi import APIRouter, Depends
from summary.api.route import tasks
from summary.core.security import verify_token
from summary.api.route import tasks, tasks_v2
from summary.core.security import verify_tenant_api_key
api_router = APIRouter(dependencies=[Depends(verify_token)])
api_router.include_router(tasks.router, tags=["tasks"])
api_router_v1 = APIRouter(dependencies=[Depends(verify_tenant_api_key)])
api_router_v1.include_router(tasks.router_tasks_v1, tags=["tasks"])
api_router_v2 = APIRouter(dependencies=[Depends(verify_tenant_api_key)])
api_router_v2.include_router(tasks_v2.router_tasks_v2, tags=["tasks"])
+3 -3
View File
@@ -42,10 +42,10 @@ class TranscribeSummarizeTaskCreation(BaseModel):
return v
router = APIRouter(prefix="/tasks")
router_tasks_v1 = APIRouter(prefix="/tasks")
@router.post("/")
@router_tasks_v1.post("/")
async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreation):
"""Create a transcription and summarization task."""
task = process_audio_transcribe_summarize_v2.apply_async(
@@ -68,7 +68,7 @@ async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreat
return {"id": task.id, "message": "Task created"}
@router.get("/{task_id}")
@router_tasks_v1.get("/{task_id}")
async def get_task_status(task_id: str):
"""Check task status by ID."""
task = AsyncResult(task_id)
+61
View File
@@ -0,0 +1,61 @@
"""API routes related to application tasks (V2 / tenant friendly)."""
from celery.result import AsyncResult
from fastapi import APIRouter, Depends, HTTPException
from summary.core.celery_worker import (
process_audio_transcribe_v2_task,
summarize_v2_task,
)
from summary.core.config import AuthorizedTenant
from summary.core.models import SummarizeTaskV2Request, TranscribeTaskV2Request
from summary.core.security import verify_tenant_api_key_v2
router_tasks_v2 = APIRouter()
@router_tasks_v2.post("/async/transcribe")
async def create_transcribe_task_v2(
request: TranscribeTaskV2Request,
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
):
"""Create a transcription task."""
task = process_audio_transcribe_v2_task.apply_async(
args=[{**request.model_dump(), "tenant_id": request_tenant.id}]
)
return {"id": task.id, "message": "Transcribe task created"}
@router_tasks_v2.post("/async/summarize")
async def create_summarize_task_v2(
request: SummarizeTaskV2Request,
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
):
"""Create a summarization task."""
task = summarize_v2_task.apply_async(
args=[{**request.model_dump(), "tenant_id": request_tenant.id}]
)
return {"id": task.id, "message": "Summarize task created"}
@router_tasks_v2.get("/async/transcribe/{task_id}")
@router_tasks_v2.get("/async/summarize/{task_id}")
async def get_task_status(
task_id: str,
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
):
"""Check task status by ID."""
task = AsyncResult(task_id)
try:
if (
isinstance(task.args, (list, tuple))
and len(task.args) > 0
and isinstance(task.args[0], dict)
and task.args[0].get("tenant_id") == request_tenant.id
):
return {"id": task_id, "status": task.status}
except (TypeError, KeyError):
pass
raise HTTPException(status_code=404, detail="Not found")
+266 -36
View File
@@ -17,6 +17,10 @@ from summary.core.config import get_settings
from summary.core.file_service import FileService, FileServiceException
from summary.core.llm_service import LLMException, LLMObservability, LLMService
from summary.core.locales import get_locale
from summary.core.models import (
SummarizeTaskV2Payload,
TranscribeTaskV2Payload,
)
from summary.core.prompt import (
FORMAT_NEXT_STEPS,
FORMAT_PLAN,
@@ -27,8 +31,19 @@ from summary.core.prompt import (
PROMPT_SYSTEM_TLDR,
PROMPT_USER_PART,
)
from summary.core.shared_models import (
SummarizeWebhookFailurePayload,
SummarizeWebhookSuccessPayload,
TranscribeWebhookFailurePayload,
TranscribeWebhookSuccessPayload,
WhisperXResponse,
webhook_payload_adapter,
)
from summary.core.transcript_formatter import TranscriptFormatter
from summary.core.webhook_service import submit_content
from summary.core.webhook_service import (
call_webhook_v2,
submit_content,
)
settings = get_settings()
analytics = get_analytics()
@@ -58,14 +73,26 @@ if settings.sentry_dsn and settings.sentry_is_enabled:
file_service = FileService()
def transcribe_audio(task_id, filename, language):
def transcribe_audio(
*,
task_id: str,
filename: str | None = None,
language: str,
cloud_storage_url=None,
raises: bool = False,
):
"""Transcribe an audio file using WhisperX.
Downloads the audio from MinIO, sends it to WhisperX for transcription,
and tracks metadata throughout the process.
Downloads the audio from MinIO or a cloud storage URL, sends it to
WhisperX for transcription, and tracks metadata throughout the process.
Returns the transcription object, or None if the file could not be retrieved.
"""
if bool(filename) == bool(cloud_storage_url):
raise ValueError(
"Either filename or cloud_storage_url must be provided, but not both."
)
logger.info("Initiating WhisperX client")
whisperx_client = openai.OpenAI(
api_key=settings.whisperx_api_key.get_secret_value(),
@@ -75,7 +102,10 @@ def transcribe_audio(task_id, filename, language):
# Transcription
try:
with file_service.prepare_audio_file(filename) as (audio_file, metadata):
with file_service.prepare_audio_file(
remote_object_key=filename,
cloud_storage_url=cloud_storage_url,
) as (audio_file, metadata):
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
if language is None:
@@ -104,8 +134,21 @@ def transcribe_audio(task_id, filename, language):
logger.info("Transcription received in %.2f seconds.", transcription_time)
logger.debug("Transcription: \n %s", transcription)
except FileServiceException:
logger.exception("Unexpected error for filename: %s", filename)
except FileServiceException as e:
# For v2 pipeline we want failures not silent errors like this
if raises:
raise e
redacted_cloud_storage_url = (
cloud_storage_url.split("?", 1)[0] if cloud_storage_url else None
)
logger.exception(
(
"Unexpected error while preparing file | filename: %s "
"| cloud_storage_url: %s"
),
filename,
redacted_cloud_storage_url,
)
return None
metadata_manager.track_transcription_metadata(task_id, transcription)
@@ -114,13 +157,13 @@ def transcribe_audio(task_id, filename, language):
def format_transcript(
transcription,
context_language,
language,
room,
recording_date,
recording_time,
download_link,
):
context_language: str | None,
language: str,
room: str | None,
recording_date: str | None,
recording_time: str | None,
download_link: str | None,
) -> tuple[str, str]:
"""Format a transcription into readable content with a title.
Resolves the locale from context_language / language, then uses
@@ -208,7 +251,9 @@ def process_audio_transcribe_summarize_v2(
task_id = self.request.id
transcription = transcribe_audio(task_id, filename, language)
transcription = transcribe_audio(
task_id=task_id, filename=filename, language=language
)
if transcription is None:
return
@@ -258,24 +303,10 @@ def task_failure_handler(task_id, exception=None, **kwargs):
metadata_manager.capture(task_id, settings.posthog_event_failure)
@celery.task(
bind=True,
autoretry_for=[LLMException, Exception],
max_retries=settings.celery_max_retries,
queue=settings.summarize_queue,
)
def summarize_transcription(
self, owner_id: str, transcript: str, email: str, sub: str, title: str
):
"""Generate a summary from the provided transcription text.
This Celery task performs the following operations:
1. Uses an LLM to generate a TL;DR summary of the transcription.
2. Breaks the transcription into parts and summarizes each part.
3. Cleans up the combined summary
4. Generates next steps.
5. Sends the final summary via webhook.
"""
def summarize_transcription_internals(
*, owner_id: str, transcript: str, session_id: str
) -> str:
"""Generate a summary from the provided transcription text."""
logger.info(
"Starting summarization task | Owner: %s",
owner_id,
@@ -292,7 +323,7 @@ def summarize_transcription(
# privacy controls in observability traces.
llm_observability = LLMObservability(
user_has_tracing_consent=user_has_tracing_consent,
session_id=self.request.id,
session_id=session_id,
user_id=owner_id,
)
llm_service = LLMService(llm_observability=llm_observability)
@@ -338,9 +369,208 @@ def summarize_transcription(
logger.info("Summary cleaned")
summary = tldr + "\n\n" + cleaned_summary + "\n\n" + next_steps
llm_observability.flush()
logger.debug("LLM observability flushed")
return summary
@celery.task(
bind=True,
autoretry_for=[LLMException, Exception],
max_retries=settings.celery_max_retries,
queue=settings.summarize_queue,
)
def summarize_transcription(
self, owner_id: str, transcript: str, email: str, sub: str, title: str
):
"""Generate a summary from the provided transcription text.
This Celery task performs the following operations:
1. Uses an LLM to generate a TL;DR summary of the transcription.
2. Breaks the transcription into parts and summarizes each part.
3. Cleans up the combined summary
4. Generates next steps.
5. Sends the final summary via webhook.
"""
summary = summarize_transcription_internals(
owner_id=owner_id, transcript=transcript, session_id=self.request.id
)
summary_title = settings.summary_title_template.format(title=title)
submit_content(summary, summary_title, email, sub)
llm_observability.flush()
logger.debug("LLM observability flushed")
##################################################################################
# Tasks v2
##################################################################################
@celery.task(
max_retries=3,
queue=settings.call_webhook_queue_v2,
autoretry_for=[exceptions.HTTPError],
)
def call_webhook_v2_task(
payload: dict,
tenant_id: str,
):
"""Calls a webhook asynchrously (retry handled by celery)."""
call_webhook_v2(
payload=webhook_payload_adapter.validate_python(payload), tenant_id=tenant_id
)
@celery.task(
bind=True,
autoretry_for=[exceptions.HTTPError],
max_retries=settings.celery_max_retries,
queue=settings.transcribe_queue_v2,
)
def process_audio_transcribe_v2_task(
self,
payload: dict,
):
"""Process an audio file by transcribing it.
This Celery task orchestrates:
1. Audio transcription via WhisperX
2. Transcript formatting
3. Webhook submission
Args:
self: Celery task instance (passed on with bind=True)
payload: Serialized dictionary of TranscribeSummarizeTaskCreationV2
"""
payload = TranscribeTaskV2Payload.model_validate(payload)
logger.info(
"Transcribing for object received | Owner: %s | ObjectId: %s",
payload.remote_id,
payload.object_id,
)
task_id = self.request.id
transcription_res = transcribe_audio( # type: ignore
task_id=task_id,
cloud_storage_url=payload.cloud_storage_url,
language=payload.language,
raises=True,
)
content, title = format_transcript(
transcription_res,
context_language=payload.context_language,
language=payload.language,
room=None,
recording_date=None,
recording_time=None,
download_link=None,
)
call_webhook_v2_task.apply_async(
args=[
TranscribeWebhookSuccessPayload(
source_id=payload.remote_id,
title=title,
transcript_text=content,
transcript_raw=WhisperXResponse(**transcription_res.model_dump()),
).model_dump(),
payload.tenant_id,
]
)
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
def handle_transcribe_v2_failed(
sender,
task_id=None,
exception=None,
args=None,
kwargs=None,
traceback=None,
einfo=None,
**kw,
):
"""Handle the failure of transcribe_v2_task.
This function is triggered when the transcribe_v2_task fails.
It sends a webhook failure payload to notify the client of the failure.
"""
task = sender
# If retries are exhausted:
if task.request.retries >= task.max_retries:
call_webhook_v2_task.apply_async(
args=[
TranscribeWebhookFailurePayload(
source_id=args[0]["remote_id"],
error_code="unknown_error",
).model_dump(),
args[0]["tenant_id"],
]
)
@celery.task(
bind=True,
autoretry_for=[LLMException, Exception],
max_retries=settings.celery_max_retries,
queue=settings.summarize_queue_v2,
)
def summarize_v2_task(
self,
payload: dict,
):
"""Generate a summary from the provided content.
This Celery task performs the following operations:
1. Uses an LLM to generate a TL;DR summary of the content.
2. Breaks the content into parts and summarizes each part.
3. Cleans up the combined summary
4. Generates next steps.
5. Sends the final summary via webhook.
"""
payload = SummarizeTaskV2Payload.model_validate(payload)
summary = summarize_transcription_internals(
owner_id=payload.remote_owner_id,
transcript=payload.content,
session_id=self.request.id,
)
call_webhook_v2_task.apply_async(
args=[
SummarizeWebhookSuccessPayload(
source_id=payload.remote_id, summary=summary
).model_dump(),
payload.tenant_id,
]
)
@signals.task_failure.connect(sender=summarize_v2_task)
def handle_summarize_v2_failed(
sender,
task_id=None,
exception=None,
args=None,
kwargs=None,
traceback=None,
einfo=None,
**kw,
):
"""Handle the failure of summarize_v2_task.
This function is triggered when the summarize_v2_task fails.
It sends a webhook failure payload to notify the client of the failure.
"""
task = sender
# If retries are exhausted:
if task.request.retries >= task.max_retries:
call_webhook_v2_task.apply_async(
args=[
SummarizeWebhookFailurePayload(
source_id=args[0]["remote_id"],
error_code="unknown_error",
).model_dump(),
args[0]["tenant_id"],
]
)
+133 -8
View File
@@ -1,21 +1,58 @@
"""Application configuration and settings."""
from functools import lru_cache
from typing import Annotated, List, Literal, Optional, Set
import logging
import os
from functools import cached_property, lru_cache
from typing import Annotated, Any, List, Literal, Mapping, Optional, Set
from fastapi import Depends
from pydantic import SecretStr
from pydantic import (
BaseModel,
ConfigDict,
Field,
SecretStr,
model_validator,
)
from pydantic_settings import BaseSettings, SettingsConfigDict
from summary.core.types import Url
logger = logging.getLogger(__name__)
class AuthorizedTenant(BaseModel):
"""Authorized tenant configuration."""
model_config = ConfigDict(frozen=True)
id: str = Field(title="Tenant Id", description="A unique ID for the tenant.")
api_key: SecretStr = Field(title="API key for using the summary API", min_length=8)
webhook_url: Url = Field(
title="Webhook URL", description="The URL to send the summary to."
)
webhook_api_key: SecretStr = Field(
title="Webhook API Key",
description="The api_key to authenticate the webhook request.",
)
V1_DEFAULT_TENANT_ID = "__deprecated_meet_tenant__"
class Settings(BaseSettings):
"""Configuration settings loaded from environment variables and .env file."""
model_config = SettingsConfigDict(env_file=".env")
model_config = SettingsConfigDict(env_file=".env", frozen=True)
app_name: str = "app"
app_name: str = "summary"
app_api_v1_str: str = "/api/v1"
app_api_token: SecretStr
app_api_v2_str: str = "/api/v2"
# Authorized Tenants
# Using env variables to store authorized tenants for now
# to avoid any other external dependency (DB)
authorized_tenants: tuple[AuthorizedTenant, ...] = Field(default_factory=tuple)
v1_tenant_id: str = V1_DEFAULT_TENANT_ID
# Audio recordings
recording_max_duration: Optional[int] = None
@@ -29,6 +66,10 @@ class Settings(BaseSettings):
transcribe_queue: str = "transcribe-queue"
summarize_queue: str = "summarize-queue"
# v2 tasks
transcribe_queue_v2: str = "transcribe-queue-v2"
summarize_queue_v2: str = "summarize-queue-v2"
call_webhook_queue_v2: str = "call-webhook-queue-v2"
# Minio settings
aws_storage_bucket_name: str
@@ -56,8 +97,6 @@ class Settings(BaseSettings):
webhook_max_retries: int = 2
webhook_status_forcelist: List[int] = [502, 503, 504]
webhook_backoff_factor: float = 0.1
webhook_api_token: SecretStr
webhook_url: str
# Locale
default_context_language: Literal["de", "en", "fr", "nl"] = "fr"
@@ -90,6 +129,92 @@ class Settings(BaseSettings):
task_tracker_redis_url: str = "redis://redis/0"
task_tracker_prefix: str = "task_metadata:"
@model_validator(mode="before")
@classmethod
def legacy_default_tenant_config(cls, data: Any) -> Any:
"""Migrate the legacy default tenant configuration."""
if isinstance(data, dict):
api_key = os.getenv("APP_API_TOKEN")
webhook_api_key = os.getenv("WEBHOOK_API_TOKEN")
webhook_url = os.getenv("WEBHOOK_URL")
if api_key and webhook_api_key and webhook_url:
logger.warning(
"Deprecated legacy app configuration detected, "
"please use only the new 'authorized_tenants' field instead."
)
authorized_tenants = list(data.get("authorized_tenants", []))
authorized_tenants.append(
AuthorizedTenant(
id=V1_DEFAULT_TENANT_ID,
api_key=SecretStr(api_key),
webhook_url=webhook_url,
webhook_api_key=SecretStr(webhook_api_key),
)
)
data["authorized_tenants"] = tuple(authorized_tenants)
return data
@model_validator(mode="after")
def validate_authorized_tenants(self):
"""Validate authorized tenants configuration."""
if len(self.authorized_tenants) == 0:
raise ValueError("No authorized tenants configured")
tenant_ids = {tenant.id for tenant in self.authorized_tenants}
if len(tenant_ids) != len(self.authorized_tenants):
raise ValueError("Duplicate tenant ids are not allowed")
api_keys = {
tenant.api_key.get_secret_value() for tenant in self.authorized_tenants
}
if len(api_keys) != len(self.authorized_tenants):
raise ValueError("Duplicate application API api_keys are not allowed")
return self
@model_validator(mode="after")
def validate_default_v1_tenant(self):
"""Validate default v1 tenant configuration."""
if not any(
tenant.id == self.v1_tenant_id for tenant in self.authorized_tenants
):
raise ValueError("v1 tenant is not configured in authorized tenants")
return self
@cached_property
def authorized_tenant_api_keys(self) -> frozenset[str]:
"""Return a frozenset of authorized tenant API api_keys."""
return frozenset(
app.api_key.get_secret_value() for app in self.authorized_tenants
)
@cached_property
def authorized_tenant_by_id(self) -> Mapping[str, AuthorizedTenant]:
"""Return a dict of authorized tenants by ID."""
return {app.id: app for app in self.authorized_tenants}
@cached_property
def authorized_tenant_by_api_key(self) -> Mapping[str, AuthorizedTenant]:
"""Return the authorized tenant for a given API api_key."""
return {app.api_key.get_secret_value(): app for app in self.authorized_tenants}
def get_authorized_tenant(
self, *, tenant_id: str | None = None, api_key: str | None = None
) -> AuthorizedTenant:
"""Return the authorized tenant for a given API api_key or id."""
if tenant_id is None and api_key is None:
raise ValueError("Either tenant_id or api_key must be provided")
if tenant_id is not None and api_key is not None:
raise ValueError("Only one of tenant_id or api_key can be provided")
if tenant_id is not None:
return self.authorized_tenant_by_id[tenant_id]
else:
return self.authorized_tenant_by_api_key[api_key]
@lru_cache
def get_settings():
+76 -5
View File
@@ -6,8 +6,10 @@ import subprocess
import tempfile
from contextlib import contextmanager
from pathlib import Path
from urllib.parse import urlparse
import mutagen
import requests
from minio import Minio
from minio.error import MinioException, S3Error
@@ -97,6 +99,57 @@ class FileService:
if response:
response.close()
def _download_from_cloud_storage_url(self, cloud_storage_url: str) -> Path:
"""Download file from a cloud storage URL to local temporary file."""
logger.info(
"Download recording from URL | cloud_storage_url: %s",
cloud_storage_url,
)
if not cloud_storage_url:
logger.warning("Invalid cloud_storage_url '%s'", cloud_storage_url)
raise ValueError("Invalid cloud_storage_url")
extension = Path(urlparse(cloud_storage_url).path).suffix.lower()
if extension not in self._allowed_extensions:
logger.warning(
"Invalid file extension '%s' from cloud_storage_url", extension
)
raise ValueError(f"Invalid file extension '{extension}'")
try:
with requests.get(
cloud_storage_url,
stream=True,
timeout=(10, 300),
) as response:
response.raise_for_status()
with tempfile.NamedTemporaryFile(
suffix=extension,
delete=False,
prefix="cloud_storage_download_",
) as tmp:
for chunk in response.iter_content(
chunk_size=self._stream_chunk_size
):
if chunk:
tmp.write(chunk)
tmp.flush()
local_path = Path(tmp.name)
logger.info(
"Recording successfully downloaded from cloud_storage_url"
)
logger.debug("Recording local file path: %s", local_path)
return local_path
except requests.RequestException as e:
raise FileServiceException(
"Unexpected error while downloading object from cloud_storage_url."
) from e
def _validate_duration(self, local_path: Path) -> float:
"""Validate audio file duration against configured maximum."""
file_metadata = mutagen.File(local_path).info
@@ -157,19 +210,37 @@ class FileService:
raise RuntimeError("Failed to extract audio.") from e
@contextmanager
def prepare_audio_file(self, remote_object_key: str):
def prepare_audio_file(
self,
remote_object_key: str | None = None,
cloud_storage_url: str | None = None,
):
"""Download and prepare audio file for processing.
Downloads file from MinIO, validates duration, and yields an open
file handle with metadata. Automatically cleans up temporary files
when the context exits.
Downloads file from MinIO or an external cloud URL, validates duration,
and yields an open file handle with metadata. Automatically cleans up
temporary files when the context exits.
"""
downloaded_path = None
processed_path = None
file_handle = None
try:
downloaded_path = self._download_from_minio(remote_object_key)
if bool(remote_object_key) == bool(cloud_storage_url):
raise ValueError(
(
"Exactly one of 'remote_object_key' or "
"'cloud_storage_url' must be provided."
)
)
if cloud_storage_url:
downloaded_path = self._download_from_cloud_storage_url(
cloud_storage_url
)
else:
downloaded_path = self._download_from_minio(remote_object_key)
duration = self._validate_duration(downloaded_path)
extension = downloaded_path.suffix.lower()
+1 -2
View File
@@ -23,8 +23,7 @@ Einige Punkte, die wir Ihnen empfehlen zu überprüfen:
""",
download_header_template=(
"\n*Laden Sie Ihre Aufnahme herunter, "
"indem Sie [diesem Link folgen]({download_link})*\n"
"\n*[Laden Sie hier Ihre Aufnahme herunter (externer Link)]({download_link})*\n"
),
hallucination_replacement_text="[Text konnte nicht transkribiert werden]",
document_default_title="Transkription",
+1 -1
View File
@@ -23,7 +23,7 @@ A few things we recommend you check:
""",
download_header_template=(
"\n*Download your recording by [following this link]({download_link})*\n"
"\n*[Download your recording (external link)]({download_link})*\n"
),
hallucination_replacement_text="[Unable to transcribe text]",
document_default_title="Transcription",
+1 -1
View File
@@ -23,7 +23,7 @@ Quelques points que nous vous conseillons de vérifier :
""",
download_header_template=(
"\n*Télécharger votre enregistrement en [suivant ce lien]({download_link})*\n"
"\n*[Télécharger votre enregistrement (lien externe)]({download_link})*\n"
),
hallucination_replacement_text="[Texte impossible à transcrire]",
document_default_title="Transcription",
+1 -1
View File
@@ -23,7 +23,7 @@ Een paar punten die wij u aanraden te controleren:
""",
download_header_template=(
"\n*Download uw opname door [deze link te volgen]({download_link})*\n"
"\n*[Download hier je opname (externe link)]({download_link})*\n"
),
hallucination_replacement_text="[Tekst kon niet worden getranscribeerd]",
document_default_title="Transcriptie",
+68
View File
@@ -0,0 +1,68 @@
"""Models for the API & Celery tasks creation."""
from pydantic import BaseModel, Field, field_validator
from summary.core.config import get_settings
from summary.core.types import Url
settings = get_settings()
class SharedV2TaskCreation(BaseModel):
"""Model that holds basic information for task creation."""
remote_id: str = Field(
title="Remote ID",
description="A unique identifier of the object in the calling system.",
)
remote_owner_id: str = Field(
title="Remote Owner ID",
description="The ID of the user who owns the object, "
"for tracking / logging purposes.",
)
class TranscribeTaskV2Request(SharedV2TaskCreation):
"""Model for creating a transcribe and summarize task (used for API request)."""
cloud_storage_url: Url = Field(
title="Cloud storage URL",
description="The URL of the audio file to transcribe.",
)
context_language: str | None = Field(
default=None,
title="Context Language",
description="The language of the context text.",
)
language: str = Field(
title="Language", description="The language of the content to summarize."
)
@field_validator("language")
@classmethod
def validate_language(cls, v):
"""Validate 'language' parameter."""
if v is not None and v not in settings.whisperx_allowed_languages:
raise ValueError(
f"Language '{v}' is not allowed. "
f"Allowed languages: {', '.join(settings.whisperx_allowed_languages)}"
)
return v
class TranscribeTaskV2Payload(TranscribeTaskV2Request):
"""Model for creating a transcribe and summarize task (used for actual task creation).""" # noqa: E501
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
class SummarizeTaskV2Request(SharedV2TaskCreation):
"""Model for creating a summarize task (used for API request)."""
content: str = Field(title="Content", description="The content to summarize.")
class SummarizeTaskV2Payload(SummarizeTaskV2Request):
"""Model for creating a summarize task (used for actual task creation)."""
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
+17 -6
View File
@@ -8,12 +8,23 @@ from .config import SettingsDeps
security = HTTPBearer()
def verify_token(
def verify_tenant_api_key(
settings: SettingsDeps,
credentials: HTTPAuthorizationCredentials = Security(security), # noqa: B008
):
"""Verify the bearer token from the Authorization header."""
token = credentials.credentials
if token != settings.app_api_token.get_secret_value():
raise HTTPException(status_code=401, detail="Invalid token")
return token
"""Verify the bearer api_key from the Authorization header."""
api_key = credentials.credentials
if api_key not in settings.authorized_tenant_api_keys:
raise HTTPException(status_code=403, detail="Unauthorized")
return api_key
def verify_tenant_api_key_v2(
settings: SettingsDeps,
credentials: HTTPAuthorizationCredentials = Security(security), # noqa: B008
):
"""Verify the bearer api_key from the Authorization header."""
api_key = credentials.credentials
if api_key not in settings.authorized_tenant_api_keys:
raise HTTPException(status_code=403, detail="Unauthorized")
return settings.get_authorized_tenant(api_key=api_key)
+124
View File
@@ -0,0 +1,124 @@
"""Transcribe / summary Shared / Webhook models."""
from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field, TypeAdapter
class WordSegment(BaseModel):
"""Word segment model for transcription tasks."""
word: str = Field(title="Word")
start: float = Field(title="Start Time", description="Start time in seconds.")
end: float = Field(title="End Time", description="End time in seconds.")
score: float | None = Field(
title="Confidence Score", description="Confidence score for the word segment."
)
speaker: str | None = Field(
title="Speaker", description="Speaker identifier for the word segment."
)
class Segment(BaseModel):
"""Segment model for transcription tasks."""
start: float = Field(title="Start Time", description="Start time in seconds.")
end: float = Field(title="End Time", description="End time in seconds.")
text: str = Field(
title="Segment Text", description="Transcribed text for the segment."
)
words: tuple[WordSegment, ...] | None = Field(
title="Word Segments", description="List of word segments within the segment."
)
speaker: str | None = Field(
title="Speaker", description="Speaker identifier for the segment."
)
class WhisperXResponse(BaseModel):
"""Model for WhisperX response."""
segments: tuple[Segment, ...] = Field(
title="Segments", description="List of transcribed segments."
)
word_segments: tuple[WordSegment, ...] = Field(
title="Word Segments", description="List of word segments."
)
class BaseWebhook(BaseModel):
"""Base webhook payload."""
source_id: str = Field(
title="Source ID",
description="The ID of the source document in the receiver system.",
)
class TranscribeWebhookSuccessPayload(BaseWebhook):
"""Payload for a successful transcription webhook."""
type: Literal["transcript"] = Field(default="transcript")
status: Literal["success"] = Field(default="success")
title: str = Field(title="Title", description="The title of the transcript.")
transcript_text: str = Field(title="Transcript", description="The transcript text.")
transcript_raw: WhisperXResponse = Field(title="Raw Transcript")
class TranscribeWebhookFailurePayload(BaseWebhook):
"""Payload for a failed transcription webhook."""
type: Literal["transcript"] = Field(default="transcript")
status: Literal["failure"] = Field(default="failure")
error_code: Literal["unknown_error"] = Field(
title="Error code", description="The error code."
)
TranscribeWebhookPayloads = Annotated[
Union[TranscribeWebhookSuccessPayload, TranscribeWebhookFailurePayload],
Field(discriminator="status"),
]
class SummarizeWebhookSuccessPayload(BaseWebhook):
"""Payload for a successful summarization webhook."""
type: Literal["summary"] = Field(default="summary")
status: Literal["success"] = Field(default="success")
summary: str = Field(title="Summary", description="The summary of the text.")
class SummarizeWebhookFailurePayload(BaseWebhook):
"""Payload for a failed summarization webhook."""
type: Literal["summary"] = Field(default="summary")
status: Literal["failure"] = Field(default="failure")
error_code: Literal["unknown_error"] = Field(
title="Error code", description="The error code."
)
SummarizeWebhookPayloads = Annotated[
Union[SummarizeWebhookSuccessPayload, SummarizeWebhookFailurePayload],
Field(discriminator="status"),
]
WebhookPayloads = Annotated[
Union[TranscribeWebhookPayloads, SummarizeWebhookPayloads],
Field(discriminator="type"),
]
webhook_payload_adapter = TypeAdapter(WebhookPayloads)
__all__ = [
"TranscribeWebhookSuccessPayload",
"TranscribeWebhookFailurePayload",
"SummarizeWebhookSuccessPayload",
"SummarizeWebhookFailurePayload",
"TranscribeWebhookPayloads",
"SummarizeWebhookPayloads",
"WebhookPayloads",
"WhisperXResponse",
]
+10
View File
@@ -0,0 +1,10 @@
"""Basic types for the summary service."""
from typing import Annotated
from pydantic import BeforeValidator, HttpUrl, TypeAdapter
http_url_adapter = TypeAdapter(HttpUrl)
Url = Annotated[
str, BeforeValidator(lambda value: str(http_url_adapter.validate_python(value)))
]
+81 -20
View File
@@ -3,18 +3,22 @@
import json
import logging
import requests
from requests import Session
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
from summary.core.config import get_settings
from summary.core.shared_models import (
WebhookPayloads,
)
settings = get_settings()
logger = logging.getLogger(__name__)
def _create_retry_session():
def _create_retry_session(api_key: str | None = None):
"""Create an HTTP session configured with retry logic."""
session = Session()
retries = Retry(
@@ -24,15 +28,16 @@ def _create_retry_session():
allowed_methods={"POST"},
)
session.mount("https://", HTTPAdapter(max_retries=retries))
if api_key:
session.headers.update({"Authorization": f"Bearer {api_key}"})
return session
def _post_with_retries(url, data):
def _post_with_retries(*, url, data, api_key: str | None = None):
"""Send POST request with automatic retries."""
session = _create_retry_session()
session.headers.update(
{"Authorization": f"Bearer {settings.webhook_api_token.get_secret_value()}"}
)
session = _create_retry_session(api_key=api_key)
try:
response = session.post(url, json=data)
response.raise_for_status()
@@ -41,22 +46,78 @@ def _post_with_retries(url, data):
session.close()
def submit_content(content, title, email, sub):
"""Submit content to the configured webhook destination.
def call_webhook_v1(*, tenant_id: str, payload: dict) -> None:
"""Call webhook with payload a payload and optional token."""
tenant = settings.get_authorized_tenant(tenant_id=tenant_id)
Builds the payload, sends it with retries, and logs the outcome.
"""
data = {
"title": title,
"content": content,
"email": email,
"sub": sub,
}
logger.debug("Submitting to %s", tenant.webhook_url)
logger.debug("Request payload: %s", json.dumps(payload, indent=2))
logger.debug("Submitting to %s", settings.webhook_url)
logger.debug("Request payload: %s", json.dumps(data, indent=2))
response = _post_with_retries(settings.webhook_url, data)
response = _post_with_retries(
url=tenant.webhook_url,
api_key=tenant.webhook_api_key.get_secret_value(),
data=payload,
)
try:
response_data = response.json()
document_id = response_data.get("id", "N/A")
except (json.JSONDecodeError, AttributeError):
document_id = "Unable to parse response"
response_data = response.text
logger.info(
"Delivery success | Document %s submitted (HTTP %s)",
document_id,
response.status_code,
)
logger.debug("Full response: %s", response_data)
def submit_content(content: str, title: str, email: str, sub: str) -> None:
"""Submit content to the configured webhook destination.
Builds the payload, sends it with retries, and logs the outcome.
Notes:
Deprecated: Use call_webhook_v2 directly instead.
Deprecated:
This will route content to the v1 default tenant
"""
data = {
"title": title,
"content": content,
"email": email,
"sub": sub,
}
call_webhook_v1(payload=data, tenant_id=settings.v1_tenant_id)
def call_webhook_v2(
*,
tenant_id: str,
payload: WebhookPayloads,
) -> None:
"""Call webhook with a payload to a specific tenant.
Request is performed wihtout retry, retry should be handled at the task level.
"""
tenant = settings.get_authorized_tenant(tenant_id=tenant_id)
logger.debug("Submitting to %s", tenant.webhook_url)
logger.debug("Request payload: %s", payload.model_dump_json(indent=2))
response = requests.post(
tenant.webhook_url,
json=payload.model_dump(),
headers={
"Authorization": f"Bearer {tenant.webhook_api_key.get_secret_value()}",
},
timeout=(10, 20),
)
response.raise_for_status()
try:
response_data = response.json()
+3 -2
View File
@@ -4,7 +4,7 @@ import sentry_sdk
from fastapi import FastAPI
from summary.api import health
from summary.api.main import api_router
from summary.api.main import api_router_v1, api_router_v2
from summary.core.config import get_settings
settings = get_settings()
@@ -17,5 +17,6 @@ app = FastAPI(
title=settings.app_name,
)
app.include_router(api_router, prefix=settings.app_api_v1_str)
app.include_router(api_router_v1, prefix=settings.app_api_v1_str)
app.include_router(api_router_v2, prefix=settings.app_api_v2_str)
app.include_router(health.router)
+1
View File
@@ -0,0 +1 @@
"""Tests for the summary service."""
+1
View File
@@ -0,0 +1 @@
"""Tests for the API summary service."""
+21
View File
@@ -0,0 +1,21 @@
"""Integration tests for the health check endpoints."""
class TestHeartbeat:
"""Tests for the /__heartbeat__ endpoint."""
def test_returns_200(self, client):
"""The heartbeat endpoint responds with 200 OK without an api_key."""
response = client.get("/__heartbeat__")
assert response.status_code == 200
class TestLBHeartbeat:
"""Tests for the /__lbheartbeat__ endpoint."""
def test_returns_200(self, client):
"""The LB heartbeat endpoint responds with 200 OK without an api_key."""
response = client.get("/__lbheartbeat__")
assert response.status_code == 200
+88
View File
@@ -0,0 +1,88 @@
"""Integration tests for the API tasks endpoints."""
# tests/unit/test_api_tasks.py
from unittest.mock import MagicMock, patch
class TestTasks:
"""Tests for the /v1/tasks endpoint."""
@patch(
"summary.api.route.tasks.process_audio_transcribe_summarize_v2.apply_async",
return_value=MagicMock(id="task-id-abc"),
)
@patch("summary.api.route.tasks.time.time", return_value=1735725600.0)
def test_create_task_returns_task_id(self, mock_time, mock_apply_async, client):
"""POST /tasks/ with valid payload returns id and dispatches Celery task."""
response = client.post(
"api/v1/tasks/",
headers={"Authorization": "Bearer test-api-token"},
json={
"owner_id": "owner-123",
"filename": "recording.mp4",
"email": "user@example.com",
"sub": "sub-123",
"room": "room-abc",
"recording_date": "2026-01-01",
"recording_time": "10:00:00",
"language": None,
"download_link": "http://example.com/file.mp4",
},
)
assert response.status_code == 200
assert response.json() == {"id": "task-id-abc", "message": "Task created"}
args = mock_apply_async.call_args.kwargs["args"]
assert args == [
"owner-123", # owner_id
"recording.mp4", # filename
"user@example.com", # email
"sub-123", # sub
1735725600.0, # frozen time
"room-abc", # room
"2026-01-01", # recording_date
"10:00:00", # recording_time
None, # language
"http://example.com/file.mp4", # download_link
None, # context_language
]
def test_create_task_invalid_language(self, client):
"""POST /tasks/ with an unsupported language returns 422."""
payload = {"language": "klingon"}
response = client.post(
"/api/v1/tasks/",
headers={"Authorization": "Bearer test-api-token"},
json=payload,
)
assert response.status_code == 422
@patch(
"summary.api.route.tasks.AsyncResult",
return_value=MagicMock(status="PENDING"),
)
def test_get_task_status_pending(self, mock_result, client):
"""GET /tasks/{id} returns PENDING status when the task has not started yet."""
response = client.get(
"/api/v1/tasks/task-id-abc",
headers={"Authorization": "Bearer test-api-token"},
)
assert response.status_code == 200
assert response.json() == {"id": "task-id-abc", "status": "PENDING"}
@patch(
"summary.api.route.tasks.AsyncResult",
return_value=MagicMock(status="SUCCESS"),
)
def test_get_task_status_success(self, mock_result, client):
"""GET /tasks/{id} returns SUCCESS status when the task has completed."""
response = client.get(
"/api/v1/tasks/task-id-abc",
headers={"Authorization": "Bearer test-api-token"},
)
assert response.status_code == 200
assert response.json()["status"] == "SUCCESS"
+130
View File
@@ -0,0 +1,130 @@
"""Integration tests for the V2 task API endpoints."""
from unittest.mock import MagicMock, patch
class TestTasksV2:
"""Tests for the /tasks async V2 endpoints."""
@patch(
"summary.api.route.tasks_v2.process_audio_transcribe_v2_task.apply_async",
return_value=MagicMock(id="transcribe-task-id-abc"),
)
def test_create_transcribe_task_v2_returns_task_id(self, mock_apply_async, client):
"""POST /async/transcribe creates a task and injects tenant_id."""
response = client.post(
"/api/v2/async/transcribe",
headers={"Authorization": "Bearer test-api-token"},
json={
"remote_id": "remote-001",
"remote_owner_id": "owner-123",
"cloud_storage_url": "https://example.com/audio.mp3",
"language": "en",
"context_language": "fr",
},
)
assert response.status_code == 200
assert response.json() == {
"id": "transcribe-task-id-abc",
"message": "Transcribe task created",
}
args = mock_apply_async.call_args.kwargs["args"]
assert args == [
{
"remote_id": "remote-001",
"remote_owner_id": "owner-123",
"cloud_storage_url": "https://example.com/audio.mp3",
"language": "en",
"context_language": "fr",
"tenant_id": "test-tenant",
}
]
@patch(
"summary.api.route.tasks_v2.summarize_v2_task.apply_async",
return_value=MagicMock(id="summarize-task-id-abc"),
)
def test_create_summarize_task_v2_returns_task_id(self, mock_apply_async, client):
"""POST /async/summarize creates a task and injects tenant_id."""
response = client.post(
"/api/v2/async/summarize",
headers={"Authorization": "Bearer test-api-token"},
json={
"remote_id": "remote-002",
"remote_owner_id": "owner-456",
"content": "This is a long meeting transcript to summarize.",
},
)
assert response.status_code == 200
assert response.json() == {
"id": "summarize-task-id-abc",
"message": "Summarize task created",
}
args = mock_apply_async.call_args.kwargs["args"]
assert args == [
{
"remote_id": "remote-002",
"remote_owner_id": "owner-456",
"content": "This is a long meeting transcript to summarize.",
"tenant_id": "test-tenant",
}
]
@patch("summary.api.route.tasks_v2.AsyncResult")
def test_get_transcribe_task_status_returns_status_for_same_tenant(
self, mock_async_result, client
):
"""GET /async/transcribe/{id} returns status when tenant matches."""
mock_async_result.return_value = MagicMock(
status="PENDING",
args=[{"tenant_id": "test-tenant"}],
)
response = client.get(
"/api/v2/async/transcribe/task-id-abc",
headers={"Authorization": "Bearer test-api-token"},
)
assert response.status_code == 200
assert response.json() == {"id": "task-id-abc", "status": "PENDING"}
mock_async_result.assert_called_once_with("task-id-abc")
@patch("summary.api.route.tasks_v2.AsyncResult")
def test_get_summarize_task_status_returns_status_for_same_tenant(
self, mock_async_result, client
):
"""GET /async/summarize/{id} returns status when tenant matches."""
mock_async_result.return_value = MagicMock(
status="SUCCESS",
args=[{"tenant_id": "test-tenant"}],
)
response = client.get(
"/api/v2/async/summarize/task-id-abc",
headers={"Authorization": "Bearer test-api-token"},
)
assert response.status_code == 200
assert response.json() == {"id": "task-id-abc", "status": "SUCCESS"}
mock_async_result.assert_called_once_with("task-id-abc")
@patch("summary.api.route.tasks_v2.AsyncResult")
def test_get_task_status_returns_404_when_args_are_missing(
self, mock_async_result, client
):
"""GET /async/.../{id} returns 404 when task args are invalid."""
mock_async_result.return_value = MagicMock(status="SUCCESS", args=None)
response = client.get(
"/api/v2/async/transcribe/task-id-abc",
headers={"Authorization": "Bearer test-api-token"},
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found"}
+32
View File
@@ -0,0 +1,32 @@
"""Integration test configuration. Provides shared fixtures."""
import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr
from summary.core.config import AuthorizedTenant, Settings, get_settings
from summary.main import app
def get_settings_override():
"""Return settings for tests."""
return Settings(
v1_tenant_id="test-tenant",
authorized_tenants=(
AuthorizedTenant(
webhook_url="https://example.com/webhook",
id="test-tenant",
api_key=SecretStr("test-api-token"),
webhook_api_key=SecretStr("test-webhook-api-key"),
),
),
)
@pytest.fixture()
def client():
"""Provide a FastAPI TestClient for tests."""
client = TestClient(app)
app.dependency_overrides[get_settings] = get_settings_override
return client