diff --git a/src/backend/core/recording/event/notification.py b/src/backend/core/recording/event/notification.py index 916c51d0..f9766444 100644 --- a/src/backend/core/recording/event/notification.py +++ b/src/backend/core/recording/event/notification.py @@ -1,6 +1,7 @@ """Service to notify external services when a new recording is ready.""" import logging +import os import smtplib from django.conf import settings @@ -150,22 +151,23 @@ class NotificationService: .first() ) + # TODO: change how we get metadata_filename + output_folder = os.getenv("AWS_S3_OUTPUT_FOLDER", "metadata") + metadata_filename = f"{output_folder}/{recording.id}-metadata.json" + if not owner_access: logger.error("No owner found for recording %s", recording.id) return False payload = { "owner_id": str(owner_access.user.id), - "filename": recording.key, + "recording_filename": recording.key, + "metadata_filename": metadata_filename, "email": owner_access.user.email, "sub": owner_access.user.sub, "room": recording.room.name, "language": recording.options.get("language"), - "recording_date": recording.created_at.astimezone( - owner_access.user.timezone - ).strftime("%Y-%m-%d"), - "recording_time": recording.created_at.astimezone( - owner_access.user.timezone - ).strftime("%H:%M"), + "recording_datetime": recording.created_at.isoformat(), + "owner_timezone": str(owner_access.user.timezone), "download_link": f"{get_recording_download_base_url()}/{recording.id}", "context_language": owner_access.user.language, } diff --git a/src/summary/summary/api/route/tasks.py b/src/summary/summary/api/route/tasks.py index 7117f6db..26c3c7ce 100644 --- a/src/summary/summary/api/route/tasks.py +++ b/src/summary/summary/api/route/tasks.py @@ -19,13 +19,14 @@ class TranscribeSummarizeTaskCreation(BaseModel): """Transcription and summarization parameters.""" owner_id: str - filename: str + recording_filename: str + metadata_filename: str email: str sub: str version: Optional[int] = 2 room: Optional[str] - recording_date: Optional[str] - recording_time: Optional[str] + recording_datetime: Optional[str] + owner_timezone: Optional[str] language: Optional[str] download_link: Optional[str] context_language: Optional[str] = None @@ -51,13 +52,14 @@ async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreat task = process_audio_transcribe_summarize_v2.apply_async( args=[ request.owner_id, - request.filename, + request.recording_filename, + request.metadata_filename, request.email, request.sub, time.time(), request.room, - request.recording_date, - request.recording_time, + request.recording_datetime, + request.owner_timezone, request.language, request.download_link, request.context_language, diff --git a/src/summary/summary/core/celery_worker.py b/src/summary/summary/core/celery_worker.py index 8ba10c30..3438d744 100644 --- a/src/summary/summary/core/celery_worker.py +++ b/src/summary/summary/core/celery_worker.py @@ -4,6 +4,7 @@ import json import time +from datetime import datetime from typing import Optional import openai @@ -28,6 +29,7 @@ from summary.core.prompt import ( PROMPT_USER_PART, ) from summary.core.transcript_formatter import TranscriptFormatter +from summary.core.user_assign import assign_speakers from summary.core.webhook_service import submit_content settings = get_settings() @@ -58,7 +60,7 @@ if settings.sentry_dsn and settings.sentry_is_enabled: file_service = FileService() -def transcribe_audio(task_id, filename, language): +def transcribe_audio(task_id, recording_filename, language): """Transcribe an audio file using WhisperX. Downloads the audio from MinIO, sends it to WhisperX for transcription, @@ -75,9 +77,13 @@ def transcribe_audio(task_id, filename, language): # Transcription try: - with file_service.prepare_audio_file(filename) as (audio_file, metadata): + with file_service.prepare_audio_file(recording_filename) as ( + audio_file, + metadata, + ): metadata_manager.track(task_id, {"audio_length": metadata["duration"]}) + # Compute language parameter if language is None: language = settings.whisperx_default_language logger.info( @@ -90,22 +96,25 @@ def transcribe_audio(task_id, filename, language): language, ) + # Call remote service for transcription transcription_start_time = time.time() - transcription = whisperx_client.audio.transcriptions.create( model=settings.whisperx_asr_model, file=audio_file, language=language ) - transcription_time = round(time.time() - transcription_start_time, 2) + # Logging + transcription_duration = round(time.time() - transcription_start_time, 2) metadata_manager.track( task_id, - {"transcription_time": transcription_time}, + {"transcription_time": transcription_duration}, + ) + logger.info( + "Transcription received in %.2f seconds.", transcription_duration ) - logger.info("Transcription received in %.2f seconds.", transcription_time) logger.debug("Transcription: \n %s", transcription) except FileServiceException: - logger.exception("Unexpected error for filename: %s", filename) + logger.exception("Unexpected error for recording: %s", recording_filename) return None metadata_manager.track_transcription_metadata(task_id, transcription) @@ -117,8 +126,8 @@ def format_transcript( context_language, language, room, - recording_date, - recording_time, + recording_datetime, + owner_timezone, download_link, ): """Format a transcription into readable content with a title. @@ -134,8 +143,8 @@ def format_transcript( return formatter.format( transcription, room=room, - recording_date=recording_date, - recording_time=recording_time, + recording_datetime=recording_datetime, + owner_timezone=owner_timezone, download_link=download_link, ) @@ -167,13 +176,14 @@ def format_actions(llm_output: dict) -> str: def process_audio_transcribe_summarize_v2( self, owner_id: str, - filename: str, + recording_filename: str, + metadata_filename: str, email: str, sub: str, received_at: float, room: Optional[str], - recording_date: Optional[str], - recording_time: Optional[str], + recording_datetime: Optional[str], + owner_timezone: Optional[str], language: Optional[str], download_link: Optional[str], context_language: Optional[str] = None, @@ -189,13 +199,14 @@ def process_audio_transcribe_summarize_v2( Args: self: Celery task instance (passed on with bind=True) owner_id: Unique identifier of the recording owner. - filename: Name of the audio file in MinIO storage. + recording_filename: Name of the audio file in MinIO storage. + metadata_filename: Name of the audio file in MinIO storage. email: Email address of the recording owner. sub: OIDC subject identifier of the recording owner. received_at: Unix timestamp when the recording was received. room: room name where the recording took place. - recording_date: Date of the recording (localized display string). - recording_time: Time of the recording (localized display string). + recording_datetime: ISO 8601 UTC datetime of the recording. + owner_timezone: IANA timezone of the recording owner (e.g. "Europe/Paris"). language: ISO 639-1 language code for transcription. download_link: URL to download the original recording. context_language: ISO 639-1 language code of the meeting summary context text. @@ -208,17 +219,29 @@ def process_audio_transcribe_summarize_v2( task_id = self.request.id - transcription = transcribe_audio(task_id, filename, language) + # Transcribe the audio + transcription = transcribe_audio(task_id, recording_filename, language) if transcription is None: return + # Assign user names from metadata and transcription + metadata = file_service.read_json(metadata_filename) + recording_start_dt = ( + datetime.fromisoformat(recording_datetime) if recording_datetime else None + ) + if recording_start_dt is not None: + assign_speakers( + metadata, transcription, recording_start_dt, overlap_threshold=0.5 + ) + + # Format output content, title = format_transcript( transcription, context_language, language, room, - recording_date, - recording_time, + recording_datetime, + owner_timezone, download_link, ) diff --git a/src/summary/summary/core/file_service.py b/src/summary/summary/core/file_service.py index 80c63200..94e632ea 100644 --- a/src/summary/summary/core/file_service.py +++ b/src/summary/summary/core/file_service.py @@ -1,5 +1,6 @@ """File service to encapsulate files' manipulations.""" +import json import logging import os import subprocess @@ -156,6 +157,26 @@ class FileService: os.remove(output_path) raise RuntimeError("Failed to extract audio.") from e + def read_json(self, object_name: str) -> dict: + """Read and parse a JSON file from MinIO storage.""" + logger.info("Reading JSON: %s", object_name) + + if not object_name: + raise ValueError("Invalid object_name") + + response = None + try: + response = self._minio_client.get_object(self._bucket_name, object_name) + return json.loads(response.read()) + except (MinioException, S3Error) as e: + raise FileServiceException( + "Unexpected error while reading JSON object." + ) from e + finally: + if response: + response.close() + response.release_conn() + @contextmanager def prepare_audio_file(self, remote_object_key: str): """Download and prepare audio file for processing. diff --git a/src/summary/summary/core/transcript_formatter.py b/src/summary/summary/core/transcript_formatter.py index 611806d2..2c6bf417 100644 --- a/src/summary/summary/core/transcript_formatter.py +++ b/src/summary/summary/core/transcript_formatter.py @@ -1,7 +1,9 @@ """Transcript formatting into readable conversation format with speaker labels.""" import logging +from datetime import datetime from typing import Optional, Tuple +from zoneinfo import ZoneInfo from summary.core.config import get_settings from summary.core.locales import LocaleStrings @@ -40,8 +42,8 @@ class TranscriptFormatter: self, transcription, room: Optional[str] = None, - recording_date: Optional[str] = None, - recording_time: Optional[str] = None, + recording_datetime: Optional[str] = None, + owner_timezone: Optional[str] = None, download_link: Optional[str] = None, ) -> Tuple[str, str]: """Format transcription into the final document and its title.""" @@ -54,7 +56,7 @@ class TranscriptFormatter: content = self._remove_hallucinations(content) content = self._add_header(content, download_link) - title = self._generate_title(room, recording_date, recording_time) + title = self._generate_title(room, recording_datetime, owner_timezone) return content, title @@ -98,15 +100,19 @@ class TranscriptFormatter: def _generate_title( self, room: Optional[str] = None, - recording_date: Optional[str] = None, - recording_time: Optional[str] = None, + recording_datetime: Optional[str] = None, + owner_timezone: Optional[str] = None, ) -> str: """Generate title from context or return default.""" - if not room or not recording_date or not recording_time: + if not room or not recording_datetime: return self._locale.document_default_title + dt = datetime.fromisoformat(recording_datetime) + if owner_timezone: + dt = dt.astimezone(ZoneInfo(owner_timezone)) + return self._locale.document_title_template.format( room=room, - room_recording_date=recording_date, - room_recording_time=recording_time, + room_recording_date=dt.strftime("%Y-%m-%d"), + room_recording_time=dt.strftime("%H:%M"), ) diff --git a/src/summary/summary/core/user_assign.py b/src/summary/summary/core/user_assign.py index 77fda530..5d68fdcd 100644 --- a/src/summary/summary/core/user_assign.py +++ b/src/summary/summary/core/user_assign.py @@ -134,18 +134,19 @@ def _parse_iso(ts: str) -> datetime: def _build_participant_timelines( metadata: dict[str, Any], - recording_start_timestamp: str, + recording_start_datetime: datetime, ) -> tuple[dict[str, list[Interval]], dict[str, str]]: """Build VAD interval timelines for each participant. Args: metadata: Dict with `events` and `participants` keys. - recording_start_timestamp: ISO timestamp used as t=0 reference. + recording_start_datetime: UTC datetime used as t=0 reference. Returns: - participant_id → merged VAD intervals (seconds relative to recording_start_timestamp). + participant_id → merged VAD intervals + (seconds relative to recording_start_datetime). participant_id → display name. - Intervals are in seconds relative to recording_start_timestamp. + Intervals are in seconds relative to recording_start_datetime. Events before recording start are clamped to 0. """ events = metadata.get("events", []) @@ -154,7 +155,7 @@ def _build_participant_timelines( for p in metadata.get("participants", []) } - ref_epoch = _parse_iso(recording_start_timestamp).timestamp() + ref_epoch = recording_start_datetime.timestamp() open_starts: dict[str, float] = {} intervals: dict[str, list[Interval]] = {} @@ -202,7 +203,7 @@ def _build_speaker_timelines( def assign_speakers( metadata: dict[str, Any], diarization: dict[str, Any], - recording_start_timestamp: str, + recording_start_datetime: datetime, overlap_threshold: float = DEFAULT_OVERLAP_THRESHOLD, ) -> AssignmentResult: """Match WhisperX speaker labels to participants. @@ -210,7 +211,7 @@ def assign_speakers( Args: metadata: User metadata with `events` and `participants`. diarization: WhisperX JSON output containing `segments`. - recording_start_timestamp: ISO timestamp for t=0 reference. + recording_start_datetime: UTC datetime for t=0 reference. overlap_threshold: Minimum overlap/speaker_duration to accept. Returns: @@ -218,7 +219,7 @@ def assign_speakers( speakers. """ participant_timelines, participant_names = _build_participant_timelines( - metadata, recording_start_timestamp + metadata, recording_start_datetime ) speaker_timelines = _build_speaker_timelines(diarization) diff --git a/src/summary/tests/api/test_api_tasks.py b/src/summary/tests/api/test_api_tasks.py index 9492bf95..d10ef10d 100644 --- a/src/summary/tests/api/test_api_tasks.py +++ b/src/summary/tests/api/test_api_tasks.py @@ -19,30 +19,33 @@ class TestTasks: headers={"Authorization": "Bearer test-api-token"}, json={ "owner_id": "owner-123", - "filename": "recording.mp4", + "recording_filename": "recording.mp4", + "metadata_filename": "metadata.json", # TODO: change "email": "user@example.com", "sub": "sub-123", "room": "room-abc", - "recording_date": "2026-01-01", - "recording_time": "10:00:00", + "recording_datetime": "2026-01-01T10:00:00+00:00", + "owner_timezone": "UTC", "language": None, "download_link": "http://example.com/file.mp4", }, ) + print(response) assert response.status_code == 200 assert response.json() == {"id": "task-id-abc", "message": "Task created"} args = mock_apply_async.call_args.kwargs["args"] assert args == [ "owner-123", # owner_id - "recording.mp4", # filename + "recording.mp4", # recording_filename + "metadata.json", # metadata_filename "user@example.com", # email "sub-123", # sub 1735725600.0, # frozen time "room-abc", # room - "2026-01-01", # recording_date - "10:00:00", # recording_time + "2026-01-01T10:00:00+00:00", # recording_datetime + "UTC", # owner_timezone None, # language "http://example.com/file.mp4", # download_link None, # context_language diff --git a/src/summary/tests/unit/test_user_assign.py b/src/summary/tests/unit/test_user_assign.py index 256d312b..9a4eec79 100644 --- a/src/summary/tests/unit/test_user_assign.py +++ b/src/summary/tests/unit/test_user_assign.py @@ -1,5 +1,7 @@ """Tests for the speaker-to-user assignment service.""" +from datetime import datetime + from summary.core.user_assign import ( AssignmentResult, Interval, @@ -10,7 +12,7 @@ from summary.core.user_assign import ( assign_speakers, ) -RECORDING_START = "2026-03-17T15:30:33.000001" +RECORDING_START = datetime.fromisoformat("2026-03-17T15:30:33.000001") METADATA_SINGLE_USER = { "events": [