Compare commits

..

1 Commits

Author SHA1 Message Date
Florent Chehab df90ea96e6 🩹(devex) fix Makefile special character support
Under some shells echo doesn't work as expected with the special formatting.

Using printf when creating the variables make it work and should be more robust.
2026-02-25 17:44:28 +01:00
46 changed files with 1004 additions and 1629 deletions
+33 -33
View File
@@ -43,12 +43,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '--target backend-production -f Dockerfile'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -86,12 +86,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -130,12 +130,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -174,13 +174,13 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/summary/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
@@ -220,14 +220,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/agents/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
# docker-context: './src/agents'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@v6
-29
View File
@@ -1,29 +0,0 @@
# /!\
# Security Note: This action is not hardened against prompt injection attacks and should only be used
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
# to ensure workflows only run after a maintainer has reviewed the PR.
name: Security Review
permissions:
pull-requests: write # Needed for leaving PR comments
contents: read
on:
pull_request:
branches:
- 'main'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 2
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
with:
comment-pr: true
exclude-directories: docs,gitlint,LICENSES,bin
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
+2 -13
View File
@@ -8,16 +8,9 @@ and this project adheres to
## [Unreleased]
### Changed
- 🔒️(backend) enhance API input validation to strengthen security #1053
## [1.9.0] - 2026-03-02
### Added
- 👷(docker) add arm64 platform support for image builds
- ✨(summary) add localization support for transcription context text
### Changed
@@ -28,17 +21,13 @@ and this project adheres to
- 🚚(frontend) rename "wellknown" directory to "well-known" #1009
- 🌐(frontend) localize SR modifier labels #1010
- ⬆️(backend) update python dependencies #1011
- ♿️(frontend) fix focus ring on tab container components #1012
- ♿️(frontend) upgrade join meeting modal accessibility #1027
- ⬆️(python) bump minimal required python version to 3.13 #1033
- ♿️(frontend) improve accessibility of the IntroSlider carousel #1026
- ♿️(frontend) add skip link component for keyboard navigation #1019
- ♿️(frontend) announce mic/camera state to SR on shortcut toggle #1052
- ♿️(a11y) fix focus ring on tab container components
### Fixed
- 🩹(frontend) fix German language preference update #1021
## [1.8.0] - 2026-02-20
### Changed
-1
View File
@@ -2,7 +2,6 @@
Gitlint extra rule to validate that the message title is of the form
"<gitmoji>(<scope>) <subject>"
"""
from __future__ import unicode_literals
import re
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.9.0"
version = "1.8.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
+11 -56
View File
@@ -2,12 +2,9 @@
# pylint: disable=abstract-method,no-name-in-module
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field
from livekit.api import ParticipantPermission
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
@@ -264,28 +261,6 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
)
class ParticipantPermission(BaseModel):
"""Mirror the LiveKit ParticipantPermission protobuf.
Control what a participant is allowed to publish, subscribe, and do within a room.
Unknown fields are rejected.
"""
can_subscribe: bool | None = None
can_publish: bool | None = None
can_publish_data: bool | None = None
can_publish_sources: list[int] = Field(
default_factory=list
) # TrackSource enum values
hidden: bool | None = None
recorder: bool | None = None
can_update_metadata: bool | None = None
agent: bool | None = None
can_subscribe_metrics: bool | None = None
model_config = {"extra": "forbid"}
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data."""
@@ -297,11 +272,10 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
allow_null=True,
help_text="Participant attributes as JSON object",
)
permission = SchemaField(
schema=ParticipantPermission | None,
permission = serializers.DictField(
required=False,
allow_null=True,
help_text="Participant permissions",
help_text="Participant permission as JSON object",
)
name = serializers.CharField(
max_length=255,
@@ -311,33 +285,6 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
help_text="Display name for the participant",
)
def validate_permission(self, permission):
"""Validate that the given permission does not include forbidden or unimplemented fields."""
if permission is None:
return None
suspicious_fields = [
field
for field in settings.PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS
if getattr(permission, field) is not None
]
if suspicious_fields:
raise SuspiciousOperation(
f"Setting the following participant permissions is not allowed: "
f"{', '.join(suspicious_fields)}."
)
if permission.can_subscribe_metrics is not None:
raise serializers.ValidationError(
{
"permission": {
"can_subscribe_metrics": "This permission is not implemented."
}
}
)
return permission
def validate(self, attrs):
"""Ensure at least one update field is provided."""
update_fields = ["metadata", "attributes", "permission", "name"]
@@ -353,4 +300,12 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
f"{', '.join(update_fields)}."
)
if "permission" in attrs:
try:
ParticipantPermission(**attrs["permission"])
except ValueError as e:
raise serializers.ValidationError(
{"permission": f"Invalid permission: {str(e)}"}
) from e
return attrs
+1 -3
View File
@@ -607,15 +607,13 @@ class RoomViewSet(
serializer = serializers.UpdateParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
permission = serializer.validated_data.get("permission")
try:
ParticipantsManagement().update(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
metadata=serializer.validated_data.get("metadata"),
attributes=serializer.validated_data.get("attributes"),
permission=permission.model_dump() if permission else None,
permission=serializer.validated_data.get("permission"),
name=serializer.validated_data.get("name"),
)
except ParticipantsManagementException:
@@ -167,7 +167,6 @@ class NotificationService:
owner_access.user.timezone
).strftime("%H:%M"),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": owner_access.user.language,
}
headers = {
@@ -8,7 +8,6 @@ import random
from unittest import mock
from uuid import uuid4
from django.core.exceptions import SuspiciousOperation
from django.urls import reverse
import pytest
@@ -133,7 +132,11 @@ def test_update_participant_success(mock_livekit_client):
1,
2,
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
"hidden": False,
"recorder": False,
"can_update_metadata": True,
"agent": False,
"can_subscribe_metrics": False,
},
"name": "John Doe",
}
@@ -148,151 +151,6 @@ def test_update_participant_success(mock_livekit_client):
mock_livekit_client.aclose.assert_called_once()
@pytest.mark.parametrize(
"permission_payload",
[
{}, # empty dict is valid
{"can_subscribe": True},
{"can_publish": True},
{"can_publish_data": True},
{"can_publish_sources": [1, 2]},
{"can_update_metadata": True},
],
)
def test_update_participant_permission_fields_are_optional(
mock_livekit_client, permission_payload
):
"""Test that each required permission field can be passed individually."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": permission_payload,
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
@pytest.mark.parametrize(
"value,permission_key",
[
(False, "hidden"),
(True, "hidden"),
(False, "recorder"),
(True, "recorder"),
(False, "agent"),
(True, "agent"),
],
)
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
def test_update_participant_suspicious_permission(
mock_suspicious, value, permission_key
):
"""Test update participant raises 400 when a restricted permission is set."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"can_update_metadata": False,
permission_key: value,
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_suspicious.assert_called_once_with(
f"Setting the following participant permissions is not allowed: {permission_key}."
)
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
def test_update_participant_suspicious_permission_multiple(mock_suspicious):
"""Test update participant raises 400 when multiple suspicious permissions are set."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"hidden": True,
"recorder": False,
"can_update_metadata": False,
"agent": True,
"can_subscribe_metrics": False,
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_suspicious.assert_called_once_with(
"Setting the following participant permissions is not allowed: hidden, recorder, agent."
)
@pytest.mark.parametrize("value", (False, True))
def test_update_participant_unimplemented_can_subscribe_metrics(value):
"""Test update participant raises 400 when can_subscribe_metrics is set."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"can_update_metadata": False,
"can_subscribe_metrics": value,
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "can_subscribe_metrics" in str(response.data)
def test_update_participant_forbidden_without_access():
"""Test update participant returns 403 when user lacks room privileges."""
client = APIClient()
@@ -368,17 +226,7 @@ def test_update_participant_invalid_permission():
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == {
"permission": [
{
"type": "extra_forbidden",
"loc": ["invalid-attributes"],
"msg": "Extra inputs are not permitted",
"input": "True",
"url": "https://errors.pydantic.dev/2.12/v/extra_forbidden",
},
]
}
assert "Invalid permission" in str(response.data)
def test_update_participant_wrong_metadata_attributes():
-6
View File
@@ -596,12 +596,6 @@ class Base(Configuration):
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
)
# if provided, treat as suspicious (possible privilege escalation attempt).
PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS = values.ListValue(
["hidden", "recorder", "agent"],
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
environ_prefix=None,
)
# Recording settings
RECORDING_ENABLE = values.BooleanValue(
+3 -5
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.9.0"
version = "1.8.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -17,13 +17,13 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.10",
]
description = "A simple video and phone conferencing tool, powered by LiveKit"
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.13"
requires-python = ">=3.10"
dependencies = [
"boto3==1.42.49",
"Brotli==1.2.0",
@@ -39,7 +39,6 @@ dependencies = [
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django-pydantic-field==0.5.4",
"django==5.2.11",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
@@ -51,7 +50,6 @@ dependencies = [
"markdown==3.10.2",
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.3.2",
"pydantic==2.12.4",
"PyJWT==2.11.0",
"python-frontmatter==1.1.0",
"requests==2.32.5",
+839 -856
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.9.0",
"version": "1.8.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -36,7 +36,7 @@
"livekit-client": "2.17.1",
"posthog-js": "1.342.1",
"react": "18.3.1",
"react-aria-components": "1.14.0",
"react-aria-components": "1.10.1",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
@@ -4,7 +4,6 @@ import { Button } from '@/primitives'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
const Heading = styled('h2', {
base: {
@@ -145,21 +144,6 @@ type Slide = {
isAvailableInBeta?: boolean
}
const carouselNavButton = css({
_focusVisible: {
outline: '2px solid var(--colors-focus-ring) !important',
outlineOffset: '1px',
},
_disabled: {
color: 'greyscale.400',
cursor: 'default',
pointerEvents: 'none',
_pressed: {
backgroundColor: 'transparent',
},
},
})
// todo - optimize how images are imported
const SLIDES: Slide[] = [
{
@@ -179,45 +163,11 @@ const SLIDES: Slide[] = [
export const IntroSlider = () => {
const [slideIndex, setSlideIndex] = useState(0)
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
const announce = useScreenReaderAnnounce()
const NUMBER_SLIDES = SLIDES.length
const goPrev = () => {
if (slideIndex === 0) return
const newIndex = slideIndex - 1
setSlideIndex(newIndex)
announce(
t('slidePosition', { current: newIndex + 1, total: NUMBER_SLIDES }),
'polite',
'global'
)
}
const goNext = () => {
if (slideIndex === NUMBER_SLIDES - 1) return
const newIndex = slideIndex + 1
setSlideIndex(newIndex)
announce(
t('slidePosition', { current: newIndex + 1, total: NUMBER_SLIDES }),
'polite',
'global'
)
}
const ariaLabelParams = {
current: slideIndex + 1,
total: NUMBER_SLIDES,
}
const previousAriaLabel = t('previous.labelWithPosition', ariaLabelParams)
const nextAriaLabel = t('next.labelWithPosition', ariaLabelParams)
return (
<Container
role="region"
aria-roledescription="carousel"
aria-label={t('carouselLabel')}
>
<Container>
<div
className={css({
display: 'flex',
@@ -230,10 +180,10 @@ export const IntroSlider = () => {
<Button
variant="secondaryText"
square
className={carouselNavButton}
aria-label={previousAriaLabel}
aria-disabled={slideIndex === 0}
onPress={goPrev}
aria-label={t('previous.label')}
tooltip={t('previous.tooltip')}
onPress={() => setSlideIndex(slideIndex - 1)}
isDisabled={slideIndex == 0}
>
<RiArrowLeftSLine />
</Button>
@@ -241,11 +191,7 @@ export const IntroSlider = () => {
</ButtonContainer>
<SlideContainer>
{SLIDES.map((slide, index) => (
<Slide
aria-hidden={index !== slideIndex}
visible={index === slideIndex}
key={index}
>
<Slide visible={index == slideIndex} key={index}>
<Image src={slide.src} alt="" role="presentation" />
<TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading>
@@ -259,10 +205,10 @@ export const IntroSlider = () => {
<Button
variant="secondaryText"
square
className={carouselNavButton}
aria-label={nextAriaLabel}
aria-disabled={slideIndex === NUMBER_SLIDES - 1}
onPress={goNext}
aria-label={t('next.label')}
tooltip={t('next.tooltip')}
onPress={() => setSlideIndex(slideIndex + 1)}
isDisabled={slideIndex == NUMBER_SLIDES - 1}
>
<RiArrowRightSLine />
</Button>
@@ -5,42 +5,37 @@ import { isRoomValid } from '@/features/rooms'
export const JoinMeetingDialog = () => {
const { t } = useTranslation('home')
const handleSubmit = (data: { roomId?: FormDataEntryValue }) => {
const roomId = (data.roomId as string)
.trim()
.replace(`${window.location.origin}/`, '')
navigateTo('room', roomId)
}
const validateRoomId = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return null
return !isRoomValid(trimmed) ? (
<>
<p>{t('joinInputError')}</p>
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
</Ul>
</>
) : null
}
return (
<Dialog title={t('joinMeeting')}>
<Form onSubmit={handleSubmit} submitLabel={t('joinInputSubmit')}>
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */}
<Form
onSubmit={(data) => {
navigateTo(
'room',
(data.roomId as string)
.trim()
.replace(`${window.location.origin}/`, '')
)
}}
submitLabel={t('joinInputSubmit')}
>
<Field
type="text"
autoFocus
isRequired
name="roomId"
label={t('joinInputLabel')}
description={t('joinInputExample', {
example: window.origin + '/azer-tyu-qsdf',
})}
validate={validateRoomId}
validate={(value) => {
return !isRoomValid(value.trim()) ? (
<>
<p>{t('joinInputError')}</p>
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
</Ul>
</>
) : null
}}
/>
</Form>
<H lvl={2}>{t('joinMeetingTipHeading')}</H>
@@ -1,6 +1,5 @@
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
@@ -88,24 +87,10 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
const deviceShortcut = useDeviceShortcut(kind)
const announce = useScreenReaderAnnounce()
useRegisterKeyboardShortcut({
id: deviceShortcut?.id,
handler: async () => {
const nextState = !enabled
try {
const didChange = await toggle(nextState)
if (didChange === false) return
const message = t(nextState ? 'turnedOn' : 'turnedOff', {
keyPrefix: `selectDevice.${kind}`,
})
announce(message, 'assertive')
} catch {
// no announce
}
},
handler: async () => await toggle(),
isDisabled: cannotUseDevice,
})
-3
View File
@@ -5,7 +5,6 @@ import { layoutStore } from '@/stores/layout'
import { useSnapshot } from 'valtio'
import { Footer } from '@/layout/Footer'
import { ScreenReaderAnnouncer } from '@/primitives'
import { SkipLink, MAIN_CONTENT_ID } from './SkipLink'
export type Layout = 'fullpage' | 'centered'
@@ -22,7 +21,6 @@ export const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
{showHeader && <SkipLink />}
<div
className={css({
display: 'flex',
@@ -37,7 +35,6 @@ export const Layout = ({ children }: { children: ReactNode }) => {
>
{showHeader && <Header />}
<main
id={MAIN_CONTENT_ID}
className={css({
flexGrow: 1,
overflow: 'auto',
-69
View File
@@ -1,69 +0,0 @@
import { type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
export const MAIN_CONTENT_ID = 'main-content'
// Visually hidden until focus (not sr-only). Must become visible on focus for keyboard users.
const StyledSkipLink = styled('a', {
base: {
position: 'absolute',
width: '1px',
height: '1px',
margin: '-1px',
padding: 0,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
textDecoration: 'none',
_focusVisible: {
position: 'fixed',
top: '0.5rem',
left: '50%',
transform: 'translateX(-50%)',
width: 'auto',
height: 'auto',
margin: 0,
padding: '0.625rem 1rem',
overflow: 'visible',
clip: 'auto',
whiteSpace: 'normal',
zIndex: 9999,
backgroundColor: 'white',
color: 'primary.800',
fontWeight: 500,
fontSize: '0.875rem',
border: '1px solid',
borderColor: 'primary.800',
borderRadius: 4,
outline: '2px solid',
outlineColor: 'focusRing',
outlineOffset: 2,
},
},
})
export const SkipLink = () => {
const { t } = useTranslation()
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
const main = document.getElementById(MAIN_CONTENT_ID)
if (!main) return
const heading = main.querySelector('h1, h2, h3') as HTMLElement | null
const target = heading ?? main
if (!target.hasAttribute('tabindex')) {
target.setAttribute('tabindex', '-1')
}
target.focus()
}
return (
<StyledSkipLink href={`#${MAIN_CONTENT_ID}`} onClick={handleClick}>
{t('skipLink')}
</StyledSkipLink>
)
}
-1
View File
@@ -52,7 +52,6 @@
"label": "OK"
}
},
"skipLink": "Zum Hauptinhalt springen",
"clipboardContent": {
"url": "Um an der Videokonferenz teilzunehmen, klicken Sie auf diesen Link: {{roomUrl}}",
"numberAndPin": "Um telefonisch teilzunehmen, wählen Sie {{phoneNumber}} und geben Sie diesen Code ein: {{pinCode}}"
+5 -9
View File
@@ -31,14 +31,12 @@
},
"introSlider": {
"previous": {
"label": "Vorherige Folie",
"labelWithPosition": "Vorherige Folie ({{current}} von {{total}})",
"tooltip": "Vorherige Folie"
"label": "Zurück",
"tooltip": "Zurück"
},
"next": {
"label": "Nächste Folie",
"labelWithPosition": "Nächste Folie ({{current}} von {{total}})",
"tooltip": "Nächste Folie"
"label": "Weiter",
"tooltip": "Weiter"
},
"beta": {
"text": "An der Beta teilnehmen",
@@ -55,8 +53,6 @@
"slide3": {
"title": "Verwandeln Sie Ihre Meetings mit KI",
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta jetzt testen!"
},
"carouselLabel": "Einführungs-Diashow",
"slidePosition": "Folie {{current}} von {{total}}"
}
}
}
-4
View File
@@ -22,8 +22,6 @@
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
"disable": "Kamera deaktivieren",
"enable": "Kamera aktivieren",
"turnedOff": "Kamera deaktiviert",
"turnedOn": "Kamera aktiviert",
"label": "Kamera",
"placeholder": "Kamera aktivieren, um die Vorschau zu sehen"
},
@@ -32,8 +30,6 @@
"permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich",
"disable": "Mikrofon deaktivieren",
"enable": "Mikrofon aktivieren",
"turnedOff": "Mikrofon deaktiviert",
"turnedOn": "Mikrofon aktiviert",
"label": "Mikrofon"
},
"audiooutput": {
-1
View File
@@ -52,7 +52,6 @@
"label": "OK"
}
},
"skipLink": "Skip to main content",
"clipboardContent": {
"url": "To join the video conference, click on this link: {{roomUrl}}",
"numberAndPin": "To join by phone, dial {{phoneNumber}} and enter this code: {{pinCode}}"
+5 -9
View File
@@ -31,14 +31,12 @@
},
"introSlider": {
"previous": {
"label": "Previous slide",
"labelWithPosition": "Previous slide ({{current}} of {{total}})",
"tooltip": "Previous slide"
"label": "previous",
"tooltip": "previous"
},
"next": {
"label": "Next slide",
"labelWithPosition": "Next slide ({{current}} of {{total}})",
"tooltip": "Next slide"
"label": "next",
"tooltip": "next"
},
"beta": {
"text": "Join the beta",
@@ -55,8 +53,6 @@
"slide3": {
"title": "Transform your meetings with AI",
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!"
},
"carouselLabel": "Introduction slideshow",
"slidePosition": "Slide {{current}} of {{total}}"
}
}
}
-4
View File
@@ -22,8 +22,6 @@
"permissionsNeeded": "Select camera - permission needed",
"disable": "Disable camera",
"enable": "Enable camera",
"turnedOff": "Camera turned off",
"turnedOn": "Camera turned on",
"label": "Camera",
"placeholder": "Enable camera to see the preview"
},
@@ -32,8 +30,6 @@
"permissionsNeeded": "Select microphone - permission needed",
"disable": "Disable microphone",
"enable": "Enable microphone",
"turnedOff": "Microphone turned off",
"turnedOn": "Microphone turned on",
"label": "Microphone"
},
"audiooutput": {
-1
View File
@@ -52,7 +52,6 @@
"label": "OK"
}
},
"skipLink": "Aller au contenu principal",
"clipboardContent": {
"url": "Pour participer à la visioconférence, cliquez sur ce lien : {{roomUrl}}",
"numberAndPin": "Pour participer par téléphone, composez le {{phoneNumber}} et saisissez ce code : {{pinCode}}"
+4 -8
View File
@@ -30,18 +30,14 @@
}
},
"introSlider": {
"carouselLabel": "Diaporama de présentation",
"previous": {
"label": "Diapositive précédente",
"labelWithPosition": "Diapositive précédente ({{current}} sur {{total}})",
"tooltip": "Diapositive précédente"
"label": "précédent",
"tooltip": "précédent"
},
"next": {
"label": "Diapositive suivante",
"labelWithPosition": "Diapositive suivante ({{current}} sur {{total}})",
"tooltip": "Diapositive suivante"
"label": "suivant",
"tooltip": "suivant"
},
"slidePosition": "Diapositive {{current}} sur {{total}}",
"beta": {
"text": "Essayer la beta",
"tooltip": "Accéder au formulaire"
-4
View File
@@ -22,8 +22,6 @@
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
"disable": "Désactiver la webcam",
"enable": "Activer la webcam",
"turnedOff": "Webcam désactivée",
"turnedOn": "Webcam activée",
"label": "Webcam",
"placeholder": "Activez la webcam pour prévisualiser l'affichage"
},
@@ -32,8 +30,6 @@
"permissionsNeeded": "Choisir le micro - autorisations nécessaires",
"disable": "Désactiver le micro",
"enable": "Activer le micro",
"turnedOff": "Micro désactivé",
"turnedOn": "Micro activé",
"label": "Microphone"
},
"audiooutput": {
-1
View File
@@ -51,7 +51,6 @@
"label": "OK"
}
},
"skipLink": "Naar de hoofdinhoud gaan",
"clipboardContent": {
"url": "Klik op deze link om deel te nemen aan de videoconferentie: {{roomUrl}}",
"numberAndPin": "Bel {{phoneNumber}} en voer deze code in om telefonisch deel te nemen: {{pinCode}}"
+5 -9
View File
@@ -31,14 +31,12 @@
},
"introSlider": {
"previous": {
"label": "Vorige dia",
"labelWithPosition": "Vorige dia ({{current}} van {{total}})",
"tooltip": "Vorige dia"
"label": "vorige",
"tooltip": "vorige"
},
"next": {
"label": "Volgende dia",
"labelWithPosition": "Volgende dia ({{current}} van {{total}})",
"tooltip": "Volgende dia"
"label": "volgende",
"tooltip": "volgende"
},
"beta": {
"text": "Word lid van de bèta",
@@ -55,8 +53,6 @@
"slide3": {
"title": "Transformeer uw vergaderingen met AI",
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!"
},
"carouselLabel": "Introductie-diavoorstelling",
"slidePosition": "Dia {{current}} van {{total}}"
}
}
}
-4
View File
@@ -22,8 +22,6 @@
"permissionsNeeded": "Selecteer camera - Toestemming vereist",
"disable": "Camera uitschakelen",
"enable": "Camera inschakelen",
"turnedOff": "Camera uitgeschakeld",
"turnedOn": "Camera ingeschakeld",
"label": "Camera",
"placeholder": "Schakel de camera in om de preview te zien"
},
@@ -32,8 +30,6 @@
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
"disable": "Microfoon dempen",
"enable": "Microfoon dempen opheffen",
"turnedOff": "Microfoon uitgeschakeld",
"turnedOn": "Microfoon ingeschakeld",
"label": "Microfoon"
},
"audiooutput": {
-5
View File
@@ -22,11 +22,6 @@ body,
outline: 2px solid transparent;
}
main#main-content :is(h1, h2, h3)[tabindex='-1']:focus {
outline: 2px solid var(--colors-focus-ring);
outline-offset: 2px;
}
[data-rac][data-focus-visible]:not(label, .react-aria-Select),
:is(a, button, input[type='text'], select, textarea):not(
[data-rac]
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.9.0",
"version": "1.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.9.0",
"version": "1.8.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.9.0",
"version": "1.8.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.9.0",
"version": "1.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.9.0",
"version": "1.8.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.9.0",
"version": "1.8.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.9.0"
version = "1.8.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
+4 -6
View File
@@ -15,8 +15,8 @@ from summary.core.config import get_settings
settings = get_settings()
class TranscribeSummarizeTaskCreation(BaseModel):
"""Transcription and summarization parameters."""
class TaskCreation(BaseModel):
"""Task data."""
owner_id: str
filename: str
@@ -28,7 +28,6 @@ class TranscribeSummarizeTaskCreation(BaseModel):
recording_time: Optional[str]
language: Optional[str]
download_link: Optional[str]
context_language: Optional[str] = None
@field_validator("language")
@classmethod
@@ -46,8 +45,8 @@ router = APIRouter(prefix="/tasks")
@router.post("/")
async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreation):
"""Create a transcription and summarization task."""
async def create_task(request: TaskCreation):
"""Create a task."""
task = process_audio_transcribe_summarize_v2.apply_async(
args=[
request.owner_id,
@@ -60,7 +59,6 @@ async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreat
request.recording_time,
request.language,
request.download_link,
request.context_language,
],
queue=settings.transcribe_queue,
)
+4 -22
View File
@@ -18,7 +18,6 @@ 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.prompt import (
FORMAT_NEXT_STEPS,
FORMAT_PLAN,
@@ -122,7 +121,6 @@ def process_audio_transcribe_summarize_v2(
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.
@@ -131,19 +129,6 @@ def process_audio_transcribe_summarize_v2(
2. Transcribes the audio using WhisperX model
3. Sends the results via webhook
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",
@@ -160,7 +145,6 @@ def process_audio_transcribe_summarize_v2(
max_retries=settings.whisperx_max_retries,
)
# Transcription
try:
with (
file_service.prepare_audio_file(filename) as (audio_file, metadata),
@@ -199,10 +183,7 @@ def process_audio_transcribe_summarize_v2(
metadata_manager.track_transcription_metadata(task_id, transcription)
# For locale of context, use in decreasing priority context_language,
# language (of meeting), default context language
locale = get_locale(context_language, language)
formatter = TranscriptFormatter(locale)
formatter = TranscriptFormatter()
content, title = formatter.format(
transcription,
@@ -240,7 +221,6 @@ def process_audio_transcribe_summarize_v2(
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
@@ -356,7 +336,9 @@ def summarize_transcription(
summary = tldr + "\n\n" + cleaned_summary + "\n\n" + next_steps
data = {
"title": settings.summary_title_template.format(title=title),
"title": settings.summary_title_template.format(
title=title,
),
"content": summary,
"email": email,
"sub": sub,
+6 -4
View File
@@ -1,7 +1,7 @@
"""Application configuration and settings."""
from functools import lru_cache
from typing import Annotated, List, Literal, Optional, Set
from typing import Annotated, List, Optional, Set
from fastapi import Depends
from pydantic import SecretStr
@@ -51,6 +51,7 @@ class Settings(BaseSettings):
# Transcription processing
hallucination_patterns: List[str] = ["Vap'n'Roll Thierry"]
hallucination_replacement_text: str = "[Texte impossible à transcrire]"
# Webhook-related settings
webhook_max_retries: int = 2
@@ -59,10 +60,11 @@ class Settings(BaseSettings):
webhook_api_token: SecretStr
webhook_url: str
# Locale
default_context_language: Literal["de", "en", "fr", "nl"] = "fr"
# Output related settings
document_default_title: Optional[str] = "Transcription"
document_title_template: Optional[str] = (
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
)
summary_title_template: Optional[str] = "Résumé de {title}"
# Summary related settings
@@ -1,30 +0,0 @@
"""Locale support for the summary service."""
from typing import Optional
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}
def get_locale(*languages: Optional[str]) -> LocaleStrings:
"""Return locale strings for the first matching language candidate.
Accept language codes in decreasing priority order and return the
locale for the first one that matches a known locale.
Fall back to the configured default_context_language.
"""
for lang in languages:
if not lang:
continue
if lang in _LOCALES:
return _LOCALES[lang].STRINGS
# Provide fallback for longer formats of ISO 639-1 (e.g. "en-au" -> "en")
base_lang = lang.split("-")[0]
if base_lang in _LOCALES:
return _LOCALES[base_lang].STRINGS
return _LOCALES[get_settings().default_context_language].STRINGS
-34
View File
@@ -1,34 +0,0 @@
"""German locale strings."""
from summary.core.locales.strings import LocaleStrings
STRINGS = LocaleStrings(
empty_transcription="""
**In Ihrer Transkription wurde kein Audioinhalt erkannt.**
*Wenn Sie glauben, dass es sich um einen Fehler handelt, zögern Sie nicht,
unseren technischen Support zu kontaktieren: visio@numerique.gouv.fr*
.
.
.
Einige Punkte, die wir Ihnen empfehlen zu überprüfen:
- War ein Mikrofon aktiviert?
- Waren Sie nah genug am Mikrofon?
- Ist das Mikrofon von guter Qualität?
- Dauert die Aufnahme länger als 30 Sekunden?
""",
download_header_template=(
"\n*Laden Sie Ihre Aufnahme herunter, "
"indem Sie [diesem Link folgen]({download_link})*\n"
),
hallucination_replacement_text="[Text konnte nicht transkribiert werden]",
document_default_title="Transkription",
document_title_template=(
'Besprechung "{room}" am {room_recording_date} um {room_recording_time}'
),
)
-33
View File
@@ -1,33 +0,0 @@
"""English locale strings."""
from summary.core.locales.strings import LocaleStrings
STRINGS = LocaleStrings(
empty_transcription="""
**No audio content was detected in your transcription.**
*If you believe this is an error, please do not hesitate to contact
our technical support: visio@numerique.gouv.fr*
.
.
.
A few things we recommend you check:
- Was a microphone enabled?
- Were you close enough to the microphone?
- Is the microphone of good quality?
- Is the recording longer than 30 seconds?
""",
download_header_template=(
"\n*Download your recording by [following this link]({download_link})*\n"
),
hallucination_replacement_text="[Unable to transcribe text]",
document_default_title="Transcription",
document_title_template=(
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
),
)
-33
View File
@@ -1,33 +0,0 @@
"""French locale strings (default)."""
from summary.core.locales.strings import LocaleStrings
STRINGS = LocaleStrings(
empty_transcription="""
**Aucun contenu audio n'a été détecté dans votre transcription.**
*Si vous pensez qu'il s'agit d'une erreur, n'hésitez pas à contacter
notre support technique : visio@numerique.gouv.fr*
.
.
.
Quelques points que nous vous conseillons de vérifier :
- Un micro était-il activé ?
- Étiez-vous suffisamment proche ?
- Le micro est-il de bonne qualité ?
- L'enregistrement dure-t-il plus de 30 secondes ?
""",
download_header_template=(
"\n*Télécharger votre enregistrement en [suivant ce lien]({download_link})*\n"
),
hallucination_replacement_text="[Texte impossible à transcrire]",
document_default_title="Transcription",
document_title_template=(
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
),
)
-33
View File
@@ -1,33 +0,0 @@
"""Dutch locale strings."""
from summary.core.locales.strings import LocaleStrings
STRINGS = LocaleStrings(
empty_transcription="""
**Er is geen audio-inhoud gedetecteerd in uw transcriptie.**
*Als u denkt dat dit een fout is, aarzel dan niet om contact op te nemen
met onze technische ondersteuning: visio@numerique.gouv.fr*
.
.
.
Een paar punten die wij u aanraden te controleren:
- Was er een microfoon ingeschakeld?
- Was u dicht genoeg bij de microfoon?
- Is de microfoon van goede kwaliteit?
- Duurt de opname langer dan 30 seconden?
""",
download_header_template=(
"\n*Download uw opname door [deze link te volgen]({download_link})*\n"
),
hallucination_replacement_text="[Tekst kon niet worden getranscribeerd]",
document_default_title="Transcriptie",
document_title_template=(
'Vergadering "{room}" op {room_recording_date} om {room_recording_time}'
),
)
@@ -1,15 +0,0 @@
"""Locale types for the summary service."""
from dataclasses import dataclass
@dataclass(frozen=True)
class LocaleStrings:
"""All translatable output strings for the summary pipeline."""
# transcript_formatter.py
empty_transcription: str
download_header_template: str
hallucination_replacement_text: str
document_default_title: str
document_title_template: str
@@ -4,13 +4,34 @@ import logging
from typing import Optional, Tuple
from summary.core.config import get_settings
from summary.core.locales import LocaleStrings
settings = get_settings()
logger = logging.getLogger(__name__)
DEFAULT_EMPTY_TRANSCRIPTION = """
**Aucun contenu audio na été détecté dans votre transcription.**
*Si vous pensez quil sagit dune erreur, nhésitez pas à contacter
notre support technique : visio@numerique.gouv.fr*
.
.
.
Quelques points que nous vous conseillons de vérifier :
- Un micro était-il activé ?
- Étiez-vous suffisamment proche ?
- Le micro est-il de bonne qualité ?
- Lenregistrement dure-t-il plus de 30 secondes ?
"""
class TranscriptFormatter:
"""Formats WhisperX transcription output into readable conversation format.
@@ -21,10 +42,12 @@ class TranscriptFormatter:
- Generating descriptive titles from context
"""
def __init__(self, locale: LocaleStrings):
"""Initialize formatter with settings and locale."""
def __init__(self):
"""Initialize formatter with settings."""
self.hallucination_patterns = settings.hallucination_patterns
self._locale = locale
self.hallucination_replacement_text = settings.hallucination_replacement_text
self.default_title = settings.document_default_title
self.default_empty_transcription = DEFAULT_EMPTY_TRANSCRIPTION
def _get_segments(self, transcription):
"""Extract segments from transcription object or dictionary."""
@@ -48,7 +71,7 @@ class TranscriptFormatter:
segments = self._get_segments(transcription)
if not segments:
content = self._locale.empty_transcription
content = self.default_empty_transcription
else:
content = self._format_speaker(segments)
content = self._remove_hallucinations(content)
@@ -60,7 +83,7 @@ class TranscriptFormatter:
def _remove_hallucinations(self, content: str) -> str:
"""Remove hallucination patterns from content."""
replacement = self._locale.hallucination_replacement_text or ""
replacement = self.hallucination_replacement_text or ""
for pattern in self.hallucination_patterns:
content = content.replace(pattern, replacement)
@@ -88,8 +111,9 @@ class TranscriptFormatter:
if not download_link:
return content
header = self._locale.download_header_template.format(
download_link=download_link
header = (
f"\n*Télécharger votre enregistrement "
f"en [suivant ce lien]({download_link})*\n"
)
content = header + content
@@ -103,9 +127,9 @@ class TranscriptFormatter:
) -> str:
"""Generate title from context or return default."""
if not room or not recording_date or not recording_time:
return self._locale.document_default_title
return self.default_title
return self._locale.document_title_template.format(
return settings.document_title_template.format(
room=room,
room_recording_date=recording_date,
room_recording_time=recording_time,