diff --git a/secrets b/secrets index 8ef9f451..2ef26100 160000 --- a/secrets +++ b/secrets @@ -1 +1 @@ -Subproject commit 8ef9f4513a63e313fdadd0a06c6f85091dad1013 +Subproject commit 2ef26100715e0cc7093d44aaed54ed5a9c68cb13 diff --git a/src/backend/core/api/demo.py b/src/backend/core/api/demo.py new file mode 100644 index 00000000..dc14f6cb --- /dev/null +++ b/src/backend/core/api/demo.py @@ -0,0 +1,316 @@ + +from rest_framework.decorators import api_view +from rest_framework.response import Response +from minio import Minio +from django.conf import settings +import openai +import logging +from ..models import Room, RoleChoices + +import tempfile +import os +import smtplib +import requests + + +logger = logging.getLogger(__name__) + + +def get_prompt(transcript, date): + return 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 transcript’s language] + [Provide a brief overview of the key points discussed] + + ### Subjects Discussed [Translate this title based on the transcript’s language] + - [Summarize each topic concisely] + + ### Next Steps [Translate this title based on the transcript’s 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.] + + """ + +def get_room_and_owners(slug): + """Wip.""" + + try: + room = Room.objects.get(slug=slug) + owner_accesses = room.accesses.filter(role=RoleChoices.OWNER) + owners = [access.user for access in owner_accesses] + + logger.info("Room %s has owners: %s", slug, owners) + + except Room.DoesNotExist: + logger.error("Room with slug %s does not exist", slug) + + owners = None + room = None + + return room, owners + + +def remove_temporary_file(path): + """Wip.""" + + if not path or not os.path.exists(path): + return + + os.remove(path) + logger.info("Temporary file %s has been deleted.", path) + +def get_blocknote_content(summary): + """Wip.""" + + if not settings.BLOCKNOTE_CONVERTER_URL: + logger.error("BLOCKNOTE_CONVERTER_URL is not configured") + return None + + headers = { + "Content-Type": "application/json" + } + + data = { + "markdown": summary + } + + logger.info("Converting summary in BlockNote.js…") + response = requests.post(settings.BLOCKNOTE_CONVERTER_URL, headers=headers, json=data) + + if response.status_code != 200: + logger.error(f"Failed to convert summary. Status code: {response.status_code}") + return None + + response_data = response.json() + if not 'content' in response_data: + logger.error(f"Content is missing: %s", response_data) + return None + + content = response_data['content'] + logger.info("Base64 content: %s", content) + + return content + + + +def get_document_link(content, email): + """Wip.""" + + logger.info("Create a document for %s", email) + + if not settings.DOCS_BASE_URL: + logger.error("DOCS_BASE_URL is not configured") + return None + + headers = { + "Content-Type": "application/json" + } + + data = { + "content": content, + "owner": email + } + + logger.info("Querying docs…") + response = requests.post(f"{settings.DOCS_BASE_URL}/api/v1.0/summary/", headers=headers, json=data) + + if response.status_code != 200: + logger.error(f"Failed to get document's id. Status code: {response.status_code}") + return None + + response_data = response.json() + if not 'id' in response_data: + logger.error(f"ID is missing: %s", response_data) + return None + + id = response_data['id'] + logger.info("Document's id: %s", id) + + return f"{settings.DOCS_BASE_URL}/docs/{id}/" + + +def email_owner_with_summary(room, link, owner): + """Wip.""" + + logger.info("Emailing owner: %s", owner) + + try: + room.email_summary(owners=[owner], link=link) + except smtplib.SMTPException: + logger.error("Error while emailing owner") + +def strip_room_slug(filename): + """Wip.""" + return filename.split("_")[2].split(".")[0] + +def strip_room_date(filename): + """Wip.""" + return filename.split("_")[1].split(".")[0] + + +def get_minio_client(): + """Wip.""" + + try: + return Minio( + settings.MINIO_URL, + access_key=settings.MINIO_ACCESS_KEY, + secret_key=settings.MINIO_SECRET_KEY, + ) + except Exception as e: + logger.error("An error occurred while creating the Minio client %s: %s", settings.MINIO_URL, str(e)) + + +def download_temporary_file(minio_client, filename): + """Wip.""" + + temp_file_path = None + + logger.info('downloading file %s', filename) + + try: + audio_file_stream = minio_client.get_object(settings.MINIO_BUCKET, object_name=filename) + + with tempfile.NamedTemporaryFile(delete=False, suffix='.ogg') as temp_audio_file: + + for data in audio_file_stream.stream(32 * 1024): + temp_audio_file.write(data) + + temp_file_path = temp_audio_file.name + logger.info('Temporary file created at %s', temp_file_path) + + audio_file_stream.close() + audio_file_stream.release_conn() + + except Exception as e: + logger.error("An error occurred while accessing the object: %s", str(e)) + + return temp_file_path + + +# todo - discuss retry policy if the webhook fail +@api_view(["POST"]) +def minio_webhook(request): + + data = request.data + + logger.info('Minio webhook sent %s', data) + + record = data["Records"][0] + s3 = record['s3'] + bucket = s3['bucket'] + bucket_name = bucket['name'] + object = s3['object'] + filename = object['key'] + + if bucket_name != settings.MINIO_BUCKET: + logger.info('Not interested in this bucket: %s', bucket_name) + return Response("Not interested in this bucket") + + if object['contentType'] != 'audio/ogg': + logger.info('Not interested in this file type: %s', object['contentType']) + return Response("Not interested in this file type") + + room_slug = strip_room_slug(filename) + room_date = strip_room_date(filename) + logger.info('file received %s for room %s', filename, room_slug) + + minio_client = get_minio_client() + + temp_file_path = None + summary = None + + try: + temp_file_path = download_temporary_file(minio_client, filename) + + if settings.OPENAI_ENABLE and temp_file_path: + logger.info('Initiating OpenAI client …') + openai_client = openai.OpenAI( + api_key=settings.OPENAI_API_KEY, + ) + + with open(temp_file_path, "rb") as audio_file: + logger.info('Querying transcription …') + transcript = openai_client.audio.transcriptions.create( + model="whisper-1", + file=audio_file + ) + + logger.info('Transcript: \n %s', transcript) + prompt = get_prompt(transcript.text, room_date) + + logger.info('Prompt: \n %s', prompt) + + summary_response = openai_client.chat.completions.create( + model="gpt-4o", + messages=[ + {"role": "system", "content": "You are a concise and structured assistant, that summarizes meeting transcripts."}, + {"role": "user", "content": prompt} + ], + ) + + summary = summary_response.choices[0].message.content + logger.info('Summary: \n %s', summary) + + except Exception as e: + logger.error("An error occurred: %s", str(e)) + raise + + finally: + remove_temporary_file(temp_file_path) + + if not summary: + logger.error("Empty summary.") + return Response("") + + room, owners = get_room_and_owners(room_slug) + + if not owners or not room: + logger.error("No owners in room %s", room_slug) + return Response("") + + content = get_blocknote_content(summary) + + if not content: + logger.error("Empty content.") + return Response("") + + owner = owners[0] + + link = get_document_link(content, owner.email) + + if not link: + logger.error("Empty link.") + return Response("") + + email_owner_with_summary(room, link, owner) + + return Response("") diff --git a/src/backend/core/models.py b/src/backend/core/models.py index f2d4e77b..7b39f40a 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -15,6 +15,8 @@ from django.utils.text import capfirst, slugify from django.utils.translation import gettext_lazy as _ +from django.template.loader import render_to_string + from timezone_field import TimeZoneField logger = getLogger(__name__) @@ -325,3 +327,24 @@ def clean_fields(self, exclude=None): else: raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'}) super().clean_fields(exclude=exclude) + + + def email_summary(self, owners, link): + """Wip""" + + template_vars = { + "title": "Votre résumé est prêt", + "link": link, + "room": self.slug, + } + msg_html = render_to_string("mail/html/summary.html", template_vars) + msg_plain = render_to_string("mail/text/invitation.txt", template_vars) + + for owner in owners: + owner.email_user( + subject="Votre résumé est prêt", + from_email=settings.EMAIL_FROM, + message=msg_plain, + html_message=msg_html, + fail_silently=False, + ) diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index f7f0c5a9..2786f58b 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -5,7 +5,7 @@ from rest_framework.routers import DefaultRouter -from core.api import get_frontend_configuration, viewsets +from core.api import get_frontend_configuration, viewsets, demo from core.authentication.urls import urlpatterns as oidc_urls # - Main endpoints @@ -24,6 +24,7 @@ *router.urls, *oidc_urls, path("config/", get_frontend_configuration, name="config"), + path("minio-webhook/", demo.minio_webhook, name="demo"), ] ), ), diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 304b5de7..56ac4b0d 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -53,6 +53,7 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str: room=room, room_join=True, room_admin=True, + room_record=True, can_update_own_metadata=True, can_publish_sources=[ "camera", diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index bf2deaa5..03b3b2e8 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -386,6 +386,53 @@ class Base(Configuration): None, environ_name="ANALYTICS_KEY", environ_prefix=None ) + # todo - totally wip + AWS_S3_ENDPOINT_URL = values.Value( + environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None + ) + AWS_S3_ACCESS_KEY_ID = values.Value( + environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None + ) + AWS_S3_SECRET_ACCESS_KEY = values.Value( + environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None + ) + AWS_S3_REGION_NAME = values.Value( + environ_name="AWS_S3_REGION_NAME", environ_prefix=None + ) + AWS_STORAGE_BUCKET_NAME = values.Value( + "meet-media-storage", + environ_name="AWS_STORAGE_BUCKET_NAME", + environ_prefix=None, + ) + + OPENAI_API_KEY = values.Value( + None, environ_name="OPENAI_API_KEY", environ_prefix=None + ) + OPENAI_ENABLE = values.BooleanValue( + True, environ_name="OPENAI_ENABLE", environ_prefix=None + ) + + # todo - totally wip + MINIO_ACCESS_KEY = values.Value( + None, environ_name="MINIO_ACCESS_KEY", environ_prefix=None + ) + MINIO_SECRET_KEY = values.Value( + None, environ_name="MINIO_SECRET_KEY", environ_prefix=None + ) + MINIO_URL = values.Value( + None, environ_name="MINIO_URL", environ_prefix=None + ) + MINIO_BUCKET = values.Value( + 'livekit-staging-livekit-egress', environ_name="MINIO_BUCKET", environ_prefix=None + ) + + BLOCKNOTE_CONVERTER_URL = values.Value( + 'https://converter-blocknote.osc-fr1.scalingo.io/', environ_name="", environ_prefix=None + ) + DOCS_BASE_URL = values.Value( + 'https://docs-ia.beta.numerique.gouv.fr', environ_name="", environ_prefix=None + ) + # pylint: disable=invalid-name @property def ENVIRONMENT(self): @@ -529,6 +576,20 @@ class Production(Base): ALLOWED_HOSTS=["foo.com", "foo.fr"] """ + LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "INFO", + }, + } + # Security ALLOWED_HOSTS = [ *values.ListValue([], environ_name="ALLOWED_HOSTS"), diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index c71c5e78..9a78884e 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -58,6 +58,8 @@ dependencies = [ "whitenoise==6.7.0", "mozilla-django-oidc==4.0.1", "livekit-api==0.7.0", + "minio==7.2.9", + "openai==1.51.2" ] [project.urls] diff --git a/src/frontend/src/features/rooms/components/RecordingIndicator.tsx b/src/frontend/src/features/rooms/components/RecordingIndicator.tsx new file mode 100644 index 00000000..675ef7bc --- /dev/null +++ b/src/frontend/src/features/rooms/components/RecordingIndicator.tsx @@ -0,0 +1,53 @@ +import { useRoomContext } from '@livekit/components-react' +import { useEffect, useState } from 'react' +import { RoomEvent } from 'livekit-client' +import { egressStore } from '@/stores/egress.ts' +import { useSnapshot } from 'valtio' + +export const RecordingIndicator = () => { + const room = useRoomContext() + const [isRecording, setIsRecording] = useState(room.isRecording) + + const egressSnap = useSnapshot(egressStore) + const egressIsStopping = egressSnap.egressIsStopping + + useEffect(() => { + const handleRecordingStatusChanges = (isRecording: boolean) => { + if (!isRecording) { + egressStore.egressIsStopping = false + } + + setIsRecording(isRecording) + } + room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges) + return () => { + room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges) + } + }, [room]) + + const getStatus = () => { + if (egressIsStopping) { + return 'saving recording' + } + if (isRecording) { + return 'recording' + } + if (!isRecording) { + return 'available' + } + } + + return ( +
Cher utilisateur,
+La réunion {{room}} a été transcrite et résumée avec succès.
+