Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fe5b38d1a | |||
| bb9b1ac84b | |||
| 8d5488c333 | |||
| 5c0e6b6479 | |||
| 077cf59082 | |||
| 4881fa20f5 | |||
| 116db1e697 | |||
| 4b76e9571f | |||
| e8739d7e70 |
@@ -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
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# /!\
|
||||
# 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 }}
|
||||
+5
-2
@@ -22,13 +22,16 @@ and this project adheres to
|
||||
- 🚚(frontend) rename "wellknown" directory to "well-known" #1009
|
||||
- 🌐(frontend) localize SR modifier labels #1010
|
||||
- ⬆️(backend) update python dependencies #1011
|
||||
- ♿️(a11y) fix focus ring on tab container components
|
||||
- ♿️(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
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🩹(frontend) fix German language preference update #1021
|
||||
|
||||
|
||||
## [1.8.0] - 2026-02-20
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -17,13 +17,13 @@ classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Natural Language :: English",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
]
|
||||
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.10"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"boto3==1.42.49",
|
||||
"Brotli==1.2.0",
|
||||
|
||||
Generated
+854
-837
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,7 @@
|
||||
"livekit-client": "2.17.1",
|
||||
"posthog-js": "1.342.1",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.10.1",
|
||||
"react-aria-components": "1.14.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.1.1",
|
||||
"use-sound": "5.0.0",
|
||||
|
||||
@@ -4,6 +4,7 @@ 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: {
|
||||
@@ -144,6 +145,21 @@ 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[] = [
|
||||
{
|
||||
@@ -163,11 +179,45 @@ 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>
|
||||
<Container
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
aria-label={t('carouselLabel')}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
@@ -180,10 +230,10 @@ export const IntroSlider = () => {
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
square
|
||||
aria-label={t('previous.label')}
|
||||
tooltip={t('previous.tooltip')}
|
||||
onPress={() => setSlideIndex(slideIndex - 1)}
|
||||
isDisabled={slideIndex == 0}
|
||||
className={carouselNavButton}
|
||||
aria-label={previousAriaLabel}
|
||||
aria-disabled={slideIndex === 0}
|
||||
onPress={goPrev}
|
||||
>
|
||||
<RiArrowLeftSLine />
|
||||
</Button>
|
||||
@@ -191,7 +241,11 @@ export const IntroSlider = () => {
|
||||
</ButtonContainer>
|
||||
<SlideContainer>
|
||||
{SLIDES.map((slide, index) => (
|
||||
<Slide visible={index == slideIndex} key={index}>
|
||||
<Slide
|
||||
aria-hidden={index !== slideIndex}
|
||||
visible={index === slideIndex}
|
||||
key={index}
|
||||
>
|
||||
<Image src={slide.src} alt="" role="presentation" />
|
||||
<TextAnimation visible={index == slideIndex}>
|
||||
<Heading>{t(`${slide.key}.title`)}</Heading>
|
||||
@@ -205,10 +259,10 @@ export const IntroSlider = () => {
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
square
|
||||
aria-label={t('next.label')}
|
||||
tooltip={t('next.tooltip')}
|
||||
onPress={() => setSlideIndex(slideIndex + 1)}
|
||||
isDisabled={slideIndex == NUMBER_SLIDES - 1}
|
||||
className={carouselNavButton}
|
||||
aria-label={nextAriaLabel}
|
||||
aria-disabled={slideIndex === NUMBER_SLIDES - 1}
|
||||
onPress={goNext}
|
||||
>
|
||||
<RiArrowRightSLine />
|
||||
</Button>
|
||||
|
||||
@@ -5,37 +5,42 @@ 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={(data) => {
|
||||
navigateTo(
|
||||
'room',
|
||||
(data.roomId as string)
|
||||
.trim()
|
||||
.replace(`${window.location.origin}/`, '')
|
||||
)
|
||||
}}
|
||||
submitLabel={t('joinInputSubmit')}
|
||||
>
|
||||
<Form onSubmit={handleSubmit} submitLabel={t('joinInputSubmit')}>
|
||||
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */}
|
||||
<Field
|
||||
type="text"
|
||||
autoFocus
|
||||
isRequired
|
||||
name="roomId"
|
||||
label={t('joinInputLabel')}
|
||||
description={t('joinInputExample', {
|
||||
example: window.origin + '/azer-tyu-qsdf',
|
||||
})}
|
||||
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
|
||||
}}
|
||||
validate={validateRoomId}
|
||||
/>
|
||||
</Form>
|
||||
<H lvl={2}>{t('joinMeetingTipHeading')}</H>
|
||||
|
||||
@@ -5,6 +5,7 @@ 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'
|
||||
|
||||
@@ -21,6 +22,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{showHeader && <SkipLink />}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
@@ -35,6 +37,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
|
||||
>
|
||||
{showHeader && <Header />}
|
||||
<main
|
||||
id={MAIN_CONTENT_ID}
|
||||
className={css({
|
||||
flexGrow: 1,
|
||||
overflow: 'auto',
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -52,6 +52,7 @@
|
||||
"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}}"
|
||||
|
||||
@@ -31,12 +31,14 @@
|
||||
},
|
||||
"introSlider": {
|
||||
"previous": {
|
||||
"label": "Zurück",
|
||||
"tooltip": "Zurück"
|
||||
"label": "Vorherige Folie",
|
||||
"labelWithPosition": "Vorherige Folie ({{current}} von {{total}})",
|
||||
"tooltip": "Vorherige Folie"
|
||||
},
|
||||
"next": {
|
||||
"label": "Weiter",
|
||||
"tooltip": "Weiter"
|
||||
"label": "Nächste Folie",
|
||||
"labelWithPosition": "Nächste Folie ({{current}} von {{total}})",
|
||||
"tooltip": "Nächste Folie"
|
||||
},
|
||||
"beta": {
|
||||
"text": "An der Beta teilnehmen",
|
||||
@@ -53,6 +55,8 @@
|
||||
"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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"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}}"
|
||||
|
||||
@@ -31,12 +31,14 @@
|
||||
},
|
||||
"introSlider": {
|
||||
"previous": {
|
||||
"label": "previous",
|
||||
"tooltip": "previous"
|
||||
"label": "Previous slide",
|
||||
"labelWithPosition": "Previous slide ({{current}} of {{total}})",
|
||||
"tooltip": "Previous slide"
|
||||
},
|
||||
"next": {
|
||||
"label": "next",
|
||||
"tooltip": "next"
|
||||
"label": "Next slide",
|
||||
"labelWithPosition": "Next slide ({{current}} of {{total}})",
|
||||
"tooltip": "Next slide"
|
||||
},
|
||||
"beta": {
|
||||
"text": "Join the beta",
|
||||
@@ -53,6 +55,8 @@
|
||||
"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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"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}}"
|
||||
|
||||
@@ -30,14 +30,18 @@
|
||||
}
|
||||
},
|
||||
"introSlider": {
|
||||
"carouselLabel": "Diaporama de présentation",
|
||||
"previous": {
|
||||
"label": "précédent",
|
||||
"tooltip": "précédent"
|
||||
"label": "Diapositive précédente",
|
||||
"labelWithPosition": "Diapositive précédente ({{current}} sur {{total}})",
|
||||
"tooltip": "Diapositive précédente"
|
||||
},
|
||||
"next": {
|
||||
"label": "suivant",
|
||||
"tooltip": "suivant"
|
||||
"label": "Diapositive suivante",
|
||||
"labelWithPosition": "Diapositive suivante ({{current}} sur {{total}})",
|
||||
"tooltip": "Diapositive suivante"
|
||||
},
|
||||
"slidePosition": "Diapositive {{current}} sur {{total}}",
|
||||
"beta": {
|
||||
"text": "Essayer la beta",
|
||||
"tooltip": "Accéder au formulaire"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"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}}"
|
||||
|
||||
@@ -31,12 +31,14 @@
|
||||
},
|
||||
"introSlider": {
|
||||
"previous": {
|
||||
"label": "vorige",
|
||||
"tooltip": "vorige"
|
||||
"label": "Vorige dia",
|
||||
"labelWithPosition": "Vorige dia ({{current}} van {{total}})",
|
||||
"tooltip": "Vorige dia"
|
||||
},
|
||||
"next": {
|
||||
"label": "volgende",
|
||||
"tooltip": "volgende"
|
||||
"label": "Volgende dia",
|
||||
"labelWithPosition": "Volgende dia ({{current}} van {{total}})",
|
||||
"tooltip": "Volgende dia"
|
||||
},
|
||||
"beta": {
|
||||
"text": "Word lid van de bèta",
|
||||
@@ -53,6 +55,8 @@
|
||||
"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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ 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]
|
||||
|
||||
@@ -112,16 +112,19 @@ class MetadataManager:
|
||||
if self._is_disabled or self.has_task_id(task_id):
|
||||
return
|
||||
|
||||
_, filename, email, _, received_at, *_ = task_args
|
||||
|
||||
start_time = time.time()
|
||||
initial_metadata = {
|
||||
"start_time": start_time,
|
||||
"start_time": time.time(),
|
||||
"asr_model": settings.whisperx_asr_model,
|
||||
"retries": 0,
|
||||
}
|
||||
|
||||
_, filename, email, _, received_at, *_ = task_args
|
||||
|
||||
initial_metadata = {
|
||||
**initial_metadata,
|
||||
"filename": filename,
|
||||
"email": email,
|
||||
"queuing_time": round(start_time - received_at, 2),
|
||||
"queuing_time": round(initial_metadata["start_time"] - received_at, 2),
|
||||
}
|
||||
|
||||
self._save_metadata(task_id, initial_metadata)
|
||||
|
||||
@@ -10,7 +10,9 @@ import openai
|
||||
import sentry_sdk
|
||||
from celery import Celery, signals
|
||||
from celery.utils.log import get_task_logger
|
||||
from requests import exceptions
|
||||
from requests import Session, exceptions
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
|
||||
from summary.core.analytics import MetadataManager, get_analytics
|
||||
from summary.core.config import get_settings
|
||||
@@ -28,7 +30,6 @@ from summary.core.prompt import (
|
||||
PROMPT_USER_PART,
|
||||
)
|
||||
from summary.core.transcript_formatter import TranscriptFormatter
|
||||
from summary.core.webhook_service import submit_content
|
||||
|
||||
settings = get_settings()
|
||||
analytics = get_analytics()
|
||||
@@ -55,17 +56,103 @@ if settings.sentry_dsn and settings.sentry_is_enabled:
|
||||
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
|
||||
|
||||
|
||||
file_service = FileService()
|
||||
file_service = FileService(logger=logger)
|
||||
|
||||
|
||||
def transcribe_audio(task_id, filename, language):
|
||||
"""Transcribe an audio file using WhisperX.
|
||||
def create_retry_session():
|
||||
"""Create an HTTP session configured with retry logic."""
|
||||
session = Session()
|
||||
retries = Retry(
|
||||
total=settings.webhook_max_retries,
|
||||
backoff_factor=settings.webhook_backoff_factor,
|
||||
status_forcelist=settings.webhook_status_forcelist,
|
||||
allowed_methods={"POST"},
|
||||
)
|
||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
return session
|
||||
|
||||
Downloads the audio from MinIO, 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.
|
||||
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 post_with_retries(url, data):
|
||||
"""Send POST request with automatic retries."""
|
||||
session = create_retry_session()
|
||||
session.headers.update(
|
||||
{"Authorization": f"Bearer {settings.webhook_api_token.get_secret_value()}"}
|
||||
)
|
||||
try:
|
||||
response = session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@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 performs the following operations:
|
||||
1. Retrieves the audio file from MinIO storage
|
||||
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",
|
||||
owner_id,
|
||||
room,
|
||||
)
|
||||
|
||||
task_id = self.request.id
|
||||
|
||||
logger.info("Initiating WhisperX client")
|
||||
whisperx_client = openai.OpenAI(
|
||||
api_key=settings.whisperx_api_key.get_secret_value(),
|
||||
@@ -75,7 +162,9 @@ def transcribe_audio(task_id, filename, language):
|
||||
|
||||
# Transcription
|
||||
try:
|
||||
with file_service.prepare_audio_file(filename) as (audio_file, metadata):
|
||||
with (
|
||||
file_service.prepare_audio_file(filename) as (audio_file, metadata),
|
||||
):
|
||||
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
|
||||
|
||||
if language is None:
|
||||
@@ -106,32 +195,16 @@ def transcribe_audio(task_id, filename, language):
|
||||
|
||||
except FileServiceException:
|
||||
logger.exception("Unexpected error for filename: %s", filename)
|
||||
return None
|
||||
return
|
||||
|
||||
metadata_manager.track_transcription_metadata(task_id, transcription)
|
||||
return transcription
|
||||
|
||||
|
||||
def format_transcript(
|
||||
transcription,
|
||||
context_language,
|
||||
language,
|
||||
room,
|
||||
recording_date,
|
||||
recording_time,
|
||||
download_link,
|
||||
):
|
||||
"""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.
|
||||
"""
|
||||
# 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)
|
||||
|
||||
return formatter.format(
|
||||
content, title = formatter.format(
|
||||
transcription,
|
||||
room=room,
|
||||
recording_date=recording_date,
|
||||
@@ -139,90 +212,32 @@ def format_transcript(
|
||||
download_link=download_link,
|
||||
)
|
||||
|
||||
data = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"email": email,
|
||||
"sub": sub,
|
||||
}
|
||||
|
||||
def format_actions(llm_output: dict) -> str:
|
||||
"""Format the actions from the LLM output into a markdown list.
|
||||
logger.debug("Submitting webhook to %s", settings.webhook_url)
|
||||
logger.debug("Request payload: %s", json.dumps(data, indent=2))
|
||||
|
||||
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 ""
|
||||
response = post_with_retries(settings.webhook_url, data)
|
||||
|
||||
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
|
||||
|
||||
@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,
|
||||
"Webhook success | Document %s submitted (HTTP %s)",
|
||||
document_id,
|
||||
response.status_code,
|
||||
)
|
||||
logger.debug("Full response: %s", response_data)
|
||||
|
||||
task_id = self.request.id
|
||||
|
||||
transcription = transcribe_audio(task_id, filename, 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
|
||||
@@ -291,11 +306,12 @@ def summarize_transcription(
|
||||
# a singleton client. This is a performance trade-off we accept to ensure per-user
|
||||
# privacy controls in observability traces.
|
||||
llm_observability = LLMObservability(
|
||||
logger=logger,
|
||||
user_has_tracing_consent=user_has_tracing_consent,
|
||||
session_id=self.request.id,
|
||||
user_id=owner_id,
|
||||
)
|
||||
llm_service = LLMService(llm_observability=llm_observability)
|
||||
llm_service = LLMService(llm_observability=llm_observability, logger=logger)
|
||||
|
||||
tldr = llm_service.call(PROMPT_SYSTEM_TLDR, transcript, name="tldr")
|
||||
|
||||
@@ -338,9 +354,20 @@ def summarize_transcription(
|
||||
logger.info("Summary cleaned")
|
||||
|
||||
summary = tldr + "\n\n" + cleaned_summary + "\n\n" + next_steps
|
||||
summary_title = settings.summary_title_template.format(title=title)
|
||||
|
||||
submit_content(summary, summary_title, email, sub)
|
||||
data = {
|
||||
"title": settings.summary_title_template.format(title=title),
|
||||
"content": summary,
|
||||
"email": email,
|
||||
"sub": sub,
|
||||
}
|
||||
|
||||
logger.debug("Submitting webhook to %s", settings.webhook_url)
|
||||
|
||||
response = post_with_retries(settings.webhook_url, data)
|
||||
|
||||
logger.info("Webhook submitted successfully. Status: %s", response.status_code)
|
||||
logger.debug("Response body: %s", response.text)
|
||||
|
||||
llm_observability.flush()
|
||||
logger.debug("LLM observability flushed")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""File service to encapsulate files' manipulations."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -16,9 +15,6 @@ from summary.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileServiceException(Exception):
|
||||
"""Base exception for file service operations."""
|
||||
|
||||
@@ -28,8 +24,10 @@ class FileServiceException(Exception):
|
||||
class FileService:
|
||||
"""Service for downloading and preparing files from MinIO storage."""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, logger):
|
||||
"""Initialize FileService with MinIO client and configuration."""
|
||||
self._logger = logger
|
||||
|
||||
endpoint = (
|
||||
settings.aws_s3_endpoint_url.removeprefix("https://")
|
||||
.removeprefix("http://")
|
||||
@@ -55,16 +53,16 @@ class FileService:
|
||||
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)
|
||||
self._logger.info("Download recording | object_key: %s", remote_object_key)
|
||||
|
||||
if not remote_object_key:
|
||||
logger.warning("Invalid object_key '%s'", remote_object_key)
|
||||
self._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)
|
||||
self._logger.warning("Invalid file extension '%s'", extension)
|
||||
raise ValueError(f"Invalid file extension '{extension}'")
|
||||
|
||||
response = None
|
||||
@@ -83,8 +81,8 @@ class FileService:
|
||||
tmp.flush()
|
||||
local_path = Path(tmp.name)
|
||||
|
||||
logger.info("Recording successfully downloaded")
|
||||
logger.debug("Recording local file path: %s", local_path)
|
||||
self._logger.info("Recording successfully downloaded")
|
||||
self._logger.debug("Recording local file path: %s", local_path)
|
||||
|
||||
return local_path
|
||||
|
||||
@@ -102,7 +100,7 @@ class FileService:
|
||||
file_metadata = mutagen.File(local_path).info
|
||||
duration = file_metadata.length
|
||||
|
||||
logger.info(
|
||||
self._logger.info(
|
||||
"Recording file duration: %.2f seconds",
|
||||
duration,
|
||||
)
|
||||
@@ -111,14 +109,14 @@ class FileService:
|
||||
error_msg = "Recording too long. Limit is %.2fs seconds" % (
|
||||
self._max_duration,
|
||||
)
|
||||
logger.error(error_msg)
|
||||
self._logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
return duration
|
||||
|
||||
def _extract_audio_from_video(self, video_path: Path) -> Path:
|
||||
"""Extract audio from video file (e.g., MP4) and save as audio file."""
|
||||
logger.info("Extracting audio from video file: %s", video_path)
|
||||
self._logger.info("Extracting audio from video file: %s", video_path)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".m4a", delete=False, prefix="audio_extract_"
|
||||
@@ -142,16 +140,16 @@ class FileService:
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True
|
||||
)
|
||||
|
||||
logger.info("Audio successfully extracted to: %s", output_path)
|
||||
self._logger.info("Audio successfully extracted to: %s", output_path)
|
||||
return output_path
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.error("ffmpeg not found. Please install ffmpeg.")
|
||||
self._logger.error("ffmpeg not found. Please install ffmpeg.")
|
||||
if output_path.exists():
|
||||
os.remove(output_path)
|
||||
raise RuntimeError("ffmpeg is not installed or not in PATH") from e
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error("Audio extraction failed: %s", e.stderr.decode())
|
||||
self._logger.error("Audio extraction failed: %s", e.stderr.decode())
|
||||
if output_path.exists():
|
||||
os.remove(output_path)
|
||||
raise RuntimeError("Failed to extract audio.") from e
|
||||
@@ -175,7 +173,7 @@ class FileService:
|
||||
extension = downloaded_path.suffix.lower()
|
||||
|
||||
if extension in settings.recording_video_extensions:
|
||||
logger.info("Video file detected, extracting audio...")
|
||||
self._logger.info("Video file detected, extracting audio...")
|
||||
extracted_audio_path = self._extract_audio_from_video(downloaded_path)
|
||||
processed_path = extracted_audio_path
|
||||
else:
|
||||
@@ -196,6 +194,8 @@ class FileService:
|
||||
|
||||
try:
|
||||
os.remove(path)
|
||||
logger.debug("Temporary file removed: %s", path)
|
||||
self._logger.debug("Temporary file removed: %s", path)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove temporary file %s: %s", path, e)
|
||||
self._logger.warning(
|
||||
"Failed to remove temporary file %s: %s", path, e
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""LLM service to encapsulate LLM's calls."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
import openai
|
||||
@@ -11,9 +10,6 @@ from summary.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMObservability:
|
||||
"""Manage observability and tracing for LLM calls using Langfuse.
|
||||
|
||||
@@ -25,11 +21,13 @@ class LLMObservability:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
logger,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
user_has_tracing_consent: bool = False,
|
||||
):
|
||||
"""Initialize the LLMObservability client."""
|
||||
self._logger = logger
|
||||
self._observability_client: Optional[Langfuse] = None
|
||||
self.session_id = session_id
|
||||
self.user_id = user_id
|
||||
@@ -77,7 +75,7 @@ class LLMObservability:
|
||||
}
|
||||
|
||||
if not self.is_enabled:
|
||||
logger.debug("Using regular OpenAI client (observability disabled)")
|
||||
self._logger.debug("Using regular OpenAI client (observability disabled)")
|
||||
return openai.OpenAI(**base_args)
|
||||
|
||||
# Langfuse's OpenAI wrapper is imported here to avoid triggering client
|
||||
@@ -85,7 +83,7 @@ class LLMObservability:
|
||||
# is missing. Conditional import ensures Langfuse only initializes when enabled.
|
||||
from langfuse.openai import openai as langfuse_openai # noqa: PLC0415
|
||||
|
||||
logger.debug("Using LangfuseOpenAI client (observability enabled)")
|
||||
self._logger.debug("Using LangfuseOpenAI client (observability enabled)")
|
||||
return langfuse_openai.OpenAI(**base_args)
|
||||
|
||||
def flush(self):
|
||||
@@ -101,10 +99,11 @@ class LLMException(Exception):
|
||||
class LLMService:
|
||||
"""Service for performing calls to the LLM configured in the settings."""
|
||||
|
||||
def __init__(self, llm_observability):
|
||||
def __init__(self, llm_observability, logger):
|
||||
"""Init the LLMService once."""
|
||||
self._client = llm_observability.get_openai_client()
|
||||
self._observability = llm_observability
|
||||
self._logger = logger
|
||||
|
||||
def call(
|
||||
self,
|
||||
@@ -141,5 +140,5 @@ class LLMService:
|
||||
return response.choices[0].message.content
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("LLM call failed: %s", e)
|
||||
self._logger.exception("LLM call failed: %s", e)
|
||||
raise LLMException(f"LLM call failed: {e}") from e
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Service for delivering content to external destinations."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from requests import Session
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _create_retry_session():
|
||||
"""Create an HTTP session configured with retry logic."""
|
||||
session = Session()
|
||||
retries = Retry(
|
||||
total=settings.webhook_max_retries,
|
||||
backoff_factor=settings.webhook_backoff_factor,
|
||||
status_forcelist=settings.webhook_status_forcelist,
|
||||
allowed_methods={"POST"},
|
||||
)
|
||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
return session
|
||||
|
||||
|
||||
def _post_with_retries(url, data):
|
||||
"""Send POST request with automatic retries."""
|
||||
session = _create_retry_session()
|
||||
session.headers.update(
|
||||
{"Authorization": f"Bearer {settings.webhook_api_token.get_secret_value()}"}
|
||||
)
|
||||
try:
|
||||
response = session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def submit_content(content, title, email, sub):
|
||||
"""Submit content to the configured webhook destination.
|
||||
|
||||
Builds the payload, sends it with retries, and logs the outcome.
|
||||
"""
|
||||
data = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"email": email,
|
||||
"sub": sub,
|
||||
}
|
||||
|
||||
logger.debug("Submitting to %s", settings.webhook_url)
|
||||
logger.debug("Request payload: %s", json.dumps(data, indent=2))
|
||||
|
||||
response = _post_with_retries(settings.webhook_url, data)
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user