mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e78cc3dab | |||
| 1af58b889e |
@@ -150,14 +150,13 @@ jobs:
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
cache: "pip"
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Check code formatting with ruff
|
||||
run: uv run ruff format . --diff
|
||||
run: ~/.local/bin/ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: uv run ruff check .
|
||||
run: ~/.local/bin/ruff check .
|
||||
|
||||
lint-summary:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -323,11 +322,6 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install ffmpeg
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ffmpeg
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
||||
@@ -8,31 +8,16 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.16.0] - 2026-05-13
|
||||
|
||||
### Added
|
||||
|
||||
- 🔒️(backend) add validation of Room.configuration
|
||||
- ✨(helm) add support multiple transcribe worker / endpoint #1247
|
||||
- ✨(backend) make LiveKit Egress recording encoding configurable #1288
|
||||
- ✨(summary) add speaker-to-participant assignment
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(summary) change tasks endpoint signature
|
||||
- ⬆️(dependencies) update urllib3 to v2.7.0 [SECURITY]
|
||||
- 🧑💻(agents) use `uv` for package management
|
||||
- ✨(summary) improve speaker-to-participant assignment
|
||||
|
||||
### Fixed
|
||||
|
||||
- ♻(frontend) standardize role terminology across localizations
|
||||
- 🐛(backend) make start-recording atomic and fault-tolerant
|
||||
- 🔒️(frontend) room ids are generated with non-cryptographic rand
|
||||
- ⬆️(mail) fix dependencies not having resolved or integrity field #1321
|
||||
- 🐛(summary) complete webm support #1328
|
||||
- 🐛(backend) add link to "Open" text in recording email
|
||||
- 🩹(frontend) fix spacing regression in mobile control bar
|
||||
|
||||
## [1.15.0] - 2026-04-30
|
||||
|
||||
@@ -71,7 +56,6 @@ and this project adheres to
|
||||
- ✨(summary) allow more file extensions #1265
|
||||
- ♿️(frontend) refocus reactions toolbar with ctrl+shift+e is activated #1262
|
||||
- ♿️(frontend) set an explicit document title on recording download page #1261
|
||||
- ♿️(frontend) add customizable accessibility fonts #1270
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ build-frontend: ## build the frontend container
|
||||
.PHONY: build-frontend
|
||||
|
||||
build-agents: ## build the multi-user-transcriber agent container
|
||||
@$(COMPOSE) build multi-user-transcriber-dev
|
||||
@$(COMPOSE) build multi-user-transcriber
|
||||
.PHONY: build-agents
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@@ -138,7 +138,7 @@ run-agents: ## start the multi-user-transcriber agent
|
||||
.PHONY: run-agents
|
||||
|
||||
run-agent-multi-user-transcriber: ## start the LiveKit agents (multi users transcriber)
|
||||
@$(COMPOSE) up --force-recreate -d multi-user-transcriber-dev
|
||||
@$(COMPOSE) up --force-recreate -d multi-user-transcriber
|
||||
.PHONY: run-agent-multi-user-transcriber
|
||||
|
||||
run-agent-metadata-collector: ## start the LiveKit agents (metadata collector)
|
||||
|
||||
+8
-12
@@ -249,7 +249,6 @@ services:
|
||||
metadata-collector-dev:
|
||||
build:
|
||||
context: ./src/agents
|
||||
target: development
|
||||
command: ["python", "metadata_collector.py", "dev"]
|
||||
environment:
|
||||
- LIVEKIT_URL=ws://livekit:7880
|
||||
@@ -262,7 +261,6 @@ services:
|
||||
- AWS_S3_SECURE_ACCESS=False
|
||||
volumes:
|
||||
- ./src/agents:/app
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- livekit
|
||||
- minio
|
||||
@@ -271,16 +269,6 @@ services:
|
||||
- action: rebuild
|
||||
path: ./src/agents
|
||||
|
||||
multi-user-transcriber-dev:
|
||||
build:
|
||||
context: ./src/agents
|
||||
target: development
|
||||
env_file:
|
||||
- env.d/development/multi_user_transcriber
|
||||
volumes:
|
||||
- ./src/agents:/app
|
||||
- /app/.venv
|
||||
|
||||
redis-summary:
|
||||
image: redis
|
||||
ports:
|
||||
@@ -342,6 +330,14 @@ services:
|
||||
- action: rebuild
|
||||
path: ./src/summary
|
||||
|
||||
multi-user-transcriber:
|
||||
build:
|
||||
context: ./src/agents
|
||||
env_file:
|
||||
- env.d/development/multi_user_transcriber
|
||||
volumes:
|
||||
- ./src/agents:/app
|
||||
|
||||
networks:
|
||||
default:
|
||||
resource-server:
|
||||
|
||||
@@ -44,14 +44,15 @@ server {
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Expires 0 always;
|
||||
|
||||
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
|
||||
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com https://*.cloud.microsoft";
|
||||
set $ministries_domains "https://courrieljf.ccomptes.fr";
|
||||
|
||||
set $nonce $request_id;
|
||||
|
||||
set $csp "upgrade-insecure-requests; ";
|
||||
set $csp "${csp}frame-ancestors ${ms_domains}; ";
|
||||
set $csp "${csp}frame-ancestors ${ms_domains} ${ministries_domains}; ";
|
||||
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
|
||||
set $csp "${csp}connect-src 'self' ${ms_domains}; ";
|
||||
set $csp "${csp}connect-src 'self' ${ms_domains} ${ministries_domains}; ";
|
||||
set $csp "${csp}frame-src 'none'; ";
|
||||
set $csp "${csp}object-src 'none'; ";
|
||||
set $csp "${csp}base-uri 'none'; ";
|
||||
|
||||
@@ -100,13 +100,6 @@ sequenceDiagram
|
||||
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
|
||||
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
||||
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
||||
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
|
||||
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
|
||||
|
||||
### Manual Storage Webhook
|
||||
@@ -148,54 +141,3 @@ Using default project meet
|
||||
|
||||
This allows you to verify which recordings are in progress, troubleshoot egress issues, and confirm that recordings are being processed correctly.
|
||||
|
||||
## Tuning recording encoding
|
||||
|
||||
By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280×720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing.
|
||||
|
||||
The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what you set is what the encoder receives.
|
||||
|
||||
### How values map to GStreamer
|
||||
|
||||
| Setting | GStreamer element | Property |
|
||||
| ------------------------------------- | ----------------- | ---------------------------------- |
|
||||
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
|
||||
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
|
||||
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
|
||||
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
|
||||
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
|
||||
|
||||
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
|
||||
|
||||
### Reference profiles
|
||||
|
||||
Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate × bitrate` but the absolute numbers depend on the host hardware.
|
||||
|
||||
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
|
||||
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
|
||||
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
|
||||
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
|
||||
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
|
||||
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
|
||||
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
|
||||
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
|
||||
|
||||
★ Recommended starting point for typical LaSuite Meet usage.
|
||||
|
||||
Environment variables for the **Low CPU / small file** profile:
|
||||
|
||||
```bash
|
||||
RECORDING_ENCODING_ENABLED=True
|
||||
RECORDING_ENCODING_WIDTH=1280
|
||||
RECORDING_ENCODING_HEIGHT=720
|
||||
RECORDING_ENCODING_FRAMERATE=15
|
||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
|
||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
|
||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||
```
|
||||
|
||||
### Caveats
|
||||
|
||||
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The recommended preset (600 kbps × 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps × 15 fps ≈ 60 kbits/frame).
|
||||
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
|
||||
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
|
||||
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
|
||||
|
||||
@@ -68,18 +68,6 @@ SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
|
||||
SUMMARY_SERVICE_API_TOKEN=password
|
||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||
|
||||
# Recording encoding (LiveKit Egress advanced options).
|
||||
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in
|
||||
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce
|
||||
# file size and CPU load on the egress worker.
|
||||
# RECORDING_ENCODING_ENABLED=False
|
||||
# RECORDING_ENCODING_WIDTH=1280
|
||||
# RECORDING_ENCODING_HEIGHT=720
|
||||
# RECORDING_ENCODING_FRAMERATE=30
|
||||
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000
|
||||
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
|
||||
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||
|
||||
# Telephony
|
||||
ROOM_TELEPHONY_ENABLED=True
|
||||
|
||||
|
||||
Generated
+250
-12
@@ -19,7 +19,7 @@
|
||||
"@types/office-runtime": "^1.0.35",
|
||||
"acorn": "^8.11.3",
|
||||
"babel-loader": "^9.1.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"eslint-plugin-office-addins": "^4.0.3",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-loader": "^5.0.0",
|
||||
@@ -4319,6 +4319,44 @@
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
"run-parallel": "^1.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.stat": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.walk": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
"fastq": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgr/core": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
|
||||
@@ -4332,6 +4370,19 @@
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/merge-streams": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
|
||||
"integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@@ -6635,20 +6686,21 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/copy-webpack-plugin": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz",
|
||||
"integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==",
|
||||
"version": "12.0.2",
|
||||
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz",
|
||||
"integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-glob": "^3.3.2",
|
||||
"glob-parent": "^6.0.1",
|
||||
"globby": "^14.0.0",
|
||||
"normalize-path": "^3.0.0",
|
||||
"schema-utils": "^4.2.0",
|
||||
"serialize-javascript": "^7.0.3",
|
||||
"tinyglobby": "^0.2.12"
|
||||
"serialize-javascript": "^6.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.9.0"
|
||||
"node": ">= 18.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -8010,6 +8062,36 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
"@nodelib/fs.walk": "^1.2.3",
|
||||
"glob-parent": "^5.1.2",
|
||||
"merge2": "^1.3.0",
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob/node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
@@ -8098,6 +8180,16 @@
|
||||
"node": ">= 4.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/faye-websocket": {
|
||||
"version": "0.11.4",
|
||||
"resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
|
||||
@@ -8666,6 +8758,37 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/globby": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz",
|
||||
"integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/merge-streams": "^2.1.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"ignore": "^7.0.3",
|
||||
"path-type": "^6.0.0",
|
||||
"slash": "^5.1.0",
|
||||
"unicorn-magic": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/globby/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -10921,6 +11044,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
@@ -12505,6 +12638,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz",
|
||||
"integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pathval": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
|
||||
@@ -12839,6 +12985,37 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -13325,6 +13502,17 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
@@ -13357,6 +13545,30 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
@@ -13630,13 +13842,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/serialize-javascript": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz",
|
||||
"integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
||||
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"dependencies": {
|
||||
"randombytes": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-index": {
|
||||
@@ -14048,6 +14260,19 @@
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
|
||||
"integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/sockjs": {
|
||||
"version": "0.3.24",
|
||||
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
|
||||
@@ -15086,6 +15311,19 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
|
||||
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"@types/office-runtime": "^1.0.35",
|
||||
"acorn": "^8.11.3",
|
||||
"babel-loader": "^9.1.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"eslint-plugin-office-addins": "^4.0.3",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-loader": "^5.0.0",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# Python
|
||||
__pycache__
|
||||
*.pyc
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
venv
|
||||
**/.venv
|
||||
|
||||
# System-specific files
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
# Docker
|
||||
compose.*
|
||||
env.d
|
||||
|
||||
# Docs
|
||||
docs
|
||||
*.md
|
||||
*.log
|
||||
|
||||
# Development/test cache & configurations
|
||||
data
|
||||
.cache
|
||||
.circleci
|
||||
.git
|
||||
.iml
|
||||
db.sqlite3
|
||||
.pylint.d
|
||||
|
||||
**/.idea
|
||||
**/.vscode
|
||||
**/.pytest_cache
|
||||
**/.mypy_cache
|
||||
**/.ruff_cache
|
||||
|
||||
# Env
|
||||
.env
|
||||
+11
-39
@@ -6,61 +6,31 @@ RUN apt-get update && apt-get install -y \
|
||||
libgobject-2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# ---- Builder image ----
|
||||
FROM base AS builder
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=0
|
||||
WORKDIR /builder
|
||||
|
||||
# Install uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
|
||||
COPY pyproject.toml .
|
||||
|
||||
WORKDIR /app
|
||||
RUN mkdir /install && \
|
||||
pip install --prefix=/install .
|
||||
|
||||
# Install production dependencies without the project itself (cacheable layer)
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project --no-dev
|
||||
|
||||
# Install the project
|
||||
COPY . /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
|
||||
# ---- Development image ----
|
||||
FROM base AS development
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . /app
|
||||
COPY pyproject.toml .
|
||||
RUN pip install --no-cache-dir ".[dev]"
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --all-extras
|
||||
COPY . .
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
CMD ["python", "metadata_collector.py", "dev"]
|
||||
|
||||
CMD ["python", "multi_user_transcriber.py", "dev"]
|
||||
|
||||
|
||||
# ---- Production image ----
|
||||
FROM base AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the pre-built virtualenv and application source
|
||||
COPY --from=builder /app /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
# Remove pip to reduce attack surface in production
|
||||
RUN pip uninstall -y pip
|
||||
@@ -69,4 +39,6 @@ RUN pip uninstall -y pip
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
COPY ./*.py /app/
|
||||
|
||||
CMD ["python", "multi_user_transcriber.py", "start"]
|
||||
|
||||
@@ -177,7 +177,7 @@ class MetadataCollector:
|
||||
|
||||
def save(self):
|
||||
"""Serialize collected events and upload as JSON to S3."""
|
||||
logger.info("Persisting metadata...")
|
||||
logger.info("Persisting metadata…")
|
||||
|
||||
participants = []
|
||||
for k, v in self.participants.items():
|
||||
@@ -372,7 +372,7 @@ async def entrypoint(ctx: JobContext):
|
||||
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
|
||||
|
||||
async def cleanup():
|
||||
logger.info("Shutting down metadata collector...")
|
||||
logger.info("Shutting down metadata collector…")
|
||||
await metadata_collector.aclose()
|
||||
|
||||
ctx.add_shutdown_callback(cleanup)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.16.0"
|
||||
version = "1.15.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.4.5",
|
||||
@@ -18,8 +18,12 @@ dev = [
|
||||
"ruff==0.15.6",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
[tool.setuptools]
|
||||
py-modules = ["multi_user_transcriber", "metadata_collector", "exceptions"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py313"
|
||||
|
||||
Generated
-1963
File diff suppressed because it is too large
Load Diff
@@ -1,52 +0,0 @@
|
||||
"""Analytics module."""
|
||||
|
||||
import logging
|
||||
from enum import StrEnum
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import posthog
|
||||
|
||||
from core.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventName(StrEnum):
|
||||
"""Analytics event names."""
|
||||
|
||||
TRANSCRIPT_GENERATION_SUCCESS = "transcript_generation_success"
|
||||
TRANSCRIPT_GENERATION_FAILURE = "transcript_generation_failure"
|
||||
SUMMARY_GENERATION_SUCCESS = "summary_generation_success"
|
||||
SUMMARY_GENERATION_FAILURE = "summary_generation_failure"
|
||||
|
||||
|
||||
def capture_event(event_name: EventName, *, user: User, properties=None) -> None:
|
||||
"""
|
||||
Capture an analytics event with user properties.
|
||||
"""
|
||||
if not settings.POSTHOG_ENABLED:
|
||||
return
|
||||
|
||||
properties = properties or {}
|
||||
properties["$set"] = {
|
||||
"name": user.full_name,
|
||||
"email": user.email,
|
||||
"sub": user.sub,
|
||||
}
|
||||
posthog.capture(event_name, distinct_id=user.id, properties=properties)
|
||||
|
||||
|
||||
def is_feature_enabled(feature_name: str, distinct_id: str) -> bool:
|
||||
"""Check if a feature flag is enabled for a user."""
|
||||
if not settings.POSTHOG_ENABLED:
|
||||
return False
|
||||
|
||||
try:
|
||||
return posthog.feature_enabled(feature_name, distinct_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("Error checking feature flag %s: %s", feature_name, e)
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["EventName", "capture_event", "is_feature_enabled"]
|
||||
@@ -136,15 +136,3 @@ class FilePermission(IsAuthenticated):
|
||||
raise Http404
|
||||
|
||||
return obj.get_abilities(request.user).get(view.action, False)
|
||||
|
||||
|
||||
class TranscribeWebhookPermission(permissions.BasePermission):
|
||||
"""
|
||||
Permissions applying to the summary webhook endpoint.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.method == "POST"
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
return False
|
||||
|
||||
@@ -17,7 +17,6 @@ from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_filters import rest_framework as django_filters
|
||||
from pydantic import ValidationError
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
filters,
|
||||
@@ -35,7 +34,7 @@ from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
|
||||
from core import analytics, enums, models, utils
|
||||
from core import enums, models, utils
|
||||
from core.api.filters import ListFileFilter
|
||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||
from core.recording.enums import FileExtension
|
||||
@@ -81,10 +80,6 @@ from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||
from ..authentication.webhooks import AiWebhookAuthentication
|
||||
from ..models import AiJobStatusChoices, AiRecordingJob
|
||||
from ..tasks.ai_job import handle_summary_received, handle_transcript_received
|
||||
from ..transcription import webhook_schemas
|
||||
from . import permissions, serializers, throttling
|
||||
from .feature_flag import FeatureFlag
|
||||
|
||||
@@ -368,7 +363,6 @@ class RoomViewSet(
|
||||
):
|
||||
try:
|
||||
MetadataCollectorService().start(recording)
|
||||
logger.debug("Started MetadataCollectorService")
|
||||
except MetadataCollectorException:
|
||||
logger.warning("Failed to start MetadataCollectorService")
|
||||
|
||||
@@ -920,9 +914,15 @@ class RecordingViewSet(
|
||||
|
||||
# Attempt to notify external services about the recording
|
||||
# This is a non-blocking operation - failures are logged but don't interrupt the flow
|
||||
notification_service.notify_external_services(recording)
|
||||
notification_succeeded = notification_service.notify_external_services(
|
||||
recording
|
||||
)
|
||||
|
||||
recording.status = models.RecordingStatusChoices.SAVED
|
||||
recording.status = (
|
||||
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
||||
if notification_succeeded
|
||||
else models.RecordingStatusChoices.SAVED
|
||||
)
|
||||
recording.save()
|
||||
|
||||
return drf_response.Response(
|
||||
@@ -1331,92 +1331,3 @@ class FileViewSet(
|
||||
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
|
||||
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
|
||||
class AiJobViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""AI jobs API."""
|
||||
|
||||
permission_classes = []
|
||||
serializer_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
"""Restrict AI jobs to current user except webhook endpoint."""
|
||||
|
||||
raise NotImplementedError()
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="webhook",
|
||||
authentication_classes=[AiWebhookAuthentication],
|
||||
permission_classes=[permissions.TranscribeWebhookPermission],
|
||||
)
|
||||
def on_ai_event(self, request):
|
||||
"""Handle incoming hook events for recordings."""
|
||||
logger.debug("Received transcribe webhook event: %s", request.data)
|
||||
|
||||
try:
|
||||
payload = webhook_schemas.webhook_payload_adapter.validate_python(
|
||||
request.data
|
||||
)
|
||||
except ValidationError as exc:
|
||||
logger.error("Invalid webhook payload: %s", exc)
|
||||
raise drf_exceptions.ValidationError(detail=exc) from exc
|
||||
|
||||
ai_recording_job = AiRecordingJob.objects.filter(
|
||||
remote_job_id=payload.job_id
|
||||
).first()
|
||||
|
||||
if not ai_recording_job:
|
||||
logger.warning("No AI recording job found for job ID: %s", payload.job_id)
|
||||
return drf_response.Response(
|
||||
{"message": "No AI recording job found for job ID, ignoring."},
|
||||
)
|
||||
|
||||
if ai_recording_job.status == AiJobStatusChoices.SUCCESS:
|
||||
logger.warning(
|
||||
"AI recording job already in success state for job ID: %s",
|
||||
payload.job_id,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{"message": "AI recording job already in success state, ignoring."},
|
||||
)
|
||||
|
||||
if isinstance(payload, webhook_schemas.TranscribeWebhookSuccessPayload):
|
||||
handle_transcript_received.apply_async(
|
||||
args=[payload.job_id, payload.transcription_data_url]
|
||||
)
|
||||
elif isinstance(payload, webhook_schemas.SummarizeWebhookSuccessPayload):
|
||||
handle_summary_received.apply_async(
|
||||
args=[payload.job_id, payload.summary_data_url]
|
||||
)
|
||||
elif isinstance(
|
||||
payload,
|
||||
(
|
||||
webhook_schemas.SummarizeWebhookFailurePayload,
|
||||
webhook_schemas.TranscribeWebhookFailurePayload,
|
||||
),
|
||||
):
|
||||
ai_recording_job.status = AiJobStatusChoices.FAILED
|
||||
ai_recording_job.save()
|
||||
analytics.capture_event(
|
||||
analytics.EventName.TRANSCRIPT_GENERATION_FAILURE
|
||||
if isinstance(payload, webhook_schemas.TranscribeWebhookFailurePayload)
|
||||
else analytics.EventName.SUMMARY_GENERATION_FAILURE,
|
||||
user=ai_recording_job.user,
|
||||
properties={
|
||||
"generation_time_seconds": (
|
||||
timezone.now() - ai_recording_job.created_at
|
||||
).total_seconds(),
|
||||
"ai_recording_job_id": ai_recording_job.id,
|
||||
"recording_id": ai_recording_job.recording.id,
|
||||
},
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError()
|
||||
|
||||
return drf_response.Response(
|
||||
{"message": "Event processed."},
|
||||
)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Webhooks authentication."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AiWebhookAuthentication(BaseAuthentication):
|
||||
"""
|
||||
Custom authentication class for AI webhook requests.
|
||||
Validates the API key in the Authorization header.
|
||||
"""
|
||||
|
||||
def authenticate(self, request):
|
||||
"""
|
||||
Authenticate the request and return a two-tuple of (user, token).
|
||||
"""
|
||||
|
||||
authorization_header: str = request.headers.get("Authorization") or ""
|
||||
if authorization_header.removeprefix("Bearer ") != settings.AI_WEBHOOK_API_KEY:
|
||||
logger.warning(
|
||||
"Authentication failed: Bad Authorization header (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed()
|
||||
|
||||
# No users are associated with the transcribe webhooks
|
||||
return AnonymousUser(), None
|
||||
@@ -1,36 +0,0 @@
|
||||
# Generated by Django 5.2.14 on 2026-05-14 12:40
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0018_rename_active_application_is_active'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AiRecordingJob',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('remote_job_id', models.CharField(blank=True, max_length=255, null=True, unique=True)),
|
||||
('type', models.CharField(choices=[('transcript', 'Transcript'), ('summary', 'Summary')], max_length=25)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('success', 'Success'), ('failed', 'Failed')], max_length=25)),
|
||||
('language', models.CharField(choices=[('fr', 'fr'), ('en', 'en'), ('de', 'de'), ('nl', 'nl')], default='fr', max_length=2)),
|
||||
('docs_app_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('recording', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_jobs', to='core.recording')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'AiJob',
|
||||
'verbose_name_plural': 'AiJobs',
|
||||
'db_table': 'ai_job',
|
||||
'ordering': ('created_at',),
|
||||
'indexes': [models.Index(fields=['recording', 'type', '-created_at'], name='ai_job_recordi_ca452e_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -590,16 +590,6 @@ class Recording(BaseModel):
|
||||
verbose_name=_("Recording options"),
|
||||
help_text=_("Recording options"),
|
||||
)
|
||||
started_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Recording start timestamp as recorded by livekit."),
|
||||
)
|
||||
ended_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Recording end timestamp as recorded by livekit."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_recording"
|
||||
@@ -746,66 +736,6 @@ class RecordingAccess(BaseAccess):
|
||||
return self._get_abilities(self.recording, user)
|
||||
|
||||
|
||||
class AiJobStatusChoices(models.TextChoices):
|
||||
"""Possible states of a file."""
|
||||
|
||||
PENDING = "pending", _("Pending")
|
||||
SUCCESS = "success", _("Success")
|
||||
FAILED = "failed", _("Failed")
|
||||
|
||||
|
||||
class AiJobTypeChoices(models.TextChoices):
|
||||
"""Possible types of Ai Jobs."""
|
||||
|
||||
TRANSCRIPT = "transcript", _("Transcript")
|
||||
SUMMARIZE = "summary", _("Summary")
|
||||
|
||||
|
||||
class AiRecordingJob(BaseModel):
|
||||
"""
|
||||
A job that is run to process an audio file.
|
||||
"""
|
||||
|
||||
remote_job_id = models.CharField(max_length=255, unique=True, null=True, blank=True)
|
||||
type = models.CharField(
|
||||
max_length=25,
|
||||
choices=AiJobTypeChoices.choices,
|
||||
)
|
||||
recording = models.ForeignKey(
|
||||
Recording, on_delete=models.CASCADE, related_name="ai_jobs"
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=25,
|
||||
choices=AiJobStatusChoices.choices,
|
||||
)
|
||||
language = models.CharField(
|
||||
max_length=2,
|
||||
choices=(("fr", "fr"), ("en", "en"), ("de", "de"), ("nl", "nl")),
|
||||
default="fr",
|
||||
)
|
||||
docs_app_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "ai_job"
|
||||
verbose_name = _("AiJob")
|
||||
verbose_name_plural = _("AiJobs")
|
||||
ordering = ("created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["recording", "type", "-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.recording.id} - {self.type} - {self.status}"
|
||||
|
||||
@property
|
||||
def user(self):
|
||||
return (
|
||||
RecordingAccess.objects.select_related("user")
|
||||
.filter(role=RoleChoices.OWNER, recording_id=self.recording.id)
|
||||
.first()
|
||||
).user
|
||||
|
||||
|
||||
class ApplicationScope(models.TextChoices):
|
||||
"""Available permission scopes for application operations."""
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""Service to notify external services when a new recording is ready."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import smtplib
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
@@ -11,12 +9,9 @@ from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import aiohttp
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
import requests
|
||||
|
||||
from core import models, utils
|
||||
from core.tasks.ai_job import call_transcribe_service
|
||||
from core import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,17 +40,22 @@ class NotificationService:
|
||||
"""Process a recording based on its mode."""
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.TRANSCRIPT:
|
||||
self._notify_summary_service(recording)
|
||||
elif recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
return self._notify_summary_service(recording)
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
summary_success = True
|
||||
if recording.options.get("transcribe", False):
|
||||
self._notify_summary_service(recording)
|
||||
self._notify_user_by_email(recording)
|
||||
else:
|
||||
logger.error(
|
||||
"Unknown recording mode %s for recording %s",
|
||||
recording.mode,
|
||||
recording.id,
|
||||
)
|
||||
summary_success = self._notify_summary_service(recording)
|
||||
|
||||
email_success = self._notify_user_by_email(recording)
|
||||
return email_success and summary_success
|
||||
|
||||
logger.error(
|
||||
"Unknown recording mode %s for recording %s",
|
||||
recording.mode,
|
||||
recording.id,
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _notify_user_by_email(recording) -> bool:
|
||||
@@ -131,50 +131,7 @@ class NotificationService:
|
||||
return not has_failures
|
||||
|
||||
@staticmethod
|
||||
async def _get_recording_timestamps(worker_id):
|
||||
"""Fetch FileInfo.started_at and ended_at from LiveKit's egress API.
|
||||
|
||||
FileInfo.started_at is more accurate than EgressInfo.started_at because
|
||||
it reflects when file recording actually began. The started_at value exposed
|
||||
in the manifest file, as well as in the EgressInfo returned by the API,
|
||||
corresponds to when the egress service received the request, not the moment
|
||||
the egress worker effectively joined the room.
|
||||
|
||||
Returns:
|
||||
Tuple of (started_at, ended_at) datetimes, either may be None.
|
||||
"""
|
||||
|
||||
if not worker_id:
|
||||
return None, None
|
||||
|
||||
custom_configuration = {
|
||||
**settings.LIVEKIT_CONFIGURATION,
|
||||
"timeout": aiohttp.ClientTimeout(total=10),
|
||||
}
|
||||
lkapi = utils.create_livekit_client(custom_configuration=custom_configuration)
|
||||
try:
|
||||
egress_list = await lkapi.egress.list_egress(
|
||||
livekit_api.ListEgressRequest(egress_id=worker_id) # pylint: disable=no-member
|
||||
)
|
||||
except (livekit_api.TwirpError, OSError, asyncio.TimeoutError):
|
||||
logger.exception("Could not fetch egress info for worker %s", worker_id)
|
||||
return None, None
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
if not egress_list.items or not egress_list.items[0].file_results:
|
||||
logger.debug("No file_results for worker %s", worker_id)
|
||||
return None, None
|
||||
|
||||
file_result = egress_list.items[0].file_results[0]
|
||||
|
||||
def _ns_to_utc(ns):
|
||||
return datetime.fromtimestamp(ns / 1e9, tz=timezone.utc) if ns else None
|
||||
|
||||
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording: models.Recording):
|
||||
def _notify_summary_service(recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
|
||||
if (
|
||||
@@ -182,17 +139,60 @@ class NotificationService:
|
||||
or not settings.SUMMARY_SERVICE_API_TOKEN
|
||||
):
|
||||
logger.error("Summary service not configured")
|
||||
return
|
||||
return False
|
||||
|
||||
started_at, ended_at = async_to_sync(
|
||||
NotificationService._get_recording_timestamps
|
||||
)(recording.worker_id)
|
||||
owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
recording.started_at = started_at
|
||||
recording.ended_at = ended_at
|
||||
recording.save()
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
payload = {
|
||||
"owner_id": str(owner_access.user.id),
|
||||
"filename": recording.key,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
"room": recording.room.name,
|
||||
"language": recording.options.get("language"),
|
||||
"recording_date": recording.created_at.astimezone(
|
||||
owner_access.user.timezone
|
||||
).strftime("%Y-%m-%d"),
|
||||
"recording_time": recording.created_at.astimezone(
|
||||
owner_access.user.timezone
|
||||
).strftime("%H:%M"),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
"context_language": owner_access.user.language,
|
||||
}
|
||||
|
||||
call_transcribe_service.apply_async(args=[recording.id])
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
logger.exception(
|
||||
"Summary service error for recording %s. URL: %s. Exception: %s",
|
||||
recording.id,
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
notification_service = NotificationService()
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Factory, configurations and Protocol to create worker services"""
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
@@ -10,17 +8,8 @@ from typing import Any, ClassVar, Dict, Optional, Protocol, Type
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from livekit import api as livekit_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Codec / frequency constants matching LiveKit's H264_720P_30 preset.
|
||||
# Kept fixed because changing them would shift the goal-post away from the
|
||||
# "safe drop-in replacement for the default preset" contract of this feature.
|
||||
_RECORDING_VIDEO_CODEC = livekit_api.VideoCodec.H264_MAIN
|
||||
_RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
|
||||
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerServiceConfig:
|
||||
@@ -29,7 +18,6 @@ class WorkerServiceConfig:
|
||||
output_folder: str
|
||||
server_configurations: Dict[str, Any]
|
||||
bucket_args: Optional[dict]
|
||||
encoding_options: Optional[Dict[str, Any]] = None
|
||||
|
||||
@classmethod
|
||||
@lru_cache
|
||||
@@ -37,24 +25,6 @@ class WorkerServiceConfig:
|
||||
"""Load configuration from Django settings with caching for efficiency."""
|
||||
|
||||
logger.debug("Loading WorkerServiceConfig from settings.")
|
||||
|
||||
encoding_options: Optional[Dict[str, Any]] = None
|
||||
if settings.RECORDING_ENCODING_ENABLED:
|
||||
# Single source of truth for the EncodingOptions kwargs:
|
||||
# operator-tunable values live in Django settings, codec / frequency
|
||||
# are pinned constants. The services layer only unpacks this dict.
|
||||
encoding_options = {
|
||||
"width": settings.RECORDING_ENCODING_WIDTH,
|
||||
"height": settings.RECORDING_ENCODING_HEIGHT,
|
||||
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
|
||||
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
|
||||
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||
"video_codec": _RECORDING_VIDEO_CODEC,
|
||||
"audio_codec": _RECORDING_AUDIO_CODEC,
|
||||
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
|
||||
}
|
||||
|
||||
return cls(
|
||||
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
||||
server_configurations=settings.LIVEKIT_CONFIGURATION,
|
||||
@@ -66,7 +36,6 @@ class WorkerServiceConfig:
|
||||
"bucket": settings.AWS_STORAGE_BUCKET_NAME,
|
||||
"force_path_style": True,
|
||||
},
|
||||
encoding_options=encoding_options,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -83,22 +83,6 @@ class BaseEgressService:
|
||||
"""
|
||||
raise NotImplementedError("Subclass must implement this method.")
|
||||
|
||||
def _build_encoding_options(self):
|
||||
"""Build a LiveKit EncodingOptions from the service config, or None.
|
||||
|
||||
When None is returned, the caller should omit the `advanced` field so
|
||||
LiveKit Egress falls back to its built-in preset (H264_720P_30).
|
||||
|
||||
The full EncodingOptions kwargs (operator-tunable values + pinned
|
||||
codec / frequency constants) are assembled in `WorkerServiceConfig`,
|
||||
so this method is a thin protobuf adapter.
|
||||
"""
|
||||
opts = self._config.encoding_options
|
||||
if not opts:
|
||||
return None
|
||||
|
||||
return livekit_api.EncodingOptions(**opts)
|
||||
|
||||
|
||||
class VideoCompositeEgressService(BaseEgressService):
|
||||
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
|
||||
@@ -120,17 +104,9 @@ class VideoCompositeEgressService(BaseEgressService):
|
||||
s3=self._s3,
|
||||
)
|
||||
|
||||
request_kwargs = {
|
||||
"room_name": room_name,
|
||||
"file_outputs": [file_output],
|
||||
"layout": "speaker-light",
|
||||
}
|
||||
|
||||
advanced = self._build_encoding_options()
|
||||
if advanced is not None:
|
||||
request_kwargs["advanced"] = advanced
|
||||
|
||||
request = livekit_api.RoomCompositeEgressRequest(**request_kwargs)
|
||||
request = livekit_api.RoomCompositeEgressRequest(
|
||||
room_name=room_name, file_outputs=[file_output], layout="speaker-light"
|
||||
)
|
||||
|
||||
response = self._handle_request(request, "start_room_composite_egress")
|
||||
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
import requests
|
||||
|
||||
from core import analytics, models
|
||||
from core.models import (
|
||||
AiJobStatusChoices,
|
||||
AiJobTypeChoices,
|
||||
AiRecordingJob,
|
||||
Recording,
|
||||
)
|
||||
from core.tasks._task import task
|
||||
from core.transcription.locales import get_locale
|
||||
from core.transcription.transcript_formatter import TranscriptFormatter
|
||||
from core.transcription.webhook_schemas import WhisperXResponse
|
||||
from core.utils import generate_download_s3_file_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@task
|
||||
def call_transcribe_service(recording_id):
|
||||
"""
|
||||
Call the transcribe service for a given recording.
|
||||
"""
|
||||
try:
|
||||
recording = Recording.objects.get(id=recording_id)
|
||||
except Recording.DoesNotExist:
|
||||
logger.error("Recoding %s does not exist", recording_id)
|
||||
return None
|
||||
|
||||
owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
metadata = None
|
||||
if (
|
||||
settings.METADATA_COLLECTOR_ENABLED
|
||||
and recording.options.get("collect_metadata", False)
|
||||
and recording.started_at
|
||||
and recording.ended_at
|
||||
):
|
||||
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
|
||||
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
|
||||
metadata = {
|
||||
"cloud_storage_url": generate_download_s3_file_url(metadata_filename),
|
||||
"start_at": recording.started_at,
|
||||
"end_at": recording.ended_at,
|
||||
}
|
||||
|
||||
language = (
|
||||
recording.options.get("language") or settings.TRANSCRIPTION_DEFAULT_LANGUAGE
|
||||
)
|
||||
ai_transcribe_job = AiRecordingJob.objects.create(
|
||||
remote_job_id=None,
|
||||
recording=recording,
|
||||
type=AiJobTypeChoices.TRANSCRIPT,
|
||||
status=AiJobStatusChoices.PENDING,
|
||||
language=language,
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
settings.AI_SERVICE_URL + "async-jobs/transcribe/",
|
||||
json={
|
||||
"user_sub": owner_access.user.sub,
|
||||
"language": language,
|
||||
"cloud_storage_url": generate_download_s3_file_url(
|
||||
recording.key, expires_in=60 * 60 * 24, override_domain=False
|
||||
),
|
||||
"metadata": metadata,
|
||||
},
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.AI_SERVICE_API_KEY}",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Creating transcription job failed for recording %s: %s", recording_id, e
|
||||
)
|
||||
ai_transcribe_job.status = AiJobStatusChoices.FAILED
|
||||
ai_transcribe_job.save()
|
||||
raise e
|
||||
|
||||
data = response.json()
|
||||
|
||||
ai_transcribe_job.remote_job_id = data["job_id"]
|
||||
ai_transcribe_job.save()
|
||||
|
||||
recording.status = models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
||||
recording.save()
|
||||
|
||||
logger.info("Transcription job created for recording %s", recording_id)
|
||||
return ai_transcribe_job.id
|
||||
|
||||
|
||||
def format_transcript( # noqa: PLR0913
|
||||
transcription,
|
||||
*,
|
||||
context_language: str | None,
|
||||
language: str,
|
||||
room: str | None,
|
||||
recording_datetime: datetime | None,
|
||||
owner_timezone: 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
|
||||
TranscriptFormatter to produce markdown content and a title.
|
||||
|
||||
Returns a (content, title) tuple.
|
||||
"""
|
||||
locale = get_locale(context_language, language)
|
||||
formatter = TranscriptFormatter(locale)
|
||||
|
||||
return formatter.format(
|
||||
transcription,
|
||||
room=room,
|
||||
recording_datetime=recording_datetime,
|
||||
owner_timezone=owner_timezone,
|
||||
download_link=download_link,
|
||||
)
|
||||
|
||||
|
||||
@task
|
||||
def handle_transcript_received(remote_job_id, url):
|
||||
"""
|
||||
Store the transcript and call the summarize service for a given recording.
|
||||
"""
|
||||
ai_transcript_job = AiRecordingJob.objects.filter(
|
||||
remote_job_id=remote_job_id, type=AiJobTypeChoices.TRANSCRIPT
|
||||
).first()
|
||||
if not ai_transcript_job:
|
||||
logger.warning("No AI recording job found for job ID: %s", remote_job_id)
|
||||
return
|
||||
|
||||
user = ai_transcript_job.user
|
||||
recording = ai_transcript_job.recording
|
||||
|
||||
response = requests.get(url, timeout=(10, 20))
|
||||
response.raise_for_status()
|
||||
transcript = WhisperXResponse(**response.json())
|
||||
|
||||
# Format output
|
||||
content, title = format_transcript(
|
||||
transcript,
|
||||
context_language=user.language,
|
||||
language=ai_transcript_job.language,
|
||||
room=recording.room.name,
|
||||
recording_datetime=recording.started_at or recording.created_at,
|
||||
owner_timezone=user.timezone,
|
||||
download_link=urljoin(settings.RECORDING_DOWNLOAD_BASE_URL, recording.id),
|
||||
)
|
||||
|
||||
create_document_in_docs(
|
||||
title=title, content=content, email=user.email, sub=user.sub
|
||||
)
|
||||
|
||||
ai_transcript_job.status = AiJobStatusChoices.SUCCESS
|
||||
ai_transcript_job.save()
|
||||
|
||||
analytics.capture_event(
|
||||
analytics.EventName.TRANSCRIPT_GENERATION_SUCCESS,
|
||||
user=ai_transcript_job.user,
|
||||
properties={
|
||||
"generation_time_seconds": (
|
||||
timezone.now() - ai_transcript_job.created_at
|
||||
).total_seconds(),
|
||||
"ai_recording_job_id": ai_transcript_job.id,
|
||||
"language": ai_transcript_job.language,
|
||||
"recording_id": ai_transcript_job.recording.id,
|
||||
"transcript_size": len(response.content),
|
||||
},
|
||||
)
|
||||
|
||||
# LLM Summarization
|
||||
if analytics.is_feature_enabled("summary-enabled", distinct_id=user.sub):
|
||||
ai_summary_job = AiRecordingJob.objects.create(
|
||||
remote_job_id=None,
|
||||
file=recording,
|
||||
type=AiJobTypeChoices.SUMMARIZE,
|
||||
status=AiJobStatusChoices.PENDING,
|
||||
language=ai_transcript_job.language,
|
||||
)
|
||||
|
||||
try:
|
||||
summary_response = requests.post(
|
||||
settings.AI_SERVICE_URL + "async-jobs/summarize/",
|
||||
json={
|
||||
"user_sub": ai_summary_job.user.sub,
|
||||
"language": ai_transcript_job.language,
|
||||
"content": content,
|
||||
},
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.AI_SERVICE_API_KEY}",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
summary_response.raise_for_status()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Creating summary job failed for recording %s: %s", recording.id, e
|
||||
)
|
||||
ai_summary_job.status = AiJobStatusChoices.FAILED
|
||||
ai_summary_job.save()
|
||||
raise e
|
||||
|
||||
ai_summary_job.remote_job_id = summary_response.json()["job_id"]
|
||||
ai_summary_job.save()
|
||||
|
||||
logger.info("Summary job created for recording %s", recording.id)
|
||||
|
||||
|
||||
@task
|
||||
def handle_summary_received(remote_job_id, url):
|
||||
"""
|
||||
Store the summary of a given file.
|
||||
"""
|
||||
ai_summary_job = AiRecordingJob.objects.filter(
|
||||
remote_job_id=remote_job_id, type=AiJobTypeChoices.SUMMARIZE
|
||||
).first()
|
||||
if not ai_summary_job:
|
||||
logger.warning("No AI file job found for job ID: %s", remote_job_id)
|
||||
return
|
||||
|
||||
recording = ai_summary_job.recording
|
||||
|
||||
logger.info("Storing summary for recording %s & url %s", recording.id, url)
|
||||
response = requests.get(url, timeout=(10, 20))
|
||||
response.raise_for_status()
|
||||
|
||||
user = ai_summary_job.user
|
||||
|
||||
# We dynamically recompute the title for the document since we don't have access to the transcript
|
||||
_, title = format_transcript(
|
||||
None,
|
||||
context_language=user.language,
|
||||
language=ai_summary_job.language,
|
||||
room=recording.room.name,
|
||||
recording_datetime=recording.started_at or recording.created_at,
|
||||
owner_timezone=user.timezone,
|
||||
download_link=urljoin(settings.RECORDING_DOWNLOAD_BASE_URL, recording.id),
|
||||
)
|
||||
|
||||
create_document_in_docs(
|
||||
title=get_locale(user.language).summary_title_template.format(title=title),
|
||||
content=response.text,
|
||||
email=user.email,
|
||||
sub=user.sub,
|
||||
)
|
||||
|
||||
logger.info("Summary created in docs for recording %s & url %s", recording.id, url)
|
||||
ai_summary_job.status = AiJobStatusChoices.SUCCESS
|
||||
ai_summary_job.save()
|
||||
|
||||
analytics.capture_event(
|
||||
analytics.EventName.TRANSCRIPT_GENERATION_SUCCESS,
|
||||
user=ai_summary_job.user,
|
||||
properties={
|
||||
"generation_time_seconds": (
|
||||
timezone.now() - ai_summary_job.created_at
|
||||
).total_seconds(),
|
||||
"ai_recording_job_id": ai_summary_job.id,
|
||||
"language": ai_summary_job.language,
|
||||
"recording_id": ai_summary_job.recording.id,
|
||||
"transcript_size": len(response.content),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_document_in_docs(*, title: str, content: str, email: str, sub: str) -> str:
|
||||
"""
|
||||
Create a document in Docs for a given file.
|
||||
"""
|
||||
|
||||
response = requests.post(
|
||||
urljoin(settings.DOCS_BASE_URL, "/api/v1.0/documents/create-for-owner/"),
|
||||
json={
|
||||
"title": title,
|
||||
"content": content,
|
||||
"email": email,
|
||||
"sub": sub,
|
||||
},
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.DOCS_SERVER_TO_SERVER_API_KEY}",
|
||||
},
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
if response.status_code != 201:
|
||||
logger.error(
|
||||
"Failed to create document in Docs %s",
|
||||
title,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
docs_app_id = response.json()["id"]
|
||||
logger.info(
|
||||
"Document created in Docs => %s (in docs)",
|
||||
docs_app_id,
|
||||
)
|
||||
return docs_app_id
|
||||
@@ -2,7 +2,7 @@
|
||||
Test worker service factories.
|
||||
"""
|
||||
|
||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
from unittest.mock import Mock
|
||||
@@ -10,9 +10,6 @@ from unittest.mock import Mock
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from livekit import (
|
||||
api as livekit_api_codec,
|
||||
)
|
||||
|
||||
from core.recording.worker.factories import (
|
||||
WorkerService,
|
||||
@@ -66,8 +63,6 @@ def test_config_initialization(default_config):
|
||||
"bucket": "test-bucket",
|
||||
"force_path_style": True,
|
||||
}
|
||||
# Encoding override is opt-in; disabled by default.
|
||||
assert default_config.encoding_options is None
|
||||
|
||||
|
||||
def test_config_immutability(default_config):
|
||||
@@ -76,45 +71,6 @@ def test_config_immutability(default_config):
|
||||
default_config.output_folder = "new/path"
|
||||
|
||||
|
||||
@override_settings(
|
||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||
AWS_S3_ENDPOINT_URL="https://s3.test.com",
|
||||
AWS_S3_ACCESS_KEY_ID="test_key",
|
||||
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
||||
AWS_S3_REGION_NAME="test-region",
|
||||
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
||||
RECORDING_ENCODING_ENABLED=True,
|
||||
RECORDING_ENCODING_WIDTH=1280,
|
||||
RECORDING_ENCODING_HEIGHT=720,
|
||||
RECORDING_ENCODING_FRAMERATE=15,
|
||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
|
||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
|
||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
|
||||
)
|
||||
def test_config_encoding_options_enabled():
|
||||
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
|
||||
|
||||
The dict mixes operator-tunable values from settings with pinned codec /
|
||||
frequency constants, so the services layer can simply unpack it.
|
||||
"""
|
||||
|
||||
WorkerServiceConfig.from_settings.cache_clear()
|
||||
config = WorkerServiceConfig.from_settings()
|
||||
|
||||
assert config.encoding_options == {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"framerate": 15,
|
||||
"video_bitrate": 600,
|
||||
"audio_bitrate": 64,
|
||||
"key_frame_interval": 10.0,
|
||||
"video_codec": livekit_api_codec.VideoCodec.H264_MAIN,
|
||||
"audio_codec": livekit_api_codec.AudioCodec.AAC,
|
||||
"audio_frequency": 48000,
|
||||
}
|
||||
|
||||
|
||||
@override_settings(
|
||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||
|
||||
@@ -39,31 +39,6 @@ def config():
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_with_encoding(config):
|
||||
"""Fixture for a config carrying custom encoding options.
|
||||
|
||||
Mirrors the dict shape produced by `WorkerServiceConfig.from_settings()`
|
||||
(operator-tunable values + pinned codec / frequency constants).
|
||||
"""
|
||||
return WorkerServiceConfig(
|
||||
output_folder=config.output_folder,
|
||||
server_configurations=config.server_configurations,
|
||||
bucket_args=config.bucket_args,
|
||||
encoding_options={
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"framerate": 15,
|
||||
"video_bitrate": 600,
|
||||
"audio_bitrate": 64,
|
||||
"key_frame_interval": 10.0,
|
||||
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||
"audio_frequency": 48000,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_s3_upload():
|
||||
"""Fixture for mocked S3Upload"""
|
||||
@@ -249,41 +224,6 @@ def test_video_composite_egress_start_missing_egress_id(video_service):
|
||||
assert "Egress ID not found" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_video_composite_egress_start_without_encoding_options(video_service):
|
||||
"""When no encoding options are configured, no `advanced` field is set.
|
||||
|
||||
LiveKit then falls back to its built-in preset (H264_720P_30).
|
||||
"""
|
||||
video_service._handle_request.return_value = Mock(egress_id="eg-1")
|
||||
video_service.start("test-room", "rec-1")
|
||||
|
||||
request = video_service._handle_request.call_args[0][0]
|
||||
# Proto oneof `options` must be unset when no advanced encoding is provided.
|
||||
assert request.WhichOneof("options") is None
|
||||
|
||||
|
||||
def test_video_composite_egress_start_with_encoding_options(config_with_encoding):
|
||||
"""Custom encoding options are forwarded as `advanced` EncodingOptions."""
|
||||
service = VideoCompositeEgressService(config_with_encoding)
|
||||
service._handle_request = Mock(return_value=Mock(egress_id="eg-2"))
|
||||
|
||||
service.start("test-room", "rec-2")
|
||||
|
||||
request = service._handle_request.call_args[0][0]
|
||||
assert request.WhichOneof("options") == "advanced"
|
||||
|
||||
advanced = request.advanced
|
||||
assert advanced.width == 1280
|
||||
assert advanced.height == 720
|
||||
assert advanced.framerate == 15
|
||||
assert advanced.video_bitrate == 600
|
||||
assert advanced.audio_bitrate == 64
|
||||
assert advanced.key_frame_interval == pytest.approx(10.0)
|
||||
assert advanced.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||
assert advanced.audio_codec == livekit_api.AudioCodec.AAC
|
||||
assert advanced.audio_frequency == 48000
|
||||
|
||||
|
||||
def test_audio_composite_egress_hrid(audio_service):
|
||||
"""Test HRID is correct"""
|
||||
assert audio_service.hrid == "audio-recording-composite-livekit-egress"
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
"""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 | None = Field(
|
||||
default=None, title="Start Time", description="Start time in seconds."
|
||||
)
|
||||
end: float | None = Field(
|
||||
default=None, title="End Time", description="End time in seconds."
|
||||
)
|
||||
score: float | None = Field(
|
||||
default=None,
|
||||
title="Confidence Score",
|
||||
description="Confidence score for the word segment.",
|
||||
)
|
||||
speaker: str | None = Field(
|
||||
default=None,
|
||||
title="Speaker",
|
||||
description="Speaker identifier for the word segment.",
|
||||
)
|
||||
|
||||
|
||||
class Segment(BaseModel):
|
||||
"""Segment model for transcription tasks."""
|
||||
|
||||
start: float | None = Field(
|
||||
default=None, title="Start Time", description="Start time in seconds."
|
||||
)
|
||||
end: float | None = Field(
|
||||
default=None, 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(
|
||||
default=None, 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."""
|
||||
|
||||
job_id: str = Field(
|
||||
title="Job ID",
|
||||
description="The ID of the job 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")
|
||||
transcription_data_url: str = Field(
|
||||
title="Transcript", description="URL to the raw transcription data."
|
||||
)
|
||||
|
||||
|
||||
class TranscribeWebhookPendingPayload(BaseWebhook):
|
||||
"""Payload for a pending transcription webhook-like response."""
|
||||
|
||||
type: Literal["transcript"] = Field(default="transcript")
|
||||
status: Literal["pending"] = Field(default="pending")
|
||||
|
||||
|
||||
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,
|
||||
TranscribeWebhookPendingPayload,
|
||||
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_data_url: str = Field(
|
||||
title="Summary", description="URL to the raw summary data."
|
||||
)
|
||||
|
||||
|
||||
class SummarizeWebhookPendingPayload(BaseWebhook):
|
||||
"""Payload for a pending summarization webhook-like response."""
|
||||
|
||||
type: Literal["summary"] = Field(default="summary")
|
||||
status: Literal["pending"] = Field(default="pending")
|
||||
|
||||
|
||||
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,
|
||||
SummarizeWebhookPendingPayload,
|
||||
SummarizeWebhookFailurePayload,
|
||||
],
|
||||
Field(discriminator="status"),
|
||||
]
|
||||
|
||||
WebhookPayloads = Annotated[
|
||||
Union[TranscribeWebhookPayloads, SummarizeWebhookPayloads],
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
||||
webhook_payload_adapter = TypeAdapter(WebhookPayloads)
|
||||
|
||||
__all__ = [
|
||||
"TranscribeWebhookSuccessPayload",
|
||||
"TranscribeWebhookPendingPayload",
|
||||
"TranscribeWebhookFailurePayload",
|
||||
"SummarizeWebhookSuccessPayload",
|
||||
"SummarizeWebhookPendingPayload",
|
||||
"SummarizeWebhookFailurePayload",
|
||||
"TranscribeWebhookPayloads",
|
||||
"SummarizeWebhookPayloads",
|
||||
"WebhookPayloads",
|
||||
"WhisperXResponse",
|
||||
"webhook_payload_adapter",
|
||||
]
|
||||
@@ -16,7 +16,6 @@ router.register("users", viewsets.UserViewSet, basename="users")
|
||||
router.register("rooms", viewsets.RoomViewSet, basename="rooms")
|
||||
router.register("recordings", viewsets.RecordingViewSet, basename="recordings")
|
||||
router.register("files", viewsets.FileViewSet, basename="files")
|
||||
router.register("ai-jobs", viewsets.AiJobViewSet, basename="ai-jobs")
|
||||
router.register(
|
||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||
)
|
||||
|
||||
@@ -455,38 +455,3 @@ def generate_upload_policy(file):
|
||||
)
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
def generate_download_s3_file_url(
|
||||
key, *, expires_in: int, override_domain: bool = True
|
||||
):
|
||||
"""
|
||||
Generate a S3 signed download url for a given key.
|
||||
"""
|
||||
|
||||
# This settings should be used if the backend application and the frontend application
|
||||
# can't connect to the object storage with the same domain. This is the case in the
|
||||
# docker compose stack used in development. The frontend application will use localhost
|
||||
# to connect to the object storage while the backend application will use the object storage
|
||||
# service name declared in the docker compose stack.
|
||||
# This is needed because the domain name is used to compute the signature. So it can't be
|
||||
# changed dynamically by the frontend application.
|
||||
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
|
||||
s3_client = boto3.client(
|
||||
"s3",
|
||||
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
|
||||
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
|
||||
config=botocore.client.Config(
|
||||
region_name=settings.AWS_S3_REGION_NAME,
|
||||
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
|
||||
),
|
||||
)
|
||||
else:
|
||||
s3_client = default_storage.connection.meta.client
|
||||
|
||||
return s3_client.generate_presigned_url(
|
||||
ClientMethod="get_object",
|
||||
Params={"Bucket": default_storage.bucket_name, "Key": key},
|
||||
ExpiresIn=expires_in,
|
||||
)
|
||||
|
||||
@@ -576,8 +576,8 @@ msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Klicken Sie auf den Button „Öffnen“ unten "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
|
||||
@@ -572,8 +572,8 @@ msgstr "To keep this recording permanently:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Click the \"Open\" button below "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
|
||||
@@ -578,8 +578,8 @@ msgstr "Pour conserver cet enregistrement de façon permanente :"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
|
||||
@@ -571,8 +571,8 @@ msgstr "Om deze opname permanent te bewaren:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Klik op de \"Openen\"-knop hieronder "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
|
||||
@@ -19,7 +19,6 @@ from socket import gethostbyname, gethostname
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import dj_database_url
|
||||
import posthog
|
||||
import sentry_sdk
|
||||
from configurations import Configuration, values
|
||||
from lasuite.configuration.values import SecretFileValue
|
||||
@@ -452,11 +451,6 @@ class Base(Configuration):
|
||||
CELERY_BROKER_URL = values.Value("redis://redis:6379/0", environ_prefix=None)
|
||||
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}, environ_prefix=None)
|
||||
|
||||
# Analytics
|
||||
POSTHOG_ENABLED = values.BooleanValue(False, environ_prefix=None)
|
||||
POSTHOG_API_KEY = values.Value(None, environ_prefix=None)
|
||||
POSTHOG_API_HOST = values.Value(None, environ_prefix=None)
|
||||
|
||||
# Session
|
||||
SESSION_ENGINE = values.Value(
|
||||
default="django.contrib.sessions.backends.cache",
|
||||
@@ -706,64 +700,12 @@ class Base(Configuration):
|
||||
RECORDING_MAX_DURATION = values.IntegerValue(
|
||||
None, environ_name="RECORDING_MAX_DURATION", environ_prefix=None
|
||||
)
|
||||
|
||||
# Recording encoding options for LiveKit Egress (video composite egress only).
|
||||
# These settings affect screen recordings handled by VideoCompositeEgressService;
|
||||
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
|
||||
# recordings), whose request never carries advanced EncodingOptions.
|
||||
# When disabled, LiveKit falls back to its built-in H264_720P_30 preset
|
||||
# (1280x720, 30 fps, 3000 kbps H.264 MAIN video, 128 kbps AAC audio).
|
||||
# When enabled, the values below are passed to LiveKit as EncodingOptions
|
||||
# (advanced) and replace the preset. Lowering framerate and bitrate reduces
|
||||
# output file size and CPU load on the egress worker.
|
||||
RECORDING_ENCODING_ENABLED = values.BooleanValue(
|
||||
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_WIDTH = values.PositiveIntegerValue(
|
||||
1280, environ_name="RECORDING_ENCODING_WIDTH", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_HEIGHT = values.PositiveIntegerValue(
|
||||
720, environ_name="RECORDING_ENCODING_HEIGHT", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_FRAMERATE = values.PositiveIntegerValue(
|
||||
30, environ_name="RECORDING_ENCODING_FRAMERATE", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS = values.PositiveIntegerValue(
|
||||
3000,
|
||||
environ_name="RECORDING_ENCODING_VIDEO_BITRATE_KBPS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
|
||||
128,
|
||||
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S = values.FloatValue(
|
||||
4.0,
|
||||
environ_name="RECORDING_ENCODING_KEY_FRAME_INTERVAL_S",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
|
||||
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
||||
)
|
||||
DOCS_BASE_URL = values.Value(
|
||||
"https://example.com",
|
||||
environ_name="DOCS_BASE_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
DOCS_SERVER_TO_SERVER_API_KEY = SecretFileValue(
|
||||
None,
|
||||
environ_name="DOCS_SERVER_TO_SERVER_API_KEY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
TRANSCRIPTION_DEFAULT_LANGUAGE = values.Value(
|
||||
default="fr", environ_name="TRANSCRIPTION_DEFAULT_LANGUAGE", environ_prefix=None
|
||||
)
|
||||
|
||||
SCREEN_RECORDING_BASE_URL = values.Value(
|
||||
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
|
||||
)
|
||||
@@ -875,11 +817,6 @@ class Base(Configuration):
|
||||
environ_name="METADATA_COLLECTOR_AGENT_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
METADATA_COLLECTOR_OUTPUT_FOLDER = values.Value(
|
||||
"metadata",
|
||||
environ_name="METADATA_COLLECTOR_OUTPUT_FOLDER",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# External Applications
|
||||
APPLICATION_ENABLED = values.BooleanValue(
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.16.0"
|
||||
version = "1.15.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -40,7 +40,7 @@ dependencies = [
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django-pydantic-field==0.5.4",
|
||||
"django==5.2.14",
|
||||
"django==5.2.13",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2026.3.4",
|
||||
@@ -61,8 +61,6 @@ dependencies = [
|
||||
"mozilla-django-oidc==5.0.2",
|
||||
"livekit-api==1.1.0",
|
||||
"aiohttp==3.13.4",
|
||||
"urllib3==2.7.0",
|
||||
"posthog>=7.14.2",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
Generated
+8
-45
@@ -148,15 +148,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backoff"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "billiard"
|
||||
version = "4.2.4"
|
||||
@@ -568,15 +559,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distro"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dj-database-url"
|
||||
version = "3.1.2"
|
||||
@@ -591,16 +573,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.2.14"
|
||||
version = "5.2.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/c5/c69e338eb2959f641045802e5ea87ca4bf5ac90c5fd08953ca10742fad51/django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4", size = 10890368, upload-time = "2026-04-07T14:02:15.072Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/b1/51ab36b2eefcf8cdb9338c7188668a157e29e30306bfc98a379704c9e10d/django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a", size = 8310982, upload-time = "2026-04-07T14:02:08.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1191,7 +1173,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.16.0"
|
||||
version = "1.15.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1222,7 +1204,6 @@ dependencies = [
|
||||
{ name = "markdown" },
|
||||
{ name = "mozilla-django-oidc" },
|
||||
{ name = "nested-multipart-parser" },
|
||||
{ name = "posthog" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt" },
|
||||
@@ -1231,7 +1212,6 @@ dependencies = [
|
||||
{ name = "redis" },
|
||||
{ name = "requests" },
|
||||
{ name = "sentry-sdk" },
|
||||
{ name = "urllib3" },
|
||||
{ name = "whitenoise" },
|
||||
]
|
||||
|
||||
@@ -1263,7 +1243,7 @@ requires-dist = [
|
||||
{ name = "brotli", specifier = "==1.2.0" },
|
||||
{ name = "celery", extras = ["redis"], specifier = "==5.6.2" },
|
||||
{ name = "dj-database-url", specifier = "==3.1.2" },
|
||||
{ name = "django", specifier = "==5.2.14" },
|
||||
{ name = "django", specifier = "==5.2.13" },
|
||||
{ name = "django-configurations", specifier = "==2.5.1" },
|
||||
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
||||
{ name = "django-countries", specifier = "==8.2.0" },
|
||||
@@ -1285,7 +1265,6 @@ requires-dist = [
|
||||
{ name = "markdown", specifier = "==3.10.2" },
|
||||
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
|
||||
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
|
||||
{ name = "posthog", specifier = ">=7.14.2" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.3" },
|
||||
{ name = "pydantic", specifier = "==2.12.5" },
|
||||
{ name = "pyjwt", specifier = "==2.12.1" },
|
||||
@@ -1294,7 +1273,6 @@ requires-dist = [
|
||||
{ name = "redis", specifier = "==5.2.1" },
|
||||
{ name = "requests", specifier = "==2.33.0" },
|
||||
{ name = "sentry-sdk", specifier = "==2.54.0" },
|
||||
{ name = "urllib3", specifier = "==2.7.0" },
|
||||
{ name = "whitenoise", specifier = "==6.12.0" },
|
||||
]
|
||||
|
||||
@@ -1526,21 +1504,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "posthog"
|
||||
version = "7.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backoff" },
|
||||
{ name = "distro" },
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/e6/fe25f9eaeb5b4b66aa738554ba1ef9feece8b1d5b6a9ea431782b6c6c58f/posthog-7.14.2.tar.gz", hash = "sha256:b913dc23acc301a95ca9b851c193b261932d01a66a9af91eb6e9883cd05d5b6b", size = 205633, upload-time = "2026-05-13T16:36:27.153Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/b8/75f9eed446c1d48405871542b36fdf1dba3333756a477bcb0d9ef70d17a4/posthog-7.14.2-py3-none-any.whl", hash = "sha256:f78b45a5ad5c72e55bf1cadebe8cf46bae2a6094e7fe01cbc7b5ec0531aab4d8", size = 240750, upload-time = "2026-05-13T16:36:25.544Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pprintpp"
|
||||
version = "0.4.0"
|
||||
@@ -2297,11 +2260,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
declare module '@fontsource-variable/lexend' {}
|
||||
declare module '@fontsource-variable/atkinson-hyperlegible-next' {}
|
||||
declare module '@fontsource/opendyslexic' {}
|
||||
Generated
+465
-454
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.16.0",
|
||||
"version": "1.15.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -13,11 +13,8 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@livekit/components-react": "2.9.19",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.0",
|
||||
@@ -47,7 +44,7 @@
|
||||
"wouter": "3.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "1.11.1",
|
||||
"@pandacss/dev": "1.8.2",
|
||||
"@tanstack/eslint-plugin-query": "5.91.4",
|
||||
"@tanstack/react-query-devtools": "5.91.3",
|
||||
"@types/humanize-duration": "3.27.4",
|
||||
@@ -62,7 +59,7 @@
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"postcss": "8.5.14",
|
||||
"postcss": "8.5.10",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "7.3.2",
|
||||
|
||||
@@ -14,14 +14,12 @@ import './i18n/init'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation()
|
||||
useTitle(import.meta.env.VITE_APP_TITLE ?? '')
|
||||
|
||||
const isSDKContext = useIsSdkContext()
|
||||
useApplyA11yFonts()
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -12,7 +12,7 @@ const controlBarRegion = cva({
|
||||
variants: {
|
||||
mobile: {
|
||||
true: {
|
||||
justifyContent: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '330px',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { Field, H, Text } from '@/primitives'
|
||||
import { Field, H } from '@/primitives'
|
||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
accessibilityStore,
|
||||
UI_FONT_OPTIONS,
|
||||
type UiFont,
|
||||
} from '@/stores/accessibility'
|
||||
import { accessibilityStore } from '@/stores/accessibility'
|
||||
import { CaptionsSettings } from '@/features/subtitle/component/CaptionsSettings'
|
||||
|
||||
export type AccessibilityTabProps = Pick<TabPanelProps, 'id'>
|
||||
@@ -17,15 +12,6 @@ export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
|
||||
const { t } = useTranslation('settings')
|
||||
const snap = useSnapshot(accessibilityStore)
|
||||
|
||||
const fontItems = useMemo(
|
||||
() =>
|
||||
UI_FONT_OPTIONS.map((font) => ({
|
||||
value: font,
|
||||
label: t(`accessibility.font.options.${font}`),
|
||||
})),
|
||||
[t]
|
||||
)
|
||||
|
||||
return (
|
||||
<TabPanel padding={'md'} flex id={id}>
|
||||
<H lvl={2}>{t('tabs.accessibility')}</H>
|
||||
@@ -47,21 +33,6 @@ export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
|
||||
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Field
|
||||
type="select"
|
||||
label={t('accessibility.font.label')}
|
||||
items={fontItems}
|
||||
selectedKey={snap.uiFont}
|
||||
onSelectionChange={(key) => {
|
||||
accessibilityStore.uiFont = key as UiFont
|
||||
}}
|
||||
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||
/>
|
||||
<Text variant="smNote" className={css({ marginTop: '0.25rem' })}>
|
||||
{t('accessibility.font.description')}
|
||||
</Text>
|
||||
</li>
|
||||
<CaptionsSettings />
|
||||
</ul>
|
||||
</TabPanel>
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { accessibilityStore, UiFont } from '@/stores/accessibility'
|
||||
|
||||
const fontImports: Partial<Record<UiFont, () => Promise<unknown>>> = {
|
||||
lexend: () => import('@fontsource-variable/lexend'),
|
||||
'atkinson-hyperlegible': () =>
|
||||
import('@fontsource-variable/atkinson-hyperlegible-next'),
|
||||
opendyslexic: () => import('@fontsource/opendyslexic'),
|
||||
}
|
||||
|
||||
const loadedFonts = new Set<UiFont>()
|
||||
|
||||
export function useApplyA11yFonts() {
|
||||
const { uiFont } = useSnapshot(accessibilityStore)
|
||||
|
||||
useEffect(() => {
|
||||
if (uiFont === 'default') {
|
||||
return
|
||||
}
|
||||
|
||||
const className = `font-${uiFont}`
|
||||
const loader = fontImports[uiFont]
|
||||
|
||||
if (loader && !loadedFonts.has(uiFont)) {
|
||||
loader().then(() => {
|
||||
loadedFonts.add(uiFont)
|
||||
document.documentElement.classList.add(className)
|
||||
})
|
||||
} else {
|
||||
document.documentElement.classList.add(className)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.documentElement.classList.remove(className)
|
||||
}
|
||||
}, [uiFont])
|
||||
}
|
||||
@@ -117,16 +117,6 @@
|
||||
"announceReactions": {
|
||||
"label": "Reaktionen vorlesen"
|
||||
},
|
||||
"font": {
|
||||
"label": "Anzeigeschrift",
|
||||
"description": "Passe die in der Oberfläche verwendete Schrift für ein besseres Leseerlebnis an.",
|
||||
"options": {
|
||||
"default": "Standard (System)",
|
||||
"lexend": "Lexend (verbesserte Lesbarkeit)",
|
||||
"atkinson-hyperlegible": "Atkinson Hyperlegible (Sehbeeinträchtigung)",
|
||||
"opendyslexic": "OpenDyslexic (Dyslexie)"
|
||||
}
|
||||
},
|
||||
"captions": {
|
||||
"heading": "Untertitel",
|
||||
"textSize": {
|
||||
|
||||
@@ -117,16 +117,6 @@
|
||||
"announceReactions": {
|
||||
"label": "Announce reactions aloud"
|
||||
},
|
||||
"font": {
|
||||
"label": "Display font",
|
||||
"description": "Customize the font used in the interface to improve your reading comfort.",
|
||||
"options": {
|
||||
"default": "Default",
|
||||
"lexend": "Lexend (improved readability)",
|
||||
"atkinson-hyperlegible": "Atkinson Hyperlegible (low vision)",
|
||||
"opendyslexic": "OpenDyslexic (dyslexia)"
|
||||
}
|
||||
},
|
||||
"captions": {
|
||||
"heading": "Captions",
|
||||
"textSize": {
|
||||
|
||||
@@ -117,16 +117,6 @@
|
||||
"announceReactions": {
|
||||
"label": "Vocaliser les réactions"
|
||||
},
|
||||
"font": {
|
||||
"label": "Police d'affichage",
|
||||
"description": "Personnalisez la police utilisée dans l'interface pour améliorer votre confort de lecture.",
|
||||
"options": {
|
||||
"default": "Par défaut",
|
||||
"lexend": "Lexend (lisibilité améliorée)",
|
||||
"atkinson-hyperlegible": "Atkinson Hyperlegible (basse vision)",
|
||||
"opendyslexic": "OpenDyslexic (dyslexie)"
|
||||
}
|
||||
},
|
||||
"captions": {
|
||||
"heading": "Sous-titres",
|
||||
"textSize": {
|
||||
|
||||
@@ -117,16 +117,6 @@
|
||||
"announceReactions": {
|
||||
"label": "Reacties hardop aankondigen"
|
||||
},
|
||||
"font": {
|
||||
"label": "Weergavelettertype",
|
||||
"description": "Pas het lettertype van de interface aan om uw leescomfort te verbeteren.",
|
||||
"options": {
|
||||
"default": "Standaard",
|
||||
"lexend": "Lexend (verbeterde leesbaarheid)",
|
||||
"atkinson-hyperlegible": "Atkinson Hyperlegible (slechtziend)",
|
||||
"opendyslexic": "OpenDyslexic (dyslexie)"
|
||||
}
|
||||
},
|
||||
"captions": {
|
||||
"heading": "Ondertitels",
|
||||
"textSize": {
|
||||
|
||||
@@ -2,19 +2,6 @@ import { proxy, subscribe } from 'valtio'
|
||||
import { STORAGE_KEYS } from '@/utils/storageKeys'
|
||||
import { deserializeToProxyMap } from '@/utils/valtio'
|
||||
|
||||
export type UiFont =
|
||||
| 'default'
|
||||
| 'lexend'
|
||||
| 'atkinson-hyperlegible'
|
||||
| 'opendyslexic'
|
||||
|
||||
export const UI_FONT_OPTIONS: UiFont[] = [
|
||||
'default',
|
||||
'lexend',
|
||||
'atkinson-hyperlegible',
|
||||
'opendyslexic',
|
||||
]
|
||||
|
||||
export type CaptionTextSize = 'small' | 'medium' | 'large'
|
||||
|
||||
export const CAPTION_TEXT_SIZE_OPTIONS: CaptionTextSize[] = [
|
||||
@@ -75,7 +62,6 @@ type AccessibilityState = {
|
||||
captionTextSize: CaptionTextSize
|
||||
captionFontColor: CaptionColor
|
||||
captionBackgroundColor: CaptionColor
|
||||
uiFont: UiFont
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: AccessibilityState = {
|
||||
@@ -83,7 +69,6 @@ const DEFAULT_STATE: AccessibilityState = {
|
||||
captionTextSize: 'medium',
|
||||
captionFontColor: 'default',
|
||||
captionBackgroundColor: 'default',
|
||||
uiFont: 'default',
|
||||
}
|
||||
|
||||
function getAccessibilityState(): AccessibilityState {
|
||||
@@ -106,9 +91,6 @@ function getAccessibilityState(): AccessibilityState {
|
||||
)
|
||||
? parsed.captionBackgroundColor
|
||||
: DEFAULT_STATE.captionBackgroundColor
|
||||
const uiFont = UI_FONT_OPTIONS.includes(parsed.uiFont)
|
||||
? parsed.uiFont
|
||||
: DEFAULT_STATE.uiFont
|
||||
return {
|
||||
...DEFAULT_STATE,
|
||||
...parsed,
|
||||
@@ -119,7 +101,6 @@ function getAccessibilityState(): AccessibilityState {
|
||||
captionTextSize,
|
||||
captionFontColor,
|
||||
captionBackgroundColor,
|
||||
uiFont,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,19 +6,6 @@ body,
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html.font-lexend {
|
||||
--fonts-sans: 'Lexend Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html.font-atkinson-hyperlegible {
|
||||
--fonts-sans:
|
||||
'Atkinson Hyperlegible Next Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html.font-opendyslexic {
|
||||
--fonts-sans: OpenDyslexic, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<mj-text>
|
||||
<p>{% trans "To keep this recording permanently:" %}</p>
|
||||
<ol>
|
||||
<li>{% blocktrans %}Click the "<a href="{{link}}">Open</a>" link below {% endblocktrans %}</li>
|
||||
<li>{% blocktrans %}Click the "Open" button below {% endblocktrans %}</li>
|
||||
<li>{% blocktrans %}Use the "Download" button in the interface {% endblocktrans %}</li>
|
||||
<li>{% blocktrans %}Save the file to your preferred location{% endblocktrans %}</li>
|
||||
</ol>
|
||||
|
||||
Generated
+97
-65
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.16.0",
|
||||
"version": "1.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.16.0",
|
||||
"version": "1.15.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
@@ -14,9 +14,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -24,8 +24,6 @@
|
||||
},
|
||||
"node_modules/@html-to/text-cli": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.5.4.tgz",
|
||||
"integrity": "sha512-V7WDfiYjXcibHGD6q61oW8HD68UPvBVkKit0X+9v54nTmLe8KDCc+56STleqqP7CzuEK5f/1jqa652fnr9Pmsw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@selderee/plugin-htmlparser2": "^0.11.0",
|
||||
@@ -76,8 +74,6 @@
|
||||
},
|
||||
"node_modules/@selderee/plugin-htmlparser2": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
|
||||
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domhandler": "^5.0.3",
|
||||
@@ -144,8 +140,6 @@
|
||||
},
|
||||
"node_modules/aspargvs": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/aspargvs/-/aspargvs-0.6.0.tgz",
|
||||
"integrity": "sha512-yUrWCd1hkK5UtDOne1gM3O+FoTFGQ+BVlSd4G7FczBz8+JaFn1uzvQzROxwp9hmlhIUtwSwyRuV9mHgd/WbXxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"peberminta": "^0.8.0"
|
||||
@@ -158,10 +152,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
@@ -182,12 +179,15 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
@@ -451,8 +451,6 @@
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -466,8 +464,6 @@
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
@@ -480,8 +476,6 @@
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -492,8 +486,6 @@
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
@@ -506,14 +498,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"version": "3.0.1",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
"domhandler": "^5.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
@@ -526,14 +516,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/editorconfig": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
|
||||
"integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==",
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
|
||||
"integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@one-ini/wasm": "0.1.1",
|
||||
"commander": "^10.0.0",
|
||||
"minimatch": "^9.0.1",
|
||||
"minimatch": "9.0.1",
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -543,6 +533,21 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/editorconfig/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/editorconfig/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/editorconfig/node_modules/commander": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
|
||||
@@ -552,6 +557,21 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/editorconfig/node_modules/minimatch": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
|
||||
"integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
@@ -559,9 +579,7 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"version": "4.4.0",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
@@ -707,8 +725,6 @@
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
@@ -862,8 +878,6 @@
|
||||
},
|
||||
"node_modules/leac": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
||||
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
@@ -906,12 +920,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"version": "9.0.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz",
|
||||
"integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -1214,6 +1228,32 @@
|
||||
"lodash": "^4.17.21"
|
||||
}
|
||||
},
|
||||
"node_modules/mjml-parser-xml/node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/mjml-parser-xml/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/mjml-parser-xml/node_modules/htmlparser2": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz",
|
||||
@@ -1472,9 +1512,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/parseley": {
|
||||
"version": "0.12.1",
|
||||
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
|
||||
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
|
||||
"version": "0.12.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"leac": "^0.6.0",
|
||||
@@ -1486,8 +1524,6 @@
|
||||
},
|
||||
"node_modules/parseley/node_modules/peberminta": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
|
||||
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
@@ -1520,17 +1556,15 @@
|
||||
},
|
||||
"node_modules/peberminta": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.8.0.tgz",
|
||||
"integrity": "sha512-YYEs+eauIjDH5nUEGi18EohWE0nV2QbGTqmxQcqgZ/0g+laPCQmuIqq7EBLVi9uim9zMgfJv0QBZEnQ3uHw/Tw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -1577,8 +1611,6 @@
|
||||
},
|
||||
"node_modules/selderee": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
|
||||
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parseley": "^0.12.0"
|
||||
@@ -1588,9 +1620,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -1710,12 +1742,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.16.0",
|
||||
"version": "1.15.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.16.0",
|
||||
"version": "1.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.16.0",
|
||||
"version": "1.15.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.16.0",
|
||||
"version": "1.15.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.16.0"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from summary.api.route import tasks_v2
|
||||
from summary.api.route import tasks, tasks_v2
|
||||
from summary.core.security import verify_tenant_api_key
|
||||
|
||||
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"])
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""API routes related to application tasks."""
|
||||
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from celery.result import AsyncResult
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from summary.core.celery_worker import (
|
||||
process_audio_transcribe_summarize_v2,
|
||||
)
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class TranscribeSummarizeTaskCreation(BaseModel):
|
||||
"""Transcription and summarization parameters."""
|
||||
|
||||
owner_id: str
|
||||
filename: str
|
||||
email: str
|
||||
sub: str
|
||||
version: Optional[int] = 2
|
||||
room: Optional[str]
|
||||
recording_date: Optional[str]
|
||||
recording_time: Optional[str]
|
||||
language: Optional[str]
|
||||
download_link: Optional[str]
|
||||
context_language: Optional[str] = None
|
||||
|
||||
@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
|
||||
|
||||
|
||||
router_tasks_v1 = APIRouter(prefix="/tasks")
|
||||
|
||||
|
||||
@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(
|
||||
args=[
|
||||
request.owner_id,
|
||||
request.filename,
|
||||
request.email,
|
||||
request.sub,
|
||||
time.time(),
|
||||
request.room,
|
||||
request.recording_date,
|
||||
request.recording_time,
|
||||
request.language,
|
||||
request.download_link,
|
||||
request.context_language,
|
||||
],
|
||||
queue=settings.transcribe_queue,
|
||||
)
|
||||
|
||||
return {"id": task.id, "message": "Task created"}
|
||||
|
||||
|
||||
@router_tasks_v1.get("/{task_id}")
|
||||
async def get_task_status(task_id: str):
|
||||
"""Check task status by ID."""
|
||||
task = AsyncResult(task_id)
|
||||
return {"id": task_id, "status": task.status}
|
||||
@@ -112,9 +112,7 @@ class MetadataManager:
|
||||
if self._is_disabled or self.has_task_id(task_id):
|
||||
return
|
||||
|
||||
# Positional args mirror process_audio_transcribe_summarize_v2 signature:
|
||||
# owner_id, recording_filename, metadata_filename, email, sub, received_at, ...
|
||||
_, filename, _, email, _, received_at, *_ = task_args
|
||||
_, filename, email, _, received_at, *_ = task_args
|
||||
|
||||
start_time = time.time()
|
||||
initial_metadata = {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import openai
|
||||
import sentry_sdk
|
||||
@@ -15,8 +16,8 @@ from summary.core.analytics import MetadataManager, get_analytics
|
||||
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 (
|
||||
RecordingMetadata,
|
||||
SummarizeTaskV2Payload,
|
||||
TranscribeTaskV2Payload,
|
||||
)
|
||||
@@ -38,9 +39,10 @@ from summary.core.shared_models import (
|
||||
WhisperXResponse,
|
||||
webhook_payload_adapter,
|
||||
)
|
||||
from summary.core.user_assign import resolve_speaker_identities
|
||||
from summary.core.transcript_formatter import TranscriptFormatter
|
||||
from summary.core.webhook_service import (
|
||||
call_webhook_v2,
|
||||
submit_content,
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
@@ -76,17 +78,23 @@ file_service = FileService()
|
||||
def transcribe_audio(
|
||||
*,
|
||||
task_id: str,
|
||||
filename: str | None = None,
|
||||
language: str,
|
||||
cloud_storage_url: str,
|
||||
cloud_storage_url=None,
|
||||
raises: bool = False,
|
||||
):
|
||||
"""Transcribe an audio file using WhisperX.
|
||||
|
||||
Downloads the audio from a cloud storage URL, sends it to
|
||||
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(),
|
||||
@@ -97,11 +105,11 @@ def transcribe_audio(
|
||||
# Transcription
|
||||
try:
|
||||
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"]})
|
||||
|
||||
# Compute language parameter
|
||||
if language is None:
|
||||
language = settings.whisperx_default_language
|
||||
logger.info(
|
||||
@@ -114,21 +122,18 @@ def transcribe_audio(
|
||||
language,
|
||||
)
|
||||
|
||||
# Call remote service for transcription
|
||||
transcription_start_time = time.time()
|
||||
|
||||
transcription = whisperx_client.audio.transcriptions.create(
|
||||
model=settings.whisperx_asr_model, file=audio_file, language=language
|
||||
)
|
||||
|
||||
# Logging
|
||||
transcription_duration = round(time.time() - transcription_start_time, 2)
|
||||
transcription_time = round(time.time() - transcription_start_time, 2)
|
||||
metadata_manager.track(
|
||||
task_id,
|
||||
{"transcription_time": transcription_duration},
|
||||
)
|
||||
logger.info(
|
||||
"Transcription received in %.2f seconds.", transcription_duration
|
||||
{"transcription_time": transcription_time},
|
||||
)
|
||||
logger.info("Transcription received in %.2f seconds.", transcription_time)
|
||||
logger.debug("Transcription: \n %s", transcription)
|
||||
|
||||
except FileServiceException as e:
|
||||
@@ -140,8 +145,10 @@ def transcribe_audio(
|
||||
)
|
||||
logger.exception(
|
||||
(
|
||||
"Unexpected error while preparing file %s "
|
||||
"Unexpected error while preparing file | filename: %s "
|
||||
"| cloud_storage_url: %s"
|
||||
),
|
||||
filename,
|
||||
redacted_cloud_storage_url,
|
||||
)
|
||||
return None
|
||||
@@ -150,59 +157,38 @@ def transcribe_audio(
|
||||
return transcription
|
||||
|
||||
|
||||
def resolve_speaker_identities_and_apply_to(
|
||||
*, transcription: WhisperXResponse, recording_metadata: RecordingMetadata, task_id
|
||||
) -> WhisperXResponse:
|
||||
"""Assign users to detected speakers and rewrite the transcriptions.
|
||||
def format_transcript(
|
||||
transcription,
|
||||
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.
|
||||
|
||||
Args:
|
||||
transcription: output of meet-whisperx after transcription and diarization
|
||||
recording_metadata: Metadata of the recording
|
||||
task_id: current task id, for logging purposes
|
||||
Resolves the locale from context_language / language, then uses
|
||||
TranscriptFormatter to produce markdown content and a title.
|
||||
|
||||
Returns a (content, title) tuple.
|
||||
"""
|
||||
logger.debug(
|
||||
"recording_start_dt: %s ; recording_end_dt: %s",
|
||||
recording_metadata.start_at,
|
||||
recording_metadata.end_at,
|
||||
locale = get_locale(context_language, language)
|
||||
formatter = TranscriptFormatter(locale)
|
||||
|
||||
return formatter.format(
|
||||
transcription,
|
||||
room=room,
|
||||
recording_date=recording_date,
|
||||
recording_time=recording_time,
|
||||
download_link=download_link,
|
||||
)
|
||||
|
||||
logger.debug("Running resolve_speaker_identities")
|
||||
try:
|
||||
metadata = file_service.read_cloud_storage_json(
|
||||
recording_metadata.cloud_storage_url
|
||||
)
|
||||
speaker_mapping = resolve_speaker_identities(
|
||||
metadata,
|
||||
transcription,
|
||||
recording_metadata.start_at,
|
||||
recording_metadata.end_at,
|
||||
)
|
||||
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
|
||||
return new_transcription
|
||||
|
||||
except FileServiceException as exc:
|
||||
logger.error(
|
||||
"Error reading metadata for task %s; skipping speaker assignment."
|
||||
" Error: %s",
|
||||
task_id,
|
||||
exc,
|
||||
)
|
||||
return transcription
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"resolve_speaker_identities failed for task %s; skipping"
|
||||
" speaker assignment. Error: %s",
|
||||
task_id,
|
||||
exc,
|
||||
)
|
||||
return transcription
|
||||
|
||||
|
||||
def format_actions(llm_output: dict) -> str:
|
||||
"""Format the actions from the LLM output into a markdown list.
|
||||
|
||||
format:
|
||||
fomat:
|
||||
- [ ] Action title Assignée à : assignee1, assignee2, Échéance : due_date
|
||||
"""
|
||||
lines = []
|
||||
@@ -217,23 +203,106 @@ def format_actions(llm_output: dict) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
# @signals.task_prerun.connect(sender=process_audio_transcribe_summarize_v2)
|
||||
# def task_started(task_id=None, task=None, args=None, **kwargs):
|
||||
# """Signal handler called before task execution begins."""
|
||||
# task_args = args or []
|
||||
# metadata_manager.create(task_id, task_args)
|
||||
#
|
||||
#
|
||||
# @signals.task_retry.connect(sender=process_audio_transcribe_summarize_v2)
|
||||
# def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
|
||||
# """Signal handler called when task execution retries."""
|
||||
# metadata_manager.retry(request.id)
|
||||
#
|
||||
#
|
||||
# @signals.task_failure.connect(sender=process_audio_transcribe_summarize_v2)
|
||||
# def task_failure_handler(task_id, exception=None, **kwargs):
|
||||
# """Signal handler called when task execution fails permanently."""
|
||||
# metadata_manager.capture(task_id, settings.posthog_event_failure)
|
||||
@celery.task(
|
||||
bind=True,
|
||||
autoretry_for=[exceptions.HTTPError],
|
||||
max_retries=settings.celery_max_retries,
|
||||
queue=settings.transcribe_queue,
|
||||
)
|
||||
def process_audio_transcribe_summarize_v2(
|
||||
self,
|
||||
owner_id: str,
|
||||
filename: str,
|
||||
email: str,
|
||||
sub: str,
|
||||
received_at: float,
|
||||
room: Optional[str],
|
||||
recording_date: Optional[str],
|
||||
recording_time: Optional[str],
|
||||
language: Optional[str],
|
||||
download_link: Optional[str],
|
||||
context_language: Optional[str] = None,
|
||||
):
|
||||
"""Process an audio file by transcribing it and generating a summary.
|
||||
|
||||
This Celery task orchestrates:
|
||||
1. Audio transcription via WhisperX
|
||||
2. Transcript formatting
|
||||
3. Webhook submission
|
||||
4. Conditional summarization queuing
|
||||
|
||||
Args:
|
||||
self: Celery task instance (passed on with bind=True)
|
||||
owner_id: Unique identifier of the recording owner.
|
||||
filename: Name of the audio file in MinIO storage.
|
||||
email: Email address of the recording owner.
|
||||
sub: OIDC subject identifier of the recording owner.
|
||||
received_at: Unix timestamp when the recording was received.
|
||||
room: room name where the recording took place.
|
||||
recording_date: Date of the recording (localized display string).
|
||||
recording_time: Time of the recording (localized display string).
|
||||
language: ISO 639-1 language code for transcription.
|
||||
download_link: URL to download the original recording.
|
||||
context_language: ISO 639-1 language code of the meeting summary context text.
|
||||
"""
|
||||
logger.info(
|
||||
"Notification received | Owner: %s | Room: %s",
|
||||
owner_id,
|
||||
room,
|
||||
)
|
||||
|
||||
task_id = self.request.id
|
||||
|
||||
transcription = transcribe_audio(
|
||||
task_id=task_id, filename=filename, language=language
|
||||
)
|
||||
if transcription is None:
|
||||
return
|
||||
|
||||
content, title = format_transcript(
|
||||
transcription,
|
||||
context_language,
|
||||
language,
|
||||
room,
|
||||
recording_date,
|
||||
recording_time,
|
||||
download_link,
|
||||
)
|
||||
|
||||
submit_content(content, title, email, sub)
|
||||
metadata_manager.capture(task_id, settings.posthog_event_success)
|
||||
|
||||
# LLM Summarization
|
||||
if (
|
||||
analytics.is_feature_enabled("summary-enabled", distinct_id=owner_id)
|
||||
and settings.is_summary_enabled
|
||||
):
|
||||
logger.info("Queuing summary generation task.")
|
||||
summarize_transcription.apply_async(
|
||||
args=[owner_id, content, email, sub, title],
|
||||
queue=settings.summarize_queue,
|
||||
)
|
||||
else:
|
||||
logger.info("Summary generation not enabled for this user. Skipping.")
|
||||
|
||||
|
||||
@signals.task_prerun.connect(sender=process_audio_transcribe_summarize_v2)
|
||||
def task_started(task_id=None, task=None, args=None, **kwargs):
|
||||
"""Signal handler called before task execution begins."""
|
||||
task_args = args or []
|
||||
metadata_manager.create(task_id, task_args)
|
||||
|
||||
|
||||
@signals.task_retry.connect(sender=process_audio_transcribe_summarize_v2)
|
||||
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
|
||||
"""Signal handler called when task execution retries."""
|
||||
metadata_manager.retry(request.id)
|
||||
|
||||
|
||||
@signals.task_failure.connect(sender=process_audio_transcribe_summarize_v2)
|
||||
def task_failure_handler(task_id, exception=None, **kwargs):
|
||||
"""Signal handler called when task execution fails permanently."""
|
||||
metadata_manager.capture(task_id, settings.posthog_event_failure)
|
||||
|
||||
|
||||
def summarize_transcription_internals(
|
||||
@@ -315,6 +384,29 @@ def summarize_transcription_internals(
|
||||
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. Run summary internals
|
||||
2. 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)
|
||||
|
||||
|
||||
##################################################################################
|
||||
# Tasks v2
|
||||
##################################################################################
|
||||
@@ -373,17 +465,6 @@ def process_audio_transcribe_v2_task(
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
# Assign speakers and rewrite transcription/diarization output
|
||||
if settings.is_resolve_speaker_identities_enabled and payload.metadata is not None:
|
||||
try:
|
||||
transcription_res = resolve_speaker_identities_and_apply_to(
|
||||
transcription=transcription_res,
|
||||
recording_metadata=payload.metadata,
|
||||
task_id=job_id,
|
||||
)
|
||||
except BaseException as e:
|
||||
logger.error(f"Failed to resolve speaker identities, skipping: {e}")
|
||||
|
||||
file_service.store_transcript(
|
||||
transcript=transcription_res,
|
||||
job_id=job_id,
|
||||
@@ -396,8 +477,6 @@ def process_audio_transcribe_v2_task(
|
||||
call_webhook_v2_task.apply_async(
|
||||
args=[success_payload.model_dump(), payload.tenant_id]
|
||||
)
|
||||
metadata_manager.capture(job_id, settings.posthog_event_success)
|
||||
|
||||
return success_payload.model_dump()
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Application configuration and settings."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from functools import cached_property, lru_cache
|
||||
from typing import Annotated, List, Literal, Mapping, Optional, Set
|
||||
from typing import Annotated, Any, List, Literal, Mapping, Optional, Set
|
||||
|
||||
from fastapi import Depends
|
||||
from pydantic import (
|
||||
@@ -35,6 +36,8 @@ class AuthorizedTenant(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
V1_DEFAULT_TENANT_ID = "__deprecated_meet_tenant__"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuration settings loaded from environment variables and .env file."""
|
||||
@@ -42,12 +45,14 @@ class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", frozen=True)
|
||||
|
||||
app_name: str = "summary"
|
||||
app_api_v1_str: str = "/api/v1"
|
||||
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
|
||||
@@ -67,6 +72,8 @@ class Settings(BaseSettings):
|
||||
celery_result_backend: str = "redis://redis/0"
|
||||
celery_max_retries: int = 1
|
||||
|
||||
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"
|
||||
@@ -96,17 +103,14 @@ class Settings(BaseSettings):
|
||||
# Transcription processing
|
||||
hallucination_patterns: List[str] = ["Vap'n'Roll Thierry"]
|
||||
|
||||
# Speaker to user assignment
|
||||
is_resolve_speaker_identities_enabled: bool = True
|
||||
resolve_speaker_identities_default_overlap_threshold: float = 0.5
|
||||
resolve_speaker_identities_enable_split_on_words: bool = True
|
||||
resolve_speaker_identities_max_word_duration: float = 1 # seconds
|
||||
|
||||
# Webhook-related settings
|
||||
webhook_max_retries: int = 2
|
||||
webhook_status_forcelist: List[int] = [502, 503, 504]
|
||||
webhook_backoff_factor: float = 0.1
|
||||
|
||||
# Locale
|
||||
default_context_language: Literal["de", "en", "fr", "nl"] = "fr"
|
||||
|
||||
# Output related settings
|
||||
summary_title_template: Optional[str] = "Résumé de {title}"
|
||||
|
||||
@@ -135,6 +139,33 @@ 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."""
|
||||
@@ -153,6 +184,16 @@ class Settings(BaseSettings):
|
||||
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."""
|
||||
|
||||
@@ -13,6 +13,7 @@ from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from minio import Minio
|
||||
from minio.error import MinioException, S3Error
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.shared_models import WhisperXResponse
|
||||
@@ -22,97 +23,6 @@ settings = get_settings()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_duration_from_packets(local_path: Path) -> float:
|
||||
"""Estimate duration from audio packet timestamps."""
|
||||
# Run ffprobe to inspect the first audio stream in the file.
|
||||
# ffprobe is part of FFmpeg and can output media metadata as JSON.
|
||||
#
|
||||
# ruff: noqa: S607 Hard to know the ffprobe path, it depends on the deployment
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
# Suppress normal ffprobe logging output.
|
||||
"-v",
|
||||
"quiet",
|
||||
# Ask ffprobe to return JSON.
|
||||
"-print_format",
|
||||
"json",
|
||||
# Select only the first audio stream.
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
# Include packet-level information in the output.
|
||||
"-show_packets",
|
||||
# Only include each packet's start timestamp and duration.
|
||||
"-show_entries",
|
||||
"packet=pts_time,duration_time",
|
||||
# Read only the last ~10 packets 99999999 is to go to the end of the file
|
||||
"-read_intervals",
|
||||
"99999999%+#10",
|
||||
# Skip non-reference frames for speed
|
||||
"-skip_frame",
|
||||
"noref",
|
||||
local_path,
|
||||
],
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True, # Decode stdout/stderr as strings instead of bytes.
|
||||
)
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
# Build a list containing the end time of each audio packet.
|
||||
#
|
||||
# For each packet:
|
||||
# end time = packet start time + packet duration
|
||||
#
|
||||
# pts_time is the packet presentation timestamp, meaning when that packet
|
||||
# starts during playback.
|
||||
#
|
||||
# duration_time may be missing, so it defaults to 0.
|
||||
packet_ends = [
|
||||
float(packet["pts_time"]) + float(packet.get("duration_time", 0))
|
||||
for packet in data.get("packets", [])
|
||||
if "pts_time" in packet
|
||||
]
|
||||
|
||||
# If no usable packets were found, the duration cannot be estimated.
|
||||
if not packet_ends:
|
||||
raise ValueError("Unable to determine recording duration.")
|
||||
|
||||
# The recording duration is estimated as the latest packet end time.
|
||||
return max(packet_ends)
|
||||
|
||||
|
||||
def get_media_duration(local_path: Path):
|
||||
"""Get media (audio or video) file duration in seconds."""
|
||||
# ruff: noqa: S607 Hard to know the ffprobe path, it depends on the deployment
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
# Suppress normal ffprobe logging output.
|
||||
"-v",
|
||||
"quiet",
|
||||
# Ask ffprobe to return JSON.
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
local_path,
|
||||
],
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
duration_value = data.get("format", {}).get("duration")
|
||||
|
||||
if duration_value not in (None, "N/A"):
|
||||
return float(duration_value)
|
||||
|
||||
return _get_duration_from_packets(local_path)
|
||||
|
||||
|
||||
class FileServiceException(Exception):
|
||||
"""Base exception for file service operations."""
|
||||
|
||||
@@ -143,6 +53,54 @@ class FileService:
|
||||
self._allowed_extensions = settings.recording_allowed_extensions
|
||||
self._max_duration = settings.recording_max_duration
|
||||
|
||||
def _download_from_minio(self, remote_object_key) -> Path:
|
||||
"""Download file from MinIO to local temporary file.
|
||||
|
||||
The file is downloaded to a temporary location for local manipulation
|
||||
such as validation, conversion, or processing before being used.
|
||||
"""
|
||||
logger.info("Download recording | object_key: %s", remote_object_key)
|
||||
|
||||
if not remote_object_key:
|
||||
logger.warning("Invalid object_key '%s'", remote_object_key)
|
||||
raise ValueError("Invalid object_key")
|
||||
|
||||
extension = Path(remote_object_key).suffix.lower()
|
||||
|
||||
if extension not in self._allowed_extensions:
|
||||
logger.warning("Invalid file extension '%s'", extension)
|
||||
raise ValueError(f"Invalid file extension '{extension}'")
|
||||
|
||||
response = None
|
||||
|
||||
try:
|
||||
response = self._minio_client.get_object(
|
||||
self._bucket_name, remote_object_key
|
||||
)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=extension, delete=False, prefix="minio_download_"
|
||||
) as tmp:
|
||||
for chunk in response.stream(self._stream_chunk_size):
|
||||
tmp.write(chunk)
|
||||
|
||||
tmp.flush()
|
||||
local_path = Path(tmp.name)
|
||||
|
||||
logger.info("Recording successfully downloaded")
|
||||
logger.debug("Recording local file path: %s", local_path)
|
||||
|
||||
return local_path
|
||||
|
||||
except (MinioException, S3Error) as e:
|
||||
raise FileServiceException(
|
||||
"Unexpected error while downloading object."
|
||||
) from e
|
||||
|
||||
finally:
|
||||
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(
|
||||
@@ -197,7 +155,25 @@ class FileService:
|
||||
|
||||
def _validate_duration(self, local_path: Path) -> float:
|
||||
"""Validate audio file duration against configured maximum."""
|
||||
duration = get_media_duration(local_path)
|
||||
# ruff: noqa: S607 Hard to know the ffprobe path, it depends on the deployment
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
local_path,
|
||||
],
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
duration = float(data["format"]["duration"])
|
||||
|
||||
logger.info(
|
||||
"Recording file duration: %.2f seconds",
|
||||
@@ -253,19 +229,11 @@ class FileService:
|
||||
os.remove(output_path)
|
||||
raise RuntimeError("Failed to extract audio.") from e
|
||||
|
||||
def read_cloud_storage_json(self, cloud_storage_url: str) -> dict:
|
||||
"""Read and parse a JSON file from MinIO storage."""
|
||||
logger.info("Reading JSON: %s", cloud_storage_url)
|
||||
local_path = self._download_from_cloud_storage_url(cloud_storage_url)
|
||||
try:
|
||||
return json.load(local_path.open("r"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
raise FileServiceException("Invalid JSON content.") from e
|
||||
|
||||
@contextmanager
|
||||
def prepare_audio_file(
|
||||
self,
|
||||
cloud_storage_url: str,
|
||||
remote_object_key: str | None = None,
|
||||
cloud_storage_url: str | None = None,
|
||||
):
|
||||
"""Download and prepare audio file for processing.
|
||||
|
||||
@@ -278,9 +246,20 @@ class FileService:
|
||||
file_handle = None
|
||||
|
||||
try:
|
||||
downloaded_path = self._download_from_cloud_storage_url(
|
||||
cloud_storage_url
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
+4
-5
@@ -2,10 +2,9 @@
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from core.transcription.locales import de, en, fr, nl
|
||||
from core.transcription.locales.strings import LocaleStrings
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.locales import de, en, fr, nl
|
||||
from summary.core.locales.strings import LocaleStrings
|
||||
|
||||
_LOCALES = {"fr": fr, "en": en, "de": de, "nl": nl}
|
||||
|
||||
@@ -28,4 +27,4 @@ def get_locale(*languages: Optional[str]) -> LocaleStrings:
|
||||
if base_lang in _LOCALES:
|
||||
return _LOCALES[base_lang].STRINGS
|
||||
|
||||
return _LOCALES[settings.TRANSCRIPTION_DEFAULT_LANGUAGE].STRINGS
|
||||
return _LOCALES[get_settings().default_context_language].STRINGS
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
"""German locale strings."""
|
||||
|
||||
from core.transcription.locales.strings import LocaleStrings
|
||||
from summary.core.locales.strings import LocaleStrings
|
||||
|
||||
STRINGS = LocaleStrings(
|
||||
empty_transcription="""
|
||||
@@ -30,5 +30,4 @@ Einige Punkte, die wir Ihnen empfehlen zu überprüfen:
|
||||
document_title_template=(
|
||||
'Besprechung "{room}" am {room_recording_date} um {room_recording_time}'
|
||||
),
|
||||
summary_title_template="Zusammenfassung von {title}",
|
||||
)
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
"""English locale strings."""
|
||||
|
||||
from core.transcription.locales.strings import LocaleStrings
|
||||
from summary.core.locales.strings import LocaleStrings
|
||||
|
||||
STRINGS = LocaleStrings(
|
||||
empty_transcription="""
|
||||
@@ -30,5 +30,4 @@ A few things we recommend you check:
|
||||
document_title_template=(
|
||||
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
|
||||
),
|
||||
summary_title_template="Summary of {title}",
|
||||
)
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
"""French locale strings (default)."""
|
||||
|
||||
from core.transcription.locales.strings import LocaleStrings
|
||||
from summary.core.locales.strings import LocaleStrings
|
||||
|
||||
STRINGS = LocaleStrings(
|
||||
empty_transcription="""
|
||||
@@ -30,5 +30,4 @@ Quelques points que nous vous conseillons de vérifier :
|
||||
document_title_template=(
|
||||
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
|
||||
),
|
||||
summary_title_template="Résumé de {title}",
|
||||
)
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
"""Dutch locale strings."""
|
||||
|
||||
from core.transcription.locales.strings import LocaleStrings
|
||||
from summary.core.locales.strings import LocaleStrings
|
||||
|
||||
STRINGS = LocaleStrings(
|
||||
empty_transcription="""
|
||||
@@ -30,5 +30,4 @@ Een paar punten die wij u aanraden te controleren:
|
||||
document_title_template=(
|
||||
'Vergadering "{room}" op {room_recording_date} om {room_recording_time}'
|
||||
),
|
||||
summary_title_template="Samenvatting van {title}",
|
||||
)
|
||||
-1
@@ -13,4 +13,3 @@ class LocaleStrings:
|
||||
hallucination_replacement_text: str
|
||||
document_default_title: str
|
||||
document_title_template: str
|
||||
summary_title_template: str
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Models for the API & Celery tasks creation."""
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.types import Url
|
||||
@@ -14,17 +14,6 @@ class SharedV2TaskCreation(BaseModel):
|
||||
user_sub: str = Field(title="User Sub", description="The user's sub.")
|
||||
|
||||
|
||||
class RecordingMetadata(BaseModel):
|
||||
"""Model for recording metadata."""
|
||||
|
||||
cloud_storage_url: Url = Field(
|
||||
title="Cloud Storage URL",
|
||||
description="The URL of the metadata file for speaker assignement.",
|
||||
)
|
||||
start_at: AwareDatetime = Field(title="Start time of the recording to transcribe")
|
||||
end_at: AwareDatetime = Field(title="End time of the recording to transcribe")
|
||||
|
||||
|
||||
class TranscribeTaskV2Request(SharedV2TaskCreation):
|
||||
"""Model for creating a transcribe and summarize task (used for API request)."""
|
||||
|
||||
@@ -38,12 +27,7 @@ class TranscribeTaskV2Request(SharedV2TaskCreation):
|
||||
description="The language of the context text.",
|
||||
)
|
||||
language: str = Field(
|
||||
title="Language", description="The language of the content to transcribe."
|
||||
)
|
||||
metadata: RecordingMetadata | None = Field(
|
||||
title="Metadata",
|
||||
description="The metadata for the transcribe task.",
|
||||
default=None,
|
||||
title="Language", description="The language of the content to summarize."
|
||||
)
|
||||
|
||||
@field_validator("language")
|
||||
|
||||
@@ -159,5 +159,4 @@ __all__ = [
|
||||
"SummarizeWebhookPayloads",
|
||||
"WebhookPayloads",
|
||||
"WhisperXResponse",
|
||||
"webhook_payload_adapter",
|
||||
]
|
||||
|
||||
+17
-22
@@ -1,13 +1,12 @@
|
||||
"""Transcript formatting into readable conversation format with speaker labels."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from django.conf import settings
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.locales import LocaleStrings
|
||||
|
||||
from core.transcription.locales.strings import LocaleStrings
|
||||
settings = get_settings()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,13 +36,13 @@ class TranscriptFormatter:
|
||||
|
||||
return None
|
||||
|
||||
def format( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
def format(
|
||||
self,
|
||||
transcription,
|
||||
room: str | None = None,
|
||||
recording_datetime: datetime | None = None,
|
||||
owner_timezone: str | None = None,
|
||||
download_link: str | None = None,
|
||||
room: Optional[str] = None,
|
||||
recording_date: Optional[str] = None,
|
||||
recording_time: Optional[str] = None,
|
||||
download_link: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""Format transcription into the final document and its title."""
|
||||
segments = self._get_segments(transcription)
|
||||
@@ -55,7 +54,7 @@ class TranscriptFormatter:
|
||||
content = self._remove_hallucinations(content)
|
||||
content = self._add_header(content, download_link)
|
||||
|
||||
title = self._generate_title(room, recording_datetime, owner_timezone)
|
||||
title = self._generate_title(room, recording_date, recording_time)
|
||||
|
||||
return content, title
|
||||
|
||||
@@ -84,7 +83,7 @@ class TranscriptFormatter:
|
||||
|
||||
return formatted_output
|
||||
|
||||
def _add_header(self, content, download_link: str | None) -> str:
|
||||
def _add_header(self, content, download_link: Optional[str]) -> str:
|
||||
"""Add download link header to the document content."""
|
||||
if not download_link:
|
||||
return content
|
||||
@@ -98,20 +97,16 @@ class TranscriptFormatter:
|
||||
|
||||
def _generate_title(
|
||||
self,
|
||||
room: str | None = None,
|
||||
recording_datetime: datetime | None = None,
|
||||
owner_timezone: str | None = None,
|
||||
room: Optional[str] = None,
|
||||
recording_date: Optional[str] = None,
|
||||
recording_time: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Generate title from context or return default."""
|
||||
if not room or not recording_datetime:
|
||||
if not room or not recording_date or not recording_time:
|
||||
return self._locale.document_default_title
|
||||
|
||||
dt = recording_datetime
|
||||
if owner_timezone:
|
||||
dt = dt.astimezone(ZoneInfo(owner_timezone))
|
||||
|
||||
return self._locale.document_title_template.format(
|
||||
room=room,
|
||||
room_recording_date=dt.strftime("%Y-%m-%d"),
|
||||
room_recording_time=dt.strftime("%H:%M"),
|
||||
room_recording_date=recording_date,
|
||||
room_recording_time=recording_time,
|
||||
)
|
||||
@@ -1,400 +0,0 @@
|
||||
"""Assign WhisperX diarization speakers to participant identities.
|
||||
|
||||
Uses per-stream VAD events to match generic SPEAKER_XX labels provided
|
||||
by diarization to real user id's by computing time overlap between
|
||||
diarization segments and VAD intervals.
|
||||
|
||||
Multiple speakers can map to the same participant (e.g. two people sharing
|
||||
one microphone). A participant with no matching speaker gets no assignment.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Interval:
|
||||
"""A time interval in seconds relative to recording start."""
|
||||
|
||||
start: float
|
||||
end: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpeakerAssignment:
|
||||
"""Maps a diarization speaker label to a participant."""
|
||||
|
||||
speaker_label: str
|
||||
participant_id: str
|
||||
participant_name: str
|
||||
score: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssignmentResult:
|
||||
"""Result of speaker-to-participant assignment."""
|
||||
|
||||
assignments: list[SpeakerAssignment] = field(default_factory=list)
|
||||
unassigned_speakers: list[str] = field(default_factory=list)
|
||||
|
||||
def apply_to(self, diarization: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a copy of diarization with speaker labels replaced by names.
|
||||
|
||||
Replaces `"speaker"` fields in segments and word_segments with the
|
||||
assigned participant name. Unassigned speakers are left as-is.
|
||||
|
||||
Args:
|
||||
diarization: WhisperX dict with `segments` and optionally
|
||||
`word_segments`.
|
||||
|
||||
Returns:
|
||||
New dict with speaker labels replaced.
|
||||
"""
|
||||
speaker_to_name = {
|
||||
a.speaker_label: a.participant_name for a in self.assignments
|
||||
}
|
||||
|
||||
name_to_speaker_count = defaultdict(int)
|
||||
for name in speaker_to_name.values():
|
||||
name_to_speaker_count[name] += 1
|
||||
|
||||
def _replace_speaker(item: dict[str, Any]) -> dict[str, Any]:
|
||||
if "speaker" in item and item["speaker"] in speaker_to_name:
|
||||
name = speaker_to_name[item["speaker"]]
|
||||
suffix = (
|
||||
f" ({item['speaker']})" if name_to_speaker_count[name] > 1 else ""
|
||||
) # Add suffix only if there are multiple detected speakers per user
|
||||
return {**item, "speaker": f"{name}{suffix}"}
|
||||
return {**item}
|
||||
|
||||
def _process_segment(
|
||||
item: dict[str, Any], include_words: bool = False
|
||||
) -> dict[str, Any]:
|
||||
new_item = _replace_speaker(item)
|
||||
if include_words and "words" in item:
|
||||
new_item["words"] = [_replace_speaker(w) for w in item["words"]]
|
||||
return new_item
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in diarization.items():
|
||||
if key not in ("segments", "word_segments"):
|
||||
result[key] = value
|
||||
continue
|
||||
result[key] = [
|
||||
_process_segment(item, include_words=(key == "segments"))
|
||||
for item in value
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def _merge_intervals(intervals: list[Interval]) -> list[Interval]:
|
||||
"""Return a list of non-overlapping intervals sorted by start time."""
|
||||
if not intervals:
|
||||
return []
|
||||
sorted_intervals = sorted(intervals, key=lambda interval: interval.start)
|
||||
merged: list[Interval] = [
|
||||
Interval(sorted_intervals[0].start, sorted_intervals[0].end)
|
||||
]
|
||||
for interval in sorted_intervals[1:]:
|
||||
if interval.start <= merged[-1].end:
|
||||
merged[-1].end = max(merged[-1].end, interval.end)
|
||||
else:
|
||||
merged.append(Interval(interval.start, interval.end))
|
||||
return merged
|
||||
|
||||
|
||||
def _total_duration(intervals: list[Interval]) -> float:
|
||||
"""Return the sum of all interval durations."""
|
||||
return sum(interval.end - interval.start for interval in intervals)
|
||||
|
||||
|
||||
def _overlap_duration(
|
||||
a_intervals: list[Interval],
|
||||
b_intervals: list[Interval],
|
||||
) -> float:
|
||||
"""Compute total overlap between two merged interval lists, sorted by start time."""
|
||||
overlap = 0.0
|
||||
i = j = 0
|
||||
while i < len(a_intervals) and j < len(b_intervals):
|
||||
a = a_intervals[i]
|
||||
b = b_intervals[j]
|
||||
lo = max(a.start, b.start)
|
||||
hi = min(a.end, b.end)
|
||||
if lo < hi:
|
||||
overlap += hi - lo
|
||||
if a.end <= b.end:
|
||||
i += 1
|
||||
else:
|
||||
j += 1
|
||||
return overlap
|
||||
|
||||
|
||||
def _format_timelines_debug(
|
||||
participant_timelines: dict[str, list[Interval]],
|
||||
participant_names: dict[str, str],
|
||||
speaker_timelines: dict[str, list[Interval]],
|
||||
) -> str:
|
||||
"""Render participant and speaker timelines side-by-side for debugging.
|
||||
|
||||
Each row is the slice between two consecutive interval boundaries
|
||||
(drawn from both sides). A filled cell marks an active participant
|
||||
(left block) or speaker (right block) during that slice, so vertical
|
||||
alignment makes overlap visually obvious.
|
||||
"""
|
||||
participant_ids = sorted(participant_timelines.keys())
|
||||
speaker_labels = sorted(speaker_timelines.keys())
|
||||
|
||||
if not participant_ids and not speaker_labels:
|
||||
return "(no timelines)"
|
||||
|
||||
boundaries: set[float] = set()
|
||||
for intervals in (*participant_timelines.values(), *speaker_timelines.values()):
|
||||
for iv in intervals:
|
||||
boundaries.add(iv.start)
|
||||
boundaries.add(iv.end)
|
||||
sorted_boundaries = sorted(boundaries)
|
||||
if len(sorted_boundaries) < 2:
|
||||
return "(no intervals)"
|
||||
|
||||
p_headers = [participant_names.get(pid, pid) for pid in participant_ids]
|
||||
s_headers = list(speaker_labels)
|
||||
p_widths = [max(len(h), 3) for h in p_headers]
|
||||
s_widths = [max(len(h), 3) for h in s_headers]
|
||||
|
||||
def _active(intervals: list[Interval], lo: float, hi: float) -> bool:
|
||||
mid = (lo + hi) / 2
|
||||
return any(iv.start <= mid < iv.end for iv in intervals)
|
||||
|
||||
def _cells(
|
||||
intervals_list: list[list[Interval]],
|
||||
widths: list[int],
|
||||
lo: float,
|
||||
hi: float,
|
||||
) -> str:
|
||||
return " ".join(
|
||||
("█" * w if _active(iv, lo, hi) else "·" * w)
|
||||
for iv, w in zip(intervals_list, widths, strict=True)
|
||||
)
|
||||
|
||||
p_iv_list = [participant_timelines[pid] for pid in participant_ids]
|
||||
s_iv_list = [speaker_timelines[sl] for sl in speaker_labels]
|
||||
|
||||
time_col = "[ start → end]"
|
||||
p_hdr = (
|
||||
" ".join(h.center(w) for h, w in zip(p_headers, p_widths, strict=True))
|
||||
or "(none)"
|
||||
)
|
||||
s_hdr = (
|
||||
" ".join(h.center(w) for h, w in zip(s_headers, s_widths, strict=True))
|
||||
or "(none)"
|
||||
)
|
||||
sep = " || "
|
||||
lines = [
|
||||
f"{time_col} {p_hdr}{sep}{s_hdr}",
|
||||
"-" * (len(time_col) + 2 + len(p_hdr) + len(sep) + len(s_hdr)),
|
||||
]
|
||||
for lo, hi in zip(sorted_boundaries, sorted_boundaries[1:], strict=False):
|
||||
time_str = f"[{lo:8.2f} → {hi:8.2f}]"
|
||||
p_row = _cells(p_iv_list, p_widths, lo, hi) or " " * len(p_hdr)
|
||||
s_row = _cells(s_iv_list, s_widths, lo, hi) or " " * len(s_hdr)
|
||||
lines.append(f"{time_str} {p_row}{sep}{s_row}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_participant_timelines(
|
||||
metadata: dict[str, Any],
|
||||
recording_start_datetime: datetime,
|
||||
recording_end_datetime: datetime | None = None,
|
||||
) -> tuple[dict[str, list[Interval]], dict[str, str]]:
|
||||
"""Build VAD interval timelines for each participant.
|
||||
|
||||
Args:
|
||||
metadata: Dict with `events` and `participants` keys.
|
||||
recording_start_datetime: UTC datetime used as t=0 reference.
|
||||
recording_end_datetime: UTC datetime of recording end. When provided,
|
||||
any open speech_start without a matching speech_end is closed at
|
||||
this time (the participant is assumed to be speaking until the end).
|
||||
|
||||
Returns:
|
||||
participant_id → merged VAD intervals
|
||||
(seconds relative to recording_start_datetime).
|
||||
participant_id → display name.
|
||||
Intervals are in seconds relative to recording_start_datetime.
|
||||
Events before recording start are clamped to 0.
|
||||
"""
|
||||
events = metadata.get("events", [])
|
||||
participants_info = {
|
||||
p["participantId"]: p.get("name", p["participantId"])
|
||||
for p in metadata.get("participants", [])
|
||||
}
|
||||
|
||||
ref_epoch = recording_start_datetime.timestamp()
|
||||
|
||||
open_starts: dict[str, float] = {}
|
||||
intervals: dict[str, list[Interval]] = {}
|
||||
|
||||
for event in events:
|
||||
pid = event["participant_id"]
|
||||
ts = datetime.fromisoformat(event["timestamp"]).timestamp() - ref_epoch
|
||||
etype = event["type"]
|
||||
|
||||
if etype == "speech_start":
|
||||
open_starts[pid] = max(ts, 0.0)
|
||||
elif etype == "speech_end":
|
||||
start = open_starts.pop(pid, None)
|
||||
if start is not None:
|
||||
end = max(ts, 0.0)
|
||||
if end > start:
|
||||
intervals.setdefault(pid, []).append(Interval(start, end))
|
||||
|
||||
# Close any speech_start that was never matched by a speech_end.
|
||||
# Assume the participant kept speaking until the recording ended.
|
||||
if recording_end_datetime is not None and open_starts:
|
||||
recording_end = recording_end_datetime.timestamp() - ref_epoch
|
||||
for pid, start in open_starts.items():
|
||||
end = max(recording_end, 0.0)
|
||||
if end > start:
|
||||
intervals.setdefault(pid, []).append(Interval(start, end))
|
||||
|
||||
for pid, pid_intervals in intervals.items():
|
||||
intervals[pid] = _merge_intervals(pid_intervals)
|
||||
|
||||
return intervals, participants_info
|
||||
|
||||
|
||||
def _build_speaker_timelines(transcription: Any) -> dict[str, list[Interval]]:
|
||||
"""Build interval timelines from WhisperX transcription segments."""
|
||||
intervals: dict[str, list[Interval]] = {}
|
||||
segments = transcription.segments if hasattr(transcription, "segments") else []
|
||||
max_word_duration = settings.resolve_speaker_identities_max_word_duration
|
||||
|
||||
for segment in segments:
|
||||
speaker = segment.get("speaker")
|
||||
if speaker is None:
|
||||
continue
|
||||
|
||||
words = [
|
||||
w
|
||||
for w in segment.get("words", [])
|
||||
if w.get("start") is not None and w.get("end") is not None
|
||||
]
|
||||
if not words:
|
||||
intervals.setdefault(speaker, []).append(
|
||||
Interval(segment["start"], segment["end"])
|
||||
)
|
||||
continue
|
||||
|
||||
start_time: float | None = segment["start"]
|
||||
for word in words:
|
||||
if start_time is None:
|
||||
start_time = word["start"]
|
||||
if not settings.resolve_speaker_identities_enable_split_on_words:
|
||||
continue
|
||||
if word["end"] - word["start"] > max_word_duration:
|
||||
end_time = word["start"] + max_word_duration
|
||||
if end_time > start_time:
|
||||
intervals.setdefault(speaker, []).append(
|
||||
Interval(start_time, end_time)
|
||||
)
|
||||
start_time = None
|
||||
|
||||
if start_time is not None:
|
||||
last = words[-1]
|
||||
end_time = min(last["end"], last["start"] + max_word_duration)
|
||||
if end_time > start_time:
|
||||
intervals.setdefault(speaker, []).append(Interval(start_time, end_time))
|
||||
|
||||
for speaker, speaker_intervals in intervals.items():
|
||||
intervals[speaker] = _merge_intervals(speaker_intervals)
|
||||
return intervals
|
||||
|
||||
|
||||
def resolve_speaker_identities(
|
||||
metadata: dict[str, Any],
|
||||
transcription: Any,
|
||||
recording_start_datetime: datetime,
|
||||
recording_end_datetime: datetime,
|
||||
overlap_threshold: float = settings.resolve_speaker_identities_default_overlap_threshold, # noqa: E501
|
||||
) -> AssignmentResult:
|
||||
"""Match WhisperX speaker labels to participants.
|
||||
|
||||
Args:
|
||||
metadata: User metadata with `events` and `participants`.
|
||||
transcription: WhisperX Transcription object with a `segments` attribute.
|
||||
recording_start_datetime: UTC datetime for t=0 reference.
|
||||
recording_end_datetime: UTC datetime of recording end. Open speech
|
||||
intervals are closed at this time.
|
||||
overlap_threshold: Minimum overlap/speaker_duration to accept.
|
||||
|
||||
Returns:
|
||||
AssignmentResult with per-speaker assignments and unassigned
|
||||
speakers.
|
||||
"""
|
||||
participant_timelines, participant_names = _build_participant_timelines(
|
||||
metadata, recording_start_datetime, recording_end_datetime
|
||||
)
|
||||
speaker_timelines = _build_speaker_timelines(transcription)
|
||||
|
||||
logger.debug(
|
||||
"Assignment inputs: %d participants, %d speakers\n%s\n%s\n%s",
|
||||
len(participant_timelines),
|
||||
len(speaker_timelines),
|
||||
participant_timelines,
|
||||
speaker_timelines,
|
||||
_format_timelines_debug(
|
||||
participant_timelines, participant_names, speaker_timelines
|
||||
),
|
||||
)
|
||||
|
||||
result = AssignmentResult()
|
||||
|
||||
for speaker, speaker_intervals in speaker_timelines.items():
|
||||
speaker_duration = _total_duration(speaker_intervals)
|
||||
if speaker_duration == 0:
|
||||
result.unassigned_speakers.append(speaker)
|
||||
continue
|
||||
|
||||
best_pid: str | None = None
|
||||
best_score: float = 0.0
|
||||
|
||||
for pid, part_intervals in participant_timelines.items():
|
||||
overlap = _overlap_duration(speaker_intervals, part_intervals)
|
||||
score = overlap / speaker_duration
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_pid = pid
|
||||
|
||||
if best_pid is not None and best_score >= overlap_threshold:
|
||||
result.assignments.append(
|
||||
SpeakerAssignment(
|
||||
speaker_label=speaker,
|
||||
participant_id=best_pid,
|
||||
participant_name=participant_names.get(best_pid, best_pid),
|
||||
score=best_score,
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"Assigned %s -> %s (score=%.3f)",
|
||||
speaker,
|
||||
participant_names.get(best_pid, best_pid),
|
||||
best_score,
|
||||
)
|
||||
else:
|
||||
result.unassigned_speakers.append(speaker)
|
||||
logger.info(
|
||||
"Speaker %s unassigned (best=%.3f, threshold=%.3f)",
|
||||
speaker,
|
||||
best_score,
|
||||
overlap_threshold,
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -46,6 +46,55 @@ def _post_with_retries(*, url, data, api_key: str | None = None):
|
||||
session.close()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
logger.debug("Submitting to %s", tenant.webhook_url)
|
||||
logger.debug("Request payload: %s", json.dumps(payload, indent=2))
|
||||
|
||||
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,
|
||||
|
||||
@@ -4,7 +4,7 @@ import sentry_sdk
|
||||
from fastapi import FastAPI
|
||||
|
||||
from summary.api import health
|
||||
from summary.api.main import api_router_v2
|
||||
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_v1, prefix=settings.app_api_v1_str)
|
||||
app.include_router(api_router_v2, prefix=settings.app_api_v2_str)
|
||||
app.include_router(health.router)
|
||||
|
||||
@@ -19,14 +19,14 @@ class TestTasks:
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
json={
|
||||
"owner_id": "owner-123",
|
||||
"recording_filename": "recording.mp4",
|
||||
"metadata_filename": "metadata.json",
|
||||
"filename": "recording.mp4",
|
||||
"email": "user@example.com",
|
||||
"sub": "sub-123",
|
||||
"room": "room-abc",
|
||||
"owner_timezone": "UTC",
|
||||
"recording_date": "2026-01-01",
|
||||
"recording_time": "10:00:00",
|
||||
"language": None,
|
||||
"download_link": "https://example.com/file.mp4",
|
||||
"download_link": "http://example.com/file.mp4",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -36,18 +36,16 @@ class TestTasks:
|
||||
args = mock_apply_async.call_args.kwargs["args"]
|
||||
assert args == [
|
||||
"owner-123", # owner_id
|
||||
"recording.mp4", # recording_filename
|
||||
"metadata.json", # metadata_filename
|
||||
"recording.mp4", # filename
|
||||
"user@example.com", # email
|
||||
"sub-123", # sub
|
||||
1735725600.0, # received_at
|
||||
1735725600.0, # frozen time
|
||||
"room-abc", # room
|
||||
"UTC", # owner_timezone
|
||||
"2026-01-01", # recording_date
|
||||
"10:00:00", # recording_time
|
||||
None, # language
|
||||
"https://example.com/file.mp4", # download_link
|
||||
"http://example.com/file.mp4", # download_link
|
||||
None, # context_language
|
||||
None, # recording_start_at
|
||||
None, # recording_end_at
|
||||
]
|
||||
|
||||
def test_create_task_invalid_language(self, client):
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,30 +0,0 @@
|
||||
"""Unit tests for the file service."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from summary.core.file_service import get_media_duration
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filename, duration",
|
||||
[
|
||||
("audio-sample-android-chrome.webm", 2.2795),
|
||||
("audio-sample-android-firefox.ogg", 2.3025),
|
||||
("audio-sample-android.m4a", 1.38),
|
||||
("audio-sample-chromium.webm", 2.65),
|
||||
("audio-sample-firefox.ogg", 2.0865),
|
||||
("audio-sample-ios-browser.webm", 2.6229),
|
||||
("audio-sample-ios.m4a", 1.408),
|
||||
("audio-sample-mac-os-safari.webm", 2.3049),
|
||||
("video-sample-visio.mp4", 5.34059),
|
||||
],
|
||||
)
|
||||
def test_validate_duration_supports_all_used_file_formats(
|
||||
filename: str, duration: float
|
||||
) -> None:
|
||||
"""Validate duration for Safari iPhone WebM files without format duration."""
|
||||
audio_path = Path(__file__).parent.parent / "assets" / filename
|
||||
|
||||
assert get_media_duration(audio_path) == pytest.approx(duration, 1e-3)
|
||||
@@ -1,708 +0,0 @@
|
||||
"""Tests for the speaker-to-user assignment service."""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from summary.core import user_assign
|
||||
from summary.core.user_assign import (
|
||||
AssignmentResult,
|
||||
Interval,
|
||||
SpeakerAssignment,
|
||||
_build_speaker_timelines,
|
||||
_merge_intervals,
|
||||
_overlap_duration,
|
||||
_total_duration,
|
||||
resolve_speaker_identities,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeTranscription:
|
||||
"""Mimics the OpenAI Transcription pydantic model for testing."""
|
||||
|
||||
segments: list = field(default_factory=list)
|
||||
|
||||
|
||||
RECORDING_START = datetime.fromisoformat("2026-03-17T15:30:33.000001")
|
||||
RECORDING_END = datetime.fromisoformat("2026-03-17T15:31:33.000001")
|
||||
|
||||
METADATA_SINGLE_USER = {
|
||||
"events": [
|
||||
{
|
||||
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
|
||||
"type": "participant_connected",
|
||||
"timestamp": "2026-03-17T15:30:33.000001",
|
||||
},
|
||||
{
|
||||
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
|
||||
"type": "speech_start",
|
||||
"timestamp": "2026-03-17T15:30:36.039456",
|
||||
},
|
||||
{
|
||||
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
|
||||
"type": "speech_end",
|
||||
"timestamp": "2026-03-17T15:30:36.589114",
|
||||
},
|
||||
{
|
||||
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
|
||||
"type": "speech_start",
|
||||
"timestamp": "2026-03-17T15:30:38.887518",
|
||||
},
|
||||
{
|
||||
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
|
||||
"type": "speech_end",
|
||||
"timestamp": "2026-03-17T15:30:39.438141",
|
||||
},
|
||||
{
|
||||
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
|
||||
"type": "participant_disconnected",
|
||||
"timestamp": "2026-03-17T15:30:43.223255",
|
||||
},
|
||||
],
|
||||
"participants": [
|
||||
{
|
||||
"participantId": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
|
||||
"name": "cameledev",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
DIARIZATION_SINGLE_SPEAKER = FakeTranscription(
|
||||
segments=[
|
||||
{
|
||||
"start": 1.363,
|
||||
"end": 3.545,
|
||||
"text": " The stale smell.",
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [
|
||||
{"word": "The", "start": 1.363, "end": 1.8},
|
||||
{"word": "stale", "start": 1.8, "end": 2.7},
|
||||
{"word": "smell.", "start": 2.7, "end": 3.545},
|
||||
],
|
||||
},
|
||||
{
|
||||
"start": 4.466,
|
||||
"end": 6.247,
|
||||
"text": "It takes heat.",
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [
|
||||
{"word": "It", "start": 4.466, "end": 4.7},
|
||||
{"word": "takes", "start": 4.7, "end": 5.5},
|
||||
{"word": "heat.", "start": 5.5, "end": 6.247},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
USER_ID = "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb"
|
||||
|
||||
|
||||
class TestMergeIntervals:
|
||||
"""Tests for _merge_intervals."""
|
||||
|
||||
def test_empty(self):
|
||||
"""Empty input returns empty list."""
|
||||
assert _merge_intervals([]) == []
|
||||
|
||||
def test_no_overlap(self):
|
||||
"""Non-overlapping intervals stay separate."""
|
||||
result = _merge_intervals([Interval(1, 2), Interval(3, 4)])
|
||||
assert len(result) == 2
|
||||
|
||||
def test_overlap(self):
|
||||
"""Overlapping intervals are merged."""
|
||||
result = _merge_intervals([Interval(1, 3), Interval(2, 4)])
|
||||
assert len(result) == 1
|
||||
assert result[0].start == 1
|
||||
assert result[0].end == 4
|
||||
|
||||
def test_adjacent(self):
|
||||
"""Adjacent intervals are merged."""
|
||||
result = _merge_intervals([Interval(1, 2), Interval(2, 3)])
|
||||
assert len(result) == 1
|
||||
assert result[0].start == 1
|
||||
assert result[0].end == 3
|
||||
|
||||
def test_unsorted(self):
|
||||
"""Unsorted input is sorted before merging."""
|
||||
result = _merge_intervals([Interval(5, 6), Interval(1, 2)])
|
||||
assert len(result) == 2
|
||||
assert result[0].start == 1
|
||||
|
||||
|
||||
class TestOverlapDuration:
|
||||
"""Tests for _overlap_duration."""
|
||||
|
||||
def test_no_overlap(self):
|
||||
"""Disjoint intervals have zero overlap."""
|
||||
a = [Interval(1, 2)]
|
||||
b = [Interval(3, 4)]
|
||||
assert math.isclose(_overlap_duration(a, b), 0.0)
|
||||
|
||||
def test_full_overlap(self):
|
||||
"""Identical intervals have full overlap."""
|
||||
a = [Interval(1, 3)]
|
||||
b = [Interval(1, 3)]
|
||||
assert math.isclose(_overlap_duration(a, b), 2.0)
|
||||
|
||||
def test_partial_overlap(self):
|
||||
"""Partially overlapping intervals."""
|
||||
a = [Interval(1, 3)]
|
||||
b = [Interval(2, 4)]
|
||||
assert math.isclose(_overlap_duration(a, b), 1.0)
|
||||
|
||||
def test_multiple_intervals(self):
|
||||
"""Multiple intervals with a spanning interval."""
|
||||
a = [Interval(1, 3), Interval(5, 7)]
|
||||
b = [Interval(2, 6)]
|
||||
assert math.isclose(_overlap_duration(a, b), 2.0)
|
||||
|
||||
def test_empty(self):
|
||||
"""Empty input yields zero overlap."""
|
||||
assert math.isclose(_overlap_duration([], [Interval(1, 2)]), 0.0)
|
||||
assert math.isclose(_overlap_duration([Interval(1, 2)], []), 0.0)
|
||||
|
||||
|
||||
class TestTotalDuration:
|
||||
"""Tests for _total_duration."""
|
||||
|
||||
def test_basic(self):
|
||||
"""Sum of durations for multiple intervals."""
|
||||
ivs = [Interval(0, 1), Interval(2, 5)]
|
||||
assert math.isclose(_total_duration(ivs), 4.0)
|
||||
|
||||
def test_empty(self):
|
||||
"""Empty input returns zero."""
|
||||
assert math.isclose(_total_duration([]), 0.0)
|
||||
|
||||
|
||||
class TestBuildSpeakerTimelines:
|
||||
"""Tests for _build_speaker_timelines."""
|
||||
|
||||
def test_segment_without_words_falls_back_to_segment_bounds(self):
|
||||
"""Segments missing a `words` key use the segment start/end as one interval."""
|
||||
transcription = FakeTranscription(
|
||||
segments=[{"start": 1.5, "end": 3.5, "speaker": "SPEAKER_00"}],
|
||||
)
|
||||
result = _build_speaker_timelines(transcription)
|
||||
assert result == {"SPEAKER_00": [Interval(1.5, 3.5)]}
|
||||
|
||||
def test_segment_with_only_none_word_timestamps_falls_back(self):
|
||||
"""If every word has None start/end, fall back to segment bounds."""
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{
|
||||
"start": 1.0,
|
||||
"end": 4.0,
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [
|
||||
{"word": "hi", "start": None, "end": None},
|
||||
{"word": "there", "start": None, "end": None},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
result = _build_speaker_timelines(transcription)
|
||||
assert result == {"SPEAKER_00": [Interval(1.0, 4.0)]}
|
||||
|
||||
def test_short_words_only_uses_segment_start_and_last_word_end(self):
|
||||
"""With no overly long words, the interval runs segment start to end."""
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{
|
||||
"start": 1.0,
|
||||
"end": 5.0,
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [
|
||||
{"word": "a", "start": 1.0, "end": 1.3},
|
||||
{"word": "b", "start": 1.4, "end": 1.7},
|
||||
{"word": "c", "start": 1.8, "end": 2.1},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
result = _build_speaker_timelines(transcription)
|
||||
# Tail: min(2.1, 1.8 + 1.0) = 2.1
|
||||
assert result == {"SPEAKER_00": [Interval(1.0, 2.1)]}
|
||||
|
||||
def test_long_word_caps_interval_at_max_duration(self):
|
||||
"""A word longer than the max-word-duration cap truncates the segment."""
|
||||
max_word_duration = (
|
||||
user_assign.settings.resolve_speaker_identities_max_word_duration
|
||||
)
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{
|
||||
"start": 0.0,
|
||||
"end": max_word_duration + 7,
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [
|
||||
{
|
||||
"word": "pause",
|
||||
"start": 0.0,
|
||||
"end": max_word_duration + 7,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
result = _build_speaker_timelines(transcription)
|
||||
assert result == {"SPEAKER_00": [Interval(0.0, max_word_duration)]}
|
||||
|
||||
def test_long_word_in_middle_splits_segment(self):
|
||||
"""Short words around a long word produce two intervals (before-cap + after)."""
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{
|
||||
"start": 0.0,
|
||||
"end": 20.0,
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [
|
||||
{"word": "a", "start": 0.0, "end": 0.5},
|
||||
{"word": "long", "start": 1.0, "end": 15.0},
|
||||
{"word": "z", "start": 18.0, "end": 18.4},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
result = _build_speaker_timelines(transcription)
|
||||
# First emit: (0.0, 1.0 + 1.0). Then start_time resets, picks up at "z" (18.0).
|
||||
# Tail: min(18.4, 18.0 + 1.0) = 18.4. So second interval is (18.0, 18.4).
|
||||
assert result == {
|
||||
"SPEAKER_00": [Interval(0.0, 2.0), Interval(18.0, 18.4)],
|
||||
}
|
||||
|
||||
def test_tail_word_is_capped_at_max_duration(self):
|
||||
"""The trailing word's end is capped at word.start + max_word_duration."""
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{
|
||||
"start": 0.0,
|
||||
"end": 50.0,
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [
|
||||
{"word": "a", "start": 0.0, "end": 0.4},
|
||||
# Last word ends inside the cap, so the cap doesn't apply.
|
||||
{"word": "b", "start": 1.0, "end": 1.5},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
result = _build_speaker_timelines(transcription)
|
||||
# Tail: min(1.5, 1.0 + 1.0) = 1.5
|
||||
assert result == {"SPEAKER_00": [Interval(0.0, 1.5)]}
|
||||
|
||||
def test_split_on_words_disabled_keeps_segment_as_one_interval(self, monkeypatch):
|
||||
"""With splitting disabled, long words don't split the interval."""
|
||||
monkeypatch.setattr(
|
||||
user_assign,
|
||||
"settings",
|
||||
user_assign.settings.model_copy(
|
||||
update={"resolve_speaker_identities_enable_split_on_words": False},
|
||||
),
|
||||
)
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{
|
||||
"start": 0.0,
|
||||
"end": 20.0,
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [
|
||||
{"word": "a", "start": 0.0, "end": 0.5},
|
||||
{"word": "long", "start": 1.0, "end": 15.0},
|
||||
{"word": "z", "start": 18.0, "end": 18.4},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
result = _build_speaker_timelines(transcription)
|
||||
# No mid-segment split; tail caps at min(18.4, 18.0 + 1.0) = 18.4.
|
||||
assert result == {"SPEAKER_00": [Interval(0.0, 18.4)]}
|
||||
|
||||
def test_multiple_speakers_keep_separate_timelines(self):
|
||||
"""Segments from different speakers populate independent timeline entries."""
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{
|
||||
"start": 0.0,
|
||||
"end": 1.0,
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [{"word": "hi", "start": 0.0, "end": 0.5}],
|
||||
},
|
||||
{
|
||||
"start": 2.0,
|
||||
"end": 3.0,
|
||||
"speaker": "SPEAKER_01",
|
||||
"words": [{"word": "yo", "start": 2.0, "end": 2.5}],
|
||||
},
|
||||
],
|
||||
)
|
||||
result = _build_speaker_timelines(transcription)
|
||||
assert result == {
|
||||
"SPEAKER_00": [Interval(0.0, 0.5)],
|
||||
"SPEAKER_01": [Interval(2.0, 2.5)],
|
||||
}
|
||||
|
||||
|
||||
class TestResolveSpeakerIdentities:
|
||||
"""Tests for resolve_speaker_identities."""
|
||||
|
||||
def test_single_speaker_single_user(self):
|
||||
"""Single speaker assigned to single user with low threshold."""
|
||||
result = resolve_speaker_identities(
|
||||
METADATA_SINGLE_USER,
|
||||
DIARIZATION_SINGLE_SPEAKER,
|
||||
RECORDING_START,
|
||||
RECORDING_END,
|
||||
overlap_threshold=0.2,
|
||||
)
|
||||
assert len(result.assignments) == 1
|
||||
assert result.assignments[0].speaker_label == "SPEAKER_00"
|
||||
assert result.assignments[0].participant_id == USER_ID
|
||||
assert result.assignments[0].participant_name == "cameledev"
|
||||
assert result.assignments[0].score > 0
|
||||
assert result.unassigned_speakers == []
|
||||
|
||||
def test_no_vad_events(self):
|
||||
"""Participant with no speech leaves speakers unassigned."""
|
||||
metadata = {
|
||||
"events": [
|
||||
{
|
||||
"participant_id": "user-a",
|
||||
"type": "participant_connected",
|
||||
"timestamp": "2026-03-17T15:30:33.000000",
|
||||
},
|
||||
],
|
||||
"participants": [{"participantId": "user-a", "name": "Silent User"}],
|
||||
}
|
||||
result = resolve_speaker_identities(
|
||||
metadata,
|
||||
DIARIZATION_SINGLE_SPEAKER,
|
||||
RECORDING_START,
|
||||
RECORDING_END,
|
||||
)
|
||||
assert len(result.assignments) == 0
|
||||
assert "SPEAKER_00" in result.unassigned_speakers
|
||||
|
||||
def test_multiple_speakers_same_user(self):
|
||||
"""Two speakers from same mic both assigned to same user."""
|
||||
metadata = {
|
||||
"events": [
|
||||
{
|
||||
"participant_id": "user-a",
|
||||
"type": "speech_start",
|
||||
"timestamp": "2026-03-17T15:30:35.000000",
|
||||
},
|
||||
{
|
||||
"participant_id": "user-a",
|
||||
"type": "speech_end",
|
||||
"timestamp": "2026-03-17T15:30:50.000000",
|
||||
},
|
||||
],
|
||||
"participants": [{"participantId": "user-a", "name": "Shared Mic"}],
|
||||
}
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{"start": 1.0, "end": 3.0, "speaker": "SPEAKER_00"},
|
||||
{"start": 5.0, "end": 7.0, "speaker": "SPEAKER_01"},
|
||||
],
|
||||
)
|
||||
result = resolve_speaker_identities(
|
||||
metadata, transcription, RECORDING_START, RECORDING_END
|
||||
)
|
||||
assert len(result.assignments) == 2
|
||||
pids = {a.participant_id for a in result.assignments}
|
||||
assert pids == {"user-a"}
|
||||
|
||||
def test_two_users_two_speakers(self):
|
||||
"""Each speaker maps to correct user by VAD overlap."""
|
||||
metadata = {
|
||||
"events": [
|
||||
{
|
||||
"participant_id": "user-a",
|
||||
"type": "speech_start",
|
||||
"timestamp": "2026-03-17T15:30:34.000001",
|
||||
},
|
||||
{
|
||||
"participant_id": "user-a",
|
||||
"type": "speech_end",
|
||||
"timestamp": "2026-03-17T15:30:37.000001",
|
||||
},
|
||||
{
|
||||
"participant_id": "user-b",
|
||||
"type": "speech_start",
|
||||
"timestamp": "2026-03-17T15:30:38.000001",
|
||||
},
|
||||
{
|
||||
"participant_id": "user-b",
|
||||
"type": "speech_end",
|
||||
"timestamp": "2026-03-17T15:30:41.000001",
|
||||
},
|
||||
],
|
||||
"participants": [
|
||||
{"participantId": "user-a", "name": "Alice"},
|
||||
{"participantId": "user-b", "name": "Bob"},
|
||||
],
|
||||
}
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{"start": 1.5, "end": 3.5, "speaker": "SPEAKER_00"},
|
||||
{"start": 5.5, "end": 7.5, "speaker": "SPEAKER_01"},
|
||||
],
|
||||
)
|
||||
result = resolve_speaker_identities(
|
||||
metadata, transcription, RECORDING_START, RECORDING_END
|
||||
)
|
||||
assert len(result.assignments) == 2
|
||||
by_speaker = {a.speaker_label: a for a in result.assignments}
|
||||
assert by_speaker["SPEAKER_00"].participant_name == "Alice"
|
||||
assert by_speaker["SPEAKER_01"].participant_name == "Bob"
|
||||
|
||||
def test_overlapping_speech_two_users(self):
|
||||
"""Simultaneous speech from two users still assigns each speaker correctly."""
|
||||
# user-a speaks from t=1s to t=6s, user-b speaks from t=3s to t=8s
|
||||
# (3s overlap where both are speaking)
|
||||
# SPEAKER_00 diarization covers t=1.5–5.5 (mostly user-a)
|
||||
# SPEAKER_01 diarization covers t=4.0–7.5 (mostly user-b)
|
||||
metadata = {
|
||||
"events": [
|
||||
{
|
||||
"participant_id": "user-a",
|
||||
"type": "speech_start",
|
||||
"timestamp": "2026-03-17T15:30:34.000001",
|
||||
},
|
||||
{
|
||||
"participant_id": "user-b",
|
||||
"type": "speech_start",
|
||||
"timestamp": "2026-03-17T15:30:36.000001",
|
||||
},
|
||||
{
|
||||
"participant_id": "user-a",
|
||||
"type": "speech_end",
|
||||
"timestamp": "2026-03-17T15:30:39.000001",
|
||||
},
|
||||
{
|
||||
"participant_id": "user-b",
|
||||
"type": "speech_end",
|
||||
"timestamp": "2026-03-17T15:30:41.000001",
|
||||
},
|
||||
],
|
||||
"participants": [
|
||||
{"participantId": "user-a", "name": "Alice"},
|
||||
{"participantId": "user-b", "name": "Bob"},
|
||||
],
|
||||
}
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{"start": 1.5, "end": 5.5, "speaker": "SPEAKER_00"},
|
||||
{"start": 4.0, "end": 7.5, "speaker": "SPEAKER_01"},
|
||||
],
|
||||
)
|
||||
result = resolve_speaker_identities(
|
||||
metadata,
|
||||
transcription,
|
||||
RECORDING_START,
|
||||
RECORDING_END,
|
||||
overlap_threshold=0.3,
|
||||
)
|
||||
assert len(result.assignments) == 2
|
||||
by_speaker = {a.speaker_label: a for a in result.assignments}
|
||||
assert by_speaker["SPEAKER_00"].participant_name == "Alice"
|
||||
assert by_speaker["SPEAKER_01"].participant_name == "Bob"
|
||||
assert result.unassigned_speakers == []
|
||||
|
||||
def test_below_threshold(self):
|
||||
"""Speaker with minimal overlap stays unassigned."""
|
||||
metadata = {
|
||||
"events": [
|
||||
{
|
||||
"participant_id": "user-a",
|
||||
"type": "speech_start",
|
||||
"timestamp": "2026-03-17T15:30:34.000001",
|
||||
},
|
||||
{
|
||||
"participant_id": "user-a",
|
||||
"type": "speech_end",
|
||||
"timestamp": "2026-03-17T15:30:35.169950",
|
||||
},
|
||||
],
|
||||
"participants": [{"participantId": "user-a", "name": "Brief User"}],
|
||||
}
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{"start": 1.0, "end": 10.0, "speaker": "SPEAKER_00"},
|
||||
],
|
||||
)
|
||||
result = resolve_speaker_identities(
|
||||
metadata,
|
||||
transcription,
|
||||
RECORDING_START,
|
||||
RECORDING_END,
|
||||
overlap_threshold=0.5,
|
||||
)
|
||||
assert len(result.assignments) == 0
|
||||
assert "SPEAKER_00" in result.unassigned_speakers
|
||||
|
||||
def test_events_before_recording_start_clamped(self):
|
||||
"""Speech events before recording start are clamped to t=0."""
|
||||
metadata = {
|
||||
"events": [
|
||||
{
|
||||
"participant_id": "user-a",
|
||||
"type": "speech_start",
|
||||
"timestamp": "2026-03-17T15:30:31.000001", # before RECORDING_START
|
||||
},
|
||||
{
|
||||
"participant_id": "user-a",
|
||||
"type": "speech_end",
|
||||
"timestamp": "2026-03-17T15:30:36.000001", # after RECORDING_START
|
||||
},
|
||||
],
|
||||
"participants": [{"participantId": "user-a", "name": "Early User"}],
|
||||
}
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{"start": 0.0, "end": 3.0, "speaker": "SPEAKER_00"},
|
||||
],
|
||||
)
|
||||
result = resolve_speaker_identities(
|
||||
metadata, transcription, RECORDING_START, RECORDING_END
|
||||
)
|
||||
assert len(result.assignments) == 1
|
||||
assert result.assignments[0].participant_name == "Early User"
|
||||
|
||||
def test_empty_diarization(self):
|
||||
"""No segments produces empty result."""
|
||||
result = resolve_speaker_identities(
|
||||
METADATA_SINGLE_USER,
|
||||
FakeTranscription(segments=[]),
|
||||
RECORDING_START,
|
||||
RECORDING_END,
|
||||
)
|
||||
assert result == AssignmentResult()
|
||||
|
||||
def test_segment_without_speaker_ignored(self):
|
||||
"""Segments missing speaker key are skipped."""
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{"start": 1.0, "end": 3.0, "text": "no speaker"},
|
||||
],
|
||||
)
|
||||
result = resolve_speaker_identities(
|
||||
METADATA_SINGLE_USER, transcription, RECORDING_START, RECORDING_END
|
||||
)
|
||||
assert result == AssignmentResult()
|
||||
|
||||
def test_unclosed_speech_closed_at_recording_end(self):
|
||||
"""Open speech_start without speech_end is closed at recording end."""
|
||||
recording_end = datetime.fromisoformat("2026-03-17T15:30:43.000001")
|
||||
metadata = {
|
||||
"events": [
|
||||
{
|
||||
"participant_id": "user-a",
|
||||
"type": "speech_start",
|
||||
"timestamp": "2026-03-17T15:30:35.000001",
|
||||
},
|
||||
# No speech_end — participant kept speaking until recording stopped
|
||||
],
|
||||
"participants": [{"participantId": "user-a", "name": "Still Talking"}],
|
||||
}
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{"start": 2.0, "end": 9.0, "speaker": "SPEAKER_00"},
|
||||
],
|
||||
)
|
||||
result = resolve_speaker_identities(
|
||||
metadata,
|
||||
transcription,
|
||||
RECORDING_START,
|
||||
recording_end,
|
||||
overlap_threshold=0.5,
|
||||
)
|
||||
assert len(result.assignments) == 1
|
||||
assert result.assignments[0].participant_name == "Still Talking"
|
||||
assert result.unassigned_speakers == []
|
||||
|
||||
|
||||
class TestApply:
|
||||
"""Tests for AssignmentResult.apply_to."""
|
||||
|
||||
def test_replaces_speakers_in_segments_and_words(self):
|
||||
"""Speaker labels replaced in segments, words, and word_segments."""
|
||||
diarization = {
|
||||
"segments": [
|
||||
{
|
||||
"start": 1.0,
|
||||
"end": 3.0,
|
||||
"text": "Hello",
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [
|
||||
{
|
||||
"word": "Hello",
|
||||
"start": 1.0,
|
||||
"end": 1.5,
|
||||
"speaker": "SPEAKER_00",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"start": 4.0,
|
||||
"end": 6.0,
|
||||
"text": "World",
|
||||
"speaker": "SPEAKER_01",
|
||||
"words": [
|
||||
{
|
||||
"word": "World",
|
||||
"start": 4.0,
|
||||
"end": 4.5,
|
||||
"speaker": "SPEAKER_01",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"word_segments": [
|
||||
{"word": "Hello", "start": 1.0, "end": 1.5, "speaker": "SPEAKER_00"},
|
||||
{"word": "World", "start": 4.0, "end": 4.5, "speaker": "SPEAKER_01"},
|
||||
],
|
||||
}
|
||||
assignment = AssignmentResult(
|
||||
assignments=[
|
||||
SpeakerAssignment("SPEAKER_00", "id-a", "Alice", 0.9),
|
||||
SpeakerAssignment("SPEAKER_01", "id-b", "Bob", 0.8),
|
||||
],
|
||||
)
|
||||
result = assignment.apply_to(diarization)
|
||||
|
||||
assert result["segments"][0]["speaker"] == "Alice"
|
||||
assert result["segments"][0]["words"][0]["speaker"] == "Alice"
|
||||
assert result["segments"][1]["speaker"] == "Bob"
|
||||
assert result["word_segments"][0]["speaker"] == "Alice"
|
||||
assert result["word_segments"][1]["speaker"] == "Bob"
|
||||
|
||||
def test_unassigned_speakers_unchanged(self):
|
||||
"""Unassigned speaker labels are left as-is."""
|
||||
diarization = {
|
||||
"segments": [
|
||||
{"start": 1.0, "end": 3.0, "speaker": "SPEAKER_02"},
|
||||
],
|
||||
}
|
||||
assignment = AssignmentResult(
|
||||
assignments=[
|
||||
SpeakerAssignment("SPEAKER_00", "id-a", "Alice", 0.9),
|
||||
],
|
||||
unassigned_speakers=["SPEAKER_02"],
|
||||
)
|
||||
result = assignment.apply_to(diarization)
|
||||
assert result["segments"][0]["speaker"] == "SPEAKER_02"
|
||||
|
||||
def test_preserves_extra_keys(self):
|
||||
"""Non-segment keys in diarization are preserved."""
|
||||
diarization = {
|
||||
"segments": [],
|
||||
"language": "en",
|
||||
"custom_field": 42,
|
||||
}
|
||||
result = AssignmentResult().apply_to(diarization)
|
||||
assert result["language"] == "en"
|
||||
assert result["custom_field"] == 42
|
||||
Reference in New Issue
Block a user