plug user assign + replace recording_date, recording_time by recording_datetime
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user