plug user assign + replace recording_date, recording_time by recording_datetime

This commit is contained in:
leo
2026-03-25 17:14:52 +01:00
parent 1889fe6505
commit 7dc7da9109
8 changed files with 116 additions and 56 deletions
@@ -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,
}
+8 -6
View File
@@ -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,
+43 -20
View File
@@ -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,
)
+21
View File
@@ -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.
@@ -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"),
)
+9 -8
View File
@@ -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)
+9 -6
View File
@@ -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
+3 -1
View File
@@ -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": [