Compare commits

...

17 Commits

Author SHA1 Message Date
lebaudantoine 10eda5c2ea 🔖(minor) bump release to 0.1.41
- fix transcription observability
- introduce auto idle disconnection
2025-10-22 11:04:04 +02:00
lebaudantoine ba3b3fe0ba (frontend) add localStorage persistence for user preference settings
Persist user preference choices across sessions using localStorage
following notification store pattern, eliminating need to reconfigure
disabled features on every meeting join and respecting user's
long-term preference decisions.
2025-10-22 10:04:47 +02:00
lebaudantoine 0c3bcd81c9 ♻️(frontend) refactor notification preferences to use Field switch
Adopt unified switch component pattern for notification preferences to
enable future addition of descriptive text per notification type,
improving consistency and providing clearer explanation capability
for notification behaviors.
2025-10-22 10:04:47 +02:00
lebaudantoine dbc66c2f07 (frontend) add user setting to disable idle disconnect feature
Allow users to opt-out of idle participant disconnection despite
default enforcement, trusting power users who modify this setting
won't forget to disconnect, though accepting risk they may block
maintenance configuration updates.
2025-10-22 10:04:47 +02:00
lebaudantoine 39be4697b0 💄(frontend) add right margin to switch description for better spacing
Add margin between switch description text and toggle button to
improve visual breathing room and prevent text from appearing
cramped against interactive control element.
2025-10-22 10:04:47 +02:00
lebaudantoine 2443fa63a5 (frontend) add idle disconnect warning dialog for LiveKit maintenance
Introduce pop-in alerting participants of automatic 2-minute idle
disconnect to enable LiveKit node configuration updates during
maintenance windows, preventing forgotten tabs from blocking
overnight production updates following patterns
from proprietary videoconference solutions.
2025-10-22 10:04:47 +02:00
lebaudantoine 214dc87b1f (frontend) add narrow "alert" dialog mode for concise messages
Introduce new narrow-width alert dialog variant to improve
readability of short messages by preventing excessively
long line lengths that occur when brief alerts use
standard dialog widths.
2025-10-22 10:04:47 +02:00
lebaudantoine 3dc23be101 (backend) add configuration for idle disconnect timeout
Expose idle disconnect timeout as configurable parameter accepting None value
to disable feature entirely, providing emergency killswitch for buggy behavior
without redeployment, following other frontend configuration patterns.
2025-10-22 10:04:47 +02:00
lebaudantoine 6b5e8081bc 🐛(celery) fix metadata task_args order broken by signal sender argument
Restore correct task_args ordering in metadata manager after commit f0939b6f
added sender argument to Celery signals for transcription task scoping,
unexpectedly shifting positional arguments and breaking metadata creation.

Issue went undetected due to missing staging analytics deployment, silently
losing production observability on microservice without blocking transcription
job execution, highlighting need for staging analytics activation.
2025-10-22 07:17:00 +02:00
lebaudantoine df671ea994 🐛(frontend) posthog-cli 0.5.0 release introduced breaking changes
Posthog-cli version wasn't pinned.
Please check issue #39846, which describe our issue, starting
0.5.0, the cli needs an API token and a Project ID.

Pin to the last stable version we used 0.4.8, and wait a bit
they already released a 0.5.1 that mitigate some of the breaking
change.

I would wait the 0.5.x to be stable and battle tested by other
developpers before switching.

Also as I consider switching the Error tracking to sentry.
2025-10-22 05:48:06 +02:00
lebaudantoine 06a5b9b17e 🩹(doc) fix wrong endpoint path
Applications to application in the application/token endpoint.
Spotted by external contributor.
2025-10-22 05:07:02 +02:00
Ghislain LE MEUR 59d4c2583b 🐛(auth) fix LiveKit token authentication field mismatch
Fixes "Invalid LiveKit token" errors caused by field mismatch between
token generation and authentication lookup.

Previously:
- generate_token() used user.sub as token identity
- LiveKitTokenAuthentication tried to retrieve user via user.id field
- This failed when sub was not a UUID (e.g., from LemonLDAP OIDC provider)

Now:
- generate_token() continues using user.sub (canonical OIDC identifier)
- LiveKitTokenAuthentication correctly looks up by sub field
- Both sides now consistently use the same field

This ensures compatibility with all RFC 7519-compliant OIDC providers,
regardless of their sub claim format.
2025-10-20 04:57:02 +02:00
Ghislain LE MEUR 4b80b4ac9f 🔖(helm) release chart 0.0.14
Fix missing image and command attributes for celery workers
2025-10-17 12:18:31 +02:00
Ghislain LE MEUR 96d7a8875b 🐛(helm) add default commands for celery workers
Without explicit commands in values.yaml,
celeryTranscribe and  celerySummarize pods
were using the Dockerfile's default CMD (uvicorn),
which started the REST API instead of Celery workers.

This fix adds default commands to values.yaml for both services,
ensuring they run as Celery workers processing their respective
queues (transcribe-queue and summarize-queue).
2025-10-17 12:18:31 +02:00
Ghislain LE MEUR dc177b69d8 🐛(summary) add image
Add missing image attributes for summary, celerySummarize and celeryTranscribe
2025-10-17 12:18:31 +02:00
Martin Guitteny 36b2156c7b ️(summary) change formating from prompt to response_format
Add ability to use response_format in call function in order to
have better result with albert-large model
Use reponse_format for next steps and plan generation
2025-10-13 12:07:54 +02:00
lebaudantoine ec94d613fa 🔖(minor) bump release to 0.1.40
- enhance technical documentation
- introduce external-api and service account
- fix inverted keyboard shortcuts
- allow configuring whisperX language (still wip)
- filter livekit event when sharing a single livekit instance
2025-10-12 17:13:09 +02:00
40 changed files with 515 additions and 67 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ RUN npm run build
# Inject PostHog sourcemap metadata into the built assets
# This metadata is essential for correctly mapping errors to source maps in production
RUN set -e && \
npx @posthog/cli sourcemap inject --directory ./dist/assets
npx @posthog/cli@0.4.8 sourcemap inject --directory ./dist/assets
COPY ./docker/dinum-frontend/dinum-styles.css \
./dist/assets/
+3 -3
View File
@@ -7,7 +7,7 @@ info:
#### Authentication Flow
1. Exchange application credentials for a JWT token via `/external-api/v1.0/applications/token`.
1. Exchange application credentials for a JWT token via `/external-api/v1.0/application/token`.
2. Use the JWT token in the `Authorization: Bearer <token>` header for all subsequent requests.
3. Tokens are scoped and allow applications to act on behalf of specific users.
@@ -40,7 +40,7 @@ tags:
description: Room management operations
paths:
/applications/token:
/application/token:
post:
tags:
- Authentication
@@ -283,7 +283,7 @@ components:
scheme: bearer
bearerFormat: JWT
description: |
JWT token obtained from the `/applications/token` endpoint.
JWT token obtained from the `/application/token` endpoint.
Include in requests as: `Authorization: Bearer <token>`
schemas:
+1 -1
View File
@@ -15,7 +15,7 @@ WHISPERX_DEFAULT_LANGUAGE="fr"
LLM_BASE_URL="https://configure-your-url.com"
LLM_API_KEY="dev-apikey"
LLM_MODEL="Qwen/Qwen2.5-Coder-32B-Instruct-AWQ"
LLM_MODEL="albert-large"
WEBHOOK_API_TOKEN="secret"
WEBHOOK_URL="https://configure-your-url.com"
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "0.1.39"
version = "0.1.41"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.2.6",
+1 -1
View File
@@ -30,7 +30,7 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
raise exceptions.AuthenticationFailed("Token missing user identity")
try:
user = UserModel.objects.get(id=user_id)
user = UserModel.objects.get(sub=user_id)
except UserModel.DoesNotExist:
user = AnonymousUser()
+5
View File
@@ -326,6 +326,11 @@ class Base(Configuration):
"is_silent_login_enabled": values.BooleanValue(
True, environ_name="FRONTEND_IS_SILENT_LOGIN_ENABLED", environ_prefix=None
),
"idle_disconnect_warning_delay": values.PositiveIntegerValue(
None,
environ_name="FRONTEND_IDLE_DISCONNECT_WARNING_DELAY",
environ_prefix=None,
),
"feedback": values.DictValue(
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
),
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.39"
version = "0.1.41"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "0.1.39",
"version": "0.1.41",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.39",
"version": "0.1.41",
"dependencies": {
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.39",
"version": "0.1.41",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
+1
View File
@@ -25,6 +25,7 @@ export interface ApiConfig {
custom_css_url?: string
use_french_gov_footer?: boolean
use_proconnect_button?: boolean
idle_disconnect_warning_delay?: number
recording?: {
is_enabled?: boolean
available_modes?: RecordingMode[]
@@ -0,0 +1,102 @@
import { Button, Dialog, H, P } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { useSnapshot } from 'valtio'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { HStack } from '@/styled-system/jsx'
import { useEffect, useState } from 'react'
import { navigateTo } from '@/navigation/navigateTo'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
export const IsIdleDisconnectModal = () => {
const connectionObserverSnap = useSnapshot(connectionObserverStore)
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
useEffect(() => {
if (connectionObserverSnap.isIdleDisconnectModalOpen) {
setTimeRemaining(IDLE_DISCONNECT_TIMEOUT_MS)
const interval = setInterval(() => {
setTimeRemaining((prev) => {
if (prev <= 1000) {
clearInterval(interval)
connectionObserverStore.isIdleDisconnectModalOpen = false
navigateTo('feedback', { duplicateIdentity: false })
return 0
}
return prev - 1000
})
}, 1000)
return () => clearInterval(interval)
}
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
const minutes = Math.floor(timeRemaining / 1000 / 60)
const seconds = (timeRemaining / 1000) % 60
const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`
return (
<Dialog
isOpen={connectionObserverSnap.isIdleDisconnectModalOpen}
role="alertdialog"
type="alert"
aria-label={t('title')}
onClose={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
}}
>
{({ close }) => {
return (
<div>
<div
className={css({
height: '50px',
width: '50px',
backgroundColor: 'blue.100',
borderRadius: '25px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontWeight: '500',
color: 'blue.800',
margin: 'auto',
})}
>
{formattedTime}
</div>
<H lvl={2} centered>
{t('title')}
</H>
<P>
{t('body', {
duration: humanizeDuration(IDLE_DISCONNECT_TIMEOUT_MS, {
language: i18n.language,
}),
})}
</P>
<P>{t('settings')}</P>
<HStack marginTop="2rem">
<Button
onPress={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
navigateTo('feedback', { duplicateIdentity: false })
}}
size="sm"
variant="secondary"
>
{t('leaveButton')}
</Button>
<Button onPress={close} size="sm" variant="primary">
{t('stayButton')}
</Button>
</HStack>
</div>
)
}}
</Dialog>
)
}
@@ -1,15 +1,72 @@
import { useRoomContext } from '@livekit/components-react'
import {
useRemoteParticipants,
useRoomContext,
} from '@livekit/components-react'
import { useEffect, useRef } from 'react'
import { DisconnectReason, RoomEvent } from 'livekit-client'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import posthog from 'posthog-js'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { useConfig } from '@/api/useConfig'
import { userPreferencesStore } from '@/stores/userPreferences'
import { useSnapshot } from 'valtio'
export const useConnectionObserver = () => {
const room = useRoomContext()
const connectionStartTimeRef = useRef<number | null>(null)
const { data } = useConfig()
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const userPreferencesSnap = useSnapshot(userPreferencesStore)
const idleDisconnectModalTimeoutRef = useRef<ReturnType<
typeof setTimeout
> | null>(null)
const remoteParticipants = useRemoteParticipants({
updateOnlyOn: [
RoomEvent.ParticipantConnected,
RoomEvent.ParticipantDisconnected,
],
})
useEffect(() => {
// Always clear existing timer on dependency change
if (idleDisconnectModalTimeoutRef.current) {
clearTimeout(idleDisconnectModalTimeoutRef.current)
idleDisconnectModalTimeoutRef.current = null
}
const isEnabled = userPreferencesSnap.is_idle_disconnect_modal_enabled
const delay = data?.idle_disconnect_warning_delay
// Disabled or invalid delay: ensure modal is closed
if (!isEnabled || !delay) {
connectionObserverStore.isIdleDisconnectModalOpen = false
return
}
if (remoteParticipants.length === 0) {
idleDisconnectModalTimeoutRef.current = setTimeout(() => {
connectionObserverStore.isIdleDisconnectModalOpen = true
}, delay)
} else {
connectionObserverStore.isIdleDisconnectModalOpen = false
}
return () => {
if (idleDisconnectModalTimeoutRef.current) {
clearTimeout(idleDisconnectModalTimeoutRef.current)
idleDisconnectModalTimeoutRef.current = null
}
}
}, [
remoteParticipants.length,
data?.idle_disconnect_warning_delay,
userPreferencesSnap.is_idle_disconnect_modal_enabled,
])
useEffect(() => {
if (!isAnalyticsEnabled) return
@@ -36,6 +36,7 @@ import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
import { Subtitles } from '@/features/subtitle/component/Subtitles'
import { CarouselLayout } from '../components/layout/CarouselLayout'
import { GridLayout } from '../components/layout/GridLayout'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
const LayoutWrapper = styled(
'div',
@@ -191,6 +192,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
isOpen={isShareErrorVisible}
onClose={() => setIsShareErrorVisible(false)}
/>
<IsIdleDisconnectModal />
<div
// todo - extract these magic values into constant
style={{
@@ -2,6 +2,8 @@ import { Field, H } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { userPreferencesStore } from '@/stores/userPreferences'
import { useSnapshot } from 'valtio'
export type GeneralTabProps = Pick<TabPanelProps, 'id'>
@@ -9,6 +11,8 @@ export const GeneralTab = ({ id }: GeneralTabProps) => {
const { t, i18n } = useTranslation('settings')
const { languagesList, currentLanguage } = useLanguageLabels()
const userPreferencesSnap = useSnapshot(userPreferencesStore)
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('language.heading')}</H>
@@ -21,6 +25,20 @@ export const GeneralTab = ({ id }: GeneralTabProps) => {
i18n.changeLanguage(lang as string)
}}
/>
<H lvl={2}>{t('preferences.title')}</H>
<Field
type="switch"
label={t('preferences.idleDisconnectModal.label')}
description={t('preferences.idleDisconnectModal.description')}
isSelected={userPreferencesSnap.is_idle_disconnect_modal_enabled}
onChange={(value) =>
(userPreferencesStore.is_idle_disconnect_modal_enabled = value)
}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
</TabPanel>
)
}
@@ -1,5 +1,5 @@
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { H, Switch } from '@/primitives'
import { Field, H } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
@@ -23,15 +23,19 @@ export const NotificationsTab = ({ id }: NotificationsTabProps) => {
{Array.from(notificationsSnap.soundNotifications).map(
([key, value]) => (
<li key={key}>
<Switch
aria-label={`${t(`actions.${value ? 'disable' : 'enable'}`)} ${t('label')} "${t(`items.${key}`)}"`}
<Field
type="switch"
aria-label={`${t(`actions.${value ? 'disable' : 'enable'}`)} ${t('label')} "${t(`items.${key}.label`)}"`}
label={t(`items.${key}.label`)}
isSelected={value}
onChange={(v) => {
notificationsStore.soundNotifications.set(key, v)
}}
>
{t(`items.${key}`)}
</Switch>
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
</li>
)
)}
+7
View File
@@ -149,6 +149,13 @@
"newTab": "Neues Fenster"
}
},
"isIdleDisconnectModal": {
"title": "Bist du noch da?",
"body": "Du bist der einzige Teilnehmer. Dieses Gespräch endet in {{duration}}. Möchtest du das Gespräch fortsetzen?",
"settings": "Um diese Nachricht nicht mehr zu sehen, gehe zu Einstellungen > Allgemein.",
"stayButton": "Gespräch fortsetzen",
"leaveButton": "Jetzt verlassen"
},
"controls": {
"microphone": "Mikrofon",
"camera": "Kamera",
+16 -3
View File
@@ -7,6 +7,13 @@
"authentication": "Authentifizierung",
"nameError": "Ihr Name darf nicht leer sein"
},
"preferences": {
"title": "Einstellungen",
"idleDisconnectModal": {
"label": "Anrufe ohne Teilnehmer verlassen",
"description": "Verlässt automatisch einen Anruf nach einigen Minuten, wenn kein anderer Teilnehmer beitritt"
}
},
"audio": {
"microphone": {
"heading": "Mikrofon",
@@ -69,9 +76,15 @@
"enable": "Aktivieren"
},
"items": {
"participantJoined": "Teilnehmer beigetreten",
"handRaised": "Hand gehoben",
"messageReceived": "Nachricht erhalten"
"participantJoined": {
"label": "Teilnehmer beigetreten"
},
"handRaised": {
"label": "Hand gehoben"
},
"messageReceived": {
"label": "Nachricht erhalten"
}
}
},
"dialog": {
+7
View File
@@ -149,6 +149,13 @@
"newTab": "New window"
}
},
"isIdleDisconnectModal": {
"title": "Are you still there?",
"body": "You are the only participant. This call will end in {{duration}}. Would you like to continue the call?",
"settings": "To stop seeing this message, go to Settings > General.",
"stayButton": "Continue the call",
"leaveButton": "Leave now"
},
"controls": {
"microphone": "Microphone",
"camera": "Camera",
+16 -3
View File
@@ -7,6 +7,13 @@
"authentication": "Authentication",
"nameError": "Your name cannot be empty"
},
"preferences": {
"title": "Preferences",
"idleDisconnectModal": {
"label": "Leave calls with no participants",
"description": "Automatically leaves a call after a few minutes if no other participant joins"
}
},
"audio": {
"microphone": {
"heading": "Microphone",
@@ -69,9 +76,15 @@
"enable": "Enable"
},
"items": {
"participantJoined": "Participant joined",
"handRaised": "Hand raised",
"messageReceived": "Message received"
"participantJoined": {
"label": "Participant joined"
},
"handRaised": {
"label": "Hand raised"
},
"messageReceived": {
"label": "Message received"
}
}
},
"dialog": {
+7
View File
@@ -149,6 +149,13 @@
"newTab": "Nouvelle fenêtre"
}
},
"isIdleDisconnectModal": {
"title": "Êtes-vous toujours là ?",
"body": "Vous êtes le seul participant. Cet appel va donc se terminer dans {{duration}}. Voulez-vous poursuivre l'appel ?",
"settings": "Pour ne plus voir ce message, accèder à Paramètres > Général.",
"stayButton": "Poursuivre l'appel",
"leaveButton": "Quitter maintenant"
},
"controls": {
"microphone": "Microphone",
"camera": "Camera",
+16 -3
View File
@@ -7,6 +7,13 @@
"authentication": "Authentification",
"nameError": "Votre Nom ne peut pas être vide"
},
"preferences": {
"title": "Préférences",
"idleDisconnectModal": {
"label": "Quitter les appels sans autre participant",
"description": "Vous fait quitter un appel au bout de quelques minutes si aucun autre participant ne vous rejoint"
}
},
"audio": {
"microphone": {
"heading": "Micro",
@@ -69,9 +76,15 @@
"enable": "Activer"
},
"items": {
"participantJoined": "Un nouveau participant",
"handRaised": "Une main levée",
"messageReceived": "Un message reçu"
"participantJoined": {
"label": "Un nouveau participant"
},
"handRaised": {
"label": "Une main levée"
},
"messageReceived": {
"label": "Un message reçu"
}
}
},
"dialog": {
+7
View File
@@ -149,6 +149,13 @@
"newTab": "Nieuw venster"
}
},
"isIdleDisconnectModal": {
"title": "Ben je er nog?",
"body": "Je bent de enige deelnemer. Dit gesprek wordt over {{duration}} beëindigd. Wil je het gesprek voortzetten?",
"settings": "Om dit bericht niet meer te zien, ga naar Instellingen > Algemeen.",
"stayButton": "Gesprek voortzetten",
"leaveButton": "Nu verlaten"
},
"controls": {
"microphone": "Microfoon",
"camera": "Camera",
+16 -3
View File
@@ -7,6 +7,13 @@
"authentication": "Authenticatie",
"nameError": "Uw naam mag niet leeg zijn"
},
"preferences": {
"title": "Voorkeuren",
"idleDisconnectModal": {
"label": "Verlaat oproepen zonder deelnemers",
"description": "Verlaat automatisch een oproep na een paar minuten als geen andere deelnemer meedoet"
}
},
"audio": {
"microphone": {
"heading": "Microfoon",
@@ -69,9 +76,15 @@
"enable": "Inschakelen"
},
"items": {
"participantJoined": "Deelnemer is toegevoegd",
"handRaised": "Hand opgestoken",
"messageReceived": "Bericht ontvangen"
"participantJoined": {
"label": "Deelnemer is toegevoegd"
},
"handRaised": {
"label": "Hand opgestoken"
},
"messageReceived": {
"label": "Bericht ontvangen"
}
}
},
"dialog": {
+4
View File
@@ -28,6 +28,10 @@ const box = cva({
width: '30rem',
maxWidth: '100%',
},
alert: {
width: '24rem',
maxWidth: '100%',
},
},
variant: {
light: {
+7 -2
View File
@@ -80,7 +80,7 @@ export type DialogProps = RACDialogProps & {
* after user interaction
*/
onOpenChange?: (isOpen: boolean) => void
type?: 'flex'
type?: 'flex' | 'alert'
innerRef?: MutableRefObject<HTMLDivElement | null>
size?: 'full' | 'large'
}
@@ -96,7 +96,12 @@ export const Dialog = ({
...dialogProps
}: DialogProps) => {
const isAlert = dialogProps['role'] === 'alertdialog'
const boxType = dialogProps['type'] !== 'flex' ? 'dialog' : undefined
const boxType =
dialogProps['type'] === 'alert'
? 'alert'
: dialogProps['type'] !== 'flex'
? 'dialog'
: undefined
return (
<StyledModalOverlay
isKeyboardDismissDisabled={isAlert}
+3 -1
View File
@@ -259,6 +259,7 @@ export const Field = <T extends object>({
}
if (type === 'switch') {
const SWITCH_COMPONENT_WIDTH = '41px'
return (
<FieldWrapper {...props.wrapperProps}>
<div
@@ -273,10 +274,11 @@ export const Field = <T extends object>({
{description && (
<Text
variant="note"
wrap={'pretty'}
wrap={'balance'}
className={css({
textStyle: 'sm',
marginBottom: '0.5rem',
marginRight: SWITCH_COMPONENT_WIDTH,
})}
>
{description}
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
isIdleDisconnectModalOpen: boolean
}
export const connectionObserverStore = proxy<State>({
isIdleDisconnectModalOpen: false,
})
@@ -0,0 +1,38 @@
import { proxy } from 'valtio'
import { subscribe } from 'valtio/index'
import { STORAGE_KEYS } from '@/utils/storageKeys'
type State = {
is_idle_disconnect_modal_enabled: boolean
}
const DEFAULT_STATE = {
is_idle_disconnect_modal_enabled: true,
}
function getUserPreferencesState(): State {
try {
const stored = localStorage.getItem(STORAGE_KEYS.USER_PREFERENCES)
if (!stored) return DEFAULT_STATE
const parsed = JSON.parse(stored)
return {
...DEFAULT_STATE,
...parsed,
}
} catch (error: unknown) {
console.error(
'[UserPreferencesStore] Failed to parse stored settings:',
error
)
return DEFAULT_STATE
}
}
export const userPreferencesStore = proxy<State>(getUserPreferencesState())
subscribe(userPreferencesStore, () => {
localStorage.setItem(
STORAGE_KEYS.USER_PREFERENCES,
JSON.stringify(userPreferencesStore)
)
})
+1
View File
@@ -3,4 +3,5 @@
*/
export const STORAGE_KEYS = {
NOTIFICATIONS: 'app_notification_settings',
USER_PREFERENCES: 'app_user_preferences',
} as const
@@ -57,6 +57,7 @@ backend:
FRONTEND_TRANSCRIPT: "{'form_beta_users': 'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'}"
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
FRONTEND_MANIFEST_LINK: "https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/"
FRONTEND_IDLE_DISCONNECT_WARNING_DELAY: 9000
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.13
version: 0.0.14
+41 -2
View File
@@ -428,6 +428,13 @@ posthog:
## @section summary
summary:
## @param summary.image.repository Repository to use to pull meet's summary container image
## @param summary.image.tag meet's summary container tag
## @param summary.image.pullPolicy summary container image pull policy
image:
repository: lasuite/meet-summary
pullPolicy: IfNotPresent
tag: "latest"
## @param summary.dpAnnotations Annotations to add to the summary Deployment
dpAnnotations: {}
@@ -527,11 +534,27 @@ summary:
## @section celeryTranscribe
celeryTranscribe:
## @param celeryTranscribe.image.repository Repository to use to pull meet's celeryTranscribe container image
## @param celeryTranscribe.image.tag meet's celeryTranscribe container tag
## @param celeryTranscribe.image.pullPolicy celeryTranscribe container image pull policy
image:
repository: lasuite/meet-summary
pullPolicy: IfNotPresent
tag: "latest"
## @param celeryTranscribe.dpAnnotations Annotations to add to the celeryTranscribe Deployment
dpAnnotations: {}
## @param celeryTranscribe.command Override the celeryTranscribe container command
command: []
command:
- "celery"
- "-A"
- "summary.core.celery_worker"
- "worker"
- "--pool=solo"
- "--loglevel=info"
- "-Q"
- "transcribe-queue"
## @param celeryTranscribe.args Override the celeryTranscribe container args
args: []
@@ -620,11 +643,27 @@ celeryTranscribe:
## @section celerySummarize
celerySummarize:
## @param celerySummarize.image.repository Repository to use to pull meet's celerySummarize container image
## @param celerySummarize.image.tag meet's celerySummarize container tag
## @param celerySummarize.image.pullPolicy celerySummarize container image pull policy
image:
repository: lasuite/meet-summary
pullPolicy: IfNotPresent
tag: "latest"
## @param celerySummarize.dpAnnotations Annotations to add to the celerySummarize Deployment
dpAnnotations: {}
## @param celerySummarize.command Override the celerySummarize container command
command: []
command:
- "celery"
- "-A"
- "summary.core.celery_worker"
- "worker"
- "--pool=solo"
- "--loglevel=info"
- "-Q"
- "summarize-queue"
## @param celerySummarize.args Override the celerySummarize container args
args: []
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "0.1.39",
"version": "0.1.41",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "0.1.39",
"version": "0.1.41",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.1.39",
"version": "0.1.41",
"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": "0.1.39",
"version": "0.1.41",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "0.1.39",
"version": "0.1.41",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "0.1.39",
"version": "0.1.41",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "0.1.39"
version = "0.1.41"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
+1 -1
View File
@@ -123,7 +123,7 @@ class MetadataManager:
logger.error("Invalid number of arguments to enable metadata manager.")
return
filename, email, _, received_at, *_ = task_args
_, filename, email, _, received_at, *_ = task_args
initial_metadata = {
**initial_metadata,
+51 -14
View File
@@ -7,7 +7,7 @@ import os
import tempfile
import time
from pathlib import Path
from typing import Optional
from typing import Any, Mapping, Optional
import openai
import sentry_sdk
@@ -22,6 +22,8 @@ from urllib3.util import Retry
from summary.core.analytics import MetadataManager, get_analytics
from summary.core.config import get_settings
from summary.core.prompt import (
FORMAT_NEXT_STEPS,
FORMAT_PLAN,
PROMPT_SYSTEM_CLEANING,
PROMPT_SYSTEM_NEXT_STEP,
PROMPT_SYSTEM_PART,
@@ -115,24 +117,53 @@ class LLMService:
base_url=settings.llm_base_url, api_key=settings.llm_api_key
)
def call(self, system_prompt: str, user_prompt: str):
def call(
self,
system_prompt: str,
user_prompt: str,
response_format: Optional[Mapping[str, Any]] = None,
):
"""Call the LLM service.
Takes a system prompt and a user prompt, and returns the LLM's response
Returns None if the call fails.
"""
try:
response = self._client.chat.completions.create(
model=settings.llm_model,
messages=[
params: dict[str, Any] = {
"model": settings.llm_model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
)
}
if response_format is not None:
params["response_format"] = response_format
response = self._client.chat.completions.create(**params)
return response.choices[0].message.content
except Exception as e:
logger.error("LLM call failed: %s", e)
raise LLMException("LLM call failed.") from e
logger.exception("LLM call failed: %s", e)
raise LLMException("LLM call failed: {e}") from e
def format_actions(llm_output: dict) -> str:
"""Format the actions from the LLM output into a markdown list.
fomat:
- [ ] Action title Assignée à : assignee1, assignee2, Échéance : due_date
"""
lines = []
for action in llm_output.get("actions", []):
title = action.get("title", "").strip()
assignees = ", ".join(action.get("assignees", [])) or "-"
due_date = action.get("due_date") or "-"
line = f"- [ ] {title} Assignée à : {assignees}, Échéance : {due_date}"
lines.append(line)
if lines:
return "### Prochaines étapes\n\n" + "\n".join(lines)
return ""
def format_segments(transcription_data):
@@ -359,13 +390,14 @@ def summarize_transcription(self, transcript: str, email: str, sub: str, title:
logger.info("TLDR generated")
parts = llm_service.call(PROMPT_SYSTEM_PLAN, transcript)
parts = llm_service.call(
PROMPT_SYSTEM_PLAN, transcript, response_format=FORMAT_PLAN
)
logger.info("Plan generated")
parts = parts.split("\n")
parts = [x for x in parts if x.strip() != ""]
logger.info("Empty parts removed")
res = json.loads(parts)
parts = res.get("titles", [])
logger.info("Parts to summarize: %s", parts)
parts_summarized = []
for part in parts:
prompt_user_part = PROMPT_USER_PART.format(part=part, transcript=transcript)
@@ -376,7 +408,12 @@ def summarize_transcription(self, transcript: str, email: str, sub: str, title:
raw_summary = "\n\n".join(parts_summarized)
next_steps = llm_service.call(PROMPT_SYSTEM_NEXT_STEP, transcript)
next_steps = llm_service.call(
PROMPT_SYSTEM_NEXT_STEP, transcript, response_format=FORMAT_NEXT_STEPS
)
next_steps = format_actions(json.loads(next_steps))
logger.info("Next steps generated")
cleaned_summary = llm_service.call(PROMPT_SYSTEM_CLEANING, raw_summary)
+52 -9
View File
@@ -4,12 +4,8 @@ PROMPT_SYSTEM_TLDR = """Tu es un agent dont le rôle est de créer un TL;DR (ré
### Résumé TL;DR
[Résumé concis et structuré]"""
PROMPT_SYSTEM_PLAN = """Ta tâche est de diviser le contenu du transcript en sujets concrets correspondant aux grands axes discutés durant la réunion. Ne crée pas de catégories génériques. Les titres doivent être courts, précis et représentatifs des échanges. Veille à ce que chaque sujet soit distinct et quaucun thème ne soit répété. Tu te limiteras à 5 ou 6 sujets maximum.
L'introduction, ordre du jour, conclusion, etc. seront rajoutés a posteriori. Tu répondras dans le format suivant sans rien ajouter d'autre:
"Titre du sujet 1
Titre du sujet 2
Titre du sujet 3
..."
PROMPT_SYSTEM_PLAN = """Ta tâche est de diviser le contenu du transcript en sujets concrets correspondant aux grands axes discutés durant la réunion. Ne crée pas de catégories génériques. Les titres doivent être courts, précis et représentatifs des échanges. Veille à ce que chaque sujet soit distinct et quaucun thème ne soit répété. Tu te limiteras à 5 ou 6 sujets maximum.
L'introduction, ordre du jour, conclusion, etc. seront rajoutés a posteriori. Si il n'y a pas de sujets clairs, réponds "Général".
"""
PROMPT_SYSTEM_PART = """Tu es un agent dont le rôle est de créer une partie du résumé d'un compte rendu de réunion. Tu utiliseras un style synthétique, administratif, à la troisième personne, sans affect. Tu recevras en entrée le transcript, et le titre du sujet correspondant. Ta tâche est de rédiger un résumé concis de cette partie et uniquement cette partie, en te concentrant uniquement sur les informations essentielles et pertinentes. Le résumé de chaque partie doit tenir en 4 à 6 phrases maximum, sans entrer dans les détails mineurs. Tu répondras dans le format suivant :
@@ -23,6 +19,53 @@ Transcript complet :
PROMPT_SYSTEM_CLEANING = """Tu es un agent dont le rôle est de nettoyer un résumé de compte rendu de réunion. Tu recevras en entrée le résumé brut, potentiellement avec des erreurs de formatage, des incohérences ou des redondances. Ta tâche est de corriger les erreurs de formatage, d'améliorer la clarté et la cohérence du texte, et de t'assurer que le résumé est bien structuré et facile à lire. Ton but principal est de retirer les redondances et les répétitions. Assure la cohérence entre les titres et homogénéise le style d’écriture entre les parties. Supprime les doublons dinformations entre les parties si présents. Si certaines parties sont plus secondaires, tu peux les fusionner ou les réduire en 1 à 2 phrases. Mets en avant les points centraux qui ont fait lobjet de décisions ou dactions. Tu répondras uniquement avec le résumé sans rien ajouter d'autre"""
PROMPT_SYSTEM_NEXT_STEP = """Tu es un agent dont le rôle est d'extraire les prochaines étapes d'un transcript de réunion. Tu utiliseras un style synthétique, administratif, à la troisième personne, sans affect. Tu recevras en entrée le transcript. Ta tâche est d'identifier et de lister toutes les actions à entreprendre, en indiquant la ou les personnes assignées et en précisant les échéances si elles sont mentionnées. Ne retiens que les actions concrètes et à venir. Ignore les remarques générales ou les constats sans suite. Les actions doivent suivre ce format strict :
### Prochaines étapes
- [ ] [Action à effectuer] Assignée à : [Nom], Échéance : [Date si mentionnée]"""
PROMPT_SYSTEM_NEXT_STEP = """Tu es un agent dont le rôle est d'extraire les prochaines étapes d'un transcript de réunion. Tu utiliseras un style synthétique, administratif, à la troisième personne, sans affect. Tu recevras en entrée le transcript. Ta tâche est d'identifier et de lister toutes les actions à entreprendre, en indiquant la ou les personnes assignées et en précisant les échéances si elles sont mentionnées. Ne retiens que les actions concrètes et à venir. Ignore les remarques générales ou les constats sans suite."""
FORMAT_NEXT_STEPS = {
"type": "json_schema",
"json_schema": {
"name": "actions",
"schema": {
"type": "object",
"properties": {
"actions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {"type": "string"},
"assignees": {
"type": "array",
"items": {"type": "string"},
"description": "Noms des personnes assignées",
},
"due_date": {
"type": "string",
"description": "Date d'échéance si mentionnée (si l'année nest pas précisée, ne pas l'ajouter)",
},
},
"required": ["title", "assignees"],
"additionalProperties": False,
},
}
},
"required": ["actions"],
"additionalProperties": False,
},
"strict": True,
},
}
FORMAT_PLAN = {
"type": "json_schema",
"json_schema": {
"name": "Titles",
"schema": {
"type": "object",
"properties": {"titles": {"type": "array", "items": {"type": "string"}}},
"required": ["titles"],
"additionalProperties": False,
},
"strict": True,
},
}