Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 4d8bb242dc ⬆️(livekit) upgrade local LiveKit server to version 1.9.0
Update development environment LiveKit server from previous version
to 1.9.0 for latest features and bug fixes.

Ensures development environment stays current
with LiveKit production version.
2025-09-09 14:32:58 +02:00
55 changed files with 385 additions and 2174 deletions
+1 -86
View File
@@ -45,7 +45,6 @@ COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
WAIT_MINIO = @$(COMPOSE_RUN) dockerize -wait tcp://minio:9000 -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -54,21 +53,6 @@ MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
# -- Frontend
PATH_FRONT = ./src/frontend
# -- MinIO / Webhook
MINIO_ALIAS ?= meet
MINIO_ENDPOINT ?= http://127.0.0.1:9000
MINIO_ACCESS_KEY ?= meet
MINIO_SECRET_KEY ?= password
MINIO_BUCKET ?= meet-media-storage
MINIO_WEBHOOK_NAME ?= recording
MINIO_WEBHOOK_ENDPOINT ?= http://app-dev:8000/api/v1.0/recordings/storage-hook/
MINIO_QUEUE_DIR ?= /data/minio/events
MINIO_QUEUE_LIMIT ?= 100000
STORAGE_EVENT_TOKEN ?= password
# ==============================================================================
# RULES
@@ -87,8 +71,7 @@ create-env-files: \
env.d/development/common \
env.d/development/crowdin \
env.d/development/postgresql \
env.d/development/kc_postgresql \
env.d/development/summary
env.d/development/kc_postgresql
.PHONY: create-env-files
bootstrap: ## Prepare Docker images for the project
@@ -108,7 +91,6 @@ bootstrap: \
build: ## build the project containers
@$(MAKE) build-backend
@$(MAKE) build-frontend
@$(MAKE) build-agents
.PHONY: build
build-backend: ## build the app-dev container
@@ -120,10 +102,6 @@ build-frontend: ## build the frontend container
@$(COMPOSE) build frontend
.PHONY: build-frontend
build-agents: ## build the agents image(s)
@$(COMPOSE) build metadata-agent
.PHONY: build-agents
down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
.PHONY: down
@@ -138,20 +116,9 @@ run-backend: ## start only the backend application and all needed services
@$(WAIT_DB)
.PHONY: run-backend
run-summary: ## start only the summary application and all needed services
@$(COMPOSE) up --force-recreate -d celery-summary-transcribe
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
.PHONY: run-summary
run-agents: ## start agents
@$(COMPOSE) up --force-recreate -d metadata-agent
.PHONY: run-agents
run:
run: ## start the wsgi (production) and development server
@$(MAKE) run-backend
@$(MAKE) run-summary
@$(MAKE) run-agents
@$(COMPOSE) up --force-recreate -d frontend
.PHONY: run
@@ -163,55 +130,6 @@ stop: ## stop the development server using Docker
@$(COMPOSE) stop
.PHONY: stop
# -- MinIO webhook (configuration & events)
minio-wait: ## wait for minio to be ready
@echo "$(BOLD)Waiting for MinIO$(RESET)"
$(WAIT_MINIO)
.PHONY: minio-wait
minio-queue-dir: minio-wait ## ensure queue dir exists in MinIO container
@echo "$(BOLD)Ensuring MinIO queue dir$(RESET)"
@$(COMPOSE) exec minio sh -lc 'mkdir -p $(MINIO_QUEUE_DIR) && chmod -R 777 $(dir $(MINIO_QUEUE_DIR)) || true'
.PHONY: minio-queue-dir
minio-alias: minio-wait ## set mc alias to MinIO
@echo "$(BOLD)Setting mc alias $(MINIO_ALIAS) -> $(MINIO_ENDPOINT)$(RESET)"
@mc alias set $(MINIO_ALIAS) $(MINIO_ENDPOINT) $(MINIO_ACCESS_KEY) $(MINIO_SECRET_KEY) >/dev/null
.PHONY: minio-alias
minio-webhook-config: minio-alias minio-queue-dir ## configure webhook on MinIO
@echo "$(BOLD)Configuring MinIO webhook $(MINIO_WEBHOOK_NAME)$(RESET)"
@mc admin config set $(MINIO_ALIAS) notify_webhook:$(MINIO_WEBHOOK_NAME) \
endpoint="$(MINIO_WEBHOOK_ENDPOINT)" \
auth_token="Bearer $(STORAGE_EVENT_TOKEN)" \
queue_dir="$(MINIO_QUEUE_DIR)" \
queue_limit="$(MINIO_QUEUE_LIMIT)"
.PHONY: minio-webhook-config
minio-restart: minio-alias ## restart MinIO after config change
@echo "$(BOLD)Restarting MinIO service$(RESET)"
@mc admin service restart $(MINIO_ALIAS)
.PHONY: minio-restart
minio-events-reset: minio-alias ## remove all bucket notifications
@echo "$(BOLD)Removing existing bucket events on $(MINIO_BUCKET)$(RESET)"
@mc event remove --force $(MINIO_ALIAS)/$(MINIO_BUCKET) >/dev/null || true
.PHONY: minio-events-reset
minio-event-add: minio-alias ## add put event -> webhook
@echo "$(BOLD)Adding put event -> webhook $(MINIO_WEBHOOK_NAME)$(RESET)"
@mc event add $(MINIO_ALIAS)/$(MINIO_BUCKET) arn:minio:sqs::$(MINIO_WEBHOOK_NAME):webhook --event put
@mc event list $(MINIO_ALIAS)/$(MINIO_BUCKET)
.PHONY: minio-event-add
minio-webhook-setup: ## full setup: alias, config, restart, reset events, add event
minio-webhook-setup: \
minio-webhook-config \
minio-restart \
minio-events-reset \
minio-event-add
.PHONY: minio-webhook-setup
# -- Front
frontend-development-install: ## install the frontend locally
@@ -328,9 +246,6 @@ env.d/development/postgresql:
env.d/development/kc_postgresql:
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
env.d/development/summary:
cp -n env.d/development/summary.dist env.d/development/summary
# -- Internationalization
env.d/development/crowdin:
-97
View File
@@ -221,100 +221,3 @@ services:
- ./docker/livekit/out:/out
depends_on:
- redis
redis-summary:
image: redis
ports:
- "6379:6379"
app-summary-dev:
build:
context: src/summary
target: development
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
env_file:
- env.d/development/summary
ports:
- "8001:8000"
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
celery-summary-transcribe:
container_name: celery-summary-transcribe
build:
context: ./src/summary
dockerfile: Dockerfile
target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue
env_file:
- env.d/development/summary
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- app-summary-dev
- minio
develop:
watch:
- action: rebuild
path: ./src/summary
celery-summary-summarize:
container_name: celery-summary-summarize
build:
context: ./src/summary
dockerfile: Dockerfile
target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue
env_file:
- env.d/development/summary
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- app-summary-dev
- minio
develop:
watch:
- action: rebuild
path: ./src/summary
metadata-agent:
build:
context: ./src/agents
dockerfile: Dockerfile
working_dir: /app
command: ["python", "metadata_extractor.py", "start"]
environment:
LIVEKIT_URL: "ws://livekit:7880"
LIVEKIT_API_KEY: "devkey"
LIVEKIT_API_SECRET: "secret"
ROOM_METADATA_AGENT_NAME: "metadata-extractor"
AWS_S3_ENDPOINT_URL: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet"
AWS_S3_SECRET_ACCESS_KEY: "password"
AWS_S3_SECURE_ACCESS: "false"
PYTHONUNBUFFERED: "1"
AWS_STORAGE_BUCKET_NAME: "meet-media-storage"
AWS_S3_REGION_NAME: "local"
depends_on:
- livekit
- minio
transcriber-agent:
build:
context: ./src/agents
dockerfile: Dockerfile
working_dir: /app
command: ["python", "multi-user-transcriber.py", "start"]
environment:
LIVEKIT_URL: "ws://livekit:7880"
LIVEKIT_API_KEY: "devkey"
LIVEKIT_API_SECRET: "secret"
PYTHONUNBUFFERED: "1"
depends_on:
- livekit
@@ -1,11 +1,5 @@
log_level: debug
redis:
address: redis:6379
keys:
devkey: secret
webhook:
api_key: devkey
urls:
- "http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/"
+1 -1
View File
@@ -142,4 +142,4 @@ Once the Kubernetes cluster is ready, start the application stack locally:
$ make start-tilt-keycloak
```
Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://meet.127.0.0.1.nip.io/](https://meet.127.0.0.1.nip.io/).
Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://visio.127.0.0.1.nip.io/](https://visio.127.0.0.1.nip.io/).
-5
View File
@@ -53,11 +53,6 @@ LIVEKIT_VERIFY_SSL=False
ALLOW_UNREGISTERED_ROOMS=False
# Recording
RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
# Telephony
-23
View File
@@ -1,23 +0,0 @@
APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_SECURE_ACCESS=false
AWS_S3_ACCESS_KEY_ID="meet"
AWS_S3_SECRET_ACCESS_KEY="password"
WHISPERX_BASE_URL="https://configure-your-url.com"
WHISPERX_ASR_MODEL="large-v2"
WHISPERX_API_KEY="your-secret-key"
LLM_BASE_URL="https://configure-your-url.com"
LLM_API_KEY="dev-apikey"
LLM_MODEL="Qwen/Qwen2.5-Coder-32B-Instruct-AWQ"
WEBHOOK_API_TOKEN="secret"
WEBHOOK_URL="https://configure-your-url.com"
POSTHOG_API_KEY="your-posthog-key"
POSTHOG_ENABLED="False"
+3 -1
View File
@@ -19,4 +19,6 @@ USER ${DOCKER_USER}
# Un-privileged user running the application
COPY --from=builder /install /usr/local
COPY . .
COPY . .
CMD ["python", "multi-user-transcriber.py", "start"]
-395
View File
@@ -1,395 +0,0 @@
"""Metadata agent that extracts metadata from active room."""
import asyncio
import json
import logging
import os
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from io import BytesIO
from typing import List, Optional
from dotenv import find_dotenv, load_dotenv
from livekit import api, rtc
from livekit.agents import (
Agent,
AgentSession,
AutoSubscribe,
JobContext,
JobProcess,
JobRequest,
RoomInputOptions,
RoomIO,
RoomOutputOptions,
WorkerOptions,
WorkerPermissions,
cli,
utils,
)
from livekit.plugins import silero
from minio import Minio
from minio.error import S3Error
load_dotenv()
logger = logging.getLogger("metadata-extractor")
AGENT_NAME = os.getenv("ROOM_METADATA_EXTRACTOR_AGENT_NAME", "metadata-extractor")
@dataclass
class MetadataEvent:
"""Wip."""
participant_id: str
type: str
timestamp: datetime
data: Optional[str] = None
def serialize(self) -> dict:
"""Return a JSON-serializable dictionary representation of the event."""
data = asdict(self)
data["timestamp"] = self.timestamp.isoformat()
return data
class VADAgent(Agent):
"""Agent that monitors voice activity for a specific participant."""
def __init__(self, participant_identity: str, events: List):
"""Wip."""
super().__init__(
instructions="not-needed",
)
self.participant_identity = participant_identity
self.events = events
async def on_enter(self) -> None:
"""Initialize VAD monitoring for this participant."""
@self.session.on("user_state_changed")
def on_user_state(event):
timestamp = datetime.now(timezone.utc)
if event.new_state == "speaking":
event = MetadataEvent(
participant_id=self.participant_identity,
type="speech_start",
timestamp=timestamp,
)
self.events.append(event)
elif event.old_state == "speaking":
event = MetadataEvent(
participant_id=self.participant_identity,
type="speech_end",
timestamp=timestamp,
)
self.events.append(event)
class MetadataAgent:
"""Monitor and manage real-time metadata extraction from meeting rooms.
Oversees VAD (Voice Activity Detection) and participant metadata streams
to track and analyze real-time events, coordinating data collection across
participants for insights like speaking activity and engagement.
"""
def __init__(self, ctx: JobContext, recording_id: str):
"""Initialize metadata agent."""
self.minio_client = Minio(
endpoint=os.getenv("AWS_S3_ENDPOINT_URL"),
access_key=os.getenv("AWS_S3_ACCESS_KEY_ID"),
secret_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
secure=os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true",
)
# todo - raise error if none
self.bucket_name = os.getenv("AWS_STORAGE_BUCKET_NAME")
logger.info("Using S3 bucket: %s", self.bucket_name)
dotenv_path = find_dotenv()
logger.info(f"Fichier .env trouvé : {dotenv_path}")
self.ctx = ctx
self._sessions: dict[str, AgentSession] = {}
self._tasks: set[asyncio.Task] = set()
self.output_filename = (
f"{os.getenv('AWS_S3_OUTPUT_FOLDER', 'recordings').strip('/')}/"
f"{json.loads(recording_id).get('recording_id')}-metadata.json"
)
# Storage for events
self.events = []
self.participants = {}
logger.info("MetadataAgent initialized")
def start(self):
"""Start listening for participant connection events."""
self.ctx.room.on("participant_connected", self.on_participant_connected)
self.ctx.room.on("participant_disconnected", self.on_participant_disconnected)
self.ctx.room.on("participant_name_changed", self.on_participant_name_changed)
self.ctx.room.register_text_stream_handler("lk.chat", self.handle_chat_stream)
logger.info("Started listening for participant events")
async def on_chat_message_received(
self, reader: rtc.TextStreamReader, participant_identity: str
):
"""Wip."""
full_text = await reader.read_all()
logger.info(
"Received chat message from %s: '%s'", participant_identity, full_text
)
self.events.append(
MetadataEvent(
participant_id=participant_identity,
type="chat_received",
timestamp=datetime.now(timezone.utc),
data=full_text,
)
)
def handle_chat_stream(self, reader, participant_identity):
"""Wip."""
task = asyncio.create_task(
self.on_chat_message_received(reader, participant_identity)
)
self._tasks.add(task)
task.add_done_callback(lambda _: self._tasks.remove(task))
def save(self):
"""Wip."""
logger.info("Persisting processed metadata output to disk…")
participants = []
for k, v in self.participants.items():
participants.append({"participantId": k, "name": v})
sorted_event = sorted(self.events, key=lambda e: e.timestamp)
payload = {
"events": [event.serialize() for event in sorted_event],
"participants": participants,
}
data = json.dumps(payload, indent=2).encode("utf-8")
stream = BytesIO(data)
try:
self.minio_client.put_object(
self.bucket_name,
self.output_filename,
stream,
length=len(data),
content_type="application/json",
)
logger.info(
"Uploaded speaker meeting metadata",
)
except S3Error:
logger.exception(
"Failed to upload meeting metadata",
)
async def aclose(self):
"""Close all sessions and cleanup resources."""
logger.info("Closing all VAD monitoring sessions…")
await utils.aio.cancel_and_wait(*self._tasks)
await asyncio.gather(
*[self._close_session(session) for session in self._sessions.values()],
return_exceptions=True,
)
self.ctx.room.off("participant_connected", self.on_participant_connected)
self.ctx.room.off("participant_disconnected", self.on_participant_disconnected)
self.ctx.room.off("participant_name_changed", self.on_participant_name_changed)
logger.info("All VAD sessions closed")
self.save()
def on_participant_connected(self, participant: rtc.RemoteParticipant):
"""Handle new participant connection by starting VAD monitoring."""
if participant.identity in self._sessions:
logger.debug("Session already exists for %s", participant.identity)
return
self.events.append(
MetadataEvent(
participant_id=participant.identity,
type="participant_connected",
timestamp=datetime.now(timezone.utc),
)
)
self.participants[participant.identity] = participant.name
logger.info("New participant connected: %s", participant.identity)
task = asyncio.create_task(self._start_session(participant))
self._tasks.add(task)
def on_task_done(task: asyncio.Task):
try:
self._sessions[participant.identity] = task.result()
except Exception:
logger.exception("Failed to start session for %s", participant.identity)
finally:
self._tasks.discard(task)
task.add_done_callback(on_task_done)
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnection by closing VAD monitoring."""
self.events.append(
MetadataEvent(
participant_id=participant.identity,
type="participant_disconnected",
timestamp=datetime.now(timezone.utc),
)
)
session = self._sessions.pop(participant.identity, None)
if session is None:
logger.debug("No session found for %s", participant.identity)
return
logger.info("Participant disconnected: %s", participant.identity)
task = asyncio.create_task(self._close_session(session))
self._tasks.add(task)
def on_close_done(_):
self._tasks.discard(task)
logger.info(
"VAD session closed for %s (remaining sessions: %d)",
participant.identity,
len(self._sessions),
)
task.add_done_callback(on_close_done)
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
"""Wip."""
logger.info("Participant's name changed: %s", participant.identity)
self.participants[participant.identity] = participant.name
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
"""Create and start VAD monitoring session for participant."""
if participant.identity in self._sessions:
return self._sessions[participant.identity]
# Create session with VAD only - no STT, LLM, or TTS
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
turn_detection="vad",
user_away_timeout=30.0,
)
# Set up room IO to receive audio from this specific participant
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
participant=participant,
input_options=RoomInputOptions(
audio_enabled=True,
text_enabled=False,
),
output_options=RoomOutputOptions(
audio_enabled=False,
transcription_enabled=False,
),
)
await room_io.start()
await session.start(
agent=VADAgent(
participant_identity=participant.identity, events=self.events
)
)
return session
async def _close_session(self, session: AgentSession) -> None:
"""Close and cleanup VAD monitoring session."""
try:
await session.drain()
await session.aclose()
except Exception:
logger.exception("Error closing session")
async def entrypoint(ctx: JobContext):
"""Initialize and run the multi-user VAD monitor."""
logger.info("Starting metadata agent in room: %s", ctx.room.name)
recording_id = ctx.job.metadata
logger.info("Recording ID: %s", recording_id)
vad_monitor = MetadataAgent(ctx, recording_id)
vad_monitor.start()
# Connect to room and subscribe to audio only
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
existing_participants = list(ctx.room.remote_participants.values())
for participant in existing_participants:
vad_monitor.on_participant_connected(participant)
async def cleanup():
logger.info("Shutting down VAD monitor...")
await vad_monitor.aclose()
ctx.add_shutdown_callback(cleanup)
async def handle_job_request(job_req: JobRequest) -> None:
"""Accept or reject the job request based on agent presence in the room."""
room_name = job_req.room.name
recording_id = job_req.job.metadata
agent_identity = f"{AGENT_NAME}-{room_name}"
async with api.LiveKitAPI() as lk:
try:
resp = await lk.room.list_participants(
list=api.ListParticipantsRequest(room=room_name)
)
already_present = any(
p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
and p.identity == agent_identity
for p in resp.participants
)
if already_present:
logger.info("Agent already in the room '%s' — reject", room_name)
await job_req.reject()
else:
logger.info(
"Accept job for '%s' — identity=%s", room_name, agent_identity
)
await job_req.accept(identity=agent_identity, metadata=recording_id)
except Exception:
logger.exception("Error treating the job for '%s'", room_name)
await job_req.reject()
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
cli.run_app(
WorkerOptions(
entrypoint_fnc=entrypoint,
prewarm_fnc=prewarm,
request_fnc=handle_job_request,
agent_name=AGENT_NAME,
permissions=WorkerPermissions(
can_publish=False,
can_publish_data=False,
can_subscribe=True,
hidden=True,
),
)
)
+2 -3
View File
@@ -1,14 +1,13 @@
[project]
name = "agents"
version = "0.1.38"
version = "0.1.23"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.2.6",
"livekit-plugins-deepgram==1.2.6",
"livekit-plugins-silero==1.2.6",
"python-dotenv==1.1.1",
"minio==7.2.15",
"python-dotenv==1.1.1"
]
[project.optional-dependencies]
+10 -55
View File
@@ -1,75 +1,30 @@
"""Feature flag handler for the Meet core app."""
import logging
import os
from functools import wraps
from logging import getLogger
from django.conf import settings
from django.http import Http404
from posthog import Posthog
logging.basicConfig(level=logging.DEBUG)
logger = getLogger(__name__)
class FeatureFlagError(Exception):
"""Feature flag management error."""
class FeatureFlag:
"""Feature flag management using Django settings and PostHog."""
"""Check if features are enabled and return error responses."""
FLAGS = {
"metadata_agent": {"posthog": "is_metadata_agent_enabled"},
"recording": {"setting": "RECORDING_ENABLE"},
"storage_event": {"setting": "RECORDING_STORAGE_EVENT_ENABLE"},
"subtitle": {"setting": "ROOM_SUBTITLE_ENABLED"},
"recording": "RECORDING_ENABLE",
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
"subtitle": "ROOM_SUBTITLE_ENABLED",
}
_ph_client = None
@classmethod
def _get_ph_client(cls):
"""Initialize and return PostHog client if configured."""
if cls._ph_client is not None:
return cls._ph_client
api_key = os.getenv("POSTHOG_API_KEY")
host = os.getenv("POSTHOG_API_HOST", "https://eu.i.posthog.com")
logging.info("PostHog config: api_key=%s, host=%s", api_key, host)
if Posthog and api_key and host:
logging.info("Initializing PostHog client")
cls._ph_client = Posthog(project_api_key=api_key, host=host)
logging.info("PostHog client initialized: %s", bool(cls._ph_client))
return cls._ph_client
@classmethod
def flag_is_active(cls, flag_name, *, distinct_id=None, default=False):
def flag_is_active(cls, flag_name):
"""Check if a feature flag is active."""
cfg = cls.FLAGS.get(flag_name)
if not cfg:
setting_name = cls.FLAGS.get(flag_name)
if setting_name is None:
return False
setting_name = cfg.get("setting")
if setting_name is not None:
return bool(getattr(settings, setting_name, False))
posthog_flag = cfg.get("posthog")
if posthog_flag:
ph = cls._get_ph_client()
if ph and distinct_id:
try:
logger.info(
"Checking PostHog flag %s for id=%s", posthog_flag, distinct_id
)
return bool(ph.feature_enabled(posthog_flag, distinct_id))
except FeatureFlagError as e:
logging.error("Error checking feature flag %s: %s", flag_name, e)
return default
return default
return default
return getattr(settings, setting_name, False)
@classmethod
def require(cls, flag_name):
+1
View File
@@ -301,6 +301,7 @@ class RoomViewSet(
"""Start recording a room."""
serializer = serializers.StartRecordingSerializer(data=request.data)
if not serializer.is_valid():
return drf_response.Response(
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
@@ -1,29 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-25 15:14
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0014_room_pin_code'),
]
operations = [
migrations.AddField(
model_name='recording',
name='metadata_dispatch_id',
field=models.CharField(blank=True, max_length=100, null=True),
),
migrations.AlterField(
model_name='resource',
name='users',
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', through_fields=('resource', 'user'), to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices=[('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'), ('de-de', 'German')], default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
-1
View File
@@ -573,7 +573,6 @@ class Recording(BaseModel):
verbose_name=_("Recording mode"),
help_text=_("Defines the mode of recording being called."),
)
metadata_dispatch_id = models.CharField(max_length=100, null=True, blank=True)
class Meta:
db_table = "meet_recording"
@@ -132,8 +132,6 @@ class NotificationService:
logger.error("No owner found for recording %s", recording.id)
return False
worker_id = recording.worker_id
payload = {
"filename": recording.key,
"email": owner_access.user.email,
@@ -145,7 +143,6 @@ class NotificationService:
"recording_time": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%H:%M"),
"worker_id": worker_id,
}
headers = {
@@ -1,19 +1,8 @@
"""Recording-related LiveKit Events Service"""
import asyncio
import logging
from logging import getLogger
from django.core.exceptions import ObjectDoesNotExist
from django.db import DatabaseError, transaction
import aiohttp
from core import models, utils
from core.api.feature_flag import FeatureFlag
from core.services.metadata import MetadataService
logging.basicConfig(level=logging.DEBUG)
logger = getLogger(__name__)
@@ -22,21 +11,6 @@ class RecordingEventsError(Exception):
"""Recording event handling fails."""
def get_recording_creator_id(recording: models.Recording) -> str | None:
"""Get the user ID of the recording creator (owner)."""
owner_id = (
models.RecordingAccess.objects.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
user__isnull=False,
)
.order_by("created_at")
.values_list("user_id", flat=True)
.first()
)
return str(owner_id) if owner_id else None
class RecordingEventsService:
"""Handles recording-related Livekit webhook events."""
@@ -73,127 +47,3 @@ class RecordingEventsService:
f"Failed to notify participants in room '{recording.room.id}' about "
f"recording limit reached (recording_id={recording.id})"
) from e
@staticmethod
def handle_egress_started(recording):
"""Start metadata agent after transaction commit."""
rec_id = recording.id
room_id = recording.room_id
creator_id = get_recording_creator_id(recording)
if not FeatureFlag.flag_is_active("metadata_agent", distinct_id=creator_id):
logger.info("Metadata agent disabled by PostHog flag for id=%s", creator_id)
return
service = MetadataService()
logger.info(
"Scheduling metadata start for recording=%s room_id=%s", rec_id, room_id
)
def _start():
"""Start metadata agent after transaction commit."""
try:
rec = (
models.Recording.objects.select_related("room")
.only("id", "status", "room_id", "room__id")
.get(id=rec_id)
)
if rec.status not in (
models.RecordingStatusChoices.INITIATED,
models.RecordingStatusChoices.ACTIVE,
):
logger.info(
"Skip metadata start: status=%s (rec=%s)", rec.status, rec.id
)
return
room = rec.room
if room.pk != room_id:
logger.error(
"Room mismatch at start: rec.room_id=%s event.room_id=%s",
room.pk,
room_id,
)
return
dispatch_id = service.start_metadata(room, rec_id)
rec.metadata_dispatch_id = dispatch_id
rec.save(update_fields=["metadata_dispatch_id"])
logger.info(
"Metadata start dispatched for rec=%s room_id=%s", rec.id, room.pk
)
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
logger.warning(
"Controller unreachable during start (rec=%s, room_id=%s): %s",
rec_id,
room_id,
e,
exc_info=True,
)
transaction.on_commit(_start, robust=True)
@staticmethod
def handle_egress_ended(recording):
"""Stop metadata agent after transaction commit."""
service = MetadataService()
rec_id = recording.id
room_id = recording.room_id
creator_id = get_recording_creator_id(recording)
if not FeatureFlag.flag_is_active("metadata_agent", distinct_id=creator_id):
logger.info("Metadata agent disabled by PostHog flag for id=%s", creator_id)
return
def _stop():
try:
rec = (
models.Recording.objects.select_related("room")
.only("id", "room_id", "room__id")
.get(id=rec_id)
)
room = rec.room
if room.pk != room_id:
logger.error(
"Room mismatch at stop: rec.room_id=%s event.room_id=%s",
room.pk,
room_id,
)
return
try:
service.stop_metadata(room, rec.metadata_dispatch_id)
logger.info(
"Metadata stop dispatched for rec=%s room_id=%s",
rec.id,
room.pk,
)
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
logger.warning(
"Controller unreachable during stop (rec=%s room_id=%s): %s",
rec_id,
room_id,
e,
exc_info=True,
)
except ObjectDoesNotExist as e:
logger.warning(
"Recording or Room not found while stopping (rec=%s room_id=%s): %s",
rec_id,
room_id,
e,
exc_info=True,
)
except DatabaseError as e:
logger.warning(
"DB error while stopping metadata (rec=%s room_id=%s): %s",
rec_id,
room_id,
e,
exc_info=True,
)
transaction.on_commit(_stop, robust=True)
+3 -136
View File
@@ -2,8 +2,6 @@
# pylint: disable=no-member
import logging
import re
import uuid
from enum import Enum
from logging import getLogger
@@ -21,8 +19,6 @@ from core.recording.services.recording_events import (
from .lobby import LobbyService
from .telephony import TelephonyException, TelephonyService
logging.basicConfig(level=logging.DEBUG)
logger = getLogger(__name__)
@@ -81,30 +77,6 @@ class LiveKitWebhookEventType(Enum):
INGRESS_ENDED = "ingress_ended"
def _extract_recording_id_from_egress(data) -> str | None:
"""Try to extract recording_id from egress data filenames.
On failure, return None.
"""
ei = getattr(data, "egress_info", None)
if not ei:
return None
fname = getattr(getattr(ei, "file", None), "filename", None)
if not fname:
try:
outs = getattr(getattr(ei, "room_composite", None), "file_outputs", [])
if outs:
fname = getattr(outs[0], "filepath", None)
except (AttributeError, IndexError, TypeError):
fname = None
if not fname:
return None
m = re.search(r"([0-9a-fA-F-]{36})\.ogg$", fname)
return m.group(1) if m else None
class LiveKitEventsService:
"""Service for processing and handling LiveKit webhook events and notifications."""
@@ -122,7 +94,7 @@ class LiveKitEventsService:
def receive(self, request):
"""Process webhook and route to appropriate handler."""
logger.debug("LiveKit webhook received")
auth_token = request.headers.get("Authorization")
if not auth_token:
raise AuthenticationError("Authorization header missing")
@@ -146,17 +118,13 @@ class LiveKitEventsService:
if not handler or not callable(handler):
return
logger.debug("Handling LiveKit webhook event: %s", data)
# pylint: disable=not-callable
handler(data)
def _handle_egress_ended(self, data):
"""Handle 'egress_ended' event."""
logger.debug(
"Egress ended: id=%s status=%s",
getattr(data.egress_info, "egress_id", None),
getattr(data.egress_info, "status", None),
)
try:
recording = models.Recording.objects.get(
worker_id=data.egress_info.egress_id
@@ -166,13 +134,6 @@ class LiveKitEventsService:
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
) from err
try:
self.recording_events.handle_egress_ended(recording)
except RecordingEventsError as e:
raise ActionFailedError(
f"Failed to process egress_ended for recording {recording.id}"
) from e
if (
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
@@ -184,100 +145,6 @@ class LiveKitEventsService:
f"Failed to process limit reached event for recording {recording}"
) from e
def _handle_egress_started(self, data):
ei = getattr(data, "egress_info", None) or (
data.get("egress_info") if isinstance(data, dict) else None
)
egress_id = None
room_name = None
status = None
if ei:
egress_id = getattr(ei, "egress_id", None) or getattr(ei, "egressId", None)
room_name = getattr(ei, "room_name", None) or getattr(ei, "roomName", None)
status = getattr(ei, "status", None)
logger.warning(
"egress_started: egress_id=%s status=%s room_name=%s",
egress_id,
status,
room_name,
)
if not egress_id:
logger.error("egress_started without egress_id")
return
rec = models.Recording.objects.filter(worker_id=egress_id).first()
if rec is None:
rec_id = _extract_recording_id_from_egress(data)
if rec_id:
rec = (
models.Recording.objects.filter(id=rec_id)
.select_related("room")
.first()
)
if rec and not rec.worker_id:
rec.worker_id = egress_id
rec.save(update_fields=["worker_id"])
logger.info(
"Attached worker_id=%s to recording=%s via filename",
egress_id,
rec.id,
)
if rec is None and room_name:
rec = (
models.Recording.objects.filter(
room__livekit_name=room_name,
status__in=[
models.RecordingStatusChoices.INITIATED,
models.RecordingStatusChoices.ACTIVE,
],
)
.order_by("-created_at")
.select_related("room")
.first()
)
if rec and not rec.worker_id:
rec.worker_id = egress_id
rec.save(update_fields=["worker_id"])
logger.info(
"Heuristically attached worker_id=%s to recording=%s via livekit room",
egress_id,
rec.id,
)
if rec is None:
logger.warning(
"egress_started: unknown egress_id=%s (room_name=%s)",
egress_id,
room_name,
)
return
lk_name = getattr(rec.room, "livekit_name", None)
if room_name and lk_name and lk_name != room_name:
logger.error(
"Room mismatch: rec[%s].room=%s != event.room=%s",
rec.id,
lk_name,
room_name,
)
return
try:
logger.debug(
"Processing egress_started for recording %s (room=%s)",
rec.id,
lk_name or rec.room_id,
)
self.recording_events.handle_egress_started(rec)
except RecordingEventsError as e:
raise ActionFailedError(
f"Failed to process egress_started for recording {rec.id}"
) from e
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
-88
View File
@@ -1,88 +0,0 @@
"""Service for managing metadata agents in LiveKit rooms."""
import json
import logging
from logging import getLogger
from django.conf import settings
from asgiref.sync import async_to_sync
from livekit.protocol.agent_dispatch import (
CreateAgentDispatchRequest,
)
from core import utils
logging.basicConfig(level=logging.DEBUG)
logger = getLogger(__name__)
class MetadataException(Exception):
"""Exception raised when metadata operations fail."""
class MetadataService:
"""Service for managing metadata agents in LiveKit rooms."""
def __init__(self) -> None:
self.agent_name = settings.ROOM_METADATA_AGENT_NAME
@async_to_sync
async def start_metadata(self, room, recording_id) -> None:
"""Start metadata agent in the specified room."""
lkapi = utils.create_livekit_client()
try:
payload = (
json.dumps({"recording_id": str(recording_id)})
if recording_id
else None
)
resp = await lkapi.agent_dispatch.create_dispatch(
CreateAgentDispatchRequest(
agent_name=self.agent_name,
room=str(room.id),
metadata=payload,
)
)
dispatch_id = resp.id
dispatch_id = getattr(resp, "id", None)
if not dispatch_id:
raise MetadataException("LiveKit did not return a dispatch_id")
logger.info(
"Agent dispatch created: room=%s agent=%s dispatch_id=%s",
room.id,
self.agent_name,
dispatch_id,
)
return dispatch_id
except Exception as e:
logger.exception("Failed to create agent dispatch for room %s", room.id)
raise MetadataException("Failed to create metadata agent") from e
finally:
await lkapi.aclose()
@async_to_sync
async def stop_metadata(self, room, dispatch_id) -> None:
"""Stop metadata agent in the specified room."""
logger.info(
"deleting agent dispatch: room=%s agent=%s dispatch_id=%s",
room.id,
self.agent_name,
dispatch_id,
)
lkapi = utils.create_livekit_client()
try:
await lkapi.agent_dispatch.delete_dispatch(
dispatch_id=str(dispatch_id), room_name=str(room.id)
)
logger.info(
"Agent dispatch deleted: room=%s agent=%s", room.id, self.agent_name
)
except Exception as e:
logger.exception("Failed to delete agent dispatch for room %s", room.id)
raise MetadataException("Failed to stop metadata agent") from e
finally:
await lkapi.aclose()
@@ -183,10 +183,9 @@ def test_start_recording_success(
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
assert response.status_code == 201
assert (
response.json()["message"]
== f"Recording successfully started for room {room.slug}"
)
assert response.json() == {
"message": f"Recording successfully started for room {room.slug}"
}
# Verify the mediator was called with the recording
recording = Recording.objects.first()
-6
View File
@@ -664,12 +664,6 @@ class Base(Configuration):
environ_prefix=None,
)
ROOM_METADATA_AGENT_NAME = values.Value(
"metadata-extractor",
environ_name="ROOM_METADATA_AGENT_NAME",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
+2 -3
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.38"
version = "0.1.34"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -38,7 +38,7 @@ dependencies = [
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.6",
"django==5.2.3",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
@@ -48,7 +48,6 @@ dependencies = [
"jsonschema==4.24.0",
"markdown==3.8.2",
"nested-multipart-parser==1.5.0",
"posthog==6.0.3",
"psycopg[binary]==3.2.9",
"PyJWT==2.10.1",
"python-frontmatter==1.1.0",
+10 -10
View File
@@ -1,16 +1,16 @@
{
"name": "meet",
"version": "0.1.38",
"version": "0.1.34",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.38",
"version": "0.1.34",
"dependencies": {
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.6.1",
"@livekit/track-processors": "0.6.0",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
@@ -26,7 +26,7 @@
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.7",
"livekit-client": "2.15.5",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.10.1",
@@ -1281,9 +1281,9 @@
}
},
"node_modules/@livekit/track-processors": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@livekit/track-processors/-/track-processors-0.6.1.tgz",
"integrity": "sha512-t9JMDvMUlaaURDDRZFQEkRYR4q2qROPOOIs3aZXQVL6v/QYgJ0tPg/QfbvHC8b6mYPwcaJgVz3KTk5XQ07fEMg==",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@livekit/track-processors/-/track-processors-0.6.0.tgz",
"integrity": "sha512-h0Ewdp2/u44QnfLsmhL/IBCkFJsl10eyodErOedP9yWTS4c8m8ibqBWaNH0bHDeqg4Ue+OzzUb7dogUb2nJ0Ow==",
"license": "Apache-2.0",
"dependencies": {
"@mediapipe/tasks-vision": "0.10.14"
@@ -7460,9 +7460,9 @@
}
},
"node_modules/livekit-client": {
"version": "2.15.7",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.7.tgz",
"integrity": "sha512-19m8Q1cvRl5PslRawDUgWXeP8vL8584tX8kiZEJaPZo83U/L6VPS/O7pP06phfJaBWeeV8sAOVtEPlQiZEHtpg==",
"version": "2.15.5",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.5.tgz",
"integrity": "sha512-zn36akmDlqZxlrTOUgYXtxtj35HQ44aJ+mgKat9BTSPiZru4RjEHOtp8RJE6jGoN2miJlWiOeEKHB2+ae3YrSw==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.38",
"version": "0.1.34",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -15,7 +15,7 @@
"dependencies": {
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.6.1",
"@livekit/track-processors": "0.6.0",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
@@ -31,7 +31,7 @@
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.7",
"livekit-client": "2.15.5",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.10.1",
@@ -10,6 +10,8 @@ import {
MediaDeviceFailure,
Room,
RoomOptions,
supportsAdaptiveStream,
supportsDynacast,
VideoPresets,
} from 'livekit-client'
import { keys } from '@/api/queryKeys'
@@ -85,10 +87,13 @@ export const Conference = ({
retry: false,
})
const isAdaptiveStreamSupported = supportsAdaptiveStream()
const isDynacastSupported = supportsDynacast()
const roomOptions = useMemo((): RoomOptions => {
return {
adaptiveStream: true,
dynacast: true,
adaptiveStream: isAdaptiveStreamSupported,
dynacast: isDynacastSupported,
publishDefaults: {
videoCodec: 'vp9',
},
@@ -111,6 +116,8 @@ export const Conference = ({
userConfig.videoPublishResolution,
userConfig.audioDeviceId,
userConfig.audioOutputDeviceId,
isAdaptiveStreamSupported,
isDynacastSupported,
])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
@@ -638,7 +638,7 @@ export const Join = ({
<Button
size="sm"
variant="tertiary"
onPress={() => openPermissionsDialog('videoinput')}
onPress={openPermissionsDialog}
>
{t(`permissionsButton.${permissionsButtonLabel}`)}
</Button>
@@ -37,22 +37,6 @@ export const Permissions = () => {
injectIconIntoTranslation(t('body.openMenu.others'))
useEffect(() => {
if (
permissions.isPermissionDialogOpen &&
permissions.isMicrophoneGranted &&
permissions.requestOrigin == 'audioinput'
) {
closePermissionsDialog()
}
if (
permissions.isPermissionDialogOpen &&
permissions.isCameraGranted &&
permissions.requestOrigin == 'videoinput'
) {
closePermissionsDialog()
}
if (
permissions.isPermissionDialogOpen &&
permissions.isCameraGranted &&
@@ -80,17 +64,13 @@ export const Permissions = () => {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
md: {
flexDirection: 'row',
},
})}
>
<img
src="/assets/camera_mic_permission.svg"
alt=""
className={css({
width: '100%',
minWidth: '290px',
minHeight: '290px',
maxWidth: '290px',
})}
@@ -15,7 +15,6 @@ import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol'
import Source = Track.Source
import { isSafari } from '@/utils/livekit'
type AudioDevicesControlProps = Omit<
UseTrackToggleProps<Source.Microphone>,
@@ -112,21 +111,19 @@ export const AudioDevicesControl = ({
onSubmit={saveAudioInputDeviceId}
/>
</div>
{!isSafari() && (
<div
style={{
flex: '1 1 0',
minWidth: 0,
}}
>
<SelectDevice
context="room"
kind="audiooutput"
id={audioOutputDeviceId}
onSubmit={saveAudioOutputDeviceId}
/>
</div>
)}
<div
style={{
flex: '1 1 0',
minWidth: 0,
}}
>
<SelectDevice
context="room"
kind="audiooutput"
id={audioOutputDeviceId}
onSubmit={saveAudioOutputDeviceId}
/>
</div>
<SettingsButton
settingTab={SettingsDialogExtendedKey.AUDIO}
onPress={close}
@@ -19,7 +19,7 @@ export const PermissionNeededButton = () => {
<Button
aria-label={t('ariaLabel')}
tooltip={t('tooltip')}
onPress={() => openPermissionsDialog()}
onPress={openPermissionsDialog}
variant="permission"
>
<div
@@ -107,7 +107,9 @@ export const ToggleDevice = <T extends ToggleSource>({
}, [enabled, kind, deviceShortcut, t])
const Icon =
isDisabled || !enabled ? deviceIcons.toggleOff : deviceIcons.toggleOn
isDisabled || cannotUseDevice || !enabled
? deviceIcons.toggleOff
: deviceIcons.toggleOn
const roomContext = useMaybeRoomContext()
if (kind === 'audioinput' && pushToTalk && roomContext) {
@@ -124,12 +126,7 @@ export const ToggleDevice = <T extends ToggleSource>({
isDisabled || cannotUseDevice || !enabled ? errorVariant : variant
}
shySelected
onPress={() => {
if (cannotUseDevice) {
openPermissionsDialog(kind)
}
toggle()
}}
onPress={() => (cannotUseDevice ? openPermissionsDialog() : toggle())}
aria-label={toggleLabel}
tooltip={
cannotUseDevice
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
import { Avatar } from '@/components/Avatar'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { getParticipantIsRoomAdmin } from '@/features/rooms/utils/getParticipantIsRoomAdmin'
import { LocalParticipant, Participant, Track } from 'livekit-client'
import { Participant, Track } from 'livekit-client'
import { isLocal } from '@/utils/livekit'
import {
useIsSpeaking,
@@ -54,11 +54,9 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
tooltip={label}
aria-label={label}
isDisabled={isMuted || !canMute}
onPress={async () =>
onPress={() =>
!isMuted && isLocal(participant)
? await (participant as LocalParticipant)?.setMicrophoneEnabled(
false
)
? muteParticipant(participant)
: setIsAlertOpen(true)
}
data-attr="participants-mute"
@@ -40,7 +40,6 @@ export const ScreenShareToggle = ({
isDisabled={!canShareScreen}
square
variant={variant}
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
onPress={(e) => {
buttonProps.onClick?.(
@@ -25,18 +25,6 @@ export const useConnectionObserver = () => {
posthog.capture('reconnect-event')
}
const handleReconnected = () => {
posthog.capture('reconnected-event')
}
const handleSignalingConnect = () => {
posthog.capture('signaling-connect-event')
}
const handleSignalingReconnect = () => {
posthog.capture('signaling-reconnect-event')
}
const handleDisconnect = (
disconnectReason: DisconnectReason | undefined
) => {
@@ -55,19 +43,13 @@ export const useConnectionObserver = () => {
}
room.on(RoomEvent.Connected, handleConnection)
room.on(RoomEvent.SignalConnected, handleSignalingConnect)
room.on(RoomEvent.Disconnected, handleDisconnect)
room.on(RoomEvent.Reconnecting, handleReconnect)
room.on(RoomEvent.Reconnected, handleReconnected)
room.on(RoomEvent.SignalReconnecting, handleSignalingReconnect)
return () => {
room.off(RoomEvent.Connected, handleConnection)
room.off(RoomEvent.SignalConnected, handleSignalingConnect)
room.off(RoomEvent.Disconnected, handleDisconnect)
room.off(RoomEvent.Reconnecting, handleReconnect)
room.off(RoomEvent.Reconnected, handleReconnected)
room.off(RoomEvent.SignalReconnecting, handleSignalingReconnect)
}
}, [room, isAnalyticsEnabled])
@@ -63,13 +63,11 @@ export function MobileControlBar({
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
hideMenu={true}
/>
<VideoDeviceControl
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
hideMenu={true}
/>
<HandToggle />
<Button
+1 -6
View File
@@ -13,7 +13,6 @@ type BaseState = {
microphonePermission: PermissionState
isLoading: boolean
isPermissionDialogOpen: boolean
requestOrigin?: 'audioinput' | 'videoinput'
}
type DerivedState = {
@@ -32,7 +31,6 @@ export const permissionsStore = proxy<BaseState>({
microphonePermission: undefined,
isLoading: true,
isPermissionDialogOpen: false,
requestOrigin: undefined,
}) as State
derive(
@@ -54,11 +52,8 @@ derive(
}
)
export const openPermissionsDialog = (
requestOrigin?: 'audioinput' | 'videoinput'
) => {
export const openPermissionsDialog = () => {
permissionsStore.isPermissionDialogOpen = true
permissionsStore.requestOrigin = requestOrigin
}
export const closePermissionsDialog = () => {
@@ -144,13 +144,11 @@ summary:
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
OPENAI_API_KEY: password
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr/v1
OPENAI_ASR_MODEL: large-v2
OPENAI_LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
AWS_S3_SECURE_ACCESS: False
WHISPERX_API_KEY: your-secret-value
WHISPERX_BASE_URL: https://configure-your-url.com
WHISPERX_ASR_MODEL: large-v2
LLM_BASE_URL: https://configure-your-url.com
LLM_API_KEY: your-secret-value
LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
WEBHOOK_API_TOKEN: password
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
@@ -170,7 +168,7 @@ summary:
- "8000"
- "--reload"
celeryTranscribe:
celery:
replicas: 1
envVars:
APP_NAME: summary-microservice
@@ -179,19 +177,16 @@ celeryTranscribe:
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
OPENAI_API_KEY: password
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr/v1
OPENAI_ASR_MODEL: large-v2
OPENAI_LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
AWS_S3_SECURE_ACCESS: False
WHISPERX_API_KEY: your-secret-value
WHISPERX_BASE_URL: https://configure-your-url.com
WHISPERX_ASR_MODEL: large-v2
LLM_BASE_URL: https://configure-your-url.com
LLM_API_KEY: your-secret-value
LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
WEBHOOK_API_TOKEN: password
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
TASK_TRACKER_REDIS_URL: redis://default:pass@redis-master:6379/1
image:
repository: localhost:5001/meet-summary
pullPolicy: Always
@@ -204,43 +199,6 @@ celeryTranscribe:
- "worker"
- "--pool=solo"
- "--loglevel=info"
- "-Q transcribe-queue"
celerySummarize:
replicas: 1
envVars:
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
WHISPERX_API_KEY: your-secret-value
WHISPERX_BASE_URL: https://configure-your-url.com
WHISPERX_ASR_MODEL: large-v2
LLM_BASE_URL: https://configure-your-url.com
LLM_API_KEY: your-secret-value
LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
WEBHOOK_API_TOKEN: password
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
TASK_TRACKER_REDIS_URL: redis://default:pass@redis-master:6379/1
image:
repository: localhost:5001/meet-summary
pullPolicy: Always
tag: "latest"
command:
- "celery"
- "-A"
- "summary.core.celery_worker"
- "worker"
- "--pool=solo"
- "--loglevel=info"
- "-Q summarize-queue"
ingressMedia:
enabled: true
@@ -151,13 +151,11 @@ summary:
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
OPENAI_API_KEY: password
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr/v1
OPENAI_ASR_MODEL: large-v2
OPENAI_LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
AWS_S3_SECURE_ACCESS: False
WHISPERX_API_KEY: your-secret-value
WHISPERX_BASE_URL: https://configure-your-url.com
WHISPERX_ASR_MODEL: large-v2
LLM_BASE_URL: https://configure-your-url.com
LLM_API_KEY: your-secret-value
LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
WEBHOOK_API_TOKEN: password
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
@@ -178,7 +176,7 @@ summary:
- "8000"
- "--reload"
celeryTranscribe:
celery:
replicas: 1
envVars:
APP_NAME: summary-microservice
@@ -187,13 +185,11 @@ celeryTranscribe:
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
OPENAI_API_KEY: password
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr/v1
OPENAI_ASR_MODEL: large-v2
OPENAI_LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
AWS_S3_SECURE_ACCESS: False
WHISPERX_API_KEY: your-secret-value
WHISPERX_BASE_URL: https://configure-your-url.com
WHISPERX_ASR_MODEL: large-v2
LLM_BASE_URL: https://configure-your-url.com
LLM_API_KEY: your-secret-value
LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
WEBHOOK_API_TOKEN: password
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
@@ -212,45 +208,6 @@ celeryTranscribe:
- "worker"
- "--pool=solo"
- "--loglevel=info"
- "-Q"
- "transcribe-queue"
celerySummarize:
replicas: 1
envVars:
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
WHISPERX_API_KEY: your-secret-value
WHISPERX_BASE_URL: https://configure-your-url.com
WHISPERX_ASR_MODEL: large-v2
LLM_BASE_URL: https://configure-your-url.com
LLM_API_KEY: your-secret-value
LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
WEBHOOK_API_TOKEN: password
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
TASK_TRACKER_REDIS_URL: redis://default:pass@redis-master:6379/1
image:
repository: localhost:5001/meet-summary
pullPolicy: Always
tag: "latest"
command:
- "celery"
- "-A"
- "summary.core.celery_worker"
- "worker"
- "--pool=solo"
- "--loglevel=info"
- "-Q"
- "summarize-queue"
agents:
replicas: 1
+9 -51
View File
@@ -171,13 +171,11 @@ summary:
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
OPENAI_API_KEY: password
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr/v1
OPENAI_ASR_MODEL: openai/whisper-large-v3
OPENAI_LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
AWS_S3_SECURE_ACCESS: False
WHISPERX_API_KEY: your-secret-value
WHISPERX_BASE_URL: https://configure-your-url.com
WHISPERX_ASR_MODEL: large-v2
LLM_BASE_URL: https://configure-your-url.com
LLM_API_KEY: your-secret-value
LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
WEBHOOK_API_TOKEN: password
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
@@ -197,7 +195,7 @@ summary:
- "8000"
- "--reload"
celeryTranscribe:
celery:
replicas: 1
envVars:
APP_NAME: summary-microservice
@@ -206,18 +204,15 @@ celeryTranscribe:
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
OPENAI_API_KEY: password
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr/v1
OPENAI_ASR_MODEL: openai/whisper-large-v3
OPENAI_LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
AWS_S3_SECURE_ACCESS: False
WHISPERX_API_KEY: your-secret-value
WHISPERX_BASE_URL: https://configure-your-url.com
WHISPERX_ASR_MODEL: large-v2
LLM_BASE_URL: https://configure-your-url.com
LLM_API_KEY: your-secret-value
LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
WEBHOOK_API_TOKEN: password
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
TASK_TRACKER_REDIS_URL: redis://default:pass@redis-master:6379/1
image:
repository: localhost:5001/meet-summary
@@ -231,43 +226,6 @@ celeryTranscribe:
- "worker"
- "--pool=solo"
- "--loglevel=info"
- "-Q transcribe-queue"
celerySummarize:
replicas: 1
envVars:
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
WHISPERX_API_KEY: your-secret-value
WHISPERX_BASE_URL: https://configure-your-url.com
WHISPERX_ASR_MODEL: large-v2
LLM_BASE_URL: https://configure-your-url.com
LLM_API_KEY: your-secret-value
LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
WEBHOOK_API_TOKEN: password
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
TASK_TRACKER_REDIS_URL: redis://default:pass@redis-master:6379/1
image:
repository: localhost:5001/meet-summary
pullPolicy: Always
tag: "latest"
command:
- "celery"
- "-A"
- "summary.core.celery_worker"
- "worker"
- "--pool=solo"
- "--loglevel=info"
- "-Q summarize-queue"
ingressMedia:
enabled: true
-29
View File
@@ -82,15 +82,9 @@ spec:
volumeMounts:
- mountPath: /data
name: data
- mountPath: /etc/ssl/certs/mkcert-ca.pem
name: mkcert
subPath: rootCA.pem
volumes:
- name: data
emptyDir:
- name: mkcert
secret:
secretName: mkcert
---
apiVersion: batch/v1
kind: Job
@@ -111,26 +105,3 @@ spec:
exit 0
restartPolicy: Never
backoffLimit: 1
---
apiVersion: batch/v1
kind: Job
metadata:
name: minio-webhook
spec:
template:
spec:
containers:
- name: mc
image: minio/mc
command:
- /bin/sh
- -c
- |
/usr/bin/mc alias set meet http://minio:9000 meet password && \
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint="https://meet.127.0.0.1.nip.io/api/v1.0/recordings/storage-hook/" auth_token="Bearer password" && \
/usr/bin/mc admin service restart meet --wait --json && \
sleep 15 && \
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put && \
exit 0
restartPolicy: Never
backoffLimit: 1
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.12
version: 0.0.11
+3 -12
View File
@@ -176,21 +176,12 @@ Requires top level scope
{{- end }}
{{/*
Full name for the Celery Transcribe
Full name for the Celery
Requires top level scope
*/}}
{{- define "meet.celeryTranscribe.fullname" -}}
{{ include "meet.fullname" . }}-celery-transcribe
{{- end }}
{{/*
Full name for the Celery Summarize
Requires top level scope
*/}}
{{- define "meet.celerySummarize.fullname" -}}
{{ include "meet.fullname" . }}-celery-summarize
{{- define "meet.celery.fullname" -}}
{{ include "meet.fullname" . }}-celery
{{- end }}
{{/*
@@ -1,26 +1,26 @@
{{- $envVars := include "meet.common.env" (list . .Values.celerySummarize) -}}
{{- $fullName := include "meet.celerySummarize.fullname" . -}}
{{- $component := "celery-summarize" -}}
{{- $envVars := include "meet.common.env" (list . .Values.celery) -}}
{{- $fullName := include "meet.celery.fullname" . -}}
{{- $component := "celery" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $fullName }}
annotations:
{{- with .Values.celerySummarize.dpAnnotations }}
{{- with .Values.celery.dpAnnotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
spec:
replicas: {{ .Values.celerySummarize.replicas }}
replicas: {{ .Values.celery.replicas }}
selector:
matchLabels:
{{- include "meet.common.selectorLabels" (list . $component) | nindent 6 }}
template:
metadata:
annotations:
{{- with .Values.celerySummarize.podAnnotations }}
{{- with .Values.celery.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
@@ -30,19 +30,19 @@ spec:
imagePullSecrets:
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
{{- end }}
shareProcessNamespace: {{ .Values.celerySummarize.shareProcessNamespace }}
shareProcessNamespace: {{ .Values.celery.shareProcessNamespace }}
containers:
{{- with .Values.celerySummarize.sidecars }}
{{- with .Values.celery.sidecars }}
{{- toYaml . | nindent 8 }}
{{- end }}
- name: {{ .Chart.Name }}
image: "{{ (.Values.celerySummarize.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.celerySummarize.image | default dict).tag | default .Values.image.tag }}"
imagePullPolicy: {{ (.Values.celerySummarize.image | default dict).pullPolicy | default .Values.image.pullPolicy }}
{{- with .Values.celerySummarize.command }}
image: "{{ (.Values.celery.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.celery.image | default dict).tag | default .Values.image.tag }}"
imagePullPolicy: {{ (.Values.celery.image | default dict).pullPolicy | default .Values.image.pullPolicy }}
{{- with .Values.celery.command }}
command:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.celerySummarize.args }}
{{- with .Values.celery.args }}
args:
{{- toYaml . | nindent 12 }}
{{- end }}
@@ -50,27 +50,27 @@ spec:
{{- if $envVars }}
{{- $envVars | indent 12 }}
{{- end }}
{{- with .Values.celerySummarize.securityContext }}
{{- with .Values.celery.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.celerySummarize.service.targetPort }}
containerPort: {{ .Values.celery.service.targetPort }}
protocol: TCP
{{- if .Values.celerySummarize.probes.liveness }}
{{- if .Values.celery.probes.liveness }}
livenessProbe:
{{- include "meet.probes.abstract" (merge .Values.celerySummarize.probes.liveness (dict "targetPort" .Values.celerySummarize.service.targetPort )) | nindent 12 }}
{{- include "meet.probes.abstract" (merge .Values.celery.probes.liveness (dict "targetPort" .Values.celery.service.targetPort )) | nindent 12 }}
{{- end }}
{{- if .Values.celerySummarize.probes.readiness }}
{{- if .Values.celery.probes.readiness }}
readinessProbe:
{{- include "meet.probes.abstract" (merge .Values.celerySummarize.probes.readiness (dict "targetPort" .Values.celerySummarize.service.targetPort )) | nindent 12 }}
{{- include "meet.probes.abstract" (merge .Values.celery.probes.readiness (dict "targetPort" .Values.celery.service.targetPort )) | nindent 12 }}
{{- end }}
{{- if .Values.celerySummarize.probes.startup }}
{{- if .Values.celery.probes.startup }}
startupProbe:
{{- include "meet.probes.abstract" (merge .Values.celerySummarize.probes.startup (dict "targetPort" .Values.celerySummarize.service.targetPort )) | nindent 12 }}
{{- include "meet.probes.abstract" (merge .Values.celery.probes.startup (dict "targetPort" .Values.celery.service.targetPort )) | nindent 12 }}
{{- end }}
{{- with .Values.celerySummarize.resources }}
{{- with .Values.celery.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
@@ -80,25 +80,25 @@ spec:
mountPath: {{ $value.path }}
subPath: content
{{- end }}
{{- range $name, $volume := .Values.celerySummarize.persistence }}
{{- range $name, $volume := .Values.celery.persistence }}
- name: "{{ $name }}"
mountPath: "{{ $volume.mountPath }}"
{{- end }}
{{- range .Values.celerySummarize.extraVolumeMounts }}
{{- range .Values.celery.extraVolumeMounts }}
- name: {{ .name }}
mountPath: {{ .mountPath }}
subPath: {{ .subPath | default "" }}
readOnly: {{ .readOnly }}
{{- end }}
{{- with .Values.celerySummarize.nodeSelector }}
{{- with .Values.celery.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.celerySummarize.affinity }}
{{- with .Values.celery.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.celerySummarize.tolerations }}
{{- with .Values.celery.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -108,7 +108,7 @@ spec:
configMap:
name: "{{ include "meet.fullname" $ }}-files-{{ $index }}"
{{- end }}
{{- range $name, $volume := .Values.celerySummarize.persistence }}
{{- range $name, $volume := .Values.celery.persistence }}
- name: "{{ $name }}"
{{- if eq $volume.type "emptyDir" }}
emptyDir: {}
@@ -117,7 +117,7 @@ spec:
claimName: "{{ $fullName }}-{{ $name }}"
{{- end }}
{{- end }}
{{- range .Values.celerySummarize.extraVolumes }}
{{- range .Values.celery.extraVolumes }}
- name: {{ .name }}
{{- if .existingClaim }}
persistentVolumeClaim:
@@ -139,7 +139,7 @@ spec:
{{- end }}
{{- end }}
---
{{ if .Values.celerySummarize.pdb.enabled }}
{{ if .Values.celery.pdb.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
@@ -1,153 +0,0 @@
{{- $envVars := include "meet.common.env" (list . .Values.celeryTranscribe) -}}
{{- $fullName := include "meet.celeryTranscribe.fullname" . -}}
{{- $component := "celery-transcribe" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $fullName }}
annotations:
{{- with .Values.celeryTranscribe.dpAnnotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
spec:
replicas: {{ .Values.celeryTranscribe.replicas }}
selector:
matchLabels:
{{- include "meet.common.selectorLabels" (list . $component) | nindent 6 }}
template:
metadata:
annotations:
{{- with .Values.celeryTranscribe.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "meet.common.selectorLabels" (list . $component) | nindent 8 }}
spec:
{{- if $.Values.image.credentials }}
imagePullSecrets:
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
{{- end }}
shareProcessNamespace: {{ .Values.celeryTranscribe.shareProcessNamespace }}
containers:
{{- with .Values.celeryTranscribe.sidecars }}
{{- toYaml . | nindent 8 }}
{{- end }}
- name: {{ .Chart.Name }}
image: "{{ (.Values.celeryTranscribe.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.celeryTranscribe.image | default dict).tag | default .Values.image.tag }}"
imagePullPolicy: {{ (.Values.celeryTranscribe.image | default dict).pullPolicy | default .Values.image.pullPolicy }}
{{- with .Values.celeryTranscribe.command }}
command:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.celeryTranscribe.args }}
args:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- if $envVars }}
{{- $envVars | indent 12 }}
{{- end }}
{{- with .Values.celeryTranscribe.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.celeryTranscribe.service.targetPort }}
protocol: TCP
{{- if .Values.celeryTranscribe.probes.liveness }}
livenessProbe:
{{- include "meet.probes.abstract" (merge .Values.celeryTranscribe.probes.liveness (dict "targetPort" .Values.celeryTranscribe.service.targetPort )) | nindent 12 }}
{{- end }}
{{- if .Values.celeryTranscribe.probes.readiness }}
readinessProbe:
{{- include "meet.probes.abstract" (merge .Values.celeryTranscribe.probes.readiness (dict "targetPort" .Values.celeryTranscribe.service.targetPort )) | nindent 12 }}
{{- end }}
{{- if .Values.celeryTranscribe.probes.startup }}
startupProbe:
{{- include "meet.probes.abstract" (merge .Values.celeryTranscribe.probes.startup (dict "targetPort" .Values.celeryTranscribe.service.targetPort )) | nindent 12 }}
{{- end }}
{{- with .Values.celeryTranscribe.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
{{- range $index, $value := .Values.mountFiles }}
- name: "files-{{ $index }}"
mountPath: {{ $value.path }}
subPath: content
{{- end }}
{{- range $name, $volume := .Values.celeryTranscribe.persistence }}
- name: "{{ $name }}"
mountPath: "{{ $volume.mountPath }}"
{{- end }}
{{- range .Values.celeryTranscribe.extraVolumeMounts }}
- name: {{ .name }}
mountPath: {{ .mountPath }}
subPath: {{ .subPath | default "" }}
readOnly: {{ .readOnly }}
{{- end }}
{{- with .Values.celeryTranscribe.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.celeryTranscribe.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.celeryTranscribe.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
volumes:
{{- range $index, $value := .Values.mountFiles }}
- name: "files-{{ $index }}"
configMap:
name: "{{ include "meet.fullname" $ }}-files-{{ $index }}"
{{- end }}
{{- range $name, $volume := .Values.celeryTranscribe.persistence }}
- name: "{{ $name }}"
{{- if eq $volume.type "emptyDir" }}
emptyDir: {}
{{- else }}
persistentVolumeClaim:
claimName: "{{ $fullName }}-{{ $name }}"
{{- end }}
{{- end }}
{{- range .Values.celeryTranscribe.extraVolumes }}
- name: {{ .name }}
{{- if .existingClaim }}
persistentVolumeClaim:
claimName: {{ .existingClaim }}
{{- else if .hostPath }}
hostPath:
{{ toYaml .hostPath | nindent 12 }}
{{- else if .csi }}
csi:
{{- toYaml .csi | nindent 12 }}
{{- else if .configMap }}
configMap:
{{- toYaml .configMap | nindent 12 }}
{{- else if .emptyDir }}
emptyDir:
{{- toYaml .emptyDir | nindent 12 }}
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}
---
{{ if .Values.celeryTranscribe.pdb.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace | quote }}
spec:
maxUnavailable: 1
selector:
matchLabels:
{{- include "meet.common.selectorLabels" (list . $component) | nindent 6 }}
{{ end }}
+46 -139
View File
@@ -524,189 +524,96 @@ summary:
pdb:
enabled: true
## @section celeryTranscribe
## @section celery
celeryTranscribe:
## @param celeryTranscribe.dpAnnotations Annotations to add to the celeryTranscribe Deployment
celery:
## @param celery.dpAnnotations Annotations to add to the celery Deployment
dpAnnotations: {}
## @param celeryTranscribe.command Override the celeryTranscribe container command
## @param celery.command Override the celery container command
command: []
## @param celeryTranscribe.args Override the celeryTranscribe container args
## @param celery.args Override the celery container args
args: []
## @param celeryTranscribe.replicas Amount of celeryTranscribe replicas
## @param celery.replicas Amount of celery replicas
replicas: 1
## @param celeryTranscribe.shareProcessNamespace Enable share process namespace between containers
## @param celery.shareProcessNamespace Enable share process namespace between containers
shareProcessNamespace: false
## @param celeryTranscribe.sidecars Add sidecars containers to celeryTranscribe deployment
## @param celery.sidecars Add sidecars containers to celery deployment
sidecars: []
## @param celeryTranscribe.migrateJobAnnotations Annotations for the migrate job
## @param celery.migrateJobAnnotations Annotations for the migrate job
migrateJobAnnotations: {}
## @param celeryTranscribe.securityContext Configure celeryTranscribe Pod security context
## @param celery.securityContext Configure celery Pod security context
securityContext: null
## @param celeryTranscribe.envVars Configure celeryTranscribe container environment variables
## @extra celeryTranscribe.envVars.BY_VALUE Example environment variable by setting value directly
## @extra celeryTranscribe.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
## @extra celeryTranscribe.envVars.FROM_CONFIGMAP.configMapKeyRef.key Key within a ConfigMap when configuring env vars from a ConfigMap
## @extra celeryTranscribe.envVars.FROM_SECRET.secretKeyRef.name Name of a Secret when configuring env vars from a Secret
## @extra celeryTranscribe.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret
## @skip celeryTranscribe.envVars
## @param celery.envVars Configure celery container environment variables
## @extra celery.envVars.BY_VALUE Example environment variable by setting value directly
## @extra celery.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
## @extra celery.envVars.FROM_CONFIGMAP.configMapKeyRef.key Key within a ConfigMap when configuring env vars from a ConfigMap
## @extra celery.envVars.FROM_SECRET.secretKeyRef.name Name of a Secret when configuring env vars from a Secret
## @extra celery.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret
## @skip celery.envVars
envVars:
<<: *commonEnvVars
## @param celeryTranscribe.podAnnotations Annotations to add to the celeryTranscribe Pod
## @param celery.podAnnotations Annotations to add to the celery Pod
podAnnotations: {}
## @param celeryTranscribe.service.type celeryTranscribe Service type
## @param celeryTranscribe.service.port celeryTranscribe Service listening port
## @param celeryTranscribe.service.targetPort celeryTranscribe container listening port
## @param celeryTranscribe.service.annotations Annotations to add to the celeryTranscribe Service
## @param celery.service.type celery Service type
## @param celery.service.port celery Service listening port
## @param celery.service.targetPort celery container listening port
## @param celery.service.annotations Annotations to add to the celery Service
service:
type: ClusterIP
port: 80
targetPort: 8000
annotations: {}
## @param celeryTranscribe.probes Configure celeryTranscribe probes
## @param celeryTranscribe.probes.liveness.path [nullable] Configure path for celeryTranscribe HTTP liveness probe
## @param celeryTranscribe.probes.liveness.targetPort [nullable] Configure port for celeryTranscribe HTTP liveness probe
## @param celeryTranscribe.probes.liveness.initialDelaySeconds [nullable] Configure initial delay for celeryTranscribe liveness probe
## @param celeryTranscribe.probes.liveness.initialDelaySeconds [nullable] Configure timeout for celeryTranscribe liveness probe
## @param celeryTranscribe.probes.startup.path [nullable] Configure path for celeryTranscribe HTTP startup probe
## @param celeryTranscribe.probes.startup.targetPort [nullable] Configure port for celeryTranscribe HTTP startup probe
## @param celeryTranscribe.probes.startup.initialDelaySeconds [nullable] Configure initial delay for celeryTranscribe startup probe
## @param celeryTranscribe.probes.startup.initialDelaySeconds [nullable] Configure timeout for celeryTranscribe startup probe
## @param celeryTranscribe.probes.readiness.path [nullable] Configure path for celeryTranscribe HTTP readiness probe
## @param celeryTranscribe.probes.readiness.targetPort [nullable] Configure port for celeryTranscribe HTTP readiness probe
## @param celeryTranscribe.probes.readiness.initialDelaySeconds [nullable] Configure initial delay for celeryTranscribe readiness probe
## @param celeryTranscribe.probes.readiness.initialDelaySeconds [nullable] Configure timeout for celeryTranscribe readiness probe
## @param celery.probes Configure celery probes
## @param celery.probes.liveness.path [nullable] Configure path for celery HTTP liveness probe
## @param celery.probes.liveness.targetPort [nullable] Configure port for celery HTTP liveness probe
## @param celery.probes.liveness.initialDelaySeconds [nullable] Configure initial delay for celery liveness probe
## @param celery.probes.liveness.initialDelaySeconds [nullable] Configure timeout for celery liveness probe
## @param celery.probes.startup.path [nullable] Configure path for celery HTTP startup probe
## @param celery.probes.startup.targetPort [nullable] Configure port for celery HTTP startup probe
## @param celery.probes.startup.initialDelaySeconds [nullable] Configure initial delay for celery startup probe
## @param celery.probes.startup.initialDelaySeconds [nullable] Configure timeout for celery startup probe
## @param celery.probes.readiness.path [nullable] Configure path for celery HTTP readiness probe
## @param celery.probes.readiness.targetPort [nullable] Configure port for celery HTTP readiness probe
## @param celery.probes.readiness.initialDelaySeconds [nullable] Configure initial delay for celery readiness probe
## @param celery.probes.readiness.initialDelaySeconds [nullable] Configure timeout for celery readiness probe
probes: {}
## @param celeryTranscribe.resources Resource requirements for the celeryTranscribe container
## @param celery.resources Resource requirements for the celery container
resources: {}
## @param celeryTranscribe.nodeSelector Node selector for the celeryTranscribe Pod
## @param celery.nodeSelector Node selector for the celery Pod
nodeSelector: {}
## @param celeryTranscribe.tolerations Tolerations for the celeryTranscribe Pod
## @param celery.tolerations Tolerations for the celery Pod
tolerations: []
## @param celeryTranscribe.affinity Affinity for the celeryTranscribe Pod
## @param celery.affinity Affinity for the celery Pod
affinity: {}
## @param celeryTranscribe.persistence Additional volumes to create and mount on the celeryTranscribe. Used for debugging purposes
## @extra celeryTranscribe.persistence.volume-name.size Size of the additional volume
## @extra celeryTranscribe.persistence.volume-name.type Type of the additional volume, persistentVolumeClaim or emptyDir
## @extra celeryTranscribe.persistence.volume-name.mountPath Path where the volume should be mounted to
## @param celery.persistence Additional volumes to create and mount on the celery. Used for debugging purposes
## @extra celery.persistence.volume-name.size Size of the additional volume
## @extra celery.persistence.volume-name.type Type of the additional volume, persistentVolumeClaim or emptyDir
## @extra celery.persistence.volume-name.mountPath Path where the volume should be mounted to
persistence: {}
## @param celeryTranscribe.extraVolumeMounts Additional volumes to mount on the celeryTranscribe.
## @param celery.extraVolumeMounts Additional volumes to mount on the celery.
extraVolumeMounts: []
## @param celeryTranscribe.extraVolumes Additional volumes to mount on the celeryTranscribe.
## @param celery.extraVolumes Additional volumes to mount on the celery.
extraVolumes: []
## @param celeryTranscribe.pdb.enabled Enable pdb on celeryTranscribe
pdb:
enabled: false
## @section celerySummarize
celerySummarize:
## @param celerySummarize.dpAnnotations Annotations to add to the celerySummarize Deployment
dpAnnotations: {}
## @param celerySummarize.command Override the celerySummarize container command
command: []
## @param celerySummarize.args Override the celerySummarize container args
args: []
## @param celerySummarize.replicas Amount of celerySummarize replicas
replicas: 1
## @param celerySummarize.shareProcessNamespace Enable share process namespace between containers
shareProcessNamespace: false
## @param celerySummarize.sidecars Add sidecars containers to celerySummarize deployment
sidecars: []
## @param celerySummarize.migrateJobAnnotations Annotations for the migrate job
migrateJobAnnotations: {}
## @param celerySummarize.securityContext Configure celerySummarize Pod security context
securityContext: null
## @param celerySummarize.envVars Configure celerySummarize container environment variables
## @extra celerySummarize.envVars.BY_VALUE Example environment variable by setting value directly
## @extra celerySummarize.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
## @extra celerySummarize.envVars.FROM_CONFIGMAP.configMapKeyRef.key Key within a ConfigMap when configuring env vars from a ConfigMap
## @extra celerySummarize.envVars.FROM_SECRET.secretKeyRef.name Name of a Secret when configuring env vars from a Secret
## @extra celerySummarize.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret
## @skip celerySummarize.envVars
envVars:
<<: *commonEnvVars
## @param celerySummarize.podAnnotations Annotations to add to the celerySummarize Pod
podAnnotations: {}
## @param celerySummarize.service.type celerySummarize Service type
## @param celerySummarize.service.port celerySummarize Service listening port
## @param celerySummarize.service.targetPort celerySummarize container listening port
## @param celerySummarize.service.annotations Annotations to add to the celerySummarize Service
service:
type: ClusterIP
port: 80
targetPort: 8000
annotations: {}
## @param celerySummarize.probes Configure celerySummarize probes
## @param celerySummarize.probes.liveness.path [nullable] Configure path for celerySummarize HTTP liveness probe
## @param celerySummarize.probes.liveness.targetPort [nullable] Configure port for celerySummarize HTTP liveness probe
## @param celerySummarize.probes.liveness.initialDelaySeconds [nullable] Configure initial delay for celerySummarize liveness probe
## @param celerySummarize.probes.liveness.initialDelaySeconds [nullable] Configure timeout for celerySummarize liveness probe
## @param celerySummarize.probes.startup.path [nullable] Configure path for celerySummarize HTTP startup probe
## @param celerySummarize.probes.startup.targetPort [nullable] Configure port for celerySummarize HTTP startup probe
## @param celerySummarize.probes.startup.initialDelaySeconds [nullable] Configure initial delay for celerySummarize startup probe
## @param celerySummarize.probes.startup.initialDelaySeconds [nullable] Configure timeout for celerySummarize startup probe
## @param celerySummarize.probes.readiness.path [nullable] Configure path for celerySummarize HTTP readiness probe
## @param celerySummarize.probes.readiness.targetPort [nullable] Configure port for celerySummarize HTTP readiness probe
## @param celerySummarize.probes.readiness.initialDelaySeconds [nullable] Configure initial delay for celerySummarize readiness probe
## @param celerySummarize.probes.readiness.initialDelaySeconds [nullable] Configure timeout for celerySummarize readiness probe
probes: {}
## @param celerySummarize.resources Resource requirements for the celerySummarize container
resources: {}
## @param celerySummarize.nodeSelector Node selector for the celerySummarize Pod
nodeSelector: {}
## @param celerySummarize.tolerations Tolerations for the celerySummarize Pod
tolerations: []
## @param celerySummarize.affinity Affinity for the celerySummarize Pod
affinity: {}
## @param celerySummarize.persistence Additional volumes to create and mount on the celerySummarize. Used for debugging purposes
## @extra celerySummarize.persistence.volume-name.size Size of the additional volume
## @extra celerySummarize.persistence.volume-name.type Type of the additional volume, persistentVolumeClaim or emptyDir
## @extra celerySummarize.persistence.volume-name.mountPath Path where the volume should be mounted to
persistence: {}
## @param celerySummarize.extraVolumeMounts Additional volumes to mount on the celerySummarize.
extraVolumeMounts: []
## @param celerySummarize.extraVolumes Additional volumes to mount on the celerySummarize.
extraVolumes: []
## @param celerySummarize.pdb.enabled Enable pdb on celerySummarize
## @param celery.pdb.enabled Enable pdb on celery
pdb:
enabled: false
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "0.1.38",
"version": "0.1.34",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "0.1.38",
"version": "0.1.34",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.1.38",
"version": "0.1.34",
"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.38",
"version": "0.1.34",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "0.1.38",
"version": "0.1.34",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "0.1.38",
"version": "0.1.34",
"author": "",
"license": "ISC",
"description": "",
-8
View File
@@ -8,14 +8,6 @@ COPY pyproject.toml .
RUN pip3 install --no-cache-dir .
FROM base AS development
WORKDIR /app
COPY . .
RUN pip3 install --no-cache-dir -e ".[dev]" || pip3 install --no-cache-dir -e .
CMD ["uvicorn", "summary.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
FROM base AS production
WORKDIR /app
-32
View File
@@ -1,32 +0,0 @@
# Experimental Stack
This is an experimental part of the stack. It currently lacks proper observability, unit tests, and other production-grade features. This serves as the base for AI features in Visio.
## Usage
From the root of the project:
```sh
make bootstrap
```
Configure your env values in `env.d/summary` to properly set up WhisperX and the LLM API you will call.
```sh
make run
```
When the stack is up, configure the MinIO webhook
*(TODO: add this step to `make bootstrap`)*
```sh
make minio-webhook-setup
```
If you want to develop on the Celery workers with hot reloading, run:
```sh
docker compose watch celery-summary-transcribe celery-summary-summarize
```
Celery workers will hot reload on any change.
+27
View File
@@ -0,0 +1,27 @@
services:
redis:
image: redis
ports:
- "6379:6379"
app:
container_name: app
build: .
command: uvicorn summary.main:app --host 0.0.0.0 --port 8000 --reload
volumes:
- .:/app
ports:
- "8000:8000"
restart: always
env_file:
".env"
depends_on:
- redis
celery_worker:
container_name: celery_worker
build: .
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug
volumes:
- .:/app
depends_on:
- redis
- app
+1 -3
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "0.1.38"
version = "0.1.34"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
@@ -15,8 +15,6 @@ dependencies = [
"posthog==6.0.3",
"requests==2.32.4",
"sentry-sdk[fastapi, celery]==2.30.0",
"pandas>=2.2.0",
"numpy>=1.26.0"
]
[project.optional-dependencies]
+18 -17
View File
@@ -8,11 +8,9 @@ from fastapi import APIRouter
from pydantic import BaseModel
from summary.core.celery_worker import (
process_audio_transcribe_summarize,
process_audio_transcribe_summarize_v2,
)
from summary.core.config import get_settings
settings = get_settings()
class TaskCreation(BaseModel):
@@ -25,7 +23,7 @@ class TaskCreation(BaseModel):
room: Optional[str]
recording_date: Optional[str]
recording_time: Optional[str]
worker_id: Optional[str]
router = APIRouter(prefix="/tasks")
@@ -33,19 +31,22 @@ router = APIRouter(prefix="/tasks")
@router.post("/")
async def create_task(request: TaskCreation):
"""Create a task."""
task = process_audio_transcribe_summarize_v2.apply_async(
args=[
request.filename,
request.email,
request.sub,
time.time(),
request.room,
request.recording_date,
request.recording_time,
request.worker_id,
],
queue=settings.transcribe_queue,
)
if request.version == 1:
task = process_audio_transcribe_summarize.delay(
request.filename, request.email, request.sub
)
else:
task = process_audio_transcribe_summarize_v2.apply_async(
args=[
request.filename,
request.email,
request.sub,
time.time(),
request.room,
request.recording_date,
request.recording_time,
]
)
return {"id": task.id, "message": "Task created"}
+1 -12
View File
@@ -48,17 +48,6 @@ class Analytics:
except Exception as e:
raise AnalyticsException("Failed to capture analytics event") from e
def is_feature_enabled(self, feature_name: str, distinct_id: str = None) -> bool:
"""Check if a feature flag is enabled for a user."""
if self.is_disabled:
return False
try:
return self._client.feature_enabled(feature_name, distinct_id)
except Exception as e:
logger.error("Error checking feature flag %s: %s", feature_name, e)
return False
@lru_cache
def get_analytics():
@@ -114,7 +103,7 @@ class MetadataManager:
initial_metadata = {
"start_time": time.time(),
"asr_model": settings.whisperx_asr_model,
"asr_model": settings.openai_asr_model,
"retries": 0,
}
+102 -356
View File
@@ -6,13 +6,10 @@ import json
import os
import tempfile
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Optional
import numpy as np
import openai
import pandas as pd
import sentry_sdk
from celery import Celery, signals
from celery.utils.log import get_task_logger
@@ -24,167 +21,7 @@ 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 (
PROMPT_SYSTEM_CLEANING,
PROMPT_SYSTEM_NEXT_STEP,
PROMPT_SYSTEM_PART,
PROMPT_SYSTEM_PLAN,
PROMPT_SYSTEM_TLDR,
PROMPT_USER_PART,
)
def nanoseconds_to_seconds(nanoseconds: int) -> float:
"""Convert nanoseconds timestamp to seconds since epoch."""
return nanoseconds / 1_000_000_000
def parse_iso_timestamp(iso_string: str) -> float:
"""Convert ISO timestamp to seconds since epoch."""
dt = datetime.fromisoformat(iso_string.replace("+00:00", ""))
return dt.timestamp()
def calculate_overlap_vectorized(starts1, ends1, starts2, ends2):
"""Calculate overlap duration between two sets of time intervals (vectorized)."""
overlap_starts = np.maximum(starts1[:, None], starts2)
overlap_ends = np.minimum(ends1[:, None], ends2)
return np.maximum(0, overlap_ends - overlap_starts)
def build_speech_segments_df(events: List[Dict[str, Any]]) -> pd.DataFrame:
"""Build speech segments DataFrame from events.
Returns: DataFrame with columns [participant_id, start_time, end_time]
"""
df = pd.DataFrame(events)
df["timestamp"] = df["timestamp"].apply(parse_iso_timestamp)
starts = df[df["type"] == "speech_start"][["participant_id", "timestamp"]]
ends = df[df["type"] == "speech_end"][["participant_id", "timestamp"]]
starts = starts.sort_values("timestamp").reset_index(drop=True)
ends = ends.sort_values("timestamp").reset_index(drop=True)
segments = pd.merge(
starts.rename(columns={"timestamp": "start_time"}),
ends.rename(columns={"timestamp": "end_time"}),
left_index=True,
right_index=True,
suffixes=("_start", "_end"),
)
segments = segments[
segments["participant_id_start"] == segments["participant_id_end"]
]
segments = segments.rename(columns={"participant_id_start": "participant_id"})
segments = segments[["participant_id", "start_time", "end_time"]]
return segments
def assign_participant_ids( # noqa: PLR0912
diarization_output: Dict[str, Any],
metadatas: List[Dict[str, Any]],
recording_metadata: Dict[str, Any],
overlap_threshold: float = 0.3,
) -> Dict[str, Any]:
"""Assign participant IDs to WhisperX diarization speakers."""
recording_start = nanoseconds_to_seconds(recording_metadata["started_at"])
participant_segments_df = build_speech_segments_df(metadatas.get("events"))
if participant_segments_df.empty:
return {}
words_df = pd.DataFrame(diarization_output)
if words_df.empty:
return {}
words_df = words_df.dropna(subset=["start", "end"])
if words_df.empty:
return {}
if "speaker" not in words_df.columns:
words_df["speaker"] = "UNKNOWN"
words_df["speaker"] = words_df["speaker"].fillna("UNKNOWN")
words_df["abs_start"] = recording_start + words_df["start"]
words_df["abs_end"] = recording_start + words_df["end"]
speaker_segments_list = []
for speaker, group in words_df.groupby("speaker"):
grp = group.sort_values("abs_start").reset_index(drop=True)
segments = []
current_start = grp.iloc[0]["abs_start"]
current_end = grp.iloc[0]["abs_end"]
for idx in range(1, len(grp)):
if grp.iloc[idx]["abs_start"] - current_end < 1.0:
current_end = grp.iloc[idx]["abs_end"]
else:
segments.append(
{
"speaker": speaker,
"start_time": current_start,
"end_time": current_end,
}
)
current_start = grp.iloc[idx]["abs_start"]
current_end = grp.iloc[idx]["abs_end"]
segments.append(
{"speaker": speaker, "start_time": current_start, "end_time": current_end}
)
speaker_segments_list.extend(segments)
speaker_segments_df = pd.DataFrame(speaker_segments_list)
speaker_to_participant = {}
for speaker in speaker_segments_df["speaker"].unique():
spk_segs = speaker_segments_df[speaker_segments_df["speaker"] == speaker]
best_match = None
best_overlap = 0
for participant_id in participant_segments_df["participant_id"].unique():
part_segs = participant_segments_df[
participant_segments_df["participant_id"] == participant_id
]
overlaps = calculate_overlap_vectorized(
spk_segs["start_time"].values,
spk_segs["end_time"].values,
part_segs["start_time"].values,
part_segs["end_time"].values,
)
total_overlap = overlaps.sum()
total_speaker_duration = (
spk_segs["end_time"] - spk_segs["start_time"]
).sum()
if total_speaker_duration > 0:
overlap_ratio = total_overlap / total_speaker_duration
if overlap_ratio > best_overlap and overlap_ratio >= overlap_threshold:
best_overlap = overlap_ratio
best_match = participant_id
speaker_to_participant[speaker] = {
"participant_id": best_match,
"confidence": best_overlap,
}
for speaker, mapping in speaker_to_participant.items():
for participant in metadatas.get("participants", []):
if participant["participantId"] == mapping["participant_id"]:
speaker_to_participant[speaker] = participant["name"]
if mapping["confidence"] < overlap_threshold + 0.2:
speaker_to_participant[speaker] += "?"
break
return speaker_to_participant
from summary.core.prompt import get_instructions
settings = get_settings()
analytics = get_analytics()
@@ -258,40 +95,7 @@ def create_retry_session():
return session
class LLMException(Exception):
"""LLM call failed."""
class LLMService:
"""Service for performing calls to the LLM configured in the settings."""
def __init__(self):
"""Init the LLMService once."""
self._client = openai.OpenAI(
base_url=settings.llm_base_url, api_key=settings.llm_api_key
)
def call(self, system_prompt: str, user_prompt: str):
"""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=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
)
return response.choices[0].message.content
except Exception as e:
logger.error("LLM call failed: %s", e)
raise LLMException("LLM call failed.") from e
def format_segments(transcription_data, metadata=None, manifest=None):
def format_segments(transcription_data):
"""Format transcription segments from WhisperX into a readable conversation format.
Processes transcription data with segments containing speaker information and text,
@@ -299,36 +103,17 @@ def format_segments(transcription_data, metadata=None, manifest=None):
conversation with speaker labels.
"""
formatted_output = ""
logger.info("Formatting segments with metadata: %s", metadata)
logger.info("Transcription data: %s", transcription_data)
if metadata:
mapping = assign_participant_ids(
diarization_output=transcription_data,
metadatas=metadata,
recording_metadata=manifest,
overlap_threshold=0.3,
)
logger.info("Speaker to participant mapping: %s", mapping)
previous_label = None
for segment in transcription_data:
spk = segment.get("speaker") or "UNKNOWN_SPEAKER"
text = segment.get("text") or ""
if not text:
continue
label = mapping.get(spk) or spk
if label != previous_label:
formatted_output += f"\n\n **{label}**: {text}"
else:
formatted_output += f" {text}"
previous_label = label
return formatted_output
if not transcription_data or not hasattr(transcription_data, "segments"):
if isinstance(transcription_data, dict) and "segments" in transcription_data:
segments = transcription_data["segments"]
else:
return "Error: Invalid transcription data format"
else:
previous_speaker = None
segments = transcription_data.segments
for segment in transcription_data:
previous_speaker = None
for segment in segments:
speaker = segment.get("speaker", "UNKNOWN_SPEAKER")
text = segment.get("text", "")
if text:
@@ -371,13 +156,88 @@ def task_failure_handler(task_id, exception=None, **kwargs):
metadata_manager.capture(task_id, settings.posthog_event_failure)
@celery.task(max_retries=settings.celery_max_retries)
def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
"""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 OpenAI-compliant API's ASR model
3. Generates a summary of the transcription using OpenAI-compliant API's LLM
4. Sends the results via webhook
"""
logger.info("Notification received")
logger.debug("filename: %s", filename)
minio_client = Minio(
settings.aws_s3_endpoint_url,
access_key=settings.aws_s3_access_key_id,
secret_key=settings.aws_s3_secret_access_key,
secure=settings.aws_s3_secure_access,
)
logger.debug("Connection to the Minio bucket successful")
audio_file_stream = minio_client.get_object(
settings.aws_storage_bucket_name, object_name=filename
)
temp_file_path = save_audio_stream(audio_file_stream)
logger.debug("Recording successfully downloaded, filepath: %s", temp_file_path)
logger.info("Initiating OpenAI client")
openai_client = openai.OpenAI(
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
max_retries=settings.openai_max_retries,
)
try:
logger.info("Querying transcription …")
with open(temp_file_path, "rb") as audio_file:
transcription = openai_client.audio.transcriptions.create(
model=settings.openai_asr_model, file=audio_file
)
transcription = transcription.text
logger.debug("Transcription: \n %s", transcription)
finally:
if os.path.exists(temp_file_path):
os.remove(temp_file_path)
logger.debug("Temporary file removed: %s", temp_file_path)
instructions = get_instructions(transcription)
summary_response = openai_client.chat.completions.create(
model=settings.openai_llm_model, messages=instructions
)
summary = summary_response.choices[0].message.content
logger.debug("Summary: \n %s", summary)
# fixme - generate a title using LLM
data = {
"title": "Votre résumé",
"content": summary,
"email": email,
"sub": sub,
}
logger.debug("Submitting webhook to %s", settings.webhook_url)
logger.debug("Request payload: %s", json.dumps(data, indent=2))
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)
@celery.task(
bind=True,
autoretry_for=[exceptions.HTTPError],
max_retries=settings.celery_max_retries,
queue=settings.transcribe_queue,
)
def process_audio_transcribe_summarize_v2( # noqa: PLR0915
def process_audio_transcribe_summarize_v2(
self,
filename: str,
email: str,
@@ -386,7 +246,6 @@ def process_audio_transcribe_summarize_v2( # noqa: PLR0915
room: Optional[str],
recording_date: Optional[str],
recording_time: Optional[str],
worker_id: Optional[str],
):
"""Process an audio file by transcribing it and generating a summary.
@@ -433,19 +292,19 @@ def process_audio_transcribe_summarize_v2( # noqa: PLR0915
logger.error(error_msg)
raise AudioValidationError(error_msg)
logger.info("Initiating WhisperX client")
whisperx_client = openai.OpenAI(
api_key=settings.whisperx_api_key,
base_url=settings.whisperx_base_url,
max_retries=settings.whisperx_max_retries,
logger.info("Initiating OpenAI client")
openai_client = openai.OpenAI(
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
max_retries=settings.openai_max_retries,
)
try:
logger.info("Querying transcription …")
transcription_start_time = time.time()
with open(temp_file_path, "rb") as audio_file:
transcription = whisperx_client.audio.transcriptions.create(
model=settings.whisperx_asr_model, file=audio_file
transcription = openai_client.audio.transcriptions.create(
model=settings.openai_asr_model, file=audio_file
)
metadata_manager.track(
task_id,
@@ -462,49 +321,12 @@ def process_audio_transcribe_summarize_v2( # noqa: PLR0915
os.remove(temp_file_path)
logger.debug("Temporary file removed: %s", temp_file_path)
if (
analytics.is_feature_enabled("is_metadata_agent_enabled", distinct_id=sub)
and settings.is_summary_enabled
):
file = filename.split("/")[1].split(".")[0]
metadata_obj = minio_client.get_object(
settings.aws_storage_bucket_name,
object_name=settings.metadata_file.format(filename=file),
)
formatted_transcription = (
DEFAULT_EMPTY_TRANSCRIPTION
if not transcription.segments
else format_segments(transcription)
)
file_manifest = "recordings/" + worker_id + ".json"
manifest_obj = minio_client.get_object(
settings.aws_storage_bucket_name,
object_name=file_manifest,
)
logger.info("Manifest file downloaded: %s", file_manifest)
logger.info("Downloading metadata file")
try:
metadata_bytes = metadata_obj.read()
metadata_json = json.loads(metadata_bytes.decode("utf-8"))
manifest_bytes = manifest_obj.read()
manifest_json = json.loads(manifest_bytes.decode("utf-8"))
finally:
metadata_obj.close()
metadata_obj.release_conn()
manifest_obj.close()
manifest_obj.release_conn()
logger.info("Metadata file successfully downloaded")
logger.debug("Manifest: %s", manifest_json)
formatted_transcription = (
DEFAULT_EMPTY_TRANSCRIPTION
if not getattr(transcription, "segments", None)
else format_segments(transcription.segments, metadata_json, manifest_json)
)
else:
formatted_transcription = (
DEFAULT_EMPTY_TRANSCRIPTION
if not transcription.segments
else format_segments(transcription.segments, None)
)
metadata_manager.track_transcription_metadata(task_id, transcription)
if not room or not recording_date or not recording_time:
@@ -532,80 +354,4 @@ def process_audio_transcribe_summarize_v2( # noqa: PLR0915
metadata_manager.capture(task_id, settings.posthog_event_success)
if (
analytics.is_feature_enabled("summary-enabled", distinct_id=sub)
and settings.is_summary_enabled
):
logger.info("Queuing summary generation task.")
summarize_transcription.apply_async(
args=[formatted_transcription, email, sub, title],
queue=settings.summarize_queue,
)
else:
logger.info("Summary generation not enabled for this user.")
@celery.task(
bind=True,
autoretry_for=[LLMException, Exception],
max_retries=settings.celery_max_retries,
queue=settings.summarize_queue,
)
def summarize_transcription(self, transcript: str, email: str, sub: str, title: str):
"""Generate a summary from the provided transcription text.
This Celery task performs the following operations:
1. Uses an LLM to generate a TL;DR summary of the transcription.
2. Breaks the transcription into parts and summarizes each part.
3. Cleans up the combined summary
4. Generates next steps.
5. Sends the final summary via webhook.
"""
logger.info("Starting summarization task")
llm_service = LLMService()
tldr = llm_service.call(PROMPT_SYSTEM_TLDR, transcript)
logger.info("TLDR generated")
parts = llm_service.call(PROMPT_SYSTEM_PLAN, transcript)
logger.info("Plan generated")
parts = parts.split("\n")
parts = [x for x in parts if x.strip() != ""]
logger.info("Empty parts removed")
parts_summarized = []
for part in parts:
prompt_user_part = PROMPT_USER_PART.format(part=part, transcript=transcript)
logger.info("Summarizing part: %s", part)
parts_summarized.append(llm_service.call(PROMPT_SYSTEM_PART, prompt_user_part))
logger.info("Parts summarized")
raw_summary = "\n\n".join(parts_summarized)
next_steps = llm_service.call(PROMPT_SYSTEM_NEXT_STEP, transcript)
logger.info("Next steps generated")
cleaned_summary = llm_service.call(PROMPT_SYSTEM_CLEANING, raw_summary)
logger.info("Summary cleaned")
summary = tldr + "\n\n" + cleaned_summary + "\n\n" + next_steps
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)
# TODO - integrate summarize the transcript and create a new document.
+5 -15
View File
@@ -24,9 +24,6 @@ class Settings(BaseSettings):
celery_result_backend: str = "redis://redis/0"
celery_max_retries: int = 1
transcribe_queue: str = "transcribe-queue"
summarize_queue: str = "summarize-queue"
# Minio settings
aws_storage_bucket_name: str
aws_s3_endpoint_url: str
@@ -35,13 +32,11 @@ class Settings(BaseSettings):
aws_s3_secure_access: bool = True
# AI-related settings
whisperx_api_key: str
whisperx_base_url: str = "https://api.openai.com/v1"
whisperx_asr_model: str = "whisper-1"
whisperx_max_retries: int = 0
llm_base_url: str
llm_api_key: str
llm_model: str
openai_api_key: str
openai_base_url: str = "https://api.openai.com/v1"
openai_asr_model: str = "whisper-1"
openai_llm_model: str = "gpt-4o"
openai_max_retries: int = 0
# Webhook-related settings
webhook_max_retries: int = 2
@@ -55,11 +50,6 @@ class Settings(BaseSettings):
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
is_summary_enabled: bool = True
metadata_file: Optional[str] = "recordings/{filename}-metadata.json"
# Sentry
sentry_is_enabled: bool = False
+46 -22
View File
@@ -1,28 +1,52 @@
# ruff: noqa
PROMPT_SYSTEM_TLDR = """Tu es un agent dont le rôle est de créer un TL;DR (résumé très concis) 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. Ta tâche est de rédiger un résumé concis et structuré, en te concentrant uniquement sur les informations essentielles et pertinentes. Tu répondras en un paragraphe structuré (3 à 6 phrases), sans rien ajouter d'autre. Tu répondras dans le format suivant sans rien ajouter d'autre:
### 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
..."
"""
def get_instructions(transcript):
"""Declare the summarize instructions."""
prompt = f"""
Audience: Coworkers.
**Do:**
- Detect the language of the transcript and provide your entire response in the same language.
- If any part of the transcript is unclear or lacks detail, politely inform the user, specifying which areas need further clarification.
- Ensure the accuracy of all information and refrain from adding unverified details.
- Format the response using proper markdown and structured sections.
- Be concise and avoid repeating yourself between the sections.
- Be super precise on nickname
- Be a nit-picker
- Auto-evaluate your response
**Don't:**
- Write something your are not sure.
- Write something that is not mention in the transcript.
- Don't make mistake while mentioning someone
**Task:**
Summarize the provided meeting transcript into clear and well-organized meeting minutes. The summary should be structured into the following sections, excluding irrelevant or inapplicable details:
1. **Summary**: Write a TL;DR of the meeting.
2. **Subjects Discussed**: List the key points or issues in bullet points.
4. **Next Steps**: Provide action items as bullet points, assigning each task to a responsible individual and including deadlines (if mentioned). Format action items as tickable checkboxes. Ensure every action is assigned and, if a deadline is provided, that it is clearly stated.
**Transcript**:
{transcript}
**Response:**
### Summary [Translate this title based on the transcripts language]
[Provide a brief overview of the key points discussed]
### Subjects Discussed [Translate this title based on the transcripts language]
- [Summarize each topic concisely]
### Next Steps [Translate this title based on the transcripts language]
- [ ] Action item [Assign to the responsible individual(s) and include a deadline if applicable, follow this strict format: Action - List of owner(s), deadline.]
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 :
### Titre du sujet [Traduire ce titre selon la langue du transcript]
[Résumé concis et structuré de la partie du transcript]
"""
PROMPT_USER_PART = """Titre de la partie à résumer : {part}
Transcript complet :
{transcript}"""
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]"""
return [
{
"role": "system",
"content": "You are a concise and structured assistant, that summarizes meeting transcripts.",
},
{"role": "user", "content": prompt},
]